diff --git a/docs/superpowers/specs/2026-08-09-workspace-selective-download-design.md b/docs/superpowers/specs/2026-08-09-workspace-selective-download-design.md new file mode 100644 index 0000000..cb47770 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-workspace-selective-download-design.md @@ -0,0 +1,143 @@ +# Workspace Selective Download — Design + +Date: 2026-08-09 +Status: Approved (approach A) + +## Goal + +Let users select individual files and folders in the workspace Tree view and +download the selection as a single zip archive. Today only "download +everything" (`/api/workspace/download`) and single-file download +(`/api/workspace/file?download=1`) exist. + +## UX + +- **Tree view only.** Each row gets a tri-state checkbox in front of the icon + (checked / unchecked / indeterminate). The "By type" view is unchanged. +- Checking a folder selects its entire subtree; unchecking a descendant of a + checked folder makes the folder partial (indeterminate) without needing to + load the subtree. +- Toolbar keeps the existing **All** download link and gains a + **Selected (N)** button, disabled when N = 0. N is the number of + effectively-selected top-level entries (collapsed `checked` set size). +- Clicking **Selected (N)** triggers a native browser download via an + `` built from the selection (no fetch + blob, so large archives + stream to disk). +- Selection is cleared when the conversation (`threadId`) changes. It survives + panel refreshes; stale paths that no longer exist are pruned lazily (the + server returns a friendly error if nothing ends up in the archive). + +## Client state model — `src/lib/workspaceSelection.ts` (new, pure) + +Two sets represent the full tri-state selection without requiring unloaded +subtrees to be materialized: + +- `checked: Set` — explicitly checked paths, **collapsed**: checking a + folder stores only the folder path and drops any descendants already in the + set. Invariant: no entry is a descendant of another entry. +- `excluded: Set` — paths explicitly unchecked underneath a collapsed + checked ancestor. + +Derived state: + +- `isChecked(path)` — some `checked` entry equals or is an ancestor of `path`, + and no `excluded` entry equals or is an ancestor of `path`. +- `isIndeterminate(dir)` — dir is not fully checked, but has at least one + `checked` or `excluded` descendant. + +Mutations (all pure, return new state): + +- `check(path)` — if covered by a collapsed checked ancestor: remove `path` + (and its `excluded` descendants) from `excluded`. Otherwise add `path` to + `checked`, dropping existing `checked`/`excluded` descendants of `path`. +- `uncheck(path)` — if `path` itself is in `checked`: remove it (and any + `excluded` descendants). If covered by a collapsed checked ancestor: add + `path` to `excluded` (dropping `checked`/`excluded` descendants of `path`, + which are moot). Otherwise no-op. +- `toParams(state)` — `{ include: string[], exclude: string[] }` for the API. + +The module has no React or fetch dependencies and is exhaustively unit-tested. + +## Component changes — `WorkspacePanel.tsx` + +- Hold `selection: { checked: Set; excluded: Set }` in state; + reset on `threadId` change (alongside the existing resets). +- Tree rows render a checkbox whose state comes from + `isChecked`/`isIndeterminate`; `onChange` applies `check`/`uncheck`. +- Toolbar: new **Selected (N)** anchor button next to **All**: + `href = /api/workspace/download?threadId=…&include=…&include=…&exclude=…` + (repeated params, `URLSearchParams.append`). +- Guard: if the generated URL would exceed ~7000 characters, show a hint to + select fewer, higher-level entries instead of issuing a request the server + would reject. (Realistic selections stay far below this; the cap is a + safety valve, not a feature.) + +## API — extend `GET /api/workspace/download` + +New optional query params (repeated): `include=` and +`exclude=`. + +- **No `include` params** → existing behavior (zip the whole workspace, + filename `workspace.zip`). Fully backwards compatible. +- **With `include` params:** + - Every `include`/`exclude` path is validated with `safeResolve` + (containment, no hidden/internal entries). Any failure → 400. + - Each `exclude` must be a strict descendant of at least one `include`; + otherwise 400 (`exclude` outside the selection is a client bug). + - Duplicate and ancestor-shadowed `include` entries are deduplicated + server-side (collapse to the topmost path). + - Zip command: same `spawn("zip", …)` as today, cwd = workspace dir, but the + `.` argument is replaced by the deduplicated include list. Excludes are + appended as extra `-x` patterns **after** glob-escaping `*`, `?`, `[`, + `]`, `\` in the exact relative path, followed by the existing + `zipExcludeArgs()` (dotfiles / ignored dirs / ignored suffixes still + apply inside selected folders). + - Filename: single include → `.zip`; multiple → + `workspace-selection.zip`. + - Everything selected-but-excluded → `zip` exit code 12 → friendly error + ("The selection is empty."), same as the existing empty-workspace path. +- Temp file lifecycle, `request.signal` abort handling, streaming response, + and `close`-based cleanup are unchanged. + +### Why server-side zip (rejected alternatives) + +- *New dedicated endpoint*: would duplicate the temp-file/abort/cleanup/error + logic; a query-param extension keeps one code path. +- *Client-side JSZip*: buffers the archive in browser memory and fetches every + file individually — unworkable for multi-GB research artifacts. + +## Error handling + +| Case | Behavior | +|---|---| +| Missing/invalid path (traversal, hidden entry) | 400 JSON error | +| `exclude` not under any `include` | 400 JSON error | +| Empty result after excludes | 400 JSON error ("The selection is empty.") | +| Client disconnects mid-archive | child `zip` killed via `SIGKILL`, temp file removed (existing) | +| `zip` binary missing | friendly error (existing) | + +## Testing + +- `src/lib/workspaceSelection.test.ts` (new): + - check/uncheck of files, folders, and nested combinations + - collapse invariant (checking a folder drops checked descendants) + - `isChecked` / `isIndeterminate` derivations, including unloaded subtrees + - `toParams` output for mixed include/exclude selections +- `src/app/api/workspace/download/route.test.ts` (new): + - single-folder include produces a zip with exactly that folder's + (non-ignored) contents + - include + exclude removes the excluded file from the archive + - multi-include produces entries under each top-level path + - `../` traversal in include/exclude → 400 + - exclude outside all includes → 400 + - filename with glob metacharacters (`[`, `*`) in exclude handled literally + - no include params → whole-workspace zip (regression guard) + - all-excluded selection → 400 "selection is empty" + - Tests inspect the produced archive by spawning `unzip -l` (or `zipinfo`) + on the temp file, consistent with how the repo already shells out to `zip`. + +## Out of scope + +- Multi-select in the "By type" view +- Per-row inline folder download buttons +- Resumable/partial downloads, download progress UI