From 407dd81512d015a6857cdb9e3cc145e97c7c3e05 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 24 Aug 2026 09:24:01 -0400 Subject: [PATCH] Build in the review tool. --- .claude/skills/address-review/SKILL.md | 119 ++ .claude/skills/leave-review/SKILL.md | 234 +++ .gitignore | 7 + README.md | 202 ++- build.zig | 94 ++ flake.nix | 5 + mise.toml | 67 + src/Layouts.zig | 74 +- src/Pane.zig | 73 +- src/Pty.zig | 37 +- src/Review.zig | 294 ++++ src/SaveLayoutDialog.zig | 13 +- src/Session.zig | 25 +- src/View.zig | 91 +- src/Window.zig | 217 +++ src/main.zig | 12 + src/review.zig | 91 + src/review/Server.zig | 1227 ++++++++++++++ src/review/Store.zig | 632 +++++++ src/review/assets.zig | 55 + src/review/git.zig | 815 +++++++++ src/review/model.zig | 166 ++ src/shortcuts.zig | 4 + src/webkit.zig | 8 +- web/index.html | 13 + web/package-lock.json | 1993 ++++++++++++++++++++++ web/package.json | 24 + web/public/favicon.svg | 17 + web/src/App.tsx | 848 ++++++++++ web/src/api.ts | 183 ++ web/src/components/CommentThread.tsx | 337 ++++ web/src/components/CommentsPanel.tsx | 248 +++ web/src/components/CommitList.tsx | 114 ++ web/src/components/Composer.tsx | 58 + web/src/components/ConfirmDialog.tsx | 61 + web/src/components/DiffView.tsx | 694 ++++++++ web/src/components/FileList.tsx | 231 +++ web/src/components/Icon.tsx | 99 ++ web/src/components/Outdated.tsx | 120 ++ web/src/components/Oversize.tsx | 112 ++ web/src/components/Resizer.tsx | 82 + web/src/components/ReviewPanel.tsx | 52 + web/src/components/ReviewProgress.tsx | 59 + web/src/lib/anchor.ts | 123 ++ web/src/lib/filetree.ts | 141 ++ web/src/lib/fingerprint.ts | 50 + web/src/lib/language.ts | 114 ++ web/src/lib/time.ts | 18 + web/src/lib/useSSE.ts | 52 + web/src/lib/viewed.ts | 199 +++ web/src/main.tsx | 16 + web/src/styles.css | 2154 ++++++++++++++++++++++++ web/src/types.ts | 124 ++ web/tsconfig.json | 20 + web/vite.config.ts | 54 + 55 files changed, 12932 insertions(+), 40 deletions(-) create mode 100644 .claude/skills/address-review/SKILL.md create mode 100644 .claude/skills/leave-review/SKILL.md create mode 100644 src/Review.zig create mode 100644 src/review.zig create mode 100644 src/review/Server.zig create mode 100644 src/review/Store.zig create mode 100644 src/review/assets.zig create mode 100644 src/review/git.zig create mode 100644 src/review/model.zig create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/CommentThread.tsx create mode 100644 web/src/components/CommentsPanel.tsx create mode 100644 web/src/components/CommitList.tsx create mode 100644 web/src/components/Composer.tsx create mode 100644 web/src/components/ConfirmDialog.tsx create mode 100644 web/src/components/DiffView.tsx create mode 100644 web/src/components/FileList.tsx create mode 100644 web/src/components/Icon.tsx create mode 100644 web/src/components/Outdated.tsx create mode 100644 web/src/components/Oversize.tsx create mode 100644 web/src/components/Resizer.tsx create mode 100644 web/src/components/ReviewPanel.tsx create mode 100644 web/src/components/ReviewProgress.tsx create mode 100644 web/src/lib/anchor.ts create mode 100644 web/src/lib/filetree.ts create mode 100644 web/src/lib/fingerprint.ts create mode 100644 web/src/lib/language.ts create mode 100644 web/src/lib/time.ts create mode 100644 web/src/lib/useSSE.ts create mode 100644 web/src/lib/viewed.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/types.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.claude/skills/address-review/SKILL.md b/.claude/skills/address-review/SKILL.md new file mode 100644 index 0000000..ed4c00d --- /dev/null +++ b/.claude/skills/address-review/SKILL.md @@ -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/`. 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//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//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. diff --git a/.claude/skills/leave-review/SKILL.md b/.claude/skills/leave-review/SKILL.md new file mode 100644 index 0000000..a1b846b --- /dev/null +++ b/.claude/skills/leave-review/SKILL.md @@ -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 ", 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=`. + +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. diff --git a/.gitignore b/.gitignore index 0d29f66..ecfe410 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ zig-out/ # build.zig.zon and must not be committed. zig-pkg/ result + +# The review UI's build inputs and outputs. `zig build` runs Vite itself (see +# build.zig) and embeds what it produces, so dist is a build artifact like any +# other; node_modules comes from `mise run web-deps`. +web/node_modules/ +web/dist/ +web/*.tsbuildinfo diff --git a/README.md b/README.md index e88acb7..4c8149e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ A proof-of-concept workspace built on [libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita: -**vertical tabs**, each holding a split tree of terminal and web panes, with -**saved layouts** that open a whole arrangement — directories, scripts and all — -in one go, and a **startup list** that opens the ones you always want. +**vertical tabs**, each holding a split tree of terminal, web and **code review** +panes, with **saved layouts** that open a whole arrangement — directories, scripts +and all — in one go, and a **startup list** that opens the ones you always want. + +A tab can hold a GitHub-style review of the repository it is working in, and the +agent in the terminal beside it can read the comments you leave there and reply +inline. See [Code review](#code-review). The sidebar holds the window controls, a new-tab button, one row per tab, and a settings gear at its foot — the layout Zen Browser uses for vertical tabs — @@ -13,11 +17,15 @@ with the content inset to its right. ## Quick start ```sh -nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed +nix develop # Zig 0.16 + GTK4 + libadwaita + node, no host toolchain needed +mise run web-deps # once: npm deps for the review pane's UI zig build run ``` Everything is pinned by `flake.nix`; nothing needs to be installed on the host. +`web-deps` is separate because it is the one step that wants the network — +`zig build` runs the UI's production build itself, but it will not install its +dependencies for you. ## Installing @@ -115,7 +123,15 @@ Layout.zig the split tree: nodes, rearranging, GtkPaned materialization Pane.zig content plus its header, drag source, and drop target Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection Browser.zig WebKitWebView plus a nav/address bar and a find bar +Review.zig the tab's review: a web view bound to its own server endpoint webkit.zig hand-written bindings for the WebKitGTK calls we make +review.zig the review server's lifecycle, and the one place with threads +review/Server.zig HTTP + SSE, the tab registry, the embedded UI +review/git.zig what git is asked for: refs, commits, the patch itself +review/Store.zig one review's comments, as JSON in the repo's git dir +review/model.zig the wire format the UI and the API both speak +review/assets.zig the built UI, carried in the binary +web/ the review UI: React + Vite, built by build.zig and embedded Layouts.zig saved tab templates: model, JSON on disk, {{substitution}} OpenLayoutDialog.zig prompts for a layout's parameters SaveLayoutDialog.zig turns the current tab into a saved layout @@ -199,8 +215,8 @@ what it can't infer: a name, the parameters, and each pane's script. There is deliberately no separate layout builder — the split tree already is one. The save dialog prefills what it can read off the live tab: each terminal's -current directory, straight out of `/proc//cwd`, and each web pane's -current page. So the usual flow is to get a tab set up the way you like, +current directory, straight out of `/proc//cwd`, each web pane's current +page, and the repository a review pane is bound to. So the usual flow is to get a tab set up the way you like, save it, and replace the literal paths with `{{parameters}}`. **Editing** a saved layout opens the same dialog on the stored one, so its @@ -303,6 +319,22 @@ A node is a split if it has a `split` key and a leaf otherwise. Saves are atomic — written to a temporary and renamed — so an interrupted write leaves the previous layouts intact rather than a file that won't parse. +A leaf's `kind` is `terminal`, `web`, or `review`. A `review` leaf takes a `cwd` +and nothing else — a review has no shell to start and no page to load, so the +only thing to say about one is which repository to point it at: + +```json +{ "kind": "review", "cwd": "{{path}}" } +``` + +The directory is expanded exactly like a terminal's, so `{{parameters}}`, a +leading `~` and `$(a command)` all work, and opening the same layout against two +worktrees gives two different reviews. Leave it out — `{ "kind": "review" }`, all +a layout saved before this field existed says — and the pane opens with nothing +bound and says so: a layout has to name the directory itself, because the +terminals it would otherwise be read off have not started yet. +See [Code review](#code-review). + ## Startup tabs Opening the same three layouts against the same three worktrees every morning is @@ -392,6 +424,12 @@ in principle, but a terminal grid is small. that takes a URL or falls back to a search, a load-progress indicator in the entry, and the page title feeding the pane header and tab label, plus find-in-page on `Ctrl+F`. See [Find in page](#find-in-page) +- **Review panes**: a GitHub-style review of the repository the tab is working + in — split/unified diff with syntax highlighting, a folder-tree file rail, + viewed marks and a progress meter, line/range/file/review-level comments, and + a commit list for reading a branch one commit at a time. One per tab, served + by a local HTTP API so the agent in the next pane can read your comments and + reply inline. See [Code review](#code-review) - **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories and scripts — opened in one go, parameterised by `{{name}}`, authored by arranging a tab and saving it. See [Layouts](#layouts) @@ -430,6 +468,7 @@ in principle, but a terminal grid is small. | `Ctrl+Shift+T` | new tab | `new_tab` | | `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` | | `Ctrl+Shift+B` | new web view in the current tab | `new_web` | +| `Ctrl+Shift+D` | review this tab's changes | `new_review` | | `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | `close_pane` | | `Ctrl+Shift+H/J/K/L` | move focus to the pane left / below / above / right | `focus_pane_left` and friends | | `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | `move_pane_left` and friends | @@ -619,6 +658,140 @@ The choice lives on the tab and not on disk, the same as a typed name: it lasts as long as the tab does. Saving the tab as a layout does not carry it, since a layout describes an arrangement of panes rather than what a row looks like. +## Code review + +A tab can hold a **review pane**: a GitHub-style review of the repository that +tab is working in, on `Ctrl+Shift+D` or the ✎ button in any pane's header. + +It exists because reading a diff and driving an agent are the same session. The +agent runs in a terminal in the tab; the review is a pane beside it; you leave +line comments, say "address the review", and the replies appear in the pane while +you watch. Nothing is copy-pasted out of a terminal, and nothing has to be +switched to. + +The UI is a web page (React, in `web/`), built by `zig build` and carried inside +the binary. It is served by an HTTP server this process runs — which is how the +agent gets at it too. + +### One review per tab, bound to a directory + +A review pane takes its repository from **the directory the tab is working in**: +the focused terminal's current directory, or the first terminal's if the focused +pane is not one. That is resolved once, when the pane opens, and then it stays +put. Re-resolving on every fetch was the alternative, and it means a `cd` in a +terminal can swap the diff out from under you mid-read; a review you have to +reopen is the better failure. + +A **layout** names the directory itself, with a `cwd` on its `review` leaf (see +[Layouts](#layouts)). It has to: a layout's review pane is built alongside its +terminals rather than after them, so there is no working directory to read off +yet. It is resolved before any pane in the tab exists, so the review is already +attached to its repository by the time the pane's page loads. + +A tab holds at most one. Two review panes would each be publishing a different +diff selection to the server, so an agent asked to review "the diff I'm looking +at" would follow whichever wrote last — the second pane would quietly break the +first. Asking for a review you already have takes you to it instead. + +Comments live in `/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// the review UI for that tab +/t//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//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//resolve" +``` + +The endpoints, all under `/t//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//` — the path is what tells the page +which review it is. `zig build` runs the production build itself and embeds the +result, so the bundle in the binary can never be older than the source it came +from. + ## Agent status Playpen is mostly used to keep several Claude Code sessions side by side, and @@ -927,11 +1100,18 @@ emit from and the client never sees it. Anything reachable only by clicking has to be reached another way — a keyboard shortcut, or `zig build test` if the thing being checked is logic rather than pixels. -`zig build test` runs the unit tests, which cover layout parsing, the -parameter/`$(...)` substitution pipeline, and the emoji table and its search. -They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither -links GTK — a test binary has one root, and those are the two files worth -testing in isolation that have nothing to do with each other. +`zig build test` runs the unit tests. They cover layout parsing, the +parameter/`$(...)` substitution pipeline, the palette, the settings file, the +shortcut table, the emoji table and its search, and — for the review server — +git's own output formats, the rule for picking a base ref, and the round trip a +review file makes through disk. Each is its own binary with its own root, since a +test binary has exactly one, and none of those roots links GTK, so they all run +without a display. + +What is *not* covered is anything that wants a socket and a browser: the HTTP +layer, the SSE stream, and the review UI. Those were checked by running the app +in the headless compositor and driving the API with `curl` — see the note about +synthetic clicks above for why the pane itself has to be reached by keyboard. `tools/gen-emoji.py` regenerates `src/emoji.zig` from Unicode's `emoji-test.txt` and CLDR's annotations. It is not part of the build — its output is committed, so diff --git a/build.zig b/build.zig index 17edd67..008b1f2 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,40 @@ pub fn build(b: *std.Build) void { .root_source_file = icons_gresource, }); + // The review pane's UI. It is a React app (see `web/`), built by Vite here so + // the bundle embedded in the binary can never be older than the source it + // came from, and embedded rather than installed alongside so a review pane + // is a web view pointed at this process and nothing else. + // + // The four filenames are pinned in `web/vite.config.ts` precisely so they + // can be named at compile time; `src/review/assets.zig` is the other half of + // that agreement. + const web_build = b.addSystemCommand(&.{ "npm", "run", "--silent", "build", "--" }); + web_build.setCwd(b.path("web")); + web_build.setName("vite build (review UI)"); + web_build.addArg("--outDir"); + const web_dist = web_build.addOutputDirectoryArg("dist"); + web_build.addArg("--emptyOutDir"); + + // Vite is not told what its inputs are, so the Run step has to be. Without + // this the bundle is cached against its argv alone and editing the UI would + // rebuild nothing; with it, `zig build` after a `.tsx` edit does the right + // thing and `zig build` after a `.zig` edit does not re-run npm. + addWebInputs(b, web_build); + + exe.root_module.addAnonymousImport("review-index.html", .{ + .root_source_file = web_dist.path(b, "index.html"), + }); + exe.root_module.addAnonymousImport("review-app.js", .{ + .root_source_file = web_dist.path(b, "assets/app.js"), + }); + exe.root_module.addAnonymousImport("review-app.css", .{ + .root_source_file = web_dist.path(b, "assets/app.css"), + }); + exe.root_module.addAnonymousImport("review-favicon.svg", .{ + .root_source_file = web_dist.path(b, "favicon.svg"), + }); + const gobject_imports = .{ .{ "adw", "adw1" }, .{ "cairo", "cairo1" }, @@ -149,6 +183,32 @@ pub fn build(b: *std.Build) void { // table and a search over it, and it imports nothing at all. It cannot hang // off the root above because a test binary has exactly one root, and // Layouts.zig has no reason to reach for the emoji table. + // The review server's two testable halves. `git.zig` is git's own output + // formats and the rule for picking a base ref — text in, text out. `Store.zig` + // is the review file, whose round trip is the one piece of this that has to + // survive the process. Neither reaches GTK, so both run without a display; + // the HTTP layer above them wants a socket and a browser, which is a + // different kind of test than this project has. + const git_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/review/git.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + test_step.dependOn(&b.addRunArtifact(git_tests).step); + + const store_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/review/Store.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + test_step.dependOn(&b.addRunArtifact(store_tests).step); + const emoji_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/emoji.zig"), @@ -158,3 +218,37 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(emoji_tests).step); } + +/// Declare every file the Vite build reads as an input of that build step. +/// +/// Zig hashes a `Run` step's file *arguments*, but a directory passed as an +/// argument is hashed by path and not by contents — so the source tree has to be +/// enumerated here, at configure time, for the cache to be honest about when the +/// bundle is stale. +/// +/// `node_modules` is deliberately not walked. It is tens of thousands of files +/// whose contents are already pinned by `package-lock.json`, which *is* listed. +fn addWebInputs(b: *std.Build, run: *std.Build.Step.Run) void { + for ([_][]const u8{ + "web/index.html", + "web/package.json", + "web/package-lock.json", + "web/tsconfig.json", + "web/vite.config.ts", + }) |file| { + run.addFileInput(b.path(file)); + } + + const io = b.graph.io; + for ([_][]const u8{ "web/src", "web/public" }) |root| { + var dir = b.build_root.handle.openDir(io, root, .{ .iterate = true }) catch continue; + defer dir.close(io); + + var walker = dir.walk(b.allocator) catch continue; + defer walker.deinit(); + while (walker.next(io) catch null) |entry| { + if (entry.kind != .file) continue; + run.addFileInput(b.path(b.pathJoin(&.{ root, entry.path }))); + } + } +} diff --git a/flake.nix b/flake.nix index b7e14f4..1d0537d 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,11 @@ pkgs.pkg-config pkgs.gdb + # The review pane's UI is a React app built by Vite, and `zig build` + # runs that build (see build.zig) so the bundle it embeds can never be + # older than the source it came from. Nothing else here needs node. + pkgs.nodejs_22 + # Used by ./shot.sh to run the app inside a throwaway headless # compositor and screenshot it, so UI can be checked without # touching the developer's real session. diff --git a/mise.toml b/mise.toml index 2417cc2..d6131b6 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,14 @@ # Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is # nothing for mise to install; these tasks just drive it. +[tasks.web-deps] +description = "Install the review UI's npm dependencies" +run = "nix develop --command npm --prefix web install --no-audit --no-fund" + +[tasks.web-dev] +description = "Hot-reloading review UI against a running playpen (open /t//)" +run = "nix develop --command npm --prefix web run dev" + [tasks.build] description = "Build a release binary into zig-out/bin" run = "nix develop --command zig build -Doptimize=ReleaseFast" @@ -117,6 +125,65 @@ rm -f "$claude/hooks/playpen-status.sh" echo "removed the hooks from $settings (previous version kept at $settings.playpen-backup)" ''' +[tasks.install-skills] +description = "Install the review skills so Claude can drive the review pane" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +dest="$claude/skills" +# Outside the skills directory on purpose: anything under it is loaded as a +# skill, so a backup kept alongside would show up in the list as a second, +# stale copy of the thing it is a backup of. +backups="$claude/playpen-skill-backups" +mkdir -p "$dest" "$backups" + +for name in address-review leave-review; do + src=".claude/skills/$name" + [ -d "$src" ] || { echo "missing $src" >&2; exit 1; } + + # Anything already installed under this name is kept, not replaced. These are + # global names: the version being overwritten may be one that drives an + # entirely different tool, and losing it silently would be the worst kind of + # breakage — the skill still runs, against a server that is not there. + if [ -e "$dest/$name" ] && [ ! -L "$dest/$name" ]; then + rm -rf "$backups/$name" + cp -r "$dest/$name" "$backups/$name" + echo "kept the previous $name at $backups/$name" + fi + + rm -rf "$dest/$name" + cp -r "$src" "$dest/$name" + echo "installed $dest/$name" +done + +echo +echo "Open a new Claude session to pick them up — skills are read at startup." +''' + +[tasks.uninstall-skills] +description = "Remove the review skills, restoring whatever they replaced" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +dest="$claude/skills" +backups="$claude/playpen-skill-backups" + +for name in address-review leave-review; do + rm -rf "$dest/$name" + if [ -d "$backups/$name" ]; then + mv "$backups/$name" "$dest/$name" + echo "restored the previous $name from the backup" + else + echo "removed $dest/$name" + fi +done +rmdir "$backups" 2>/dev/null || true +''' + [tasks.install] description = "Install playpen into ~/.local with a desktop entry and icon" depends = ["build"] diff --git a/src/Layouts.zig b/src/Layouts.zig index 929cd55..8fb1297 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -47,7 +47,11 @@ pub const Orientation = enum { horizontal, vertical }; /// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than /// imported so this module stays free of GTK and of the widget tree. -pub const Kind = enum { terminal, web }; +/// +/// `review` uses `cwd` and nothing else: a review has no shell to start and no +/// page to load, so the only thing a layout has to say about one is which +/// repository to point it at. +pub const Kind = enum { terminal, web, review }; /// A value the user supplies when opening a layout. pub const Parameter = struct { @@ -63,6 +67,16 @@ pub const Pane = struct { kind: Kind = .terminal, /// Directory to start in. A leading `~` is expanded at open time. + /// + /// For a `review` pane this is the directory whose repository the tab + /// reviews, resolved as the tab opens rather than read off a terminal — + /// which is the only way a layout can say it, since the panes it would be + /// read off have not started yet. + /// + /// Left empty — which is what a layout saved before this field existed says + /// — the pane opens with no review bound and says so, since the gesture + /// that would bind one (asking for the tab's review) finds a review pane + /// already there and takes you to it instead. cwd: []const u8 = "", /// Script to run once the shell is up. Empty means "just a shell". @@ -791,3 +805,61 @@ test "closingParen counts nesting" { test "an unknown parameter is left visible rather than blanked" { try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{}); } + +test "a review leaf carries a directory through the layout file" { + const gpa = std.testing.allocator; + + var layouts: Layouts = .init(gpa); + defer layouts.deinit(); + + try layouts.parse( + \\{"version":1,"layouts":[{"name":"Work","root":{ + \\ "split":"horizontal","ratio":0.5, + \\ "first":{"kind":"terminal","cwd":"{{path}}"}, + \\ "second":{"kind":"review","cwd":"{{path}}"}}}]} + ); + try std.testing.expectEqual(@as(usize, 1), layouts.items.items.len); + + // The directory is the one field a review leaf has, and it is a template + // like any other — a layout aimed at a project reviews that project. + const second = layouts.items.items[0].root.split.second; + try std.testing.expectEqual(Kind.review, second.pane.kind); + try std.testing.expectEqualStrings("{{path}}", second.pane.cwd); + try std.testing.expectEqualStrings("", second.pane.url); + try expectPath("/tmp/x", second.pane.cwd, &.{.{ .name = "path", .value = "/tmp/x" }}); + + // And it survives being written back out, which is what "save tab as + // layout" does to a tab that has its review open. + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} }; + try writeNode(&json, second); + try std.testing.expectEqualStrings( + "{\"kind\":\"review\",\"cwd\":\"{{path}}\"}", + out.written(), + ); +} + +// A layout saved before the directory existed says nothing about one, and still +// has to open — its review pane comes up unbound rather than the whole layout +// refusing to parse. +test "a review leaf without a directory still round-trips" { + const gpa = std.testing.allocator; + + var layouts: Layouts = .init(gpa); + defer layouts.deinit(); + + try layouts.parse( + \\{"version":1,"layouts":[{"name":"Work","root":{"kind":"review"}}]} + ); + + const root = layouts.items.items[0].root; + try std.testing.expectEqual(Kind.review, root.pane.kind); + try std.testing.expectEqualStrings("", root.pane.cwd); + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} }; + try writeNode(&json, root); + try std.testing.expectEqualStrings("{\"kind\":\"review\"}", out.written()); +} diff --git a/src/Pane.zig b/src/Pane.zig index 2463fec..ef74998 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -1,7 +1,7 @@ -//! One pane inside a view: either a terminal or a web view, plus the chrome -//! needed to tell panes apart and rearrange them — a header strip showing the -//! content's title, which doubles as the drag handle, and a drop target -//! covering the whole pane. +//! One pane inside a view: a terminal, a web view, or the tab's code review, +//! plus the chrome needed to tell panes apart and rearrange them — a header +//! strip showing the content's title, which doubles as the drag handle, and a +//! drop target covering the whole pane. //! //! The header exists mainly so dragging a pane never competes with the //! content's own mouse handling. Grabbing anywhere in the terminal body would @@ -9,8 +9,8 @@ //! page would collide with the page itself. //! //! Everything below the header is behind `Content`, so the layout, drag and -//! drop, and focus tracking are all written once and neither kind of content -//! is special-cased. +//! drop, and focus tracking are all written once and no kind of content is +//! special-cased. const std = @import("std"); const gdk = @import("gdk"); @@ -19,6 +19,7 @@ const gtk = @import("gtk"); const Browser = @import("Browser.zig"); const Layout = @import("Layout.zig"); +const Review = @import("Review.zig"); const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); @@ -32,11 +33,17 @@ pub const Kind = enum { terminal, web, + /// The tab's code review. Unlike the other two there can be only one in a + /// view — see `View.addPane` — because it is bound to the tab rather than + /// being a thing you can have several of. + review, + /// Icon standing in for this kind in the pane header and the tab row. pub fn iconName(self: Kind) [:0]const u8 { return switch (self) { .terminal => "utilities-terminal-symbolic", .web => "web-browser-symbolic", + .review => "document-edit-symbolic", }; } @@ -45,6 +52,7 @@ pub const Kind = enum { return switch (self) { .terminal => "shell", .web => "web", + .review => "review", }; } }; @@ -55,12 +63,18 @@ pub const Kind = enum { pub const Spec = union(Kind) { terminal: Terminal.Options, web: Browser.Options, + review: Review.Options, /// A plain pane of the given kind, with nothing preloaded. + /// + /// A review pane with nothing preloaded has no endpoint to talk to, so it + /// opens explaining itself. Everything that opens one for real fills the + /// options in — see `Window.addReview`. pub fn plain(of: Kind) Spec { return switch (of) { .terminal => .{ .terminal = .{} }, .web => .{ .web = .{} }, + .review => .{ .review = .{} }, }; } @@ -70,16 +84,16 @@ pub const Spec = union(Kind) { }; /// What the content of a pane is currently doing. Only a terminal ever -/// reports this — a web pane is always `.idle` — but it lives here rather -/// than on Terminal so the view can aggregate across panes without caring -/// which kind each one is. +/// reports this — a web or review pane is always `.idle` — but it lives here +/// rather than on Terminal so the view can aggregate across panes without +/// caring which kind each one is. pub const Status = Terminal.Status; -/// What a content kind reports back to its pane. Shared by both kinds so the -/// pane can wire either one up with the same handlers. +/// What a content kind reports back to its pane. Shared by all three kinds so +/// the pane can wire any of them up with the same handlers. /// -/// A web pane simply never calls `on_status` or `on_input`; it has no -/// equivalent of a long-running job to report. +/// A web or review pane simply never calls `on_status` or `on_input`; neither +/// has an equivalent of a long-running job to report. pub const Callbacks = struct { on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, @@ -89,12 +103,13 @@ pub const Callbacks = struct { ctx: ?*anyopaque, }; -/// The two things a pane can hold. Both expose the same three operations and -/// report back through the same three callbacks, which is the whole reason a -/// pane can stay ignorant of which one it has. +/// The three things a pane can hold. All of them expose the same three +/// operations and report back through the same callbacks, which is the whole +/// reason a pane can stay ignorant of which one it has. pub const Content = union(Kind) { terminal: *Terminal, web: *Browser, + review: *Review, pub fn widget(self: Content) *gtk.Widget { return switch (self) { @@ -336,6 +351,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane { self.content = switch (spec) { .terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) }, .web => |opts| .{ .web = try .create(alloc, opts, callbacks) }, + .review => |opts| .{ .review = try .create(alloc, opts, callbacks) }, }; errdefer self.content.destroy(); @@ -385,7 +401,7 @@ pub fn grabFocus(self: *Pane) void { pub fn terminal(self: *Pane) ?*Terminal { return switch (self.content) { .terminal => |t| t, - .web => null, + else => null, }; } @@ -395,7 +411,17 @@ pub fn terminal(self: *Pane) ?*Terminal { pub fn browser(self: *Pane) ?*Browser { return switch (self.content) { .web => |b| b, - .terminal => null, + else => null, + }; +} + +/// The review this pane holds, or null if it holds something else. Used by the +/// view to enforce one review per tab, and by the window to reload the one +/// that is open. +pub fn review(self: *Pane) ?*Review { + return switch (self.content) { + .review => |r| r, + else => null, }; } @@ -467,6 +493,13 @@ fn buildHeader(self: *Pane) void { _ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{}); header.append(web.as(gtk.Widget)); + const review_button = gtk.Button.newFromIconName(Kind.review.iconName()); + review_button.as(gtk.Widget).addCssClass("flat"); + review_button.as(gtk.Widget).addCssClass("playpen-pane-button"); + review_button.as(gtk.Widget).setTooltipText("Review this tab's changes (Ctrl+Shift+D)"); + _ = gtk.Button.signals.clicked.connect(review_button, *Pane, &onReviewClicked, self, .{}); + header.append(review_button.as(gtk.Widget)); + // Last before close, so the destructive button stays on the end where it // is expected and the zoom toggle sits with the other view controls. self.zoom.as(gtk.Widget).addCssClass("flat"); @@ -711,6 +744,10 @@ fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { }; } +fn onReviewClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { + self.view.requestReview(); +} + fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { self.view.toggleZoom(self); } diff --git a/src/Pty.zig b/src/Pty.zig index 421eaf2..3c159f3 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -92,11 +92,16 @@ pub const Error = error{ /// `dir` is the directory the child starts in. A null, or a directory that /// cannot be entered, leaves it wherever the app was started — a layout /// naming a path that no longer exists should still give you a usable shell. +/// +/// `env_extra` is added to the child's environment as `NAME=value` strings, +/// replacing any the parent already had under the same name. This is how a +/// shell learns about the tab it is running in. pub fn create( alloc: std.mem.Allocator, path: [:0]const u8, argv: []const [:0]const u8, dir: ?[:0]const u8, + env_extra: []const []const u8, size: Winsize, ) !Pty { const master = c.posix_openpt(O_RDWR | O_NOCTTY); @@ -118,7 +123,7 @@ pub fn create( defer alloc.free(argv_z); for (argv, 0..) |arg, i| argv_z[i] = arg.ptr; - const envp_z = try buildEnv(alloc); + const envp_z = try buildEnv(alloc, env_extra); defer freeEnv(alloc, envp_z); const slave_path_z = try alloc.dupeZ(u8, slave_path); @@ -198,9 +203,18 @@ pub fn loginShell(buf: []u8) ?[]const u8 { } /// Copy the current environment, forcing the variables that describe what -/// kind of terminal we are. We advertise xterm-256color rather than -/// ghostty's own terminfo because we don't install a terminfo entry. -fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 { +/// kind of terminal we are, and adding whatever the caller supplied. We +/// advertise xterm-256color rather than ghostty's own terminfo because we +/// don't install a terminfo entry. +/// +/// Anything we are about to define is dropped from the inherited copy first, +/// so a variable set in playpen's own environment cannot shadow the value this +/// pane is supposed to see — which matters most for the ones that describe the +/// pane itself, since inheriting a stale one is worse than having none. +fn buildEnv( + alloc: std.mem.Allocator, + extra: []const []const u8, +) ![:null]?[*:0]const u8 { var list: std.ArrayListUnmanaged([*:0]const u8) = .empty; defer list.deinit(alloc); errdefer for (list.items) |item| alloc.free(std.mem.span(item)); @@ -211,16 +225,31 @@ fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 { // Drop the variables we're about to define ourselves. if (std.mem.startsWith(u8, span, "TERM=")) continue; if (std.mem.startsWith(u8, span, "COLORTERM=")) continue; + if (shadowedBy(span, extra)) continue; try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr); } try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr); try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr); + for (extra) |entry| { + try list.append(alloc, (try alloc.dupeZ(u8, entry)).ptr); + } const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null); for (list.items, 0..) |item, idx| result[idx] = item; return result; } +/// Whether an inherited `NAME=value` entry names a variable the caller is +/// about to define. +fn shadowedBy(entry: []const u8, extra: []const []const u8) bool { + const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false; + for (extra) |candidate| { + const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue; + if (std.mem.eql(u8, entry[0 .. eq + 1], candidate[0 .. candidate_eq + 1])) return true; + } + return false; +} + fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void { for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e)); alloc.free(envp); diff --git a/src/Review.zig b/src/Review.zig new file mode 100644 index 0000000..f6d48ff --- /dev/null +++ b/src/Review.zig @@ -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:/t/`. + /// + /// 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); +} diff --git a/src/SaveLayoutDialog.zig b/src/SaveLayoutDialog.zig index c290c23..fe228e5 100644 --- a/src/SaveLayoutDialog.zig +++ b/src/SaveLayoutDialog.zig @@ -36,8 +36,12 @@ const ParamRow = struct { const PaneRow = struct { node: *Layouts.Node, - /// Directory and script, for a terminal pane. + /// Directory, for a terminal pane or a review pane. The two mean slightly + /// different things by it — where the shell starts, versus which repository + /// is reviewed — but they are the same field, written back the same way. cwd: ?*gtk.Entry = null, + + /// Script to run, for a terminal pane. command: ?*gtk.Entry = null, /// Page to open, for a web pane. @@ -245,6 +249,13 @@ fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node) const url = field(grid, 0, "Address", spec.url, "https://example.com"); self.panes[index].url = url; }, + // A review is a repository, so the directory is the whole of it. It is + // the same field a terminal's is, down to the `$(...)` expansion, and + // the same one a review captured off a live tab is prefilled with. + .review => { + const cwd = field(grid, 0, "Directory", spec.cwd, "~/projects/{{path}}"); + self.panes[index].cwd = cwd; + }, } box.append(grid.as(gtk.Widget)); diff --git a/src/Session.zig b/src/Session.zig index 6876888..9aef349 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -106,6 +106,16 @@ pub const Options = struct { /// Script fed to the shell once it is up. command: []const u8 = "", + + /// The review endpoint of the tab this pane lives in, exported to the shell + /// as `PLAYPEN_REVIEW_URL`. + /// + /// It is the whole reason the review server has a stable address: an agent + /// running in this pane reads it out of its own environment and can then + /// fetch the comments the person next to it left, with no discovery step and + /// no chance of picking up another tab's review. Empty leaves the variable + /// unset, which is what an agent sees when the server never started. + review_url: []const u8 = "", }; pub fn create( @@ -179,7 +189,20 @@ pub fn create( null; defer if (cwd_z) |z| alloc.free(z); - self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{ + // One entry, and only when there is a review to point at: an empty + // `PLAYPEN_REVIEW_URL` would read as "there is a review server, and it is at + // the empty string", which is worse than the variable being absent. + var env_buf: [1][]const u8 = undefined; + var env_extra: []const []const u8 = &.{}; + var review_env: []u8 = &.{}; + defer alloc.free(review_env); + if (opts.review_url.len > 0) { + review_env = try std.fmt.allocPrint(alloc, "PLAYPEN_REVIEW_URL={s}", .{opts.review_url}); + env_buf[0] = review_env; + env_extra = &env_buf; + } + + self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{ .ws_row = rows, .ws_col = cols, }); diff --git a/src/View.zig b/src/View.zig index 04f6676..56597a0 100644 --- a/src/View.zig +++ b/src/View.zig @@ -1,5 +1,5 @@ //! A view: the content of one tab, holding one or more panes — terminals, web -//! views, or a mix — arranged in a split tree. +//! views, the tab's code review, or a mix — arranged in a split tree. //! //! Dragging a pane rearranges the view live rather than on release. Each time //! the drop target changes, the move is applied for real, so what you see @@ -15,6 +15,7 @@ const Browser = @import("Browser.zig"); const Layout = @import("Layout.zig"); const Layouts = @import("Layouts.zig"); const Pane = @import("Pane.zig"); +const Review = @import("Review.zig"); const Terminal = @import("Terminal.zig"); const View = @This(); @@ -75,6 +76,18 @@ zoomed: ?*Pane = null, drag: ?Drag = null, +/// What a review pane in this view should be opened with. +/// +/// Set by the window when the tab is created, because the endpoint is a property +/// of the tab and not of any pane. Held here so `applyLayout` can build a review +/// pane out of a saved layout without the layout having to carry a URL that +/// would be wrong the moment the tab changed. +/// +/// It is also what every *terminal* in this view is handed as +/// `PLAYPEN_REVIEW_URL` — see `stamp` — so an agent started in any pane of the +/// tab can reach the tab's review without being told where it is. +review_spec: Review.Options = .{}, + /// Set while the view is being torn down, so a pane's child exiting doesn't /// try to remove it from a list we're already draining. closing: bool = false, @@ -89,6 +102,13 @@ on_status: *const fn (ctx: ?*anyopaque) void, /// one that finished before your last visit looks identical to one that /// finished after it. on_finished: *const fn (ctx: ?*anyopaque) void, + +/// Something in here asked for a review pane. +/// +/// It goes up to the window rather than being handled here because opening a +/// review means resolving the directory the tab is working in and registering it +/// with the server, and neither of those is a property of the split tree. +on_review: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, pub const Callbacks = struct { @@ -96,6 +116,7 @@ pub const Callbacks = struct { on_title: *const fn (ctx: ?*anyopaque) void, on_status: *const fn (ctx: ?*anyopaque) void, on_finished: *const fn (ctx: ?*anyopaque) void, + on_review: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }; @@ -111,6 +132,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View { .on_title = cbs.on_title, .on_status = cbs.on_status, .on_finished = cbs.on_finished, + .on_review = cbs.on_review, .ctx = cbs.ctx, }; @@ -243,9 +265,31 @@ pub fn focus(self: *View) void { if (self.focusedPane()) |pane| pane.grabFocus(); } +/// The review pane in this view, if it has one. +/// +/// There is at most one by construction — see `addPane` — which is what lets +/// callers treat this as "the review" rather than "a review". +pub fn reviewPane(self: *View) ?*Pane { + for (self.panes.items) |pane| { + if (pane.kind == .review) return pane; + } + return null; +} + /// Add a pane, splitting the focused one so the new pane appears beside /// whatever you were working in. -pub fn addPane(self: *View, spec: Pane.Spec) !void { +/// +/// A second review pane is refused rather than opened. Two of them would each +/// publish their own diff selection to the server, and an agent asked to review +/// "the diff I'm looking at" would follow whichever wrote last — so the second +/// one would quietly break the first. `error.ReviewAlreadyOpen` is what the +/// window turns into "go to the one you have". +pub fn addPane(self: *View, raw: Pane.Spec) !void { + if (raw.kind() == .review and self.reviewPane() != null) { + return error.ReviewAlreadyOpen; + } + const spec = self.stamp(raw); + const pane = try Pane.create(self.alloc, self, spec); errdefer pane.destroy(); @@ -272,6 +316,31 @@ pub fn addPane(self: *View, spec: Pane.Spec) !void { self.on_title(self.ctx); } +/// Fill in the parts of a spec that come from the tab rather than the caller. +/// +/// Both arms are the tab's review endpoint: a review pane *is* that endpoint, +/// and a terminal is handed it so anything started in the pane can find it. Done +/// here, in the one place every pane is built, rather than at each of the four +/// call sites that construct a spec. +fn stamp(self: *View, spec: Pane.Spec) Pane.Spec { + if (self.review_spec.url.len == 0) return spec; + return switch (spec) { + .terminal => |opts| .{ .terminal = blk: { + var stamped = opts; + stamped.review_url = self.review_spec.url; + break :blk stamped; + } }, + .review => .{ .review = self.review_spec }, + .web => spec, + }; +} + +/// Ask the window to open a review pane in this tab. The pane header's button +/// and the keyboard shortcut both land here. +pub fn requestReview(self: *View) void { + self.on_review(self.ctx); +} + pub fn closePane(self: *View, pane: *Pane) void { if (self.closing) return; @@ -443,7 +512,7 @@ fn buildNode( const pane_spec = try self.paneSpec(p, bindings); defer freePaneSpec(self.alloc, pane_spec); - const pane = try Pane.create(self.alloc, self, pane_spec); + const pane = try Pane.create(self.alloc, self, self.stamp(pane_spec)); errdefer pane.destroy(); const node = try self.layout.newLeaf(pane); @@ -485,6 +554,11 @@ fn paneSpec( .web => .{ .web = .{ .url = try Layouts.expand(self.alloc, p.url, bindings), } }, + // The layout's own `cwd` is not read here. A review pane is handed an + // endpoint, not a directory: which repository that endpoint serves is + // settled by the window before the view is built, precisely so the + // page's first load already finds one. See `Window.bindLayoutReview`. + .review => .{ .review = self.review_spec }, }; } @@ -495,6 +569,9 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void { alloc.free(o.command); }, .web => |o| alloc.free(o.url), + // Borrowed from the window, which owns the strings for as long as the + // tab does. + .review => {}, } } @@ -523,6 +600,14 @@ fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layo .kind = .web, .url = b.currentUrl(), }), + // The repository this review is bound to, so a captured tab + // reopens on the same one. Prefilled rather than fixed: the dialog + // is where a literal path becomes `{{a parameter}}`, exactly as it + // is for a terminal's directory. + .review => try builder.pane(.{ + .kind = .review, + .cwd = self.review_spec.repo, + }), }, .split => |s| try builder.split( switch (s.paned.as(gtk.Orientable).getOrientation()) { diff --git a/src/Window.zig b/src/Window.zig index d65525b..d063c5c 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -27,6 +27,7 @@ const View = @import("View.zig"); const appearance = @import("appearance.zig"); const emoji = @import("emoji.zig"); const key = @import("key.zig"); +const review = @import("review.zig"); const shortcuts = @import("shortcuts.zig"); const Window = @This(); @@ -186,6 +187,15 @@ const Tab = struct { /// The layout this tab was opened from, if any. Null for a plain shell. source: ?Source = null, + /// This tab's review endpoint — `http://127.0.0.1:/t/` — owned + /// by the window's allocator, or empty when the review server never started. + /// + /// Every tab has one from the moment it exists, whether or not it has a + /// review pane, because it is what its terminals are handed as + /// `PLAYPEN_REVIEW_URL`. An agent started in a tab should not have to be + /// restarted because a review pane opened after it did. + review_url: []u8 = &.{}, + /// The row's right-click menu, parented to this tab's row. menu_popover: *gtk.Popover, @@ -458,6 +468,7 @@ fn newTabEmpty(self: *Window) !*Tab { .on_title = &onViewTitle, .on_status = &onViewStatus, .on_finished = &onViewFinished, + .on_review = &onViewReview, .ctx = tab, }); errdefer view.destroy(); @@ -524,9 +535,38 @@ fn newTabEmpty(self: *Window) !*Tab { try self.tabs.append(self.alloc, tab); + // The tab is announced to the review server as soon as it exists, so its + // endpoint is real before the first shell in it starts. Which repository the + // endpoint reviews is decided later: from the directory the tab is working + // in when a review pane is opened by hand (`openReview`), or from the + // directory a layout named (`bindLayoutReview`). + self.bindReview(tab); + return tab; } +/// Give a tab its review endpoint and tell the server the tab exists. +/// +/// Best-effort throughout: a tab with no endpoint is a tab whose terminals get +/// no `PLAYPEN_REVIEW_URL` and whose review pane explains itself, which is a +/// smaller problem than refusing to open the tab. +fn bindReview(self: *Window, tab: *Tab) void { + const server = review.get() orelse return; + + server.registerTab(tab.pageName()) catch |err| { + std.log.warn("review: could not register {s}: {s}", .{ + tab.pageName(), + @errorName(err), + }); + return; + }; + + var buf: [256]u8 = undefined; + const url = server.tabUrl(&buf, tab.pageName()) catch return; + tab.review_url = self.alloc.dupe(u8, url) catch return; + tab.view.review_spec = .{ .url = tab.review_url }; +} + // ------------------------------------------------------------------------- // The row menu // @@ -887,6 +927,10 @@ fn buildLayoutTab( ) !*Tab { const tab = try self.newTabEmpty(); + // Before the panes, not after: a review pane starts loading its page as it + // is built, so the repository has to be attached first. + if (layoutReviewDir(layout.root)) |dir| self.bindLayoutReview(tab, dir, bindings); + tab.view.applyLayout(layout.root, bindings) catch |err| { // A half-built view has no panes to work in and no shell to close, so // drop the tab rather than leave an empty one behind. Discarded rather @@ -1452,6 +1496,13 @@ fn discardTab(self: *Window, tab: *Tab) void { /// by whoever took the tab out of the window, which on the teardown path is not /// the same code. fn releaseTab(self: *Window, tab: *Tab) void { + // Before the URL is freed: the server is holding this tab's id, and its + // store, until told the tab has gone. The comments themselves are on disk + // and stay there, so a tab reopened on the same repository picks the review + // back up where it left off. + if (review.get()) |server| server.unregisterTab(tab.pageName()); + if (tab.review_url.len > 0) self.alloc.free(tab.review_url); + if (tab.custom_name) |name| self.alloc.free(name); self.freeSource(tab); self.alloc.destroy(tab); @@ -1737,6 +1788,12 @@ fn onViewFinished(ctx: ?*anyopaque) void { } /// The view lost its last pane, so the tab goes with it. +/// A pane in this tab asked for the tab's review. +fn onViewReview(ctx: ?*anyopaque) void { + const tab: *Tab = @ptrCast(@alignCast(ctx.?)); + tab.window.openReview(tab); +} + fn onViewEmpty(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.closeTab(tab); @@ -1811,6 +1868,162 @@ fn addPane(self: *Window, kind: View.Kind) void { }; } +// ------------------------------------------------------------------------- +// The review pane +// +// A tab has at most one, and it is bound to a repository: the one the tab is +// working in, or the one its layout named. Both halves of that are decided here +// rather than in the pane or the view: the pane is a web view, the view is a +// split tree, and "which repository is this tab about" is a question only the +// window — which can see the tab's terminals and what it was opened from — is in +// a position to answer. + +/// Open the visible tab's review, or go to the one it already has. +/// +/// The repository is resolved once, here, from the directory the tab is working +/// in, and then stays put for as long as the review is open. Re-resolving on +/// every fetch was the alternative, and it means a `cd` in a terminal can swap +/// the diff out from under someone mid-read; a review you have to reopen is the +/// better failure. +fn openReview(self: *Window, tab: *Tab) void { + // Already open: take them to it rather than reporting a refusal. Asking for + // the review twice is a reasonable way to say "where is my review". + if (tab.view.reviewPane()) |pane| { + self.select(tab); + tab.view.setFocused(pane); + pane.grabFocus(); + return; + } + + const server = review.get() orelse { + // The pane still opens, and says this. Better than a shortcut that looks + // broken because nothing happened. + self.addReviewPane(tab); + return; + }; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = self.tabDirectory(tab, &buf); + + server.openReview(tab.pageName(), dir) catch |err| { + // Most often `dir` is simply not inside a repository, which is not a + // failure of playpen's and not worth a dialog: the pane's own empty + // state explains it, and the log line is here for the rest. + std.log.info("review: no repository for {s} at {s}: {s}", .{ + tab.pageName(), + dir, + @errorName(err), + }); + self.addReviewPane(tab); + return; + }; + + // The pane header shows the repository's name, which is only knowable once + // the server has resolved the work-tree root. + tab.view.review_spec.repo = server.repoPath(tab.pageName()) orelse dir; + self.addReviewPane(tab); +} + +/// The directory a layout points its review at, or null if it has no review +/// pane or leaves the directory to the tab. +/// +/// The first review leaf decides it. A layout holding two is refused as the +/// second pane is built — one review per tab — so there is never a second +/// directory to disagree with this one. +fn layoutReviewDir(node: *const Layouts.Node) ?[]const u8 { + switch (node.*) { + .pane => |p| { + if (p.kind != .review or p.cwd.len == 0) return null; + return p.cwd; + }, + .split => |s| return layoutReviewDir(s.first) orelse layoutReviewDir(s.second), + } +} + +/// Bind a tab's review to the directory its layout named, while the view is +/// still empty. +/// +/// This is `openReview` without the pane: the layout has already said the tab +/// has a review in it, and all that is missing is which repository. Resolving it +/// here rather than after the panes are built is what makes the result +/// deterministic — the review pane's page is fetched from the server on another +/// thread the moment the pane exists, and a repository attached afterwards would +/// sometimes arrive first and sometimes second. +/// +/// The directory goes through the same expansion a terminal's does, so a layout +/// can review `{{a parameter}}` or `$(whatever a script prints)`. +fn bindLayoutReview( + self: *Window, + tab: *Tab, + template: []const u8, + bindings: []const Layouts.Binding, +) void { + const server = review.get() orelse return; + + const dir = Layouts.expandPath(self.alloc, template, bindings) catch |err| { + std.log.warn("review: could not resolve \"{s}\": {s}", .{ template, @errorName(err) }); + return; + }; + defer self.alloc.free(dir); + + server.openReview(tab.pageName(), dir) catch |err| { + // Same as opening a review by hand: a directory that isn't in a + // repository is the pane's own empty state to explain, not a reason to + // refuse the rest of the tab. + std.log.info("review: no repository for {s} at {s}: {s}", .{ + tab.pageName(), + dir, + @errorName(err), + }); + return; + }; + + // Borrowed from the server, which keeps it for as long as the tab's review + // lives — longer than any pane in the tab. + if (server.repoPath(tab.pageName())) |repo| tab.view.review_spec.repo = repo; +} + +fn addReviewPane(self: *Window, tab: *Tab) void { + tab.view.addPane(.plain(.review)) catch |err| { + std.log.err("failed to open the review pane: {s}", .{@errorName(err)}); + return; + }; + self.select(tab); +} + +/// The directory a tab is working in, copied into `buf`. +/// +/// Read from a terminal's own process rather than from anything recorded when +/// the tab opened, because the directory that matters is the one you are working +/// in now: a tab opened in a monorepo root and `cd`-ed into a worktree is a tab +/// about that worktree. The focused pane is asked first, so a split holding two +/// repositories reviews the one you are looking at. +/// +/// Falls back to playpen's own working directory, which at least gives the +/// server something to fail on that the user can recognise in the message. +fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 { + _ = self; + + if (tab.view.focusedPane()) |focused| { + if (focused.terminal()) |terminal| { + if (terminal.session.pty.cwd(buf)) |dir| return dir; + } + } + for (tab.view.panes.items) |pane| { + const terminal = pane.terminal() orelse continue; + if (terminal.session.pty.cwd(buf)) |dir| return dir; + } + + // Playpen's own directory, which at least gives the server something to + // fail on that the user can recognise in the message. `std.c` rather than + // `std.posix`, matching `Pty.zig`: the latter has been churning across Zig + // releases and this is one call. + if (std.c.getcwd(buf.ptr, buf.len) != null) { + return std.mem.sliceTo(buf, 0); + } + return "."; +} + fn selectIndex(self: *Window, index: usize) void { if (index >= self.tabs.items.len) return; self.select(self.tabs.items[index]); @@ -1887,6 +2100,10 @@ fn perform(self: *Window, action: shortcuts.Action) bool { .new_terminal => self.addPane(.terminal), .new_web => self.addPane(.web), + + // Not `addPane`: opening a review is more than adding a pane, and asking + // for one you already have takes you to it instead of refusing. + .new_review => if (self.activeTab()) |tab| self.openReview(tab), .rename_tab => if (self.activeTab()) |tab| self.beginRename(tab), .toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(), .toggle_sidebar => self.toggleSidebar(), diff --git a/src/main.zig b/src/main.zig index 2373a9d..630f97d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -13,6 +13,7 @@ const Settings = @import("Settings.zig"); const Window = @import("Window.zig"); const appearance = @import("appearance.zig"); const icons = @import("icons.zig"); +const review = @import("review.zig"); /// libghostty-vt logs unimplemented sequences at debug level, which is very /// chatty against a real shell. Keep the app's own warnings and errors. @@ -29,6 +30,11 @@ pub fn main() u8 { // of it. A no-op if the app never got as far as activating. defer Settings.deinit(); + // Also before the allocator, and after the window: the review server owns + // threads, and stopping it waits for the ones still inside a request rather + // than freeing the memory they are reading. A no-op if it never started. + defer review.deinit(); + // Non-unique so every launch is its own process. The default GApplication // behavior hands off to an already-running instance over D-Bus, which for // a terminal means a second launch silently does nothing visible here and @@ -55,6 +61,12 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { // for one by name. icons.init(); + // Before the window, because every tab is announced to the server as it is + // created and every terminal is handed the endpoint of the tab it opens in. + // Started unconditionally rather than on the first review pane, so an agent + // running in a tab has a `PLAYPEN_REVIEW_URL` from the moment it starts. + review.init(gpa.allocator()); + const window = Window.create(gpa.allocator(), app) catch |err| { std.log.err("failed to create window: {s}", .{@errorName(err)}); return; diff --git a/src/review.zig b/src/review.zig new file mode 100644 index 0000000..dc9dff2 --- /dev/null +++ b/src/review.zig @@ -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; + }; +} diff --git a/src/review/Server.zig b/src/review/Server.zig new file mode 100644 index 0000000..bd747d3 --- /dev/null +++ b/src/review/Server.zig @@ -0,0 +1,1227 @@ +//! The review server: one HTTP listener for the whole app, with a tab's review +//! hanging off its own path. +//! +//! ## Why a server at all +//! +//! The review UI is a web page — the same one this was ported from — so it needs +//! something to fetch from. But the server is not only there for the page: it is +//! the seam an agent works through. Claude running in a terminal pane reads +//! `PLAYPEN_REVIEW_URL` out of its environment, pulls the comments the user left +//! in the pane next to it, and posts replies that show up live. That loop is the +//! whole point, and it is why the API stays a documented HTTP contract rather +//! than becoming an internal function call once the UI moved in-process. +//! +//! ## One server, one review per tab +//! +//! Each tab gets a path of its own — `/t//` for the page, and +//! `/t//api/...` for everything else — so a request can never be +//! ambiguous about which review it means. The tool this replaces answered that +//! question with a `?repo=` parameter on every call and a tab bar inside the +//! page; here the window already has tabs, so the review is a pane in one and +//! the tab id in the path is the whole of the addressing. +//! +//! ## Threads +//! +//! The listener and each connection run on their own thread, because a request +//! spends most of its time inside `git diff` and doing that on the GTK main loop +//! would freeze the window for the length of every fetch. That makes the tab +//! registry and each `Store` shared state, which is what the mutexes here are +//! for. Nothing in this file touches a widget. + +const std = @import("std"); + +const Store = @import("Store.zig"); +const assets = @import("assets.zig"); +const git = @import("git.zig"); +const model = @import("model.zig"); + +const Server = @This(); + +/// Where the review directory sits inside a repository's git dir. +/// +/// Inside the git dir so it never appears in the diff being reviewed, and so a +/// worktree's comments belong to that worktree rather than to the repository it +/// was cut from. +const store_dir = "playpen-review"; +const store_file = "reviews.json"; + +/// Loopback only, and deliberately not configurable. This serves a filesystem +/// browser's worth of a private repository with no authentication; the one thing +/// keeping that honest is that nothing off this machine can reach it. +const bind_host = "127.0.0.1"; + +/// How long an SSE stream waits between checks for something to send. +/// +/// A poll rather than a condition variable: the same tick is what notices a +/// browser that has gone away, and a parked thread would only find out on the +/// next event — which for an idle review is never. +const sse_tick_ms = 200; + +/// How long an idle SSE stream goes before sending a comment frame nobody reads. +/// +/// It is the keep-alive, and more usefully it is how a closed connection is +/// discovered: the write fails, the thread exits, and the client is dropped. +const sse_heartbeat_ms = 15_000; + +/// Cap on a queued-but-unsent event backlog per client. Past this the oldest are +/// dropped rather than the connection blocked — a browser that has stopped +/// reading is one that is about to be closed, and the page refetches on +/// reconnect anyway. +const sse_backlog = 32; + +/// Cap on a request body. Comment bodies are prose; anything larger is a mistake +/// or an attack, and either way there is nothing here worth the memory. +const max_body = 4 * 1024 * 1024; + +/// Cap on request headers, which is also the read buffer size. +const max_head = 16 * 1024; + +// ------------------------------------------------------------------------- + +/// One tab's review. +/// +/// A tab is registered as soon as it exists, before it has a review, so that its +/// terminals can be handed a `PLAYPEN_REVIEW_URL` from birth — an agent should +/// not have to be restarted because a review pane opened after it did. `repo` is +/// filled in when the review pane opens and resolves the tab's directory. +const Tab = struct { + /// The tab's name in the window, which is also its path segment. + id: []u8, + + /// The tab has closed, so nothing new may find it — but its review is not + /// freed until the server is. + /// + /// This is what makes closing a tab safe while a request is in flight. A + /// handler resolves a tab's repository and store once and then spends + /// seconds inside `git diff`; freeing either from the main loop in the + /// meantime is a use-after-free, and the alternatives are worse. Waiting for + /// the request would freeze the window for the length of a git call, and + /// reference counting would put an atomic on every field a handler touches. + /// + /// So a closed tab is retired instead: unreachable through `findTab`, so no + /// new request can mutate it, and released with everything else at shutdown. + /// The cost is one review's comments held per tab closed in a session, which + /// is a few kilobytes and bounded by how many tabs you open. + retired: bool = false, + + /// The work tree under review, once one has been resolved. Null means the + /// tab has no review pane open, which the API reports as `open: false` + /// rather than as an error — "not yet" is a normal answer. + repo: ?git.Repo = null, + + /// Owned copies of the two paths in `repo`. + repo_path: ?[]u8 = null, + git_dir: ?[]u8 = null, + + store: ?*Store = null, + + /// The diff selection the page last published. + /// + /// The base ref, the uncommitted toggle and the selected commit are chosen + /// in the browser, so without this an agent has no way to know which diff + /// the user is looking at — and a comment anchored to another diff's line + /// numbers has no line to land on. + ctx: ?model.DiffContext = null, + ctx_base: [256]u8 = @splat(0), + ctx_commit: [128]u8 = @splat(0), +}; + +/// One connected event stream. +const Client = struct { + tab_id: []u8, + mutex: std.Io.Mutex = .init, + + /// Serialized frames waiting to be written, oldest first. Owned. + queue: std.ArrayListUnmanaged([]u8) = .empty, + + /// Set when the stream's own thread has finished with this client, so the + /// broadcaster stops queueing for it. + done: bool = false, +}; + +gpa: std.mem.Allocator, +io: std.Io, + +/// Guards `tabs` and `clients`. Held only long enough to look something up or +/// copy a frame — never across a git invocation or a socket write. +mutex: std.Io.Mutex = .init, + +tabs: std.ArrayListUnmanaged(*Tab) = .empty, +clients: std.ArrayListUnmanaged(*Client) = .empty, + +listener: ?std.Io.net.Server = null, +port: u16 = 0, + +accept_thread: ?std.Thread = null, +running: std.atomic.Value(bool) = .init(false), + +/// Connection threads currently alive, so shutdown can wait for them rather +/// than free the server out from under one. +active: std.atomic.Value(u32) = .init(0), + +// ------------------------------------------------------------------------- +// Lifecycle + +pub fn create(gpa: std.mem.Allocator, io: std.Io) !*Server { + const self = try gpa.create(Server); + self.* = .{ .gpa = gpa, .io = io }; + return self; +} + +/// Start listening. `port` of 0 takes whatever the kernel offers, which is what +/// keeps two playpen windows from fighting over one number; the port that was +/// actually bound is in `self.port` afterwards. +pub fn start(self: *Server, port: u16) !void { + const address: std.Io.net.IpAddress = try .parse(bind_host, port); + var listener = try address.listen(self.io, .{ .reuse_address = true }); + errdefer listener.deinit(self.io); + + self.listener = listener; + self.port = listener.socket.address.getPort(); + self.running.store(true, .release); + + self.accept_thread = std.Thread.spawn(.{}, acceptLoop, .{self}) catch |err| { + self.running.store(false, .release); + self.listener = null; + listener.deinit(self.io); + return err; + }; + std.log.info("review: serving on http://{s}:{d}", .{ bind_host, self.port }); +} + +/// Stop serving and free everything. +/// +/// Connections are asked to stop rather than cut off, and then waited for: a +/// detached thread still inside a handler would be writing into memory this is +/// about to free. The wait is bounded because a thread wedged in a syscall must +/// not be able to stop the window from closing — leaking the server's memory as +/// the process exits is the better of the two failures. +pub fn destroy(self: *Server) void { + self.running.store(false, .release); + + // Unblocks the accept thread, which is otherwise parked in accept(). std + // documents shutdown on a listening socket as the way to cancel an accept + // in flight, which is why the socket is not simply closed here — closing an + // fd another thread is blocked on is how you get it reused underneath it. + if (self.listener) |listener| { + const stream: std.Io.net.Stream = .{ .socket = listener.socket }; + stream.shutdown(self.io, .both) catch {}; + } + if (self.accept_thread) |thread| thread.join(); + if (self.listener) |*listener| listener.deinit(self.io); + + var waited_ms: u32 = 0; + while (self.active.load(.acquire) > 0 and waited_ms < 3000) : (waited_ms += 10) { + std.Io.sleep(self.io, .fromMilliseconds(10), .awake) catch break; + } + if (self.active.load(.acquire) > 0) { + std.log.warn("review: {d} connection(s) still running; leaking the server", .{ + self.active.load(.acquire), + }); + return; + } + + for (self.tabs.items) |tab| self.freeTab(tab); + self.tabs.deinit(self.gpa); + + for (self.clients.items) |client| self.freeClient(client); + self.clients.deinit(self.gpa); + + self.gpa.destroy(self); +} + +fn freeTab(self: *Server, tab: *Tab) void { + if (tab.store) |store| store.close(); + if (tab.repo_path) |p| self.gpa.free(p); + if (tab.git_dir) |p| self.gpa.free(p); + self.gpa.free(tab.id); + self.gpa.destroy(tab); +} + +fn freeClient(self: *Server, client: *Client) void { + for (client.queue.items) |frame| self.gpa.free(frame); + client.queue.deinit(self.gpa); + self.gpa.free(client.tab_id); + self.gpa.destroy(client); +} + +// ------------------------------------------------------------------------- +// The registry, as the window sees it. +// +// All four of these are called from the GTK main loop and take the same lock the +// connection threads do. + +/// Announce a tab, before it has a review. Idempotent. +pub fn registerTab(self: *Server, id: []const u8) !void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + if (self.findTab(id) != null) return; + + const tab = try self.gpa.create(Tab); + errdefer self.gpa.destroy(tab); + tab.* = .{ .id = try self.gpa.dupe(u8, id) }; + try self.tabs.append(self.gpa, tab); +} + +/// Point a tab's review at the repository containing `dir`. +/// +/// Called once, when the review pane opens: the pane resolves the directory the +/// tab is working in and this turns it into a work tree. Resolving once rather +/// than on every fetch is deliberate — a review whose repository moves under you +/// while you are reading it is worse than one you have to reopen. +pub fn openReview(self: *Server, id: []const u8, dir: []const u8) !void { + // `git.zig` allocates and never frees — every other caller hands it a + // request arena — so this one supplies its own, and the two paths the tab + // keeps are copied out of it before it goes. + var scratch: std.heap.ArenaAllocator = .init(self.gpa); + defer scratch.deinit(); + const a = scratch.allocator(); + + const repo = try git.open(a, self.io, dir); + const store_path = try std.fs.path.join(a, &.{ repo.git_dir, store_dir, store_file }); + + const store = try Store.open(self.gpa, self.io, store_path); + errdefer store.close(); + + const repo_path = try self.gpa.dupe(u8, repo.path); + errdefer self.gpa.free(repo_path); + const git_dir = try self.gpa.dupe(u8, repo.git_dir); + errdefer self.gpa.free(git_dir); + + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + const tab = self.findTab(id) orelse return error.NoSuchTab; + + // Reopening the same review would give two in-memory copies of one file, + // and whichever saved last would clobber the other. + if (tab.repo != null) { + store.close(); + self.gpa.free(repo_path); + self.gpa.free(git_dir); + return; + } + + tab.repo_path = repo_path; + tab.git_dir = git_dir; + tab.repo = .{ .path = repo_path, .git_dir = git_dir }; + tab.store = store; +} + +/// The tab itself went away. See `Tab.retired` for why this frees nothing. +pub fn unregisterTab(self: *Server, id: []const u8) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + const tab = self.findTab(id) orelse return; + tab.retired = true; +} + +/// The work tree a tab's review is bound to, or null if it has none. +/// +/// Borrowed: it lives as long as the tab's review does, which outlives every +/// pane in the tab, so the window can hold it for the pane header. +pub fn repoPath(self: *Server, id: []const u8) ?[]const u8 { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + const tab = self.findTab(id) orelse return null; + return tab.repo_path; +} + +/// The URL of a tab's review, into `buf`. This is what goes into a terminal's +/// environment as `PLAYPEN_REVIEW_URL`. +pub fn tabUrl(self: *Server, buf: []u8, id: []const u8) ![]const u8 { + return std.fmt.bufPrint(buf, "http://{s}:{d}/t/{s}", .{ bind_host, self.port, id }); +} + +/// The live tab with this id. Assumes the lock is held. +fn findTab(self: *Server, id: []const u8) ?*Tab { + for (self.tabs.items) |tab| { + if (tab.retired) continue; + if (std.mem.eql(u8, tab.id, id)) return tab; + } + return null; +} + +// ------------------------------------------------------------------------- +// Accepting + +fn acceptLoop(self: *Server) void { + while (self.running.load(.acquire)) { + var listener = self.listener orelse break; + const conn = listener.accept(self.io) catch |err| switch (err) { + error.ConnectionAborted, error.WouldBlock => continue, + else => break, + }; + + _ = self.active.fetchAdd(1, .acq_rel); + const thread = std.Thread.spawn(.{}, serve, .{ self, conn }) catch { + _ = self.active.fetchSub(1, .acq_rel); + conn.close(self.io); + continue; + }; + thread.detach(); + } +} + +/// One connection, for as many requests as the client keeps it open for. +fn serve(self: *Server, conn: std.Io.net.Stream) void { + defer { + conn.close(self.io); + _ = self.active.fetchSub(1, .acq_rel); + } + + const read_buf = self.gpa.alloc(u8, max_head) catch return; + defer self.gpa.free(read_buf); + const write_buf = self.gpa.alloc(u8, 64 * 1024) catch return; + defer self.gpa.free(write_buf); + + var reader = conn.reader(self.io, read_buf); + var writer = conn.writer(self.io, write_buf); + var http: std.http.Server = .init(&reader.interface, &writer.interface); + + while (self.running.load(.acquire)) { + var request = http.receiveHead() catch return; + + // A request's worth of git output, JSON and parsed bodies, dropped in + // one go once the response is on the wire. + var arena: std.heap.ArenaAllocator = .init(self.gpa); + defer arena.deinit(); + + self.route(&arena, &request) catch return; + } +} + +// ------------------------------------------------------------------------- +// Routing + +/// A parsed request target: the path with its query stripped off. +const Target = struct { + path: []const u8, + query: []const u8, + + fn parse(target: []const u8) Target { + if (std.mem.indexOfScalar(u8, target, '?')) |i| return .{ + .path = target[0..i], + .query = target[i + 1 ..], + }; + return .{ .path = target, .query = "" }; + } +}; + +fn route(self: *Server, arena: *std.heap.ArenaAllocator, request: *std.http.Server.Request) !void { + const a = arena.allocator(); + const target: Target = .parse(request.head.target); + const method = request.head.method; + + // App-wide, and the only two routes an agent needs to find its way in. + if (std.mem.eql(u8, target.path, "/api/health") or + std.mem.eql(u8, target.path, "/api/tabs")) + { + return self.handleTabs(a, request); + } + if (std.mem.eql(u8, target.path, "/")) return self.handleIndex(a, request); + + // Everything else lives under a tab. + if (!std.mem.startsWith(u8, target.path, "/t/")) { + return notFound(request); + } + const rest = target.path["/t/".len..]; + const slash = std.mem.indexOfScalar(u8, rest, '/'); + const tab_id = if (slash) |i| rest[0..i] else rest; + if (tab_id.len == 0) return notFound(request); + + // `/t/` without the slash would make the page's relative asset URLs + // resolve against `/t/`, so send the browser to the canonical form. + if (slash == null) { + const location = try std.fmt.allocPrint(a, "/t/{s}/", .{tab_id}); + return request.respond("", .{ + .status = .moved_permanently, + .extra_headers = &.{.{ .name = "location", .value = location }}, + }); + } + + const sub = rest[slash.? + 1 ..]; + if (!std.mem.startsWith(u8, sub, "api/")) { + return self.serveAsset(request, sub); + } + return self.handleApi(a, request, tab_id, sub["api/".len..], target.query, method); +} + +/// A plain page listing the open reviews, for someone who lands on the root. +/// +/// Not the review UI: that needs a tab to belong to, and guessing one would be +/// worse than saying which exist. +fn handleIndex(self: *Server, a: std.mem.Allocator, request: *std.http.Server.Request) !void { + var out: std.Io.Writer.Allocating = .init(a); + try out.writer.writeAll( + \\playpen review + \\ + \\

playpen review

+ \\

Each tab's review lives on its own path.

    + ); + + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + for (self.tabs.items) |tab| { + if (tab.retired) continue; + try out.writer.print("
  • {s} — {s}
  • ", .{ + tab.id, + tab.id, + if (tab.repo_path) |p| p else "no review pane open", + }); + } + try out.writer.writeAll("
"); + + return request.respond(out.written(), .{ + .extra_headers = &.{.{ .name = "content-type", .value = "text/html; charset=utf-8" }}, + }); +} + +/// Every tab and what it is reviewing — how an agent with no +/// `PLAYPEN_REVIEW_URL` finds the review it wants. +fn handleTabs(self: *Server, a: std.mem.Allocator, request: *std.http.Server.Request) !void { + var states: std.ArrayListUnmanaged(model.TabState) = .empty; + + { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + for (self.tabs.items) |tab| { + if (tab.retired) continue; + try states.append(a, .{ + .id = tab.id, + .open = tab.repo != null, + .path = if (tab.repo_path) |p| p else "", + // Deliberately not read from git: this endpoint is polled, and a + // process spawn per tab per poll is not worth a branch name that + // `api/repo` already reports. + .branch = "", + .drafts = if (tab.store) |s| s.countByStatus(.draft) else 0, + .openComments = if (tab.store) |s| s.countByStatus(.submitted) else 0, + .context = tab.ctx, + }); + } + } + + return writeJson(a, request, .ok, .{ + .ok = true, + .port = self.port, + .tabs = states.items, + }); +} + +// ------------------------------------------------------------------------- +// Static assets + +fn serveAsset(self: *Server, request: *std.http.Server.Request, sub: []const u8) !void { + _ = self; + const name = if (sub.len == 0) "index.html" else sub; + const file = assets.find(name) orelse { + // Anything the bundle does not have is a client-side route, and the SPA + // is what answers those. + const index = assets.find("index.html") orelse return notFound(request); + return respondAsset(request, index); + }; + return respondAsset(request, file); +} + +fn respondAsset(request: *std.http.Server.Request, file: assets.File) !void { + return request.respond(file.bytes, .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = file.mime }, + // The bundle changes only when playpen is rebuilt, and the page is + // reloaded far more often than that — but a stale script after an + // upgrade looks like a broken review, so this is deliberately not cached. + .{ .name = "cache-control", .value = "no-cache" }, + }, + }); +} + +// ------------------------------------------------------------------------- +// The API +// +// Handlers are grouped by what they need: `handleApi` resolves the tab and its +// review once, so nothing below has to re-check that a review is open. + +fn handleApi( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, + path: []const u8, + query: []const u8, + method: std.http.Method, +) !void { + // The event stream outlives the request, so it is handled before anything + // that would want the tab's store for the length of one response. + if (std.mem.eql(u8, path, "events")) { + return self.streamEvents(a, request, tab_id); + } + + const resolved = blk: { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + const tab = self.findTab(tab_id) orelse break :blk null; + break :blk Resolved{ + .repo = tab.repo, + .store = tab.store, + // Copied rather than borrowed: the tab's context lives in fixed + // buffers that `handleSetContext` overwrites in place, so a base ref + // read across a git call could otherwise be half of one ref and half + // of the next. + .ctx = if (tab.ctx) |ctx| .{ + .base = a.dupe(u8, ctx.base) catch ctx.base, + .uncommitted = ctx.uncommitted, + .commit = a.dupe(u8, ctx.commit) catch ctx.commit, + } else null, + }; + }; + const tab = resolved orelse return writeError( + a, + request, + .not_found, + "no such tab — check /api/tabs for the ones that exist", + ); + + if (std.mem.eql(u8, path, "repo")) { + if (method == .POST) return notFound(request); + return self.handleRepo(a, request, tab); + } + + const repo = tab.repo orelse return writeError( + a, + request, + .conflict, + "no review open in this tab — add a review pane to it first", + ); + const store = tab.store.?; + + if (std.mem.eql(u8, path, "repo/context")) { + return self.handleSetContext(a, request, tab_id); + } + if (std.mem.eql(u8, path, "diff")) { + return self.handleDiff(a, request, repo, tab, query); + } + if (std.mem.eql(u8, path, "file")) { + return self.handleFile(a, request, repo, query); + } + if (std.mem.eql(u8, path, "comments")) { + return switch (method) { + .GET => writeJson(a, request, .ok, try store.list(a)), + .POST => self.handleCreateComment(a, request, tab_id, store, tab.ctx), + else => notFound(request), + }; + } + if (std.mem.startsWith(u8, path, "comments/")) { + return self.handleCommentAction(a, request, tab_id, store, path["comments/".len..], method); + } + if (std.mem.startsWith(u8, path, "review/")) { + return self.handleReviewAction(a, request, tab_id, store, path["review/".len..], method); + } + return notFound(request); +} + +/// A tab's review, copied out from under the lock so handlers can take their +/// time — most of which is spent inside git. +/// +/// The pointers stay valid for the length of the request because a tab's review +/// is never freed while the server is running; see `Tab.retired`. +const Resolved = struct { + repo: ?git.Repo, + store: ?*Store, + ctx: ?model.DiffContext, +}; + +fn handleRepo( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab: Resolved, +) !void { + const repo = tab.repo orelse return writeJson(a, request, .ok, .{ .open = false }); + const info = git.info(repo, a, self.io); + const store = tab.store.?; + + return writeJson(a, request, .ok, .{ + .open = true, + .path = info.path, + .branch = info.branch, + .branches = info.branches, + .refs = info.refs, + .suggestedBase = info.suggestedBase, + .drafts = store.countByStatus(.draft), + .openComments = store.countByStatus(.submitted), + .context = tab.ctx, + }); +} + +/// Record which diff the page has on screen. +fn handleSetContext( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, +) !void { + const Body = struct { + base: []const u8 = "", + uncommitted: bool = false, + commit: []const u8 = "", + }; + const body = try readBody(a, request) orelse return writeError( + a, + request, + .bad_request, + "expected a JSON body", + ); + const parsed = std.json.parseFromSliceLeaky(Body, a, body, .{ + .ignore_unknown_fields = true, + }) catch return writeError(a, request, .bad_request, "malformed JSON"); + + if (parsed.base.len == 0) { + return writeError(a, request, .bad_request, "base is required"); + } + + { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + const tab = self.findTab(tab_id) orelse return notFound(request); + + // Copied into the tab's own fixed buffers rather than allocated: this is + // written on every base-ref change and read by every other request, and + // a heap string here would need a lifetime rule of its own. + const base = clampCopy(&tab.ctx_base, parsed.base); + const commit = clampCopy(&tab.ctx_commit, parsed.commit); + tab.ctx = .{ .base = base, .uncommitted = parsed.uncommitted, .commit = commit }; + } + return writeJson(a, request, .ok, .{ .ok = true }); +} + +fn clampCopy(buf: []u8, value: []const u8) []const u8 { + const n = @min(value.len, buf.len); + @memcpy(buf[0..n], value[0..n]); + return buf[0..n]; +} + +fn handleDiff( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + repo: git.Repo, + tab: Resolved, + query: []const u8, +) !void { + const ctx: model.DiffContext = .{ + .base = try queryValue(a, query, "base") orelse + (if (tab.ctx) |c| c.base else "HEAD"), + .uncommitted = queryFlag(a, query, "uncommitted") catch false, + .commit = try queryValue(a, query, "commit") orelse "", + }; + const opts: git.Options = .{ + .force = queryFlag(a, query, "force") catch false, + .ignore_whitespace = queryFlag(a, query, "ignoreWhitespace") catch false, + }; + + const payload = git.diff(repo, a, self.io, ctx, opts) catch |err| return writeError( + a, + request, + if (err == error.BadCommit) .bad_request else .bad_gateway, + switch (err) { + error.BadCommit => "commit is not a sha", + else => "git could not produce that diff — check the base ref", + }, + ); + return writeJson(a, request, .ok, payload); +} + +fn handleFile( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + repo: git.Repo, + query: []const u8, +) !void { + const path = try queryValue(a, query, "path") orelse return writeError( + a, + request, + .bad_request, + "path is required", + ); + const ref = try queryValue(a, query, "ref") orelse ""; + + // A miss is ordinary — a newly added file has no base version — so this is a + // 404 with no detail rather than something the UI has to explain. + const content = git.fileAt(repo, a, self.io, ref, path) catch return notFound(request); + return request.respond(content, .{ + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain; charset=utf-8" }}, + }); +} + +fn handleCreateComment( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, + store: *Store, + published: ?model.DiffContext, +) !void { + const Body = struct { + level: ?model.Level = null, + file: []const u8 = "", + side: []const u8 = "", + line: u32 = 0, + endLine: u32 = 0, + body: []const u8 = "", + author: ?model.Author = null, + base: ?[]const u8 = null, + uncommitted: ?bool = null, + commit: []const u8 = "", + }; + const raw = try readBody(a, request) orelse return writeError( + a, + request, + .bad_request, + "expected a JSON body", + ); + const in = std.json.parseFromSliceLeaky(Body, a, raw, .{ + .ignore_unknown_fields = true, + }) catch return writeError(a, request, .bad_request, "malformed JSON"); + + if (in.body.len == 0) return writeError(a, request, .bad_request, "body is required"); + + const level = in.level orelse .line; + var new: Store.New = .{ + .level = level, + .body = in.body, + // Only an agent may claim to be one. Anything else is the page, and the + // page's comments are drafts — see `Store.add`. + .author = if (in.author == .claude) .claude else .user, + }; + + // The anchor is validated per level, and the fields that do not apply are + // cleared rather than trusted: a file-level comment carrying a line number + // would render as a line comment on whatever line that happened to be. + switch (level) { + .line => { + if (in.file.len == 0) { + return writeError(a, request, .bad_request, "file is required for a line comment"); + } + new.file = in.file; + new.side = if (std.mem.eql(u8, in.side, model.side_old)) + model.side_old + else + model.side_new; + new.line = in.line; + new.endLine = @max(in.endLine, in.line); + }, + .file => { + if (in.file.len == 0) { + return writeError(a, request, .bad_request, "file is required for a file comment"); + } + new.file = in.file; + }, + .review => {}, + } + + // An agent posting without a context gets the one the page published, which + // is the diff the user is actually looking at. + new.context = .{ + .base = in.base orelse (if (published) |c| c.base else "HEAD"), + .uncommitted = in.uncommitted orelse (if (published) |c| c.uncommitted else true), + .commit = if (in.commit.len > 0) + in.commit + else if (in.base == null and published != null) published.?.commit else "", + }; + + const comment = store.add(new) catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, "comment", comment); + return writeJson(a, request, .created, comment); +} + +/// `comments/{id}`, `comments/{id}/replies`, `comments/{id}/replies/{replyId}`, +/// `comments/{id}/resolve` and `comments/{id}/reopen`. +fn handleCommentAction( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, + store: *Store, + path: []const u8, + method: std.http.Method, +) !void { + const slash = std.mem.indexOfScalar(u8, path, '/'); + const id = if (slash) |i| path[0..i] else path; + const action = if (slash) |i| path[i + 1 ..] else ""; + if (id.len == 0) return notFound(request); + + if (action.len == 0) { + return switch (method) { + .PATCH => { + const body = try bodyText(a, request) orelse return writeError( + a, + request, + .bad_request, + "body is required", + ); + const comment = store.updateBody(id, body) catch |err| + return storeError(a, request, err); + try self.notify(a, tab_id, "comment", comment); + return writeJson(a, request, .ok, comment); + }, + .DELETE => { + store.delete(id) catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, "delete", .{ .id = id }); + return request.respond("", .{ .status = .no_content }); + }, + else => notFound(request), + }; + } + + if (std.mem.eql(u8, action, "resolve") or std.mem.eql(u8, action, "reopen")) { + if (method != .POST) return notFound(request); + const resolving = std.mem.eql(u8, action, "resolve"); + const comment = store.setStatus( + id, + if (resolving) .resolved else .submitted, + ) catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, if (resolving) "resolve" else "reopen", comment); + return writeJson(a, request, .ok, comment); + } + + if (std.mem.eql(u8, action, "replies")) { + if (method != .POST) return notFound(request); + const Body = struct { body: []const u8 = "", author: ?model.Author = null }; + const raw = try readBody(a, request) orelse return writeError( + a, + request, + .bad_request, + "expected a JSON body", + ); + const in = std.json.parseFromSliceLeaky(Body, a, raw, .{ + .ignore_unknown_fields = true, + }) catch return writeError(a, request, .bad_request, "malformed JSON"); + if (in.body.len == 0) return writeError(a, request, .bad_request, "body is required"); + + // A reply defaults to the agent's, because the agent is the one that + // posts without saying: the page always names itself. + const comment = store.addReply(id, in.author orelse .claude, in.body) catch |err| + return storeError(a, request, err); + try self.notify(a, tab_id, "reply", comment); + return writeJson(a, request, .ok, comment); + } + + if (std.mem.startsWith(u8, action, "replies/")) { + if (method != .PATCH) return notFound(request); + const reply_id = action["replies/".len..]; + const body = try bodyText(a, request) orelse return writeError( + a, + request, + .bad_request, + "body is required", + ); + const comment = store.updateReplyBody(id, reply_id, body) catch |err| + return storeError(a, request, err); + try self.notify(a, tab_id, "comment", comment); + return writeJson(a, request, .ok, comment); + } + return notFound(request); +} + +/// `review/submit`, `review/reset`, `review/delete-resolved` and +/// `review/pending`. +fn handleReviewAction( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, + store: *Store, + action: []const u8, + method: std.http.Method, +) !void { + if (std.mem.eql(u8, action, "pending")) { + if (method != .GET) return notFound(request); + return writeJson(a, request, .ok, try store.pending(a)); + } + if (method != .POST) return notFound(request); + + if (std.mem.eql(u8, action, "submit")) { + const n = store.submitDrafts() catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, "submit", .{ .submitted = n }); + return writeJson(a, request, .ok, .{ .submitted = n }); + } + if (std.mem.eql(u8, action, "reset")) { + const n = store.reset() catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, "reset", .{ .cleared = n }); + return writeJson(a, request, .ok, .{ .cleared = n }); + } + if (std.mem.eql(u8, action, "delete-resolved")) { + const n = store.deleteResolved() catch |err| return storeError(a, request, err); + try self.notify(a, tab_id, "delete-resolved", .{ .deleted = n }); + return writeJson(a, request, .ok, .{ .deleted = n }); + } + return notFound(request); +} + +// ------------------------------------------------------------------------- +// Events + +/// Queue an event for every stream watching this tab. +/// +/// Serialized once here rather than per client: the frame is identical for all +/// of them, and building it under the registry lock would hold that lock for the +/// length of a JSON encode. +fn notify( + self: *Server, + a: std.mem.Allocator, + tab_id: []const u8, + kind: []const u8, + data: anytype, +) !void { + const payload = try std.json.Stringify.valueAlloc(a, .{ + .type = kind, + .data = data, + }, .{}); + const frame = try std.fmt.allocPrint(self.gpa, "data: {s}\n\n", .{payload}); + defer self.gpa.free(frame); + + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + for (self.clients.items) |client| { + if (!std.mem.eql(u8, client.tab_id, tab_id)) continue; + + client.mutex.lockUncancelable(self.io); + defer client.mutex.unlock(self.io); + if (client.done) continue; + + const copy = self.gpa.dupe(u8, frame) catch continue; + if (client.queue.items.len >= sse_backlog) { + self.gpa.free(client.queue.orderedRemove(0)); + } + client.queue.append(self.gpa, copy) catch self.gpa.free(copy); + } +} + +/// Hold the connection open and write events as they arrive. +fn streamEvents( + self: *Server, + a: std.mem.Allocator, + request: *std.http.Server.Request, + tab_id: []const u8, +) !void { + _ = a; + + const client = try self.gpa.create(Client); + client.* = .{ .tab_id = try self.gpa.dupe(u8, tab_id) }; + { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + try self.clients.append(self.gpa, client); + } + defer self.dropClient(client); + + var body_buf: [8 * 1024]u8 = undefined; + var body = try request.respondStreaming(&body_buf, .{ .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-cache" }, + .{ .name = "connection", .value = "keep-alive" }, + }, + } }); + + // A comment frame, so EventSource fires `onopen` at once instead of waiting + // for the first real change. + push(&body, ": connected\n\n") catch return; + + var since_beat_ms: u32 = 0; + while (self.running.load(.acquire)) { + const frames = self.takeFrames(client); + defer if (frames.len > 0) self.gpa.free(frames); + + if (frames.len == 0) { + since_beat_ms += sse_tick_ms; + if (since_beat_ms >= sse_heartbeat_ms) { + since_beat_ms = 0; + push(&body, ": ping\n\n") catch return; + } + std.Io.sleep(self.io, .fromMilliseconds(sse_tick_ms), .awake) catch return; + continue; + } + + since_beat_ms = 0; + + // Every frame is freed on the way past, whether or not the write took, + // so a client that has gone away does not leak the backlog it never + // received. `failed` rather than an early return for the same reason. + var failed = false; + for (frames) |frame| { + if (!failed) push(&body, frame) catch { + failed = true; + }; + self.gpa.free(frame); + } + if (failed) return; + } + body.end() catch {}; +} + +/// Push one event frame all the way out to the socket. +/// +/// Both flushes are load-bearing, and the reason is not obvious: a +/// `BodyWriter`'s own `flush` only flushes the *connection's* writer, not the +/// body writer's buffer. Without draining that buffer first the frame sits in it +/// until the next 8 KB of events arrive — which for a review is somewhere +/// between minutes and never — and the page waits forever for a change that has +/// already happened. +fn push(body: *std.http.BodyWriter, frame: []const u8) !void { + try body.writer.writeAll(frame); + // Buffer -> chunked framing -> the connection's writer. + try body.writer.flush(); + // The connection's writer -> the socket. + try body.flush(); +} + +/// Take everything queued for a client, leaving its queue empty. +/// +/// The caller owns both the frames and the slice holding them — except when the +/// hand-off itself could not allocate, which comes back as an empty slice that +/// is not an allocation and must not be freed. +fn takeFrames(self: *Server, client: *Client) [][]u8 { + client.mutex.lockUncancelable(self.io); + defer client.mutex.unlock(self.io); + return client.queue.toOwnedSlice(self.gpa) catch &.{}; +} + +fn dropClient(self: *Server, client: *Client) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + + // Marked before it is unlinked so a broadcast that already has the pointer + // stops adding to a queue nobody will drain. + { + client.mutex.lockUncancelable(self.io); + defer client.mutex.unlock(self.io); + client.done = true; + } + + for (self.clients.items, 0..) |candidate, i| { + if (candidate != client) continue; + _ = self.clients.orderedRemove(i); + self.freeClient(client); + return; + } +} + +// ------------------------------------------------------------------------- +// Request and response plumbing + +fn readBody( + a: std.mem.Allocator, + request: *std.http.Server.Request, +) !?[]const u8 { + const buf = try a.alloc(u8, 64 * 1024); + const reader = request.readerExpectNone(buf); + const body = reader.allocRemaining(a, .limited(max_body)) catch return null; + return if (body.len == 0) null else body; +} + +/// The `body` field of a `{"body": "..."}` request, which is the shape three of +/// the edit endpoints share. +fn bodyText(a: std.mem.Allocator, request: *std.http.Server.Request) !?[]const u8 { + const raw = try readBody(a, request) orelse return null; + const parsed = std.json.parseFromSliceLeaky( + struct { body: []const u8 = "" }, + a, + raw, + .{ .ignore_unknown_fields = true }, + ) catch return null; + return if (parsed.body.len == 0) null else parsed.body; +} + +/// One query parameter, percent-decoded into `a`. Null when absent; present but +/// empty comes back as an empty string, which for `base` means "not set". +fn queryValue(a: std.mem.Allocator, query: []const u8, name: []const u8) !?[]const u8 { + var it = std.mem.splitScalar(u8, query, '&'); + while (it.next()) |pair| { + const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; + if (!std.mem.eql(u8, pair[0..eq], name)) continue; + + const raw = pair[eq + 1 ..]; + const scratch = try a.dupe(u8, raw); + // `+` for a space is form encoding rather than URI encoding, but every + // browser and every `curl -G` will produce it, so both are accepted. + for (scratch) |*c| if (c.* == '+') { + c.* = ' '; + }; + const decoded = std.Uri.percentDecodeInPlace(scratch); + return if (decoded.len == 0) "" else decoded; + } + return null; +} + +/// A query parameter read as a boolean, accepting what both the page and a +/// hand-written `curl` produce: `1`, `true`, `yes`, or their negatives. +fn queryFlag(a: std.mem.Allocator, query: []const u8, name: []const u8) !bool { + const value = try queryValue(a, query, name) orelse return false; + if (value.len == 0) return false; + return switch (value[0]) { + '1', 't', 'T', 'y', 'Y' => true, + else => false, + }; +} + +fn writeJson( + a: std.mem.Allocator, + request: *std.http.Server.Request, + status: std.http.Status, + value: anytype, +) !void { + const body = std.json.Stringify.valueAlloc(a, value, .{}) catch + return request.respond("{\"error\":\"out of memory\"}", .{ + .status = .internal_server_error, + .extra_headers = &.{.{ .name = "content-type", .value = "application/json" }}, + }); + return request.respond(body, .{ + .status = status, + .extra_headers = &.{.{ .name = "content-type", .value = "application/json" }}, + }); +} + +fn writeError( + a: std.mem.Allocator, + request: *std.http.Server.Request, + status: std.http.Status, + message: []const u8, +) !void { + return writeJson(a, request, status, .{ .@"error" = message }); +} + +fn storeError( + a: std.mem.Allocator, + request: *std.http.Server.Request, + err: Store.Error, +) !void { + return switch (err) { + error.NotFound => writeError(a, request, .not_found, "no such comment in this review"), + error.SaveFailed => writeError( + a, + request, + .internal_server_error, + "the review could not be written to disk; nothing was changed", + ), + error.OutOfMemory => writeError(a, request, .internal_server_error, "out of memory"), + }; +} + +fn notFound(request: *std.http.Server.Request) !void { + return request.respond("{\"error\":\"not found\"}", .{ + .status = .not_found, + .extra_headers = &.{.{ .name = "content-type", .value = "application/json" }}, + }); +} diff --git a/src/review/Store.zig b/src/review/Store.zig new file mode 100644 index 0000000..2e827b9 --- /dev/null +++ b/src/review/Store.zig @@ -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]); +} diff --git a/src/review/assets.zig b/src/review/assets.zig new file mode 100644 index 0000000..da279ad --- /dev/null +++ b/src/review/assets.zig @@ -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; +} diff --git a/src/review/git.zig b/src/review/git.zig new file mode 100644 index 0000000..373cc94 --- /dev/null +++ b/src/review/git.zig @@ -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 ^ ` 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)); +} diff --git a/src/review/model.zig b/src/review/model.zig new file mode 100644 index 0000000..9794d0f --- /dev/null +++ b/src/review/model.zig @@ -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, +}; diff --git a/src/shortcuts.zig b/src/shortcuts.zig index 321cf70..db4d584 100644 --- a/src/shortcuts.zig +++ b/src/shortcuts.zig @@ -108,6 +108,7 @@ pub const Action = enum { close_pane, new_terminal, new_web, + new_review, rename_tab, toggle_zoom, toggle_sidebar, @@ -156,6 +157,9 @@ pub const defaults: []const Binding = &.{ .{ .chord = chord("ctrl+shift+w"), .action = .close_pane }, .{ .chord = chord("ctrl+shift+e"), .action = .new_terminal }, .{ .chord = chord("ctrl+shift+b"), .action = .new_web }, + // `d` for diff. `r` would read better but it is `rename_tab`'s, and that is + // in people's fingers. + .{ .chord = chord("ctrl+shift+d"), .action = .new_review }, .{ .chord = chord("ctrl+shift+r"), .action = .rename_tab }, .{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom }, .{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom }, diff --git a/src/webkit.zig b/src/webkit.zig index a8cbc9f..3ed2233 100644 --- a/src/webkit.zig +++ b/src/webkit.zig @@ -88,11 +88,17 @@ pub const WebView = opaque { /// they know about, so WebKit's own signals go through the untyped /// GObject entry point. Property changes don't need this: `notify` is /// declared on `gobject.Object`, which this can be cast to. + /// + /// The handler is taken as it comes, for the same reason the find + /// controller's is: these signals differ in shape — `close` carries + /// nothing, `load-changed` carries a load event, `load-failed` carries + /// three arguments and returns whether it handled the failure — so + /// matching the handler to the signal is the caller's job. pub fn connectSignal( self: *WebView, comptime signal: [:0]const u8, comptime Data: type, - handler: *const fn (*WebView, Data) callconv(.c) void, + handler: anytype, data: Data, ) void { _ = gobject.signalConnectData( diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..fde8702 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + review + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..7413b6e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1993 @@ +{ + "name": "playpen-review-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playpen-review-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gitdiff-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz", + "integrity": "sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==", + "license": "MIT" + }, + "node_modules/hast-util-parse-selector": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", + "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz", + "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^3.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-diff-view": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/react-diff-view/-/react-diff-view-3.3.3.tgz", + "integrity": "sha512-CPveApk6n7ZbkW7T6PoptR7LWAvD9hohTHZ7WnKnu3GZkTfUB5rvg486apPo94iYVi4fZd3Nt+rtBZ5877exoQ==", + "license": "MIT", + "dependencies": { + "classnames": "^2.3.2", + "diff-match-patch": "^1.0.5", + "gitdiff-parser": "^0.3.1", + "lodash": "^4.17.21", + "shallow-equal": "^3.1.0", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/refractor": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz", + "integrity": "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^7.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4f1dd68 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..281f3d2 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..97f3110 --- /dev/null +++ b/web/src/App.tsx @@ -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 { + try { + const raw = JSON.parse(localStorage.getItem(CTX_KEY) ?? '{}'); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [path, v] of Object.entries(raw as Record)) { + const c = v as Partial; + 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(undefined); + const [ctxByRepo, setCtxByRepo] = useState>(loadCtxByRepo); + const [resetOpen, setResetOpen] = useState(false); + const [clearResolvedOpen, setClearResolvedOpen] = useState(false); + const [viewType, setViewType] = useState('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(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(null); + const [oversizeDismissed, setOversizeDismissed] = useState(false); + const [comments, setComments] = useState([]); + const [draft, setDraft] = useState(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(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(null); + const [toast, setToast] = useState(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
Connecting…
; + } + + // 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 ( +
+
+ +

No review in this tab

+

+ A review pane takes its repository from the directory the tab is + working in, and {tabId ? {tabId} : 'this tab'} isn't + inside a git work tree. Close the pane and reopen it from a tab whose + terminal is in one. +

+ {error &&

{error}

} + +
+
+ ); + } + + return ( +
+
+
+ + + + {repo.path.split('/').pop()} + + {repo.branch} + +
+ +
+ + + {/* 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. */} + + + {/* Which commit is on screen, and the way back out of it. */} + {ctx.commit && ( + + )} + + + +
+ + +
+ + + + {payload && } + + + + + + + + {connected ? 'live' : 'offline'} + + + +
+
+ + {error && ( +
+ + {error} +
+ )} + +
+ + + setSidebarWidth(SIDEBAR_DEFAULT)} + /> + +
+ {loading && !payload ? ( +
Loading diff…
+ ) : oversized ? ( + + ) : payload ? ( +
+ setDraft({ level: 'review' })} + onSubmit={submitDraft} + onCancel={() => setDraft(null)} + onChanged={refetchComments} + /> + setDraft(null)} + onSubmitDraft={submitDraft} + onChanged={refetchComments} + /> + +
+ ) : null} +
+ + {showComments ? ( + <> + setCommentsWidth(COMMENTS_DEFAULT)} + /> + setClearResolvedOpen(true)} + onCollapse={() => setCommentsOpen(false)} + /> + + ) : ( + setCommentsOpen(true)} + /> + )} +
+ + {oversized && !oversizeDismissed && ( + setOversizeDismissed(true)} + /> + )} + + {resetOpen && ( + setResetOpen(false)} + > +

+ {repo.path.split('/').pop()} starts over as if you had just + opened it: +

+
    +
  • {resetCommentsLine(comments.length)}
  • +
  • every file unmarked as viewed
  • +
  • base ref back to HEAD
  • +
+

+ This can't be undone. Reviews in other tabs are left + alone. +

+
+ )} + + {clearResolvedOpen && ( + setClearResolvedOpen(false)} + > +

+ {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. +

+

+ This can't be undone. +

+
+ )} + + {toast &&
{toast}
} +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..8a2834a --- /dev/null +++ b/web/src/api.ts @@ -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//` — 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(res: Response): Promise { + 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; +} + +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>), + + // 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), + + // 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 => { + 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) + .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), + + updateComment: (id: string, body: string) => + fetch(`${apiBase}/comments/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }).then(json), + + deleteComment: (id: string) => + fetch(`${apiBase}/comments/${id}`, { method: 'DELETE' }).then(json), + + addReply: (id: string, body: string, author: Author = 'user') => + postJSON(`${apiBase}/comments/${id}/replies`, { body, author }).then(json), + + 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), + + resolve: (id: string) => postJSON(`${apiBase}/comments/${id}/resolve`).then(json), + reopen: (id: string) => postJSON(`${apiBase}/comments/${id}/reopen`).then(json), + + // 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 }>), +}; diff --git a/web/src/components/CommentThread.tsx b/web/src/components/CommentThread.tsx new file mode 100644 index 0000000..9dd46c1 --- /dev/null +++ b/web/src/components/CommentThread.tsx @@ -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 ( +
+ {comments.map((c) => ( + + ))} +
+ ); +} + +// 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(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 ( +
+ +
+ ); + } + + return ( +
+ {resolved && ( +
+ + + + Resolved + +
+ )} + setEditingId(comment.id)} + editor={ + editingId === comment.id ? ( + saveEdit(comment.id, body)} + onCancel={() => setEditingId(null)} + /> + ) : null + } + /> + {comment.replies.map((r) => ( + setEditingId(r.id)} + editor={ + editingId === r.id ? ( + saveEdit(r.id, body)} + onCancel={() => setEditingId(null)} + /> + ) : null + } + /> + ))} + +
+