DSH / Atlas
2026-07-28implementedarchitecture

A capability-discriminated directory-picker seam for the web-GUI host

web GUI 宿主的能力可辨识目录选择 seam

The web GUI's "Open local folder" flow was hardwired to one interaction: `host.pickDirectory` invoked a native OS chooser compiled into `dsh-host-apiproxy` (private module, test-only injection point). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the in-app directory browser (Figma `Harness` 802-56979) needs listing/creation primitives, which are a different inte

English

Problem

The web GUI's "Open local folder" flow was hardwired to one interaction: host.pickDirectory invoked a native OS chooser compiled into dsh-host-apiproxy (private module, test-only injection point). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the in-app directory browser (Figma Harness 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance.

Decision

A three-package capability seam in packages/host/directory-picker (Service Definition), directory-picker-native, directory-picker-browse (backends) — with one contract method: capability() returns a discriminated union, { kind: 'native', pick(signal) } or { kind: 'browse', list(path?), createDirectory(path, name) }. The gateway (dsh-host-apiproxy) injects directoryPicker, serves the matching RPCs, and answers directory-picker-unavailable for the other kind. The union is discriminated because the backends differ in interaction shape — flattening them into one method set would force every backend to fake the other's shape.

The client side is slot-composed, not advertisement-branched. ui-workspace's two trigger surfaces each declare a single directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are dual-face: the browser half registers the matching interaction into both holes — -native a renderless occupant driving host.pickDirectory, -browse the in-app Select Workspace Directory dialog. The hole's owner conversation (open/busy/onPicked/onCancel/onError) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (createWorkspace({path}), retryable error dialog, Choose again), the occupant owns everything between open and the picked path. One cordis.yml row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (single hole). The earlier host.describe.directoryPicker advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (ctx.slots.entries) replaces it as the per-menu-open occupancy read.

Placement and policy rulings folded into this decision:

  • Not the ctx.fs seam. packages/fs/ is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping fs-sandbox for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; packages/host/ is its consumer-domain home.
  • Dependency survey (hand-roll vs adopt). Node's stdlib is the maintained cross-platform OS layer (readdir(withFileTypes), homedir, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (node-file-manager, files-and-folders, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (drivelist native addon, windows-drive-letters ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib.
  • Hidden entries: return-and-flag. The host stamps hidden (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (aria-pressed + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' FILE_ATTRIBUTE_HIDDEN is not exposed by dirents — documented limitation until a native probe pays for itself.
  • Path-editor cancel scope: the dialog card. The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from listing.home; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (aria-expanded/aria-controls/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets.
  • The path editor advertises itself, and the panes follow the draft. The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. The pane arity is the invariant: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever any pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (scanned), because the Host resolves what it is given: .. segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter.
  • Navigation lands selection-anchored, quiet, and bounded. Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as one frame when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands.
  • Symlinks: follow for enterability. stat probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and workspace.create already canonicalizes via realpath at adoption.
  • Listing levels are bounded, and streamed. One list call returns at most maxEntries rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via opendir into a name-sorted window of maxEntries + 1 candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire DirectoryListing carries a required truncated flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and list(path, signal) threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
  • Whole-filesystem scope, no roots config. workspace.create accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
  • The native backend stays. Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the native interaction through its own dialog API). Kind naming: dialog was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; native names where the chooser runs.

Alternatives considered

  • Extend ctx.fs with browse methods. Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam.
  • One uniform Service Definition method set (pick(): path). Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
  • Direct stdlib calls inside apiproxy (no seam). Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
  • Adopting a file-manager/drive-enumeration dependency. Rejected per the survey above; recorded here as the dependency policy requires.
  • A flip-label show-hidden toggle ("Hide hidden files"). Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once.
  • Pure relatedTarget blur cancellation (no mousedown suppression). Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null relatedTarget and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together.
  • A permanently visible path input above the Miller view. Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question.
  • Scanning the draft on every keystroke, or only on Enter. Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on.
  • Emptying a pane on a prefix miss (what shipped first). Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness.
  • A wire separator field on DirectoryListing (host stamps path.sep). Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the listing.home heuristic — but it touches the Service Definition type and every backend; the browse client's separatorOf carries a TODO pointing at this alternative until a wire change is next scheduled.

Consequences

  • cordis.yml chooses the interaction; apps/cli mounts the -auto chooser, which resolves the host's situation at boot and mounts -native or -browse itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction.
  • The wire gains host.listDirectory/host.createDirectory and four error codes; the connection fixture serves a deterministic browse tree and a deterministic pickDirectory path for keyless assembled tests.
  • A future interaction (or an Electron provider of the native interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
  • ApiProxyDefaults.pickDirectory (test-only injection) is gone; tests provide a stub ctx.directoryPicker like any other service.

中文

问题

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

决策

packages/host/ 落一个三包能力 seam——directory-picker(Service Definition)、directory-picker-nativedirectory-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.directoryFlowsidebar.workspaces.directoryFlow;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是双面包:浏览器一侧把匹配的交互注册进两个洞——-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 事件、沙箱可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 fs-sandbox 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;packages/host/ 是它消费方域的家。
  • 依赖调研(手写 vs 引入)。 Node 标准库本身就是维护中的跨平台 OS 层(readdir(withFileTypes)homedir、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(node-file-managerfiles-and-folders、Syncfusion 的提供方)是整套 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-expandedaria-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 选择器,它在启动时判定宿主处境并自行挂载 -native-browse,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。
  • 协议新增 host.listDirectoryhost.createDirectory 与四个错误码;connection fixture 提供确定性浏览树与确定性 pickDirectory 路径供无密钥组装测试使用。
  • 未来的新交互(或提供 native 交互的 Electron 提供方)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
  • ApiProxyDefaults.pickDirectory(仅测试注入)删除;测试像提供其他服务一样提供 stub ctx.directoryPicker