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-20

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 reachPlanRepository in plan-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.
  • path absent 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 marked truncated.
  • 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: path given twice.

Wiring:

  • Route module routes/plans/tree.ts, plus its rewrite in function-rewrites.ts, regenerated into api-rewrites.json and vercel.json.
  • Wire schemas planTree* in plan-wire.ts, with JSON Schema goldens recorded for the four new schemas only; existing goldens are unchanged.
  • HostedPlanClient.readTree and planTreePath.

The desktop doesn't call this route yet; part 3 does, through the host like planning.file.

Evidence

  • Platform-independent checks: ./scripts/check.sh exit 0, apart from knip flagging four exported types and the new PlanRepositoryReach, which nothing used yet. I un-exported them and re-ran knip, tsc and biome, all clean.
  • New tests:
    • plans-app.test.ts on 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 by path, 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.sh green.
  • JSON Schema goldens: four new files (plan-wire-planTree{Request,Entry,Folder,Answer}Schema.json), recorded with LUKE_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 as type assertion.
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new raw async primitives.
  • No discarded Effect, Stream, or Layer.
  • Test count: +5 (4 in plans-app.test.ts, 1 in plan-client.test.ts).
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged. 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 --preview pass against this PR's Preview needs a Mac; the desktop doesn't call the route until part 3.

šŸ¤– Generated with Claude Code


Devin Review