Skip to content

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

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