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

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