use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceShellProjection { pub schema: String, pub workspace_id: String, pub workspace_name: String, pub active_page_id: Option, pub active_page_title: Option, pub degraded: bool, pub degraded_reason: Option, pub dev_fixture: bool, pub starred_items: Vec, pub my_page_items: Vec, pub bottom_entries: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceShellItem { pub id: String, pub title: String, pub icon: Option, pub shortcut_id: Option, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub kind: Option, pub target_id: Option, pub relative_path: Option, pub document_id: Option, pub parent_id: Option, pub href: String, pub depth: u32, pub active: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceShellEntry { pub id: String, pub label: String, pub href: String, pub icon: String, } /// 使用页面树投影生成顶栏页面祖先链。 pub fn render_page_breadcrumb_html( projection: &WorkspaceShellProjection, active_page_id: Option<&str>, ) -> String { let Some(active_page_id) = active_page_id .map(str::trim) .filter(|value| !value.is_empty()) else { return String::new(); }; let by_id = projection .my_page_items .iter() .map(|item| (item.id.as_str(), item)) .collect::>(); let mut chain = Vec::new(); let mut cursor = Some(active_page_id); let mut seen = std::collections::HashSet::new(); while let Some(id) = cursor { if !seen.insert(id) { break; } let Some(item) = by_id.get(id) else { break; }; chain.push(*item); cursor = item.parent_id.as_deref(); if chain.len() >= 64 { break; } } chain.reverse(); chain .iter() .enumerate() .map(|(index, item)| { let separator = if index == 0 { String::new() } else { r#""#.to_string() }; let title = escape_html(&item.title); let id = escape_html(&item.id); if index + 1 == chain.len() { format!( r#"{separator}{title}"# ) } else { format!( r#"{separator}{title}"#, escape_html(&item.href) ) } }) .collect() } pub fn build_workspace_shell_projection( dataset: &Value, workspace_id: &str, active_page_id: Option<&str>, default_workspace_name: &str, ) -> WorkspaceShellProjection { let active_page_id = active_page_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let documents = dataset .get("documents") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let workspace_name = resolve_workspace_name(dataset, workspace_id) .unwrap_or_else(|| default_workspace_name.trim().to_string()) .if_empty_else(|| "个人空间".to_string()); let mut my_page_items = documents .iter() .filter_map(|document| document_to_item(document, workspace_id, None)) .collect::>(); my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone())); let active_page_id = active_page_id .or_else(|| { dataset .get("active_page_id") .or_else(|| dataset.get("activePageId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) .or_else(|| my_page_items.first().map(|item| item.id.clone())); apply_active_page_to_items(&mut my_page_items, active_page_id.as_deref()); let mut starred_items = documents .iter() .filter(|document| { document .get("is_starred") .or_else(|| document.get("isStarred")) .and_then(Value::as_bool) .unwrap_or(false) }) .filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref())) .collect::>(); let starred_page_ids = starred_items .iter() .map(|item| item.id.clone()) .collect::>(); 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()); let degraded = dataset .get("degraded") .or_else(|| dataset.get("is_degraded")) .and_then(Value::as_bool) .unwrap_or(false); let degraded_reason = dataset .get("degraded_reason") .or_else(|| dataset.get("degradedReason")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let dev_fixture = dataset .get("dev_fixture") .or_else(|| dataset.get("devFixture")) .and_then(Value::as_bool) .unwrap_or(false); WorkspaceShellProjection { schema: "mnote.workspace_shell.v1".into(), workspace_id: workspace_id.to_string(), workspace_name, active_page_id, active_page_title, degraded, degraded_reason, dev_fixture, starred_items, my_page_items, bottom_entries: vec![ WorkspaceShellEntry { id: "trash".into(), label: "垃圾箱".into(), href: "/trash".into(), icon: "delete".into(), }, WorkspaceShellEntry { id: "templates".into(), label: "模板中心".into(), href: "/templates".into(), icon: "inventory_2".into(), }, ], } } fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option { dataset .get("workspaces") .and_then(Value::as_array) .and_then(|workspaces| { workspaces.iter().find_map(|workspace| { let id = workspace .get("id") .or_else(|| workspace.get("workspace_id")) .or_else(|| workspace.get("workspaceId")) .and_then(Value::as_str)?; if id != workspace_id { return None; } workspace .get("name") .or_else(|| workspace.get("title")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) }) } fn document_to_item( document: &Value, workspace_id: &str, active_page_id: Option<&str>, ) -> Option { let document_workspace_id = document .get("workspace_id") .or_else(|| document.get("workspaceId")) .and_then(Value::as_str) .unwrap_or(workspace_id); if document_workspace_id != workspace_id { return None; } if document .get("deleted_at") .or_else(|| document.get("deletedAt")) .is_some_and(|value| !value.is_null()) { return None; } let id = document .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let title = document .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题"); let parent_id = document .get("parent_id") .or_else(|| document.get("parentId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let depth = document .get("depth") .and_then(Value::as_u64) .or_else(|| parent_id.as_ref().map(|_| 1)) .unwrap_or(0) as u32; Some(WorkspaceShellItem { id: id.to_string(), title: title.to_string(), icon: document .get("icon") .and_then(Value::as_str) .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, active: active_page_id.is_some_and(|active_id| active_id == id), }) } 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, ) -> Option { 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 { 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); } } fn active_title_from_items( items: &[WorkspaceShellItem], active_page_id: Option<&str>, ) -> Option { active_page_id.and_then(|active_id| { items .iter() .find(|item| item.id == active_id) .map(|item| item.title.clone()) }) } pub fn apply_active_page(projection: &mut WorkspaceShellProjection, active_page_id: Option<&str>) { let normalized = active_page_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); projection.active_page_id = normalized; apply_active_page_to_items( &mut projection.my_page_items, projection.active_page_id.as_deref(), ); apply_active_page_to_items( &mut projection.starred_items, projection.active_page_id.as_deref(), ); projection.active_page_title = active_title_from_items( &projection.my_page_items, projection.active_page_id.as_deref(), ); } trait StringEmptyExt { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; } impl StringEmptyExt for String { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String { if self.trim().is_empty() { fallback() } else { self } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() { let dataset = json!({ "active_workspace_id": "ws_demo", "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [ { "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true }, { "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false } ] }); let projection = build_workspace_shell_projection( &dataset, "ws_demo", Some("page_home"), "开发用户 的工作区", ); assert_eq!(projection.schema, "mnote.workspace_shell.v1"); assert_eq!(projection.workspace_id, "ws_demo"); assert_eq!(projection.workspace_name, "开发用户 的工作区"); assert_eq!(projection.active_page_id.as_deref(), Some("page_home")); assert_eq!(projection.active_page_title.as_deref(), Some("个人")); assert!(!projection.degraded); assert!(!projection.dev_fixture); assert_eq!(projection.starred_items.len(), 1); assert_eq!(projection.starred_items[0].title, "个人"); assert_eq!(projection.my_page_items.len(), 2); assert!(projection.my_page_items[0].active); assert_eq!( projection.my_page_items[1].parent_id.as_deref(), Some("page_home") ); assert!(projection .bottom_entries .iter() .any(|entry| entry.label == "垃圾箱")); assert!(projection .bottom_entries .iter() .any(|entry| entry.label == "模板中心")); } #[test] fn page_breadcrumb_renders_all_ancestors_as_links() { let projection = build_workspace_shell_projection( &json!({ "workspaces": [{ "id": "ws_demo", "name": "我的空间" }], "documents": [ { "id": "root", "workspace_id": "ws_demo", "title": "个人", "parent_id": null }, { "id": "parent", "workspace_id": "ws_demo", "title": "密码", "parent_id": "root" }, { "id": "current", "workspace_id": "ws_demo", "title": "deepseek ai", "parent_id": "parent" } ] }), "ws_demo", Some("current"), "我的空间", ); let html = render_page_breadcrumb_html(&projection, Some("current")); assert!(html.contains(r#"data-breadcrumb-document-id="root""#)); assert!(html.contains(r#"data-breadcrumb-document-id="parent""#)); assert!(html.contains(r#"data-breadcrumb-document-id="current""#)); assert!(html.contains(r#"href="/documents/root?workspaceId=ws_demo""#)); assert!(html.contains(r#"href="/documents/parent?workspaceId=ws_demo""#)); assert!(html.contains("deepseek ai")); assert_eq!(html.matches("wolai-breadcrumb-separator").count(), 2); } #[test] fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() { let dataset = json!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [ { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 }, { "id": "doc_child", "workspace_id": "ws_demo", "title": "Child", "parent_id": "doc_root", "sort_order": 1 } ] }); let projection = build_workspace_shell_projection( &dataset, "ws_demo", Some("doc_root"), "开发用户 的工作区", ); 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\"")); assert!(html.contains("data-node-id=\"doc_child\"")); assert!(html.contains("data-parent-id=\"doc_root\"")); assert!(html.contains("data-active=\"true\"")); 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")); 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!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [ { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 } ] }); let projection = build_workspace_shell_projection( &dataset, "ws_demo", Some("doc_root"), "开发用户 的工作区", ); let html = render_workspace_shell_sidebar_html( &projection, Some(r#"
    "#), Some(r#"
      "#), None, None, ); assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#)); assert!(html.contains(r#"data-mnote-sidebar-tree-tab="filetree""#)); assert!(html.contains(r#"data-mnote-sidebar-tree-panel="page""#)); assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#)); assert!(html.contains(r#"id="sidebar-tree-root""#)); assert!(html.contains(r#"id="sidebar-file-tree-root""#)); assert!(!html.contains(r#"data-filetree-ssr="pending""#)); assert!(!html.contains("wolai-file-tree-section")); } #[test] fn workspace_shell_sidebar_html_can_render_filetree_tab_initially_active() { let dataset = json!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [ { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 } ] }); let projection = build_workspace_shell_projection( &dataset, "ws_demo", Some("doc_root"), "开发用户 的工作区", ); let html = render_workspace_shell_sidebar_html( &projection, Some(r#"
        "#), Some(r#"
          "#), Some("filetree"), None, ); assert!(html.contains( r#"class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="page" aria-selected="false""# )); assert!(html.contains( r#"class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="filetree" aria-selected="true""# )); assert!(html.contains(r#"data-mnote-sidebar-tree-panel="page" hidden"#)); assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree">"#), Some(r#"
            "#), None, None, ); assert!(html.contains(r#"id="sidebar-file-tree-root""#)); assert!(html.contains(r#"data-filetree-ssr="pending""#)); assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#)); assert!(html.contains(r#"aria-busy="true""#)); } #[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#"
              "#), 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!({ "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, None, None, None); assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\"")); assert!(!html.contains("暂无页面")); } #[test] fn workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states() { let degraded_dataset = json!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [], "degraded": true, "degraded_reason": "projection_unavailable" }); let degraded_projection = build_workspace_shell_projection( °raded_dataset, "ws_demo", None, "开发用户 的工作区", ); let degraded_html = render_workspace_shell_sidebar_html(°raded_projection, None, None, None, None); assert!(degraded_projection.degraded); assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\"")); assert!(degraded_html .contains("data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\"")); let dev_dataset = json!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], "documents": [], "dev_fixture": true }); 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, None); assert!(dev_projection.dev_fixture); assert!(dev_html.contains("data-mnote-dev-fixture=\"true\"")); assert!(dev_html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\"")); } } pub fn render_workspace_shell_sidebar_html( projection: &WorkspaceShellProjection, 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() } else { projection .starred_items .iter() .map(render_item_row) .collect::>() .join("") }; let projected_my_pages = projection .my_page_items .iter() .map(render_item_row) .collect::>() .join(""); let tree_html = sidebar_tree_html .map(str::trim) .filter(|value| !value.is_empty()) .map(|html| { format!( r#""#, escape_html(&projection.workspace_id), ) }) .unwrap_or_default(); let file_tree_content = file_tree_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(); // Propagate shell-first pending marker onto the FileTree root so the client // can hydrate without scanning for nested placeholders. let pending_attr = if html.contains("data-filetree-ssr=\"pending\"") || html.contains("data-rust-filetree-renderer=\"pending_shell_v1\"") { r#" data-filetree-ssr="pending" aria-busy="true""# } else { "" }; format!( r#""#, escape_html(&projection.workspace_id), ) }) .unwrap_or_else(|| { String::new() }); let my_pages = if !tree_html.is_empty() { tree_html } else if !projected_my_pages.is_empty() { projected_my_pages } else { String::new() }; let filetree_initially_active = initial_tree_mode .map(str::trim) .is_some_and(|value| value == "filetree") && !file_tree_content.trim().is_empty(); let page_tab_class = if filetree_initially_active { "wolai-sidebar-tab wolai-sidebar-tab-muted" } else { "wolai-sidebar-tab wolai-sidebar-tab-active" }; let page_aria_selected = if filetree_initially_active { "false" } else { "true" }; let filetree_tab_class = if filetree_initially_active { "wolai-sidebar-tab wolai-sidebar-tab-active" } else { "wolai-sidebar-tab wolai-sidebar-tab-muted" }; let filetree_aria_selected = if filetree_initially_active { "true" } else { "false" }; let page_panel_hidden = if filetree_initially_active { " hidden" } else { "" }; let filetree_panel_hidden = if filetree_initially_active { "" } else { " hidden" }; let bottom_entries = projection .bottom_entries .iter() .map(|entry| { let extra_attrs = if entry.id == "trash" { format!( r#" data-testid="mnote-sidebar-trash-entry" data-mnote-action="open-trash-modal" data-workspace-id="{}""#, escape_html(&projection.workspace_id), ) } else { String::new() }; format!( r#"{}{}"#, escape_html(&entry.href), extra_attrs, render_symbol(&entry.icon, "wolai-footer-icon"), escape_html(&entry.label), ) }) .collect::>() .join(""); let status_markers = format!( r#"{degraded_marker}{dev_fixture_marker}"#, degraded_marker = if projection.degraded { format!( r#""#, escape_html( projection .degraded_reason .as_deref() .unwrap_or("projection_unavailable") ), ) } else { String::new() }, dev_fixture_marker = if projection.dev_fixture { r#""#.to_string() } else { String::new() }, ); format!( r#"{status_markers}
              {}星标置顶
              {starred_rows}
              {my_pages}
              {file_tree_content}
              "#, render_symbol("star", "wolai-section-icon"), render_symbol("folder_open", "wolai-folder-icon"), escape_html(&projection.workspace_id), escape_html(&projection.workspace_id), ) } fn render_item_row(item: &WorkspaceShellItem) -> String { let active_class = if item.active { " wolai-active-row" } else { "" }; let depth_style = if item.depth == 0 { String::new() } else { format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 12) }; let parent_attr = item .parent_id .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 { "" }; 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#"{}{}"#, 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#""# .to_string() }) .unwrap_or_default(); if item.href.trim().is_empty() { return format!( r#"
              {row_body}{shortcut_action}
              "#, escape_html(&item.id), escape_html(item.document_id.as_deref().unwrap_or(&item.id)), item.depth, item.active, ); } format!( r#"{row_body}{shortcut_action}"#, escape_html(&item.href), escape_html(&item.id), escape_html(item.document_id.as_deref().unwrap_or(&item.id)), item.depth, item.active, ) } fn item_icon_name(icon: Option<&str>) -> &'static str { match icon.unwrap_or_default().trim() { "star" | "★" => "star", "folder" | "folder_open" | "▱" => "folder_open", "delete" | "trash" | "⌫" => "delete", "inventory_2" | "template" | "◇" => "inventory_2", "home" | "page" | "▣" | "⌂" | "" => "home", _ => "home", } } fn render_symbol(icon: &str, class_name: &str) -> String { let icon = item_icon_name(Some(icon)); let filled_class = if icon == "star" || icon == "home" { " material-symbols-filled" } else { "" }; format!( r#""#, filled_class, escape_html(class_name), escape_html(icon), ) } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }