Skip to content

g1t/apps/web/app/components/diff-view.tsx

826 lines30,177 bytesCodeBlame
1import {
2 ChevronDown,
3 ChevronRight,
4 Columns2,
5 FileDiff as FileIcon,
6 FileMinus,
7 FilePlus,
8 Folder,
9 MessageSquarePlus,
10 Rows3,
11 Search,
12} from "lucide-react";
13import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
14import { Form, Link } from "react-router";
15
16import type { Comment, Comparison, DiffLine } from "@g1t/contracts";
17
18import type {
19 HighlightedComparison,
20 HighlightedFile,
21 HighlightedLine,
22} from "../lib/diff";
23import { Markdown } from "./markdown";
24import { Avatar, Button, EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
25import { Checkbox } from "./ui/checkbox";
26
27const ROW_STYLES: Record<DiffLine["kind"], string> = {
28 context: "",
29 add: "bg-success/[0.09]",
30 delete: "bg-danger/[0.09]",
31};
32
33const GUTTER_STYLES: Record<DiffLine["kind"], string> = {
34 context: "text-faint",
35 add: "bg-success/[0.14] text-success/70",
36 delete: "bg-danger/[0.14] text-danger/70",
37};
38
39const MARKERS: Record<DiffLine["kind"], string> = {
40 context: " ",
41 add: "+",
42 delete: "−",
43};
44
45/** Comments on lines of the change, and whether the viewer may add one. */
46export type LineReview = { comments: Comment[]; canComment: boolean };
47
48type Layout = "unified" | "split";
49
50/** Reads a per-viewer preference; storage can be missing or refuse. */
51function stored(key: string): string | null {
52 try {
53 return window.localStorage.getItem(key);
54 } catch {
55 return null;
56 }
57}
58
59function store(key: string, value: string | null) {
60 try {
61 if (value == null) window.localStorage.removeItem(key);
62 else window.localStorage.setItem(key, value);
63 } catch {
64 // A private window: the preference lasts until the page is left.
65 }
66}
67
68/** `+12 −3` with a five-block bar. */
69export function Stat({ additions, deletions }: { additions: number; deletions: number }) {
70 const total = additions + deletions;
71 const green = total === 0 ? 0 : Math.round((additions / total) * 5);
72 return (
73 <span className="flex shrink-0 items-center gap-2 font-mono text-xs">
74 <span className="text-success">+{additions}</span>
75 <span className="text-danger">−{deletions}</span>
76 <span className="flex gap-px" aria-hidden="true">
77 {Array.from({ length: 5 }, (_, i) => (
78 <span
79 key={i}
80 className={`size-2 rounded-xs ${
81 total === 0 ? "bg-line" : i < green ? "bg-success" : "bg-danger"
82 }`}
83 />
84 ))}
85 </span>
86 </span>
87 );
88}
89
90function fileIcon(status: HighlightedFile["status"]) {
91 return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon;
92}
93
94function fileTone(status: HighlightedFile["status"]) {
95 return status === "added" ? "text-success" : status === "deleted" ? "text-danger" : "text-faint";
96}
97
98/** One comment made on a line, shown under that line. */
99function LineComment({ comment }: { comment: Comment }) {
100 return (
101 <div className="rounded-lg border border-line bg-bg font-sans">
102 <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted">
103 <Avatar name={comment.author.username} size={16} />
104 <span className="font-medium text-fg">{comment.author.username}</span>
105 <TimeAgo at={comment.createdAt} />
106 </p>
107 <div className="px-3 py-2 text-sm">
108 <Markdown source={comment.body} />
109 </div>
110 </div>
111 );
112}
113
114/** A line's text, highlighted when the server could. */
115function Code({ line }: { line: HighlightedLine | null }) {
116 if (!line) return null;
117 if (line.html != null) {
118 return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />;
119 }
120 return <>{line.text || " "}</>;
121}
122
123/** The comments on a line and, when open, the box to add one. */
124function Thread({
125 path,
126 line,
127 comments,
128 open,
129 onClose,
130}: {
131 path: string;
132 line: number;
133 comments: Comment[];
134 open: boolean;
135 onClose: () => void;
136}) {
137 // The box stays, saying it is posting, until the new comment shows above it.
138 const count = useRef(comments.length);
139 useEffect(() => {
140 if (comments.length > count.current) onClose();
141 count.current = comments.length;
142 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it
143 }, [comments.length]);
144 return (
145 <div className="max-w-2xl space-y-2">
146 {comments.map((comment) => (
147 <LineComment key={comment.id} comment={comment} />
148 ))}
149 {open && (
150 <Form method="post" className="space-y-2 font-sans">
151 <input type="hidden" name="action" value="comment" />
152 <input type="hidden" name="path" value={path} />
153 <input type="hidden" name="line" value={line} />
154 <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
155 <div className="flex gap-2">
156 <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…">
157 Comment
158 </SubmitButton>
159 <Button variant="quiet" type="button" onClick={onClose}>
160 Cancel
161 </Button>
162 </div>
163 </Form>
164 )}
165 </div>
166 );
167}
168
169function CommentButton({ line, onClick }: { line: number; onClick: () => void }) {
170 return (
171 <button
172 type="button"
173 aria-label={`Comment on line ${line}`}
174 onClick={onClick}
175 className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex"
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-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}>
203 {line.old}
204 </td>
205 <td className={`relative w-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}>
206 {line.new}
207 {canComment && line.new != null && (
208 <CommentButton line={line.new} onClick={() => setWriting(open ? null : line.new)} />
209 )}
210 </td>
211 <td className={`w-5 text-center select-none ${fileToneFor(line.kind)}`}>
212 {MARKERS[line.kind]}
213 </td>
214 <td className="pr-4 whitespace-pre">
215 <Code line={line} />
216 </td>
217 </tr>
218 {(here.length > 0 || open) && line.new != null && (
219 <tr>
220 <td colSpan={4} className="border-y border-line bg-surface p-3">
221 <Thread
222 path={file.path}
223 line={line.new}
224 comments={here}
225 open={open}
226 onClose={() => setWriting(null)}
227 />
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
316 path={file.path}
317 line={line}
318 comments={here}
319 open={open}
320 onClose={() => setWriting(null)}
321 />
322 </td>
323 </tr>
324 )}
325 </Fragment>
326 );
327 })}
328 </Fragment>
329 ))}
330 </tbody>
331 </table>
332 );
333}
334
335function HunkGap({ colSpan }: { colSpan: number }) {
336 return (
337 <tr aria-hidden="true">
338 <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint">
339 ⋯
340 </td>
341 </tr>
342 );
343}
344
345function FileSection({
346 file,
347 review,
348 layout,
349 collapsed,
350 viewed,
351 onToggle,
352 onViewed,
353 fileBase,
354}: {
355 file: HighlightedFile;
356 review?: LineReview;
357 layout: Layout;
358 collapsed: boolean;
359 viewed: boolean;
360 onToggle: () => void;
361 onViewed: (viewed: boolean) => void;
362 /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */
363 fileBase?: string;
364}) {
365 const Icon = fileIcon(file.status);
366 const comments = review?.comments.filter((comment) => comment.path === file.path) ?? [];
367 const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)));
368 // Made against an earlier version of the change, on a line no longer in it.
369 const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line));
370 // Shown plain at once, and highlighted in the browser when it comes near
371 // the screen: the page never waits for colours.
372 const section = useRef<HTMLElement>(null);
373 const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null);
374 useEffect(() => {
375 setHighlighted(null);
376 const element = section.current;
377 // Highlighted by the server already: one of the change's first files.
378 const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null));
379 if (!element || collapsed || done) return;
380 let cancelled = false;
381 const observer = new IntersectionObserver(
382 ([entry]) => {
383 if (!entry?.isIntersecting) return;
384 observer.disconnect();
385 void import("../lib/shiki")
386 .then(({ highlightFile }) => highlightFile(file))
387 .then((result) => {
388 if (!cancelled && result) setHighlighted(result);
389 })
390 .catch(() => {});
391 },
392 { rootMargin: "600px 0px" },
393 );
394 observer.observe(element);
395 return () => {
396 cancelled = true;
397 observer.disconnect();
398 };
399 }, [file, collapsed]);
400 const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false };
401 return (
402 <section
403 ref={section}
404 id={`file-${file.path}`}
405 data-diff-file={file.path}
406 className="scroll-mt-28 rounded-xl border border-line"
407 >
408 <header
409 className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
410 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
411 }`}
412 >
413 <button
414 type="button"
415 onClick={onToggle}
416 aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`}
417 aria-expanded={!collapsed}
418 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
419 >
420 {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}
421 </button>
422 <Icon size={15} className={fileTone(file.status)} />
423 <button
424 type="button"
425 onClick={onToggle}
426 className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`}
427 >
428 {file.path}
429 </button>
430 {comments.length > 0 && (
431 <span className="flex items-center gap-1 text-xs text-muted">
432 <MessageSquarePlus size={12} />
433 {comments.length}
434 </span>
435 )}
436 <Stat additions={file.additions} deletions={file.deletions} />
437 {fileBase && file.status !== "deleted" && (
438 <span className="hidden items-center gap-0.5 text-xs sm:flex">
439 <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg">
440 View
441 </Link>
442 <Link
443 to={`${fileBase}/${file.path}?blame=1`}
444 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
445 >
446 Blame
447 </Link>
448 </span>
449 )}
450 <label
451 className={`ml-1 flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs transition-colors select-none ${
452 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
453 }`}
454 >
455 <Checkbox
456 checked={viewed}
457 onCheckedChange={(checked) => onViewed(checked === true)}
458 className="size-3.5 rounded-[4px] [&_svg]:size-2.5"
459 />
460 Viewed
461 </label>
462 </header>
463 {!collapsed && (
464 <div className="overflow-hidden rounded-b-xl">
465 {outdated.length > 0 && (
466 <div className="space-y-2 border-b border-line bg-surface/50 p-3">
467 <p className="text-xs text-faint">On lines that have since changed</p>
468 {outdated.map((comment) => (
469 <LineComment key={comment.id} comment={comment} />
470 ))}
471 </div>
472 )}
473 {file.binary ? (
474 <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p>
475 ) : file.hunks.length === 0 ? (
476 <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p>
477 ) : (
478 <div className="overflow-x-auto">
479 {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />}
480 </div>
481 )}
482 </div>
483 )}
484 </section>
485 );
486}
487
488type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile };
489
490/** The changed files as folders, with folders of one folder merged: `src/lib`. */
491function treeOf(files: HighlightedFile[]): TreeNode[] {
492 const root: TreeNode = { name: "", path: "", children: [] };
493 for (const file of files) {
494 const parts = file.path.split("/");
495 let node = root;
496 parts.forEach((part, index) => {
497 const path = parts.slice(0, index + 1).join("/");
498 let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1));
499 if (!child) {
500 child = { name: part, path, children: [] };
501 if (index === parts.length - 1) child.file = file;
502 node.children.push(child);
503 }
504 node = child;
505 });
506 }
507 const squash = (node: TreeNode): TreeNode => {
508 let current = node;
509 while (!current.file && current.children.length === 1 && !current.children[0]!.file) {
510 const only = current.children[0]!;
511 current = { ...only, name: `${current.name}/${only.name}` };
512 }
513 const children = current.children
514 .map(squash)
515 .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name));
516 return { ...current, children };
517 };
518 return squash(root).children;
519}
520
521function Tree({
522 nodes,
523 depth,
524 active,
525 viewed,
526 onPick,
527}: {
528 nodes: TreeNode[];
529 depth: number;
530 active: string | null;
531 viewed: Set<string>;
532 onPick: (path: string) => void;
533}) {
534 return (
535 <ul>
536 {nodes.map((node) => {
537 if (!node.file) {
538 return (
539 <li key={node.path}>
540 <p
541 className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint"
542 style={{ paddingLeft: depth * 12 + 8 }}
543 >
544 <Folder size={13} className="shrink-0" />
545 {node.name}
546 </p>
547 <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} />
548 </li>
549 );
550 }
551 const file = node.file;
552 const Icon = fileIcon(file.status);
553 return (
554 <li key={node.path}>
555 <button
556 type="button"
557 onClick={() => onPick(file.path)}
558 style={{ paddingLeft: depth * 12 + 8 }}
559 className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${
560 active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
561 } ${viewed.has(file.path) ? "opacity-55" : ""}`}
562 >
563 <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} />
564 <span className="min-w-0 grow truncate">{node.name}</span>
565 <span className="shrink-0 text-[0.625rem] tabular-nums">
566 {file.additions > 0 && <span className="text-success">+{file.additions}</span>}
567 {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>}
568 </span>
569 </button>
570 </li>
571 );
572 })}
573 </ul>
574 );
575}
576
577/**
578 * A change, file by file: a tree of what changed beside the diffs, unified
579 * or side by side, each file foldable and markable as viewed. `j` and `k`
580 * move between files and `t` filters them.
581 */
582export function DiffView({
583 comparison,
584 review,
585 empty,
586 fileBase,
587}: {
588 comparison: Comparison;
589 review?: LineReview;
590 /** What to say when nothing changed. */
591 empty?: React.ReactNode;
592 /** Where each file can be read as of the change, to link to it. */
593 fileBase?: string;
594}) {
595 const { files, truncated } = comparison;
596 const [layout, setLayout] = useState<Layout>("unified");
597 const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
598 const [viewed, setViewed] = useState<Set<string>>(new Set());
599 const [active, setActive] = useState<string | null>(files[0]?.path ?? null);
600 const [filter, setFilter] = useState("");
601 const filterInput = useRef<HTMLInputElement>(null);
602 const viewedKey = `g1t:viewed:${comparison.head}`;
603
604 // Preferences live in the browser: read them once it is there.
605 useEffect(() => {
606 if (stored("g1t:diff-layout") === "split") setLayout("split");
607 const saved = stored(viewedKey);
608 if (saved) {
609 try {
610 const paths = new Set<string>(JSON.parse(saved));
611 setViewed(paths);
612 setCollapsed(new Set(paths));
613 } catch {
614 // Something else wrote it.
615 }
616 }
617 }, [viewedKey]);
618
619 const changeLayout = (next: Layout) => {
620 setLayout(next);
621 store("g1t:diff-layout", next);
622 };
623 const markViewed = (path: string, on: boolean) => {
624 setViewed((previous) => {
625 const next = new Set(previous);
626 if (on) next.add(path);
627 else next.delete(path);
628 store(viewedKey, next.size ? JSON.stringify([...next]) : null);
629 return next;
630 });
631 // Marking a file viewed folds it away, as a reviewer moves on.
632 setCollapsed((previous) => {
633 const next = new Set(previous);
634 if (on) next.add(path);
635 else next.delete(path);
636 return next;
637 });
638 };
639 const toggle = (path: string) =>
640 setCollapsed((previous) => {
641 const next = new Set(previous);
642 if (next.has(path)) next.delete(path);
643 else next.add(path);
644 return next;
645 });
646
647 const shownFiles = useMemo(() => {
648 const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
649 return words.length === 0
650 ? files
651 : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word)));
652 }, [files, filter]);
653 const tree = useMemo(() => treeOf(shownFiles), [shownFiles]);
654
655 const jump = useCallback((path: string) => {
656 setCollapsed((previous) => {
657 if (!previous.has(path)) return previous;
658 const next = new Set(previous);
659 next.delete(path);
660 return next;
661 });
662 setActive(path);
663 requestAnimationFrame(() =>
664 document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: "smooth", block: "start" }),
665 );
666 }, []);
667
668 // The file at the top of the screen is the current one.
669 useEffect(() => {
670 const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")];
671 if (sections.length === 0) return;
672 const observer = new IntersectionObserver(
673 (entries) => {
674 const top = entries
675 .filter((entry) => entry.isIntersecting)
676 .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
677 if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null);
678 },
679 { rootMargin: "-112px 0px -60% 0px" },
680 );
681 sections.forEach((section) => observer.observe(section));
682 return () => observer.disconnect();
683 }, [shownFiles, collapsed]);
684
685 useEffect(() => {
686 const onKey = (event: KeyboardEvent) => {
687 const target = event.target as HTMLElement | null;
688 if (event.metaKey || event.ctrlKey || event.altKey) return;
689 if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
690 const index = shownFiles.findIndex((file) => file.path === active);
691 if (event.key === "j" || event.key === "k") {
692 const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))];
693 if (next) {
694 event.preventDefault();
695 jump(next.path);
696 }
697 } else if (event.key === "t") {
698 event.preventDefault();
699 filterInput.current?.focus();
700 } else if (event.key === "v" && active) {
701 event.preventDefault();
702 markViewed(active, !viewed.has(active));
703 }
704 };
705 window.addEventListener("keydown", onKey);
706 return () => window.removeEventListener("keydown", onKey);
707 });
708
709 if (files.length === 0) {
710 return (
711 <EmptyState title="No changes">
712 {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."}
713 </EmptyState>
714 );
715 }
716 const additions = files.reduce((sum, file) => sum + file.additions, 0);
717 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
718 const allCollapsed = files.every((file) => collapsed.has(file.path));
719 return (
720 <div>
721 <div className="sticky top-14 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">
722 <span className="text-sm text-muted">
723 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
724 </span>
725 <Stat additions={additions} deletions={deletions} />
726 <span className="flex items-center gap-2 text-xs text-muted">
727 <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line">
728 <span
729 className="block h-full rounded-full bg-accent transition-all"
730 style={{ width: `${(viewed.size / files.length) * 100}%` }}
731 />
732 </span>
733 {viewed.size}/{files.length} viewed
734 </span>
735 <div className="ml-auto flex items-center gap-2">
736 <button
737 type="button"
738 onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))}
739 className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg"
740 >
741 {allCollapsed ? "Expand all" : "Collapse all"}
742 </button>
743 <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Layout">
744 {(
745 [
746 ["unified", <Rows3 key="u" size={13} />, "Unified"],
747 ["split", <Columns2 key="s" size={13} />, "Split"],
748 ] as const
749 ).map(([value, icon, label]) => (
750 <button
751 key={value}
752 type="button"
753 role="radio"
754 aria-checked={layout === value}
755 onClick={() => changeLayout(value)}
756 className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${
757 layout === value ? "bg-raised text-fg" : "text-muted hover:text-fg"
758 }`}
759 >
760 {icon}
761 {label}
762 </button>
763 ))}
764 </div>
765 </div>
766 </div>
767 <div className="flex gap-4">
768 {files.length > 1 && (
769 <aside className="sticky top-28 hidden max-h-[calc(100vh-8rem)] w-60 shrink-0 flex-col self-start xl:flex">
770 <div className="relative">
771 <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
772 <input
773 ref={filterInput}
774 value={filter}
775 onChange={(event) => setFilter(event.target.value)}
776 onKeyDown={(event) => {
777 if (event.key === "Escape") {
778 setFilter("");
779 event.currentTarget.blur();
780 } else if (event.key === "Enter" && shownFiles[0]) {
781 jump(shownFiles[0].path);
782 event.currentTarget.blur();
783 }
784 }}
785 placeholder="Filter files"
786 autoComplete="off"
787 data-1p-ignore
788 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"
789 />
790 <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">
791 t
792 </kbd>
793 </div>
794 <nav className="mt-2 min-h-0 overflow-y-auto pb-2">
795 <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} />
796 </nav>
797 <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint">
798 <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "}
799 <kbd className="font-mono">v</kbd> mark viewed
800 </p>
801 </aside>
802 )}
803 <div className="min-w-0 grow space-y-3">
804 {review?.canComment && (
805 <p className="text-xs text-faint">Hover a line and press the button beside its number to comment on it.</p>
806 )}
807 {shownFiles.map((file) => (
808 <FileSection
809 key={file.path}
810 file={file}
811 review={review}
812 layout={layout}
813 collapsed={collapsed.has(file.path)}
814 viewed={viewed.has(file.path)}
815 onToggle={() => toggle(file.path)}
816 onViewed={(on) => markViewed(file.path, on)}
817 fileBase={fileBase}
818 />
819 ))}
820 {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>}
821 {truncated && <p className="text-center text-sm text-muted">This change is too large to show in full.</p>}
822 </div>
823 </div>
824 </div>
825 );
826}