963 lines
37 KiB
TypeScript
963 lines
37 KiB
TypeScript
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 { Menu, MenuItem, MenuSeparator } from './components/Menu';
|
|
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;
|
|
|
|
// How often the pane asks whether the diff it is showing still matches the work
|
|
// tree, and why it asks at all rather than being told.
|
|
//
|
|
// The pane must not reload itself. An agent editing files while you read is the
|
|
// normal case here, and a diff that reloads under you loses your scroll position,
|
|
// your place in a hunk, and — if the composer is open — what you were typing. So
|
|
// the page polls a digest (see api.revision) and puts a banner up; refreshing
|
|
// stays your decision.
|
|
//
|
|
// A poll is a `git diff` on the server, so it isn't free. At this interval it is
|
|
// invisible next to the work of whatever is doing the editing, and it stops
|
|
// entirely while the pane is hidden or already known to be stale.
|
|
const POLL_MS = 8000;
|
|
|
|
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',
|
|
);
|
|
// The digest of the diff on screen, and a newer one a poll has seen — see
|
|
// POLL_MS. `stale` holding the newer revision rather than a bare flag is what
|
|
// lets "not now" re-arm against the current state instead of going quiet.
|
|
const [revision, setRevision] = useState<string | null>(null);
|
|
const [stale, setStale] = useState<string | null>(null);
|
|
// 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
|
|
// judgment, 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, snapshots, 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);
|
|
setStale(null);
|
|
setRevision(d.revision || 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 arrives means the comment list moved and is
|
|
// worth refetching.
|
|
//
|
|
// The handshake refetches too, which it did not used to. The stream is dropped
|
|
// while the pane is hidden (see useSSE) and reconnects when it comes back, so
|
|
// `connected` is the one moment the rail is known to be behind: every comment
|
|
// written while this pane was off screen is a frame nobody was listening for.
|
|
// Treating the handshake as "something changed" is what closes that gap — and
|
|
// the same gap after a dropped connection, which was always there.
|
|
//
|
|
// Comments only. The diff is not pushed: the server has no watcher on the work
|
|
// tree, and a comment arriving is a change to the rail, which can be applied
|
|
// under the reader safely. A changed *diff* cannot — see the poll below.
|
|
useSSE(useCallback(() => refetchComments(), [refetchComments]));
|
|
|
|
// Notice that the diff on screen has been overtaken, and say so. Nothing here
|
|
// reloads anything: it sets `stale`, the banner offers the refresh, and the
|
|
// polling stops until one or the other happens.
|
|
useEffect(() => {
|
|
if (!path || !revision || stale) return;
|
|
|
|
let canceled = false;
|
|
let busy = false;
|
|
|
|
const check = async () => {
|
|
// A hidden pane is a pane nobody is reading. It gets checked the moment it
|
|
// comes back instead, which is when the answer matters.
|
|
if (canceled || busy || document.hidden) return;
|
|
busy = true;
|
|
const seq = reqRef.current;
|
|
try {
|
|
const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs });
|
|
// A load that started while this was in flight has already answered the
|
|
// question, with a revision this closure doesn't know about.
|
|
if (canceled || seq !== reqRef.current) return;
|
|
if (now && now !== revision) setStale(now);
|
|
} catch {
|
|
// A failed poll says nothing about the diff — the next one will.
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
};
|
|
|
|
const timer = window.setInterval(check, POLL_MS);
|
|
const onVisible = () => {
|
|
if (!document.hidden) check();
|
|
};
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
window.addEventListener('focus', onVisible);
|
|
return () => {
|
|
canceled = true;
|
|
window.clearInterval(timer);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
window.removeEventListener('focus', onVisible);
|
|
};
|
|
}, [path, ctx, ignoreWs, revision, stale]);
|
|
|
|
// dismissStale keeps the diff you are reading and stops pointing at it, but
|
|
// takes the newer state as the baseline — so the *next* change says so too,
|
|
// rather than the banner being a one-off you can only silence once.
|
|
const dismissStale = useCallback(() => {
|
|
setRevision((prev) => stale ?? prev);
|
|
setStale(null);
|
|
}, [stale]);
|
|
|
|
// 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">
|
|
{/* One row, and it has to survive a pane a third of a screen wide. What
|
|
earns a place on it is what you reach for *while reading* a diff; the
|
|
rest is in the overflow menu, and every label on it is a `.btn-label`
|
|
the narrow layout can drop without the control going with it. */}
|
|
<header className="topbar">
|
|
<div className="brand" title={`${repo.path} · ${repo.branch}`}>
|
|
<span className="brand-mark">
|
|
<Icon name="file-diff" />
|
|
</span>
|
|
<span className="brand-name">{repo.path.split('/').pop()}</span>
|
|
<span className="brand-branch">{repo.branch}</span>
|
|
</div>
|
|
|
|
<div className="controls">
|
|
<label className="control">
|
|
<span className="control-label">base</span>
|
|
{/* Capped in CSS, not left to size itself: a native select is as wide
|
|
as its widest option, and a repository with a few hundred refs in
|
|
it — one `origin/feature/…` is enough — makes that the whole bar.
|
|
The name on screen is short; the list opens at full width. */}
|
|
<select
|
|
className="base-select"
|
|
value={ctx.base}
|
|
aria-label="Base ref"
|
|
title={`Diffing against ${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}
|
|
aria-pressed={ctx.uncommitted && !ctx.commit}
|
|
title={
|
|
ctx.commit
|
|
? "Doesn't apply while you're reading a single commit"
|
|
: 'Include uncommitted working-tree changes'
|
|
}
|
|
>
|
|
<Icon name="pencil" size={14} />
|
|
<span className="btn-label">uncommitted</span>
|
|
</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>
|
|
)}
|
|
|
|
<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} />
|
|
<span className="btn-label">split</span>
|
|
</button>
|
|
<button
|
|
className={viewType === 'unified' ? 'is-active' : ''}
|
|
onClick={() => setViewType('unified')}
|
|
aria-pressed={viewType === 'unified'}
|
|
title="Unified view"
|
|
>
|
|
<Icon name="rows" size={14} />
|
|
<span className="btn-label">unified</span>
|
|
</button>
|
|
</div>
|
|
|
|
<span className="control-divider" />
|
|
|
|
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
|
|
|
|
<button
|
|
className={`icon-btn${stale ? ' is-attention' : ''}`}
|
|
onClick={() => loadDiff(ctx)}
|
|
title={stale ? 'Reload the diff — the work tree has moved on' : 'Refresh diff'}
|
|
aria-label="Refresh diff"
|
|
>
|
|
<Icon name="sync" />
|
|
</button>
|
|
|
|
{/* Set-and-forget controls. `marked` puts a dot on the trigger when one
|
|
of them is not on its default, so a diff that is quietly hiding
|
|
whitespace never looks like a diff that has none. */}
|
|
<Menu icon="kebab-horizontal" label="Review options" marked={ignoreWs}>
|
|
{(close) => (
|
|
<>
|
|
<MenuItem
|
|
checked={ignoreWs}
|
|
onClick={() => setIgnoreWs(!ignoreWs)}
|
|
hint="git diff -w — files with nothing but whitespace in them leave the change set"
|
|
>
|
|
Ignore whitespace
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={theme === 'dark' ? 'sun' : 'moon'}
|
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
>
|
|
{theme === 'dark' ? 'Light theme' : 'Dark theme'}
|
|
</MenuItem>
|
|
<MenuSeparator />
|
|
<MenuItem
|
|
icon="trash"
|
|
danger
|
|
onClick={() => {
|
|
close();
|
|
setResetOpen(true);
|
|
}}
|
|
hint="Delete every comment and clear the viewed files"
|
|
>
|
|
Reset review…
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</Menu>
|
|
|
|
<button className="btn-submit" onClick={submitReview} disabled={draftCount === 0}>
|
|
Submit<span className="btn-label-tail"> review</span>
|
|
{draftCount > 0 && <span className="count">{draftCount}</span>}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* The diff has moved on. Said rather than acted on: see POLL_MS. */}
|
|
{stale && (
|
|
<div className="banner banner-stale">
|
|
<Icon name="alert" />
|
|
<span className="banner-text">
|
|
This diff is out of date — the work tree has changed since it loaded.
|
|
</span>
|
|
<button className="btn-ghost" onClick={() => loadDiff(ctx)}>
|
|
<Icon name="sync" size={14} />
|
|
Refresh
|
|
</button>
|
|
<button
|
|
className="icon-btn"
|
|
onClick={dismissStale}
|
|
title="Keep reading this one — you'll be told again if it changes further"
|
|
aria-label="Dismiss"
|
|
>
|
|
<Icon name="x" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{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}
|
|
snapshots={snapshots}
|
|
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>
|
|
);
|
|
}
|