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