Skip to content

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 DirPicker seam 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: new R-FS-* requirements (to fold into 0002).

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)
}
  • macPickerosascript -e 'POSIX path of (choose folder …)'.
  • linuxPickerzenity --file-selection --directory (fallback kdialog).
  • nullPickerAvailable()=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/501 when 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/absent path starts 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 hasaddShoot 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.svelte modal: 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

  1. 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.
  2. Slice 2 — native picker. DirPicker seam + macOS/Linux impls + /fs/picker
  3. /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 fake DirPicker and 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. zenity then kdialog, else nullPicker (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.