g1t/apps/web/app/components/diff-view.tsx
| 1 | import { FileDiff as FileIcon, FileMinus, FilePlus } from "lucide-react"; |
| 2 | |
| 3 | import type { Comparison, DiffLine, FileDiff } from "@g1t/contracts"; |
| 4 | |
| 5 | import { EmptyState } from "./ui"; |
| 6 | |
| 7 | const ROW_STYLES: Record<DiffLine["kind"], string> = { |
| 8 | context: "", |
| 9 | add: "bg-accent/10", |
| 10 | delete: "bg-danger/10", |
| 11 | }; |
| 12 | |
| 13 | const MARKERS: Record<DiffLine["kind"], string> = { |
| 14 | context: " ", |
| 15 | add: "+", |
| 16 | delete: "-", |
| 17 | }; |
| 18 | |
| 19 | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ |
| 20 | function Stat({ |
| 21 | additions, |
| 22 | deletions, |
| 23 | }: { |
| 24 | additions: number; |
| 25 | deletions: number; |
| 26 | }) { |
| 27 | const total = additions + deletions; |
| 28 | const green = total === 0 ? 0 : Math.round((additions / total) * 5); |
| 29 | return ( |
| 30 | <span className="flex shrink-0 items-center gap-2 font-mono text-xs"> |
| 31 | <span className="text-accent">+{additions}</span> |
| 32 | <span className="text-danger">−{deletions}</span> |
| 33 | <span className="flex gap-px" aria-hidden="true"> |
| 34 | {Array.from({ length: 5 }, (_, i) => ( |
| 35 | <span |
| 36 | key={i} |
| 37 | className={`size-2 rounded-xs ${ |
| 38 | total === 0 ? "bg-line" : i < green ? "bg-accent" : "bg-danger" |
| 39 | }`} |
| 40 | /> |
| 41 | ))} |
| 42 | </span> |
| 43 | </span> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | function File({ file }: { file: FileDiff }) { |
| 48 | const Icon = |
| 49 | file.status === "added" |
| 50 | ? FilePlus |
| 51 | : file.status === "deleted" |
| 52 | ? FileMinus |
| 53 | : FileIcon; |
| 54 | return ( |
| 55 | <section |
| 56 | id={`file-${file.path}`} |
| 57 | className="scroll-mt-20 overflow-hidden rounded-xl border border-line" |
| 58 | > |
| 59 | <header className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5"> |
| 60 | <Icon |
| 61 | size={15} |
| 62 | className={ |
| 63 | file.status === "added" |
| 64 | ? "text-accent" |
| 65 | : file.status === "deleted" |
| 66 | ? "text-danger" |
| 67 | : "text-faint" |
| 68 | } |
| 69 | /> |
| 70 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> |
| 71 | {file.path} |
| 72 | </span> |
| 73 | <Stat additions={file.additions} deletions={file.deletions} /> |
| 74 | </header> |
| 75 | {file.binary ? ( |
| 76 | <p className="px-4 py-6 text-sm text-muted"> |
| 77 | Binary or large file; its contents are not shown. |
| 78 | </p> |
| 79 | ) : file.hunks.length === 0 ? ( |
| 80 | <p className="px-4 py-6 text-sm text-muted">No line changes.</p> |
| 81 | ) : ( |
| 82 | <div className="overflow-x-auto"> |
| 83 | <table className="w-full border-collapse font-mono text-xs leading-5"> |
| 84 | <tbody> |
| 85 | {file.hunks.map((hunk, index) => ( |
| 86 | <HunkRows key={index} lines={hunk.lines} first={index === 0} /> |
| 87 | ))} |
| 88 | </tbody> |
| 89 | </table> |
| 90 | </div> |
| 91 | )} |
| 92 | </section> |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | function HunkRows({ lines, first }: { lines: DiffLine[]; first: boolean }) { |
| 97 | return ( |
| 98 | <> |
| 99 | {!first && ( |
| 100 | <tr aria-hidden="true"> |
| 101 | <td |
| 102 | colSpan={4} |
| 103 | className="border-y border-line bg-surface py-1 text-center text-faint" |
| 104 | > |
| 105 | ⋯ |
| 106 | </td> |
| 107 | </tr> |
| 108 | )} |
| 109 | {lines.map((line, index) => ( |
| 110 | <tr key={index} className={ROW_STYLES[line.kind]}> |
| 111 | <td className="w-10 px-2 text-right text-faint select-none"> |
| 112 | {line.old} |
| 113 | </td> |
| 114 | <td className="w-10 px-2 text-right text-faint select-none"> |
| 115 | {line.new} |
| 116 | </td> |
| 117 | <td |
| 118 | className={`w-5 text-center select-none ${ |
| 119 | line.kind === "add" |
| 120 | ? "text-accent" |
| 121 | : line.kind === "delete" |
| 122 | ? "text-danger" |
| 123 | : "text-faint" |
| 124 | }`} |
| 125 | > |
| 126 | {MARKERS[line.kind]} |
| 127 | </td> |
| 128 | <td className="pr-4 whitespace-pre">{line.text}</td> |
| 129 | </tr> |
| 130 | ))} |
| 131 | </> |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | export function DiffView({ comparison }: { comparison: Comparison }) { |
| 136 | const { files, truncated } = comparison; |
| 137 | if (files.length === 0) { |
| 138 | return ( |
| 139 | <EmptyState title="No changes yet"> |
| 140 | Nothing has been pushed to this pull request's fork, or it matches the |
| 141 | repository it came from. |
| 142 | </EmptyState> |
| 143 | ); |
| 144 | } |
| 145 | const additions = files.reduce((sum, file) => sum + file.additions, 0); |
| 146 | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); |
| 147 | return ( |
| 148 | <div className="space-y-4"> |
| 149 | <div className="flex items-center gap-3 text-sm text-muted"> |
| 150 | <span> |
| 151 | <span className="font-medium text-fg">{files.length}</span>{" "} |
| 152 | {files.length === 1 ? "file" : "files"} changed |
| 153 | </span> |
| 154 | <Stat additions={additions} deletions={deletions} /> |
| 155 | </div> |
| 156 | {files.length > 1 && ( |
| 157 | <ul className="rounded-xl border border-line bg-surface p-2 text-sm"> |
| 158 | {files.map((file) => ( |
| 159 | <li key={file.path}> |
| 160 | <a |
| 161 | href={`#file-${file.path}`} |
| 162 | className="flex items-center gap-3 rounded-md px-2 py-1 hover:bg-raised" |
| 163 | > |
| 164 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> |
| 165 | {file.path} |
| 166 | </span> |
| 167 | <Stat additions={file.additions} deletions={file.deletions} /> |
| 168 | </a> |
| 169 | </li> |
| 170 | ))} |
| 171 | </ul> |
| 172 | )} |
| 173 | {files.map((file) => ( |
| 174 | <File key={file.path} file={file} /> |
| 175 | ))} |
| 176 | {truncated && ( |
| 177 | <p className="text-center text-sm text-muted"> |
| 178 | This change is too large to show in full. |
| 179 | </p> |
| 180 | )} |
| 181 | </div> |
| 182 | ); |
| 183 | } |