Skip to content

feat(file-browser): 「显示被忽略的目录」开关 + 文件树虚拟滚动与滚动位置恢复 - #4398

Open
zyjisdog wants to merge 60 commits into
makecindy:mainfrom
zyjisdog:cindy/fierce-goodall-p3
Open

zyjisdog wants to merge 60 commits into
makecindy:mainfrom
zyjisdog:cindy/fierce-goodall-p3

Conversation

@zyjisdog

@zyjisdog zyjisdog commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

本 PR 重建自 #4301(原 PR 因 fork 分支被删而关闭)。分支已同步 upstream/main,并包含其后的虚拟滚动 / 滚动锚点与多轮评审修复;测试结果见下。

这次改了什么

摘要

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:

  • RSB 多标签 keep-alive 切回、开关切换造成行索引整体位移、组件卸载重挂,三种「视口没了又回来」都能回位;
  • 数据分批到达期间锚点 sticky,跟随新插入的行校正;用户滚动 / 点击 / 展开等交互立即接管,不再被回拉;
  • 非激活 tab 不再渲染文件树行(放行 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 文档、测试或工程维护
  • 其他:

范围

  • 关联 Issue / 需求:维护者确认:#4301 文件树「显示被忽略的目录」开关 #4305(维护者确认门,三项回应见下);用户报障:新建同名目录看不到、切换开关时文件树闪空白、大目录卡顿、切换 tab 丢滚动位置。
  • 本 PR 包含:
    • 忽略清单拆两层 + showIgnoredDirs 开关(默认关);偏好只存 override(localStorage,经 useFileBrowserPreference 全局共享,改回默认即清键);
    • 传输链一路透传:desktop IPC → SSH 远端 daemon(协议字段可选、append-only,FILE_SERVICE_BUNDLE_VERSION 0.2.4 → 0.2.5,触发一次 daemon 重推)→ device-link 被控端 listDir;caps 增加能力位,老被控端按「不支持」处理(开关禁用 + 原因文案,树按隐藏态建立);
    • 文件树虚拟滚动 + 滚动锚点(treeScrollStore / useTreeScrollRestore / timerows 行 key)、非激活 tab 不渲染、行内编辑钉住;
    • device-op 祖先段过滤修复;daemon watcher 事件恒真层;watcher 启动竞态 / 迟到失败 / 回滚 / 重连重探等一系列修复;
    • 标题行图标钮统一 pill 圆角(共享常量 FILE_TREE_HEADER_ICON_BUTTON_CLASS)+ 两个宿主共 8 个图标钮补齐本地化 aria-label(含源码守卫测试);i18n 5 语言;design-inventory 重新生成;设计证据文本入仓。
  • 明确不包含:
    • 不改默认行为(开关默认关,未开启用户零变化);
    • 不含 mobile 侧适配(理由见「多端适配」第 3 条);
    • 不改 ignore 判定规则本身(.gitignore 语义不变),只是把内置清单的可见性做成开关;
    • device-link 的 watch 订阅载荷仍不带过滤选项(与既有 hideMetaFiles 同款限制,见「影响与回滚」)。
  • 用户可见变化:标题行新增眼睛开关;打开后出现此前被隐藏的目录;大目录滚动流畅;切换 tab / 开关 / 重挂后滚动位置恢复;隐藏 tab 不再渲染行。
  • 是否存在 breaking change:无。协议只新增可选字段,旧 daemon 忽略;偏好只写 override。

UI 变化

  • 引用的设计规范:
    • DESIGN.md §5 Border Radius Scale:标题行图标钮统一 pill(rounded-full);控件框(含 transient hover / pressed 表面)登记在 pill 档,6px 不在受治理取值内(No 3px / 6px / 10px)。
    • DESIGN.md §14.6 Icon-only Controls and Tooltips:图标控件同时给 Tip(tooltip)与分语言 aria-label;有状态控件文案描述下一步动作(关时「显示被忽略的目录」、开时「隐藏被忽略的目录」),带 aria-pressed + 按下态持久底色 + 图标随状态在 EyeOff/Eye 间切换;同排 4 个存量图标钮(搜索 / 退出搜索 / 收起 / 刷新)的 aria-label 一并补齐,并有源码守卫测试防漂移。
    • DESIGN.md §3 排版档位 / 双模式交付门槛:不写固定 px 行高;颜色全部走语义 token(sidebar-action-icon / sidebar-item-active / sidebar-item-active-foreground / --cmd-palette-item-meta 等),Light/Dark 同一套;check:design-colors 新增硬编码颜色 0 处。
    • 虚拟滚动:不改变视觉规格(行高、缩进、hover/pressed、滚动条行为与既有行一致),只改渲染窗口;行内编辑态在滚动中保持可见。
  • 截图 / 录屏:Level 2 证据已上传(真实 dev 构建 + CDP 采集,开关打开态,alpha 展平到主题表面色)→ PR 评论 视觉证据(RSB / doc × Light-Dark);artifact 落在 fork release design-evidence-4398。文本证据在 docs/design-evidence/2026-09-11/file-tree-header-icon-buttons.md(评论链接已回写)。

评审与修复记录

  • 轮一 ~ 轮三(review bots + 维护者确认门 维护者确认:#4301 文件树「显示被忽略的目录」开关 #4305):daemon watcher 启动竞态、事件洪水恒真层、能力门控(老被控端不再「开关看着开了、树没变」的假成功)、迟到失败误删替换注册、展开态按开关分片(存量用户零迁移)、图标钮可访问名等 P1 全部修复;回归均验证过「把源码回退到修复前会失败」。
  • 独立 reviewer × 4 轮(GLM-5.3-flash,fresh context,对抗性口径):第一轮修复 1 条 P1(虚拟化下行内编辑行被回收),并在运行中的 DEV 实例实机复现验证;后三轮为 P3 / 硬化项(交互接管锚点语义、测试 store 复位、offset 钳位不变量、注释与真实机制对齐、active=false 用例锁真实机制等)。最终结论:可合,无 P0/P1。
  • 轮四(PR feat(file-browser): 「显示被忽略的目录」开关 + 文件树虚拟滚动与滚动位置恢复 #4398 上的自动评审,head c024ed908):Greptile P1(daemon watcher 退避到顶后永久停摆)+ 3×P2(普通 resize 回拉过期锚点;首次切到无锚点 scope 时视口落到另一批条目;design-evidence 哈希未绑定最终源码)全部处置并逐条回复(commit 259dd125e);三条代码修复都做了「去掉修复后新用例必失败」的变异验证。
  • 轮五(评论 @codex review / @greptileai 触发复审):Codex 提 1 条 P2 —— 大小写变体恒真目录(NODE_MODULES / library)自身的事件被恒真层吞掉、行陈旧到手动刷新;已修复(commit 2895362c8,名字比较折叠大小写,与 ignore 包 ignorecase 默认口径一致),并做了同款变异验证。同轮第二次复审又提 1 条同源 P2(device-op 的 REVEALABLE_IGNORE_DIR_NAMES 段比较区分大小写,变体目录内部事件会继续推过 relay)→ 修复 23600c41d(名单归一为小写集合 + 段比较折叠;renderer 关闭开关的首帧 seed 过滤同类问题一并折叠)。
  • 轮六(Codex 复审,head c450ba78a):1 条 P2 —— 并集变化 / watcher 报错触发重建时,旧 entry 的 pending 随 entry 丢弃,仍在线消费者会漏事件、树陈旧到下次事件或手动刷新;已修复(commit 4c13affaa:closeEntry 增加 flushPending,重建路径先转发再拆旧;最后一个消费者 stop / stopAll 保持丢弃),并做了同款变异验证。
  • 轮七(Codex 复审,head 4c13affaa):1 条 P1 —— 创建 store 时借来的展开态被剪枝后回写本 scope,而本 scope 自己的持久记录要到 initStore 才读,导致切回隐藏态时用户原有展开位被抹掉(目录无故折叠)。已修复(commit 8fdfa0f8e:先把本 scope 记录并进快照并以 keep 豁免剪枝,两个方向各补一条回归 + 变异验证)。
  • 轮八(Codex 复审,head 8fdfa0f8e):1 条 P1 —— 本地 watcher 的预过滤 glob 走 picomatch 默认选项(无 nocase,已核实 @parcel/watcher/wrapper.js),大小写不敏感卷上 NODE_MODULES / Library 变体会绕过预过滤,开关打开时整棵依赖树事件推过 watcher-host + IPC。已修复(commit 90e5554b8:名单名字段展开为大小写不敏感字符类,ALWAYS / REVEALABLE 两组同源生效),并做了同款变异验证。
  • 轮九(合并 upstream/main @ 5adbdd80d,head f60ef8408):上游推进 22 个提交后 PR 变为冲突(GitHub CONFLICTING)。唯一冲突仍是生成物 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,head 00d0ddf4a;随后 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 走查 + 我们的 showIgnoredDirs caps 探测都在。
    • 合并后专门核过「两边都改了同一处」的死引用:FileBrowserBody.tsx 的 Tip 与 mapIpcErrorToI18nKey 两个 import 都已无人使用(头部按钮抽到 FileTreeHeaderActions、错误映射被 openHtmlFileByPreference 取代)→ 两个 import 都不再引入;WorkdirBrowseSidebar.tsx / preload / vite-env / 五个 locale 逐项核对,两边的改动都在(locale 新增键 4×5 全在)。
    • fix(desktop): 文件树行宽随内容展开并补常显横向滚动条 #4436 契约在虚拟化实现上的移植(14237f1da):结构契约测试接树视口替身(jsdom 无布局,虚拟器否则产 0 行);虚拟化下 scrollToPath 只按行索引滚纵向,深层宽行会停在视口右侧之外 —— 新增 alignRowHorizontally,纵向到位后(现在 / 下一帧 / smooth 滚动结束后)把该行横轴拉进可见区,等价非虚拟化版 scrollIntoView 的 inline: 'nearest';去掉对齐调用后新用例必失败,已做变异验证。
    • 合并后本地验证:9 个受影响包 typecheck 全过;pnpm test:unit:related 全量 59 PASS / 0 FAIL(817s);test:runner 里 Pi 更新器 ZIP 用例在 Git Bash 下会命中 GNU tar(/usr/bin/tar 不支持 zip),PATH 让 System32 的 bsdtar 优先即通过 —— 本机环境差异,与改动无关。
  • 轮十一(Greptile 复审 @ 14237f1da,head 690de7334):1 条 P2 —— scrollToPath 的横轴对齐只在三个固定时刻(调用时 / 下一帧 / 320ms 后)尝试,而目标行离当前虚拟窗口较远时要等 smooth 纵向滚动把它带进 overscan 才挂载,三次尝试可能全部落空:纵向跳到位、深层长路径仍留在横向视口之外。已改为挂载驱动:alignRowHorizontally 返回「行是否已在 DOM」,外层 alignRowHorizontallyWhenMounted 逐帧重试到行出现(上限 120 帧≈2s,超限即放弃,不留常驻任务),行一出现只对齐一次。补「目标行后挂载」用例(把行排到 120 行之后、调用后再模拟虚拟器把它带进视口)——把重试降级为单次尝试后该用例必失败,已做变异验证。
  • 轮十二(Greptile 复审 @ 690de7334,head a1dce9727):1 条 P2 —— 用户在首个目标行挂载前连续选择两个搜索结果时,每次 scrollToPath 都启动一个独立的逐帧对齐任务、后一次不取消前一次;旧目标稍后挂载仍会改写共享容器的 scrollLeft,把横向视口从最新目标拉回旧行。已给 startRowHorizontalAlign 加取消函数(cancelled 标志 + cancelAnimationFrame),scrollToPath 启动新任务前先取消上一次、卸载时也取消 —— 只有最近一次导航能完成对齐。补「连续两次导航、只让旧目标挂载」用例(去掉取消调用后必失败,expected 500 to be 0,已做变异验证)。
  • 轮十三(Greptile 复审 @ a1dce9727,head f2a5d68d6):1 条 P2 —— scrollToPath 的取消原先写在 index < 0 提前返回之后,「先导航到未挂载的有效目标、再导航到不存在 / 未展开的路径」会留下旧任务,旧行稍后挂载时仍会把 scrollLeft 拉回过时目标。已把取消提到方法最前面(每次导航意图都作废上一次,含提前返回分支),其余行为不变。补「无效导航」用例(把取消挪回去必失败,expected 500 to be 0,已做变异验证)。
  • 轮十四(合并 upstream/main @ aaa400e39,head 249bfc969):上游再推进 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。
  • 轮十五(CI 修复 @ 249bfc969,head 3a1b35e54):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,head c5792765b):上游前进 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 unit PASS,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,head 8d358e0a6):上游进 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,判为本机负载假红。
  • 轮二十一(合并上游,head 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 分片连续两轮红的定性与证据: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)。
  • 轮二十二(合并上游 202 个提交,head 4ca11b3a0):这次有两处冲突,其中一处是真实代码冲突。workdir-browse/FileTreeView.tsx 的右键菜单:本 PR 把菜单项重构成数据驱动的 menuActions(TreeMenuAction[] + danger 标记),上游 fix(ui): Desktop 弹窗统一外框与出现动画:遮罩模糊、共享面板与菜单同款阴影 #5453「Desktop 弹窗统一外框与出现动画」则把菜单项呈现交回共享 DropdownMenuItem(删掉逐项 top-px 对齐补偿与菜单专属阴影类)。解法:保留本 PR 的 menuActions 架构(动作集与上游逐项等价、顺序一致),渲染改为上游统一后的形态 —— 去掉硬编码的旧类,危险项走 DropdownMenuItem 原生 variant="danger",label 去掉 top-px span,避免在本 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 分片竞态 flake 的完整证据(轮二十二后):连续三轮 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 的门禁:

  1. SSH 远程工作区 —— 已适配。 showIgnoredDirs 沿 desktop IPC → 远端 daemon 透传(可选字段、append-only,bundle 0.2.4 → 0.2.5 触发一次 daemon 重推);daemon 侧 listDir matcher 与 watchStart 选项变化重建随本 PR 一起落地;事件侧恒真层 WATCH_ALWAYS_IGNORE 是 desktop 预过滤与 daemon 事件过滤的单源。
  2. 新增 IPC channel / 推送事件 —— 无。 只在既有 channel 上加可选字段(本地 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。
  3. 手机版 —— 本次不涉及。 mobile 侧从来没有列表过滤语义透传(更基础的 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 信号做一次能力重探(单次 caps invoke,失败瞬态不落定、不缓存、恢复后重探,由单测锁定),不构成「重连 → 洪峰 → 再被踢」的自放大链。

怎么验证的

自动验证

pnpm --filter desktop run typecheck   → 通过(tsc --noEmit)
pnpm test:unit:related                → 通过(desktop 全量单测 + file-browser-core + remote-file-service;Node v22.23.2 / Windows)
pnpm --filter desktop exec vitest run <workdir-browse / file-browser 定向> → 224 tests passed
pnpm --filter desktop exec vitest run useTreeScrollRestore.test.tsx → 通过(14 tests,新增 2 条)
pnpm --filter desktop exec vitest run useFileTree.test.tsx → 通过(23 tests,新增 2 条)
pnpm --filter desktop exec vitest run watcher.test.ts → 通过(15 tests,新增 1 条)
pnpm --filter @cindy/remote-file-service exec vitest run watchOptions.test.ts → 通过(22 tests,新增 3 条)
pnpm check:design-inventory           → ✅ GENERATED 区块最新(50 个 surface)
pnpm check:i18n                       → ✅ 9616 key,en / zh-CN / zh-TW / ja / ko 全部一致
pnpm check:i18n-glossary              → ✅ 无新增违规
pnpm check:brand-terminology          → ✅ PASS
pnpm check:endpoints                  → ✅ guard passed
pnpm check:design-colors              → ✅ 新增硬编码颜色 0 处
pnpm check:dco                        → ✅ 39 commits 签名 + 5 merge 豁免(range 8f37af83..90e5554b)

关键回归断言(虚拟化钉住编辑行、锚点跟随分批数据、device-op 祖先段过滤等)都验证过「把源码回退到修复前会失败」,不是只跑通一次。

手工验证

  • DEV 实例目检(worktree checkout,CDP 9222 驱动 + 截图):虚拟滚动的行窗口、切换开关两态、RSB 多标签切换后的滚动位置恢复、行内编辑钉住、被忽略目录的显示/隐藏与事件联动;本轮目检发现并修复了 2 处实机问题。
  • 开关功能的上一轮目检:两种布局(RSB / doc 模式侧栏)× Light / Dark × 开关两态,入口位置、按下态反馈、图标切换与 tooltip 文案均检查过,并用 getComputedStyle 实测颜色 / 圆角 / 尺寸(记录见设计证据文档)。

未执行的验证

  • macOS / Linux 实机未跑:本机为 Windows;跨平台差异只到代码层面(mac 的 fsevents 与 parcel watcher 共享同一份预过滤清单,未实机验证)。
  • 远端 SSH / device-link 被控端端到端未实机验证:验证到协议字段、能力位探测与 matcher 重建的单测层面。
  • 老被控端「开关不可用」态未实机目检:该状态只在 device-link 连到老 Desktop 时出现,本机无此环境;行为(点击不改偏好、树按隐藏态建立、aria-disabled 呈现)由单测锁定。
  • mobile 不涉及,未跑 mobile 验证。

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:

影响与回滚

  • 影响范围:
    • 默认路径(开关关)行为与改动前一致: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 同款限制)。
    • 性能:开关打开 + 超大工程(Unity 类)时目录扫描明显变慢,这是默认保持关闭的理由;虚拟滚动本身大幅降低渲染开销(放行 node_modules 场景阻塞减半)。
  • 回滚 / 降级方式:git revert 本分支即可(纯新增可选字段 + 默认关的开关 + 渲染优化,无数据迁移)。已写入 localStorage 的 override 回滚后成为无用键、不影响行为;用户也可在标题行关掉开关立即降级。

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s,见 DCO)
  • UI 改动已在「UI 变化」注明引用的设计规范章节
  • 未提交凭证、令牌或授权文件
  • 已核对受影响的文档(design-inventory 重新生成;设计证据文本入仓)
  • 已确认测试结果或说明未执行原因

zyjisdog and others added 30 commits September 11, 2026 17:03
文件树的内置忽略清单把 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>
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 21, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 21, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 21, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 22, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 22, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 23, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 27, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 27, 2026
…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)
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 28, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 28, 2026
…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)
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 29, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Sep 29, 2026
…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>
@zyjisdog zyjisdog closed this Oct 1, 2026
@zyjisdog zyjisdog reopened this Oct 1, 2026
…l-p3

Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>

# Conflicts:
#	docs/design-rules/design-inventory.md
@zyjisdog

zyjisdog commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

轮二十:上游 24e5e8000(25 个提交)已合入,head = 8d358e0a6,PR 回到 MERGEABLE(冲突仍只有生成物 design-inventory.md,重算消解;上游未碰本 PR 路径、未改依赖)。

关于上一轮 Windows unit tests (2/2) 的红,先把证据摆出来:

  • 唯一失败用例是上游的 packages/maker-core/src/agents/pi/__tests__/pi-subagent-runs.test.ts > launch fence > composes two acquisitions that start in the same tick,报错 Test timed out in 5000ms;该用例体是 for (let iteration = 0; iteration < 16; iteration += 1) 的循环,在 Windows runner 上偶发跑不完 5s。
  • 同一分片其余 2705 passed / 25 skipped,只此 1 条;且这个文件本 PR 从未修改,git diff upstream/main -- <该文件> 为空(与上游逐字节一致)。
  • 结论:上游测试在 Windows runner 的超时 flake,与本 PR 无关。本账号对上游只有读权限,不能 gh run rerun,所以用「合并上游 + 推送新提交」触发一轮全新 CI 顺带重跑这两个 Windows 分片。

本轮本地验证:全仓 10 个 typecheck 包 0 失败;pnpm test:unit:related 里 file-browser-core / remote-file-service PASS,desktop 出现 3 条上游用例失败(两条 60s 超时 + 一条断言 false),本 PR 均未改这三个文件,单跑复核 314 passed / 0 failed,判为本机负载假红。

@greptileai 麻烦按 8d358e0a6 再扫一轮,谢谢。

…l-p3

Signed-off-by: Zhang Yunjin <zhangyunjin@zju.edu.cn>

# Conflicts:
#	docs/design-rules/design-inventory.md
@zyjisdog

zyjisdog commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

轮二十一:上游 75 个提交已合入(head = ba0777bf1,PR MERGEABLE,冲突仍只有生成物 design-inventory.md)。

关于 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 that start in the same tick,第二轮 gives up quietly on a lock that does not, and heals on the next boundary)。
  • 同分片其余 2705 passed / 25 skipped,只此 1 条;Linux 双分片两轮都过。
  • 该文件本 PR 从未修改,与上游逐字节一致(git diff upstream/main -- <该文件> 为空)。
  • 本地单跑该文件:121 passed;CI 上超时的那条在本机只花 839ms(该组用例体是 16 次迭代 + 文件锁 unlink)。
  • 判定:上游这条 Windows 用例在 runner 上贴着 5s 超时线,属于上游测试稳定性问题,与本 PR 的文件浏览器改动无因果关系。仓库必需检查 verify(Linux 双分片 + verify-checks)两轮均 pass。

我这边对上游只有读权限,无法 gh run rerun;这一轮推送已触发全新 CI,会再跑一遍两个 Windows 分片。若仍只红在这一条,需要有写权限的维护者重跑该 job(或上游给这组用例放宽 timeout)。

本轮本地验证:desktop / mobile / file-browser-core / remote-file-service typecheck 0 失败;pnpm test:unit:related 三包全 PASS。

@greptileai 请按 ba0777bf1 再扫一轮,谢谢。

zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Oct 1, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Oct 1, 2026
…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>
@zyjisdog

zyjisdog commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

冲突已解决(轮二十二,head = 4ca11b3a0,PR MERGEABLE)。这轮上游进 202 个提交,冲突有两处,其中一处是真实代码冲突,解法说明如下:

冲突:workdir-browse/FileTreeView.tsx 的右键菜单

  • 本 PR:把菜单项从内联 JSX 重构成数据驱动的 menuActions(TreeMenuAction[],带 danger 标记)。
  • 上游 fix(ui): Desktop 弹窗统一外框与出现动画:遮罩模糊、共享面板与菜单同款阴影 #5453「Desktop 弹窗统一外框与出现动画:遮罩模糊、共享面板与菜单同款阴影」:把菜单项的逐项样式(旧 h-7 px-2.5 text-[var(--msg-assistant-text)] 类、relative top-px 对齐补偿、菜单专属阴影类)删掉,统一交给共享 DropdownMenuItem。
  • 解法:保留本 PR 的 menuActions 架构(与上游动作集逐项等价、顺序一致),渲染改为上游统一后的形态 —— 不再硬编码旧类,危险项走 DropdownMenuItem 原生 variant="danger",label 去掉 top-px span。这样既保住本 PR 的重构,又不在 PR 内另造一套与共享弹窗冲突的平行样式。上游随之无用的 isFolderMenu 分支变量已删除,无残留引用。

另一处:生成物 docs/design-rules/design-inventory.md(pnpm design:inventory 重算,未手改)。

验证:依赖变化(apps/mobile、packages/maker-shared、lockfile)已 pnpm install --frozen-lockfile;workdir-browse + file-browser 定向 23 文件 272 passed;全仓 10 个 typecheck 包 0 失败;pnpm test:unit:related(desktop / file-browser-core / remote-file-service)三包全 PASS;本 PR 相对上游仍是 57 个文件。

@greptileai 麻烦按 4ca11b3a0 再扫一轮,重点看下菜单渲染这块与上游共享组件的一致性,谢谢。

@zyjisdog zyjisdog closed this Oct 5, 2026
@zyjisdog zyjisdog reopened this Oct 5, 2026
@zyjisdog zyjisdog closed this Oct 5, 2026
@zyjisdog zyjisdog reopened this Oct 5, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Oct 5, 2026
zyjisdog added a commit to zyjisdog/cindy that referenced this pull request Oct 5, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-discussion 等待维护者讨论(review-pr) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:large-diff 改动量较大(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) touches:rules 改动碰到规则 / 规范文档(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants