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

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
@@ -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