Improve toolbar review layout.
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user