Build in the review tool.
This commit is contained in:
+848
@@ -0,0 +1,848 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { parseDiff, type ViewType } from 'react-diff-view';
|
||||
|
||||
import { api, tabId } from './api';
|
||||
import type { Comment, DiffContext, DiffPayload, DraftTarget, RepoState } from './types';
|
||||
import { buildAnchors, isOutdated } from './lib/anchor';
|
||||
import { pathOf } from './lib/filetree';
|
||||
import { fingerprintFiles } from './lib/fingerprint';
|
||||
import { useSSE } from './lib/useSSE';
|
||||
import { CommentsPanel, CommentsTab } from './components/CommentsPanel';
|
||||
import { CommitList } from './components/CommitList';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { DiffView } from './components/DiffView';
|
||||
import { Icon } from './components/Icon';
|
||||
import { FileList } from './components/FileList';
|
||||
import { OutdatedPanel } from './components/Outdated';
|
||||
import { OversizeNotice, OversizeWarning } from './components/Oversize';
|
||||
import { Resizer } from './components/Resizer';
|
||||
import { ReviewPanel } from './components/ReviewPanel';
|
||||
import { ReviewProgress } from './components/ReviewProgress';
|
||||
import { useViewedFiles } from './lib/viewed';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
// Side-panel sizing: persisted across sessions, clamped so a rail can't swallow
|
||||
// the diff or shrink past a usable width.
|
||||
const SIDEBAR_DEFAULT = 288;
|
||||
const SIDEBAR_MIN = 180;
|
||||
const SIDEBAR_MAX = 720;
|
||||
const COMMENTS_DEFAULT = 320;
|
||||
const COMMENTS_MIN = 220;
|
||||
const COMMENTS_MAX = 720;
|
||||
|
||||
// The narrowest the diff itself is allowed to get.
|
||||
//
|
||||
// This exists because a review pane is not a browser window. The rails were
|
||||
// sized for something a screen wide; at half a playpen tab, three of them
|
||||
// side by side leave the diff a couple of hundred pixels and it renders one
|
||||
// character per line, which reads as the tool being broken rather than as the
|
||||
// pane being narrow. So the rails give way instead: they are clamped to what
|
||||
// is left over, and the comments rail folds away entirely when even that is not
|
||||
// enough. The *stored* widths are never touched by any of this — opening a
|
||||
// review in a cramped pane must not cost you the rail sizes you chose.
|
||||
const MIN_DIFF = 420;
|
||||
|
||||
const CTX_KEY = 'review-ctx-by-repo';
|
||||
const IGNORE_WS_KEY = 'review-ignore-whitespace';
|
||||
|
||||
// The diff every review opens on, and the placeholder for when nothing is open
|
||||
// at all. A module constant so its identity is stable across renders and can't
|
||||
// retrigger the diff fetch.
|
||||
//
|
||||
// HEAD is the default because it's the one base that can't surprise you: it
|
||||
// shows the work in front of you and nothing else. A release branch is often
|
||||
// what you actually want, but when its history has moved on under the branch
|
||||
// you're reviewing the diff fills with commits nobody asked about — which is
|
||||
// exactly the case where an unasked-for default hurts. The picker offers that
|
||||
// ref first (see RepoInfo.suggestedBase), one click away.
|
||||
const HEAD_CTX: DiffContext = { base: 'HEAD', uncommitted: true };
|
||||
|
||||
function savedWidth(key: string, fallback: number, min: number, max: number) {
|
||||
const saved = Number(localStorage.getItem(key));
|
||||
if (!Number.isFinite(saved) || saved <= 0) return fallback;
|
||||
return Math.min(Math.max(saved, min), max);
|
||||
}
|
||||
|
||||
// The base-ref selection has to survive a reload. It decides which diff you're
|
||||
// looking at, and losing it drops you back on the default base — which used to
|
||||
// look exactly like every comment you'd written having vanished, since a comment
|
||||
// written against another base has no line in the diff you land on.
|
||||
//
|
||||
// Keyed by repository path rather than by tab: a tab is a slot in a window and
|
||||
// gets renumbered, while the work tree is the thing the selection is about. Close
|
||||
// a review pane and open another on the same repo and you land where you left off.
|
||||
function loadCtxByRepo(): Record<string, DiffContext> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(CTX_KEY) ?? '{}');
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out: Record<string, DiffContext> = {};
|
||||
for (const [path, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const c = v as Partial<DiffContext>;
|
||||
if (typeof c?.base === 'string' && typeof c?.uncommitted === 'boolean') {
|
||||
out[path] = { base: c.base, uncommitted: c.uncommitted };
|
||||
// A commit you were reading on its own is part of that selection, so a
|
||||
// reload lands back on it rather than on the whole change set.
|
||||
if (typeof c.commit === 'string' && c.commit) out[path].commit = c.commit;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function initialTheme(): Theme {
|
||||
const saved = localStorage.getItem('review-theme');
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
// What a reset would delete, in words, for the confirmation dialog.
|
||||
function resetCommentsLine(n: number): string {
|
||||
if (n === 0) return 'no comments to delete';
|
||||
const threads = n === 1 ? '1 comment thread' : `all ${n} comment threads`;
|
||||
return `${threads} deleted — drafts, submitted, and resolved alike`;
|
||||
}
|
||||
|
||||
function scrollToFile(path: string) {
|
||||
document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// flashComment scrolls a thread into view and pulses its outline. Returns false
|
||||
// when the thread isn't in the DOM — its file is collapsed, or the diff hasn't
|
||||
// rendered it yet.
|
||||
function flashComment(id: string): boolean {
|
||||
const el = document.getElementById(`comment-${id}`);
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.remove('is-flashed');
|
||||
void el.offsetWidth; // restart the flash when the same card is re-clicked
|
||||
el.classList.add('is-flashed');
|
||||
window.setTimeout(() => el.classList.remove('is-flashed'), 1800);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Frames to wait for a thread to mount after its file is asked to expand. A
|
||||
// large file can take a few to lay out; past this we give up and settle for the
|
||||
// file header rather than leaving the click with nothing to show.
|
||||
const REVEAL_FRAMES = 60;
|
||||
|
||||
// waitForComment retries the jump each frame until the thread appears, then
|
||||
// falls back. Frames, not a timeout: the thread arrives on a render, and this
|
||||
// way the scroll happens on the very first frame it exists.
|
||||
function waitForComment(id: string, fallback: () => void, frames = REVEAL_FRAMES) {
|
||||
if (flashComment(id)) return;
|
||||
if (frames <= 0) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => waitForComment(id, fallback, frames - 1));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// The one review this page is. Undefined while loading; null once the server
|
||||
// has said this tab has no review pane bound to a repository.
|
||||
const [repo, setRepo] = useState<RepoState | null | undefined>(undefined);
|
||||
const [ctxByRepo, setCtxByRepo] = useState<Record<string, DiffContext>>(loadCtxByRepo);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [clearResolvedOpen, setClearResolvedOpen] = useState(false);
|
||||
const [viewType, setViewType] = useState<ViewType>('split');
|
||||
// Hide changes that are only whitespace. Not part of the diff context, and so
|
||||
// not per repo either: it's how you read a diff — like split/unified — rather
|
||||
// than which diff you're reading, and a reformatting commit in one worktree
|
||||
// doesn't make it the wrong setting in the next.
|
||||
const [ignoreWs, setIgnoreWs] = useState(
|
||||
() => localStorage.getItem(IGNORE_WS_KEY) === 'true',
|
||||
);
|
||||
const [payload, setPayload] = useState<DiffPayload | null>(null);
|
||||
// See the effect that clears these: a diff withheld for being too large, and
|
||||
// whether its warning has been answered one way or the other.
|
||||
const [oversized, setOversized] = useState<DiffPayload | null>(null);
|
||||
const [oversizeDismissed, setOversizeDismissed] = useState(false);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [draft, setDraft] = useState<DraftTarget | null>(null);
|
||||
// The file a jump wants opened, if it was collapsed. The sequence number makes
|
||||
// a repeat request for the same file a distinct one, so re-clicking a comment
|
||||
// after re-folding its file opens it again.
|
||||
const [reveal, setReveal] = useState<{ file: string; seq: number } | null>(null);
|
||||
const revealSeq = useRef(0);
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() =>
|
||||
savedWidth('review-sidebar-width', SIDEBAR_DEFAULT, SIDEBAR_MIN, SIDEBAR_MAX),
|
||||
);
|
||||
const [commentsWidth, setCommentsWidth] = useState(() =>
|
||||
savedWidth('review-comments-width', COMMENTS_DEFAULT, COMMENTS_MIN, COMMENTS_MAX),
|
||||
);
|
||||
const [commentsOpen, setCommentsOpen] = useState(
|
||||
() => localStorage.getItem('review-comments-open') !== 'false',
|
||||
);
|
||||
const [connected, setConnected] = useState(false);
|
||||
// Tracked so the rail clamping below re-runs when the pane is resized —
|
||||
// dragging a split in playpen is the common case, not a rare one.
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const path = repo?.path ?? null;
|
||||
|
||||
// ctxByRepo holds only the repos whose base ref you've actually changed;
|
||||
// anything else falls back to HEAD. Deriving rather than seeding state avoids a
|
||||
// frame where the review is open but has no context yet.
|
||||
const ctx = path ? (ctxByRepo[path] ?? HEAD_CTX) : HEAD_CTX;
|
||||
|
||||
// The parsed diff lives here rather than in DiffView because the comments rail
|
||||
// needs it too: deciding which comments the diff can still place is one
|
||||
// judgement, made once, so the rail and the diff can't disagree about it.
|
||||
const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]);
|
||||
|
||||
// What each file's diff currently says, digested. Viewed marks are stored
|
||||
// against these, so a file whose code moved since you signed off on it comes
|
||||
// back unmarked instead of quietly staying checked.
|
||||
const fingerprints = useMemo(() => fingerprintFiles(parsedFiles), [parsedFiles]);
|
||||
|
||||
const { viewed, changed, setFileViewed, clearViewed } = useViewedFiles(
|
||||
path,
|
||||
ctx,
|
||||
fingerprints,
|
||||
ignoreWs,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('review-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewport(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('review-sidebar-width', String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('review-comments-width', String(commentsWidth));
|
||||
}, [commentsWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('review-comments-open', String(commentsOpen));
|
||||
}, [commentsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CTX_KEY, JSON.stringify(ctxByRepo));
|
||||
}, [ctxByRepo]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(IGNORE_WS_KEY, String(ignoreWs));
|
||||
}, [ignoreWs]);
|
||||
|
||||
// The window's tab is what the review is named after, so it belongs in the
|
||||
// browser title too: a review opened in a real browser alongside two others is
|
||||
// otherwise three identical tabs.
|
||||
useEffect(() => {
|
||||
const name = repo?.path.split('/').pop();
|
||||
document.title = name ? `${name} · review` : 'review';
|
||||
}, [repo]);
|
||||
|
||||
const flash = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(null), 2600);
|
||||
}, []);
|
||||
|
||||
const loadRepo = useCallback(async () => {
|
||||
try {
|
||||
const info = await api.repo();
|
||||
setRepo(info.open ? (info as RepoState) : null);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setRepo(null);
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepo();
|
||||
}, [loadRepo]);
|
||||
|
||||
// A diff the server held back for being too big to render, and whether the
|
||||
// warning about it has been dismissed. It isn't a `payload`: nothing is loaded,
|
||||
// so the diff area shows the notice instead of a change set — but it carries
|
||||
// the file summary, which is what the warning counts.
|
||||
useEffect(() => {
|
||||
setOversized(null);
|
||||
setOversizeDismissed(false);
|
||||
}, [path, ctx, ignoreWs]);
|
||||
|
||||
// Sequence guard: changing the base ref quickly can land an older response
|
||||
// after a newer one, which would show a diff the controls no longer describe.
|
||||
const reqRef = useRef(0);
|
||||
|
||||
// force answers the size warning: load the diff however big it turned out to be.
|
||||
// Toggling the whitespace preference re-identifies this callback, which is what
|
||||
// reloads the diff under the new setting.
|
||||
const loadDiff = useCallback(
|
||||
async (c: DiffContext, force = false) => {
|
||||
const seq = ++reqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, cs] = await Promise.all([
|
||||
api.diff(c, { force, ignoreWhitespace: ignoreWs }),
|
||||
api.comments(),
|
||||
]);
|
||||
if (seq !== reqRef.current) return;
|
||||
setComments(cs);
|
||||
setError(null);
|
||||
if (d.oversized) {
|
||||
setPayload(null);
|
||||
setOversized(d);
|
||||
setOversizeDismissed(false);
|
||||
} else {
|
||||
setPayload(d);
|
||||
setOversized(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq === reqRef.current) setError(String(e));
|
||||
} finally {
|
||||
if (seq === reqRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[ignoreWs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) loadDiff(ctx);
|
||||
}, [path, ctx, loadDiff]);
|
||||
|
||||
// Tell the server which diff is on screen, so an agent asked to review it lands
|
||||
// its comments on the lines you're actually looking at. Best-effort — nothing on
|
||||
// screen depends on it.
|
||||
useEffect(() => {
|
||||
if (path) api.setContext(ctx).catch(() => {});
|
||||
}, [path, ctx]);
|
||||
|
||||
const setCtx = useCallback(
|
||||
(next: DiffContext) => {
|
||||
if (path) setCtxByRepo((prev) => ({ ...prev, [path]: next }));
|
||||
},
|
||||
[path],
|
||||
);
|
||||
|
||||
// selectCommit narrows the diff to one commit of the range, or back to the whole
|
||||
// change set with undefined. Everything else about the selection is left alone,
|
||||
// so leaving a commit returns you to the diff you drilled into it from.
|
||||
const selectCommit = useCallback(
|
||||
(sha: string | undefined) => setCtx({ ...ctx, commit: sha }),
|
||||
[ctx, setCtx],
|
||||
);
|
||||
|
||||
// loadOversized answers the size warning by asking for the diff again, this
|
||||
// time without the guard. Dismissing the warning first is what takes the modal
|
||||
// down while the (slow, by definition) fetch runs.
|
||||
const loadOversized = useCallback(() => {
|
||||
setOversizeDismissed(true);
|
||||
loadDiff(ctx, true);
|
||||
}, [ctx, loadDiff]);
|
||||
|
||||
const refetchComments = useCallback(() => {
|
||||
api.comments().then(setComments).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Live updates. Every event on this stream is about this review — the stream is
|
||||
// the tab's own — so anything that isn't the opening handshake means the comment
|
||||
// list moved and is worth refetching.
|
||||
useSSE(
|
||||
useCallback(
|
||||
(e) => {
|
||||
setConnected(true);
|
||||
if (e.type === 'connected') return;
|
||||
refetchComments();
|
||||
},
|
||||
[refetchComments],
|
||||
),
|
||||
);
|
||||
|
||||
// submitDraft creates the comment for the currently-open draft (line/range,
|
||||
// file, or review level).
|
||||
const submitDraft = useCallback(
|
||||
async (body: string) => {
|
||||
const d = draft;
|
||||
if (!d) return;
|
||||
if (d.level === 'line') {
|
||||
await api.createComment({
|
||||
level: 'line',
|
||||
file: d.file,
|
||||
side: d.side,
|
||||
line: d.startLine,
|
||||
endLine: d.endLine,
|
||||
body,
|
||||
ctx,
|
||||
});
|
||||
} else if (d.level === 'file') {
|
||||
await api.createComment({ level: 'file', file: d.file, body, ctx });
|
||||
} else {
|
||||
await api.createComment({ level: 'review', body, ctx });
|
||||
}
|
||||
setDraft(null);
|
||||
refetchComments();
|
||||
},
|
||||
[draft, ctx, refetchComments],
|
||||
);
|
||||
|
||||
const submitReview = useCallback(async () => {
|
||||
const { submitted } = await api.submit();
|
||||
refetchComments();
|
||||
if (submitted === 0) {
|
||||
flash('No draft comments to submit.');
|
||||
return;
|
||||
}
|
||||
// The pane's own tab is where the agent that should pick these up is running,
|
||||
// so name it: with several reviews open, which one Claude is meant to work in
|
||||
// is the one thing the user has to get right.
|
||||
flash(
|
||||
`Submitted ${submitted} comment${submitted === 1 ? '' : 's'} — ` +
|
||||
`say “address the review” in ${tabId || 'this tab'}.`,
|
||||
);
|
||||
}, [refetchComments, flash]);
|
||||
|
||||
// resetReview throws the whole review away: every comment on the server, the
|
||||
// viewed marks in this browser, and the base-ref selection, which goes back to
|
||||
// the default this repo would open on. Nothing is recoverable, hence the
|
||||
// confirmation in front of it.
|
||||
const resetReview = useCallback(async () => {
|
||||
setResetOpen(false);
|
||||
try {
|
||||
await api.reset();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return;
|
||||
}
|
||||
clearViewed();
|
||||
if (path) {
|
||||
setCtxByRepo((prev) => {
|
||||
const { [path]: _dropped, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
setDraft(null);
|
||||
setComments([]);
|
||||
refetchComments();
|
||||
flash('Review reset.');
|
||||
}, [path, clearViewed, refetchComments, flash]);
|
||||
|
||||
// deleteResolved clears the finished threads and nothing else. Resolved
|
||||
// threads are the record of what has already been dealt with, so this is
|
||||
// confirmed like the reset is — it's just destructive on a smaller scale.
|
||||
const deleteResolved = useCallback(async () => {
|
||||
setClearResolvedOpen(false);
|
||||
let deleted: number;
|
||||
try {
|
||||
({ deleted } = await api.deleteResolved());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return;
|
||||
}
|
||||
refetchComments();
|
||||
flash(
|
||||
deleted === 0
|
||||
? 'No resolved comments to delete.'
|
||||
: `Deleted ${deleted} resolved comment${deleted === 1 ? '' : 's'}.`,
|
||||
);
|
||||
}, [refetchComments, flash]);
|
||||
|
||||
// jumpToComment scrolls to a comment's thread in the diff and flashes it. A
|
||||
// line thread is only in the DOM while its file is expanded, so when the
|
||||
// thread isn't there we ask the file to open (`reveal`) and scroll as soon as
|
||||
// the thread mounts — the file expanding and the jump landing are one action.
|
||||
const jumpToComment = useCallback((c: Comment) => {
|
||||
if (flashComment(c.id)) return;
|
||||
if (c.level === 'review') return; // always rendered; nothing to open
|
||||
setReveal({ file: c.file, seq: ++revealSeq.current });
|
||||
waitForComment(c.id, () => scrollToFile(c.file));
|
||||
}, []);
|
||||
|
||||
const draftCount = useMemo(
|
||||
() => comments.filter((c) => c.status === 'draft').length,
|
||||
[comments],
|
||||
);
|
||||
const openCount = useMemo(
|
||||
() => comments.filter((c) => c.status === 'submitted').length,
|
||||
[comments],
|
||||
);
|
||||
const resolvedCount = useMemo(
|
||||
() => comments.filter((c) => c.status === 'resolved').length,
|
||||
[comments],
|
||||
);
|
||||
const reviewComments = useMemo(
|
||||
() => comments.filter((c) => c.level === 'review'),
|
||||
[comments],
|
||||
);
|
||||
const fileOrder = useMemo(() => (payload?.files ?? []).map(pathOf), [payload]);
|
||||
|
||||
// What the rails actually get, as opposed to what they are set to. See
|
||||
// MIN_DIFF: the comments rail yields first, then the file rail, and the diff
|
||||
// keeps the rest.
|
||||
const commentsRoom = viewport - MIN_DIFF - SIDEBAR_MIN;
|
||||
const showComments = commentsOpen && commentsRoom >= COMMENTS_MIN;
|
||||
const railCommentsWidth = Math.min(commentsWidth, commentsRoom);
|
||||
const railSidebarWidth = Math.max(
|
||||
SIDEBAR_MIN,
|
||||
Math.min(sidebarWidth, viewport - MIN_DIFF - (showComments ? railCommentsWidth : 0)),
|
||||
);
|
||||
|
||||
// The commit list comes from whichever payload we have. An oversized diff has no
|
||||
// patch but does carry the range, and picking one commit out of it is the
|
||||
// quickest route to something the browser will actually render.
|
||||
const range = payload ?? oversized;
|
||||
const commits = range?.commits ?? [];
|
||||
|
||||
const anchors = useMemo(
|
||||
() => (payload ? buildAnchors(parsedFiles, payload.context) : null),
|
||||
[parsedFiles, payload],
|
||||
);
|
||||
|
||||
// Comments the diff on screen has nowhere to put. They are still shown —
|
||||
// flagged outdated in the rail, and either at the top of their file or, when
|
||||
// the file itself has left the change set, in a panel under the diff.
|
||||
const outdated = useMemo(
|
||||
() => new Set(comments.filter((c) => isOutdated(c, anchors)).map((c) => c.id)),
|
||||
[comments, anchors],
|
||||
);
|
||||
const orphanedComments = useMemo(
|
||||
() => comments.filter((c) => outdated.has(c.id) && !anchors?.files.has(c.file)),
|
||||
[comments, outdated, anchors],
|
||||
);
|
||||
|
||||
if (repo === undefined) {
|
||||
return <div className="app loading">Connecting…</div>;
|
||||
}
|
||||
|
||||
// No review bound to this tab. It isn't an error and there is nothing to pick
|
||||
// from — the pane takes its repository from the directory the tab is working
|
||||
// in — so this says what to do rather than offering a browser.
|
||||
if (!repo) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="empty-state">
|
||||
<Icon name="file-diff" size={32} />
|
||||
<h1>No review in this tab</h1>
|
||||
<p>
|
||||
A review pane takes its repository from the directory the tab is
|
||||
working in, and {tabId ? <code>{tabId}</code> : 'this tab'} isn't
|
||||
inside a git work tree. Close the pane and reopen it from a tab whose
|
||||
terminal is in one.
|
||||
</p>
|
||||
{error && <p className="empty-state-error">{error}</p>}
|
||||
<button className="btn-submit" onClick={loadRepo}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">
|
||||
<Icon name="file-diff" />
|
||||
</span>
|
||||
<span className="brand-name">{repo.path.split('/').pop()}</span>
|
||||
<span className="brand-branch" title={repo.path}>
|
||||
{repo.branch}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<label className="control">
|
||||
<span className="control-label">base</span>
|
||||
<select
|
||||
value={ctx.base}
|
||||
// A commit selected out of the old range has no place in the new
|
||||
// one, so changing the base drops back to the whole change set.
|
||||
onChange={(e) => setCtx({ ...ctx, base: e.target.value, commit: undefined })}
|
||||
>
|
||||
<option value="HEAD">HEAD ({repo.branch})</option>
|
||||
{/* The release branch (or main) sits directly under HEAD rather
|
||||
than buried in a ref list hundreds long — it's the base you
|
||||
reach for when HEAD isn't the one you want. */}
|
||||
{repo.suggestedBase && (
|
||||
<option value={repo.suggestedBase}>{repo.suggestedBase}</option>
|
||||
)}
|
||||
{repo.refs
|
||||
?.filter((r) => r !== repo.suggestedBase)
|
||||
.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* A single commit's diff is fixed history — the working tree has
|
||||
nothing to do with it — so the toggle is disabled rather than
|
||||
quietly doing nothing. */}
|
||||
<button
|
||||
className={`toggle${ctx.uncommitted && !ctx.commit ? ' is-on' : ''}`}
|
||||
onClick={() => setCtx({ ...ctx, uncommitted: !ctx.uncommitted })}
|
||||
disabled={!!ctx.commit}
|
||||
title={
|
||||
ctx.commit
|
||||
? "Doesn't apply while you're reading a single commit"
|
||||
: 'Include uncommitted working-tree changes'
|
||||
}
|
||||
>
|
||||
uncommitted
|
||||
</button>
|
||||
|
||||
{/* Which commit is on screen, and the way back out of it. */}
|
||||
{ctx.commit && (
|
||||
<button
|
||||
className="commit-chip"
|
||||
onClick={() => selectCommit(undefined)}
|
||||
title="Back to every commit in the range"
|
||||
>
|
||||
<Icon name="git-commit" size={14} />
|
||||
{commits.find((c) => c.sha === ctx.commit)?.shortSha ?? ctx.commit.slice(0, 7)}
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`toggle${ignoreWs ? ' is-on' : ''}`}
|
||||
onClick={() => setIgnoreWs(!ignoreWs)}
|
||||
aria-pressed={ignoreWs}
|
||||
title="Ignore whitespace-only changes (git diff -w) — files with nothing else in them leave the change set"
|
||||
>
|
||||
ignore whitespace
|
||||
</button>
|
||||
|
||||
<div className="segmented" role="group" aria-label="Diff layout">
|
||||
<button
|
||||
className={viewType === 'split' ? 'is-active' : ''}
|
||||
onClick={() => setViewType('split')}
|
||||
aria-pressed={viewType === 'split'}
|
||||
title="Split view"
|
||||
>
|
||||
<Icon name="columns" size={14} /> split
|
||||
</button>
|
||||
<button
|
||||
className={viewType === 'unified' ? 'is-active' : ''}
|
||||
onClick={() => setViewType('unified')}
|
||||
aria-pressed={viewType === 'unified'}
|
||||
title="Unified view"
|
||||
>
|
||||
<Icon name="rows" size={14} /> unified
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="control-divider" />
|
||||
|
||||
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
|
||||
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => loadDiff(ctx)}
|
||||
title="Refresh diff"
|
||||
aria-label="Refresh diff"
|
||||
>
|
||||
<Icon name="sync" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Icon name={theme === 'dark' ? 'moon' : 'sun'} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn is-danger"
|
||||
onClick={() => setResetOpen(true)}
|
||||
title="Reset review — delete every comment and clear viewed files"
|
||||
aria-label="Reset review"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
|
||||
<span className={`conn${connected ? ' is-live' : ''}`} title="Live connection">
|
||||
<Icon name="dot-fill" size={12} />
|
||||
{connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
|
||||
<button className="btn-submit" onClick={submitReview} disabled={draftCount === 0}>
|
||||
Submit review
|
||||
{draftCount > 0 && <span className="count">{draftCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="banner banner-error">
|
||||
<Icon name="alert" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="body">
|
||||
<aside className="sidebar" style={{ width: railSidebarWidth }}>
|
||||
{commits.length > 0 && (
|
||||
<CommitList
|
||||
commits={commits}
|
||||
more={range?.moreCommits === true}
|
||||
selected={ctx.commit}
|
||||
onSelect={selectCommit}
|
||||
/>
|
||||
)}
|
||||
{payload && (
|
||||
<FileList files={payload.files} comments={comments} onSelect={scrollToFile} />
|
||||
)}
|
||||
<div className="sidebar-foot">
|
||||
{openCount > 0 && (
|
||||
<div className="review-status">
|
||||
<Icon name="dot-fill" size={12} /> {openCount} open for Claude
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Resizer
|
||||
width={railSidebarWidth}
|
||||
min={SIDEBAR_MIN}
|
||||
max={SIDEBAR_MAX}
|
||||
onChange={setSidebarWidth}
|
||||
onReset={() => setSidebarWidth(SIDEBAR_DEFAULT)}
|
||||
/>
|
||||
|
||||
<main className="main">
|
||||
{loading && !payload ? (
|
||||
<div className="loading">Loading diff…</div>
|
||||
) : oversized ? (
|
||||
<OversizeNotice
|
||||
files={oversized.files}
|
||||
base={ctx.base}
|
||||
suggested={repo.suggestedBase}
|
||||
commits={commits.length}
|
||||
onLoad={loadOversized}
|
||||
/>
|
||||
) : payload ? (
|
||||
<div className="diff-scroll">
|
||||
<ReviewPanel
|
||||
comments={reviewComments}
|
||||
draftActive={draft?.level === 'review'}
|
||||
onStart={() => setDraft({ level: 'review' })}
|
||||
onSubmit={submitDraft}
|
||||
onCancel={() => setDraft(null)}
|
||||
onChanged={refetchComments}
|
||||
/>
|
||||
<DiffView
|
||||
files={parsedFiles}
|
||||
comments={comments}
|
||||
outdated={outdated}
|
||||
viewType={viewType}
|
||||
ctx={ctx}
|
||||
draft={draft}
|
||||
viewed={viewed}
|
||||
changed={changed}
|
||||
reveal={reveal}
|
||||
onSetViewed={setFileViewed}
|
||||
onStartDraft={setDraft}
|
||||
onCancelDraft={() => setDraft(null)}
|
||||
onSubmitDraft={submitDraft}
|
||||
onChanged={refetchComments}
|
||||
/>
|
||||
<OutdatedPanel comments={orphanedComments} onChanged={refetchComments} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{showComments ? (
|
||||
<>
|
||||
<Resizer
|
||||
width={railCommentsWidth}
|
||||
min={COMMENTS_MIN}
|
||||
max={COMMENTS_MAX}
|
||||
panel="right"
|
||||
onChange={setCommentsWidth}
|
||||
onReset={() => setCommentsWidth(COMMENTS_DEFAULT)}
|
||||
/>
|
||||
<CommentsPanel
|
||||
comments={comments}
|
||||
outdated={outdated}
|
||||
fileOrder={fileOrder}
|
||||
width={railCommentsWidth}
|
||||
onJump={jumpToComment}
|
||||
onDeleteResolved={() => setClearResolvedOpen(true)}
|
||||
onCollapse={() => setCommentsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CommentsTab
|
||||
count={draftCount + openCount}
|
||||
onExpand={() => setCommentsOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{oversized && !oversizeDismissed && (
|
||||
<OversizeWarning
|
||||
files={oversized.files}
|
||||
base={ctx.base}
|
||||
suggested={repo.suggestedBase}
|
||||
commits={commits.length}
|
||||
onLoad={loadOversized}
|
||||
onCancel={() => setOversizeDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resetOpen && (
|
||||
<ConfirmDialog
|
||||
title="Reset this review?"
|
||||
confirmLabel="Reset review"
|
||||
onConfirm={resetReview}
|
||||
onCancel={() => setResetOpen(false)}
|
||||
>
|
||||
<p>
|
||||
<code>{repo.path.split('/').pop()}</code> starts over as if you had just
|
||||
opened it:
|
||||
</p>
|
||||
<ul>
|
||||
<li>{resetCommentsLine(comments.length)}</li>
|
||||
<li>every file unmarked as viewed</li>
|
||||
<li>base ref back to HEAD</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>This can't be undone.</strong> Reviews in other tabs are left
|
||||
alone.
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
{clearResolvedOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete resolved comments?"
|
||||
confirmLabel={`Delete ${resolvedCount} resolved`}
|
||||
onConfirm={deleteResolved}
|
||||
onCancel={() => setClearResolvedOpen(false)}
|
||||
>
|
||||
<p>
|
||||
{resolvedCount === 1
|
||||
? 'The 1 resolved thread is removed'
|
||||
: `All ${resolvedCount} resolved threads are removed`}
|
||||
, with their replies. Drafts, open threads, and your viewed files are
|
||||
left alone.
|
||||
</p>
|
||||
<p>
|
||||
<strong>This can't be undone.</strong>
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user