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