- 归档 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 .
213 lines
10 KiB
Markdown
213 lines
10 KiB
Markdown
# 5-33 FileTree ViewState 与请求代际收口 v1
|
||
|
||
> 状态:done
|
||
> Owner:05-editor-mainline / 03-rust-web
|
||
> 前置:`design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md`
|
||
> 背景:5-32 已把 FileTree lazy children、cache 和局部 refresh 推到可用层,但 Sidex / VSCode 对照显示,当前仍缺少统一 view-state、request generation、collapsed stale 和 reveal command。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。
|
||
|
||
## 1. 结论
|
||
|
||
FileTree 下一步不应继续围绕 DOM 还原做补丁,而应收口为一个显式状态机:
|
||
|
||
1. `FileTreeViewState` 负责展开、选择、焦点、滚动、loaded/stale parent。
|
||
2. `FileTreeRequestTracker` 负责同一 parent 的 in-flight 去重、request generation 和过期结果丢弃。
|
||
3. `FileTreeRevealCommand` 负责从 active resource / command result 精确展开父链、选中目标、聚焦目标。
|
||
|
||
这三层只管理浏览器 view model,不改变 local-first 文件系统事实源,也不新增第二套树真相。数据真相仍来自 Rust projection / kernel command result。
|
||
|
||
## 2. Sidex / VSCode 对照
|
||
|
||
### 2.1 可借鉴的行为
|
||
|
||
- `AsyncDataTree.refreshNode` 会复用相交 subtree refresh promise,避免同一节点重复 refresh。
|
||
- collapsed node 被刷新时只标记 `stale`,不立即解析 children。
|
||
- `doGetChildren` 对同一 node 的 children promise 去重。
|
||
- 慢请求超过阈值后进入 slow/loading state,完成后清理。
|
||
- Explorer `selectResource` 负责展开 parent chain、reveal、focus、selection,而不是依赖 DOM 模糊匹配。
|
||
|
||
### 2.2 不照搬的内容
|
||
|
||
- 不引入 VSCode workbench service container。
|
||
- 不照搬完整 AsyncDataTree 实现。
|
||
- 不把前端 view state 升级成系统事实源。
|
||
- 不把 PageTree / ResourceTree 强行塞进同一个前端树组件。
|
||
|
||
## 3. MNote 当前缺口
|
||
|
||
### 3.1 状态分散
|
||
|
||
当前 FileTree 展开状态至少分散在:
|
||
|
||
- DOM `aria-expanded` / `data-filetree-children-loaded`
|
||
- `fileTreeExpandedRelativePaths`
|
||
- `sessionStorage`
|
||
- `fileTreeState.loadedParents/loadingParents/dirtyParents`
|
||
- 后端 projection 的 expanded/default scope
|
||
|
||
这会导致 refresh 替换 DOM 后,逻辑展开状态与实际 children DOM 不一致。
|
||
|
||
### 3.2 缺少 request generation
|
||
|
||
当前已能用 `loadingParents` 复用 in-flight promise,但缺少 generation:
|
||
|
||
- A 请求慢,B 请求快时,A 完成后仍可能 patch 旧 rows。
|
||
- scope/rootUri 切换后,旧请求返回仍可能误写当前 DOM。
|
||
- watcher refresh 与用户展开请求并发时,无法判定谁是最新。
|
||
|
||
### 3.3 collapsed stale 语义不清
|
||
|
||
当前未展开且未 loaded 的 parent 在局部 patch 时直接跳过,但没有显式 `stale` 标记。结果是:
|
||
|
||
- 用户展开时不知道是否应该强制拉最新 children。
|
||
- watch/command 只知道 parent dirty,不知道 dirty 是“已展开需后台更新”还是“折叠后下次展开更新”。
|
||
|
||
### 3.4 reveal/focus 仍靠 DOM 匹配
|
||
|
||
resource tab 激活、命令执行后,FileTree 的 active row/reveal 仍依赖 DOM 查询、path includes 或当前可见节点。lazy children 下,目标父链可能还没加载,必须由 tree runtime command 负责展开父链。
|
||
|
||
## 4. 目标
|
||
|
||
- 同一 `rootUri + scope + parentRelativePath` 在任一时刻最多一个 active children 请求。
|
||
- 任一 fetch/render 结果必须带 generation;过期结果不得 patch DOM 或覆盖 cache。
|
||
- collapsed parent 收到 refresh 时只标记 stale,不立即加载 children。
|
||
- expanded parent 收到 refresh 时保留当前 children,后台刷新并 patch。
|
||
- active resource / command result 可通过 canonical row id 或 relative path 精确 reveal。
|
||
- render/patch 后 selection/focus 基于 logical id 复投影,不依赖“当前 DOM 还在”。
|
||
|
||
## 5. 非目标
|
||
|
||
- 不实现虚拟滚动;大目录 viewport virtualization 另行设计。
|
||
- 不重写 Rust projection 协议。
|
||
- 不改变 FileTree 排序、DND、右键命令语义。
|
||
- 不修搜索索引 rebuild 问题;该问题独立记录在 `bugs/04-tree-domain/process/2026-05-27-local-search-query-rebuilds-index.md`。
|
||
|
||
## 6. 设计
|
||
|
||
### 6.1 FileTreeViewState
|
||
|
||
建议在 `sidebar-tree-live-apply-runtime.js` 中把 FileTree 状态收敛为:
|
||
|
||
```js
|
||
const fileTreeViewState = {
|
||
rootUri: '',
|
||
scope: '',
|
||
expandedParents: new Set(),
|
||
selectedRowIds: new Set(),
|
||
focusedRowId: '',
|
||
activeRowId: '',
|
||
scrollTop: 0,
|
||
loadedParents: new Set(),
|
||
staleParents: new Set(),
|
||
dirtyParents: new Set(),
|
||
rowsByParent: new Map(),
|
||
revisionByParent: new Map(),
|
||
};
|
||
```
|
||
|
||
约束:
|
||
|
||
- key 必须包含 `rootUri`,避免多个 workspace/local root 串状态。
|
||
- `selectedRowIds/focusedRowId` 存 logical row id,不存 DOM element。
|
||
- `expandedParents` 只表达用户视图意图;children 是否已加载由 `loadedParents/staleParents` 表达。
|
||
- scope/rootUri 变化时必须 reset 或 hydrate 对应 namespace 的状态。
|
||
|
||
### 6.2 FileTreeRequestTracker
|
||
|
||
建议新增轻量 request tracker:
|
||
|
||
```js
|
||
const fileTreeRequests = {
|
||
generation: 0,
|
||
loadingByParent: new Map(),
|
||
latestGenerationByParent: new Map(),
|
||
};
|
||
```
|
||
|
||
请求流程:
|
||
|
||
1. `beginFileTreeRequest(parent)` 递增 generation,并记录 parent 最新 generation。
|
||
2. 如果 parent 已有 in-flight promise,直接返回该 promise。
|
||
3. fetch 结果回来后检查 `isLatestFileTreeRequest(parent, generation)`。
|
||
4. 若不是最新,返回 `{ stale: true }`,不得写 cache,不得 patch DOM。
|
||
5. scope/rootUri 切换时递增全局 generation,旧请求全部自然失效。
|
||
|
||
### 6.3 stale parent 规则
|
||
|
||
- collapsed + dirty:`staleParents.add(parent)`,不拉 children。
|
||
- expanded + dirty:保留旧 rows,后台 refresh parent。
|
||
- expand parent:
|
||
- 若 loaded 且不 stale,直接渲染 cache。
|
||
- 若 stale 或未 loaded,进入 loading state 并 fetch children。
|
||
- fetch 失败:
|
||
- 不清旧 rows。
|
||
- parent 标记 `stale`。
|
||
- row 展示可恢复错误状态,允许重试。
|
||
|
||
### 6.4 reveal command
|
||
|
||
新增内部 command:
|
||
|
||
```js
|
||
revealFileTreeResource({
|
||
rootUri,
|
||
relativePath,
|
||
rowId,
|
||
select: true,
|
||
focus: true,
|
||
scroll: true,
|
||
})
|
||
```
|
||
|
||
行为:
|
||
|
||
1. 校验 rootUri 是否当前 root。
|
||
2. 计算 parent chain,例如 `design/03-rust-web/process`。
|
||
3. 逐层调用 `getFileTreeChildren(parent)`,确保父链已加载。
|
||
4. 渲染每层 children 后设置 expanded。
|
||
5. 使用 rowId 或 relativePath 精确选中目标。
|
||
6. 同步 `selectedRowIds/focusedRowId/activeRowId`。
|
||
|
||
## 7. Checklist
|
||
|
||
- [x] 审查 `sidebar-tree-live-apply-runtime.js` 中所有读写展开、选择、focus 的入口,列出迁移点。
|
||
- [x] 新增 `fileTreeViewState`,把 `fileTreeExpandedRelativePaths` 迁入 view-state namespace。
|
||
- [x] 新增 request generation,覆盖 `getFileTreeChildren` 的 children 请求结果写入。
|
||
- [x] 让 scope/rootUri 切换使旧 generation 失效。
|
||
- [x] 把 collapsed + dirty 改成 stale parent,不立即加载 children。
|
||
- [x] render/patch 后按 logical state 复投影 selection/focus。
|
||
- [x] 新增 `revealFileTreeResource` 内部 command,替代 path includes DOM 匹配。
|
||
- [x] 补 JS/Rust 字符串契约测试,检查 generation/stale 关键函数存在。
|
||
- [x] 扩展 `scripts/task494-filetree-lazy-loading-dedup-smoke.js`,覆盖慢请求后切 scope、快速展开/收起、watch refresh 后不折叠。
|
||
|
||
## 8. 验收
|
||
|
||
- 快速连续点击同一目录 5 次,只产生 1 个 in-flight children 请求。
|
||
- 人为延迟第一个 children 请求,再触发 refresh,旧请求返回后不会覆盖新 rows。
|
||
- collapsed parent 收到 watcher/command dirty 后不发 children 请求;下一次展开才请求。
|
||
- active resource tab 能 reveal 未加载的深层 filetree row。
|
||
- 创建/重命名/删除后 selection/focus 不因局部 patch 丢失。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --test-threads=1` 通过。
|
||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过。
|
||
|
||
## 9. 风险
|
||
|
||
- 如果把 view-state 写成新的事实源,会和 Rust projection 冲突。必须限定为浏览器视图状态。
|
||
- 如果 generation 只绑定 parent,不绑定 rootUri/scope,仍会出现跨 workspace 旧请求污染。
|
||
- 如果 reveal command 隐式加载过多 parent chain,深层大目录仍可能慢;需要只加载 path chain,不加载 siblings 的 children。
|
||
|
||
## 10. 执行记录
|
||
|
||
- 2026-05-27:在 `sidebar-tree-live-apply-runtime.js` 中新增 `staleParents`、`requestGeneration`、`latestGenerationByParent`、`beginFileTreeRequest(...)`、`isLatestFileTreeRequest(...)`。
|
||
- 2026-05-27:`getFileTreeChildren(...)` 写入 cache/DOM 前检查 generation;scope/rootUri 切换时递增 generation 并清理 latest generation map,使旧请求自然失效。
|
||
- 2026-05-27:`refreshFileTreeParent(...)` 对 collapsed non-root parent 只标记 stale,不立即加载 children;下一次展开绕过旧 cache 拉取最新 children。
|
||
- 2026-05-27:新增 `sidebar_filetree_runtime_discards_stale_generation_results` 契约测试。
|
||
- 2026-05-27:审查 FileTree 展开/selection/focus 入口后,确认 `fileTreeViewState` 已承接 expanded/loaded/stale/dirty/selection/focus/active;`fileTreeExpandedRelativePaths` 保留为 `fileTreeViewState.expandedParents` 的兼容 alias。
|
||
- 2026-05-27:`renderFileProjection(...)`、`patchFileTreeParentChildren(...)` 以及 lazy children render 后按 logical view-state 复投影 selection/focus/active,避免局部 patch 后丢失选中态。
|
||
- 2026-05-27:`revealFileTreeResource(...)` 接入 `selectSidebarFileTreeDocument(...)` 的 DOM miss fallback;active document 变更可按 local markdown documentId 解出 relative path,逐层加载未展开父链并选中目标 row。
|
||
- 2026-05-27:扩展 `task494-filetree-lazy-loading-dedup-smoke.js`,覆盖 watch batch 局部刷新不折叠、未加载深层 resource reveal、opened Markdown rename 后 BufferStore rekey。
|
||
- 验证:
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。
|
||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`cachedExpandMs=33`。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。
|
||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=32`。
|