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.
2677 lines
122 KiB
JavaScript
2677 lines
122 KiB
JavaScript
export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|
const {
|
|
activeSidebarTreeMode,
|
|
cssEscape,
|
|
currentDocumentId,
|
|
currentFileTreeActiveRowId,
|
|
currentRootUri,
|
|
currentSourceKind,
|
|
currentWorkspaceId,
|
|
escapeHtml,
|
|
fileTreeRuntimeFunction,
|
|
localFilePathFromAssetId,
|
|
navigateToDocument,
|
|
refreshEditorLocalAttachmentExistence,
|
|
restoreSidebarTreeTab,
|
|
rowTitle,
|
|
schedulePendingLocalFolderRestoreFocus,
|
|
shortMindmapFileName,
|
|
syncSidebarFileTreeSelection,
|
|
} = dependencies;
|
|
var fileTreeViewState = {
|
|
rootUri: '',
|
|
scope: '',
|
|
expandedParents: new Set(),
|
|
selectedRowIds: new Set(),
|
|
focusedRowId: '',
|
|
activeRowId: '',
|
|
scrollTop: 0,
|
|
rowsByParent: new Map(),
|
|
loadedParents: new Set(),
|
|
loadingParents: new Map(),
|
|
dirtyParents: new Set(),
|
|
staleParents: new Set(),
|
|
revisionByParent: new Map(),
|
|
requestGeneration: 0,
|
|
latestGenerationByParent: new Map()
|
|
};
|
|
var fileTreeState = fileTreeViewState;
|
|
var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;
|
|
var pageTreeState = {
|
|
pageTreeRequestGeneration: 0
|
|
};
|
|
var fileTreeLazyCacheRootUri = '';
|
|
var fileTreeExpansionStorageRootUri = '';
|
|
var fileTreeExpansionRestoreTimer = 0;
|
|
var fileTreeVisibleHydrateTimer = 0;
|
|
var fileTreeVisibleHydrateQueued = false;
|
|
var fileTreeCommandBatchRefreshParents = new Map();
|
|
var fileTreeRevealRequests = new Map();
|
|
var FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX = 20;
|
|
var FILETREE_VISIBLE_EXPANDED_HYDRATE_BATCH = 2;
|
|
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
|
|
var SIDEBAR_TREE_VIEW_STATE_KEY = 'mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}';
|
|
var SIDEBAR_TREE_VIEW_STATE_API_KEY = 'sidebarTreeViewState.v1';
|
|
var SIDEBAR_TREE_VIEW_STATE_SAVE_DELAY_MS = 500;
|
|
var sidebarTreeViewStates = new Map();
|
|
var sidebarTreeViewStateRequests = new Map();
|
|
var sidebarTreeViewStateSaveTimers = new Map();
|
|
|
|
function updateTitleEverywhere(documentId, title) {
|
|
if (!documentId) return;
|
|
var escaped = cssEscape(documentId);
|
|
var escapedDocRowId = cssEscape('doc:' + documentId);
|
|
var isCurrentDocument = currentDocumentId() === documentId;
|
|
var pageSelectors = [
|
|
'.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
|
'.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title',
|
|
'a[href="/documents/' + escaped + '"] > .wolai-row-title',
|
|
'a[href^="/documents/' + escaped + '?"] > .wolai-row-title'
|
|
];
|
|
pageSelectors.forEach(function(selector) {
|
|
document.querySelectorAll(selector).forEach(function(node) {
|
|
if (node instanceof HTMLElement) node.textContent = title;
|
|
});
|
|
});
|
|
document.querySelectorAll('.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title').forEach(function(node) {
|
|
if (node instanceof HTMLElement) node.textContent = fileTreePageTitle(title);
|
|
});
|
|
document.querySelectorAll('[data-page-title-input="true"][data-document-id="' + escaped + '"]').forEach(function(node) {
|
|
if (!(node instanceof HTMLTextAreaElement)) return;
|
|
node.value = title;
|
|
node.setAttribute('data-title-last-saved', title);
|
|
node.setAttribute('data-title-save-status', 'saved');
|
|
node.style.height = 'auto';
|
|
node.style.height = Math.max(48, node.scrollHeight) + 'px';
|
|
});
|
|
document.querySelectorAll('[data-document-pane="true"][data-pane-document-id="' + escaped + '"] [data-page-title-current="true"]').forEach(function(node) {
|
|
if (node instanceof HTMLElement) node.textContent = title;
|
|
});
|
|
if (isCurrentDocument) {
|
|
document.title = title;
|
|
var topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
|
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
|
}
|
|
}
|
|
|
|
function fileTreePageTitle(title) {
|
|
var normalized = String(title || '无标题').trim() || '无标题';
|
|
return normalized.endsWith('.md') ? normalized : normalized + '.md';
|
|
}
|
|
|
|
function isFileTreePageRow(row) {
|
|
if (!(row instanceof HTMLElement)) return false;
|
|
if (row.getAttribute('data-asset-id')) return false;
|
|
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
|
|
if (documentId.indexOf('local-md:') === 0) return true;
|
|
var rowKind = row.getAttribute('data-row-kind') || '';
|
|
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'index' || rowKind === 'markdown';
|
|
}
|
|
|
|
function normalizeFileTreePageRenameTitle(value) {
|
|
var normalized = String(value || '').trim();
|
|
if (/\.md$/i.test(normalized)) normalized = normalized.slice(0, -3).trim();
|
|
return normalized;
|
|
}
|
|
|
|
function validateFileTreeRename(row, draft) {
|
|
var raw = String(draft || '').trim();
|
|
if (!raw) return '名称不能为空';
|
|
if (/[\\/:*?"<>|]/.test(raw)) return '文件名不能包含 / \\ : * ? " < > |';
|
|
if (!isFileTreePageRow(row)) return '';
|
|
var pageTitle = normalizeFileTreePageRenameTitle(raw);
|
|
if (!pageTitle) return '名称不能为空';
|
|
var expectedFileName = fileTreePageTitle(pageTitle).toLocaleLowerCase();
|
|
var rowId = row.getAttribute('data-row-id') || '';
|
|
var parentId = row.getAttribute('data-parent-id') || '';
|
|
var siblings = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="document"], #sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="doc"], #sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="markdown"]'));
|
|
var duplicate = siblings.some(function(sibling) {
|
|
if (!(sibling instanceof HTMLElement)) return false;
|
|
if ((sibling.getAttribute('data-row-id') || '') === rowId) return false;
|
|
if ((sibling.getAttribute('data-parent-id') || '') !== parentId) return false;
|
|
return fileTreePageTitle(normalizeFileTreePageRenameTitle(rowTitle(sibling))).toLocaleLowerCase() === expectedFileName;
|
|
});
|
|
return duplicate ? '同级已存在同名页面' : '';
|
|
}
|
|
|
|
function documentIdFromDelta(data) {
|
|
return String(data && (data.documentId || data.id || (data.document && data.document.id) || (data.node && data.node.id) || (data.args && data.args.documentId)) || '').trim();
|
|
}
|
|
|
|
function parentIdFromDelta(data) {
|
|
if (!data || typeof data !== 'object') return null;
|
|
if (Object.prototype.hasOwnProperty.call(data, 'parentId')) return data.parentId ? String(data.parentId).trim() : null;
|
|
if (Object.prototype.hasOwnProperty.call(data, 'parent_id')) return data.parent_id ? String(data.parent_id).trim() : null;
|
|
if (data.args && Object.prototype.hasOwnProperty.call(data.args, 'parentId')) return data.args.parentId ? String(data.args.parentId).trim() : null;
|
|
return null;
|
|
}
|
|
|
|
function treeRootForMode(mode) {
|
|
var id = mode === 'filetree' ? 'sidebar-file-tree-root' : 'sidebar-tree-root';
|
|
return document.querySelector('#' + id + ' .tree-root');
|
|
}
|
|
|
|
function rowSelectorForDocument(mode, documentId) {
|
|
var escaped = cssEscape(documentId);
|
|
if (mode === 'filetree') {
|
|
return '.tree-row[data-shell-mode="filetree"][data-doc-id="' + escaped + '"], .tree-row[data-shell-mode="filetree"][data-document-id="' + escaped + '"]';
|
|
}
|
|
return '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"]';
|
|
}
|
|
|
|
function ensureTreeChildren(parentRow) {
|
|
var parentNode = parentRow ? parentRow.closest('.tree-node') : null;
|
|
if (!parentNode) return null;
|
|
var children = parentNode.querySelector(':scope > .tree-children');
|
|
if (!children) {
|
|
children = document.createElement('ul');
|
|
children.className = 'tree-children';
|
|
parentNode.appendChild(children);
|
|
}
|
|
children.classList.remove('tree-children--collapsed');
|
|
parentRow.setAttribute('aria-expanded', 'true');
|
|
var toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
|
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
|
return children;
|
|
}
|
|
|
|
function removeDocumentRowForMode(mode, documentId) {
|
|
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
|
var node = row ? row.closest('.tree-node') : null;
|
|
if (!node || !node.parentElement) return false;
|
|
node.parentElement.removeChild(node);
|
|
return true;
|
|
}
|
|
|
|
function commandDocumentId(result, fallback) {
|
|
var value = result && (
|
|
(result.execution && (result.execution.documentId || result.execution.id || result.execution.nodeId)) ||
|
|
result.documentId ||
|
|
result.id ||
|
|
result.nodeId ||
|
|
(result.document && (result.document.id || result.document.documentId)) ||
|
|
(result.node && (result.node.id || result.node.documentId)) ||
|
|
(result.payload && result.payload.documentId)
|
|
);
|
|
return String(value || fallback || '').trim();
|
|
}
|
|
|
|
function commandDocumentTitle(result, fallback) {
|
|
var value = result && (
|
|
(result.execution && result.execution.title) ||
|
|
result.title ||
|
|
(result.document && result.document.title) ||
|
|
(result.node && result.node.title) ||
|
|
(result.payload && result.payload.title)
|
|
);
|
|
return String(value || fallback || '新页面').trim() || '新页面';
|
|
}
|
|
|
|
function normalizeDocumentRowId(value) {
|
|
return String(value || '').trim().replace(/^doc:/, '').replace(/^index:/, '');
|
|
}
|
|
|
|
function appendRenderedTreeNode(container, html) {
|
|
if (!container || !html) return false;
|
|
var empty = container.querySelector(':scope > .tree-empty');
|
|
if (empty && empty.parentElement) empty.parentElement.removeChild(empty);
|
|
var template = document.createElement('template');
|
|
template.innerHTML = html;
|
|
var node = template.content.firstElementChild;
|
|
if (!node) return false;
|
|
container.appendChild(node);
|
|
return true;
|
|
}
|
|
|
|
function localPageInsertDepth(parentId) {
|
|
if (!parentId) return 0;
|
|
var parentRow = document.querySelector(rowSelectorForDocument('page', parentId));
|
|
var depth = parentRow ? Number(parentRow.getAttribute('data-depth') || 0) : 0;
|
|
return Number.isFinite(depth) ? depth + 1 : 1;
|
|
}
|
|
|
|
function upsertPageDocumentRow(documentId, parentId, title) {
|
|
if (!documentId) return false;
|
|
var existing = document.querySelector(rowSelectorForDocument('page', documentId));
|
|
if (existing instanceof HTMLElement) {
|
|
updateTitleEverywhere(documentId, title);
|
|
moveDocumentRowForMode('page', documentId, parentId || null);
|
|
return true;
|
|
}
|
|
var root = treeRootForMode('page');
|
|
if (!root) return false;
|
|
var targetContainer = root;
|
|
if (parentId) {
|
|
var parentRow = document.querySelector(rowSelectorForDocument('page', parentId));
|
|
targetContainer = ensureTreeChildren(parentRow);
|
|
if (!targetContainer) targetContainer = root;
|
|
}
|
|
var grouped = new Map();
|
|
grouped.set(parentId || '', [{
|
|
id: documentId,
|
|
nodeId: documentId,
|
|
documentId: documentId,
|
|
parentNodeId: parentId || '',
|
|
rowKind: 'document',
|
|
title: title,
|
|
expandable: false,
|
|
childCount: 0
|
|
}]);
|
|
return appendRenderedTreeNode(targetContainer, renderPageRows(parentId || '', grouped, currentDocumentId(), localPageInsertDepth(parentId)));
|
|
}
|
|
|
|
function fileTreeParentContainer(parentId) {
|
|
var root = treeRootForMode('filetree');
|
|
if (!root) return null;
|
|
if (!parentId) return root;
|
|
var parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + parentId) + '"]');
|
|
var children = ensureTreeChildren(parentRow);
|
|
return children || root;
|
|
}
|
|
|
|
function localFileInsertDepth(parentId) {
|
|
if (!parentId) return 0;
|
|
var parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + parentId) + '"]');
|
|
var level = parentRow ? Number(parentRow.getAttribute('aria-level') || 1) : 1;
|
|
return Number.isFinite(level) ? level : 1;
|
|
}
|
|
|
|
function upsertFileDocumentRows(documentId, parentId, title) {
|
|
if (!documentId) return false;
|
|
var existing = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]');
|
|
if (existing instanceof HTMLElement) {
|
|
updateTitleEverywhere(documentId, title);
|
|
moveDocumentRowForMode('filetree', documentId, parentId || null);
|
|
return true;
|
|
}
|
|
var targetContainer = fileTreeParentContainer(parentId);
|
|
if (!targetContainer) return false;
|
|
var parentNodeId = parentId ? 'doc:' + parentId : '';
|
|
var docNodeId = 'doc:' + documentId;
|
|
var depth = localFileInsertDepth(parentId);
|
|
var grouped = new Map();
|
|
grouped.set(parentNodeId, [{
|
|
id: docNodeId,
|
|
nodeId: docNodeId,
|
|
rowId: docNodeId,
|
|
rowKind: 'document',
|
|
title: fileTreePageTitle(title),
|
|
documentId: documentId,
|
|
parentNodeId: parentNodeId,
|
|
depth: depth,
|
|
expandable: false,
|
|
childCount: 0
|
|
}]);
|
|
return appendRenderedTreeNode(targetContainer, renderFileRows(parentNodeId, grouped, currentDocumentId()));
|
|
}
|
|
|
|
function applyCreatedDocumentLocally(result, parentId, fallbackTitle) {
|
|
var documentId = commandDocumentId(result, null);
|
|
if (!documentId) return false;
|
|
var normalizedParentId = normalizeDocumentRowId(parentId);
|
|
var title = commandDocumentTitle(result, fallbackTitle);
|
|
var pageChanged = upsertPageDocumentRow(documentId, normalizedParentId, title);
|
|
var fileChanged = upsertFileDocumentRows(documentId, normalizedParentId, title);
|
|
if (pageChanged || fileChanged) {
|
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'create');
|
|
}
|
|
return pageChanged || fileChanged;
|
|
}
|
|
|
|
function localCommandNeedsProjectionRefresh(action, result) {
|
|
if (currentSourceKind() !== 'local_folder') return false;
|
|
if (action === 'create' || action === 'rename' || action === 'move' || action === 'delete' || action === 'archive' || action === 'trash' || action === 'purge') {
|
|
return true;
|
|
}
|
|
if (result && (result.previousDocumentId || result.previousRelativePath || result.relativePath || result.trashPath)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function parentRelativePathForPath(relativePath) {
|
|
var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, '');
|
|
if (!normalized || normalized === '.') return '';
|
|
var index = normalized.lastIndexOf('/');
|
|
return index > 0 ? normalized.slice(0, index) : '';
|
|
}
|
|
|
|
function addCommandRefreshParent(parents, value) {
|
|
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
|
if (normalized === '.') normalized = '';
|
|
parents.add(normalized);
|
|
}
|
|
|
|
function watchBatchPathIsMarkdown(item) {
|
|
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim().toLowerCase();
|
|
return relativePath.endsWith('.md') || relativePath.endsWith('.markdown');
|
|
}
|
|
|
|
function watchBatchEventKind(item) {
|
|
return String(item && (item.kind || item.eventKind || item.event_kind) || '').trim();
|
|
}
|
|
|
|
function watchBatchNeedsPageTreeRefresh(item) {
|
|
if (!watchBatchPathIsMarkdown(item)) return false;
|
|
var kind = watchBatchEventKind(item);
|
|
return kind.indexOf('Create') >= 0
|
|
|| kind.indexOf('Remove') >= 0
|
|
|| kind.indexOf('Modify(Name') >= 0
|
|
|| kind.indexOf('Rename') >= 0;
|
|
}
|
|
|
|
function addAffectedParentsFromCommandResult(parents, result) {
|
|
var affectedParents = Array.isArray(result && result.affectedParents)
|
|
? result.affectedParents
|
|
: Array.isArray(result && result.execution && result.execution.affectedParents)
|
|
? result.execution.affectedParents
|
|
: null;
|
|
if (!affectedParents) return false;
|
|
var before = parents.size;
|
|
affectedParents.forEach(function(parent) {
|
|
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
|
});
|
|
return parents.size > before;
|
|
}
|
|
|
|
function collectFileTreeRefreshParentsForCommand(result, body) {
|
|
var parents = new Set();
|
|
if (!addAffectedParentsFromCommandResult(parents, result)) {
|
|
document.documentElement.setAttribute('data-mnote-filetree-command-refresh-fallback', 'legacy-path-fields');
|
|
var values = [
|
|
result && (result.parentRelativePath || result.parent_relative_path),
|
|
result && result.execution && (result.execution.parentRelativePath || result.execution.parent_relative_path),
|
|
result && (result.relativePath || result.relative_path),
|
|
result && result.execution && (result.execution.relativePath || result.execution.relative_path),
|
|
result && (result.previousRelativePath || result.previous_relative_path),
|
|
result && result.execution && (result.execution.previousRelativePath || result.execution.previous_relative_path),
|
|
body && (body.parentRelativePath || body.parent_relative_path)
|
|
];
|
|
values.forEach(function(value) {
|
|
var normalized = String(value || '').trim();
|
|
if (!normalized) return;
|
|
if (normalized.indexOf('/') >= 0 || /\.[^/]+$/.test(normalized)) addCommandRefreshParent(parents, parentRelativePathForPath(normalized));
|
|
else addCommandRefreshParent(parents, normalized);
|
|
});
|
|
}
|
|
if (!parents.size) addCommandRefreshParent(parents, currentFileTreeScope());
|
|
return parents;
|
|
}
|
|
|
|
function fileTreeRefreshParentsForCommand(result, body) {
|
|
var parents = collectFileTreeRefreshParentsForCommand(result, body);
|
|
return Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
|
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
|
setTreeLiveApplyError(error && error.message ? error.message : '文件树局部刷新失败');
|
|
return false;
|
|
});
|
|
}));
|
|
}
|
|
|
|
function queueFileTreeBatchRefresh(batchId, result, body) {
|
|
var normalizedBatchId = String(batchId || '').trim();
|
|
if (!normalizedBatchId) return false;
|
|
var batchParents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
|
|
if (!batchParents) {
|
|
batchParents = new Set();
|
|
fileTreeCommandBatchRefreshParents.set(normalizedBatchId, batchParents);
|
|
}
|
|
collectFileTreeRefreshParentsForCommand(result, body).forEach(function(parent) {
|
|
batchParents.add(parent);
|
|
});
|
|
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-pending', normalizedBatchId);
|
|
return true;
|
|
}
|
|
|
|
function flushFileTreeBatchRefresh(batchId) {
|
|
var normalizedBatchId = String(batchId || '').trim();
|
|
if (!normalizedBatchId) return false;
|
|
var parents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
|
|
fileTreeCommandBatchRefreshParents.delete(normalizedBatchId);
|
|
if (!parents || !parents.size) return false;
|
|
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-applied', normalizedBatchId);
|
|
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
|
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
|
setTreeLiveApplyError(error && error.message ? error.message : '文件树批量刷新失败');
|
|
return false;
|
|
});
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
function applyLocalFolderWatchBatch(payload) {
|
|
var batch = payload && (payload.payload || payload);
|
|
var changedPaths = Array.isArray(batch && batch.changedPaths)
|
|
? batch.changedPaths
|
|
: Array.isArray(batch && batch.changed_paths)
|
|
? batch.changed_paths
|
|
: [];
|
|
var affectedParents = Array.isArray(batch && batch.affectedParents)
|
|
? batch.affectedParents
|
|
: 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();
|
|
return { relativePath: parentRelativePathForPath(relativePath), reason: 'derived-from-changed-path' };
|
|
}).filter(function(parent, index, list) {
|
|
return index === list.findIndex(function(candidate) { return candidate.relativePath === parent.relativePath; });
|
|
});
|
|
if (affectedParents.length) {
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-derived-parents', String(affectedParents.length));
|
|
}
|
|
}
|
|
if (!affectedParents.length) {
|
|
setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents');
|
|
return false;
|
|
}
|
|
var parents = new Set();
|
|
affectedParents.forEach(function(parent) {
|
|
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
|
});
|
|
var needsSidebarRefresh = changedPaths.some(watchBatchNeedsPageTreeRefresh);
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
|
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
|
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
|
setTreeLiveApplyError(error && error.message ? error.message : '文件树 watch batch 刷新失败');
|
|
return false;
|
|
});
|
|
})).then(function() {
|
|
if (needsSidebarRefresh) {
|
|
void refreshLocalFolderSidebarSnapshot();
|
|
} else {
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-sidebar-refresh-skipped', 'content-only');
|
|
}
|
|
markLocalFolderWatchApplied('watch_batch');
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function refreshLocalFolderAfterCommand(action, result, body) {
|
|
if (!localCommandNeedsProjectionRefresh(action, result)) return Promise.resolve(false);
|
|
if (body && body.batchId) {
|
|
queueFileTreeBatchRefresh(body.batchId, result, body);
|
|
return Promise.resolve(true);
|
|
}
|
|
return fileTreeRefreshParentsForCommand(result, body);
|
|
}
|
|
|
|
function sortOrderFromDelta(data) {
|
|
var raw = data && (data.sortOrder ?? data.sort_order);
|
|
var value = Number(raw);
|
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
}
|
|
|
|
function insertTreeNodeAtSortOrder(targetContainer, node, sortOrder) {
|
|
if (!targetContainer || !node) return false;
|
|
if (sortOrder === null || sortOrder === undefined) {
|
|
targetContainer.appendChild(node);
|
|
return true;
|
|
}
|
|
var siblings = Array.from(targetContainer.querySelectorAll(':scope > .tree-node')).filter(function(candidate) {
|
|
return candidate !== node;
|
|
});
|
|
var targetIndex = Math.max(0, Math.min(Number(sortOrder), siblings.length));
|
|
var referenceNode = siblings[targetIndex] || null;
|
|
if (referenceNode) targetContainer.insertBefore(node, referenceNode);
|
|
else targetContainer.appendChild(node);
|
|
return true;
|
|
}
|
|
|
|
function moveDocumentRowForMode(mode, documentId, parentId, sortOrder) {
|
|
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
|
var node = row ? row.closest('.tree-node') : null;
|
|
var root = treeRootForMode(mode);
|
|
if (!row || !node || !root) return false;
|
|
var targetContainer = root;
|
|
if (parentId) {
|
|
var parentRow = document.querySelector(rowSelectorForDocument(mode, parentId));
|
|
targetContainer = ensureTreeChildren(parentRow);
|
|
if (!targetContainer) return false;
|
|
row.setAttribute('data-parent-id', parentId);
|
|
} else {
|
|
row.removeAttribute('data-parent-id');
|
|
}
|
|
return insertTreeNodeAtSortOrder(targetContainer, node, sortOrder);
|
|
}
|
|
|
|
function applyMoveDocumentDelta(data) {
|
|
var documentId = documentIdFromDelta(data);
|
|
if (!documentId) return false;
|
|
var parentId = parentIdFromDelta(data);
|
|
var sortOrder = sortOrderFromDelta(data);
|
|
var movedPage = moveDocumentRowForMode('page', documentId, parentId, sortOrder);
|
|
var movedFile = moveDocumentRowForMode('filetree', documentId, parentId, sortOrder);
|
|
return movedPage || movedFile;
|
|
}
|
|
|
|
function applyRemoveDocumentDelta(data) {
|
|
var documentId = documentIdFromDelta(data);
|
|
if (!documentId) return false;
|
|
var removedPage = removeDocumentRowForMode('page', documentId);
|
|
var removedFile = removeDocumentRowForMode('filetree', documentId);
|
|
return removedPage || removedFile;
|
|
}
|
|
|
|
function readProjection(value) {
|
|
var runtimeFn = fileTreeRuntimeFunction('readProjection');
|
|
if (runtimeFn) return runtimeFn(value);
|
|
if (!value || typeof value !== 'object') return null;
|
|
if (value.result && typeof value.result === 'object') return value.result;
|
|
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
|
|
if (value.data && value.data.tree) return value.data.tree;
|
|
if (value.tree && typeof value.tree === 'object') return value.tree;
|
|
return value;
|
|
}
|
|
|
|
function readSidebarDataset(value) {
|
|
var runtimeFn = fileTreeRuntimeFunction('readSidebarDataset');
|
|
if (runtimeFn) return runtimeFn(value);
|
|
if (!value || typeof value !== 'object') return null;
|
|
if (value.snapshot && value.snapshot.dataset && typeof value.snapshot.dataset === 'object') return value.snapshot.dataset;
|
|
if (value.data && value.data.dataset && typeof value.data.dataset === 'object') return value.data.dataset;
|
|
if (value.dataset && typeof value.dataset === 'object') return value.dataset;
|
|
if (value.sidebar && typeof value.sidebar === 'object') return value.sidebar;
|
|
return null;
|
|
}
|
|
|
|
function readDatasetProjection(value, snakeCaseKey, camelCaseKey) {
|
|
var runtimeFn = fileTreeRuntimeFunction('readDatasetProjection');
|
|
if (runtimeFn) return runtimeFn(value, snakeCaseKey, camelCaseKey);
|
|
var dataset = readSidebarDataset(value);
|
|
if (!dataset || typeof dataset !== 'object') return null;
|
|
if (dataset[snakeCaseKey] && typeof dataset[snakeCaseKey] === 'object') return dataset[snakeCaseKey];
|
|
if (dataset[camelCaseKey] && typeof dataset[camelCaseKey] === 'object') return dataset[camelCaseKey];
|
|
return null;
|
|
}
|
|
|
|
function setTreeLiveApplyError(reason) {
|
|
document.documentElement.setAttribute('data-mnote-tree-live-apply-error', String(reason || 'tree_live_apply_failed'));
|
|
}
|
|
|
|
function projectionItems(projection) {
|
|
var runtimeFn = fileTreeRuntimeFunction('projectionItems');
|
|
if (runtimeFn) return runtimeFn(projection);
|
|
var resolved = readProjection(projection);
|
|
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
|
|
}
|
|
|
|
function hasProjectionItems(projection) {
|
|
var runtimeFn = fileTreeRuntimeFunction('hasProjectionItems');
|
|
if (runtimeFn) return runtimeFn(projection);
|
|
var resolved = readProjection(projection);
|
|
return Boolean(resolved && Array.isArray(resolved.items));
|
|
}
|
|
|
|
function hasProjectionItemArray(projection) {
|
|
var resolved = readProjection(projection);
|
|
return Boolean(resolved && Array.isArray(resolved.items));
|
|
}
|
|
|
|
function nodeIdOf(item) {
|
|
var runtimeFn = fileTreeRuntimeFunction('nodeIdOf');
|
|
if (runtimeFn) return runtimeFn(item);
|
|
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
|
|
}
|
|
|
|
function rowIdOf(item) {
|
|
var runtimeFn = fileTreeRuntimeFunction('rowIdOf');
|
|
if (runtimeFn) return runtimeFn(item);
|
|
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
|
|
}
|
|
|
|
function parentIdOf(item) {
|
|
var runtimeFn = fileTreeRuntimeFunction('parentIdOf');
|
|
if (runtimeFn) return runtimeFn(item);
|
|
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
|
|
}
|
|
|
|
function titleOf(item) {
|
|
var runtimeFn = fileTreeRuntimeFunction('titleOf');
|
|
if (runtimeFn) return runtimeFn(item);
|
|
return String(item && item.title || '无标题').trim() || '无标题';
|
|
}
|
|
|
|
function fileWorkspaceRelativePath(item) {
|
|
var runtimeFn = fileTreeRuntimeFunction('fileWorkspaceRelativePath');
|
|
if (runtimeFn) return runtimeFn(item);
|
|
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
|
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
|
|
var fromWorkspacePath = String(workspacePath.relativePath || '').trim();
|
|
if (fromWorkspacePath) return fromWorkspacePath;
|
|
var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {};
|
|
var source = extra.source && typeof extra.source === 'object' ? extra.source : {};
|
|
var fromSource = String(source.relativePath || '').trim();
|
|
if (fromSource) return fromSource;
|
|
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
|
|
}
|
|
|
|
function normalizeFileTreeRelativePath(value) {
|
|
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
|
return normalized === '.' ? '' : normalized;
|
|
}
|
|
|
|
function fileTreeRowByRelativePath(relativePath) {
|
|
var normalized = normalizeFileTreeRelativePath(relativePath);
|
|
if (!normalized) return null;
|
|
return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]');
|
|
}
|
|
|
|
function groupRowsByParent(rows) {
|
|
var runtimeFn = fileTreeRuntimeFunction('groupRowsByParent');
|
|
if (runtimeFn) return runtimeFn(rows);
|
|
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
|
|
var grouped = new Map();
|
|
rows.forEach(function(item) {
|
|
var parentId = parentIdOf(item);
|
|
if (!ids.has(parentId)) parentId = '';
|
|
if (!grouped.has(parentId)) grouped.set(parentId, []);
|
|
grouped.get(parentId).push(item);
|
|
});
|
|
return grouped;
|
|
}
|
|
|
|
function fileTreeParentKey(rootUri, parentRelativePath) {
|
|
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
|
|
}
|
|
|
|
function currentFileTreeParentKey(parentRelativePath) {
|
|
return fileTreeParentKey(currentRootUri(), parentRelativePath);
|
|
}
|
|
|
|
function stableTreeViewStateHash(value) {
|
|
var hash = 0xcbf29ce484222325n;
|
|
var prime = 0x100000001b3n;
|
|
var text = String(value || '').trim();
|
|
for (var index = 0; index < text.length; index += 1) {
|
|
hash ^= BigInt(text.charCodeAt(index) & 0xff);
|
|
hash = (hash * prime) & 0xffffffffffffffffn;
|
|
}
|
|
return hash.toString(16).padStart(16, '0');
|
|
}
|
|
|
|
function currentActorStorageId() {
|
|
var body = document.body instanceof HTMLElement ? document.body : null;
|
|
var fromBody = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
|
if (fromBody) return fromBody;
|
|
var match = document.cookie.match(/(?:^|;\s*)mnote_actor_id=([^;]+)/);
|
|
if (match) {
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch (_) {
|
|
return match[1] || '';
|
|
}
|
|
}
|
|
return 'anonymous';
|
|
}
|
|
|
|
function sidebarTreeViewScope(treeKind) {
|
|
var normalizedTreeKind = String(treeKind || '').trim() === 'pagetree' ? 'pagetree' : 'filetree';
|
|
var rootUri = String(currentRootUri() || '').trim();
|
|
// 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'),
|
|
sourceKind: String(currentSourceKind() || 'local_folder').trim() || 'local_folder',
|
|
treeKind: normalizedTreeKind,
|
|
rootUri: rootUri,
|
|
scope: scope
|
|
};
|
|
}
|
|
|
|
function sidebarTreeViewStateMapKey(scope) {
|
|
return [
|
|
scope.userId,
|
|
scope.workspaceId,
|
|
scope.sourceKind,
|
|
scope.treeKind,
|
|
scope.rootUri,
|
|
scope.scope
|
|
].join('\n');
|
|
}
|
|
|
|
function sidebarTreeViewLocalStorageKey(scope) {
|
|
return SIDEBAR_TREE_VIEW_STATE_KEY
|
|
.replace('{userId}', encodeURIComponent(scope.userId || 'anonymous'))
|
|
.replace('{workspaceId}', encodeURIComponent(scope.workspaceId || 'default'))
|
|
.replace('{sourceKind}', encodeURIComponent(scope.sourceKind || 'local_folder'))
|
|
.replace('{treeKind}', encodeURIComponent(scope.treeKind || 'filetree'))
|
|
.replace('{rootUriHash}', stableTreeViewStateHash(scope.rootUri || ''))
|
|
.replace('{scopeHash}', stableTreeViewStateHash(scope.scope || 'root'));
|
|
}
|
|
|
|
function normalizeSidebarTreeViewState(treeKind, value, source) {
|
|
var scope = sidebarTreeViewScope(treeKind);
|
|
var state = value && typeof value === 'object' ? value : {};
|
|
return {
|
|
schemaVersion: 1,
|
|
treeKind: scope.treeKind,
|
|
rootUri: scope.rootUri,
|
|
scope: scope.scope,
|
|
expandedIds: new Set(Array.isArray(state.expandedIds) ? state.expandedIds.map(String).filter(Boolean) : []),
|
|
expandedRelativePaths: new Set(Array.isArray(state.expandedRelativePaths) ? state.expandedRelativePaths.map(normalizeFileTreeRelativePath).filter(Boolean) : []),
|
|
selectedId: String(state.selectedId || '').trim(),
|
|
focusedId: String(state.focusedId || '').trim(),
|
|
activeId: String(state.activeId || '').trim(),
|
|
scrollTop: Math.max(0, Number(state.scrollTop || 0) || 0),
|
|
updatedAtMs: Number(state.updatedAtMs || 0) || 0,
|
|
hasUserState: source === 'sqlite' || source === 'local' || source === 'legacy',
|
|
source: source || 'default'
|
|
};
|
|
}
|
|
|
|
function serializeSidebarTreeViewState(state) {
|
|
return {
|
|
schemaVersion: 1,
|
|
treeKind: state.treeKind,
|
|
rootUri: state.rootUri,
|
|
scope: state.scope,
|
|
expandedIds: Array.from(state.expandedIds || []).slice(0, 512),
|
|
expandedRelativePaths: Array.from(state.expandedRelativePaths || []).slice(0, 512),
|
|
selectedId: String(state.selectedId || ''),
|
|
focusedId: String(state.focusedId || ''),
|
|
activeId: String(state.activeId || ''),
|
|
scrollTop: Math.max(0, Number(state.scrollTop || 0) || 0),
|
|
updatedAtMs: Number(state.updatedAtMs || 0) || Date.now()
|
|
};
|
|
}
|
|
|
|
function sidebarTreeViewStateFor(treeKind) {
|
|
var scope = sidebarTreeViewScope(treeKind);
|
|
var key = sidebarTreeViewStateMapKey(scope);
|
|
var state = sidebarTreeViewStates.get(key);
|
|
if (!state) {
|
|
var localValue = null;
|
|
try {
|
|
if (window.localStorage) {
|
|
localValue = JSON.parse(window.localStorage.getItem(sidebarTreeViewLocalStorageKey(scope)) || 'null');
|
|
}
|
|
} catch (_) {
|
|
localValue = null;
|
|
}
|
|
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') {
|
|
fileTreeViewState.expandedParents = state.expandedRelativePaths;
|
|
fileTreeExpandedRelativePaths = state.expandedRelativePaths;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function loadSidebarTreeViewState(treeKind) {
|
|
var scope = sidebarTreeViewScope(treeKind);
|
|
var key = sidebarTreeViewStateMapKey(scope);
|
|
if (sidebarTreeViewStateRequests.has(key)) return sidebarTreeViewStateRequests.get(key);
|
|
var url = new URL('/api/tree/view-state', window.location.origin);
|
|
url.searchParams.set('workspaceId', scope.workspaceId);
|
|
url.searchParams.set('sourceKind', scope.sourceKind);
|
|
url.searchParams.set('treeKind', scope.treeKind);
|
|
if (scope.rootUri) url.searchParams.set('rootUri', scope.rootUri);
|
|
url.searchParams.set('scope', scope.scope);
|
|
var promise = fetch(url.toString(), {
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
headers: { accept: 'application/json' }
|
|
}).then(function(response) {
|
|
return response.json().catch(function() { return null; }).then(function(payload) {
|
|
if (!response.ok) return null;
|
|
var result = payload && payload.result ? payload.result : null;
|
|
if (!result || !result.state) return null;
|
|
var latestScope = sidebarTreeViewScope(scope.treeKind);
|
|
if (sidebarTreeViewStateMapKey(latestScope) !== key) return null;
|
|
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) {
|
|
document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-load-error', String(error && error.message || error || 'load_failed'));
|
|
return null;
|
|
}).finally(function() {
|
|
sidebarTreeViewStateRequests.delete(key);
|
|
});
|
|
sidebarTreeViewStateRequests.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
function persistSidebarTreeViewState(treeKind, options) {
|
|
options = options || {};
|
|
var scope = sidebarTreeViewScope(treeKind);
|
|
var key = sidebarTreeViewStateMapKey(scope);
|
|
var state = sidebarTreeViewStateFor(scope.treeKind);
|
|
state.hasUserState = true;
|
|
state.source = state.source === 'legacy' ? 'legacy' : 'local';
|
|
state.updatedAtMs = Date.now();
|
|
var serialized = serializeSidebarTreeViewState(state);
|
|
try {
|
|
if (window.localStorage) {
|
|
window.localStorage.setItem(sidebarTreeViewLocalStorageKey(scope), JSON.stringify(serialized));
|
|
}
|
|
} catch (_) {}
|
|
var flush = function() {
|
|
sidebarTreeViewStateSaveTimers.delete(key);
|
|
var latestScope = sidebarTreeViewScope(scope.treeKind);
|
|
if (sidebarTreeViewStateMapKey(latestScope) !== key) return;
|
|
fetch('/api/tree/view-state', {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
workspaceId: scope.workspaceId,
|
|
sourceKind: scope.sourceKind,
|
|
treeKind: scope.treeKind,
|
|
rootUri: scope.rootUri,
|
|
scope: scope.scope,
|
|
state: serialized
|
|
})
|
|
}).then(function(response) {
|
|
return response.json().catch(function() { return null; }).then(function(payload) {
|
|
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'tree_view_state_save_failed_' + response.status);
|
|
document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-saved', scope.treeKind + ':' + scope.scope);
|
|
return payload;
|
|
});
|
|
}).catch(function(error) {
|
|
document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-save-error', String(error && error.message || error || 'save_failed'));
|
|
});
|
|
};
|
|
if (options.flush) {
|
|
if (sidebarTreeViewStateSaveTimers.has(key)) {
|
|
window.clearTimeout(sidebarTreeViewStateSaveTimers.get(key));
|
|
sidebarTreeViewStateSaveTimers.delete(key);
|
|
}
|
|
flush();
|
|
return;
|
|
}
|
|
if (sidebarTreeViewStateSaveTimers.has(key)) window.clearTimeout(sidebarTreeViewStateSaveTimers.get(key));
|
|
sidebarTreeViewStateSaveTimers.set(key, window.setTimeout(flush, SIDEBAR_TREE_VIEW_STATE_SAVE_DELAY_MS));
|
|
}
|
|
|
|
function flushPendingSidebarTreeViewState(treeKind) {
|
|
var scope = sidebarTreeViewScope(treeKind);
|
|
var key = sidebarTreeViewStateMapKey(scope);
|
|
if (!sidebarTreeViewStateSaveTimers.has(key)) return;
|
|
persistSidebarTreeViewState(scope.treeKind, { flush: true });
|
|
}
|
|
|
|
function applySidebarTreeViewState(treeKind, state) {
|
|
if (String(treeKind || '').trim() === 'pagetree') {
|
|
applyPageTreeExpansionState(state);
|
|
return;
|
|
}
|
|
fileTreeExpandedRelativePaths = state.expandedRelativePaths;
|
|
fileTreeViewState.expandedParents = state.expandedRelativePaths;
|
|
restorePersistedFileTreeExpansionState();
|
|
}
|
|
|
|
function applyPageTreeExpansionState(state) {
|
|
if (!state || !state.hasUserState) return false;
|
|
var changed = false;
|
|
document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
|
|
var button = row.querySelector('[data-rust-action="toggle"]');
|
|
var children = pageTreeChildrenContainer(row);
|
|
if (!nodeId || !button || !(children instanceof HTMLElement)) return;
|
|
var expanded = state.expandedIds.has(nodeId);
|
|
// 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;
|
|
}
|
|
|
|
function pageTreeActiveParentIds(grouped, activeId) {
|
|
var parentsById = new Map();
|
|
grouped.forEach(function(items) {
|
|
items.forEach(function(item) {
|
|
parentsById.set(nodeIdOf(item), parentIdOf(item));
|
|
});
|
|
});
|
|
var parents = new Set();
|
|
var cursor = String(activeId || '').trim();
|
|
var guard = 0;
|
|
while (cursor && parentsById.has(cursor) && guard < 512) {
|
|
cursor = String(parentsById.get(cursor) || '').trim();
|
|
if (cursor) parents.add(cursor);
|
|
guard += 1;
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
function projectionParentRelativePath(projection) {
|
|
var resolved = readProjection(projection);
|
|
return String(resolved && (resolved.parentRelativePath || resolved.parent_relative_path) || '').trim();
|
|
}
|
|
|
|
function isFileTreeRootProjectionParent(parentRelativePath) {
|
|
return normalizeFileTreeRelativePath(parentRelativePath) === normalizeFileTreeRelativePath(currentFileTreeScope());
|
|
}
|
|
|
|
function rememberFileTreeProjection(parentRelativePath, rows, projection) {
|
|
var key = currentFileTreeParentKey(parentRelativePath);
|
|
fileTreeState.rowsByParent.set(key, rows);
|
|
fileTreeState.loadedParents.add(key);
|
|
fileTreeState.dirtyParents.delete(key);
|
|
fileTreeState.staleParents.delete(key);
|
|
var resolved = readProjection(projection);
|
|
var watchRevision = resolved && (resolved.watchRevision || resolved.watch_revision);
|
|
if (watchRevision) fileTreeState.revisionByParent.set(key, watchRevision);
|
|
}
|
|
|
|
function cachedFileTreeRows(parentRelativePath) {
|
|
return fileTreeState.rowsByParent.get(currentFileTreeParentKey(parentRelativePath)) || [];
|
|
}
|
|
|
|
function beginFileTreeRequest(key) {
|
|
fileTreeState.requestGeneration += 1;
|
|
fileTreeState.latestGenerationByParent.set(key, fileTreeState.requestGeneration);
|
|
return fileTreeState.requestGeneration;
|
|
}
|
|
|
|
function isLatestFileTreeRequest(key, generation) {
|
|
return fileTreeState.latestGenerationByParent.get(key) === generation;
|
|
}
|
|
|
|
function markFileTreeParentStale(key) {
|
|
fileTreeState.staleParents.add(key);
|
|
fileTreeState.dirtyParents.add(key);
|
|
}
|
|
|
|
function beginPageTreeRequest() {
|
|
pageTreeState.pageTreeRequestGeneration += 1;
|
|
return pageTreeState.pageTreeRequestGeneration;
|
|
}
|
|
|
|
function isLatestPageTreeRequest(generation) {
|
|
return generation === pageTreeState.pageTreeRequestGeneration;
|
|
}
|
|
|
|
function yieldForPageTreeRender() {
|
|
return new Promise(function(resolve) {
|
|
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
|
|
window.requestIdleCallback(function() { resolve(); }, { timeout: 120 });
|
|
return;
|
|
}
|
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
window.requestAnimationFrame(function() { resolve(); });
|
|
return;
|
|
}
|
|
setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
function pageTreeChevronSvg() {
|
|
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');
|
|
var pageTreeExpandedIds = pageTreeStateView.expandedIds;
|
|
var activeParentIds = pageTreeActiveParentIds(grouped, activeId);
|
|
return (grouped.get(parentId) || []).map(function(item) {
|
|
var nodeId = nodeIdOf(item);
|
|
var title = titleOf(item);
|
|
var depth = computedDepth;
|
|
var parent = parentIdOf(item);
|
|
var children = grouped.get(nodeId) || [];
|
|
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
|
// 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 === 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="' + 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="' + 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('');
|
|
}
|
|
|
|
async function renderPageProjection(projection) {
|
|
var generation = beginPageTreeRequest();
|
|
var tree = document.getElementById('sidebar-tree-root');
|
|
if (!tree) return false;
|
|
var rows = projectionItems(projection).filter(function(item) {
|
|
return String(item.rowKind || 'document') === 'document';
|
|
});
|
|
await yieldForPageTreeRender();
|
|
if (!isLatestPageTreeRequest(generation)) {
|
|
document.documentElement.setAttribute('data-mnote-page-tree-stale-projection-ignored', String(generation));
|
|
return false;
|
|
}
|
|
var activeId = currentDocumentId();
|
|
var template = document.createElement('template');
|
|
template.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '') + '</ul>';
|
|
if (!isLatestPageTreeRequest(generation)) {
|
|
document.documentElement.setAttribute('data-mnote-page-tree-stale-projection-ignored', String(generation));
|
|
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;
|
|
}
|
|
|
|
function fileDocumentId(item) {
|
|
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
|
if (meta.documentId) return String(meta.documentId).trim();
|
|
if (item && item.documentId) return String(item.documentId).trim();
|
|
if (item && item.rowKind === 'document') return nodeIdOf(item);
|
|
if (item && item.rowKind === 'index') return nodeIdOf(item).replace(/^index:/, '');
|
|
return '';
|
|
}
|
|
|
|
function fileAssetId(item) {
|
|
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
|
if (meta.assetId) return String(meta.assetId).trim();
|
|
if (item && item.assetId) return String(item.assetId).trim();
|
|
if (item && item.rowKind === 'asset') return nodeIdOf(item).replace(/^asset:/, '');
|
|
return '';
|
|
}
|
|
|
|
function fileObjectIdentity(item) {
|
|
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
|
// Phase A1: workspacePath 优先于旧猜测逻辑
|
|
var wp = meta.workspacePath;
|
|
if (wp && typeof wp === 'object' && wp.objectIdentity && typeof wp.objectIdentity === 'object') {
|
|
return wp.objectIdentity;
|
|
}
|
|
if (meta.objectIdentity && typeof meta.objectIdentity === 'object') return meta.objectIdentity;
|
|
var rowKind = String(item && item.rowKind || '');
|
|
var documentId = fileDocumentId(item) || null;
|
|
var assetId = fileAssetId(item) || null;
|
|
var iconKind = iconKindOf(item);
|
|
var objectKind = rowKind === 'document' ? 'page' : rowKind === 'index' ? 'index' : iconKind === 'mindmap' ? 'mindmap' : 'attachment';
|
|
return { objectKind: objectKind, documentId: documentId, blockId: null, assetId: assetId };
|
|
}
|
|
|
|
function fileOwnerDocumentId(item, fallbackDocumentId, objectIdentity) {
|
|
var identity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : fileObjectIdentity(item);
|
|
var owner = String(identity && identity.documentId || '').trim();
|
|
if (owner) return owner;
|
|
var assetId = fileAssetId(item);
|
|
var localPath = localFilePathFromAssetId(assetId);
|
|
if (localPath && localPath.indexOf('/') > 0) {
|
|
var parts = localPath.split('/');
|
|
var bundleName = parts[0] || '';
|
|
if (bundleName) return 'local-md:' + bundleName + '~2F' + bundleName + '.md';
|
|
}
|
|
return String(fallbackDocumentId || '').trim();
|
|
}
|
|
|
|
function objectIdentityAttr(identity) {
|
|
try {
|
|
return JSON.stringify(identity || {});
|
|
} catch (_error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function iconKindOf(item) {
|
|
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
|
}
|
|
|
|
function fileCapabilitiesAttr(item) {
|
|
try {
|
|
return JSON.stringify(Array.isArray(item && item.capabilities) ? item.capabilities : []);
|
|
} catch (_error) {
|
|
return '[]';
|
|
}
|
|
}
|
|
|
|
function isFileTreeProjectionPageRow(rowKind, assetId) {
|
|
if (assetId) return false;
|
|
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'markdown';
|
|
}
|
|
|
|
function normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity) {
|
|
var title = String(rawTitle || '').trim();
|
|
var isMindmap = iconKind === 'mindmap' || String(objectIdentity && objectIdentity.objectKind || '') === 'mindmap';
|
|
if (!isMindmap) return title || '无标题';
|
|
return title || shortMindmapFileName(assetId);
|
|
}
|
|
|
|
function renderFileRows(parentId, grouped, activeId, activeRowId) {
|
|
var fileTreeStateView = sidebarTreeViewStateFor('filetree');
|
|
var expandedRelativePaths = fileTreeStateView.expandedRelativePaths;
|
|
return (grouped.get(parentId) || []).map(function(item) {
|
|
var nodeId = nodeIdOf(item);
|
|
var rowId = rowIdOf(item);
|
|
var rowKind = String(item.rowKind || 'document');
|
|
var rawTitle = titleOf(item);
|
|
var depth = Number(item.depth || 0);
|
|
var parent = parentIdOf(item);
|
|
var documentId = fileDocumentId(item);
|
|
var assetId = fileAssetId(item);
|
|
var relativePath = fileWorkspaceRelativePath(item);
|
|
var objectIdentity = fileObjectIdentity(item);
|
|
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
|
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));
|
|
var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId;
|
|
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
|
|
var toggle = expandable
|
|
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
|
|
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
|
var createAction = rowKind === 'document'
|
|
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
|
|
: '';
|
|
var childRowsHtml = children.length
|
|
? renderFileRows(nodeId, grouped, activeId, activeRowId)
|
|
: cachedChildren.length
|
|
? renderFileRows('', groupRowsByParent(cachedChildren), activeId, activeRowId)
|
|
: '';
|
|
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(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('');
|
|
}
|
|
|
|
function ensureFileTreeLazyCacheScope() {
|
|
var rootUri = currentRootUri();
|
|
var scope = currentFileTreeScope();
|
|
if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;
|
|
fileTreeLazyCacheRootUri = rootUri;
|
|
fileTreeState.rootUri = rootUri;
|
|
fileTreeState.scope = scope;
|
|
fileTreeState.rowsByParent.clear();
|
|
fileTreeState.loadedParents.clear();
|
|
fileTreeState.loadingParents.clear();
|
|
fileTreeState.dirtyParents.clear();
|
|
fileTreeState.staleParents.clear();
|
|
fileTreeState.revisionByParent.clear();
|
|
fileTreeState.latestGenerationByParent.clear();
|
|
fileTreeState.requestGeneration += 1;
|
|
var nextState = sidebarTreeViewStateFor('filetree');
|
|
fileTreeExpandedRelativePaths = nextState.expandedRelativePaths;
|
|
fileTreeViewState.expandedParents = nextState.expandedRelativePaths;
|
|
loadStoredFileTreeExpansionState(rootUri);
|
|
}
|
|
|
|
function storedFileTreeExpansionBuckets() {
|
|
try {
|
|
if (!window.sessionStorage) return {};
|
|
var parsed = JSON.parse(window.sessionStorage.getItem(FILETREE_EXPANSION_STORAGE_KEY) || '{}');
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function loadStoredFileTreeExpansionState(rootUri) {
|
|
var normalizedRootUri = String(rootUri || '').trim();
|
|
if (!normalizedRootUri || fileTreeExpansionStorageRootUri === normalizedRootUri) return;
|
|
fileTreeExpansionStorageRootUri = normalizedRootUri;
|
|
var state = sidebarTreeViewStateFor('filetree');
|
|
if (state.hasUserState && state.expandedRelativePaths.size) return;
|
|
var buckets = storedFileTreeExpansionBuckets();
|
|
var paths = Array.isArray(buckets[normalizedRootUri]) ? buckets[normalizedRootUri] : [];
|
|
paths.forEach(function(path) {
|
|
var normalized = String(path || '').trim();
|
|
if (normalized) state.expandedRelativePaths.add(normalized);
|
|
});
|
|
if (state.expandedRelativePaths.size) {
|
|
state.hasUserState = true;
|
|
state.source = 'legacy';
|
|
fileTreeExpandedRelativePaths = state.expandedRelativePaths;
|
|
fileTreeViewState.expandedParents = state.expandedRelativePaths;
|
|
persistSidebarTreeViewState('filetree');
|
|
}
|
|
}
|
|
|
|
function persistFileTreeExpansionState() {
|
|
var rootUri = String(currentRootUri() || fileTreeLazyCacheRootUri || '').trim();
|
|
if (!rootUri) return;
|
|
var state = sidebarTreeViewStateFor('filetree');
|
|
try {
|
|
if (!window.sessionStorage) return;
|
|
var buckets = storedFileTreeExpansionBuckets();
|
|
buckets[rootUri] = Array.from(state.expandedRelativePaths)
|
|
.map(function(path) { return String(path || '').trim(); })
|
|
.filter(Boolean)
|
|
.slice(0, 256);
|
|
window.sessionStorage.setItem(FILETREE_EXPANSION_STORAGE_KEY, JSON.stringify(buckets));
|
|
} catch (_) {}
|
|
}
|
|
|
|
function scheduleRestorePersistedFileTreeExpansionState() {
|
|
if (fileTreeExpansionRestoreTimer) window.clearTimeout(fileTreeExpansionRestoreTimer);
|
|
fileTreeExpansionRestoreTimer = window.setTimeout(function() {
|
|
fileTreeExpansionRestoreTimer = 0;
|
|
restorePersistedFileTreeExpansionState();
|
|
}, 0);
|
|
}
|
|
|
|
function scheduleFileTreeIdleTask(callback, timeout) {
|
|
if (typeof window.requestIdleCallback === 'function') {
|
|
return window.requestIdleCallback(callback, { timeout: timeout || 800 });
|
|
}
|
|
if (typeof window.requestAnimationFrame === 'function') {
|
|
return window.requestAnimationFrame(function() {
|
|
window.setTimeout(callback, 0);
|
|
});
|
|
}
|
|
return window.setTimeout(callback, 0);
|
|
}
|
|
|
|
function isFileTreeRowVisibleForHydrate(row) {
|
|
if (!(row instanceof HTMLElement)) return false;
|
|
if (typeof row.getBoundingClientRect !== 'function') return true;
|
|
var rect = row.getBoundingClientRect();
|
|
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
if (!viewportHeight) return true;
|
|
return rect.bottom >= -64 && rect.top <= viewportHeight + 256;
|
|
}
|
|
|
|
function fileTreeRowNeedsVisibleHydrate(row) {
|
|
if (!(row instanceof HTMLElement)) return false;
|
|
if (currentSourceKind() !== 'local_folder') return false;
|
|
if (!currentRootUri()) return false;
|
|
if (row.getAttribute('data-shell-mode') !== 'filetree') return false;
|
|
if (String(row.getAttribute('data-row-kind') || '').trim() !== 'folder') return false;
|
|
if (row.getAttribute('aria-expanded') === 'true') {
|
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
|
if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return false;
|
|
if (row.getAttribute('data-filetree-children-loaded') !== 'true' && row.getAttribute('data-filetree-children-loading') !== 'true') {
|
|
return isFileTreeRowVisibleForHydrate(row);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function collectVisibleExpandedFileTreeRowsForHydrate() {
|
|
var rows = [];
|
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
|
if (rows.length >= FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX) return;
|
|
if (fileTreeRowNeedsVisibleHydrate(row)) rows.push(row);
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function scheduleHydrateVisibleExpandedFileTreeRows(reason) {
|
|
if (fileTreeVisibleHydrateQueued) return;
|
|
if (currentSourceKind() !== 'local_folder') return;
|
|
fileTreeVisibleHydrateQueued = true;
|
|
fileTreeVisibleHydrateTimer = scheduleFileTreeIdleTask(function() {
|
|
fileTreeVisibleHydrateTimer = 0;
|
|
fileTreeVisibleHydrateQueued = false;
|
|
hydrateVisibleExpandedFileTreeRows(reason || 'idle');
|
|
}, 800);
|
|
}
|
|
|
|
function hydrateVisibleExpandedFileTreeRows(reason) {
|
|
if (currentSourceKind() !== 'local_folder') return false;
|
|
var rows = collectVisibleExpandedFileTreeRowsForHydrate();
|
|
if (!rows.length) return false;
|
|
var batch = rows.slice(0, FILETREE_VISIBLE_EXPANDED_HYDRATE_BATCH);
|
|
batch.forEach(function(row) {
|
|
var button = row.querySelector('[data-rust-action="toggle"]');
|
|
void loadFileTreeChildren(row, button, { persist: false, idleHydrate: true }).then(function(loaded) {
|
|
if (loaded) {
|
|
document.documentElement.setAttribute('data-mnote-filetree-idle-hydrate-applied', String(reason || 'idle'));
|
|
scheduleHydrateVisibleExpandedFileTreeRows('cascade');
|
|
}
|
|
});
|
|
});
|
|
if (rows.length > batch.length) scheduleHydrateVisibleExpandedFileTreeRows('batch');
|
|
return true;
|
|
}
|
|
|
|
function rememberFileTreeExpansionState() {
|
|
var changed = false;
|
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
|
if (!relativePath) return;
|
|
if (row.getAttribute('aria-expanded') === 'true') {
|
|
if (!fileTreeExpandedRelativePaths.has(relativePath)) changed = true;
|
|
fileTreeExpandedRelativePaths.add(relativePath);
|
|
} else {
|
|
if (fileTreeExpandedRelativePaths.has(relativePath)) changed = true;
|
|
fileTreeExpandedRelativePaths.delete(relativePath);
|
|
}
|
|
});
|
|
if (changed) persistFileTreeExpansionState();
|
|
}
|
|
|
|
function rememberFileTreeSelectionState() {
|
|
fileTreeViewState.selectedRowIds.clear();
|
|
fileTreeViewState.focusedRowId = '';
|
|
fileTreeViewState.activeRowId = '';
|
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
|
if (!rowId) return;
|
|
if (row.getAttribute('data-selected') === 'true') fileTreeViewState.selectedRowIds.add(rowId);
|
|
if (row.getAttribute('data-focused') === 'true') fileTreeViewState.focusedRowId = rowId;
|
|
if (row.getAttribute('data-active') === 'true') fileTreeViewState.activeRowId = rowId;
|
|
});
|
|
}
|
|
|
|
function reprojectFileTreeSelectionState() {
|
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
|
row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)));
|
|
row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId));
|
|
if (fileTreeViewState.activeRowId) {
|
|
row.setAttribute('data-active', String(rowId === fileTreeViewState.activeRowId));
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderFileProjection(projection) {
|
|
var tree = document.getElementById('sidebar-file-tree-root');
|
|
if (!tree) return false;
|
|
ensureFileTreeLazyCacheScope();
|
|
rememberFileTreeExpansionState();
|
|
rememberFileTreeSelectionState();
|
|
var rows = projectionItems(projection);
|
|
var parentRelativePath = projectionParentRelativePath(projection) || currentFileTreeScope();
|
|
rememberFileTreeProjection(parentRelativePath, rows, projection);
|
|
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
|
|
return patchFileTreeParentChildren(parentRelativePath, rows);
|
|
}
|
|
var activeId = currentDocumentId();
|
|
var activeRowId = currentFileTreeActiveRowId();
|
|
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');
|
|
return true;
|
|
}
|
|
|
|
function patchFileTreeParentChildren(parentRelativePath, rows) {
|
|
var row = fileTreeRowByRelativePath(parentRelativePath);
|
|
if (!(row instanceof HTMLElement)) return true;
|
|
var node = row.closest('.tree-node');
|
|
if (!node) return true;
|
|
rememberFileTreeSelectionState();
|
|
var button = row.querySelector('[data-rust-action="toggle"]');
|
|
var wasExpanded = row.getAttribute('aria-expanded') === 'true';
|
|
var children = node.querySelector(':scope > .tree-children');
|
|
if (!children && !wasExpanded && row.getAttribute('data-filetree-children-loaded') !== 'true') {
|
|
return true;
|
|
}
|
|
if (!children) {
|
|
children = document.createElement('ul');
|
|
children.className = 'tree-children';
|
|
node.appendChild(children);
|
|
}
|
|
var template = document.createElement('template');
|
|
template.innerHTML = renderFileRows('', groupRowsByParent(rows), currentDocumentId(), currentFileTreeActiveRowId());
|
|
children.replaceChildren(template.content.cloneNode(true));
|
|
children.classList.toggle('tree-children--collapsed', !wasExpanded);
|
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
|
row.removeAttribute('data-filetree-children-loading');
|
|
setTreeRowExpanded(row, button, wasExpanded);
|
|
syncSidebarFileTreeSelection();
|
|
reprojectFileTreeSelectionState();
|
|
scheduleHydrateVisibleExpandedFileTreeRows('patch');
|
|
return true;
|
|
}
|
|
|
|
function renderSidebarSnapshot(payload) {
|
|
var renderedPage = false;
|
|
if (hasProjectionItemArray(payload)) {
|
|
renderedPage = true;
|
|
void renderPageProjection(payload);
|
|
}
|
|
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
|
var renderedFile = fileProjection && hasProjectionItemArray(fileProjection) ? renderFileProjection(fileProjection) : false;
|
|
return renderedPage || renderedFile;
|
|
}
|
|
|
|
function renderLiveSidebarSnapshot(payload) {
|
|
var renderedPage = false;
|
|
if (hasProjectionItemArray(payload)) {
|
|
renderedPage = true;
|
|
void renderPageProjection(payload);
|
|
}
|
|
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
|
var hasFileProjection = fileProjection && hasProjectionItemArray(fileProjection);
|
|
if (currentFileTreeScope()) {
|
|
if (!hasFileProjection) return renderedPage;
|
|
var projectionParent = projectionParentRelativePath(fileProjection);
|
|
if (isFileTreeRootProjectionParent(projectionParent)) {
|
|
return renderFileProjection(fileProjection) || renderedPage;
|
|
}
|
|
document.documentElement.setAttribute('data-mnote-filetree-scoped-live-snapshot-ignored', projectionParent || 'root');
|
|
return true;
|
|
}
|
|
var renderedFile = hasFileProjection ? renderFileProjection(fileProjection) : false;
|
|
return renderedPage || renderedFile;
|
|
}
|
|
|
|
function replaceSidebarTreeFromDocument(nextDocument, rootId) {
|
|
var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument');
|
|
if (runtimeFn) return runtimeFn(nextDocument, rootId);
|
|
var current = document.getElementById(rootId);
|
|
var next = nextDocument ? nextDocument.getElementById(rootId) : null;
|
|
if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false;
|
|
current.innerHTML = next.innerHTML;
|
|
Array.from(next.attributes || []).forEach(function(attr) {
|
|
current.setAttribute(attr.name, attr.value);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function applyLocalFolderSidebarSnapshot(nextDocument, options) {
|
|
var runtimeFn = fileTreeRuntimeFunction('applyLocalFolderSidebarSnapshot');
|
|
if (runtimeFn) return runtimeFn(nextDocument, options);
|
|
options = options || {};
|
|
var pageRootId = String(options.pageRootId || 'sidebar-tree-root');
|
|
var fileRootId = String(options.fileRootId || 'sidebar-file-tree-root');
|
|
var appliedPage = replaceSidebarTreeFromDocument(nextDocument, pageRootId);
|
|
var appliedFile = replaceSidebarTreeFromDocument(nextDocument, fileRootId);
|
|
var applied = appliedPage || appliedFile;
|
|
if (applied && typeof options.onApplied === 'function') options.onApplied({
|
|
pageRootApplied: appliedPage,
|
|
fileRootApplied: appliedFile
|
|
});
|
|
return applied;
|
|
}
|
|
|
|
function markLocalFolderWatchApplied(value) {
|
|
var runtimeFn = fileTreeRuntimeFunction('markLocalFolderWatchApplied');
|
|
if (runtimeFn) return runtimeFn(value);
|
|
var appliedValue = String(value || 'projection');
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', appliedValue);
|
|
document.documentElement.removeAttribute('data-mnote-local-folder-watch-disabled');
|
|
return true;
|
|
}
|
|
|
|
function currentFileTreeScope() {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var fromUrl = String(params.get('fileTreeScope') || '').trim();
|
|
if (fromUrl) return fromUrl;
|
|
var root = document.getElementById('sidebar-file-tree-root');
|
|
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
|
|
}
|
|
|
|
async function fetchFileTreeProjection(parentRelativePath, options) {
|
|
options = options || {};
|
|
var rootUri = currentRootUri();
|
|
if (!rootUri) return null;
|
|
var url = new URL(options.childrenOnly ? '/api/tree/projections/file/children' : '/api/tree/projections/file', 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 currentId = currentDocumentId();
|
|
if (!options.childrenOnly && currentId) url.searchParams.set('rootNodeId', currentId);
|
|
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
|
var payload = await response.json().catch(function() { return null; });
|
|
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_projection_failed_' + response.status);
|
|
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);
|
|
fileTreeState.dirtyParents.add(key);
|
|
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
|
|
var row = fileTreeRowByRelativePath(parentRelativePath);
|
|
if (row instanceof HTMLElement && row.getAttribute('aria-expanded') !== 'true') {
|
|
markFileTreeParentStale(key);
|
|
return true;
|
|
}
|
|
}
|
|
if (fileTreeState.loadingParents.has(key)) {
|
|
// 变更事件可能正好撞上同一 parent 的懒加载请求。
|
|
// 旧请求结果不包含刚落盘的文件,不能直接拿它满足本次刷新。
|
|
await fileTreeState.loadingParents.get(key).catch(function() { return []; });
|
|
}
|
|
var projection = await fetchFileTreeProjection(parentRelativePath, { childrenOnly: false });
|
|
if (!projection) return false;
|
|
return renderFileProjection(projection);
|
|
}
|
|
|
|
async function refreshLocalFolderSidebarSnapshot() {
|
|
var workspaceId = currentWorkspaceId();
|
|
var rootUri = currentRootUri();
|
|
var currentId = currentDocumentId();
|
|
var fileTreeScope = currentFileTreeScope();
|
|
if (!workspaceId || !rootUri) return false;
|
|
|
|
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
|
|
sidebarUrl.searchParams.set('workspaceId', workspaceId);
|
|
sidebarUrl.searchParams.set('sourceKind', 'local_folder');
|
|
sidebarUrl.searchParams.set('rootUri', rootUri);
|
|
if (currentId) sidebarUrl.searchParams.set('rootNodeId', currentId);
|
|
if (fileTreeScope) sidebarUrl.searchParams.set('parentRelativePath', fileTreeScope);
|
|
var responses = await Promise.allSettled([
|
|
fetch(sidebarUrl.toString(), { headers: { accept: 'application/json' } }),
|
|
refreshFileTreeParent(fileTreeScope)
|
|
]);
|
|
var sidebarResponse = responses[0].status === 'fulfilled' ? responses[0].value : null;
|
|
var renderedFile = responses[1].status === 'fulfilled' ? responses[1].value : false;
|
|
if ((!sidebarResponse || !sidebarResponse.ok) && !renderedFile) return false;
|
|
|
|
var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null;
|
|
var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null;
|
|
var renderedPage = false;
|
|
if (resolvedSidebarPayload && hasProjectionItemArray(resolvedSidebarPayload)) {
|
|
renderedPage = await renderPageProjection(resolvedSidebarPayload);
|
|
}
|
|
if (renderedFile && fileTreeScope) {
|
|
var fileRoot = document.getElementById('sidebar-file-tree-root');
|
|
if (fileRoot instanceof HTMLElement) fileRoot.setAttribute('data-mnote-filetree-scope', fileTreeScope);
|
|
document.documentElement.setAttribute('data-mnote-filetree-scope', fileTreeScope);
|
|
}
|
|
if (!renderedPage && !renderedFile) return false;
|
|
syncSidebarFileTreeSelection();
|
|
schedulePendingLocalFolderRestoreFocus();
|
|
restoreSidebarTreeTab();
|
|
refreshEditorLocalAttachmentExistence();
|
|
markLocalFolderWatchApplied('projection');
|
|
return true;
|
|
}
|
|
|
|
function startLocalFolderSidebarWatch() {
|
|
if (currentSourceKind() !== 'local_folder') return;
|
|
var rootUri = currentRootUri();
|
|
if (!rootUri) return;
|
|
ensureFileTreeLazyCacheScope();
|
|
scheduleRestorePersistedFileTreeExpansionState();
|
|
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
|
if (treeTransport !== 'local-folder-events') {
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'static');
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-disabled', 'events-required');
|
|
}
|
|
}
|
|
|
|
function isTitleOnlyDocumentPatch(candidate) {
|
|
if (!candidate || typeof candidate !== 'object') return false;
|
|
var allowedKeys = {
|
|
id: true,
|
|
documentId: true,
|
|
title: true,
|
|
updatedAt: true,
|
|
updated_at: true
|
|
};
|
|
return Object.keys(candidate).every(function(key) {
|
|
return allowedKeys[key] === true;
|
|
});
|
|
}
|
|
|
|
function deltaNeedsProjectionRefresh(payload) {
|
|
var data = payload && (payload.data || payload.delta || payload);
|
|
var op = data && typeof data.op === 'string' ? String(data.op).trim() : '';
|
|
if (!op || op === 'noop') return false;
|
|
if (op === 'upsert_document') {
|
|
return !isTitleOnlyDocumentPatch(data.node || data.document || null);
|
|
}
|
|
if (op === 'upsert_documents') {
|
|
var documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
|
|
if (documents.length > 0 && documents.every(isTitleOnlyDocumentPatch)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function localFileTreeRelativePathFromRow(row) {
|
|
if (!(row instanceof HTMLElement)) return '';
|
|
var direct = String(row.getAttribute('data-local-relative-path') || '').trim();
|
|
if (direct) return direct;
|
|
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath');
|
|
if (runtimeFn) {
|
|
try {
|
|
return String(runtimeFn(row, { localFilePathFromAssetId: localFilePathFromAssetId }) || '').trim();
|
|
} catch (_) {}
|
|
}
|
|
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
|
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
|
|
if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length);
|
|
if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length);
|
|
if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length);
|
|
if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length);
|
|
return '';
|
|
}
|
|
|
|
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 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 (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);
|
|
else fileTreeExpandedRelativePaths.delete(relativePath);
|
|
if (shouldPersist) {
|
|
persistFileTreeExpansionState();
|
|
persistSidebarTreeViewState('filetree');
|
|
}
|
|
}
|
|
|
|
function markExistingFileTreeChildrenLoaded(row, button, 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 instanceof HTMLElement)) return false;
|
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
|
children.classList.remove('tree-children--collapsed');
|
|
setTreeRowExpanded(row, button, true, options);
|
|
syncSidebarFileTreeSelection();
|
|
return true;
|
|
}
|
|
|
|
function renderCachedFileTreeChildren(row, button, relativePath, options) {
|
|
var key = currentFileTreeParentKey(relativePath);
|
|
if (fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key)) return false;
|
|
var cachedRows = cachedFileTreeRows(relativePath);
|
|
if (!cachedRows.length) 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 template = document.createElement('template');
|
|
template.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
|
|
children.replaceChildren(template.content.cloneNode(true));
|
|
children.classList.remove('tree-children--collapsed');
|
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
|
setTreeRowExpanded(row, button, true, options);
|
|
syncSidebarFileTreeSelection();
|
|
return true;
|
|
}
|
|
|
|
async function getFileTreeChildren(parentRelativePath) {
|
|
ensureFileTreeLazyCacheScope();
|
|
var key = currentFileTreeParentKey(parentRelativePath);
|
|
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key) && !fileTreeState.staleParents.has(key)) {
|
|
return fileTreeState.rowsByParent.get(key) || [];
|
|
}
|
|
if (fileTreeState.loadingParents.has(key)) {
|
|
return fileTreeState.loadingParents.get(key);
|
|
}
|
|
var generation = beginFileTreeRequest(key);
|
|
var promise = fetchFileTreeProjection(parentRelativePath, { childrenOnly: true }).then(function(projection) {
|
|
if (!isLatestFileTreeRequest(key, generation)) {
|
|
return fileTreeState.rowsByParent.get(key) || [];
|
|
}
|
|
var rows = projectionItems(projection);
|
|
rememberFileTreeProjection(parentRelativePath, rows, projection);
|
|
return rows;
|
|
}).finally(function() {
|
|
fileTreeState.loadingParents.delete(key);
|
|
});
|
|
fileTreeState.loadingParents.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
async function loadFileTreeChildren(row, button, options) {
|
|
if (!(row instanceof HTMLElement)) return false;
|
|
if (row.getAttribute('data-shell-mode') !== 'filetree') return false;
|
|
if (currentSourceKind() !== 'local_folder') return false;
|
|
ensureFileTreeLazyCacheScope();
|
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
|
var key = currentFileTreeParentKey(relativePath);
|
|
var stale = fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key);
|
|
if (!stale && markExistingFileTreeChildrenLoaded(row, button, options)) return true;
|
|
if (!stale && relativePath && renderCachedFileTreeChildren(row, button, relativePath, options)) return true;
|
|
if (row.getAttribute('data-filetree-children-loaded') === 'true') return false;
|
|
var rootUri = currentRootUri();
|
|
if (!rootUri || !relativePath) return false;
|
|
setTreeRowExpanded(row, button, true, options);
|
|
row.setAttribute('data-filetree-children-loading', 'true');
|
|
try {
|
|
var rows = await getFileTreeChildren(relativePath);
|
|
if (!rows.length) {
|
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
|
setTreeRowExpanded(row, button, true, options);
|
|
return true;
|
|
}
|
|
return renderCachedFileTreeChildren(row, button, relativePath, options);
|
|
} catch (error) {
|
|
setTreeLiveApplyError(error && error.message ? error.message : '文件树子目录加载失败');
|
|
return false;
|
|
} finally {
|
|
row.removeAttribute('data-filetree-children-loading');
|
|
}
|
|
}
|
|
|
|
function fileTreeParentChainForRelativePath(relativePath) {
|
|
var normalized = normalizeFileTreeRelativePath(relativePath);
|
|
if (!normalized) return [];
|
|
var parts = normalized.split('/').filter(Boolean);
|
|
parts.pop();
|
|
var chain = [];
|
|
for (var index = 0; index < parts.length; index += 1) {
|
|
chain.push(parts.slice(0, index + 1).join('/'));
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
async function revealFileTreeResource(input) {
|
|
input = input || {};
|
|
var rootUri = String(input.rootUri || currentRootUri() || '').trim();
|
|
if (!rootUri || rootUri !== currentRootUri()) return false;
|
|
ensureFileTreeLazyCacheScope();
|
|
var relativePath = normalizeFileTreeRelativePath(input.relativePath || input.relative_path || '');
|
|
var rowId = String(input.rowId || input.row_id || '').trim();
|
|
var requestKey = [rootUri, currentFileTreeScope(), relativePath, rowId].join('\n');
|
|
if (fileTreeRevealRequests.has(requestKey)) return fileTreeRevealRequests.get(requestKey);
|
|
var request = (async function() {
|
|
var parentChain = fileTreeParentChainForRelativePath(relativePath);
|
|
for (var index = 0; index < parentChain.length; index += 1) {
|
|
var parentRelativePath = parentChain[index];
|
|
var parentRow = fileTreeRowByRelativePath(parentRelativePath);
|
|
if (!(parentRow instanceof HTMLElement)) continue;
|
|
var button = parentRow.querySelector('[data-rust-action="toggle"]');
|
|
var childrenLoaded = parentRow.getAttribute('data-filetree-children-loaded') === 'true';
|
|
var expanded = parentRow.getAttribute('aria-expanded') === 'true';
|
|
if (!childrenLoaded) {
|
|
await getFileTreeChildren(parentRelativePath);
|
|
renderCachedFileTreeChildren(parentRow, button, parentRelativePath);
|
|
}
|
|
if (!expanded) {
|
|
setTreeRowExpanded(parentRow, button, true);
|
|
}
|
|
}
|
|
var targetRow = rowId
|
|
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]')
|
|
: null;
|
|
if (!(targetRow instanceof HTMLElement) && relativePath) {
|
|
targetRow = fileTreeRowByRelativePath(relativePath);
|
|
}
|
|
if (!(targetRow instanceof HTMLElement)) return false;
|
|
var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim();
|
|
if (targetRowId) {
|
|
fileTreeViewState.selectedRowIds = new Set([targetRowId]);
|
|
fileTreeViewState.focusedRowId = targetRowId;
|
|
fileTreeViewState.activeRowId = targetRowId;
|
|
}
|
|
reprojectFileTreeSelectionState();
|
|
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
|
|
return true;
|
|
})().finally(function() {
|
|
fileTreeRevealRequests.delete(requestKey);
|
|
});
|
|
fileTreeRevealRequests.set(requestKey, request);
|
|
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;
|
|
}
|
|
children.classList.toggle('tree-children--collapsed');
|
|
var collapsed = children.classList.contains('tree-children--collapsed');
|
|
setTreeRowExpanded(row, button, !collapsed);
|
|
}
|
|
|
|
function shouldAutoRestoreFileTreeExpansion(relativePath) {
|
|
var normalized = normalizeFileTreeRelativePath(relativePath);
|
|
if (!normalized) return false;
|
|
var scope = normalizeFileTreeRelativePath(currentFileTreeScope());
|
|
if (!scope) return normalized.indexOf('/') < 0;
|
|
if (normalized.indexOf(scope + '/') !== 0) return false;
|
|
return normalized.slice(scope.length + 1).indexOf('/') < 0;
|
|
}
|
|
|
|
function restorePersistedFileTreeExpansionState() {
|
|
if (currentSourceKind() !== 'local_folder') return false;
|
|
ensureFileTreeLazyCacheScope();
|
|
if (!fileTreeExpandedRelativePaths.size) return false;
|
|
var restored = false;
|
|
var needsVisibleHydrate = false;
|
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
if (String(row.getAttribute('data-row-kind') || '').trim() !== 'folder') return;
|
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
|
if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return;
|
|
// 首屏只自动恢复当前 scope 的直接子目录。历史 view-state 可能记录了
|
|
// 多层 design 目录展开;一次性递归恢复会长时间占满浏览器主线程。
|
|
if (!shouldAutoRestoreFileTreeExpansion(relativePath)) return;
|
|
var button = row.querySelector('[data-rust-action="toggle"]');
|
|
if (markExistingFileTreeChildrenLoaded(row, button, { persist: false })) {
|
|
restored = true;
|
|
return;
|
|
}
|
|
if (row.getAttribute('data-filetree-children-loaded') === 'true') {
|
|
setTreeRowExpanded(row, button, true, { persist: false });
|
|
restored = true;
|
|
return;
|
|
}
|
|
setTreeRowExpanded(row, button, true, { persist: false });
|
|
needsVisibleHydrate = true;
|
|
restored = true;
|
|
});
|
|
if (restored) document.documentElement.setAttribute('data-mnote-filetree-expansion-restored', 'true');
|
|
if (needsVisibleHydrate) scheduleHydrateVisibleExpandedFileTreeRows('restore');
|
|
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');
|
|
flushPendingSidebarTreeViewState('pagetree');
|
|
});
|
|
window.addEventListener('pagehide', function() {
|
|
flushPendingSidebarTreeViewState('filetree');
|
|
flushPendingSidebarTreeViewState('pagetree');
|
|
});
|
|
document.addEventListener('scroll', function() {
|
|
scheduleHydrateVisibleExpandedFileTreeRows('scroll');
|
|
}, true);
|
|
|
|
window.addEventListener('tree:title-updated', function(event) {
|
|
var detail = event.detail || {};
|
|
var previousDocumentId = detail.previousDocumentId || (detail.payload && detail.payload.result && detail.payload.result.previousDocumentId) || '';
|
|
if (previousDocumentId && previousDocumentId !== detail.documentId) {
|
|
removeDocumentRowForMode('page', previousDocumentId);
|
|
removeDocumentRowForMode('filetree', previousDocumentId);
|
|
if (currentDocumentId() === previousDocumentId) {
|
|
navigateToDocument(detail.documentId, detail.workspaceId || currentWorkspaceId(), { treeView: activeSidebarTreeMode() });
|
|
}
|
|
}
|
|
updateTitleEverywhere(detail.documentId, detail.title);
|
|
document.documentElement.setAttribute('data-mnote-title-local-applied', 'true');
|
|
});
|
|
|
|
window.addEventListener('tree:local-command', function(event) {
|
|
var detail = event.detail || {};
|
|
var body = detail.body || {};
|
|
var action = String(body.action || '').trim();
|
|
var result = detail.result || {};
|
|
if (action === 'create') {
|
|
applyCreatedDocumentLocally(result, body.parentId || null, body.title || '新页面');
|
|
refreshLocalFolderAfterCommand(action, result, body);
|
|
return;
|
|
}
|
|
if (action === 'rename' && body.documentId && body.title) {
|
|
var newDocumentId = commandDocumentId(result, body.documentId);
|
|
if (newDocumentId && newDocumentId !== body.documentId) {
|
|
removeDocumentRowForMode('page', body.documentId);
|
|
removeDocumentRowForMode('filetree', body.documentId);
|
|
if (currentDocumentId() === body.documentId) {
|
|
navigateToDocument(newDocumentId, body.workspaceId || currentWorkspaceId(), { treeView: activeSidebarTreeMode() });
|
|
}
|
|
} else {
|
|
updateTitleEverywhere(body.documentId, body.title);
|
|
}
|
|
refreshLocalFolderAfterCommand(action, result, body);
|
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename');
|
|
return;
|
|
}
|
|
if (action === 'move' && body.documentId) {
|
|
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) {
|
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move');
|
|
}
|
|
refreshLocalFolderAfterCommand(action, result, body);
|
|
return;
|
|
}
|
|
if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) {
|
|
if (applyRemoveDocumentDelta({ documentId: body.documentId })) {
|
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove');
|
|
}
|
|
refreshLocalFolderAfterCommand(action, result, body);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('tree:local-command-batch-complete', function(event) {
|
|
var detail = event.detail || {};
|
|
flushFileTreeBatchRefresh(detail.batchId || detail.batch_id || '');
|
|
});
|
|
|
|
window.addEventListener('tree:snapshot', function(event) {
|
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
|
if (renderLiveSidebarSnapshot(payload)) {
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
|
|
return;
|
|
}
|
|
setTreeLiveApplyError('tree_snapshot_missing_projection_payload');
|
|
});
|
|
|
|
window.addEventListener('tree:delta', function(event) {
|
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
|
var data = payload && (payload.data || payload.delta || payload);
|
|
var documentPatch = data && (data.document || data.node);
|
|
if (data && data.op === 'upsert_document' && documentPatch) {
|
|
updateTitleEverywhere(documentPatch.id || documentPatch.documentId, documentPatch.title || '无标题');
|
|
}
|
|
if (data && data.op === 'move_document' && applyMoveDocumentDelta(data)) {
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
|
return;
|
|
}
|
|
if (data && data.op === 'remove_document' && applyRemoveDocumentDelta(data)) {
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
|
return;
|
|
}
|
|
var documents = data && (data.upsertDocuments || data.upsert_documents);
|
|
if (Array.isArray(documents)) {
|
|
documents.forEach(function(doc) {
|
|
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
|
|
});
|
|
}
|
|
if (renderLiveSidebarSnapshot(payload)) {
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
|
return;
|
|
}
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
|
if (deltaNeedsProjectionRefresh(payload)) {
|
|
setTreeLiveApplyError('tree_delta_missing_projection_payload');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('tree:resync', function(event) {
|
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
|
if (renderLiveSidebarSnapshot(payload)) {
|
|
refreshEditorLocalAttachmentExistence();
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
|
return;
|
|
}
|
|
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
|
setTreeLiveApplyError('tree_resync_missing_projection_payload');
|
|
});
|
|
|
|
window.addEventListener('mnote:local-folder:sidebar-refresh-requested', function(event) {
|
|
var detail = event.detail || {};
|
|
document.documentElement.setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-received', String(detail.reason || 'event-bus'));
|
|
applyLocalFolderWatchBatch(detail);
|
|
});
|
|
|
|
window.addEventListener('tree:local-folder-watch-batch', function(event) {
|
|
if (event.detail && event.detail.viaEventBus === true) {
|
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-skipped', 'event-bus-orchestrated');
|
|
return;
|
|
}
|
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
|
applyLocalFolderWatchBatch(payload);
|
|
});
|
|
|
|
window.addEventListener('tree:error', function(event) {
|
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
|
var code = payload && (payload.code || payload.error || payload.message);
|
|
document.documentElement.setAttribute('data-mnote-tree-live-error-schema', String(payload && payload.schema || ''));
|
|
setTreeLiveApplyError(code || 'tree_live_error');
|
|
});
|
|
}
|
|
|
|
return {
|
|
applyCreatedDocumentLocally,
|
|
applyMoveDocumentDelta,
|
|
applyRemoveDocumentDelta,
|
|
commandDocumentId,
|
|
commandDocumentTitle,
|
|
deltaNeedsProjectionRefresh,
|
|
fileTreePageTitle,
|
|
flushFileTreeBatchRefresh,
|
|
installTreeLiveApplyEventListeners,
|
|
isFileTreePageRow,
|
|
applyLocalFolderWatchBatch,
|
|
hydratePendingFileTreeShell,
|
|
isPendingFileTreeShell,
|
|
localCommandNeedsProjectionRefresh,
|
|
normalizeFileTreePageRenameTitle,
|
|
objectIdentityAttr,
|
|
refreshLocalFolderSidebarSnapshot,
|
|
refreshLocalFolderAfterCommand,
|
|
removeDocumentRowForMode,
|
|
renderPageProjection,
|
|
renderSidebarSnapshot,
|
|
revealFileTreeResource,
|
|
restorePersistedFileTreeExpansionState,
|
|
restorePersistedPageTreeExpansionState,
|
|
setTreeLiveApplyError,
|
|
startLocalFolderSidebarWatch,
|
|
toggleChildren,
|
|
updateTitleEverywhere,
|
|
validateFileTreeRename,
|
|
};
|
|
};
|