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, pub title: String, pub depth: u32, pub expandable: bool, pub expanded: bool, pub icon_kind: String, pub document_id: Option, pub asset_id: Option, pub object_identity: Option, pub selected: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileTreeInitialRenderInput { pub rows: Vec, } pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> { rows.iter() .map(|row| { let test_id = match row.row_kind.as_str() { "document" => protocol::TEST_ID_FILETREE_DOC_ROW, "index" => protocol::TEST_ID_FILETREE_INDEX_ROW, _ => protocol::TEST_ID_FILETREE_ASSET_ROW, }; (test_id, row.row_id.clone()) }) .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, Vec>, ) { let toggle_html = if row.expandable { format!( r#""#, row_id = escape_html(&row.row_id), label = if row.expanded { "折叠" } else { "展开" }, title = escape_html(&row.title), marker = if row.expanded { "▾" } else { "▸" }, ) } else { r#""#.to_string() }; let parent_attr = row .parent_node_id .as_deref() .map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id))) .unwrap_or_default(); let create_action_html = if row.row_kind == "document" { format!( r#""#, row_id = escape_html(&row.row_id), node_id = escape_html(&row.node_id), ) } else { String::new() }; html.push_str(&format!( r#"
  • {toggle_html}
    {create_action_html}
    "#, 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), parent_attr = parent_attr, document_id = escape_html(row.document_id.as_deref().unwrap_or_default()), asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()), object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()), selected = row.selected, toggle_html = toggle_html, icon_kind = escape_html(&row.icon_kind), create_action_html = create_action_html, title = escape_html(&row.title), )); if row.expandable { if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { html.push_str(if row.expanded { r#"
      "# } else { r#"
        "# }); for child in children { render_filetree_row(html, child, children_by_parent); } html.push_str("
      "); } } html.push_str(""); } pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String { let mut html = String::from( r#"
        "#, ); if input.rows.is_empty() { html.push_str( r#"
      • 暂无文件或页面
      • "#, ); html.push_str("
      "); return html; } let ids = input .rows .iter() .map(|row| row.node_id.clone()) .collect::>(); let mut children_by_parent = BTreeMap::, Vec>::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("
    "); 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, object_identity: Some( r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(), ), 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, object_identity: Some( r#"{"objectKind":"index","documentId":"page_root","blockId":null,"assetId":null}"#.into(), ), 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("data-doc-id=\"page_root\"")); assert!(html.contains("data-object-identity=\"{"objectKind":"index"")); assert!(html.contains("tree-children")); assert!(html.contains("首页 <安全>")); assert!(html.contains("data-selected=\"true\"")); } }