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>,
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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-05-27 11:31:12 +08:00
|
|
|
|
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>,
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
/// 使用页面树投影生成顶栏页面祖先链。
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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<_>>();
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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());
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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,
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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(),
|
2026-04-30 05:46:36 +08:00
|
|
|
|
icon: "delete".into(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
},
|
|
|
|
|
|
WorkspaceShellEntry {
|
|
|
|
|
|
id: "templates".into(),
|
|
|
|
|
|
label: "模板中心".into(),
|
|
|
|
|
|
href: "/templates".into(),
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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-05-27 11:31:12 +08:00
|
|
|
|
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()),
|
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-05-27 11:31:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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("个人"));
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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 == "模板中心"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
#[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"),
|
|
|
|
|
|
"开发用户 的工作区",
|
|
|
|
|
|
);
|
2026-05-27 11:31:12 +08:00
|
|
|
|
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
|
|
|
|
|
|
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-05-27 11:31:12 +08:00
|
|
|
|
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>"));
|
2026-04-30 06:58:17 +08:00
|
|
|
|
assert!(html.contains("class=\"material-symbols-outlined"));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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-05-27 11:31:12 +08:00
|
|
|
|
#[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""#));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>"#),
|
2026-05-26 14:34:47 +08:00
|
|
|
|
None,
|
2026-05-27 11:31:12 +08:00
|
|
|
|
None,
|
2026-04-29 16:23:49 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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""#));
|
2026-07-21 05:13:05 +08:00
|
|
|
|
assert!(!html.contains(r#"data-filetree-ssr="pending""#));
|
2026-04-29 16:23:49 +08:00
|
|
|
|
assert!(!html.contains("wolai-file-tree-section"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 14:34:47 +08:00
|
|
|
|
#[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"),
|
2026-05-27 11:31:12 +08:00
|
|
|
|
None,
|
2026-05-26 14:34:47 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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"#));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
#[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""#));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[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""#));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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, "开发用户 的工作区");
|
2026-05-27 11:31:12 +08:00
|
|
|
|
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
|
2026-05-21 23:53:39 +08:00
|
|
|
|
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
|
|
|
|
|
assert!(!html.contains("暂无页面"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
2026-05-14 05:52:08 +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(
|
|
|
|
|
|
°raded_dataset,
|
|
|
|
|
|
"ws_demo",
|
|
|
|
|
|
None,
|
|
|
|
|
|
"开发用户 的工作区",
|
|
|
|
|
|
);
|
2026-05-26 14:34:47 +08:00
|
|
|
|
let degraded_html =
|
2026-05-27 11:31:12 +08:00
|
|
|
|
render_workspace_shell_sidebar_html(°raded_projection, None, None, None, None);
|
2026-05-14 05:52:08 +08:00
|
|
|
|
|
|
|
|
|
|
assert!(degraded_projection.degraded);
|
|
|
|
|
|
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
2026-05-16 07:38:45 +08:00
|
|
|
|
assert!(degraded_html
|
|
|
|
|
|
.contains("data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""));
|
2026-05-14 05:52:08 +08:00
|
|
|
|
|
|
|
|
|
|
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, "开发用户 的工作区");
|
2026-05-27 11:31:12 +08:00
|
|
|
|
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);
|
2026-05-14 05:52:08 +08:00
|
|
|
|
|
|
|
|
|
|
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-05-26 14:34:47 +08:00
|
|
|
|
initial_tree_mode: Option<&str>,
|
2026-05-27 11:31:12 +08:00
|
|
|
|
file_tree_scope: 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| {
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
// 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 {
|
|
|
|
|
|
""
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
format!(
|
2026-07-21 05:13:05 +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="{}"{file_tree_scope_attr}{pending_attr}>{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(|| {
|
2026-05-21 23:53:39 +08:00
|
|
|
|
String::new()
|
2026-04-29 16:23:49 +08:00
|
|
|
|
});
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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-05-21 23:53:39 +08:00
|
|
|
|
String::new()
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-05-26 14:34:47 +08:00
|
|
|
|
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"
|
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let bottom_entries = projection
|
|
|
|
|
|
.bottom_entries
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|entry| {
|
2026-05-16 07:38:45 +08:00
|
|
|
|
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()
|
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
format!(
|
2026-05-16 07:38:45 +08:00
|
|
|
|
r#"<a href="{}" class="wolai-footer-entry"{}>{}{}</a>"#,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
escape_html(&entry.href),
|
2026-05-16 07:38:45 +08:00
|
|
|
|
extra_attrs,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
render_symbol(&entry.icon, "wolai-footer-icon"),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
escape_html(&entry.label),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("");
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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>"#,
|
2026-05-16 07:38:45 +08:00
|
|
|
|
escape_html(
|
|
|
|
|
|
projection
|
|
|
|
|
|
.degraded_reason
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.unwrap_or("projection_unavailable")
|
|
|
|
|
|
),
|
2026-05-14 05:52:08 +08:00
|
|
|
|
)
|
|
|
|
|
|
} 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!(
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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>"#,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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-05-27 11:31:12 +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();
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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();
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let aria_current = if item.active {
|
|
|
|
|
|
r#" aria-current="page""#
|
|
|
|
|
|
} else {
|
|
|
|
|
|
""
|
|
|
|
|
|
};
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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>"#,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
escape_html(&item.title),
|
2026-05-27 11:31:12 +08:00
|
|
|
|
);
|
|
|
|
|
|
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,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
""
|
2026-04-30 05:46:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
format!(
|
2026-04-30 06:58:17 +08:00
|
|
|
|
r#"<span class="material-symbols-outlined{} {}" data-icon="{}" aria-hidden="true"></span>"#,
|
|
|
|
|
|
filled_class,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
escape_html(class_name),
|
|
|
|
|
|
escape_html(icon),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
fn escape_html(value: &str) -> String {
|
|
|
|
|
|
value
|
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
|
}
|