g1t/apps/web/app/components/diff-view.tsx

310 lines10,482 bytesCodeBlame

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 verdicts1import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react";
2import { useState } from "react";
3import { Form } from "react-router";
Diffs on attempts; hosted agent presented as the g1t agent4
Acceptance checks in sandboxes, line comments and review verdicts5import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts";
Diffs on attempts; hosted agent presented as the g1t agent6
Acceptance checks in sandboxes, line comments and review verdicts7import { Markdown } from "./markdown";
8import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
Diffs on attempts; hosted agent presented as the g1t agent9
10const ROW_STYLES: Record<DiffLine["kind"], string> = {
11 context: "",
12 add: "bg-accent/10",
13 delete: "bg-danger/10",
14};
15
16const MARKERS: Record<DiffLine["kind"], string> = {
17 context: " ",
18 add: "+",
19 delete: "-",
20};
21
Acceptance checks in sandboxes, line comments and review verdicts22/** Comments on lines of the change, and whether the viewer may add one. */
23export type LineReview = { comments: Comment[]; canComment: boolean };
24
Diffs on attempts; hosted agent presented as the g1t agent25/** `+12 −3` with a five-block bar, like the summary on a pull request. */
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer26function Stat({
27 additions,
28 deletions,
29}: {
30 additions: number;
31 deletions: number;
32}) {
Diffs on attempts; hosted agent presented as the g1t agent33 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 footer43 className={`size-2 rounded-xs ${
Diffs on attempts; hosted agent presented as the g1t agent44 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 verdicts53/** One comment made on a line, shown under that line. */
54function 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
69function File({ file, review }: { file: FileDiff; review?: LineReview }) {
Diffs on attempts; hosted agent presented as the g1t agent70 const Icon =
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer71 file.status === "added"
72 ? FilePlus
73 : file.status === "deleted"
74 ? FileMinus
75 : FileIcon;
Acceptance checks in sandboxes, line comments and review verdicts76 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 agent84 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 verdicts105 {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 agent113 {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 verdicts124 <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 agent132 ))}
133 </tbody>
134 </table>
135 </div>
136 )}
137 </section>
138 );
139}
140
Acceptance checks in sandboxes, line comments and review verdicts141function 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 agent156 return (
157 <>
158 {!first && (
159 <tr aria-hidden="true">
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer160 <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 agent164 ⋯
165 </td>
166 </tr>
167 )}
Acceptance checks in sandboxes, line comments and review verdicts168 {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 agent242 </>
243 );
244}
245
Acceptance checks in sandboxes, line comments and review verdicts246/** Groups a line's rows without adding an element to the table. */
247function Rows({ children }: { children: React.ReactNode }) {
248 return <>{children}</>;
249}
250
251export function DiffView({
252 comparison,
253 review,
254}: {
255 comparison: Comparison;
256 review?: LineReview;
257}) {
Diffs on attempts; hosted agent presented as the g1t agent258 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 verdicts262 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 agent264 </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 verdicts277 {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 agent282 </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 verdicts301 <File key={file.path} file={file} review={review} />
Diffs on attempts; hosted agent presented as the g1t agent302 ))}
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}