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