12.2 KB377 lines
Blame
1"use client";
2
3import { useState, useEffect } from "react";
4import type { ParsedFile, DiffHunk, DiffLine } from "./page";
5
6const DIFF_VIEW_KEY = "grove:diff-view";
7
8function getStoredView(): "unified" | "split" {
9 if (typeof window === "undefined") return "unified";
10 const stored = localStorage.getItem(DIFF_VIEW_KEY);
11 return stored === "split" ? "split" : "unified";
12}
13
14interface Props {
15 files: ParsedFile[];
16 owner: string;
17 repo: string;
18 gitSha: string;
19}
20
21interface SplitSide {
22 num: number | null;
23 content: string;
24 html?: string;
25 type: "del" | "add" | "context" | "empty";
26}
27
28interface SplitRow {
29 type: "context" | "add" | "del" | "change";
30 left: SplitSide;
31 right: SplitSide;
32}
33
34function buildSplitRows(hunk: DiffHunk): SplitRow[] {
35 const rows: SplitRow[] = [];
36 const lines = hunk.lines;
37 let i = 0;
38
39 while (i < lines.length) {
40 const line = lines[i];
41
42 if (line.type === "context") {
43 rows.push({
44 type: "context",
45 left: { num: line.oldNum, content: line.content, html: line.html, type: "context" },
46 right: { num: line.newNum, content: line.content, html: line.html, type: "context" },
47 });
48 i++;
49 } else {
50 // Collect consecutive del lines then add lines
51 const dels: typeof lines = [];
52 const adds: typeof lines = [];
53 while (i < lines.length && lines[i].type === "del") {
54 dels.push(lines[i]);
55 i++;
56 }
57 while (i < lines.length && lines[i].type === "add") {
58 adds.push(lines[i]);
59 i++;
60 }
61
62 const maxLen = Math.max(dels.length, adds.length);
63 for (let j = 0; j < maxLen; j++) {
64 const del = dels[j];
65 const add = adds[j];
66 rows.push({
67 type: del && add ? "change" : del ? "del" : "add",
68 left: del
69 ? { num: del.oldNum, content: del.content, html: del.html, type: "del" }
70 : { num: null, content: "", type: "empty" },
71 right: add
72 ? { num: add.newNum, content: add.content, html: add.html, type: "add" }
73 : { num: null, content: "", type: "empty" },
74 });
75 }
76 }
77 }
78
79 return rows;
80}
81
82function cellBg(type: "del" | "add" | "context" | "empty"): string {
83 if (type === "add") return "var(--diff-add-bg)";
84 if (type === "del") return "var(--diff-del-bg)";
85 return "transparent";
86}
87
88function cellColor(type: "del" | "add" | "context" | "empty"): string {
89 if (type === "add") return "var(--diff-add-text)";
90 if (type === "del") return "var(--diff-del-text)";
91 return "var(--text-secondary)";
92}
93
94export function DiffViewer({ files, owner, repo, gitSha }: Props) {
95 const [view, setView] = useState<"unified" | "split">(getStoredView);
96
97 useEffect(() => {
98 localStorage.setItem(DIFF_VIEW_KEY, view);
99 }, [view]);
100
101 let additions = 0;
102 let deletions = 0;
103 for (const file of files) {
104 for (const hunk of file.hunks) {
105 for (const line of hunk.lines) {
106 if (line.type === "add") additions++;
107 if (line.type === "del") deletions++;
108 }
109 }
110 }
111
112 return (
113 <>
114 <div className="flex items-center gap-3 text-xs mb-4" style={{ color: "var(--text-muted)" }}>
115 <span>{files.length} file{files.length !== 1 ? "s" : ""} changed</span>
116 {additions > 0 && (
117 <span style={{ color: "var(--diff-add-text)" }}>+{additions}</span>
118 )}
119 {deletions > 0 && (
120 <span style={{ color: "var(--diff-del-text)" }}>-{deletions}</span>
121 )}
122 <div className="ml-auto flex gap-1">
123 <button
124 onClick={() => setView("unified")}
125 className="px-2 py-0.5"
126 style={{
127 color: view === "unified" ? "var(--accent)" : "var(--text-faint)",
128 backgroundColor: view === "unified" ? "var(--bg-inset)" : "transparent",
129 border: "1px solid",
130 borderColor: view === "unified" ? "var(--border-subtle)" : "transparent",
131 cursor: "pointer",
132 fontSize: "0.75rem",
133 }}
134 >
135 Unified
136 </button>
137 <button
138 onClick={() => setView("split")}
139 className="px-2 py-0.5"
140 style={{
141 color: view === "split" ? "var(--accent)" : "var(--text-faint)",
142 backgroundColor: view === "split" ? "var(--bg-inset)" : "transparent",
143 border: "1px solid",
144 borderColor: view === "split" ? "var(--border-subtle)" : "transparent",
145 cursor: "pointer",
146 fontSize: "0.75rem",
147 }}
148 >
149 Split
150 </button>
151 </div>
152 </div>
153
154 {files.map((file) => (
155 <div key={file.path} className="mb-4">
156 <div
157 className="text-sm font-mono py-2 px-3"
158 style={{
159 backgroundColor: "var(--bg-inset)",
160 border: "1px solid var(--border-subtle)",
161 borderBottom: "none",
162 color: "var(--text-secondary)",
163 }}
164 >
165 <a
166 href={`/${owner}/${repo}/blob/${gitSha}/${file.path}`}
167 className="hover:underline"
168 style={{ color: "var(--accent)" }}
169 >
170 {file.path}
171 </a>
172 </div>
173 {file.hunks.length === 0 ? (
174 <div
175 className="text-sm py-3 px-3"
176 style={{
177 border: "1px solid var(--border-subtle)",
178 color: "var(--text-faint)",
179 }}
180 >
181 Binary file
182 </div>
183 ) : view === "unified" ? (
184 <UnifiedView hunks={file.hunks} />
185 ) : (
186 <SplitView hunks={file.hunks} />
187 )}
188 </div>
189 ))}
190 </>
191 );
192}
193
194function UnifiedView({ hunks }: { hunks: DiffHunk[] }) {
195 return (
196 <div className="overflow-x-auto shiki" style={{ border: "1px solid var(--border-subtle)" }}>
197 <table className="w-full text-sm font-mono" style={{ borderCollapse: "collapse" }}>
198 <tbody>
199 {hunks.flatMap((hunk, hi) => [
200 <tr key={`hdr-${hi}`}>
201 <td
202 colSpan={3}
203 className="text-xs py-1 px-3"
204 style={{
205 backgroundColor: "var(--bg-inset)",
206 color: "var(--text-faint)",
207 }}
208 >
209 {hunk.header}
210 </td>
211 </tr>,
212 ...hunk.lines.map((line, li) => (
213 <tr
214 key={`${hi}-${li}`}
215 style={{
216 backgroundColor:
217 line.type === "add"
218 ? "var(--diff-add-bg)"
219 : line.type === "del"
220 ? "var(--diff-del-bg)"
221 : "transparent",
222 }}
223 >
224 <td
225 className="text-right select-none px-1 py-0 w-8"
226 style={{
227 color: "var(--text-faint)",
228 borderRight: "1px solid var(--divide)",
229 fontSize: "0.75rem",
230 }}
231 >
232 {line.oldNum ?? ""}
233 </td>
234 <td
235 className="text-right select-none px-1 py-0 w-8"
236 style={{
237 color: "var(--text-faint)",
238 borderRight: "1px solid var(--divide)",
239 fontSize: "0.75rem",
240 }}
241 >
242 {line.newNum ?? ""}
243 </td>
244 {line.html ? (
245 <td
246 className="pl-3 py-0 whitespace-pre"
247 dangerouslySetInnerHTML={{ __html: line.html }}
248 />
249 ) : (
250 <td
251 className="pl-3 py-0 whitespace-pre"
252 style={{
253 color:
254 line.type === "add"
255 ? "var(--diff-add-text)"
256 : line.type === "del"
257 ? "var(--diff-del-text)"
258 : "var(--text-secondary)",
259 }}
260 >
261 {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
262 {line.content}
263 </td>
264 )}
265 </tr>
266 )),
267 ])}
268 </tbody>
269 </table>
270 </div>
271 );
272}
273
274function SplitView({ hunks }: { hunks: DiffHunk[] }) {
275 return (
276 <div className="overflow-x-auto shiki" style={{ border: "1px solid var(--border-subtle)" }}>
277 <table className="w-full text-sm font-mono" style={{ borderCollapse: "collapse", tableLayout: "fixed" }}>
278 <colgroup>
279 <col style={{ width: "2.5rem" }} />
280 <col />
281 <col style={{ width: "2.5rem" }} />
282 <col />
283 </colgroup>
284 <tbody>
285 {hunks.flatMap((hunk, hi) => {
286 const rows = buildSplitRows(hunk);
287 return [
288 <tr key={`hdr-${hi}`}>
289 <td
290 colSpan={4}
291 className="text-xs py-1 px-3"
292 style={{
293 backgroundColor: "var(--bg-inset)",
294 color: "var(--text-faint)",
295 }}
296 >
297 {hunk.header}
298 </td>
299 </tr>,
300 ...rows.map((row, ri) => (
301 <tr key={`${hi}-${ri}`}>
302 {/* Left side (old) */}
303 <td
304 className="text-right select-none px-1 py-0"
305 style={{
306 color: "var(--text-faint)",
307 backgroundColor: cellBg(row.left.type),
308 borderRight: "1px solid var(--divide)",
309 fontSize: "0.75rem",
310 }}
311 >
312 {row.left.num ?? ""}
313 </td>
314 {row.left.html ? (
315 <td
316 className="pl-3 py-0 whitespace-pre overflow-hidden"
317 style={{
318 backgroundColor: cellBg(row.left.type),
319 borderRight: "1px solid var(--border-subtle)",
320 }}
321 dangerouslySetInnerHTML={{ __html: row.left.html }}
322 />
323 ) : (
324 <td
325 className="pl-3 py-0 whitespace-pre overflow-hidden"
326 style={{
327 color: cellColor(row.left.type),
328 backgroundColor: cellBg(row.left.type),
329 borderRight: "1px solid var(--border-subtle)",
330 }}
331 >
332 {row.left.type === "del" ? "-" : row.left.type === "context" ? " " : ""}
333 {row.left.content}
334 </td>
335 )}
336 {/* Right side (new) */}
337 <td
338 className="text-right select-none px-1 py-0"
339 style={{
340 color: "var(--text-faint)",
341 backgroundColor: cellBg(row.right.type),
342 borderRight: "1px solid var(--divide)",
343 fontSize: "0.75rem",
344 }}
345 >
346 {row.right.num ?? ""}
347 </td>
348 {row.right.html ? (
349 <td
350 className="pl-3 py-0 whitespace-pre overflow-hidden"
351 style={{
352 backgroundColor: cellBg(row.right.type),
353 }}
354 dangerouslySetInnerHTML={{ __html: row.right.html }}
355 />
356 ) : (
357 <td
358 className="pl-3 py-0 whitespace-pre overflow-hidden"
359 style={{
360 color: cellColor(row.right.type),
361 backgroundColor: cellBg(row.right.type),
362 }}
363 >
364 {row.right.type === "add" ? "+" : row.right.type === "context" ? " " : ""}
365 {row.right.content}
366 </td>
367 )}
368 </tr>
369 )),
370 ];
371 })}
372 </tbody>
373 </table>
374 </div>
375 );
376}
377