chore: land tree view-state, vault, Pi module split, and repo hygiene

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.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
@@ -1365,8 +1365,9 @@ const AI_ADMIN_SCRIPT: &str = r#"
}
if (knowledgePanel) {
var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {};
// S5: hydrate read-only health + directory summary from /api/knowledge-rag/status
knowledgePanel.innerHTML =
'<div class="mnote-ai-admin-admin-list">' +
'<div class="mnote-ai-admin-admin-list" data-ai-admin-knowledge-live>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>LightRAG</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag('唯一默认 provider', 'green') + renderTag('MNote facade', 'blue') +
@@ -1377,7 +1378,11 @@ const AI_ADMIN_SCRIPT: &str = r#"
renderKv('查询入口', 'mnote.knowledge_rag.query') +
renderKv('引用回跳', 'citation / open-reference') +
renderKv('Pi 接入方式', '只能通过 MNote knowledge facade 查询') +
renderKv('Health', '加载中…') +
renderKv('Pipeline', '加载中…') +
renderKv('Documents', '加载中…') +
'</div>' +
'<p class="mnote-ai-admin-mini-desc" data-ai-admin-knowledge-summary>正在读取 /api/knowledge-rag/status…</p>' +
'</div>' +
'</div>' +
'<div class="mnote-ai-admin-admin-card">' +
@@ -1385,10 +1390,60 @@ const AI_ADMIN_SCRIPT: &str = r#"
renderTag('复用 allowed roots', 'green') + renderTag('不引入第二套 RAG', 'orange') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<p class="mnote-ai-admin-mini-desc">资料 source/index/status 后续接 /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。</p>' +
'<p class="mnote-ai-admin-mini-desc">目录授权继续来自 MNote allowed rootsdirectory_grants);本面板只读展示 LightRAG health / pipeline / documents 摘要,不维护第二套目录。</p>' +
'</div>' +
'</div>' +
'</div>';
requestJson('/api/knowledge-rag/status', { method: 'GET' }).then(function (statusPayload) {
if (!knowledgePanel) return;
var health = statusPayload && statusPayload.health || {};
var healthOk = health.ok === true || (health.health && health.health.ok === true);
var healthLabel = healthOk ? 'ok' : (health.message || health.code || 'unavailable');
var pipeline = statusPayload && statusPayload.pipeline || {};
var pipelineLabel = pipeline.ok === false
? (pipeline.message || pipeline.code || 'error')
: (pipeline.status || pipeline.phase || pipeline.state || (pipeline.ok === true ? 'ok' : JSON.stringify(pipeline).slice(0, 80)));
var docs = statusPayload && statusPayload.documents || {};
var docList = Array.isArray(docs.documents) ? docs.documents : [];
var groups = docs.rawStatusGroups || {};
var groupKeys = Object.keys(groups || {});
var groupSummary = groupKeys.length
? groupKeys.map(function (k) { return k + '=' + groups[k]; }).join(', ')
: (docList.length + ' docs');
var registry = statusPayload && statusPayload.registry || null;
var entryCount = registry && Array.isArray(registry.entries) ? registry.entries.length : 0;
var rootCount = registry && Array.isArray(registry.indexed_roots || registry.indexedRoots)
? (registry.indexed_roots || registry.indexedRoots).length
: 0;
var provider = (statusPayload && statusPayload.provider) || lightrag.provider || 'lightrag';
knowledgePanel.innerHTML =
'<div class="mnote-ai-admin-admin-list" data-ai-admin-knowledge-live>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>LightRAG</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(healthOk ? 'health ok' : 'health down', healthOk ? 'green' : 'orange') +
renderTag(String(provider), 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<div class="mnote-ai-admin-kv-grid">' +
renderKv('Provider', provider) +
renderKv('Endpoint', (statusPayload && statusPayload.endpoint) || '-') +
renderKv('Health', healthLabel) +
renderKv('Pipeline', String(pipelineLabel)) +
renderKv('Documents', groupSummary) +
renderKv('Registry entries', String(entryCount)) +
renderKv('Indexed roots', String(rootCount)) +
renderKv('查询入口', 'mnote.knowledge_rag.query') +
'</div>' +
'<p class="mnote-ai-admin-mini-desc" data-ai-admin-knowledge-summary>只读摘要来自 /api/knowledge-rag/status;目录授权真相仍是 control-plane directory_grants / allowed roots。</p>' +
'</div>' +
'</div>' +
'</div>';
}).catch(function (err) {
if (!knowledgePanel) return;
var msg = err && err.message ? err.message : 'status unavailable';
var summary = knowledgePanel.querySelector('[data-ai-admin-knowledge-summary]');
if (summary) summary.textContent = 'LightRAG status 读取失败: ' + msg;
});
}
if (healthGrid) {
healthGrid.innerHTML = [
@@ -2747,10 +2802,14 @@ const AI_ADMIN_SCRIPT: &str = r#"
collectSkillsFromDOM().forEach(function(skill) {
var id = skill.id || skill.name;
if (!id) return;
// S2: preserve source/risk/scopes so delete/save does not soft-break skills.
body.skills[id] = {
name: skill.name,
enabled: skill.enabled,
description: skill.description || ''
description: skill.description || '',
source: skill.source || '',
riskLevel: skill.riskLevel || skill.risk_level || 'low',
requiredScopes: skill.requiredScopes || skill.required_scopes || []
};
});
}
@@ -2768,7 +2827,10 @@ const AI_ADMIN_SCRIPT: &str = r#"
networkPolicy: server.networkPolicy || 'deny-all',
secretRefs: server.secretRefs || [],
facadeOnly: server.facadeOnly !== false,
sandbox: true
sandbox: true,
description: server.description || '',
riskLevel: server.riskLevel || server.risk_level || 'medium',
requiredScopes: server.requiredScopes || server.required_scopes || []
};
});
}
@@ -97,8 +97,18 @@ pub(super) fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
data-pane-role={pane_role_attr_three}
data-title-endpoint="/api/documents/title"
rows="1"
readonly
data-document-user-readonly-mode="true"
>{model.title.clone()}</textarea>
</h1>
<button
type="button"
class="document-edit-mode-toggle"
data-document-edit-mode-toggle="true"
aria-pressed="false"
data-document-editing="false"
title="进入编辑模式"
>"开始编辑"</button>
</div>
<div class="document-shell-meta" aria-label="页面元信息">
<span data-page-title-current="true">{model.title.clone()}</span>
@@ -161,6 +171,9 @@ pub fn DocumentPage(
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
@@ -194,6 +207,9 @@ pub fn DocumentPage(
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 local_folder 上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -293,7 +309,7 @@ pub fn DocumentPage(
hide_title_header: secondary_hide_title_header,
};
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} breadcrumb_html={breadcrumb_html.unwrap_or_default()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live} vault_nav_href={vault_nav_href.unwrap_or_default()}>
<div
class="document-workspace"
data-testid="mnote-document-workspace"
+7 -1
View File
@@ -21,6 +21,9 @@ pub fn HomePage(
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// 当前选中的页面 id(可选)
#[prop(optional)]
active_page_id: Option<String>,
@@ -36,6 +39,9 @@ pub fn HomePage(
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 local_folder 上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let active_page_id = active_page_id.unwrap_or_default();
let active_page_title = active_page_title
@@ -54,7 +60,7 @@ pub fn HomePage(
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} breadcrumb_html={breadcrumb_html.unwrap_or_default()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live} vault_nav_href={vault_nav_href.unwrap_or_default()}>
{move || if has_active_page {
view! {
<main class="document-shell document-shell--workspace-entry" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
+234 -7
View File
@@ -62,15 +62,25 @@ pub fn PageLayout(
/// 顶栏当前页面标题(可选)
#[prop(optional)]
topbar_title: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// 是否显示管理员授权能力
#[prop(optional)]
show_admin_access_policy: bool,
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 sourceKind/rootUri,避免首击全页丢失上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let _ = show_admin_access_policy;
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
let vault_nav_href = vault_nav_href
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "/vault".to_string());
let ws_name = workspace_name
.map(|value| value.trim().to_string())
@@ -80,6 +90,15 @@ pub fn PageLayout(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人".to_string());
let breadcrumb_html = breadcrumb_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
r#"<span class="wolai-breadcrumb-current"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{}</span></span>"#,
crate::routes::web_shell::escape_html(&topbar_title),
)
});
let sidebar_sections_html = workspace_sidebar_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
@@ -135,7 +154,7 @@ pub fn PageLayout(
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
<button type="button" title="导航页" aria-label="导航页" data-mnote-action="open-navigation-page"><span class="material-symbols-outlined nav-icon" data-icon="home" aria-hidden="true"></span></button>
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
<a href={vault_nav_href.clone()} class:active={current_nav == "vault"} title="密码箱" aria-label="密码箱" data-testid="mnote-nav-vault" data-mnote-nav="vault"><span class="material-symbols-outlined nav-icon" data-icon="lock" aria-hidden="true"></span></a>
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
<button
type="button"
@@ -172,9 +191,9 @@ pub fn PageLayout(
<div class="wolai-topbar-left">
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" data-state="closed" aria-haspopup="true" aria-expanded="false" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
<a class="wolai-breadcrumb-root wolai-breadcrumb-link" href="/" aria-label="返回工作区首页">{ws_name.clone()}</a>
<span class="wolai-breadcrumb-separator" aria-hidden="true">""</span>
<span class="wolai-breadcrumb-current"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{topbar_title}</span></span>
<div class="wolai-breadcrumb-pages" data-breadcrumb-pages="true" inner_html={breadcrumb_html}></div>
</nav>
</div>
<div class="wolai-topbar-actions" aria-label="页面操作">
@@ -288,10 +307,12 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: false"));
assert!(!SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: true"));
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: false")
);
assert!(
!SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: true")
);
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("data-search-switch=\"knowledge\" role=\"switch\" aria-checked=\"false\""));
assert!(!SIDEBAR_TREE_RUNTIME_JS
@@ -400,6 +421,29 @@ mod tests {
.contains("var expanded = expandable && item.expandedByDefault !== false"),
"PageTree 不能只依赖 projection expandedByDefault 决定展开"
);
assert!(
render_page_rows.contains("item.expandedByDefault === true")
|| render_page_rows.contains("expandedByDefault === true"),
"无 user view-state 时 PageTree 只能按 expandedByDefault === true 展开(与 SSR 一致)"
);
// PageTree view-state scope must be stable "root" (Sidex workspace-scoped),
// not current fileTreeScope — otherwise refresh under a folder loses expands.
let view_scope =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope");
assert!(
view_scope.contains("pagetree") && view_scope.contains("'root'"),
"pagetree view-state scope must pin to root"
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("function restorePersistedPageTreeExpansionState"),
"runtime must restore pagetree expandedIds after refresh"
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function setPageTreeExpandPresentation"),
"runtime must own a single presentation write path for page expand"
);
let render_file_rows =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
@@ -407,6 +451,154 @@ mod tests {
assert!(render_file_rows.contains("expandedRelativePaths"));
}
#[test]
fn page_tree_first_click_expands_ssr_children_without_waiting_for_fetch() {
// Shallow SSR paints nested rows under a collapsed container without
// data-page-tree-children-loaded. First toggle must expand immediately.
let toggle_children =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "toggleChildren");
assert!(
toggle_children.contains("pageTreeRowIsOpen(row)"),
"toggleChildren must use visual open state, not aria-expanded alone"
);
assert!(
toggle_children.contains("children.children.length > 0"),
"toggleChildren must expand when SSR already rendered child rows"
);
assert!(
toggle_children.contains("data-page-tree-children-loaded"),
"toggleChildren must mark SSR children as loaded on first expand"
);
let load_page_children =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadPageTreeChildren");
assert!(
load_page_children.contains("children.children.length > 0"),
"loadPageTreeChildren must treat non-empty SSR children as already loaded"
);
assert!(
load_page_children.contains("Optimistic UI")
|| load_page_children.contains("children.classList.remove('tree-children--collapsed')"),
"lazy expand must open chevron/container before network"
);
// Late view-state GET must not snap shut an expand the user just did
// (privacy window / cold localStorage is the common repro).
let load_view_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadSidebarTreeViewState");
assert!(
load_view_state.contains("current.hasUserState"),
"async view-state load must merge with in-session expands"
);
assert!(
load_view_state.contains("current.expandedIds.forEach"),
"async view-state load must union expandedIds from the session"
);
let apply_page_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "applyPageTreeExpansionState");
assert!(
apply_page_state.contains("pageTreeRowIsOpen(row)"),
"applying view-state must preserve rows that are actually open this session"
);
// Boot must reconcile false "expanded" arrows before the first click,
// apply local view-state, and restore persisted expands (Sidex setInput).
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function syncPageTreeExpandVisualState"),
"runtime must ship syncPageTreeExpandVisualState"
);
let install = js_function_body(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS,
"installTreeLiveApplyEventListeners",
);
assert!(
install.contains("syncPageTreeExpandVisualState()"),
"install must run expand/chevron sync on boot"
);
assert!(
install.contains("applyPageTreeExpansionState")
|| install.contains("scheduleRestorePersistedPageTreeExpansionState"),
"install must apply/restore pagetree view-state on boot"
);
let boot_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewStateFor");
assert!(
boot_state.contains("applySidebarTreeViewState"),
"localStorage view-state must apply to DOM immediately (not wait for API)"
);
// PageTree view-state scope must stay stable "root" (not fileTreeScope),
// otherwise refresh/navigation loses expandedIds under a different key.
let scope_fn = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope");
assert!(
scope_fn.contains("pagetree") && scope_fn.contains("'root'"),
"pagetree view-state scope must be stable root, not current fileTreeScope"
);
let restore_fn = js_function_body(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS,
"restorePersistedPageTreeExpansionState",
);
assert!(
!restore_fn.is_empty(),
"runtime must restore persisted page-tree expands after refresh"
);
assert!(
restore_fn.contains("loadPageTreeChildren") || restore_fn.contains("setTreeRowExpanded"),
"restore must open SSR children or hydrate empty bodies"
);
let presentation =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "setPageTreeExpandPresentation");
assert!(
presentation.contains("data-expanded") && presentation.contains("aria-expanded"),
"single write path must set both aria-expanded and data-expanded"
);
let render_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
assert!(
render_rows.contains("expandedByDefault === true")
|| render_rows.contains("item.expandedByDefault === true"),
"client default expand must match SSR (only true, not !== false)"
);
assert!(
render_rows.contains("data-expanded="),
"client-rendered page rows must emit data-expanded for chevron CSS"
);
}
#[test]
fn sidebar_tree_toggle_uses_wolai_style_rotating_chevron() {
// Hide non-rotating SSR SVG; show solid triangle via ::before that rotates 90°.
const MAIN_CSS: &str = include_str!("../styles/components/main.css");
assert!(
MAIN_CSS.contains(".sidebar-tree .tree-toggle .tree-toggle-icon")
|| MAIN_CSS.contains(".tree-toggle > svg"),
"main.css must hide the static SVG chevron inside tree-toggle"
);
assert!(
MAIN_CSS.contains(".sidebar-tree .tree-toggle::before"),
"main.css must draw Wolai-style triangle via ::before"
);
assert!(
MAIN_CSS.contains("rotate(90deg)"),
"main.css must rotate chevron when expanded"
);
assert!(
MAIN_CSS.contains("data-expanded=\"true\"")
|| MAIN_CSS.contains("[data-expanded=\"true\"]"),
"main.css must key rotation off data-expanded (real fold state)"
);
// Icon must track real fold state (children container), not only aria.
assert!(
MAIN_CSS.contains("tree-children--collapsed")
&& MAIN_CSS.contains(":has(> .tree-children"),
"main.css must bind chevron rotation to actual children open/collapsed state"
);
}
#[test]
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
let html = crate::ssr::render_view(leptos::view! {
@@ -650,6 +842,30 @@ mod tests {
assert!(!folder_branch.contains("window.location.assign"));
}
#[test]
fn vault_nav_soft_opens_without_replacing_sidebar_tree() {
// Left-rail 密码箱 must soft-swap .mnote-content so PageTree expand state survives.
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openVaultWorkbenchSoft"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.__mnoteOpenVaultWorkbench"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("void openVaultWorkbenchSoft(vaultUrl)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildVaultWorkbenchShellHtml"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("article.mnote-content"));
// Must not full-assign when soft path is taken (vault click branch).
let vault_branch = SIDEBAR_TREE_RUNTIME_JS
.split("var vaultNavLink = closestAction(")
.nth(1)
.and_then(|rest| rest.split("var localFolderTrigger").next())
.expect("vault nav click branch exists");
assert!(
vault_branch.contains("openVaultWorkbenchSoft"),
"vault click must soft-open workbench"
);
assert!(
!vault_branch.contains("window.location.assign(nextHref)"),
"vault soft-open must not full-navigate on primary click"
);
}
#[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!(
@@ -1648,6 +1864,17 @@ mod tests {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
}
#[test]
fn sidebar_filetree_watch_fallback_keeps_refresh_scoped() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-local-folder-watch-batch-fallback-scoped"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
"addCommandRefreshParent(fallbackParents, parentRelativePathForPath(relativePath))"
));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fallbackResync === true || batch.requiresResync === true)) {\n document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));\n void refreshLocalFolderSidebarSnapshot();"));
}
#[test]
fn sidebar_filetree_runtime_has_view_state_namespace() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {"));
+14 -3
View File
@@ -17,6 +17,7 @@
/// - `styles/components/search.css`: 搜索弹窗组件
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
/// - `styles/components/vault.css`: 密码箱 workbench
pub const MNOTE_CSS: &str = concat!(
include_str!("styles/tokens.css"),
"\n",
@@ -37,6 +38,8 @@ pub const MNOTE_CSS: &str = concat!(
include_str!("styles/components/page-ai.css"),
"\n",
include_str!("styles/components/ui-debug.css"),
"\n",
include_str!("styles/components/vault.css"),
);
#[cfg(test)]
@@ -215,8 +218,16 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装
// 仍保持在可审阅范围内
assert!(MNOTE_CSS.len() < 190000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包
assert!(MNOTE_CSS.len() < 220000);
}
#[test]
fn mnote_css_contains_vault_workbench_selectors() {
assert!(MNOTE_CSS.contains(".mnote-vault-workbench"));
assert!(MNOTE_CSS.contains(".mnote-vault-list-item"));
assert!(MNOTE_CSS.contains(".mnote-vault-secret-value"));
assert!(MNOTE_CSS.contains("[data-testid=\"mnote-nav-vault\"]") || MNOTE_CSS.contains(".mnote-vault-header"));
}
}
@@ -324,7 +324,7 @@
.sidebar-tree .tree-spacer {
width: 20px;
height: 24px;
color: #B8B5AF;
color: #9B968E;
font-size: 16px;
}
@@ -335,29 +335,60 @@
justify-content: center;
line-height: 1;
font-size: 0;
/* Wolai-style: one clear chevron via ::before; hide SSR/JS SVG that does not rotate. */
}
.sidebar-tree .tree-toggle .tree-toggle-icon,
.sidebar-tree .tree-toggle > svg {
display: none !important;
}
.sidebar-tree .tree-toggle:hover {
background: rgba(27, 28, 28, 0.06);
color: #6F6A62;
}
/* Collapsed: solid right-pointing triangle (Wolai ▶). Expanded: rotate to ▼.
Prefer real fold state: aria-expanded / data-expanded must stay in lockstep
with .tree-children--collapsed (syncPageTreeExpandVisualState). */
.sidebar-tree .tree-toggle::before {
content: "";
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
border-style: solid;
border-width: 4.5px 0 4.5px 7px;
border-color: transparent transparent transparent currentColor;
display: block;
transform-origin: 50% 50%;
transform-origin: 40% 50%;
transition: transform 0.12s ease;
}
.sidebar-tree .tree-row[aria-expanded="true"] > .tree-toggle::before {
/* Prefer data-expanded (set with children visibility) so aria alone cannot
* leave the chevron open while .tree-children--collapsed is present. */
.sidebar-tree .tree-row[data-shell-mode="page"][data-expanded="true"] > .tree-toggle::before,
.sidebar-tree .tree-toggle[data-expanded="true"]::before,
.sidebar-tree .tree-row[aria-expanded="true"]:not([data-expanded="false"]) > .tree-toggle::before,
.sidebar-tree .tree-toggle[aria-expanded="true"]:not([data-expanded="false"])::before {
transform: rotate(90deg);
}
/* When children are visibly collapsed, never keep a rotated chevron. */
.sidebar-tree .tree-node:has(> .tree-children.tree-children--collapsed) > .tree-row > .tree-toggle::before {
transform: none !important;
}
/* Empty children body (lazy hydrate pending or failed): always ▶, never ▼. */
.sidebar-tree .tree-node:has(> .tree-children:empty) > .tree-row > .tree-toggle::before {
transform: none !important;
}
/* When children are open (not collapsed) AND non-empty, force rotated chevron. */
.sidebar-tree .tree-node:has(> .tree-children:not(.tree-children--collapsed):not(:empty)) > .tree-row > .tree-toggle::before {
transform: rotate(90deg) !important;
}
.sidebar-tree .tree-row[data-active="true"] .tree-toggle {
color: #8F8A84;
color: #6F6A62;
}
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
@@ -1058,6 +1089,32 @@
white-space: nowrap;
}
.wolai-breadcrumb-pages {
display: inline-flex;
align-items: center;
gap: 9px;
min-width: 0;
overflow: hidden;
}
.wolai-breadcrumb-link,
.wolai-breadcrumb-current {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wolai-breadcrumb-link {
color: #6D6A65;
text-decoration: none;
}
.wolai-breadcrumb-link:hover {
color: var(--atelier-text);
text-decoration: underline;
text-underline-offset: 3px;
}
.wolai-breadcrumb-separator {
color: #D0CDC8;
}
@@ -2069,6 +2126,32 @@
text-underline-offset: 6px;
}
.document-edit-mode-toggle {
min-width: 76px;
height: 32px;
margin-top: 8px;
border: 1px solid rgba(55, 53, 47, 0.12);
border-radius: 6px;
background: #FFFFFF;
color: #37352F;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.document-edit-mode-toggle:hover {
background: rgba(55, 53, 47, 0.06);
}
.document-edit-mode-toggle[data-document-editing="true"] {
border-color: rgba(35, 131, 226, 0.24);
color: #0F6CBD;
}
.document-title-input[readonly] {
cursor: default;
}
.document-pane-close {
width: 28px;
height: 28px;
@@ -0,0 +1,734 @@
/* Password vault workbench — dedicated /vault CRUD surface */
.mnote-vault-workbench {
display: flex;
flex-direction: column;
gap: 16px;
width: min(1120px, calc(100vw - 48px));
min-height: calc(100vh - 120px);
margin: 28px auto 40px;
color: var(--atelier-text, #1b1c1c);
}
.mnote-vault-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px 24px;
}
.mnote-vault-header-main {
min-width: 0;
flex: 1 1 280px;
}
.mnote-vault-header h1 {
margin: 0 0 6px;
font-size: 28px;
font-weight: 650;
line-height: 36px;
}
.mnote-vault-header-main > p {
margin: 0;
color: #6d6a65;
font-size: 13px;
line-height: 20px;
}
.mnote-vault-status {
margin: 8px 0 0;
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #8b8782;
}
.mnote-vault-status[data-type="error"] {
color: #c93a32;
}
.mnote-vault-status[data-type="success"] {
color: #23834d;
}
.mnote-vault-status[data-type="info"] {
color: #6d6a65;
}
.mnote-vault-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: 0 1 auto;
}
.mnote-vault-header-actions > button,
.mnote-vault-detail-actions button,
.mnote-vault-secret-actions button,
.mnote-vault-secret-edit button,
.mnote-vault-tabs button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: #37352f;
font: inherit;
font-size: 12px;
line-height: 28px;
cursor: pointer;
}
.mnote-vault-header-actions > button:hover,
.mnote-vault-detail-actions button:hover,
.mnote-vault-secret-actions button:hover,
.mnote-vault-secret-edit button:hover,
.mnote-vault-tabs button:hover {
background: #f7f7f6;
}
.mnote-vault-detail-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
}
.mnote-vault-detail-actions button.is-danger:hover {
background: #fdf2f1;
}
.mnote-vault-header-actions input[type="search"] {
width: min(240px, 42vw);
height: 30px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
}
.mnote-vault-tabs {
display: inline-flex;
gap: 0;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
overflow: hidden;
}
.mnote-vault-tabs button {
border: 0;
border-radius: 0;
background: #ffffff;
}
.mnote-vault-tabs button.is-active,
.mnote-vault-tabs button[aria-selected="true"] {
background: #f4f3f3;
font-weight: 600;
}
.mnote-vault-body {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 0;
min-height: 480px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 6px;
background: #ffffff;
overflow: hidden;
}
.mnote-vault-list {
min-height: 0;
max-height: calc(100vh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
border-right: 1px solid rgba(27, 28, 28, 0.08);
background: #fafaf9;
}
.mnote-vault-list-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
margin: 0;
padding: 4px 8px 4px 10px;
border: 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.04);
background: transparent;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.mnote-vault-list-item:hover {
background: #f1f0ef;
}
.mnote-vault-list-item.is-active {
background: #ebe9e7;
box-shadow: inset 2px 0 0 #1b1c1c;
}
.mnote-vault-list-main {
display: flex;
flex: 1 1 auto;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.mnote-vault-list-title {
flex: 0 1 auto;
max-width: 62%;
overflow: hidden;
color: #1b1c1c;
font-size: 12px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-list-meta {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #9b9a97;
font-size: 11px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-list-icons {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
line-height: 1;
}
.mnote-vault-list-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 11px;
font-weight: 700;
line-height: 1;
opacity: 0.9;
}
.mnote-vault-list-icon.is-shared {
color: #2e7d32;
}
.mnote-vault-list-icon.is-ai {
color: #1565c0;
font-size: 9px;
}
.mnote-vault-list-tags {
display: none;
}
.mnote-vault-tag {
display: inline-block;
padding: 1px 6px;
border-radius: 999px;
background: rgba(27, 28, 28, 0.06);
color: #5a5a5a;
font-size: 11px;
line-height: 16px;
}
.mnote-vault-tag--shared {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.mnote-vault-tag--ai {
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-role-badge {
display: inline-flex;
align-items: center;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 18px;
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-detail-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.mnote-vault-detail-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-status-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
line-height: 16px;
letter-spacing: 0.01em;
}
.mnote-vault-status-chip.is-shared {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.mnote-vault-status-chip.is-ai {
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-hint-mini {
margin: 4px 0 0;
color: #9b9a97;
font-size: 12px;
line-height: 18px;
}
.mnote-vault-linkish {
border: 0;
padding: 0;
background: none;
color: #1565c0;
font: inherit;
font-size: inherit;
text-decoration: underline;
cursor: pointer;
}
.mnote-vault-list-item.is-shared-ai {
box-shadow: inset 3px 0 0 #2e7d32;
}
.mnote-vault-list-item.is-ai-copy {
box-shadow: inset 3px 0 0 #1565c0;
}
.mnote-vault-workbench[data-vault-role="ai"] .mnote-vault-header h1 {
color: #1565c0;
}
.mnote-vault-detail {
min-width: 0;
min-height: 0;
max-height: calc(100vh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px 22px 28px;
}
.mnote-vault-empty {
padding: 28px 12px;
color: #8b8782;
font-size: 13px;
line-height: 20px;
}
.mnote-vault-detail-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.mnote-vault-detail-header h2 {
margin: 0;
min-width: 0;
font-size: 20px;
font-weight: 650;
line-height: 28px;
}
.mnote-vault-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mnote-vault-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.mnote-vault-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) auto;
align-items: start;
gap: 8px 12px;
padding: 8px 0;
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
.mnote-vault-field-row > label {
padding-top: 4px;
color: #6d6a65;
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.mnote-vault-field-row > div,
.mnote-vault-field-row > pre {
min-width: 0;
margin: 0;
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.mnote-vault-field-row input[type="text"],
.mnote-vault-field-row input[type="password"],
.mnote-vault-field-row textarea {
width: 100%;
box-sizing: border-box;
min-height: 30px;
padding: 6px 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
line-height: 18px;
}
.mnote-vault-field-row textarea {
min-height: 120px;
resize: vertical;
}
.mnote-vault-secret-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
}
.mnote-vault-secret-actions,
.mnote-vault-secret-edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mnote-vault-secret-edit {
grid-column: 2 / -1;
}
.mnote-vault-secret-edit input {
flex: 1 1 180px;
}
.mnote-vault-muted {
color: #9b9a97;
}
.mnote-vault-notes {
grid-template-columns: 96px minmax(0, 1fr);
}
.mnote-vault-notes pre {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
color: #37352f;
}
.mnote-vault-form .mnote-vault-field-row {
grid-template-columns: 120px minmax(0, 1fr);
}
.mnote-vault-folder-controls {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.mnote-vault-folder-controls select {
width: 100%;
box-sizing: border-box;
min-height: 30px;
padding: 6px 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
line-height: 18px;
}
.mnote-vault-folder-controls input[type="text"] {
width: 100%;
box-sizing: border-box;
}
@media (max-width: 820px) {
.mnote-vault-workbench {
width: calc(100vw - 24px);
margin: 18px auto 28px;
}
.mnote-vault-body {
grid-template-columns: 1fr;
min-height: 0;
}
.mnote-vault-list {
max-height: 220px;
border-right: 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
}
.mnote-vault-detail {
max-height: none;
}
.mnote-vault-field-row {
grid-template-columns: 1fr;
}
.mnote-vault-form .mnote-vault-field-row {
grid-template-columns: 1fr;
}
.mnote-vault-secret-edit {
grid-column: auto;
}
}
/* Folder tree list */
.mnote-vault-folder {
border-bottom: 1px solid rgba(27, 28, 28, 0.05);
}
.mnote-vault-site-group {
border-bottom: 0;
}
.mnote-vault-site-toggle .mnote-vault-folder-name {
font-weight: 500;
color: #5c5a56;
}
.mnote-vault-secret-input-plain {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: 0.01em;
}
.mnote-vault-folder-label {
padding: 4px 10px 2px;
color: #8b8782;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.mnote-vault-folder-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
padding: 3px 8px 3px calc(8px + var(--vault-depth, 0) * 10px);
border: 0;
background: transparent;
color: #37352f;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-align: left;
cursor: pointer;
}
.mnote-vault-folder-toggle:hover {
background: #f1f0ef;
}
.mnote-vault-folder-chevron {
display: inline-block;
width: 12px;
color: #8b8782;
font-size: 11px;
}
.mnote-vault-folder-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-folder-count {
flex: 0 0 auto;
color: #8b8782;
font-size: 11px;
font-weight: 500;
}
.mnote-vault-list-item-wrap {
padding-left: calc(var(--vault-depth, 0) * 10px);
}
.mnote-vault-list-item-wrap .mnote-vault-list-item {
border-bottom-color: rgba(27, 28, 28, 0.04);
}
.mnote-vault-hint {
margin: 8px 0 0;
color: #8b8782;
font-size: 12px;
line-height: 18px;
}
.mnote-vault-hint code {
padding: 0 4px;
border-radius: 3px;
background: #f1f0ef;
font-size: 11px;
}
.mnote-vault-sibling-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mnote-vault-sibling {
height: 26px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: #37352f;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mnote-vault-sibling:hover {
background: #f7f7f6;
}
/* Cipher book panel */
.mnote-vault-cipher-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 16px;
}
.mnote-vault-cipher-add input {
flex: 1 1 120px;
min-width: 100px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
font: inherit;
font-size: 13px;
}
.mnote-vault-cipher-add button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mnote-vault-cipher-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.06);
}
.mnote-vault-cipher-key {
font-size: 13px;
font-weight: 600;
}
.mnote-vault-cipher-value {
overflow: hidden;
color: #37352f;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-cipher-actions {
display: flex;
gap: 6px;
}
.mnote-vault-cipher-actions button {
height: 26px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.mnote-vault-cipher-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
}
@media (max-width: 820px) {
.mnote-vault-cipher-row {
grid-template-columns: 1fr;
}
}