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

36 KiB
Raw Blame History

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/filescoped 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_snapshotrender_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

  • 扩展 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:用真实 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,并保存打开前后截图。
  • task497 不允许只用 context.request.fetch 或直接 page.goto(最终文档 URL) 作为主验收;可以用 direct request 作为对照指标,但真实验收必须来自 UI 点击路径。
  • task497 断言打开过程中旧“我的空间”页面树不会长期残留、scoped FileTree 先可交互、editor surface 可见,且目标 md 打开不会因为 page tree projection 全量重建而明显延迟。
  • 新增 task498-starred-page-tree-scope-and-local-edit-smoke.js:覆盖星标 scoped folder 打开后 PageTree 使用同一 scope,且本地 Markdown 输入 111 后经 /api/page-body/write 保存,切页再回不丢内容。
  • 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.jsnode scripts/task494-filetree-lazy-loading-dedup-smoke.js、新增 task497、相关 Rust filters、git diff --checkcodegraph 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,不立即解析子树。
    • doGetChildrenrefreshPromises 对同一 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-pathJS renderer 有该属性;这导致首屏 SSR 与后续 JS patch 数据属性不一致。

实测证据

在当前 3000 登录态下,直接访问:

/?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

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

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 规则:

function fileTreeParentKey(rootUri, parentRelativePath) {
  return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
}

请求规则:

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

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 时明确 parentRelativePathwatchRevision.entryCountitems.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

新增脚本:

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=truechildren 仍存在。
  • 第二次展开耗时低于 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 文件打开补充验收

  • REDtask494-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。
  • GREENnavigateToDocument(...) 允许 fileTreeScope 场景继续使用 openPrimaryDocument(...)scoped root snapshot 改为忽略非 scope projectionfallback polling 只标记 scoped stalelegacy office 兼容先检查候选段落再取索引。
  • 真实路径验证:进入 fileTreeScope=design,展开 design/05-editor-mainlinedesign/05-editor-mainline/process,打开 5-32-filetree-lazy-loading-sidex-alignment-v1.md 后页面 marker 保留,scopeRootRequests=0workspaceRootRequests=0childrenRequests=2

性能目标

  • localhost 下 design/03-rust-web children 请求保持单次 <100ms
  • 1000 个 direct children 的目录 projection 目标 <300ms,不得因每个子目录重复深扫导致线性倍增到秒级。
  • 同一路径 5 次快速点击最多 1 个 in-flight 请求。

实施 Checklist

  • 修改 FileTreeRenderRow 增加 relative_path 字段,并在 SSR renderer 输出 data-local-relative-path
  • collect_filetree_render_rows 从 projection item 读取 relativePath
  • fileTreeLazyChildrenCache 升级为 fileTreeState.rowsByParent/loadingParents/loadedParents/dirtyParents
  • loadFileTreeChildren 改为 getFileTreeChildren(parentRelativePath),复用 in-flight promise。
  • renderFileProjection 改为更新 datasource state,再按 visible state patch DOM,不整树替换 expanded subtree。
  • refreshLocalFolderSidebarSnapshot 只刷新当前 scope parent,且不清除 expanded children。
  • local command 成功后按 parent 刷新;无 parent 才刷新 scope。
  • 后端新增 scan_directory_shallow_with_revision,减少 children projection 重复读目录。
  • 新增 Rust 单测和 browser smoke。
  • 重跑 cargo test -p mnote-web --manifest-path rust/Cargo.tomlcargo test -p control-plane --manifest-path rust/Cargo.tomlcargo fmt --check --all --manifest-path rust/Cargo.toml
  • codegraph sync . 并确认 status;已有无关 pending 只记录不清理。

验证记录

  • 2026-05-26node scripts/task494-filetree-lazy-loading-dedup-smoke.js 通过,targetChildrenRequests=1,收起后再次展开无 children 重复请求,cache 展开耗时 33ms
  • 2026-05-26cargo test -p mnote-web --manifest-path rust/Cargo.toml 通过,567 passed
  • 2026-05-26cargo test -p control-plane --manifest-path rust/Cargo.toml 通过,23 passed
  • 2026-05-26cargo fmt --check --all --manifest-path rust/Cargo.toml 通过。
  • 2026-05-26codegraph sync . 已执行;codegraph status . 仍报告 Pending Changes: Added: 5 files,属于当前工作树既有未提交/新增文件状态,本轮不清理。
  • 2026-05-27task492 RED 先失败于星标文件夹等待 sidebar projectionGREEN 后 node scripts/task492-sidebar-starred-shortcuts-smoke.js 通过,确认 scoped FileTree 先于延迟页面树可交互。
  • 2026-05-27task497 RED 先失败于真实点击星标 design 后 scoped FileTree 未先完成;GREEN 后 node scripts/task497-local-page-tree-filetree-open-performance-smoke.js 通过。最终样本:filetreeInteractiveAt=1779857492435targetClickAt=1779857492621pageAggregateResponseAt=1779857492667editorVisibleAt=1779857492823longTasks=[]directDocumentMs=8;截图保存在 tmp/task497-local-page-tree-filetree-open-performance-smoke/
  • 2026-05-27task498 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=1779860840801targetClickAt=1779860840985pageAggregateResponseAt=1779860841032editorVisibleAt=1779860841196longTasks=[]directDocumentMs=7scoped PageTree 断言改为检查 design scope 内本地页面行,而不是要求父目录名 design 必须作为可见行出现。
  • 2026-05-27Rust filters 通过:root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_htmlsidebar_runtime_applies_page_projection_with_generation_and_yieldsidebar_filetree_runtimelocal_folder_file_projectiontree_projection_routeslocal_folder_file_tree_children_snapshot_scans_parent_oncelocal_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes
  • 2026-05-27node scripts/task494-filetree-lazy-loading-dedup-smoke.js 通过,targetChildrenRequests=1staleScopeChildrenRequests=1cachedExpandMs=33

2026-05-27 SidebarTreeViewState 用户保存态跟进

再次 Sidex 对照结论

Sidex / VSCode Explorer 的展开程度不是由每次 projection 的 expandedByDefault 推断出来,而是由 TreeViewState 驱动:

  • explorerView.ts 定义 TREE_VIEW_STATE_STORAGE_KEY = 'workbench.explorer.treeViewState'
  • onWillSaveStateupdateAnyCollapsedContext() 都会调用 storeTreeViewState(),写入 JSON.stringify(this.tree.getViewState())
  • setTreeInput() 首次输入时从 workspace storage 读取 view state;已有 input 时直接使用当前 tree.getViewState()
  • 无 view state 时才使用保守默认展开:多 root 最多展开 5 个 root;单 folder workspace 受 explorer.expandSingleFolderWorkspaces 控制。
  • 默认 collapseByDefault 返回 truechildren 默认折叠;打开 / reveal 文件时只展开目标 parent chain。
  • filter session 会临时保存 tree.getViewState(),说明 Sidex 把展开、选择、焦点等视为 tree widget state,而不是文件系统 projection state。

MNote 当前状态与 Sidex 的差距:

  • FileTree 只有 mnote.localFileTree.expandedRelativePaths.v1,使用 sessionStoragerootUri 保存 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 最小结构:

{
  "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

  • 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 增加。
  • RED route 测试:在 mnote-web 增加 /api/tree/view-state 的 GET/PUT 测试,使用真实 session actor;断言服务端从 session 取 user,不接受客户端伪造 userId。
  • RED route 测试:同一用户同一 workspace 下,treeKind=filetree&scope=designtreeKind=pagetree&scope=design 返回不同 statescope=rootscope=design 也互不污染。
  • RED JS runtime 契约测试:断言 sidebar-tree-live-apply-runtime.js 中存在 SIDEBAR_TREE_VIEW_STATE_KEYloadSidebarTreeViewStatepersistSidebarTreeViewStateapplySidebarTreeViewState,并且 PageTree 不再只用 item.expandedByDefault !== false 决定展开。
  • 新增 rust/crates/mnote-web/src/routes/tree_view_state.rs,只负责 Sidebar tree view state API;不要把这部分逻辑塞进 projection route 或 local folder source。
  • rust/crates/mnote-web/src/routes/mod.rs 注册:
    • GET /api/tree/view-state
    • PUT /api/tree/view-state
  • 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}"
  • API value 校验:
    • schemaVersion == 1
    • expandedIds / expandedRelativePaths 最多保留 512 条。
    • selectedId/focusedId/activeId 限制最大长度。
    • scrollTop 必须是非负数字。
    • 不接受 rowsByParent/loadedParents/loadingParents/revisionByParent 等派生字段。
  • 服务端写入 user_ui_preferences
    • scope_kind = "sidebar_tree"
    • key = "sidebarTreeViewState.v1"
    • workspace_id/source_kind 按当前请求写入,保证用户、workspace、source 隔离。
  • 服务端读取时只返回当前 actor 的 active preference;无记录时返回 { schemaVersion: 1, treeKind, rootUri, scope, expandedIds: [], expandedRelativePaths: [] },并标记 source = "default"
  • 前端新增统一内存 state
    • sidebarTreeViewStates = new Map()
    • key 包含 userId/workspaceId/sourceKind/treeKind/rootUri/scope
    • FileTree 现有 fileTreeExpandedRelativePaths 改为该 state 的 expandedRelativePaths 引用。
    • PageTree 新增 expandedIds
  • 前端启动流程:
    • 先从带用户维度的 localStorage 镜像读首屏 state。
    • 异步 GET SQLite state。
    • SQLite 返回后只在 generation/rootUri/scope 仍匹配时应用,避免旧请求覆盖当前 DOM。
  • 前端保存流程:
    • toggle 更新内存态;selectedId/focusedId/activeId/scrollTop 已纳入 API 与序列化边界。当前主 sidebar 未提供 collapse all / expand all 入口,本轮不新增入口;后续入口必须复用同一保存态。
    • 写 localStorage 镜像。
    • debounce 300-800ms PUT 到 SQLite。
    • unload / visibility hidden 时 flush pending save。
  • FileTree 迁移:
    • 读取旧 mnote.localFileTree.expandedRelativePaths.v1 作为 fallback。
    • 只在新 SQLite state 缺失时导入旧 expanded paths。
    • 导入后写入新 state;旧 key 不主动删除。
  • PageTree 应用:
    • renderPageRows() 改为优先查 SidebarTreeViewState.expandedIds
    • 没有用户态时,只展开 active/reveal parent chain;最后才使用 expandedByDefault
    • 本地 PageTree 的 depth < 2 默认展开只能作为“无用户态 + 无 active reveal”的首次兜底。
  • FileTree 应用:
    • renderFileRows() 优先查 SidebarTreeViewState.expandedRelativePaths
    • loadFileTreeChildren() 展开 parent 时写用户态,但不得因为自动恢复 children 加载失败而删除 expanded path。
    • scoped root projection 替换时保留同 scope 的 expanded state,不回退到 root scope。
  • reveal 规则:
    • 打开星标 scoped folder 时,只 reveal scoped root 和当前 active document parent chain。
    • 打开 design/05-editor-mainline/process/5-32...md 时,只展开 designdesign/05-editor-mainlinedesign/05-editor-mainline/process
    • reveal 产生的自动展开要标记为 reason = "reveal",可写入保存态;projection refresh 产生的默认展开不得覆盖保存态。
  • 新建页面回归:
    • 新建页面后,新节点 parent 保持展开。
    • 上一个文件夹不被自动折叠。
    • 当前新页面不会出现“闪一下 md 再折叠”的状态反转。
  • 新增浏览器 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 和截图。
  • 扩展 task497
    • 打开目标 Markdown 后,断言没有 PageTree depth < 2 导致的大面积默认展开覆盖用户折叠态。
    • 记录 view-state GET/PUT timing,确保 editor ready 不等待 PUT 完成。
  • 扩展 task498
    • 星标 design 的 PageTree scope 必须读取 pagetree/design state。
    • root PageTree state 不得覆盖 starred scoped PageTree。
  • 增加多用户隔离 smoke 或 route test
    • user A 展开 design/process
    • user B 登录同 workspace,不应继承 user A 的展开态。
  • 验证命令:
    • 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.v1user_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/pagetreeroot/design scope 隔离、派生 cache 字段拒绝。
  • node scripts/task497-local-page-tree-filetree-open-performance-smoke.js:通过,真实 Chromium 点击星标 design 后逐级打开目标 Markdown;最终样本 pageTreeUserStateRestoredAt=1779870589458filetreeInteractiveAt=1779870589562targetClickAt=1779870589750editorVisibleAt=1779870589815longTasks=[]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=1779870615533pageStateSavedAt=1779870616150reloadedStateRestoredAt=1779870616332afterNavigationRestoredAt=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 同时产出。