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
+70
View File
@@ -52,6 +52,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -143,6 +158,18 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-compression"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -431,6 +458,27 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -635,6 +683,24 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "comrak"
version = "0.52.0"
@@ -4010,12 +4076,16 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"bitflags 2.13.0",
"bytes",
"futures-core",
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.3",
"tower-layer",
"tower-service",
+23
View File
@@ -0,0 +1,23 @@
# mnote-tiptap-island(产品名)
> 状态:Phase 3 薄 alias / 迁移计划(架构收口 21)
>
> 当前实现路径仍为:`rust/spikes/leptos-tiptap-spike/`
> 稳定协议:`mnote.leptos_tiptap.bridge.v1`
> 稳定事件前缀:`mnote:tiptap-island:*`(与历史 `mnote:leptos-tiptap-spike:*` 双发兼容)
## 为何尚未物理迁出 spikes/
WASM island 的 Trunk/生成资产路径、大量 smoke 与 `mnote-web` asset route 仍绑定 `leptos-tiptap-spike` 目录名与 `mnote-leptos-tiptap-spike-island.*` 文件名。一次物理搬家会打断热更新与回归基线。
## 迁移完成条件
1. 构建脚本与 `web_shell` asset route 改为 `mnote-tiptap-island` 命名。
2. 浏览器 host 默认监听 `mnote:tiptap-island:*`spike 前缀仅兼容。
3. 物理目录迁到本 crate 后删除本 README 的「薄 alias」说明,并更新 `AGENTS.md`
## 当前可安全使用的产品表述
- 产品名:`mnote-tiptap-island`
- 实现目录(过渡):`rust/spikes/leptos-tiptap-spike`
- 宿主适配:`rust/crates/mnote-web/browser/document-*-runtime.js`
+1 -1
View File
@@ -23,7 +23,7 @@ serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-tungstenite = "0.29"
tower-http = { version = "0.6", features = ["trace"] }
tower-http = { version = "0.6", features = ["trace", "compression-gzip", "compression-br"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
tower = "0.5"
@@ -28,7 +28,10 @@ import {
(() => {
const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__';
const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
// 历史 spike 前缀仍是 island runtime 的默认派发;稳定前缀 `mnote:tiptap-island`
// 由 island 双发(架构收口 21 Phase 3),host 优先监听 spike 事件保持兼容。
const EVENT_PREFIX = 'mnote:leptos-tiptap-spike';
const STABLE_EVENT_PREFIX = 'mnote:tiptap-island';
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
const READY_EVENT = `${EVENT_PREFIX}:ready`;
@@ -50,6 +53,72 @@ import {
return url.toString();
};
const refreshBreadcrumb = (documentId, title, workspaceId) => {
const host = document.querySelector('[data-breadcrumb-pages="true"]');
if (!(host instanceof HTMLElement)) return;
const rows = Array.from(document.querySelectorAll(
'.wolai-page-row[data-node-id], .tree-row[data-shell-mode="page"][data-node-id]',
));
const byId = new Map();
rows.forEach((row) => {
const id = String(row.getAttribute('data-node-id') || row.getAttribute('data-document-id') || '').trim();
if (!id || byId.has(id)) return;
const titleNode = row.querySelector('.wolai-row-title, .tree-link-title');
byId.set(id, {
id,
title: String(titleNode?.textContent || '').trim() || '无标题',
parentId: String(row.getAttribute('data-parent-id') || '').trim(),
href: row instanceof HTMLAnchorElement ? row.href : '',
});
});
const chain = [];
const seen = new Set();
let cursor = String(documentId || '').trim();
while (cursor && !seen.has(cursor) && chain.length < 64) {
const item = byId.get(cursor);
if (!item) break;
seen.add(cursor);
chain.push(item);
cursor = item.parentId;
}
chain.reverse();
if (!chain.length) return;
const query = new URLSearchParams(window.location.search);
if (workspaceId) query.set('workspaceId', workspaceId);
const children = [];
chain.forEach((item, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.className = 'wolai-breadcrumb-separator';
separator.setAttribute('aria-hidden', 'true');
separator.textContent = '';
children.push(separator);
}
if (index === chain.length - 1) {
const current = document.createElement('span');
current.className = 'wolai-breadcrumb-current';
current.dataset.breadcrumbDocumentId = item.id;
const icon = document.createElement('span');
icon.className = 'material-symbols-outlined wolai-home-icon';
icon.dataset.icon = 'home';
icon.setAttribute('aria-hidden', 'true');
const text = document.createElement('span');
text.dataset.pageTitleCurrent = 'true';
text.textContent = title || item.title;
current.append(icon, text);
children.push(current);
} else {
const link = document.createElement('a');
link.className = 'wolai-breadcrumb-link';
link.dataset.breadcrumbDocumentId = item.id;
link.href = item.href || `/documents/${encodeURIComponent(item.id)}?${query.toString()}`;
link.textContent = item.title;
children.push(link);
}
});
host.replaceChildren(...children);
};
const parseJsonScript = (id) => {
const node = document.getElementById(id);
if (!node) return null;
@@ -406,6 +475,33 @@ import {
const ensureLocalFolderEventChannel = (...args) => documentSessions.ensureLocalFolderEventChannel(...args);
const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args);
const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args);
const setDocumentSessionUserReadOnlyMode = (...args) => documentSessions.setDocumentSessionUserReadOnlyMode(...args);
const syncDocumentEditModeChrome = (session) => {
if (!session) return;
sessionViews(session).forEach((view) => {
const pane = view.runtimeDescriptor.root.closest('[data-document-pane="true"]');
const shell = view.runtimeDescriptor.root.closest('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-document-user-readonly-mode', String(session.userReadOnlyMode !== false));
shell.setAttribute('data-document-effective-readonly', String(session.readOnly === true));
}
const titleInput = pane?.querySelector('[data-page-title-input="true"]');
if (titleInput instanceof HTMLTextAreaElement) {
titleInput.readOnly = session.readOnly === true;
titleInput.setAttribute('data-document-user-readonly-mode', String(session.userReadOnlyMode !== false));
}
const toggle = pane?.querySelector('[data-document-edit-mode-toggle]');
if (toggle instanceof HTMLButtonElement) {
const editing = session.readOnly !== true;
toggle.textContent = editing ? '退出编辑' : '开始编辑';
toggle.title = editing ? '切回只读浏览' : '进入编辑模式';
toggle.setAttribute('aria-pressed', String(editing));
toggle.setAttribute('data-document-editing', String(editing));
toggle.disabled = session.permissionReadOnly === true && session.userReadOnlyMode === false;
}
});
};
const updatePaneChrome = (runtimeDescriptor) => {
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
@@ -467,8 +563,7 @@ import {
document.body.dataset.mnoteShell = 'document';
delete document.body.dataset.mindmapId;
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
refreshBreadcrumb(documentId, title, workspaceId);
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
detail: {
documentId,
@@ -566,13 +661,20 @@ import {
titleInput.setAttribute('data-title-endpoint', '/api/documents/title');
titleInput.rows = 1;
titleHeading.appendChild(titleInput);
const modeToggle = document.createElement('button');
modeToggle.type = 'button';
modeToggle.className = 'document-edit-mode-toggle';
modeToggle.setAttribute('data-document-edit-mode-toggle', 'true');
modeToggle.setAttribute('aria-pressed', 'false');
modeToggle.setAttribute('data-document-editing', 'false');
modeToggle.textContent = '开始编辑';
const meta = document.createElement('div');
meta.className = 'document-shell-meta';
meta.setAttribute('aria-label', '页面元信息');
const currentTitle = document.createElement('span');
currentTitle.setAttribute('data-page-title-current', 'true');
meta.appendChild(currentTitle);
header.append(titleHeading, meta);
header.append(titleHeading, modeToggle, meta);
const aggregateMarker = document.createElement('section');
aggregateMarker.setAttribute('data-page-aggregate-snapshot', 'mnote.page_aggregate.v1');
@@ -636,6 +738,7 @@ import {
}
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
syncDocumentEditModeChrome(session);
const mountOptions = {
documentId: session.documentId,
workspaceId: session.workspaceId,
@@ -657,6 +760,7 @@ import {
setStatus(runtimeDescriptor, 'mounting-editor');
enhanceEditorAttachmentLinksSoon();
paneViewRegistry.set(paneRole, view);
syncDocumentEditModeChrome(session);
return view;
} catch (error) {
unmountEditorViewBinding(view);
@@ -739,6 +843,24 @@ import {
anchor.removeAttribute('href');
}, true);
document.addEventListener('click', (event) => {
const editToggle = event.target instanceof Element
? event.target.closest('[data-document-edit-mode-toggle]')
: null;
if (editToggle instanceof HTMLButtonElement) {
event.preventDefault();
const pane = editToggle.closest('[data-document-pane="true"]');
const documentId = pane?.getAttribute('data-pane-document-id') || document.body?.dataset.documentId || '';
const session = Array.from(documentSessionRegistry.values())
.find((item) => item && item.documentId === documentId);
if (!session) return;
setDocumentSessionUserReadOnlyMode(
session,
!(session.userReadOnlyMode !== false),
'mnote-web-document-edit-mode-toggle',
);
syncDocumentEditModeChrome(session);
return;
}
const target = event.target;
const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null;
if (!(anchor instanceof HTMLAnchorElement)) return;
@@ -1022,6 +1144,7 @@ import {
const runtime = await loadRuntime();
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
syncDocumentEditModeChrome(session);
const mountOptions = {
documentId: session.documentId,
@@ -1045,6 +1168,7 @@ import {
setStatus(runtimeDescriptor, 'mounting-editor');
enhanceEditorAttachmentLinksSoon();
paneViewRegistry.set(runtimeDescriptor.paneRole, view);
syncDocumentEditModeChrome(session);
} catch (error) {
unmountEditorViewBinding(view);
throw error;
@@ -28,6 +28,34 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const SESSION_RELEASE_DELAY_MS = 1200;
const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1';
const effectiveSessionReadOnly = (session) => Boolean(session?.permissionReadOnly || session?.userReadOnlyMode);
const applySessionPermissionReadOnly = (session, permissionReadOnly) => {
if (!session) return false;
session.permissionReadOnly = Boolean(permissionReadOnly);
session.readOnly = effectiveSessionReadOnly(session);
return session.readOnly;
};
const setDocumentSessionUserReadOnlyMode = (session, userReadOnlyMode, source) => {
if (!session) return false;
session.userReadOnlyMode = Boolean(userReadOnlyMode);
session.readOnly = effectiveSessionReadOnly(session);
sessionViews(session).forEach((view) => {
if (view.mountId == null) return;
dispatchRuntimeCommand(view, {
command: 'setEditable',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-user-readonly-mode');
});
setSessionStatus(session, session.readOnly ? 'read-only' : 'editable');
return session.readOnly;
};
const ensureLocalFolderSelfChangeSuppressions = () => {
const now = Date.now();
const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map
@@ -660,7 +688,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly));
session.dirty = false;
session.hasExternalConflict = false;
session.externalChangePending = false;
@@ -1315,7 +1343,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly));
session.dirty = false;
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
@@ -1831,7 +1859,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
lastExternalConflictDetectionKey: conflictDetectionKey || '',
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
bufferDirtyState: 'Clean',
readOnly: Boolean(permissions.readOnly),
permissionReadOnly: Boolean(permissions.readOnly),
userReadOnlyMode: true,
readOnly: true,
dirty: false,
saving: false,
hasExternalConflict: false,
@@ -1849,6 +1879,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
localFolderChannel: null,
};
ensureLocalFolderEventChannel(session);
session.readOnly = effectiveSessionReadOnly(session);
return session;
};
@@ -1885,6 +1916,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sessionHasRecentLocalInput,
sessionMatchesDocumentWorkspace,
sessionViews,
setDocumentSessionUserReadOnlyMode,
setSessionStatus,
};
};
@@ -389,6 +389,21 @@ export const localMarkdownRelativePathFromDocumentId = (documentId) => {
return decodeLocalIdSegment(segment).replace(/^\/+/, '');
};
export const localMarkdownRelativePathFromDocumentsHref = (href) => {
const value = String(href || '').trim();
if (!value) return '';
let url = null;
try {
url = new URL(value, window.location?.origin || 'http://127.0.0.1:3000');
} catch (_) {
return '';
}
if (!url.pathname.startsWith('/documents/')) return '';
const segment = decodeURIComponent(url.pathname.slice('/documents/'.length));
if (!segment.startsWith('local-md:')) return '';
return localMarkdownRelativePathFromDocumentId(segment);
};
export const localMarkdownDocumentIdFromRelativePath = (relativePath) => {
const normalized = String(relativePath || '').trim().replace(/^\/+/, '');
if (!normalized) return '';
@@ -440,7 +455,12 @@ export const normalizeLocalPageRelativePath = (value, context) => {
if (!text || text.startsWith('#')) return '';
if (text.startsWith('/documents/')) return text;
if (text.startsWith('/api/') || text.startsWith('http://') || text.startsWith('https://') || text.startsWith('mailto:')) return '';
const baseDir = text.startsWith('/') ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId);
const currentPath = localMarkdownRelativePathFromDocumentId(context?.documentId);
const textPath = text.replace(/^\/+/, '');
const currentTop = currentPath.split('/').filter(Boolean)[0] || '';
const textTop = textPath.split('/').filter(Boolean)[0] || '';
const looksRootRelative = Boolean(currentTop && textTop && currentTop === textTop);
const baseDir = text.startsWith('/') || looksRootRelative ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId);
const parts = (baseDir ? `${baseDir}/${text}` : text)
.replace(/\\/g, '/')
.split('/');
@@ -752,6 +772,32 @@ export const tiptapNodeToEditorBlock = (node, index) => {
childBlockIds: [],
};
}
if (node?.type === 'paragraph') {
const children = Array.isArray(node?.content) ? node.content : [];
if (children.length === 1 && children[0]?.type === 'text') {
const textNode = children[0];
const linkMark = Array.isArray(textNode.marks)
? textNode.marks.find((mark) => {
const className = String(mark?.attrs?.class || '');
return mark?.type === 'link' && className.split(/\s+/).includes('mnote-page-block-link');
})
: null;
if (linkMark?.attrs?.href) {
const href = String(linkMark.attrs.href || '').trim();
const sourcePath = localMarkdownRelativePathFromDocumentsHref(href) || unwrapMarkdownLinkTarget(href);
return {
blockId,
blockType: 'page_reference',
props: {
title: String(textNode.text || '').trim() || '页面',
sourcePath,
},
contentNodes: inlineTextNodes(node),
childBlockIds: [],
};
}
}
}
if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
if (node?.type === 'heading') {
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
@@ -795,6 +841,11 @@ export const legacyBlocksFromEditorDocument = (editorDocument) => (
? { language: block.props?.language || null }
: block.blockType === 'mindmap'
? mindmapPropsFromAttrs(block.props || {}, block.blockId)
: block.blockType === 'page_reference'
? {
title: block.props?.title || flattenText(block.contentNodes || []) || '页面',
sourcePath: block.props?.sourcePath || block.props?.source_path || block.props?.href || '',
}
: block.blockType === 'image'
? { ...(block.props || {}) }
: block.blockType === 'toc'
@@ -300,7 +300,14 @@
return vm;
}
if (event.type === 'assistant_abort') {
// S4: abort is idempotent — second apply (SSE after optimistic) must not mutate again.
if (vm.runtimeStatus === STATE_ABORTED && vm.abortResponse) {
return vm;
}
var abortMsg = vm.currentAssistant || findLastAssistant(vm) || ensurePiRunAssistant(vm);
if (abortMsg.status === 'aborted' && !vm.currentAssistant) {
return vm;
}
abortMsg.status = 'aborted';
abortMsg.abortReason = event.reason || payload.reason || payload.stopReason || payload.error || payload.message || '已中止';
abortMsg.abortResponse = payload || { reason: abortMsg.abortReason };
@@ -3572,15 +3579,31 @@
])
.then(function (results) {
var data = results[0] || {};
var effectiveData = results[1] || null;
piLabState.enabled = !!data.enabled;
piLabState.sessionId = data.sessionId || piLabState.sessionId || null;
piLabState.providerSessionId = data.providerSessionId || piLabState.providerSessionId || null;
// Consume backend status/default fields when present.
if (data.defaultModelProvider) piLabState.defaultModelProvider = data.defaultModelProvider;
if (data.defaultModelId) piLabState.defaultModelId = data.defaultModelId;
if (data.defaultThinkingLevel) piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel);
piLabState.modelProvider = (data.session && data.session.modelProvider) || piLabState.modelProvider || null;
piLabState.modelId = (data.session && data.session.modelId) || piLabState.modelId || null;
// S1: prefer effective AI settings as management-surface truth; status is
// fallback only when effective load failed. Do not let env-stale status
// overwrite a successfully loaded effective defaultModel.
if (effectiveData) {
applyEffectiveConfig(effectiveData);
} else {
if (data.defaultModelProvider) piLabState.defaultModelProvider = data.defaultModelProvider;
if (data.defaultModelId) piLabState.defaultModelId = data.defaultModelId;
}
if (data.defaultThinkingLevel && !piLabState.defaultThinkingLevel) {
piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel);
}
// Active session model wins; otherwise keep effective defaults already applied.
piLabState.modelProvider = (data.session && data.session.modelProvider)
|| piLabState.modelProvider
|| piLabState.defaultModelProvider
|| null;
piLabState.modelId = (data.session && data.session.modelId)
|| piLabState.modelId
|| piLabState.defaultModelId
|| null;
var sessionThinkingLevel = data.session && data.session.thinkingLevel;
if (sessionThinkingLevel) {
piLabState.thinkingLevel = normalizeThinkingLevel(sessionThinkingLevel);
@@ -3851,6 +3874,8 @@
es.close();
piLabEventSource = null;
}
// S4: optimistic abort already applied UI; ignore duplicate SSE abort.
if (piLabState.status === STATE_ABORTED) return;
applyPiRunEvent({ type: 'assistant_abort', payload: payload });
setState(STATE_ABORTED);
updateMessages();
@@ -4056,9 +4081,12 @@
var deltaToolPayload = assistantToolCallPayload(msgData);
if (deltaToolPayload) upsertToolCall(deltaToolPayload, 'running', { args: deltaToolPayload.args || {} });
} else if (eventType === 'response' && payload.command === 'abort') {
applyPiRunEvent({ type: 'assistant_abort', payload: payload });
setState(STATE_ABORTED);
updateMessages();
// S4: ignore duplicate abort after optimistic UI apply.
if (piLabState.status !== STATE_ABORTED) {
applyPiRunEvent({ type: 'assistant_abort', payload: payload });
setState(STATE_ABORTED);
updateMessages();
}
} else if (eventType === 'citation' || msgDataType === 'citation') {
applyPiRunEvent({ type: 'citation', payload: { source: payload.source || msgData.source || '', title: payload.title || msgData.title || '', url: payload.url || msgData.url || '#' } });
updateMessages();
@@ -4088,15 +4116,132 @@
}
}
function pageAiBlockingDirtyStateLocal(dirtyState) {
var state = String(dirtyState || '').trim();
var normalized = state.toLowerCase();
if (normalized === 'dirty') return 'Dirty';
if (normalized === 'stale') return 'Stale';
if (normalized === 'deleted') return 'Deleted';
if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified';
return '';
}
function buildPiLabAgentTargetPackage(context) {
var active = activeEditorSnapshot() || {};
var activeWorkspacePath = active.workspacePath && typeof active.workspacePath === 'object' ? active.workspacePath : {};
var relativePath = normalizeSlashes(context && context.pagePath || activeWorkspacePath.relativePath || active.path || '');
var rootUri = String(context && context.rootUri || activeWorkspacePath.rootUri || active.rootUri || '').trim();
var workspaceId = String(context && context.workspaceId || activeWorkspacePath.workspaceId || active.workspaceId || '').trim();
var documentId = String(context && context.pageId || active.documentId || activeWorkspacePath.documentId || '').trim();
var resourceKind = String(activeWorkspacePath.resourceKind || active.resourceKind || active.kind || 'markdown_page').trim() || 'markdown_page';
var objectIdentity = String(active.objectIdentity || activeWorkspacePath.objectIdentity || documentId || relativePath || '').trim();
var onlyofficeSessionId = String(active.onlyofficeSessionId || active.bridgeSessionId || '').trim();
var dirtyState = String(active.dirtyState || active.dirtyGuard || '').trim();
var primaryTargetId = objectIdentity || documentId || relativePath;
var workspacePath = {
schema: 'mnote.workspace_path.v1',
workspaceId: workspaceId,
sourceKind: 'local_folder',
rootUri: rootUri,
relativePath: relativePath,
documentId: documentId,
objectIdentity: objectIdentity,
resourceKind: resourceKind,
};
var targetEntry = {
targetId: primaryTargetId,
objectIdentity: objectIdentity,
documentId: documentId,
workspaceId: workspaceId,
sourceKind: 'local_folder',
rootUri: rootUri,
relativePath: relativePath,
resourceKind: resourceKind,
dirtyState: dirtyState,
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
paneRole: active.paneRole || 'primary',
title: context && context.pageTitle || active.title || '',
policy: {
permission: relativePath ? 'read_write' : 'read',
writeRequiresCleanBuffer: true,
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
return {
schema: 'mnote.agent_target_package.v1',
source: 'pi_lab_send_target_snapshot',
frozenAt: Date.now(),
primaryTargetId: primaryTargetId,
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
workspaceId: workspaceId,
sourceKind: 'local_folder',
rootUri: rootUri,
documentId: documentId,
objectIdentity: objectIdentity,
resourceKind: resourceKind,
workspacePath: workspacePath,
currentFile: relativePath ? {
rootUri: rootUri,
relativePath: relativePath,
documentId: documentId,
objectIdentity: objectIdentity,
resourceKind: resourceKind
} : null,
allowedFiles: relativePath ? [relativePath] : [],
targets: [targetEntry],
policy: {
writeRequiresExplicitTarget: true,
allowedFilesSource: 'selected_page_ai_target',
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
}
function fetchPiLabTargetBufferState(context, targetPackage) {
var workspacePath = targetPackage && targetPackage.workspacePath || {};
var documentId = String(context && context.pageId || workspacePath.documentId || '').trim();
var rootUri = String(context && context.rootUri || workspacePath.rootUri || '').trim();
if (!documentId || !rootUri) return Promise.resolve(null);
var relativePath = String(workspacePath.relativePath || context && context.pagePath || '').trim();
var url = new URL('/api/documents/buffer-state', window.location.origin);
url.searchParams.set('documentId', documentId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
var workspaceId = String(context && context.workspaceId || workspacePath.workspaceId || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
if (relativePath) url.searchParams.set('relativePath', relativePath);
return fetch(url.toString(), { cache: 'no-store', credentials: 'same-origin', headers: { accept: 'application/json' } })
.then(function (response) {
return response.json().catch(function () { return null; }).then(function (payload) {
if (!response.ok || !payload || payload.ok !== true) return null;
return payload.result || null;
});
})
.catch(function () { return null; });
}
function assertPiLabTargetWritable(context, targetPackage) {
var active = activeEditorSnapshot() || {};
var snapshotDirty = pageAiBlockingDirtyStateLocal(active.dirtyState || active.dirtyGuard);
return fetchPiLabTargetBufferState(context, targetPackage).then(function (bufferState) {
var bufferDirty = pageAiBlockingDirtyStateLocal(bufferState && bufferState.dirtyState);
var blockedState = bufferDirty || snapshotDirty;
if (!blockedState) return bufferState;
var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。');
error.code = 'page_ai_target_buffer_not_writable';
error.documentId = String(context && context.pageId || '').trim();
error.dirtyState = blockedState;
throw error;
});
}
function sendPrompt(text, options) {
var prompt = String(text || '').trim();
if (!prompt) return;
if (!prompt) return Promise.resolve();
var streamingBehavior = options && options.streamingBehavior;
var midStream = piLabState.status === STATE_STREAMING && streamingAssistantMsg;
applyPiRunEvent({ type: 'user_prompt', text: prompt, meta: midStream ? (streamingBehavior === 'followUp' ? 'follow-up queued' : 'steer') : '' });
if (!midStream) applyPiRunEvent({ type: 'assistant_begin' });
setState(STATE_STREAMING);
updateMessages();
var context = applyCurrentContextToState();
var contextSelection = selectedContextPayload(context);
var refs = contextSelection.refs || [];
@@ -4104,27 +4249,39 @@
var includeAnyContext = refs.length > 0;
var includePageContext = refs.indexOf('current_page') >= 0 || refs.indexOf('selection') >= 0 || refs.indexOf('lightrag') >= 0;
var includeFolderContext = refs.indexOf('folder') >= 0;
var body = {
sessionId: piLabState.sessionId,
message: prompt,
rootUri: includeAnyContext ? (context.rootUri || undefined) : undefined,
workspaceId: includeAnyContext ? (context.workspaceId || undefined) : undefined,
pagePath: includePageContext ? (context.pagePath || undefined) : undefined,
pageTitle: includePageContext ? (context.pageTitle || undefined) : undefined,
folderPath: includeFolderContext && selectedFolderPath !== null ? selectedFolderPath : undefined,
contextRefs: contextSelection.refs,
selectedContext: contextSelection,
};
if (streamingBehavior) body.streamingBehavior = streamingBehavior;
if (piLabState.pendingImages && piLabState.pendingImages.length) {
body.images = piLabState.pendingImages;
piLabState.pendingImages = [];
}
return fetch(API.SEND, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
var targetPackage = buildPiLabAgentTargetPackage(context);
// S7: block send when open target buffer is dirty/stale (fail_on_dirty_or_stale).
var writableGate = (context && context.pagePath)
? assertPiLabTargetWritable(context, targetPackage)
: Promise.resolve(null);
return writableGate.then(function () {
applyPiRunEvent({ type: 'user_prompt', text: prompt, meta: midStream ? (streamingBehavior === 'followUp' ? 'follow-up queued' : 'steer') : '' });
if (!midStream) applyPiRunEvent({ type: 'assistant_begin' });
setState(STATE_STREAMING);
updateMessages();
var body = {
sessionId: piLabState.sessionId,
message: prompt,
rootUri: includeAnyContext ? (context.rootUri || undefined) : undefined,
workspaceId: includeAnyContext ? (context.workspaceId || undefined) : undefined,
pagePath: includePageContext ? (context.pagePath || undefined) : undefined,
pageTitle: includePageContext ? (context.pageTitle || undefined) : undefined,
folderPath: includeFolderContext && selectedFolderPath !== null ? selectedFolderPath : undefined,
contextRefs: contextSelection.refs,
selectedContext: contextSelection,
targetPackage: targetPackage,
};
if (streamingBehavior) body.streamingBehavior = streamingBehavior;
if (piLabState.pendingImages && piLabState.pendingImages.length) {
body.images = piLabState.pendingImages;
piLabState.pendingImages = [];
}
return fetch(API.SEND, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
})
.then(function (r) {
return responseJsonOrError(r, 'Send failed');
@@ -4134,7 +4291,15 @@
if (!piLabEventSource || piLabEventSource.readyState === EventSource.CLOSED) connectEventSource(data.sessionId || piLabState.sessionId);
})
.catch(function (err) {
if (streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_error', message: err.message });
var message = err && err.message ? err.message : 'Send failed';
if (err && err.code === 'page_ai_target_buffer_not_writable') {
showPiToast(message, 'warning');
updateDiagnostics(message);
setState(piLabState.sessionId ? STATE_STARTED : STATE_IDLE);
updateMessages();
return;
}
if (streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_error', message: message });
setState(STATE_STARTED);
updateMessages();
});
@@ -4330,23 +4495,19 @@
piLabEventSource.close();
piLabEventSource = null;
}
if (piLabState.sessionId) {
fetch(API.ABORT, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: piLabState.sessionId }),
}).then(function (response) {
return response.json().catch(function () { return {}; });
}).then(function (payload) {
applyPiRunEvent({ type: 'assistant_abort', payload: Object.assign({ stopReason: 'aborted' }, payload || {}) });
updateMessages();
}).catch(function () {});
}
applyPiRunEvent({ type: 'assistant_abort', payload: { reason: '已中止', stopReason: 'aborted' } });
setState(STATE_ABORTED);
updateMessages();
}
// S4: apply abort UI state exactly once (optimistic). Do not re-apply from ABORT response.
applyPiRunEvent({ type: 'assistant_abort', payload: { reason: '已中止', stopReason: 'aborted' } });
setState(STATE_ABORTED);
updateMessages();
if (piLabState.sessionId) {
fetch(API.ABORT, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: piLabState.sessionId }),
}).catch(function () {});
}
}
function startNewConversation() {
var previousSessionId = piLabState.sessionId;
@@ -441,11 +441,6 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function applyLocalFolderWatchBatch(payload) {
var batch = payload && (payload.payload || payload);
if (batch && (batch.fallbackResync === true || batch.requiresResync === true)) {
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));
void refreshLocalFolderSidebarSnapshot();
return true;
}
var changedPaths = Array.isArray(batch && batch.changedPaths)
? batch.changedPaths
: Array.isArray(batch && batch.changed_paths)
@@ -456,6 +451,31 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
: Array.isArray(batch && batch.affected_parents)
? batch.affected_parents
: [];
if (batch && (batch.fallbackResync === true || batch.requiresResync === true)) {
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));
var fallbackParents = new Set();
affectedParents.forEach(function(parent) {
addCommandRefreshParent(fallbackParents, parent && (parent.relativePath || parent.relative_path));
});
changedPaths.forEach(function(item) {
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim();
addCommandRefreshParent(fallbackParents, parentRelativePathForPath(relativePath));
});
if (fallbackParents.size) {
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback-scoped', String(fallbackParents.size));
void Promise.all(Array.from(fallbackParents).map(function(parentRelativePath) {
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
setTreeLiveApplyError(error && error.message ? error.message : '文件树 fallback scoped 刷新失败');
return false;
});
})).then(function() {
markLocalFolderWatchApplied('watch_batch_fallback_scoped');
});
} else {
void refreshLocalFolderSidebarSnapshot();
}
return true;
}
if (!affectedParents.length) {
affectedParents = changedPaths.map(function(item) {
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim();
@@ -714,7 +734,13 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function sidebarTreeViewScope(treeKind) {
var normalizedTreeKind = String(treeKind || '').trim() === 'pagetree' ? 'pagetree' : 'filetree';
var rootUri = String(currentRootUri() || '').trim();
var scope = currentFileTreeScope() || 'root';
// Sidex stores explorer view-state per workspace, not per current folder
// reveal path. PageTree must stay on a stable "root" scope so expand state
// survives refresh / navigation even when fileTreeScope changes.
// FileTree keeps scope = current fileTreeScope (folder-scoped projection).
var scope = normalizedTreeKind === 'pagetree'
? 'root'
: (currentFileTreeScope() || 'root');
return {
userId: currentActorStorageId(),
workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'),
@@ -797,6 +823,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
}
state = normalizeSidebarTreeViewState(scope.treeKind, localValue, localValue ? 'local' : 'default');
sidebarTreeViewStates.set(key, state);
// Sidex applies stored view-state on setInput; apply local snapshot immediately
// so first paint / refresh does not wait for the control-plane GET.
if (state.hasUserState) {
applySidebarTreeViewState(scope.treeKind, state);
}
void loadSidebarTreeViewState(scope.treeKind);
}
if (scope.treeKind === 'filetree') {
@@ -827,9 +858,36 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
if (!result || !result.state) return null;
var latestScope = sidebarTreeViewScope(scope.treeKind);
if (sidebarTreeViewStateMapKey(latestScope) !== key) return null;
var loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, result.source === 'sqlite' ? 'sqlite' : 'default');
var sourceTag = result.source === 'sqlite' || result.source === 'control-plane' ? 'sqlite' : 'default';
var loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, sourceTag);
// Privacy / cold boot: async view-state must not clobber expand that the
// user already performed while this request was in flight (first-click
// expand would snap shut when default/empty state lands).
var current = sidebarTreeViewStates.get(key);
if (current && current.hasUserState) {
var currentUpdated = Number(current.updatedAtMs || 0) || 0;
var loadedUpdated = Number(loaded.updatedAtMs || 0) || 0;
if (currentUpdated > loadedUpdated) {
current.expandedIds.forEach(function(id) { loaded.expandedIds.add(id); });
current.expandedRelativePaths.forEach(function(path) { loaded.expandedRelativePaths.add(path); });
loaded.hasUserState = true;
loaded.updatedAtMs = Math.max(currentUpdated, loadedUpdated);
if (current.selectedId && !loaded.selectedId) loaded.selectedId = current.selectedId;
if (current.focusedId && !loaded.focusedId) loaded.focusedId = current.focusedId;
if (current.activeId && !loaded.activeId) loaded.activeId = current.activeId;
} else {
// Server is newer: still union in-session expands so a late GET never
// collapses a node the user just opened.
current.expandedIds.forEach(function(id) { loaded.expandedIds.add(id); });
current.expandedRelativePaths.forEach(function(path) { loaded.expandedRelativePaths.add(path); });
loaded.hasUserState = true;
}
}
sidebarTreeViewStates.set(key, loaded);
applySidebarTreeViewState(scope.treeKind, loaded);
if (scope.treeKind === 'pagetree') {
scheduleRestorePersistedPageTreeExpansionState();
}
return loaded;
});
}).catch(function(error) {
@@ -919,16 +977,30 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
if (!(row instanceof HTMLElement)) return;
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
var button = row.querySelector('[data-rust-action="toggle"]');
var node = row.closest('.tree-node');
var children = node ? node.querySelector(':scope > .tree-children') : null;
var children = pageTreeChildrenContainer(row);
if (!nodeId || !button || !(children instanceof HTMLElement)) return;
var expanded = state.expandedIds.has(nodeId);
row.setAttribute('aria-expanded', String(expanded));
button.setAttribute('aria-expanded', String(expanded));
children.classList.toggle('tree-children--collapsed', !expanded);
// Keep rows the user already opened this session (and that are actually
// showing children) even if a stale view-state snapshot omitted the id.
if (!expanded && pageTreeRowIsOpen(row)) {
state.expandedIds.add(nodeId);
expanded = true;
}
var hasChildRows = children.children.length > 0;
// Only open presentation when children exist in DOM. Empty bodies stay
// collapsed (icon closed); restorePersistedPageTreeExpansionState hydrates.
var openNow = expanded && hasChildRows;
setPageTreeExpandPresentation(row, button, openNow, {
children: children,
forceCollapsed: !openNow
});
if (openNow) {
row.setAttribute('data-page-tree-children-loaded', 'true');
}
changed = true;
});
if (changed) document.documentElement.setAttribute('data-mnote-page-tree-view-state-applied', state.scope || 'root');
syncPageTreeExpandVisualState();
return changed;
}
@@ -1016,6 +1088,52 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return '<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>';
}
function pageTreeRelativePathFromItem(item) {
if (!item || typeof item !== 'object') return '';
var direct = String(item.expandRelativePath || item.relativePath || '').trim();
if (direct) return direct.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
var meta = item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
var fromWorkspace = String(workspacePath.relativePath || '').trim();
if (fromWorkspace) return fromWorkspace.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {};
var source = extra.source && typeof extra.source === 'object' ? extra.source : {};
return String(source.relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function pageTreeExpandRelativePath(relativePath) {
var rp = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!rp) return '';
if (!/\.md$/i.test(rp)) return rp;
var stem = rp.slice(0, -3);
var slash = rp.lastIndexOf('/');
if (slash >= 0) {
var parent = rp.slice(0, slash);
var file = rp.slice(slash + 1);
var fileStem = file.replace(/\.md$/i, '');
var parentName = parent.indexOf('/') >= 0 ? parent.slice(parent.lastIndexOf('/') + 1) : parent;
if (parentName === fileStem) return parent;
return stem;
}
return stem;
}
function pageTreeExpandPathFromRow(row) {
if (!(row instanceof HTMLElement)) return '';
var direct = String(row.getAttribute('data-local-relative-path') || '').trim();
if (direct) return direct.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
// local-md:<encoded path> is not human path; fall back empty and let API skip.
if (nodeId.indexOf('local-dir:') === 0) {
try {
return decodeURIComponent(nodeId.slice('local-dir:'.length).replace(/~/g, '%'));
} catch (_) {
return nodeId.slice('local-dir:'.length);
}
}
return '';
}
function renderPageRows(parentId, grouped, activeId, inheritedDepth) {
var computedDepth = Number(inheritedDepth || 0);
var pageTreeStateView = sidebarTreeViewStateFor('pagetree');
@@ -1028,20 +1146,37 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var parent = parentIdOf(item);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && (
// Align with SSR (expandedByDefault defaults false) and Sidex view-state:
// only expand when user state lists the id, or (no user state yet) the
// active ancestor / explicit expandedByDefault === true.
// IMPORTANT: never paint aria/data-expanded=true for an empty children UL.
// Lazy scope hydrate only has one level of rows; nested expandedIds are
// restored by restorePersistedPageTreeExpansionState cascade, not by
// faking an open empty container (that caused ▼ with no children after refresh).
var wantExpanded = expandable && (
pageTreeExpandedIds.has(nodeId) ||
(!pageTreeStateView.hasUserState && (activeParentIds.has(nodeId) || item.expandedByDefault !== false))
(!pageTreeStateView.hasUserState && (
activeParentIds.has(nodeId) ||
item.expandedByDefault === true
))
);
var expanded = wantExpanded && children.length > 0;
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var openable = Boolean(meta.documentId || item.documentId || !String(nodeId).startsWith('local-dir:'));
var expandPath = pageTreeExpandRelativePath(pageTreeRelativePathFromItem(item));
if (!expandPath && expandable) {
expandPath = pageTreeExpandRelativePath(String(item.expandRelativePath || item.relativePath || ''));
}
var pathAttr = expandPath ? ' data-local-relative-path="' + escapeHtml(expandPath) + '"' : '';
var expandedAttr = String(Boolean(expandable && expanded));
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + String(expanded) + '">' + pageTreeChevronSvg() + '</button>'
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + expandedAttr + '" data-expanded="' + expandedAttr + '">' + pageTreeChevronSvg() + '</button>'
: '<span class="tree-spacer" aria-hidden="true"></span>';
var childHtml = expandable
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>'
: '';
var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + expandedAttr + '" data-expanded="' + expandedAttr + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + pathAttr + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
}).join('');
}
@@ -1065,6 +1200,14 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return false;
}
tree.replaceChildren(template.content.cloneNode(true));
// Re-apply persisted expand after full projection replace (Sidex setInput + viewState).
var pageState = sidebarTreeViewStateFor('pagetree');
if (pageState.hasUserState) {
applyPageTreeExpansionState(pageState);
scheduleRestorePersistedPageTreeExpansionState();
} else {
syncPageTreeExpandVisualState();
}
return true;
}
@@ -1165,10 +1308,19 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var iconKind = iconKindOf(item);
var indexStatus = String((item && item.indexStatus) || (item && item.resourceMeta && item.resourceMeta.indexStatus) || '').trim();
if (!['indexed', 'indexing', 'failed'].includes(indexStatus)) indexStatus = '';
// S5: FileTree tooltip shows RAG index status when known.
var indexStatusLabel = indexStatus === 'indexed'
? '已索引'
: indexStatus === 'indexing'
? '索引中'
: indexStatus === 'failed'
? '索引失败'
: '';
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
var title = isFileTreeProjectionPageRow(rowKind, assetId)
? fileTreePageTitle(rawTitle)
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
var linkTitle = indexStatusLabel ? (title + ' · ' + indexStatusLabel) : title;
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length);
var expanded = expandable && (expandedRelativePaths.has(relativePath) || (!fileTreeStateView.hasUserState && item.expandedByDefault !== false));
@@ -1188,7 +1340,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var childHtml = expandable && expanded && childRowsHtml
? '<ul class="tree-children">' + childRowsHtml + '</ul>'
: '';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '"' + (indexStatus ? ' data-index-status="' + escapeHtml(indexStatus) + '"' : '') + ' data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '"' + (indexStatus ? ' data-index-status="' + escapeHtml(indexStatus) + '"' : '') + ' data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(linkTitle) + '"><span class="tree-link-title" title="' + escapeHtml(linkTitle) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
}).join('');
}
@@ -1402,6 +1554,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var template = document.createElement('template');
template.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
tree.replaceChildren(template.content.cloneNode(true));
// Root hydration replaces pending shell; mark FileTree ready for shell-first home.
tree.setAttribute('data-filetree-ssr', 'ready');
tree.removeAttribute('aria-busy');
reprojectFileTreeSelectionState();
scheduleRestorePersistedFileTreeExpansionState();
scheduleHydrateVisibleExpandedFileTreeRows('render');
@@ -1534,6 +1689,42 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return readProjection(payload && (payload.result || payload));
}
function isPendingFileTreeShell() {
var root = document.getElementById('sidebar-file-tree-root');
if (!(root instanceof HTMLElement)) return false;
if (root.getAttribute('data-filetree-ssr') === 'pending') return true;
return !!root.querySelector('[data-filetree-ssr="pending"], [data-rust-filetree-renderer="pending_shell_v1"]');
}
async function hydratePendingFileTreeShell(reason) {
if (!isPendingFileTreeShell()) return false;
var root = document.getElementById('sidebar-file-tree-root');
if (root instanceof HTMLElement) {
root.setAttribute('data-filetree-ssr', 'hydrating');
root.setAttribute('aria-busy', 'true');
}
var fileTreeScope = currentFileTreeScope();
var rendered = false;
try {
rendered = await refreshFileTreeParent(fileTreeScope);
} catch (error) {
rendered = false;
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[mnote] hydratePendingFileTreeShell failed', reason || 'boot', error);
}
}
if (root instanceof HTMLElement) {
if (rendered) {
root.setAttribute('data-filetree-ssr', 'ready');
root.removeAttribute('aria-busy');
} else {
root.setAttribute('data-filetree-ssr', 'pending');
root.setAttribute('aria-busy', 'true');
}
}
return rendered;
}
async function refreshFileTreeParent(parentRelativePath) {
ensureFileTreeLazyCacheScope();
var key = currentFileTreeParentKey(parentRelativePath);
@@ -1658,24 +1849,137 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return '';
}
function setTreeRowExpanded(row, button, expanded, options) {
row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
var shellMode = row.getAttribute('data-shell-mode') || '';
if (button) {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸';
function pageTreeChildrenContainer(row) {
if (!(row instanceof HTMLElement)) return null;
var node = row.closest('.tree-node');
return node ? node.querySelector(':scope > .tree-children') : null;
}
// Single write path for page-tree expand presentation (Sidex twistie model):
// aria-expanded + data-expanded + children class stay one unit so chevron CSS
// and click logic never disagree after SSR / refresh / restore.
function setPageTreeExpandPresentation(row, button, expanded, options) {
options = options || {};
var children = options.children instanceof HTMLElement
? options.children
: pageTreeChildrenContainer(row);
var open = Boolean(expanded);
// Never paint open/▼ for an empty children container unless explicitly allowed
// (in-flight optimistic expand before fetch returns).
if (
open &&
children instanceof HTMLElement &&
children.children.length === 0 &&
options.allowEmptyOpen !== true
) {
open = false;
if (options.forceCollapsed !== false) options.forceCollapsed = true;
}
var shouldPersist = !(options && options.persist === false);
var attr = open ? 'true' : 'false';
if (row instanceof HTMLElement) {
row.setAttribute('aria-expanded', attr);
row.setAttribute('data-expanded', attr);
}
if (button instanceof HTMLElement) {
button.setAttribute('aria-expanded', attr);
button.setAttribute('data-expanded', attr);
var title = '';
try {
title = String(rowTitle(row) || '').trim();
} catch (_) {
title = String(
(row && row.querySelector('.tree-link-title') && row.querySelector('.tree-link-title').textContent) || ''
).trim();
}
button.setAttribute('aria-label', (open ? '折叠 ' : '展开 ') + title);
}
if (children instanceof HTMLElement) {
var forceCollapsed = options.forceCollapsed;
if (forceCollapsed === true) {
children.classList.add('tree-children--collapsed');
} else if (forceCollapsed === false) {
children.classList.remove('tree-children--collapsed');
} else {
children.classList.toggle('tree-children--collapsed', !open);
}
if (open && children.children.length > 0 && forceCollapsed !== true) {
if (row instanceof HTMLElement) {
row.setAttribute('data-page-tree-children-loaded', 'true');
}
}
}
}
// Visual open only: children visible with rows. Never trust aria alone.
function pageTreeRowIsOpen(row) {
if (!(row instanceof HTMLElement)) return false;
var children = pageTreeChildrenContainer(row);
if (!(children instanceof HTMLElement)) return false;
if (children.classList.contains('tree-children--collapsed')) return false;
return children.children.length > 0;
}
// Icon and aria always follow visible children (Sidex twistie = tree open state).
// Never leave chevron open while .tree-children--collapsed / empty — that was the
// refresh desync. Restoring expandedIds re-opens children; icon follows after.
function syncPageTreeExpandVisualState() {
var changed = false;
document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
var button = row.querySelector('[data-rust-action="toggle"]');
if (!button) return;
var children = pageTreeChildrenContainer(row);
if (!(children instanceof HTMLElement)) {
if (row.getAttribute('aria-expanded') === 'true' || row.getAttribute('data-expanded') === 'true') {
setPageTreeExpandPresentation(row, button, false, { children: null });
changed = true;
}
return;
}
var hasChildRows = children.children.length > 0;
var visuallyOpen = !children.classList.contains('tree-children--collapsed') && hasChildRows;
var ariaOpen = row.getAttribute('aria-expanded') === 'true';
var dataOpen = row.getAttribute('data-expanded') === 'true';
var buttonOpen = button.getAttribute('aria-expanded') === 'true'
|| button.getAttribute('data-expanded') === 'true';
if (ariaOpen !== visuallyOpen || dataOpen !== visuallyOpen || buttonOpen !== visuallyOpen) {
setPageTreeExpandPresentation(row, button, visuallyOpen, {
children: children,
forceCollapsed: !visuallyOpen
});
changed = true;
}
if (hasChildRows) {
row.setAttribute('data-page-tree-children-loaded', 'true');
}
});
if (changed) {
document.documentElement.setAttribute('data-mnote-page-tree-expand-synced', String(Date.now()));
}
return changed;
}
function setTreeRowExpanded(row, button, expanded, options) {
var shellMode = row.getAttribute('data-shell-mode') || '';
if (shellMode === 'page') {
setPageTreeExpandPresentation(row, button, expanded);
var shouldPersistPage = !(options && options.persist === false);
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
if (!nodeId) return;
var pageState = sidebarTreeViewStateFor('pagetree');
if (expanded) pageState.expandedIds.add(nodeId);
else pageState.expandedIds.delete(nodeId);
pageState.hasUserState = true;
if (shouldPersist) persistSidebarTreeViewState('pagetree');
if (shouldPersistPage) persistSidebarTreeViewState('pagetree');
return;
}
row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
row.setAttribute('data-expanded', expanded ? 'true' : 'false');
if (button) {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸';
}
var shouldPersist = !(options && options.persist === false);
var relativePath = localFileTreeRelativePathFromRow(row);
if (!relativePath) return;
if (expanded) fileTreeExpandedRelativePaths.add(relativePath);
@@ -1838,9 +2142,199 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return request;
}
async function fetchPageTreeProjection(parentRelativePath) {
var rootUri = currentRootUri();
if (!rootUri) return null;
var url = new URL('/api/tree/projections/page', window.location.origin);
var workspaceId = currentWorkspaceId();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
if (parentRelativePath) url.searchParams.set('parentRelativePath', parentRelativePath);
var response = await fetch(url.toString(), {
credentials: 'include',
headers: { accept: 'application/json' },
cache: 'no-store'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok) {
throw new Error(payload && (payload.error || payload.message) || 'page_tree_projection_failed_' + response.status);
}
return readProjection(payload && (payload.result || payload));
}
function patchPageTreeParentChildren(row, button, childItems, options) {
if (!(row instanceof HTMLElement)) return false;
var node = row.closest('.tree-node');
if (!node) return false;
var children = node.querySelector(':scope > .tree-children');
if (!children) {
children = document.createElement('ul');
children.className = 'tree-children';
node.appendChild(children);
}
var parentNodeId = String(row.getAttribute('data-node-id') || '').trim();
var parentDepth = Number(row.getAttribute('data-depth') || 0);
// Scope projection returns one shallow level under parentRelativePath. Server parentNodeId
// often points at a directory/group id that is not present in this response. Do NOT run
// groupRowsByParent here: it rewrites unknown parents to "" and renderPageRows(parentId)
// then paints zero children (page-tree expand appears broken).
var scoped = (Array.isArray(childItems) ? childItems : []).map(function(item) {
var next = Object.assign({}, item);
next.parentNodeId = parentNodeId;
next.parentId = parentNodeId;
// Preserve expandable for nested lazy expand even when childCount is still 0.
if (next.expandable == null && Number(next.childCount || 0) > 0) {
next.expandable = true;
}
return next;
});
var activeId = currentDocumentId();
var grouped = new Map();
grouped.set(parentNodeId, scoped);
var html = renderPageRows(parentNodeId, grouped, activeId, parentDepth + 1);
var template = document.createElement('template');
template.innerHTML = html;
children.replaceChildren(template.content.cloneNode(true));
row.setAttribute('data-page-tree-children-loaded', 'true');
if (scoped.length === 0) {
// Scope returned no children: keep closed (▶) and stop restore retries.
children.classList.add('tree-children--collapsed');
setTreeRowExpanded(row, button, false, options);
row.setAttribute('data-page-tree-restore-empty', 'true');
return false;
}
children.classList.remove('tree-children--collapsed');
row.removeAttribute('data-page-tree-restore-empty');
setTreeRowExpanded(row, button, true, options);
return true;
}
async function loadPageTreeChildren(row, button, options) {
if (!(row instanceof HTMLElement)) return false;
if (row.getAttribute('data-shell-mode') !== 'page') return false;
if (currentSourceKind() !== 'local_folder') return false;
var node = row.closest('.tree-node');
var children = node ? node.querySelector(':scope > .tree-children') : null;
// SSR often already painted nested rows under a collapsed container without
// data-page-tree-children-loaded. Treat non-empty children as loaded so the
// first click expands immediately (no "dead" click waiting on fetch).
if (children && children.children.length > 0) {
row.setAttribute('data-page-tree-children-loaded', 'true');
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true, options);
// Nested expandedIds may still need cascade hydrate after SSR paint.
if (!(options && options.skipCascadeRestore)) {
scheduleRestorePersistedPageTreeExpansionState();
}
return true;
}
var alreadyLoaded = row.getAttribute('data-page-tree-children-loaded') === 'true';
if (alreadyLoaded && children) {
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true, options);
if (!(options && options.skipCascadeRestore)) {
scheduleRestorePersistedPageTreeExpansionState();
}
return true;
}
if (row.getAttribute('data-page-tree-children-loading') === 'true') {
// In-flight lazy load: optimistic empty-open so first click responds;
// hydrate completion rewrites children and re-syncs icons.
if (children) children.classList.remove('tree-children--collapsed');
setPageTreeExpandPresentation(row, button, true, {
children: children,
forceCollapsed: false,
allowEmptyOpen: true
});
return false;
}
var relativePath = pageTreeExpandPathFromRow(row);
if (!relativePath) {
// Expandable without path: toggle empty container only.
if (children) {
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true, options);
}
return false;
}
var rootUri = currentRootUri();
if (!rootUri) return false;
// Optimistic UI: allow empty-open chevron until fetch fills children.
if (children) children.classList.remove('tree-children--collapsed');
setPageTreeExpandPresentation(row, button, true, {
children: children,
forceCollapsed: false,
allowEmptyOpen: true
});
// Still record expanded id for persistence while hydrate runs.
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
if (nodeId && !(options && options.persist === false)) {
var pageState = sidebarTreeViewStateFor('pagetree');
pageState.expandedIds.add(nodeId);
pageState.hasUserState = true;
persistSidebarTreeViewState('pagetree');
} else if (nodeId && options && options.persist === false) {
var pageStateNoPersist = sidebarTreeViewStateFor('pagetree');
pageStateNoPersist.expandedIds.add(nodeId);
pageStateNoPersist.hasUserState = true;
}
row.setAttribute('data-page-tree-children-loading', 'true');
try {
var projection = await fetchPageTreeProjection(relativePath);
if (!projection) return false;
var items = projectionItems(projection).filter(function(item) {
return String(item.rowKind || 'document') === 'document';
});
// Drop self row if scope returns the page at this folder.
var selfId = String(row.getAttribute('data-node-id') || '').trim();
items = items.filter(function(item) {
return nodeIdOf(item) !== selfId;
});
var patched = patchPageTreeParentChildren(row, button, items, options);
// After one shallow level is painted, cascade restore deeper expandedIds.
if (patched && !(options && options.skipCascadeRestore)) {
scheduleRestorePersistedPageTreeExpansionState();
}
return patched;
} catch (error) {
setTreeLiveApplyError(error && error.message ? error.message : '页面树子节点加载失败');
if (children) children.classList.add('tree-children--collapsed');
setTreeRowExpanded(row, button, false, options);
return false;
} finally {
row.removeAttribute('data-page-tree-children-loading');
syncPageTreeExpandVisualState();
}
}
function toggleChildren(row, button) {
var li = row && row.parentElement;
var children = li ? li.querySelector(':scope > .tree-children') : null;
var shellMode = row && row.getAttribute('data-shell-mode') || '';
if (shellMode === 'page') {
// Use visual open state (children not collapsed), not aria alone.
// Otherwise a desynced "arrow open / children hidden" first click collapses.
if (pageTreeRowIsOpen(row)) {
if (children) children.classList.add('tree-children--collapsed');
setTreeRowExpanded(row, button, false);
return;
}
// SSR / previous expand already has child rows: expand immediately.
if (children && children.children.length > 0) {
row.setAttribute('data-page-tree-children-loaded', 'true');
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true);
return;
}
if (children && row.getAttribute('data-page-tree-children-loaded') === 'true') {
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true);
return;
}
void loadPageTreeChildren(row, button);
return;
}
if (!children) {
void loadFileTreeChildren(row, button);
return;
@@ -1892,9 +2386,116 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return restored;
}
// Sidex-style: after load/setInput, expand nodes listed in view-state.
// PageTree shallow SSR may already contain children (open immediately) or need
// lazy children fetch for persisted expandedIds without nested rows.
var pageTreeExpansionRestoreTimer = 0;
var pageTreeExpansionRestoreQueued = false;
var PAGE_TREE_RESTORE_MAX = 24;
function scheduleRestorePersistedPageTreeExpansionState() {
if (pageTreeExpansionRestoreQueued) return;
pageTreeExpansionRestoreQueued = true;
var run = function() {
pageTreeExpansionRestoreQueued = false;
pageTreeExpansionRestoreTimer = 0;
restorePersistedPageTreeExpansionState();
};
if (typeof window.requestAnimationFrame === 'function') {
pageTreeExpansionRestoreTimer = window.requestAnimationFrame(run);
} else {
pageTreeExpansionRestoreTimer = window.setTimeout(run, 0);
}
}
function restorePersistedPageTreeExpansionState() {
var state = sidebarTreeViewStateFor('pagetree');
if (!state || !state.hasUserState || !state.expandedIds.size) {
// No user expands: force icons to match collapsed SSR children after refresh.
syncPageTreeExpandVisualState();
return false;
}
var restored = 0;
var pendingLoads = [];
document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
if (restored >= PAGE_TREE_RESTORE_MAX) return;
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
if (!nodeId || !state.expandedIds.has(nodeId)) return;
var button = row.querySelector('[data-rust-action="toggle"]');
var children = pageTreeChildrenContainer(row);
if (!button || !(children instanceof HTMLElement)) return;
if (pageTreeRowIsOpen(row)) {
restored += 1;
return;
}
// Shallow SSR already nested child rows: open immediately (icon + body).
if (children.children.length > 0) {
row.setAttribute('data-page-tree-children-loaded', 'true');
setTreeRowExpanded(row, button, true, { persist: false });
restored += 1;
return;
}
// Empty body: do not fake-open the chevron; hydrate then open (icon follows).
if (row.getAttribute('data-page-tree-children-loading') === 'true') return;
// Scope already returned empty for this row — skip infinite restore loops.
if (row.getAttribute('data-page-tree-restore-empty') === 'true') return;
if (currentSourceKind() === 'local_folder') {
pendingLoads.push({ row: row, button: button });
restored += 1;
}
});
pendingLoads.slice(0, 6).forEach(function(item) {
// Cascade further levels after each hydrate paints its children.
void loadPageTreeChildren(item.row, item.button, {
persist: false,
skipCascadeRestore: false
});
});
if (restored) {
document.documentElement.setAttribute('data-mnote-page-tree-expansion-restored', String(restored));
}
// After open/hydrate schedule: icons follow visible children only.
syncPageTreeExpandVisualState();
// If only shallow levels were opened from SSR children, schedule another
// pass so nested expandedIds under newly-visible rows get hydrated.
if (restored > 0 && pendingLoads.length === 0) {
// Children already in DOM — nested empty expanded rows need a follow-up.
var needsMore = false;
state.expandedIds.forEach(function(id) {
if (needsMore) return;
var nested = document.querySelector(
'#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="' +
String(id).replace(/\\/g, '\\\\').replace(/"/g, '\\"') +
'"]'
);
if (!(nested instanceof HTMLElement)) return;
if (pageTreeRowIsOpen(nested)) return;
var nestedChildren = pageTreeChildrenContainer(nested);
if (nestedChildren && nestedChildren.children.length === 0) needsMore = true;
});
if (needsMore) scheduleRestorePersistedPageTreeExpansionState();
}
return restored > 0;
}
function installTreeLiveApplyEventListeners() {
// Load local view-state, apply to SSR DOM, then reconcile icons (Sidex setInput).
sidebarTreeViewStateFor('pagetree');
applyPageTreeExpansionState(sidebarTreeViewStateFor('pagetree'));
scheduleRestorePersistedPageTreeExpansionState();
syncPageTreeExpandVisualState();
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(function() {
restorePersistedPageTreeExpansionState();
syncPageTreeExpandVisualState();
});
} else {
window.setTimeout(function() {
restorePersistedPageTreeExpansionState();
syncPageTreeExpandVisualState();
}, 0);
}
document.addEventListener('visibilitychange', function() {
if (!document.hidden) return;
flushPendingSidebarTreeViewState('filetree');
@@ -1919,7 +2520,6 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
}
}
updateTitleEverywhere(detail.documentId, detail.title);
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
document.documentElement.setAttribute('data-mnote-title-local-applied', 'true');
});
@@ -2054,6 +2654,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
installTreeLiveApplyEventListeners,
isFileTreePageRow,
applyLocalFolderWatchBatch,
hydratePendingFileTreeShell,
isPendingFileTreeShell,
localCommandNeedsProjectionRefresh,
normalizeFileTreePageRenameTitle,
objectIdentityAttr,
@@ -2064,6 +2666,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
renderSidebarSnapshot,
revealFileTreeResource,
restorePersistedFileTreeExpansionState,
restorePersistedPageTreeExpansionState,
setTreeLiveApplyError,
startLocalFolderSidebarWatch,
toggleChildren,
@@ -464,6 +464,149 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return true;
}
/**
* Soft-open password vault: swap main content only, keep sidebar PageTree/FileTree DOM.
* Full /vault navigation re-SSRs the tree and drops expand state — avoid that on left-rail click.
*/
function ensureVaultWorkbenchRuntimeLoaded() {
if (window.mnote && window.mnote.vaultWorkbench && typeof window.mnote.vaultWorkbench.boot === 'function') {
return Promise.resolve(window.mnote.vaultWorkbench);
}
return new Promise(function (resolve, reject) {
var existing = document.querySelector('script[data-mnote-vault-runtime="1"]');
if (existing) {
var onLoad = function () {
existing.removeEventListener('load', onLoad);
existing.removeEventListener('error', onError);
if (window.mnote && window.mnote.vaultWorkbench) resolve(window.mnote.vaultWorkbench);
else reject(new Error('vault_workbench_runtime_missing'));
};
var onError = function () {
existing.removeEventListener('load', onLoad);
existing.removeEventListener('error', onError);
reject(new Error('vault_workbench_runtime_load_failed'));
};
existing.addEventListener('load', onLoad);
existing.addEventListener('error', onError);
return;
}
var script = document.createElement('script');
script.src = '/api/mnote-browser-runtime/vault-workbench-runtime.js';
script.defer = true;
script.setAttribute('data-mnote-vault-runtime', '1');
script.onload = function () {
if (window.mnote && window.mnote.vaultWorkbench) resolve(window.mnote.vaultWorkbench);
else reject(new Error('vault_workbench_runtime_missing'));
};
script.onerror = function () {
reject(new Error('vault_workbench_runtime_load_failed'));
};
document.head.appendChild(script);
});
}
function buildVaultWorkbenchShellHtml(workspaceId, rootUri) {
var ws = escapeHtml(String(workspaceId || '').trim());
var root = escapeHtml(String(rootUri || '').trim());
return (
'<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="' +
ws +
'" data-root-uri="' +
root +
'" data-status="active">' +
'<header class="mnote-vault-header">' +
'<div class="mnote-vault-header-main">' +
'<h1>密码箱</h1>' +
'<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>' +
'</div>' +
'<div class="mnote-vault-header-actions">' +
'<button type="button" data-vault-create data-testid="vault-create">新建</button>' +
'<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>' +
'<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />' +
'<div class="mnote-vault-tabs" role="tablist">' +
'<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>' +
'<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false">已删除</button>' +
'</div>' +
'</div>' +
'</header>' +
'<div class="mnote-vault-body">' +
'<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>' +
'<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>' +
'</div>' +
'</section>'
);
}
function markVaultNavActive() {
document.querySelectorAll('.mnote-sidebar-nav a.active, .wolai-quick-actions a.active').forEach(function (link) {
link.classList.remove('active');
});
document
.querySelectorAll('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"]')
.forEach(function (link) {
link.classList.add('active');
});
}
async function openVaultWorkbenchSoft(targetUrl, options) {
var url =
targetUrl instanceof URL
? targetUrl
: new URL(String(targetUrl || '/vault'), window.location.origin);
if (url.pathname !== '/vault') {
window.location.assign(url.pathname + url.search + url.hash);
return false;
}
copyWorkspaceSourceParams(url);
if (!url.searchParams.get('sourceKind') && (url.searchParams.get('rootUri') || currentRootUri())) {
url.searchParams.set('sourceKind', 'local_folder');
}
if (!url.searchParams.get('rootUri')) {
var bodyRoot = currentRootUri();
if (bodyRoot) url.searchParams.set('rootUri', bodyRoot);
}
var rootUri = (url.searchParams.get('rootUri') || currentRootUri() || '').trim();
var workspaceId = (
currentWorkspaceId() ||
resolveWorkspaceId(document.body) ||
(document.getElementById('sidebar-tree-root') &&
document.getElementById('sidebar-tree-root').getAttribute('data-workspace-id')) ||
''
).trim();
var content = document.querySelector('article.mnote-content');
if (!(content instanceof HTMLElement)) {
window.location.assign(url.pathname + url.search + url.hash);
return false;
}
var nextHref = url.pathname + url.search + url.hash;
if (mnoteNavigationInFlight === 'vault:' + nextHref) return true;
mnoteNavigationInFlight = 'vault:' + nextHref;
try {
// Preserve #sidebar-tree-root / #sidebar-file-tree-root; only replace main pane.
content.innerHTML = buildVaultWorkbenchShellHtml(workspaceId, rootUri);
if (document.body instanceof HTMLElement) {
document.body.setAttribute('data-mnote-page', 'vault');
if (rootUri) document.body.setAttribute('data-mnote-root-uri', rootUri);
document.body.setAttribute('data-mnote-source-kind', 'local_folder');
}
document.title = '密码箱';
markVaultNavActive();
if (!(options && options.replaceUrl === false)) {
window.history.pushState({ mnoteSoftNav: 'vault' }, '', nextHref);
}
var vaultApi = await ensureVaultWorkbenchRuntimeLoaded();
if (vaultApi && typeof vaultApi.boot === 'function') vaultApi.boot();
return true;
} catch (error) {
console.warn('[mnote vault] soft open failed, falling back to full navigation', error);
window.location.assign(nextHref);
return false;
} finally {
if (mnoteNavigationInFlight === 'vault:' + nextHref) mnoteNavigationInFlight = '';
}
}
window.__mnoteOpenVaultWorkbench = openVaultWorkbenchSoft;
function openCurrentNavigationPage(trigger) {
if (currentSourceKind() === 'local_folder') {
var rootUri = currentRootUri();
@@ -3387,6 +3530,34 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var vaultNavLink = closestAction(
e.target,
'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]'
);
if (vaultNavLink instanceof HTMLAnchorElement) {
// Modifier / middle-click: keep native full navigation (new tab etc.).
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
try {
var enrichUrl = new URL(vaultNavLink.getAttribute('href') || '/vault', window.location.origin);
if (enrichUrl.pathname === '/vault') {
copyWorkspaceSourceParams(enrichUrl);
vaultNavLink.setAttribute('href', enrichUrl.pathname + enrichUrl.search + enrichUrl.hash);
}
} catch (_) {}
} else {
try {
var vaultUrl = new URL(vaultNavLink.getAttribute('href') || '/vault', window.location.origin);
if (vaultUrl.pathname === '/vault') {
e.preventDefault();
copyWorkspaceSourceParams(vaultUrl);
vaultNavLink.setAttribute('href', vaultUrl.pathname + vaultUrl.search + vaultUrl.hash);
void openVaultWorkbenchSoft(vaultUrl);
return;
}
} catch (_) {}
}
}
var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]');
if (localFolderTrigger) {
e.preventDefault();
@@ -3908,6 +4079,15 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
sidebarFileTreeSelection.focusedRowId = rowId;
});
syncSidebarFileTreeSelection();
// Shell-first home: FileTree may be a pending placeholder; hydrate after first paint.
if (typeof sidebarTreeLiveApply.hydratePendingFileTreeShell === 'function') {
var schedulePendingHydrate = typeof queueMicrotask === 'function'
? queueMicrotask
: function(fn) { setTimeout(fn, 0); };
schedulePendingHydrate(function() {
void sidebarTreeLiveApply.hydratePendingFileTreeShell('boot');
});
}
schedulePendingLocalFolderRestoreFocus();
var recentPageOptionsApply = { documentId: '', at: 0 };
function applyPrimaryPageOptionsOnce(documentId) {
File diff suppressed because it is too large Load Diff
+4
View File
@@ -18,6 +18,7 @@ use std::fs;
use std::sync::Arc;
#[cfg(not(test))]
use std::time::Duration;
use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -332,6 +333,9 @@ fn open_turso_synced_control_plane_store() -> Arc<dyn ControlPlaneStore> {
pub fn build_app(state: AppState) -> Router {
build_router(state)
// Outer layers run first on request / last on response. Compress HTML/JSON
// for large local-folder SSR shells (PageTree/FileTree).
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http().on_failure(()))
.layer(axum::middleware::from_fn(log_failed_response))
.layer(axum::middleware::from_fn(inject_request_context))
@@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlowWeKnora/LightRAG 仅作为 legacy fallback",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGWeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlowWeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGWeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -38,7 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "知识库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow",
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -128,6 +128,26 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-vault",
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。",
category: "security",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
// P1 vault tools (when registered); skill remains discoverable before tools land.
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
],
content: include_str!("../../../../../skills/mnote-vault/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-chat-only",
title: "纯聊天",
@@ -361,6 +381,32 @@ mod tests {
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[test]
fn skill_registry_exposes_vault_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-vault")
.expect("reasonix should see vault skill");
assert_eq!(skill["readOnly"], true);
assert_eq!(skill["category"], "security");
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.vault.resolve"));
assert!(hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-vault"));
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
let body = find_skill("mnote-vault", Some("hermes"))
.expect("hermes can read vault skill")
.content;
assert!(body.contains(".mnote/vault"));
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
}
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
+169 -17
View File
@@ -510,16 +510,17 @@ pub async fn user_access_scopes(
/// `GET /api/ai-admin/access-scopes`
///
/// Admin-only variant. Validates that the current actor has admin
/// privileges via `is_local_access_policy_admin_context` before
/// returning directory grants.
/// privileges from the active control-plane session before
/// returning **all** directory grants (not only the admin actor's own).
/// Source of truth matches `/api/admin/access-policy` → `list_directory_grants()`.
pub async fn admin_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let _actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -528,8 +529,13 @@ pub async fn admin_access_scopes(
.with_context(&context));
}
// S3: admin SoT is the full grant table, not actor-scoped grants.
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
filter_directory_grants_to_access_scopes(grants, query.workspace_id.as_deref());
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
@@ -551,7 +557,7 @@ pub async fn admin_receipts(
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -629,7 +635,7 @@ pub async fn admin_directory_access_requests(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let requests = list_directory_access_requests(&state, None)?;
Ok(Json(json!({
"ok": true,
@@ -645,7 +651,7 @@ pub async fn approve_directory_access_request(
Json(_body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let request = find_pending_directory_access_request(&state, &request_id)?;
let user_id = request
.get("userId")
@@ -720,7 +726,7 @@ pub async fn reject_directory_access_request(
Json(body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let _ = find_pending_directory_access_request(&state, &request_id)?;
let metadata = json!({
"requestId": request_id,
@@ -901,7 +907,7 @@ pub async fn admin_get_settings(
Extension(context): Extension<RequestContext>,
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
let policy = state
@@ -939,7 +945,7 @@ pub async fn admin_put_settings(
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
@@ -1036,7 +1042,7 @@ pub async fn admin_list_users(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let users = state
.control_plane()
.list_users(500)
@@ -1049,7 +1055,7 @@ pub async fn admin_list_users(
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
@@ -1069,7 +1075,7 @@ pub async fn admin_get_user_settings(
Path(user_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
let global_policy = load_policy_value(&state, &global_owner, None);
@@ -1092,7 +1098,7 @@ pub async fn admin_put_user_settings(
Json(body): Json<UserAiPolicyBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
if user_id == global_owner {
@@ -1567,12 +1573,30 @@ fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
servers
}
fn policy_removed_default_ids(model_policy: &Value, key: &str) -> std::collections::HashSet<String> {
model_policy
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(|value| value.trim().to_string()))
.filter(|value| !value.is_empty())
.collect()
})
.unwrap_or_default()
}
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
let mut skills = default_skill_registry();
let configured = policy_map::<SkillConfig>(model_policy, "skills");
for (id, config) in configured {
skills.insert(id, config);
}
// S2: omit built-in defaults the admin explicitly deleted (tombstones).
for id in policy_removed_default_ids(model_policy, "removedDefaultSkills") {
skills.remove(&id);
}
skills
}
@@ -1582,6 +1606,9 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConf
for (id, config) in configured {
servers.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultMcpServers") {
servers.remove(&id);
}
servers
}
@@ -1594,6 +1621,9 @@ fn effective_pi_extension_registry(model_policy: &Value) -> HashMap<String, PiEx
}
extensions.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultPiExtensions") {
extensions.remove(&id);
}
extensions
}
@@ -1693,6 +1723,11 @@ fn is_configured_admin_user(user_id: &str) -> bool {
.unwrap_or(false)
}
fn is_admin_user_for_display(user_id: &str, stored_role: &str) -> bool {
local_folder_source::is_local_access_policy_admin_actor(user_id, stored_role)
|| is_configured_admin_user(user_id)
}
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
load_model_policy_and_quota(state, actor_id, workspace_id).0
}
@@ -2238,8 +2273,8 @@ fn filter_directory_grants_to_access_scopes(
/// Ensures admin auth. Reuses the existing admin check from
/// `local_folder_source::is_local_access_policy_admin_context`.
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
if !local_folder_source::is_local_access_policy_admin_context(context) {
fn ensure_admin(state: &AppState, context: &RequestContext) -> Result<(), WebError> {
if !crate::routes::gateway::current_actor_is_local_admin(state, context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -2634,13 +2669,22 @@ fn merge_policy_with_existing(
// The admin settings UI edits this registry as the desired full
// state. Re-merging deleted rows back from the persisted policy
// makes "删除" a no-op, so presence of this section means replace.
//
// S2: also write tombstones for built-in defaults omitted from the
// payload, so effective_*_registry does not rehydrate ghosts.
if let Some(ref skills) = body.skills {
let mut merged = serde_json::Map::new();
for (k, v) in skills {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_skill_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["skills"] = Value::Object(merged);
model_policy["removedDefaultSkills"] = json!(removed_defaults);
}
// ── Replace MCP servers map ─────────────────────────────────────
@@ -2653,7 +2697,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_mcp_server_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["mcpServers"] = Value::Object(merged);
model_policy["removedDefaultMcpServers"] = json!(removed_defaults);
}
if let Some(ref extensions) = body.pi_extensions {
@@ -2662,7 +2712,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_pi_extension_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["piExtensions"] = Value::Object(merged);
model_policy["removedDefaultPiExtensions"] = json!(removed_defaults);
}
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
@@ -3253,6 +3309,79 @@ mod tests {
assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true);
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
assert!(parsed["piExtensions"].as_object().unwrap().is_empty());
// S2: omitting a built-in default from the replace payload tombs it.
let removed_skills = parsed["removedDefaultSkills"]
.as_array()
.expect("removedDefaultSkills");
assert!(
removed_skills.iter().any(|v| v.as_str() == Some("vpn")),
"vpn default skill should be tombstoned when omitted: {parsed}"
);
let removed_mcp = parsed["removedDefaultMcpServers"]
.as_array()
.expect("removedDefaultMcpServers");
assert!(
removed_mcp
.iter()
.any(|v| v.as_str() == Some("context7")),
"context7 default MCP should be tombstoned when omitted: {parsed}"
);
}
/// S2: deleted default skills/MCP must not reappear via effective registries.
#[test]
fn effective_registries_honor_removed_default_tombstones() {
let policy = json!({
"skills": {
"custom-only": {
"name": "Custom Only",
"enabled": true,
"description": "",
"source": "/tmp/custom/SKILL.md",
"riskLevel": "low",
"requiredScopes": []
}
},
"removedDefaultSkills": ["vpn", "chrome-bridge"],
"mcpServers": {
"custom-mcp": {
"name": "Custom MCP",
"enabled": true,
"url": "",
"transport": "stdio",
"command": "custom-mcp",
"networkPolicy": "deny-all",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "",
"riskLevel": "medium",
"requiredScopes": []
}
},
"removedDefaultMcpServers": ["context7", "codegraph"]
});
let skills = effective_skill_registry(&policy);
assert!(skills.contains_key("custom-only"));
assert!(
!skills.contains_key("vpn"),
"tombstoned default skill must not rehydrate"
);
assert!(!skills.contains_key("chrome-bridge"));
// Untombstoned defaults still present.
assert!(skills.contains_key("context7") || skills.contains_key("searxng"));
let mcps = effective_mcp_registry(&policy);
assert!(mcps.contains_key("custom-mcp"));
assert!(!mcps.contains_key("context7"));
assert!(!mcps.contains_key("codegraph"));
// Admin projection must match effective (no ghost defaults).
let admin = project_admin_settings(&policy, &json!({}), None);
assert!(!admin.skills.contains_key("vpn"));
assert!(admin.skills.contains_key("custom-only"));
assert!(!admin.mcp_servers.contains_key("context7"));
assert!(admin.mcp_servers.contains_key("custom-mcp"));
}
// ─── Admin projection ────────────────────────────────────────────────
@@ -3479,4 +3608,27 @@ mod tests {
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
"mnote-ai-settings-display-admin-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&policy_root);
std::fs::create_dir_all(&policy_root).expect("create policy root");
let policy_file = policy_root.join("access-policy.json");
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
.expect("write access policy");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
assert!(is_admin_user_for_display("liaibo", "user"));
assert!(!is_admin_user_for_display("shujuan", "user"));
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&policy_root);
}
}
+551 -21
View File
@@ -4,9 +4,10 @@ use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state,
is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, load_local_trash_entries,
is_local_access_policy_admin_actor, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
@@ -15,8 +16,8 @@ use crate::routes::web_shell::{
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_editor_runtime_preload_links, render_local_file_tree_html,
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html,
render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::workspace_shell::{
@@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_admin_required",
@@ -240,7 +241,7 @@ pub async fn admin_ai_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
@@ -267,7 +268,7 @@ pub async fn settings_entry(
ai_management_response(
&state,
&context,
is_local_access_policy_admin_context(&context),
current_actor_is_local_admin(&state, &context),
)
}
@@ -410,10 +411,15 @@ pub async fn root_entry(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
// Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned).
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
requested_page_id.as_deref(),
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())?
};
let workspace_id = page_tree_snapshot
.dataset
@@ -452,12 +458,19 @@ pub async fn root_entry(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?;
// Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan.
// treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML.
let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some();
let file_tree_html = if needs_sync_file_tree {
render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -480,7 +493,8 @@ pub async fn root_entry(
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
})?
.to_string();
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
let snapshot =
load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?;
let workspace_id = snapshot
.dataset
.get("workspace")
@@ -511,8 +525,12 @@ pub async fn root_entry(
&snapshot,
selected_active_page_id.as_deref(),
);
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
// Local-first landing also prefers shell-first FileTree (hydrate after paint).
let file_tree_html = if requests_filetree_first {
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -593,7 +611,11 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html(
&workspace_projection,
Some(active_page_id.as_str()),
);
let show_admin_access_policy = current_actor_is_local_admin(&state, &context);
let navigation_notice_html = render_navigation_guard_notice(&query);
let navigation_html = if active_page_id.trim().is_empty() {
if active_source_kind.as_deref() == Some("local_folder") {
@@ -618,6 +640,10 @@ pub async fn root_entry(
} else {
None
};
let vault_nav_href = vault_nav_href_for_context(
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -625,11 +651,13 @@ pub async fn root_entry(
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
breadcrumb_html={breadcrumb_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
vault_nav_href={vault_nav_href.clone()}
/>
})
};
@@ -708,6 +736,7 @@ document.body.appendChild(s);
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={true}
vault_nav_href={vault_nav_href.clone()}
/>
});
let body_extra = format!(
@@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String {
.collect()
}
/// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context.
pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String {
let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty());
let source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| root_uri.map(|_| "local_folder"));
match (source_kind, root_uri) {
(Some(source_kind), Some(root_uri)) => format!(
"/vault?sourceKind={}&rootUri={}",
query_escape(source_kind),
query_escape(root_uri)
),
(Some(source_kind), None) => {
format!("/vault?sourceKind={}", query_escape(source_kind))
}
(None, Some(root_uri)) => format!(
"/vault?sourceKind=local_folder&rootUri={}",
query_escape(root_uri)
),
(None, None) => "/vault".to_string(),
}
}
/// GET /files → permanent product surface is /vault (left-rail password vault).
pub async fn files_redirect_to_vault(
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
let mut location = String::from("/vault");
let mut parts: Vec<String> = Vec::new();
if let Some(root_uri) = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("rootUri={}", query_escape(root_uri)));
}
if let Some(source_kind) = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("sourceKind={}", query_escape(source_kind)));
} else if !parts.is_empty() {
parts.push("sourceKind=local_folder".to_string());
}
if let Some(workspace_id) = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("workspaceId={}", query_escape(workspace_id)));
}
if !parts.is_empty() {
location.push('?');
location.push_str(&parts.join("&"));
}
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, location)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
/// GET /vault — dedicated password vault workbench (local_folder only for P0).
pub async fn vault_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !crate::routes::vault::vault_feature_enabled() {
return Err(crate::routes::vault::vault_disabled_error().with_context(&context));
}
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// Product path: local_folder workspace with rootUri.
if source_kind == Some("local_folder") || root_uri.is_some() {
let root_uri = root_uri.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
// Best-effort ensure vault dirs so first paint has a writable layout.
let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection =
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let bootstrap = crate::routes::vault::bootstrap_list_json(
&workspace_root,
crate::routes::vault_store::VaultItemStatus::Active,
)
.unwrap_or_else(|_| {
json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
})
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, root_uri, &bootstrap);
let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri));
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
vault_nav_href={vault_nav_href.clone()}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}" data-mnote-page="vault">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(root_uri),
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return Ok(response);
}
// Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap).
// Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503.
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let workspace_id = normalize_optional_id(query.workspace_id.as_deref())
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
.map(ToOwned::to_owned)
.unwrap_or_else(|| "local-folder".to_string());
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&workspace_dataset,
&workspace_id,
None,
&default_workspace_name,
);
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(""),
Some(""),
None,
None,
);
let empty_bootstrap = json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
"needsLocalFolder": true,
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, "", &empty_bootstrap);
let workspace_name = workspace_projection.workspace_name.clone();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={String::new()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-page="vault" data-mnote-vault-needs-folder="1">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
Ok(response)
}
fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String {
let bootstrap_raw = bootstrap.to_string();
let bootstrap_json = escape_script_json(&bootstrap_raw);
let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js");
format!(
r#"<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="{workspace_id}" data-root-uri="{root_uri_esc}" data-status="active">
<header class="mnote-vault-header">
<div class="mnote-vault-header-main">
<h1></h1>
<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>
</div>
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create"></button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">簿</button>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true"></button>
<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false"></button>
</div>
</div>
</header>
<div class="mnote-vault-body">
<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>
<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>
</div>
<script type="application/json" id="__MNOTE_VAULT_BOOTSTRAP__">{bootstrap_json}</script>
<script src="{runtime_src}" defer></script>
</section>"#,
workspace_id = escape_html(workspace_id),
root_uri_esc = escape_html(root_uri),
bootstrap_json = bootstrap_json,
runtime_src = runtime_src,
)
}
pub async fn trash_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1147,7 +1474,7 @@ pub async fn trash_entry(
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
@@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
context.auth.actor_type.trim().to_string()
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role);
}
}
is_local_access_policy_admin_context(context)
}
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -3232,6 +3569,104 @@ mod tests {
assert!(!html.contains("开发用户 的空间"));
}
#[tokio::test]
async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() {
use control_plane::{session_token_hash, CreateSessionInput};
let app_state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
app_state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("normal-user-session".into()),
email: Some("normal@example.com".into()),
username: "normal-user-session".into(),
display_name: "普通用户".into(),
role: Some("user".into()),
password_hash: None,
})
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "normal-user-session".into(),
token_hash: session_token_hash("normal-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create control-plane session");
let app = build_app(app_state);
let stale_admin_cookie =
"mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin";
let settings_response = app
.clone()
.oneshot(
Request::builder()
.uri("/settings")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("settings response");
assert_eq!(settings_response.status(), StatusCode::OK);
let body = to_bytes(settings_response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-ai-admin-role=\"user\""));
assert!(!html.contains("data-ai-admin-role=\"admin\""));
assert!(!html.contains("href=\"#ai-admin-users\""));
let admin_page_response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/ai")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin page response");
assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN);
let admin_api_response = app
.oneshot(
Request::builder()
.uri("/api/ai-admin/users")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin api response");
assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_access_policy_entry_requires_admin_actor() {
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -3780,10 +4215,105 @@ mod tests {
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
// Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault).
// Leptos HTML-escapes `&` as `&amp;` in attribute values — match fragments, not full attribute.
let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#)
&& html.contains("/vault?sourceKind=local_folder")
&& html.contains("rootUri=");
assert!(
vault_href_ok,
"vault nav SSR href must include sourceKind+rootUri: {}",
html.lines()
.find(|line| line.contains("mnote-nav-vault"))
.unwrap_or("(no vault nav line)")
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/vault")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::OK,
"bare /vault must not 503 via retired cloud workspace bootstrap"
);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
html.contains(r#"data-mnote-page="vault""#)
|| html.contains(r#"data-testid="mnote-vault-workbench""#)
|| html.contains("密码箱"),
"bare vault should render password vault shell: {}",
&html[..html.len().min(500)]
);
assert!(
html.contains(r#"data-mnote-vault-needs-folder="1""#)
|| html.contains("needsLocalFolder"),
"bare vault should signal needs local folder"
);
}
#[tokio::test]
async fn root_entry_local_folder_accepts_gzip_encoding() {
let root = temp_root("mnote-root-local-folder-gzip");
std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!("/?sourceKind=local_folder&rootUri={root_uri}"))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.header(header::ACCEPT_ENCODING, "gzip, br")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
// CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK.
assert!(
encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"),
"unexpected content-encoding: {encoding}"
);
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
+18 -2
View File
@@ -4,7 +4,9 @@ use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot, load_local_folder_page_tree_snapshot_with_reveal,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
@@ -121,7 +123,21 @@ async fn project_projection(
load_local_folder_file_tree_snapshot(&root_uri)
}
} else if let Some(parent_relative_path) = parent_relative_path.as_deref() {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
// Children fetch stays shallow; only reveal when root_node_id is present.
if root_node_id.is_some() {
load_local_folder_page_tree_scope_snapshot_with_reveal(
&root_uri,
parent_relative_path,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
}
} else if root_node_id.is_some() {
load_local_folder_page_tree_snapshot_with_reveal(
&root_uri,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_snapshot(&root_uri)
}
File diff suppressed because it is too large Load Diff
@@ -197,7 +197,21 @@ fn parse_markdown_attachment_link_with_paths(
{
return None;
}
let target_path = std::path::Path::new(target);
if page_reference_from_target(target, label).is_some()
&& !attachment_paths.contains(target)
&& !is_local_markdown_asset_href(target)
{
return None;
}
let target_without_fragment = target
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(target);
let target_path_part = target_without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(target_without_fragment);
let target_path = std::path::Path::new(target_path_part);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
@@ -320,7 +334,40 @@ fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usiz
fn should_collect_attachment_href(raw_href: &str) -> bool {
let trimmed = raw_href.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("mailto:") {
return false;
}
page_reference_from_target(trimmed, "").is_none() || is_local_markdown_asset_href(trimmed)
}
fn is_local_markdown_asset_href(raw_href: &str) -> bool {
let trimmed = raw_href
.trim()
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_href.trim());
let without_fragment = trimmed
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(trimmed);
let without_query = without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(without_fragment);
let normalized = without_query
.strip_prefix("./")
.unwrap_or(without_query)
.replace('\\', "/");
let extension = Path::new(&normalized)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
if !matches!(extension.as_deref(), Some("md" | "markdown")) {
return false;
}
normalized
.split('/')
.any(|segment| segment == ".assets" || segment.ends_with(".assets"))
}
fn build_attachment_ref(
@@ -686,6 +733,9 @@ fn append_ast_paragraph<'a>(
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
}
if append_paragraph_attachment_sequence(node, blocks, attachment_paths) {
return;
}
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
@@ -716,6 +766,9 @@ fn append_ast_list_item<'a>(
if children.next().is_none()
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
{
if append_paragraph_attachment_sequence(paragraph, blocks, attachment_paths) {
return;
}
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
@@ -724,13 +777,49 @@ fn append_ast_list_item<'a>(
}
}
let mut content = Vec::new();
let mut emitted_child_blocks = false;
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks, attachment_paths),
NodeValue::Paragraph => {
let mut paragraph_blocks = Vec::new();
if append_paragraph_attachment_sequence(
child,
&mut paragraph_blocks,
attachment_paths,
) {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
blocks.extend(paragraph_blocks);
emitted_child_blocks = true;
} else {
content.extend(collect_inline_children(child));
}
}
_ => {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
append_ast_block(child, blocks, attachment_paths);
emitted_child_blocks = true;
}
}
}
if !content.is_empty() || !emitted_child_blocks {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
}
}
fn push_list_item_content_block(
blocks: &mut Vec<MarkdownBlock>,
ordered: bool,
is_task: bool,
checked: bool,
content: Vec<MarkdownInline>,
) {
if is_task {
blocks.push(MarkdownBlock::Todo { checked, content });
} else if ordered {
@@ -876,6 +965,39 @@ fn paragraph_attachment_media<'a>(
link_attachment_media(first, attachment_paths)
}
fn append_paragraph_attachment_sequence<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) -> bool {
let mut parsed = Vec::<MarkdownBlock>::new();
for child in node.children() {
match &child.data.borrow().value {
NodeValue::SoftBreak | NodeValue::LineBreak => continue,
NodeValue::Text(text) if text.as_ref().trim().is_empty() => continue,
NodeValue::Image(link) => parsed.push(MarkdownBlock::Image {
alt: collect_plain_text(child).trim().to_string(),
source_path: link.url.clone(),
}),
NodeValue::Link(_) => {
if let Some((name, source_path)) = link_attachment_media(child, attachment_paths) {
parsed.push(MarkdownBlock::Media { name, source_path });
} else if let Some((title, source_path)) = link_page_reference(child) {
parsed.push(MarkdownBlock::PageReference { title, source_path });
} else {
return false;
}
}
_ => return false,
}
}
if parsed.is_empty() {
return false;
}
blocks.extend(parsed);
true
}
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
@@ -982,6 +1104,9 @@ fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
let target = normalized_markdown_link_target(target)?;
if let Some(source_path) = local_markdown_relative_path_from_documents_target(target) {
return Some(page_reference_title_and_path(&source_path, label));
}
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
@@ -1004,20 +1129,59 @@ fn page_reference_from_target(target: &str, label: &str) -> Option<(String, Stri
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
let fallback_title = std::path::Path::new(path_part)
Some(page_reference_title_and_path(path_part, label))
}
fn page_reference_title_and_path(source_path: &str, label: &str) -> (String, String) {
let fallback_title = std::path::Path::new(source_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("页面")
.trim();
let title = label.trim();
Some((
(
if title.is_empty() {
fallback_title.to_string()
} else {
title.to_string()
},
path_part.to_string(),
))
source_path.to_string(),
)
}
fn local_markdown_relative_path_from_documents_target(target: &str) -> Option<String> {
let normalized = target.trim();
let path_and_query = if normalized.starts_with("/documents/") {
normalized
} else if let Some((_, rest)) = normalized.split_once("://") {
let slash = rest.find('/')?;
&rest[slash..]
} else {
return None;
};
let path_part = path_and_query
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(path_and_query)
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(path_and_query);
let segment = path_part.strip_prefix("/documents/")?;
let decoded_segment = percent_decode_lossy(segment);
let local_id = decoded_segment.strip_prefix("local-md:")?;
let relative_path = percent_decode_lossy(&local_id.replace('~', "%"))
.trim_start_matches('/')
.to_string();
if relative_path.is_empty() {
return None;
}
let extension = std::path::Path::new(&relative_path)
.extension()
.and_then(|value| value.to_str())?;
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
Some(relative_path)
}
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
@@ -1363,6 +1527,28 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_consecutive_attachment_links_parse_as_blocks() {
let blocks = markdown_to_blocks(
"[身份证](.assets/身份证_李爱波.pdf)\n[本科证书](.assets/本科证书证明.pdf)\n![照片](.assets/photo.png)\n",
);
let items = blocks.as_array().expect("blocks array");
assert_eq!(items.len(), 3);
assert_eq!(items[0]["type"].as_str(), Some("media"));
assert_eq!(
items[0]["props"]["sourcePath"].as_str(),
Some(".assets/身份证_李爱波.pdf")
);
assert_eq!(items[1]["type"].as_str(), Some("media"));
assert_eq!(
items[1]["props"]["sourcePath"].as_str(),
Some(".assets/本科证书证明.pdf")
);
assert_eq!(items[2]["type"].as_str(), Some("image"));
assert_eq!(items[2]["props"]["src"].as_str(), Some(".assets/photo.png"));
}
#[test]
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
@@ -1411,6 +1597,46 @@ mod tests {
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
}
#[test]
fn markdown_runtime_local_documents_url_parses_as_page_reference_block() {
let blocks = markdown_to_blocks(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
);
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("page_reference"));
assert_eq!(first["props"]["title"].as_str(), Some("爱斯特完结项目"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md")
);
}
#[test]
fn markdown_runtime_local_documents_url_is_not_attachment_ref() {
let root = std::env::temp_dir().join(format!(
"mnote-page-ref-attachment-filter-{}",
std::process::id()
));
let owner_dir = root.join("liaibo的个人空间/项目/完结项目");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
let owner = owner_dir.join("完结项目.md");
let refs = parse_markdown_attachment_refs(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert!(
refs.is_empty(),
"内部页面链接不能进入 attachmentRefs: {refs:?}"
);
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
+68
View File
@@ -42,6 +42,9 @@ mod tree;
mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
mod vault;
mod vault_path;
mod vault_store;
pub(crate) mod web_shell;
mod ws;
@@ -75,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/health", get(health::health))
.route("/", get(gateway::root_entry))
.route("/trash", get(gateway::trash_entry))
.route("/vault", get(gateway::vault_entry))
.route("/files", get(gateway::files_redirect_to_vault))
.route("/favicon.ico", get(gateway::favicon))
.route("/settings", get(gateway::settings_entry))
.route(
@@ -370,6 +375,64 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
get(web_shell::document_editor_adapter_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
get(web_shell::vault_workbench_runtime_asset),
)
.route("/api/vault/ensure", post(vault::ensure))
.route("/api/vault/reindex", post(vault::reindex))
.route("/api/vault/list", get(vault::list))
.route("/api/vault/items", post(vault::create_item))
.route(
"/api/vault/items/{id}",
get(vault::get_item)
.patch(vault::update_item)
.delete(vault::delete_item),
)
.route(
"/api/vault/items/{id}/restore",
post(vault::restore_item),
)
.route("/api/vault/items/{id}/purge", post(vault::purge_item))
.route(
"/api/vault/items/{id}/reveal",
post(vault::reveal_item),
)
.route(
"/api/vault/items/{id}/resolve",
post(vault::resolve_item),
)
.route(
"/api/vault/items/{id}/share-to-ai",
post(vault::share_to_ai),
)
.route(
"/api/vault/items/{id}/unshare-from-ai",
post(vault::unshare_from_ai),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
"/api/vault/ai/items/{id}/resolve",
post(vault::resolve_ai_item),
)
.route(
"/api/vault/ai/items/{id}/login",
post(vault::login_ai_item),
)
.route(
"/api/vault/ai/items/{id}/session",
post(vault::put_ai_session),
)
.route("/api/vault/cipher-book", get(vault::list_cipher_book))
.route(
"/api/vault/cipher-book/{key}",
put(vault::put_cipher_key).delete(vault::delete_cipher_key),
)
.route(
"/api/vault/cipher-book/{key}/reveal",
post(vault::reveal_cipher_key),
)
.route("/api/search/documents", post(search::documents))
.route(
"/api/search/local-index/refresh",
@@ -859,6 +922,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/documents/buffer-state/dirty",
post(documents::mark_buffer_dirty),
)
.route(
"/api/documents/buffer-state/dirty:0",
post(documents::mark_buffer_dirty),
)
.route("/api/documents/purge", post(documents::purge))
.route("/api/documents/empty-trash", post(documents::empty_trash))
.route("/api/documents/title", post(documents::title))
@@ -1624,6 +1691,7 @@ mod tests {
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
] {
let response = app(false)
.oneshot(
@@ -0,0 +1,43 @@
//! Pi Page AI 常量与 schema 名(产品口径,非 spike)。
pub(super) const PI_LAB_VERSION: &str = "1.0.0";
pub(super) const PI_LAB_PROVIDER: &str = "pi";
pub(super) const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
pub(super) const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
pub(super) const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
pub(super) const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
pub(super) const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
pub(super) const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
pub(super) const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
pub(super) const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
pub(super) const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
pub(super) const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
pub(super) const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
pub(super) const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
pub(super) const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
pub(super) const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
pub(super) const PI_LAB_MAX_SESSIONS: usize = 16;
pub(super) const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
pub(super) const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
pub(super) const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
pub(super) const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
pub(super) const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
pub(super) const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
pub(super) const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
pub(super) const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
pub(super) const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
pub(super) const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
@@ -0,0 +1,12 @@
//! Pi Rust Page AI route module.
//!
//! - [`constants`]:版本 / schema / profile 常量
//! - [`runtime`]HTTP handlers、RPC、session 热缓存与桥接
//!
//! 会话元数据权威源:Turso/libSQL control-plane。
//! 进程内 `HashMap` 仅为热缓存;重启后由 `hydrate_session_from_control_plane` 恢复元数据。
mod constants;
mod runtime;
pub use runtime::*;
@@ -1,7 +1,8 @@
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片
//! Pi Rust Page AI — MNote 托管的生产 Page AI 后端。
//!
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端OpenHub 与 Pi TS 已退役到 recycle 边界。
//! 默认启用`MNOTE_PAGE_AI_PI_LAB`OpenHub 与 Pi TS 已退役到 recycle 边界。
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
//! 会话元数据 / run / tool event 持久化到 Turso/libSQL control-plane;进程内表仅作热缓存。
use crate::app::AppState;
use crate::context::RequestContext;
@@ -35,52 +36,13 @@ use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex};
use tokio_stream::wrappers::BroadcastStream;
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
const PI_LAB_PROVIDER: &str = "pi";
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
const PI_LAB_MAX_SESSIONS: usize = 16;
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
use super::constants::*;
static PI_LAB_SESSIONS: LazyLock<StdMutex<HashMap<String, PiLabSession>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
/// 热缓存;权威查询走 control-plane `list_ai_tool_events` / `list_ai_file_patches`。
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
LazyLock::new(|| StdMutex::new(Vec::new()));
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
@@ -297,6 +259,9 @@ pub struct PiLabSendRequest {
pub folder_path: Option<String>,
pub context_refs: Option<Vec<String>>,
pub selected_context: Option<Value>,
/// S7: optional agent target package (mnote.agent_target_package.v1) from Page AI host.
#[serde(default)]
pub target_package: Option<Value>,
}
#[derive(Debug, Deserialize)]
@@ -787,6 +752,8 @@ fn resolve_file_path(
} else {
path.to_string()
};
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&relative_path)
.map_err(|error| error.with_context(context))?;
let target =
resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?;
return Ok((target, Some(root_uri.to_string()), Some(relative_path)));
@@ -800,6 +767,18 @@ fn resolve_file_path(
)
.with_context(context));
}
// Absolute-path tools: deny any path whose workspace-relative form hits vault.
let normalized_abs = path.replace('\\', "/");
if let Some(idx) = normalized_abs.find("/.mnote/vault") {
let tail = &normalized_abs[idx + 1..]; // drop leading '/'
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(tail)
.map_err(|error| error.with_context(context))?;
} else if crate::routes::vault_path::is_vault_sensitive_relative_path(&normalized_abs) {
return Err(crate::routes::vault_path::vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
)
.with_context(context));
}
let allowed = active_allowed_roots(state, context)?;
let target = canonical_or_parent(&requested);
let allowed_root = allowed
@@ -1545,11 +1524,19 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
| "mnote.reference.open"
| "mnote.vault.list"
| "mnote.vault.get"
| "mnote.tool_receipt.write" => "allow".into(),
// plan: no secret resolve/login
_ => "deny".into(),
},
Some("auto_edit") => match tool_name {
"mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(),
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
"mnote.codex_rescue.request" => "ask".into(),
_ => base_policy.into(),
},
@@ -1564,12 +1551,52 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
"mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => {
"ask".into()
}
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => match tool_name {
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => base_policy.into(),
}
}
/// Strip secret values before control-plane receipt persistence.
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
if tool_name != "mnote.vault.resolve"
&& tool_name != "mnote.vault.login"
&& tool_name != "mnote.vault.session"
{
return payload.clone();
}
let mut safe = payload.clone();
if let Some(obj) = safe.as_object_mut() {
if obj.contains_key("value") {
obj.insert("value".into(), Value::String("[redacted]".into()));
}
if obj.contains_key("cookieHeader") {
obj.insert("cookieHeader".into(), Value::String("[redacted]".into()));
}
if let Some(secret) = obj.get_mut("secret") {
if let Some(s) = secret.as_object_mut() {
if s.contains_key("value") {
s.insert("value".into(), Value::String("[redacted]".into()));
}
}
}
obj.insert("receiptRedacted".into(), Value::Bool(true));
}
safe
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
@@ -2321,6 +2348,36 @@ fn pi_lab_tool_definitions() -> Vec<PiLabToolDefinition> {
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_list",
mnote_name: "mnote.vault.list",
label: "MNote AI vault list",
description: "List credentials in the shared AI password book (L0 metadata only, no secret plaintext). Multi-agent single credential pool. Optional status=active|deleted. Do NOT use generic file tools on .mnote/vault.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_get",
mnote_name: "mnote.vault.get",
label: "MNote AI vault get",
description: "Get one AI password-book item by id (secrets masked). Params: id. Use mnote.vault.resolve to obtain password/apikey/token plaintext for automation.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_resolve",
mnote_name: "mnote.vault.resolve",
label: "MNote AI vault resolve",
description: "Resolve a secret field from the shared AI password book with cipher-book expansion. Params: id, field=password|apikey|token. Prefer mnote.vault.login for site login (session reuse). Do not paste value into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_login",
mnote_name: "mnote.vault.login",
label: "MNote AI vault login",
description: "ONE-SHOT multi-agent login for AI password book. Params: id, optional forceRefresh. Reuses saved session if fresh; otherwise api_first password login and saves session. If Cloudflare/captcha: returns human_required — human uses chrome-bridge/Paseo browser then mnote.vault.session. Prefer this over list+resolve+browser for logins. Do not paste cookieHeader/password into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_session",
mnote_name: "mnote.vault.session",
label: "MNote AI vault session write-back",
description: "Write browser-captured cookies into AI password book after human Cloudflare/captcha login. Params: id, cookieHeader, optional expiresAt, source=human_bridge|browser. Next mnote.vault.login will reuse them.",
},
PiLabToolDefinition {
pi_name: "mnote_tool_receipt_write",
mnote_name: "mnote.tool_receipt.write",
@@ -3028,6 +3085,96 @@ fn publish_event(session_id: &str, kind: &str, payload: Value) {
let _ = PI_LAB_EVENT_TX.send(event);
}
fn status_from_persisted(status: &str) -> PiLabSessionStatus {
match status {
"turn_running" => PiLabSessionStatus::Idle,
"runtime_running" => PiLabSessionStatus::Idle,
"aborted" => PiLabSessionStatus::Aborted,
"error" => PiLabSessionStatus::Error,
_ => PiLabSessionStatus::Idle,
}
}
/// 从 control-plane 恢复会话元数据到热缓存(不自动拉起子进程)。
fn hydrate_session_from_control_plane(
state: &AppState,
user_id: &str,
session_id: &str,
) -> Result<Option<PiLabSession>, WebError> {
if session_id.trim().is_empty() || is_pi_lab_warmup_session_id(session_id) {
return Ok(None);
}
if get_session(session_id).is_some() {
return Ok(get_session(session_id));
}
let run_id = pi_run_id(session_id);
let Some(run) = state
.control_plane()
.find_ai_runtime_run(user_id, &run_id)
.map_err(|e| WebError::internal(format!("hydrate Pi session 失败: {e}")))?
else {
return Ok(None);
};
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Ok(None);
}
if run.user_id != user_id {
return Ok(None);
}
let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
let str_field = |key: &str| -> Option<String> {
runtime
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let now = now_ms();
let session = PiLabSession {
session_id: run.session_id.clone(),
mnote_user_id: run.user_id.clone(),
bridge_token: generate_bridge_token(),
status: status_from_persisted(&run.status),
provider_session_id: str_field("providerSessionId")
.unwrap_or_else(|| run.session_id.clone()),
pi_session_dir: str_field("piSessionDir").unwrap_or_default(),
pi_session_file: str_field("piSessionFile"),
root_uri: str_field("rootUri"),
workspace_id: run
.workspace_id
.clone()
.or_else(|| str_field("workspaceId")),
page_path: run.document_id.clone().or_else(|| str_field("pagePath")),
page_title: run.title.clone().or_else(|| str_field("pageTitle")),
model_provider: str_field("modelProvider"),
model_id: str_field("modelId"),
thinking_level: str_field("thinkingLevel"),
allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(),
runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(),
runtime_pid: None,
runtime_mode: str_field("runtimeMode").unwrap_or_else(|| "real".to_string()),
runtime_error: None,
created_at_ms: now,
updated_at_ms: now,
message_count: runtime
.get("messageCount")
.and_then(Value::as_u64)
.unwrap_or(0),
};
if session.pi_session_dir.trim().is_empty() {
return Ok(None);
}
upsert_session(session.clone());
let _ = persist_append_event(
state,
&session,
"session_hydrated",
&json!({"source": "control_plane", "runId": run.run_id}),
);
Ok(Some(session))
}
fn upsert_session(session: PiLabSession) {
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
sessions.insert(session.session_id.clone(), session);
@@ -3219,11 +3366,25 @@ fn get_session_for_context(
context: &RequestContext,
session_id: &str,
) -> Result<PiLabSession, WebError> {
let session = get_session(session_id).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
})?;
ensure_session_owner(state, context, &session)?;
Ok(session)
if let Some(session) = get_session(session_id) {
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
// S6: process restart / cold cache — hydrate metadata from control-plane
// so send/events/status can resume the same sessionId without a full start.
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
if let Ok(Some(session)) =
hydrate_session_from_control_plane(state, actor_id, session_id)
{
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
}
Err(WebError::bad_request_code(
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在",
))
}
fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> {
@@ -4668,12 +4829,153 @@ impl PiLabToolFacade {
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
"mnote.reference.open",
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
"mnote.codex_rescue.request",
"mnote.tool_receipt.write"
],
}))
}
fn vault_list(&self, params: Value) -> Result<Value, WebError> {
let status = params
.get("status")
.and_then(Value::as_str)
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
crate::routes::vault::list_ai_vault_items(status)
}
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
})?;
crate::routes::vault::get_ai_vault_item(&id)
}
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.resolve 需要 id",
)
})?;
let field = string_param(&params, "field").ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_field_required",
"mnote.vault.resolve 需要 field=password|apikey|token",
)
})?;
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 resolve 需要登录会话",
));
}
crate::routes::vault::resolve_ai_vault_secret(
&id,
&field,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_login(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.login 需要 id",
)
})?;
let force = bool_param(&params, "forceRefresh")
.or_else(|| bool_param(&params, "force_refresh"))
.unwrap_or(false);
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 login 需要登录会话",
));
}
crate::routes::vault::login_ai_vault_credential(
&id,
force,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_session(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.session 需要 id",
)
})?;
let cookie = string_param(&params, "cookieHeader")
.or_else(|| string_param(&params, "cookie_header"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_cookie_required",
"mnote.vault.session 需要 cookieHeader",
)
})?;
let expires = string_param(&params, "expiresAt")
.or_else(|| string_param(&params, "expires_at"));
let source = string_param(&params, "source").unwrap_or_else(|| "human_bridge".into());
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 session 需要登录会话",
));
}
crate::routes::vault::put_ai_vault_session(
&id,
&cookie,
expires.as_deref(),
&source,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) = resolve_file_path(
&self.state,
@@ -5205,6 +5507,11 @@ async fn execute_tool(
}
"mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await,
"mnote.reference.open" => facade.reference_open(params.clone()).await,
"mnote.vault.list" => facade.vault_list(params.clone()),
"mnote.vault.get" => facade.vault_get(params.clone()),
"mnote.vault.resolve" => facade.vault_resolve(params.clone()),
"mnote.vault.login" => facade.vault_login(params.clone()),
"mnote.vault.session" => facade.vault_session(params.clone()),
"mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await,
"mnote.tool_receipt.write" => Ok(json!({
"requestedReceipt": params,
@@ -5286,7 +5593,14 @@ async fn execute_tool(
before_file_version.clone(),
after_file_version.clone(),
);
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
// Never persist vault secret values into control-plane receipts / journals.
let receipt_safe_payload = redact_vault_tool_payload(&tool_name, &payload);
let receipt_payload = write_receipt(
&facade.state,
receipt,
&receipt_safe_payload,
citation_count,
);
let elapsed_ms = now_ms().saturating_sub(started) as u64;
if let Some(session) = session.as_ref() {
let tool_event_id = receipt_payload
@@ -5467,6 +5781,17 @@ pub async fn status(
let runtime_error = current_session
.as_ref()
.and_then(|session| session.runtime_error.clone());
// S1: status defaults must match effective AI policy (management surface truth),
// not only env hardcodes. Frontend checkStatus previously overwrote effective
// defaults with these fields.
let effective_policy =
ai_settings::load_effective_ai_runtime_policy(&state, &actor_id, None);
let (status_default_provider, status_default_model_id) = match effective_policy
.resolve_requested_model(None, None)
{
Ok(resolved) => (resolved.provider, resolved.model_id),
Err(_) => (default_model_provider(), default_model_id()),
};
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
@@ -5480,8 +5805,9 @@ pub async fn status(
"runtimeAvailable": runtime_available,
"runtimeInstallHint": runtime_install_hint,
"runtimeError": runtime_error,
"defaultModelProvider": default_model_provider(),
"defaultModelId": default_model_id(),
"defaultModelProvider": status_default_provider,
"defaultModelId": status_default_model_id,
"defaultModel": format!("{status_default_provider}/{status_default_model_id}"),
"defaultThinkingLevel": default_thinking_level(),
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
"advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty),
@@ -5590,6 +5916,7 @@ pub async fn bootstrap(
folder_path: None,
context_refs: None,
selected_context: None,
target_package: None,
}),
)
.await?
@@ -5614,6 +5941,9 @@ pub async fn start(
let mut requested_session = requested_session;
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
if let Some(existing_session_id) = request.session_id.as_deref() {
if get_session(existing_session_id).is_none() {
let _ = hydrate_session_from_control_plane(&state, &actor_id, existing_session_id)?;
}
if let Some(mut existing_session) = get_session(existing_session_id) {
ensure_session_owner(&state, &context, &existing_session)?;
if session_runtime_is_usable(&existing_session)
@@ -5888,6 +6218,8 @@ pub async fn send(
"folderPath": request.folder_path,
"contextRefs": request.context_refs,
"selectedContext": request.selected_context,
// S7: host-built agent target package (optional; client also enforces dirty gate).
"targetPackage": request.target_package,
},
});
if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) {
@@ -9463,7 +9795,7 @@ mod tests {
#[test]
fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() {
let source = include_str!("page_ai_pi.rs");
let source = include_str!("runtime.rs");
assert!(!source.contains(".arg(\"--no-prompt-templates\")"));
assert!(!source.contains(".arg(\"--no-context-files\")"));
}
@@ -9836,6 +10168,129 @@ mod tests {
assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed");
}
/// S1: admin/user AI policy defaultModel must apply on the next Pi start when
/// the client does not pass modelProvider/modelId (management surface is truth).
#[tokio::test]
async fn start_without_model_uses_effective_default_and_picks_up_policy_change() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
let state = test_state();
let actor_id = "pi_default_model_s1_user";
let root = temp_root("mnote-pi-default-model-s1-root");
let root_uri = grant_directory(&state, actor_id, &root, "write");
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-fast",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let app = build_app(state.clone());
let (status_a, payload_a) = request_json(
app.clone(),
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_a",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_a, StatusCode::OK, "start A: {payload_a}");
assert_eq!(payload_a["session"]["modelProvider"], "omniroute");
assert_eq!(payload_a["session"]["modelId"], "pi-fast");
assert_eq!(
payload_a["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-fast"
);
// Simulate management-surface defaultModel change (same control-plane policy).
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let (status_b, payload_b) = request_json(
app,
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_b",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_b, StatusCode::OK, "start B: {payload_b}");
assert_eq!(payload_b["session"]["modelProvider"], "omniroute");
assert_eq!(
payload_b["session"]["modelId"],
"pi-reason",
"next start without explicit model must pick up new defaultModel"
);
assert_eq!(
payload_b["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-reason"
);
}
/// S1: /status defaultModel* must mirror effective policy, not only env hardcodes.
#[tokio::test]
async fn status_default_model_matches_effective_ai_policy() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
// Env hardcode must not win over policy when policy is present.
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL", "env-stale-model");
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER", "omniroute");
let state = test_state();
let actor_id = "pi_status_default_s1_user";
// Policy FK requires the user row first (grant_directory also upserts user).
let _root = grant_directory(
&state,
actor_id,
&temp_root("mnote-pi-status-default-s1-root"),
"read",
);
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-reason", "omniroute/pi-fast"]
}),
);
let app = build_app(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/status")
.header("x-mnote-actor-id", actor_id)
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["defaultModelProvider"], "omniroute");
assert_eq!(
payload["defaultModelId"], "pi-reason",
"status must not report env-stale default when policy exists: {payload}"
);
assert_eq!(payload["defaultModel"], "omniroute/pi-reason");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER");
}
#[test]
fn hydrate_session_mcp_cache_copies_shared_cache() {
let root = temp_root("mnote-pi-shared-mcp-cache-root");
+78 -9
View File
@@ -421,6 +421,41 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default()
}
/// Map a page-tree row's storage relative path to the directory scope used for lazy expand.
/// - Nested bundle `Folder/Folder.md` → `Folder`
/// - Sibling page `Folder.md` (+ `Folder/`) → `Folder`
/// - Page-group / directory row → itself
pub(crate) fn page_tree_expand_relative_path(relative_path: &str) -> String {
let rp = relative_path
.trim()
.trim_matches('/')
.replace('\\', "/");
if rp.is_empty() {
return String::new();
}
let lower = rp.to_ascii_lowercase();
if !lower.ends_with(".md") {
return rp;
}
let stem = &rp[..rp.len().saturating_sub(3)];
if let Some((parent, file)) = rp.rsplit_once('/') {
let file_stem = file
.strip_suffix(".md")
.or_else(|| file.strip_suffix(".MD"))
.or_else(|| file.strip_suffix(".Md"))
.unwrap_or(file);
let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent);
if parent_name == file_stem {
// Nested bundle: markdown lives inside a same-named folder.
return parent.to_string();
}
// Sibling page under a parent directory.
return stem.to_string();
}
// Root-level sibling page.
stem.to_string()
}
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
let mut rows: Vec<PageTreeRenderRow> = projection
.get("items")
@@ -441,6 +476,47 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
if row_kind != "document" {
return None;
}
let expandable = item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
});
let relative_path = item
.get("expandRelativePath")
.or_else(|| item.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.get("workspacePath"))
.and_then(|path| path.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.pointer("/extra/source/relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
let expand_relative_path = if expandable {
relative_path
.as_deref()
.map(page_tree_expand_relative_path)
.filter(|value| !value.is_empty())
} else {
None
};
Some(PageTreeRenderRow {
node_id: node_id.to_string(),
parent_node_id: item
@@ -458,15 +534,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.unwrap_or("无标题")
.to_string(),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
}),
expandable,
expanded: item
.get("expandedByDefault")
.and_then(Value::as_bool)
@@ -480,6 +548,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.filter(|value| !value.is_empty())
.is_some()
|| !node_id.starts_with("local-dir:"),
expand_relative_path,
})
})
.collect()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
//! Shared vault path deny predicate for general file surfaces.
//!
//! Any workspace-relative path under `.mnote/vault` must be rejected on
//! open/stat/resource/local_file surfaces. Vault content is only reachable
//! via `/api/vault/*`.
use crate::error::WebError;
use axum::http::StatusCode;
/// Workspace-root-relative path that points at the password vault system space.
///
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments
/// (`..`) are rejected before classification (callers usually already do this).
pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/");
while value.starts_with("./") {
value = value[2..].to_string();
}
value = value.trim_start_matches('/').to_string();
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
}
/// Returns true when `rel` is `.mnote/vault` or any path under it.
pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
let n = normalize_workspace_relative_path(rel);
if n.is_empty() {
return false;
}
if n
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
// Escape / empty segments are not vault matches; callers reject escape.
return false;
}
n == ".mnote/vault" || n.starts_with(".mnote/vault/")
}
/// 403 with stable code for general file surfaces that hit vault paths.
pub fn vault_path_denied_error(message: impl Into<String>) -> WebError {
WebError::new(
StatusCode::FORBIDDEN,
"vault_path_denied",
message.into(),
)
}
/// Reject vault-relative paths before reading or writing general local files.
pub fn deny_if_vault_sensitive_relative_path(rel: &str) -> Result<(), WebError> {
if is_vault_sensitive_relative_path(rel) {
return Err(vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_vault_root_and_children() {
assert!(is_vault_sensitive_relative_path(".mnote/vault"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/entries/x.md"));
assert!(is_vault_sensitive_relative_path(
"./.mnote/vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote\\vault\\entries\\x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/trash/entries/x.md"
));
}
#[test]
fn allows_non_vault_paths() {
assert!(!is_vault_sensitive_relative_path(""));
assert!(!is_vault_sensitive_relative_path("notes/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/trash/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/index/search-index.json"));
assert!(!is_vault_sensitive_relative_path("mnote/vault/x.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/vault-backup/x.md"));
assert!(!is_vault_sensitive_relative_path("个人/密码/a.md"));
}
#[test]
fn deny_helper_returns_stable_code() {
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/entries/x.md")
.expect_err("must deny");
assert_eq!(err.code(), "vault_path_denied");
assert_eq!(err.status(), StatusCode::FORBIDDEN);
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
}
File diff suppressed because it is too large Load Diff
+57 -10
View File
@@ -11,8 +11,9 @@ use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
@@ -21,12 +22,12 @@ use crate::routes::snapshot_support::{
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput,
render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
WorkspaceShellProjection,
apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html,
render_workspace_shell_sidebar_html, WorkspaceShellProjection,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
@@ -261,6 +262,7 @@ pub async fn document_page_shell(
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id));
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
@@ -300,6 +302,10 @@ pub async fn document_page_shell(
);
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context(
primary_source_kind,
primary_root_uri,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -308,6 +314,7 @@ pub async fn document_page_shell(
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
breadcrumb_html={breadcrumb_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
@@ -318,6 +325,7 @@ pub async fn document_page_shell(
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
@@ -766,6 +774,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
let saving = false;
const saveTitle = async () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
const title = input.value.trim() || '';
const currentTarget = resolveTitleTarget(input);
autosize(input);
@@ -820,6 +834,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
};
input.addEventListener('input', () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
autosize(input);
setStatus(input, (input.value.trim() || '') === readLastSavedTitle() ? 'saved' : 'dirty');
});
@@ -3103,6 +3123,20 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn vault_workbench_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/vault-workbench-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
@@ -3603,9 +3637,13 @@ pub(crate) fn render_local_sidebar_tree_html_scoped(
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
Ok(render_local_sidebar_tree_html_from_snapshot(
&snapshot,
@@ -3633,6 +3671,12 @@ pub(crate) fn render_local_file_tree_html(
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}
/// Fast path for home SSR: keep FileTree shell structure without scanning the folder.
/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`.
pub(crate) fn render_local_file_tree_pending_shell_html() -> String {
render_filetree_pending_shell_html()
}
pub(crate) fn render_local_file_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
@@ -5320,8 +5364,10 @@ mod tests {
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(session_runtime.contains("if (!response.ok) {"));
assert!(session_runtime.contains(
"if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))"
));
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
assert!(session_runtime.contains("targetSession.views.size === 0"));
@@ -5707,7 +5753,8 @@ mod tests {
assert!(runtime.contains("legacyInlineContentToTiptap"));
assert!(runtime.contains("legacyStylesToTiptapMarks"));
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
assert!(runtime.contains("const sourcePath = firstNonEmptyText("));
assert!(runtime.contains("block?.props?.sourcePath"));
assert!(runtime.contains("marks.push({ type: 'bold' })"));
assert!(runtime.contains("marks.push({ type: 'italic' })"));
assert!(runtime.contains("marks.push({ type: 'underline' })"));
@@ -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;
}
}
@@ -129,6 +129,14 @@ fn render_filetree_row(
html.push_str("</li>");
}
/// Placeholder FileTree shell for shell-first home SSR.
/// Client hydrates rows via `/api/tree/projections/file` without blocking first paint.
pub fn render_filetree_pending_shell_html() -> String {
String::from(
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#,
)
}
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
@@ -306,4 +314,13 @@ mod tests {
assert!(html.contains(r#"data-index-status="indexed""#));
assert!(html.contains(r#"<button type="button" class="tree-link""#));
}
#[test]
fn filetree_pending_shell_marks_ssr_pending_for_client_hydrate() {
let html = super::render_filetree_pending_shell_html();
assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#));
assert!(html.contains(r#"data-filetree-ssr="pending""#));
assert!(html.contains(r#"aria-busy="true""#));
assert!(!html.contains("tree-row"));
}
}
@@ -10,6 +10,8 @@ pub struct PageTreeRenderRow {
pub expandable: bool,
pub expanded: bool,
pub openable: bool,
/// Local-folder relative path used for lazy children fetch (directory scope).
pub expand_relative_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -105,7 +107,7 @@ fn render_page_row(
.unwrap_or(false);
let toggle_html = if row.expandable {
format!(
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}">{marker}</button>"#,
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}" data-expanded="{expanded_state}">{marker}</button>"#,
node_id = escape_html(&row.node_id),
label = if expanded { "折叠" } else { "展开" },
title = escape_html(&row.title),
@@ -115,21 +117,29 @@ fn render_page_row(
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
};
let parent_attr = input
let source_row = input
.rows
.iter()
.find(|source| source.node_id == row.node_id)
.find(|source| source.node_id == row.node_id);
let parent_attr = source_row
.and_then(|source| source.parent_node_id.as_deref())
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let expand_path_attr = source_row
.and_then(|source| source.expand_relative_path.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|path| format!(r#" data-local-relative-path="{}""#, escape_html(path)))
.unwrap_or_default();
let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth);
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-page-openable="{openable}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}" data-page-openable="{openable}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr}{expand_path_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-page-openable="{openable}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}" data-page-openable="{openable}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = render_depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
test_id = row.test_id,
parent_attr = parent_attr,
expand_path_attr = expand_path_attr,
depth = render_depth,
active = active,
selected = active,
@@ -140,18 +150,22 @@ fn render_page_row(
toggle_html = toggle_html,
title = escape_html(&row.title),
));
// Always emit a children container for expandable rows so lazy expand can
// inject scope rows without a second full-tree snapshot (Sidex-style).
if row.expandable {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(if expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children {
render_page_row(html, child, children_by_parent, input);
}
html.push_str("</ul>");
let children = children_by_parent
.get(&Some(row.node_id.clone()))
.map(Vec::as_slice)
.unwrap_or(&[]);
html.push_str(if expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children {
render_page_row(html, child, children_by_parent, input);
}
html.push_str("</ul>");
}
html.push_str("</li>");
}
@@ -210,6 +224,7 @@ mod tests {
expandable: true,
expanded: false,
openable: true,
expand_relative_path: Some("home".into()),
},
PageTreeRenderRow {
node_id: "page_child".into(),
@@ -219,6 +234,7 @@ mod tests {
expandable: false,
expanded: false,
openable: true,
expand_relative_path: None,
},
]);
@@ -242,6 +258,7 @@ mod tests {
expandable: true,
expanded: true,
openable: true,
expand_relative_path: Some("home".into()),
},
PageTreeRenderRow {
node_id: "page_child".into(),
@@ -251,6 +268,7 @@ mod tests {
expandable: false,
expanded: false,
openable: true,
expand_relative_path: None,
},
],
active_node_id: Some("page_root".into()),
@@ -266,9 +284,31 @@ mod tests {
assert!(html.contains("data-rust-action=\"create\""));
assert!(html.contains("draggable=\"true\""));
assert!(html.contains("tree-children"));
assert!(html.contains(r#"data-local-relative-path="home""#));
assert!(html.contains("子页"));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-active=\"true\""));
assert!(html.contains("data-focused=\"true\""));
}
#[test]
fn tree_shell_page_renderer_emits_empty_children_container_for_lazy_expandable() {
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: vec![PageTreeRenderRow {
node_id: "page_lazy".into(),
parent_node_id: None,
title: "可展开".into(),
depth: 0,
expandable: true,
expanded: false,
openable: true,
expand_relative_path: Some("docs".into()),
}],
active_node_id: None,
focused_node_id: None,
});
assert!(html.contains("tree-children--collapsed"));
assert!(html.contains(r#"data-local-relative-path="docs""#));
assert!(html.contains("data-rust-action=\"toggle\""));
}
}
+116 -1
View File
@@ -46,6 +46,64 @@ pub struct WorkspaceShellEntry {
pub icon: String,
}
/// 使用页面树投影生成顶栏页面祖先链。
pub fn render_page_breadcrumb_html(
projection: &WorkspaceShellProjection,
active_page_id: Option<&str>,
) -> String {
let Some(active_page_id) = active_page_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return String::new();
};
let by_id = projection
.my_page_items
.iter()
.map(|item| (item.id.as_str(), item))
.collect::<std::collections::HashMap<_, _>>();
let mut chain = Vec::new();
let mut cursor = Some(active_page_id);
let mut seen = std::collections::HashSet::new();
while let Some(id) = cursor {
if !seen.insert(id) {
break;
}
let Some(item) = by_id.get(id) else {
break;
};
chain.push(*item);
cursor = item.parent_id.as_deref();
if chain.len() >= 64 {
break;
}
}
chain.reverse();
chain
.iter()
.enumerate()
.map(|(index, item)| {
let separator = if index == 0 {
String::new()
} else {
r#"<span class="wolai-breadcrumb-separator" aria-hidden="true"></span>"#.to_string()
};
let title = escape_html(&item.title);
let id = escape_html(&item.id);
if index + 1 == chain.len() {
format!(
r#"{separator}<span class="wolai-breadcrumb-current" data-breadcrumb-document-id="{id}"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{title}</span></span>"#
)
} else {
format!(
r#"{separator}<a class="wolai-breadcrumb-link" data-breadcrumb-document-id="{id}" href="{}">{title}</a>"#,
escape_html(&item.href)
)
}
})
.collect()
}
pub fn build_workspace_shell_projection(
dataset: &Value,
workspace_id: &str,
@@ -553,6 +611,32 @@ mod tests {
.any(|entry| entry.label == "模板中心"));
}
#[test]
fn page_breadcrumb_renders_all_ancestors_as_links() {
let projection = build_workspace_shell_projection(
&json!({
"workspaces": [{ "id": "ws_demo", "name": "我的空间" }],
"documents": [
{ "id": "root", "workspace_id": "ws_demo", "title": "个人", "parent_id": null },
{ "id": "parent", "workspace_id": "ws_demo", "title": "密码", "parent_id": "root" },
{ "id": "current", "workspace_id": "ws_demo", "title": "deepseek ai", "parent_id": "parent" }
]
}),
"ws_demo",
Some("current"),
"我的空间",
);
let html = render_page_breadcrumb_html(&projection, Some("current"));
assert!(html.contains(r#"data-breadcrumb-document-id="root""#));
assert!(html.contains(r#"data-breadcrumb-document-id="parent""#));
assert!(html.contains(r#"data-breadcrumb-document-id="current""#));
assert!(html.contains(r#"href="/documents/root?workspaceId=ws_demo""#));
assert!(html.contains(r#"href="/documents/parent?workspaceId=ws_demo""#));
assert!(html.contains("deepseek ai"));
assert_eq!(html.matches("wolai-breadcrumb-separator").count(), 2);
}
#[test]
fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() {
let dataset = json!({
@@ -652,6 +736,7 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#));
assert!(html.contains(r#"id="sidebar-tree-root""#));
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(!html.contains(r#"data-filetree-ssr="pending""#));
assert!(!html.contains("wolai-file-tree-section"));
}
@@ -687,6 +772,27 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
}
#[test]
fn workspace_shell_sidebar_html_propagates_pending_filetree_shell_marker() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": []
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(
&projection,
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#),
None,
None,
);
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(html.contains(r#"data-filetree-ssr="pending""#));
assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#));
assert!(html.contains(r#"aria-busy="true""#));
}
#[test]
fn workspace_shell_sidebar_html_marks_filetree_scope() {
let dataset = json!({
@@ -805,8 +911,17 @@ pub fn render_workspace_shell_sidebar_html(
)
})
.unwrap_or_default();
// Propagate shell-first pending marker onto the FileTree root so the client
// can hydrate without scanning for nested placeholders.
let pending_attr = if html.contains("data-filetree-ssr=\"pending\"")
|| html.contains("data-rust-filetree-renderer=\"pending_shell_v1\"")
{
r#" data-filetree-ssr="pending" aria-busy="true""#
} else {
""
};
format!(
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}{pending_attr}>{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})
@@ -1,7 +1,8 @@
use crate::editor_runtime::{
block_hover_state::{BlockMenuLayout, HoveredBlockState},
block_transform::{
apply_text_align, run_block_turn_into_action, run_block_turn_into_folded_heading_action,
apply_highlight_color, apply_text_align, apply_text_color, clear_text_color,
run_block_turn_into_action, run_block_turn_into_folded_heading_action,
run_block_turn_into_page_action, top_level_block_is_page, top_level_block_matches_action,
top_level_block_range,
},
@@ -19,9 +20,9 @@ use crate::{
};
use leptos::prelude::*;
use leptos_tiptap::{TiptapEditorHandle, TiptapSelectionState, TiptapTextAlign};
use serde_json::Value;
use serde_json::{json, Value};
use wasm_bindgen_futures::spawn_local;
use web_sys::{MouseEvent, WheelEvent};
use web_sys::{window, CustomEvent, CustomEventInit, MouseEvent, WheelEvent};
#[derive(Clone, Copy)]
struct FoldedHeadingAction {
@@ -58,6 +59,167 @@ const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [
},
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg(test)]
enum BlockMenuSecondarySurface {
InlineFeedback,
Dialog,
Submenu,
}
#[derive(Clone, Copy)]
#[cfg(test)]
struct BlockMenuSecondaryActionSpec {
test_id: &'static str,
surface: BlockMenuSecondarySurface,
}
#[cfg(test)]
const fn block_menu_secondary_action_specs() -> [BlockMenuSecondaryActionSpec; 5] {
[
BlockMenuSecondaryActionSpec {
test_id: "block-drag-menu-item-copy-link",
surface: BlockMenuSecondarySurface::InlineFeedback,
},
BlockMenuSecondaryActionSpec {
test_id: "block-drag-menu-item-move",
surface: BlockMenuSecondarySurface::Dialog,
},
BlockMenuSecondaryActionSpec {
test_id: "block-drag-menu-item-history",
surface: BlockMenuSecondarySurface::Dialog,
},
BlockMenuSecondaryActionSpec {
test_id: "block-drag-menu-item-comment",
surface: BlockMenuSecondarySurface::Dialog,
},
BlockMenuSecondaryActionSpec {
test_id: "block-drag-menu-item-color",
surface: BlockMenuSecondarySurface::Submenu,
},
]
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockAuxPanel {
Move,
History,
Comment,
}
#[derive(Clone, Copy)]
struct BlockColorOption {
id: &'static str,
label: &'static str,
value: &'static str,
}
const BLOCK_TEXT_COLORS: [BlockColorOption; 5] = [
BlockColorOption {
id: "default",
label: "默认",
value: "#37352f",
},
BlockColorOption {
id: "gray",
label: "灰色",
value: "#787774",
},
BlockColorOption {
id: "red",
label: "红色",
value: "#e03e3e",
},
BlockColorOption {
id: "blue",
label: "蓝色",
value: "#337ea9",
},
BlockColorOption {
id: "green",
label: "绿色",
value: "#448361",
},
];
const BLOCK_HIGHLIGHT_COLORS: [BlockColorOption; 5] = [
BlockColorOption {
id: "gray",
label: "灰底",
value: "#f1f1ef",
},
BlockColorOption {
id: "red",
label: "红底",
value: "#fdebec",
},
BlockColorOption {
id: "yellow",
label: "黄底",
value: "#fbf3db",
},
BlockColorOption {
id: "blue",
label: "蓝底",
value: "#e7f3f8",
},
BlockColorOption {
id: "green",
label: "绿底",
value: "#edf3ec",
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secondary_block_menu_entries_are_real_surfaces_not_placeholder_feedback() {
let specs = block_menu_secondary_action_specs();
assert_eq!(
specs
.iter()
.find(|spec| spec.test_id == "block-drag-menu-item-copy-link")
.unwrap()
.surface,
BlockMenuSecondarySurface::InlineFeedback
);
assert_eq!(
specs
.iter()
.find(|spec| spec.test_id == "block-drag-menu-item-move")
.unwrap()
.surface,
BlockMenuSecondarySurface::Dialog
);
assert_eq!(
specs
.iter()
.find(|spec| spec.test_id == "block-drag-menu-item-history")
.unwrap()
.surface,
BlockMenuSecondarySurface::Dialog
);
assert_eq!(
specs
.iter()
.find(|spec| spec.test_id == "block-drag-menu-item-comment")
.unwrap()
.surface,
BlockMenuSecondarySurface::Dialog
);
assert_eq!(
specs
.iter()
.find(|spec| spec.test_id == "block-drag-menu-item-color")
.unwrap()
.surface,
BlockMenuSecondarySurface::Submenu
);
}
}
pub(crate) struct BlockHandleMenuViewProps {
pub(crate) editor: TiptapEditorHandle,
pub(crate) block: HoveredBlockState,
@@ -115,6 +277,106 @@ fn close_block_transform_menus(signals: BlockHandleMenuSignals) {
signals.set_block_folded_title_open.set(false);
}
fn dispatch_mnote_toast(message: &str, kind: &str) {
let Some(win) = window() else {
return;
};
let Ok(detail) = serde_wasm_bindgen::to_value(&json!({
"message": message,
"kind": kind,
})) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail);
if let Ok(event) = CustomEvent::new_with_event_init_dict("mnote:toast", &init) {
let _ = win.dispatch_event(&event);
}
}
fn set_current_block_selection(
editor: TiptapEditorHandle,
block_index: usize,
) -> Result<(), String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))
}
fn persist_block_menu_command(
editor: TiptapEditorHandle,
signals: BlockHandleMenuSignals,
success_message: &str,
) {
let (html, snapshot, json_text) = read_editor_snapshot(editor);
signals.set_dirty_count.update(|count| *count += 1);
signals.set_html_output.set(html.clone());
signals.set_document_json.set(snapshot.clone());
signals.set_json_output.set(json_text);
match persist_document_state(
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
&signals.title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => signals
.set_command_feedback
.set(format!("{success_message},并已写入本地草稿")),
Err(err) => signals
.set_command_feedback
.set(format!("{success_message},但本地保存失败:{err}")),
}
}
fn handle_block_text_color_click(
editor: TiptapEditorHandle,
block_index: usize,
value: &'static str,
signals: BlockHandleMenuSignals,
) {
let result = set_current_block_selection(editor, block_index)
.and_then(|_| apply_text_color(editor, value).map(str::to_string));
match result {
Ok(message) => persist_block_menu_command(editor, signals, &message),
Err(err) => signals.set_command_feedback.set(err),
}
}
fn handle_block_highlight_color_click(
editor: TiptapEditorHandle,
block_index: usize,
value: &'static str,
signals: BlockHandleMenuSignals,
) {
let result = set_current_block_selection(editor, block_index)
.and_then(|_| apply_highlight_color(editor, Some(value)).map(str::to_string));
match result {
Ok(message) => persist_block_menu_command(editor, signals, &message),
Err(err) => signals.set_command_feedback.set(err),
}
}
fn handle_block_clear_color_click(
editor: TiptapEditorHandle,
block_index: usize,
signals: BlockHandleMenuSignals,
) {
let result = set_current_block_selection(editor, block_index).and_then(|_| {
let message = clear_text_color(editor)?;
let _ = apply_highlight_color(editor, None)?;
Ok(message.to_string())
});
match result {
Ok(message) => persist_block_menu_command(editor, signals, &message),
Err(err) => signals.set_command_feedback.set(err),
}
}
fn handle_turn_into_action_click(
editor: TiptapEditorHandle,
block_index: usize,
@@ -358,6 +620,8 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
let block_index = block.index;
let menu_top = format!("{}px", menu_layout.top);
let menu_bottom = "auto";
let (block_aux_panel, set_block_aux_panel) = signal::<Option<BlockAuxPanel>>(None);
let (block_color_open, set_block_color_open) = signal(false);
view! {
<div
@@ -509,16 +773,18 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
data-testid="block-drag-menu-item-copy-link"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_aux_panel.set(None);
set_block_color_open.set(false);
let Some(block_id) = runtime_block_id_from_index(block_index) else {
signals
.set_command_feedback
.set("当前块缺少 Rust block id,无法复制锚点链接".to_string());
let message = "当前块缺少 Rust block id,无法复制锚点链接";
signals.set_command_feedback.set(message.to_string());
dispatch_mnote_toast(message, "warning");
return;
};
let Some(anchor_url) = current_page_anchor_url(&block_id) else {
signals
.set_command_feedback
.set("当前页面 URL 不可用,无法复制锚点链接".to_string());
let message = "当前页面 URL 不可用,无法复制锚点链接";
signals.set_command_feedback.set(message.to_string());
dispatch_mnote_toast(message, "warning");
return;
};
if let Some(block_element) = editor_block_by_id(&block_id) {
@@ -527,12 +793,14 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
spawn_local(async move {
match write_mnote_text_to_clipboard(anchor_url.clone()).await {
Ok(value) if value.as_bool().unwrap_or(false) => {
signals.set_command_feedback.set(format!("已复制块链接:{}", block_id));
let message = format!("已复制块链接:{}", block_id);
signals.set_command_feedback.set(message.clone());
dispatch_mnote_toast(&message, "success");
}
_ => {
signals
.set_command_feedback
.set(format!("块链接已生成但剪贴板写入失败:{}", anchor_url));
let message = format!("块链接已生成但剪贴板写入失败:{}", anchor_url);
signals.set_command_feedback.set(message.clone());
dispatch_mnote_toast(&message, "warning");
}
}
});
@@ -547,9 +815,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
data-testid="block-drag-menu-item-move"
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals
.set_command_feedback
.set("移动/嵌入流程后续接 Page Aggregate 命令".to_string());
set_block_color_open.set(false);
set_block_aux_panel.set(Some(BlockAuxPanel::Move));
signals.set_command_feedback.set("已打开移动/嵌入面板".to_string());
}
>
<span class="block-drag-menu-icon">{material_icon("arrow_outward")}</span>
@@ -561,9 +829,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
data-testid="block-drag-menu-item-history"
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals
.set_command_feedback
.set("块历史入口已记录,恢复流程后续接版本链路".to_string());
set_block_color_open.set(false);
set_block_aux_panel.set(Some(BlockAuxPanel::History));
signals.set_command_feedback.set("已打开块历史面板".to_string());
}
>
<span class="block-drag-menu-icon">{material_icon("history")}</span>
@@ -574,7 +842,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
data-testid="block-drag-menu-item-comment"
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals.set_command_feedback.set("评论入口已打开".to_string());
set_block_color_open.set(false);
set_block_aux_panel.set(Some(BlockAuxPanel::Comment));
signals.set_command_feedback.set("已打开评论面板".to_string());
}
>
<span class="block-drag-menu-icon">{material_icon("chat")}</span>
@@ -585,15 +855,13 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
class="block-drag-menu-item"
data-testid="block-drag-menu-item-color"
on:mouseenter=move |_| {
signals
.set_command_feedback
.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
set_block_aux_panel.set(None);
set_block_color_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals
.set_command_feedback
.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
set_block_aux_panel.set(None);
set_block_color_open.update(|open| *open = !*open);
}
>
<span class="block-drag-menu-icon">{material_icon("palette")}</span>
@@ -603,17 +871,13 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-align-center"
data-active=move || signals.selection_state.get().align_center.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_aux_panel.set(None);
set_block_color_open.set(false);
let result = (|| -> Result<(), String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
set_current_block_selection(editor, block_index)?;
apply_text_align(editor, TiptapTextAlign::Center)?;
let (html, snapshot, json_text) = read_editor_snapshot(editor);
signals.set_dirty_count.update(|count| *count += 1);
@@ -662,6 +926,112 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In
().into_any()
}
}}
{move || {
if block_color_open.get() {
view! {
<div
class="block-drag-submenu block-color-panel"
data-testid="block-color-panel"
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<div class="toolbar-popover-section">
<div class="toolbar-popover-title">"文字颜色"</div>
<div class="toolbar-color-grid">
{BLOCK_TEXT_COLORS.iter().map(|option| {
let value = option.value;
let label = option.label;
let testid = format!("block-text-color-{}", option.id);
view! {
<button
class="toolbar-menu-item toolbar-color-item"
data-testid=testid
on:click=move |_| {
handle_block_text_color_click(editor, block_index, value, signals);
set_block_color_open.set(false);
}
>
<span class="toolbar-color-chip" style:background=value></span>
<strong>{label}</strong>
</button>
}
}).collect_view()}
</div>
</div>
<div class="toolbar-popover-section">
<div class="toolbar-popover-title">"背景颜色"</div>
<div class="toolbar-color-grid">
{BLOCK_HIGHLIGHT_COLORS.iter().map(|option| {
let value = option.value;
let label = option.label;
let testid = format!("block-highlight-color-{}", option.id);
view! {
<button
class="toolbar-menu-item toolbar-color-item"
data-testid=testid
on:click=move |_| {
handle_block_highlight_color_click(editor, block_index, value, signals);
set_block_color_open.set(false);
}
>
<span class="toolbar-color-chip" style:background=value></span>
<strong>{label}</strong>
</button>
}
}).collect_view()}
</div>
</div>
<div class="toolbar-popover-section">
<button
class="toolbar-menu-item"
data-testid="block-clear-color"
on:click=move |_| {
handle_block_clear_color_click(editor, block_index, signals);
set_block_color_open.set(false);
}
>
<strong>"清除颜色"</strong>
<span>"移除当前块文字色与背景色"</span>
</button>
</div>
</div>
}.into_any()
} else {
().into_any()
}
}}
{move || {
match block_aux_panel.get() {
Some(BlockAuxPanel::Move) => view! {
<div class="block-drag-aux-panel" data-testid="block-move-panel" role="dialog" aria-label="移动或嵌入当前块">
<strong>"移动/嵌入到"</strong>
<span>"当前 MVP 仅开放块内编辑;跨页移动和嵌入将接入 Page Aggregate 命令。"</span>
<button class="block-drag-menu-item" disabled=true>"选择目标页面(开发中)"</button>
</div>
}.into_any(),
Some(BlockAuxPanel::History) => view! {
<div class="block-drag-aux-panel" data-testid="block-history-panel" role="dialog" aria-label="块历史">
<strong>"块历史"</strong>
<span>"当前块尚未接入版本链路;这里先显示明确面板,避免误判为点击失效。"</span>
</div>
}.into_any(),
Some(BlockAuxPanel::Comment) => view! {
<div class="block-drag-aux-panel" data-testid="block-comment-panel" role="dialog" aria-label="评论">
<strong>"评论"</strong>
<span>"评论数据层尚未开放写入;后续会在这里显示评论线程。"</span>
<button class="block-drag-menu-item" disabled=true>"添加评论(开发中)"</button>
</div>
}.into_any(),
None => ().into_any(),
}
}}
</div>
</div>
}
@@ -10,6 +10,7 @@ use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{window, CustomEvent, CustomEventInit, Node, RequestInit, RequestMode, Response};
use crate::editor_root_element;
use crate::editor_runtime::attachment_upload::dispatch_editor_upload_request;
use crate::editor_runtime::block_hover_state::HoveredBlockState;
use crate::editor_runtime::block_menu_document::{
@@ -18,7 +19,6 @@ use crate::editor_runtime::block_menu_document::{
use crate::editor_runtime::command_sync::sync_persisted_editor_command;
use crate::editor_runtime::persistence::persisted_document_identity;
use crate::editor_runtime::slash_actions::SlashActionKind;
use crate::editor_root_element;
pub(crate) fn prosemirror_node_size(node: &Value) -> Option<u32> {
match node.get("type").and_then(Value::as_str) {
@@ -282,9 +282,10 @@ pub(crate) fn run_slash_action(
let _ = editor.focus();
editor.insert_table(4, 3, false)
}
SlashActionKind::Mindmap => {
editor.insert_content(TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), None)
}
SlashActionKind::Mindmap => editor.insert_content(
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
),
SlashActionKind::Image => {
dispatch_editor_upload_request("image", "image/*")?;
return Ok("已打开图片上传");
@@ -441,10 +442,7 @@ pub(crate) fn run_block_turn_into_folded_heading_action(
})
}
pub(crate) fn top_level_block_is_page(
editor: TiptapEditorHandle,
block_index: usize,
) -> bool {
pub(crate) fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool {
editor
.get_json()
.ok()
@@ -3,18 +3,26 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub(crate) const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
pub(crate) const RUNTIME_VERSION: &str = "1.1.0";
pub(crate) const RUNTIME_NAME: &str = "mnote-tiptap-island-runtime";
pub(crate) const RUNTIME_VERSION: &str = "1.2.0";
pub(crate) const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
pub(crate) const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
/// 稳定产品事件前缀(Phase 3);spike 前缀短期双发兼容。
pub(crate) const STABLE_EVENT_PREFIX: &str = "mnote:tiptap-island";
pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
pub(crate) const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
pub(crate) const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
pub(crate) const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready";
pub(crate) const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change";
pub(crate) const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
pub(crate) const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
pub(crate) const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
pub(crate) const STABLE_COMMAND_EVENT: &str = "mnote:tiptap-island:command";
pub(crate) const STABLE_READY_EVENT: &str = "mnote:tiptap-island:ready";
pub(crate) const STABLE_CHANGE_EVENT: &str = "mnote:tiptap-island:change";
pub(crate) const STABLE_STATE_EVENT: &str = "mnote:tiptap-island:state";
pub(crate) const STABLE_STATUS_EVENT: &str = "mnote:tiptap-island:status";
pub(crate) const STABLE_SELECTION_EVENT: &str = "mnote:tiptap-island:selection";
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -14,8 +14,8 @@
//! - B7: 块菜单按钮(popover trigger + focus
//! - B8: WASM gzipped 增量验证(本模块自身极小,不引入新 dep)
use leptos::prelude::*;
use leptos::ev;
use leptos::prelude::*;
/// 按钮语义变体
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -20,8 +20,8 @@ use std::collections::HashMap;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{CustomEvent, CustomEventInit, EventTarget, HtmlElement, HtmlInputElement};
use crate::editor_runtime::mount::next_mount_id;
use crate::editor_runtime::icons::material_icon;
use crate::editor_runtime::mount::next_mount_id;
thread_local! {
static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell<HashMap<u32, MountedMindmapShell>> = std::cell::RefCell::new(HashMap::new());
@@ -1,22 +1,22 @@
pub(crate) mod attachment_links;
pub(crate) mod attachment_upload;
pub(crate) mod block_dnd;
pub(crate) mod button;
pub(crate) mod block_hover_state;
pub(crate) mod icons;
pub(crate) mod block_handle_menu_view;
pub(crate) mod block_hover_state;
pub(crate) mod block_menu_document;
pub(crate) mod block_menu_overlay;
pub(crate) mod block_transform;
pub(crate) mod bridge_dispatch;
pub(crate) mod bridge_events;
pub(crate) mod button;
pub(crate) mod command_sync;
pub(crate) mod dismiss;
pub(crate) mod content_layout;
pub(crate) mod dismiss;
pub(crate) mod dom_events;
pub(crate) mod dom_selection;
pub(crate) mod editor_focus;
pub(crate) mod history_safe_commands;
pub(crate) mod icons;
pub(crate) mod mindmap_node_view;
pub(crate) mod mount;
pub(crate) mod overlays;
@@ -74,11 +74,8 @@ pub(crate) fn next_mount_id() -> u32 {
pub(crate) fn set_runtime_mount_context(context: Option<(u32, EventTarget, RuntimeDeliveryMode)>) {
RUNTIME_MOUNT_CONTEXT.with(|cell| {
*cell.borrow_mut() = context.map(|(id, target, mode)| RuntimeMountContext {
id,
target,
mode,
});
*cell.borrow_mut() =
context.map(|(id, target, mode)| RuntimeMountContext { id, target, mode });
});
}
@@ -622,7 +622,6 @@ pub(crate) fn try_close_block_menu_overlays(
true
}
/// 在 overlay 打开时注册 focus-trap 与 dismiss 层,在关闭时自动清理。
/// 这是 B7/B8 的 radix 化收口:将手写的 Escape / click-outside / tab 循环统一到此函数。
///
@@ -13,7 +13,9 @@ use crate::editor_runtime::bridge_dispatch::HostCommandKind;
use crate::editor_runtime::bridge_events::{
BridgeEnvelope, ChangePayload, HostCommandEnvelope, HostStatusPayload, ReadyPayload,
StatePayload, CHANGE_EVENT, COMMAND_EVENT, EVENT_PREFIX, PROTOCOL, READY_EVENT, RUNTIME_NAME,
RUNTIME_VERSION, SELECTION_EVENT, STATE_EVENT, STATUS_EVENT,
RUNTIME_VERSION, SELECTION_EVENT, STABLE_CHANGE_EVENT, STABLE_COMMAND_EVENT,
STABLE_READY_EVENT, STABLE_SELECTION_EVENT, STABLE_STATE_EVENT,
STABLE_STATUS_EVENT, STATE_EVENT, STATUS_EVENT,
};
use crate::editor_runtime::dom_selection::SelectionPayload;
@@ -100,22 +102,27 @@ where
pub(crate) fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) {
dispatch_custom_event_to_target(target, READY_EVENT, payload);
dispatch_custom_event_to_target(target, STABLE_READY_EVENT, payload);
}
pub(crate) fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) {
dispatch_custom_event_to_target(target, CHANGE_EVENT, payload);
dispatch_custom_event_to_target(target, STABLE_CHANGE_EVENT, payload);
}
pub(crate) fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) {
dispatch_custom_event_to_target(target, STATE_EVENT, payload);
dispatch_custom_event_to_target(target, STABLE_STATE_EVENT, payload);
}
pub(crate) fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) {
dispatch_custom_event_to_target(target, STATUS_EVENT, payload);
dispatch_custom_event_to_target(target, STABLE_STATUS_EVENT, payload);
}
pub(crate) fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) {
dispatch_custom_event_to_target(target, SELECTION_EVENT, payload);
dispatch_custom_event_to_target(target, STABLE_SELECTION_EVENT, payload);
}
pub(crate) fn register_unmount_handle<M: Any + leptos::prelude::Mountable + 'static>(
@@ -1321,6 +1321,34 @@ pub(crate) const SPIKE_STYLE: &str = r#"
pointer-events: auto;
}
.block-drag-aux-panel {
position: absolute;
left: calc(100% + 8px);
top: 112px;
width: 236px;
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
z-index: 132;
color: #37352f;
pointer-events: auto;
}
.block-drag-aux-panel > strong {
font-size: 13px;
line-height: 1.35;
}
.block-drag-aux-panel > span {
color: #6b7280;
font-size: 12px;
line-height: 1.45;
}
.block-drag-menu-item[disabled] {
color: #a1a1aa;
cursor: default;
@@ -2637,11 +2665,28 @@ pub(crate) const SPIKE_STYLE: &str = r#"
border-top: 1px solid rgba(148, 163, 184, 0.26);
}
.block-handle-shell,
.block-drop-indicator {
display: none;
}
.block-handle-shell {
left: 4px;
width: 44px;
min-height: 44px;
padding: 2px;
}
.block-handle-insert {
display: none;
}
.block-handle-trigger {
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
}
.app-shell-embedded .editor-surface {
padding-left: 0;
}
+8 -6
View File
@@ -2338,12 +2338,14 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
});
});
let preview_mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some() {
event.prevent_default();
let _ = set_image_preview_pan.try_set(None);
}
});
let preview_mouseup_handle =
window_event_listener(ev::mouseup, move |event: MouseEvent| {
if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some()
{
event.prevent_default();
let _ = set_image_preview_pan.try_set(None);
}
});
on_cleanup(move || {
drop(keydown_handle);
drop(preview_mousemove_handle);