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:
@@ -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`
|
||||
@@ -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
@@ -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 是 RAGFlow;WeKnora/LightRAG 仅作为 legacy fallback。",
|
||||
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAG;WeKnora/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 是 RAGFlow,WeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAG;WeKnora/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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `&` 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)
|
||||
|
||||
@@ -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\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!(
|
||||
|
||||
@@ -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::*;
|
||||
+508
-53
@@ -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(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "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(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.resolve 需要 id",
|
||||
)
|
||||
})?;
|
||||
let field = string_param(¶ms, "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(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.login 需要 id",
|
||||
)
|
||||
})?;
|
||||
let force = bool_param(¶ms, "forceRefresh")
|
||||
.or_else(|| bool_param(¶ms, "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(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.session 需要 id",
|
||||
)
|
||||
})?;
|
||||
let cookie = string_param(¶ms, "cookieHeader")
|
||||
.or_else(|| string_param(¶ms, "cookie_header"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_cookie_required",
|
||||
"mnote.vault.session 需要 cookieHeader",
|
||||
)
|
||||
})?;
|
||||
let expires = string_param(¶ms, "expiresAt")
|
||||
.or_else(|| string_param(¶ms, "expires_at"));
|
||||
let source = string_param(¶ms, "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");
|
||||
@@ -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
@@ -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 roots(directory_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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {"));
|
||||
|
||||
@@ -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("首页 <安全>"));
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user