Implement sidebar tree view state persistence
This commit is contained in:
@@ -360,6 +360,183 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js
|
||||
- 2026-05-27:Rust filters 通过:`root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html`、`sidebar_runtime_applies_page_projection_with_generation_and_yield`、`sidebar_filetree_runtime`、`local_folder_file_projection`、`tree_projection_routes`、`local_folder_file_tree_children_snapshot_scans_parent_once`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`。
|
||||
- 2026-05-27:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=33`。
|
||||
|
||||
## 2026-05-27 SidebarTreeViewState 用户保存态跟进
|
||||
|
||||
### 再次 Sidex 对照结论
|
||||
|
||||
Sidex / VSCode Explorer 的展开程度不是由每次 projection 的 `expandedByDefault` 推断出来,而是由 TreeViewState 驱动:
|
||||
|
||||
- `explorerView.ts` 定义 `TREE_VIEW_STATE_STORAGE_KEY = 'workbench.explorer.treeViewState'`。
|
||||
- `onWillSaveState` 与 `updateAnyCollapsedContext()` 都会调用 `storeTreeViewState()`,写入 `JSON.stringify(this.tree.getViewState())`。
|
||||
- `setTreeInput()` 首次输入时从 workspace storage 读取 view state;已有 input 时直接使用当前 `tree.getViewState()`。
|
||||
- 无 view state 时才使用保守默认展开:多 root 最多展开 5 个 root;单 folder workspace 受 `explorer.expandSingleFolderWorkspaces` 控制。
|
||||
- 默认 `collapseByDefault` 返回 `true`,children 默认折叠;打开 / reveal 文件时只展开目标 parent chain。
|
||||
- filter session 会临时保存 `tree.getViewState()`,说明 Sidex 把展开、选择、焦点等视为 tree widget state,而不是文件系统 projection state。
|
||||
|
||||
MNote 当前状态与 Sidex 的差距:
|
||||
|
||||
- FileTree 只有 `mnote.localFileTree.expandedRelativePaths.v1`,使用 `sessionStorage` 按 `rootUri` 保存 expanded relative paths;它不是用户级持久态,也没有完整区分 `workspaceId/sourceKind/treeKind/scope`。
|
||||
- PageTree 没有用户展开保存态,`renderPageRows()` 直接用 `item.expandedByDefault !== false`;本地 PageTree 后端还有 `depth < 2` 默认展开,所以用户看到的“有开有关”主要来自后端 projection 默认值。
|
||||
- `fileTreeViewState` 里已有 `selectedRowIds/focusedRowId/activeRowId/scrollTop` 字段,但当前没有作为完整 view state 写入 control-plane,也没有统一应用到 PageTree。
|
||||
- debug/internal `tree-shell-runtime.js` 的内存 `expandedIds` 不是主 sidebar 产品路径保存态,不能作为本问题的已完成实现。
|
||||
|
||||
### 存储决策
|
||||
|
||||
`SidebarTreeViewState` 是用户 UI 偏好态,应以 SQLite control-plane 为主存储,浏览器 storage 只能作为首屏镜像 / 离线加速缓存。
|
||||
|
||||
优先复用现有 `user_ui_preferences` 表,不新增专表:
|
||||
|
||||
- 现有表已按 `user_id/workspace_id/source_kind/scope_kind/scope_id/key` 做唯一约束。
|
||||
- `control-plane` 已有 `upsert_user_ui_preference()` 与 `list_user_ui_preferences()`,并已有用户隔离测试。
|
||||
- 新状态可作为 `key = 'sidebarTreeViewState.v1'` 的 JSON value 存入该表。
|
||||
- `scope_kind = 'sidebar_tree'`,`scope_id` 使用稳定 scope key:`{treeKind}:{rootUriHash}:{scopeHash}`。
|
||||
- `workspace_id` 使用当前 workspace;local folder workspace 继续使用 `local_workspace_id_from_root_uri(rootUri)`。
|
||||
- `source_kind` 使用当前 sourceKind,例如 `local_folder`。
|
||||
|
||||
浏览器镜像只允许作为非权威缓存:
|
||||
|
||||
- key 形如 `mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}`。
|
||||
- 页面启动可先读镜像减少闪烁,但必须用 `/api/tree/view-state` 或等价 control-plane API 的 SQLite 返回值校正。
|
||||
- 写入时可先更新内存 / 镜像,再 debounce PUT 到 SQLite;服务端必须从 session 推导 `user_id`,不能信任客户端提交的 `userId`。
|
||||
|
||||
状态 JSON 最小结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"expandedIds": [],
|
||||
"expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"],
|
||||
"selectedId": "local:markdown:design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md",
|
||||
"focusedId": "",
|
||||
"activeId": "",
|
||||
"scrollTop": 0,
|
||||
"updatedAtMs": 0
|
||||
}
|
||||
```
|
||||
|
||||
不持久化以下派生运行态:
|
||||
|
||||
- `rowsByParent`
|
||||
- `loadedParents`
|
||||
- `loadingParents`
|
||||
- `dirtyParents`
|
||||
- `staleParents`
|
||||
- `revisionByParent`
|
||||
- `latestGenerationByParent`
|
||||
|
||||
这些仍然属于 FileTreeDataSource / PageTree render cache,不能写成用户偏好。
|
||||
|
||||
### 行为规则
|
||||
|
||||
- projection apply 优先级:`SQLite 用户保存态` > `当前 active/reveal parent chain` > `后端 expandedByDefault`。
|
||||
- 已存在用户保存态时,后端 `expandedByDefault` 不得覆盖用户折叠选择。
|
||||
- 打开 Markdown / 星标文件夹只 reveal 目标 parent chain,不展开整棵 PageTree 或 FileTree。
|
||||
- PageTree 与 FileTree 使用同一套保存机制,但 `treeKind` 必须分开,防止页面树展开状态污染文件树。
|
||||
- starred scoped folder 必须使用 scope key 隔离,不能复用 workspace root 的展开状态。
|
||||
- collapse all / expand all 属于明确用户操作,必须写入保存态;普通 projection refresh 不得写入“自动折叠”覆盖用户态。
|
||||
- 新建页面 / 新建文件夹后,只允许 reveal 新节点 parent chain;不得折叠其它已展开节点。
|
||||
- 当保存态里的节点已不存在时,应用时静默跳过,并在下次写入时自然清理。
|
||||
|
||||
### 待执行 Checklist:SidebarTreeViewState
|
||||
|
||||
- [x] RED Rust 测试:在 `control-plane` 层新增或扩展 `user_ui_preferences_are_scoped_upserted_and_user_isolated`,写入 `key = sidebarTreeViewState.v1`,断言 alice/bob 隔离、`workspace_id/source_kind/scope_kind/scope_id/key` upsert 后 revision 增加。
|
||||
- [x] RED route 测试:在 `mnote-web` 增加 `/api/tree/view-state` 的 GET/PUT 测试,使用真实 session actor;断言服务端从 session 取 user,不接受客户端伪造 userId。
|
||||
- [x] RED route 测试:同一用户同一 workspace 下,`treeKind=filetree&scope=design` 与 `treeKind=pagetree&scope=design` 返回不同 state;`scope=root` 与 `scope=design` 也互不污染。
|
||||
- [x] RED JS runtime 契约测试:断言 `sidebar-tree-live-apply-runtime.js` 中存在 `SIDEBAR_TREE_VIEW_STATE_KEY`、`loadSidebarTreeViewState`、`persistSidebarTreeViewState`、`applySidebarTreeViewState`,并且 PageTree 不再只用 `item.expandedByDefault !== false` 决定展开。
|
||||
- [x] 新增 `rust/crates/mnote-web/src/routes/tree_view_state.rs`,只负责 Sidebar tree view state API;不要把这部分逻辑塞进 projection route 或 local folder source。
|
||||
- [x] 在 `rust/crates/mnote-web/src/routes/mod.rs` 注册:
|
||||
- `GET /api/tree/view-state`
|
||||
- `PUT /api/tree/view-state`
|
||||
- [x] API 入参规范化:
|
||||
- `workspaceId` 为空且 `sourceKind=local_folder` 时,用 `local_workspace_id_from_root_uri(rootUri)`。
|
||||
- `treeKind` 只允许 `filetree` / `pagetree`。
|
||||
- `rootUri` 使用 canonical root uri。
|
||||
- `scope` 为空时统一为 `root`。
|
||||
- `scope_id = "{treeKind}:{rootUriHash}:{scopeHash}"`。
|
||||
- [x] API value 校验:
|
||||
- `schemaVersion == 1`。
|
||||
- `expandedIds` / `expandedRelativePaths` 最多保留 512 条。
|
||||
- `selectedId/focusedId/activeId` 限制最大长度。
|
||||
- `scrollTop` 必须是非负数字。
|
||||
- 不接受 `rowsByParent/loadedParents/loadingParents/revisionByParent` 等派生字段。
|
||||
- [x] 服务端写入 `user_ui_preferences`:
|
||||
- `scope_kind = "sidebar_tree"`。
|
||||
- `key = "sidebarTreeViewState.v1"`。
|
||||
- `workspace_id/source_kind` 按当前请求写入,保证用户、workspace、source 隔离。
|
||||
- [x] 服务端读取时只返回当前 actor 的 active preference;无记录时返回 `{ schemaVersion: 1, treeKind, rootUri, scope, expandedIds: [], expandedRelativePaths: [] }`,并标记 `source = "default"`。
|
||||
- [x] 前端新增统一内存 state:
|
||||
- `sidebarTreeViewStates = new Map()`。
|
||||
- key 包含 `userId/workspaceId/sourceKind/treeKind/rootUri/scope`。
|
||||
- FileTree 现有 `fileTreeExpandedRelativePaths` 改为该 state 的 `expandedRelativePaths` 引用。
|
||||
- PageTree 新增 `expandedIds`。
|
||||
- [x] 前端启动流程:
|
||||
- 先从带用户维度的 `localStorage` 镜像读首屏 state。
|
||||
- 异步 GET SQLite state。
|
||||
- SQLite 返回后只在 generation/rootUri/scope 仍匹配时应用,避免旧请求覆盖当前 DOM。
|
||||
- [x] 前端保存流程:
|
||||
- toggle 更新内存态;`selectedId/focusedId/activeId/scrollTop` 已纳入 API 与序列化边界。当前主 sidebar 未提供 collapse all / expand all 入口,本轮不新增入口;后续入口必须复用同一保存态。
|
||||
- 写 localStorage 镜像。
|
||||
- debounce 300-800ms PUT 到 SQLite。
|
||||
- unload / visibility hidden 时 flush pending save。
|
||||
- [x] FileTree 迁移:
|
||||
- 读取旧 `mnote.localFileTree.expandedRelativePaths.v1` 作为 fallback。
|
||||
- 只在新 SQLite state 缺失时导入旧 expanded paths。
|
||||
- 导入后写入新 state;旧 key 不主动删除。
|
||||
- [x] PageTree 应用:
|
||||
- `renderPageRows()` 改为优先查 `SidebarTreeViewState.expandedIds`。
|
||||
- 没有用户态时,只展开 active/reveal parent chain;最后才使用 `expandedByDefault`。
|
||||
- 本地 PageTree 的 `depth < 2` 默认展开只能作为“无用户态 + 无 active reveal”的首次兜底。
|
||||
- [x] FileTree 应用:
|
||||
- `renderFileRows()` 优先查 `SidebarTreeViewState.expandedRelativePaths`。
|
||||
- `loadFileTreeChildren()` 展开 parent 时写用户态,但不得因为自动恢复 children 加载失败而删除 expanded path。
|
||||
- scoped root projection 替换时保留同 scope 的 expanded state,不回退到 root scope。
|
||||
- [x] reveal 规则:
|
||||
- 打开星标 scoped folder 时,只 reveal scoped root 和当前 active document parent chain。
|
||||
- 打开 `design/05-editor-mainline/process/5-32...md` 时,只展开 `design`、`design/05-editor-mainline`、`design/05-editor-mainline/process`。
|
||||
- reveal 产生的自动展开要标记为 `reason = "reveal"`,可写入保存态;projection refresh 产生的默认展开不得覆盖保存态。
|
||||
- [x] 新建页面回归:
|
||||
- 新建页面后,新节点 parent 保持展开。
|
||||
- 上一个文件夹不被自动折叠。
|
||||
- 当前新页面不会出现“闪一下 md 再折叠”的状态反转。
|
||||
- [x] 新增浏览器 smoke `scripts/task499-sidebar-tree-view-state-smoke.js`:
|
||||
- 登录测试账号。
|
||||
- 进入本地 workspace。
|
||||
- 打开星标 `design`。
|
||||
- 在 FileTree 展开 `design/05-editor-mainline/process`。
|
||||
- 在 PageTree 折叠一个默认展开节点并展开另一个节点。
|
||||
- 刷新页面,断言 FileTree/PageTree 状态分别恢复。
|
||||
- 切到其它页面再回来,断言状态仍恢复。
|
||||
- 记录 `/api/tree/view-state` GET/PUT 请求、response payload 和截图。
|
||||
- [x] 扩展 `task497`:
|
||||
- 打开目标 Markdown 后,断言没有 PageTree `depth < 2` 导致的大面积默认展开覆盖用户折叠态。
|
||||
- 记录 view-state GET/PUT timing,确保 editor ready 不等待 PUT 完成。
|
||||
- [x] 扩展 `task498`:
|
||||
- 星标 `design` 的 PageTree scope 必须读取 `pagetree/design` state。
|
||||
- root PageTree state 不得覆盖 starred scoped PageTree。
|
||||
- [x] 增加多用户隔离 smoke 或 route test:
|
||||
- user A 展开 `design/process`。
|
||||
- user B 登录同 workspace,不应继承 user A 的展开态。
|
||||
- [x] 验证命令:
|
||||
- `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences`
|
||||
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state`
|
||||
- `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`
|
||||
- `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`
|
||||
- `node scripts/task499-sidebar-tree-view-state-smoke.js`
|
||||
- `git diff --check`
|
||||
- `codegraph sync .`
|
||||
- `codegraph status .`
|
||||
|
||||
### 2026-05-27 SidebarTreeViewState 验证证据
|
||||
|
||||
- `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences`:通过,覆盖 `sidebarTreeViewState.v1` 在 `user_ui_preferences` 中的 upsert revision 与 alice/bob 用户隔离。
|
||||
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state`:通过,覆盖 `/api/tree/view-state` GET/PUT、session actor 优先、`filetree/pagetree` 与 `root/design` scope 隔离、派生 cache 字段拒绝。
|
||||
- `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`:通过,真实 Chromium 点击星标 `design` 后逐级打开目标 Markdown;最终样本 `pageTreeUserStateRestoredAt=1779870589458`,`filetreeInteractiveAt=1779870589562`,`targetClickAt=1779870589750`,`editorVisibleAt=1779870589815`,`longTasks=[]`,`directDocumentMs=8`。
|
||||
- `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`:通过,覆盖星标 `design` 使用 scoped PageTree state、root PageTree state 不覆盖 scoped PageTree、本地 Markdown 输入 `111` 后切页再回不丢。
|
||||
- `node scripts/task499-sidebar-tree-view-state-smoke.js`:通过,记录 `/api/tree/view-state` GET/PUT、刷新与切页后 FileTree/PageTree 状态恢复;最终样本 `fileStateSavedAt=1779870615533`,`pageStateSavedAt=1779870616150`,`reloadedStateRestoredAt=1779870616332`,`afterNavigationRestoredAt=1779870616436`。
|
||||
|
||||
## 与 5-30 的关系
|
||||
|
||||
`5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。
|
||||
|
||||
Reference in New Issue
Block a user