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
+20
View File
@@ -811,6 +811,7 @@ The endpoints, all under `/t/<tabId>/api`:
| `GET repo` | the repository, its refs, the comment counts, and the diff selection on screen |
| `POST repo/context` | what the page publishes when you change the base ref |
| `GET diff` | `base`, `uncommitted`, `commit`, `force`, `ignoreWhitespace` |
| `GET diff/revision` | a digest of what that same selection resolves to now — one hash, so the page can poll it |
| `GET file` | a file's contents at a ref, for expanding collapsed context |
| `GET/POST comments` | list, or open a thread |
| `PATCH/DELETE comments/{id}` | edit or delete one |
@@ -828,6 +829,25 @@ author starts as a draft. An agent has no drafting step — it posts a review it
has already decided on — so `"author":"claude"` is born submitted: an open thread,
with no **Submit review** click standing between it and being read.
### The diff never moves under you
An agent editing files while you read is the normal case here, so the pane has to
have an answer for "the diff you are looking at is no longer the diff". Reloading
itself is not that answer: it would lose your scroll position, your place in a
hunk, and whatever you had half-typed into a composer.
So the page asks `GET diff/revision` every few seconds — a hash of the patch plus
`HEAD`, which is cheap enough to ask for on a timer and catches both an
uncommitted edit and work being committed out from under the range. When it stops
matching the revision the diff came with, a banner says so and offers the refresh.
Dismissing it keeps the diff you are reading and re-arms against what is there
now, so the *next* change tells you too. Polling stops while the pane is hidden,
and starts again the moment it comes back.
Comments are the other half, and they work the other way round: those arrive over
`GET events` and are applied live, because a thread appearing in the rail doesn't
move anything you were reading.
### Comments are markdown
A comment body is markdown, rendered where it is read: fenced code (highlighted
+52 -6
View File
@@ -608,6 +608,9 @@ fn handleApi(
if (std.mem.eql(u8, path, "diff")) {
return self.handleDiff(a, request, repo, tab, query);
}
if (std.mem.eql(u8, path, "diff/revision")) {
return self.handleRevision(a, request, repo, tab, query);
}
if (std.mem.eql(u8, path, "file")) {
return self.handleFile(a, request, repo, query);
}
@@ -709,6 +712,21 @@ fn clampCopy(buf: []u8, value: []const u8) []const u8 {
return buf[0..n];
}
/// The diff selection a request names, falling back to the one the tab last
/// published for anything it leaves out.
fn queryContext(
a: std.mem.Allocator,
query: []const u8,
tab: Resolved,
) !model.DiffContext {
return .{
.base = try queryValue(a, query, "base") orelse
(if (tab.ctx) |c| c.base else "HEAD"),
.uncommitted = queryFlag(a, query, "uncommitted") catch false,
.commit = try queryValue(a, query, "commit") orelse "",
};
}
fn handleDiff(
self: *Server,
a: std.mem.Allocator,
@@ -717,12 +735,7 @@ fn handleDiff(
tab: Resolved,
query: []const u8,
) !void {
const ctx: model.DiffContext = .{
.base = try queryValue(a, query, "base") orelse
(if (tab.ctx) |c| c.base else "HEAD"),
.uncommitted = queryFlag(a, query, "uncommitted") catch false,
.commit = try queryValue(a, query, "commit") orelse "",
};
const ctx = try queryContext(a, query, tab);
const opts: git.Options = .{
.force = queryFlag(a, query, "force") catch false,
.ignore_whitespace = queryFlag(a, query, "ignoreWhitespace") catch false,
@@ -740,6 +753,39 @@ fn handleDiff(
return writeJson(a, request, .ok, payload);
}
/// A digest of what the selection resolves to *now*, for a page holding the one
/// that came with the diff it is showing. Answering "has this moved?" without
/// shipping the patch again is the whole reason it exists: the page polls this
/// and raises a banner, rather than reloading the diff under whoever is reading
/// it. See `git.revision`.
fn handleRevision(
self: *Server,
a: std.mem.Allocator,
request: *std.http.Server.Request,
repo: git.Repo,
tab: Resolved,
query: []const u8,
) !void {
const ctx = try queryContext(a, query, tab);
const opts: git.Options = .{
// Forced, because the answer has to be comparable with the revision the
// page already has — which, if it got here, is one it was given.
.force = true,
.ignore_whitespace = queryFlag(a, query, "ignoreWhitespace") catch false,
};
const rev = git.revision(repo, a, self.io, ctx, opts) catch |err| return writeError(
a,
request,
if (err == error.BadCommit) .bad_request else .bad_gateway,
switch (err) {
error.BadCommit => "commit is not a sha",
else => "git could not produce that diff — check the base ref",
},
);
return writeJson(a, request, .ok, .{ .revision = rev });
}
fn handleFile(
self: *Server,
a: std.mem.Allocator,
+52
View File
@@ -339,9 +339,61 @@ pub fn diff(
.files = files,
.commits = listed.items,
.moreCommits = listed.more,
.revision = try digest(repo, gpa, io, patch),
};
}
/// A digest of what a selection resolves to right now, for noticing that the
/// diff already on someone's screen has been overtaken.
///
/// Cheap enough to ask for on a timer — the answer is one hash — which is the
/// point: a review pane that reloaded itself every time an agent saved a file
/// would move the diff out from under whoever is reading it, so the page polls
/// this instead and says so.
///
/// The oversize guard is deliberately not applied. This is asked for by a page
/// that already has a patch on screen, so the answer has to be comparable with
/// the `revision` that came with it, guard or no guard.
pub fn revision(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
ctx: model.DiffContext,
opts: Options,
) Error![]const u8 {
try validateCommit(ctx.commit);
const patch = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
"--no-color",
"--find-renames",
}));
return digest(repo, gpa, io, patch);
}
/// The digest itself, given a patch already in hand: the patch, hashed, plus
/// HEAD.
///
/// Both halves are needed. The patch alone misses work being *committed*, which
/// leaves `base`..worktree byte for byte the same while the commit list beside
/// it grows an entry. HEAD alone misses every edit that hasn't been committed,
/// which is most of what a review watches for.
///
/// Nothing here is adversarial — a collision costs one banner that never
/// appears — so a 64-bit non-cryptographic hash is plenty.
fn digest(
repo: Repo,
gpa: std.mem.Allocator,
io: std.Io,
patch: []const u8,
) Error![]const u8 {
// Best-effort: a repository with no commits has no HEAD to resolve, and the
// patch on its own is still a usable digest.
const head = run(gpa, io, repo.path, &.{ "rev-parse", "HEAD" }) catch "";
var hasher = std.hash.Wyhash.init(0);
hasher.update(trim(head));
hasher.update(patch);
return std.fmt.allocPrint(gpa, "{x}", .{hasher.final()});
}
/// Whether a change set is past what the UI can render at once. Binary files
/// count for no lines, hence the file cap alongside the line one.
fn oversized(files: []const model.DiffFile) bool {
+9
View File
@@ -137,6 +137,15 @@ pub const DiffPayload = struct {
/// `files` is still filled in, so the caller can say how big it is and
/// offer to load it anyway with `force`.
oversized: bool = false,
/// A digest of what this selection resolved to when the patch was produced
/// — see `git.revision`. The page keeps it and asks `GET api/diff/revision`
/// for the current one every so often, which is how it can say the diff on
/// screen has gone stale without reloading it under the reader.
///
/// Empty when the patch was withheld, since there is then nothing on screen
/// to go stale.
revision: []const u8 = "",
};
/// What `GET api/repo` returns for a tab that has a review open.
+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" />
+12
View File
@@ -104,6 +104,18 @@ export const api = {
)}`,
).then(json<DiffPayload>),
// A digest of what the selection resolves to right now, for comparing with the
// `revision` that came with the diff on screen. One hash, so it's cheap to ask
// for repeatedly — which is what lets the page notice the diff is out of date
// without pulling it out from under whoever is reading it.
revision: (ctx: DiffContext, opts: { ignoreWhitespace?: boolean } = {}) =>
fetch(
`${apiBase}/diff/revision${q(
...ctxParams(ctx),
...(opts.ignoreWhitespace ? ['ignoreWhitespace=1'] : []),
)}`,
).then(json<{ revision: string }>),
// 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> => {
+2
View File
@@ -57,6 +57,8 @@ const PATHS = {
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',
'kebab-horizontal':
'M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
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',
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Icon, type IconName } from './Icon';
interface Props {
// What the trigger looks like. `label` is for assistive tech and the tooltip.
icon: IconName;
label: string;
// The items, given a `close` they can call. Settings that stay useful in a row
// — the theme, whitespace — leave the menu open; anything that opens a dialog
// or navigates closes it first.
children: (close: () => void) => ReactNode;
// Drawn on the trigger when something inside is worth surfacing from the
// closed state (a non-default setting is on).
marked?: boolean;
}
// Menu is the top bar's overflow: the controls that are set once and then left
// alone, kept off a bar that has to fit in half a pane.
//
// It exists because of the width. Every control here was on the bar, and at the
// widths a review pane actually gets — a split beside a terminal — the row ran
// off the end and took the submit button with it. What stayed out is what you
// touch while reading a diff; what moved in is what you set and forget.
//
// Deliberately not a <select> or a native popup: the items are a mix of toggles,
// a radio group and a destructive action, and each one has to show its own state.
export function Menu({ icon, label, children, marked }: Props) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
// Outside-press and Escape both close. Pointerdown rather than click so a
// press that lands on the diff closes the menu before the diff acts on it.
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
window.addEventListener('pointerdown', onDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onDown);
window.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="menu" ref={wrapRef}>
<button
className={`icon-btn${open ? ' is-open' : ''}${marked ? ' is-marked' : ''}`}
onClick={() => setOpen(!open)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
>
<Icon name={icon} />
</button>
{open && (
<div className="menu-pop" role="menu">
{children(close)}
</div>
)}
</div>
);
}
interface ItemProps {
children: ReactNode;
onClick: () => void;
icon?: IconName;
// Shown as a checkmark. Undefined for an item that isn't a toggle at all, so
// it doesn't reserve the column.
checked?: boolean;
danger?: boolean;
hint?: string;
}
export function MenuItem({ children, onClick, icon, checked, danger, hint }: ItemProps) {
return (
<button
className={`menu-item${danger ? ' is-danger' : ''}`}
role={checked === undefined ? 'menuitem' : 'menuitemcheckbox'}
aria-checked={checked}
onClick={onClick}
title={hint}
>
<span className="menu-item-mark">
{checked ? <Icon name="check" size={14} /> : icon ? <Icon name={icon} size={14} /> : null}
</span>
<span className="menu-item-label">{children}</span>
</button>
);
}
export function MenuSeparator() {
return <div className="menu-sep" role="separator" />;
}
+2 -2
View File
@@ -26,8 +26,8 @@ export function useSSE(onEvent: (e: ServerEvent) => void): void {
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.
// onmessage; synthesize it, so a caller that wants to know the stream is up
// rather than only that something changed — has an event to see.
es.onopen = () => handler.current({ type: 'connected', data: null });
es.onmessage = (ev) => {
try {
+179 -11
View File
@@ -442,11 +442,16 @@ textarea:focus-visible {
height: 100%;
}
/* The bar has to hold together in a pane a third of a screen wide, so nothing
in it is allowed to set the row's width: the brand truncates, the controls
drop their labels (see the media queries at the end of this section), and
wrapping is the last resort rather than the row running off the end. */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
gap: 8px 16px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
@@ -459,6 +464,8 @@ textarea:focus-visible {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.brand-mark {
color: var(--accent);
@@ -468,6 +475,8 @@ textarea:focus-visible {
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The branch, beside the repository's name. There is no tab bar here to carry
this — the review is the whole page — so the top bar is where "which
@@ -483,7 +492,25 @@ textarea:focus-visible {
.controls {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
/* The word on a control, as opposed to its glyph. Dropped whole in the compact
layout, which is why every label that can go is wrapped in one. */
.btn-label {
white-space: nowrap;
}
/* A native select is as wide as its widest option, and one `origin/feature/…`
in a repository's ref list would make that the width of the bar. The closed
control is capped; the list still opens at its natural width. */
.base-select {
max-width: 168px;
overflow: hidden;
text-overflow: ellipsis;
}
.control {
@@ -525,21 +552,89 @@ textarea:focus-visible {
background: var(--border);
}
.conn {
font-size: 12px;
color: var(--muted);
/* ---- Overflow menu ----------------------------------------------------- */
/* The top bar's set-and-forget controls, in Primer's action menu: a panel of
rows, each with a 14px mark column so checkmarks and glyphs line up. */
.menu {
position: relative;
display: inline-flex;
}
.icon-btn.is-open {
background: var(--neutral-muted);
color: var(--text);
}
/* A dot on the trigger when something inside is off its default — a diff that is
quietly hiding whitespace must not look like a diff that has none. */
.icon-btn.is-marked::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--accent);
}
.icon-btn.is-marked {
position: relative;
}
/* The refresh button, while the diff on screen is known to be behind. */
.icon-btn.is-attention {
color: var(--attention);
}
.menu-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 200px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-2);
}
.menu-item {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
width: 100%;
padding: 6px 8px;
background: transparent;
color: var(--text);
border: 0;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.conn .octicon {
color: var(--border-strong);
.menu-item:hover {
background: var(--neutral-muted);
}
.conn.is-live {
color: var(--add-fg);
.menu-item-mark {
display: inline-flex;
justify-content: center;
width: 14px;
flex: none;
color: var(--muted);
}
.conn.is-live .octicon {
color: var(--add-fg);
.menu-item[aria-checked='true'] .menu-item-mark {
color: var(--accent);
}
/* Same bargain as .icon-btn.is-danger: quiet in the list, red once you're on it. */
.menu-item.is-danger:hover {
background: var(--del-bg);
color: var(--del-fg);
}
.menu-item.is-danger:hover .menu-item-mark {
color: var(--del-fg);
}
.menu-sep {
height: 1px;
margin: 4px 0;
background: var(--border);
}
/* ---- Review progress ---------------------------------------------------- */
@@ -1202,6 +1297,26 @@ body.is-resizing {
.banner-error .octicon {
color: var(--del-fg);
}
/* The diff on screen has been overtaken. Primer's attention yellow, because it
is a notice and not a failure: nothing is broken, the patch is just older than
the work tree. The refresh sits in the banner so the fix is where the news is,
and the dismiss is there because sometimes you want to finish reading first. */
.banner-stale {
background: var(--attention-soft);
color: var(--text);
border-bottom: 1px solid var(--attention-muted);
}
.banner-stale .octicon {
color: var(--attention);
}
.banner-stale .btn-ghost .octicon,
.banner-stale .icon-btn .octicon {
color: inherit;
}
.banner-text {
flex: 1 1 auto;
min-width: 0;
}
/* ---- File card ---------------------------------------------------------- */
/* GitHub's per-file box: 1px border, 6px radius, no shadow, muted header. */
@@ -2262,3 +2377,56 @@ h6.md-h {
color: var(--del-fg);
font-size: 13px;
}
/* ---- Narrow panes ------------------------------------------------------ */
/* The review is the whole webview of its pane, so a width query here is a
question about the pane — drag a split in playpen and these fire.
Nothing is removed that you cannot get to: what goes first is the words
beside glyphs that already say the same thing, then the progress meter,
which is the one thing on the bar that is only ever read. */
@media (max-width: 960px) {
.topbar {
gap: 8px 10px;
padding: 8px 10px;
}
.controls {
gap: 6px;
}
.brand-branch,
.control-label,
.btn-label,
.btn-label-tail {
display: none;
}
/* A label-less button is a square around its glyph, not a wide empty one. */
.controls .toggle {
padding: 0 8px;
}
.controls .segmented button {
padding: 0 8px;
}
.base-select {
max-width: 132px;
}
.review-progress-files {
display: none;
}
.review-progress-track {
width: 48px;
}
}
@media (max-width: 620px) {
.review-progress,
.control-divider {
display: none;
}
}
/* Past this the name of the repository is the least of what you need: the tab it
is in already says which review this is, and the diff needs the room. */
@media (max-width: 520px) {
.brand-name {
display: none;
}
}
+5
View File
@@ -121,4 +121,9 @@ export interface DiffPayload {
// 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;
// A digest of what this selection resolved to when the patch was made. The
// page holds on to it and asks the server for the current one on a timer, so
// it can say the diff has been overtaken instead of reloading it mid-read.
// Empty when the patch was withheld — nothing on screen to go stale.
revision?: string;
}