checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -22,6 +22,7 @@ pub struct FileTreeRuntimeRow {
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub active_row_id: Option<String>,
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
@@ -70,6 +71,18 @@ pub enum FileTreeRuntimeAction {
OpenRow {
row_id: String,
},
FocusNext,
FocusPrevious,
FocusFirst,
FocusLast,
OpenFocused,
ContextMenuFocused,
BeginRenameFocused,
DeleteSelection,
CopySelection,
CutSelection,
PasteIntoFocused,
Escape,
ContextMenuRow {
row_id: String,
},
@@ -90,6 +103,11 @@ pub enum FileTreeIntentEvent {
row_id: String,
target: FileTreeOpenTarget,
},
KeyboardCommand {
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
@@ -103,6 +121,16 @@ pub enum FileTreeIntentEvent {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileTreeKeyboardCommand {
Rename,
Delete,
Copy,
Cut,
Paste,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
@@ -241,16 +269,86 @@ impl FileTreeRuntimeState {
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
let mut state = self.clone();
state.active_row_id = Some(row_id);
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
target,
})],
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }),
],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::FocusNext => transition(
focus_relative_row(self, env, 1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusPrevious => transition(
focus_relative_row(self, env, -1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusFirst => transition(
focus_absolute_row(self, env, 0),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusLast => transition(
focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::OpenFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Rename,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Delete,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CopySelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Copy,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CutSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Cut,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Paste,
Vec::new(),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::Escape => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
@@ -267,6 +365,75 @@ impl FileTreeRuntimeState {
}
}
fn focus_relative_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
offset: isize,
) -> FileTreeRuntimeState {
if env.visible_row_ids.is_empty() {
return state.clone();
}
let current = state
.selection
.focused_row_id
.as_ref()
.and_then(|row_id| {
env.visible_row_ids
.iter()
.position(|candidate| candidate == row_id)
})
.unwrap_or(0);
let next = if offset.is_negative() {
current.saturating_sub(offset.unsigned_abs())
} else {
(current + offset as usize).min(env.visible_row_ids.len() - 1)
};
focus_absolute_row(state, env, next)
}
fn focus_absolute_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
index: usize,
) -> FileTreeRuntimeState {
let mut next = state.clone();
next.selection.focused_row_id = env.visible_row_ids.get(index).cloned();
next
}
fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec<String> {
if !state.selection.selected_row_ids.is_empty() {
return state.selection.selected_row_ids.iter().cloned().collect();
}
state
.selection
.focused_row_id
.iter()
.cloned()
.collect::<Vec<_>>()
}
fn keyboard_command_transition(
state: &FileTreeRuntimeState,
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
) -> FileTreeRuntimeTransition {
if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) {
return transition(state.clone(), []);
}
transition(
state.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command,
row_ids,
target_row_id,
},
)],
)
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
@@ -309,8 +476,9 @@ fn transition<const N: usize>(
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeOpenTarget, FileTreeRuntimeAction,
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
@@ -436,6 +604,7 @@ mod tests {
},
},
)));
assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root"));
let transition = transition.state.reduce(
&env(),
@@ -551,4 +720,70 @@ mod tests {
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
#[test]
fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() {
let state = FileTreeRuntimeState {
active_row_id: Some("doc:root".into()),
selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected(
&["doc:root".to_string()],
),
..FileTreeRuntimeState::default()
};
let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext);
assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root"));
assert_eq!(
focused.state.selection.focused_row_id.as_deref(),
Some("index:root")
);
assert!(focused
.state
.selection
.selected_row_ids
.contains("doc:root"));
let opened = focused
.state
.reduce(&env(), FileTreeRuntimeAction::OpenFocused);
assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root"));
assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let rename = focused
.state
.reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused);
assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Rename,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let cut = focused
.state
.reduce(&env(), FileTreeRuntimeAction::CutSelection);
assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Cut,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let escaped = FileTreeRuntimeState {
drag_row_ids: vec!["doc:root".into()],
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("index:root".into()),
..focused.state
}
.reduce(&env(), FileTreeRuntimeAction::Escape);
assert!(escaped.state.drag_row_ids.is_empty());
assert_eq!(escaped.state.drag_effect, None);
assert_eq!(escaped.state.drop_target_row_id, None);
}
}