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