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
@@ -360,6 +360,183 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js
- 2026-05-27Rust 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` 使用当前 workspacelocal 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;不得折叠其它已展开节点。
- 当保存态里的节点已不存在时,应用时静默跳过,并在下次写入时自然清理。
### 待执行 ChecklistSidebarTreeViewState
- [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 如何懒加载、缓存、刷新、避免重复请求”。