# Agent Note：web GUI 宿主的能力可辨识目录选择 seam

状态：已实现

[English](2026-07-28-directory-picker-capability-seam.md) | 中文

## 问题

web GUI 的"打开本地文件夹"流程被焊死在一种交互上：`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器（私有模块，仅测试注入缝）。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器（Figma `Harness` 802-56979）需要列举／创建原语，那是**另一种交互契约**，不是同一契约的另一种实现。想换交互只能改网关源码，违背仓库"一切皆插件"的立场。

## 决策

在 `packages/host/` 落一个三包能力 seam——`directory-picker`（接口）、`directory-picker-native`、`directory-picker-browse`（后端）——唯一契约方法 `capability()` 返回**可辨识联合**：`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关（`dsh-host-apiproxy`）注入 `directoryPicker`，提供对应的 RPC，另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识，是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。

**client 侧靠 slot 组合，而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞（`conversation.hero.workspace.directoryFlow`／`sidebar.workspaces.directoryFlow`；之所以是两个 key，是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同）。后端包是**双面包**：browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者，`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话（`open`/`busy`/`onPicked`/`onCancel`/`onError`）承载整个交换：ui-workspace 保留触发（菜单入口仅在洞被占用时渲染）与接纳（`createWorkspace({path})`、冲突／错误对话框、重新选择），占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程；错配在构造上不可能，同时挂两个流程包会在 client 加载期失败（`single` 洞）。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后，供客户端分支用的 wire 事实不再有任何消费者。洞注册表（`ctx.slots.entries`）取而代之，成为每次打开菜单的占用读取。

并入本决策的位置与策略裁决：

- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型／会话的存储栈（policy 事件、sandbox 可换后端）。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实（home 锚定、隐藏约定）也不是存储原语。picker seam 保持无展示、无模型；`packages/host/` 是它消费方域的家。
- **依赖调研（手写 vs 引入）。** Node 标准库本身就是维护中的跨平台 OS 层（`readdir(withFileTypes)`、`homedir`、路径语义）；调研过的替代品都过不了依赖门槛——文件管理器包（`node-file-manager`、`files-and-folders`、Syncfusion 的 provider）是整套 HTTP 应用（契合度不过），盘符工具（原生插件 `drivelist`、约七年未更的 `windows-drive-letters`）健康度／比例失当。browse 后端是标准库上的薄适配。
- **隐藏条目：返回并打标。** 宿主标注 `hidden`（POSIX 点前缀约定）并返回全部条目；客户端过滤。展示策略留在客户端，"显示隐藏"开关正是作为这一纯客户端改动落地：标签固定的 footer 开关，其状态由按下态呈现承载（`aria-pressed` + 勾选符号）；以点开头的路径草稿前缀会显出它所指名的隐藏条目；当前选中项则不受隐藏与前缀两种过滤影响（它锚定着双栏视图）。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制，直到原生探测值回其成本。
- **路径编辑器的取消范围：对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消，两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后，输入框已不在事件路径上，但 Escape 仍须收起编辑器（而非对话框），其后的焦点离开也仍须取消。不取消的豁免：窗口／标签页失焦、卡片内焦点移动，以及指针路径（编辑期间行与开关在 mousedown 时抑制焦点夺取）。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断；下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义（`aria-expanded`／`aria-controls`／active-descendant、结果播报）同样被延期——目前二者在辅助技术看来是彼此独立的控件。
- **导航以选中项为锚、安静且有界地落地。** 在展示根之外（与 crumb 头部渲染的是同一塌缩，因此 crumb 与分栏形态永不相左），落地即双栏：重新选中目标在父层级中的实际条目（Windows 上按平台惯例折叠大小写），右侧展示其子项，因此 crumb 跳转读作后退一栏，而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时，目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染，导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交（Enter 提交的导航绝不会被滞塞的父层级扣作人质），迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行，任何较新的意图都会在线上将其中止（因此在落地窗口内按 Escape 即撤回整次导航）；父层级这一程失败，或被截断的父窗口缺少目标时，都保留单栏落地——升级的存在正是为了锚定选中项，绝不能反而让它悬空。加载指示器遵循同一安静规则：它浮于内容右下角（绝不是会挪动布局的一行；截断／错误行占据左下角，并在扫描期间持续渲染），且仅在扫描超出 300ms 静默窗口后才出现，因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则：选取后立即分栏本身就是其选中态反馈（aria-current、crumb 跟随），而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准；远程部署（每层级一次 RPC，通常 100–400ms）会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时，重新审视该窗口或补上按下反馈。
- **符号链接：为可进入性而跟随。** 用 `stat` 探测符号链接（断链／循环→跳过）；面包屑保留操作者导航的逻辑路径，`workspace.create` 在接纳时本就做 realpath 规范化。
- **列举层级有上限，且流式处理。** 单次 `list` 至多返回 `maxEntries` 行（配置项，默认 1000——GitHub 网页端目录列举的同一上限）。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口，内存保持 O(maxEntries)，可进入性探测只触及窗口内候选；线上 `DirectoryListing` 携带必填的 `truncated` 标志，让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝（超大层级不能为每个 dirent 付出一次全窗扫描），且 `list(path, signal)` 透传载体的请求信号，滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await（打开、每次读取、每次符号链接探测）都与信号赛跑，中止路径放弃而非等待 close（Node 会把 close 排在在飞读取之后），被放弃的 settlement 全部吞掉，清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存／响应性漏洞。
- **全盘可浏览，不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法，浏览根只会是 UX 范围而非边界；没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
- **native 后端保留。** 插件化正是目的：多方都能提供该 seam（Electron 壳可以经自己的对话框 API 提供 `native` 交互）。kind 命名：最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现（应用内弹窗），这个词起不到判别作用；`native` 命名的是选择器运行的位置。

## 曾考虑的替代方案

- **给 `ctx.fs` 增加浏览方法。** 否决：上述权限域耦合；且面向展示的列举契约（hidden 标志、面包屑、home 锚点）不属于存储 seam。
- **统一方法集的 seam（`pick(): path`）。** 否决：应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端，需要协议上的原语；而对话框实现不了原语。交互差异不可约，故用判别标签。
- **apiproxy 里直接调标准库（不建 seam）。** 否决：换装点仍是改网关源码，失去 fixture／测试后端，与促成这项工作的插件教义相悖。
- **引入文件管理器／盘符枚举依赖。** 按上文调研否决；依赖政策要求记录于此。
- **动作标签随状态翻转的"显示隐藏"开关（"隐藏隐藏文件"）。** 否决：会翻转的动作标签在状态与动作之间有歧义，还把否定叠了两层；固定标签加按下态呈现一次说清两者。
- **纯 relatedTarget 失焦取消（不做 mousedown 抑制）。** 否决：Safari 在指针按下时不给按钮聚焦，点击触发的 focusout 因而携带空 `relatedTarget`，会在点击落地前就取消编辑器；编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。
- **在 `DirectoryListing` 上增设线上 `separator` 字段（宿主标注 `path.sep`）。** 延期而非否决：它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端；browse 客户端的 `separatorOf` 挂着指向本方案的 TODO，直到下次安排线上变更。

## 后果

- `cordis.yml` 决定交互形态；`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.md)，它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`，一行仍同时切换后端与 UI；直接组合某个后端行即固定交互。
- 协议新增 `host.listDirectory`／`host.createDirectory` 与四个错误码；connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
- 未来的新交互（或提供 `native` 交互的 Electron 实现）只是一个双面后端包——无需网关手术，也不动 ui-workspace。
- `ApiProxyDefaults.pickDirectory`（仅测试注入）删除；测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
