Conversation
文件树的内置忽略清单把 build / dist / out / bin / obj / Temp / Logs / vendor / target / Library 这类目录无差别隐藏,用户在树里新建同名目录后 既看不到、点刷新也不会出现(过滤发生在 listDir 的数据源头,不是缓存问题)。 按裁决加开关而不改默认,开关位置就是用户撞见问题的地方:文件树标题行, 与「搜索 / 收起 / 刷新」并列(紧挨搜索——两者都是"树里显示什么")。 - 内置清单拆两层:VCS 元数据与 OS 垃圾永远隐藏;依赖 / 构建产物 / 缓存 归入可放行的 REVEALABLE 层,由 showIgnoredDirs 控制(默认 false, 未自定义的用户行为不变,不需要迁移)。 - 开关进 useFileTree 的 store key:切换即换 store,listDir 与 watcher 一起用新 matcher 重建,不需要用户手动刷新。 - watcher 的 parcel 预过滤同步拆层:开关打开后只保留 .git 与 node_modules / Library(原生递归 watch 的代价与收益不成比例,这两个 目录仍然只有列出、内部改动不推事件,注释里写明了边界)。 - 传输链一路透传:desktop IPC → SSH 远端 daemon(协议字段可选、 append-only,bundle 版本 0.2.4 → 0.2.5 以触发重推)→ device-link 被控端 listDir;daemon 侧 watchStart 选项变化会重建 matcher。 - 开关组件 FileTreeIgnoredDirsToggle 由 RSB 文件浏览器与 doc 模式侧栏 共用:同一份全局偏好只留一个入口,两处以同一视觉/交互出现。 desktop 侧偏好存 localStorage 且只存 override(规则 20),形态对齐 ReviewTabBody 的状态开关:aria-pressed + 按下底色 + 图标随状态切换, 文案说下一步动作(DESIGN.md §14.6),圆角走 §5 的 pill 档。 - device-link 的 watch 不接该开关(topic 订阅载荷里没有过滤选项,与 hideMetaFiles 同款限制):目录能列出,内部改动不推事件。 测试:file-browser-core 的内置清单分层 / 新建目录可见性回归、daemon watchStart 选项重建、desktop watcher 选项重建、偏好 override 语义、 useFileTree 的 store 分离、开关组件(含两处标题行的接线与位置守卫), 以及设置页排版门禁。 Signed-off-by: zyjisdog <zhangyunjin@caep.cn>
两条 P1(两个评审 bot 都独立指出)与一条 P2: 1. 启动窗口内到达的新选项被丢掉、并留下「没有 watcher」的空档。 控制端切开关时 renderer 是「先 stop 再带新选项 start」;如果首个 watchStart 的 matcher 还在异步加载,旧实现让第二次 start 复用那个 promise —— 新选项既不记录也不重建,而 stop 打的 stopDuringStart 标记又让那个 watcher 自拆,最终两个 RPC 都报 success,workdir 却 再也没有 watcher(文件树的实时刷新静默失效)。 改成 WorkdirWatchManager 收敛到「最后一次被请求的选项」:每次 start 写 desired,循环里重读并推进,直到「选项相符的 watcher 已就位」或 「意图被 stop 撤销」。同一 workdir 仍只有一个原生 watcher,选项变化 走 closeEntry + 重建(不能走 stop(),它会撤销 desired)。 2. daemon 缺事件侧恒真层:开关打开后 node_modules / Library 被一并 放行给事件过滤,而 fs.watch recursive 会把它们的每个改动路径都推成 fileTree 帧过 SSH → IPC(装依赖 / Unity 导入时是路径数同阶的洪水)。 desktop 本地 watcher 一直有 PREFILTER_ALWAYS 兜底,daemon 侧没有, 于是 PR 里「可列出、内部改动不推事件」的边界在 SSH 路径上是假的。 新增 file-browser-core 的 WATCH_ALWAYS_IGNORE(单源)与 createEventIgnoreMatcher(),daemon 事件过滤与 desktop 预过滤吃同一 份名单。 3. (P2,不改行为只把判据写清)文件名筛选不跟随「显示被忽略的目录」: rg 后端无法只放行构建产物而保留用户自己的 gitignore 规则,单让 fallback 跟随会让筛选结果随机器上装没装 rg 而变。补决策测试把两侧 差值钉住,并把注释里过期的「清单 = 文件树可见全集」改成默认口径。 测试:两条 P1 各补回归用例(收敛竞态断言「旧 watcher 关闭 + 新 watcher 活着且按新选项推事件」;恒真层断言 node_modules / Library 不推而 build / dist 仍推)。把源码回退到上一版跑,新增用例正好这两条失败, 证明它们盯的是真问题。file-browser-core 41 passed、remote-file-service watchOptions 7 passed、desktop 定向 25 passed;desktop typecheck 通过。 Signed-off-by: zyjisdog <zhangyunjin@caep.cn>
同一行里原先只有新增的「显示被忽略的目录」开关走 pill,搜索 / 收起 / 刷新 各自写 rounded-md(6px):一行两种圆角。按 DESIGN.md §5 Step 2,控件框(含 transient hover / pressed 表面)登记在 pill 档,6px 不在受治理取值里,所以 统一走 pill 是把这行收进合规,不是新增例外。 - 新增 FILE_TREE_HEADER_ICON_BUTTON_CLASS:这行的搜索 / 显示被忽略的目录 / 收起 / 刷新,以及搜索态的 X,全部取同一份类名 —— 两个宿主不再各自复制, 以后在这行加按钮也只能拿到同一个值。 - doc 模式宿主的标题触发器(项目名下拉)同排且同样带 hover 表面,一并收为 pill。 - 未顺带迁移该行之外的存量控件(错误态「重试」文字按钮、切换项目下拉项)。 几何守卫测试:两个宿主的标题行必须引用共享常量,且不再自写 rounded-(避免 以后再次漂移)。 目检 + 实测(dev 实例,CDP getComputedStyle):RSB 文件浏览器与 doc 模式侧栏 两种布局 × Light/Dark,四个按钮实测圆角 9999px(尺寸 20×20、图标 14); 按下态底色 light rgb(60,63,67) / dark rgb(238,238,238),与 token 一致。 证据文本见 docs/design-evidence/2026-09-11/file-tree-header-icon-buttons.md (栅格不入仓)。 Signed-off-by: zyjisdog <zhangyunjin@caep.cn>
评审(Codex P1×3 / P2×2)与本分支既有失败一并处理: - device-link 能力门控:caps 增加 showIgnoredDirs。老被控端的 listDir 会静默 忽略该字段,标题行开关不再装成可用 —— 按不可用渲染并在 tip / aria-label 里说明原因,树同时按隐藏态建立(不向老端发它看不懂的字段)。 - remote-watch:watchStart 失败清理只回收本次 start 装上的那条注册。切开关会 在同一 key 上 stop→start 装替换注册,旧请求迟到 reject 不得把它删成孤儿 (否则后续 stop() 提前返回、listener 泄漏、daemon 侧 watch 不停)。 - watcher:PREFILTER_ALWAYS 无条件进 parcel ignore 名单 —— 目录当时不存在 (npm install / Unity 导入)就不会有第二次机会,开关打开后是路径数同阶的 事件洪水;REVEALABLE 仅在开关关闭时预过滤。 - expandedStore:展开态按 showIgnoredDirs 分片(隐藏态沿用历史键,零迁移)。 否则放行态展开过 node_modules / Library 后切回隐藏态,init 会把上百个巨大 隐藏目录当成"已展开"并行 listDir。 - 标题行搜索 / 退出搜索 / 收起 / 刷新四个存量图标钮补本地化 aria-label (DESIGN.md §14.6;上一轮统一圆角改过这些控件,可访问名一并补齐)。 本分支既有失败(与本次评审无关,但 CI 单元测试会挂): - deviceOp.test.ts 有 4 条 SSH listDir 期望漏了新增的 showIgnoredDirs 字段; - workdirBrowseRemoteSafety.test.ts 的整行文本断言在 useFileTree 折行后失配, 改为对调用块本身断言,保留"必须带 remoteHostId / deviceId"的安全语义。 验证:pnpm test:unit:related 通过(desktop 全量 + file-browser-core / remote-file-service);desktop typecheck 通过;check:i18n、check:i18n-glossary、 check:brand-terminology、check:endpoints、check:design-colors、 check:design-inventory --check 全部通过。新增回归用例均验证过"在修复前源码上 会失败"。 Signed-off-by: zyjisdog <zhangyunjin@caep.cn>
Codex 在 4c2af4b 上提的 P1:device-link 暂时连不上(被控端重连中)时,caps 探测的 reject 被当成「确定性不支持」;而 useFileTree 的探测 effect 只依赖 (deviceId, workdir) —— 面板不卸载就不会再探,开关会一直禁着并显示「对方版本 过旧」,即使连接早已恢复。 - deviceSupportsRevealIgnoredDirs 改成三态:true / false = 已探定的结论, null = 瞬态(隧道不可达 / 重连中)。确定性判定沿用 isDeviceTooOldError (老被控端没有 remote-op channel,invoke 会被快速拒回 CHANNEL_NOT_ALLOWED)。 - 缓存只留肯定结论 true:「不支持」不再留在缓存里 —— 被控端在一次掉线期间 升级是真实场景,负缓存会把它钉在「不支持」直到重启;瞬态同样不缓存。 - useFileTree 用既有的 useDeviceLinkReconnectEpoch(relay 或目标设备恢复 online 时自增)驱动重探。瞬态结果保持「未知」,不推翻已有结论(避免重连时 开关闪一下);换设备才重置结论。 回归:新增 fileBrowserTransportRevealCaps.test.ts(5 条三态/缓存语义)与 useFileTree 两条(瞬态保持未知 + 重连后重探落定、已定结论不被重连重置)。 这 4 条断言在修复前的源码上都会失败(已实跑确认)。 验证:pnpm --filter desktop run typecheck ✅;pnpm test:unit:related ✅ (desktop 全量 + file-browser-core + remote-file-service)。 Signed-off-by: zyjisdog <zhangyunjin@caep.cn>
开关进 useFileTree 的 store key,切换即换一份 store;新 store 从空快照 + initialLoading 起步,FileTreeView 会把整棵树替换成空白占位(本地 <300ms 连 spinner 都没有),数据回来后才重新挂载——视觉上闪一下。同排的「刷新」 在同一份 store 上原地 refetch,从不经过空态,所以不闪。 - 切 store 时继承同 workdir 另一半 reveal scope 的 store 快照:它此刻还在 stores 表里(旧 store 的 refCount 归零发生在本次 commit 的 effect cleanup,晚于 render 期间的 useMemo),首帧就有树可渲染。 - initStore 把 seed 的全部目录用新 matcher 重拉校正,含已收起但有缓存的 目录——否则用户之后展开它会直接命中 stale 缓存。 - expanded 由覆盖改为并集合并:新 scope 的 localStorage 可能没有记录, 覆盖会把已展开的目录折叠(同一个闪烁的另一面)。 - 错误态一并继承,避免「错误 → 空树 → 错误」的抖动。 回归测试两条(切开关不回到 initialLoading 空白 / 展开态不折叠);禁用 seed 时恰好这两条失败,其余照常通过。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
1. 切开关的 store 预热只借根列表,不再跨 reveal scope 继承子树缓存与展开集合。 reveal 态展开过 node_modules 时,旧实现会把它带进 hidden store 的 expanded 与 warm 列表 —— hidden 侧为被忽略路径发 listDir(SSH 上可达数百个 RPC), 并在根列表回来前把被忽略的行显示出来。展开态仍按各自 scope 从 localStorage 恢复(expandedStore 的分片本意)。 2. watcher 预过滤从「根级绝对路径」改为 glob。实测 @parcel/watcher 2.5.6 (win32):非 glob 项走 ignorePaths 前缀比较,`nested/node_modules/x` 不是 `/workdir/node_modules` 的后代 —— monorepo(packages/foo/node_modules)与 嵌套 Unity 工程(client/Library)整目录漏过预过滤,开关打开时 install/导入 的每个生成文件都推一条事件过 watcher-host + IPC。glob `**/<name>/**` 与层级无关(目录项必须带 `/**` 后缀,否则只匹配目录自身,实测)。 3. 能力探测的肯定结论改为按连接代次失效。只缓存 true 而缓存不带代次时, 被控端被回滚到老 Desktop(deviceId 不变)后,重连触发的重探会命中旧 promise、不再发 caps RPC —— 开关保持可按而 listDir 静默忽略该字段。 代次取自 useDeviceLinkReconnectEpoch。 回归测试各一条(回退对应修复时精确失败):关开关不把 reveal-only 路径带进 hidden store / 预过滤用 glob 覆盖任意层级 / 肯定结论按连接代次失效。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审指出:`useDeviceLinkReconnectEpoch` 是 `useState(0)` 起步的 hook 局部计数器,
只观察挂载期间的事件。面板卸载 →(此时设备重连 / 被回滚,没有任何 hook 看得到)→
面板重挂载时新实例又从 0 开始,而进程内的缓存还留着上一代的
`{epoch: 0, probe: true}` —— 跳过 caps RPC,开关对着已回滚的老端一直可按。
上一版把 hook 局代次当缓存 key 没有封住这条路径。
改为缓存自己订阅全局 reconnect 流:
- 模块级 `reconnectGeneration`,由进程级 `onPresenceChanged` / `onStatusChanged`
订阅维护(任一设备或 relay 恢复 online 即自增)。订阅不随面板生命周期起落、
故意常驻 —— 它本身就是「面板不在时也要记账」的那只耳朵;惰性建立,测试环境
没有 deviceLink 时静默跳过。
- `deviceSupportsRevealIgnoredDirs` 回到两参签名:代次是缓存内部状态,不从调用方传。
- `useFileTree` 侧保留 `revealReconnectEpoch` 的 effect 依赖,它仍负责「何时重问」。
回归测试两条(回退代次判定时精确失败):重连(即使没有任何 hook 观察)作废肯定
结论缓存 / relay 恢复 online 同样作废缓存。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
两件事落在同一处代码(useFileTree 的 store 预热):
1. 观感:上一版把预热从「整棵可见树」降级成「只借根列表」后,关闭开关的消失
被拆成两段 —— 子行先因 expanded 掉落而消失(父行还在),root 数据回来再拿掉
父行 —— 多级展开时看起来就是「从最子级逐级折叠回去」。改回整棵借:过渡期树
保持不动,数据回来整体替换,消失是一次性的。
为不让这个过渡窗口退化成「重复上一版被评审否掉的行为」:
- warm 仍只拉 restored,不重拉 seed 的子树(不给被忽略路径白发 listDir);
- 新增 pruneExpandedForCurrentTree:root 数据落地后按新树逐级查父链,剪掉借来
的不可达展开位 —— 否则它们会随下次操作写进 hidden scope 的 localStorage,
下次启动白发一批 listDir。
2. 评审 P1:兄弟 store 还在首次 listDir 上(慢通道)时没有可显示内容,上一版仍
置 initialLoading:false,FileTreeView 会把空 rows 渲染成「此文件夹为空」。现在
只有 entries 非空或已有终态错误时才当可显示的 seed,否则保持 loading。
回归测试两条(用 dd0737e 的实现跑,恰好这两条失败):关开关整树保持到数据回来
再一次性替换并剪枝 / 兄弟 store 无首帧数据时保持 initialLoading。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
两处评审 P2(round 5): 1. useFileTree.ts:关开关后 reveal 态根列表被原样继承,node_modules / build 这些 行要等新 matcher 的 root 数据回来才消失 —— 慢通道(SSH / device-link)下是数秒 的「开关关了却还看得见」。现在切到隐藏态的首帧就按内置名单把它们滤掉;子树 缓存与展开集合不动(父行不可达 → 整棵子树同帧消失),不破坏上一轮的「整体一次 性消失」。名单单源在 file-browser-core,为此拆出零依赖的 ignoreNames 模块并经 子路径导出给 renderer(包根 barrel 会把 node:fs 拖进浏览器 bundle); `.gitignore` 自定义条目仍要等新数据 —— 不为它加 IPC。 2. remote-file-service/watch.ts:同一 SSH workdir 会被 desktop 文件树与 device-link 控制端同时订阅(前者带用户开关、后者恒为隐藏),desired 按 workdir 存单值 → 后到的隐藏态请求把 reveal matcher 覆盖掉:desktop 仍列着 build / dist,它们的 改动却永远没有事件。改为按 consumerId 登记需求并取可见性并集,任一消费者在就 不拆 watcher、最后一个 stop 才关;协议 watchStart / watchStop 加可选 consumerId (缺省归默认消费者,老控制端不受影响),调用方分别用 desktop-tree / device-link。 回归测试 4 条(反证:用修复前的实现跑,恰好这 4 条失败):关开关首帧滤掉被忽略 目录 / 多消费者并集不重建 / 部分 stop 收敛收窄 / 最后 stop 才拆 watcher。 门禁:desktop + remote-file-service typecheck、file-browser-core tsc、 pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 P1(在 f4e71d6 上):首帧过滤只扫了根列表。被忽略目录嵌在已展开的普通目录 下面时(`packages/foo/node_modules`),它挂在父目录的缓存列表里,首帧照旧渲染; 更糟的是 pruneExpandedForCurrentTree 拿**过期的 reveal 子列表**判可达性,把它判 成可达 → 一直留到手动刷新或事件到来。 改成遍历所有缓存列表逐条过滤: - 渲染:父列表里没有那一行,展开的父目录下不再显示被忽略目录; - prune:可达性判据同步修正,嵌套展开位被剪掉,不会随下次操作写进 hidden scope 的 localStorage; - 仍不重拉任何目录、不动展开集合,「整体一次性消失」的观感不变。 回归测试(反证:用 f4e71d6 的实现跑恰好这条失败):展开 packages / packages/foo 后关开关,两层列表里的 node_modules 首帧即消失,prune 把两个嵌套展开位都剪掉而 保留 packages/foo 自身。 门禁:desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 6,三条都落在 watch.ts: 1. 恒真忽略目录的**自身**事件被吞:`ignore` 的目录模式(`node_modules/`)同时匹配 目录自身与全部后代,而事件过滤要的只是后代 —— 开关打开时这一行就在树里,它被 建 / 删 / 改名必须让父目录 refetch,否则树陈旧到手动刷新。改为按路径段判定 「内部」,并在兜底的 BUILTIN_IGNORE_ALWAYS 层显式放行目录自身。 2. watcher 出错时调 stop(workdir)(默认 consumerId):删错人,且 reconcile 看到 同一个坏 entry 选项没变而原地返回 —— 直播静默冻结到手动改开关。改为拆掉**这个** entry(带 `entries.get(workdir) === entry` 守卫)并保留消费者意图、按剩余并集重建。 3. start 失败时不回滚已写入的 desired:控制端只清本地注册、不会再发 watchStop → daemon 留下幽灵消费者:抬高别的消费者的可见性并集,最后一人 stop 时还会留下 孤儿 watcher。改为 reconcile 失败时回滚**本次**写入的意图(引用比较,不碰并发覆盖)。 回归测试 3 条(反证:用 1f327fa 的实现跑,恰好这 3 条失败):目录自身事件仍推、 出错重建、失败回滚后并集不被抬高且能正常拆 watcher。 门禁:remote-file-service + desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 7 + 自查: 1. hidden → reveal 方向不能只靠过滤(useFileTree):hidden 态的数据**缺**了被忽略 目录,开开关后继承来的展开父目录若继续用 hidden 版列表,node_modules / build 就一直不可见,直到手动刷新。按方向分别处理:切到 reveal 时重拉继承的展开父 目录(按新 matcher);切到 hidden 时仍走首帧过滤(近似 hidden 视图,不白发 listDir 去拿「少一些」的数据)。 2. rename 迁移的持久化 scope 对齐有效开关(WorkdirBrowseSidebar):device-link 老 被控端不支持 showIgnoredDirs 时 useFileTree 会退回隐藏态 store,而迁移仍按 用户偏好写 reveal scope → 落在不生效的那一格(隐藏 scope 存旧路径、重挂载后 请求已不存在的目录、新目录展开态丢失)。 3. 自查发现的真 bug(watch.ts):start() 回滚后没有重新收敛。失败可能发生在「选项 变化 → closeEntry 拆掉旧 watcher → startInner 失败」之后 —— 回滚后原有消费者 **没有 watcher**,而且它不会重试,直播静默失效到手动刷新。改为回滚后按恢复的 意图再 reconcile 一次。 (自查还提出过「stopDuringStart 在失败路径残留」,复核后**不成立**:reconcile 的 finally 本来就清理成功/失败两条路径 —— 已回退那处无意义改动与无法区分修复前后的 用例,只保留真发现。) 测试:开开关重拉继承父目录 / 回滚后重建原有消费者 watcher / rename 迁移 scope 源码 守卫(反证:用 4edfbb8 的实现跑,恰好这三条失败)。 门禁:desktop + remote-file-service typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 P1(本地 watcher 侧的 daemon 同类问题): `**/<name>/**` 会把**目录自身**的事件一并吞掉 —— 探针实测(2.5.6/win32 真实 subscribe):`mkdir node_modules` 一条事件都没有,`nested/Library` 也只有父目录的 create。开关打开时新建 / 删除 / 改名 node_modules / Library 都不会让 renderer refetch 父目录,那一行缺失或陈旧到手动刷新。daemon 侧已按同一裁决修过(见 watch.ts 的 isInsideAlwaysIgnoredDir),本地 watcher 漏了。 修法:每个目录发**两条** glob,目录自身不匹配: - `**/<name>/*` → 一级内容忽略 - `**/<name>/**/*` → 深层内容忽略 探针复核(同机同版本): - mkdir node_modules → create:node_modules ✅ - mkdir nested/Library → create:nested + create:nested/Library ✅ - rename node_modules → nm2 → create:nm2 + delete:node_modules ✅ - write node_modules/a.js 与 mkdir sub + write sub/b.js → 零事件 ✅ - 仍不进入子目录(不会退回「监听整个 node_modules」的资源爆炸)。 注释另记:将来名单出现**文件**项(如 .DS_Store)要发不带后缀的形态 —— 文件没有 「内部」。 反证:用 155b66d 的实现跑,恰好这 3 条失败。 门禁:desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 P1(e5508f9 上):同一 consumerId 的重叠 start(双窗口启同一 SSH workdir) 共享同一次启动、一起失败时,后到的那个把 previous 快照成前一个**同样失败的** attempt;回滚「恢复」它 → 失败注册复活 → 回滚后 fire-and-forget 的 reconcile 会 真给它建一个 watcher(两个 caller 都已报失败,不会再发 watchStop)→ 孤儿 watcher + 残留过滤意图。 修法:不再快照「进入时的前值」,改为跟踪**已提交(committed)意图** —— - desired 的值变成 ConsumerIntent { options(当前尝试), committed(最近一次 reconcile 成功后生效过的值) }; - start 成功后才记 committed;失败回滚恢复到 committed; - 没有 committed(该 consumer 从未成功注册)就直接删注册。 连带修一处不变量:reconcile 在 effectiveOptions === null 时原本只 return、不拆 已存在的 watcher —— 回滚删掉最后一个消费者后会留下没人再 stop 的孤儿。现在 「无消费者 ⇒ 无 watcher」在 reconcile 里统一维护(与 stop / stopAll 的显式拆除 幂等叠加)。 回归测试(反证:用 e5508f9 的实现跑,恰好第一条失败): - 同一 consumerId 的重叠 start 都失败 → 不留活着的孤儿 watcher,且下一次同选项 start 真能建出活 watcher; - 正向锁定:已有生效注册的消费者改选项失败 → 回滚到已生效值而不是删注册。 门禁:remote-file-service + desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 8 的两条 P1: 1. remote-watch.ts(重叠替换全失败时的孤儿):同 (host, workdir) 的多窗口共用一个 consumerId,而 stop 侧有引用计数(还有别窗在 watch 就不发 watchStop)。两个替换 start 同时失败时,本地条目全被删、daemon 侧却留着注册与回滚重建出来的 watcher —— 没有任何客户端会再去 stop 它,它还会抬高 device-link 的可见性并集。现在 最后一个失败者在确认本地再无同 (host, workdir) 条目后补发一次 watchStop。 2. useFileTree.ts(refresh 重扫隐藏子树):filterSeedTreeForHiddenView 只拿掉行、 仍保留 node_modules 子树的缓存 key —— 而 refresh() 按 entries 的 key 逐个 listDir,隐藏态下一次刷新会给这些不可达路径白发数百个 RPC(SSH / device-link)。 现在整棵丢掉被移除目录的缓存(不只那一行),refresh 只扫仍可达的 key。 回归测试(反证:用 8b8597e 的实现跑,恰好对应 3 条失败): - 同 workdir 两个窗口的 start 都失败 → 最后一个失败者撤回 daemon 注册(先失败 者不撤:对方可能站起来); - 关开关丢弃被移除目录的缓存子树 → refresh 不再重扫它们; - 原「关开关整树保持」用例的缓存断言同步改为「整棵缓存被丢」。 门禁:desktop + remote-file-service typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
自查 + 实测发现:FileTreeRow 没有 memo,且 onContextMenu 是父组件的内联箭头 (即使加 memo 也会被击穿)。切「显示被忽略的目录」时 store 会多次 emit(root + 每个恢复展开的目录各一次),每次都重渲染全部行 —— 日常 workdir 2700+ 行时表现 为点开关后界面无响应。 修法: - FileTreeRow 包 memo,只在 entry 引用或 selected/expanded/loading 变化时重渲染; - onContextMenu 签名改成 (entry, e)(entry 由行内回传),父组件用 ref 转发到最新 实现 + useCallback([]) —— 避免连锁要求把 hasContextActions / canOpenEntry* 全部 useCallback 化;调用方传进来的其它 handler 本来就已经是 useCallback。 实测(CDP 挂真实 dev 实例,PerformanceObserver 抓长任务;2826 行树,同一操作序列, 修复前后各跑两遍): - 开开关(78 → 2826 行):3 个长任务共 1200ms(最长 1092ms) → 1 个 515ms; - 关开关(2826 → 78 行):1 个 51ms → 1 个 91ms(行数减少的方向本来就快)。 剩余 515ms 是「一次性渲染 2826 行」的固有成本(约 0.18ms/行),不属本修复范围 —— 要彻底消除需要虚拟滚动或分帧渲染,留作后续独立改动。 门禁:desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
评审 P2(merge 后的 head 上):切到隐藏态时 expanded 会继承另一半 scope 的展开
集合(过渡态,含 node_modules)。若用户在 root 数据回来前操作任一目录,
toggleFolder 那一发 saveExpandedSet 会把整个集合(含 reveal-only 路径)写进隐藏
scope —— 而剪枝发生在 root 数据回来后,旧实现只改内存快照、不回写,脏路径就固化
进了 localStorage:下次挂载按它们逐个 listDir(上限 200,SSH / device-link 上
代价明显)。
修法:pruneExpandedForCurrentTree 修剪后原地 emit 的同时**回写持久化**(同一
scope),覆盖过渡窗口里可能已写入的继承路径。
为什么不在保存点过滤(评审建议的另一条):过渡窗口里 root 数据还没到,entries
里没有根列表,可达性判据根本不成立 —— 剪不了,只能靠回写兜住。原有「祖先列表
缺失时保守保留」的语义不变。
回归测试(反证:用 merge 后 HEAD 的实现跑,恰好这条失败):reveal 态展开
node_modules → 切隐藏态(root 挂起)→ 过渡窗口里 toggleFolder('src') → root 回来
后断言隐藏 scope 最后一次持久化不含 node_modules、仍含 src。
门禁:desktop typecheck、pnpm test:unit:related 全 PASS。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 9 的两条 P2: 1. useFileTree.ts:剪枝挂在 fetch 成功分支上,根请求**失败**时根本不执行 —— 而继承 来的 reveal-only 展开位会随用户在错误恢复前的操作(或 expandToPath 等路径)写进 本 scope 的 localStorage,下次挂载变成最多 200 次无用 listDir。改为在 getOrCreateStore 里按(可能已过滤的)继承树剪一次(内部顺带回写本 scope),不再 依赖 root 成功路径 —— 根请求失败时也有干净的展开集合。 2. remote-file-service/watch.ts:watcher 报错后紧接着的 reconcile 若又失败(远程 挂载短暂不可用 / 目录正在被替换),旧实现只记日志 —— 消费者意图仍在 desired、 但没有 watcher、也没有定时器再收敛:SSH 连接没断的情况下事件会永久停止。改为 带退避(500ms 起、×2、上限 8s)与次数上限(5 次)的重试,并在 start 成功 / stop / stopAll 时清理重试状态,避免持续失败时形成紧密重建循环。 回归测试(反证:用 HEAD 的实现跑,恰好对应 4 条失败): - 创建隐藏 store 时即剪枝(根请求失败也不残留); - 过渡窗口操作目录也不会把 reveal-only 展开位写进隐藏 scope(原「剪枝回写」用例 按新语义改造 —— 不再依赖回写兜底); - watcher 出错后收敛失败 → 退避重试直到恢复。 门禁:desktop + remote-file-service typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 10 的两条 P1:
1. remote-watch.ts:引用计数扫描建立在 `${windowId}::${hostId}::${workdir}` 的字符串
拆分上 —— workdir 是合法 POSIX 路径,可以包含 `::`(如 /srv/foo::bar),拆分会把
路径截断,于是认不出另一个窗口在看同一 workdir:停一个窗口(或处理启动失败)会误发
watchStop,把剩下那个窗口的实时更新一起关掉。改为把 hostId / workdir 结构化存在
RegistryEntry 上,两处扫描(正常 stop + 失败清理)都直接比字段,不再解析拼接串。
2. useFileBrowserPreference.ts:模块级内存缓存 + 只活在挂载期间的 storage listener。
本窗口在「没有任何消费者」的窗口期里摘掉 listener 后,另一个窗口改的偏好收不到;
重挂载时 readPreference 走缓存早退,于是开关与文件树永久陈旧到下一次变更或刷新。
改为首个监听者(重)挂载时让缓存失效并回落 localStorage。
回归测试(反证:用 HEAD 的实现跑,恰好这两条失败):
- workdir 含 `::` 时引用计数仍准确:停一个窗口不误发 watchStop;
- 无消费者窗口期后重挂载会重读 localStorage。
门禁:desktop typecheck、pnpm test:unit:related 全 PASS。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 11 的两条 P1:
1. expandedStore.ts:reveal scope 键是 `${workdir}::reveal` 裸后缀 —— workdir 以
`::reveal` 结尾时(如 /srv/project::reveal),它的隐藏键恰好等于 /srv/project 的
reveal 键:两个项目的展开态互相覆盖,恢复时按无关路径发最多 200 个 listDir。
改为用**不可打印的 NUL 分隔**(POSIX 路径不允许含 NUL),并在读取时回退旧版
`::reveal` 键(升级不丢展开态)。不回删旧键 —— 它与隐藏键共用字符串空间,删它
会误删另一个 workdir 的数据(自查测试当场抓到)。
2. remote-file-service/watch.ts:上一轮只给 watcher error handler 加了退避重试,
**回滚后的恢复路径**还是只记日志 —— 改选项时「拆旧 watcher → 重建失败 → 回滚
恢复意图 → 恢复也失败」之后,消费者有注册、无 watcher、无定时器,SSH 直播冻结
到下一次显式 start / stop。改为同样走 scheduleReconcileRetry。
回归测试(反证:用 HEAD 的实现跑,恰好这两条失败):
- workdir 以 ::reveal 结尾时不与截断后的 reveal scope 撞键(+ 旧键回退用例);
- 回滚后的恢复失败:退避重试直到恢复。
门禁:desktop + remote-file-service typecheck、pnpm test:unit:related 全 PASS。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 12 的两条 P2: 1. useFileTree.ts:切开关时新 store 会先借兄弟 scope 的树(seed 过渡),这批 entries 是**借来的**,根请求一旦失败就不代表当前视图。此前只写 loadError,而 FileBrowserBody 的错误占位仅在 entries 为空时才显示 —— 于是「开关已按下 + 树 还是隐藏态旧数据」看起来像加载成功,没有失败提示也没有重试入口。现在标记 store.seeded,根请求失败时清掉借来的树(顺带剪掉借来的展开态),让错误占位出现; 只清一次,之后恢复既有「保留旧树」语义(那时 entries 确实属于当前视图)。 2. remote-file-service/watch.ts:部分停止(还有别的消费者)时按剩余并集收窄 matcher 的重建失败,此前只记日志 —— 旧 watcher 那一刻已经拆了,消费者有注册、无 watcher、无定时器,SSH 实时事件永久停止到下一次显式 start / stop 或 daemon 重连。 补上和 watcher error / rollback 路径一致的 scheduleReconcileRetry。 回归测试(反证:用 HEAD 的实现跑,恰好这两条失败): - 切开关后根请求失败:清掉借来的旧树让错误可见; - 部分停止后的重建失败:退避重试直到恢复。 门禁:desktop + remote-file-service typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 P1:上一轮把 reveal scope 键换成 NUL 分隔后,我加了一个「读取时回退旧版 `::reveal` 键」的迁移让步 —— 但它只是把同一个撞键换了个方向: `/srv/project::reveal` 以自己的隐藏态存了数据时,`/srv/project` 的 reveal 态 (新键不存在)回退读到的是**那个 workdir 的隐藏态**,initStore 又会对无关路径 发最多 200 次 listDir。 `::reveal` 这个字符串无法区分「旧版 reveal 写入的数据」与「另一个 workdir 的隐藏 数据」,所以一律不读。代价明确:升级后「放行态展开记录」一次性丢失(重新展开即 可),隐藏态键与历史一致、不受影响。 回归测试(反证:用 HEAD 的实现跑,恰好这条失败): - 不读旧版 ::reveal 键:不把别的 workdir 的隐藏态误当自己的 reveal 态。 门禁:desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
评审 round 14 的两条 P1: 1. fileTreeHeaderButtonClass.ts:共享类名只给了 hover / pressed,没有 focus-visible, 而 G3 全局规则把非输入元素的 outline 一律去掉了 —— 键盘 Tab 穿过文件树标题行 时没有任何可见焦点指示(新加的「显示被忽略的目录」开关与同行的搜索 / 收起 / 刷新按钮都一样)。按仓库既有惯例补 focus-visible:outline-none + ring-2 + var(--focus-ring)。 2. device-op.ts:device-link 订阅的是隐藏态视图,而 daemon 的 watcher 用的是与 desktop 文件树(可能开着开关)并集后的 matcher —— WATCH_ALWAYS_IGNORE 只挡 node_modules / Library,dist / build / Temp / .cache 这类目录内部的事件照样会 发过来。转发前没按 device-link 自己的可见性再滤一道,一次构建就会把成千上万条 控制器根本不会显示的帧推过共享 relay(聚合背压 → 断连)。现在在转发回调里按 REVEALABLE_IGNORE_DIR_NAMES 判路径段丢弃。 file-browser-core 的 ignoreNames 新增派生的 REVEALABLE_IGNORE_DIR_NAMES(去尾 斜杠的目录名集合),desktop shared 转发之。 回归测试(反证:用 HEAD 的实现跑,恰好这两条失败): - watch: SSH 转发前按 device-link 的隐藏态可见性过滤被忽略目录事件; - 文件树标题图标钮带可见的键盘焦点环(源码守卫)。 门禁:desktop typecheck、pnpm test:unit:related、check:design-colors、 check:design-inventory 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
实测(CDP + PerformanceObserver 长任务,dev 实例):RSB 多标签是 keep-alive,两个 文件浏览器 tab 各自渲染一棵树 —— 展开 node_modules(+1373 行/树)时: - 单棵树:最长长任务 332-379ms - 两棵树:565-740ms(接近线性翻倍) CPU profile 热点都是每行的既有成本:DOM 创建 1289ms + GC 425ms + react-i18next createI18nWrapper 257ms + lucide 图标 232ms(dev,总采样 4.1s)。 修法:PluginBodyHost 早已把 active 传给 plugin 的 TabBody,文件浏览器此前忽略了它。 现在 FileTreeView 接受 active(默认 true),active === false 时直接不产出行 DOM —— 数据层(useFileTree 的 store / watcher / 展开态持久化)留在父组件 FileBrowserBody, 切回该 tab 时按当前 store 一次性渲染,不重新拉盘、不丢展开态。 实测效果(同样两个 tab):DOM 里 [data-relpath] 从 2830 → 1419(只有激活树), 展开 node_modules 最长长任务 565-740ms → 312-360ms。 回归测试(反证:用 HEAD 的实现跑,恰好这条失败): - active=false 时不产出任何行 DOM;不传/true 照常渲染。 门禁:desktop typecheck、pnpm test:unit:related 全 PASS。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
device-link 转发前按隐藏态可见性过滤 daemon 事件时,isInsideRevealableIgnoreDir 把 relPath 的叶子段也拿去比忽略名单:名为 dist / build / .cache 的普通文件 (listDir 照常显示)的 add/change/unlink 都会被丢掉,行陈旧到手动刷新。 忽略名单是目录规则,只有祖先段命中才代表「落在被忽略目录内部」。补一条同名文件 的事件转发断言。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…ectory MakerSendTransactionDeps.statDirectory 是 makecindy#4398(文件浏览器)加的 required 成员;上游其后新增的 codexPickerHarness.ts / piRetiredRouteSendFlow.test.ts 里两个 mock 还没它 → 集成分支 typecheck:desktop 报 TS2741。补 stub(isDirectory 恒 true),语义与既有 mock 的 checkWorkDirExists 一致。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> (cherry picked from commit 42564671ee06c94d6051e01d35ea563cefdb25fb)
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…ectory MakerSendTransactionDeps.statDirectory 是 makecindy#4398(文件浏览器)加的 required 成员;上游其后新增的 codexPickerHarness.ts / piRetiredRouteSendFlow.test.ts 里两个 mock 还没它 → 集成分支 typecheck:desktop 报 TS2741。补 stub(isDirectory 恒 true),语义与既有 mock 的 checkWorkDirExists 一致。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> (cherry picked from commit 42564671ee06c94d6051e01d35ea563cefdb25fb)
Upstream-PR: makecindy#4398 Upstream-PR-Head: 6baf14b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…ectory MakerSendTransactionDeps.statDirectory 是 makecindy#4398(文件浏览器)加的 required 成员;上游其后新增的 codexPickerHarness.ts / piRetiredRouteSendFlow.test.ts 里两个 mock 还没它 → 集成分支 typecheck:desktop 报 TS2741。补 stub(isDirectory 恒 true),语义与既有 mock 的 checkWorkDirExists 一致。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> (cherry picked from commit 42564671ee06c94d6051e01d35ea563cefdb25fb)
上游前进 500+ 提交后的例行同步。冲突只有生成台账 docs/design-rules/design-inventory.md:rerere 重放的是本 PR 旧基线生成的台账, rerere 重放后按 pnpm design:inventory 重新生成(51 surfaces),并删掉上游已移除的 孤儿人工行 mobile.automations(上游把该路由降级为 legacy redirect,不再是 surface), pnpm check:design-inventory 通过。PR 行为(忽略目录开关 / 虚拟滚动 / 滚动恢复) 经符号级核对未丢。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…l-p3 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
|
轮二十:上游 关于上一轮
本轮本地验证:全仓 10 个 typecheck 包 0 失败; @greptileai 麻烦按 |
…l-p3 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> # Conflicts: # docs/design-rules/design-inventory.md
|
轮二十一:上游 75 个提交已合入(head = 关于
我这边对上游只有读权限,无法 本轮本地验证:desktop / mobile / file-browser-core / remote-file-service typecheck 0 失败; @greptileai 请按 |
Upstream-PR: makecindy#4398 Upstream-PR-Head: ba0777b Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…ectory MakerSendTransactionDeps.statDirectory 是 makecindy#4398(文件浏览器)加的 required 成员;上游其后新增的 codexPickerHarness.ts / piRetiredRouteSendFlow.test.ts 里两个 mock 还没它 → 集成分支 typecheck:desktop 报 TS2741。补 stub(isDirectory 恒 true),语义与既有 mock 的 checkWorkDirExists 一致。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> (cherry picked from commit 42564671ee06c94d6051e01d35ea563cefdb25fb)
…l-p3
上游进 202 个提交,冲突两处:
1. 生成物 docs/design-rules/design-inventory.md —— pnpm design:inventory 重算。
2. workdir-browse/FileTreeView.tsx 的右键菜单渲染 —— 真实代码冲突:
- 本 PR 把菜单项重构成数据驱动的 menuActions(TreeMenuAction[],带 danger 标记),
上游 makecindy#5453「Desktop 弹窗统一外框与出现动画」把菜单项的呈现交回共享
DropdownMenuItem(删掉逐项 top-px 对齐补偿与菜单专属阴影类)。
- 解法:保留本 PR 的 menuActions 架构(动作集与上游逐项等价、顺序一致),
渲染改为上游统一后的形态 —— 不再硬编码 h-7/px-2.5/text-[var(--msg-assistant-text)]
等旧类,危险项走 DropdownMenuItem 原生 variant="danger",label 去掉 top-px span。
避免在本 PR 内另造一套与共享弹窗冲突的平行样式。
- 上游的 isFolderMenu 分支变量随之删除(菜单已数据驱动),无残留引用。
依赖变化(apps/mobile、packages/maker-shared、pnpm-lock)已 pnpm install --frozen-lockfile。
验证:workdir-browse + file-browser 定向 23 文件 272 passed;全仓 10 个带 typecheck 的
包 0 失败;本 PR 相对上游仍是 57 个文件。
Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
|
冲突已解决(轮二十二,head = 冲突:
另一处:生成物 验证:依赖变化(apps/mobile、packages/maker-shared、lockfile)已 @greptileai 麻烦按 |
Upstream-PR: makecindy#4398 Upstream-PR-Head: 4ca11b3 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>
…ectory MakerSendTransactionDeps.statDirectory 是 makecindy#4398(文件浏览器)加的 required 成员;上游其后新增的 codexPickerHarness.ts / piRetiredRouteSendFlow.test.ts 里两个 mock 还没它 → 集成分支 typecheck:desktop 报 TS2741。补 stub(isDirectory 恒 true),语义与既有 mock 的 checkWorkDirExists 一致。 Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn> (cherry picked from commit 42564671ee06c94d6051e01d35ea563cefdb25fb)
这次改了什么
摘要
1)文件树新增「显示被忽略的目录」开关。 侧边栏(RSB)与 doc 模式的文件树会把
build/dist/out/bin/obj/node_modules/Library/Temp/Logs等目录无条件隐藏——这是内置忽略清单在listDir数据源头做的过滤,用户在树里新建同名目录后既看不到、刷新也不会出现。本 PR 把清单拆两层:VCS 元数据(.git/.svn/.hg)与 OS 垃圾永远隐藏;依赖、构建产物、缓存目录归入可放行层,由标题行新开关(眼睛图标,紧跟搜索)控制,默认关——存量行为与性能特征不变,无需迁移。开关放在用户撞见问题的地方(文件树标题行),RSB 文件浏览器与 doc 模式侧栏共用同一组件;切换即换 store,listDir与 watcher 一起按新 matcher 重建,不需要手动刷新。2)文件树虚拟滚动 + 滚动位置记忆。 大目录(放行
node_modules或超大工程)此前一次性渲染全部行,主线程卡顿。现在用既有依赖@tanstack/react-virtual只渲染视口内行(+ overscan);滚动位置不再存scrollTop,而是「顶部行稳定 key + 行内偏移」的锚点,scope = 视口身份(RSB tab / doc 侧栏)× store key(隐藏态 / 放行态),内存 LRU 128:node_modules场景阻塞减半),激活时同步视口尺寸并恢复位置;offset保持0 ≤ offset < 行高不变量(含滚动越界钳位)。3)事件与过滤修复。
device-op的「隐藏态不可见目录」过滤只判祖先目录段:同名普通文件(dist/build/.cache这类无扩展名的构建脚本、配置文件)的 fs 事件不再被吞;daemon 侧补事件恒真层WATCH_ALWAYS_IGNORE(与 desktop 预过滤同源),放行目录内部改动不推事件;并修复 watcher 注册 / 回滚 / 能力重探等一批评审问题(见「评审与修复记录」)。分支已同步
upstream/main(合并至8380fe9e6,累计 132 个上游提交;冲突为生成物docs/design-rules/design-inventory.md,按上游版本落定后用pnpm design:inventory在合并后的树上重新生成)。变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
showIgnoredDirs开关(默认关);偏好只存 override(localStorage,经useFileBrowserPreference全局共享,改回默认即清键);FILE_SERVICE_BUNDLE_VERSION0.2.4 → 0.2.5,触发一次 daemon 重推)→ device-link 被控端listDir;caps增加能力位,老被控端按「不支持」处理(开关禁用 + 原因文案,树按隐藏态建立);treeScrollStore/useTreeScrollRestore/timerows行 key)、非激活 tab 不渲染、行内编辑钉住;device-op祖先段过滤修复;daemon watcher 事件恒真层;watcher 启动竞态 / 迟到失败 / 回滚 / 重连重探等一系列修复;FILE_TREE_HEADER_ICON_BUTTON_CLASS)+ 两个宿主共 8 个图标钮补齐本地化aria-label(含源码守卫测试);i18n 5 语言;design-inventory重新生成;设计证据文本入仓。.gitignore语义不变),只是把内置清单的可见性做成开关;hideMetaFiles同款限制,见「影响与回滚」)。UI 变化
rounded-full);控件框(含 transient hover / pressed 表面)登记在 pill 档,6px 不在受治理取值内(No 3px / 6px / 10px)。Tip(tooltip)与分语言aria-label;有状态控件文案描述下一步动作(关时「显示被忽略的目录」、开时「隐藏被忽略的目录」),带aria-pressed+ 按下态持久底色 + 图标随状态在EyeOff/Eye间切换;同排 4 个存量图标钮(搜索 / 退出搜索 / 收起 / 刷新)的aria-label一并补齐,并有源码守卫测试防漂移。sidebar-action-icon/sidebar-item-active/sidebar-item-active-foreground/--cmd-palette-item-meta等),Light/Dark 同一套;check:design-colors新增硬编码颜色 0 处。design-evidence-4398。文本证据在docs/design-evidence/2026-09-11/file-tree-header-icon-buttons.md(评论链接已回写)。评审与修复记录
offset钳位不变量、注释与真实机制对齐、active=false用例锁真实机制等)。最终结论:可合,无 P0/P1。c024ed908):Greptile P1(daemon watcher 退避到顶后永久停摆)+ 3×P2(普通 resize 回拉过期锚点;首次切到无锚点 scope 时视口落到另一批条目;design-evidence 哈希未绑定最终源码)全部处置并逐条回复(commit259dd125e);三条代码修复都做了「去掉修复后新用例必失败」的变异验证。@codex review/@greptileai触发复审):Codex 提 1 条 P2 —— 大小写变体恒真目录(NODE_MODULES/library)自身的事件被恒真层吞掉、行陈旧到手动刷新;已修复(commit2895362c8,名字比较折叠大小写,与ignore包ignorecase默认口径一致),并做了同款变异验证。同轮第二次复审又提 1 条同源 P2(device-op的REVEALABLE_IGNORE_DIR_NAMES段比较区分大小写,变体目录内部事件会继续推过 relay)→ 修复23600c41d(名单归一为小写集合 + 段比较折叠;renderer 关闭开关的首帧 seed 过滤同类问题一并折叠)。c450ba78a):1 条 P2 —— 并集变化 / watcher 报错触发重建时,旧 entry 的pending随 entry 丢弃,仍在线消费者会漏事件、树陈旧到下次事件或手动刷新;已修复(commit4c13affaa:closeEntry增加flushPending,重建路径先转发再拆旧;最后一个消费者 stop / stopAll 保持丢弃),并做了同款变异验证。4c13affaa):1 条 P1 —— 创建 store 时借来的展开态被剪枝后回写本 scope,而本 scope 自己的持久记录要到initStore才读,导致切回隐藏态时用户原有展开位被抹掉(目录无故折叠)。已修复(commit8fdfa0f8e:先把本 scope 记录并进快照并以keep豁免剪枝,两个方向各补一条回归 + 变异验证)。8fdfa0f8e):1 条 P1 —— 本地 watcher 的预过滤 glob 走 picomatch 默认选项(无nocase,已核实@parcel/watcher/wrapper.js),大小写不敏感卷上NODE_MODULES/Library变体会绕过预过滤,开关打开时整棵依赖树事件推过 watcher-host + IPC。已修复(commit90e5554b8:名单名字段展开为大小写不敏感字符类,ALWAYS / REVEALABLE 两组同源生效),并做了同款变异验证。upstream/main@5adbdd80d,headf60ef8408):上游推进 22 个提交后 PR 变为冲突(GitHubCONFLICTING)。唯一冲突仍是生成物docs/design-rules/design-inventory.md,用pnpm design:inventory在合并树上重算消解(未手改正文)。合并带入新 workspace 包@cindy/model-compat(上游 feat(providers): unify model imports and native harness defaults #4402),需pnpm install --frozen-lockfile重链接后desktop/mobile等 9 个受影响包 typecheck 才通过——合并后首次 typecheck 报的Cannot find module '@cindy/model-compat'全部是本地 node_modules 未重链接所致,锁文件零改动。合并后本地全量单测 59 PASS / 0 FAIL / 6 SKIP(765s)。upstream/main@8380fe9e6,head00d0ddf4a;随后14237f1da收口移植):上游再推进 110 个提交后 PR 再次CONFLICTING,8 个冲突文件逐个人工合并——packages/remote-file-service/src/protocol.ts/apps/desktop/src/main/file-browser/{index,device-op}.ts/deviceOp.test.ts/rpc.test.ts:上游同期加了includeIgnored(手机 HTML 快照枚举用,完全绕过展示过滤)与maxEntries(超限返DIRECTORY_TOO_LARGE),与我们的showIgnoredDirs(只放行依赖 / 构建产物 / 缓存,VCS / OS 垃圾仍藏)正交,两参数共存、includeIgnored优先;caps 探测把四种能力位并列返回(showIgnoredDirs+completeDirectoryListing+fileRead),两侧用例都保留。FileTreeView.tsx/FileBrowserBody.tsx:与 fix(desktop): 文件树行宽随内容展开并补常显横向滚动条 #4436(横滚契约)和 feat(files): 统一远程文件读取并按需预览 HTML #4346(统一远程文件读取 / 按需预览 HTML)相撞,逐处合并;上游的createDeviceFileOperations走查 + 我们的showIgnoredDirscaps 探测都在。FileBrowserBody.tsx的Tip与mapIpcErrorToI18nKey两个 import 都已无人使用(头部按钮抽到FileTreeHeaderActions、错误映射被openHtmlFileByPreference取代)→ 两个 import 都不再引入;WorkdirBrowseSidebar.tsx/ preload / vite-env / 五个 locale 逐项核对,两边的改动都在(locale 新增键 4×5 全在)。14237f1da):结构契约测试接树视口替身(jsdom 无布局,虚拟器否则产 0 行);虚拟化下scrollToPath只按行索引滚纵向,深层宽行会停在视口右侧之外 —— 新增alignRowHorizontally,纵向到位后(现在 / 下一帧 / smooth 滚动结束后)把该行横轴拉进可见区,等价非虚拟化版scrollIntoView的inline: 'nearest';去掉对齐调用后新用例必失败,已做变异验证。pnpm test:unit:related全量 59 PASS / 0 FAIL(817s);test:runner里 Pi 更新器 ZIP 用例在 Git Bash 下会命中 GNU tar(/usr/bin/tar不支持 zip),PATH 让 System32 的 bsdtar 优先即通过 —— 本机环境差异,与改动无关。14237f1da,head690de7334):1 条 P2 ——scrollToPath的横轴对齐只在三个固定时刻(调用时 / 下一帧 / 320ms 后)尝试,而目标行离当前虚拟窗口较远时要等 smooth 纵向滚动把它带进 overscan 才挂载,三次尝试可能全部落空:纵向跳到位、深层长路径仍留在横向视口之外。已改为挂载驱动:alignRowHorizontally返回「行是否已在 DOM」,外层alignRowHorizontallyWhenMounted逐帧重试到行出现(上限 120 帧≈2s,超限即放弃,不留常驻任务),行一出现只对齐一次。补「目标行后挂载」用例(把行排到 120 行之后、调用后再模拟虚拟器把它带进视口)——把重试降级为单次尝试后该用例必失败,已做变异验证。690de7334,heada1dce9727):1 条 P2 —— 用户在首个目标行挂载前连续选择两个搜索结果时,每次scrollToPath都启动一个独立的逐帧对齐任务、后一次不取消前一次;旧目标稍后挂载仍会改写共享容器的scrollLeft,把横向视口从最新目标拉回旧行。已给startRowHorizontalAlign加取消函数(cancelled标志 +cancelAnimationFrame),scrollToPath启动新任务前先取消上一次、卸载时也取消 —— 只有最近一次导航能完成对齐。补「连续两次导航、只让旧目标挂载」用例(去掉取消调用后必失败,expected 500 to be 0,已做变异验证)。a1dce9727,headf2a5d68d6):1 条 P2 ——scrollToPath的取消原先写在index < 0提前返回之后,「先导航到未挂载的有效目标、再导航到不存在 / 未展开的路径」会留下旧任务,旧行稍后挂载时仍会把scrollLeft拉回过时目标。已把取消提到方法最前面(每次导航意图都作废上一次,含提前返回分支),其余行为不变。补「无效导航」用例(把取消挪回去必失败,expected 500 to be 0,已做变异验证)。upstream/main@aaa400e39,head249bfc969):上游再推进 21 个提交后 PR 再次CONFLICTING;唯一冲突仍是生成物docs/design-rules/design-inventory.md,用pnpm design:inventory重算消解。8 个「两边都改过」的文件逐项核过:apps/desktop/src/preload/preload.ts、src/renderer/vite-env.d.ts相对上游零缺失(本 PR 只多出showIgnoredDirs),5 个 locale 的本 PR 新增键全在。合并后本地:受影响包(desktop / mobile / design-tokens / device-link / maker-core)typecheck 全过;pnpm test:unit:related全量 59 PASS / 0 FAIL。249bfc969,head3a1b35e54):Windows 单测分片 1/2 红了 1 条,但不是断言失败 —— vitest 报 1 个未捕获异常ReferenceError: window is not defined,来源是 @tanstack/virtual-core 的observeElementOffset:没有 scrollend 的环境(jsdom)里它用 150ms debounce(isScrollingResetDelay默认值)通知 React,而本轮新增的用例 dispatch 过 scroll 后立刻结束,定时器在 jsdom 拆环境之后才触发。已在treeViewportStub加flushTreeVirtualizerScrollReset()(afterEach 内 await 150ms+余量、包 act),并接到三个会 dispatch scroll 的测试文件;用假定时器探针量到「scroll 后挂起定时器 = 1 → 推进 200ms 后 = 0」。d1fd9c91e,headc5792765b):上游前进 6 个提交(127 文件,含.gitattributes的 Windows 换行统一、cindy-updater 安装版本、drizzle 0108–0109、maker-core 自动评审等)。唯一冲突仍是生成物docs/design-rules/design-inventory.md—— 用pnpm design:inventory重算消解,未手改。合并后本 PR 自己触及的路径(workdir-browse/file-browser/file-browser-core/remote-file-service)与合并前逐字节一致,上游没有动它们。四个受影响包 typecheck 全过;本地test:unit:related里apps/desktop unitPASS,packages/maker-core unit报的是与之无关的 Windows 临时目录EBUSY(把 maker-core 回退到合并前源码同样复现,且当时有另一个 worktree 在并发跑测试)→ 环境问题,以 CI 为准。e588c76ba):上游再进 18 个提交(125 文件,含 Cindy 项目管理工具、slider 家族、远程桌面防窥屏等)。冲突仍是生成物docs/design-rules/design-inventory.md,重算消解;无package.json/ lockfile 变化。合并后本 PR 路径仍与合并前逐字节一致。七个包(desktop / mobile / design-tokens / device-link / maker-core / remote-file-service / file-browser-core)typecheck 全过;pnpm test:unit:related全量 59 PASS / 0 FAIL(上一轮 maker-core 的EBUSY确认为本机并发假红,本轮同机复跑全绿——本地测试门那张锁也会被别的 worktree 占用,第一次跑直接 exit 75 没跑起来,等锁后重跑才拿到结果)。c3fcefd49):上游进 1 个提交(15 文件,导航「记住上次入口」feat(navigation): 记住上次使用的伙伴或任务入口 #4590)。冲突仍是生成物docs/design-rules/design-inventory.md,重算消解;无依赖变化、上游未触碰本 PR 路径。desktop / mobile / maker-core / remote-file-service / file-browser-core typecheck 全过;pnpm test:unit:related全量 59 PASS / 0 FAIL。3e238a8fa):上游一次进 114 个提交(840 文件差异,含远程桌面 Linux 支持、mobile 任务卡、codex 重连、i18n 等),并动了package.json/pnpm-lock.yaml。冲突只有生成物docs/design-rules/design-inventory.md(pnpm design:inventory重算消解),另pnpm install --frozen-lockfile重装依赖。合并后本 PR 相对上游的差异仍是自己的 57 个文件(workdir-browse/file-browser/remote-file-service/ i18n / 设计证据),showIgnoredDirs、tree-hscroll、alignRowHorizontally、flushTreeVirtualizerScrollReset等符号全部在位。全仓 10 个带 typecheck 的包全过;pnpm test:unit:related全量 59 PASS / 0 FAIL。24e5e8000,head8d358e0a6):上游进 25 个提交,未触碰本 PR 路径、未改依赖;冲突仍只有生成物docs/design-rules/design-inventory.md(pnpm design:inventory重算)。本 PR 相对上游仍是 57 个文件、关键符号齐全。全仓 10 个带 typecheck 的包 0 失败(其中 mobile 一度报obscuredContentInsets不存在,是本地node_modules未应用react-native-webview@13.16.1的 patch,pnpm install --frozen-lockfile后即恢复)。单测门禁test:unit:related(desktop / file-browser-core / remote-file-service)中后两包 PASS;desktop 出现 3 条上游用例失败(ghostOauthAccounts/ghostOauthFlow各 60s 超时、pi-package-store-security断言 false),本 PR 未改这三个文件,单跑复核 314 passed / 0 failed,判为本机负载假红。ba0777bf1):上游再进 75 个提交;无依赖变化,冲突仍只有生成物docs/design-rules/design-inventory.md。本 PR 相对上游仍是 57 个文件、关键符号齐全;desktop / mobile / file-browser-core / remote-file-service typecheck 0 失败;pnpm test:unit:related(desktop / file-browser-core / remote-file-service)三包全 PASS。Windows unit tests (2/2)两次失败的唯一用例都在上游packages/maker-core/src/agents/pi/__tests__/pi-subagent-runs.test.ts的launch fence > overlapping boundaries in one process组,报错均为Test timed out in 5000ms(第一轮composes two acquisitions...,第二轮gives up quietly on a lock that does not...),同分片 2705 passed / 25 skipped 只此 1 条。该文件本 PR 从未修改、与上游逐字节一致(git diff upstream/main -- <file>为空);本地单跑该文件 121 passed,CI 上超时的那条本地仅 839ms。该组用例体是 16 次迭代 + 文件锁 unlink,在 Windows runner 上贴 5s 超时线 → 判为上游测试在 Windows runner 的稳定性问题,与本 PR 无关。仓库必需检查verify(Linux 双分片 + verify-checks)两轮均 pass;如需重跑 Windows 分片需维护者权限(gh run rerun)。4ca11b3a0):这次有两处冲突,其中一处是真实代码冲突。workdir-browse/FileTreeView.tsx的右键菜单:本 PR 把菜单项重构成数据驱动的menuActions(TreeMenuAction[]+danger标记),上游 fix(ui): Desktop 弹窗统一外框与出现动画:遮罩模糊、共享面板与菜单同款阴影 #5453「Desktop 弹窗统一外框与出现动画」则把菜单项呈现交回共享DropdownMenuItem(删掉逐项top-px对齐补偿与菜单专属阴影类)。解法:保留本 PR 的menuActions架构(动作集与上游逐项等价、顺序一致),渲染改为上游统一后的形态 —— 去掉硬编码的旧类,危险项走DropdownMenuItem原生variant="danger",label 去掉top-pxspan,避免在本 PR 内另造一套与共享弹窗冲突的平行样式;随之删除上游的isFolderMenu分支变量(无残留引用)。另一处仍是生成物design-inventory.md(重算消解)。依赖变化(apps/mobile、packages/maker-shared、lockfile)已pnpm install --frozen-lockfile。验证:workdir-browse + file-browser 定向 23 文件 272 passed;全仓 10 个 typecheck 包 0 失败;pnpm test:unit:related三包全 PASS;本 PR 相对上游仍是 57 个文件。Windows unit tests (2/2)红,每轮只有 1 条、且落在不同文件,全是等待异步副作用的竞态用例 —— ①maker-core/.../pi-subagent-runs.test.ts(Test timed out in 5000ms,2705 passed / 25 skipped);②desktop/.../maker-host/__tests__/codexAuthInvalidation.test.ts(expected "spy" to be called once, but got 0 times,23723 passed / 58 skipped);③desktop/.../mcp-integrations/__tests__/ios-simulator.test.ts(同一spy报错,同样 23723 passed)。三个文件本 PR 均未修改、git diff upstream/main -- <file>逐字节为空,本地单跑分别 121 passed / 72 passed / 157 passed。第四轮重跑即全绿(Windows 1/2 28m34s、2/2 25m7s,Linux 双分片、verify、verify-checks 全 pass)—— 「轮流换文件、每次仅 1 条」是 runner 负载竞态的典型形态,与本 PR 无因果关系。test:unit:related出现过两次已知 flaky(固定端口 HTTP、CDP 事件顺序),单独复跑通过、后续全绿;与本改动(renderer + device-op)无机制交叠。多端适配(SSH 远程 / 设备互联 / 手机版)
按
docs/dev-rules/remote-and-mobile-adaptation.md的门禁:showIgnoredDirs沿 desktop IPC → 远端 daemon 透传(可选字段、append-only,bundle 0.2.4 → 0.2.5 触发一次 daemon 重推);daemon 侧listDirmatcher 与watchStart选项变化重建随本 PR 一起落地;事件侧恒真层WATCH_ALWAYS_IGNORE是 desktop 预过滤与 daemon 事件过滤的单源。file-browser:listDir/file-browser:watchStart,远程file-browser:remote-op已在packages/device-link/src/allowlist.ts白名单);watch 推送复用既有maker:file-browser:event与fs-watch:<workdir>topic。不需要改 allowlist,也不新增 topic。hideMetaFiles同样缺失),这不是本 PR 造成的退化;偏好粒度也不同(desktop 本机全局 vs mobile per-workdir)。本 PR 不改变 mobile 现有行为(新字段默认false,逐位一致)。若要补,改动面(约 8 处、纯 JS/TS、指纹中性)与所需的 mobile 实机(Light/Dark)目检已在上一轮说明中评估,适合独立 PR。故障半径三问:本 PR 未修改
packages/device-link、apps/desktop/src/main/device-link、apps/desktop/src/renderer/features/device-link的任何重试 / 超时 / teardown / 重连代码;只消费既有useDeviceLinkReconnectEpoch信号做一次能力重探(单次capsinvoke,失败瞬态不落定、不缓存、恢复后重探,由单测锁定),不构成「重连 → 洪峰 → 再被踢」的自放大链。怎么验证的
自动验证
关键回归断言(虚拟化钉住编辑行、锚点跟随分批数据、
device-op祖先段过滤等)都验证过「把源码回退到修复前会失败」,不是只跑通一次。手工验证
getComputedStyle实测颜色 / 圆角 / 尺寸(记录见设计证据文档)。未执行的验证
aria-disabled呈现)由单测锁定。风险
风险分类
影响与回滚
listDir过滤集合、watcher 预过滤集合、缓存 key 回到原语义;未开启开关的用户感受不到差异。虚拟滚动只改渲染窗口,不改变行结构 / 交互语义;滚动锚点是内存态,进程重启清零。listDir/watchStart各新增一个可选字段,append-only;bundle 0.2.4 → 0.2.5 触发一次 daemon 重推,旧 daemon 忽略新字段;控制端有caps能力门控,不会出现假成功。node_modules/Library始终不进原生递归 watch——开关打开后可以被列出,但内部改动不推事件(成本与收益不成比例);device-link 的 watch 订阅载荷不带过滤选项(与既有hideMetaFiles同款限制)。node_modules场景阻塞减半)。git revert本分支即可(纯新增可选字段 + 默认关的开关 + 渲染优化,无数据迁移)。已写入 localStorage 的 override 回滚后成为无用键、不影响行为;用户也可在标题行关掉开关立即降级。提交前检查
git commit -s,见 DCO)design-inventory重新生成;设计证据文本入仓)