feat(web): GET /api/plans/{id}/tree lists a folder of the plan's repository for the File tab's tree
Merged
DA
7d ago+862+862+012345678901234567890123456789-20-20-01234567890123456789
Part 2 of 3 of Dean's ask to polish the file viewer. Part 1 (#2081) made the File tab span the panel. Part 3 adds a collapsible file tree on the right of the File tab and clickable breadcrumbs, and it reads folders through this route.
Summary
GET /api/plans/{id}/tree?path= lists one folder of the plan's repository, read where and as the File tab reads a file (hosted/plan-tree.ts):
- The plan is read under the account first, so another account's plan answers 404.
- Then the Luke GitHub App's reach check and its read token for that one repository. This step is now shared with the file read as
reachPlanRepositoryinplan-file.ts. The token is used for the one listing and goes nowhere else. - It lists the head of the default branch, and the answer names that branch, the same one the File tab reads its file on.
pathabsent or empty means the root.
GitHub read (github/repository-folder.ts):
- Uses the git trees API with the tree-ish
<branch>:<path>. The contents API silently stops a folder at 1,000 entries; git trees reports when it truncated. - Above
PLAN_TREE_BOUNDS.MAX_ENTRIES(5,000) the answer is cut and markedtruncated. - Folders are listed as folders. Files, links and submodules are listed as files; the File tab already refuses a link or submodule as "no regular file" when it is opened.
Refusals reuse the File tab's vocabulary (planFileRefusalSchema):
- Listed by name: secret files like
.env*, as GitHub and every editor show them. Their contents stay refused by the file read. (The orchestrator approved this.) - Refused before GitHub is asked:
- a folder named like a secret file, or any folder inside one
- a path that would leave the tree (an empty,
.or..segment)
missing, naming the branch: a path that holds no folder, whether there is nothing there or a file.unavailable: GitHub failing.no-repository: a plan with no repository.- Invalid request:
pathgiven twice.
Wiring:
- Route module
routes/plans/tree.ts, plus its rewrite infunction-rewrites.ts, regenerated intoapi-rewrites.jsonandvercel.json. - Wire schemas
planTree*inplan-wire.ts, with JSON Schema goldens recorded for the four new schemas only; existing goldens are unchanged. HostedPlanClient.readTreeandplanTreePath.
The desktop doesn't call this route yet; part 3 does, through the host like planning.file.
Evidence
- Platform-independent checks:
./scripts/check.shexit 0, apart from knip flagging four exported types and the newPlanRepositoryReach, which nothing used yet. I un-exported them and re-ran knip,tscand biome, all clean. - New tests:
plans-app.test.tson PGlite, with the GitHub fake (a test Layer at GitHub):- lists a folder as GitHub holds it on the App's token, mapping each entry kind
- another account's plan answers 404
- the platform-landed
/api/plans/{id}/tree?path=lands on the tree route - truncation, both past the bound and when GitHub cut it
- every refusal above
plan-client.test.ts: the root is asked with no query, a folder bypath, a refusal is read, and a path the wire refuses is never sent.
- macOS Electron verification (
./scripts/verify.sh): not run, and not needed: no desktop code changes here.
Invariants
-
./scripts/check.shgreen. - JSON Schema goldens: four new files (
plan-wire-planTree{Request,Entry,Folder,Answer}Schema.json), recorded withLUKE_UPDATE_FIXTURES=1; no existing golden changed. - Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
- Gateway envelope goldens unchanged.
- No new
astype assertion. - No
Effect.runPromise/runSync/runForkoutside the runtime edges. - No new raw async primitives.
- No discarded Effect, Stream, or Layer.
- Test count: +5 (4 in
plans-app.test.ts, 1 inplan-client.test.ts). - No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part. -
PRIVACY.mdunchanged. The listing reads names only through the same App token the file read uses. - No dependency changes.
- Barrel/door rule unaffected.
Notes
- Blockers or follow-up verification: none. A
./scripts/run.sh --previewpass against this PR's Preview needs a Mac; the desktop doesn't call the route until part 3.
š¤ Generated with Claude Code