Files
mnote/design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

551 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
> 状态:done
> Owner05-editor-mainline / 03-rust-web
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
## 结论
当前问题不能只用“加载后恢复展开”解决。正确模型应对齐 VS Code/Sidex Explorer 与 MUI X lazy tree 的三个原则:
1. 同一个 `rootUri + parentRelativePath` 的 children 请求必须有 in-flight 去重和结果缓存。
2. refresh/watch/local command 只刷新受影响 parent,不整棵替换已展开子树。
3. 后端 projection 应一次扫描生成 children、`childrenCount/expandable` 和 revision,避免为了 child_count/watch revision 重复读目录。
## 2026-05-27 页面树 / 文件树打开性能纠偏方案
### 根因判断
本轮纠偏不再把“隐藏页面树”作为性能策略。页面树必须继续可见,且星标 scoped folder 打开后必须用本地 Markdown 页面树投影替换旧“我的空间”页面树。
当前卡顿的核心是:`页面树可见` 被实现成 `打开文件树 / 星标文件夹 / 文档前同步完成全量页面树 projection`。即使在 localhost,这也会把全盘 Markdown 扫描、页面树 DOM 替换和 editor mount 串到同一条打开链路上。
已确认的高风险点:
- `gateway.rs` 本地 root 入口已经读取一次 `load_local_folder_page_tree_snapshot(root_uri)`,后续 `render_local_sidebar_tree_html(...)` 仍可能再次触发同一 root 的页面树扫描。
- `sidebar-tree-runtime.js` 星标文件夹打开必须并行请求 `/api/tree/projections/sidebar` 与 scoped `/api/tree/projections/file`scoped FileTree 应先可交互,慢页面树只负责替换旧页面树。
- `sidebar-tree-live-apply-runtime.js` 不应把 `renderSidebarSnapshot(...)` 写成 page tree 与 file tree 同步整块应用;二者需要独立 generation / stale guard。
- Page Aggregate / editor runtime 已经与 runtime asset 并行,但如果 workspace shell 先同步重建页面树,editor mount 仍会被前置 shell 构建拖慢。
### Sidex / Context7 对齐原则
- Sidex / VSCode Explorer 使用 `AsyncDataTree` 按需 resolve children,慢 children 进入 slow/loading state,不把整个树同步塞进 DOM。
- Explorer refresh 按受影响 parent、view state 和 request promise 管理,而不是 root projection 返回后整棵替换。
- 浏览器侧参考 MDN:用 `PerformanceObserver` 记录 long task / long animation frame;用 `AbortController` 或 request generation 丢弃过期 fetch;避免大段同步 DOM insertion 阻塞主线程。
- Tokio 侧 `spawn_blocking` 只能避免阻塞 async worker,不能消除全盘扫描成本;本地 projection 仍需要减少触发次数、限并发、缓存和局部化。
### 新策略
1. 页面树保留,但从打开关键路径降级为“可见的异步 projection”:
- SSR 可以输出已有/轻量页面树壳。
- scoped filetree 先应用并可点击。
- page tree projection 完成后只替换 `#sidebar-tree-root`
- 旧“我的空间”页面树必须在本地 rootUri 确认后标记 stale/loading,不能继续被当成当前页面树。
2. FileTree / PageTree projection 拆开应用:
- `applyFileProjection(...)``applyPageProjection(...)` 独立 generation。
- scope/rootUri 切换时旧请求自然失效。
- 页面树慢请求返回后,不得覆盖新的 scoped filetree state。
3. gateway SSR 去重:
- 同一请求内复用已加载的 `page_tree_snapshot`
- 禁止 `load_local_folder_page_tree_snapshot``render_local_sidebar_tree_html` 对同一 root 重复扫描。
- Page Aggregate/editor mount 不等待 sidebar root 重建。
4. 本地 PageTree 进入 lazy / cached projection
- 短期:按 `rootUri + watchRevision` 做 page tree snapshot cache / stale-while-revalidate。
- 中期:PageTree 改为顶层 + active/reveal path projection,不再每次全量递归 Markdown。
5. 观测与验收必须落到真实浏览器:
- 记录 projection request start/end、filetree interactive time、Page Aggregate TTFB、editor ready、longtask count。
- localhost 打开目标 Markdown 时,不能因页面树 projection 重建而阻塞 editor surface。
- 必须包含一条模拟用户手动操作的 Playwright/Chromium 测试:从首页进入本地 workspace,点击星标文件夹或文件树节点,逐级导航到目标 Markdown,而不是只用 `context.request` 或直接访问最终 URL。
### 待执行 Checklist
- [x] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。
- [x] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:用真实 Chromium 从首页进入本地 workspace,点击星标 `design` 文件夹,再通过 Explorer 逐级打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`;记录 sidebar/filetree/page aggregate/editor ready 时序、network/resource timing、long task,并保存打开前后截图。
- [x] `task497` 不允许只用 `context.request.fetch` 或直接 `page.goto(最终文档 URL)` 作为主验收;可以用 direct request 作为对照指标,但真实验收必须来自 UI 点击路径。
- [x] `task497` 断言打开过程中旧“我的空间”页面树不会长期残留、scoped FileTree 先可交互、editor surface 可见,且目标 md 打开不会因为 page tree projection 全量重建而明显延迟。
- [x] 新增 `task498-starred-page-tree-scope-and-local-edit-smoke.js`:覆盖星标 scoped folder 打开后 PageTree 使用同一 scope,且本地 Markdown 输入 `111` 后经 `/api/page-body/write` 保存,切页再回不丢内容。
- [x] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`
- [x] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projectionfile projection 可以先应用,sidebar projection 后补页面树。
- [x] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。
- [x] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。
- [x] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。
- [x] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。
- [x] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js``node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check``codegraph sync/status`
## Context7 参考结论
Context7 查询结果:
- VS Code `DataTree` / `AsyncDataTree` 面向“数据懒发现”的树,例如文件浏览器。数据源用 `hasChildren(element)``getChildren(element)` 异步解析 childrentree 组件负责并发 refresh 管理和 slow loading 状态。
- MUI X `RichTreeViewPro` lazy loading 要求 `dataSource.getChildrenCount()``dataSource.getTreeItems()`,并建议通过 `dataSourceCache` 缓存 children 数据;大数据场景还需要 request throttle,避免用户快速操作时打爆服务端。
映射到 MNote
- `parentRelativePath` 就是 tree item identity。
- `child_count/expandable` 就是 `getChildrenCount` 的最小版本。
- `fileTreeLazyChildrenCache` 不能只是渲染后的临时兜底,应该成为 FileTreeDataSource cache。
- `refreshLocalFolderSidebarSnapshot()` 不能绕过 cache 直接 `tree.innerHTML = ...`
## CodeGraph / Sidex 对照
已用 CodeGraph 索引确认:
- Sidex 项目索引存在:`reference-code/sidex-main`,约 2904 files / 124438 nodes。
- MNote 项目索引存在:`/mnt/Data1T/mnote`,约 382 files / 9520 nodes。
Sidex 关键链路:
- `reference-code/sidex-main/src/vs/base/browser/ui/tree/asyncDataTree.ts`
- `refreshNode` 会检查 `subTreeRefreshPromises`,相交 refresh 会复用已有 promise。
- collapsed node refresh 时只标记 `stale` 并清 children,不立即解析子树。
- `doGetChildren``refreshPromises` 对同一 node 的 `getChildren` 去重。
- 异步 children 超过 800ms 会进入 slow state,由 UI 显示 loading。
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/views/explorerView.ts`
- `refresh(recursive, item)` 刷新指定 item,默认不是整棵 DOM 替换。
- `setTreeInput` 读取/保存 view state,不把展开状态绑在一次 DOM 结果上。
- `selectResource` 显式展开 parent chain,用于 reveal,而不是预加载整棵树。
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/explorerService.ts`
- 文件 create/copy/move 事件刷新 parent item。
- parent 未 resolved 时先 resolve parent,再 refresh parent。
MNote 当前链路:
- `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js`
- `renderFileProjection()` 会直接替换 `#sidebar-file-tree-root.innerHTML`,已展开子树 DOM 会丢失。
- `refreshLocalFolderSidebarSnapshot()` 同时拉 sidebar projection 和 file projection,随后整树渲染。
- `loadFileTreeChildren()` 只用 row 上的 `data-filetree-children-loading` 做加载中状态;如果 refresh 替换 DOMloading 标记会丢。
- `fileTreeLazyChildrenCache` 只在请求成功后写入,缺少 in-flight promise 去重。
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `load_local_folder_file_tree_scope_snapshot()``scan_directory()`
- `scan_directory()` 读取当前目录后,对每个子目录调用 `has_visible_child()` 再读一次该子目录。
- 之后又调用 `local_folder_watch_revision_for_directory()` 再读一次当前目录生成 revision。
- `rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs`
- SSR filetree row 目前缺少 `data-local-relative-path`JS renderer 有该属性;这导致首屏 SSR 与后续 JS patch 数据属性不一致。
## 实测证据
在当前 3000 登录态下,直接访问:
```text
/?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&rootUri=file:///mnt/Data1T/mnote&treeView=filetree&fileTreeScope=design
```
结果:
- SSR 首屏无 `/api/tree/projections/file` 网络请求。
- `#sidebar-file-tree-root` 显示 `design/` 子项 17 行。
- `documentElement` 未设置 `data-mnote-filetree-scope`,但 URL 有 `fileTreeScope=design`
- 首屏 row 的 `data-local-relative-path` 为空,rowId 为 `local:folder:design/03-rust-web`
展开 `design/03-rust-web`
```text
GET /api/tree/projections/file/children?...&parentRelativePath=design/03-rust-web
status=200
time=14ms
size=3119 bytes
children=done/process/reference
```
这说明本机此子目录单次后端请求并不慢。用户感知慢更可能来自:
- 某些更大的目录触发多次 children 请求或 watch refresh。
- refresh 整树替换导致已加载 children 丢失,看起来像“加载完又折叠”。
- 首屏 SSR / JS renderer 属性不一致,导致 lazy loader 依赖 rowId fallback。
- watcher/local command refresh 与用户展开请求并发,造成同一路径重复请求或请求完成后 DOM 被替换。
## 目标
- 打开 scoped folder 后只显示该 scope 的直接 children,不加载 workspace root siblings。
- 展开某个目录时,同一 `rootUri + parentRelativePath` 在 in-flight 期间最多一个网络请求。
- 请求完成后不因 watch refresh / command refresh 折叠已展开子树。
- 第一次展开慢目录时显示 loading 状态;第二次展开命中 cache,不发网络请求。
- 创建/重命名/删除只刷新受影响 parent,不重刷整棵 filetree。
- SSR 与 JS 渲染输出同一套 row data attributes。
## 非目标
- 不在本阶段实现虚拟滚动。
- 不引入 React/MUI TreeView;只吸收 lazy tree 的数据源/cache模型。
- 不重写 Rust kernel 树协议。
- 不改变 local-first 文件系统事实源。
## 新 FileTreeDataSource 模型
`sidebar-tree-live-apply-runtime.js` 中收口为浏览器端 FileTreeDataSource
```js
const fileTreeState = {
rootUri: '',
scope: '',
rowsByParent: new Map(), // key: parentRelativePath -> rows[]
loadedParents: new Set(), // loaded parentRelativePath
loadingParents: new Map(), // parentRelativePath -> Promise
expandedParents: new Set(), // parentRelativePath
dirtyParents: new Set(), // parentRelativePath
revisionByParent: new Map(), // parentRelativePath -> watchRevision
};
```
Key 规则:
```js
function fileTreeParentKey(rootUri, parentRelativePath) {
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
}
```
请求规则:
```js
async function getFileTreeChildren(parentRelativePath) {
const key = fileTreeParentKey(currentRootUri(), parentRelativePath);
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key)) {
return fileTreeState.rowsByParent.get(key) || [];
}
if (fileTreeState.loadingParents.has(key)) {
return fileTreeState.loadingParents.get(key);
}
const promise = fetchFileTreeChildren(parentRelativePath)
.then(rows => {
fileTreeState.rowsByParent.set(key, rows);
fileTreeState.loadedParents.add(key);
fileTreeState.dirtyParents.delete(key);
return rows;
})
.finally(() => fileTreeState.loadingParents.delete(key));
fileTreeState.loadingParents.set(key, promise);
return promise;
}
```
渲染规则:
- `renderFileProjection()` 不再无条件替换整棵 `innerHTML`
- 新 projection 只更新当前 scope/root parent 的 `rowsByParent`
- 对每个 expanded parent,渲染时优先使用 `rowsByParent` 已缓存 children。
- 如果 parent 被标记 dirty,保留展开 UI 和旧 children,后台刷新该 parent;刷新完成后 patch 该 parent children。
- collapsed parent 不加载 children,只保留 `expandable`
## 后端 projection 优化
当前后端一次 children projection 至少可能读:
1. 当前目录 `read_sorted_entries()`
2. 每个子目录一次 `has_visible_child()`
3. 当前目录一次 `local_folder_watch_revision_for_directory()`
改为单 pass helper
```rust
struct LocalFolderScanResult {
rows: Vec<LocalFolderRow>,
watch_revision: LocalFolderWatchRevision,
}
fn scan_directory_shallow_with_revision(
root: &Path,
directory: &Path,
parent_node_id: Option<String>,
depth: u32,
root_source_uri: &str,
workspace_id: &str,
metadata: &LocalFolderMetadata,
) -> Result<LocalFolderScanResult, WebError>
```
要求:
- 只读取当前目录一次。
- 对子目录只做 `has_visible_child_fast()`,且最多读到第一个可见 child 后停止。
- 生成 `watchRevision` 时复用当前目录 entries,不再二次 `read_sorted_entries()`
- 返回 projection 时明确 `parentRelativePath``watchRevision.entryCount``items.length`
## Watch / refresh 策略
替代当前“watch root 变更后刷新 sidebar + filetree root/scope”的粗刷新:
- `local-folder-watch` 仍可作为 fallback,但 filetree 侧只标记 dirty,不立即整树替换。
- 如果当前 scope root 变更,刷新 scope parent。
- 如果已展开 parent 变更,刷新对应 parent。
- 如果无法定位 parent,刷新当前 scope parent,但保留 expanded parent cache。
- local command 成功后用 command result 的 `parentRelativePath` 定位刷新 parent;没有 parent 信息时才降级刷新 scope parent。
## 测试与验收
### Rust 单测
- `local_folder_file_tree_children_snapshot_scans_parent_once`
- 构造含多个目录的 temp root。
- 调用 `load_local_folder_file_tree_children_snapshot(rootUri, "design")`
- 断言 `projection.parentRelativePath == "design"`
- 断言 `items` 只包含直接 children。
- 断言 `watchRevision.entryCount == items.len()`
- `filetree_ssr_rows_include_local_relative_path`
- 构造 `FileTreeRenderRow`
- 断言 HTML 包含 `data-local-relative-path="design/03-rust-web"`
### JS runtime 字符串/契约测试
`rust/crates/mnote-web/src/ssr/pages/layout.rs` 中增加断言:
- 包含 `loadingParents` / `loadedParents` / `rowsByParent`
- `loadFileTreeChildren` 在 fetch 前检查 `loadingParents.has(key)`
- `renderFileProjection` 不包含无条件 `tree.innerHTML =`
- `refreshLocalFolderSidebarSnapshot` 不直接绕过 FileTreeDataSource。
### 浏览器 smoke
新增脚本:
```text
scripts/task494-filetree-lazy-loading-dedup-smoke.js
```
断言:
- 登录测试账号。
- 访问 `fileTreeScope=design`
- 展开 `design/03-rust-web`
- 记录 `/api/tree/projections/file/children?...parentRelativePath=design/03-rust-web` 请求数为 1。
- 收起再展开同一目录,children 请求数仍为 1。
- 触发一次 `tree:resync` 或等待 watcher tick 后,该 row 仍 `aria-expanded=true`children 仍存在。
- 第二次展开耗时低于 50ms 或无网络请求。
- 从星标 scoped 文件夹打开已可见 Markdown 时,必须走 pane 内导航;页面 JS marker 不丢失,且不重新请求 scope 根 projection。
- scoped 模式收到 root snapshot 或 coarse local-folder-watch revision 时,不得兜底重拉 scope 根 projection。
- 普通 Markdown mount 时,不得为了 legacy office 附件兼容重拉 workspace root projection。
### 2026-05-27 scoped 文件打开补充验收
- RED`task494-filetree-lazy-loading-dedup-smoke.js` 在 scoped `design` 文件树点击 `design/Overview.md` 后失败于 JS marker 被清空,确认当前实现发生整页 reload。
- RED:同一 smoke 继续失败于 root live snapshot / coarse local-folder-watch revision 触发 scope 根 projection。
- GREEN`navigateToDocument(...)` 允许 `fileTreeScope` 场景继续使用 `openPrimaryDocument(...)`scoped root snapshot 改为忽略非 scope projectionfallback polling 只标记 scoped stalelegacy office 兼容先检查候选段落再取索引。
- 真实路径验证:进入 `fileTreeScope=design`,展开 `design/05-editor-mainline``design/05-editor-mainline/process`,打开 `5-32-filetree-lazy-loading-sidex-alignment-v1.md` 后页面 marker 保留,`scopeRootRequests=0``workspaceRootRequests=0``childrenRequests=2`
### 性能目标
- localhost 下 `design/03-rust-web` children 请求保持单次 `<100ms`
- 1000 个 direct children 的目录 projection 目标 `<300ms`,不得因每个子目录重复深扫导致线性倍增到秒级。
- 同一路径 5 次快速点击最多 1 个 in-flight 请求。
## 实施 Checklist
- [x] 修改 `FileTreeRenderRow` 增加 `relative_path` 字段,并在 SSR renderer 输出 `data-local-relative-path`
- [x] `collect_filetree_render_rows` 从 projection item 读取 `relativePath`
- [x]`fileTreeLazyChildrenCache` 升级为 `fileTreeState.rowsByParent/loadingParents/loadedParents/dirtyParents`
- [x] `loadFileTreeChildren` 改为 `getFileTreeChildren(parentRelativePath)`,复用 in-flight promise。
- [x] `renderFileProjection` 改为更新 datasource state,再按 visible state patch DOM,不整树替换 expanded subtree。
- [x] `refreshLocalFolderSidebarSnapshot` 只刷新当前 scope parent,且不清除 expanded children。
- [x] local command 成功后按 parent 刷新;无 parent 才刷新 scope。
- [x] 后端新增 `scan_directory_shallow_with_revision`,减少 children projection 重复读目录。
- [x] 新增 Rust 单测和 browser smoke。
- [x] 重跑 `cargo test -p mnote-web --manifest-path rust/Cargo.toml``cargo test -p control-plane --manifest-path rust/Cargo.toml``cargo fmt --check --all --manifest-path rust/Cargo.toml`
- [x]`codegraph sync .` 并确认 status;已有无关 pending 只记录不清理。
## 验证记录
- 2026-05-26`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,收起后再次展开无 children 重复请求,cache 展开耗时 `33ms`
- 2026-05-26`cargo test -p mnote-web --manifest-path rust/Cargo.toml` 通过,`567 passed`
- 2026-05-26`cargo test -p control-plane --manifest-path rust/Cargo.toml` 通过,`23 passed`
- 2026-05-26`cargo fmt --check --all --manifest-path rust/Cargo.toml` 通过。
- 2026-05-26`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。
- 2026-05-27`task492` RED 先失败于星标文件夹等待 sidebar projectionGREEN 后 `node scripts/task492-sidebar-starred-shortcuts-smoke.js` 通过,确认 scoped FileTree 先于延迟页面树可交互。
- 2026-05-27`task497` RED 先失败于真实点击星标 `design` 后 scoped FileTree 未先完成;GREEN 后 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779857492435``targetClickAt=1779857492621``pageAggregateResponseAt=1779857492667``editorVisibleAt=1779857492823``longTasks=[]``directDocumentMs=8`;截图保存在 `tmp/task497-local-page-tree-filetree-open-performance-smoke/`
- 2026-05-27`task498` RED 先失败于星标 `design` 后 PageTree 为空 / 未按 scope 替换;补 scoped `/api/tree/projections/sidebar?parentRelativePath=design` 后进入保存段,再 RED 失败于 `hydrateMindmapAttrsFromDom is not defined`,确认 dirty session 保存计时器抛错导致 `/api/page-body/write` 未发出。GREEN 后 `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` 通过,覆盖 PageTree scope、保存到磁盘、切换 `Other.md` 再回 `Home.md` 仍保留 `111`
- 2026-05-27:复跑 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779860840801``targetClickAt=1779860840985``pageAggregateResponseAt=1779860841032``editorVisibleAt=1779860841196``longTasks=[]``directDocumentMs=7`scoped PageTree 断言改为检查 design scope 内本地页面行,而不是要求父目录名 `design` 必须作为可见行出现。
- 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 如何懒加载、缓存、刷新、避免重复请求”。
`5-30` 中的 `rootUri` 必须成为 shortcut 显式字段;缺失时星标文件夹应失效提示,不允许从 `workspaceId` 反推路径。
## 决策记录
- 2026-05-26`展开恢复`只能作为兼容保护,不是性能修复。
- 2026-05-26FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计,不按 DOM 替换后再恢复。
- 2026-05-26:后端 children projection 需要单 pass shallow scan,并把 watch revision 与 rows 同时产出。