2026-05-27 11:31:12 +08:00
|
|
|
|
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
|
|
|
|
|
|
|
|
|
|
|
|
> 状态:process
|
|
|
|
|
|
> Owner:05-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。
|
2026-05-27 12:53:55 +08:00
|
|
|
|
- 必须包含一条模拟用户手动操作的 Playwright/Chromium 测试:从首页进入本地 workspace,点击星标文件夹或文件树节点,逐级导航到目标 Markdown,而不是只用 `context.request` 或直接访问最终 URL。
|
2026-05-27 11:31:12 +08:00
|
|
|
|
|
|
|
|
|
|
### 待执行 Checklist
|
|
|
|
|
|
|
2026-05-27 12:53:55 +08:00
|
|
|
|
- [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 全量重建而明显延迟。
|
2026-05-27 13:50:31 +08:00
|
|
|
|
- [x] 新增 `task498-starred-page-tree-scope-and-local-edit-smoke.js`:覆盖星标 scoped folder 打开后 PageTree 使用同一 scope,且本地 Markdown 输入 `111` 后经 `/api/page-body/write` 保存,切页再回不丢内容。
|
2026-05-27 12:53:55 +08:00
|
|
|
|
- [x] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。
|
|
|
|
|
|
- [x] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file 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`。
|
2026-05-27 11:31:12 +08:00
|
|
|
|
|
|
|
|
|
|
## Context7 参考结论
|
|
|
|
|
|
|
|
|
|
|
|
Context7 查询结果:
|
|
|
|
|
|
|
|
|
|
|
|
- VS Code `DataTree` / `AsyncDataTree` 面向“数据懒发现”的树,例如文件浏览器。数据源用 `hasChildren(element)` 和 `getChildren(element)` 异步解析 children,tree 组件负责并发 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 替换 DOM,loading 标记会丢。
|
|
|
|
|
|
- `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 projection;fallback polling 只标记 scoped stale;legacy 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 12:53:55 +08:00
|
|
|
|
- 2026-05-27:`task492` RED 先失败于星标文件夹等待 sidebar projection,GREEN 后 `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 13:50:31 +08:00
|
|
|
|
- 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-27 12:53:55 +08:00
|
|
|
|
- 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 11:31:12 +08:00
|
|
|
|
|
|
|
|
|
|
## 与 5-30 的关系
|
|
|
|
|
|
|
|
|
|
|
|
`5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。
|
|
|
|
|
|
|
|
|
|
|
|
`5-30` 中的 `rootUri` 必须成为 shortcut 显式字段;缺失时星标文件夹应失效提示,不允许从 `workspaceId` 反推路径。
|
|
|
|
|
|
|
|
|
|
|
|
## 决策记录
|
|
|
|
|
|
|
|
|
|
|
|
- 2026-05-26:`展开恢复`只能作为兼容保护,不是性能修复。
|
|
|
|
|
|
- 2026-05-26:FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计,不按 DOM 替换后再恢复。
|
|
|
|
|
|
- 2026-05-26:后端 children projection 需要单 pass shallow scan,并把 watch revision 与 rows 同时产出。
|