Build in the review tool.
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
|
- 2–4 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 ~10–12 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.
|
||||||
@@ -4,3 +4,10 @@ zig-out/
|
|||||||
# build.zig.zon and must not be committed.
|
# build.zig.zon and must not be committed.
|
||||||
zig-pkg/
|
zig-pkg/
|
||||||
result
|
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
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
A proof-of-concept workspace built on
|
A proof-of-concept workspace built on
|
||||||
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
|
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
|
||||||
**vertical tabs**, each holding a split tree of terminal and web panes, with
|
**vertical tabs**, each holding a split tree of terminal, web and **code review**
|
||||||
**saved layouts** that open a whole arrangement — directories, scripts and all —
|
panes, with **saved layouts** that open a whole arrangement — directories, scripts
|
||||||
in one go, and a **startup list** that opens the ones you always want.
|
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
|
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 —
|
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
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```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
|
zig build run
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
|
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
|
## 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
|
Pane.zig content plus its header, drag source, and drop target
|
||||||
Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection
|
Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection
|
||||||
Browser.zig WebKitWebView plus a nav/address bar and a find bar
|
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
|
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}}
|
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
|
||||||
OpenLayoutDialog.zig prompts for a layout's parameters
|
OpenLayoutDialog.zig prompts for a layout's parameters
|
||||||
SaveLayoutDialog.zig turns the current tab into a saved layout
|
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.
|
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
|
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 directory, straight out of `/proc/<pid>/cwd`, each web pane's current
|
||||||
current page. So the usual flow is to get a tab set up the way you like,
|
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}}`.
|
save it, and replace the literal paths with `{{parameters}}`.
|
||||||
|
|
||||||
**Editing** a saved layout opens the same dialog on the stored one, so its
|
**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
|
atomic — written to a temporary and renamed — so an interrupted write leaves
|
||||||
the previous layouts intact rather than a file that won't parse.
|
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
|
## Startup tabs
|
||||||
|
|
||||||
Opening the same three layouts against the same three worktrees every morning is
|
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
|
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
|
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)
|
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
|
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
|
||||||
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
||||||
arranging a tab and saving it. See [Layouts](#layouts)
|
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+T` | new tab | `new_tab` |
|
||||||
| `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` |
|
| `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+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+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+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 |
|
| `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
|
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.
|
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
|
## Agent status
|
||||||
|
|
||||||
Playpen is mostly used to keep several Claude Code sessions side by side, and
|
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
|
to be reached another way — a keyboard shortcut, or `zig build test` if the
|
||||||
thing being checked is logic rather than pixels.
|
thing being checked is logic rather than pixels.
|
||||||
|
|
||||||
`zig build test` runs the unit tests, which cover layout parsing, the
|
`zig build test` runs the unit tests. They cover layout parsing, the
|
||||||
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
|
parameter/`$(...)` substitution pipeline, the palette, the settings file, the
|
||||||
They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither
|
shortcut table, the emoji table and its search, and — for the review server —
|
||||||
links GTK — a test binary has one root, and those are the two files worth
|
git's own output formats, the rule for picking a base ref, and the round trip a
|
||||||
testing in isolation that have nothing to do with each other.
|
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`
|
`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
|
and CLDR's annotations. It is not part of the build — its output is committed, so
|
||||||
|
|||||||
@@ -47,6 +47,40 @@ pub fn build(b: *std.Build) void {
|
|||||||
.root_source_file = icons_gresource,
|
.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 = .{
|
const gobject_imports = .{
|
||||||
.{ "adw", "adw1" },
|
.{ "adw", "adw1" },
|
||||||
.{ "cairo", "cairo1" },
|
.{ "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
|
// 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
|
// off the root above because a test binary has exactly one root, and
|
||||||
// Layouts.zig has no reason to reach for the emoji table.
|
// 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(.{
|
const emoji_tests = b.addTest(.{
|
||||||
.root_module = b.createModule(.{
|
.root_module = b.createModule(.{
|
||||||
.root_source_file = b.path("src/emoji.zig"),
|
.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);
|
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 })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.gdb
|
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
|
# Used by ./shot.sh to run the app inside a throwaway headless
|
||||||
# compositor and screenshot it, so UI can be checked without
|
# compositor and screenshot it, so UI can be checked without
|
||||||
# touching the developer's real session.
|
# touching the developer's real session.
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is
|
# Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is
|
||||||
# nothing for mise to install; these tasks just drive it.
|
# 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]
|
[tasks.build]
|
||||||
description = "Build a release binary into zig-out/bin"
|
description = "Build a release binary into zig-out/bin"
|
||||||
run = "nix develop --command zig build -Doptimize=ReleaseFast"
|
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)"
|
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]
|
[tasks.install]
|
||||||
description = "Install playpen into ~/.local with a desktop entry and icon"
|
description = "Install playpen into ~/.local with a desktop entry and icon"
|
||||||
depends = ["build"]
|
depends = ["build"]
|
||||||
|
|||||||
+73
-1
@@ -47,7 +47,11 @@ pub const Orientation = enum { horizontal, vertical };
|
|||||||
|
|
||||||
/// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than
|
/// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than
|
||||||
/// imported so this module stays free of GTK and of the widget tree.
|
/// 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.
|
/// A value the user supplies when opening a layout.
|
||||||
pub const Parameter = struct {
|
pub const Parameter = struct {
|
||||||
@@ -63,6 +67,16 @@ pub const Pane = struct {
|
|||||||
kind: Kind = .terminal,
|
kind: Kind = .terminal,
|
||||||
|
|
||||||
/// Directory to start in. A leading `~` is expanded at open time.
|
/// 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 = "",
|
cwd: []const u8 = "",
|
||||||
|
|
||||||
/// Script to run once the shell is up. Empty means "just a shell".
|
/// 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" {
|
test "an unknown parameter is left visible rather than blanked" {
|
||||||
try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{});
|
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
@@ -1,7 +1,7 @@
|
|||||||
//! One pane inside a view: either a terminal or a web view, plus the chrome
|
//! One pane inside a view: a terminal, a web view, or the tab's code review,
|
||||||
//! needed to tell panes apart and rearrange them — a header strip showing the
|
//! plus the chrome needed to tell panes apart and rearrange them — a header
|
||||||
//! content's title, which doubles as the drag handle, and a drop target
|
//! strip showing the content's title, which doubles as the drag handle, and a
|
||||||
//! covering the whole pane.
|
//! drop target covering the whole pane.
|
||||||
//!
|
//!
|
||||||
//! The header exists mainly so dragging a pane never competes with the
|
//! The header exists mainly so dragging a pane never competes with the
|
||||||
//! content's own mouse handling. Grabbing anywhere in the terminal body would
|
//! content's own mouse handling. Grabbing anywhere in the terminal body would
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
//! page would collide with the page itself.
|
//! page would collide with the page itself.
|
||||||
//!
|
//!
|
||||||
//! Everything below the header is behind `Content`, so the layout, drag and
|
//! 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
|
//! drop, and focus tracking are all written once and no kind of content is
|
||||||
//! is special-cased.
|
//! special-cased.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const gdk = @import("gdk");
|
const gdk = @import("gdk");
|
||||||
@@ -19,6 +19,7 @@ const gtk = @import("gtk");
|
|||||||
|
|
||||||
const Browser = @import("Browser.zig");
|
const Browser = @import("Browser.zig");
|
||||||
const Layout = @import("Layout.zig");
|
const Layout = @import("Layout.zig");
|
||||||
|
const Review = @import("Review.zig");
|
||||||
const Terminal = @import("Terminal.zig");
|
const Terminal = @import("Terminal.zig");
|
||||||
const View = @import("View.zig");
|
const View = @import("View.zig");
|
||||||
|
|
||||||
@@ -32,11 +33,17 @@ pub const Kind = enum {
|
|||||||
terminal,
|
terminal,
|
||||||
web,
|
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.
|
/// Icon standing in for this kind in the pane header and the tab row.
|
||||||
pub fn iconName(self: Kind) [:0]const u8 {
|
pub fn iconName(self: Kind) [:0]const u8 {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
.terminal => "utilities-terminal-symbolic",
|
.terminal => "utilities-terminal-symbolic",
|
||||||
.web => "web-browser-symbolic",
|
.web => "web-browser-symbolic",
|
||||||
|
.review => "document-edit-symbolic",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +52,7 @@ pub const Kind = enum {
|
|||||||
return switch (self) {
|
return switch (self) {
|
||||||
.terminal => "shell",
|
.terminal => "shell",
|
||||||
.web => "web",
|
.web => "web",
|
||||||
|
.review => "review",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -55,12 +63,18 @@ pub const Kind = enum {
|
|||||||
pub const Spec = union(Kind) {
|
pub const Spec = union(Kind) {
|
||||||
terminal: Terminal.Options,
|
terminal: Terminal.Options,
|
||||||
web: Browser.Options,
|
web: Browser.Options,
|
||||||
|
review: Review.Options,
|
||||||
|
|
||||||
/// A plain pane of the given kind, with nothing preloaded.
|
/// 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 {
|
pub fn plain(of: Kind) Spec {
|
||||||
return switch (of) {
|
return switch (of) {
|
||||||
.terminal => .{ .terminal = .{} },
|
.terminal => .{ .terminal = .{} },
|
||||||
.web => .{ .web = .{} },
|
.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
|
/// 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
|
/// reports this — a web or review pane is always `.idle` — but it lives here
|
||||||
/// than on Terminal so the view can aggregate across panes without caring
|
/// rather than on Terminal so the view can aggregate across panes without
|
||||||
/// which kind each one is.
|
/// caring which kind each one is.
|
||||||
pub const Status = Terminal.Status;
|
pub const Status = Terminal.Status;
|
||||||
|
|
||||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
/// What a content kind reports back to its pane. Shared by all three kinds so
|
||||||
/// pane can wire either one up with the same handlers.
|
/// 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
|
/// A web or review pane simply never calls `on_status` or `on_input`; neither
|
||||||
/// equivalent of a long-running job to report.
|
/// has an equivalent of a long-running job to report.
|
||||||
pub const Callbacks = struct {
|
pub const Callbacks = struct {
|
||||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||||
@@ -89,12 +103,13 @@ pub const Callbacks = struct {
|
|||||||
ctx: ?*anyopaque,
|
ctx: ?*anyopaque,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The two things a pane can hold. Both expose the same three operations and
|
/// The three things a pane can hold. All of them expose the same three
|
||||||
/// report back through the same three callbacks, which is the whole reason a
|
/// operations and report back through the same callbacks, which is the whole
|
||||||
/// pane can stay ignorant of which one it has.
|
/// reason a pane can stay ignorant of which one it has.
|
||||||
pub const Content = union(Kind) {
|
pub const Content = union(Kind) {
|
||||||
terminal: *Terminal,
|
terminal: *Terminal,
|
||||||
web: *Browser,
|
web: *Browser,
|
||||||
|
review: *Review,
|
||||||
|
|
||||||
pub fn widget(self: Content) *gtk.Widget {
|
pub fn widget(self: Content) *gtk.Widget {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
@@ -336,6 +351,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
|||||||
self.content = switch (spec) {
|
self.content = switch (spec) {
|
||||||
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
|
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
|
||||||
.web => |opts| .{ .web = 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();
|
errdefer self.content.destroy();
|
||||||
|
|
||||||
@@ -385,7 +401,7 @@ pub fn grabFocus(self: *Pane) void {
|
|||||||
pub fn terminal(self: *Pane) ?*Terminal {
|
pub fn terminal(self: *Pane) ?*Terminal {
|
||||||
return switch (self.content) {
|
return switch (self.content) {
|
||||||
.terminal => |t| t,
|
.terminal => |t| t,
|
||||||
.web => null,
|
else => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +411,17 @@ pub fn terminal(self: *Pane) ?*Terminal {
|
|||||||
pub fn browser(self: *Pane) ?*Browser {
|
pub fn browser(self: *Pane) ?*Browser {
|
||||||
return switch (self.content) {
|
return switch (self.content) {
|
||||||
.web => |b| b,
|
.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, .{});
|
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
|
||||||
header.append(web.as(gtk.Widget));
|
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
|
// 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.
|
// is expected and the zoom toggle sits with the other view controls.
|
||||||
self.zoom.as(gtk.Widget).addCssClass("flat");
|
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 {
|
fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||||
self.view.toggleZoom(self);
|
self.view.toggleZoom(self);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-4
@@ -92,11 +92,16 @@ pub const Error = error{
|
|||||||
/// `dir` is the directory the child starts in. A null, or a directory that
|
/// `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
|
/// 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.
|
/// 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(
|
pub fn create(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
path: [:0]const u8,
|
path: [:0]const u8,
|
||||||
argv: []const [:0]const u8,
|
argv: []const [:0]const u8,
|
||||||
dir: ?[:0]const u8,
|
dir: ?[:0]const u8,
|
||||||
|
env_extra: []const []const u8,
|
||||||
size: Winsize,
|
size: Winsize,
|
||||||
) !Pty {
|
) !Pty {
|
||||||
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
||||||
@@ -118,7 +123,7 @@ pub fn create(
|
|||||||
defer alloc.free(argv_z);
|
defer alloc.free(argv_z);
|
||||||
for (argv, 0..) |arg, i| argv_z[i] = arg.ptr;
|
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);
|
defer freeEnv(alloc, envp_z);
|
||||||
|
|
||||||
const slave_path_z = try alloc.dupeZ(u8, slave_path);
|
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
|
/// Copy the current environment, forcing the variables that describe what
|
||||||
/// kind of terminal we are. We advertise xterm-256color rather than
|
/// kind of terminal we are, and adding whatever the caller supplied. We
|
||||||
/// ghostty's own terminfo because we don't install a terminfo entry.
|
/// advertise xterm-256color rather than ghostty's own terminfo because we
|
||||||
fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
|
/// 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;
|
var list: std.ArrayListUnmanaged([*:0]const u8) = .empty;
|
||||||
defer list.deinit(alloc);
|
defer list.deinit(alloc);
|
||||||
errdefer for (list.items) |item| alloc.free(std.mem.span(item));
|
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.
|
// Drop the variables we're about to define ourselves.
|
||||||
if (std.mem.startsWith(u8, span, "TERM=")) continue;
|
if (std.mem.startsWith(u8, span, "TERM=")) continue;
|
||||||
if (std.mem.startsWith(u8, span, "COLORTERM=")) 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, span)).ptr);
|
||||||
}
|
}
|
||||||
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
|
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
|
||||||
try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).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);
|
const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null);
|
||||||
for (list.items, 0..) |item, idx| result[idx] = item;
|
for (list.items, 0..) |item, idx| result[idx] = item;
|
||||||
return result;
|
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 {
|
fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
|
||||||
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
|
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
|
||||||
alloc.free(envp);
|
alloc.free(envp);
|
||||||
|
|||||||
+294
@@ -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);
|
||||||
|
}
|
||||||
@@ -36,8 +36,12 @@ const ParamRow = struct {
|
|||||||
const PaneRow = struct {
|
const PaneRow = struct {
|
||||||
node: *Layouts.Node,
|
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,
|
cwd: ?*gtk.Entry = null,
|
||||||
|
|
||||||
|
/// Script to run, for a terminal pane.
|
||||||
command: ?*gtk.Entry = null,
|
command: ?*gtk.Entry = null,
|
||||||
|
|
||||||
/// Page to open, for a web pane.
|
/// 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");
|
const url = field(grid, 0, "Address", spec.url, "https://example.com");
|
||||||
self.panes[index].url = url;
|
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));
|
box.append(grid.as(gtk.Widget));
|
||||||
|
|||||||
+24
-1
@@ -106,6 +106,16 @@ pub const Options = struct {
|
|||||||
|
|
||||||
/// Script fed to the shell once it is up.
|
/// Script fed to the shell once it is up.
|
||||||
command: []const u8 = "",
|
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(
|
pub fn create(
|
||||||
@@ -179,7 +189,20 @@ pub fn create(
|
|||||||
null;
|
null;
|
||||||
defer if (cwd_z) |z| alloc.free(z);
|
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_row = rows,
|
||||||
.ws_col = cols,
|
.ws_col = cols,
|
||||||
});
|
});
|
||||||
|
|||||||
+88
-3
@@ -1,5 +1,5 @@
|
|||||||
//! A view: the content of one tab, holding one or more panes — terminals, web
|
//! 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
|
//! 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
|
//! 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 Layout = @import("Layout.zig");
|
||||||
const Layouts = @import("Layouts.zig");
|
const Layouts = @import("Layouts.zig");
|
||||||
const Pane = @import("Pane.zig");
|
const Pane = @import("Pane.zig");
|
||||||
|
const Review = @import("Review.zig");
|
||||||
const Terminal = @import("Terminal.zig");
|
const Terminal = @import("Terminal.zig");
|
||||||
|
|
||||||
const View = @This();
|
const View = @This();
|
||||||
@@ -75,6 +76,18 @@ zoomed: ?*Pane = null,
|
|||||||
|
|
||||||
drag: ?Drag = 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
|
/// 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.
|
/// try to remove it from a list we're already draining.
|
||||||
closing: bool = false,
|
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
|
/// one that finished before your last visit looks identical to one that
|
||||||
/// finished after it.
|
/// finished after it.
|
||||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
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,
|
ctx: ?*anyopaque = null,
|
||||||
|
|
||||||
pub const Callbacks = struct {
|
pub const Callbacks = struct {
|
||||||
@@ -96,6 +116,7 @@ pub const Callbacks = struct {
|
|||||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||||
|
on_review: *const fn (ctx: ?*anyopaque) void,
|
||||||
ctx: ?*anyopaque,
|
ctx: ?*anyopaque,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +132,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
|||||||
.on_title = cbs.on_title,
|
.on_title = cbs.on_title,
|
||||||
.on_status = cbs.on_status,
|
.on_status = cbs.on_status,
|
||||||
.on_finished = cbs.on_finished,
|
.on_finished = cbs.on_finished,
|
||||||
|
.on_review = cbs.on_review,
|
||||||
.ctx = cbs.ctx,
|
.ctx = cbs.ctx,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,9 +265,31 @@ pub fn focus(self: *View) void {
|
|||||||
if (self.focusedPane()) |pane| pane.grabFocus();
|
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
|
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||||
/// whatever you were working in.
|
/// 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);
|
const pane = try Pane.create(self.alloc, self, spec);
|
||||||
errdefer pane.destroy();
|
errdefer pane.destroy();
|
||||||
|
|
||||||
@@ -272,6 +316,31 @@ pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
|||||||
self.on_title(self.ctx);
|
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 {
|
pub fn closePane(self: *View, pane: *Pane) void {
|
||||||
if (self.closing) return;
|
if (self.closing) return;
|
||||||
|
|
||||||
@@ -443,7 +512,7 @@ fn buildNode(
|
|||||||
const pane_spec = try self.paneSpec(p, bindings);
|
const pane_spec = try self.paneSpec(p, bindings);
|
||||||
defer freePaneSpec(self.alloc, pane_spec);
|
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();
|
errdefer pane.destroy();
|
||||||
|
|
||||||
const node = try self.layout.newLeaf(pane);
|
const node = try self.layout.newLeaf(pane);
|
||||||
@@ -485,6 +554,11 @@ fn paneSpec(
|
|||||||
.web => .{ .web = .{
|
.web => .{ .web = .{
|
||||||
.url = try Layouts.expand(self.alloc, p.url, bindings),
|
.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);
|
alloc.free(o.command);
|
||||||
},
|
},
|
||||||
.web => |o| alloc.free(o.url),
|
.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,
|
.kind = .web,
|
||||||
.url = b.currentUrl(),
|
.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(
|
.split => |s| try builder.split(
|
||||||
switch (s.paned.as(gtk.Orientable).getOrientation()) {
|
switch (s.paned.as(gtk.Orientable).getOrientation()) {
|
||||||
|
|||||||
+217
@@ -27,6 +27,7 @@ const View = @import("View.zig");
|
|||||||
const appearance = @import("appearance.zig");
|
const appearance = @import("appearance.zig");
|
||||||
const emoji = @import("emoji.zig");
|
const emoji = @import("emoji.zig");
|
||||||
const key = @import("key.zig");
|
const key = @import("key.zig");
|
||||||
|
const review = @import("review.zig");
|
||||||
const shortcuts = @import("shortcuts.zig");
|
const shortcuts = @import("shortcuts.zig");
|
||||||
|
|
||||||
const Window = @This();
|
const Window = @This();
|
||||||
@@ -186,6 +187,15 @@ const Tab = struct {
|
|||||||
/// The layout this tab was opened from, if any. Null for a plain shell.
|
/// The layout this tab was opened from, if any. Null for a plain shell.
|
||||||
source: ?Source = null,
|
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.
|
/// The row's right-click menu, parented to this tab's row.
|
||||||
menu_popover: *gtk.Popover,
|
menu_popover: *gtk.Popover,
|
||||||
|
|
||||||
@@ -458,6 +468,7 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
.on_title = &onViewTitle,
|
.on_title = &onViewTitle,
|
||||||
.on_status = &onViewStatus,
|
.on_status = &onViewStatus,
|
||||||
.on_finished = &onViewFinished,
|
.on_finished = &onViewFinished,
|
||||||
|
.on_review = &onViewReview,
|
||||||
.ctx = tab,
|
.ctx = tab,
|
||||||
});
|
});
|
||||||
errdefer view.destroy();
|
errdefer view.destroy();
|
||||||
@@ -524,9 +535,38 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
|
|
||||||
try self.tabs.append(self.alloc, 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;
|
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
|
// The row menu
|
||||||
//
|
//
|
||||||
@@ -887,6 +927,10 @@ fn buildLayoutTab(
|
|||||||
) !*Tab {
|
) !*Tab {
|
||||||
const tab = try self.newTabEmpty();
|
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| {
|
tab.view.applyLayout(layout.root, bindings) catch |err| {
|
||||||
// A half-built view has no panes to work in and no shell to close, so
|
// 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
|
// 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
|
/// by whoever took the tab out of the window, which on the teardown path is not
|
||||||
/// the same code.
|
/// the same code.
|
||||||
fn releaseTab(self: *Window, tab: *Tab) void {
|
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);
|
if (tab.custom_name) |name| self.alloc.free(name);
|
||||||
self.freeSource(tab);
|
self.freeSource(tab);
|
||||||
self.alloc.destroy(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.
|
/// 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 {
|
fn onViewEmpty(ctx: ?*anyopaque) void {
|
||||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||||
tab.window.closeTab(tab);
|
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 {
|
fn selectIndex(self: *Window, index: usize) void {
|
||||||
if (index >= self.tabs.items.len) return;
|
if (index >= self.tabs.items.len) return;
|
||||||
self.select(self.tabs.items[index]);
|
self.select(self.tabs.items[index]);
|
||||||
@@ -1887,6 +2100,10 @@ fn perform(self: *Window, action: shortcuts.Action) bool {
|
|||||||
|
|
||||||
.new_terminal => self.addPane(.terminal),
|
.new_terminal => self.addPane(.terminal),
|
||||||
.new_web => self.addPane(.web),
|
.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),
|
.rename_tab => if (self.activeTab()) |tab| self.beginRename(tab),
|
||||||
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
|
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
|
||||||
.toggle_sidebar => self.toggleSidebar(),
|
.toggle_sidebar => self.toggleSidebar(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const Settings = @import("Settings.zig");
|
|||||||
const Window = @import("Window.zig");
|
const Window = @import("Window.zig");
|
||||||
const appearance = @import("appearance.zig");
|
const appearance = @import("appearance.zig");
|
||||||
const icons = @import("icons.zig");
|
const icons = @import("icons.zig");
|
||||||
|
const review = @import("review.zig");
|
||||||
|
|
||||||
/// libghostty-vt logs unimplemented sequences at debug level, which is very
|
/// libghostty-vt logs unimplemented sequences at debug level, which is very
|
||||||
/// chatty against a real shell. Keep the app's own warnings and errors.
|
/// 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.
|
// of it. A no-op if the app never got as far as activating.
|
||||||
defer Settings.deinit();
|
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
|
// 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
|
// 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
|
// 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.
|
// for one by name.
|
||||||
icons.init();
|
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| {
|
const window = Window.create(gpa.allocator(), app) catch |err| {
|
||||||
std.log.err("failed to create window: {s}", .{@errorName(err)});
|
std.log.err("failed to create window: {s}", .{@errorName(err)});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -108,6 +108,7 @@ pub const Action = enum {
|
|||||||
close_pane,
|
close_pane,
|
||||||
new_terminal,
|
new_terminal,
|
||||||
new_web,
|
new_web,
|
||||||
|
new_review,
|
||||||
rename_tab,
|
rename_tab,
|
||||||
toggle_zoom,
|
toggle_zoom,
|
||||||
toggle_sidebar,
|
toggle_sidebar,
|
||||||
@@ -156,6 +157,9 @@ pub const defaults: []const Binding = &.{
|
|||||||
.{ .chord = chord("ctrl+shift+w"), .action = .close_pane },
|
.{ .chord = chord("ctrl+shift+w"), .action = .close_pane },
|
||||||
.{ .chord = chord("ctrl+shift+e"), .action = .new_terminal },
|
.{ .chord = chord("ctrl+shift+e"), .action = .new_terminal },
|
||||||
.{ .chord = chord("ctrl+shift+b"), .action = .new_web },
|
.{ .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+r"), .action = .rename_tab },
|
||||||
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
|
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
|
||||||
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
|
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
|
||||||
|
|||||||
+7
-1
@@ -88,11 +88,17 @@ pub const WebView = opaque {
|
|||||||
/// they know about, so WebKit's own signals go through the untyped
|
/// they know about, so WebKit's own signals go through the untyped
|
||||||
/// GObject entry point. Property changes don't need this: `notify` is
|
/// GObject entry point. Property changes don't need this: `notify` is
|
||||||
/// declared on `gobject.Object`, which this can be cast to.
|
/// 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(
|
pub fn connectSignal(
|
||||||
self: *WebView,
|
self: *WebView,
|
||||||
comptime signal: [:0]const u8,
|
comptime signal: [:0]const u8,
|
||||||
comptime Data: type,
|
comptime Data: type,
|
||||||
handler: *const fn (*WebView, Data) callconv(.c) void,
|
handler: anytype,
|
||||||
data: Data,
|
data: Data,
|
||||||
) void {
|
) void {
|
||||||
_ = gobject.signalConnectData(
|
_ = gobject.signalConnectData(
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+1993
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 }>),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 doesn’t touch — the base ref moved, or the change was
|
||||||
|
undone. Nothing has been lost; resolve or delete them when you’re 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 wasn’t rendered.
|
||||||
|
</p>
|
||||||
|
<p>{advice(base, suggested)}</p>
|
||||||
|
{commitAdvice(commits) && <p>{commitAdvice(commits)}</p>}
|
||||||
|
<button className="btn-ghost" onClick={onLoad}>
|
||||||
|
Load it anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)]));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user