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:
@@ -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
Reference in New Issue
Block a user