Files
mnote/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md
T

364 lines
20 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
> 状态:process
> 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。
### 待执行 Checklist
- [ ] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。
- [ ] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`,记录 sidebar/filetree/page aggregate/editor ready 时序和 long task。
- [ ] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`
- [ ] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projectionfile projection 可以先应用,sidebar projection 后补页面树。
- [ ] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。
- [ ] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。
- [ ] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。
- [ ] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。
- [ ] 验证:`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`,属于当前工作树既有未提交/新增文件状态,本轮不清理。
## 与 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 同时产出。