Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
1173 lines
44 KiB
Rust
1173 lines
44 KiB
Rust
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,
|
||
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>,
|
||
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,
|
||
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::<std::collections::HashMap<_, _>>();
|
||
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#"<span class="wolai-breadcrumb-separator" aria-hidden="true">›</span>"#.to_string()
|
||
};
|
||
let title = escape_html(&item.title);
|
||
let id = escape_html(&item.id);
|
||
if index + 1 == chain.len() {
|
||
format!(
|
||
r#"{separator}<span class="wolai-breadcrumb-current" data-breadcrumb-document-id="{id}"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{title}</span></span>"#
|
||
)
|
||
} else {
|
||
format!(
|
||
r#"{separator}<a class="wolai-breadcrumb-link" data-breadcrumb-document-id="{id}" href="{}">{title}</a>"#,
|
||
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::<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()));
|
||
|
||
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::<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());
|
||
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<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("无标题");
|
||
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<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);
|
||
}
|
||
}
|
||
|
||
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(),
|
||
);
|
||
}
|
||
|
||
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</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!({
|
||
"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>"#),
|
||
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#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||
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"><div"#));
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_shell_sidebar_html_propagates_pending_filetree_shell_marker() {
|
||
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,
|
||
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||
Some(r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#),
|
||
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#"<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!({
|
||
"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::<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!(
|
||
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),
|
||
)
|
||
})
|
||
.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#"<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}{pending_attr}>{html}</div></div>"#,
|
||
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#"<a href="{}" class="wolai-footer-entry"{}>{}{}</a>"#,
|
||
escape_html(&entry.href),
|
||
extra_attrs,
|
||
render_symbol(&entry.icon, "wolai-footer-icon"),
|
||
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()
|
||
},
|
||
);
|
||
|
||
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><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),
|
||
)
|
||
}
|
||
|
||
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#"<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,
|
||
)
|
||
}
|
||
|
||
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#"<span class="material-symbols-outlined{} {}" data-icon="{}" aria-hidden="true"></span>"#,
|
||
filled_class,
|
||
escape_html(class_name),
|
||
escape_html(icon),
|
||
)
|
||
}
|
||
|
||
fn escape_html(value: &str) -> String {
|
||
value
|
||
.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
.replace('\'', "'")
|
||
}
|