docs(webui): spec for workspace selective download

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-09 15:19:45 +08:00
parent 0e34383aed
commit e44a9f0b2c
@@ -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
`<a href>` 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<string>` — 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<string>` — 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<string>; excluded: Set<string> }` 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=<relPath>` and
`exclude=<relPath>`.
- **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 → `<basename>.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