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