g1t/apps/web/app/components/diff-view.tsx
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.
| Agents as a team: lifecycle, merge queue, billing and a new shell | 1 | import { |
| 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"; | |
| 13 | import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | import { Form, Link } from "react-router"; | |
| Diffs on attempts; hosted agent presented as the g1t agent | 15 | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 16 | import type { Comment, Comparison, DiffLine } from "@g1t/contracts"; |
| Diffs on attempts; hosted agent presented as the g1t agent | 17 | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 18 | import type { |
| 19 | HighlightedComparison, | |
| 20 | HighlightedFile, | |
| 21 | HighlightedLine, | |
| 22 | } from "../lib/diff"; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 23 | import { Markdown } from "./markdown"; |
| 24 | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| Workspace names and icons, and a component kit for every control | 25 | import { Checkbox } from "./ui/checkbox"; |
| Diffs on attempts; hosted agent presented as the g1t agent | 26 | |
| 27 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 28 | context: "", | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 29 | add: "bg-accent/[0.09]", |
| 30 | delete: "bg-danger/[0.09]", | |
| 31 | }; | |
| 32 | ||
| 33 | const 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", | |
| Diffs on attempts; hosted agent presented as the g1t agent | 37 | }; |
| 38 | ||
| 39 | const MARKERS: Record<DiffLine["kind"], string> = { | |
| 40 | context: " ", | |
| 41 | add: "+", | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 42 | delete: "−", |
| Diffs on attempts; hosted agent presented as the g1t agent | 43 | }; |
| 44 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 45 | /** Comments on lines of the change, and whether the viewer may add one. */ |
| 46 | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 47 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 48 | type Layout = "unified" | "split"; |
| 49 | ||
| 50 | /** Reads a per-viewer preference; storage can be missing or refuse. */ | |
| 51 | function stored(key: string): string | null { | |
| 52 | try { | |
| 53 | return window.localStorage.getItem(key); | |
| 54 | } catch { | |
| 55 | return null; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | function 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. */ | |
| 69 | export function Stat({ additions, deletions }: { additions: number; deletions: number }) { | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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} | |
| docs.g1t.sh, generated OpenAPI with an interactive reference, full footer | 80 | className={`size-2 rounded-xs ${ |
| Diffs on attempts; hosted agent presented as the g1t agent | 81 | total === 0 ? "bg-line" : i < green ? "bg-accent" : "bg-danger" |
| 82 | }`} | |
| 83 | /> | |
| 84 | ))} | |
| 85 | </span> | |
| 86 | </span> | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 90 | function fileIcon(status: HighlightedFile["status"]) { |
| 91 | return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon; | |
| 92 | } | |
| 93 | ||
| 94 | function fileTone(status: HighlightedFile["status"]) { | |
| 95 | return status === "added" ? "text-accent" : status === "deleted" ? "text-danger" : "text-faint"; | |
| 96 | } | |
| 97 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 98 | /** One comment made on a line, shown under that line. */ |
| 99 | function 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 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 114 | /** A line's text, highlighted when the server could. */ |
| 115 | function 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. */ | |
| 124 | function 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> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 157 | ); |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 158 | } |
| 159 | ||
| 160 | function 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> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 170 | ); |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 171 | } |
| 172 | ||
| 173 | type FileProps = { | |
| 174 | file: HighlightedFile; | |
| 175 | comments: Comment[]; | |
| 176 | canComment: boolean; | |
| 177 | }; | |
| 178 | ||
| 179 | function UnifiedRows({ file, comments, canComment }: FileProps) { | |
| 180 | const [writing, setWriting] = useState<number | null>(null); | |
| Diffs on attempts; hosted agent presented as the g1t agent | 181 | return ( |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | ||
| 232 | function 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. */ | |
| 237 | function 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 | ||
| 258 | function 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 | ||
| 326 | function 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 | ||
| 336 | function 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 ( | |
| Diffs on attempts; hosted agent presented as the g1t agent | 391 | <section |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 392 | ref={section} |
| Diffs on attempts; hosted agent presented as the g1t agent | 393 | id={`file-${file.path}`} |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 394 | data-diff-file={file.path} |
| 395 | className="scroll-mt-28 rounded-xl border border-line" | |
| Diffs on attempts; hosted agent presented as the g1t agent | 396 | > |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 397 | <header |
| Two limits, real invoices, trust that grows by itself, sales signals | 398 | className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | > | |
| Diffs on attempts; hosted agent presented as the g1t agent | 417 | {file.path} |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | )} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 425 | <Stat additions={file.additions} deletions={file.deletions} /> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | > | |
| Workspace names and icons, and a component kit for every control | 444 | <Checkbox |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 445 | checked={viewed} |
| Workspace names and icons, and a component kit for every control | 446 | onCheckedChange={(checked) => onViewed(checked === true)} |
| 447 | className="size-3.5 rounded-[4px] [&_svg]:size-2.5" | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 448 | /> |
| 449 | Viewed | |
| 450 | </label> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 451 | </header> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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} /> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 459 | ))} |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | )} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 471 | </div> |
| 472 | )} | |
| 473 | </section> | |
| 474 | ); | |
| 475 | } | |
| 476 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 477 | type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile }; |
| 478 | ||
| 479 | /** The changed files as folders, with folders of one folder merged: `src/lib`. */ | |
| 480 | function 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 | ||
| 510 | function Tree({ | |
| 511 | nodes, | |
| 512 | depth, | |
| 513 | active, | |
| 514 | viewed, | |
| 515 | onPick, | |
| Acceptance checks in sandboxes, line comments and review verdicts | 516 | }: { |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 517 | nodes: TreeNode[]; |
| 518 | depth: number; | |
| 519 | active: string | null; | |
| 520 | viewed: Set<string>; | |
| 521 | onPick: (path: string) => void; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 522 | }) { |
| Diffs on attempts; hosted agent presented as the g1t agent | 523 | return ( |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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); | |
| Acceptance checks in sandboxes, line comments and review verdicts | 542 | return ( |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 560 | ); |
| 561 | })} | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 562 | </ul> |
| Diffs on attempts; hosted agent presented as the g1t agent | 563 | ); |
| 564 | } | |
| 565 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | */ | |
| Acceptance checks in sandboxes, line comments and review verdicts | 571 | export function DiffView({ |
| 572 | comparison, | |
| 573 | review, | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 574 | empty, |
| 575 | fileBase, | |
| Acceptance checks in sandboxes, line comments and review verdicts | 576 | }: { |
| 577 | comparison: Comparison; | |
| 578 | review?: LineReview; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 583 | }) { |
| Diffs on attempts; hosted agent presented as the g1t agent | 584 | const { files, truncated } = comparison; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | ||
| Diffs on attempts; hosted agent presented as the g1t agent | 698 | if (files.length === 0) { |
| 699 | return ( | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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."} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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); | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 707 | const allCollapsed = files.every((file) => collapsed.has(file.path)); |
| Diffs on attempts; hosted agent presented as the g1t agent | 708 | return ( |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 709 | <div> |
| Two limits, real invoices, trust that grows by itself, sales signals | 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"> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 711 | <span className="text-sm text-muted"> |
| 712 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 713 | </span> |
| 714 | <Stat additions={additions} deletions={deletions} /> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | /> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 721 | </span> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 755 | </div> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | /> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 808 | ))} |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 813 | </div> |
| 814 | ); | |
| 815 | } |