328 lines
14 KiB
Rust
328 lines
14 KiB
Rust
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 relative_path: Option<String>,
|
|
pub object_identity: Option<String>,
|
|
pub index_status: 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)> {
|
|
rows.iter()
|
|
.map(|row| {
|
|
let test_id = match row.row_kind.as_str() {
|
|
// markdown 与 document 同属文档行(command_document_id 已同等对待)
|
|
"document" | "markdown" => 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" | "markdown" => 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>>,
|
|
) {
|
|
let toggle_html = if row.expandable {
|
|
format!(
|
|
r#"<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="{row_id}" aria-label="{label} {title}">{marker}</button>"#,
|
|
row_id = escape_html(&row.row_id),
|
|
label = if row.expanded { "折叠" } else { "展开" },
|
|
title = escape_html(&row.title),
|
|
marker = if row.expanded { "▾" } else { "▸" },
|
|
)
|
|
} else {
|
|
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.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#"<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="{row_id}" data-node-id="{node_id}" aria-label="新建子页面">+</button>"#,
|
|
row_id = escape_html(&row.row_id),
|
|
node_id = escape_html(&row.node_id),
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let command_document_id = match row.row_kind.as_str() {
|
|
"document" | "markdown" => row.document_id.as_deref().unwrap_or(&row.node_id),
|
|
"index" => row.node_id.strip_prefix("index:").unwrap_or(&row.node_id),
|
|
_ => "",
|
|
};
|
|
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
|
let index_status_attr = row
|
|
.index_status
|
|
.as_deref()
|
|
.map(|status| format!(r#" data-index-status="{}""#, escape_html(status)))
|
|
.unwrap_or_default();
|
|
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}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}"{index_status_attr} data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<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}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<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),
|
|
parent_attr = parent_attr,
|
|
document_id = escape_html(command_document_id),
|
|
owner_document_id = escape_html(owner_document_id),
|
|
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
|
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
|
|
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
|
index_status_attr = index_status_attr,
|
|
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 && 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>");
|
|
}
|
|
|
|
/// Placeholder FileTree shell for shell-first home SSR.
|
|
/// Client hydrates rows via `/api/tree/projections/file` without blocking first paint.
|
|
pub fn render_filetree_pending_shell_html() -> String {
|
|
String::from(
|
|
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#,
|
|
)
|
|
}
|
|
|
|
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("</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,
|
|
relative_path: None,
|
|
object_identity: Some(
|
|
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
|
),
|
|
index_status: None,
|
|
selected: true,
|
|
},
|
|
FileTreeRenderRow {
|
|
row_id: "asset:mind_1".into(),
|
|
row_kind: "asset".into(),
|
|
node_id: "asset:mind_1".into(),
|
|
parent_node_id: Some("page_root".into()),
|
|
title: "思维导图.json".into(),
|
|
depth: 1,
|
|
expandable: false,
|
|
expanded: false,
|
|
icon_kind: "mindmap".into(),
|
|
document_id: Some("page_root".into()),
|
|
asset_id: Some("mind_1".into()),
|
|
relative_path: Some("assets/思维导图.json".into()),
|
|
object_identity: Some(
|
|
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
|
|
),
|
|
index_status: 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-asset-row\""));
|
|
assert!(!html.contains("data-testid=\"filetree-index-row\""));
|
|
assert!(!html.contains("index.md"));
|
|
assert!(html.contains("data-doc-id=\"page_root\""));
|
|
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
|
|
assert!(html.contains("data-asset-id=\"mind_1\""));
|
|
assert!(html.contains("data-local-relative-path=\"assets/思维导图.json\""));
|
|
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
|
assert!(html.contains("tree-children"));
|
|
assert!(html.contains("首页 <安全>"));
|
|
assert!(html.contains("title=\"首页 <安全>\""));
|
|
assert!(html.contains("title=\"思维导图.json\""));
|
|
assert!(html.contains("data-selected=\"true\""));
|
|
}
|
|
|
|
#[test]
|
|
fn tree_shell_filetree_renderer_does_not_ssr_collapsed_descendants() {
|
|
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
|
rows: vec![
|
|
FileTreeRenderRow {
|
|
row_id: "local:folder:docs".into(),
|
|
row_kind: "folder".into(),
|
|
node_id: "local:node:docs".into(),
|
|
parent_node_id: None,
|
|
title: "docs".into(),
|
|
depth: 0,
|
|
expandable: true,
|
|
expanded: false,
|
|
icon_kind: "folder".into(),
|
|
document_id: None,
|
|
asset_id: None,
|
|
relative_path: Some("docs".into()),
|
|
object_identity: None,
|
|
index_status: None,
|
|
selected: false,
|
|
},
|
|
FileTreeRenderRow {
|
|
row_id: "local:markdown:docs/README.md".into(),
|
|
row_kind: "markdown".into(),
|
|
node_id: "local:node:docs/README.md".into(),
|
|
parent_node_id: Some("local:node:docs".into()),
|
|
title: "README.md".into(),
|
|
depth: 1,
|
|
expandable: false,
|
|
expanded: false,
|
|
icon_kind: "file".into(),
|
|
document_id: Some("local-md:docs~2FREADME.md".into()),
|
|
asset_id: None,
|
|
relative_path: Some("docs/README.md".into()),
|
|
object_identity: None,
|
|
index_status: None,
|
|
selected: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
assert!(html.contains("data-row-id=\"local:folder:docs\""));
|
|
assert!(html.contains("data-local-relative-path=\"docs\""));
|
|
assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\""));
|
|
assert!(!html.contains("README.md"));
|
|
assert!(!html.contains("tree-children--collapsed"));
|
|
}
|
|
|
|
#[test]
|
|
fn filetree_ssr_rows_include_local_relative_path() {
|
|
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
|
rows: vec![FileTreeRenderRow {
|
|
row_id: "local:folder:design/03-rust-web".into(),
|
|
row_kind: "folder".into(),
|
|
node_id: "local:node:design/03-rust-web".into(),
|
|
parent_node_id: None,
|
|
title: "03-rust-web".into(),
|
|
depth: 1,
|
|
expandable: true,
|
|
expanded: false,
|
|
icon_kind: "folder".into(),
|
|
document_id: None,
|
|
asset_id: None,
|
|
relative_path: Some("design/03-rust-web".into()),
|
|
object_identity: None,
|
|
index_status: Some("indexed".into()),
|
|
selected: false,
|
|
}],
|
|
});
|
|
|
|
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
|
|
assert!(html.contains(r#"data-index-status="indexed""#));
|
|
assert!(html.contains(r#"<button type="button" class="tree-link""#));
|
|
}
|
|
|
|
#[test]
|
|
fn filetree_pending_shell_marks_ssr_pending_for_client_hydrate() {
|
|
let html = super::render_filetree_pending_shell_html();
|
|
assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#));
|
|
assert!(html.contains(r#"data-filetree-ssr="pending""#));
|
|
assert!(html.contains(r#"aria-busy="true""#));
|
|
assert!(!html.contains("tree-row"));
|
|
}
|
|
}
|