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