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

310 lines10,482 bytesCodeBlame
1import { FileDiff as FileIcon, FileMinus, FilePlus, MessageSquarePlus } from "lucide-react";
2import { useState } from "react";
3import { Form } from "react-router";
4
5import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts";
6
7import { Markdown } from "./markdown";
8import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
9
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
22/** Comments on lines of the change, and whether the viewer may add one. */
23export type LineReview = { comments: Comment[]; canComment: boolean };
24
25/** `+12 −3` with a five-block bar, like the summary on a pull request. */
26function 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. */
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 }) {
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
141function 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. */
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}) {
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}