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:
@@ -1,10 +1,25 @@
|
||||
use super::protocol;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeRenderRow {
|
||||
pub row_id: String,
|
||||
pub row_kind: String,
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeInitialRenderInput {
|
||||
pub rows: Vec<FileTreeRenderRow>,
|
||||
}
|
||||
|
||||
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
||||
@@ -19,3 +34,139 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn row_test_id(row_kind: &str) -> &'static str {
|
||||
match row_kind {
|
||||
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
|
||||
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
|
||||
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_filetree_row(
|
||||
html: &mut String,
|
||||
row: &FileTreeRenderRow,
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
|
||||
) {
|
||||
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="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
test_id = row_test_id(&row.row_kind),
|
||||
row_id = escape_html(&row.row_id),
|
||||
row_kind = escape_html(&row.row_kind),
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
icon_kind = escape_html(&row.icon_kind),
|
||||
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_filetree_row(html, child, children_by_parent);
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
}
|
||||
}
|
||||
html.push_str("</li>");
|
||||
}
|
||||
|
||||
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
|
||||
let mut html = String::from(
|
||||
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
|
||||
);
|
||||
if input.rows.is_empty() {
|
||||
html.push_str(
|
||||
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面。</li>"#,
|
||||
);
|
||||
html.push_str("</ul>");
|
||||
return html;
|
||||
}
|
||||
|
||||
let ids = input
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.node_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut children_by_parent = BTreeMap::<Option<String>, Vec<FileTreeRenderRow>>::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_filetree_row(&mut html, root, &children_by_parent);
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_renderer_outputs_initial_nested_html_contract() {
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows: vec![
|
||||
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: true,
|
||||
expanded: true,
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
selected: true,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
row_id: "index:page_root".into(),
|
||||
row_kind: "index".into(),
|
||||
node_id: "index:page_root".into(),
|
||||
parent_node_id: Some("page_root".into()),
|
||||
title: "index.md".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
icon_kind: "index".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
|
||||
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user