g1t/apps/web/app/components/diff-view.tsx
| 1 | import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form } from "react-router"; |
| 4 | |
| 5 | import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts"; |
| 6 | |
| 7 | import { Markdown } from "./markdown"; |
| 8 | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; |
| 9 | |
| 10 | const ROW_STYLES: Record<DiffLine["kind"], string> = { |
| 11 | context: "", |
| 12 | add: "bg-accent/10", |
| 13 | delete: "bg-danger/10", |
| 14 | }; |
| 15 | |
| 16 | const MARKERS: Record<DiffLine["kind"], string> = { |
| 17 | context: " ", |
| 18 | add: "+", |
| 19 | delete: "-", |
| 20 | }; |
| 21 | |
| 22 | /** Comments on lines of the change, and whether the viewer may add one. */ |
| 23 | export type LineReview = { comments: Comment[]; canComment: boolean }; |
| 24 | |
| 25 | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ |
| 26 | function Stat({ |
| 27 | additions, |
| 28 | deletions, |
| 29 | }: { |
| 30 | additions: number; |
| 31 | deletions: number; |
| 32 | }) { |
| 33 | const total = additions + deletions; |
| 34 | const green = total === 0 ? 0 : Math.round((additions / total) * 5); |
| 35 | return ( |
| 36 | <span className="flex shrink-0 items-center gap-2 font-mono text-xs"> |
| 37 | <span className="text-accent">+{additions}</span> |
| 38 | <span className="text-danger">−{deletions}</span> |
| 39 | <span className="flex gap-px" aria-hidden="true"> |
| 40 | {Array.from({ length: 5 }, (_, i) => ( |
| 41 | <span |
| 42 | key={i} |
| 43 | className={`size-2 rounded-xs ${ |
| 44 | total === 0 ? "bg-line" : i < green ? "bg-accent" : "bg-danger" |
| 45 | }`} |
| 46 | /> |
| 47 | ))} |
| 48 | </span> |
| 49 | </span> |
| 50 | ); |
| 51 | } |
| 52 | |
| 53 | /** One comment made on a line, shown under that line. */ |
| 54 | function LineComment({ comment }: { comment: Comment }) { |
| 55 | return ( |
| 56 | <div className="rounded-lg border border-line bg-bg font-sans"> |
| 57 | <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted"> |
| 58 | <Avatar name={comment.author.username} size={16} /> |
| 59 | <span className="font-medium text-fg">{comment.author.username}</span> |
| 60 | <TimeAgo at={comment.createdAt} /> |
| 61 | </p> |
| 62 | <div className="px-3 py-2 text-sm"> |
| 63 | <Markdown source={comment.body} /> |
| 64 | </div> |
| 65 | </div> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | function File({ file, review }: { file: FileDiff; review?: LineReview }) { |
| 70 | const Icon = |
| 71 | file.status === "added" |
| 72 | ? FilePlus |
| 73 | : file.status === "deleted" |
| 74 | ? FileMinus |
| 75 | : FileIcon; |
| 76 | const comments = review?.comments.filter((comment) => comment.path === file.path) ?? []; |
| 77 | const shown = new Set( |
| 78 | file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new)), |
| 79 | ); |
| 80 | // Made against an earlier version of the change, on a line no longer in it. |
| 81 | const outdated = comments.filter( |
| 82 | (comment) => comment.line == null || !shown.has(comment.line), |
| 83 | ); |
| 84 | return ( |
| 85 | <section |
| 86 | id={`file-${file.path}`} |
| 87 | className="scroll-mt-20 overflow-hidden rounded-xl border border-line" |
| 88 | > |
| 89 | <header className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5"> |
| 90 | <Icon |
| 91 | size={15} |
| 92 | className={ |
| 93 | file.status === "added" |
| 94 | ? "text-accent" |
| 95 | : file.status === "deleted" |
| 96 | ? "text-danger" |
| 97 | : "text-faint" |
| 98 | } |
| 99 | /> |
| 100 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> |
| 101 | {file.path} |
| 102 | </span> |
| 103 | <Stat additions={file.additions} deletions={file.deletions} /> |
| 104 | </header> |
| 105 | {outdated.length > 0 && ( |
| 106 | <div className="space-y-2 border-b border-line bg-surface/50 p-3"> |
| 107 | <p className="text-xs text-faint">On lines that have since changed</p> |
| 108 | {outdated.map((comment) => ( |
| 109 | <LineComment key={comment.id} comment={comment} /> |
| 110 | ))} |
| 111 | </div> |
| 112 | )} |
| 113 | {file.binary ? ( |
| 114 | <p className="px-4 py-6 text-sm text-muted"> |
| 115 | Binary or large file; its contents are not shown. |
| 116 | </p> |
| 117 | ) : file.hunks.length === 0 ? ( |
| 118 | <p className="px-4 py-6 text-sm text-muted">No line changes.</p> |
| 119 | ) : ( |
| 120 | <div className="overflow-x-auto"> |
| 121 | <table className="w-full border-collapse font-mono text-xs leading-5"> |
| 122 | <tbody> |
| 123 | {file.hunks.map((hunk, index) => ( |
| 124 | <HunkRows |
| 125 | key={index} |
| 126 | path={file.path} |
| 127 | lines={hunk.lines} |
| 128 | first={index === 0} |
| 129 | comments={comments} |
| 130 | canComment={review?.canComment ?? false} |
| 131 | /> |
| 132 | ))} |
| 133 | </tbody> |
| 134 | </table> |
| 135 | </div> |
| 136 | )} |
| 137 | </section> |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | function HunkRows({ |
| 142 | path, |
| 143 | lines, |
| 144 | first, |
| 145 | comments, |
| 146 | canComment, |
| 147 | }: { |
| 148 | path: string; |
| 149 | lines: DiffLine[]; |
| 150 | first: boolean; |
| 151 | comments: Comment[]; |
| 152 | canComment: boolean; |
| 153 | }) { |
| 154 | // The line whose comment box is open, if any. |
| 155 | const [writing, setWriting] = useState<number | null>(null); |
| 156 | return ( |
| 157 | <> |
| 158 | {!first && ( |
| 159 | <tr aria-hidden="true"> |
| 160 | <td |
| 161 | colSpan={4} |
| 162 | className="border-y border-line bg-surface py-1 text-center text-faint" |
| 163 | > |
| 164 | ⋯ |
| 165 | </td> |
| 166 | </tr> |
| 167 | )} |
| 168 | {lines.map((line, index) => { |
| 169 | const here = comments.filter( |
| 170 | (comment) => line.new != null && comment.line === line.new, |
| 171 | ); |
| 172 | const open = line.new != null && writing === line.new; |
| 173 | return ( |
| 174 | <Rows key={index}> |
| 175 | <tr className={`group ${ROW_STYLES[line.kind]}`}> |
| 176 | <td className="w-10 px-2 text-right text-faint select-none">{line.old}</td> |
| 177 | <td className="relative w-10 px-2 text-right text-faint select-none"> |
| 178 | {line.new} |
| 179 | {/* Lines that exist after the change can be commented on. */} |
| 180 | {canComment && line.new != null && ( |
| 181 | <button |
| 182 | type="button" |
| 183 | aria-label={`Comment on line ${line.new}`} |
| 184 | onClick={() => setWriting(open ? null : line.new)} |
| 185 | className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex" |
| 186 | > |
| 187 | <MessageSquarePlus size={12} /> |
| 188 | </button> |
| 189 | )} |
| 190 | </td> |
| 191 | <td |
| 192 | className={`w-5 text-center select-none ${ |
| 193 | line.kind === "add" |
| 194 | ? "text-accent" |
| 195 | : line.kind === "delete" |
| 196 | ? "text-danger" |
| 197 | : "text-faint" |
| 198 | }`} |
| 199 | > |
| 200 | {MARKERS[line.kind]} |
| 201 | </td> |
| 202 | <td className="pr-4 whitespace-pre">{line.text}</td> |
| 203 | </tr> |
| 204 | {(here.length > 0 || open) && ( |
| 205 | <tr> |
| 206 | <td colSpan={4} className="border-y border-line bg-surface p-3"> |
| 207 | <div className="max-w-2xl space-y-2"> |
| 208 | {here.map((comment) => ( |
| 209 | <LineComment key={comment.id} comment={comment} /> |
| 210 | ))} |
| 211 | {open && ( |
| 212 | <Form |
| 213 | method="post" |
| 214 | className="space-y-2 font-sans" |
| 215 | onSubmit={() => setWriting(null)} |
| 216 | > |
| 217 | <input type="hidden" name="action" value="comment" /> |
| 218 | <input type="hidden" name="path" value={path} /> |
| 219 | <input type="hidden" name="line" value={line.new ?? ""} /> |
| 220 | <Textarea |
| 221 | name="body" |
| 222 | rows={3} |
| 223 | required |
| 224 | autoFocus |
| 225 | placeholder={`Comment on line ${line.new}`} |
| 226 | /> |
| 227 | <div className="flex gap-2"> |
| 228 | <Button type="submit">Comment</Button> |
| 229 | <Button variant="quiet" type="button" onClick={() => setWriting(null)}> |
| 230 | Cancel |
| 231 | </Button> |
| 232 | </div> |
| 233 | </Form> |
| 234 | )} |
| 235 | </div> |
| 236 | </td> |
| 237 | </tr> |
| 238 | )} |
| 239 | </Rows> |
| 240 | ); |
| 241 | })} |
| 242 | </> |
| 243 | ); |
| 244 | } |
| 245 | |
| 246 | /** Groups a line's rows without adding an element to the table. */ |
| 247 | function Rows({ children }: { children: React.ReactNode }) { |
| 248 | return <>{children}</>; |
| 249 | } |
| 250 | |
| 251 | export function DiffView({ |
| 252 | comparison, |
| 253 | review, |
| 254 | }: { |
| 255 | comparison: Comparison; |
| 256 | review?: LineReview; |
| 257 | }) { |
| 258 | const { files, truncated } = comparison; |
| 259 | if (files.length === 0) { |
| 260 | return ( |
| 261 | <EmptyState title="No changes yet"> |
| 262 | Nothing has been pushed to this pull request yet, or it matches the |
| 263 | branch it would merge into. |
| 264 | </EmptyState> |
| 265 | ); |
| 266 | } |
| 267 | const additions = files.reduce((sum, file) => sum + file.additions, 0); |
| 268 | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); |
| 269 | return ( |
| 270 | <div className="space-y-4"> |
| 271 | <div className="flex items-center gap-3 text-sm text-muted"> |
| 272 | <span> |
| 273 | <span className="font-medium text-fg">{files.length}</span>{" "} |
| 274 | {files.length === 1 ? "file" : "files"} changed |
| 275 | </span> |
| 276 | <Stat additions={additions} deletions={deletions} /> |
| 277 | {review?.canComment && ( |
| 278 | <span className="ml-auto text-xs text-faint"> |
| 279 | Hover a line and press the button beside its number to comment on it. |
| 280 | </span> |
| 281 | )} |
| 282 | </div> |
| 283 | {files.length > 1 && ( |
| 284 | <ul className="rounded-xl border border-line bg-surface p-2 text-sm"> |
| 285 | {files.map((file) => ( |
| 286 | <li key={file.path}> |
| 287 | <a |
| 288 | href={`#file-${file.path}`} |
| 289 | className="flex items-center gap-3 rounded-md px-2 py-1 hover:bg-raised" |
| 290 | > |
| 291 | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> |
| 292 | {file.path} |
| 293 | </span> |
| 294 | <Stat additions={file.additions} deletions={file.deletions} /> |
| 295 | </a> |
| 296 | </li> |
| 297 | ))} |
| 298 | </ul> |
| 299 | )} |
| 300 | {files.map((file) => ( |
| 301 | <File key={file.path} file={file} review={review} /> |
| 302 | ))} |
| 303 | {truncated && ( |
| 304 | <p className="text-center text-sm text-muted"> |
| 305 | This change is too large to show in full. |
| 306 | </p> |
| 307 | )} |
| 308 | </div> |
| 309 | ); |
| 310 | } |