# 5-32 FileTree 大目录懒加载与展开状态设计 v1

> 状态：done
> 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。
   - 必须包含一条模拟用户手动操作的 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 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`。

## 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：`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：`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：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 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` 使用当前 workspace；local 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；不得折叠其它已展开节点。
- 当保存态里的节点已不存在时，应用时静默跳过，并在下次写入时自然清理。

### 待执行 Checklist：SidebarTreeViewState

- [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-26：FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计，不按 DOM 替换后再恢复。
- 2026-05-26：后端 children projection 需要单 pass shallow scan，并把 watch revision 与 rows 同时产出。
