Improve local filetree view state and sidebar performance

This commit is contained in:
lix-2026
2026-05-27 11:31:12 +08:00
parent 58e2fdb5d8
commit 3ae33cc21d
56 changed files with 8614 additions and 461 deletions
+404 -13
View File
@@ -23,6 +23,14 @@ pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
pub shortcut_id: Option<String>,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub kind: Option<String>,
pub target_id: Option<String>,
pub relative_path: Option<String>,
pub document_id: Option<String>,
pub parent_id: Option<String>,
pub href: String,
pub depth: u32,
@@ -88,6 +96,21 @@ pub fn build_workspace_shell_projection(
})
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
.collect::<Vec<_>>();
let starred_page_ids = starred_items
.iter()
.map(|item| item.id.clone())
.collect::<std::collections::BTreeSet<_>>();
for shortcut in sidebar_shortcuts(dataset) {
if let Some(item) = shortcut_to_item(
shortcut,
workspace_id,
active_page_id.as_deref(),
&documents,
&starred_page_ids,
) {
starred_items.push(item);
}
}
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
@@ -214,6 +237,14 @@ fn document_to_item(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
shortcut_id: None,
workspace_id: Some(workspace_id.to_string()),
source_kind: None,
root_uri: None,
kind: Some("page".to_string()),
target_id: Some(id.to_string()),
relative_path: None,
document_id: Some(id.to_string()),
parent_id,
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
@@ -221,6 +252,207 @@ fn document_to_item(
})
}
fn sidebar_shortcuts(dataset: &Value) -> Vec<&Value> {
dataset
.get("sidebar_shortcuts")
.or_else(|| dataset.get("sidebarShortcuts"))
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn shortcut_to_item(
shortcut: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
documents: &[Value],
starred_page_ids: &std::collections::BTreeSet<String>,
) -> Option<WorkspaceShellItem> {
let shortcut_workspace_id = shortcut
.get("workspace_id")
.or_else(|| shortcut.get("workspaceId"))
.and_then(Value::as_str)
.unwrap_or(workspace_id);
let source_kind = shortcut
.get("source_kind")
.or_else(|| shortcut.get("sourceKind"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("workspace");
if shortcut_workspace_id != workspace_id && source_kind != "local_folder" {
return None;
}
let kind = shortcut
.get("kind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let target_id = shortcut
.get("target_id")
.or_else(|| shortcut.get("targetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if kind == "page" {
let document_id = shortcut
.get("document_id")
.or_else(|| shortcut.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(target_id);
if starred_page_ids.contains(document_id) {
return None;
}
let root_uri = shortcut_root_uri(shortcut);
if let Some(document) = documents.iter().find(|document| {
document.get("id").and_then(Value::as_str).map(str::trim) == Some(document_id)
}) {
let mut item = document_to_item(document, workspace_id, active_page_id)?;
let shortcut_id = shortcut
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(document_id)
.to_string();
item.id = shortcut_id.clone();
item.shortcut_id = Some(shortcut_id);
item.kind = Some("page".to_string());
item.workspace_id = Some(shortcut_workspace_id.to_string());
item.source_kind = Some(source_kind.to_string());
item.root_uri = root_uri.clone();
item.href = shortcut_document_href(
document_id,
shortcut_workspace_id,
source_kind,
root_uri.as_deref(),
);
item.target_id = Some(target_id.to_string());
item.document_id = Some(document_id.to_string());
return Some(item);
}
}
let title = shortcut
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
if kind == "folder" {
"文件夹"
} else {
"无标题"
}
});
let relative_path = shortcut
.get("relative_path")
.or_else(|| shortcut.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let document_id = shortcut
.get("document_id")
.or_else(|| shortcut.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let root_uri = shortcut_root_uri(shortcut);
let shortcut_id = shortcut
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(target_id)
.to_string();
Some(WorkspaceShellItem {
id: shortcut_id.clone(),
title: title.to_string(),
icon: shortcut
.get("icon")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| (kind == "folder").then(|| "folder_open".to_string())),
shortcut_id: Some(shortcut_id),
workspace_id: Some(shortcut_workspace_id.to_string()),
source_kind: Some(source_kind.to_string()),
root_uri: root_uri.clone(),
kind: Some(kind.to_string()),
target_id: Some(target_id.to_string()),
relative_path,
document_id: document_id.clone(),
parent_id: None,
href: document_id
.as_ref()
.map(|id| {
shortcut_document_href(id, shortcut_workspace_id, source_kind, root_uri.as_deref())
})
.unwrap_or_default(),
depth: 0,
active: kind == "page"
&& active_page_id.is_some_and(|active_id| document_id.as_deref() == Some(active_id)),
})
}
fn shortcut_root_uri(shortcut: &Value) -> Option<String> {
shortcut
.get("rootUri")
.or_else(|| shortcut.get("root_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
shortcut
.get("metadata")
.and_then(|metadata| metadata.get("rootUri").or_else(|| metadata.get("root_uri")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn shortcut_document_href(
document_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: Option<&str>,
) -> String {
let mut href = format!(
"/documents/{}?workspaceId={}",
document_id,
encode_query_component(workspace_id)
);
if !source_kind.trim().is_empty() && source_kind != "workspace" {
href.push_str("&sourceKind=");
href.push_str(&encode_query_component(source_kind));
}
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
href.push_str("&rootUri=");
href.push_str(&encode_query_component(root_uri));
}
href
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(*byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
for item in items {
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
@@ -336,7 +568,7 @@ mod tests {
Some("doc_root"),
"开发用户 的工作区",
);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
assert!(html.contains("data-node-id=\"doc_root\""));
@@ -346,12 +578,52 @@ mod tests {
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
assert!(html.contains("data-mnote-action=\"create-page\""));
assert!(html.contains("data-testid=\"wolai-sidebar-create-folder\""));
assert!(html.contains("data-mnote-action=\"create-folder\""));
assert!(html.contains("wolai-section-add--folder"));
assert!(html.contains("data-icon=\"folder_open\""));
assert!(!html.contains(">create_new_folder</span>"));
assert!(html.contains("class=\"material-symbols-outlined"));
assert!(html.contains("data-icon=\"home\""));
assert!(html.contains("data-icon=\"star\""));
assert!(html.contains("data-icon=\"delete\""));
}
#[test]
fn workspace_shell_sidebar_html_renders_sqlite_folder_shortcut_attrs() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [],
"sidebarShortcuts": [{
"id": "shortcut_design",
"workspaceId": "local-ws:demo",
"kind": "folder",
"sourceKind": "local_folder",
"targetId": "folder:design",
"relativePath": "design",
"title": "design",
"icon": "folder_open",
"metadata": {
"rootUri": "file:///tmp/mnote-demo"
}
}]
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert_eq!(projection.starred_items.len(), 1);
assert!(html.contains(r#"data-mnote-shortcut-kind="folder""#));
assert!(html.contains(r#"data-mnote-shortcut-id="shortcut_design""#));
assert!(html.contains(r#"data-workspace-id="local-ws:demo""#));
assert!(html.contains(r#"data-mnote-shortcut-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-shortcut-target-id="folder:design""#));
assert!(html.contains(r#"data-mnote-shortcut-relative-path="design""#));
assert!(html.contains(r#"data-mnote-shortcut-root-uri="file:///tmp/mnote-demo""#));
assert!(html.contains(r#"data-mnote-shortcut-action="menu""#));
assert!(html.contains(r#"role="button""#));
}
#[test]
fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
let dataset = json!({
@@ -371,6 +643,7 @@ mod tests {
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
None,
None,
);
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
@@ -401,6 +674,7 @@ mod tests {
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
Some("filetree"),
None,
);
assert!(html.contains(
@@ -413,6 +687,26 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
}
#[test]
fn workspace_shell_sidebar_html_marks_filetree_scope() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": []
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(
&projection,
None,
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
Some("filetree"),
Some("design"),
);
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(html.contains(r#"data-mnote-filetree-scope="design""#));
}
#[test]
fn workspace_shell_sidebar_html_outputs_empty_state() {
let dataset = json!({
@@ -421,7 +715,7 @@ mod tests {
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
assert!(!html.contains("暂无页面"));
@@ -442,7 +736,7 @@ mod tests {
"开发用户 的工作区",
);
let degraded_html =
render_workspace_shell_sidebar_html(&degraded_projection, None, None, None);
render_workspace_shell_sidebar_html(&degraded_projection, None, None, None, None);
assert!(degraded_projection.degraded);
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
@@ -456,7 +750,7 @@ mod tests {
});
let dev_projection =
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None);
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);
assert!(dev_projection.dev_fixture);
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
@@ -469,6 +763,7 @@ pub fn render_workspace_shell_sidebar_html(
sidebar_tree_html: Option<&str>,
file_tree_html: Option<&str>,
initial_tree_mode: Option<&str>,
file_tree_scope: Option<&str>,
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
@@ -500,8 +795,18 @@ pub fn render_workspace_shell_sidebar_html(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
let file_tree_scope_attr = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
format!(
r#" data-mnote-filetree-scope="{}""#,
escape_html(value)
)
})
.unwrap_or_default();
format!(
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div>"#,
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})
@@ -594,10 +899,11 @@ pub fn render_workspace_shell_sidebar_html(
);
format!(
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button><button type="button" class="wolai-section-add wolai-section-add--folder" data-testid="wolai-sidebar-create-folder" data-mnote-action="create-folder" data-workspace-id="{}" title="新建文件夹" aria-label="新建文件夹"><span class="material-symbols-outlined" data-icon="folder_open" aria-hidden="true"></span></button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
render_symbol("star", "wolai-section-icon"),
render_symbol("folder_open", "wolai-folder-icon"),
escape_html(&projection.workspace_id),
escape_html(&projection.workspace_id),
)
}
@@ -613,20 +919,105 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
.as_deref()
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let workspace_attr = item
.workspace_id
.as_deref()
.map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id)))
.unwrap_or_default();
let shortcut_id_attr = item
.shortcut_id
.as_deref()
.map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id)))
.unwrap_or_default();
let source_kind_attr = item
.source_kind
.as_deref()
.map(|source_kind| {
format!(
r#" data-mnote-shortcut-source-kind="{}""#,
escape_html(source_kind)
)
})
.unwrap_or_default();
let root_uri_attr = item
.root_uri
.as_deref()
.map(|root_uri| {
format!(
r#" data-mnote-shortcut-root-uri="{}""#,
escape_html(root_uri)
)
})
.unwrap_or_default();
let aria_current = if item.active {
r#" aria-current="page""#
} else {
""
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(&item.id),
item.depth,
item.active,
let kind_attr = item
.kind
.as_deref()
.map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind)))
.unwrap_or_default();
let target_attr = item
.target_id
.as_deref()
.map(|target_id| {
format!(
r#" data-mnote-shortcut-target-id="{}""#,
escape_html(target_id)
)
})
.unwrap_or_default();
let relative_attr = item
.relative_path
.as_deref()
.map(|relative_path| {
format!(
r#" data-mnote-shortcut-relative-path="{}""#,
escape_html(relative_path)
)
})
.unwrap_or_default();
let document_attr = item
.document_id
.as_deref()
.map(|document_id| {
format!(
r#" data-mnote-shortcut-document-id="{}""#,
escape_html(document_id)
)
})
.unwrap_or_default();
let row_body = format!(
r#"<span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span>"#,
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
escape_html(&item.title),
);
let shortcut_action = item
.shortcut_id
.as_deref()
.map(|_| {
r#"<button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>"#
.to_string()
})
.unwrap_or_default();
if item.href.trim().is_empty() {
return format!(
r#"<div class="wolai-page-row{active_class}" role="button" tabindex="0" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</div>"#,
escape_html(&item.id),
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
item.depth,
item.active,
);
}
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
item.depth,
item.active,
)
}