Files
DevFlow/docs/02-架构设计/专项设计/FileTree大目录-方案-2026-10-10.md
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00

9.2 KiB

FileTree 大目录卡顿 — 可实施方案

日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(Wave Q) 定位: 「单个目录上千条合法条目」的卡顿风险可实施方案。本文只写方案,不改码。 触发: Wave Q 实测 — 后端 get_module_file_tree 不封顶、前端 FileTree 全量 v-for,当前靠三重缓解兜底。 结论预告: 推荐 方案 B(后端 offset/limit 分页 + 前端「加载更多」),先做期 1 最小可用;虚拟滚动本期否决(见 §二·A 的实证疤痕)。


一、现状与证据(带行号)

1.1 后端:列目录不封顶

src-tauri/src/commands/module.rs 命令 get_module_file_tree(707-834):

  • 774-777 std::fs::read_dir(&target_dir) 遍历目标目录全部条目;
  • 781-791 过滤噪音目录(is_noise_entry,FileTree 档)+ 隐藏文件(. 开头);
  • 810-818 每条 push 进 items;
  • 820-824 排序(目录优先 + 字母序,稳定可预期);
  • 830-833 一次性返回 { path, entries }。

无任何条数上限。对照同语义的另一档位:AI 侧 list_dir_recursive(src-tauri/src/commands/ai/tool_registry.rs:923-965)有 max_entries 封顶(调用处 tools/file.rs:347 传 1000,达上限返 truncated)。即"列目录"这一语义,AI 上下文档有封顶、文件浏览器档没有 —— 这是 Wave Q 要补的缺口。

1.2 前端:单层全量 v-for

src/components/project/FileTree.vue:

  • 25-33 <li v-for="entry in entries" :key="entry.path"> —— 当前层全部条目一次性渲染;
  • 95-106 FileTree 自引用递归:展开的子目录再加一层同构 <ul>;
  • 157-205 entries 为该层全量(命中 loadedChildren 缓存则整层直赋)。

滚动容器是共享的 .explorer-tree(FileExplorer.vue:806-813,overflow-y: auto),不是每层各自滚动 —— 这决定了虚拟滚动若要落地,需先把递归树拍平成"单一带缩进的可见行列表"(见 §二·A)。

1.3 三重缓解(既定兜底,也是本次要复用的底座)

  1. 按层懒加载:FileTree 仅在某目录被展开时才拉该层(loadEntries 174-205;toggleDir 208-237),从不整树加载。
  2. 噪音 / 隐藏过滤:后端 is_noise_entry(FileTree 档,tool_registry.rs:1045-1056)+ . 隐藏文件(module.rs:789)剔除最大宗条目(.git 数千项、node_modules/target/dist/__pycache__ 等)。
  3. KeepAlive 保活:src/views/ProjectDetail.vue:83-85 <KeepAlive> 包 FileExplorer,切 tab 不销毁、不重挂载、不重拉。

附随机制:loadedChildren 缓存(FileExplorer.vue:280)+ 在途去重 / 请求序号(loadSeq/inflightEntries,FileTree.vue:170-171)使重复展开秒开、并发请求去重。

残留风险:三重缓解把"上千条"压缩成"单层上千条",但对一个真实含上千条且非噪音的目录(如日志目录、生成代码目录、扁平资源目录),后端仍全量返回 + 前端仍全量 DOM。


二、方案对比

维度 A 前端虚拟滚动 B 后端 offset/limit + 前端「加载更多」 C 两者结合
机制 只渲染可视窗口(+ overscan) 后端分页切片,前端累积 B 分页 + A 窗口化
后端改动 无 get_module_file_tree 加 offset/limit/total 同 B
前端改动 FileTree 重构为扁平窗口化列表 FileTree 累积 entries + 尾部按钮 B + 窗口化
依赖 需新库(项目现有依赖无)或自研 无 需新库
与现有架构 冲突(递归树需拍平;共享滚动容器需重排) 天然契合(每层本就独立请求) 中
风险 高(高度测量/展开态/选中态同步;项目有自研虚拟滚动翻车疤痕) 低(改动集中、可回退) 高
适配量级 上万条 上千条(本场景) 上万条

A 的风险:项目实证疤痕

自研虚拟滚动曾落地于消息列表(自研 IntersectionObserver + sentinel + ResizeObserver),因 IO/RO 时序竞态致消息重叠,2026-06-18 彻底移除(见 docs/待决策.md:132-136、docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md:362-363;MessageList.vue 至今标注"虚拟滚动已移除,恒渲染")。

并且 package.json 现有依赖无任何虚拟滚动库(@vueuse/core 也未安装)。再自研 = 重蹈覆辙;引库 = 新增依赖 + 仍需适配递归树拍平 —— 成本与"上千条"的收益不成比例。

推荐:B(最小可用)

理由:

  1. 零架构冲突:现有模型就是"每层一个独立请求 + loadedChildren 缓存",把"整层"切片成"整层分批"是该模型的自然延伸,不引入新范式。
  2. 最小 diff、低风险、可回退(见 §三)。
  3. 量级匹配:上千条 DOM 在现代浏览器渲染在数十 ms 量级;首屏限 200~300 条后交互即流畅,足以消除卡顿。无需为"上千条"上虚拟滚动的复杂度(A/C 属 YAGNI,仅当实测 >5000 条再评估)。
  4. 给用户明确信号:「加载更多 (已加载/总数)」比"悄悄截断"更符合用户预期。

三、方案 B 改动面 / 风险 / 回退 / 验收

3.1 改动面(期 1 最小可用)

层 文件 改动
后端 src-tauri/src/commands/module.rs get_module_file_tree 加 offset: Option<usize>、limit: Option<usize> 参数;返回加 total。先完整收集 + 过滤 + 排序,再切片(切片点放在 820-824 排序之后),保证分页稳定。limit 缺省 = None → 返回全量(保持旧行为)。
前端 API src/api/module.ts getModuleFileTree 加 offset/limit 参数;FileTreeResult 加 total?: number。
前端组件 src/components/project/FileTree.vue entries 改为"累积注入":首屏拉 limit 条,loadMore() 按 offset=已加载数 追加,直到 total;层尾渲染「加载更多 (N/M)」;分页状态并入 loadedChildren 缓存(建议其值由 FileTreeEntry[] 扩展为 { entries, total } 或新增 loadedTotals map,否则重展开丢失"还有更多"状态)。
i18n src/i18n/zh-CN/fileExplorer.ts、src/i18n/en/fileExplorer.ts 新增 loadMore(「加载更多」/「Load more」)、可选 allLoaded。

不改 FileExplorer.vue(缓存语义、展开 / 选中逻辑不变)。改动集中、职责单一。

3.2 风险

  • offset 错位:目录内容在分页期间变化 → 可能重复 / 漏项。自用场景 + refresh 清缓存重拉(resetTreeState,FileExplorer.vue:330-336)已覆盖,可接受。
  • 多次请求 git status:每个分片请求都重跑 git_status_map_cached,但有缓存(10s TTL),开销可控。
  • has_children 开销:现每目录一次 read_dir 短路统计;分页后只对返回的前 N 条目录计算,反而降开销。
  • loadMore 与首屏请求竞争:需让 loadMore 复用同一 inflight 去重键或使用独立键,避免与 onMounted 首屏请求互相覆盖(机制见 FileTree.vue:185-188)。

3.3 回退

  • 后端 limit/offset 缺省 = None = 全量;前端不传 limit 即旧行为。
  • 或前端恒定不渲染"加载更多"(首屏即全量)。
  • 改动集中在 3 个源文件 + 2 个 i18n 文件,单次 revert 即可回滚。

3.4 验收口径

  • 造数:批量子项造 1500 条目录(如 1500 个空文件)。
  • 首屏 DOM 行数 ≤ limit(如 200);层尾显「加载更多 (200/1500)」;点击逐次追加 200 条直到 1500;加载完按钮隐藏。
  • 回归:选中文件高亮、git 状态标、空目录标、展开子目录、切工程、刷新,均正常。
  • 命令:cargo check -p devflow EXIT 0;分页切片纯函数加单测(module.rs:1574 已有 mod tests 模块可承接);bunx vue-tsc --noEmit EXIT 0;node scripts/check-i18n.cjs 通过。

四、分期(最小可用优先)

  • 期 1(MVP,必做):后端点分页 + 前端「加载更多」按钮。
  • 期 2(体验,可选):触底自动加载(IO sentinel)。注意:滚动容器是共享 .explorer-tree(非每层独立滚动),触底自动加载需定位"当前层尾部的 sentinel",实现比按钮复杂;且只做"触底追加"、不做"卸载裁剪",以规避历史 IO 竞态。可延后。
  • 期 3(仅当实测触发):若实测 >5000 条仍卡,评估虚拟滚动,且用成熟库(如 @vueuse/core useVirtualList)而非自研,并先把递归树拍平为单一带缩进列表。

五、与既有能力的复用

  • 懒加载缓存:loadedChildren(FileExplorer.vue:280,FileTree.vue:180/197/228)→ 分页后仍存"累积 entries",展开秒开语义不变。
  • 噪音过滤:is_noise_entry FileTree 档(tool_registry.rs:1045-1056)→ 分页在过滤之后切片,过滤语义不变(不会因分页漏滤)。
  • KeepAlive:ProjectDetail.vue:83-85 → 分页游标随组件状态在 tab 切换间保留,回来不重拉。
  • 在途去重 / 序号守卫:FileTree.vue:170-171/187 → loadMore 复用,避免并发覆盖。

六、拍板点

  1. 分页粒度:首屏 limit 与每次追加步长各取多少?(建议各 200 —— 兼顾首屏速度与点击次数)
  2. 交互形态:MVP 用「加载更多」按钮(推荐:简单、可测、与共享滚动容器不冲突)vs 触底自动加载(期 2 再评估)?
  3. 是否本期引入虚拟滚动:建议否决(YAGNI + 自研疤痕),仅当实测 >5000 条再以成熟库评估 —— 是否认可?