feat(tree): complete rust family runtime checklist

- add tree shell runtime artifact contracts and page/filetree/picker runtime reducers
- sink tree.subtree.move write operation through Rust and formalize command event plans
- harden file tree search projection contract and route thin-proxy boundaries
- record completed harness tasks and move design docs into process/done
This commit is contained in:
lix-2026
2026-04-27 10:27:15 +08:00
parent e564dfde02
commit 4ab36a9386
30 changed files with 2502 additions and 432 deletions
@@ -3,12 +3,15 @@ pub mod drag_drop_state;
pub mod dispatcher;
pub mod expansion_state;
pub mod filetree_renderer;
pub mod filetree_runtime;
pub mod filetree_selection;
pub mod focus_state;
pub mod keyboard_state;
pub mod loader;
pub mod page_renderer;
pub mod page_runtime;
pub mod picker_renderer;
pub mod picker_runtime;
pub mod picker_state;
pub mod protocol;
pub mod renderer_input;
@@ -78,8 +78,19 @@ fn render_page_row(
.find(|source| source.node_id == row.node_id)
.map(|source| source.expanded)
.unwrap_or(false);
let toggle_html = if row.expandable {
format!(
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}">{marker}</button>"#,
node_id = escape_html(&row.node_id),
label = if expanded { "折叠" } else { "展开" },
title = escape_html(&row.title),
marker = if expanded { "" } else { "" },
)
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
};
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
@@ -87,6 +98,7 @@ fn render_page_row(
active = active,
focused = focused,
tab_index = if focused { "0" } else { "-1" },
toggle_html = toggle_html,
title = escape_html(&row.title),
));
if row.expandable && expanded {
@@ -206,6 +218,8 @@ mod tests {
assert!(html.contains("data-rust-rendered-row=\"page\""));
assert!(html.contains("data-shell-mode=\"page\""));
assert!(html.contains("data-rust-action=\"open\""));
assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(html.contains("data-rust-action=\"create\""));
assert!(html.contains("draggable=\"true\""));
assert!(html.contains("tree-children"));
@@ -52,11 +52,12 @@ fn render_picker_row(
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
) {
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
active = row.active,
tabindex = if row.active { "0" } else { "-1" },
title = escape_html(&row.title),
));
if row.expandable && row.expanded {
@@ -77,8 +78,9 @@ pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
);
if input.allow_root_pick {
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
focused = input.root_active,
tabindex = if input.root_active { "0" } else { "-1" },
));
}
@@ -180,6 +182,14 @@ mod tests {
expandable: false,
expanded: false,
active: true,
}, PickerRenderRow {
node_id: "page_other".into(),
parent_node_id: None,
title: "其他页面".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
}],
});
@@ -190,5 +200,7 @@ mod tests {
assert!(html.contains("data-testid=\"tree-picker-row\""));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-focused=\"true\""));
assert!(html.contains("tabindex=\"0\""));
assert!(html.contains("tabindex=\"-1\""));
}
}
@@ -26,6 +26,7 @@ pub struct TreeShellRendererInput {
pub excluded_picker_ids: BTreeSet<String>,
pub picker_state_reducer: Option<PickerStateReducerContract>,
pub command_dispatcher: TreeShellCommandDispatcher,
pub runtime_artifact: TreeShellRuntimeArtifactBoundary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -35,6 +36,41 @@ pub struct TreeShellCommandDispatcher {
pub command_names: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeArtifactBoundary {
pub contract_name: &'static str,
pub input_fields: BTreeSet<&'static str>,
pub output_channels: BTreeSet<&'static str>,
pub event_kinds: BTreeSet<&'static str>,
}
impl Default for TreeShellRuntimeArtifactBoundary {
fn default() -> Self {
Self {
contract_name: "rust_tree_shell_runtime_artifact_v1",
input_fields: BTreeSet::from([
"rendererInput",
"projectionItems",
"expandedIds",
"selectedRowIds",
"activePickerItem",
"focusedId",
]),
output_channels: BTreeSet::from(["domPatch", "intentEvent", "commandDispatchEvent"]),
event_kinds: BTreeSet::from([
"focus",
"keyboard",
"expandCollapse",
"selection",
"contextMenu",
"dragDrop",
"pick",
]),
}
}
}
impl TreeShellRendererInput {
pub fn page(input: PageTreeRendererInput) -> Self {
Self {
@@ -49,6 +85,7 @@ impl TreeShellRendererInput {
excluded_picker_ids: BTreeSet::new(),
picker_state_reducer: None,
command_dispatcher: input.command_dispatcher,
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
}
}
@@ -65,6 +102,7 @@ impl TreeShellRendererInput {
excluded_picker_ids: BTreeSet::new(),
picker_state_reducer: None,
command_dispatcher: input.command_dispatcher,
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
}
}
@@ -81,6 +119,7 @@ impl TreeShellRendererInput {
excluded_picker_ids: input.excluded_picker_ids,
picker_state_reducer: Some(PickerStateReducerContract::default()),
command_dispatcher: input.command_dispatcher,
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
}
}
}
@@ -113,8 +152,8 @@ pub struct PickerRendererInput {
#[cfg(test)]
mod tests {
use super::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput, TreeShellRendererMode,
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput,
TreeShellCommandDispatcher, TreeShellRendererInput, TreeShellRendererMode,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use std::collections::BTreeSet;
@@ -158,7 +197,10 @@ mod tests {
});
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a"));
assert!(filetree
.filetree_selection
.selected_row_ids
.contains("asset:a"));
assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!(
filetree
@@ -188,4 +230,36 @@ mod tests {
Some("rust_picker_state_reducer_v1")
);
}
#[test]
fn tree_shell_renderer_input_exposes_runtime_artifact_boundary() {
let page = TreeShellRendererInput::page(PageTreeRendererInput {
projection_item_ids: vec!["doc:root".into()],
expanded_ids: set(&["doc:root"]),
focused_id: Some("doc:root".into()),
command_dispatcher: dispatcher(),
});
let artifact = page.runtime_artifact;
assert_eq!(
artifact.contract_name,
"rust_tree_shell_runtime_artifact_v1"
);
assert!(artifact.input_fields.contains("rendererInput"));
assert!(artifact.input_fields.contains("projectionItems"));
assert!(artifact.input_fields.contains("expandedIds"));
assert!(artifact.input_fields.contains("selectedRowIds"));
assert!(artifact.input_fields.contains("activePickerItem"));
assert!(artifact.input_fields.contains("focusedId"));
assert!(artifact.output_channels.contains("domPatch"));
assert!(artifact.output_channels.contains("intentEvent"));
assert!(artifact.output_channels.contains("commandDispatchEvent"));
assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse"));
assert!(artifact.event_kinds.contains("selection"));
assert!(artifact.event_kinds.contains("contextMenu"));
assert!(artifact.event_kinds.contains("dragDrop"));
assert!(artifact.event_kinds.contains("pick"));
}
}