Implement sidebar tree view state persistence
This commit is contained in:
@@ -13,6 +13,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
||||
persistLocalFolderSelfChangeSuppression,
|
||||
persistSidebarTreeMode,
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
refreshLocalFolderAfterCommand,
|
||||
resolveWorkspaceId,
|
||||
rowCenter,
|
||||
selectSidebarFileTreeDocument,
|
||||
@@ -107,7 +108,9 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
||||
persistLocalFolderSelfChangeSuppression(nextDocumentId, Date.now() + 5000);
|
||||
}
|
||||
if (nextDocumentId && currentSourceKind() === 'local_folder') {
|
||||
await refreshLocalFolderSidebarSnapshot();
|
||||
if (typeof refreshLocalFolderAfterCommand === 'function') {
|
||||
await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null });
|
||||
}
|
||||
selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true });
|
||||
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -362,6 +362,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
persistLocalFolderSelfChangeSuppression,
|
||||
persistSidebarTreeMode,
|
||||
refreshLocalFolderSidebarSnapshot: (...args) => refreshLocalFolderSidebarSnapshot(...args),
|
||||
refreshLocalFolderAfterCommand: (...args) => refreshLocalFolderAfterCommand(...args),
|
||||
resolveWorkspaceId,
|
||||
rowCenter: (...args) => rowCenter(...args),
|
||||
selectSidebarFileTreeDocument: (...args) => selectSidebarFileTreeDocument(...args),
|
||||
@@ -377,6 +378,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const removeDocumentRowForMode = (...args) => sidebarTreeLiveApply.removeDocumentRowForMode(...args);
|
||||
const applyCreatedDocumentLocally = (...args) => sidebarTreeLiveApply.applyCreatedDocumentLocally(...args);
|
||||
const localCommandNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.localCommandNeedsProjectionRefresh(...args);
|
||||
const refreshLocalFolderAfterCommand = (...args) => sidebarTreeLiveApply.refreshLocalFolderAfterCommand(...args);
|
||||
const applyMoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyMoveDocumentDelta(...args);
|
||||
const applyRemoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyRemoveDocumentDelta(...args);
|
||||
const setTreeLiveApplyError = (...args) => sidebarTreeLiveApply.setTreeLiveApplyError(...args);
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
@@ -326,7 +327,11 @@ pub async fn root_entry(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||
let page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
|
||||
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
let workspace_id = page_tree_snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
@@ -2860,6 +2865,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_filetree_scope_renders_scoped_page_tree() {
|
||||
let root = temp_root("mnote-root-local-folder-scoped-page-tree");
|
||||
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
|
||||
.expect("create scoped design tree");
|
||||
std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
|
||||
std::fs::write(
|
||||
root.join("design")
|
||||
.join("05-editor-mainline")
|
||||
.join("Target.md"),
|
||||
"# Target\n",
|
||||
)
|
||||
.expect("write scoped page");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
|
||||
assert!(
|
||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
|
||||
let root = temp_root("mnote-root-local-folder-no-active-page");
|
||||
|
||||
@@ -4330,8 +4330,8 @@ fn add_local_tree_command_affected_parents(result: &Value, action: &str) -> Vec<
|
||||
"old-parent",
|
||||
);
|
||||
}
|
||||
if let Some(parent_path) = local_tree_result_string(result, "parentRelativePath") {
|
||||
push_local_tree_affected_parent(&mut parents, &mut seen, parent_path, "parent");
|
||||
if let Some(parent_path) = result.get("parentRelativePath").and_then(Value::as_str) {
|
||||
push_local_tree_affected_parent(&mut parents, &mut seen, parent_path.to_string(), "parent");
|
||||
}
|
||||
if normalized_action != "delete"
|
||||
&& normalized_action != "trash"
|
||||
@@ -4607,6 +4607,7 @@ fn create_local_markdown_page(
|
||||
let stem = determine_page_stem(&parent_directory, title);
|
||||
let dir_target = parent_directory.join(&stem);
|
||||
let target = dir_target.join(format!("{}.md", stem));
|
||||
let parent_relative_path = normalize_relative_path(root, &parent_directory)?;
|
||||
let relative_path = normalize_relative_path(root, &target)?;
|
||||
let page_id = local_markdown_path_page_id(&relative_path);
|
||||
let display_title = file_stem_title(&format!("{stem}.md"));
|
||||
@@ -4628,6 +4629,7 @@ fn create_local_markdown_page(
|
||||
"id": page_id,
|
||||
"documentId": page_id,
|
||||
"title": display_title,
|
||||
"parentRelativePath": parent_relative_path,
|
||||
"relativePath": relative_path,
|
||||
"action": "create",
|
||||
"sourceKind": "local_folder",
|
||||
@@ -4642,6 +4644,7 @@ fn create_local_folder(
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
|
||||
let target = parent_directory.join(determine_folder_stem(&parent_directory, title));
|
||||
let parent_relative_path = normalize_relative_path(root, &parent_directory)?;
|
||||
fs::create_dir_all(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
@@ -4651,6 +4654,7 @@ fn create_local_folder(
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": local_directory_group_id(&normalize_relative_path(root, &target)?),
|
||||
"parentRelativePath": parent_relative_path,
|
||||
"relativePath": normalize_relative_path(root, &target)?,
|
||||
"action": "createFolder",
|
||||
"sourceKind": "local_folder",
|
||||
@@ -12620,6 +12624,16 @@ fn main() {}
|
||||
);
|
||||
assert_eq!(result["action"], "create");
|
||||
assert_eq!(result["sourceKind"], "local_folder");
|
||||
assert_eq!(result["parentRelativePath"], "");
|
||||
assert!(
|
||||
result["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("")),
|
||||
"create page affectedParents 应包含承载新页面包的父级 root: {}",
|
||||
result["affectedParents"]
|
||||
);
|
||||
|
||||
// 页面标题来自文件名,默认正文不再写第二个标题真相。
|
||||
let content = std::fs::read_to_string(root.join(relative_path)).expect("read markdown");
|
||||
@@ -12661,6 +12675,16 @@ fn main() {}
|
||||
.exists(),
|
||||
"default folder creation must not create page markdown"
|
||||
);
|
||||
assert_eq!(result["parentRelativePath"], "");
|
||||
assert!(
|
||||
result["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("")),
|
||||
"create folder affectedParents 应包含承载新文件夹的父级 root: {}",
|
||||
result["affectedParents"]
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
mod tree_view_state;
|
||||
pub(crate) mod ui_preferences;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
@@ -92,6 +93,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/ui/preferences",
|
||||
put(ui_preferences::update_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/view-state",
|
||||
get(tree_view_state::get_tree_view_state).put(tree_view_state::put_tree_view_state),
|
||||
)
|
||||
.route(
|
||||
"/api/leptos-tiptap-runtime/manifest.json",
|
||||
get(web_shell::leptos_tiptap_manifest),
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
||||
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
||||
const MAX_STATE_ITEMS: usize = 512;
|
||||
const MAX_ID_LENGTH: usize = 2048;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TreeViewStateQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "tree_kind")]
|
||||
tree_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TreeViewStateUpdateRequest {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "tree_kind")]
|
||||
tree_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
state: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TreeViewStateScope {
|
||||
workspace_id: String,
|
||||
source_kind: String,
|
||||
tree_kind: String,
|
||||
root_uri: String,
|
||||
scope: String,
|
||||
scope_id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_tree_view_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<TreeViewStateQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
&query.workspace_id,
|
||||
&query.source_kind,
|
||||
&query.tree_kind,
|
||||
&query.root_uri,
|
||||
&query.scope,
|
||||
)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(&actor_id, Some(&scope.workspace_id), Some(&scope.source_kind))
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let Some(record) = find_tree_view_state_record(&preferences, &scope) else {
|
||||
return Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"default",
|
||||
None,
|
||||
default_state(&scope),
|
||||
)));
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(&record.value_json).map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state JSON 无效: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let normalized = normalize_state(&context, &scope, parsed)?;
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn put_tree_view_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<TreeViewStateUpdateRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
ensure_actor_user(&state, &actor_id)?;
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
&request.workspace_id,
|
||||
&request.source_kind,
|
||||
&request.tree_kind,
|
||||
&request.root_uri,
|
||||
&request.scope,
|
||||
)?;
|
||||
let normalized = normalize_state(&context, &scope, request.state)?;
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: actor_id.clone(),
|
||||
workspace_id: Some(scope.workspace_id.clone()),
|
||||
source_kind: Some(scope.source_kind.clone()),
|
||||
scope_kind: SIDEBAR_TREE_SCOPE_KIND.to_string(),
|
||||
scope_id: scope.scope_id.clone(),
|
||||
key: SIDEBAR_TREE_VIEW_STATE_KEY.to_string(),
|
||||
value_json: normalized.to_string(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"tree_view_state_write_failed",
|
||||
format!("写入 tree view state 失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
}
|
||||
|
||||
fn find_tree_view_state_record<'a>(
|
||||
preferences: &'a [UserUiPreferenceRecord],
|
||||
scope: &TreeViewStateScope,
|
||||
) -> Option<&'a UserUiPreferenceRecord> {
|
||||
preferences.iter().find(|preference| {
|
||||
preference.scope_kind == SIDEBAR_TREE_SCOPE_KIND
|
||||
&& preference.scope_id == scope.scope_id
|
||||
&& preference.key == SIDEBAR_TREE_VIEW_STATE_KEY
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_scope(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
tree_kind: &str,
|
||||
root_uri: &str,
|
||||
scope: &str,
|
||||
) -> Result<TreeViewStateScope, WebError> {
|
||||
let source_kind = normalize_or_default(source_kind, "convex_workspace");
|
||||
let tree_kind = normalize_tree_kind(context, tree_kind)?;
|
||||
let root_uri = root_uri.trim().to_string();
|
||||
let scope = normalize_or_default(scope, "root");
|
||||
let workspace_id = if source_kind == "local_folder" {
|
||||
local_workspace_id_from_root_uri(&root_uri).map_err(|error| error.with_context(context))?
|
||||
} else {
|
||||
normalize_or_default(workspace_id, "default")
|
||||
};
|
||||
let scope_id = format!(
|
||||
"{}:{}:{}",
|
||||
tree_kind,
|
||||
stable_hash_16(&root_uri),
|
||||
stable_hash_16(&scope)
|
||||
);
|
||||
Ok(TreeViewStateScope {
|
||||
workspace_id,
|
||||
source_kind,
|
||||
tree_kind,
|
||||
root_uri,
|
||||
scope,
|
||||
scope_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_tree_kind(context: &RequestContext, tree_kind: &str) -> Result<String, WebError> {
|
||||
match tree_kind.trim() {
|
||||
"filetree" => Ok("filetree".to_string()),
|
||||
"pagetree" => Ok("pagetree".to_string()),
|
||||
_ => Err(WebError::bad_request_code(
|
||||
"tree_view_state_tree_kind_invalid",
|
||||
"treeKind 只允许 filetree 或 pagetree",
|
||||
)
|
||||
.with_context(context)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_state(
|
||||
context: &RequestContext,
|
||||
scope: &TreeViewStateScope,
|
||||
value: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let schema_version = object
|
||||
.get("schemaVersion")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1);
|
||||
if schema_version != 1 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_view_state_schema_invalid",
|
||||
"schemaVersion 必须为 1",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
for derived_key in [
|
||||
"rowsByParent",
|
||||
"loadedParents",
|
||||
"loadingParents",
|
||||
"dirtyParents",
|
||||
"staleParents",
|
||||
"revisionByParent",
|
||||
"latestGenerationByParent",
|
||||
] {
|
||||
if object.contains_key(derived_key) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_view_state_contains_derived_cache",
|
||||
format!("tree view state 不能包含派生运行态 {derived_key}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
}
|
||||
object.insert("schemaVersion".to_string(), json!(1));
|
||||
object.insert("treeKind".to_string(), json!(scope.tree_kind));
|
||||
object.insert("rootUri".to_string(), json!(scope.root_uri));
|
||||
object.insert("scope".to_string(), json!(scope.scope));
|
||||
object.insert(
|
||||
"expandedIds".to_string(),
|
||||
json!(bounded_string_array(object.get("expandedIds"), MAX_STATE_ITEMS)),
|
||||
);
|
||||
object.insert(
|
||||
"expandedRelativePaths".to_string(),
|
||||
json!(bounded_string_array(
|
||||
object.get("expandedRelativePaths"),
|
||||
MAX_STATE_ITEMS
|
||||
)),
|
||||
);
|
||||
for key in ["selectedId", "focusedId", "activeId"] {
|
||||
object.insert(key.to_string(), json!(bounded_string(object.get(key))));
|
||||
}
|
||||
object.insert(
|
||||
"scrollTop".to_string(),
|
||||
json!(object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)),
|
||||
);
|
||||
if !object.contains_key("updatedAtMs") {
|
||||
object.insert("updatedAtMs".to_string(), json!(0));
|
||||
}
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
fn default_state(scope: &TreeViewStateScope) -> Value {
|
||||
normalize_state(
|
||||
&RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("static uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
),
|
||||
scope,
|
||||
json!({ "schemaVersion": 1 }),
|
||||
)
|
||||
.expect("default tree view state is valid")
|
||||
}
|
||||
|
||||
fn response_payload(
|
||||
actor_id: &str,
|
||||
scope: &TreeViewStateScope,
|
||||
source: &str,
|
||||
revision: Option<i64>,
|
||||
state: Value,
|
||||
) -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"userId": actor_id,
|
||||
"workspaceId": scope.workspace_id,
|
||||
"sourceKind": scope.source_kind,
|
||||
"treeKind": scope.tree_kind,
|
||||
"rootUri": scope.root_uri,
|
||||
"scope": scope.scope,
|
||||
"scopeId": scope.scope_id,
|
||||
"scopeKind": SIDEBAR_TREE_SCOPE_KIND,
|
||||
"key": SIDEBAR_TREE_VIEW_STATE_KEY,
|
||||
"source": source,
|
||||
"revision": revision,
|
||||
"state": state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bounded_string_array(value: Option<&Value>, limit: usize) -> Vec<String> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(|item| item.chars().take(MAX_ID_LENGTH).collect::<String>())
|
||||
.take(limit)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn bounded_string(value: Option<&Value>) -> String {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(MAX_ID_LENGTH).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_or_default(value: &str, default: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash_16(value: &str) -> String {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in value.trim().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"tree_view_state_auth_required",
|
||||
"tree view state 需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(actor_id.to_string()),
|
||||
email: None,
|
||||
username: actor_id.to_string(),
|
||||
display_name: actor_id.to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| WebError::internal(format!("确保用户记录失败: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stable_scope_id_separates_tree_kind_and_scope() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let filetree = resolve_scope(
|
||||
&context,
|
||||
"local:_mnt_Data1T_mnote",
|
||||
"local_folder",
|
||||
"filetree",
|
||||
"file:///mnt/Data1T/mnote",
|
||||
"design",
|
||||
)
|
||||
.expect("filetree scope");
|
||||
let pagetree = resolve_scope(
|
||||
&context,
|
||||
"local:_mnt_Data1T_mnote",
|
||||
"local_folder",
|
||||
"pagetree",
|
||||
"file:///mnt/Data1T/mnote",
|
||||
"design",
|
||||
)
|
||||
.expect("pagetree scope");
|
||||
assert_ne!(filetree.scope_id, pagetree.scope_id);
|
||||
assert!(filetree.scope_id.starts_with("filetree:"));
|
||||
assert!(pagetree.scope_id.starts_with("pagetree:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_state_rejects_derived_cache() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::GET,
|
||||
&"/api/tree/view-state".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let root = std::env::current_dir().expect("cwd");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let scope = resolve_scope(
|
||||
&context,
|
||||
"ws",
|
||||
"local_folder",
|
||||
"filetree",
|
||||
&root_uri,
|
||||
"root",
|
||||
)
|
||||
.expect("scope");
|
||||
let error = normalize_state(
|
||||
&context,
|
||||
&scope,
|
||||
json!({ "schemaVersion": 1, "rowsByParent": {} }),
|
||||
)
|
||||
.expect_err("derived cache rejected");
|
||||
assert_eq!(error.code(), "tree_view_state_contains_derived_cache");
|
||||
}
|
||||
}
|
||||
@@ -2610,6 +2610,128 @@ mod tests {
|
||||
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() {
|
||||
let app = app();
|
||||
let put_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/tree/view-state")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"userId": "mallory",
|
||||
"workspaceId": "default",
|
||||
"sourceKind": "local_folder",
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"expandedRelativePaths": ["design/05-editor-mainline"],
|
||||
"expandedIds": [],
|
||||
"selectedId": "local:folder:design/05-editor-mainline",
|
||||
"focusedId": "",
|
||||
"activeId": "",
|
||||
"scrollTop": 12
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(put_response.status(), StatusCode::OK);
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
|
||||
assert_eq!(put_payload["result"]["userId"], "alice");
|
||||
assert_ne!(put_payload["result"]["workspaceId"], "default");
|
||||
assert!(put_payload["result"]["scopeId"]
|
||||
.as_str()
|
||||
.expect("scope id")
|
||||
.starts_with("filetree:"));
|
||||
|
||||
let get_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let get_body = to_bytes(get_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
|
||||
assert_eq!(get_payload["result"]["source"], "sqlite");
|
||||
assert_eq!(
|
||||
get_payload["result"]["state"]["expandedRelativePaths"][0],
|
||||
"design/05-editor-mainline"
|
||||
);
|
||||
|
||||
let page_tree_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "alice")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(page_tree_response.status(), StatusCode::OK);
|
||||
let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json");
|
||||
assert_eq!(page_tree_payload["result"]["source"], "default");
|
||||
assert_eq!(
|
||||
page_tree_payload["result"]["state"]["expandedRelativePaths"]
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
let bob_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
|
||||
.header("x-mnote-actor-id", "bob")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(bob_response.status(), StatusCode::OK);
|
||||
let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json");
|
||||
assert_eq!(bob_payload["result"]["source"], "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
|
||||
@@ -346,6 +346,29 @@ mod tests {
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_live_apply_runtime_uses_persisted_tree_view_state() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("SIDEBAR_TREE_VIEW_STATE_KEY"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function loadSidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function persistSidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applySidebarTreeViewState"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/view-state"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}"));
|
||||
|
||||
let render_page_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
|
||||
assert!(render_page_rows.contains("sidebarTreeViewStateFor('pagetree'"));
|
||||
assert!(render_page_rows.contains("expandedIds"));
|
||||
assert!(
|
||||
!render_page_rows.contains("var expanded = expandable && item.expandedByDefault !== false"),
|
||||
"PageTree 不能只依赖 projection expandedByDefault 决定展开"
|
||||
);
|
||||
|
||||
let render_file_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
|
||||
assert!(render_file_rows.contains("sidebarTreeViewStateFor('filetree'"));
|
||||
assert!(render_file_rows.contains("expandedRelativePaths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
@@ -1163,6 +1186,35 @@ mod tests {
|
||||
.contains("if (!isLatestPageTreeRequest(generation))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_refreshes_scoped_page_tree_projection() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("sidebarUrl.searchParams.set('parentRelativePath', fileTreeScope)"),
|
||||
"scoped filetree 入口刷新 PageTree 时必须请求同 scope projection"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("sidebarTreeViewStateFor('pagetree')"),
|
||||
"SSR 初始 PageTree 也必须触发 pagetree 用户保存态加载"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_page_create_does_not_full_refresh_local_folder_sidebar() {
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderSidebarSnapshot();"),
|
||||
"本地新建页面不能全量刷新 sidebar,否则会折叠/重建其它已展开节点"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderAfterCommand('create'"),
|
||||
"本地新建页面应等待局部 parent refresh,而不是全量刷新 sidebar"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("selectSidebarFileTreeDocument(nextDocumentId"),
|
||||
"本地新建页面仍应只 reveal/select 新节点"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_prefers_command_affected_parents() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
|
||||
@@ -622,6 +622,7 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a,
|
||||
@@ -1374,7 +1375,7 @@ body {
|
||||
background: var(--atelier-sidebar);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-sidebar-header,
|
||||
@@ -1439,6 +1440,13 @@ body {
|
||||
padding: 0 8px 18px;
|
||||
}
|
||||
|
||||
.wolai-my-pages-section {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wolai-section-title,
|
||||
.wolai-sidebar-tabs {
|
||||
height: 32px;
|
||||
@@ -1496,7 +1504,10 @@ body {
|
||||
}
|
||||
|
||||
.wolai-sidebar-tab-panels {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wolai-sidebar-tab-panel[hidden] {
|
||||
@@ -1996,6 +2007,7 @@ body {
|
||||
}
|
||||
|
||||
.wolai-sidebar-footer {
|
||||
flex: 0 0 auto;
|
||||
border-top: 0;
|
||||
background: var(--atelier-sidebar);
|
||||
}
|
||||
@@ -4470,6 +4482,15 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_scroll_is_contained_to_tree_panels() {
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar,\n.wolai-sidebar {\n width: var(--mnote-sidebar-width, 248px);\n background: var(--atelier-sidebar);\n border-right: 0;\n box-shadow: none;\n overflow: hidden;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-body {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-my-pages-section {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-tab-panels {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-sidebar-footer {\n flex: 0 0 auto;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_policy_rows_wrap_long_grant_values() {
|
||||
assert!(MNOTE_CSS.contains(
|
||||
|
||||
Reference in New Issue
Block a user