feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -1,9 +1,15 @@
use super::protocol;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRenderRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub title: String,
pub depth: u32,
pub expandable: bool,
pub expanded: bool,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -13,6 +19,13 @@ pub struct PickerRenderResult {
pub allow_root_pick: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerInitialRenderInput {
pub rows: Vec<PickerRenderRow>,
pub allow_root_pick: bool,
pub root_active: bool,
}
pub fn build_picker_render_result(
rows: &[PickerRenderRow],
allow_root_pick: bool,
@@ -24,10 +37,84 @@ pub fn build_picker_render_result(
}
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn render_picker_row(
html: &mut String,
row: &PickerRenderRow,
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>"#,
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,
title = escape_html(&row.title),
));
if row.expandable && row.expanded {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#);
for child in children {
render_picker_row(html, child, children_by_parent);
}
html.push_str("</ul>");
}
}
html.push_str("</li>");
}
pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">"#,
);
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>"#,
focused = input.root_active,
));
}
let ids = input
.rows
.iter()
.map(|row| row.node_id.clone())
.collect::<BTreeSet<_>>();
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PickerRenderRow>>::new();
for row in &input.rows {
let parent_id = row
.parent_node_id
.as_ref()
.filter(|parent_id| ids.contains(*parent_id))
.cloned();
children_by_parent
.entry(parent_id)
.or_default()
.push(row.clone());
}
if let Some(roots) = children_by_parent.get(&None).cloned() {
for root in &roots {
render_picker_row(&mut html, root, &children_by_parent);
}
}
html.push_str("</ul>");
html
}
#[cfg(test)]
mod tests {
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
use super::{build_picker_render_result, PickerRenderRow};
use super::{
build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput,
PickerRenderRow,
};
#[test]
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
@@ -35,18 +122,41 @@ mod tests {
FileTreeRenderRow {
row_id: "doc:page_root".into(),
row_kind: "document".into(),
node_id: "page_root".into(),
parent_node_id: None,
title: "首页".into(),
depth: 0,
expandable: false,
expanded: false,
icon_kind: "page".into(),
document_id: Some("page_root".into()),
asset_id: None,
selected: false,
},
FileTreeRenderRow {
row_id: "asset:asset_1".into(),
row_kind: "asset".into(),
node_id: "asset:asset_1".into(),
parent_node_id: Some("page_root".into()),
title: "附件".into(),
depth: 1,
expandable: false,
expanded: false,
icon_kind: "file".into(),
document_id: Some("page_root".into()),
asset_id: Some("asset_1".into()),
selected: false,
},
]);
let picker = build_picker_render_result(
&[PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
}],
true,
);
@@ -56,4 +166,29 @@ mod tests {
assert_eq!(picker.root_test_id, "tree-picker-root");
assert!(picker.allow_root_pick);
}
#[test]
fn tree_shell_picker_renderer_outputs_initial_html_contract() {
let html = render_initial_picker_html(&PickerInitialRenderInput {
allow_root_pick: true,
root_active: false,
rows: vec![PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
}],
});
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
assert!(html.contains("data-rust-rendered-row=\"picker\""));
assert!(html.contains("data-testid=\"tree-picker-root\""));
assert!(html.contains("data-testid=\"tree-picker-row\""));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-focused=\"true\""));
}
}