Improve toolbar review layout.

This commit is contained in:
Greyson Parrelli
2026-08-25 19:51:42 -04:00
parent 44619293c3
commit 0fd0aa44c7
11 changed files with 590 additions and 60 deletions
+155 -41
View File
@@ -13,6 +13,7 @@ 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';
@@ -43,6 +44,20 @@ const COMMENTS_MAX = 720;
// 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';
@@ -177,7 +192,11 @@ export default function App() {
const [commentsOpen, setCommentsOpen] = useState(
() => localStorage.getItem('review-comments-open') !== 'false',
);
const [connected, setConnected] = useState(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);
@@ -296,6 +315,8 @@ export default function App() {
if (seq !== reqRef.current) return;
setComments(cs);
setError(null);
setStale(null);
setRevision(d.revision || null);
if (d.oversized) {
setPayload(null);
setOversized(d);
@@ -354,10 +375,13 @@ export default function App() {
// 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.
//
// 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(
(e) => {
setConnected(true);
if (e.type === 'connected') return;
refetchComments();
},
@@ -365,6 +389,56 @@ export default function App() {
),
);
// 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 cancelled = 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 (cancelled || 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 (cancelled || 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 () => {
cancelled = 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(
@@ -546,22 +620,31 @@ export default function App() {
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">
<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" title={repo.path}>
{repo.branch}
</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 })}
@@ -590,13 +673,15 @@ export default function App() {
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'
}
>
uncommitted
<Icon name="pencil" size={14} />
<span className="btn-label">uncommitted</span>
</button>
{/* Which commit is on screen, and the way back out of it. */}
@@ -612,15 +697,6 @@ export default function App() {
</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' : ''}
@@ -628,7 +704,8 @@ export default function App() {
aria-pressed={viewType === 'split'}
title="Split view"
>
<Icon name="columns" size={14} /> split
<Icon name="columns" size={14} />
<span className="btn-label">split</span>
</button>
<button
className={viewType === 'unified' ? 'is-active' : ''}
@@ -636,7 +713,8 @@ export default function App() {
aria-pressed={viewType === 'unified'}
title="Unified view"
>
<Icon name="rows" size={14} /> unified
<Icon name="rows" size={14} />
<span className="btn-label">unified</span>
</button>
</div>
@@ -645,42 +723,78 @@ export default function App() {
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
<button
className="icon-btn"
className={`icon-btn${stale ? ' is-attention' : ''}`}
onClick={() => loadDiff(ctx)}
title="Refresh diff"
title={stale ? 'Reload the diff — the work tree has moved on' : '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>
{/* 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 review
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" />