Skip to content
942 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Say why git over SSH is not on yet1import {
2 ChevronDown,
3 ChevronRight,
4 Columns2,
5 FileDiff as FileIcon,
6 FileMinus,
7 FilePlus,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)8 Files,
Say why git over SSH is not on yet9 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 } from "@g1t/contracts";
18
19import type {
20 HighlightedFile,
21 HighlightedLine,
22} from "../lib/diff";
23import { Markdown } from "./markdown";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.24import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
25import { Avatar } from "./ui/avatar";
26import { Button } from "./ui/button";
27import { Card } from "./ui/card";
Say why git over SSH is not on yet28import { Checkbox } from "./ui/checkbox";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)29import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet";
Say why git over SSH is not on yet30
31const ROW_STYLES: Record<DiffLine["kind"], string> = {
32 context: "",
Merge branch 'worktree-agent-ad7c6d88d93adc817'33 add: "bg-success/[0.09]",
Say why git over SSH is not on yet34 delete: "bg-danger/[0.09]",
35};
36
37const GUTTER_STYLES: Record<DiffLine["kind"], string> = {
38 context: "text-faint",
Merge branch 'worktree-agent-ad7c6d88d93adc817'39 add: "bg-success/[0.14] text-success/70",
Say why git over SSH is not on yet40 delete: "bg-danger/[0.14] text-danger/70",
41};
42
43const MARKERS: Record<DiffLine["kind"], string> = {
44 context: " ",
45 add: "+",
46 delete: "−",
47};
48
49/** Comments on lines of the change, and whether the viewer may add one. */
50export type LineReview = { comments: Comment[]; canComment: boolean };
51
52type Layout = "unified" | "split";
53
54/** Reads a per-viewer preference; storage can be missing or refuse. */
55function stored(key: string): string | null {
56 try {
57 return window.localStorage.getItem(key);
58 } catch {
59 return null;
60 }
61}
62
63function store(key: string, value: string | null) {
64 try {
65 if (value == null) window.localStorage.removeItem(key);
66 else window.localStorage.setItem(key, value);
67 } catch {
68 // A private window: the preference lasts until the page is left.
69 }
70}
71
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)72/** Whether the screen is phone-sized: a change is shown unified there, never split. */
73function useNarrow(): boolean {
74 const [narrow, setNarrow] = useState(false);
75 useEffect(() => {
76 const query = window.matchMedia("(max-width: 639px)");
77 const update = () => setNarrow(query.matches);
78 update();
79 query.addEventListener("change", update);
80 return () => query.removeEventListener("change", update);
81 }, []);
82 return narrow;
83}
84
85/** `+12 −3` with a five-block bar (the bar from `sm` up). */
Say why git over SSH is not on yet86export function Stat({ additions, deletions }: { additions: number; deletions: number }) {
87 const total = additions + deletions;
88 const green = total === 0 ? 0 : Math.round((additions / total) * 5);
89 return (
90 <span className="flex shrink-0 items-center gap-2 font-mono text-xs">
Merge branch 'worktree-agent-ad7c6d88d93adc817'91 <span className="text-success">+{additions}</span>
Say why git over SSH is not on yet92 <span className="text-danger">−{deletions}</span>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)93 <span className="hidden gap-px sm:flex" aria-hidden="true">
Say why git over SSH is not on yet94 {Array.from({ length: 5 }, (_, i) => (
95 <span
96 key={i}
97 className={`size-2 rounded-xs ${
Merge branch 'worktree-agent-ad7c6d88d93adc817'98 total === 0 ? "bg-line" : i < green ? "bg-success" : "bg-danger"
Say why git over SSH is not on yet99 }`}
100 />
101 ))}
102 </span>
103 </span>
104 );
105}
106
107function fileIcon(status: HighlightedFile["status"]) {
108 return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon;
109}
110
111function fileTone(status: HighlightedFile["status"]) {
Merge branch 'worktree-agent-ad7c6d88d93adc817'112 return status === "added" ? "text-success" : status === "deleted" ? "text-danger" : "text-faint";
Say why git over SSH is not on yet113}
114
115/** One comment made on a line, shown under that line. */
116function LineComment({ comment }: { comment: Comment }) {
117 return (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.118 <Card tone="bg" radius="lg" className="font-sans">
Say why git over SSH is not on yet119 <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted">
120 <Avatar name={comment.author.username} size={16} />
121 <span className="font-medium text-fg">{comment.author.username}</span>
122 <TimeAgo at={comment.createdAt} />
123 </p>
124 <div className="px-3 py-2 text-sm">
125 <Markdown source={comment.body} />
126 </div>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.127 </Card>
Say why git over SSH is not on yet128 );
129}
130
131/** A line's text, highlighted when the server could. */
132function Code({ line }: { line: HighlightedLine | null }) {
133 if (!line) return null;
134 if (line.html != null) {
135 return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />;
136 }
137 return <>{line.text || " "}</>;
138}
139
140/** The comments on a line and, when open, the box to add one. */
141function Thread({
142 path,
143 line,
144 comments,
145 open,
146 onClose,
147}: {
148 path: string;
149 line: number;
150 comments: Comment[];
151 open: boolean;
152 onClose: () => void;
153}) {
Merge branch 'worktree-agent-a985c01f43538af01'154 // The box stays, saying it is posting, until the new comment shows above it.
155 const count = useRef(comments.length);
156 useEffect(() => {
157 if (comments.length > count.current) onClose();
158 count.current = comments.length;
159 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it
160 }, [comments.length]);
Say why git over SSH is not on yet161 return (
162 <div className="max-w-2xl space-y-2">
163 {comments.map((comment) => (
164 <LineComment key={comment.id} comment={comment} />
165 ))}
166 {open && (
Merge branch 'worktree-agent-a985c01f43538af01'167 <Form method="post" className="space-y-2 font-sans">
Say why git over SSH is not on yet168 <input type="hidden" name="action" value="comment" />
169 <input type="hidden" name="path" value={path} />
170 <input type="hidden" name="line" value={line} />
171 <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
172 <div className="flex gap-2">
Merge branch 'worktree-agent-a985c01f43538af01'173 <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…">
174 Comment
175 </SubmitButton>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.176 <Button variant="outline" type="button" onClick={onClose}>
Say why git over SSH is not on yet177 Cancel
178 </Button>
179 </div>
180 </Form>
181 )}
182 </div>
183 );
184}
185
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)186function CommentButton({ line, onClick }: { line: number; onClick: (event: React.MouseEvent) => void }) {
Say why git over SSH is not on yet187 return (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.188 <Button
Say why git over SSH is not on yet189 type="button"
190 aria-label={`Comment on line ${line}`}
191 onClick={onClick}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.192 variant="accent" size="icon"
193 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"
Say why git over SSH is not on yet194 >
195 <MessageSquarePlus size={12} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.196 </Button>
Say why git over SSH is not on yet197 );
198}
199
200type FileProps = {
201 file: HighlightedFile;
202 comments: Comment[];
203 canComment: boolean;
204};
205
206function UnifiedRows({ file, comments, canComment }: FileProps) {
207 const [writing, setWriting] = useState<number | null>(null);
208 return (
209 <table className="w-full border-collapse font-mono text-xs leading-5">
210 <tbody>
211 {file.hunks.map((hunk, h) => (
212 <Fragment key={h}>
213 {h > 0 && <HunkGap colSpan={4} />}
214 {hunk.lines.map((line, index) => {
215 const here = comments.filter((c) => line.new != null && c.line === line.new);
216 const open = line.new != null && writing === line.new;
217 return (
218 <Fragment key={index}>
219 <tr className={`group ${ROW_STYLES[line.kind]}`}>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)220 <td className={`w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${GUTTER_STYLES[line.kind]}`}>
Say why git over SSH is not on yet221 {line.old}
222 </td>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)223 <td
224 // A tap on the number comments on the line: a touch screen has no hover for the button.
225 onClick={canComment && line.new != null ? () => setWriting(open ? null : line.new) : undefined}
226 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]}`}
227 >
Say why git over SSH is not on yet228 {line.new}
229 {canComment && line.new != null && (
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)230 <CommentButton
231 line={line.new}
232 onClick={(event) => {
233 event.stopPropagation();
234 setWriting(open ? null : line.new);
235 }}
236 />
Say why git over SSH is not on yet237 )}
238 </td>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)239 <td className={`w-4 text-center select-none sm:w-5 ${fileToneFor(line.kind)}`}>
Say why git over SSH is not on yet240 {MARKERS[line.kind]}
241 </td>
242 <td className="pr-4 whitespace-pre">
243 <Code line={line} />
244 </td>
245 </tr>
246 {(here.length > 0 || open) && line.new != null && (
247 <tr>
248 <td colSpan={4} className="border-y border-line bg-surface p-3">
249 <Thread
250 path={file.path}
251 line={line.new}
252 comments={here}
253 open={open}
254 onClose={() => setWriting(null)}
255 />
256 </td>
257 </tr>
258 )}
259 </Fragment>
260 );
261 })}
262 </Fragment>
263 ))}
264 </tbody>
265 </table>
266 );
267}
268
269function fileToneFor(kind: DiffLine["kind"]) {
Merge branch 'worktree-agent-ad7c6d88d93adc817'270 return kind === "add" ? "text-success" : kind === "delete" ? "text-danger" : "text-faint";
Say why git over SSH is not on yet271}
272
273/** Lines side by side: a run of deletions beside the additions that replaced it. */
274function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] {
275 const rows: [HighlightedLine | null, HighlightedLine | null][] = [];
276 let index = 0;
277 while (index < lines.length) {
278 const line = lines[index]!;
279 if (line.kind === "context") {
280 rows.push([line, line]);
281 index++;
282 continue;
283 }
284 const removed: HighlightedLine[] = [];
285 const added: HighlightedLine[] = [];
286 while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!);
287 while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!);
288 for (let n = 0; n < Math.max(removed.length, added.length); n++) {
289 rows.push([removed[n] ?? null, added[n] ?? null]);
290 }
291 }
292 return rows;
293}
294
295function SplitRows({ file, comments, canComment }: FileProps) {
296 const [writing, setWriting] = useState<number | null>(null);
297 const side = (line: HighlightedLine | null, which: "old" | "new") => {
298 const kind = line ? (line.kind === "context" ? "context" : line.kind) : null;
299 const empty = "bg-surface/60";
300 return (
301 <>
302 <td
303 className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${
304 which === "new" ? "border-l border-line" : ""
305 }`}
306 >
307 {line?.[which]}
308 {which === "new" && canComment && line?.new != null && (
309 <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} />
310 )}
311 </td>
312 <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}>
313 <Code line={line} />
314 </td>
315 </>
316 );
317 };
318 return (
319 <table className="w-full table-fixed border-collapse font-mono text-xs leading-5">
320 <colgroup>
321 <col className="w-12" />
322 <col />
323 <col className="w-12" />
324 <col />
325 </colgroup>
326 <tbody>
327 {file.hunks.map((hunk, h) => (
328 <Fragment key={h}>
329 {h > 0 && <HunkGap colSpan={4} />}
330 {pairs(hunk.lines).map(([before, after], index) => {
331 const line = after?.new ?? null;
332 const here = comments.filter((c) => line != null && c.line === line);
333 const open = line != null && writing === line;
334 return (
335 <Fragment key={index}>
336 <tr className="group">
337 {side(before && before.kind !== "add" ? before : null, "old")}
338 {side(after && after.kind !== "delete" ? after : null, "new")}
339 </tr>
340 {(here.length > 0 || open) && line != null && (
341 <tr>
342 <td colSpan={4} className="border-y border-line bg-surface p-3">
343 <Thread
344 path={file.path}
345 line={line}
346 comments={here}
347 open={open}
348 onClose={() => setWriting(null)}
349 />
350 </td>
351 </tr>
352 )}
353 </Fragment>
354 );
355 })}
356 </Fragment>
357 ))}
358 </tbody>
359 </table>
360 );
361}
362
363function HunkGap({ colSpan }: { colSpan: number }) {
364 return (
365 <tr aria-hidden="true">
366 <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint">
367 ⋯
368 </td>
369 </tr>
370 );
371}
372
373function FileSection({
374 file,
375 review,
376 layout,
377 collapsed,
378 viewed,
379 onToggle,
380 onViewed,
381 fileBase,
382}: {
383 file: HighlightedFile;
384 review?: LineReview;
385 layout: Layout;
386 collapsed: boolean;
387 viewed: boolean;
388 onToggle: () => void;
389 onViewed: (viewed: boolean) => void;
390 /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */
391 fileBase?: string;
392}) {
393 const Icon = fileIcon(file.status);
394 const comments = review?.comments.filter((comment) => comment.path === file.path) ?? [];
395 const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)));
396 // Made against an earlier version of the change, on a line no longer in it.
397 const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line));
398 // Shown plain at once, and highlighted in the browser when it comes near
399 // the screen: the page never waits for colours.
400 const section = useRef<HTMLElement>(null);
401 const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null);
402 useEffect(() => {
403 setHighlighted(null);
404 const element = section.current;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit405 // Highlighted by the server already: one of the change's first files.
406 const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null));
407 if (!element || collapsed || done) return;
Say why git over SSH is not on yet408 let cancelled = false;
409 const observer = new IntersectionObserver(
410 ([entry]) => {
411 if (!entry?.isIntersecting) return;
412 observer.disconnect();
413 void import("../lib/shiki")
414 .then(({ highlightFile }) => highlightFile(file))
415 .then((result) => {
416 if (!cancelled && result) setHighlighted(result);
417 })
418 .catch(() => {});
419 },
420 { rootMargin: "600px 0px" },
421 );
422 observer.observe(element);
423 return () => {
424 cancelled = true;
425 observer.disconnect();
426 };
427 }, [file, collapsed]);
428 const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false };
429 return (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.430 <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]">
431 <section
432 ref={section}
433 id={`file-${file.path}`}
434 data-diff-file={file.path}
435 // Lands below the site bar and the change's toolbar, whatever their height.
436
Say why git over SSH is not on yet437 >
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.438 <header
439 // Sticks under the change's toolbar (its height is --diff-toolbar), not behind it.
440 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 ${
441 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
442 }`}>
443 <Button
444 type="button"
445 onClick={onToggle}
446 aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`}
447 aria-expanded={!collapsed}
448 variant="ghost"
449 size="inline"
450 className="-m-1.5 rounded p-2 text-faint sm:m-0 sm:p-0.5"
451 >
452 {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}
453 </Button>
454 <Icon size={15} className={`hidden shrink-0 sm:block ${fileTone(file.status)}`} />
455 {/* A long path is cut at its start, so the file's own name shows. */}
456 <button
457 type="button"
458 onClick={onToggle}
459 dir="rtl"
460 className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`}
461 >
462 <bdi>{file.path}</bdi>
463 </button>
464 {comments.length > 0 && (
465 <span className="flex items-center gap-1 text-xs text-muted">
466 <MessageSquarePlus size={12} />
467 {comments.length}
468 </span>
Say why git over SSH is not on yet469 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.470 <Stat additions={file.additions} deletions={file.deletions} />
471 {fileBase && file.status !== "deleted" && (
472 <span className="hidden items-center gap-0.5 text-xs sm:flex">
473 <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg">
474 View
475 </Link>
476 <Link
477 to={`${fileBase}/${file.path}?blame=1`}
478 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
479 >
480 Blame
481 </Link>
482 </span>
Say why git over SSH is not on yet483 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.484 <label
485 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 ${
486 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
487 }`}
488 >
489 <Checkbox
490 checked={viewed}
491 onCheckedChange={(checked) => onViewed(checked === true)}
492 className="size-3.5 rounded-[4px] [&_svg]:size-2.5"
493 />
494 <span className="sr-only sm:not-sr-only">Viewed</span>
495 </label>
496 </header>
497 {!collapsed && (
498 <div className="overflow-hidden rounded-b-xl">
499 {outdated.length > 0 && (
500 <div className="space-y-2 border-b border-line bg-surface/50 p-3">
501 <p className="text-xs text-faint">On lines that have since changed</p>
502 {outdated.map((comment) => (
503 <LineComment key={comment.id} comment={comment} />
504 ))}
505 </div>
506 )}
507 {file.binary ? (
508 <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p>
509 ) : file.hunks.length === 0 ? (
510 <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p>
511 ) : (
512 <div className="overflow-x-auto">
513 {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />}
514 </div>
515 )}
516 </div>
517 )}
518 </section>
519 </Card>
Say why git over SSH is not on yet520 );
521}
522
523type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile };
524
525/** The changed files as folders, with folders of one folder merged: `src/lib`. */
526function treeOf(files: HighlightedFile[]): TreeNode[] {
527 const root: TreeNode = { name: "", path: "", children: [] };
528 for (const file of files) {
529 const parts = file.path.split("/");
530 let node = root;
531 parts.forEach((part, index) => {
532 const path = parts.slice(0, index + 1).join("/");
533 let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1));
534 if (!child) {
535 child = { name: part, path, children: [] };
536 if (index === parts.length - 1) child.file = file;
537 node.children.push(child);
538 }
539 node = child;
540 });
541 }
542 const squash = (node: TreeNode): TreeNode => {
543 let current = node;
544 while (!current.file && current.children.length === 1 && !current.children[0]!.file) {
545 const only = current.children[0]!;
546 current = { ...only, name: `${current.name}/${only.name}` };
547 }
548 const children = current.children
549 .map(squash)
550 .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name));
551 return { ...current, children };
552 };
553 return squash(root).children;
554}
555
556function Tree({
557 nodes,
558 depth,
559 active,
560 viewed,
561 onPick,
562}: {
563 nodes: TreeNode[];
564 depth: number;
565 active: string | null;
566 viewed: Set<string>;
567 onPick: (path: string) => void;
568}) {
569 return (
570 <ul>
571 {nodes.map((node) => {
572 if (!node.file) {
573 return (
574 <li key={node.path}>
575 <p
576 className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint"
577 style={{ paddingLeft: depth * 12 + 8 }}
578 >
579 <Folder size={13} className="shrink-0" />
580 {node.name}
581 </p>
582 <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} />
583 </li>
584 );
585 }
586 const file = node.file;
587 const Icon = fileIcon(file.status);
588 return (
589 <li key={node.path}>
590 <button
591 type="button"
592 onClick={() => onPick(file.path)}
593 style={{ paddingLeft: depth * 12 + 8 }}
594 className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${
595 active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
596 } ${viewed.has(file.path) ? "opacity-55" : ""}`}
597 >
598 <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} />
599 <span className="min-w-0 grow truncate">{node.name}</span>
600 <span className="shrink-0 text-[0.625rem] tabular-nums">
Merge branch 'worktree-agent-ad7c6d88d93adc817'601 {file.additions > 0 && <span className="text-success">+{file.additions}</span>}
Say why git over SSH is not on yet602 {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>}
603 </span>
604 </button>
605 </li>
606 );
607 })}
608 </ul>
609 );
610}
611
612/**
613 * A change, file by file: a tree of what changed beside the diffs, unified
614 * or side by side, each file foldable and markable as viewed. `j` and `k`
615 * move between files and `t` filters them.
616 */
617export function DiffView({
618 comparison,
619 review,
620 empty,
621 fileBase,
622}: {
623 comparison: Comparison;
624 review?: LineReview;
625 /** What to say when nothing changed. */
626 empty?: React.ReactNode;
627 /** Where each file can be read as of the change, to link to it. */
628 fileBase?: string;
629}) {
630 const { files, truncated } = comparison;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)631 const [chosenLayout, setLayout] = useState<Layout>("unified");
632 // Side by side has no room on a phone.
633 const narrow = useNarrow();
634 const layout: Layout = narrow ? "unified" : chosenLayout;
635 const [picking, setPicking] = useState(false);
Say why git over SSH is not on yet636 const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
637 const [viewed, setViewed] = useState<Set<string>>(new Set());
638 const [active, setActive] = useState<string | null>(files[0]?.path ?? null);
639 const [filter, setFilter] = useState("");
640 const filterInput = useRef<HTMLInputElement>(null);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)641 // The toolbar's height, as it wraps at each width, so the files' own
642 // sticky headers sit below it rather than under it.
643 const frame = useRef<HTMLDivElement>(null);
644 const toolbar = useRef<HTMLDivElement>(null);
645 const noFiles = files.length === 0;
646 useEffect(() => {
647 const bar = toolbar.current;
648 const root = frame.current;
649 if (!bar || !root) return;
650 const measure = () => root.style.setProperty("--diff-toolbar", `${bar.offsetHeight}px`);
651 measure();
652 if (typeof ResizeObserver === "undefined") return;
653 const observer = new ResizeObserver(measure);
654 observer.observe(bar);
655 return () => observer.disconnect();
656 }, [noFiles]);
Say why git over SSH is not on yet657 const viewedKey = `g1t:viewed:${comparison.head}`;
658
659 // Preferences live in the browser: read them once it is there.
660 useEffect(() => {
661 if (stored("g1t:diff-layout") === "split") setLayout("split");
662 const saved = stored(viewedKey);
663 if (saved) {
664 try {
665 const paths = new Set<string>(JSON.parse(saved));
666 setViewed(paths);
667 setCollapsed(new Set(paths));
668 } catch {
669 // Something else wrote it.
670 }
671 }
672 }, [viewedKey]);
673
674 const changeLayout = (next: Layout) => {
675 setLayout(next);
676 store("g1t:diff-layout", next);
677 };
678 const markViewed = (path: string, on: boolean) => {
679 setViewed((previous) => {
680 const next = new Set(previous);
681 if (on) next.add(path);
682 else next.delete(path);
683 store(viewedKey, next.size ? JSON.stringify([...next]) : null);
684 return next;
685 });
686 // Marking a file viewed folds it away, as a reviewer moves on.
687 setCollapsed((previous) => {
688 const next = new Set(previous);
689 if (on) next.add(path);
690 else next.delete(path);
691 return next;
692 });
693 };
694 const toggle = (path: string) =>
695 setCollapsed((previous) => {
696 const next = new Set(previous);
697 if (next.has(path)) next.delete(path);
698 else next.add(path);
699 return next;
700 });
701
702 const shownFiles = useMemo(() => {
703 const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
704 return words.length === 0
705 ? files
706 : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word)));
707 }, [files, filter]);
708 const tree = useMemo(() => treeOf(shownFiles), [shownFiles]);
709
710 const jump = useCallback((path: string) => {
711 setCollapsed((previous) => {
712 if (!previous.has(path)) return previous;
713 const next = new Set(previous);
714 next.delete(path);
715 return next;
716 });
717 setActive(path);
718 requestAnimationFrame(() =>
719 document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: "smooth", block: "start" }),
720 );
721 }, []);
722
723 // The file at the top of the screen is the current one.
724 useEffect(() => {
725 const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")];
726 if (sections.length === 0) return;
727 const observer = new IntersectionObserver(
728 (entries) => {
729 const top = entries
730 .filter((entry) => entry.isIntersecting)
731 .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
732 if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null);
733 },
734 { rootMargin: "-112px 0px -60% 0px" },
735 );
736 sections.forEach((section) => observer.observe(section));
737 return () => observer.disconnect();
738 }, [shownFiles, collapsed]);
739
740 useEffect(() => {
741 const onKey = (event: KeyboardEvent) => {
742 const target = event.target as HTMLElement | null;
743 if (event.metaKey || event.ctrlKey || event.altKey) return;
744 if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
745 const index = shownFiles.findIndex((file) => file.path === active);
746 if (event.key === "j" || event.key === "k") {
747 const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))];
748 if (next) {
749 event.preventDefault();
750 jump(next.path);
751 }
752 } else if (event.key === "t") {
753 event.preventDefault();
754 filterInput.current?.focus();
755 } else if (event.key === "v" && active) {
756 event.preventDefault();
757 markViewed(active, !viewed.has(active));
758 }
759 };
760 window.addEventListener("keydown", onKey);
761 return () => window.removeEventListener("keydown", onKey);
762 });
763
764 if (files.length === 0) {
765 return (
766 <EmptyState title="No changes">
767 {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."}
768 </EmptyState>
769 );
770 }
771 const additions = files.reduce((sum, file) => sum + file.additions, 0);
772 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
773 const allCollapsed = files.every((file) => collapsed.has(file.path));
774 return (
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)775 <div ref={frame}>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar776 <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">
Say why git over SSH is not on yet777 <span className="text-sm text-muted">
778 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
779 </span>
780 <Stat additions={additions} deletions={deletions} />
781 <span className="flex items-center gap-2 text-xs text-muted">
782 <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line">
783 <span
784 className="block h-full rounded-full bg-accent transition-all"
785 style={{ width: `${(viewed.size / files.length) * 100}%` }}
786 />
787 </span>
788 {viewed.size}/{files.length} viewed
789 </span>
790 <div className="ml-auto flex items-center gap-2">
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)791 {files.length > 1 && (
792 // Below xl the tree is not beside the diffs: it opens as a sheet.
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.793 <Button
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)794 type="button"
795 onClick={() => setPicking(true)}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.796 variant="outline"
797 size="inline"
798 className="flex min-h-9 px-2.5 text-xs text-muted xl:hidden font-normal"
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)799 >
800 <Files size={13} />
801 Files
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.802 </Button>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)803 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.804 <Button
Say why git over SSH is not on yet805 type="button"
806 onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.807 variant="ghost"
808 size="inline"
809 className="px-2 py-1 text-xs font-normal"
Say why git over SSH is not on yet810 >
811 {allCollapsed ? "Expand all" : "Collapse all"}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.812 </Button>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)813 <div className="hidden rounded-md border border-line p-0.5 sm:flex" role="radiogroup" aria-label="Layout">
Say why git over SSH is not on yet814 {(
815 [
816 ["unified", <Rows3 key="u" size={13} />, "Unified"],
817 ["split", <Columns2 key="s" size={13} />, "Split"],
818 ] as const
819 ).map(([value, icon, label]) => (
820 <button
821 key={value}
822 type="button"
823 role="radio"
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)824 aria-checked={chosenLayout === value}
Say why git over SSH is not on yet825 onClick={() => changeLayout(value)}
826 className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)827 chosenLayout === value ? "bg-raised text-fg" : "text-muted hover:text-fg"
Say why git over SSH is not on yet828 }`}
829 >
830 {icon}
831 {label}
832 </button>
833 ))}
834 </div>
835 </div>
836 </div>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)837 {files.length > 1 && (
838 <Sheet open={picking} onOpenChange={setPicking}>
839 <SheetContent
840 side="left"
841 // No keyboard popping up over the list on a phone: the filter is a tap away.
842 onOpenAutoFocus={(event) => event.preventDefault()}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar843 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"
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)844 >
845 <SheetHeader>
846 <SheetTitle>Files changed</SheetTitle>
847 <SheetDescription>
848 {files.length} files · {viewed.size} viewed
849 </SheetDescription>
850 </SheetHeader>
851 <div className="relative px-4 pt-3">
852 <Search size={14} className="pointer-events-none absolute top-1/2 left-6.5 mt-1.5 -translate-y-1/2 text-faint" />
853 <input
854 value={filter}
855 onChange={(event) => setFilter(event.target.value)}
856 placeholder="Filter files"
857 aria-label="Filter files"
858 autoComplete="off"
859 data-1p-ignore
860 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"
861 />
862 </div>
863 <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]">
864 <Tree
865 nodes={tree}
866 depth={0}
867 active={active}
868 viewed={viewed}
869 onPick={(path) => {
870 setPicking(false);
871 // Once the sheet has let the page go, so its scroll is not undone.
872 setTimeout(() => jump(path), 50);
873 }}
874 />
875 {shownFiles.length === 0 && <p className="px-2 py-6 text-center text-sm text-muted">No file matches.</p>}
876 </nav>
877 </SheetContent>
878 </Sheet>
879 )}
Say why git over SSH is not on yet880 <div className="flex gap-4">
881 {files.length > 1 && (
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.882 <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">
Say why git over SSH is not on yet883 <div className="relative">
884 <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
885 <input
886 ref={filterInput}
887 value={filter}
888 onChange={(event) => setFilter(event.target.value)}
889 onKeyDown={(event) => {
890 if (event.key === "Escape") {
891 setFilter("");
892 event.currentTarget.blur();
893 } else if (event.key === "Enter" && shownFiles[0]) {
894 jump(shownFiles[0].path);
895 event.currentTarget.blur();
896 }
897 }}
898 placeholder="Filter files"
899 autoComplete="off"
900 data-1p-ignore
901 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"
902 />
903 <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">
904 t
905 </kbd>
906 </div>
907 <nav className="mt-2 min-h-0 overflow-y-auto pb-2">
908 <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} />
909 </nav>
910 <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint">
911 <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "}
912 <kbd className="font-mono">v</kbd> mark viewed
913 </p>
914 </aside>
915 )}
916 <div className="min-w-0 grow space-y-3">
917 {review?.canComment && (
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)918 <p className="text-xs text-faint">
919 <span className="pointer-coarse:hidden">Hover a line and press the button beside its number to comment on it.</span>
920 <span className="hidden pointer-coarse:inline">Tap a line number to comment on it.</span>
921 </p>
Say why git over SSH is not on yet922 )}
923 {shownFiles.map((file) => (
924 <FileSection
925 key={file.path}
926 file={file}
927 review={review}
928 layout={layout}
929 collapsed={collapsed.has(file.path)}
930 viewed={viewed.has(file.path)}
931 onToggle={() => toggle(file.path)}
932 onViewed={(on) => markViewed(file.path, on)}
933 fileBase={fileBase}
934 />
935 ))}
936 {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>}
937 {truncated && <p className="text-center text-sm text-muted">This change is too large to show in full.</p>}
938 </div>
939 </div>
940 </div>
941 );
942}

This file's history is long; its oldest lines are credited to the oldest commit read.