- 看板: 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·简洁·扩展性·高内聚低耦合·单一真值来源)
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 三重缓解(既定兜底,也是本次要复用的底座)
- 按层懒加载:FileTree 仅在某目录被展开时才拉该层(
loadEntries174-205;toggleDir208-237),从不整树加载。 - 噪音 / 隐藏过滤:后端
is_noise_entry(FileTree 档,tool_registry.rs:1045-1056)+.隐藏文件(module.rs:789)剔除最大宗条目(.git数千项、node_modules/target/dist/__pycache__等)。 - 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(最小可用)
理由:
- 零架构冲突:现有模型就是"每层一个独立请求 +
loadedChildren缓存",把"整层"切片成"整层分批"是该模型的自然延伸,不引入新范式。 - 最小 diff、低风险、可回退(见 §三)。
- 量级匹配:上千条 DOM 在现代浏览器渲染在数十 ms 量级;首屏限 200~300 条后交互即流畅,足以消除卡顿。无需为"上千条"上虚拟滚动的复杂度(A/C 属 YAGNI,仅当实测 >5000 条再评估)。
- 给用户明确信号:「加载更多 (已加载/总数)」比"悄悄截断"更符合用户预期。
三、方案 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 devflowEXIT 0;分页切片纯函数加单测(module.rs:1574已有mod tests模块可承接);bunx vue-tsc --noEmitEXIT 0;node scripts/check-i18n.cjs通过。
四、分期(最小可用优先)
- 期 1(MVP,必做):后端点分页 + 前端「加载更多」按钮。
- 期 2(体验,可选):触底自动加载(IO sentinel)。注意:滚动容器是共享
.explorer-tree(非每层独立滚动),触底自动加载需定位"当前层尾部的 sentinel",实现比按钮复杂;且只做"触底追加"、不做"卸载裁剪",以规避历史 IO 竞态。可延后。 - 期 3(仅当实测触发):若实测 >5000 条仍卡,评估虚拟滚动,且用成熟库(如
@vueuse/coreuseVirtualList)而非自研,并先把递归树拍平为单一带缩进列表。
五、与既有能力的复用
- 懒加载缓存:
loadedChildren(FileExplorer.vue:280,FileTree.vue:180/197/228)→ 分页后仍存"累积 entries",展开秒开语义不变。 - 噪音过滤:
is_noise_entryFileTree 档(tool_registry.rs:1045-1056)→ 分页在过滤之后切片,过滤语义不变(不会因分页漏滤)。 - KeepAlive:
ProjectDetail.vue:83-85→ 分页游标随组件状态在 tab 切换间保留,回来不重拉。 - 在途去重 / 序号守卫:
FileTree.vue:170-171/187→loadMore复用,避免并发覆盖。
六、拍板点
- 分页粒度:首屏
limit与每次追加步长各取多少?(建议各 200 —— 兼顾首屏速度与点击次数) - 交互形态:MVP 用「加载更多」按钮(推荐:简单、可测、与共享滚动容器不冲突)vs 触底自动加载(期 2 再评估)?
- 是否本期引入虚拟滚动:建议否决(YAGNI + 自研疤痕),仅当实测 >5000 条再以成熟库评估 —— 是否认可?