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
+70
View File
@@ -2402,6 +2402,76 @@ mod tests {
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
.expect("list bob preferences");
assert!(bob_preferences.is_empty());
let sidebar_scope = "filetree:root-hash:design-hash";
let sidebar_value = serde_json::json!({
"schemaVersion": 1,
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"expandedRelativePaths": ["design/05-editor-mainline"],
"expandedIds": [],
"selectedId": "",
"focusedId": "",
"activeId": "",
"scrollTop": 0
})
.to_string();
let sidebar_first = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "sidebar_tree".to_string(),
scope_id: sidebar_scope.to_string(),
key: "sidebarTreeViewState.v1".to_string(),
value_json: sidebar_value,
})
.expect("insert sidebar tree view state");
let sidebar_updated = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "sidebar_tree".to_string(),
scope_id: sidebar_scope.to_string(),
key: "sidebarTreeViewState.v1".to_string(),
value_json: serde_json::json!({
"schemaVersion": 1,
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"],
"expandedIds": [],
"selectedId": "",
"focusedId": "",
"activeId": "",
"scrollTop": 0
})
.to_string(),
})
.expect("upsert sidebar tree view state");
assert_eq!(sidebar_updated.id, sidebar_first.id);
assert_eq!(sidebar_updated.revision, sidebar_first.revision + 1);
let alice_sidebar_preferences = store
.list_user_ui_preferences(
"alice",
Some("local:_mnt_Data1T_mnote"),
Some("local_folder"),
)
.expect("list alice sidebar preferences");
assert!(alice_sidebar_preferences.iter().any(|preference| {
preference.scope_kind == "sidebar_tree"
&& preference.scope_id == sidebar_scope
&& preference.key == "sidebarTreeViewState.v1"
}));
let bob_sidebar_preferences = store
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
.expect("list bob sidebar preferences");
assert!(bob_sidebar_preferences.is_empty());
}
#[test]
@@ -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);
+56 -2
View File
@@ -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);
}
+5
View File
@@ -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
+22 -1
View File
@@ -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(