g1t/apps/web/app/components/diff-view.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Acceptance checks in sandboxes, line comments and review verdicts | 1 | import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react"; |
| 2 | import { useState } from "react"; | |
| 3 | import { Form } from "react-router"; | |
| Diffs on attempts; hosted agent presented as the g1t agent | 4 | |
| Acceptance checks in sandboxes, line comments and review verdicts | 5 | import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts"; |
| Diffs on attempts; hosted agent presented as the g1t agent | 6 | |
| Acceptance checks in sandboxes, line comments and review verdicts | 7 | import { Markdown } from "./markdown"; |
| 8 | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 22 | /** Comments on lines of the change, and whether the viewer may add one. */ |
| 23 | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 24 | ||
| Diffs on attempts; hosted agent presented as the g1t agent | 25 | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ |
| docs.g1t.sh, generated OpenAPI with an interactive reference, full footer | 26 | function Stat({ |
| 27 | additions, | |
| 28 | deletions, | |
| 29 | }: { | |
| 30 | additions: number; | |
| 31 | deletions: number; | |
| 32 | }) { | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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} | |
| docs.g1t.sh, generated OpenAPI with an interactive reference, full footer | 43 | className={`size-2 rounded-xs ${ |
| Diffs on attempts; hosted agent presented as the g1t agent | 44 | total === 0 ? "bg-line" : i < green ? "bg-accent" : "bg-danger" |
| 45 | }`} | |
| 46 | /> | |
| 47 | ))} | |
| 48 | </span> | |
| 49 | </span> | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 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 }) { | |
| Diffs on attempts; hosted agent presented as the g1t agent | 70 | const Icon = |
| docs.g1t.sh, generated OpenAPI with an interactive reference, full footer | 71 | file.status === "added" |
| 72 | ? FilePlus | |
| 73 | : file.status === "deleted" | |
| 74 | ? FileMinus | |
| 75 | : FileIcon; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | ); | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | )} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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) => ( | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | /> | |
| Diffs on attempts; hosted agent presented as the g1t agent | 132 | ))} |
| 133 | </tbody> | |
| 134 | </table> | |
| 135 | </div> | |
| 136 | )} | |
| 137 | </section> | |
| 138 | ); | |
| 139 | } | |
| 140 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 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); | |
| Diffs on attempts; hosted agent presented as the g1t agent | 156 | return ( |
| 157 | <> | |
| 158 | {!first && ( | |
| 159 | <tr aria-hidden="true"> | |
| docs.g1t.sh, generated OpenAPI with an interactive reference, full footer | 160 | <td |
| 161 | colSpan={4} | |
| 162 | className="border-y border-line bg-surface py-1 text-center text-faint" | |
| 163 | > | |
| Diffs on attempts; hosted agent presented as the g1t agent | 164 | ⋯ |
| 165 | </td> | |
| 166 | </tr> | |
| 167 | )} | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | })} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 242 | </> |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | }) { | |
| Diffs on attempts; hosted agent presented as the g1t agent | 258 | const { files, truncated } = comparison; |
| 259 | if (files.length === 0) { | |
| 260 | return ( | |
| 261 | <EmptyState title="No changes yet"> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 262 | Nothing has been pushed to this pull request yet, or it matches the |
| 263 | branch it would merge into. | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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} /> | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | )} | |
| Diffs on attempts; hosted agent presented as the g1t agent | 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) => ( | |
| Acceptance checks in sandboxes, line comments and review verdicts | 301 | <File key={file.path} file={file} review={review} /> |
| Diffs on attempts; hosted agent presented as the g1t agent | 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 | } |