The browse interaction also presents a dialog (the in-app modal), so 'dialog' failed to discriminate the two capability kinds; 'native' names where the chooser runs. Package directory-picker-dialog -> directory-picker-native, kind 'dialog' -> 'native', with every seam/gateway/client/doc reference updated and the seam Agent Note's naming rationale rewritten to match.
5.5 KiB
Agent Note: A capability-discriminated directory-picker seam for the web-GUI host
Status: implemented
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 seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned 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 (interface), 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, advertises the kind through host.describe.directoryPicker, serves the matching RPCs, and answers directory-picker-unavailable for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (cordis.yml) is the swap point; 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.
Placement and policy rulings folded into this decision:
- Not the
ctx.fsseam.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 — swappingfs-sandboxfor 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 (drivelistnative 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 planned show-hidden toggle becomes a client-only change. Windows'FILE_ATTRIBUTE_HIDDENis not exposed by dirents — documented limitation until a native probe pays for itself. - Symlinks: follow for enterability.
statprobes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, andworkspace.createalready canonicalizes via realpath at adoption. - Whole-filesystem scope, no roots config.
workspace.createaccepts 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
nativeinteraction through its own dialog API). Kind naming:dialogwas the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate;nativenames where the chooser runs.
Alternatives considered
- Extend
ctx.fswith 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 seam 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.
Consequences
cordis.ymlchooses the interaction;apps/clicurrently mounts-native(unchanged behavior). The GUI already gates its picking affordance ondescribe.directoryPicker(non-nativekinds hide it); the in-app browser PR flips the default to-browseand adds the browse UI.- The wire gains
host.listDirectory/host.createDirectory, four error codes, and thedescribe.directoryPickerfield; the connection fixture serves a deterministic browse tree for keyless assembled tests. - A future interaction (or an Electron provider of the
nativeinteraction) is one backend package plus a client branch — no gateway surgery. ApiProxyDefaults.pickDirectory(test-only injection) is gone; tests provide a stubctx.directoryPickerlike any other service.