Build in the review tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 09:24:01 -04:00
parent 4eef6a28d3
commit 407dd81512
55 changed files with 12932 additions and 40 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>review</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1993
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "playpen-review-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-diff-view": "^3.2.1",
"react-dom": "^18.3.1",
"refractor": "^4.8.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+17
View File
@@ -0,0 +1,17 @@
<!-- The topbar brand mark as a favicon: the Octicon `file-diff` glyph knocked
out of a Primer-blue tile (bgColor.accent.emphasis #316dca, shaded a shade
either side for depth). Same shape and colour as <Icon name="file-diff" />
in the header, so the tab matches the app. Vite copies public/ to dist/,
which web/embed.go ships inside the binary. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="tile" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4184e4"/>
<stop offset="1" stop-color="#2f6ac9"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#tile)"/>
<g transform="translate(4 4) scale(1.5)" fill="#ffffff">
<path d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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