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