0021 — Directory picker (native + server-side browse)¶
- Status: IMPLEMENTED (2026-07-13). Both slices landed; §6 agreed on the
recommendations. The native dialog's real run (osascript/zenity) can't be
exercised on the headless box/CI — it's fake-tested behind the
DirPickerseam here and awaits a manual smoke on Hailey's Mac (the browser fallback is fully covered). - Realises:
0017#1,0019§3/§9.1 ("directory-picker fix"). - Depends on:
0003(studio contract),0011(studio auth/loopback),0012(config lifecycle). Anchors: newR-FS-*requirements (to fold into0002).
1. Why¶
Registering a shoot means pasting an absolute server-side path into the New
Shoot modal (Library.svelte; addShoot ingests from s.props.FS at that path).
That's the biggest live ingest friction. The browser's showDirectoryPicker()
can't fix it: it hides the real path and hands back file handles to upload —
wrong for local-first krites, whose server reads originals in place. So the
fix must be server-side: the studio process already has the filesystem the
originals live on.
2. Decision — accommodate both (agreed 2026-07-13)¶
- Native OS dialog when the studio runs on a machine with a display (Hailey's macOS, or a Linux desktop) — the familiar Finder/GTK folder chooser.
- Server-side folder browser (a custom in-app modal) as the fallback when there's no display — remote/headless testing, this dev box, CI.
The frontend prefers native when the server reports it's available, else opens the browser modal. The text field stays for power users who paste/type a path.
3. Backend¶
3.1 The DirPicker provider seam (injected)¶
A native dialog is a heavy, platform-specific backend, so it sits behind an
interface chosen at construction and injected into the studio Server (the keryx
rule — functional option, no package-level hooks; faked in tests):
type DirPicker interface {
// Available reports whether a native folder dialog can run now (a display is
// present and the tool exists). False → the frontend uses the browse modal.
Available() bool
// Pick opens the native dialog and blocks until the user chooses or cancels.
// ok=false on cancel. Never returns a path the process can't stat.
Pick(ctx context.Context) (path string, ok bool, err error)
}
macPicker—osascript -e 'POSIX path of (choose folder …)'.linuxPicker—zenity --file-selection --directory(fallbackkdialog).nullPicker—Available()=false; the default when no display/tool.
Selected at construction from GOOS + env ($DISPLAY/$WAYLAND_DISPLAY on
Linux). Injected via studio.WithDirPicker(...).
Security: the tool is exec'd with argument vectors, never a shell string,
and no user input is interpolated into the AppleScript/args (the dialog
returns a path; nothing is injected). The returned path is filepath.Cleaned
and stat-checked before use.
3.2 Endpoints (studio, 0003 §5)¶
GET /api/v1/fs/picker→{ "native": bool }— capability probe.POST /api/v1/fs/pick→{ "path": "…" }or{ "cancelled": true }— runs the native dialog.409/501when native isn't available (frontend shouldn't call it then). Blocks while the dialog is open.GET /api/v1/fs/list?path=…→{ "path", "parent", "dirs": [{ "name", "path" }] }— directories only, sorted; empty/absentpathstarts at the user's home. Always available (the fallback + the native-off path).
3.3 Trust boundary¶
/fs/list exposes the server's directory tree to the loopback client. That's the
same trust boundary the studio already has — addShoot already ingests any
server path, and the studio is single-user, localhost-bound with cookie/bearer
auth (0011). No new exposure; noted for the record (R-FS-3). Directories only,
no file contents, no writes.
4. Frontend¶
- New Shoot modal gains a Browse… button beside the (retained) path field.
- On open, probe
GET /fs/picker. Browse… then either: - native available →
POST /fs/pick, fill the field with the chosen path; - else → open a
FolderBrowser.sveltemodal: a breadcrumb + a list of subfolders (from/fs/list), click to descend, Use this folder to pick. - Picking fills the path field (and can auto-submit). Manual entry still works.
5. Phasing¶
- Slice 1 — server-side browse (fully testable here).
/fs/list+FolderBrowser.svelte+ Browse-opens-modal. Go tests over a memmap FS; Playwright over the modal. Works headless, on CI, and everywhere. - Slice 2 — native picker.
DirPickerseam + macOS/Linux impls +/fs/picker /fs/pick; Browse prefers native when available. The native path can't run on this headless box/CI, so it's unit-tested via a fakeDirPickerand the capability flag; the real dialog is manually verified on Hailey's Mac.
Each slice: TDD (fake picker / memmap FS), docs-as-we-go, just ci + wasm-check
green (the picker lives in pkg/studio, not the deterministic engine).
6. Open questions (agree before building)¶
- Q1 — start directory for
/fs/list. Home dir (os.UserHomeDir)? Recommendation: yes, with breadcrumb up to root. Confirm. - Q2 — Linux native tool.
zenitythenkdialog, elsenullPicker(fallback modal). Acceptable, or prefer a pure-Go dialog lib (adds a dep)? Recommendation: shell out to zenity/kdialog, no dep. - Q3 — auto-submit on pick, or just fill the field? Recommendation: fill the field + focus the name input, so the user can still name the shoot. Confirm.
- Q4 — hidden folders / permissions in the browser. Skip dotfolders and unreadable dirs silently? Recommendation: yes.
7. Non-goals¶
- No file upload / no browser File System Access API (wrong model — §1).
- No writing anywhere via these endpoints; browse is read-only, directories only.
- No arbitrary remote filesystem access beyond the studio's own host.