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

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

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

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