Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
956 linesCodeBlameRaw
1import {
2 ChevronDown,
3 ChevronRight,
4 Columns2,
5 FileDiff as FileIcon,
6 FileMinus,
7 FilePlus,
8 Files,
9 Folder,
10 MessageSquarePlus,
11 Rows3,
12 Search,
13} from "lucide-react";
14import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
15import { Form, Link } from "react-router";
16
17import type { Comment, Comparison, DiffLine } from "@g1t/contracts";
18
19import {
20 type FileHtml,
21 type HighlightedFile,
22 type HighlightedLine,
23 diffContent,
24 htmlOfFile,
25 withHtml,
26} from "../lib/diff";
27import { Markdown } from "./markdown";
28import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
29import { Avatar } from "./ui/avatar";
30import { Button } from "./ui/button";
31import { Card } from "./ui/card";
32import { Checkbox } from "./ui/checkbox";
33import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet";
34
35const ROW_STYLES: Record<DiffLine["kind"], string> = {
36 context: "",
37 add: "bg-success/9",
38 delete: "bg-danger/9",
39};
40
41const GUTTER_STYLES: Record<DiffLine["kind"], string> = {
42 context: "text-faint",
43 add: "bg-success/[0.14] text-success/70",
44 delete: "bg-danger/[0.14] text-danger/70",
45};
46
47const MARKERS: Record<DiffLine["kind"], string> = {
48 context: " ",
49 add: "+",
50 delete: "−",
51};
52
53/** Comments on lines of the change, and whether the viewer may add one. */
54export type LineReview = { comments: Comment[]; canComment: boolean };
55
56type Layout = "unified" | "split";
57
58/** Reads a per-viewer preference; storage can be missing or refuse. */
59function stored(key: string): string | null {
60 try {
61 return window.localStorage.getItem(key);
62 } catch {
63 return null;
64 }
65}
66
67function store(key: string, value: string | null) {
68 try {
69 if (value == null) window.localStorage.removeItem(key);
70 else window.localStorage.setItem(key, value);
71 } catch {
72 // A private window: the preference lasts until the page is left.
73 }
74}
75
76/** Whether the screen is phone-sized: a change is shown unified there, never split. */
77function useNarrow(): boolean {
78 const [narrow, setNarrow] = useState(false);
79 useEffect(() => {
80 const query = window.matchMedia("(max-width: 639px)");
81 const update = () => setNarrow(query.matches);
82 update();
83 query.addEventListener("change", update);
84 return () => query.removeEventListener("change", update);
85 }, []);
86 return narrow;
87}
88
89/** `+12 −3` with a five-block bar (the bar from `sm` up). */
90export function Stat({ additions, deletions }: { additions: number; deletions: number }) {
91 const total = additions + deletions;
92 const green = total === 0 ? 0 : Math.round((additions / total) * 5);
93 return (
94 <span className="flex shrink-0 items-center gap-2 font-mono text-xs">
95 <span className="text-success">+{additions}</span>
96 <span className="text-danger">−{deletions}</span>
97 <span className="hidden gap-px sm:flex" aria-hidden="true">
98 {Array.from({ length: 5 }, (_, i) => (
99 <span
100 key={i}
101 className={`size-2 rounded-xs ${
102 total === 0 ? "bg-line" : i < green ? "bg-success" : "bg-danger"
103 }`}
104 />
105 ))}
106 </span>
107 </span>
108 );
109}
110
111function fileIcon(status: HighlightedFile["status"]) {
112 return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon;
113}
114
115function fileTone(status: HighlightedFile["status"]) {
116 return status === "added" ? "text-success" : status === "deleted" ? "text-danger" : "text-faint";
117}
118
119/** One comment made on a line, shown under that line. */
120function LineComment({ comment }: { comment: Comment }) {
121 return (
122 <Card tone="bg" radius="lg" className="font-sans">
123 <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted">
124 <Avatar name={comment.author.username} size={16} />
125 <span className="font-medium text-fg">{comment.author.username}</span>
126 <TimeAgo at={comment.createdAt} />
127 </p>
128 <div className="px-3 py-2 text-sm">
129 <Markdown source={comment.body} />
130 </div>
131 </Card>
132 );
133}
134
135/** A line's text, highlighted when the server could. */
136function Code({ line }: { line: HighlightedLine | null }) {
137 if (!line) return null;
138 if (line.html != null) {
139 return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />;
140 }
141 return <>{line.text || " "}</>;
142}
143
144/** The comments on a line and, when open, the box to add one. */
145function Thread({
146 path,
147 line,
148 comments,
149 open,
150 onClose,
151}: {
152 path: string;
153 line: number;
154 comments: Comment[];
155 open: boolean;
156 onClose: () => void;
157}) {
158 // The box stays, saying it is posting, until the new comment shows above it.
159 const count = useRef(comments.length);
160 useEffect(() => {
161 if (comments.length > count.current) onClose();
162 count.current = comments.length;
163 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it
164 }, [comments.length]);
165 return (
166 <div className="max-w-2xl space-y-2">
167 {comments.map((comment) => (
168 <LineComment key={comment.id} comment={comment} />
169 ))}
170 {open && (
171 <Form method="post" className="space-y-2 font-sans">
172 <input type="hidden" name="action" value="comment" />
173 <input type="hidden" name="path" value={path} />
174 <input type="hidden" name="line" value={line} />
175 <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
176 <div className="flex gap-2">
177 <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…">
178 Comment
179 </SubmitButton>
180 <Button variant="outline" type="button" onClick={onClose}>
181 Cancel
182 </Button>
183 </div>
184 </Form>
185 )}
186 </div>
187 );
188}
189
190function CommentButton({ line, onClick }: { line: number; onClick: (event: React.MouseEvent) => void }) {
191 return (
192 <Button
193 type="button"
194 aria-label={`Comment on line ${line}`}
195 onClick={onClick}
196 variant="accent" size="icon"
197 className="absolute top-0 -right-2.5 z-10 hidden size-5 rounded group-hover:flex focus-visible:flex pointer-coarse:-right-1 pointer-coarse:flex pointer-coarse:size-7"
198 >
199 <MessageSquarePlus size={12} />
200 </Button>
201 );
202}
203
204type FileProps = {
205 file: HighlightedFile;
206 comments: Comment[];
207 canComment: boolean;
208};
209
210function UnifiedRows({ file, comments, canComment }: FileProps) {
211 const [writing, setWriting] = useState<number | null>(null);
212 return (
213 <table className="w-full border-collapse font-mono text-xs leading-5">
214 <tbody>
215 {file.hunks.map((hunk, h) => (
216 <Fragment key={h}>
217 {h > 0 && <HunkGap colSpan={4} />}
218 {hunk.lines.map((line, index) => {
219 const here = comments.filter((c) => line.new != null && c.line === line.new);
220 const open = line.new != null && writing === line.new;
221 return (
222 <Fragment key={index}>
223 <tr className={`group ${ROW_STYLES[line.kind]}`}>
224 <td className={`w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${GUTTER_STYLES[line.kind]}`}>
225 {line.old}
226 </td>
227 <td
228 // A tap on the number comments on the line: a touch screen has no hover for the button.
229 onClick={canComment && line.new != null ? () => setWriting(open ? null : line.new) : undefined}
230 className={`relative w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${canComment && line.new != null ? "cursor-pointer" : ""} ${GUTTER_STYLES[line.kind]}`}
231 >
232 {line.new}
233 {canComment && line.new != null && (
234 <CommentButton
235 line={line.new}
236 onClick={(event) => {
237 event.stopPropagation();
238 setWriting(open ? null : line.new);
239 }}
240 />
241 )}
242 </td>
243 <td className={`w-4 text-center select-none sm:w-5 ${fileToneFor(line.kind)}`}>
244 {MARKERS[line.kind]}
245 </td>
246 <td className="pr-4 whitespace-pre">
247 <Code line={line} />
248 </td>
249 </tr>
250 {(here.length > 0 || open) && line.new != null && (
251 <tr>
252 <td colSpan={4} className="border-y border-line bg-surface p-3">
253 <Thread
254 path={file.path}
255 line={line.new}
256 comments={here}
257 open={open}
258 onClose={() => setWriting(null)}
259 />
260 </td>
261 </tr>
262 )}
263 </Fragment>
264 );
265 })}
266 </Fragment>
267 ))}
268 </tbody>
269 </table>
270 );
271}
272
273function fileToneFor(kind: DiffLine["kind"]) {
274 return kind === "add" ? "text-success" : kind === "delete" ? "text-danger" : "text-faint";
275}
276
277/** Lines side by side: a run of deletions beside the additions that replaced it. */
278function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] {
279 const rows: [HighlightedLine | null, HighlightedLine | null][] = [];
280 let index = 0;
281 while (index < lines.length) {
282 const line = lines[index]!;
283 if (line.kind === "context") {
284 rows.push([line, line]);
285 index++;
286 continue;
287 }
288 const removed: HighlightedLine[] = [];
289 const added: HighlightedLine[] = [];
290 while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!);
291 while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!);
292 for (let n = 0; n < Math.max(removed.length, added.length); n++) {
293 rows.push([removed[n] ?? null, added[n] ?? null]);
294 }
295 }
296 return rows;
297}
298
299function SplitRows({ file, comments, canComment }: FileProps) {
300 const [writing, setWriting] = useState<number | null>(null);
301 const side = (line: HighlightedLine | null, which: "old" | "new") => {
302 const kind = line ? (line.kind === "context" ? "context" : line.kind) : null;
303 const empty = "bg-surface/60";
304 return (
305 <>
306 <td
307 className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${
308 which === "new" ? "border-l border-line" : ""
309 }`}
310 >
311 {line?.[which]}
312 {which === "new" && canComment && line?.new != null && (
313 <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} />
314 )}
315 </td>
316 <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}>
317 <Code line={line} />
318 </td>
319 </>
320 );
321 };
322 return (
323 <table className="w-full table-fixed border-collapse font-mono text-xs leading-5">
324 <colgroup>
325 <col className="w-12" />
326 <col />
327 <col className="w-12" />
328 <col />
329 </colgroup>
330 <tbody>
331 {file.hunks.map((hunk, h) => (
332 <Fragment key={h}>
333 {h > 0 && <HunkGap colSpan={4} />}
334 {pairs(hunk.lines).map(([before, after], index) => {
335 const line = after?.new ?? null;
336 const here = comments.filter((c) => line != null && c.line === line);
337 const open = line != null && writing === line;
338 return (
339 <Fragment key={index}>
340 <tr className="group">
341 {side(before && before.kind !== "add" ? before : null, "old")}
342 {side(after && after.kind !== "delete" ? after : null, "new")}
343 </tr>
344 {(here.length > 0 || open) && line != null && (
345 <tr>
346 <td colSpan={4} className="border-y border-line bg-surface p-3">
347 <Thread
348 path={file.path}
349 line={line}
350 comments={here}
351 open={open}
352 onClose={() => setWriting(null)}
353 />
354 </td>
355 </tr>
356 )}
357 </Fragment>
358 );
359 })}
360 </Fragment>
361 ))}
362 </tbody>
363 </table>
364 );
365}
366
367function HunkGap({ colSpan }: { colSpan: number }) {
368 return (
369 <tr aria-hidden="true">
370 <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint">
371 ⋯
372 </td>
373 </tr>
374 );
375}
376
377function FileSection({
378 file,
379 review,
380 layout,
381 collapsed,
382 viewed,
383 onToggle,
384 onViewed,
385 fileBase,
386}: {
387 file: HighlightedFile;
388 review?: LineReview;
389 layout: Layout;
390 collapsed: boolean;
391 viewed: boolean;
392 onToggle: () => void;
393 onViewed: (viewed: boolean) => void;
394 /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */
395 fileBase?: string;
396}) {
397 const Icon = fileIcon(file.status);
398 const comments = review?.comments.filter((comment) => comment.path === file.path) ?? [];
399 const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)));
400 // Made against an earlier version of the change, on a line no longer in it.
401 const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line));
402 // Shown plain at once, and highlighted in the browser when it comes near
403 // the screen: the page never waits for colours.
404 const section = useRef<HTMLElement>(null);
405 const [highlighted, setHighlighted] = useState<{ content: string; html: FileHtml } | null>(null);
406 // What the colours depend on. A refresh hands the page a new `file` with
407 // the same lines: it keeps its colours rather than going plain and being
408 // highlighted again, which flashed every coloured diff on the page.
409 const content = useMemo(() => `${file.path}\n${diffContent(file)}`, [file]);
410 const latest = useRef(file);
411 latest.current = file;
412 const kept = highlighted?.content === content ? highlighted.html : null;
413 const drawn = useMemo(() => (kept ? withHtml(file, kept) : file), [file, kept]);
414 useEffect(() => {
415 const element = section.current;
416 const file = latest.current;
417 // Highlighted by the server already: one of the change's first files.
418 const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null));
419 if (!element || collapsed || done || kept) return;
420 let cancelled = false;
421 const observer = new IntersectionObserver(
422 ([entry]) => {
423 if (!entry?.isIntersecting) return;
424 observer.disconnect();
425 void import("../lib/shiki")
426 .then(({ highlightFile }) => highlightFile(file))
427 .then((result) => {
428 if (!cancelled && result) setHighlighted({ content, html: htmlOfFile(result) });
429 })
430 .catch(() => {});
431 },
432 { rootMargin: "600px 0px" },
433 );
434 observer.observe(element);
435 return () => {
436 cancelled = true;
437 observer.disconnect();
438 };
439 // `kept` is whether `content` is highlighted already: the lines, not the object they came in.
440 // eslint-disable-next-line react-hooks/exhaustive-deps
441 }, [content, collapsed, kept != null]);
442 const props = { file: drawn, comments, canComment: review?.canComment ?? false };
443 return (
444 <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]">
445 <section
446 ref={section}
447 id={`file-${file.path}`}
448 data-diff-file={file.path}
449 // Lands below the site bar and the change's toolbar, whatever their height.
450
451 >
452 <header
453 // Sticks under the change's toolbar (its height is --diff-toolbar), not behind it.
454 className={`sticky top-[calc(var(--topbar-h)+var(--diff-toolbar,0px))] z-20 flex items-center gap-2 border-line bg-surface/95 px-2 py-2 backdrop-blur sm:gap-2.5 sm:px-3 ${
455 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
456 }`}>
457 <Button
458 type="button"
459 onClick={onToggle}
460 aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`}
461 aria-expanded={!collapsed}
462 variant="ghost"
463 size="inline"
464 className="-m-1.5 rounded p-2 text-faint sm:m-0 sm:p-0.5"
465 >
466 {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}
467 </Button>
468 <Icon size={15} className={`hidden shrink-0 sm:block ${fileTone(file.status)}`} />
469 {/* A long path is cut at its start, so the file's own name shows. */}
470 <button
471 type="button"
472 onClick={onToggle}
473 dir="rtl"
474 className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`}
475 >
476 <bdi>{file.path}</bdi>
477 </button>
478 {comments.length > 0 && (
479 <span className="flex items-center gap-1 text-xs text-muted">
480 <MessageSquarePlus size={12} />
481 {comments.length}
482 </span>
483 )}
484 <Stat additions={file.additions} deletions={file.deletions} />
485 {fileBase && file.status !== "deleted" && (
486 <span className="hidden items-center gap-0.5 text-xs sm:flex">
487 <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg">
488 View
489 </Link>
490 <Link
491 to={`${fileBase}/${file.path}?blame=1`}
492 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
493 >
494 Blame
495 </Link>
496 </span>
497 )}
498 <label
499 className={`ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1.5 text-xs transition-colors select-none sm:py-0.5 ${
500 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
501 }`}
502 >
503 <Checkbox
504 checked={viewed}
505 onCheckedChange={(checked) => onViewed(checked === true)}
506 className="size-3.5 rounded-sm [&_svg]:size-2.5"
507 />
508 <span className="sr-only sm:not-sr-only">Viewed</span>
509 </label>
510 </header>
511 {!collapsed && (
512 <div className="overflow-hidden rounded-b-xl">
513 {outdated.length > 0 && (
514 <div className="space-y-2 border-b border-line bg-surface/50 p-3">
515 <p className="text-xs text-faint">On lines that have since changed</p>
516 {outdated.map((comment) => (
517 <LineComment key={comment.id} comment={comment} />
518 ))}
519 </div>
520 )}
521 {file.binary ? (
522 <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p>
523 ) : file.hunks.length === 0 ? (
524 <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p>
525 ) : (
526 <div className="overflow-x-auto">
527 {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />}
528 </div>
529 )}
530 </div>
531 )}
532 </section>
533 </Card>
534 );
535}
536
537type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile };
538
539/** The changed files as folders, with folders of one folder merged: `src/lib`. */
540function treeOf(files: HighlightedFile[]): TreeNode[] {
541 const root: TreeNode = { name: "", path: "", children: [] };
542 for (const file of files) {
543 const parts = file.path.split("/");
544 let node = root;
545 parts.forEach((part, index) => {
546 const path = parts.slice(0, index + 1).join("/");
547 let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1));
548 if (!child) {
549 child = { name: part, path, children: [] };
550 if (index === parts.length - 1) child.file = file;
551 node.children.push(child);
552 }
553 node = child;
554 });
555 }
556 const squash = (node: TreeNode): TreeNode => {
557 let current = node;
558 while (!current.file && current.children.length === 1 && !current.children[0]!.file) {
559 const only = current.children[0]!;
560 current = { ...only, name: `${current.name}/${only.name}` };
561 }
562 const children = current.children
563 .map(squash)
564 .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name));
565 return { ...current, children };
566 };
567 return squash(root).children;
568}
569
570function Tree({
571 nodes,
572 depth,
573 active,
574 viewed,
575 onPick,
576}: {
577 nodes: TreeNode[];
578 depth: number;
579 active: string | null;
580 viewed: Set<string>;
581 onPick: (path: string) => void;
582}) {
583 return (
584 <ul>
585 {nodes.map((node) => {
586 if (!node.file) {
587 return (
588 <li key={node.path}>
589 <p
590 className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint"
591 style={{ paddingLeft: depth * 12 + 8 }}
592 >
593 <Folder size={13} className="shrink-0" />
594 {node.name}
595 </p>
596 <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} />
597 </li>
598 );
599 }
600 const file = node.file;
601 const Icon = fileIcon(file.status);
602 return (
603 <li key={node.path}>
604 <button
605 type="button"
606 onClick={() => onPick(file.path)}
607 style={{ paddingLeft: depth * 12 + 8 }}
608 className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${
609 active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
610 } ${viewed.has(file.path) ? "opacity-55" : ""}`}
611 >
612 <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} />
613 <span className="min-w-0 grow truncate">{node.name}</span>
614 <span className="shrink-0 text-[0.625rem] tabular-nums">
615 {file.additions > 0 && <span className="text-success">+{file.additions}</span>}
616 {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>}
617 </span>
618 </button>
619 </li>
620 );
621 })}
622 </ul>
623 );
624}
625
626/**
627 * A change, file by file: a tree of what changed beside the diffs, unified
628 * or side by side, each file foldable and markable as viewed. `j` and `k`
629 * move between files and `t` filters them.
630 */
631export function DiffView({
632 comparison,
633 review,
634 empty,
635 fileBase,
636}: {
637 comparison: Comparison;
638 review?: LineReview;
639 /** What to say when nothing changed. */
640 empty?: React.ReactNode;
641 /** Where each file can be read as of the change, to link to it. */
642 fileBase?: string;
643}) {
644 const { files, truncated } = comparison;
645 const [chosenLayout, setLayout] = useState<Layout>("unified");
646 // Side by side has no room on a phone.
647 const narrow = useNarrow();
648 const layout: Layout = narrow ? "unified" : chosenLayout;
649 const [picking, setPicking] = useState(false);
650 const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
651 const [viewed, setViewed] = useState<Set<string>>(new Set());
652 const [active, setActive] = useState<string | null>(files[0]?.path ?? null);
653 const [filter, setFilter] = useState("");
654 const filterInput = useRef<HTMLInputElement>(null);
655 // The toolbar's height, as it wraps at each width, so the files' own
656 // sticky headers sit below it rather than under it.
657 const frame = useRef<HTMLDivElement>(null);
658 const toolbar = useRef<HTMLDivElement>(null);
659 const noFiles = files.length === 0;
660 useEffect(() => {
661 const bar = toolbar.current;
662 const root = frame.current;
663 if (!bar || !root) return;
664 const measure = () => root.style.setProperty("--diff-toolbar", `${bar.offsetHeight}px`);
665 measure();
666 if (typeof ResizeObserver === "undefined") return;
667 const observer = new ResizeObserver(measure);
668 observer.observe(bar);
669 return () => observer.disconnect();
670 }, [noFiles]);
671 const viewedKey = `g1t:viewed:${comparison.head}`;
672
673 // Preferences live in the browser: read them once it is there.
674 useEffect(() => {
675 if (stored("g1t:diff-layout") === "split") setLayout("split");
676 const saved = stored(viewedKey);
677 if (saved) {
678 try {
679 const paths = new Set<string>(JSON.parse(saved));
680 setViewed(paths);
681 setCollapsed(new Set(paths));
682 } catch {
683 // Something else wrote it.
684 }
685 }
686 }, [viewedKey]);
687
688 const changeLayout = (next: Layout) => {
689 setLayout(next);
690 store("g1t:diff-layout", next);
691 };
692 const markViewed = (path: string, on: boolean) => {
693 setViewed((previous) => {
694 const next = new Set(previous);
695 if (on) next.add(path);
696 else next.delete(path);
697 store(viewedKey, next.size ? JSON.stringify([...next]) : null);
698 return next;
699 });
700 // Marking a file viewed folds it away, as a reviewer moves on.
701 setCollapsed((previous) => {
702 const next = new Set(previous);
703 if (on) next.add(path);
704 else next.delete(path);
705 return next;
706 });
707 };
708 const toggle = (path: string) =>
709 setCollapsed((previous) => {
710 const next = new Set(previous);
711 if (next.has(path)) next.delete(path);
712 else next.add(path);
713 return next;
714 });
715
716 const shownFiles = useMemo(() => {
717 const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
718 return words.length === 0
719 ? files
720 : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word)));
721 }, [files, filter]);
722 const tree = useMemo(() => treeOf(shownFiles), [shownFiles]);
723
724 const jump = useCallback((path: string) => {
725 setCollapsed((previous) => {
726 if (!previous.has(path)) return previous;
727 const next = new Set(previous);
728 next.delete(path);
729 return next;
730 });
731 setActive(path);
732 requestAnimationFrame(() =>
733 document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: "smooth", block: "start" }),
734 );
735 }, []);
736
737 // The file at the top of the screen is the current one.
738 useEffect(() => {
739 const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")];
740 if (sections.length === 0) return;
741 const observer = new IntersectionObserver(
742 (entries) => {
743 const top = entries
744 .filter((entry) => entry.isIntersecting)
745 .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
746 if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null);
747 },
748 { rootMargin: "-112px 0px -60% 0px" },
749 );
750 sections.forEach((section) => observer.observe(section));
751 return () => observer.disconnect();
752 }, [shownFiles, collapsed]);
753
754 useEffect(() => {
755 const onKey = (event: KeyboardEvent) => {
756 const target = event.target as HTMLElement | null;
757 if (event.metaKey || event.ctrlKey || event.altKey) return;
758 if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
759 const index = shownFiles.findIndex((file) => file.path === active);
760 if (event.key === "j" || event.key === "k") {
761 const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))];
762 if (next) {
763 event.preventDefault();
764 jump(next.path);
765 }
766 } else if (event.key === "t") {
767 event.preventDefault();
768 filterInput.current?.focus();
769 } else if (event.key === "v" && active) {
770 event.preventDefault();
771 markViewed(active, !viewed.has(active));
772 }
773 };
774 window.addEventListener("keydown", onKey);
775 return () => window.removeEventListener("keydown", onKey);
776 });
777
778 if (files.length === 0) {
779 return (
780 <EmptyState title="No changes">
781 {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."}
782 </EmptyState>
783 );
784 }
785 const additions = files.reduce((sum, file) => sum + file.additions, 0);
786 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
787 const allCollapsed = files.every((file) => collapsed.has(file.path));
788 return (
789 <div ref={frame}>
790 <div ref={toolbar} className="sticky top-(--topbar-h) z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
791 <span className="text-sm text-muted">
792 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
793 </span>
794 <Stat additions={additions} deletions={deletions} />
795 <span className="flex items-center gap-2 text-xs text-muted">
796 <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line">
797 <span
798 className="block h-full rounded-full bg-accent transition-all"
799 style={{ width: `${(viewed.size / files.length) * 100}%` }}
800 />
801 </span>
802 {viewed.size}/{files.length} viewed
803 </span>
804 <div className="ml-auto flex items-center gap-2">
805 {files.length > 1 && (
806 // Below xl the tree is not beside the diffs: it opens as a sheet.
807 <Button
808 type="button"
809 onClick={() => setPicking(true)}
810 variant="outline"
811 size="inline"
812 className="flex min-h-9 px-2.5 text-xs text-muted xl:hidden font-normal"
813 >
814 <Files size={13} />
815 Files
816 </Button>
817 )}
818 <Button
819 type="button"
820 onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))}
821 variant="ghost"
822 size="inline"
823 className="px-2 py-1 text-xs font-normal"
824 >
825 {allCollapsed ? "Expand all" : "Collapse all"}
826 </Button>
827 <div className="hidden rounded-md border border-line p-0.5 sm:flex" role="radiogroup" aria-label="Layout">
828 {(
829 [
830 ["unified", <Rows3 key="u" size={13} />, "Unified"],
831 ["split", <Columns2 key="s" size={13} />, "Split"],
832 ] as const
833 ).map(([value, icon, label]) => (
834 <button
835 key={value}
836 type="button"
837 role="radio"
838 aria-checked={chosenLayout === value}
839 onClick={() => changeLayout(value)}
840 className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${
841 chosenLayout === value ? "bg-raised text-fg" : "text-muted hover:text-fg"
842 }`}
843 >
844 {icon}
845 {label}
846 </button>
847 ))}
848 </div>
849 </div>
850 </div>
851 {files.length > 1 && (
852 <Sheet open={picking} onOpenChange={setPicking}>
853 <SheetContent
854 side="left"
855 // No keyboard popping up over the list on a phone: the filter is a tap away.
856 onOpenAutoFocus={(event) => event.preventDefault()}
857 className="inset-x-0 top-auto bottom-0 h-[80dvh] max-w-none rounded-t-2xl pt-0 border-t border-r-0 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in sm:max-w-none xl:hidden"
858 >
859 <SheetHeader>
860 <SheetTitle>Files changed</SheetTitle>
861 <SheetDescription>
862 {files.length} files · {viewed.size} viewed
863 </SheetDescription>
864 </SheetHeader>
865 <div className="relative px-4 pt-3">
866 <Search size={14} className="pointer-events-none absolute top-1/2 left-6.5 mt-1.5 -translate-y-1/2 text-faint" />
867 <input
868 value={filter}
869 onChange={(event) => setFilter(event.target.value)}
870 placeholder="Filter files"
871 aria-label="Filter files"
872 autoComplete="off"
873 data-1p-ignore
874 className="h-10 w-full rounded-md border border-line bg-bg pr-3 pl-8 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
875 />
876 </div>
877 <nav aria-label="Files changed" className="min-h-0 grow overflow-y-auto px-2 py-2 [&_button]:min-h-10 [&_button]:text-[0.8125rem] [&_p]:min-h-8 [&_p]:text-[0.8125rem]">
878 <Tree
879 nodes={tree}
880 depth={0}
881 active={active}
882 viewed={viewed}
883 onPick={(path) => {
884 setPicking(false);
885 // Once the sheet has let the page go, so its scroll is not undone.
886 setTimeout(() => jump(path), 50);
887 }}
888 />
889 {shownFiles.length === 0 && <p className="px-2 py-6 text-center text-sm text-muted">No file matches.</p>}
890 </nav>
891 </SheetContent>
892 </Sheet>
893 )}
894 <div className="flex gap-4">
895 {files.length > 1 && (
896 <aside className="sticky top-[calc(var(--topbar-h)+3.5rem)] hidden max-h-[calc(100vh-var(--topbar-h)-4.5rem)] w-60 shrink-0 flex-col self-start xl:flex">
897 <div className="relative">
898 <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
899 <input
900 ref={filterInput}
901 value={filter}
902 onChange={(event) => setFilter(event.target.value)}
903 onKeyDown={(event) => {
904 if (event.key === "Escape") {
905 setFilter("");
906 event.currentTarget.blur();
907 } else if (event.key === "Enter" && shownFiles[0]) {
908 jump(shownFiles[0].path);
909 event.currentTarget.blur();
910 }
911 }}
912 placeholder="Filter files"
913 autoComplete="off"
914 data-1p-ignore
915 className="h-8 w-full rounded-md border border-line bg-surface/60 pr-7 pl-7 text-xs outline-none placeholder:text-faint focus:border-accent-dim"
916 />
917 <kbd className="absolute top-1/2 right-2 -translate-y-1/2 rounded border border-line px-1 font-mono text-[0.625rem] text-faint">
918 t
919 </kbd>
920 </div>
921 <nav className="mt-2 min-h-0 overflow-y-auto pb-2">
922 <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} />
923 </nav>
924 <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint">
925 <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "}
926 <kbd className="font-mono">v</kbd> mark viewed
927 </p>
928 </aside>
929 )}
930 <div className="min-w-0 grow space-y-3">
931 {review?.canComment && (
932 <p className="text-xs text-faint">
933 <span className="pointer-coarse:hidden">Hover a line and press the button beside its number to comment on it.</span>
934 <span className="hidden pointer-coarse:inline">Tap a line number to comment on it.</span>
935 </p>
936 )}
937 {shownFiles.map((file) => (
938 <FileSection
939 key={file.path}
940 file={file}
941 review={review}
942 layout={layout}
943 collapsed={collapsed.has(file.path)}
944 viewed={viewed.has(file.path)}
945 onToggle={() => toggle(file.path)}
946 onViewed={(on) => markViewed(file.path, on)}
947 fileBase={fileBase}
948 />
949 ))}
950 {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>}
951 {truncated && <p className="text-center text-sm text-muted">This change is too large to show in full.</p>}
952 </div>
953 </div>
954 </div>
955 );
956}