# 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`（Service Definition）、`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、结果播报）同样被延期——目前二者在辅助技术看来是彼此独立的控件。
- **路径编辑器自我点明，各栏跟随草稿。** 点击即编辑的区域不再是隐形的：栏右端坐着一枚铅笔图标，悬停／聚焦时**整条栏**以编辑器自身的那只框亮起——轮廓与内边距在两种模式下都由栏承载，于是悬停预览的正是点击后出现的那只输入框，区域与输入框互换时也没有任何尺寸变化。编辑器打开期间，各栏跟随草稿，而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤，无一匹配的末段解除过滤（还在拼写中的名字不该把正在拼写它的那一栏清空），而其余任何目录部分都会在停顿 250ms 后被扫描，并经由每次导航共用的那套以选中项为锚的双栏落地落定，于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**：最后一栏永远是路径所指的那一层，其上一层在它旁边，只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后，正在键入的那一层会留在左栏，而它自己的子栏仍立在右边，于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方：提交的路径关闭编辑器并呈现失败，草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交：自提交至落地由它独占视图（同一批按键武装的防抖计时器会被扣住，而不是顶掉这次导航；提交被拒后仍扣住，直到下一次编辑），也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键，而非以它指名的目录部分为键，于是顶掉在飞扫描的那次按键会重新武装等待，被拒提交之后的编辑也能释放那道扣留；而它读取的分栏是 ref 而非依赖，否则落地会重新武装等待，遇到以不同拼写作答的宿主便会永远扫描下去。以及，走过的路不回退：关闭编辑器——包括取消——都把分栏留在草稿带到的地方，由面包屑指明、Open 的兜底目标随之而动，因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行，焦点会被重新停回编辑器——Modal 并没有焦点陷阱。另有两条规则保证一次按键只让视图移动一次：这段行走会**等齐两程**，而不套用提交导航的等待上限（推测性扫描没有任何东西在等它，先落单栏再升级恰恰就是它要避免的那次闪动，而且一旦末段按键中止了缓慢的父层级这一程，双栏视图就会永久丢失）；末段也只过滤**最后一栏**——去收窄一个草稿已经走开的分栏，会让视图先因收窄动一次、再因它自己的落地动一次。此外，层级会持续应答产生它的那段目录文本（`scanned`），因为宿主会规范化它收到的东西：`..` 段与 Windows 的正斜杠都会抵达一个自身路径拼写不同的层级；没有这份记忆，这类草稿会每敲一键就重扫一次，而且永远过滤不了。
- **导航以选中项为锚、安静且有界地落地。** 在展示根之外（与 crumb 头部渲染的是同一塌缩，因此 crumb 与分栏形态永不相左），落地即双栏：重新选中目标在父层级中的实际条目（Windows 上按平台惯例折叠大小写），右侧展示其子项，因此 crumb 跳转读作后退一栏，而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时，目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染，导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交（Enter 提交的导航绝不会被滞塞的父层级扣作人质），迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行，任何较新的意图都会在线上将其中止（因此在落地窗口内按 Escape 即撤回整次导航）；父层级这一程失败，或被截断的父窗口缺少目标时，都保留单栏落地——升级的存在正是为了锚定选中项，绝不能反而让它悬空。加载指示器遵循同一安静规则：它浮于内容右下角（绝不是会挪动布局的一行；截断／错误行占据左下角，并在扫描期间持续渲染），且仅在扫描超出 300ms 静默窗口后才出现，因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则：选取后立即分栏本身就是其选中态反馈（aria-current、crumb 跟随），而导航除了换栏本身没有任何东西可确认这次点击。三个时序常量——200ms 父层级上限、300ms 静默窗口，以及编辑器的 250ms 草稿停顿——都按本地列举校准；远程部署（每层级一次 RPC，通常 100–400ms）会落在静默窗口之内、crumb 上却没有按下态，而且要先付停顿再付 RPC 分栏才跟上——待远程消费方落地时，三者一并重新审视。
- **符号链接：为可进入性而跟随。** 用 `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。
- **统一的 Service Definition 方法集（`pick(): path`）。** 否决：应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端，需要协议上的原语；而对话框实现不了原语。交互差异不可约，故用判别标签。
- **apiproxy 里直接调标准库（不建 seam）。** 否决：换装点仍是改网关源码，失去 fixture／测试后端，与促成这项工作的插件教义相悖。
- **引入文件管理器／盘符枚举依赖。** 按上文调研否决；依赖政策要求记录于此。
- **动作标签随状态翻转的"显示隐藏"开关（"隐藏隐藏文件"）。** 否决：会翻转的动作标签在状态与动作之间有歧义，还把否定叠了两层；固定标签加按下态呈现一次说清两者。
- **纯 relatedTarget 失焦取消（不做 mousedown 抑制）。** 否决：Safari 在指针按下时不给按钮聚焦，点击触发的 focusout 因而携带空 `relatedTarget`，会在点击落地前就取消编辑器；编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。
- **在 Miller 视图上方常驻一个路径输入框。** 否决：面包屑本就在回答"我在哪儿"，再常驻一个字段是重复回答，还要从 500px 卡片里挪走一行——那是列需要的高度。图标加悬停亮起的区域，把这个入口放在了已经回答该问题的那一栏上。
- **每敲一个键就扫描草稿，或只在 Enter 时扫描。** 每键扫描：走完一段路径就是每个字符一次列举，其中多数目录操作者只是路过而非停留。只在 Enter 时扫描（最初落地的行为）：整个编辑过程中各栏与所键入文本各说各话——正是本条所回应的抱怨。250ms 的停顿把扫描收敛为"键入真正停下来的每个目录一次"。
- **前缀无一匹配时清空该栏（最初落地的行为）。** 否决：名字敲到一半时"无匹配"才是常态，于是恰恰在操作者需要它确认名字时把栏清空了；解除过滤保住了层级的可读性，代价只是短暂的宽松。
- **在 `DirectoryListing` 上增设线上 `separator` 字段（宿主标注 `path.sep`）。** 延期而非否决：它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 Service Definition 类型与每个后端；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`。
