flagon-io/g1t

public

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

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

815 lines29,527 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";
25import { Checkbox } from "./ui/checkbox";
26
27const ROW_STYLES: Record<DiffLine["kind"], string> = {
28 context: "",
29 add: "bg-accent/[0.09]",
30 delete: "bg-danger/[0.09]",
31};
32
33const GUTTER_STYLES: Record<DiffLine["kind"], string> = {
34 context: "text-faint",
35 add: "bg-accent/[0.14] text-accent/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-accent">+{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-accent" : "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-accent" : 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 return (
138 <div className="max-w-2xl space-y-2">
139 {comments.map((comment) => (
140 <LineComment key={comment.id} comment={comment} />
141 ))}
142 {open && (
143 <Form method="post" className="space-y-2 font-sans" onSubmit={onClose}>
144 <input type="hidden" name="action" value="comment" />
145 <input type="hidden" name="path" value={path} />
146 <input type="hidden" name="line" value={line} />
147 <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
148 <div className="flex gap-2">
149 <Button type="submit">Comment</Button>
150 <Button variant="quiet" type="button" onClick={onClose}>
151 Cancel
152 </Button>
153 </div>
154 </Form>
155 )}
156 </div>
157 );
158}
159
160function CommentButton({ line, onClick }: { line: number; onClick: () => void }) {
161 return (
162 <button
163 type="button"
164 aria-label={`Comment on line ${line}`}
165 onClick={onClick}
166 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"
167 >
168 <MessageSquarePlus size={12} />
169 </button>
170 );
171}
172
173type FileProps = {
174 file: HighlightedFile;
175 comments: Comment[];
176 canComment: boolean;
177};
178
179function UnifiedRows({ file, comments, canComment }: FileProps) {
180 const [writing, setWriting] = useState<number | null>(null);
181 return (
182 <table className="w-full border-collapse font-mono text-xs leading-5">
183 <tbody>
184 {file.hunks.map((hunk, h) => (
185 <Fragment key={h}>
186 {h > 0 && <HunkGap colSpan={4} />}
187 {hunk.lines.map((line, index) => {
188 const here = comments.filter((c) => line.new != null && c.line === line.new);
189 const open = line.new != null && writing === line.new;
190 return (
191 <Fragment key={index}>
192 <tr className={`group ${ROW_STYLES[line.kind]}`}>
193 <td className={`w-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}>
194 {line.old}
195 </td>
196 <td className={`relative w-12 px-2 text-right select-none ${GUTTER_STYLES[line.kind]}`}>
197 {line.new}
198 {canComment && line.new != null && (
199 <CommentButton line={line.new} onClick={() => setWriting(open ? null : line.new)} />
200 )}
201 </td>
202 <td className={`w-5 text-center select-none ${fileToneFor(line.kind)}`}>
203 {MARKERS[line.kind]}
204 </td>
205 <td className="pr-4 whitespace-pre">
206 <Code line={line} />
207 </td>
208 </tr>
209 {(here.length > 0 || open) && line.new != null && (
210 <tr>
211 <td colSpan={4} className="border-y border-line bg-surface p-3">
212 <Thread
213 path={file.path}
214 line={line.new}
215 comments={here}
216 open={open}
217 onClose={() => setWriting(null)}
218 />
219 </td>
220 </tr>
221 )}
222 </Fragment>
223 );
224 })}
225 </Fragment>
226 ))}
227 </tbody>
228 </table>
229 );
230}
231
232function fileToneFor(kind: DiffLine["kind"]) {
233 return kind === "add" ? "text-accent" : kind === "delete" ? "text-danger" : "text-faint";
234}
235
236/** Lines side by side: a run of deletions beside the additions that replaced it. */
237function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] {
238 const rows: [HighlightedLine | null, HighlightedLine | null][] = [];
239 let index = 0;
240 while (index < lines.length) {
241 const line = lines[index]!;
242 if (line.kind === "context") {
243 rows.push([line, line]);
244 index++;
245 continue;
246 }
247 const removed: HighlightedLine[] = [];
248 const added: HighlightedLine[] = [];
249 while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!);
250 while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!);
251 for (let n = 0; n < Math.max(removed.length, added.length); n++) {
252 rows.push([removed[n] ?? null, added[n] ?? null]);
253 }
254 }
255 return rows;
256}
257
258function SplitRows({ file, comments, canComment }: FileProps) {
259 const [writing, setWriting] = useState<number | null>(null);
260 const side = (line: HighlightedLine | null, which: "old" | "new") => {
261 const kind = line ? (line.kind === "context" ? "context" : line.kind) : null;
262 const empty = "bg-surface/60";
263 return (
264 <>
265 <td
266 className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${
267 which === "new" ? "border-l border-line" : ""
268 }`}
269 >
270 {line?.[which]}
271 {which === "new" && canComment && line?.new != null && (
272 <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} />
273 )}
274 </td>
275 <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}>
276 <Code line={line} />
277 </td>
278 </>
279 );
280 };
281 return (
282 <table className="w-full table-fixed border-collapse font-mono text-xs leading-5">
283 <colgroup>
284 <col className="w-12" />
285 <col />
286 <col className="w-12" />
287 <col />
288 </colgroup>
289 <tbody>
290 {file.hunks.map((hunk, h) => (
291 <Fragment key={h}>
292 {h > 0 && <HunkGap colSpan={4} />}
293 {pairs(hunk.lines).map(([before, after], index) => {
294 const line = after?.new ?? null;
295 const here = comments.filter((c) => line != null && c.line === line);
296 const open = line != null && writing === line;
297 return (
298 <Fragment key={index}>
299 <tr className="group">
300 {side(before && before.kind !== "add" ? before : null, "old")}
301 {side(after && after.kind !== "delete" ? after : null, "new")}
302 </tr>
303 {(here.length > 0 || open) && line != null && (
304 <tr>
305 <td colSpan={4} className="border-y border-line bg-surface p-3">
306 <Thread
307 path={file.path}
308 line={line}
309 comments={here}
310 open={open}
311 onClose={() => setWriting(null)}
312 />
313 </td>
314 </tr>
315 )}
316 </Fragment>
317 );
318 })}
319 </Fragment>
320 ))}
321 </tbody>
322 </table>
323 );
324}
325
326function HunkGap({ colSpan }: { colSpan: number }) {
327 return (
328 <tr aria-hidden="true">
329 <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint">
330 ⋯
331 </td>
332 </tr>
333 );
334}
335
336function FileSection({
337 file,
338 review,
339 layout,
340 collapsed,
341 viewed,
342 onToggle,
343 onViewed,
344 fileBase,
345}: {
346 file: HighlightedFile;
347 review?: LineReview;
348 layout: Layout;
349 collapsed: boolean;
350 viewed: boolean;
351 onToggle: () => void;
352 onViewed: (viewed: boolean) => void;
353 /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */
354 fileBase?: string;
355}) {
356 const Icon = fileIcon(file.status);
357 const comments = review?.comments.filter((comment) => comment.path === file.path) ?? [];
358 const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)));
359 // Made against an earlier version of the change, on a line no longer in it.
360 const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line));
361 // Shown plain at once, and highlighted in the browser when it comes near
362 // the screen: the page never waits for colours.
363 const section = useRef<HTMLElement>(null);
364 const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null);
365 useEffect(() => {
366 setHighlighted(null);
367 const element = section.current;
368 if (!element || collapsed) return;
369 let cancelled = false;
370 const observer = new IntersectionObserver(
371 ([entry]) => {
372 if (!entry?.isIntersecting) return;
373 observer.disconnect();
374 void import("../lib/shiki")
375 .then(({ highlightFile }) => highlightFile(file))
376 .then((result) => {
377 if (!cancelled && result) setHighlighted(result);
378 })
379 .catch(() => {});
380 },
381 { rootMargin: "600px 0px" },
382 );
383 observer.observe(element);
384 return () => {
385 cancelled = true;
386 observer.disconnect();
387 };
388 }, [file, collapsed]);
389 const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false };
390 return (
391 <section
392 ref={section}
393 id={`file-${file.path}`}
394 data-diff-file={file.path}
395 className="scroll-mt-28 rounded-xl border border-line"
396 >
397 <header
398 className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
399 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
400 }`}
401 >
402 <button
403 type="button"
404 onClick={onToggle}
405 aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`}
406 aria-expanded={!collapsed}
407 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
408 >
409 {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}
410 </button>
411 <Icon size={15} className={fileTone(file.status)} />
412 <button
413 type="button"
414 onClick={onToggle}
415 className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`}
416 >
417 {file.path}
418 </button>
419 {comments.length > 0 && (
420 <span className="flex items-center gap-1 text-xs text-muted">
421 <MessageSquarePlus size={12} />
422 {comments.length}
423 </span>
424 )}
425 <Stat additions={file.additions} deletions={file.deletions} />
426 {fileBase && file.status !== "deleted" && (
427 <span className="hidden items-center gap-0.5 text-xs sm:flex">
428 <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg">
429 View
430 </Link>
431 <Link
432 to={`${fileBase}/${file.path}?blame=1`}
433 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
434 >
435 Blame
436 </Link>
437 </span>
438 )}
439 <label
440 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 ${
441 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
442 }`}
443 >
444 <Checkbox
445 checked={viewed}
446 onCheckedChange={(checked) => onViewed(checked === true)}
447 className="size-3.5 rounded-[4px] [&_svg]:size-2.5"
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-16 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}