Implement sidebar tree view state persistence

This commit is contained in:
lix-2026
2026-05-27 16:32:05 +08:00
parent bd7470d03e
commit e43efacbce
15 changed files with 1875 additions and 17 deletions
@@ -45,6 +45,12 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var fileTreeExpansionRestoreTimer = 0;
var fileTreeCommandBatchRefreshParents = new Map();
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;
@@ -438,12 +444,12 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
}
function refreshLocalFolderAfterCommand(action, result, body) {
if (!localCommandNeedsProjectionRefresh(action, result)) return;
if (!localCommandNeedsProjectionRefresh(action, result)) return Promise.resolve(false);
if (body && body.batchId) {
queueFileTreeBatchRefresh(body.batchId, result, body);
return;
return Promise.resolve(true);
}
void fileTreeRefreshParentsForCommand(result, body);
return fileTreeRefreshParentsForCommand(result, body);
}
function sortOrderFromDelta(data) {
@@ -624,6 +630,271 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
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();
var scope = currentFileTreeScope() || 'root';
return {
userId: currentActorStorageId(),
workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'),
sourceKind: String(currentSourceKind() || 'convex_workspace').trim() || 'convex_workspace',
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 || 'convex_workspace'))
.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);
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 loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, result.source === 'sqlite' ? 'sqlite' : 'default');
sidebarTreeViewStates.set(key, loaded);
applySidebarTreeViewState(scope.treeKind, loaded);
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 node = row.closest('.tree-node');
var children = node ? node.querySelector(':scope > .tree-children') : null;
if (!nodeId || !button || !(children instanceof HTMLElement)) return;
var expanded = state.expandedIds.has(nodeId);
row.setAttribute('aria-expanded', String(expanded));
button.setAttribute('aria-expanded', String(expanded));
children.classList.toggle('tree-children--collapsed', !expanded);
changed = true;
});
if (changed) document.documentElement.setAttribute('data-mnote-page-tree-view-state-applied', state.scope || 'root');
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();
@@ -692,6 +963,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
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);
@@ -699,7 +973,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var parent = parentIdOf(item);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var expanded = expandable && (
pageTreeExpandedIds.has(nodeId) ||
(!pageTreeStateView.hasUserState && (activeParentIds.has(nodeId) || item.expandedByDefault !== false))
);
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var openable = Boolean(meta.documentId || item.documentId || !String(nodeId).startsWith('local-dir:'));
var toggle = expandable
@@ -816,6 +1093,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
}
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);
@@ -835,7 +1114,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length);
var expanded = expandable && (fileTreeExpandedRelativePaths.has(relativePath) || item.expandedByDefault !== false);
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
@@ -871,7 +1150,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
fileTreeState.revisionByParent.clear();
fileTreeState.latestGenerationByParent.clear();
fileTreeState.requestGeneration += 1;
fileTreeExpandedRelativePaths.clear();
var nextState = sidebarTreeViewStateFor('filetree');
fileTreeExpandedRelativePaths = nextState.expandedRelativePaths;
fileTreeViewState.expandedParents = nextState.expandedRelativePaths;
loadStoredFileTreeExpansionState(rootUri);
}
@@ -889,21 +1170,31 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
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) fileTreeExpandedRelativePaths.add(normalized);
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(fileTreeExpandedRelativePaths)
buckets[rootUri] = Array.from(state.expandedRelativePaths)
.map(function(path) { return String(path || '').trim(); })
.filter(Boolean)
.slice(0, 256);
@@ -1144,6 +1435,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
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)
@@ -1281,15 +1573,27 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function setTreeRowExpanded(row, button, expanded) {
row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
var shellMode = row.getAttribute('data-shell-mode') || '';
if (button) {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
button.textContent = expanded ? '▾' : '▸';
if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸';
}
if (shellMode === 'page') {
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;
persistSidebarTreeViewState('pagetree');
return;
}
var relativePath = localFileTreeRelativePathFromRow(row);
if (!relativePath) return;
if (expanded) fileTreeExpandedRelativePaths.add(relativePath);
else fileTreeExpandedRelativePaths.delete(relativePath);
persistFileTreeExpansionState();
persistSidebarTreeViewState('filetree');
}
function markExistingFileTreeChildrenLoaded(row, button) {
@@ -1462,7 +1766,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
restored = true;
return;
}
void loadFileTreeChildren(row, button);
void loadFileTreeChildren(row, button).then(function(loaded) {
if (loaded) restorePersistedFileTreeExpansionState();
});
restored = true;
});
if (restored) document.documentElement.setAttribute('data-mnote-filetree-expansion-restored', 'true');
@@ -1471,6 +1777,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function installTreeLiveApplyEventListeners() {
sidebarTreeViewStateFor('pagetree');
document.addEventListener('visibilitychange', function() {
if (!document.hidden) return;
flushPendingSidebarTreeViewState('filetree');
flushPendingSidebarTreeViewState('pagetree');
});
window.addEventListener('pagehide', function() {
flushPendingSidebarTreeViewState('filetree');
flushPendingSidebarTreeViewState('pagetree');
});
window.addEventListener('tree:title-updated', function(event) {
var detail = event.detail || {};
var previousDocumentId = detail.previousDocumentId || (detail.payload && detail.payload.result && detail.payload.result.previousDocumentId) || '';
@@ -1611,6 +1928,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
normalizeFileTreePageRenameTitle,
objectIdentityAttr,
refreshLocalFolderSidebarSnapshot,
refreshLocalFolderAfterCommand,
removeDocumentRowForMode,
renderSidebarSnapshot,
revealFileTreeResource,