Build in the review tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 09:24:01 -04:00
parent 4eef6a28d3
commit 407dd81512
55 changed files with 12932 additions and 40 deletions
+119
View File
@@ -0,0 +1,119 @@
---
name: address-review
description: Fetch the code-review comments left in this playpen tab's review pane, address each one (edit code + post an inline reply), and resolve the thread. Use when the user says "address the review", "check the review", "any review comments?", or runs /address-review.
---
# Address the review in this tab
Playpen serves a GitHub-style review UI for the repository the current tab is
working in. The user leaves line-level comments in the review pane and clicks
**Submit review**; this skill pulls those comments, addresses them in code, and
replies inline so the responses appear live in the pane — no copy-pasting from
the terminal.
## 1. Find the review
Every terminal pane in playpen is handed its own tab's review endpoint:
```bash
BASE="$PLAYPEN_REVIEW_URL" # e.g. http://127.0.0.1:8420/t/tab3
```
That variable is the whole of the addressing. It names **this tab's** review, so
there is no repository to pass and no way to address comments meant for another
worktree.
If it is empty — you are running outside playpen, or in a shell started before
the server came up — discover it instead:
```bash
curl -s http://127.0.0.1:8420/api/tabs
```
That lists every tab with the `path` it is reviewing. Match `path` against
`git rev-parse --show-toplevel` and build the URL as
`http://127.0.0.1:8420/t/<id>`. Do **not** guess: reviewing the wrong tab means
addressing another branch's comments. If nothing matches, or the connection is
refused, tell the user to open a review pane in this tab (**Ctrl+Shift+D**) and
stop.
Confirm the review is open:
```bash
curl -s "$BASE/api/repo"
```
- `{"open":true,…}` — good, go on.
- `{"open":false}` or a 409 — this tab has no review pane. Ask the user to open
one and stop.
## 2. Fetch the pending comments
```bash
curl -s "$BASE/api/review/pending"
```
Returns this review's submitted, unresolved comments. Each has:
- `id` — use this to reply and resolve
- `level` — how it's anchored:
- `line``file` + `side` (`new`/`old`) + `line`..`endLine` (a line or range)
- `file``file` only (a comment about the whole file)
- `review` → not tied to anything (a comment about the overall change set)
- `file`, `side`, `line`, `endLine` — the anchor, per `level` above
- `body` — what the reviewer wants
- `author``user` for the reviewer's own comment, `claude` for one left by a
review you ran yourself (the `leave-review` skill). Both are real work and both
are addressed the same way; just say which is which in your summary. If the
user asked specifically for *their* comments, filter to `author: "user"`.
- `replies` — any prior back-and-forth on the thread
Treat each level appropriately: for `line` address the specific lines or range;
for `file` consider the file as a whole; for `review` weigh it against the entire
change set.
If the array is empty, say there's nothing to address and stop.
## 3. Address each comment
For every pending comment, in order:
1. **Read the context.** Open `file` around `line` (on the given `side`) so you
understand what the reviewer is pointing at.
2. **Decide the response type:**
- **Change request** → make the edit with your normal file-editing tools.
- **Question / discussion** → don't necessarily edit; answer in the reply.
- **Unclear** → ask a clarifying question in the reply and leave the thread
open (skip the resolve step).
3. **Post an inline reply** describing exactly what you did (or your answer):
```bash
curl -s -X POST "$BASE/api/comments/<id>/replies" \
-H 'Content-Type: application/json' \
-d '{"body":"Done — main() now logs and returns the error instead of printing.","author":"claude"}'
```
4. **Resolve the thread** once it's fully handled (skip if you asked a question):
```bash
curl -s -X POST "$BASE/api/comments/<id>/resolve"
```
Replies and resolutions appear in the review pane immediately over its live
connection.
## 4. Summarize
Report back: which comments you addressed and how, which you left open (and why),
and any code changes you made. Do **not** commit unless the user asks. If your
edits changed line numbers, mention that the reviewer may want to hit
**↻ Refresh diff** in the pane to re-anchor against the new code.
## Notes
- Keep replies concise and specific — they're read inside a comment thread.
- Comment ids are unique per review. A `404` from a reply or resolve usually
means `$PLAYPEN_REVIEW_URL` points at a different tab than you think.
- Anchoring is by line number in the diff at comment time; after your edits the
original line may have moved. That's expected — the reply plus resolve keeps
each round coherent, and the user refreshes the diff for the next round.
+234
View File
@@ -0,0 +1,234 @@
---
name: leave-review
description: Review the diff open in this playpen tab's review pane and leave your own line-level comments there (as Claude), instead of printing a terminal report. Can delegate the analysis to another review skill — pr-review, security-review, or any project-specific reviewer — and post its findings as inline comments. Use when the user says "review the diff", "leave a review", "review my changes in the pane", "review this with <skill>", or runs /leave-review.
---
# Leave a review in this tab's review pane
Playpen serves a GitHub-style review UI for the repository the current tab is
working in. Normally the user leaves comments there and an agent addresses them —
the `address-review` skill. This skill runs the loop the other way: **you** review
the diff on screen and leave the comments, anchored to real lines, so the user
reads them in the same pane they'd read a colleague's review in — reply, resolve,
or hand them back to be fixed.
**This skill reviews. It does not fix.** Do not edit a single file while running
it, even for something trivial or obviously right — a review whose findings have
already been silently applied is impossible to read. Fixing is `address-review`'s
job, and your comments land in its queue automatically (see step 7).
## 1. Set the target
```bash
BASE="$PLAYPEN_REVIEW_URL" # e.g. http://127.0.0.1:8420/t/tab3
curl -s "$BASE/api/repo"
```
`$PLAYPEN_REVIEW_URL` is exported into every terminal pane and names **this
tab's** review, so there is nothing to choose and no way to leave your review on
someone else's branch.
- `{"open":true,…}` with a `context` object — good, go to step 2.
- `{"open":false}` or a 409 — this tab has no review pane. Tell the user to open
one (**Ctrl+Shift+D**) and stop. Don't review a different tab.
- `$PLAYPEN_REVIEW_URL` empty, or connection refused — list the tabs with
`curl -s http://127.0.0.1:8420/api/tabs` and match a tab's `path` against
`git rev-parse --show-toplevel`. If nothing matches, stop and say so.
## 2. Find out which diff to review
The base ref, the **uncommitted** toggle, and any single commit picked out of the
range are chosen in the pane, and the pane publishes that selection to the
server. It's the `context` field from step 1:
```json
"context": { "base": "main", "uncommitted": true, "commit": "" }
```
Review **that** diff. It's what the user is looking at, and it's the only diff
whose line numbers the pane can place a comment on.
- `"context": null` — nothing has been on screen yet. Ask the user to open the
review pane, then re-read it. Don't guess a base.
- A non-empty `"commit"` means they're reading one commit of the range on its
own. Review that commit; the numbers only mean anything there.
- If the user asks for a different diff ("review just the last commit"), you can
pass your own `base`/`uncommitted`/`commit` — but say plainly that those
comments will only appear once they switch the pane to match.
## 3. Get the diff
```bash
curl -s "$BASE/api/diff?base=main&uncommitted=true"
```
The `patch` field is the exact bytes the pane renders, and `files` is the
per-file summary — read that first, for scale. `commits` lists the commits the
range spans, so a user asking about "the second commit" can be answered by
re-fetching with `&commit=<sha>`.
Use this rather than running `git diff` yourself: identical bytes means identical
line numbers, which is what makes an anchor land.
A very large change set comes back with `"oversized": true` and no patch. Add
`&force=1` to get it anyway, or narrow to one commit.
Read the surrounding code with your normal tools — the patch alone is rarely
enough to tell a real bug from a fine one. If the patch is empty, say so and stop.
## 4. Pick the lens — and delegate when there's a skill for it
This skill is the delivery mechanism; the *judgment* can come from a skill that
already knows this codebase. Before reviewing anything yourself, check the skills
available to you for one that fits this repo or what the user asked for:
- **The user named one** — "review this with pr-review", "do a security review",
"/leave-review pr-review". Invoke it with the Skill tool. Their choice wins.
- **A project-specific reviewer exists** for this repo (e.g. `pr-review` for
Signal-Android conventions) — prefer it over your own generic pass, and say
which one you used.
- **Several apply** — run them in turn (conventions pass, then security pass) and
merge the findings, dropping duplicates.
- **None fits** — review it yourself against the rubric below.
When you invoke a review skill, follow its instructions as written, but note up
front that its findings are going to be posted as inline comments rather than
printed — so you need, for each finding, a **file path and a line number in this
diff**, plus the rule name it fired. Then continue at step 5 with its report as
your finding list. If it produces a terminal report anyway, that's fine: its
numbered findings are exactly the input you need.
Two limits:
- **Only delegate to skills that review.** Some skills change code as part of
their job (`simplify`, for instance, applies its own fixes). Don't invoke one of
those here — it would edit the diff out from under the review. If the user asked
for one by name, say why you're not running it and offer its rubric as a lens
instead.
- **If a delegate doesn't apply** (a Signal-Android reviewer in an unrelated repo,
say) don't force it. Fall back to your own pass and mention the swap.
### Rubric for your own pass
Restrict findings to **added and modified lines** — don't review code the diff
didn't touch, unless the change made it newly wrong. In rough priority order:
1. **Correctness** — logic that doesn't do what the code around it clearly
intends; off-by-one, inverted condition, wrong variable, missed case.
2. **Error handling** — swallowed errors, unchecked returns, an error path that
leaves state half-updated, panics on input the caller controls.
3. **Resources and lifetimes** — leaks, missing close/cancel, work that outlives
what it belongs to.
4. **Concurrency** — data races, state mutated without the lock its neighbours
take, deadlock ordering.
5. **Interface and contract** — a caller that can now be silently wrong; a
behaviour change not reflected in the doc comment right above it.
6. **Tests** — a new branch with real failure modes and no test; a test that
would pass with the bug still in.
7. **Fit** — code that ignores an existing helper, layering, or naming pattern
the file establishes.
8. **Cruft** — commented-out code, narration comments, debug logging, a stray
TODO with no owner.
## 5. Turn findings into comments
Choose the anchor per finding, most specific that will actually render:
| Finding | Anchor |
| --- | --- |
| A specific line | `"level":"line"`, `file`, `line` |
| A construct spanning lines | add `endLine` |
| A deleted line (the change removed something needed) | add `"side":"old"` |
| The file as a whole, or a line not in the patch | `"level":"file"`, `file` |
| Cross-cutting: architecture, a missing test file, the change set as a whole | `"level":"review"` |
**Verify every line number against the patch before you post it.** Read the hunk
header — `@@ -old,n +new,m @@` — and count: the number you pass must be a line the
patch actually shows on that side (an added `+` line or a context line). A comment
on a line the pane doesn't render is invisible; when in doubt, widen to a range or
drop to a file-level comment. Never post a line number you inferred from your
memory of the file.
Three details that decide whether a comment lands where you meant:
- `file` takes the path as the diff names it: the **new** path, or the old one
for a deleted file.
- A range hangs its thread off the **end** line, GitHub-style. Put the range
around the construct and let it anchor at the bottom.
- Prefer `"side":"new"`. Old-side line numbers are positions in the base
revision, so they stop meaning anything the moment the user changes the base
ref, and the comment goes outdated. Only use `old` when the finding really is
about a line the change deleted.
**Don't repeat what's already been said.** Fetch the existing threads first:
```bash
curl -s "$BASE/api/comments"
```
Skip anything the user already raised, and anything **you** raised on an earlier
pass — including threads they resolved. Re-posting a resolved finding is the
fastest way to make this skill not worth running twice.
Write each body like a comment in a thread someone has to read:
- Lead with what's wrong, in one sentence. Then why it matters, then the fix.
- 24 sentences. Show the fix as code when that's shorter than describing it.
- Open with a severity label so the rail can be triaged: `Blocking:`,
`Should fix:`, `Nit:`, or `Question:`.
- When a delegate skill produced it, name the rule at the end in parentheses —
e.g. `(pr-review: LogTagInlined)` — so the user can trace it.
- **Bodies render as plain text, not markdown.** Newlines and indentation are
preserved, so an indented line or two is how you show suggested code. Backticks
around an identifier read fine; `**bold**` and ``` fences just show up as
punctuation, so skip them.
- No praise-only comments, no restating what the diff does, no "consider possibly
maybe". One issue per comment; group unrelated nits in one file-level comment
rather than five line comments.
- Aim for **at most ~1012 comments**. Past that, keep the serious ones and roll
the tail into a single review-level comment. A wall of comments reads as noise
and buries the two that mattered.
## 6. Post them
```bash
curl -s -X POST "$BASE/api/comments" -H 'Content-Type: application/json' -d '{
"level":"line","file":"src/review/Store.zig","side":"new","line":84,"endLine":91,
"author":"claude",
"body":"Should fix: save() runs while the write lock is held, so a slow disk blocks every reader for the length of the write. Snapshot the list under the lock and write outside it."
}'
```
- `base`, `uncommitted` and `commit` default to the selection the pane published,
so leave them out unless you deliberately reviewed another diff.
- `"author":"claude"` is the point: the comment shows up as **Claude**'s in the
pane, and lands as an **open thread** rather than a draft of the user's. Drafts
are theirs alone — never post as `"author":"user"`.
- Post the most serious findings first — the comments rail is ordered by creation.
- Finish with **one review-level comment** as the review body: what you reviewed
(base ref, file count), which lens you used, and the two or three themes worth
the user's attention. Without it they can't tell what was checked versus what
came up clean.
Everything appears in the pane immediately over its live connection.
## 7. Summarize in the terminal
Report: the lens used, how many comments you left and where (`file:line — one-line
summary` each), and anything you deliberately didn't comment on. Then tell them
the two ways forward:
- Read them in the pane — reply, or resolve the ones they disagree with.
- Or say **"address the review"** — your comments are open threads, so they're in
the same queue the user's comments go into, and `address-review` will fix them
the same way. Mention this: it's the whole loop, and it's not obvious that your
own findings come back to you as work.
## Notes
- Your comments count toward the review's **open** count, the same as the user's —
it means "threads awaiting someone", not "awaiting Claude".
- If the user edits code after you review, your anchors drift. That's expected;
they hit **↻ Refresh diff** and the next pass re-anchors.
- Reviewing a large diff: work file group by file group and post as you go, so a
long pass still leaves usable comments if it's interrupted.
+7
View File
@@ -4,3 +4,10 @@ zig-out/
# build.zig.zon and must not be committed.
zig-pkg/
result
# The review UI's build inputs and outputs. `zig build` runs Vite itself (see
# build.zig) and embeds what it produces, so dist is a build artifact like any
# other; node_modules comes from `mise run web-deps`.
web/node_modules/
web/dist/
web/*.tsbuildinfo
+191 -11
View File
@@ -2,9 +2,13 @@
A proof-of-concept workspace built on
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
**vertical tabs**, each holding a split tree of terminal and web panes, with
**saved layouts** that open a whole arrangement — directories, scripts and all —
in one go, and a **startup list** that opens the ones you always want.
**vertical tabs**, each holding a split tree of terminal, web and **code review**
panes, with **saved layouts** that open a whole arrangement — directories, scripts
and all — in one go, and a **startup list** that opens the ones you always want.
A tab can hold a GitHub-style review of the repository it is working in, and the
agent in the terminal beside it can read the comments you leave there and reply
inline. See [Code review](#code-review).
The sidebar holds the window controls, a new-tab button, one row per tab, and a
settings gear at its foot — the layout Zen Browser uses for vertical tabs —
@@ -13,11 +17,15 @@ with the content inset to its right.
## Quick start
```sh
nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
nix develop # Zig 0.16 + GTK4 + libadwaita + node, no host toolchain needed
mise run web-deps # once: npm deps for the review pane's UI
zig build run
```
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
`web-deps` is separate because it is the one step that wants the network —
`zig build` runs the UI's production build itself, but it will not install its
dependencies for you.
## Installing
@@ -115,7 +123,15 @@ Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
Pane.zig content plus its header, drag source, and drop target
Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection
Browser.zig WebKitWebView plus a nav/address bar and a find bar
Review.zig the tab's review: a web view bound to its own server endpoint
webkit.zig hand-written bindings for the WebKitGTK calls we make
review.zig the review server's lifecycle, and the one place with threads
review/Server.zig HTTP + SSE, the tab registry, the embedded UI
review/git.zig what git is asked for: refs, commits, the patch itself
review/Store.zig one review's comments, as JSON in the repo's git dir
review/model.zig the wire format the UI and the API both speak
review/assets.zig the built UI, carried in the binary
web/ the review UI: React + Vite, built by build.zig and embedded
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
OpenLayoutDialog.zig prompts for a layout's parameters
SaveLayoutDialog.zig turns the current tab into a saved layout
@@ -199,8 +215,8 @@ what it can't infer: a name, the parameters, and each pane's script. There is
deliberately no separate layout builder — the split tree already is one.
The save dialog prefills what it can read off the live tab: each terminal's
current directory, straight out of `/proc/<pid>/cwd`, and each web pane's
current page. So the usual flow is to get a tab set up the way you like,
current directory, straight out of `/proc/<pid>/cwd`, each web pane's current
page, and the repository a review pane is bound to. So the usual flow is to get a tab set up the way you like,
save it, and replace the literal paths with `{{parameters}}`.
**Editing** a saved layout opens the same dialog on the stored one, so its
@@ -303,6 +319,22 @@ A node is a split if it has a `split` key and a leaf otherwise. Saves are
atomic — written to a temporary and renamed — so an interrupted write leaves
the previous layouts intact rather than a file that won't parse.
A leaf's `kind` is `terminal`, `web`, or `review`. A `review` leaf takes a `cwd`
and nothing else — a review has no shell to start and no page to load, so the
only thing to say about one is which repository to point it at:
```json
{ "kind": "review", "cwd": "{{path}}" }
```
The directory is expanded exactly like a terminal's, so `{{parameters}}`, a
leading `~` and `$(a command)` all work, and opening the same layout against two
worktrees gives two different reviews. Leave it out — `{ "kind": "review" }`, all
a layout saved before this field existed says — and the pane opens with nothing
bound and says so: a layout has to name the directory itself, because the
terminals it would otherwise be read off have not started yet.
See [Code review](#code-review).
## Startup tabs
Opening the same three layouts against the same three worktrees every morning is
@@ -392,6 +424,12 @@ in principle, but a terminal grid is small.
that takes a URL or falls back to a search, a load-progress indicator in the
entry, and the page title feeding the pane header and tab label, plus
find-in-page on `Ctrl+F`. See [Find in page](#find-in-page)
- **Review panes**: a GitHub-style review of the repository the tab is working
in — split/unified diff with syntax highlighting, a folder-tree file rail,
viewed marks and a progress meter, line/range/file/review-level comments, and
a commit list for reading a branch one commit at a time. One per tab, served
by a local HTTP API so the agent in the next pane can read your comments and
reply inline. See [Code review](#code-review)
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts)
@@ -430,6 +468,7 @@ in principle, but a terminal grid is small.
| `Ctrl+Shift+T` | new tab | `new_tab` |
| `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` |
| `Ctrl+Shift+B` | new web view in the current tab | `new_web` |
| `Ctrl+Shift+D` | review this tab's changes | `new_review` |
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | `close_pane` |
| `Ctrl+Shift+H/J/K/L` | move focus to the pane left / below / above / right | `focus_pane_left` and friends |
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | `move_pane_left` and friends |
@@ -619,6 +658,140 @@ The choice lives on the tab and not on disk, the same as a typed name: it lasts
as long as the tab does. Saving the tab as a layout does not carry it, since a
layout describes an arrangement of panes rather than what a row looks like.
## Code review
A tab can hold a **review pane**: a GitHub-style review of the repository that
tab is working in, on `Ctrl+Shift+D` or the ✎ button in any pane's header.
It exists because reading a diff and driving an agent are the same session. The
agent runs in a terminal in the tab; the review is a pane beside it; you leave
line comments, say "address the review", and the replies appear in the pane while
you watch. Nothing is copy-pasted out of a terminal, and nothing has to be
switched to.
The UI is a web page (React, in `web/`), built by `zig build` and carried inside
the binary. It is served by an HTTP server this process runs — which is how the
agent gets at it too.
### One review per tab, bound to a directory
A review pane takes its repository from **the directory the tab is working in**:
the focused terminal's current directory, or the first terminal's if the focused
pane is not one. That is resolved once, when the pane opens, and then it stays
put. Re-resolving on every fetch was the alternative, and it means a `cd` in a
terminal can swap the diff out from under you mid-read; a review you have to
reopen is the better failure.
A **layout** names the directory itself, with a `cwd` on its `review` leaf (see
[Layouts](#layouts)). It has to: a layout's review pane is built alongside its
terminals rather than after them, so there is no working directory to read off
yet. It is resolved before any pane in the tab exists, so the review is already
attached to its repository by the time the pane's page loads.
A tab holds at most one. Two review panes would each be publishing a different
diff selection to the server, so an agent asked to review "the diff I'm looking
at" would follow whichever wrote last — the second pane would quietly break the
first. Asking for a review you already have takes you to it instead.
Comments live in `<git-dir>/playpen-review/reviews.json`. Inside the git
directory, so they never show up in the diff being reviewed, and so a worktree's
comments belong to that worktree rather than to the repository it was cut from.
Closing the pane leaves them there; opening another one in the same tab picks the
review back up.
### The server, and how an agent finds it
One server for the whole window, on `127.0.0.1:8420` (the next free port up if
that one is taken; `PLAYPEN_REVIEW_PORT` overrides where it starts looking). Each
tab is its own endpoint:
```
/t/<tabId>/ the review UI for that tab
/t/<tabId>/api/... that tab's review
/api/tabs every tab and what it is reviewing
```
The tab id in the path *is* the addressing. There is no repository parameter on
any call, so a request cannot land on the wrong review.
Every terminal pane is handed its own tab's endpoint as **`PLAYPEN_REVIEW_URL`**,
from the moment the pane opens — before any review pane exists, so an agent never
has to be restarted because you opened one after it. That variable is the whole
of the discovery step:
```bash
curl -s "$PLAYPEN_REVIEW_URL/api/review/pending" # what is waiting for you
curl -s "$PLAYPEN_REVIEW_URL/api/diff?base=main&uncommitted=true"
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/replies" \
-H 'Content-Type: application/json' \
-d '{"body":"Done — it returns the error now.","author":"claude"}'
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/resolve"
```
The endpoints, all under `/t/<tabId>/api`:
| | |
| --- | --- |
| `GET repo` | the repository, its refs, the comment counts, and the diff selection on screen |
| `POST repo/context` | what the page publishes when you change the base ref |
| `GET diff` | `base`, `uncommitted`, `commit`, `force`, `ignoreWhitespace` |
| `GET file` | a file's contents at a ref, for expanding collapsed context |
| `GET/POST comments` | list, or open a thread |
| `PATCH/DELETE comments/{id}` | edit or delete one |
| `POST comments/{id}/replies` | reply on a thread |
| `PATCH comments/{id}/replies/{replyId}` | edit a reply |
| `POST comments/{id}/resolve`, `.../reopen` | close or reopen |
| `POST review/submit` | flip every draft to submitted |
| `POST review/reset`, `review/delete-resolved` | throw the review away, or just the finished threads |
| `GET review/pending` | the submitted, unresolved queue — what an agent works |
| `GET events` | server-sent events, so the page updates live |
A comment's status follows from who wrote it, and that is the whole rule. You
compose drafts and decide when to submit them, so a comment posted without an
author starts as a draft. An agent has no drafting step — it posts a review it
has already decided on — so `"author":"claude"` is born submitted: an open thread,
with no **Submit review** click standing between it and being read.
### The two skills
`mise run install-skills` installs them into `~/.claude/skills`, keeping whatever
was there under `~/.claude/playpen-skill-backups` (outside the skills directory,
since anything inside it is loaded as a skill). `mise run uninstall-skills` puts
the originals back.
- **`address-review`** — pull the comments you submitted, edit the code, reply on
each thread, resolve it.
- **`leave-review`** — the loop the other way: the agent reviews the diff you are
looking at and leaves its own comments, as **Claude**, anchored to real lines.
It is the delivery mechanism, not the judgment: point it at a review skill you
have already tuned for a repo and it posts that skill's findings as inline
comments.
Together they close the circle. `leave-review`'s comments are open threads, so
they land in the same queue yours do, and "address the review" fixes them the same
way.
### The server runs on threads. Nothing else does.
`review.zig` is the one place in playpen with threads in it: the listener and each
connection get their own. That is not gold-plating — a request spends most of its
life inside `git diff`, and doing that on the GTK main loop would freeze the
window for the length of every fetch. Nothing under `src/review/` touches a
widget, and nothing in the widget tree touches a socket; the two meet at the tab
registry, which is what the mutexes there are for.
### Working on the UI
```sh
mise run web-deps # npm install, once
mise run web-dev # Vite on :5173, proxying the API to a running playpen
```
Then open `http://localhost:5173/t/<tabId>/` — the path is what tells the page
which review it is. `zig build` runs the production build itself and embeds the
result, so the bundle in the binary can never be older than the source it came
from.
## Agent status
Playpen is mostly used to keep several Claude Code sessions side by side, and
@@ -927,11 +1100,18 @@ emit from and the client never sees it. Anything reachable only by clicking has
to be reached another way — a keyboard shortcut, or `zig build test` if the
thing being checked is logic rather than pixels.
`zig build test` runs the unit tests, which cover layout parsing, the
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither
links GTK — a test binary has one root, and those are the two files worth
testing in isolation that have nothing to do with each other.
`zig build test` runs the unit tests. They cover layout parsing, the
parameter/`$(...)` substitution pipeline, the palette, the settings file, the
shortcut table, the emoji table and its search, and — for the review server —
git's own output formats, the rule for picking a base ref, and the round trip a
review file makes through disk. Each is its own binary with its own root, since a
test binary has exactly one, and none of those roots links GTK, so they all run
without a display.
What is *not* covered is anything that wants a socket and a browser: the HTTP
layer, the SSE stream, and the review UI. Those were checked by running the app
in the headless compositor and driving the API with `curl` — see the note about
synthetic clicks above for why the pane itself has to be reached by keyboard.
`tools/gen-emoji.py` regenerates `src/emoji.zig` from Unicode's `emoji-test.txt`
and CLDR's annotations. It is not part of the build — its output is committed, so
+94
View File
@@ -47,6 +47,40 @@ pub fn build(b: *std.Build) void {
.root_source_file = icons_gresource,
});
// The review pane's UI. It is a React app (see `web/`), built by Vite here so
// the bundle embedded in the binary can never be older than the source it
// came from, and embedded rather than installed alongside so a review pane
// is a web view pointed at this process and nothing else.
//
// The four filenames are pinned in `web/vite.config.ts` precisely so they
// can be named at compile time; `src/review/assets.zig` is the other half of
// that agreement.
const web_build = b.addSystemCommand(&.{ "npm", "run", "--silent", "build", "--" });
web_build.setCwd(b.path("web"));
web_build.setName("vite build (review UI)");
web_build.addArg("--outDir");
const web_dist = web_build.addOutputDirectoryArg("dist");
web_build.addArg("--emptyOutDir");
// Vite is not told what its inputs are, so the Run step has to be. Without
// this the bundle is cached against its argv alone and editing the UI would
// rebuild nothing; with it, `zig build` after a `.tsx` edit does the right
// thing and `zig build` after a `.zig` edit does not re-run npm.
addWebInputs(b, web_build);
exe.root_module.addAnonymousImport("review-index.html", .{
.root_source_file = web_dist.path(b, "index.html"),
});
exe.root_module.addAnonymousImport("review-app.js", .{
.root_source_file = web_dist.path(b, "assets/app.js"),
});
exe.root_module.addAnonymousImport("review-app.css", .{
.root_source_file = web_dist.path(b, "assets/app.css"),
});
exe.root_module.addAnonymousImport("review-favicon.svg", .{
.root_source_file = web_dist.path(b, "favicon.svg"),
});
const gobject_imports = .{
.{ "adw", "adw1" },
.{ "cairo", "cairo1" },
@@ -149,6 +183,32 @@ pub fn build(b: *std.Build) void {
// table and a search over it, and it imports nothing at all. It cannot hang
// off the root above because a test binary has exactly one root, and
// Layouts.zig has no reason to reach for the emoji table.
// The review server's two testable halves. `git.zig` is git's own output
// formats and the rule for picking a base ref — text in, text out. `Store.zig`
// is the review file, whose round trip is the one piece of this that has to
// survive the process. Neither reaches GTK, so both run without a display;
// the HTTP layer above them wants a socket and a browser, which is a
// different kind of test than this project has.
const git_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/review/git.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
test_step.dependOn(&b.addRunArtifact(git_tests).step);
const store_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/review/Store.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
test_step.dependOn(&b.addRunArtifact(store_tests).step);
const emoji_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/emoji.zig"),
@@ -158,3 +218,37 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(emoji_tests).step);
}
/// Declare every file the Vite build reads as an input of that build step.
///
/// Zig hashes a `Run` step's file *arguments*, but a directory passed as an
/// argument is hashed by path and not by contents — so the source tree has to be
/// enumerated here, at configure time, for the cache to be honest about when the
/// bundle is stale.
///
/// `node_modules` is deliberately not walked. It is tens of thousands of files
/// whose contents are already pinned by `package-lock.json`, which *is* listed.
fn addWebInputs(b: *std.Build, run: *std.Build.Step.Run) void {
for ([_][]const u8{
"web/index.html",
"web/package.json",
"web/package-lock.json",
"web/tsconfig.json",
"web/vite.config.ts",
}) |file| {
run.addFileInput(b.path(file));
}
const io = b.graph.io;
for ([_][]const u8{ "web/src", "web/public" }) |root| {
var dir = b.build_root.handle.openDir(io, root, .{ .iterate = true }) catch continue;
defer dir.close(io);
var walker = dir.walk(b.allocator) catch continue;
defer walker.deinit();
while (walker.next(io) catch null) |entry| {
if (entry.kind != .file) continue;
run.addFileInput(b.path(b.pathJoin(&.{ root, entry.path })));
}
}
}
+5
View File
@@ -49,6 +49,11 @@
pkgs.pkg-config
pkgs.gdb
# The review pane's UI is a React app built by Vite, and `zig build`
# runs that build (see build.zig) so the bundle it embeds can never be
# older than the source it came from. Nothing else here needs node.
pkgs.nodejs_22
# Used by ./shot.sh to run the app inside a throwaway headless
# compositor and screenshot it, so UI can be checked without
# touching the developer's real session.
+67
View File
@@ -1,6 +1,14 @@
# Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is
# nothing for mise to install; these tasks just drive it.
[tasks.web-deps]
description = "Install the review UI's npm dependencies"
run = "nix develop --command npm --prefix web install --no-audit --no-fund"
[tasks.web-dev]
description = "Hot-reloading review UI against a running playpen (open /t/<tabId>/)"
run = "nix develop --command npm --prefix web run dev"
[tasks.build]
description = "Build a release binary into zig-out/bin"
run = "nix develop --command zig build -Doptimize=ReleaseFast"
@@ -117,6 +125,65 @@ rm -f "$claude/hooks/playpen-status.sh"
echo "removed the hooks from $settings (previous version kept at $settings.playpen-backup)"
'''
[tasks.install-skills]
description = "Install the review skills so Claude can drive the review pane"
run = '''
#!/usr/bin/env bash
set -euo pipefail
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
dest="$claude/skills"
# Outside the skills directory on purpose: anything under it is loaded as a
# skill, so a backup kept alongside would show up in the list as a second,
# stale copy of the thing it is a backup of.
backups="$claude/playpen-skill-backups"
mkdir -p "$dest" "$backups"
for name in address-review leave-review; do
src=".claude/skills/$name"
[ -d "$src" ] || { echo "missing $src" >&2; exit 1; }
# Anything already installed under this name is kept, not replaced. These are
# global names: the version being overwritten may be one that drives an
# entirely different tool, and losing it silently would be the worst kind of
# breakage — the skill still runs, against a server that is not there.
if [ -e "$dest/$name" ] && [ ! -L "$dest/$name" ]; then
rm -rf "$backups/$name"
cp -r "$dest/$name" "$backups/$name"
echo "kept the previous $name at $backups/$name"
fi
rm -rf "$dest/$name"
cp -r "$src" "$dest/$name"
echo "installed $dest/$name"
done
echo
echo "Open a new Claude session to pick them up — skills are read at startup."
'''
[tasks.uninstall-skills]
description = "Remove the review skills, restoring whatever they replaced"
run = '''
#!/usr/bin/env bash
set -euo pipefail
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
dest="$claude/skills"
backups="$claude/playpen-skill-backups"
for name in address-review leave-review; do
rm -rf "$dest/$name"
if [ -d "$backups/$name" ]; then
mv "$backups/$name" "$dest/$name"
echo "restored the previous $name from the backup"
else
echo "removed $dest/$name"
fi
done
rmdir "$backups" 2>/dev/null || true
'''
[tasks.install]
description = "Install playpen into ~/.local with a desktop entry and icon"
depends = ["build"]
+73 -1
View File
@@ -47,7 +47,11 @@ pub const Orientation = enum { horizontal, vertical };
/// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than
/// imported so this module stays free of GTK and of the widget tree.
pub const Kind = enum { terminal, web };
///
/// `review` uses `cwd` and nothing else: a review has no shell to start and no
/// page to load, so the only thing a layout has to say about one is which
/// repository to point it at.
pub const Kind = enum { terminal, web, review };
/// A value the user supplies when opening a layout.
pub const Parameter = struct {
@@ -63,6 +67,16 @@ pub const Pane = struct {
kind: Kind = .terminal,
/// Directory to start in. A leading `~` is expanded at open time.
///
/// For a `review` pane this is the directory whose repository the tab
/// reviews, resolved as the tab opens rather than read off a terminal —
/// which is the only way a layout can say it, since the panes it would be
/// read off have not started yet.
///
/// Left empty — which is what a layout saved before this field existed says
/// — the pane opens with no review bound and says so, since the gesture
/// that would bind one (asking for the tab's review) finds a review pane
/// already there and takes you to it instead.
cwd: []const u8 = "",
/// Script to run once the shell is up. Empty means "just a shell".
@@ -791,3 +805,61 @@ test "closingParen counts nesting" {
test "an unknown parameter is left visible rather than blanked" {
try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{});
}
test "a review leaf carries a directory through the layout file" {
const gpa = std.testing.allocator;
var layouts: Layouts = .init(gpa);
defer layouts.deinit();
try layouts.parse(
\\{"version":1,"layouts":[{"name":"Work","root":{
\\ "split":"horizontal","ratio":0.5,
\\ "first":{"kind":"terminal","cwd":"{{path}}"},
\\ "second":{"kind":"review","cwd":"{{path}}"}}}]}
);
try std.testing.expectEqual(@as(usize, 1), layouts.items.items.len);
// The directory is the one field a review leaf has, and it is a template
// like any other — a layout aimed at a project reviews that project.
const second = layouts.items.items[0].root.split.second;
try std.testing.expectEqual(Kind.review, second.pane.kind);
try std.testing.expectEqualStrings("{{path}}", second.pane.cwd);
try std.testing.expectEqualStrings("", second.pane.url);
try expectPath("/tmp/x", second.pane.cwd, &.{.{ .name = "path", .value = "/tmp/x" }});
// And it survives being written back out, which is what "save tab as
// layout" does to a tab that has its review open.
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} };
try writeNode(&json, second);
try std.testing.expectEqualStrings(
"{\"kind\":\"review\",\"cwd\":\"{{path}}\"}",
out.written(),
);
}
// A layout saved before the directory existed says nothing about one, and still
// has to open — its review pane comes up unbound rather than the whole layout
// refusing to parse.
test "a review leaf without a directory still round-trips" {
const gpa = std.testing.allocator;
var layouts: Layouts = .init(gpa);
defer layouts.deinit();
try layouts.parse(
\\{"version":1,"layouts":[{"name":"Work","root":{"kind":"review"}}]}
);
const root = layouts.items.items[0].root;
try std.testing.expectEqual(Kind.review, root.pane.kind);
try std.testing.expectEqualStrings("", root.pane.cwd);
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} };
try writeNode(&json, root);
try std.testing.expectEqualStrings("{\"kind\":\"review\"}", out.written());
}
+55 -18
View File
@@ -1,7 +1,7 @@
//! One pane inside a view: either a terminal or a web view, plus the chrome
//! needed to tell panes apart and rearrange them — a header strip showing the
//! content's title, which doubles as the drag handle, and a drop target
//! covering the whole pane.
//! One pane inside a view: a terminal, a web view, or the tab's code review,
//! plus the chrome needed to tell panes apart and rearrange them — a header
//! strip showing the content's title, which doubles as the drag handle, and a
//! drop target covering the whole pane.
//!
//! The header exists mainly so dragging a pane never competes with the
//! content's own mouse handling. Grabbing anywhere in the terminal body would
@@ -9,8 +9,8 @@
//! page would collide with the page itself.
//!
//! Everything below the header is behind `Content`, so the layout, drag and
//! drop, and focus tracking are all written once and neither kind of content
//! is special-cased.
//! drop, and focus tracking are all written once and no kind of content is
//! special-cased.
const std = @import("std");
const gdk = @import("gdk");
@@ -19,6 +19,7 @@ const gtk = @import("gtk");
const Browser = @import("Browser.zig");
const Layout = @import("Layout.zig");
const Review = @import("Review.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
@@ -32,11 +33,17 @@ pub const Kind = enum {
terminal,
web,
/// The tab's code review. Unlike the other two there can be only one in a
/// view — see `View.addPane` — because it is bound to the tab rather than
/// being a thing you can have several of.
review,
/// Icon standing in for this kind in the pane header and the tab row.
pub fn iconName(self: Kind) [:0]const u8 {
return switch (self) {
.terminal => "utilities-terminal-symbolic",
.web => "web-browser-symbolic",
.review => "document-edit-symbolic",
};
}
@@ -45,6 +52,7 @@ pub const Kind = enum {
return switch (self) {
.terminal => "shell",
.web => "web",
.review => "review",
};
}
};
@@ -55,12 +63,18 @@ pub const Kind = enum {
pub const Spec = union(Kind) {
terminal: Terminal.Options,
web: Browser.Options,
review: Review.Options,
/// A plain pane of the given kind, with nothing preloaded.
///
/// A review pane with nothing preloaded has no endpoint to talk to, so it
/// opens explaining itself. Everything that opens one for real fills the
/// options in — see `Window.addReview`.
pub fn plain(of: Kind) Spec {
return switch (of) {
.terminal => .{ .terminal = .{} },
.web => .{ .web = .{} },
.review => .{ .review = .{} },
};
}
@@ -70,16 +84,16 @@ pub const Spec = union(Kind) {
};
/// What the content of a pane is currently doing. Only a terminal ever
/// reports this — a web pane is always `.idle` — but it lives here rather
/// than on Terminal so the view can aggregate across panes without caring
/// which kind each one is.
/// reports this — a web or review pane is always `.idle` — but it lives here
/// rather than on Terminal so the view can aggregate across panes without
/// caring which kind each one is.
pub const Status = Terminal.Status;
/// What a content kind reports back to its pane. Shared by both kinds so the
/// pane can wire either one up with the same handlers.
/// What a content kind reports back to its pane. Shared by all three kinds so
/// the pane can wire any of them up with the same handlers.
///
/// A web pane simply never calls `on_status` or `on_input`; it has no
/// equivalent of a long-running job to report.
/// A web or review pane simply never calls `on_status` or `on_input`; neither
/// has an equivalent of a long-running job to report.
pub const Callbacks = struct {
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
@@ -89,12 +103,13 @@ pub const Callbacks = struct {
ctx: ?*anyopaque,
};
/// The two things a pane can hold. Both expose the same three operations and
/// report back through the same three callbacks, which is the whole reason a
/// pane can stay ignorant of which one it has.
/// The three things a pane can hold. All of them expose the same three
/// operations and report back through the same callbacks, which is the whole
/// reason a pane can stay ignorant of which one it has.
pub const Content = union(Kind) {
terminal: *Terminal,
web: *Browser,
review: *Review,
pub fn widget(self: Content) *gtk.Widget {
return switch (self) {
@@ -336,6 +351,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
self.content = switch (spec) {
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
.web => |opts| .{ .web = try .create(alloc, opts, callbacks) },
.review => |opts| .{ .review = try .create(alloc, opts, callbacks) },
};
errdefer self.content.destroy();
@@ -385,7 +401,7 @@ pub fn grabFocus(self: *Pane) void {
pub fn terminal(self: *Pane) ?*Terminal {
return switch (self.content) {
.terminal => |t| t,
.web => null,
else => null,
};
}
@@ -395,7 +411,17 @@ pub fn terminal(self: *Pane) ?*Terminal {
pub fn browser(self: *Pane) ?*Browser {
return switch (self.content) {
.web => |b| b,
.terminal => null,
else => null,
};
}
/// The review this pane holds, or null if it holds something else. Used by the
/// view to enforce one review per tab, and by the window to reload the one
/// that is open.
pub fn review(self: *Pane) ?*Review {
return switch (self.content) {
.review => |r| r,
else => null,
};
}
@@ -467,6 +493,13 @@ fn buildHeader(self: *Pane) void {
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
header.append(web.as(gtk.Widget));
const review_button = gtk.Button.newFromIconName(Kind.review.iconName());
review_button.as(gtk.Widget).addCssClass("flat");
review_button.as(gtk.Widget).addCssClass("playpen-pane-button");
review_button.as(gtk.Widget).setTooltipText("Review this tab's changes (Ctrl+Shift+D)");
_ = gtk.Button.signals.clicked.connect(review_button, *Pane, &onReviewClicked, self, .{});
header.append(review_button.as(gtk.Widget));
// Last before close, so the destructive button stays on the end where it
// is expected and the zoom toggle sits with the other view controls.
self.zoom.as(gtk.Widget).addCssClass("flat");
@@ -711,6 +744,10 @@ fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
};
}
fn onReviewClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.requestReview();
}
fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.toggleZoom(self);
}
+33 -4
View File
@@ -92,11 +92,16 @@ pub const Error = error{
/// `dir` is the directory the child starts in. A null, or a directory that
/// cannot be entered, leaves it wherever the app was started — a layout
/// naming a path that no longer exists should still give you a usable shell.
///
/// `env_extra` is added to the child's environment as `NAME=value` strings,
/// replacing any the parent already had under the same name. This is how a
/// shell learns about the tab it is running in.
pub fn create(
alloc: std.mem.Allocator,
path: [:0]const u8,
argv: []const [:0]const u8,
dir: ?[:0]const u8,
env_extra: []const []const u8,
size: Winsize,
) !Pty {
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
@@ -118,7 +123,7 @@ pub fn create(
defer alloc.free(argv_z);
for (argv, 0..) |arg, i| argv_z[i] = arg.ptr;
const envp_z = try buildEnv(alloc);
const envp_z = try buildEnv(alloc, env_extra);
defer freeEnv(alloc, envp_z);
const slave_path_z = try alloc.dupeZ(u8, slave_path);
@@ -198,9 +203,18 @@ pub fn loginShell(buf: []u8) ?[]const u8 {
}
/// Copy the current environment, forcing the variables that describe what
/// kind of terminal we are. We advertise xterm-256color rather than
/// ghostty's own terminfo because we don't install a terminfo entry.
fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
/// kind of terminal we are, and adding whatever the caller supplied. We
/// advertise xterm-256color rather than ghostty's own terminfo because we
/// don't install a terminfo entry.
///
/// Anything we are about to define is dropped from the inherited copy first,
/// so a variable set in playpen's own environment cannot shadow the value this
/// pane is supposed to see — which matters most for the ones that describe the
/// pane itself, since inheriting a stale one is worse than having none.
fn buildEnv(
alloc: std.mem.Allocator,
extra: []const []const u8,
) ![:null]?[*:0]const u8 {
var list: std.ArrayListUnmanaged([*:0]const u8) = .empty;
defer list.deinit(alloc);
errdefer for (list.items) |item| alloc.free(std.mem.span(item));
@@ -211,16 +225,31 @@ fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
// Drop the variables we're about to define ourselves.
if (std.mem.startsWith(u8, span, "TERM=")) continue;
if (std.mem.startsWith(u8, span, "COLORTERM=")) continue;
if (shadowedBy(span, extra)) continue;
try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr);
}
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr);
for (extra) |entry| {
try list.append(alloc, (try alloc.dupeZ(u8, entry)).ptr);
}
const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null);
for (list.items, 0..) |item, idx| result[idx] = item;
return result;
}
/// Whether an inherited `NAME=value` entry names a variable the caller is
/// about to define.
fn shadowedBy(entry: []const u8, extra: []const []const u8) bool {
const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false;
for (extra) |candidate| {
const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue;
if (std.mem.eql(u8, entry[0 .. eq + 1], candidate[0 .. candidate_eq + 1])) return true;
}
return false;
}
fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
alloc.free(envp);
+294
View File
@@ -0,0 +1,294 @@
//! The third thing a pane can hold: the review UI for the tab it lives in.
//!
//! Underneath it is a web view, the same as `Browser` — the UI is a web page,
//! served by the review server in this same process (see `src/review/`). What
//! makes it its own kind of pane rather than a web pane pointed at a URL is
//! everything around that:
//!
//! - **It is bound to the tab.** The URL is the tab's own review endpoint, and
//! it is not navigable. There is no address bar, because there is nowhere
//! else to go: a review pane showing another tab's review would be a way to
//! leave comments on the wrong branch.
//! - **One per tab.** Enforced by `View.addPane`, since two panes on one
//! review would each be publishing a different diff selection to the server
//! and an agent would follow whichever wrote last.
//! - **It can fail to load and say so.** A web pane that cannot reach a host
//! is showing you a page; this one failing means the server did not come up,
//! which is a playpen problem and worth an explanation and a retry button
//! rather than WebKit's network error.
//!
//! The callback shape matches `Terminal`'s and `Browser`'s exactly, so a pane
//! drives any of the three through the same handlers.
const std = @import("std");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Pane = @import("Pane.zig");
const webkit = @import("webkit.zig");
const Review = @This();
/// Everything needed to open one.
pub const Options = struct {
/// The tab's review endpoint — `http://127.0.0.1:<port>/t/<tabId>`.
///
/// Empty means the review server never started, which the pane reports
/// rather than leaving a blank web view.
url: []const u8 = "",
/// The work tree being reviewed, for the pane header. Empty until the tab
/// has resolved one.
repo: []const u8 = "",
};
/// How long to wait before retrying a load that failed.
///
/// A review pane opened in the same gesture that starts the server can lose the
/// race with it, and that is by far the most likely reason for the first load to
/// fail — so one quiet retry turns the common failure into a flicker rather than
/// an error the user has to answer.
const retry_delay_ms = 400;
alloc: std.mem.Allocator,
/// Vertical box: the error bar (hidden in the normal case) above the page.
box: *gtk.Box,
view: *webkit.WebView,
/// Shown only when a load fails, so the normal case is the page and nothing else.
error_bar: *gtk.Box,
error_label: *gtk.Label,
/// The endpoint this pane is bound to, NUL-terminated for WebKit. Owned.
url: [:0]u8,
/// Title for the pane header: the repository's name, or a placeholder.
label: [64:0]u8 = @splat(0),
/// Set once the automatic retry has been spent, so a genuinely unreachable
/// server produces one error rather than a reload loop.
retried: bool = false,
/// The pending retry's GLib source id, so tearing the pane down cancels it
/// instead of letting it fire into freed memory.
retry_source: c_uint = 0,
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub fn create(
alloc: std.mem.Allocator,
opts: Options,
cbs: Pane.Callbacks,
) !*Review {
const self = try alloc.create(Review);
errdefer alloc.destroy(self);
const url = try alloc.dupeZ(u8, opts.url);
errdefer alloc.free(url);
self.* = .{
.alloc = alloc,
.box = gtk.Box.new(.vertical, 0),
.view = .new(),
.error_bar = gtk.Box.new(.horizontal, 8),
.error_label = gtk.Label.new(""),
.url = url,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.on_focus = cbs.on_focus,
.ctx = cbs.ctx,
};
self.setLabel(opts.repo);
self.box.append(self.buildErrorBar());
const view_widget = self.view.as(gtk.Widget);
view_widget.setHexpand(1);
view_widget.setVexpand(1);
self.box.append(view_widget);
// The page sets its own title — the repository and branch — which is better
// than anything this side could compose, so the header follows it.
_ = gobject.Object.signals.notify.connect(
self.view.as(gobject.Object),
*Review,
&onNotifyTitle,
self,
.{ .detail = "title" },
);
self.view.connectSignal("load-failed", *Review, &onLoadFailed, self);
self.view.connectSignal("load-changed", *Review, &onLoadChanged, self);
// Fires for focus landing anywhere inside, so clicking the retry button
// marks the pane active just as clicking the page does.
const focus = gtk.EventControllerFocus.new();
_ = gtk.EventControllerFocus.signals.enter.connect(
focus,
*Review,
&onFocusEnter,
self,
.{},
);
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
if (url.len > 0) {
self.view.loadUri(url);
} else {
self.showError("The review server isn't running — check the log for why.");
}
return self;
}
pub fn destroy(self: *Review) void {
if (self.retry_source != 0) {
_ = glib.Source.remove(self.retry_source);
self.retry_source = 0;
}
// The widget tree outlives this struct by a moment: the pane frees its
// content first and drops the widgets' last reference afterwards. Tearing
// down a page makes WebKit emit signals on the way out, so every handler
// bound to `self` has to go before `self` does — same hazard as `Browser`.
_ = gobject.signalHandlersDisconnectMatched(
self.view.as(gobject.Object),
.{ .data = true },
0,
0,
null,
null,
self,
);
self.alloc.free(self.url);
self.alloc.destroy(self);
}
pub fn widget(self: *Review) *gtk.Widget {
return self.box.as(gtk.Widget);
}
pub fn grabFocus(self: *Review) void {
_ = self.view.as(gtk.Widget).grabFocus();
}
/// Title for the pane header and the tab row.
pub fn title(self: *const Review) []const u8 {
return std.mem.sliceTo(&self.label, 0);
}
/// Reload the page. Bound to the pane's own reload, and to the error bar's
/// button, so a server that came up late can be picked up without reopening.
pub fn reload(self: *Review) void {
self.retried = false;
self.hideError();
if (self.url.len == 0) return;
// `loadUri` rather than `reload`, because a failed load leaves the view with
// no URI to reload — WebKit would do nothing at all.
self.view.loadUri(self.url);
}
fn setLabel(self: *Review, repo: []const u8) void {
const name = if (repo.len == 0)
"review"
else
std.fs.path.basename(repo);
const n = @min(name.len, self.label.len - 1);
@memcpy(self.label[0..n], name[0..n]);
@memset(self.label[n..], 0);
}
// -------------------------------------------------------------------------
// The error bar
fn buildErrorBar(self: *Review) *gtk.Widget {
const bar = self.error_bar.as(gtk.Widget);
bar.addCssClass("playpen-review-error");
bar.setVisible(0);
const icon = gtk.Image.newFromIconName("process-stop-symbolic");
self.error_bar.append(icon.as(gtk.Widget));
self.error_label.setXalign(0);
self.error_label.setWrap(1);
self.error_label.as(gtk.Widget).setHexpand(1);
self.error_bar.append(self.error_label.as(gtk.Widget));
const retry = gtk.Button.newWithLabel("Reload");
retry.as(gtk.Widget).addCssClass("playpen-review-retry");
_ = gtk.Button.signals.clicked.connect(retry, *Review, &onRetryClicked, self, .{});
self.error_bar.append(retry.as(gtk.Widget));
return bar;
}
fn showError(self: *Review, message: [:0]const u8) void {
self.error_label.setText(message);
self.error_bar.as(gtk.Widget).setVisible(1);
}
fn hideError(self: *Review) void {
self.error_bar.as(gtk.Widget).setVisible(0);
}
// -------------------------------------------------------------------------
// Callbacks
fn onNotifyTitle(_: *gobject.Object, _: *gobject.ParamSpec, self: *Review) callconv(.c) void {
const raw = self.view.getTitle() orelse return;
const span = std.mem.span(raw);
if (span.len == 0) return;
const n = @min(span.len, self.label.len - 1);
@memcpy(self.label[0..n], span[0..n]);
@memset(self.label[n..], 0);
self.on_title(self.ctx, self.title());
}
/// WebKit's `load-changed`. Only the `finished` phase matters here: a load that
/// got through is what clears an error left over from the attempt before it.
fn onLoadChanged(_: *webkit.WebView, event: c_uint, self: *Review) callconv(.c) void {
const load_finished = 3; // WEBKIT_LOAD_FINISHED
if (event == load_finished) self.hideError();
}
/// WebKit's `load-failed`. Returning true says the failure is handled, which
/// suppresses the browser error page — this pane has its own bar for it, and a
/// "server not found" page inside a tab is more confusing than helpful.
fn onLoadFailed(
_: *webkit.WebView,
_: c_uint,
_: [*:0]const u8,
_: ?*anyopaque,
self: *Review,
) callconv(.c) c_int {
if (!self.retried) {
self.retried = true;
self.retry_source = glib.timeoutAddOnce(retry_delay_ms, &onRetryTimeout, self);
return 1;
}
self.showError("Couldn't reach the review server in this playpen.");
return 1;
}
fn onRetryTimeout(data: ?*anyopaque) callconv(.c) void {
const self: *Review = @ptrCast(@alignCast(data.?));
self.retry_source = 0;
if (self.url.len > 0) self.view.loadUri(self.url);
}
fn onRetryClicked(_: *gtk.Button, self: *Review) callconv(.c) void {
self.reload();
}
fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Review) callconv(.c) void {
self.on_focus(self.ctx);
}
+12 -1
View File
@@ -36,8 +36,12 @@ const ParamRow = struct {
const PaneRow = struct {
node: *Layouts.Node,
/// Directory and script, for a terminal pane.
/// Directory, for a terminal pane or a review pane. The two mean slightly
/// different things by it — where the shell starts, versus which repository
/// is reviewed — but they are the same field, written back the same way.
cwd: ?*gtk.Entry = null,
/// Script to run, for a terminal pane.
command: ?*gtk.Entry = null,
/// Page to open, for a web pane.
@@ -245,6 +249,13 @@ fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node)
const url = field(grid, 0, "Address", spec.url, "https://example.com");
self.panes[index].url = url;
},
// A review is a repository, so the directory is the whole of it. It is
// the same field a terminal's is, down to the `$(...)` expansion, and
// the same one a review captured off a live tab is prefilled with.
.review => {
const cwd = field(grid, 0, "Directory", spec.cwd, "~/projects/{{path}}");
self.panes[index].cwd = cwd;
},
}
box.append(grid.as(gtk.Widget));
+24 -1
View File
@@ -106,6 +106,16 @@ pub const Options = struct {
/// Script fed to the shell once it is up.
command: []const u8 = "",
/// The review endpoint of the tab this pane lives in, exported to the shell
/// as `PLAYPEN_REVIEW_URL`.
///
/// It is the whole reason the review server has a stable address: an agent
/// running in this pane reads it out of its own environment and can then
/// fetch the comments the person next to it left, with no discovery step and
/// no chance of picking up another tab's review. Empty leaves the variable
/// unset, which is what an agent sees when the server never started.
review_url: []const u8 = "",
};
pub fn create(
@@ -179,7 +189,20 @@ pub fn create(
null;
defer if (cwd_z) |z| alloc.free(z);
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{
// One entry, and only when there is a review to point at: an empty
// `PLAYPEN_REVIEW_URL` would read as "there is a review server, and it is at
// the empty string", which is worse than the variable being absent.
var env_buf: [1][]const u8 = undefined;
var env_extra: []const []const u8 = &.{};
var review_env: []u8 = &.{};
defer alloc.free(review_env);
if (opts.review_url.len > 0) {
review_env = try std.fmt.allocPrint(alloc, "PLAYPEN_REVIEW_URL={s}", .{opts.review_url});
env_buf[0] = review_env;
env_extra = &env_buf;
}
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{
.ws_row = rows,
.ws_col = cols,
});
+88 -3
View File
@@ -1,5 +1,5 @@
//! A view: the content of one tab, holding one or more panes — terminals, web
//! views, or a mix — arranged in a split tree.
//! views, the tab's code review, or a mix — arranged in a split tree.
//!
//! Dragging a pane rearranges the view live rather than on release. Each time
//! the drop target changes, the move is applied for real, so what you see
@@ -15,6 +15,7 @@ const Browser = @import("Browser.zig");
const Layout = @import("Layout.zig");
const Layouts = @import("Layouts.zig");
const Pane = @import("Pane.zig");
const Review = @import("Review.zig");
const Terminal = @import("Terminal.zig");
const View = @This();
@@ -75,6 +76,18 @@ zoomed: ?*Pane = null,
drag: ?Drag = null,
/// What a review pane in this view should be opened with.
///
/// Set by the window when the tab is created, because the endpoint is a property
/// of the tab and not of any pane. Held here so `applyLayout` can build a review
/// pane out of a saved layout without the layout having to carry a URL that
/// would be wrong the moment the tab changed.
///
/// It is also what every *terminal* in this view is handed as
/// `PLAYPEN_REVIEW_URL` — see `stamp` — so an agent started in any pane of the
/// tab can reach the tab's review without being told where it is.
review_spec: Review.Options = .{},
/// Set while the view is being torn down, so a pane's child exiting doesn't
/// try to remove it from a list we're already draining.
closing: bool = false,
@@ -89,6 +102,13 @@ on_status: *const fn (ctx: ?*anyopaque) void,
/// one that finished before your last visit looks identical to one that
/// finished after it.
on_finished: *const fn (ctx: ?*anyopaque) void,
/// Something in here asked for a review pane.
///
/// It goes up to the window rather than being handled here because opening a
/// review means resolving the directory the tab is working in and registering it
/// with the server, and neither of those is a property of the split tree.
on_review: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub const Callbacks = struct {
@@ -96,6 +116,7 @@ pub const Callbacks = struct {
on_title: *const fn (ctx: ?*anyopaque) void,
on_status: *const fn (ctx: ?*anyopaque) void,
on_finished: *const fn (ctx: ?*anyopaque) void,
on_review: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
@@ -111,6 +132,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
.on_title = cbs.on_title,
.on_status = cbs.on_status,
.on_finished = cbs.on_finished,
.on_review = cbs.on_review,
.ctx = cbs.ctx,
};
@@ -243,9 +265,31 @@ pub fn focus(self: *View) void {
if (self.focusedPane()) |pane| pane.grabFocus();
}
/// The review pane in this view, if it has one.
///
/// There is at most one by construction — see `addPane` — which is what lets
/// callers treat this as "the review" rather than "a review".
pub fn reviewPane(self: *View) ?*Pane {
for (self.panes.items) |pane| {
if (pane.kind == .review) return pane;
}
return null;
}
/// Add a pane, splitting the focused one so the new pane appears beside
/// whatever you were working in.
pub fn addPane(self: *View, spec: Pane.Spec) !void {
///
/// A second review pane is refused rather than opened. Two of them would each
/// publish their own diff selection to the server, and an agent asked to review
/// "the diff I'm looking at" would follow whichever wrote last — so the second
/// one would quietly break the first. `error.ReviewAlreadyOpen` is what the
/// window turns into "go to the one you have".
pub fn addPane(self: *View, raw: Pane.Spec) !void {
if (raw.kind() == .review and self.reviewPane() != null) {
return error.ReviewAlreadyOpen;
}
const spec = self.stamp(raw);
const pane = try Pane.create(self.alloc, self, spec);
errdefer pane.destroy();
@@ -272,6 +316,31 @@ pub fn addPane(self: *View, spec: Pane.Spec) !void {
self.on_title(self.ctx);
}
/// Fill in the parts of a spec that come from the tab rather than the caller.
///
/// Both arms are the tab's review endpoint: a review pane *is* that endpoint,
/// and a terminal is handed it so anything started in the pane can find it. Done
/// here, in the one place every pane is built, rather than at each of the four
/// call sites that construct a spec.
fn stamp(self: *View, spec: Pane.Spec) Pane.Spec {
if (self.review_spec.url.len == 0) return spec;
return switch (spec) {
.terminal => |opts| .{ .terminal = blk: {
var stamped = opts;
stamped.review_url = self.review_spec.url;
break :blk stamped;
} },
.review => .{ .review = self.review_spec },
.web => spec,
};
}
/// Ask the window to open a review pane in this tab. The pane header's button
/// and the keyboard shortcut both land here.
pub fn requestReview(self: *View) void {
self.on_review(self.ctx);
}
pub fn closePane(self: *View, pane: *Pane) void {
if (self.closing) return;
@@ -443,7 +512,7 @@ fn buildNode(
const pane_spec = try self.paneSpec(p, bindings);
defer freePaneSpec(self.alloc, pane_spec);
const pane = try Pane.create(self.alloc, self, pane_spec);
const pane = try Pane.create(self.alloc, self, self.stamp(pane_spec));
errdefer pane.destroy();
const node = try self.layout.newLeaf(pane);
@@ -485,6 +554,11 @@ fn paneSpec(
.web => .{ .web = .{
.url = try Layouts.expand(self.alloc, p.url, bindings),
} },
// The layout's own `cwd` is not read here. A review pane is handed an
// endpoint, not a directory: which repository that endpoint serves is
// settled by the window before the view is built, precisely so the
// page's first load already finds one. See `Window.bindLayoutReview`.
.review => .{ .review = self.review_spec },
};
}
@@ -495,6 +569,9 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
alloc.free(o.command);
},
.web => |o| alloc.free(o.url),
// Borrowed from the window, which owns the strings for as long as the
// tab does.
.review => {},
}
}
@@ -523,6 +600,14 @@ fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layo
.kind = .web,
.url = b.currentUrl(),
}),
// The repository this review is bound to, so a captured tab
// reopens on the same one. Prefilled rather than fixed: the dialog
// is where a literal path becomes `{{a parameter}}`, exactly as it
// is for a terminal's directory.
.review => try builder.pane(.{
.kind = .review,
.cwd = self.review_spec.repo,
}),
},
.split => |s| try builder.split(
switch (s.paned.as(gtk.Orientable).getOrientation()) {
+217
View File
@@ -27,6 +27,7 @@ const View = @import("View.zig");
const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig");
const key = @import("key.zig");
const review = @import("review.zig");
const shortcuts = @import("shortcuts.zig");
const Window = @This();
@@ -186,6 +187,15 @@ const Tab = struct {
/// The layout this tab was opened from, if any. Null for a plain shell.
source: ?Source = null,
/// This tab's review endpoint — `http://127.0.0.1:<port>/t/<name>` — owned
/// by the window's allocator, or empty when the review server never started.
///
/// Every tab has one from the moment it exists, whether or not it has a
/// review pane, because it is what its terminals are handed as
/// `PLAYPEN_REVIEW_URL`. An agent started in a tab should not have to be
/// restarted because a review pane opened after it did.
review_url: []u8 = &.{},
/// The row's right-click menu, parented to this tab's row.
menu_popover: *gtk.Popover,
@@ -458,6 +468,7 @@ fn newTabEmpty(self: *Window) !*Tab {
.on_title = &onViewTitle,
.on_status = &onViewStatus,
.on_finished = &onViewFinished,
.on_review = &onViewReview,
.ctx = tab,
});
errdefer view.destroy();
@@ -524,9 +535,38 @@ fn newTabEmpty(self: *Window) !*Tab {
try self.tabs.append(self.alloc, tab);
// The tab is announced to the review server as soon as it exists, so its
// endpoint is real before the first shell in it starts. Which repository the
// endpoint reviews is decided later: from the directory the tab is working
// in when a review pane is opened by hand (`openReview`), or from the
// directory a layout named (`bindLayoutReview`).
self.bindReview(tab);
return tab;
}
/// Give a tab its review endpoint and tell the server the tab exists.
///
/// Best-effort throughout: a tab with no endpoint is a tab whose terminals get
/// no `PLAYPEN_REVIEW_URL` and whose review pane explains itself, which is a
/// smaller problem than refusing to open the tab.
fn bindReview(self: *Window, tab: *Tab) void {
const server = review.get() orelse return;
server.registerTab(tab.pageName()) catch |err| {
std.log.warn("review: could not register {s}: {s}", .{
tab.pageName(),
@errorName(err),
});
return;
};
var buf: [256]u8 = undefined;
const url = server.tabUrl(&buf, tab.pageName()) catch return;
tab.review_url = self.alloc.dupe(u8, url) catch return;
tab.view.review_spec = .{ .url = tab.review_url };
}
// -------------------------------------------------------------------------
// The row menu
//
@@ -887,6 +927,10 @@ fn buildLayoutTab(
) !*Tab {
const tab = try self.newTabEmpty();
// Before the panes, not after: a review pane starts loading its page as it
// is built, so the repository has to be attached first.
if (layoutReviewDir(layout.root)) |dir| self.bindLayoutReview(tab, dir, bindings);
tab.view.applyLayout(layout.root, bindings) catch |err| {
// A half-built view has no panes to work in and no shell to close, so
// drop the tab rather than leave an empty one behind. Discarded rather
@@ -1452,6 +1496,13 @@ fn discardTab(self: *Window, tab: *Tab) void {
/// by whoever took the tab out of the window, which on the teardown path is not
/// the same code.
fn releaseTab(self: *Window, tab: *Tab) void {
// Before the URL is freed: the server is holding this tab's id, and its
// store, until told the tab has gone. The comments themselves are on disk
// and stay there, so a tab reopened on the same repository picks the review
// back up where it left off.
if (review.get()) |server| server.unregisterTab(tab.pageName());
if (tab.review_url.len > 0) self.alloc.free(tab.review_url);
if (tab.custom_name) |name| self.alloc.free(name);
self.freeSource(tab);
self.alloc.destroy(tab);
@@ -1737,6 +1788,12 @@ fn onViewFinished(ctx: ?*anyopaque) void {
}
/// The view lost its last pane, so the tab goes with it.
/// A pane in this tab asked for the tab's review.
fn onViewReview(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.window.openReview(tab);
}
fn onViewEmpty(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.window.closeTab(tab);
@@ -1811,6 +1868,162 @@ fn addPane(self: *Window, kind: View.Kind) void {
};
}
// -------------------------------------------------------------------------
// The review pane
//
// A tab has at most one, and it is bound to a repository: the one the tab is
// working in, or the one its layout named. Both halves of that are decided here
// rather than in the pane or the view: the pane is a web view, the view is a
// split tree, and "which repository is this tab about" is a question only the
// window — which can see the tab's terminals and what it was opened from — is in
// a position to answer.
/// Open the visible tab's review, or go to the one it already has.
///
/// The repository is resolved once, here, from the directory the tab is working
/// in, and then stays put for as long as the review is open. Re-resolving on
/// every fetch was the alternative, and it means a `cd` in a terminal can swap
/// the diff out from under someone mid-read; a review you have to reopen is the
/// better failure.
fn openReview(self: *Window, tab: *Tab) void {
// Already open: take them to it rather than reporting a refusal. Asking for
// the review twice is a reasonable way to say "where is my review".
if (tab.view.reviewPane()) |pane| {
self.select(tab);
tab.view.setFocused(pane);
pane.grabFocus();
return;
}
const server = review.get() orelse {
// The pane still opens, and says this. Better than a shortcut that looks
// broken because nothing happened.
self.addReviewPane(tab);
return;
};
var buf: [std.fs.max_path_bytes]u8 = undefined;
const dir = self.tabDirectory(tab, &buf);
server.openReview(tab.pageName(), dir) catch |err| {
// Most often `dir` is simply not inside a repository, which is not a
// failure of playpen's and not worth a dialog: the pane's own empty
// state explains it, and the log line is here for the rest.
std.log.info("review: no repository for {s} at {s}: {s}", .{
tab.pageName(),
dir,
@errorName(err),
});
self.addReviewPane(tab);
return;
};
// The pane header shows the repository's name, which is only knowable once
// the server has resolved the work-tree root.
tab.view.review_spec.repo = server.repoPath(tab.pageName()) orelse dir;
self.addReviewPane(tab);
}
/// The directory a layout points its review at, or null if it has no review
/// pane or leaves the directory to the tab.
///
/// The first review leaf decides it. A layout holding two is refused as the
/// second pane is built — one review per tab — so there is never a second
/// directory to disagree with this one.
fn layoutReviewDir(node: *const Layouts.Node) ?[]const u8 {
switch (node.*) {
.pane => |p| {
if (p.kind != .review or p.cwd.len == 0) return null;
return p.cwd;
},
.split => |s| return layoutReviewDir(s.first) orelse layoutReviewDir(s.second),
}
}
/// Bind a tab's review to the directory its layout named, while the view is
/// still empty.
///
/// This is `openReview` without the pane: the layout has already said the tab
/// has a review in it, and all that is missing is which repository. Resolving it
/// here rather than after the panes are built is what makes the result
/// deterministic — the review pane's page is fetched from the server on another
/// thread the moment the pane exists, and a repository attached afterwards would
/// sometimes arrive first and sometimes second.
///
/// The directory goes through the same expansion a terminal's does, so a layout
/// can review `{{a parameter}}` or `$(whatever a script prints)`.
fn bindLayoutReview(
self: *Window,
tab: *Tab,
template: []const u8,
bindings: []const Layouts.Binding,
) void {
const server = review.get() orelse return;
const dir = Layouts.expandPath(self.alloc, template, bindings) catch |err| {
std.log.warn("review: could not resolve \"{s}\": {s}", .{ template, @errorName(err) });
return;
};
defer self.alloc.free(dir);
server.openReview(tab.pageName(), dir) catch |err| {
// Same as opening a review by hand: a directory that isn't in a
// repository is the pane's own empty state to explain, not a reason to
// refuse the rest of the tab.
std.log.info("review: no repository for {s} at {s}: {s}", .{
tab.pageName(),
dir,
@errorName(err),
});
return;
};
// Borrowed from the server, which keeps it for as long as the tab's review
// lives — longer than any pane in the tab.
if (server.repoPath(tab.pageName())) |repo| tab.view.review_spec.repo = repo;
}
fn addReviewPane(self: *Window, tab: *Tab) void {
tab.view.addPane(.plain(.review)) catch |err| {
std.log.err("failed to open the review pane: {s}", .{@errorName(err)});
return;
};
self.select(tab);
}
/// The directory a tab is working in, copied into `buf`.
///
/// Read from a terminal's own process rather than from anything recorded when
/// the tab opened, because the directory that matters is the one you are working
/// in now: a tab opened in a monorepo root and `cd`-ed into a worktree is a tab
/// about that worktree. The focused pane is asked first, so a split holding two
/// repositories reviews the one you are looking at.
///
/// Falls back to playpen's own working directory, which at least gives the
/// server something to fail on that the user can recognise in the message.
fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
_ = self;
if (tab.view.focusedPane()) |focused| {
if (focused.terminal()) |terminal| {
if (terminal.session.pty.cwd(buf)) |dir| return dir;
}
}
for (tab.view.panes.items) |pane| {
const terminal = pane.terminal() orelse continue;
if (terminal.session.pty.cwd(buf)) |dir| return dir;
}
// Playpen's own directory, which at least gives the server something to
// fail on that the user can recognise in the message. `std.c` rather than
// `std.posix`, matching `Pty.zig`: the latter has been churning across Zig
// releases and this is one call.
if (std.c.getcwd(buf.ptr, buf.len) != null) {
return std.mem.sliceTo(buf, 0);
}
return ".";
}
fn selectIndex(self: *Window, index: usize) void {
if (index >= self.tabs.items.len) return;
self.select(self.tabs.items[index]);
@@ -1887,6 +2100,10 @@ fn perform(self: *Window, action: shortcuts.Action) bool {
.new_terminal => self.addPane(.terminal),
.new_web => self.addPane(.web),
// Not `addPane`: opening a review is more than adding a pane, and asking
// for one you already have takes you to it instead of refusing.
.new_review => if (self.activeTab()) |tab| self.openReview(tab),
.rename_tab => if (self.activeTab()) |tab| self.beginRename(tab),
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
.toggle_sidebar => self.toggleSidebar(),
+12
View File
@@ -13,6 +13,7 @@ const Settings = @import("Settings.zig");
const Window = @import("Window.zig");
const appearance = @import("appearance.zig");
const icons = @import("icons.zig");
const review = @import("review.zig");
/// libghostty-vt logs unimplemented sequences at debug level, which is very
/// chatty against a real shell. Keep the app's own warnings and errors.
@@ -29,6 +30,11 @@ pub fn main() u8 {
// of it. A no-op if the app never got as far as activating.
defer Settings.deinit();
// Also before the allocator, and after the window: the review server owns
// threads, and stopping it waits for the ones still inside a request rather
// than freeing the memory they are reading. A no-op if it never started.
defer review.deinit();
// Non-unique so every launch is its own process. The default GApplication
// behavior hands off to an already-running instance over D-Bus, which for
// a terminal means a second launch silently does nothing visible here and
@@ -55,6 +61,12 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
// for one by name.
icons.init();
// Before the window, because every tab is announced to the server as it is
// created and every terminal is handed the endpoint of the tab it opens in.
// Started unconditionally rather than on the first review pane, so an agent
// running in a tab has a `PLAYPEN_REVIEW_URL` from the moment it starts.
review.init(gpa.allocator());
const window = Window.create(gpa.allocator(), app) catch |err| {
std.log.err("failed to create window: {s}", .{@errorName(err)});
return;
+91
View File
@@ -0,0 +1,91 @@
//! The review server, as the rest of the app sees it: one process-wide instance,
//! started at activation and stopped on the way out.
//!
//! A singleton for the same reason `Settings` is one — there is exactly one of
//! it, everything wants at it, and threading a pointer down through the window,
//! the tab, the view and the pane would be four parameters carried for one
//! consumer. What lives here is only the lifecycle; the server itself is
//! `review/Server.zig`, and nothing in this file or under it touches a widget.
//!
//! This is also the one place in playpen with threads in it. `std.Io.Threaded`
//! is created here and handed to the server, which runs its listener and each
//! connection on a thread of its own. That is not gold-plating: a request spends
//! most of its life inside `git diff`, and doing that on the GTK main loop would
//! freeze the window for the length of every fetch.
const std = @import("std");
pub const Server = @import("review/Server.zig");
/// The port the server tries first, and the range it walks if that is taken.
///
/// A predictable number matters more here than it looks. Every terminal pane is
/// handed a `PLAYPEN_REVIEW_URL` so an agent never has to guess — but a person
/// poking at the API with `curl`, or running the UI's dev server against a live
/// review, is much better off with a number they can remember than with whatever
/// the kernel handed out. The walk is for a second playpen window: the first one
/// keeps 8420, the second quietly takes 8421.
const preferred_port: u16 = 8420;
const port_range: u16 = 20;
/// Override for the port to try first, for developing the UI against a specific
/// window. Read once at startup.
const port_env = "PLAYPEN_REVIEW_PORT";
var threaded: std.Io.Threaded = undefined;
var server: ?*Server = null;
/// Start the server. Called once at activation, before the first window.
///
/// Failure is logged and swallowed. A playpen with no review server is a
/// playpen with no review panes, which is a smaller loss than refusing to open a
/// window at all — and a review pane opened in that state says so rather than
/// showing a blank page.
pub fn init(gpa: std.mem.Allocator) void {
threaded = .init(gpa, .{});
const instance = Server.create(gpa, threaded.io()) catch |err| {
std.log.warn("review: could not create the server: {s}", .{@errorName(err)});
threaded.deinit();
return;
};
const first = firstPort();
var port = first;
while (port < first + port_range) : (port += 1) {
instance.start(port) catch continue;
server = instance;
return;
}
// Every port in the range was taken — more playpen windows than the range
// allows, or something else living there. An ephemeral port still gives a
// working review; it is only less guessable.
instance.start(0) catch |err| {
std.log.warn("review: could not listen: {s}", .{@errorName(err)});
instance.destroy();
threaded.deinit();
return;
};
server = instance;
}
pub fn deinit() void {
const instance = server orelse return;
server = null;
instance.destroy();
threaded.deinit();
}
/// The running server, or null if it never started.
pub fn get() ?*Server {
return server;
}
fn firstPort() u16 {
const raw = std.mem.span(std.c.getenv(port_env) orelse return preferred_port);
return std.fmt.parseInt(u16, std.mem.trim(u8, raw, " \t"), 10) catch {
std.log.warn("review: ignoring {s}={s}, which is not a port", .{ port_env, raw });
return preferred_port;
};
}
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
//! The comment store for one review: every thread, persisted as JSON.
//!
//! The file lives inside the repository's git directory, so it never shows up
//! in the diff being reviewed and is naturally per-worktree. It is written
//! whole on every mutation, through an unnamed temporary that is renamed into
//! place — a half-written review file is a lost review, and this is a few
//! kilobytes, so there is nothing to gain by being cleverer.
//!
//! **Everything here is called from the server's connection threads**, which is
//! why the mutex is on the store rather than around its call sites: a reply
//! arriving over HTTP and the UI reloading the list are genuinely concurrent.
//!
//! Each comment owns an arena. Freeing a thread is then dropping one allocator
//! rather than walking a struct-shaped graph of strings, and editing a body can
//! leave the old one behind without leaking anything that outlives the comment.
const std = @import("std");
const model = @import("model.zig");
const Store = @This();
const Comment = model.Comment;
const Reply = model.Reply;
/// One thread and the arena its strings live in.
const Entry = struct {
arena: std.heap.ArenaAllocator,
comment: Comment,
replies: std.ArrayListUnmanaged(Reply) = .empty,
fn deinit(self: *Entry, gpa: std.mem.Allocator) void {
self.arena.deinit();
gpa.destroy(self);
}
};
pub const Error = error{
NotFound,
OutOfMemory,
/// The review could not be written to disk. The in-memory change is rolled
/// back before this is returned, so a failed save never leaves the store
/// claiming something the file does not say.
SaveFailed,
};
gpa: std.mem.Allocator,
io: std.Io,
mutex: std.Io.Mutex = .init,
/// Absolute path to the JSON file. Owned.
path: []u8,
/// Threads in creation order, which is the order everything is served in.
entries: std.ArrayListUnmanaged(*Entry) = .empty,
/// Cap on the review file. A review is comments a person typed; anything past
/// this is a corrupt or hand-edited file, and refusing it is better than
/// spending the memory to find out.
const max_file_bytes = 32 * 1024 * 1024;
/// Open the store backing `path`, loading whatever is already there.
///
/// A missing file is the normal first-run case and loads as an empty review. A
/// file that exists but cannot be parsed is *not* silently discarded: it is
/// reported, so the caller can refuse to open the review rather than overwrite
/// someone's comments on the next save.
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !*Store {
const self = try gpa.create(Store);
errdefer gpa.destroy(self);
self.* = .{
.gpa = gpa,
.io = io,
.path = try gpa.dupe(u8, path),
};
errdefer gpa.free(self.path);
try self.load();
return self;
}
pub fn close(self: *Store) void {
for (self.entries.items) |entry| entry.deinit(self.gpa);
self.entries.deinit(self.gpa);
self.gpa.free(self.path);
self.gpa.destroy(self);
}
// -------------------------------------------------------------------------
// Persistence
/// The on-disk shape. Deliberately the same object review-tool wrote, so a
/// repository that was reviewed there opens here with its comments intact.
const FileData = struct {
comments: []const Comment = &.{},
};
fn load(self: *Store) !void {
const bytes = std.Io.Dir.cwd().readFileAlloc(
self.io,
self.path,
self.gpa,
.limited(max_file_bytes),
) catch |err| switch (err) {
error.FileNotFound => return,
else => return err,
};
defer self.gpa.free(bytes);
if (std.mem.trim(u8, bytes, " \t\r\n").len == 0) return;
const parsed = try std.json.parseFromSlice(
FileData,
self.gpa,
bytes,
.{ .ignore_unknown_fields = true },
);
defer parsed.deinit();
for (parsed.value.comments) |c| {
const entry = try self.adopt(c);
errdefer entry.deinit(self.gpa);
try self.entries.append(self.gpa, entry);
}
// Creation order is the order everything is served in, and a file written
// by an older version — or edited by hand — need not already be in it.
std.mem.sort(*Entry, self.entries.items, {}, lessByCreated);
}
fn lessByCreated(_: void, a: *Entry, b: *Entry) bool {
// RFC 3339 in UTC sorts lexicographically, which is most of why the
// timestamps are stored as text.
return std.mem.order(u8, a.comment.createdAt, b.comment.createdAt) == .lt;
}
/// Copy a parsed comment into an entry that owns every string in it.
fn adopt(self: *Store, c: Comment) !*Entry {
const entry = try self.gpa.create(Entry);
errdefer self.gpa.destroy(entry);
entry.* = .{ .arena = .init(self.gpa), .comment = undefined };
errdefer entry.arena.deinit();
const a = entry.arena.allocator();
entry.comment = .{
.id = try a.dupe(u8, c.id),
.level = c.level,
.file = try a.dupe(u8, c.file),
.side = try a.dupe(u8, c.side),
.line = c.line,
.endLine = c.endLine,
.body = try a.dupe(u8, c.body),
.author = c.author,
.status = c.status,
.replies = &.{},
.context = .{
.base = try a.dupe(u8, c.context.base),
.uncommitted = c.context.uncommitted,
.commit = try a.dupe(u8, c.context.commit),
},
.createdAt = try a.dupe(u8, c.createdAt),
.updatedAt = try a.dupe(u8, c.updatedAt),
};
for (c.replies) |r| {
try entry.replies.append(a, .{
.id = try a.dupe(u8, r.id),
.author = r.author,
.body = try a.dupe(u8, r.body),
.createdAt = try a.dupe(u8, r.createdAt),
});
}
entry.comment.replies = entry.replies.items;
return entry;
}
/// Write the whole review out. Callers must hold the mutex.
fn save(self: *Store) Error!void {
var flat: std.ArrayListUnmanaged(Comment) = .empty;
defer flat.deinit(self.gpa);
flat.ensureTotalCapacity(self.gpa, self.entries.items.len) catch return error.OutOfMemory;
for (self.entries.items) |entry| flat.appendAssumeCapacity(entry.comment);
const json = std.json.Stringify.valueAlloc(
self.gpa,
FileData{ .comments = flat.items },
.{ .whitespace = .indent_2 },
) catch return error.OutOfMemory;
defer self.gpa.free(json);
var atomic = std.Io.Dir.cwd().createFileAtomic(self.io, self.path, .{
.make_path = true,
.replace = true,
}) catch return error.SaveFailed;
defer atomic.deinit(self.io);
var buf: [4096]u8 = undefined;
var writer = atomic.file.writer(self.io, &buf);
writer.interface.writeAll(json) catch return error.SaveFailed;
writer.interface.flush() catch return error.SaveFailed;
atomic.replace(self.io) catch return error.SaveFailed;
}
// -------------------------------------------------------------------------
// Reading
//
// Every read returns a snapshot allocated with the caller's allocator rather
// than lending out the store's own strings: the caller is a connection thread
// about to serialize and write to a socket, and holding the store's lock for
// the length of a socket write would let a stalled client block every other
// request.
/// Every thread in the review, oldest first.
///
/// Deliberately not filtered by diff context. A comment is content someone
/// typed: it has to survive the base ref moving, the working tree being
/// committed, or the page being reloaded onto a different selection. Whether a
/// comment still lines up with the diff on screen is the frontend's judgement —
/// it has the parsed diff, and it marks the ones it cannot place as outdated.
pub fn list(self: *Store, gpa: std.mem.Allocator) ![]Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
return self.snapshot(gpa, null);
}
/// The submitted, unresolved threads — the actionable queue an agent works.
pub fn pending(self: *Store, gpa: std.mem.Allocator) ![]Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
return self.snapshot(gpa, .submitted);
}
fn snapshot(self: *Store, gpa: std.mem.Allocator, only: ?model.Status) ![]Comment {
var out: std.ArrayListUnmanaged(Comment) = .empty;
errdefer out.deinit(gpa);
for (self.entries.items) |entry| {
if (only) |status| if (entry.comment.status != status) continue;
try out.append(gpa, entry.comment);
}
return out.toOwnedSlice(gpa);
}
/// How many threads are in a status. Both counts the UI badges with.
pub fn countByStatus(self: *Store, status: model.Status) u32 {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
var n: u32 = 0;
for (self.entries.items) |entry| {
if (entry.comment.status == status) n += 1;
}
return n;
}
// -------------------------------------------------------------------------
// Writing
/// What a caller supplies to open a thread. Identity, status and timestamps are
/// assigned here.
pub const New = struct {
level: model.Level = .line,
file: []const u8 = "",
side: []const u8 = "",
line: u32 = 0,
endLine: u32 = 0,
body: []const u8,
author: model.Author = .user,
context: model.DiffContext = .{},
};
/// Open a thread.
///
/// The status follows from the author, and that is the whole rule. A person
/// composes drafts and decides when to submit them, so their comment starts as
/// a draft. An agent has no drafting step — it posts a review it has already
/// decided on — so its comments are born submitted: open threads, with no
/// "Submit review" click standing between them and being read.
pub fn add(self: *Store, in: New) Error!Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.gpa.create(Entry) catch return error.OutOfMemory;
entry.* = .{ .arena = .init(self.gpa), .comment = undefined };
errdefer entry.deinit(self.gpa);
const a = entry.arena.allocator();
const now = try self.stampAlloc(a);
var id_buf: [16]u8 = undefined;
entry.comment = .{
.id = a.dupe(u8, self.newId(&id_buf)) catch return error.OutOfMemory,
.level = in.level,
.file = a.dupe(u8, in.file) catch return error.OutOfMemory,
.side = a.dupe(u8, in.side) catch return error.OutOfMemory,
.line = in.line,
.endLine = if (in.level == .line and in.endLine < in.line) in.line else in.endLine,
.body = a.dupe(u8, in.body) catch return error.OutOfMemory,
.author = in.author,
.status = if (in.author == .claude) .submitted else .draft,
.replies = &.{},
.context = .{
.base = a.dupe(u8, in.context.base) catch return error.OutOfMemory,
.uncommitted = in.context.uncommitted,
.commit = a.dupe(u8, in.context.commit) catch return error.OutOfMemory,
},
.createdAt = now,
.updatedAt = now,
};
self.entries.append(self.gpa, entry) catch return error.OutOfMemory;
errdefer _ = self.entries.pop();
try self.save();
return entry.comment;
}
pub fn updateBody(self: *Store, id: []const u8, body: []const u8) Error!Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.find(id) orelse return error.NotFound;
const a = entry.arena.allocator();
const previous = entry.comment.body;
entry.comment.body = a.dupe(u8, body) catch return error.OutOfMemory;
errdefer entry.comment.body = previous;
try self.touch(entry);
return entry.comment;
}
pub fn delete(self: *Store, id: []const u8) Error!void {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const index = self.indexOf(id) orelse return error.NotFound;
const entry = self.entries.orderedRemove(index);
self.save() catch |err| {
// Put it back rather than leave the store disagreeing with the file.
self.entries.insert(self.gpa, index, entry) catch entry.deinit(self.gpa);
return err;
};
entry.deinit(self.gpa);
}
pub fn addReply(
self: *Store,
id: []const u8,
author: model.Author,
body: []const u8,
) Error!Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.find(id) orelse return error.NotFound;
const a = entry.arena.allocator();
var id_buf: [16]u8 = undefined;
entry.replies.append(a, .{
.id = a.dupe(u8, self.newId(&id_buf)) catch return error.OutOfMemory,
.author = author,
.body = a.dupe(u8, body) catch return error.OutOfMemory,
.createdAt = try self.stampAlloc(a),
}) catch return error.OutOfMemory;
errdefer _ = entry.replies.pop();
entry.comment.replies = entry.replies.items;
try self.touch(entry);
return entry.comment;
}
/// Edit one reply. Reply ids are only unique inside their thread, so both are
/// required.
pub fn updateReplyBody(
self: *Store,
id: []const u8,
reply_id: []const u8,
body: []const u8,
) Error!Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.find(id) orelse return error.NotFound;
const a = entry.arena.allocator();
for (entry.replies.items) |*reply| {
if (!std.mem.eql(u8, reply.id, reply_id)) continue;
const previous = reply.body;
reply.body = a.dupe(u8, body) catch return error.OutOfMemory;
errdefer reply.body = previous;
entry.comment.replies = entry.replies.items;
try self.touch(entry);
return entry.comment;
}
return error.NotFound;
}
pub fn setStatus(self: *Store, id: []const u8, status: model.Status) Error!Comment {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.find(id) orelse return error.NotFound;
const previous = entry.comment.status;
entry.comment.status = status;
errdefer entry.comment.status = previous;
try self.touch(entry);
return entry.comment;
}
/// Flip every draft to submitted, and report how many moved.
///
/// Review-wide rather than per diff context, to match `list`: a draft visible in
/// the rail has to be submittable, or changing the base ref after writing one
/// would strand it as a draft no agent ever sees.
pub fn submitDrafts(self: *Store) Error!u32 {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
var n: u32 = 0;
for (self.entries.items) |entry| {
if (entry.comment.status != .draft) continue;
entry.comment.status = .submitted;
entry.comment.updatedAt = self.stampAlloc(entry.arena.allocator()) catch
entry.comment.updatedAt;
n += 1;
}
if (n == 0) return 0;
try self.save();
return n;
}
/// Delete every thread — drafts, submitted, and resolved alike.
///
/// This backs the UI's "reset review" button, and a review that kept its
/// resolved threads would not be the fresh start that asks for.
pub fn reset(self: *Store) Error!u32 {
return self.deleteWhere(null);
}
/// Delete the resolved threads and leave everything else alone, for tidying
/// finished work out of the rail without throwing the review out.
pub fn deleteResolved(self: *Store) Error!u32 {
return self.deleteWhere(.resolved);
}
fn deleteWhere(self: *Store, status: ?model.Status) Error!u32 {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
var doomed: std.ArrayListUnmanaged(*Entry) = .empty;
defer doomed.deinit(self.gpa);
var kept: std.ArrayListUnmanaged(*Entry) = .empty;
errdefer kept.deinit(self.gpa);
for (self.entries.items) |entry| {
const matches = if (status) |s| entry.comment.status == s else true;
if (matches) {
doomed.append(self.gpa, entry) catch return error.OutOfMemory;
} else {
kept.append(self.gpa, entry) catch return error.OutOfMemory;
}
}
if (doomed.items.len == 0) {
kept.deinit(self.gpa);
return 0;
}
// Swap the surviving list in before saving, and swap the old one back if
// the write fails, so a failed reset is a no-op rather than a half-reset.
const previous = self.entries;
self.entries = kept;
self.save() catch |err| {
self.entries.deinit(self.gpa);
self.entries = previous;
return err;
};
var old = previous;
old.deinit(self.gpa);
const n: u32 = @intCast(doomed.items.len);
for (doomed.items) |entry| entry.deinit(self.gpa);
return n;
}
// -------------------------------------------------------------------------
// Internals. All of these assume the mutex is held.
fn find(self: *Store, id: []const u8) ?*Entry {
const index = self.indexOf(id) orelse return null;
return self.entries.items[index];
}
fn indexOf(self: *Store, id: []const u8) ?usize {
for (self.entries.items, 0..) |entry, i| {
if (std.mem.eql(u8, entry.comment.id, id)) return i;
}
return null;
}
/// Stamp a thread as just-edited and persist. A failed save leaves the stamp
/// moved, which is the one inconsistency worth accepting: the caller has
/// already rolled back the change that mattered.
fn touch(self: *Store, entry: *Entry) Error!void {
entry.comment.updatedAt = self.stampAlloc(entry.arena.allocator()) catch
entry.comment.updatedAt;
try self.save();
}
fn stampAlloc(self: *Store, gpa: std.mem.Allocator) error{OutOfMemory}![]const u8 {
var buf: [32]u8 = undefined;
return gpa.dupe(u8, stamp(self.io, &buf));
}
/// Now, as RFC 3339 in UTC.
///
/// Text rather than a number because it is what the wire format and the on-disk
/// file both carry, and because it sorts: the store's ordering is a
/// lexicographic compare on this, with no parsing step in between.
pub fn stamp(io: std.Io, buf: *[32]u8) []const u8 {
const now = std.Io.Timestamp.now(io, .real);
const secs: i64 = @intCast(@divFloor(now.nanoseconds, std.time.ns_per_s));
const millis: u64 = @intCast(@divFloor(@mod(now.nanoseconds, std.time.ns_per_s), std.time.ns_per_ms));
const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(secs, 0)) };
const day = epoch.getEpochDay();
const year_day = day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
const time = epoch.getDaySeconds();
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{
year_day.year,
month_day.month.numeric(),
month_day.day_index + 1,
time.getHoursIntoDay(),
time.getMinutesIntoHour(),
time.getSecondsIntoMinute(),
millis,
}) catch unreachable;
}
/// Eight random bytes, hex. Ids only have to be unique inside one review, so
/// there is nothing to gain from a UUID's shape.
fn newId(self: *Store, buf: *[16]u8) []const u8 {
var raw: [8]u8 = undefined;
self.io.random(&raw);
return std.fmt.bufPrint(buf, "{x}", .{&raw}) catch unreachable;
}
// -------------------------------------------------------------------------
// Tests
//
// The store is the one piece of the review server with state that has to survive
// the process, so what these cover is the round trip: what a review looks like
// after being written, closed, and opened again.
test "store round-trips a review through the file" {
const gpa = std.testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &dir_buf);
const path = try std.fs.path.join(gpa, &.{ dir_buf[0..dir_len], "nested", "reviews.json" });
defer gpa.free(path);
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const a = arena.allocator();
{
const store = try open(gpa, io, path);
defer store.close();
const c = try store.add(.{
.level = .line,
.file = "src/main.zig",
.side = model.side_new,
.line = 12,
.body = "Should fix: this leaks.",
.context = .{ .base = "main", .uncommitted = true },
});
try std.testing.expectEqual(model.Status.draft, c.status);
try std.testing.expectEqual(@as(u32, 12), c.endLine);
const agent = try store.add(.{ .body = "Nit: naming.", .author = .claude, .level = .review });
try std.testing.expectEqual(model.Status.submitted, agent.status);
_ = try store.addReply(c.id, .claude, "Fixed.");
try std.testing.expectEqual(@as(u32, 1), store.countByStatus(.draft));
try std.testing.expectEqual(@as(u32, 1), try store.submitDrafts());
try std.testing.expectEqual(@as(u32, 2), store.countByStatus(.submitted));
_ = try store.setStatus(agent.id, .resolved);
try std.testing.expectEqual(@as(u32, 1), try store.deleteResolved());
}
// Reopen: what survived the process is what the file said.
const store = try open(gpa, io, path);
defer store.close();
const all = try store.list(a);
try std.testing.expectEqual(@as(usize, 1), all.len);
try std.testing.expectEqualStrings("src/main.zig", all[0].file);
try std.testing.expectEqual(@as(usize, 1), all[0].replies.len);
try std.testing.expectEqualStrings("Fixed.", all[0].replies[0].body);
try std.testing.expectEqual(model.Status.submitted, all[0].status);
try std.testing.expectError(error.NotFound, store.setStatus("nope", .resolved));
try std.testing.expectEqual(@as(u32, 1), try store.reset());
try std.testing.expectEqual(@as(usize, 0), (try store.list(a)).len);
}
test "stamp is RFC 3339 and sorts" {
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
var buf: [32]u8 = undefined;
const s = stamp(threaded.io(), &buf);
try std.testing.expectEqual(@as(usize, 24), s.len);
try std.testing.expectEqual(@as(u8, 'T'), s[10]);
try std.testing.expectEqual(@as(u8, 'Z'), s[23]);
}
+55
View File
@@ -0,0 +1,55 @@
//! The review UI, carried inside the binary.
//!
//! The page is a React app built by Vite (see `web/`), and `build.zig` runs that
//! build and hands the four files it produces to `@embedFile`. Bundling them the
//! way the icons are bundled keeps playpen a single binary: the review pane is a
//! web view pointed at this process, not at a directory someone has to have
//! installed alongside it.
//!
//! The bundle's filenames are pinned in `web/vite.config.ts` rather than left as
//! Vite's content hashes, precisely so this list can be written down. Cache
//! busting is not needed for a bundle that only changes when the binary does,
//! and the server sends `cache-control: no-cache` for these anyway.
const std = @import("std");
pub const File = struct {
bytes: []const u8,
mime: []const u8,
};
const index_html = @embedFile("review-index.html");
const app_js = @embedFile("review-app.js");
const app_css = @embedFile("review-app.css");
const favicon_svg = @embedFile("review-favicon.svg");
/// Whether the UI was built into this binary at all.
///
/// False when `build.zig` could not run the Vite build — no node, or the
/// dependencies were never installed — in which case a placeholder page saying
/// so is embedded instead of the app. The review pane still opens; it just
/// explains itself rather than rendering a blank web view.
pub const present = index_html.len > 0 and app_js.len > 0;
/// Look up one file by the path the browser asked for, relative to the tab root.
pub fn find(name: []const u8) ?File {
const path = std.mem.trimStart(u8, name, "/");
if (path.len == 0 or std.mem.eql(u8, path, "index.html")) return .{
.bytes = index_html,
.mime = "text/html; charset=utf-8",
};
if (std.mem.eql(u8, path, "assets/app.js")) return .{
.bytes = app_js,
.mime = "text/javascript; charset=utf-8",
};
if (std.mem.eql(u8, path, "assets/app.css")) return .{
.bytes = app_css,
.mime = "text/css; charset=utf-8",
};
if (std.mem.eql(u8, path, "favicon.svg")) return .{
.bytes = favicon_svg,
.mime = "image/svg+xml",
};
return null;
}
+815
View File
@@ -0,0 +1,815 @@
//! Everything the review server asks git for: the repository's identity, the
//! refs the base picker offers, the commits a range spans, and the patch itself.
//!
//! It shells out to the `git` binary rather than linking a library, for the same
//! reason the tool this was ported from did: the output of `git diff` is the
//! thing the UI renders, so producing it any other way would mean rendering a
//! patch git did not write, and every line number in every comment is anchored
//! to those exact bytes.
//!
//! Every function takes an allocator and returns memory owned by it. The server
//! hands each request an arena, so nothing here frees anything: the whole
//! request's worth of git output goes away in one drop when the response has
//! been written.
const std = @import("std");
const model = @import("model.zig");
/// How long a single git invocation may run before it is killed.
///
/// Generous, because a cold-cache `git diff` against a release branch on a large
/// repository genuinely takes seconds. It exists so a repository in a strange
/// state — an interrupted rebase holding a lock, a network filesystem gone
/// away — costs one failed request rather than a connection thread parked
/// forever.
const timeout_s = 60;
/// Cap on what one git invocation may print. A patch is the big one: the
/// oversize guard below is what normally keeps it in hand, and this is the
/// backstop for the cases the guard cannot see coming.
const max_output = 256 * 1024 * 1024;
pub const Error = error{
NotARepository,
GitFailed,
BadCommit,
OutOfMemory,
};
/// A handle on one work tree.
pub const Repo = struct {
/// Absolute path to the work-tree root.
path: []const u8,
/// Absolute path to the git directory, which for a worktree is the
/// per-worktree one — so a review's comments live with the worktree they
/// were written about rather than in the shared repository.
git_dir: []const u8,
};
/// Resolve `path` to the work tree containing it.
///
/// Any directory inside the repository works, which is what lets an agent pass
/// its `$PWD` and the review pane pass a terminal's current directory without
/// either having to know where the root is.
///
/// Both paths point into `gpa` allocations that are larger than the slices
/// themselves — they are git's output with the trailing newline trimmed — so, as
/// everywhere else here, they belong to an arena and must not be freed
/// individually. `Server.openReview` copies them out of one.
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Error!Repo {
const top = run(gpa, io, path, &.{ "rev-parse", "--show-toplevel" }) catch
return error.NotARepository;
const root = trim(top);
if (root.len == 0) return error.NotARepository;
const dir = run(gpa, io, root, &.{ "rev-parse", "--absolute-git-dir" }) catch
return error.NotARepository;
return .{ .path = root, .git_dir = trim(dir) };
}
/// The short name of the checked-out branch, or "HEAD" when detached.
pub fn currentBranch(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const u8 {
const out = run(gpa, io, repo.path, &.{ "rev-parse", "--abbrev-ref", "HEAD" }) catch
return "HEAD";
const branch = trim(out);
return if (branch.len == 0) "HEAD" else branch;
}
/// Candidate base refs for the picker: local and remote branches, plus tags.
pub fn refs(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const []const u8 {
return lines(gpa, run(gpa, io, repo.path, &.{
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
"refs/remotes",
"refs/tags",
}) catch return &.{}, "origin/HEAD");
}
/// Local branch names only.
pub fn branches(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const []const u8 {
return lines(gpa, run(gpa, io, repo.path, &.{
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
}) catch return &.{}, null);
}
/// Split output into non-empty trimmed lines, dropping `skip` if given.
fn lines(gpa: std.mem.Allocator, out: []const u8, skip: ?[]const u8) []const []const u8 {
var list: std.ArrayListUnmanaged([]const u8) = .empty;
var it = std.mem.splitScalar(u8, out, '\n');
while (it.next()) |raw| {
const line = trim(raw);
if (line.len == 0) continue;
if (skip) |s| if (std.mem.eql(u8, line, s)) continue;
list.append(gpa, line) catch return list.items;
}
return list.items;
}
pub fn info(repo: Repo, gpa: std.mem.Allocator, io: std.Io) model.RepoInfo {
const branch = currentBranch(repo, gpa, io);
const all_refs = refs(repo, gpa, io);
return .{
.path = repo.path,
.branch = branch,
.branches = branches(repo, gpa, io),
.refs = all_refs,
.suggestedBase = suggestedBase(repo.path, branch, all_refs),
};
}
// -------------------------------------------------------------------------
// Picking a base ref
const Version = struct { major: u32, minor: u32 };
/// Parse a bare release-branch name like "8.21" or "9.5".
fn versionOf(name: []const u8) ?Version {
const dot = std.mem.indexOfScalar(u8, name, '.') orelse return null;
if (dot == 0 or dot == name.len - 1) return null;
const major = std.fmt.parseInt(u32, name[0..dot], 10) catch return null;
const minor = std.fmt.parseInt(u32, name[dot + 1 ..], 10) catch return null;
return .{ .major = major, .minor = minor };
}
/// The ref with the greatest `x.x` version, comparing major then minor
/// numerically.
///
/// A plain local branch (`8.21`) beats a remote-prefixed one (`origin/8.21`),
/// which is why the whole ref name is tried before its last path segment:
/// `origin/8.21` only ever wins when there is no local `8.21`. Returns empty
/// when no ref looks like a version at all.
pub fn highestVersionBranch(all: []const []const u8) []const u8 {
var best_plain: []const u8 = "";
var best_plain_v: Version = .{ .major = 0, .minor = 0 };
var best_remote: []const u8 = "";
var best_remote_v: Version = .{ .major = 0, .minor = 0 };
for (all) |ref| {
if (versionOf(ref)) |v| {
if (best_plain.len == 0 or v.major > best_plain_v.major or
(v.major == best_plain_v.major and v.minor > best_plain_v.minor))
{
best_plain = ref;
best_plain_v = v;
}
continue;
}
const tail = if (std.mem.lastIndexOfScalar(u8, ref, '/')) |i| ref[i + 1 ..] else ref;
if (versionOf(tail)) |v| {
if (best_remote.len == 0 or v.major > best_remote_v.major or
(v.major == best_remote_v.major and v.minor > best_remote_v.minor))
{
best_remote = ref;
best_remote_v = v;
}
}
}
return if (best_plain.len > 0) best_plain else best_remote;
}
/// The ref the picker offers directly under HEAD — the one you most likely want
/// when the uncommitted-only diff HEAD gives is not it:
///
/// - repositories whose path names android: the highest `x.x` release branch,
/// matching a release-branch development flow;
/// - otherwise `main`.
///
/// It is a suggestion rather than the default on purpose. A base whose history
/// has moved on — a release branch rebased since the work was cut from it — puts
/// every commit in that gap into the diff, producing a change set far larger
/// than what is actually under review. That is a bad thing to open on unasked,
/// so the picker offers it and the user takes it.
///
/// Empty when there is nothing useful to suggest: no candidate exists, or the
/// only one is the branch you are already on, and diffing a ref against itself
/// shows nothing.
pub fn suggestedBase(path: []const u8, branch: []const u8, all: []const []const u8) []const u8 {
if (isAndroidPath(path)) {
const v = highestVersionBranch(all);
if (v.len > 0 and !std.mem.eql(u8, v, branch)) return v;
}
if (!std.mem.eql(u8, branch, "main")) {
for (all) |ref| if (std.mem.eql(u8, ref, "main")) return "main";
}
return "";
}
/// Whether a work-tree path names android, the heuristic that selects the
/// release-branch flow. A path check because it has to work before anything has
/// been read out of the repository.
fn isAndroidPath(path: []const u8) bool {
return std.ascii.indexOfIgnoreCase(path, "android") != null;
}
// -------------------------------------------------------------------------
// Diffs
/// The per-request preferences that shape a diff without changing which commits
/// it spans.
///
/// Deliberately separate from `model.DiffContext`: that type is also the tag
/// stored on every comment, and a comment written with one of these set is a
/// comment about the same diff, not another one.
pub const Options = struct {
/// Pass `-w`, dropping changes that are only whitespace — including files
/// whose every change is whitespace, which leave the change set entirely.
/// git omits them from `--numstat` and `--name-status` too, so the file list
/// agrees with the patch.
ignore_whitespace: bool = false,
/// Skip the oversize guard, for a caller that has been told how big the
/// change set is and wants the patch regardless.
force: bool = false,
};
/// What one diff can be before the UI cannot be expected to render it.
///
/// The browser parses the whole patch, tokenizes every line for highlighting,
/// and mounts every hunk at once, so a change set past this locks the page up
/// long enough to look like a crash. The usual cause is not a genuinely huge
/// review but a base ref whose history has moved on, which pads the diff with
/// commits nobody is reviewing — see `suggestedBase`.
const max_diff_lines = 20000;
const max_diff_files = 400;
/// Cap on the commit list a diff reports.
///
/// Listing commits is cheap; summarizing each one's stats is a diff apiece, so
/// an unbounded range — a base ref hundreds of releases back — would pay for
/// thousands of them on every fetch. The newest are the ones kept, since those
/// are the work under review, and the caller is told the list was cut rather
/// than left to assume it is whole.
const max_commits = 500;
/// The argument list for a context, with `extra` spliced in before the revisions:
///
/// - a single commit selected: that commit against its parent, whatever the
/// other fields say;
/// - uncommitted included: base against the working tree;
/// - uncommitted excluded: base against HEAD, so only committed work.
fn diffArgs(
gpa: std.mem.Allocator,
ctx: model.DiffContext,
opts: Options,
extra: []const []const u8,
) Error![]const []const u8 {
const base = if (ctx.base.len == 0) "HEAD" else ctx.base;
var args: std.ArrayListUnmanaged([]const u8) = .empty;
if (ctx.commit.len > 0) {
// One commit on its own is `show`, not `diff`, because it covers the two
// shapes `diff <sha>^ <sha>` cannot be told to handle: a root commit,
// which has no parent to name, and a merge, which `-m --first-parent`
// renders as the change it brought onto the branch rather than as
// nothing at all. `--format=` drops the commit header, leaving the patch.
try args.appendSlice(gpa, &.{ "show", "--format=", "-m", "--first-parent" });
} else {
try args.append(gpa, "diff");
}
if (opts.ignore_whitespace) try args.append(gpa, "-w");
try args.appendSlice(gpa, extra);
if (ctx.commit.len > 0) {
try args.append(gpa, ctx.commit);
} else if (ctx.uncommitted) {
try args.append(gpa, base);
} else {
try args.appendSlice(gpa, &.{ base, "HEAD" });
}
return args.items;
}
/// Object names a client may select: a hex sha, abbreviated or full.
///
/// Anything else is refused rather than handed to git, where a value beginning
/// with `-` would be read as a flag.
fn validateCommit(sha: []const u8) Error!void {
if (sha.len == 0) return;
if (sha.len < 4 or sha.len > 64) return error.BadCommit;
for (sha) |c| if (!std.ascii.isHex(c)) return error.BadCommit;
}
/// The patch plus the per-file summary and the commit list, for one selection.
///
/// The summary is gathered first, and when it says the change set is past what
/// the UI can render the patch is left out and `oversized` is set, so the caller
/// can say how big the thing is and ask before loading it. `opts.force` skips
/// the check. Either way the summary comes back, which is what the size question
/// gets answered from.
pub fn diff(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
ctx: model.DiffContext,
opts: Options,
) Error!model.DiffPayload {
try validateCommit(ctx.commit);
const files = try diffFiles(repo, gpa, io, ctx, opts);
// Best-effort: the commit list is a way to navigate the diff, not part of
// it, and a base ref `git diff` accepts but `git log` will not walk — a bare
// tree, say — should not cost the user the patch they asked for.
const listed = commits(repo, gpa, io, ctx) catch
Commits{ .items = &.{}, .more = false };
if (!opts.force and oversized(files)) return .{
.context = ctx,
.patch = "",
.files = files,
.commits = listed.items,
.moreCommits = listed.more,
.oversized = true,
};
const patch = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
"--no-color",
"--find-renames",
}));
return .{
.context = ctx,
.patch = patch,
.files = files,
.commits = listed.items,
.moreCommits = listed.more,
};
}
/// Whether a change set is past what the UI can render at once. Binary files
/// count for no lines, hence the file cap alongside the line one.
fn oversized(files: []const model.DiffFile) bool {
if (files.len > max_diff_files) return true;
var total: u64 = 0;
for (files) |f| total += f.additions + f.deletions;
return total > max_diff_lines;
}
/// The full contents of a file at a ref, for expanding collapsed context between
/// hunks. An empty ref means HEAD.
pub fn fileAt(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
ref: []const u8,
path: []const u8,
) Error![]const u8 {
const spec = try std.fmt.allocPrint(gpa, "{s}:{s}", .{
if (ref.len == 0) "HEAD" else ref,
path,
});
return run(gpa, io, repo.path, &.{ "show", spec });
}
/// Per-file status and add/delete counts, from `--numstat` keyed by new path
/// with `--name-status` supplying the status word.
fn diffFiles(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
ctx: model.DiffContext,
opts: Options,
) Error![]const model.DiffFile {
const numstat = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
"--numstat",
"--find-renames",
}));
const name_status = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
"--name-status",
"--find-renames",
}));
var status_by_path: std.StringHashMapUnmanaged([]const u8) = .empty;
try parseNameStatus(gpa, name_status, &status_by_path);
var out: std.ArrayListUnmanaged(model.DiffFile) = .empty;
var it = std.mem.splitScalar(u8, numstat, '\n');
while (it.next()) |line| {
if (line.len == 0) continue;
const first = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
const second = std.mem.indexOfScalarPos(u8, line, first + 1, '\t') orelse continue;
// "-" for a binary file, which parses as zero — the file cap above is
// what keeps those from slipping past the oversize guard.
const adds = std.fmt.parseInt(u32, line[0..first], 10) catch 0;
const dels = std.fmt.parseInt(u32, line[first + 1 .. second], 10) catch 0;
const paths = try parsePathField(gpa, line[second + 1 ..]);
try out.append(gpa, .{
.oldPath = paths.old,
.newPath = paths.new,
.status = status_by_path.get(paths.new) orelse "modified",
.additions = adds,
.deletions = dels,
});
}
return out.items;
}
const Paths = struct { old: []const u8, new: []const u8 };
/// numstat's path field, which spells a rename either as `old => new` or in the
/// brace form `dir/{a => b}/file`. For a plain path both halves are the same.
fn parsePathField(gpa: std.mem.Allocator, raw: []const u8) Error!Paths {
const field = trim(raw);
if (std.mem.indexOf(u8, field, "=>") == null) return .{ .old = field, .new = field };
if (std.mem.indexOfScalar(u8, field, '{')) |open_brace| {
const rest = field[open_brace + 1 ..];
if (std.mem.indexOfScalar(u8, rest, '}')) |close_brace| {
const inner = rest[0..close_brace];
const suffix = rest[close_brace + 1 ..];
if (std.mem.indexOf(u8, inner, "=>")) |arrow| {
const prefix = field[0..open_brace];
return .{
.old = try join(gpa, prefix, trim(inner[0..arrow]), suffix),
.new = try join(gpa, prefix, trim(inner[arrow + 2 ..]), suffix),
};
}
}
}
if (std.mem.indexOf(u8, field, "=>")) |arrow| return .{
.old = trim(field[0..arrow]),
.new = trim(field[arrow + 2 ..]),
};
return .{ .old = field, .new = field };
}
/// Splice the three pieces of a brace rename together, collapsing the doubled
/// separators an empty middle leaves behind (`dir/{ => sub}/f` gives `dir//f`).
fn join(gpa: std.mem.Allocator, prefix: []const u8, middle: []const u8, suffix: []const u8) Error![]const u8 {
const raw = try std.fmt.allocPrint(gpa, "{s}{s}{s}", .{ prefix, middle, suffix });
if (std.mem.indexOf(u8, raw, "//") == null) return raw;
var out: std.ArrayListUnmanaged(u8) = .empty;
try out.ensureTotalCapacity(gpa, raw.len);
for (raw, 0..) |c, i| {
if (c == '/' and i + 1 < raw.len and raw[i + 1] == '/') continue;
out.appendAssumeCapacity(c);
}
return out.items;
}
/// Map each path to a human status word.
fn parseNameStatus(
gpa: std.mem.Allocator,
out: []const u8,
into: *std.StringHashMapUnmanaged([]const u8),
) Error!void {
var it = std.mem.splitScalar(u8, out, '\n');
while (it.next()) |line| {
if (line.len == 0) continue;
var fields = std.mem.splitScalar(u8, line, '\t');
const code = fields.next() orelse continue;
if (code.len == 0) continue;
// A rename or copy names both paths; the new one is what the file list
// is keyed by, so skip past the old.
const first = fields.next() orelse continue;
const path = if (code[0] == 'R' or code[0] == 'C')
(fields.next() orelse continue)
else
first;
try into.put(gpa, path, switch (code[0]) {
'A' => "added",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
else => "modified",
});
}
}
// -------------------------------------------------------------------------
// Commits
pub const Commits = struct {
items: []const model.Commit,
/// The range holds more than `max_commits`; the newest were kept.
more: bool,
};
/// One record per commit: a NUL to split records on, then unit-separated
/// fields. Both are bytes git will not put in a subject or an author name, so no
/// field can spell the end of its own record.
const commit_format = "--format=%x00%H%x1f%h%x1f%an%x1f%aI%x1f%s";
/// The commits a context spans — reachable from HEAD but not from the base ref —
/// oldest first, the order they were written in.
///
/// `ctx.commit` is ignored on purpose: narrowing the view to one commit should
/// not shrink the list it was picked out of, or there would be no way back to a
/// sibling. A base of HEAD, which a review opens on, spans no commits at all —
/// that diff is the uncommitted work — and comes back empty.
pub fn commits(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
ctx: model.DiffContext,
) Error!Commits {
if (ctx.base.len == 0 or std.mem.eql(u8, ctx.base, "HEAD")) {
return .{ .items = &.{}, .more = false };
}
// One past the cap, so hitting it is distinguishable from filling it
// exactly.
const limit = try std.fmt.allocPrint(gpa, "--max-count={d}", .{max_commits + 1});
const range = try std.fmt.allocPrint(gpa, "{s}..HEAD", .{ctx.base});
const out = try run(gpa, io, repo.path, &.{
"log", limit, commit_format, "--shortstat", range,
});
// git walks newest first; reversed at the end so the cap drops the oldest
// commits rather than the ones the branch is being judged on.
var list: std.ArrayListUnmanaged(model.Commit) = .empty;
var more = false;
var records = std.mem.splitScalar(u8, out, 0);
while (records.next()) |record| {
const commit = parseCommit(record) orelse continue;
if (list.items.len == max_commits) {
more = true;
break;
}
try list.append(gpa, commit);
}
std.mem.reverse(model.Commit, list.items);
return .{ .items = list.items, .more = more };
}
/// One `commit_format` record: the field line, then the shortstat summary git
/// appends under it — absent for a merge, whose diff it does not summarize.
fn parseCommit(record: []const u8) ?model.Commit {
const newline = std.mem.indexOfScalar(u8, record, '\n') orelse record.len;
var fields = std.mem.splitScalar(u8, record[0..newline], '\x1f');
const sha = fields.next() orelse return null;
const short = fields.next() orelse return null;
const author = fields.next() orelse return null;
const date = fields.next() orelse return null;
const subject = fields.next() orelse return null;
if (sha.len == 0) return null;
var commit: model.Commit = .{
.sha = sha,
.shortSha = short,
.author = author,
.date = date,
.subject = subject,
};
if (newline < record.len) parseShortstat(record[newline..], &commit);
return commit;
}
/// Pull the counts out of `git log --shortstat`'s summary line:
///
/// 3 files changed, 12 insertions(+), 4 deletions(-)
///
/// Each clause is absent when its count is zero, so this reads by keyword rather
/// than by position.
fn parseShortstat(tail: []const u8, into: *model.Commit) void {
var it = std.mem.tokenizeAny(u8, tail, " ,\n\t");
var previous: ?[]const u8 = null;
while (it.next()) |word| {
defer previous = word;
const number = previous orelse continue;
const count = std.fmt.parseInt(u32, number, 10) catch continue;
if (std.mem.startsWith(u8, word, "file")) {
into.files = count;
} else if (std.mem.startsWith(u8, word, "insertion")) {
into.additions = count;
} else if (std.mem.startsWith(u8, word, "deletion")) {
into.deletions = count;
}
}
}
// -------------------------------------------------------------------------
// Running git
fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\r\n");
}
/// Run git in `dir` and return its stdout, owned by `gpa`.
///
/// A non-zero exit is a failure even when something was printed: git writes
/// partial output before giving up on a bad revision, and treating that as a
/// diff would render a patch that is not the one asked for.
fn run(
gpa: std.mem.Allocator,
io: std.Io,
dir: []const u8,
args: []const []const u8,
) Error![]const u8 {
var argv: std.ArrayListUnmanaged([]const u8) = .empty;
try argv.ensureTotalCapacity(gpa, args.len + 1);
argv.appendAssumeCapacity("git");
argv.appendSliceAssumeCapacity(args);
const result = std.process.run(gpa, io, .{
.argv = argv.items,
.cwd = .{ .path = dir },
.stdout_limit = .limited(max_output),
.stderr_limit = .limited(64 * 1024),
.timeout = .{ .duration = .{ .raw = .fromSeconds(timeout_s), .clock = .awake } },
}) catch |err| {
std.log.warn("review: git {s}: {s}", .{ args[0], @errorName(err) });
return error.GitFailed;
};
switch (result.term) {
.exited => |code| if (code != 0) {
std.log.warn("review: git {s} exited {d}: {s}", .{
args[0], code, trim(result.stderr),
});
return error.GitFailed;
},
else => {
std.log.warn("review: git {s} died: {s}", .{ args[0], trim(result.stderr) });
return error.GitFailed;
},
}
return result.stdout;
}
// -------------------------------------------------------------------------
// Tests
//
// The pure half of this file — picking a base ref, and reading git's own output
// formats — is what these cover. Anything that runs git wants a repository in a
// known state, which is a different kind of test than this project has.
test "highestVersionBranch compares numerically, not lexically" {
try std.testing.expectEqualStrings("8.21", highestVersionBranch(&.{ "8.9", "8.21", "8.2" }));
try std.testing.expectEqualStrings("9.5", highestVersionBranch(&.{ "8.99", "9.5" }));
try std.testing.expectEqualStrings("", highestVersionBranch(&.{ "main", "feature/x" }));
}
test "highestVersionBranch prefers a local branch over a remote one" {
try std.testing.expectEqualStrings(
"8.21",
highestVersionBranch(&.{ "origin/8.21", "8.21" }),
);
try std.testing.expectEqualStrings(
"origin/8.21",
highestVersionBranch(&.{ "origin/8.21", "main" }),
);
}
test "suggestedBase declines the branch you are already on" {
try std.testing.expectEqualStrings("", suggestedBase("/w/proj", "main", &.{"main"}));
try std.testing.expectEqualStrings("main", suggestedBase("/w/proj", "feature", &.{"main"}));
// Sitting on the release branch: the version candidate is declined for
// being the branch itself, and `main` is what is left to offer.
try std.testing.expectEqualStrings(
"main",
suggestedBase("/w/Signal-Android", "9.5", &.{ "9.5", "main" }),
);
try std.testing.expectEqualStrings(
"",
suggestedBase("/w/Signal-Android", "9.5", &.{"9.5"}),
);
try std.testing.expectEqualStrings(
"9.5",
suggestedBase("/w/Signal-Android", "feature", &.{ "9.5", "main" }),
);
}
test "parsePathField reads both rename spellings" {
const gpa = std.testing.allocator;
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const a = arena.allocator();
const plain = try parsePathField(a, "src/main.zig");
try std.testing.expectEqualStrings("src/main.zig", plain.old);
try std.testing.expectEqualStrings("src/main.zig", plain.new);
const arrow = try parsePathField(a, "old.zig => new.zig");
try std.testing.expectEqualStrings("old.zig", arrow.old);
try std.testing.expectEqualStrings("new.zig", arrow.new);
const brace = try parsePathField(a, "src/{a => b}/file.zig");
try std.testing.expectEqualStrings("src/a/file.zig", brace.old);
try std.testing.expectEqualStrings("src/b/file.zig", brace.new);
// An empty half of the brace form would leave a doubled separator behind.
const moved = try parsePathField(a, "src/{ => sub}/file.zig");
try std.testing.expectEqualStrings("src/file.zig", moved.old);
try std.testing.expectEqualStrings("src/sub/file.zig", moved.new);
}
test "parseNameStatus keys renames by the new path" {
const gpa = std.testing.allocator;
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const a = arena.allocator();
var map: std.StringHashMapUnmanaged([]const u8) = .empty;
try parseNameStatus(a, "A\tadded.zig\n" ++
"D\tgone.zig\n" ++
"M\ttouched.zig\n" ++
"R096\told.zig\tnew.zig\n", &map);
try std.testing.expectEqualStrings("added", map.get("added.zig").?);
try std.testing.expectEqualStrings("deleted", map.get("gone.zig").?);
try std.testing.expectEqualStrings("modified", map.get("touched.zig").?);
try std.testing.expectEqualStrings("renamed", map.get("new.zig").?);
try std.testing.expect(map.get("old.zig") == null);
}
test "parseCommit reads a record with and without a shortstat" {
const with = parseCommit(
"abc123\x1fabc\x1fA Dev\x1f2026-01-02T03:04:05Z\x1fFix the thing\n" ++
" 3 files changed, 12 insertions(+), 4 deletions(-)\n",
).?;
try std.testing.expectEqualStrings("abc123", with.sha);
try std.testing.expectEqualStrings("Fix the thing", with.subject);
try std.testing.expectEqual(@as(u32, 3), with.files);
try std.testing.expectEqual(@as(u32, 12), with.additions);
try std.testing.expectEqual(@as(u32, 4), with.deletions);
// A merge: git prints no summary line, so the counts stay zero.
const merge = parseCommit("def\x1fdef\x1fA Dev\x1f2026-01-02T03:04:05Z\x1fMerge").?;
try std.testing.expectEqual(@as(u32, 0), merge.files);
try std.testing.expect(parseCommit("") == null);
try std.testing.expect(parseCommit("only\x1ftwo") == null);
}
test "parseShortstat handles an absent clause" {
var commit: model.Commit = .{
.sha = "",
.shortSha = "",
.author = "",
.date = "",
.subject = "",
};
parseShortstat(" 1 file changed, 5 insertions(+)\n", &commit);
try std.testing.expectEqual(@as(u32, 1), commit.files);
try std.testing.expectEqual(@as(u32, 5), commit.additions);
try std.testing.expectEqual(@as(u32, 0), commit.deletions);
}
test "validateCommit refuses anything that is not a sha" {
try validateCommit("");
try validateCommit("abc1");
try validateCommit("0123456789abcdef");
try std.testing.expectError(error.BadCommit, validateCommit("--upload-pack=evil"));
try std.testing.expectError(error.BadCommit, validateCommit("main"));
try std.testing.expectError(error.BadCommit, validateCommit("abc"));
}
test "diffArgs picks the right git subcommand for each selection" {
const gpa = std.testing.allocator;
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const a = arena.allocator();
// Uncommitted work against a base: base alone, so git compares the work tree.
const dirty = try diffArgs(a, .{ .base = "main", .uncommitted = true }, .{}, &.{"--numstat"});
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
"diff", "--numstat", "main",
}), dirty);
// Committed only: base against HEAD.
const clean = try diffArgs(a, .{ .base = "main" }, .{}, &.{});
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
"diff", "main", "HEAD",
}), clean);
// A single commit is `show`, and the uncommitted toggle stops applying.
const one = try diffArgs(
a,
.{ .base = "main", .uncommitted = true, .commit = "abc123" },
.{ .ignore_whitespace = true },
&.{},
);
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
"show", "--format=", "-m", "--first-parent", "-w", "abc123",
}), one);
// An empty base is HEAD, which is what a review opens on.
const head = try diffArgs(a, .{ .uncommitted = true }, .{}, &.{});
try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "diff", "HEAD" }), head);
}
test "oversized counts lines, and files for the binary case" {
const small: []const model.DiffFile = &.{
.{ .oldPath = "a", .newPath = "a", .status = "modified", .additions = 10, .deletions = 5 },
};
try std.testing.expect(!oversized(small));
const huge: []const model.DiffFile = &.{
.{ .oldPath = "a", .newPath = "a", .status = "modified", .additions = 20001, .deletions = 0 },
};
try std.testing.expect(oversized(huge));
}
+166
View File
@@ -0,0 +1,166 @@
//! The shapes the review API speaks: comments, replies, the diff payload, and
//! the diff selection everything is scoped to.
//!
//! Field names here *are* the wire format — the web UI's `types.ts` reads them
//! verbatim, and `std.json` derives both directions from the declarations — so
//! they are camelCase rather than Zig's usual snake_case. Renaming one is a
//! protocol change, not a refactor.
const std = @import("std");
/// Which side of a hunk a comment is anchored to.
///
/// A string rather than an enum because a file- or review-level comment has no
/// side at all, and the wire format spells that as `""`. An optional enum would
/// serialize it as `null`, which the UI's anchoring code reads as a side it
/// simply does not know.
pub const side_old = "old";
pub const side_new = "new";
pub const Author = enum { user, claude };
/// What a comment is anchored to.
pub const Level = enum {
/// A line, or an inclusive range of them, in one file.
line,
/// A whole file, no line.
file,
/// The change set as a whole, no file.
review,
};
pub const Status = enum {
/// Still being composed. Invisible to an agent — see `Store.pending`.
draft,
/// Submitted, and so actionable.
submitted,
/// Dealt with and closed.
resolved,
};
/// The selection that produced a diff.
///
/// Used both as the query for fetching one and as the tag stored on every
/// comment, which is what keeps comments written against one base ref from
/// landing on another's line numbers.
pub const DiffContext = struct {
base: []const u8 = "",
uncommitted: bool = false,
/// Narrows the view to a single commit out of the range `base` spans. The
/// diff is then that commit alone and `uncommitted` no longer applies.
///
/// It belongs in the context rather than beside it because a line number
/// only means something inside one revision: line 40 of a file as one
/// commit left it is not line 40 at the tip of the branch.
commit: []const u8 = "",
pub fn eql(a: DiffContext, b: DiffContext) bool {
return a.uncommitted == b.uncommitted and
std.mem.eql(u8, a.base, b.base) and
std.mem.eql(u8, a.commit, b.commit);
}
};
pub const Reply = struct {
id: []const u8,
author: Author,
body: []const u8,
createdAt: []const u8,
};
/// A comment thread. The anchor depends on `level`:
///
/// - `.line` — `file` + `side` + `line`..`endLine`, inclusive.
/// - `.file` — `file` alone.
/// - `.review` — nothing.
pub const Comment = struct {
id: []const u8,
level: Level,
file: []const u8,
side: []const u8,
line: u32,
endLine: u32,
body: []const u8,
author: Author,
status: Status,
replies: []const Reply,
context: DiffContext,
createdAt: []const u8,
updatedAt: []const u8,
};
/// One commit in the range a diff spans — an entry in the list the UI offers so
/// a large change set can be read a commit at a time.
pub const Commit = struct {
sha: []const u8,
shortSha: []const u8,
author: []const u8,
date: []const u8,
subject: []const u8,
/// What the commit changed on its own. Zero for a merge, whose diff
/// `git log --shortstat` does not summarize.
files: u32 = 0,
additions: u32 = 0,
deletions: u32 = 0,
};
/// Summary metadata for one changed file.
pub const DiffFile = struct {
oldPath: []const u8,
newPath: []const u8,
/// "added" | "deleted" | "modified" | "renamed" | "copied"
status: []const u8,
additions: u32,
deletions: u32,
};
/// What `GET api/diff` returns.
pub const DiffPayload = struct {
context: DiffContext,
patch: []const u8,
files: []const DiffFile,
/// The commits the change set is made of, oldest first — the range
/// `context.base` spans, whether or not `context.commit` narrows the patch
/// to one of them. It rides along with the diff so the list and the patch
/// can never describe different change sets, and it is filled in even when
/// the patch is withheld for being oversized: picking one commit out of the
/// range is the quickest way to get something readable on screen.
commits: []const Commit,
/// The range holds more commits than `commits` lists. The newest are kept.
moreCommits: bool = false,
/// The change set is past what the UI can render, so `patch` was withheld.
/// `files` is still filled in, so the caller can say how big it is and
/// offer to load it anyway with `force`.
oversized: bool = false,
};
/// What `GET api/repo` returns for a tab that has a review open.
pub const RepoInfo = struct {
path: []const u8,
branch: []const u8,
branches: []const []const u8,
refs: []const []const u8,
/// The ref the base picker offers directly under HEAD. Decided server-side
/// because the rule depends on the repository — see `git.suggestedBase`.
/// Empty when there is nothing worth suggesting.
suggestedBase: []const u8,
};
/// One entry in `GET /api/tabs`: which tab is reviewing what, and how much is
/// waiting on someone. This is the discovery endpoint an agent uses when it has
/// no `PLAYPEN_REVIEW_URL` to go on.
pub const TabState = struct {
id: []const u8,
open: bool,
path: []const u8,
branch: []const u8,
drafts: u32,
openComments: u32,
context: ?DiffContext,
};
+4
View File
@@ -108,6 +108,7 @@ pub const Action = enum {
close_pane,
new_terminal,
new_web,
new_review,
rename_tab,
toggle_zoom,
toggle_sidebar,
@@ -156,6 +157,9 @@ pub const defaults: []const Binding = &.{
.{ .chord = chord("ctrl+shift+w"), .action = .close_pane },
.{ .chord = chord("ctrl+shift+e"), .action = .new_terminal },
.{ .chord = chord("ctrl+shift+b"), .action = .new_web },
// `d` for diff. `r` would read better but it is `rename_tab`'s, and that is
// in people's fingers.
.{ .chord = chord("ctrl+shift+d"), .action = .new_review },
.{ .chord = chord("ctrl+shift+r"), .action = .rename_tab },
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
+7 -1
View File
@@ -88,11 +88,17 @@ pub const WebView = opaque {
/// they know about, so WebKit's own signals go through the untyped
/// GObject entry point. Property changes don't need this: `notify` is
/// declared on `gobject.Object`, which this can be cast to.
///
/// The handler is taken as it comes, for the same reason the find
/// controller's is: these signals differ in shape — `close` carries
/// nothing, `load-changed` carries a load event, `load-failed` carries
/// three arguments and returns whether it handled the failure — so
/// matching the handler to the signal is the caller's job.
pub fn connectSignal(
self: *WebView,
comptime signal: [:0]const u8,
comptime Data: type,
handler: *const fn (*WebView, Data) callconv(.c) void,
handler: anytype,
data: Data,
) void {
_ = gobject.signalConnectData(
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>review</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1993
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "playpen-review-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-diff-view": "^3.2.1",
"react-dom": "^18.3.1",
"refractor": "^4.8.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+17
View File
@@ -0,0 +1,17 @@
<!-- The topbar brand mark as a favicon: the Octicon `file-diff` glyph knocked
out of a Primer-blue tile (bgColor.accent.emphasis #316dca, shaded a shade
either side for depth). Same shape and colour as <Icon name="file-diff" />
in the header, so the tab matches the app. Vite copies public/ to dist/,
which web/embed.go ships inside the binary. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="tile" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4184e4"/>
<stop offset="1" stop-color="#2f6ac9"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#tile)"/>
<g transform="translate(4 4) scale(1.5)" fill="#ffffff">
<path d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+848
View File
@@ -0,0 +1,848 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { parseDiff, type ViewType } from 'react-diff-view';
import { api, tabId } from './api';
import type { Comment, DiffContext, DiffPayload, DraftTarget, RepoState } from './types';
import { buildAnchors, isOutdated } from './lib/anchor';
import { pathOf } from './lib/filetree';
import { fingerprintFiles } from './lib/fingerprint';
import { useSSE } from './lib/useSSE';
import { CommentsPanel, CommentsTab } from './components/CommentsPanel';
import { CommitList } from './components/CommitList';
import { ConfirmDialog } from './components/ConfirmDialog';
import { DiffView } from './components/DiffView';
import { Icon } from './components/Icon';
import { FileList } from './components/FileList';
import { OutdatedPanel } from './components/Outdated';
import { OversizeNotice, OversizeWarning } from './components/Oversize';
import { Resizer } from './components/Resizer';
import { ReviewPanel } from './components/ReviewPanel';
import { ReviewProgress } from './components/ReviewProgress';
import { useViewedFiles } from './lib/viewed';
type Theme = 'light' | 'dark';
// Side-panel sizing: persisted across sessions, clamped so a rail can't swallow
// the diff or shrink past a usable width.
const SIDEBAR_DEFAULT = 288;
const SIDEBAR_MIN = 180;
const SIDEBAR_MAX = 720;
const COMMENTS_DEFAULT = 320;
const COMMENTS_MIN = 220;
const COMMENTS_MAX = 720;
// The narrowest the diff itself is allowed to get.
//
// This exists because a review pane is not a browser window. The rails were
// sized for something a screen wide; at half a playpen tab, three of them
// side by side leave the diff a couple of hundred pixels and it renders one
// character per line, which reads as the tool being broken rather than as the
// pane being narrow. So the rails give way instead: they are clamped to what
// is left over, and the comments rail folds away entirely when even that is not
// enough. The *stored* widths are never touched by any of this — opening a
// review in a cramped pane must not cost you the rail sizes you chose.
const MIN_DIFF = 420;
const CTX_KEY = 'review-ctx-by-repo';
const IGNORE_WS_KEY = 'review-ignore-whitespace';
// The diff every review opens on, and the placeholder for when nothing is open
// at all. A module constant so its identity is stable across renders and can't
// retrigger the diff fetch.
//
// HEAD is the default because it's the one base that can't surprise you: it
// shows the work in front of you and nothing else. A release branch is often
// what you actually want, but when its history has moved on under the branch
// you're reviewing the diff fills with commits nobody asked about — which is
// exactly the case where an unasked-for default hurts. The picker offers that
// ref first (see RepoInfo.suggestedBase), one click away.
const HEAD_CTX: DiffContext = { base: 'HEAD', uncommitted: true };
function savedWidth(key: string, fallback: number, min: number, max: number) {
const saved = Number(localStorage.getItem(key));
if (!Number.isFinite(saved) || saved <= 0) return fallback;
return Math.min(Math.max(saved, min), max);
}
// The base-ref selection has to survive a reload. It decides which diff you're
// looking at, and losing it drops you back on the default base — which used to
// look exactly like every comment you'd written having vanished, since a comment
// written against another base has no line in the diff you land on.
//
// Keyed by repository path rather than by tab: a tab is a slot in a window and
// gets renumbered, while the work tree is the thing the selection is about. Close
// a review pane and open another on the same repo and you land where you left off.
function loadCtxByRepo(): Record<string, DiffContext> {
try {
const raw = JSON.parse(localStorage.getItem(CTX_KEY) ?? '{}');
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: Record<string, DiffContext> = {};
for (const [path, v] of Object.entries(raw as Record<string, unknown>)) {
const c = v as Partial<DiffContext>;
if (typeof c?.base === 'string' && typeof c?.uncommitted === 'boolean') {
out[path] = { base: c.base, uncommitted: c.uncommitted };
// A commit you were reading on its own is part of that selection, so a
// reload lands back on it rather than on the whole change set.
if (typeof c.commit === 'string' && c.commit) out[path].commit = c.commit;
}
}
return out;
} catch {
return {};
}
}
function initialTheme(): Theme {
const saved = localStorage.getItem('review-theme');
if (saved === 'light' || saved === 'dark') return saved;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
// What a reset would delete, in words, for the confirmation dialog.
function resetCommentsLine(n: number): string {
if (n === 0) return 'no comments to delete';
const threads = n === 1 ? '1 comment thread' : `all ${n} comment threads`;
return `${threads} deleted — drafts, submitted, and resolved alike`;
}
function scrollToFile(path: string) {
document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: 'smooth' });
}
// flashComment scrolls a thread into view and pulses its outline. Returns false
// when the thread isn't in the DOM — its file is collapsed, or the diff hasn't
// rendered it yet.
function flashComment(id: string): boolean {
const el = document.getElementById(`comment-${id}`);
if (!el) return false;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('is-flashed');
void el.offsetWidth; // restart the flash when the same card is re-clicked
el.classList.add('is-flashed');
window.setTimeout(() => el.classList.remove('is-flashed'), 1800);
return true;
}
// Frames to wait for a thread to mount after its file is asked to expand. A
// large file can take a few to lay out; past this we give up and settle for the
// file header rather than leaving the click with nothing to show.
const REVEAL_FRAMES = 60;
// waitForComment retries the jump each frame until the thread appears, then
// falls back. Frames, not a timeout: the thread arrives on a render, and this
// way the scroll happens on the very first frame it exists.
function waitForComment(id: string, fallback: () => void, frames = REVEAL_FRAMES) {
if (flashComment(id)) return;
if (frames <= 0) {
fallback();
return;
}
requestAnimationFrame(() => waitForComment(id, fallback, frames - 1));
}
export default function App() {
// The one review this page is. Undefined while loading; null once the server
// has said this tab has no review pane bound to a repository.
const [repo, setRepo] = useState<RepoState | null | undefined>(undefined);
const [ctxByRepo, setCtxByRepo] = useState<Record<string, DiffContext>>(loadCtxByRepo);
const [resetOpen, setResetOpen] = useState(false);
const [clearResolvedOpen, setClearResolvedOpen] = useState(false);
const [viewType, setViewType] = useState<ViewType>('split');
// Hide changes that are only whitespace. Not part of the diff context, and so
// not per repo either: it's how you read a diff — like split/unified — rather
// than which diff you're reading, and a reformatting commit in one worktree
// doesn't make it the wrong setting in the next.
const [ignoreWs, setIgnoreWs] = useState(
() => localStorage.getItem(IGNORE_WS_KEY) === 'true',
);
const [payload, setPayload] = useState<DiffPayload | null>(null);
// See the effect that clears these: a diff withheld for being too large, and
// whether its warning has been answered one way or the other.
const [oversized, setOversized] = useState<DiffPayload | null>(null);
const [oversizeDismissed, setOversizeDismissed] = useState(false);
const [comments, setComments] = useState<Comment[]>([]);
const [draft, setDraft] = useState<DraftTarget | null>(null);
// The file a jump wants opened, if it was collapsed. The sequence number makes
// a repeat request for the same file a distinct one, so re-clicking a comment
// after re-folding its file opens it again.
const [reveal, setReveal] = useState<{ file: string; seq: number } | null>(null);
const revealSeq = useRef(0);
const [theme, setTheme] = useState<Theme>(initialTheme);
const [sidebarWidth, setSidebarWidth] = useState(() =>
savedWidth('review-sidebar-width', SIDEBAR_DEFAULT, SIDEBAR_MIN, SIDEBAR_MAX),
);
const [commentsWidth, setCommentsWidth] = useState(() =>
savedWidth('review-comments-width', COMMENTS_DEFAULT, COMMENTS_MIN, COMMENTS_MAX),
);
const [commentsOpen, setCommentsOpen] = useState(
() => localStorage.getItem('review-comments-open') !== 'false',
);
const [connected, setConnected] = useState(false);
// Tracked so the rail clamping below re-runs when the pane is resized —
// dragging a split in playpen is the common case, not a rare one.
const [viewport, setViewport] = useState(() => window.innerWidth);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const path = repo?.path ?? null;
// ctxByRepo holds only the repos whose base ref you've actually changed;
// anything else falls back to HEAD. Deriving rather than seeding state avoids a
// frame where the review is open but has no context yet.
const ctx = path ? (ctxByRepo[path] ?? HEAD_CTX) : HEAD_CTX;
// The parsed diff lives here rather than in DiffView because the comments rail
// needs it too: deciding which comments the diff can still place is one
// judgement, made once, so the rail and the diff can't disagree about it.
const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]);
// What each file's diff currently says, digested. Viewed marks are stored
// against these, so a file whose code moved since you signed off on it comes
// back unmarked instead of quietly staying checked.
const fingerprints = useMemo(() => fingerprintFiles(parsedFiles), [parsedFiles]);
const { viewed, changed, setFileViewed, clearViewed } = useViewedFiles(
path,
ctx,
fingerprints,
ignoreWs,
);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('review-theme', theme);
}, [theme]);
useEffect(() => {
const onResize = () => setViewport(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
localStorage.setItem('review-sidebar-width', String(sidebarWidth));
}, [sidebarWidth]);
useEffect(() => {
localStorage.setItem('review-comments-width', String(commentsWidth));
}, [commentsWidth]);
useEffect(() => {
localStorage.setItem('review-comments-open', String(commentsOpen));
}, [commentsOpen]);
useEffect(() => {
localStorage.setItem(CTX_KEY, JSON.stringify(ctxByRepo));
}, [ctxByRepo]);
useEffect(() => {
localStorage.setItem(IGNORE_WS_KEY, String(ignoreWs));
}, [ignoreWs]);
// The window's tab is what the review is named after, so it belongs in the
// browser title too: a review opened in a real browser alongside two others is
// otherwise three identical tabs.
useEffect(() => {
const name = repo?.path.split('/').pop();
document.title = name ? `${name} · review` : 'review';
}, [repo]);
const flash = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast(null), 2600);
}, []);
const loadRepo = useCallback(async () => {
try {
const info = await api.repo();
setRepo(info.open ? (info as RepoState) : null);
setError(null);
} catch (e) {
setRepo(null);
setError(String(e));
}
}, []);
useEffect(() => {
loadRepo();
}, [loadRepo]);
// A diff the server held back for being too big to render, and whether the
// warning about it has been dismissed. It isn't a `payload`: nothing is loaded,
// so the diff area shows the notice instead of a change set — but it carries
// the file summary, which is what the warning counts.
useEffect(() => {
setOversized(null);
setOversizeDismissed(false);
}, [path, ctx, ignoreWs]);
// Sequence guard: changing the base ref quickly can land an older response
// after a newer one, which would show a diff the controls no longer describe.
const reqRef = useRef(0);
// force answers the size warning: load the diff however big it turned out to be.
// Toggling the whitespace preference re-identifies this callback, which is what
// reloads the diff under the new setting.
const loadDiff = useCallback(
async (c: DiffContext, force = false) => {
const seq = ++reqRef.current;
setLoading(true);
try {
const [d, cs] = await Promise.all([
api.diff(c, { force, ignoreWhitespace: ignoreWs }),
api.comments(),
]);
if (seq !== reqRef.current) return;
setComments(cs);
setError(null);
if (d.oversized) {
setPayload(null);
setOversized(d);
setOversizeDismissed(false);
} else {
setPayload(d);
setOversized(null);
}
} catch (e) {
if (seq === reqRef.current) setError(String(e));
} finally {
if (seq === reqRef.current) setLoading(false);
}
},
[ignoreWs],
);
useEffect(() => {
if (path) loadDiff(ctx);
}, [path, ctx, loadDiff]);
// Tell the server which diff is on screen, so an agent asked to review it lands
// its comments on the lines you're actually looking at. Best-effort — nothing on
// screen depends on it.
useEffect(() => {
if (path) api.setContext(ctx).catch(() => {});
}, [path, ctx]);
const setCtx = useCallback(
(next: DiffContext) => {
if (path) setCtxByRepo((prev) => ({ ...prev, [path]: next }));
},
[path],
);
// selectCommit narrows the diff to one commit of the range, or back to the whole
// change set with undefined. Everything else about the selection is left alone,
// so leaving a commit returns you to the diff you drilled into it from.
const selectCommit = useCallback(
(sha: string | undefined) => setCtx({ ...ctx, commit: sha }),
[ctx, setCtx],
);
// loadOversized answers the size warning by asking for the diff again, this
// time without the guard. Dismissing the warning first is what takes the modal
// down while the (slow, by definition) fetch runs.
const loadOversized = useCallback(() => {
setOversizeDismissed(true);
loadDiff(ctx, true);
}, [ctx, loadDiff]);
const refetchComments = useCallback(() => {
api.comments().then(setComments).catch(() => {});
}, []);
// Live updates. Every event on this stream is about this review — the stream is
// the tab's own — so anything that isn't the opening handshake means the comment
// list moved and is worth refetching.
useSSE(
useCallback(
(e) => {
setConnected(true);
if (e.type === 'connected') return;
refetchComments();
},
[refetchComments],
),
);
// submitDraft creates the comment for the currently-open draft (line/range,
// file, or review level).
const submitDraft = useCallback(
async (body: string) => {
const d = draft;
if (!d) return;
if (d.level === 'line') {
await api.createComment({
level: 'line',
file: d.file,
side: d.side,
line: d.startLine,
endLine: d.endLine,
body,
ctx,
});
} else if (d.level === 'file') {
await api.createComment({ level: 'file', file: d.file, body, ctx });
} else {
await api.createComment({ level: 'review', body, ctx });
}
setDraft(null);
refetchComments();
},
[draft, ctx, refetchComments],
);
const submitReview = useCallback(async () => {
const { submitted } = await api.submit();
refetchComments();
if (submitted === 0) {
flash('No draft comments to submit.');
return;
}
// The pane's own tab is where the agent that should pick these up is running,
// so name it: with several reviews open, which one Claude is meant to work in
// is the one thing the user has to get right.
flash(
`Submitted ${submitted} comment${submitted === 1 ? '' : 's'}` +
`say “address the review” in ${tabId || 'this tab'}.`,
);
}, [refetchComments, flash]);
// resetReview throws the whole review away: every comment on the server, the
// viewed marks in this browser, and the base-ref selection, which goes back to
// the default this repo would open on. Nothing is recoverable, hence the
// confirmation in front of it.
const resetReview = useCallback(async () => {
setResetOpen(false);
try {
await api.reset();
} catch (e) {
setError(String(e));
return;
}
clearViewed();
if (path) {
setCtxByRepo((prev) => {
const { [path]: _dropped, ...rest } = prev;
return rest;
});
}
setDraft(null);
setComments([]);
refetchComments();
flash('Review reset.');
}, [path, clearViewed, refetchComments, flash]);
// deleteResolved clears the finished threads and nothing else. Resolved
// threads are the record of what has already been dealt with, so this is
// confirmed like the reset is — it's just destructive on a smaller scale.
const deleteResolved = useCallback(async () => {
setClearResolvedOpen(false);
let deleted: number;
try {
({ deleted } = await api.deleteResolved());
} catch (e) {
setError(String(e));
return;
}
refetchComments();
flash(
deleted === 0
? 'No resolved comments to delete.'
: `Deleted ${deleted} resolved comment${deleted === 1 ? '' : 's'}.`,
);
}, [refetchComments, flash]);
// jumpToComment scrolls to a comment's thread in the diff and flashes it. A
// line thread is only in the DOM while its file is expanded, so when the
// thread isn't there we ask the file to open (`reveal`) and scroll as soon as
// the thread mounts — the file expanding and the jump landing are one action.
const jumpToComment = useCallback((c: Comment) => {
if (flashComment(c.id)) return;
if (c.level === 'review') return; // always rendered; nothing to open
setReveal({ file: c.file, seq: ++revealSeq.current });
waitForComment(c.id, () => scrollToFile(c.file));
}, []);
const draftCount = useMemo(
() => comments.filter((c) => c.status === 'draft').length,
[comments],
);
const openCount = useMemo(
() => comments.filter((c) => c.status === 'submitted').length,
[comments],
);
const resolvedCount = useMemo(
() => comments.filter((c) => c.status === 'resolved').length,
[comments],
);
const reviewComments = useMemo(
() => comments.filter((c) => c.level === 'review'),
[comments],
);
const fileOrder = useMemo(() => (payload?.files ?? []).map(pathOf), [payload]);
// What the rails actually get, as opposed to what they are set to. See
// MIN_DIFF: the comments rail yields first, then the file rail, and the diff
// keeps the rest.
const commentsRoom = viewport - MIN_DIFF - SIDEBAR_MIN;
const showComments = commentsOpen && commentsRoom >= COMMENTS_MIN;
const railCommentsWidth = Math.min(commentsWidth, commentsRoom);
const railSidebarWidth = Math.max(
SIDEBAR_MIN,
Math.min(sidebarWidth, viewport - MIN_DIFF - (showComments ? railCommentsWidth : 0)),
);
// The commit list comes from whichever payload we have. An oversized diff has no
// patch but does carry the range, and picking one commit out of it is the
// quickest route to something the browser will actually render.
const range = payload ?? oversized;
const commits = range?.commits ?? [];
const anchors = useMemo(
() => (payload ? buildAnchors(parsedFiles, payload.context) : null),
[parsedFiles, payload],
);
// Comments the diff on screen has nowhere to put. They are still shown —
// flagged outdated in the rail, and either at the top of their file or, when
// the file itself has left the change set, in a panel under the diff.
const outdated = useMemo(
() => new Set(comments.filter((c) => isOutdated(c, anchors)).map((c) => c.id)),
[comments, anchors],
);
const orphanedComments = useMemo(
() => comments.filter((c) => outdated.has(c.id) && !anchors?.files.has(c.file)),
[comments, outdated, anchors],
);
if (repo === undefined) {
return <div className="app loading">Connecting</div>;
}
// No review bound to this tab. It isn't an error and there is nothing to pick
// from — the pane takes its repository from the directory the tab is working
// in — so this says what to do rather than offering a browser.
if (!repo) {
return (
<div className="app">
<div className="empty-state">
<Icon name="file-diff" size={32} />
<h1>No review in this tab</h1>
<p>
A review pane takes its repository from the directory the tab is
working in, and {tabId ? <code>{tabId}</code> : 'this tab'} isn't
inside a git work tree. Close the pane and reopen it from a tab whose
terminal is in one.
</p>
{error && <p className="empty-state-error">{error}</p>}
<button className="btn-submit" onClick={loadRepo}>
Try again
</button>
</div>
</div>
);
}
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="brand-mark">
<Icon name="file-diff" />
</span>
<span className="brand-name">{repo.path.split('/').pop()}</span>
<span className="brand-branch" title={repo.path}>
{repo.branch}
</span>
</div>
<div className="controls">
<label className="control">
<span className="control-label">base</span>
<select
value={ctx.base}
// A commit selected out of the old range has no place in the new
// one, so changing the base drops back to the whole change set.
onChange={(e) => setCtx({ ...ctx, base: e.target.value, commit: undefined })}
>
<option value="HEAD">HEAD ({repo.branch})</option>
{/* The release branch (or main) sits directly under HEAD rather
than buried in a ref list hundreds long — it's the base you
reach for when HEAD isn't the one you want. */}
{repo.suggestedBase && (
<option value={repo.suggestedBase}>{repo.suggestedBase}</option>
)}
{repo.refs
?.filter((r) => r !== repo.suggestedBase)
.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
{/* A single commit's diff is fixed history the working tree has
nothing to do with it so the toggle is disabled rather than
quietly doing nothing. */}
<button
className={`toggle${ctx.uncommitted && !ctx.commit ? ' is-on' : ''}`}
onClick={() => setCtx({ ...ctx, uncommitted: !ctx.uncommitted })}
disabled={!!ctx.commit}
title={
ctx.commit
? "Doesn't apply while you're reading a single commit"
: 'Include uncommitted working-tree changes'
}
>
uncommitted
</button>
{/* Which commit is on screen, and the way back out of it. */}
{ctx.commit && (
<button
className="commit-chip"
onClick={() => selectCommit(undefined)}
title="Back to every commit in the range"
>
<Icon name="git-commit" size={14} />
{commits.find((c) => c.sha === ctx.commit)?.shortSha ?? ctx.commit.slice(0, 7)}
<Icon name="x" size={12} />
</button>
)}
<button
className={`toggle${ignoreWs ? ' is-on' : ''}`}
onClick={() => setIgnoreWs(!ignoreWs)}
aria-pressed={ignoreWs}
title="Ignore whitespace-only changes (git diff -w) — files with nothing else in them leave the change set"
>
ignore whitespace
</button>
<div className="segmented" role="group" aria-label="Diff layout">
<button
className={viewType === 'split' ? 'is-active' : ''}
onClick={() => setViewType('split')}
aria-pressed={viewType === 'split'}
title="Split view"
>
<Icon name="columns" size={14} /> split
</button>
<button
className={viewType === 'unified' ? 'is-active' : ''}
onClick={() => setViewType('unified')}
aria-pressed={viewType === 'unified'}
title="Unified view"
>
<Icon name="rows" size={14} /> unified
</button>
</div>
<span className="control-divider" />
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
<button
className="icon-btn"
onClick={() => loadDiff(ctx)}
title="Refresh diff"
aria-label="Refresh diff"
>
<Icon name="sync" />
</button>
<button
className="icon-btn"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="Toggle theme"
aria-label="Toggle theme"
>
<Icon name={theme === 'dark' ? 'moon' : 'sun'} />
</button>
<button
className="icon-btn is-danger"
onClick={() => setResetOpen(true)}
title="Reset review — delete every comment and clear viewed files"
aria-label="Reset review"
>
<Icon name="trash" />
</button>
<span className={`conn${connected ? ' is-live' : ''}`} title="Live connection">
<Icon name="dot-fill" size={12} />
{connected ? 'live' : 'offline'}
</span>
<button className="btn-submit" onClick={submitReview} disabled={draftCount === 0}>
Submit review
{draftCount > 0 && <span className="count">{draftCount}</span>}
</button>
</div>
</header>
{error && (
<div className="banner banner-error">
<Icon name="alert" />
{error}
</div>
)}
<div className="body">
<aside className="sidebar" style={{ width: railSidebarWidth }}>
{commits.length > 0 && (
<CommitList
commits={commits}
more={range?.moreCommits === true}
selected={ctx.commit}
onSelect={selectCommit}
/>
)}
{payload && (
<FileList files={payload.files} comments={comments} onSelect={scrollToFile} />
)}
<div className="sidebar-foot">
{openCount > 0 && (
<div className="review-status">
<Icon name="dot-fill" size={12} /> {openCount} open for Claude
</div>
)}
</div>
</aside>
<Resizer
width={railSidebarWidth}
min={SIDEBAR_MIN}
max={SIDEBAR_MAX}
onChange={setSidebarWidth}
onReset={() => setSidebarWidth(SIDEBAR_DEFAULT)}
/>
<main className="main">
{loading && !payload ? (
<div className="loading">Loading diff</div>
) : oversized ? (
<OversizeNotice
files={oversized.files}
base={ctx.base}
suggested={repo.suggestedBase}
commits={commits.length}
onLoad={loadOversized}
/>
) : payload ? (
<div className="diff-scroll">
<ReviewPanel
comments={reviewComments}
draftActive={draft?.level === 'review'}
onStart={() => setDraft({ level: 'review' })}
onSubmit={submitDraft}
onCancel={() => setDraft(null)}
onChanged={refetchComments}
/>
<DiffView
files={parsedFiles}
comments={comments}
outdated={outdated}
viewType={viewType}
ctx={ctx}
draft={draft}
viewed={viewed}
changed={changed}
reveal={reveal}
onSetViewed={setFileViewed}
onStartDraft={setDraft}
onCancelDraft={() => setDraft(null)}
onSubmitDraft={submitDraft}
onChanged={refetchComments}
/>
<OutdatedPanel comments={orphanedComments} onChanged={refetchComments} />
</div>
) : null}
</main>
{showComments ? (
<>
<Resizer
width={railCommentsWidth}
min={COMMENTS_MIN}
max={COMMENTS_MAX}
panel="right"
onChange={setCommentsWidth}
onReset={() => setCommentsWidth(COMMENTS_DEFAULT)}
/>
<CommentsPanel
comments={comments}
outdated={outdated}
fileOrder={fileOrder}
width={railCommentsWidth}
onJump={jumpToComment}
onDeleteResolved={() => setClearResolvedOpen(true)}
onCollapse={() => setCommentsOpen(false)}
/>
</>
) : (
<CommentsTab
count={draftCount + openCount}
onExpand={() => setCommentsOpen(true)}
/>
)}
</div>
{oversized && !oversizeDismissed && (
<OversizeWarning
files={oversized.files}
base={ctx.base}
suggested={repo.suggestedBase}
commits={commits.length}
onLoad={loadOversized}
onCancel={() => setOversizeDismissed(true)}
/>
)}
{resetOpen && (
<ConfirmDialog
title="Reset this review?"
confirmLabel="Reset review"
onConfirm={resetReview}
onCancel={() => setResetOpen(false)}
>
<p>
<code>{repo.path.split('/').pop()}</code> starts over as if you had just
opened it:
</p>
<ul>
<li>{resetCommentsLine(comments.length)}</li>
<li>every file unmarked as viewed</li>
<li>base ref back to HEAD</li>
</ul>
<p>
<strong>This can't be undone.</strong> Reviews in other tabs are left
alone.
</p>
</ConfirmDialog>
)}
{clearResolvedOpen && (
<ConfirmDialog
title="Delete resolved comments?"
confirmLabel={`Delete ${resolvedCount} resolved`}
onConfirm={deleteResolved}
onCancel={() => setClearResolvedOpen(false)}
>
<p>
{resolvedCount === 1
? 'The 1 resolved thread is removed'
: `All ${resolvedCount} resolved threads are removed`}
, with their replies. Drafts, open threads, and your viewed files are
left alone.
</p>
<p>
<strong>This can't be undone.</strong>
</p>
</ConfirmDialog>
)}
{toast && <div className="toast">{toast}</div>}
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import type {
Author,
Comment,
DiffContext,
DiffPayload,
Level,
RepoState,
Side,
} from './types';
// Every review lives under its tab's own path — `/t/<tabId>/` — and this page was
// served from inside one, so the tab it belongs to is simply where it is. That is
// the whole of the addressing: there is no repository to name, no tab bar to keep
// in sync, and no way for a request to land on the wrong review.
//
// Taken from the document URL rather than injected at build time so the same
// bundle serves every tab, and so opening a review in an ordinary browser
// (handy when the pane itself is misbehaving) works without ceremony.
const base = (() => {
const match = /^\/t\/[^/]+\//.exec(window.location.pathname);
// The fallback keeps `vite dev` usable, where the page is served from `/` and
// the proxy in vite.config.ts forwards to a tab chosen there.
return match ? match[0].slice(0, -1) : '';
})();
export const apiBase = `${base}/api`;
// The tab this page is the review for. Shown in the UI's title, and the thing to
// quote when telling an agent which review to work on.
export const tabId = (() => {
const match = /^\/t\/([^/]+)\//.exec(window.location.pathname);
return match ? decodeURIComponent(match[1]) : '';
})();
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text();
// The server answers errors as `{"error": "..."}`; surfacing that sentence
// beats surfacing a status code, because it is written for a person.
try {
const parsed = JSON.parse(body) as { error?: string };
if (parsed?.error) throw new Error(parsed.error);
} catch (e) {
if (e instanceof Error && e.message && !e.message.startsWith('Unexpected')) throw e;
}
throw new Error(`${res.status} ${res.statusText}: ${body}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
function ctxParams(ctx: DiffContext): string[] {
const params = [
`base=${encodeURIComponent(ctx.base)}`,
`uncommitted=${ctx.uncommitted}`,
];
if (ctx.commit) params.push(`commit=${encodeURIComponent(ctx.commit)}`);
return params;
}
const q = (...parts: string[]) => (parts.length ? `?${parts.join('&')}` : '');
const postJSON = (url: string, body?: unknown) =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
export const api = {
// The review this tab has open, or `{open: false}` when it has none — which
// happens if the pane outlives the tab's repository, or the page is opened by
// hand against a tab that never resolved one.
repo: () => fetch(`${apiBase}/repo`).then(json<{ open: boolean } & Partial<RepoState>>),
// Publish the diff selection on screen. The base ref, the uncommitted toggle
// and the selected commit are browser state, so without this an agent asked to
// review "the diff I'm looking at" has no way to know what that is — and a
// comment anchored to another diff's line numbers has no line to land on.
// Best-effort: the UI works fine if it fails.
setContext: (ctx: DiffContext) =>
postJSON(`${apiBase}/repo/context`, {
base: ctx.base,
uncommitted: ctx.uncommitted,
commit: ctx.commit ?? '',
}).then(json<{ ok: boolean }>),
// A diff too big for the browser to render comes back `oversized`, with the
// file summary but no patch — enough to say how big it is and ask. Pass force
// to get the patch anyway; that's the answer to the question, not a default.
//
// ignoreWhitespace drops changes that are only whitespace (and the files where
// that's all there is). It shapes the patch, not the selection — comments stay
// tagged with the context, so toggling it never re-files them.
diff: (
ctx: DiffContext,
opts: { force?: boolean; ignoreWhitespace?: boolean } = {},
) =>
fetch(
`${apiBase}/diff${q(
...ctxParams(ctx),
...(opts.force ? ['force=1'] : []),
...(opts.ignoreWhitespace ? ['ignoreWhitespace=1'] : []),
)}`,
).then(json<DiffPayload>),
// Full contents of a file at a ref, for expanding collapsed context. Null when
// the file doesn't exist at that ref (e.g. a newly added file).
fileContent: async (ref: string, path: string): Promise<string | null> => {
const res = await fetch(
`${apiBase}/file${q(
`ref=${encodeURIComponent(ref)}`,
`path=${encodeURIComponent(path)}`,
)}`,
);
return res.ok ? res.text() : null;
},
// Every comment in the review, whichever base ref it was written against. Each
// carries its own `context`; lib/anchor decides which ones the diff on screen
// can still place. Deliberately not filtered server-side — see Store.list — so
// changing the base ref can never look like losing comments.
comments: () =>
fetch(`${apiBase}/comments`)
.then(json<Comment[] | null>)
.then((cs) => cs ?? []),
createComment: (input: {
level: Level;
file?: string;
side?: Side;
line?: number;
endLine?: number;
body: string;
ctx: DiffContext;
}) =>
postJSON(`${apiBase}/comments`, {
level: input.level,
file: input.file ?? '',
side: input.side ?? '',
line: input.line ?? 0,
endLine: input.endLine ?? 0,
body: input.body,
base: input.ctx.base,
uncommitted: input.ctx.uncommitted,
commit: input.ctx.commit ?? '',
}).then(json<Comment>),
updateComment: (id: string, body: string) =>
fetch(`${apiBase}/comments/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
}).then(json<Comment>),
deleteComment: (id: string) =>
fetch(`${apiBase}/comments/${id}`, { method: 'DELETE' }).then(json<void>),
addReply: (id: string, body: string, author: Author = 'user') =>
postJSON(`${apiBase}/comments/${id}/replies`, { body, author }).then(json<Comment>),
updateReply: (id: string, replyId: string, body: string) =>
fetch(`${apiBase}/comments/${id}/replies/${replyId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
}).then(json<Comment>),
resolve: (id: string) => postJSON(`${apiBase}/comments/${id}/resolve`).then(json<Comment>),
reopen: (id: string) => postJSON(`${apiBase}/comments/${id}/reopen`).then(json<Comment>),
// Submits every draft in the review — the same set the rail shows.
submit: () => postJSON(`${apiBase}/review/submit`).then(json<{ submitted: number }>),
// Deletes every comment, whatever its status. The reviewer's viewed marks are
// browser-side — see lib/viewed — so a full reset clears those too; App does both.
reset: () => postJSON(`${apiBase}/review/reset`).then(json<{ cleared: number }>),
// Deletes the resolved comments and leaves everything else — drafts, open
// threads, and the viewed marks — alone.
deleteResolved: () =>
postJSON(`${apiBase}/review/delete-resolved`).then(json<{ deleted: number }>),
};
+337
View File
@@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import type { Comment } from '../types';
import { api } from '../api';
import { Icon } from './Icon';
function initials(author: string): string {
return author === 'claude' ? 'AI' : 'ME';
}
function timeAgo(iso: string): string {
const then = new Date(iso).getTime();
const secs = Math.max(1, Math.round((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}
interface Props {
comments: Comment[];
onChanged: () => void;
}
// CommentThread renders every comment anchored to one line, GitHub-style, with
// its replies and a reply composer.
export function CommentThread({ comments, onChanged }: Props) {
return (
<div className="thread">
{comments.map((c) => (
<SingleThread key={c.id} comment={c} onChanged={onChanged} />
))}
</div>
);
}
// summarize reduces a thread to the single line shown while it is collapsed.
function summarize(body: string): string {
const line = body.trim().split('\n')[0];
return line.length > 110 ? line.slice(0, 110) + '…' : line;
}
function SingleThread({
comment,
onChanged,
}: {
comment: Comment;
onChanged: () => void;
}) {
const [replyText, setReplyText] = useState('');
const [busy, setBusy] = useState(false);
// The id of the message being edited — the comment's own id for the opening
// message, a reply's id for a reply. Ids are unique across the thread, so one
// piece of state is enough, and at most one editor is ever open.
const [editingId, setEditingId] = useState<string | null>(null);
// Resolved threads collapse to a one-line summary, GitHub-style. Not derived
// from `status`: reopening has to leave the thread open, and expanding a
// resolved thread must not reopen it.
const [showResolved, setShowResolved] = useState(false);
const resolved = comment.status === 'resolved';
const submitReply = async () => {
if (!replyText.trim()) return;
setBusy(true);
try {
await api.addReply(comment.id, replyText.trim(), 'user');
setReplyText('');
onChanged();
} finally {
setBusy(false);
}
};
// Editing the opening message and editing a reply hit different endpoints,
// so the target id decides which one.
const saveEdit = async (targetId: string, body: string) => {
setBusy(true);
try {
targetId === comment.id
? await api.updateComment(comment.id, body)
: await api.updateReply(comment.id, targetId, body);
setEditingId(null);
onChanged();
} finally {
setBusy(false);
}
};
const del = async () => {
setBusy(true);
try {
await api.deleteComment(comment.id);
onChanged();
} finally {
setBusy(false);
}
};
const toggleResolve = async () => {
setBusy(true);
try {
resolved
? await api.reopen(comment.id)
: await api.resolve(comment.id);
// Resolving collapses the thread; anything reopened starts expanded.
setShowResolved(false);
onChanged();
} finally {
setBusy(false);
}
};
const replyCount = comment.replies.length;
// The element id is the jump target used by the comments rail, so it has to
// stay on the outermost node in both the collapsed and expanded shapes.
if (resolved && !showResolved) {
return (
<div id={`comment-${comment.id}`} className="thread-card is-resolved">
<button
className="thread-collapsed"
onClick={() => setShowResolved(true)}
title="Show resolved conversation"
>
<span className="thread-resolved-check">
<Icon name="check-circle-fill" />
</span>
<span className="thread-resolved-label">Resolved</span>
<span className="thread-collapsed-preview">{summarize(comment.body)}</span>
{replyCount > 0 && (
<span className="thread-collapsed-count">
{replyCount + 1} comments
</span>
)}
<span className="thread-collapsed-show">Show resolved</span>
</button>
</div>
);
}
return (
<div
id={`comment-${comment.id}`}
className={`thread-card${resolved ? ' is-resolved' : ''}`}
>
{resolved && (
<div className="thread-resolved-bar">
<span className="thread-resolved-check">
<Icon name="check-circle-fill" />
</span>
<span className="thread-resolved-label">Resolved</span>
<button className="thread-hide" onClick={() => setShowResolved(false)}>
Hide
</button>
</div>
)}
<Bubble
author={comment.author}
body={comment.body}
time={comment.createdAt}
status={comment.status}
onEdit={busy ? undefined : () => setEditingId(comment.id)}
editor={
editingId === comment.id ? (
<BodyEditor
initial={comment.body}
busy={busy}
onSave={(body) => saveEdit(comment.id, body)}
onCancel={() => setEditingId(null)}
/>
) : null
}
/>
{comment.replies.map((r) => (
<Bubble
key={r.id}
author={r.author}
body={r.body}
time={r.createdAt}
onEdit={busy ? undefined : () => setEditingId(r.id)}
editor={
editingId === r.id ? (
<BodyEditor
initial={r.body}
busy={busy}
onSave={(body) => saveEdit(r.id, body)}
onCancel={() => setEditingId(null)}
/>
) : null
}
/>
))}
<div className="thread-reply">
<textarea
className="reply-input"
placeholder={resolved ? 'Reopen to reply…' : 'Reply…'}
value={replyText}
disabled={resolved || busy}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitReply();
}}
/>
<div className="thread-actions">
<button className="btn-ghost" onClick={del} disabled={busy}>
Delete
</button>
<button className="btn-ghost" onClick={toggleResolve} disabled={busy}>
{resolved ? 'Reopen' : 'Resolve'}
</button>
<button
className="btn-primary"
onClick={submitReply}
disabled={busy || resolved || !replyText.trim()}
>
Reply
</button>
</div>
</div>
</div>
);
}
// BodyEditor edits a comment's text in place of its rendered body. It starts
// from the saved text and only reports a change on save, so cancelling always
// leaves the stored comment untouched.
function BodyEditor({
initial,
busy,
onSave,
onCancel,
}: {
initial: string;
busy: boolean;
onSave: (body: string) => void;
onCancel: () => void;
}) {
const [text, setText] = useState(initial);
const ref = useRef<HTMLTextAreaElement>(null);
// Focus with the caret at the end — you're almost always amending, not
// retyping from the start.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}, []);
const trimmed = text.trim();
const unchanged = trimmed === initial.trim();
const save = () => {
if (trimmed && !unchanged) onSave(trimmed);
};
return (
<div className="bubble-edit">
<textarea
ref={ref}
className="edit-input"
value={text}
disabled={busy}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save();
if (e.key === 'Escape') onCancel();
}}
/>
<div className="edit-actions">
<span className="composer-hint"> to save · esc to cancel</span>
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
Cancel
</button>
<button
className="btn-primary"
onClick={save}
disabled={busy || !trimmed || unchanged}
>
Save
</button>
</div>
</div>
);
}
function Bubble({
author,
body,
time,
status,
editor,
onEdit,
}: {
author: string;
body: string;
time: string;
status?: string;
// When present, replaces the rendered body — the comment is being edited.
editor?: ReactNode;
// Opens the editor for this message. Omitted while the thread is busy.
onEdit?: () => void;
}) {
return (
<div className="bubble">
<div className={`avatar avatar-${author}`}>{initials(author)}</div>
<div className="bubble-body">
<div className="bubble-head">
<span className="bubble-author">
{author === 'claude' ? 'Claude' : 'You'}
</span>
<span className="bubble-time">{timeAgo(time)}</span>
{status === 'draft' && <span className="pill pill-draft">draft</span>}
{status === 'submitted' && (
<span className="pill pill-open">open</span>
)}
{status === 'resolved' && (
<span className="pill pill-resolved">resolved</span>
)}
{!editor && onEdit && (
<button
className="bubble-edit-btn"
onClick={onEdit}
title="Edit this comment"
aria-label="Edit this comment"
>
<Icon name="pencil" size={12} />
</button>
)}
</div>
{editor ?? <div className="bubble-text">{body}</div>}
</div>
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { useMemo, useState } from 'react';
import type { Comment, Status } from '../types';
import { Icon } from './Icon';
// Filters are by status, plus `outdated`, which cuts across status: it's about
// whether the diff can still place a comment, not where it is in its lifecycle.
type Filter = 'all' | 'draft' | 'submitted' | 'resolved' | 'outdated';
const FILTERS: { key: Filter; label: string }[] = [
{ key: 'all', label: 'all' },
{ key: 'draft', label: 'drafts' },
{ key: 'submitted', label: 'open' },
{ key: 'resolved', label: 'done' },
{ key: 'outdated', label: 'outdated' },
];
interface Props {
comments: Comment[];
// Ids of comments the current diff can't place — see lib/anchor.
outdated: ReadonlySet<string>;
fileOrder: string[]; // file paths in diff order, for grouping
width: number;
onJump: (c: Comment) => void;
onDeleteResolved: () => void;
onCollapse: () => void;
}
// location describes where a comment lives, in the compact form the rail shows.
function location(c: Comment): string {
if (c.level === 'review') return 'overall';
if (c.level === 'file') return 'whole file';
const line = c.endLine || c.line;
return c.endLine && c.endLine !== c.line
? `L${c.line}${c.endLine}`
: `L${line}`;
}
function statusPill(status: Status) {
const label =
status === 'draft' ? 'draft' : status === 'resolved' ? 'resolved' : 'open';
return <span className={`pill pill-${status === 'submitted' ? 'open' : status}`}>{label}</span>;
}
// CommentsPanel is the right rail: every comment in the review, grouped by file,
// with a click to jump to the thread in the diff.
export function CommentsPanel({
comments,
outdated,
fileOrder,
width,
onJump,
onDeleteResolved,
onCollapse,
}: Props) {
const [filter, setFilter] = useState<Filter>('all');
const counts = useMemo(() => {
const c = {
all: comments.length,
draft: 0,
submitted: 0,
resolved: 0,
outdated: 0,
};
for (const cm of comments) {
c[cm.status]++;
if (outdated.has(cm.id)) c.outdated++;
}
return c;
}, [comments, outdated]);
// Group by file (review-level comments first), keeping the diff's file order
// and line order within a file.
const groups = useMemo(() => {
const shown = comments.filter((c) =>
filter === 'all'
? true
: filter === 'outdated'
? outdated.has(c.id)
: c.status === filter,
);
const rank = new Map(fileOrder.map((p, i) => [p, i]));
const byFile = new Map<string, Comment[]>();
const review: Comment[] = [];
for (const c of shown) {
if (c.level === 'review') {
review.push(c);
continue;
}
const list = byFile.get(c.file);
if (list) list.push(c);
else byFile.set(c.file, [c]);
}
const files = [...byFile.entries()].sort(
([a], [b]) =>
(rank.get(a) ?? Number.MAX_SAFE_INTEGER) -
(rank.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b),
);
// File-level comments head their file's group; line comments follow in
// line order.
const levelRank = (c: Comment) => (c.level === 'file' ? 0 : 1);
for (const [, cs] of files) {
cs.sort(
(a, b) =>
levelRank(a) - levelRank(b) ||
(a.line || 0) - (b.line || 0) ||
a.createdAt.localeCompare(b.createdAt),
);
}
return [
...(review.length > 0
? ([['', review]] as [string, Comment[]][])
: []),
...files,
];
}, [comments, outdated, fileOrder, filter]);
const total = groups.reduce((n, [, cs]) => n + cs.length, 0);
return (
<aside className="comments-rail" style={{ width }}>
<div className="comments-head">
<span className="comments-title">
comments{comments.length > 0 && <span className="count">{comments.length}</span>}
</span>
<span className="comments-head-actions">
{/* Only offered when there's something to clear — a control that can
never do anything is just noise in a narrow rail. */}
{counts.resolved > 0 && (
<button
className="rail-action is-danger"
onClick={onDeleteResolved}
title={`Delete ${counts.resolved} resolved comment${
counts.resolved === 1 ? '' : 's'
}`}
aria-label="Delete resolved comments"
>
<Icon name="trash" size={14} />
</button>
)}
<button
className="rail-action"
onClick={onCollapse}
title="Hide comments"
aria-label="Hide comments"
>
<Icon name="chevron-right" />
</button>
</span>
</div>
<div className="comments-filters">
{FILTERS.map((f) => (
<button
key={f.key}
className={`comments-filter${filter === f.key ? ' is-active' : ''}`}
onClick={() => setFilter(f.key)}
disabled={counts[f.key] === 0 && f.key !== 'all'}
>
{f.label}
<span className="comments-filter-n">{counts[f.key]}</span>
</button>
))}
</div>
<div className="comments-list">
{total === 0 ? (
<p className="comments-empty">
{comments.length === 0
? 'No comments yet. Drag across the line gutter to start one.'
: 'Nothing matches this filter.'}
</p>
) : (
groups.map(([file, cs]) => (
<section key={file || '__review'} className="comments-group">
<h3 className="comments-group-head" title={file || 'Review-level'}>
{file ? file.split('/').pop() : 'Review'}
{file && (
<span className="comments-group-dir">
{file.slice(0, file.length - (file.split('/').pop()?.length ?? 0))}
</span>
)}
</h3>
<ul>
{cs.map((c) => (
<li key={c.id}>
<button
className={`comment-card status-${c.status}${
outdated.has(c.id) ? ' is-outdated' : ''
}`}
onClick={() => onJump(c)}
title={
outdated.has(c.id)
? 'Outdated — the code it was written on is no longer in this diff. Jump to it.'
: 'Jump to this comment'
}
>
<span className="comment-card-head">
<span className="comment-card-where">{location(c)}</span>
{statusPill(c.status)}
{outdated.has(c.id) && (
<span className="pill pill-outdated">outdated</span>
)}
<span className="comment-card-who">
{c.author === 'claude' ? 'Claude' : 'You'}
</span>
</span>
<span className="comment-card-body">{c.body}</span>
{c.replies.length > 0 && (
<span className="comment-card-replies">
<Icon name="reply" size={12} /> {c.replies.length}{' '}
{c.replies.length === 1 ? 'reply' : 'replies'}
{c.replies[c.replies.length - 1].author === 'claude' &&
' · Claude'}
</span>
)}
</button>
</li>
))}
</ul>
</section>
))
)}
</div>
</aside>
);
}
// CommentsTab is the thin strip shown in place of the rail when it's collapsed.
export function CommentsTab({
count,
onExpand,
}: {
count: number;
onExpand: () => void;
}) {
return (
<button className="comments-tab" onClick={onExpand} title="Show comments">
<Icon name="chevron-left" />
<span className="comments-tab-label">comments</span>
{count > 0 && <span className="count">{count}</span>}
</button>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react';
import type { Commit } from '../types';
import { relativeTime } from '../lib/time';
import { Icon } from './Icon';
interface Props {
// The commits the change set spans, oldest first.
commits: Commit[];
// The range holds more than this list — see git.maxCommits.
more: boolean;
// The sha currently being read on its own, if any.
selected?: string;
// Select one commit's diff, or the whole change set again with undefined.
onSelect: (sha: string | undefined) => void;
}
const OPEN_KEY = 'review-commits-open';
// CommitList is the top of the left rail: the commits the diff is made of, any one
// of which can be read on its own.
//
// It's the answer to a change set that only makes sense a step at a time — a
// branch where one commit moves code and the next changes it, which read as one
// unintelligible patch together. The rows are in the order they were written,
// because that's the order they were meant to be read in.
export function CommitList({ commits, more, selected, onSelect }: Props) {
const [open, setOpen] = useState(
() => localStorage.getItem(OPEN_KEY) !== 'false',
);
const toggle = () => {
setOpen(!open);
localStorage.setItem(OPEN_KEY, String(!open));
};
return (
<nav className="commitlist">
<button
className="commitlist-head"
onClick={toggle}
aria-expanded={open}
title={open ? 'Hide the commit list' : 'Show the commit list'}
>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={12} />
<span>
{commits.length}
{more ? '+' : ''} commit{commits.length === 1 && !more ? '' : 's'}
</span>
{/* Which one you're on stays legible with the list folded away. */}
{selected && (
<span className="commitlist-head-sha">{shortOf(commits, selected)}</span>
)}
</button>
{open && (
<ul>
<li>
<button
className={`commitlist-row${selected ? '' : ' is-active'}`}
onClick={() => onSelect(undefined)}
title="Show every commit in the range at once"
>
<span className="commitlist-icon">
<Icon name="file-diff" size={14} />
</span>
<span className="commitlist-subject">All commits</span>
</button>
</li>
{more && (
<li className="commitlist-note">
only the newest {commits.length} are listed the range holds more
</li>
)}
{commits.map((c) => (
<li key={c.sha}>
<button
className={`commitlist-row${c.sha === selected ? ' is-active' : ''}`}
onClick={() => onSelect(c.sha)}
title={`${c.subject}\n\n${c.sha}\n${c.author}`}
>
<span className="commitlist-icon">
<Icon name="git-commit" size={14} />
</span>
<span className="commitlist-body">
<span className="commitlist-subject">{c.subject}</span>
<span className="commitlist-meta">
<span className="commitlist-sha">{c.shortSha}</span>
{c.author && <span className="commitlist-author">{c.author}</span>}
{c.date && <span>{relativeTime(c.date)}</span>}
{/* A merge gets no stats from git, so there's nothing to show. */}
{c.files > 0 && (
<span className="commitlist-stats">
<span className="add">+{c.additions}</span>
<span className="del">{c.deletions}</span>
</span>
)}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</nav>
);
}
// shortOf abbreviates the selected sha, preferring the abbreviation git chose for
// it. A commit selected before a refresh dropped it out of the range still has to
// render as something, hence the fallback.
function shortOf(commits: Commit[], sha: string): string {
return commits.find((c) => c.sha === sha)?.shortSha ?? sha.slice(0, 7);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
onSubmit: (body: string) => Promise<void>;
onCancel: () => void;
}
// Composer is the inline "add a comment" box shown under a line.
export function Composer({ onSubmit, onCancel }: Props) {
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
const submit = async () => {
if (!text.trim()) return;
setBusy(true);
try {
await onSubmit(text.trim());
setText('');
} finally {
setBusy(false);
}
};
return (
<div className="composer">
<textarea
ref={ref}
className="composer-input"
placeholder="Leave a comment on this line…"
value={text}
disabled={busy}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit();
if (e.key === 'Escape') onCancel();
}}
/>
<div className="composer-actions">
<span className="composer-hint"> to add · esc to cancel</span>
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
Cancel
</button>
<button
className="btn-primary"
onClick={submit}
disabled={busy || !text.trim()}
>
Add comment
</button>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Icon } from './Icon';
interface Props {
title: string;
children: ReactNode;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
// A modal confirmation, for actions that destroy something the user can't get
// back. Escape and a click outside both cancel, and focus lands on Cancel rather
// than the destructive button so a stray Enter can't confirm it.
export function ConfirmDialog({
title,
children,
confirmLabel,
onConfirm,
onCancel,
}: Props) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return (
<div className="dialog-overlay" onMouseDown={onCancel}>
<div
className="dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="dialog-head">
<h2 className="dialog-title">{title}</h2>
<button className="icon-btn" onClick={onCancel} aria-label="Cancel">
<Icon name="x" />
</button>
</div>
<div className="dialog-body">{children}</div>
<div className="dialog-actions">
<button className="btn-ghost" ref={cancelRef} onClick={onCancel}>
Cancel
</button>
<button className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+694
View File
@@ -0,0 +1,694 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import {
Decoration,
Diff,
Hunk,
getChangeKey,
getCollapsedLinesCountBetween,
markEdits,
tokenize,
useSourceExpansion,
type ChangeData,
type FileData,
type HunkData,
type HunkTokens,
type ViewType,
} from 'react-diff-view';
import { api } from '../api';
import type { Comment, DiffContext, DraftTarget, Side } from '../types';
import { anchorLine, changeKeyIndex, filePath, lineFor } from '../lib/anchor';
import { languageForFile, refractorAdapter } from '../lib/language';
import { CommentThread } from './CommentThread';
import { Composer } from './Composer';
import { Icon } from './Icon';
import { OutdatedNote } from './Outdated';
interface Props {
files: FileData[];
comments: Comment[];
// Ids of comments whose anchor is no longer in the diff (see lib/anchor).
// Those belonging to a file still in the change set are shown in that file,
// apart from the code, rather than pinned to a line that no longer means
// what they were written about.
outdated: ReadonlySet<string>;
viewType: ViewType;
ctx: DiffContext;
draft: DraftTarget | null;
viewed: ReadonlySet<string>;
// Files that lost their viewed mark this session because their diff changed.
// Flagged in the header so the mark coming off doesn't look like a glitch.
changed: ReadonlySet<string>;
// Path of a file to force open, with a sequence number so re-requesting the
// same file counts as a new request. Set when a jump targets a thread inside
// a collapsed file.
reveal: { file: string; seq: number } | null;
onSetViewed: (file: string, viewed: boolean) => void;
onStartDraft: (d: DraftTarget) => void;
onCancelDraft: () => void;
onSubmitDraft: (body: string) => Promise<void>;
onChanged: () => void;
}
// Highlighting a whole file is linear in its size, but Prism on a megabyte of
// minified output blocks the frame for long enough to feel broken. Past this we
// render the diff unhighlighted, as GitHub does for generated blobs.
const MAX_HIGHLIGHT_BYTES = 512 * 1024;
export function DiffView({
files,
comments,
outdated,
viewType,
ctx,
viewed,
changed,
reveal,
onSetViewed,
onStartDraft,
onCancelDraft,
onSubmitDraft,
onChanged,
draft,
}: Props) {
if (files.length === 0) {
return (
<div className="empty-diff">
<div className="empty-diff-mark"></div>
<p>No changes for this selection.</p>
<p className="muted">Try a different base ref or toggle uncommitted changes.</p>
</div>
);
}
return (
<>
{files.map((file) => (
<FileView
key={filePath(file) + file.oldRevision + file.newRevision}
file={file}
base={ctx.base}
comments={comments.filter((c) => c.file === filePath(file))}
outdated={outdated}
viewType={viewType}
draft={draft}
viewed={viewed.has(filePath(file))}
changed={changed.has(filePath(file))}
reveal={reveal?.file === filePath(file) ? reveal.seq : null}
onSetViewed={onSetViewed}
onStartDraft={onStartDraft}
onCancelDraft={onCancelDraft}
onSubmitDraft={onSubmitDraft}
onChanged={onChanged}
/>
))}
</>
);
}
function statusLabel(type: string): string {
switch (type) {
case 'add':
return 'added';
case 'delete':
return 'deleted';
case 'rename':
return 'renamed';
case 'copy':
return 'copied';
default:
return 'modified';
}
}
interface DragState {
side: Side;
anchor: number;
head: number;
}
function FileView({
file,
base,
comments,
outdated,
viewType,
draft,
viewed,
changed,
reveal,
onSetViewed,
onStartDraft,
onCancelDraft,
onSubmitDraft,
onChanged,
}: {
file: FileData;
base: string;
comments: Comment[];
outdated: ReadonlySet<string>;
viewType: ViewType;
draft: DraftTarget | null;
viewed: boolean;
changed: boolean;
reveal: number | null;
onSetViewed: Props['onSetViewed'];
onStartDraft: (d: DraftTarget) => void;
onCancelDraft: () => void;
onSubmitDraft: Props['onSubmitDraft'];
onChanged: () => void;
}) {
const path = filePath(file);
// Collapsed and viewed are independent — you can fold a file you haven't read
// and read one you leave open — but a file already marked viewed opens folded,
// and the checkbox folds it for you (see toggleViewed).
const [collapsed, setCollapsed] = useState(viewed);
const [drag, setDrag] = useState<DragState | null>(null);
// A jump from the comments rail can target a thread inside a collapsed file;
// opening the file here is what puts that thread in the DOM for the scroll to
// find. Keyed on the request's sequence number, so clicking the same comment
// again after re-folding the file opens it again.
useEffect(() => {
if (reveal != null) setCollapsed(false);
}, [reveal]);
// Losing the viewed mark to a change is the one thing that unfolds a file on
// its own. Marking it viewed folded it away; the code under that fold is no
// longer the code you approved, so it comes back open.
useEffect(() => {
if (changed) setCollapsed(false);
}, [changed]);
// Comments still anchored in this diff render against their line (or, for
// file-level ones, at the top of the file). The rest are outdated: kept, but
// gathered above the code with a note, since the line they named is gone.
const lineComments = comments.filter(
(c) => c.level === 'line' && !outdated.has(c.id),
);
const fileComments = comments.filter((c) => c.level === 'file');
const staleComments = comments.filter(
(c) => c.level === 'line' && outdated.has(c.id),
);
// Fetch the base-side source so collapsed context can be expanded on demand.
// Added files have no base version, so expansion is disabled for them.
const [oldSource, setOldSource] = useState<string | null>(null);
useEffect(() => {
if (file.type === 'add') {
setOldSource(null);
return;
}
let cancelled = false;
api.fileContent(base, file.oldPath).then((s) => {
if (!cancelled) setOldSource(s);
});
return () => {
cancelled = true;
};
}, [base, file.oldPath, file.type]);
const [hunks, expandRange] = useSourceExpansion(file.hunks, oldSource);
const canExpand = oldSource != null;
// Number of lines in the base file, ignoring the trailing newline so we don't
// count a phantom empty line at the end.
const totalOldLines = useMemo(
() => (oldSource != null ? oldSource.replace(/\n$/, '').split('\n').length : null),
[oldSource],
);
// Highlighting is done over the *whole* file, never over the visible hunks
// alone. Prism is a stateful tokenizer: a construct that opens above the first
// visible line — a block comment, a template literal, a heredoc — leaves it in
// the wrong state and mis-colours everything after it, so what got highlighted
// would depend on which context happened to be collapsed. Handing it the base
// source (react-diff-view derives the head side by applying `hunks`) makes the
// result identical no matter what is expanded.
//
// A wholly added or deleted file needs no base source: its hunks already carry
// every line, so tokenizing them is exact. Otherwise we wait for the fetch
// rather than highlight a fragment — a beat of plain text beats wrong colours.
const tokens: HunkTokens | undefined = useMemo(() => {
const lang = languageForFile(path);
if (!lang) return undefined;
const whole = file.type === 'add' ? undefined : (oldSource ?? undefined);
if (file.type !== 'add' && whole === undefined) return undefined;
if (whole !== undefined && whole.length > MAX_HIGHLIGHT_BYTES) return undefined;
try {
return tokenize(hunks, {
highlight: true,
refractor: refractorAdapter,
language: lang,
oldSource: whole,
// Word-level marks inside a changed line, the way GitHub shows them.
enhancers: [markEdits(hunks, { type: 'block' })],
});
} catch {
return undefined;
}
}, [hunks, path, oldSource, file.type]);
// Map "side:line" -> react-diff-view change key, so we can attach widgets.
const lineKeyToChangeKey = useMemo(() => changeKeyIndex(hunks), [hunks]);
// rangeKeys returns the change keys of lines [start, end] on a side, used to
// highlight a selection or an existing comment's range.
const rangeKeys = useCallback(
(side: Side, start: number, end: number): string[] => {
const lo = Math.min(start, end);
const hi = Math.max(start, end);
const keys: string[] = [];
for (const hunk of hunks) {
for (const change of hunk.changes) {
const l = lineFor(change, side);
if (l != null && l >= lo && l <= hi) keys.push(getChangeKey(change));
}
}
return keys;
},
[hunks],
);
// Group line comments (and the active line-draft composer) by change key. A
// range comment anchors to its end line. Anything that isn't outdated has a
// line in this diff by construction, so a missing key here would mean the
// anchor index and the rendered hunks disagreed — drop it rather than render
// the thread against the wrong line; it still shows in the comments rail.
const widgets = useMemo(() => {
const contentByKey: Record<string, ReactNode[]> = {};
const grouped: Record<string, Comment[]> = {};
for (const c of lineComments) {
const key = lineKeyToChangeKey[`${c.side}:${anchorLine(c)}`];
if (key) (grouped[key] ??= []).push(c);
}
for (const [key, cs] of Object.entries(grouped)) {
contentByKey[key] = [
<CommentThread key="thread" comments={cs} onChanged={onChanged} />,
];
}
if (draft?.level === 'line' && draft.file === path) {
(contentByKey[draft.changeKey] ??= []).push(
<Composer key="composer" onSubmit={onSubmitDraft} onCancel={onCancelDraft} />,
);
}
const built: Record<string, ReactNode> = {};
for (const [key, nodes] of Object.entries(contentByKey)) {
built[key] = <div className="line-widget">{nodes}</div>;
}
return built;
}, [lineComments, lineKeyToChangeKey, draft, path, onChanged, onSubmitDraft, onCancelDraft]);
// Highlight the lines being dragged, or the pending line-draft's range.
const selectedKeys = useMemo(() => {
if (drag) return rangeKeys(drag.side, drag.anchor, drag.head);
if (draft?.level === 'line' && draft.file === path) {
return rangeKeys(draft.side, draft.startLine, draft.endLine);
}
return [];
}, [drag, draft, path, rangeKeys]);
const selectedSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
const generateLineClassName = useCallback(
({ changes }: { changes: ChangeData[] }) => {
// A split-view row can have an empty side, so `changes` may contain a
// falsy slot — getChangeKey() throws on those. Skip work when nothing is
// selected, and guard falsy changes otherwise.
if (selectedSet.size === 0) return '';
return changes.some((c) => c && selectedSet.has(getChangeKey(c)))
? 'line-selected'
: '';
},
[selectedSet],
);
// Click-and-drag range selection on the gutter (GitHub style).
const gutterEvents = useMemo(
() => ({
onMouseDown: (
{ change, side }: { change: ChangeData | null; side?: Side },
e: { preventDefault(): void },
) => {
if (!change) return;
const s = side ?? 'new';
const line = lineFor(change, s);
if (line == null) return;
e.preventDefault();
setDrag({ side: s, anchor: line, head: line });
},
onMouseEnter: ({ change, side }: { change: ChangeData | null; side?: Side }) => {
setDrag((d) => {
if (!d || !change || (side ?? 'new') !== d.side) return d;
const line = lineFor(change, d.side);
return line == null ? d : { ...d, head: line };
});
},
}),
[],
);
// Finish a drag anywhere on the page: open a composer for the selected range.
useEffect(() => {
if (!drag) return;
const onUp = () => {
const start = Math.min(drag.anchor, drag.head);
const end = Math.max(drag.anchor, drag.head);
const key = lineKeyToChangeKey[`${drag.side}:${end}`];
setDrag(null);
if (key) {
onStartDraft({
level: 'line',
file: path,
side: drag.side,
startLine: start,
endLine: end,
changeKey: key,
});
}
};
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, [drag, lineKeyToChangeKey, onStartDraft, path]);
const openCount = comments.filter((c) => c.status !== 'resolved').length;
const additions = countChanges(file, 'insert');
const deletions = countChanges(file, 'delete');
// Marking a file viewed folds it away, and unmarking brings it back — the
// reason you'd touch the checkbox is that you're done with (or returning to)
// this file, so the fold is the point.
const toggleViewed = () => {
onSetViewed(path, !viewed);
setCollapsed(!viewed);
};
return (
<section className={`file${viewed ? ' is-viewed' : ''}`} id={`file-${path}`}>
<header
className={`file-head${collapsed ? ' is-collapsed' : ''}${
viewed ? ' is-viewed' : ''
}`}
>
<button
className="file-collapse"
onClick={() => setCollapsed((v) => !v)}
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} />
</button>
<span className="file-path">{path}</span>
<button
className="icon-btn"
onClick={() => navigator.clipboard?.writeText(path)}
title="Copy path"
aria-label="Copy path"
>
<Icon name="copy" />
</button>
{file.type !== 'modify' && (
<span className={`file-status file-status-${file.type}`}>
{statusLabel(file.type)}
</span>
)}
{file.type === 'rename' && (
<span className="file-rename muted"> {file.oldPath}</span>
)}
{changed && !viewed && (
<span
className="file-status file-status-changed"
title="This file's diff changed since you marked it viewed, so the mark came off"
>
changed since viewed
</span>
)}
<span className="file-head-right">
<span className="file-stat file-stat-add">+{additions}</span>
<span className="file-stat file-stat-del">{deletions}</span>
<DiffStat additions={additions} deletions={deletions} />
<label
className={`viewed-check${viewed ? ' is-on' : ''}`}
title={
viewed
? 'Mark as not viewed (expands the file)'
: 'Mark as viewed (collapses the file)'
}
>
<input type="checkbox" checked={viewed} onChange={toggleViewed} />
Viewed
</label>
<button
className="icon-btn has-label"
onClick={() => onStartDraft({ level: 'file', file: path })}
title={
openCount > 0
? `${openCount} open comment${openCount === 1 ? '' : 's'} — add another`
: 'Comment on this file'
}
>
<Icon name="comment" />
{openCount > 0 && openCount}
</button>
</span>
</header>
{!collapsed && (
<>
{(fileComments.length > 0 ||
(draft?.level === 'file' && draft.file === path)) && (
<div className="file-level-comments">
{fileComments.length > 0 && (
<CommentThread comments={fileComments} onChanged={onChanged} />
)}
{draft?.level === 'file' && draft.file === path && (
<Composer onSubmit={onSubmitDraft} onCancel={onCancelDraft} />
)}
</div>
)}
{staleComments.length > 0 && (
<OutdatedNote comments={staleComments} onChanged={onChanged} />
)}
{file.isBinary ? (
<div className="binary-note">Binary file not shown.</div>
) : (
<Diff
className={drag ? 'is-dragging' : undefined}
diffType={file.type}
viewType={viewType}
hunks={hunks}
tokens={tokens}
widgets={widgets}
gutterType="default"
gutterEvents={gutterEvents}
selectedChanges={selectedKeys}
generateLineClassName={generateLineClassName}
optimizeSelection
>
{(renderHunks) => {
const out: ReactElement[] = [];
renderHunks.forEach((hunk, i) => {
const prev: HunkData | null = i > 0 ? renderHunks[i - 1] : null;
const collapsed = getCollapsedLinesCountBetween(prev, hunk);
// Ranges are [start, end) — end is EXCLUSIVE, matching
// react-diff-view's expandFromRawCode (slice semantics).
const start = prev ? prev.oldStart + prev.oldLines : 1;
out.push(
<Decoration key={`deco-${i}`}>
<UnfoldHeader
content={hunk.content}
collapsed={collapsed}
canExpand={canExpand}
rangeStart={start}
rangeEnd={start + collapsed}
position={i === 0 ? 'leading' : 'middle'}
onExpand={expandRange}
/>
</Decoration>,
);
out.push(<Hunk key={`hunk-${i}`} hunk={hunk} />);
});
// Trailing gap: lines after the last hunk to end of file.
const last = renderHunks[renderHunks.length - 1];
if (last && canExpand && totalOldLines != null) {
const start = last.oldStart + last.oldLines;
const collapsed = totalOldLines - start + 1;
if (collapsed > 0) {
out.push(
<Decoration key="deco-tail">
<UnfoldHeader
content=""
collapsed={collapsed}
canExpand={canExpand}
rangeStart={start}
rangeEnd={start + collapsed}
position="trailing"
onExpand={expandRange}
/>
</Decoration>,
);
}
}
return out;
}}
</Diff>
)}
</>
)}
</section>
);
}
// Lines revealed per click on a directional expander, as on GitHub.
const CHUNK = 20;
// Where a gap of hidden lines sits relative to the hunks around it. It decides
// which way the gap can be opened: one above the first hunk can only be walked
// upwards from that hunk, one after the last only downwards from where it
// ended, and one between two hunks from either end.
type GapPosition = 'leading' | 'middle' | 'trailing';
// UnfoldHeader renders the hunk-header bar: an accent-tinted band carrying the
// @@ range, plus — when there are collapsed lines above the hunk and we have the
// base source to fill them from — GitHub's blue expander block in the
// line-number column.
function UnfoldHeader({
content,
collapsed,
canExpand,
rangeStart,
rangeEnd,
position,
onExpand,
}: {
content: string;
collapsed: number;
canExpand: boolean;
rangeStart: number;
// rangeEnd is EXCLUSIVE: the range [rangeStart, rangeEnd) is revealed.
rangeEnd: number;
position: GapPosition;
onExpand: (start: number, end: number) => void;
}) {
if (!canExpand || collapsed <= 0) {
// Still lay out the (empty) gutter block so the @@ text lines up with code.
return (
<div className="hunk-deco">
<span className="unfold-controls" />
<HunkText content={content} />
</div>
);
}
// A gap small enough to open in one click gets a single two-way control; there
// is nothing for a second, identical button to do.
const oneClick = collapsed <= CHUNK;
const all = () => onExpand(rangeStart, rangeEnd);
const controls =
oneClick && position === 'middle' ? (
<button
className="unfold-btn"
title={`Expand ${collapsed} hidden line${collapsed === 1 ? '' : 's'}`}
onClick={all}
>
<Icon name="unfold" />
</button>
) : (
<>
{position !== 'leading' && (
<button
className="unfold-btn"
title={oneClick ? `Expand ${collapsed} hidden lines` : 'Expand down'}
onClick={oneClick ? all : () => onExpand(rangeStart, rangeStart + CHUNK)}
>
<Icon name="fold-down" />
</button>
)}
{position !== 'trailing' && (
<button
className="unfold-btn"
title={oneClick ? `Expand ${collapsed} hidden lines` : 'Expand up'}
onClick={oneClick ? all : () => onExpand(rangeEnd - CHUNK, rangeEnd)}
>
<Icon name="fold-up" />
</button>
)}
</>
);
return (
<div className="hunk-deco">
<span className="unfold-controls is-expandable">{controls}</span>
<HunkText content={content} />
</div>
);
}
// HunkText prints the hunk header the way GitHub does: the @@ range in subtle
// text, and the enclosing declaration git tacked on after it a shade brighter.
function HunkText({ content }: { content: string }) {
const end = content.indexOf('@@', 2);
if (end < 0) return <span className="unfold-text">{content}</span>;
return (
<span className="unfold-text">
<span className="unfold-range">{content.slice(0, end + 2)}</span>
{content.slice(end + 2)}
</span>
);
}
// DiffStat is GitHub's five-block bar: the file's additions and deletions scaled
// onto five squares, with any remainder left neutral. Under six total changes
// the blocks are exact, so a one-line change reads as one green square.
function DiffStat({
additions,
deletions,
}: {
additions: number;
deletions: number;
}) {
const total = additions + deletions;
let add = 0;
let del = 0;
if (total > 0 && total <= 5) {
add = additions;
del = deletions;
} else if (total > 5) {
add = Math.floor((additions / total) * 5);
// Never round a non-empty side away to nothing.
if (additions > 0 && add === 0) add = 1;
if (deletions > 0 && add === 5) add = 4;
del = 5 - add;
}
return (
<span
className="diffstat"
title={`${additions} addition${additions === 1 ? '' : 's'} & ${deletions} deletion${deletions === 1 ? '' : 's'}`}
>
{[0, 1, 2, 3, 4].map((i) => (
<span
key={i}
className={i < add ? 'is-add' : i < add + del ? 'is-del' : ''}
/>
))}
</span>
);
}
function countChanges(file: FileData, type: 'insert' | 'delete'): number {
let n = 0;
for (const hunk of file.hunks) {
for (const change of hunk.changes) {
if (change.type === type) n++;
}
}
return n;
}
+231
View File
@@ -0,0 +1,231 @@
import { useMemo, useState } from 'react';
import type { Comment, DiffFile } from '../types';
import {
buildTree,
dirPaths,
flatten,
pathOf,
type DirNode,
type FileNode,
} from '../lib/filetree';
import { Icon } from './Icon';
interface Props {
files: DiffFile[];
comments: Comment[];
onSelect: (path: string) => void;
}
type Mode = 'tree' | 'list';
function initialMode(): Mode {
return localStorage.getItem('review-filelist-mode') === 'list'
? 'list'
: 'tree';
}
// FileList is the left rail: every changed file with its stats and open-comment
// count, either as a GitHub-style collapsible folder tree or a flat list.
// Clicking a file scrolls to it.
export function FileList({ files, comments, onSelect }: Props) {
const [mode, setMode] = useState<Mode>(initialMode);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const openByFile = useMemo(() => {
const m = new Map<string, number>();
for (const c of comments) {
if (c.status === 'resolved') continue;
m.set(c.file, (m.get(c.file) ?? 0) + 1);
}
return m;
}, [comments]);
const tree = useMemo(() => buildTree(files, openByFile), [files, openByFile]);
const rows = useMemo(() => flatten(tree, collapsed), [tree, collapsed]);
const allCollapsed = useMemo(() => {
const dirs = dirPaths(tree);
return dirs.length > 0 && dirs.every((p) => collapsed.has(p));
}, [tree, collapsed]);
const chooseMode = (next: Mode) => {
setMode(next);
localStorage.setItem('review-filelist-mode', next);
};
const toggleDir = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
const toggleAll = () =>
setCollapsed(allCollapsed ? new Set() : new Set(dirPaths(tree)));
return (
<nav className="filelist">
<div className="filelist-head">
<span>
{files.length} file{files.length === 1 ? '' : 's'} changed
</span>
<span className="filelist-head-actions">
{mode === 'tree' && (
<button
className="rail-action"
onClick={toggleAll}
title={allCollapsed ? 'Expand all folders' : 'Collapse all folders'}
>
<Icon name={allCollapsed ? 'unfold' : 'fold-up'} />
</button>
)}
<button
className="rail-action"
onClick={() => chooseMode(mode === 'tree' ? 'list' : 'tree')}
title={mode === 'tree' ? 'Show as flat list' : 'Show as folder tree'}
>
<Icon
name={mode === 'tree' ? 'list-unordered' : 'file-directory-fill'}
/>
</button>
</span>
</div>
<ul>
{mode === 'list'
? files.map((f) => (
<FileRow
key={pathOf(f)}
node={{
kind: 'file',
path: pathOf(f),
name: pathOf(f),
file: f,
open: openByFile.get(pathOf(f)) ?? 0,
}}
depth={0}
showDir
onSelect={onSelect}
/>
))
: rows.map(({ node, depth }) =>
node.kind === 'dir' ? (
<DirRow
key={`dir:${node.path}`}
node={node}
depth={depth}
collapsed={collapsed.has(node.path)}
onToggle={() => toggleDir(node.path)}
/>
) : (
<FileRow
key={node.path}
node={node}
depth={depth}
onSelect={onSelect}
/>
),
)}
</ul>
</nav>
);
}
// indent mirrors the tree depth; the chevron column keeps files aligned with
// the folder name above them.
function indent(depth: number) {
return { paddingLeft: 8 + depth * 13 };
}
function DirRow({
node,
depth,
collapsed,
onToggle,
}: {
node: DirNode;
depth: number;
collapsed: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
className="filelist-dir-row"
style={indent(depth)}
onClick={onToggle}
title={node.path}
aria-expanded={!collapsed}
>
<span className="filelist-chevron">
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} size={12} />
</span>
<span className="filelist-icon">
<Icon
name={collapsed ? 'file-directory-fill' : 'file-directory-open-fill'}
/>
</span>
<span className="filelist-folder">{node.name}</span>
<span className="filelist-stats">
{node.open > 0 && (
<span className="filelist-badge">
<Icon name="comment" size={12} />
{node.open}
</span>
)}
{collapsed && (
<>
<span className="add">+{node.additions}</span>
<span className="del">{node.deletions}</span>
</>
)}
</span>
</button>
</li>
);
}
function FileRow({
node,
depth,
showDir = false,
onSelect,
}: {
node: FileNode;
depth: number;
showDir?: boolean;
onSelect: (path: string) => void;
}) {
const name = showDir ? node.path.split('/').pop() : node.name;
const dir = showDir ? node.path.slice(0, node.path.length - (name?.length ?? 0)) : '';
return (
<li>
<button
className="filelist-file-row"
style={indent(depth)}
onClick={() => onSelect(node.path)}
title={node.path}
>
<span className="filelist-chevron" />
<span className={`filelist-icon is-${node.file.status}`}>
<Icon name="file-diff" />
</span>
<span className="filelist-name">
{dir && <span className="filelist-dir">{dir}</span>}
{name}
</span>
<span className="filelist-stats">
{node.open > 0 && (
<span className="filelist-badge">
<Icon name="comment" size={12} />
{node.open}
</span>
)}
<span className="add">+{node.file.additions}</span>
<span className="del">{node.file.deletions}</span>
</span>
</button>
</li>
);
}
+99
View File
@@ -0,0 +1,99 @@
// Octicons — GitHub's own icon set, inlined.
//
// The path data below is copied verbatim from @primer/octicons (16px variants),
// so an icon here is the same shape GitHub draws. They are inlined rather than
// pulled in as a dependency because we need a dozen of ~600, and a local table
// keeps the icon set visible in one place instead of hidden behind imports.
//
// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so colour
// comes from the surrounding text colour and size from the `size` prop.
const PATHS = {
'chevron-down':
'M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z',
'chevron-right':
'M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z',
'chevron-left':
'M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z',
'file-directory-fill':
'M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z',
'file-directory-open-fill':
'M.513 1.513A1.75 1.75 0 0 1 1.75 1h3.5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1H13a1 1 0 0 1 1 1v.5H2.75a.75.75 0 0 0 0 1.5h11.978a1 1 0 0 1 .994 1.117L15 13.25A1.75 1.75 0 0 1 13.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75c0-.464.184-.91.513-1.237Z',
'file-diff':
'M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z',
copy: 'M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
unfold:
'm8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z',
'fold-down':
'm8.177 14.323 2.896-2.896a.25.25 0 0 0-.177-.427H8.75V7.764a.75.75 0 1 0-1.5 0V11H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0ZM2.25 5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 4.25a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5a.75.75 0 0 1 .75.75ZM8.25 5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 4.25a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5a.75.75 0 0 1 .75.75Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z',
'fold-up':
'M7.823 1.677 4.927 4.573A.25.25 0 0 0 5.104 5H7.25v3.236a.75.75 0 1 0 1.5 0V5h2.146a.25.25 0 0 0 .177-.427L8.177 1.677a.25.25 0 0 0-.354 0ZM13.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5Zm-3.75.75a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75ZM7.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5ZM4 11.75a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75ZM1.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5Z',
comment:
'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
check:
'M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z',
'check-circle-fill':
'M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z',
search:
'M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z',
x: 'M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z',
plus: 'M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z',
sync: 'M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z',
sun: 'M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Z',
moon: 'M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z',
'list-unordered':
'M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
star: 'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z',
'star-fill':
'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z',
reply:
'M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z',
'git-commit':
'M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z',
'git-branch':
'M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z',
columns:
'M2.75 0h2.5C6.216 0 7 .784 7 1.75v12.5A1.75 1.75 0 0 1 5.25 16h-2.5A1.75 1.75 0 0 1 1 14.25V1.75C1 .784 1.784 0 2.75 0Zm8 0h2.5C14.216 0 15 .784 15 1.75v12.5A1.75 1.75 0 0 1 13.25 16h-2.5A1.75 1.75 0 0 1 9 14.25V1.75C9 .784 9.784 0 10.75 0ZM2.5 1.75v12.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Zm8 0v12.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z',
rows: 'M16 10.75v2.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25v-2.5C0 9.784.784 9 1.75 9h12.5c.966 0 1.75.784 1.75 1.75Zm0-8v2.5A1.75 1.75 0 0 1 14.25 7H1.75A1.75 1.75 0 0 1 0 5.25v-2.5C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75Zm-1.75-.25H1.75a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm0 8H1.75a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Z',
repo: 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z',
'dot-fill': 'M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
trash:
'M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z',
home: 'M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z',
'arrow-up':
'M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z',
alert:
'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
'git-pull-request':
'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z',
'link-external':
'M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5A1.75 1.75 0 0 1 3.75 2Zm6.854-1h3.396a.25.25 0 0 1 .25.25v3.396a.25.25 0 0 1-.427.177L12.5 3.561 8.53 7.53a.75.75 0 0 1-1.06-1.06l3.969-3.97-1.262-1.323a.25.25 0 0 1 .177-.427Z',
pencil:
'M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z',
clock:
'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z',
} as const;
export type IconName = keyof typeof PATHS;
interface Props {
name: IconName;
size?: number;
className?: string;
}
export function Icon({ name, size = 16, className }: Props) {
return (
<svg
className={className ? `octicon ${className}` : 'octicon'}
viewBox="0 0 16 16"
width={size}
height={size}
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path d={PATHS[name]} />
</svg>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useMemo } from 'react';
import type { Comment } from '../types';
import { CommentThread } from './CommentThread';
import { Icon } from './Icon';
// Outdated comments: threads whose anchor is no longer in the diff on screen
// (see lib/anchor). They are never hidden — a comment is something the reviewer
// wrote, and the code moving out from under it is exactly when they most need to
// see it again — but they can't be pinned to a line, so they get their own
// framing that says where they used to point.
//
// Two shapes, by how much is missing:
// - OutdatedNote — the file is still in the change set, one line is gone; the
// note sits at the top of that file.
// - OutdatedPanel — the file has left the change set entirely; the panel sits
// below the diff, grouped by path.
// where describes the anchor a comment was written against.
function where(c: Comment): string {
if (c.level === 'file') return 'whole file';
if (c.endLine && c.endLine !== c.line) return `L${c.line}${c.endLine}`;
return `L${c.endLine || c.line}`;
}
// ctxLabel names the diff selection a comment was written against, for the ones
// whose base ref is no longer the one being viewed.
function ctxLabel(c: Comment): string {
// A comment written while reading one commit belongs to that commit, and saying
// so is the whole explanation for why it can't be placed here.
if (c.context.commit) return `commit ${c.context.commit.slice(0, 7)}`;
const base = c.context.base || 'HEAD';
return c.context.uncommitted ? `${base} + uncommitted` : base;
}
// OutdatedNote heads a file whose diff no longer contains the lines these
// comments named.
export function OutdatedNote({
comments,
onChanged,
}: {
comments: Comment[];
onChanged: () => void;
}) {
return (
<div className="outdated-note">
<div className="outdated-head">
<Icon name="alert" size={14} />
<strong>
{comments.length} outdated comment{comments.length === 1 ? '' : 's'}
</strong>
<span className="muted">
the line{comments.length === 1 ? '' : 's'} {comments.map(where).join(', ')}{' '}
{comments.length === 1 ? 'is' : 'are'} no longer in this diff
</span>
</div>
<div className="outdated-threads">
<CommentThread comments={comments} onChanged={onChanged} />
</div>
</div>
);
}
// OutdatedPanel collects comments on files the current diff doesn't touch at
// all, so they stay reachable, replyable and resolvable.
export function OutdatedPanel({
comments,
onChanged,
}: {
comments: Comment[];
onChanged: () => void;
}) {
// Group by path, files in alphabetical order, comments in line order.
const groups = useMemo(() => {
const byFile = new Map<string, Comment[]>();
for (const c of comments) {
const list = byFile.get(c.file);
if (list) list.push(c);
else byFile.set(c.file, [c]);
}
for (const [, cs] of byFile) {
cs.sort(
(a, b) =>
(a.level === 'file' ? 0 : 1) - (b.level === 'file' ? 0 : 1) ||
(a.line || 0) - (b.line || 0) ||
a.createdAt.localeCompare(b.createdAt),
);
}
return [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [comments]);
if (groups.length === 0) return null;
return (
<section className="outdated-panel">
<div className="outdated-panel-head">
<Icon name="alert" size={14} />
<span className="outdated-panel-title">
Outdated comments
<span className="count">{comments.length}</span>
</span>
<span className="muted">
on files this diff doesnt touch the base ref moved, or the change was
undone. Nothing has been lost; resolve or delete them when youre done.
</span>
</div>
{groups.map(([file, cs]) => (
<div key={file} className="outdated-group">
<div className="outdated-group-head">
<span className="outdated-group-path">{file}</span>
<span className="outdated-group-meta muted">
{cs.map(where).join(', ')} · written against {ctxLabel(cs[0])}
</span>
</div>
<CommentThread comments={cs} onChanged={onChanged} />
</div>
))}
</section>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { DiffFile } from '../types';
import { ConfirmDialog } from './ConfirmDialog';
import { Icon } from './Icon';
// A diff the server refused to hand over unasked, because rendering it would
// wedge the page (see git.Repo.Diff). Two shapes, both built from the file
// summary that came back in its place:
//
// - OversizeWarning — the modal that asks, put up as soon as the diff lands.
// - OversizeNotice — what stands in for the diff afterwards, so a dismissed
// warning doesn't leave an empty screen with no way back.
//
// The size is nearly always a base ref whose history has moved on rather than a
// genuinely enormous review, so both of them point at the base and at what to
// switch to.
const n = (x: number) => x.toLocaleString();
// sizeLine describes the change set in one phrase: "412 files, 87,204 changed
// lines". Binary files count for no lines, so the file count carries them.
function sizeLine(files: DiffFile[]): string {
const lines = files.reduce((total, f) => total + f.additions + f.deletions, 0);
return `${n(files.length)} file${files.length === 1 ? '' : 's'}, ${n(lines)} changed line${
lines === 1 ? '' : 's'
}`;
}
// commitAdvice points at the other way through a change set too big to render:
// the commits are listed in the left rail whether or not the patch loaded, and
// one of them at a time costs nothing.
function commitAdvice(commits: number): string | null {
if (commits === 0) return null;
return (
'The commits it spans are listed in the left rail — reading one at a time ' +
'renders only that commit, however big the whole range is.'
);
}
// advice suggests the way out, which depends on what the base already is.
function advice(base: string, suggested: string): string {
if (base === 'HEAD') {
return 'That is a lot of uncommitted work for one screen.';
}
const alternative = suggested && suggested !== base ? `${suggested}, or HEAD` : 'HEAD';
return (
`A diff this size usually means ${base} has moved on since this work was ` +
`cut from it, so the change set is padded with commits nobody is reviewing. ` +
`Switching the base to ${alternative} will show only the work itself.`
);
}
export function OversizeWarning({
files,
base,
suggested,
commits,
onLoad,
onCancel,
}: {
files: DiffFile[];
base: string;
suggested: string;
commits: number;
onLoad: () => void;
onCancel: () => void;
}) {
return (
<ConfirmDialog
title="This diff is very large"
confirmLabel="Load it anyway"
onConfirm={onLoad}
onCancel={onCancel}
>
<p>
<code>{base}</code> gives <strong>{sizeLine(files)}</strong>. Rendering
that much at once can leave the page unresponsive for a while.
</p>
<p>{advice(base, suggested)}</p>
{commitAdvice(commits) && <p>{commitAdvice(commits)}</p>}
</ConfirmDialog>
);
}
export function OversizeNotice({
files,
base,
suggested,
commits,
onLoad,
}: {
files: DiffFile[];
base: string;
suggested: string;
commits: number;
onLoad: () => void;
}) {
return (
<div className="oversize-notice">
<Icon name="alert" size={20} />
<h2>Diff not loaded</h2>
<p>
<code>{base}</code> gives {sizeLine(files)} enough to make the page
unresponsive, so it wasnt rendered.
</p>
<p>{advice(base, suggested)}</p>
{commitAdvice(commits) && <p>{commitAdvice(commits)}</p>}
<button className="btn-ghost" onClick={onLoad}>
Load it anyway
</button>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useRef, useState } from 'react';
interface Props {
width: number;
min: number;
max: number;
onChange: (width: number) => void;
onReset: () => void;
// Which panel this divider sizes. A right-hand panel grows as the pointer
// moves left, so the delta is mirrored.
panel?: 'left' | 'right';
}
// Resizer is a draggable divider between a side panel and the diff. It also
// takes focus so the panel can be sized with the arrow keys.
export function Resizer({
width,
min,
max,
onChange,
onReset,
panel = 'left',
}: Props) {
const [dragging, setDragging] = useState(false);
const start = useRef({ x: 0, width: 0 });
const sign = panel === 'right' ? -1 : 1;
const clamp = useCallback(
(w: number) => Math.min(Math.max(w, min), max),
[min, max],
);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
start.current = { x: e.clientX, width };
setDragging(true);
document.body.classList.add('is-resizing');
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
onChange(clamp(start.current.width + sign * (e.clientX - start.current.x)));
};
const stop = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
document.body.classList.remove('is-resizing');
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = (e.shiftKey ? 48 : 16) * sign;
if (e.key === 'ArrowLeft') onChange(clamp(width - step));
else if (e.key === 'ArrowRight') onChange(clamp(width + step));
else if (e.key === 'Home' || e.key === 'Enter') onReset();
else return;
e.preventDefault();
};
return (
<div
className={`resizer${dragging ? ' is-dragging' : ''}`}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={stop}
onPointerCancel={stop}
onDoubleClick={onReset}
onKeyDown={onKeyDown}
title="Drag to resize · double-click to reset"
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Comment } from '../types';
import { CommentThread } from './CommentThread';
import { Composer } from './Composer';
import { Icon } from './Icon';
interface Props {
comments: Comment[]; // review-level comments
draftActive: boolean;
onStart: () => void;
onSubmit: (body: string) => Promise<void>;
onCancel: () => void;
onChanged: () => void;
}
// ReviewPanel holds general comments about the whole change set (not tied to any
// file or line), shown above the file diffs.
export function ReviewPanel({
comments,
draftActive,
onStart,
onSubmit,
onCancel,
onChanged,
}: Props) {
const empty = comments.length === 0 && !draftActive;
return (
<section className="review-panel">
<div className="review-panel-head">
<span className="review-panel-title">Review discussion</span>
{!draftActive && (
<button className="btn-ghost" onClick={onStart}>
<Icon name="plus" size={14} /> general comment
</button>
)}
</div>
{empty ? (
<p className="review-panel-empty">
No general comments yet leave one about the overall change set.
</p>
) : (
<div className="review-threads">
{comments.length > 0 && (
<CommentThread comments={comments} onChanged={onChanged} />
)}
{draftActive && <Composer onSubmit={onSubmit} onCancel={onCancel} />}
</div>
)}
</section>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { DiffFile } from '../types';
import { pathOf } from '../lib/filetree';
import { Icon } from './Icon';
interface Props {
files: DiffFile[];
viewed: ReadonlySet<string>;
}
// weightOf is a file's share of the review. Changed lines, not file count, is
// what reading a diff actually costs — a 400-line rewrite isn't one thirtieth of
// a 30-file branch just because it's one file. Files with no counted lines (pure
// renames, binaries) still weigh 1 so they can't vanish from the total.
function weightOf(f: DiffFile): number {
return Math.max(1, f.additions + f.deletions);
}
// ReviewProgress is the right end of the tab bar: how much of the diff — by
// weight, not by file — you've marked viewed.
export function ReviewProgress({ files, viewed }: Props) {
if (files.length === 0) return null;
let total = 0;
let done = 0;
let seen = 0;
for (const f of files) {
const w = weightOf(f);
total += w;
if (viewed.has(pathOf(f))) {
done += w;
seen++;
}
}
const complete = seen === files.length;
// Don't let rounding show 100% with files still unread: a big file marked
// viewed can swamp a one-liner that hasn't been.
const pct = complete ? 100 : Math.min(99, Math.round((done / total) * 100));
return (
<div
className={`review-progress${complete ? ' is-complete' : ''}`}
title={`${pct}% of the diff viewed — ${seen} of ${files.length} file${
files.length === 1 ? '' : 's'
}`}
>
<span className="review-progress-track">
<span className="review-progress-fill" style={{ width: `${pct}%` }} />
</span>
<span className="review-progress-label">
{complete && <Icon name="check-circle-fill" size={12} />}
{pct}%
</span>
<span className="review-progress-files">
{seen}/{files.length}
</span>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
// Deciding whether a comment still points at real code.
//
// Comments outlive the diff they were written against: the base ref moves, the
// working tree gets committed, the code under a thread gets rewritten. The store
// hands back every comment in the repository, so this module answers the one
// question the UI needs — can a comment be placed in the diff currently on
// screen? The ones that can't are flagged outdated and shown apart, never
// dropped: a comment the user typed is review content, and losing it silently
// because the code moved is the worst thing this tool could do.
import { getChangeKey, type ChangeData, type FileData } from 'react-diff-view';
import type { Comment, DiffContext, Side } from '../types';
// filePath returns the path comments are anchored to (new path, or old for
// deletes).
export function filePath(file: FileData): string {
return file.type === 'delete' ? file.oldPath : file.newPath;
}
// anchorLine is the diff line a line-comment hangs off (its end line).
export function anchorLine(c: Comment): number {
return c.endLine || c.line;
}
// lineFor returns the line number a change occupies on the given side, or null
// if the change has no line on that side (e.g. an insert has no old line).
export function lineFor(change: ChangeData, side: Side): number | null {
if (side === 'new') {
if (change.type === 'insert') return change.lineNumber;
if (change.type === 'normal') return change.newLineNumber;
return null;
}
if (change.type === 'delete') return change.lineNumber;
if (change.type === 'normal') return change.oldLineNumber;
return null;
}
// lineKey identifies one line of one file. NUL-separated because NUL cannot
// occur in a path, so no path can spell another file's key.
function lineKey(path: string, side: Side, line: number): string {
return `${path}\u0000${side}:${line}`;
}
// DiffAnchors is everything the diff on screen offers to hang a comment on.
export interface DiffAnchors {
files: Set<string>; // paths in the change set
lines: Set<string>; // lineKey() for every line the diff carries
ctx: DiffContext; // the selection this diff was produced from
}
// buildAnchors indexes a parsed diff. It reads each file's original hunks, not
// the expanded ones — what a reviewer has unfolded is a view preference and
// shouldn't change whether a comment counts as current.
export function buildAnchors(files: FileData[], ctx: DiffContext): DiffAnchors {
const paths = new Set<string>();
const lines = new Set<string>();
for (const file of files) {
const path = filePath(file);
paths.add(path);
for (const hunk of file.hunks) {
for (const change of hunk.changes) {
const nl = lineFor(change, 'new');
const ol = lineFor(change, 'old');
if (nl != null) lines.add(lineKey(path, 'new', nl));
if (ol != null) lines.add(lineKey(path, 'old', ol));
}
}
}
return { files: paths, lines, ctx };
}
export function sameCtx(a: DiffContext, b: DiffContext): boolean {
return (
a.base === b.base &&
a.uncommitted === b.uncommitted &&
(a.commit ?? '') === (b.commit ?? '')
);
}
// isOutdated reports that a comment's anchor is missing from the diff on screen:
// its file has left the change set, or the line it hangs off is no longer part
// of the diff. Pass anchors=null while a diff is still loading — with nothing to
// compare against, nothing is outdated.
export function isOutdated(c: Comment, anchors: DiffAnchors | null): boolean {
if (!anchors) return false;
// A review-level comment is anchored to the change set as a whole, which is
// whatever is on screen. It is never outdated.
if (c.level === 'review') return false;
if (!anchors.files.has(c.file)) return true;
if (c.level === 'file') return false;
// A single commit's diff numbers lines in that commit's revision of the file,
// so the same number means something else in another commit — and something
// else again in the full diff, where the file is at the tip of the branch. That
// goes for both sides, unlike the base-ref case below: in a commit diff neither
// side is the working file.
if ((c.context.commit ?? '') !== (anchors.ctx.commit ?? '')) return true;
// Old-side line numbers are positions in the *base* revision, so they only
// mean anything against the base they were written against; against a
// different base the same number is a different line. New-side numbers are
// positions in the working file and stay valid as the base moves.
if (c.side === 'old' && !sameCtx(c.context, anchors.ctx)) return true;
return !anchors.lines.has(lineKey(c.file, c.side, anchorLine(c)));
}
// changeKeyIndex maps "side:line" -> react-diff-view change key for one file's
// hunks, so threads and composers can be attached as line widgets. Built from
// the hunks actually being rendered (expansion included), unlike buildAnchors.
export function changeKeyIndex(
hunks: readonly { changes: ChangeData[] }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const hunk of hunks) {
for (const change of hunk.changes) {
const key = getChangeKey(change);
const nl = lineFor(change, 'new');
const ol = lineFor(change, 'old');
if (nl != null) map[`new:${nl}`] = key;
if (ol != null) map[`old:${ol}`] = key;
}
}
return map;
}
+141
View File
@@ -0,0 +1,141 @@
import type { DiffFile } from '../types';
// A GitHub-style tree over the changed-file paths: directories nest, and a
// directory chain with no branching (`web/src/components`) collapses into one
// row so the rail doesn't waste indentation on empty levels.
export interface FileNode {
kind: 'file';
path: string; // full path, also the comment key
name: string;
file: DiffFile;
open: number; // unresolved comments on this file
}
export interface DirNode {
kind: 'dir';
path: string; // full path of the deepest merged segment; the collapse key
name: string; // may be "a/b/c" after chain-collapsing
children: TreeNode[];
files: number;
additions: number;
deletions: number;
open: number;
}
export type TreeNode = FileNode | DirNode;
export function pathOf(f: DiffFile): string {
return f.status === 'deleted' ? f.oldPath : f.newPath;
}
// Mutable scratch node used while inserting paths.
interface Draft {
name: string;
path: string;
dirs: Map<string, Draft>;
files: FileNode[];
}
function draft(name: string, path: string): Draft {
return { name, path, dirs: new Map(), files: [] };
}
export function buildTree(
files: DiffFile[],
openByFile: Map<string, number>,
): TreeNode[] {
const root = draft('', '');
for (const file of files) {
const path = pathOf(file);
const parts = path.split('/');
const name = parts.pop() ?? path;
let cur = root;
let prefix = '';
for (const part of parts) {
prefix = prefix ? `${prefix}/${part}` : part;
let next = cur.dirs.get(part);
if (!next) {
next = draft(part, prefix);
cur.dirs.set(part, next);
}
cur = next;
}
cur.files.push({
kind: 'file',
path,
name,
file,
open: openByFile.get(path) ?? 0,
});
}
return childrenOf(root);
}
// childrenOf finishes a draft's children: directories first (alphabetical),
// then files, with stats rolled up and single-child chains merged.
function childrenOf(d: Draft): TreeNode[] {
const dirs = [...d.dirs.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map(finishDir);
const files = [...d.files].sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...files];
}
function finishDir(d: Draft): DirNode {
const children = childrenOf(d);
// A lone subdirectory folds into this row: "web" + "src" → "web/src".
const only = children.length === 1 ? children[0] : null;
if (only && only.kind === 'dir') {
return { ...only, name: `${d.name}/${only.name}` };
}
return {
kind: 'dir',
path: d.path,
name: d.name,
children,
files: children.reduce((n, c) => n + (c.kind === 'dir' ? c.files : 1), 0),
additions: children.reduce(
(n, c) => n + (c.kind === 'dir' ? c.additions : c.file.additions),
0,
),
deletions: children.reduce(
(n, c) => n + (c.kind === 'dir' ? c.deletions : c.file.deletions),
0,
),
open: children.reduce((n, c) => n + c.open, 0),
};
}
export function dirPaths(nodes: TreeNode[]): string[] {
return nodes.flatMap((n) =>
n.kind === 'dir' ? [n.path, ...dirPaths(n.children)] : [],
);
}
export interface Row {
node: TreeNode;
depth: number;
}
// flatten walks the tree in display order, skipping the contents of collapsed
// directories.
export function flatten(
nodes: TreeNode[],
collapsed: Set<string>,
depth = 0,
out: Row[] = [],
): Row[] {
for (const node of nodes) {
out.push({ node, depth });
if (node.kind === 'dir' && !collapsed.has(node.path)) {
flatten(node.children, collapsed, depth + 1, out);
}
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
// A short digest of what a file's diff actually says, used to notice that a file
// changed after you marked it viewed (see lib/viewed).
//
// What goes in is deliberately narrow: the file's status, its paths, and the
// type + text of every line in every hunk. Line *numbers* stay out — an edit
// elsewhere in the change set can shift a hunk's offsets without altering a
// character of what this file does, and unmarking a file over that would train
// you to ignore the signal. Binary files have no hunks to compare, so they lean
// on the blob revisions git printed in the index line instead.
import type { FileData } from 'react-diff-view';
import { filePath } from './anchor';
// cyrb53: a small, fast, non-cryptographic 53-bit string hash. Nothing here is
// adversarial — a collision would only mean a file staying marked viewed
// through a change — and 53 bits is far past the point where that matters.
function cyrb53(s: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < s.length; i++) {
const ch = s.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
// fingerprintFile digests one file's contribution to the diff.
export function fingerprintFile(file: FileData): string {
const parts: string[] = [file.type, file.oldPath, file.newPath];
if (file.isBinary) {
parts.push(file.oldRevision ?? '', file.newRevision ?? '');
}
for (const hunk of file.hunks) {
for (const change of hunk.changes) {
// First letter of the type is enough to separate insert/delete/normal.
parts.push(change.type[0] + change.content);
}
}
return cyrb53(parts.join('\n'));
}
// fingerprintFiles maps each file in a parsed diff to its digest, keyed by the
// same path viewed marks and comments use.
export function fingerprintFiles(files: FileData[]): Map<string, string> {
return new Map(files.map((f) => [filePath(f), fingerprintFile(f)]));
}
+114
View File
@@ -0,0 +1,114 @@
import { refractor } from 'refractor';
// A bundler resolves `refractor` to its core (no languages registered), and the
// built-in "common" set gets tree-shaken away. So we register every language we
// map, explicitly. Each module self-registers its own dependencies
// (e.g. tsx pulls in jsx + typescript), so importing the leaves is enough.
import javascript from 'refractor/lang/javascript.js';
import jsx from 'refractor/lang/jsx.js';
import typescript from 'refractor/lang/typescript.js';
import tsx from 'refractor/lang/tsx.js';
import go from 'refractor/lang/go.js';
import python from 'refractor/lang/python.js';
import ruby from 'refractor/lang/ruby.js';
import rust from 'refractor/lang/rust.js';
import java from 'refractor/lang/java.js';
import kotlin from 'refractor/lang/kotlin.js';
import c from 'refractor/lang/c.js';
import cpp from 'refractor/lang/cpp.js';
import csharp from 'refractor/lang/csharp.js';
import php from 'refractor/lang/php.js';
import swift from 'refractor/lang/swift.js';
import scala from 'refractor/lang/scala.js';
import bash from 'refractor/lang/bash.js';
import yaml from 'refractor/lang/yaml.js';
import json from 'refractor/lang/json.js';
import toml from 'refractor/lang/toml.js';
import markup from 'refractor/lang/markup.js';
import css from 'refractor/lang/css.js';
import scss from 'refractor/lang/scss.js';
import less from 'refractor/lang/less.js';
import sql from 'refractor/lang/sql.js';
import markdown from 'refractor/lang/markdown.js';
import docker from 'refractor/lang/docker.js';
import makefile from 'refractor/lang/makefile.js';
for (const lang of [
markup, css, javascript, typescript, jsx, tsx, go, python, ruby, rust, java,
kotlin, c, cpp, csharp, php, swift, scala, bash, yaml, json, toml, scss, less,
sql, markdown, docker, makefile,
]) {
refractor.register(lang);
}
// Map file extensions to Prism/refractor language names.
const EXT_TO_LANG: Record<string, string> = {
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
tsx: 'tsx',
go: 'go',
py: 'python',
rb: 'ruby',
rs: 'rust',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
c: 'c',
h: 'c',
cc: 'cpp',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
swift: 'swift',
scala: 'scala',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yml: 'yaml',
yaml: 'yaml',
json: 'json',
toml: 'toml',
xml: 'markup',
html: 'markup',
vue: 'markup',
svelte: 'markup',
css: 'css',
scss: 'scss',
less: 'less',
sql: 'sql',
md: 'markdown',
markdown: 'markdown',
};
// languageForFile returns a refractor language name that is registered, or
// null when we should fall back to plain (unhighlighted) rendering.
export function languageForFile(path: string): string | null {
const base = path.split('/').pop() ?? path;
const lower = base.toLowerCase();
let lang: string | undefined;
if (lower === 'dockerfile') lang = 'docker';
else if (lower === 'makefile') lang = 'makefile';
else {
const ext = lower.includes('.') ? lower.split('.').pop()! : '';
lang = EXT_TO_LANG[ext];
}
if (lang && refractor.registered(lang)) return lang;
return null;
}
// react-diff-view (v3) expects refractor.highlight() to return an ARRAY of
// nodes (refractor v3 behavior). refractor v4 returns a `root` node instead, so
// we adapt by handing back its children. Pass this to tokenize().
export const refractorAdapter = {
highlight(value: string, language: string) {
return refractor.highlight(value, language).children;
},
} as unknown as { highlight: typeof refractor.highlight };
export { refractor };
+18
View File
@@ -0,0 +1,18 @@
// relativeTime renders a timestamp the way a list of work is read — how old is
// this — rather than as a date nobody parses at a glance. Past a month the
// relative form stops meaning anything, so it falls back to the locale date.
//
// Comment timestamps deliberately don't use this: a thread that arrived seconds
// ago wants second granularity, which this rounds away (see CommentThread).
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (!Number.isFinite(then) || then <= 0) return '';
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(then).toLocaleDateString();
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react';
import { apiBase } from '../api';
export interface ServerEvent {
type: string;
data: unknown;
}
// useSSE subscribes to this tab's event stream and invokes onEvent for each
// message. Reconnects automatically if the connection drops.
//
// The stream is scoped by the URL it is opened on — the tab's own `api/events` —
// so unlike the tool this came from there is nothing to filter here: activity in
// another tab's review never arrives in the first place.
export function useSSE(onEvent: (e: ServerEvent) => void): void {
const handler = useRef(onEvent);
handler.current = onEvent;
useEffect(() => {
let es: EventSource | null = null;
let closed = false;
let retry: ReturnType<typeof setTimeout> | undefined;
const connect = () => {
if (closed) return;
es = new EventSource(`${apiBase}/events`);
// The server's opening nudge is an SSE comment, so it never reaches
// onmessage; synthesize the event so the live indicator lights on connect
// rather than waiting for the first real change.
es.onopen = () => handler.current({ type: 'connected', data: null });
es.onmessage = (ev) => {
try {
handler.current(JSON.parse(ev.data) as ServerEvent);
} catch {
/* ignore malformed frames */
}
};
es.onerror = () => {
es?.close();
if (!closed) retry = setTimeout(connect, 2000);
};
};
connect();
return () => {
closed = true;
if (retry) clearTimeout(retry);
es?.close();
};
}, []);
}
+199
View File
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { DiffContext } from '../types';
// Per-file "viewed" marks — the reviewer's own progress through a diff.
//
// They live in localStorage rather than the comment store because they aren't
// review content: nothing about them is meant for Claude, and they shouldn't
// travel with the comments file in .git. Scoping is per (repo, base ref, selected
// commit, and whether whitespace is ignored): the same path against a different
// base is a different diff, so its marks are separate. Toggling `uncommitted`
// deliberately keeps them, since folding your working tree in and out of view
// shouldn't cost you your place.
//
// A single commit is scoped apart for the same reason a base ref is: reading one
// commit of a branch is not reading the branch. Signing off on a file there
// shouldn't tick it off in the full diff — the rest of the change set may touch it
// again — and marking your way through the range shouldn't pre-tick the commits
// you drill into.
//
// The whitespace toggle is in the key for the opposite reason: ignoring
// whitespace rewrites the hunks, so every fingerprint changes and a shared key
// would delete the lot on the way through — a glance at what the reformatting
// did would cost you the whole review. Kept apart, each mode remembers its own
// progress and toggling back finds it intact.
//
// A mark records *what* was viewed, not just that it was: alongside each path we
// store a fingerprint of that file's diff at the moment it was marked (see
// lib/fingerprint). When the diff is reloaded and a file's fingerprint no longer
// matches, the mark is dropped — the code you signed off on isn't the code
// that's there now, so the file goes back in the pile, and `changed` reports it
// so the file doesn't just silently reappear.
const PREFIX = 'review-viewed';
// Fingerprint stored for marks made before fingerprints existed. They can't be
// compared against anything, so they're grandfathered: always current, never
// auto-unmarked. The next toggle replaces one with a real fingerprint.
const LEGACY = '';
// Marks maps a file path to the fingerprint of its diff when it was marked.
type Marks = Record<string, string>;
// A ref can't contain a colon (git check-ref-format), so a suffix can never be
// mistaken for part of the base.
function keyFor(
repo: string | null,
ctx: DiffContext,
ignoreWhitespace: boolean,
): string | null {
if (!repo) return null;
const commit = ctx.commit ? `:c${ctx.commit}` : '';
return `${PREFIX}:${repo}:${ctx.base}${commit}${ignoreWhitespace ? ':w' : ''}`;
}
// load reads a repo's marks, accepting the older array-of-paths format that
// predates fingerprints.
function load(key: string | null): Marks {
if (!key) return {};
try {
const raw = JSON.parse(localStorage.getItem(key) ?? '{}');
if (Array.isArray(raw)) {
const out: Marks = {};
for (const path of raw) {
if (typeof path === 'string') out[path] = LEGACY;
}
return out;
}
if (!raw || typeof raw !== 'object') return {};
const out: Marks = {};
for (const [path, fp] of Object.entries(raw as Record<string, unknown>)) {
if (typeof fp === 'string') out[path] = fp;
}
return out;
} catch {
return {};
}
}
function save(key: string | null, marks: Marks) {
if (!key) return;
if (Object.keys(marks).length === 0) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify(marks));
}
// clearRepo drops every mark a repository has — under any base ref or commit, not
// just the diff on screen: resetting a review is a fresh start, and marks left
// under another key would reappear the moment you switched to it.
function clearRepo(repo: string | null) {
if (!repo) return;
const prefix = `${PREFIX}:${repo}:`;
const stale = Object.keys(localStorage).filter((k) => k.startsWith(prefix));
for (const k of stale) localStorage.removeItem(k);
}
// isCurrent asks whether a mark still describes the file in the diff on screen.
// A path missing from the diff keeps its mark: the file has left this change set
// (or the diff hasn't loaded yet), which says nothing about whether the work you
// reviewed changed — and if it comes back different, the fingerprint will say so
// then.
function isCurrent(marked: string, current: string | undefined): boolean {
return marked === LEGACY || current === undefined || current === marked;
}
// useViewedFiles returns the viewed set for a review, the files that lost their
// mark because they changed, and a setter for one file. `fingerprints` is the
// current digest of each file in the diff on screen (see lib/fingerprint).
//
// localStorage is read back on every write, so two browser tabs on the same
// review each see the other's marks instead of clobbering the whole set.
export function useViewedFiles(
repo: string | null,
ctx: DiffContext,
fingerprints: ReadonlyMap<string, string>,
ignoreWhitespace = false,
) {
const key = keyFor(repo, ctx, ignoreWhitespace);
const [marks, setMarks] = useState<Marks>(() => load(key));
// Files whose mark was dropped because their diff moved. Deliberately memory
// only: it's a "look again at this one" nudge for the session you're in, not a
// state worth resurrecting on reload.
const [changed, setChanged] = useState<ReadonlySet<string>>(new Set());
useEffect(() => {
setMarks(load(key));
setChanged(new Set());
}, [key]);
// Marks are shared by every tab on this origin, so another tab changing or
// clearing them has to land here too. `storage` fires exactly when that write
// happens, which the review's SSE stream can't tell us: it knows nothing about
// browser-side state, and its reset event races the localStorage clear.
useEffect(() => {
if (!key) return;
const onStorage = (e: StorageEvent) => {
if (e.key === null || e.key === key) setMarks(load(key));
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [key]);
// Persist the unmarking of files that changed. `viewed` below already ignores
// stale marks, so this isn't what makes them disappear from the UI — it's what
// stops one coming back to life later, when the same file's diff happens to
// match a fingerprint you signed off on two base refs ago.
useEffect(() => {
if (!key || fingerprints.size === 0) return;
const stored = load(key);
const stale = Object.keys(stored).filter(
(path) => !isCurrent(stored[path], fingerprints.get(path)),
);
if (stale.length === 0) return;
const next = { ...stored };
for (const path of stale) delete next[path];
save(key, next);
setMarks(next);
setChanged((prev) => new Set([...prev, ...stale]));
}, [key, fingerprints]);
// A mark whose file no longer matches is not a mark. Deciding this here rather
// than leaning on the effect above matters: the effect runs after the render
// that brought the new diff in, and a file that flashed up as viewed for one
// frame would have already mounted folded.
const viewed = useMemo(() => {
const out = new Set<string>();
for (const [path, fp] of Object.entries(marks)) {
if (isCurrent(fp, fingerprints.get(path))) out.add(path);
}
return out;
}, [marks, fingerprints]);
const setFileViewed = useCallback(
(path: string, next: boolean) => {
const updated = load(key);
if (next) updated[path] = fingerprints.get(path) ?? LEGACY;
else delete updated[path];
save(key, updated);
setMarks(updated);
// Whichever way it was toggled, the file has just had the user's attention.
setChanged((prev) => {
if (!prev.has(path)) return prev;
const rest = new Set(prev);
rest.delete(path);
return rest;
});
},
[key, fingerprints],
);
// Wipes this repository's marks under every base ref. Part of resetting a
// review; the comments half lives on the server.
const clearViewed = useCallback(() => {
clearRepo(repo);
setMarks({});
setChanged(new Set());
}, [repo]);
return { viewed, changed, setFileViewed, clearViewed };
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
// No webfonts: the styling copies GitHub, and GitHub renders its UI and its
// diffs in the platform's own system and monospace faces.
// Import the library's base diff styles BEFORE ours so our overrides win.
import 'react-diff-view/style/index.css';
import './styles.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+2154
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
// Mirrors the JSON the review server produces — see src/review/model.zig, whose
// field names are the wire format.
//
// There is no repository in here to choose between: a review pane is bound to one
// work tree, and the tab's path is what names it. Everything below describes that
// one review.
export type Side = 'old' | 'new';
export type Author = 'user' | 'claude';
export type Status = 'draft' | 'submitted' | 'resolved';
export type Level = 'line' | 'file' | 'review';
// DraftTarget is what an in-progress (unsent) comment is anchored to.
export type DraftTarget =
| {
level: 'line';
file: string;
side: Side;
startLine: number;
endLine: number;
changeKey: string; // key of the end line, where the composer renders
}
| { level: 'file'; file: string }
| { level: 'review' };
export interface Reply {
id: string;
author: Author;
body: string;
createdAt: string;
}
export interface DiffContext {
base: string;
uncommitted: boolean;
// The sha of one commit out of the range, when the view is narrowed to it: the
// diff is then that commit alone, and `uncommitted` no longer applies. Absent
// for the whole change set, which is what a review opens on.
//
// Part of the context rather than a display preference beside it, because a
// line number only means something inside one revision — line 40 as one commit
// left it isn't line 40 at the tip of the branch — so a comment written here
// must not be placed on the full diff's lines.
commit?: string;
}
// Commit is one entry in the list of commits a diff spans, as the left rail shows
// them. `files`/`additions`/`deletions` are what the commit changed on its own,
// and are 0 for a merge, whose diff git doesn't summarize.
export interface Commit {
sha: string;
shortSha: string;
author: string;
date: string;
subject: string;
files: number;
additions: number;
deletions: number;
}
export interface Comment {
id: string;
level: Level;
file: string;
side: Side;
line: number;
endLine: number;
body: string;
author: Author;
status: Status;
replies: Reply[];
context: DiffContext;
createdAt: string;
updatedAt: string;
}
export interface RepoInfo {
path: string;
branch: string;
branches: string[];
refs: string[];
// The ref the base picker offers directly under HEAD, which a review opens on:
// the release branch, or main, depending on the repo. Decided server-side (see
// git.suggestedBase) so the rule lives in one place. Empty when there's nothing
// worth suggesting.
suggestedBase: string;
}
// What GET api/repo answers with for a tab that has a review open: the
// repository, plus the comment counts and the diff selection the server has on
// record.
export interface RepoState extends RepoInfo {
drafts: number;
openComments: number;
// The selection this tab last published (see api.setContext). Absent until the
// page publishes one. The browser is the source of truth for what's on screen;
// this copy is what lets an agent review, and anchor comments to, the same diff.
context?: DiffContext | null;
}
export interface DiffFile {
oldPath: string;
newPath: string;
status: string;
additions: number;
deletions: number;
}
export interface DiffPayload {
context: DiffContext;
patch: string;
files: DiffFile[];
// The commits the change set is made of, oldest first — the whole range, even
// when `context.commit` narrows the patch to one of them, so the list you
// picked from is still there to pick again. Filled in for an oversized diff
// too, where choosing a single commit is the fastest way to something readable.
commits: Commit[] | null;
// The range holds more commits than `commits` lists (the newest are kept).
moreCommits?: boolean;
// The change set is past what the UI can render, so the server withheld the
// patch. `files` is still filled in, so the size can be described before
// anything asks for it again with force. See git.Repo.Diff.
oversized?: boolean;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Minimal typing for process.env so we don't need @types/node just for this.
declare const process: { env: Record<string, string | undefined> };
// Where a `vite dev` session sends its API calls. Playpen prefers 8420 and walks
// upward if it's taken, so this is right unless you have two windows open — in
// which case read the real one out of PLAYPEN_REVIEW_URL in any of its terminals.
const apiPort = process.env.PLAYPEN_REVIEW_PORT || '8420';
const apiTarget = `http://127.0.0.1:${apiPort}`;
export default defineConfig({
plugins: [react()],
// Relative asset URLs, because the page is served from a tab's path
// (`/t/tab3/`) and not from the server root. With an absolute base the
// browser would ask for `/assets/app.js` and get the tab router instead.
base: './',
server: {
port: 5173,
proxy: {
// A tab's own API and event stream. Matched as a regex so the HTML at
// `/t/<id>/` still comes from Vite — only the calls underneath it are
// forwarded, which is what lets hot reloading work against a live review.
//
// Open http://localhost:5173/t/<tabId>/ to develop against a real tab.
'^/t/[^/]+/api': { target: apiTarget, changeOrigin: true },
// The app-wide endpoints: /api/tabs and /api/health.
'/api': { target: apiTarget, changeOrigin: true },
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
// Fixed filenames rather than Vite's content hashes. The bundle is embedded
// in the playpen binary (src/review/assets.zig), so the Zig side has to be
// able to name the files at compile time — and cache busting buys nothing
// for a bundle that only changes when the binary does.
rollupOptions: {
output: {
entryFileNames: 'assets/app.js',
chunkFileNames: 'assets/app.js',
assetFileNames: 'assets/app[extname]',
// One file, so there is one name to embed. The app is a few hundred
// kilobytes loaded from localhost; splitting it saves nothing.
manualChunks: undefined,
},
},
},
});