Files
mnote/rust/crates/mnote-web/src/workspace_shell.rs
T

586 lines
22 KiB
Rust
Raw Normal View History

2026-04-29 12:24:44 +08:00
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<String>,
pub active_page_title: Option<String>,
pub degraded: bool,
pub degraded_reason: Option<String>,
pub dev_fixture: bool,
2026-04-29 12:24:44 +08:00
pub starred_items: Vec<WorkspaceShellItem>,
pub my_page_items: Vec<WorkspaceShellItem>,
pub bottom_entries: Vec<WorkspaceShellEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
2026-04-29 14:36:24 +08:00
pub parent_id: Option<String>,
2026-04-29 12:24:44 +08:00
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 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::<Vec<_>>();
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()));
2026-04-29 14:36:24 +08:00
apply_active_page_to_items(&mut my_page_items, active_page_id.as_deref());
2026-04-29 12:24:44 +08:00
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::<Vec<_>>();
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
2026-04-29 14:36:24 +08:00
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);
2026-04-29 12:24:44 +08:00
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,
2026-04-29 12:24:44 +08:00
starred_items,
my_page_items,
bottom_entries: vec![
WorkspaceShellEntry {
id: "trash".into(),
label: "垃圾箱".into(),
href: "/trash".into(),
icon: "delete".into(),
2026-04-29 12:24:44 +08:00
},
WorkspaceShellEntry {
id: "templates".into(),
label: "模板中心".into(),
href: "/templates".into(),
icon: "inventory_2".into(),
2026-04-29 12:24:44 +08:00
},
],
}
}
fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
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<WorkspaceShellItem> {
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("无标题");
2026-04-29 14:36:24 +08:00
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);
2026-04-29 12:24:44 +08:00
let depth = document
.get("depth")
.and_then(Value::as_u64)
2026-04-29 14:36:24 +08:00
.or_else(|| parent_id.as_ref().map(|_| 1))
2026-04-29 12:24:44 +08:00
.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),
2026-04-29 14:36:24 +08:00
parent_id,
2026-04-29 12:24:44 +08:00
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
active: active_page_id.is_some_and(|active_id| active_id == id),
})
}
2026-04-29 14:36:24 +08:00
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<String> {
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(),
);
}
2026-04-29 12:24:44 +08:00
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);
2026-04-29 12:24:44 +08:00
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);
2026-04-29 14:36:24 +08:00
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 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);
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\""));
2026-04-30 06:58:17 +08:00
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\""));
2026-04-29 14:36:24 +08:00
}
2026-04-29 16:23:49 +08:00
#[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#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
);
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("wolai-file-tree-section"));
}
2026-04-29 14:36:24 +08:00
#[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);
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
2026-04-29 12:24:44 +08:00
}
#[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(
&degraded_dataset,
"ws_demo",
None,
"开发用户 的工作区",
);
let degraded_html = render_workspace_shell_sidebar_html(&degraded_projection, 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);
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\""));
}
2026-04-29 12:24:44 +08:00
}
pub fn render_workspace_shell_sidebar_html(
projection: &WorkspaceShellProjection,
sidebar_tree_html: Option<&str>,
2026-04-29 14:36:24 +08:00
file_tree_html: Option<&str>,
2026-04-29 12:24:44 +08:00
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
} else {
projection
.starred_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("")
};
let projected_my_pages = projection
.my_page_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("");
let tree_html = sidebar_tree_html
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
2026-04-29 14:36:24 +08:00
r#"<div class="sidebar-tree-section sidebar-page-tree-section" data-testid="wolai-sidebar-page-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree" data-tree-shell-mode="page" data-workspace-id="{}">{html}</div></div>"#,
escape_html(&projection.workspace_id),
2026-04-29 12:24:44 +08:00
)
})
.unwrap_or_default();
2026-04-29 16:23:49 +08:00
let file_tree_content = file_tree_html
2026-04-29 14:36:24 +08:00
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
2026-04-29 16:23:49 +08:00
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>"#,
2026-04-29 14:36:24 +08:00
escape_html(&projection.workspace_id),
)
})
2026-04-29 16:23:49 +08:00
.unwrap_or_else(|| {
r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-filetree-empty-state">暂无文件或页面</div>"#.to_string()
});
2026-04-29 14:36:24 +08:00
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
let my_pages = if !tree_html.is_empty() {
2026-04-29 12:24:44 +08:00
tree_html
2026-04-29 14:36:24 +08:00
} else if !projected_my_pages.is_empty() {
projected_my_pages
2026-04-29 12:24:44 +08:00
} else {
2026-04-29 14:36:24 +08:00
empty_state.to_string()
2026-04-29 12:24:44 +08:00
};
let bottom_entries = projection
.bottom_entries
.iter()
.map(|entry| {
format!(
r#"<a href="{}" class="wolai-footer-entry">{}{}</a>"#,
2026-04-29 12:24:44 +08:00
escape_html(&entry.href),
render_symbol(&entry.icon, "wolai-footer-icon"),
2026-04-29 12:24:44 +08:00
escape_html(&entry.label),
)
})
.collect::<Vec<_>>()
.join("");
let status_markers = format!(
r#"{degraded_marker}{dev_fixture_marker}"#,
degraded_marker = if projection.degraded {
format!(
r#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")),
)
} else {
String::new()
},
dev_fixture_marker = if projection.dev_fixture {
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="workspace-shell"></span>"#.to_string()
} else {
String::new()
},
);
2026-04-29 12:24:44 +08:00
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="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" 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">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" 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"),
2026-04-29 14:36:24 +08:00
escape_html(&projection.workspace_id),
2026-04-29 12:24:44 +08:00
)
}
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 {
2026-04-29 16:23:49 +08:00
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 12)
2026-04-29 12:24:44 +08:00
};
2026-04-29 14:36:24 +08:00
let parent_attr = item
.parent_id
.as_deref()
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let aria_current = if item.active {
r#" aria-current="page""#
} else {
""
};
2026-04-29 12:24:44 +08:00
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>"#,
2026-04-29 12:24:44 +08:00
escape_html(&item.href),
escape_html(&item.id),
escape_html(&item.id),
2026-04-29 14:36:24 +08:00
item.depth,
item.active,
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
2026-04-29 12:24:44 +08:00
escape_html(&item.title),
)
}
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));
2026-04-30 06:58:17 +08:00
let filled_class = if icon == "star" || icon == "home" {
" material-symbols-filled"
} else {
""
};
format!(
2026-04-30 06:58:17 +08:00
r#"<span class="material-symbols-outlined{} {}" data-icon="{}" aria-hidden="true"></span>"#,
filled_class,
escape_html(class_name),
escape_html(icon),
)
}
2026-04-29 12:24:44 +08:00
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}