Skip to content
File

Blob: src/client/islands/commit-diff-expander.tsx

typescript378 lines
1/// <reference lib="dom" />
2 
3import type { CommitFilePatchResult } from "@/shared/git/types";
4import { useEffect, useState } from "react";
5 
6import { hydrateIsland } from "@/client/hydrate";
7import { inferHljsLang } from "@/shared/web/syntax";
8 
9export type CommitDiffEntryView = {
10 path: string;
11 changeType: "A" | "M" | "D";
12 oldOid?: string;
13 newOid?: string;
14 oldMode?: string;
15 newMode?: string;
16};
17 
18export type CommitDiffSummaryView = {
19 added: number;
20 modified: number;
21 deleted: number;
22 total: number;
23};
24 
25export type CommitDiffExpanderProps = {
26 owner: string;
27 repo: string;
28 commitOid: string;
29 refEnc: string;
30 diffBaseRefEnc: string;
31 diffCompareMode: "root" | "first-parent";
32 diffEntries: CommitDiffEntryView[];
33 diffSummary: CommitDiffSummaryView;
34 diffTruncated: boolean;
35 diffTruncateReason: "" | "max_files" | "max_tree_pairs" | "time_budget" | "soft_budget";
36 parentsCount: number;
37};
38 
39const CHANGE_META: Record<
40 CommitDiffEntryView["changeType"],
41 { label: string; cls: string; dot: string }
42> = {
43 A: {
44 label: "Added",
45 cls: "diff-badge-add",
46 dot: "●",
47 },
48 M: {
49 label: "Modified",
50 cls: "diff-badge-mod",
51 dot: "●",
52 },
53 D: {
54 label: "Deleted",
55 cls: "diff-badge-del",
56 dot: "●",
57 },
58};
59 
60function diffHref(
61 entry: CommitDiffEntryView,
62 owner: string,
63 repo: string,
64 refEnc: string,
65 diffBaseRefEnc: string
66) {
67 if (entry.changeType === "D") {
68 if (!diffBaseRefEnc) return null;
69 return `/${owner}/${repo}/blob?ref=${diffBaseRefEnc}&path=${encodeURIComponent(entry.path)}`;
70 }
71 return `/${owner}/${repo}/blob?ref=${refEnc}&path=${encodeURIComponent(entry.path)}`;
72}
73 
74function patchSummary(patch: CommitFilePatchResult): string {
75 if (patch.skipReason === "binary") {
76 return "Binary file — patch preview is unavailable.";
77 }
78 if (patch.skipReason === "too_many_lines") {
79 return "File has too many lines to render a patch preview.";
80 }
81 if (patch.skipReason === "too_large") {
82 return "File is too large to render a patch preview.";
83 }
84 return "Patch preview is unavailable.";
85}
86 
87/** Classify a raw diff line for styling. */
88function lineType(line: string): "add" | "del" | "hunk" | "ctx" {
89 if (line.startsWith("+")) return "add";
90 if (line.startsWith("-")) return "del";
91 if (line.startsWith("@@")) return "hunk";
92 return "ctx";
93}
94 
95const LINE_TYPE_CLASS: Record<ReturnType<typeof lineType>, string> = {
96 add: "diff-line-add",
97 del: "diff-line-del",
98 hunk: "diff-line-hunk",
99 ctx: "",
100};
101 
102/** Render a patch string as syntax-highlighted, line-colored diff table. */
103function DiffPatchView({ patch, filePath }: { patch: string; filePath: string }) {
104 const [renderedPatch, setRenderedPatch] = useState(() => buildDiffPatchMarkup(patch, filePath));
105 
106 useEffect(() => {
107 let cancelled = false;
108 
109 setRenderedPatch(buildDiffPatchMarkup(patch, filePath));
110 
111 const lang = inferHljsLang(filePath);
112 if (!lang) {
113 return () => {
114 cancelled = true;
115 };
116 }
117 
118 void import("@/client/components/highlight")
119 .then(({ highlightCode }) => {
120 if (cancelled) {
121 return;
122 }
123 
124 setRenderedPatch(buildDiffPatchMarkup(patch, filePath, highlightCode));
125 })
126 .catch((error) => {
127 console.error("Failed to load syntax highlighter", error);
128 });
129 
130 return () => {
131 cancelled = true;
132 };
133 }, [filePath, patch]);
134 
135 return (
136 <div className="diff-patch-container">
137 <pre className="diff-patch-pre">
138 <table className={`diff-patch-table hljs ${renderedPatch.languageClass}`}>
139 <tbody>
140 {renderedPatch.rows.map((row, i) => (
141 <tr key={i} className={LINE_TYPE_CLASS[row.type]}>
142 <td className="diff-gutter">{row.prefix}</td>
143 <td
144 className="diff-code"
145 dangerouslySetInnerHTML={{ __html: row.html || "&nbsp;" }}
146 />
147 </tr>
148 ))}
149 </tbody>
150 </table>
151 </pre>
152 </div>
153 );
154}
155 
156function buildDiffPatchMarkup(
157 patch: string,
158 filePath: string,
159 highlight?: (code: string, language?: string | null) => { html: string; languageClass: string }
160) {
161 const lang = inferHljsLang(filePath);
162 const rawLines = patch.split("\n");
163 
164 // Remove trailing empty line from split
165 if (rawLines.length > 1 && rawLines[rawLines.length - 1] === "") {
166 rawLines.pop();
167 }
168 
169 // Split lines into segments: header (before first hunk) and code lines
170 const rows: Array<{
171 type: ReturnType<typeof lineType>;
172 prefix: string;
173 html: string;
174 }> = [];
175 
176 let inHeader = true;
177 
178 for (const raw of rawLines) {
179 if (inHeader) {
180 if (raw.startsWith("@@")) {
181 inHeader = false;
182 rows.push({ type: "hunk", prefix: "", html: escapeHtml(raw) });
183 } else {
184 // diff header lines (--- a/... +++ b/... etc.)
185 rows.push({ type: "hunk", prefix: "", html: escapeHtml(raw) });
186 }
187 continue;
188 }
189 
190 const lt = lineType(raw);
191 if (lt === "hunk") {
192 rows.push({ type: "hunk", prefix: "", html: escapeHtml(raw) });
193 continue;
194 }
195 
196 // Strip prefix character for highlighting, keep it for gutter
197 const prefix = raw.length > 0 ? raw[0] : " ";
198 const code = raw.length > 1 ? raw.slice(1) : "";
199 const highlighted = highlight ? highlight(code, lang) : null;
200 
201 rows.push({ type: lt, prefix, html: highlighted ? highlighted.html : escapeHtml(code) });
202 }
203 
204 return {
205 rows,
206 languageClass: lang ? `language-${lang}` : "language-plaintext",
207 };
208}
209 
210function escapeHtml(str: string): string {
211 return str
212 .replace(/&/g, "&amp;")
213 .replace(/</g, "&lt;")
214 .replace(/>/g, "&gt;")
215 .replace(/"/g, "&quot;");
216}
217 
218export function CommitDiffExpanderIsland({
219 owner,
220 repo,
221 commitOid,
222 refEnc,
223 diffBaseRefEnc,
224 diffCompareMode,
225 diffEntries,
226 diffSummary,
227 diffTruncated,
228 diffTruncateReason,
229 parentsCount,
230}: CommitDiffExpanderProps) {
231 const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
232 const [loadingByPath, setLoadingByPath] = useState<Record<string, boolean>>({});
233 const [errorByPath, setErrorByPath] = useState<Record<string, string | null>>({});
234 const [patchByPath, setPatchByPath] = useState<Record<string, CommitFilePatchResult | undefined>>(
235 {}
236 );
237 
238 async function togglePatch(path: string) {
239 if (loadingByPath[path]) {
240 return;
241 }
242 
243 if (expandedByPath[path]) {
244 setExpandedByPath((current) => ({ ...current, [path]: false }));
245 return;
246 }
247 
248 setExpandedByPath((current) => ({ ...current, [path]: true }));
249 setErrorByPath((current) => ({ ...current, [path]: null }));
250 
251 if (patchByPath[path]) {
252 return;
253 }
254 
255 setLoadingByPath((current) => ({ ...current, [path]: true }));
256 try {
257 const response = await fetch(
258 `/${owner}/${repo}/commit/${commitOid}/diff?path=${encodeURIComponent(path)}`,
259 {
260 headers: { Accept: "application/json" },
261 }
262 );
263 if (!response.ok) {
264 throw new Error(`HTTP ${response.status}`);
265 }
266 const data = (await response.json()) as CommitFilePatchResult;
267 setPatchByPath((current) => ({ ...current, [path]: data }));
268 } catch (error) {
269 setErrorByPath((current) => ({
270 ...current,
271 [path]: error instanceof Error ? error.message : "Failed to load patch",
272 }));
273 } finally {
274 setLoadingByPath((current) => ({ ...current, [path]: false }));
275 }
276 }
277 
278 return (
279 <div className="diff-viewer">
280 {/* Summary bar */}
281 <div className="diff-summary">
282 <div className="diff-summary-stats">
283 <strong>
284 {diffSummary.total} file{diffSummary.total !== 1 ? "s" : ""} changed
285 </strong>
286 {diffSummary.added > 0 ? (
287 <span className="diff-stat-add">+{diffSummary.added} added</span>
288 ) : null}
289 {diffSummary.modified > 0 ? (
290 <span className="diff-stat-mod">~{diffSummary.modified} modified</span>
291 ) : null}
292 {diffSummary.deleted > 0 ? (
293 <span className="diff-stat-del">-{diffSummary.deleted} deleted</span>
294 ) : null}
295 </div>
296 {diffCompareMode === "first-parent" && parentsCount > 1 ? (
297 <span className="diff-compare-label">Compared against first parent</span>
298 ) : null}
299 </div>
300
301 {/* Truncation warning */}
302 {diffTruncated ? (
303 <div className="rounded-xl border border-amber-500/20 bg-amber-500/10 p-4 text-sm text-amber-300 mx-4 mt-3 mb-0">
304 Showing a partial diff list due to runtime limits
305 {diffTruncateReason ? ` (${diffTruncateReason})` : ""}.
306 </div>
307 ) : null}
308
309 {/* File list */}
310 {diffEntries.length ? (
311 <div className="diff-file-list">
312 {diffEntries.map((entry) => {
313 const href = diffHref(entry, owner, repo, refEnc, diffBaseRefEnc);
314 const isExpanded = Boolean(expandedByPath[entry.path]);
315 const isLoading = Boolean(loadingByPath[entry.path]);
316 const error = errorByPath[entry.path];
317 const patch = patchByPath[entry.path];
318 const meta = CHANGE_META[entry.changeType];
319
320 return (
321 <div key={`${entry.changeType}:${entry.path}`} className="diff-file-entry">
322 {/* File row */}
323 <div className="diff-file-row">
324 <div className="diff-file-info">
325 <span className={`diff-badge ${meta.cls}`} title={meta.label}>
326 {entry.changeType}
327 </span>
328 <span className="diff-file-path">
329 {href ? <a href={href}>{entry.path}</a> : <span>{entry.path}</span>}
330 </span>
331 </div>
332 <button
333 className={`diff-toggle-btn ${isExpanded ? "active" : ""}`}
334 type="button"
335 onClick={() => void togglePatch(entry.path)}
336 >
337 <span className="diff-toggle-icon">{isExpanded ? "▾" : "▸"}</span>
338 {isExpanded ? "Hide patch" : "Show patch"}
339 </button>
340 </div>
341
342 {/* Expanded patch area */}
343 {isExpanded ? (
344 <div className="diff-expand-area">
345 {isLoading ? (
346 <div className="diff-loading">
347 <span className="diff-spinner" />
348 Loading patch…
349 </div>
350 ) : null}
351 {!isLoading && error ? (
352 <div className="rounded-xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-400 mb-0">
353 Failed to load patch: {error}
354 </div>
355 ) : null}
356 {!isLoading && !error && patch?.patch ? (
357 <DiffPatchView patch={patch.patch} filePath={entry.path} />
358 ) : null}
359 {!isLoading && !error && patch && !patch.patch ? (
360 <div className="diff-skip-msg">{patchSummary(patch)}</div>
361 ) : null}
362 </div>
363 ) : null}
364 </div>
365 );
366 })}
367 </div>
368 ) : (
369 <div className="diff-empty">No changed files were detected for this commit.</div>
370 )}
371 </div>
372 );
373}
374 
375export function initCommitDiffExpander() {
376 hydrateIsland<CommitDiffExpanderProps>("commit-diff-expander", CommitDiffExpanderIsland);
377}