Commitea9ed4cunknown_key
polish(diff): Vercel-quality file header + tinted hunks + syntax + tabular numbers
4 files changed+950−23ea9ed4c4e2fa04e7a14d501c33d5727d4c95c9d7
4 changed files+950−23
Modifiedsrc/routes/compare.tsx+7−2View fileUnifiedSplit
@@ -17,7 +17,8 @@
1717
1818import { Hono } from "hono";
1919import { Layout } from "../views/layout";
20import { RepoHeader, DiffView } from "../views/components";
20import { RepoHeader } from "../views/components";
21import { DiffView } from "../views/diff-view";
2122import { IssueNav } from "./issues";
2223import {
2324 listBranches,
@@ -734,7 +735,11 @@ compare.get("/:owner/:repo/compare/:spec?", async (c) => {
734735 </span>
735736 </span>
736737 </div>
737 <DiffView raw={raw} files={files} />
738 <DiffView
739 raw={raw}
740 files={files}
741 viewFileBase={`/${owner}/${repo}/blob/${head}`}
742 />
738743 </>
739744 )}
740745 </Layout>
Modifiedsrc/routes/pulls.tsx+110−19View fileUnifiedSplit
@@ -25,7 +25,8 @@ import {
2525 issueComments,
2626} from "../db/schema";
2727import { Layout } from "../views/layout";
28import { RepoHeader, DiffView } from "../views/components";
28import { RepoHeader } from "../views/components";
29import { DiffView } from "../views/diff-view";
2930import { ReactionsBar } from "../views/reactions";
3031import { summariseReactions } from "../lib/reactions";
3132import { loadPrTemplate } from "../lib/templates";
@@ -293,19 +294,51 @@ const PRS_LIST_STYLES = `
293294 }
294295
295296 .prs-empty {
296 padding: 36px 24px;
297 position: relative;
298 padding: 56px 32px;
297299 text-align: center;
298300 border: 1px dashed var(--border);
299 border-radius: 12px;
300 background: var(--bg-secondary);
301 border-radius: 16px;
302 background: var(--bg-elevated);
301303 color: var(--text-muted);
304 overflow: hidden;
305 }
306 .prs-empty::before {
307 content: '';
308 position: absolute;
309 inset: -40% -20% auto auto;
310 width: 320px; height: 320px;
311 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
312 filter: blur(70px);
313 opacity: 0.55;
314 pointer-events: none;
315 animation: prsEmptyOrb 16s ease-in-out infinite;
316 }
317 prsEmptyOrb {
318 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
319 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
320 }
321 (prefers-reduced-motion: reduce) {
322 .prs-empty::before { animation: none; }
302323 }
324 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
303325 .prs-empty strong {
304326 display: block;
305327 color: var(--text-strong);
306 font-size: 15px;
307 margin-bottom: 4px;
328 font-family: var(--font-display);
329 font-size: 22px;
330 font-weight: 700;
331 letter-spacing: -0.018em;
332 margin-bottom: 2px;
308333 }
334 .prs-empty-sub {
335 font-size: 14.5px;
336 color: var(--text-muted);
337 line-height: 1.55;
338 max-width: 460px;
339 margin: 0 0 18px;
340 }
341 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
309342
310343 (max-width: 720px) {
311344 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
@@ -903,6 +936,43 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
903936 const user = c.get("user");
904937 const state = c.req.query("state") || "open";
905938
939 // ── Loading skeleton (flag-gated) ──
940 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
941 // the user see the page structure before counts + select resolve.
942 // Behind a flag for now — we don't ship flashes.
943 if (c.req.query("skeleton") === "1") {
944 return c.html(
945 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
946 <RepoHeader owner={ownerName} repo={repoName} />
947 <PrNav owner={ownerName} repo={repoName} active="pulls" />
948 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
949 <style
950 dangerouslySetInnerHTML={{
951 __html: `
952 .prs-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: prsSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
953 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
954 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
955 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
956 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
957 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
958 .prs-skel-row { height: 76px; border-radius: 12px; }
959 `,
960 }}
961 />
962 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
963 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
964 <div class="prs-skel-list" aria-hidden="true">
965 {Array.from({ length: 6 }).map(() => (
966 <div class="prs-skel prs-skel-row" />
967 ))}
968 </div>
969 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
970 Loading pull requests for {ownerName}/{repoName}…
971 </span>
972 </Layout>
973 );
974 }
975
906976 const resolved = await resolveRepo(ownerName, repoName);
907977 if (!resolved) return c.notFound();
908978
@@ -1004,18 +1074,35 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
10041074
10051075 {prList.length === 0 ? (
10061076 <div class="prs-empty">
1007 <strong>
1008 {isAllState
1009 ? "Pick a filter above to browse PRs."
1010 : `No ${state} pull requests.`}
1011 </strong>
1012 <span>
1013 {state === "open"
1014 ? "Push a branch and open one to kick off AI review + gate checks."
1015 : isAllState
1016 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1017 : "Try a different filter."}
1018 </span>
1077 <div class="prs-empty-inner">
1078 <strong>
1079 {isAllState
1080 ? "Pick a filter above to browse PRs."
1081 : `No ${state} pull requests.`}
1082 </strong>
1083 <p class="prs-empty-sub">
1084 {state === "open"
1085 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1086 : isAllState
1087 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1088 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1089 </p>
1090 <div class="prs-empty-cta">
1091 {user && state === "open" && (
1092 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1093 + New pull request
1094 </a>
1095 )}
1096 {state !== "open" && (
1097 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1098 View open PRs
1099 </a>
1100 )}
1101 <a href={`/${ownerName}/${repoName}`} class="btn">
1102 Back to code
1103 </a>
1104 </div>
1105 </div>
10191106 </div>
10201107 ) : (
10211108 <div class="prs-list">
@@ -1573,7 +1660,11 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
15731660 </nav>
15741661
15751662 {tab === "files" ? (
1576 <DiffView raw={diffRaw} files={diffFiles} />
1663 <DiffView
1664 raw={diffRaw}
1665 files={diffFiles}
1666 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
1667 />
15771668 ) : (
15781669 <>
15791670 {pr.body && (
Modifiedsrc/routes/web.tsx+6−2View fileUnifiedSplit
@@ -21,12 +21,12 @@ import {
2121 Breadcrumb,
2222 FileTable,
2323 CommitList,
24 DiffView,
2524 RepoCard,
2625 BranchSwitcher,
2726 HighlightedCode,
2827 PlainCode,
2928} from "../views/components";
29import { DiffView } from "../views/diff-view";
3030import {
3131 getTree,
3232 getBlob,
@@ -2935,7 +2935,11 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
29352935 </div>
29362936 )}
29372937 </div>
2938 <DiffView raw={raw} files={files} />
2938 <DiffView
2939 raw={raw}
2940 files={files}
2941 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
2942 />
29392943 </Layout>
29402944 );
29412945});
Addedsrc/views/diff-view.tsx+827−0View fileUnifiedSplit
@@ -0,0 +1,827 @@
1/**
2 * DiffView — polished file-diff renderer for PR detail, commit detail, and
3 * compare pages.
4 *
5 * Visual goals (Vercel-quality):
6 * • file header with filename in mono, copy-path icon, expand/collapse,
7 * traffic-light +X/-Y stat pills, file-status pill
8 * • unified diff body with subtle row tints, tabular-numbers gutter,
9 * gradient hairlines between hunks
10 * • syntax highlighting via src/lib/highlight.ts (reuses the blob theme)
11 * • optional "View file at this revision" link
12 * • big-file fallback (skip render if a single file > BIG_FILE_LINES)
13 *
14 * IMPORTANT: this lives outside src/views/components.tsx on purpose — that
15 * file is locked. The legacy DiffView export in components.tsx is no longer
16 * imported by any route, but is left intact to avoid touching the locked file.
17 */
18
19import type { FC } from "hono/jsx";
20import type { GitDiffFile } from "../git/repository";
21import { highlightCode } from "../lib/highlight";
22
23/**
24 * Render a span whose inner HTML is a pre-rendered highlight.js string.
25 * Falls back to plain escaped text when `html` is null.
26 */
27const CodeSpan: FC<{ html: string | null; text: string }> = ({ html: h, text }) => {
28 if (h != null) {
29 return (
30 <span class="diff-code" dangerouslySetInnerHTML={{ __html: h }} />
31 );
32 }
33 return <span class="diff-code">{text || ""}</span>;
34};
35
36// ─── Types ──────────────────────────────────────────────────────────────
37
38interface ParsedHunk {
39 header: string;
40 oldStart: number;
41 newStart: number;
42 lines: Array<{
43 kind: "ctx" | "add" | "del";
44 text: string;
45 oldNum: number | null;
46 newNum: number | null;
47 }>;
48}
49
50interface ParsedFile {
51 /** path shown to the user (== new path for renames, else the only path) */
52 path: string;
53 /** original path on a rename, otherwise null */
54 oldPath: string | null;
55 status: "added" | "modified" | "renamed" | "deleted" | "binary";
56 binary: boolean;
57 /** raw line count across all hunks — for big-file fallback */
58 lineCount: number;
59 hunks: ParsedHunk[];
60}
61
62// Skip inline rendering for individual files larger than this. Mirrors the
63// "huge diff" UX from GitHub — keeps the page responsive on monster PRs.
64const BIG_FILE_LINES = 1000;
65
66// ─── Parser ─────────────────────────────────────────────────────────────
67
68/**
69 * Parse `git diff` output into structured per-file hunks. Robust against
70 * the various preamble lines (`index …`, `new file mode …`, rename
71 * headers, binary markers).
72 */
73function parseUnifiedDiff(raw: string): ParsedFile[] {
74 const files: ParsedFile[] = [];
75 if (!raw) return files;
76
77 const diffStart = /^diff --git a\/(.+?) b\/(.+)$/;
78 const hunkStart = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
79
80 let cur: ParsedFile | null = null;
81 let curHunk: ParsedHunk | null = null;
82 let oldNum = 0;
83 let newNum = 0;
84
85 const flushHunk = () => {
86 if (cur && curHunk) {
87 cur.hunks.push(curHunk);
88 cur.lineCount += curHunk.lines.length;
89 }
90 curHunk = null;
91 };
92 const flushFile = () => {
93 flushHunk();
94 if (cur) files.push(cur);
95 cur = null;
96 };
97
98 for (const line of raw.split("\n")) {
99 const dm = line.match(diffStart);
100 if (dm) {
101 flushFile();
102 const [, oldP, newP] = dm;
103 cur = {
104 path: newP,
105 oldPath: oldP !== newP ? oldP : null,
106 status: oldP !== newP ? "renamed" : "modified",
107 binary: false,
108 lineCount: 0,
109 hunks: [],
110 };
111 continue;
112 }
113 if (!cur) continue;
114
115 // Preamble: status discovery.
116 if (line.startsWith("new file mode")) cur.status = "added";
117 else if (line.startsWith("deleted file mode")) cur.status = "deleted";
118 else if (line.startsWith("rename from")) cur.status = "renamed";
119 else if (line.startsWith("Binary files")) {
120 cur.binary = true;
121 cur.status = cur.status === "modified" ? "binary" : cur.status;
122 continue;
123 }
124
125 // Skip the headers we don't render.
126 if (
127 line.startsWith("index ") ||
128 line.startsWith("--- ") ||
129 line.startsWith("+++ ") ||
130 line.startsWith("new file mode") ||
131 line.startsWith("deleted file mode") ||
132 line.startsWith("old mode") ||
133 line.startsWith("new mode") ||
134 line.startsWith("similarity index") ||
135 line.startsWith("rename from") ||
136 line.startsWith("rename to") ||
137 line.startsWith("copy from") ||
138 line.startsWith("copy to") ||
139 line.startsWith("Binary files") ||
140 line.startsWith("GIT binary patch")
141 ) {
142 continue;
143 }
144
145 const hm = line.match(hunkStart);
146 if (hm) {
147 flushHunk();
148 oldNum = parseInt(hm[1], 10);
149 newNum = parseInt(hm[2], 10);
150 curHunk = {
151 header: line,
152 oldStart: oldNum,
153 newStart: newNum,
154 lines: [],
155 };
156 continue;
157 }
158 if (!curHunk) continue;
159
160 if (line.startsWith("+")) {
161 curHunk.lines.push({
162 kind: "add",
163 text: line.slice(1),
164 oldNum: null,
165 newNum: newNum++,
166 });
167 } else if (line.startsWith("-")) {
168 curHunk.lines.push({
169 kind: "del",
170 text: line.slice(1),
171 oldNum: oldNum++,
172 newNum: null,
173 });
174 } else if (line.startsWith("\\")) {
175 // "\\ No newline at end of file" — keep as a context marker.
176 curHunk.lines.push({
177 kind: "ctx",
178 text: line,
179 oldNum: null,
180 newNum: null,
181 });
182 } else {
183 // Context line: leading space (or empty on a fully empty line).
184 curHunk.lines.push({
185 kind: "ctx",
186 text: line.startsWith(" ") ? line.slice(1) : line,
187 oldNum: oldNum++,
188 newNum: newNum++,
189 });
190 }
191 }
192 flushFile();
193 return files;
194}
195
196// ─── Stat aggregation ───────────────────────────────────────────────────
197
198function countAddsDels(file: ParsedFile): { add: number; del: number } {
199 let add = 0;
200 let del = 0;
201 for (const h of file.hunks) {
202 for (const ln of h.lines) {
203 if (ln.kind === "add") add += 1;
204 else if (ln.kind === "del") del += 1;
205 }
206 }
207 return { add, del };
208}
209
210// ─── Per-line syntax highlighting ───────────────────────────────────────
211
212/**
213 * Run the existing highlight.js pipeline on the file's logical post-change
214 * content, then split it by line so each diff row gets its colored span.
215 * If hljs can't determine a language, we fall back to plain escaped text.
216 */
217function highlightFile(
218 filename: string,
219 hunks: ParsedHunk[]
220): { perLine: Map<string, string>; language: string | null } {
221 // Build a synthetic representation of "what the file might look like"
222 // by joining every non-deleted line. This lets hljs see enough syntax
223 // context that the highlight is meaningful.
224 const segments: string[] = [];
225 for (const h of hunks) {
226 for (const ln of h.lines) {
227 if (ln.kind === "del") continue;
228 segments.push(ln.text);
229 }
230 }
231 const joined = segments.join("\n");
232 const { html: highlighted, language } = highlightCode(joined, filename);
233
234 // Map each non-deleted line back to its highlighted variant by index.
235 const splitLines = highlighted.split("\n");
236 const perLine = new Map<string, string>();
237 let idx = 0;
238 for (const h of hunks) {
239 for (const ln of h.lines) {
240 if (ln.kind === "del") continue;
241 // key uses both hunk identity + line number to disambiguate
242 const key = `${h.newStart}:${ln.newNum ?? "x"}:${idx}`;
243 perLine.set(key, splitLines[idx] ?? escapeHtml(ln.text));
244 idx += 1;
245 }
246 }
247 return { perLine, language };
248}
249
250function highlightDeletedLines(
251 filename: string,
252 hunks: ParsedHunk[]
253): Map<string, string> {
254 const segments: string[] = [];
255 for (const h of hunks) {
256 for (const ln of h.lines) {
257 if (ln.kind !== "del") continue;
258 segments.push(ln.text);
259 }
260 }
261 const joined = segments.join("\n");
262 if (!joined) return new Map();
263 const { html: highlighted } = highlightCode(joined, filename);
264 const splitLines = highlighted.split("\n");
265 const perLine = new Map<string, string>();
266 let idx = 0;
267 for (const h of hunks) {
268 for (const ln of h.lines) {
269 if (ln.kind !== "del") continue;
270 const key = `${h.oldStart}:${ln.oldNum ?? "x"}:${idx}`;
271 perLine.set(key, splitLines[idx] ?? escapeHtml(ln.text));
272 idx += 1;
273 }
274 }
275 return perLine;
276}
277
278function escapeHtml(str: string): string {
279 return str
280 .replace(/&/g, "&")
281 .replace(/</g, "<")
282 .replace(/>/g, ">")
283 .replace(/"/g, """);
284}
285
286// ─── File header pieces ─────────────────────────────────────────────────
287
288const StatusPill: FC<{ status: ParsedFile["status"] }> = ({ status }) => {
289 const label =
290 status === "added"
291 ? "Added"
292 : status === "deleted"
293 ? "Deleted"
294 : status === "renamed"
295 ? "Renamed"
296 : status === "binary"
297 ? "Binary"
298 : "Modified";
299 return <span class={`diff-status diff-status-${status}`}>{label}</span>;
300};
301
302const StatPills: FC<{ add: number; del: number }> = ({ add, del }) => (
303 <span class="diff-stat-pills" aria-label={`+${add} additions, -${del} deletions`}>
304 <span class="diff-stat-pill diff-stat-add">+{add}</span>
305 <span class="diff-stat-pill diff-stat-del">−{del}</span>
306 </span>
307);
308
309// ─── Public component ──────────────────────────────────────────────────
310
311export interface DiffViewProps {
312 raw: string;
313 files: GitDiffFile[];
314 /** When set, file headers gain "View file" links to `${viewFileBase}/${path}`. */
315 viewFileBase?: string;
316}
317
318export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase }) => {
319 const parsed = parseUnifiedDiff(raw);
320
321 // Stat fallback: if --numstat gave us per-file counts they trump our
322 // hunk-based count (only --numstat sees binary deltas accurately).
323 const statByPath = new Map<string, { add: number; del: number }>();
324 for (const f of files) {
325 statByPath.set(f.path, { add: f.additions, del: f.deletions });
326 }
327 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
328 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
329
330 return (
331 <div class="diff-view">
332 <style dangerouslySetInnerHTML={{ __html: DIFF_VIEW_CSS }} />
333
334 <div class="diff-summary">
335 <span class="diff-summary-count">
336 <strong>{files.length || parsed.length}</strong>{" "}
337 changed file{(files.length || parsed.length) !== 1 ? "s" : ""}
338 </span>
339 <StatPills add={totalAdd} del={totalDel} />
340 </div>
341
342 {parsed.map((file, fIdx) => {
343 const counts =
344 statByPath.get(file.path) ??
345 statByPath.get(file.oldPath ?? "") ??
346 countAddsDels(file);
347 const id = `diff-file-${fIdx}`;
348 const tooBig = file.lineCount > BIG_FILE_LINES;
349 const showDeletedOnly = file.status === "deleted";
350 const blobHref = viewFileBase
351 ? `${viewFileBase}/${file.path}`
352 : null;
353
354 // Highlight the post-change view + (separately) the pre-change view
355 // for deletions, so syntax colors survive both sides of a diff.
356 const { perLine: addedHighlights, language } = file.binary || tooBig
357 ? { perLine: new Map<string, string>(), language: null }
358 : highlightFile(file.path, file.hunks);
359 const deletedHighlights = file.binary || tooBig
360 ? new Map<string, string>()
361 : highlightDeletedLines(file.path, file.hunks);
362
363 return (
364 <details class="diff-file" id={id} open>
365 <summary class="diff-file-summary">
366 <span class="diff-file-chevron" aria-hidden="true">{"▾"}</span>
367 <StatusPill status={file.status} />
368 <span class="diff-file-path" title={file.path}>
369 {file.oldPath ? (
370 <>
371 <span class="diff-file-old">{file.oldPath}</span>
372 <span class="diff-file-arrow" aria-hidden="true">{" → "}</span>
373 <span class="diff-file-new">{file.path}</span>
374 </>
375 ) : (
376 file.path
377 )}
378 </span>
379 <button
380 type="button"
381 class="diff-file-copy"
382 data-copy={file.path}
383 title="Copy path"
384 aria-label={`Copy path ${file.path}`}
385 >
386 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
387 <path
388 fill="currentColor"
389 d="M4 1.5h6A1.5 1.5 0 0 1 11.5 3v1h-1V3a.5.5 0 0 0-.5-.5H4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h1v1H4A1.5 1.5 0 0 1 2.5 11V3A1.5 1.5 0 0 1 4 1.5Z"
390 />
391 <path
392 fill="currentColor"
393 d="M6 5.5h6A1.5 1.5 0 0 1 13.5 7v6A1.5 1.5 0 0 1 12 14.5H6A1.5 1.5 0 0 1 4.5 13V7A1.5 1.5 0 0 1 6 5.5Zm0 1A.5.5 0 0 0 5.5 7v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V7a.5.5 0 0 0-.5-.5H6Z"
394 />
395 </svg>
396 </button>
397 <span class="diff-file-spacer" />
398 <StatPills add={counts.add} del={counts.del} />
399 {blobHref && (
400 <a
401 href={blobHref}
402 class="diff-file-blob-link"
403 title="View file at this revision"
404 >
405 View file
406 </a>
407 )}
408 </summary>
409
410 {file.binary ? (
411 <div class="diff-empty">Binary file not shown.</div>
412 ) : tooBig ? (
413 <div class="diff-empty diff-empty-big">
414 Large file ({file.lineCount.toLocaleString()} lines).{" "}
415 {blobHref ? (
416 <a href={blobHref}>Load full file</a>
417 ) : (
418 "Skipped inline render."
419 )}
420 </div>
421 ) : file.hunks.length === 0 ? (
422 <div class="diff-empty">No textual changes.</div>
423 ) : (
424 <div class={`diff-body${language ? " has-hljs" : ""}`}>
425 {file.hunks.map((hunk, hIdx) => (
426 <>
427 {hIdx > 0 && <div class="diff-hunk-gap" aria-hidden="true" />}
428 <div class="diff-hunk-header" role="separator">
429 <span class="diff-hunk-header-text">{hunk.header}</span>
430 </div>
431 {hunk.lines.map((ln, lIdx) => {
432 const key =
433 ln.kind === "del"
434 ? `${hunk.oldStart}:${ln.oldNum ?? "x"}`
435 : `${hunk.newStart}:${ln.newNum ?? "x"}`;
436 // Lookup the highlight match. Our maps key with an
437 // index suffix, so iterate to find it (cheap — small).
438 let highlighted: string | null = null;
439 if (showDeletedOnly || ln.kind === "del") {
440 for (const [k, v] of deletedHighlights) {
441 if (k.startsWith(`${hunk.oldStart}:${ln.oldNum ?? "x"}:`)) {
442 highlighted = v;
443 deletedHighlights.delete(k);
444 break;
445 }
446 }
447 } else {
448 for (const [k, v] of addedHighlights) {
449 if (k.startsWith(`${hunk.newStart}:${ln.newNum ?? "x"}:`)) {
450 highlighted = v;
451 addedHighlights.delete(k);
452 break;
453 }
454 }
455 }
456 const marker =
457 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
458 return (
459 <div
460 class={`diff-row diff-row-${ln.kind}`}
461 data-line={key}
462 >
463 <span class="diff-gutter diff-gutter-old">
464 {ln.oldNum ?? ""}
465 </span>
466 <span class="diff-gutter diff-gutter-new">
467 {ln.newNum ?? ""}
468 </span>
469 <span class="diff-marker" aria-hidden="true">
470 {marker}
471 </span>
472 <CodeSpan html={highlighted} text={ln.text} />
473 </div>
474 );
475 })}
476 </>
477 ))}
478 </div>
479 )}
480 </details>
481 );
482 })}
483
484 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
485 </div>
486 );
487};
488
489// ─── Inline script: copy-path button ───────────────────────────────────
490
491const DIFF_VIEW_JS = `
492(function () {
493 document.addEventListener('click', function (e) {
494 var t = e.target;
495 if (!t) return;
496 var btn = t.closest && t.closest('.diff-file-copy');
497 if (!btn) return;
498 e.preventDefault();
499 var path = btn.getAttribute('data-copy') || '';
500 if (!navigator.clipboard) return;
501 navigator.clipboard.writeText(path).then(function () {
502 btn.classList.add('is-copied');
503 setTimeout(function () { btn.classList.remove('is-copied'); }, 1200);
504 }).catch(function () {});
505 });
506})();
507`;
508
509// ─── Inline CSS ────────────────────────────────────────────────────────
510
511const DIFF_VIEW_CSS = `
512 .diff-view {
513 margin-top: 16px;
514 font-family: var(--font-mono);
515 }
516 .diff-summary {
517 display: flex;
518 align-items: center;
519 gap: 12px;
520 margin-bottom: 16px;
521 padding: 10px 14px;
522 background: var(--bg-elevated);
523 border: 1px solid var(--border);
524 border-radius: var(--r-md);
525 font-family: var(--font-sans, inherit);
526 font-size: 13px;
527 color: var(--text-muted);
528 }
529 .diff-summary-count strong { color: var(--text); }
530
531 .diff-stat-pills {
532 display: inline-flex;
533 align-items: center;
534 gap: 4px;
535 font-variant-numeric: tabular-nums;
536 }
537 .diff-stat-pill {
538 display: inline-flex;
539 align-items: center;
540 padding: 2px 8px;
541 border-radius: 999px;
542 font-size: 12px;
543 font-weight: 600;
544 font-family: var(--font-mono);
545 line-height: 1.4;
546 }
547 .diff-stat-add {
548 color: #6ee7b7;
549 background: rgba(52,211,153,0.12);
550 border: 1px solid rgba(52,211,153,0.22);
551 }
552 .diff-stat-del {
553 color: #fca5a5;
554 background: rgba(248,113,113,0.10);
555 border: 1px solid rgba(248,113,113,0.22);
556 }
557
558 .diff-file {
559 margin-bottom: 18px;
560 border: 1px solid var(--border);
561 border-radius: var(--r-md);
562 background: var(--bg-elevated);
563 overflow: hidden;
564 position: relative;
565 }
566 .diff-file::before {
567 content: '';
568 position: absolute;
569 top: 0; left: 0; right: 0;
570 height: 1px;
571 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
572 opacity: 0.7;
573 pointer-events: none;
574 }
575
576 .diff-file-summary {
577 list-style: none;
578 display: flex;
579 align-items: center;
580 gap: 10px;
581 padding: 10px 14px;
582 background: var(--bg-secondary);
583 border-bottom: 1px solid var(--border);
584 cursor: pointer;
585 user-select: none;
586 font-family: var(--font-sans, inherit);
587 font-size: 13px;
588 }
589 .diff-file-summary::-webkit-details-marker { display: none; }
590 .diff-file[open] .diff-file-summary { border-bottom: 1px solid var(--border); }
591 .diff-file:not([open]) .diff-file-summary { border-bottom: 0; }
592
593 .diff-file-chevron {
594 display: inline-flex;
595 color: var(--text-muted);
596 transition: transform 120ms ease;
597 width: 12px;
598 text-align: center;
599 }
600 .diff-file:not([open]) .diff-file-chevron { transform: rotate(-90deg); }
601
602 .diff-file-path {
603 font-family: var(--font-mono);
604 color: var(--text);
605 font-weight: 500;
606 overflow: hidden;
607 text-overflow: ellipsis;
608 white-space: nowrap;
609 }
610 .diff-file-old { color: var(--text-muted); text-decoration: line-through; }
611 .diff-file-arrow { color: var(--text-muted); }
612 .diff-file-new { color: var(--text); }
613
614 .diff-file-copy {
615 background: transparent;
616 border: 1px solid transparent;
617 border-radius: 6px;
618 color: var(--text-muted);
619 width: 24px;
620 height: 24px;
621 display: inline-flex;
622 align-items: center;
623 justify-content: center;
624 cursor: pointer;
625 padding: 0;
626 transition: all 120ms ease;
627 }
628 .diff-file-copy:hover {
629 color: var(--text);
630 background: var(--bg-elevated);
631 border-color: var(--border);
632 }
633 .diff-file-copy.is-copied {
634 color: var(--green, #6ee7b7);
635 background: rgba(52,211,153,0.12);
636 border-color: rgba(52,211,153,0.30);
637 }
638 .diff-file-copy.is-copied::after {
639 content: 'Copied';
640 position: absolute;
641 margin-left: 28px;
642 font-size: 11px;
643 color: var(--green, #6ee7b7);
644 font-family: var(--font-sans, inherit);
645 }
646
647 .diff-file-spacer { flex: 1; }
648
649 .diff-file-blob-link {
650 font-family: var(--font-sans, inherit);
651 font-size: 12px;
652 color: var(--text-muted);
653 padding: 3px 10px;
654 border-radius: 6px;
655 border: 1px solid var(--border);
656 background: var(--bg-elevated);
657 text-decoration: none;
658 transition: all 120ms ease;
659 }
660 .diff-file-blob-link:hover {
661 color: var(--text);
662 border-color: rgba(140,109,255,0.45);
663 background: var(--accent-gradient-faint, var(--bg-elevated));
664 }
665
666 .diff-status {
667 display: inline-flex;
668 align-items: center;
669 padding: 2px 8px;
670 border-radius: 4px;
671 font-size: 11px;
672 font-weight: 600;
673 font-family: var(--font-sans, inherit);
674 text-transform: uppercase;
675 letter-spacing: 0.04em;
676 line-height: 1.5;
677 border: 1px solid transparent;
678 }
679 .diff-status-added {
680 color: #6ee7b7;
681 background: rgba(52,211,153,0.10);
682 border-color: rgba(52,211,153,0.22);
683 }
684 .diff-status-modified {
685 color: #fcd34d;
686 background: rgba(252,211,77,0.08);
687 border-color: rgba(252,211,77,0.22);
688 }
689 .diff-status-renamed {
690 color: #93c5fd;
691 background: rgba(147,197,253,0.10);
692 border-color: rgba(147,197,253,0.25);
693 }
694 .diff-status-deleted {
695 color: #fca5a5;
696 background: rgba(248,113,113,0.10);
697 border-color: rgba(248,113,113,0.22);
698 }
699 .diff-status-binary {
700 color: var(--text-muted);
701 background: var(--bg-elevated);
702 border-color: var(--border);
703 }
704
705 /* ─── Diff body ─── */
706 .diff-body {
707 font-family: var(--font-mono);
708 font-size: 12.5px;
709 line-height: 1.55;
710 overflow-x: auto;
711 }
712 .diff-empty {
713 padding: 18px 16px;
714 text-align: center;
715 color: var(--text-muted);
716 font-family: var(--font-sans, inherit);
717 font-size: 13px;
718 }
719 .diff-empty-big { background: rgba(140,109,255,0.04); }
720
721 .diff-hunk-gap {
722 height: 1px;
723 background: linear-gradient(90deg, transparent 0%, var(--border) 25%, var(--border) 75%, transparent 100%);
724 margin: 0;
725 opacity: 0.7;
726 }
727 .diff-hunk-header {
728 padding: 4px 16px 4px 86px;
729 background: rgba(140,109,255,0.05);
730 color: var(--text-muted);
731 font-size: 11.5px;
732 border-top: 1px solid rgba(140,109,255,0.18);
733 border-bottom: 1px solid rgba(140,109,255,0.18);
734 }
735 .diff-hunk-header-text { font-family: var(--font-mono); }
736
737 .diff-row {
738 display: grid;
739 grid-template-columns: 44px 44px 16px 1fr;
740 align-items: stretch;
741 min-height: 1.55em;
742 }
743 .diff-gutter {
744 text-align: right;
745 padding: 0 6px;
746 color: var(--text-muted);
747 background: var(--bg-elevated);
748 border-right: 1px solid var(--border);
749 user-select: none;
750 font-variant-numeric: tabular-nums;
751 font-size: 11.5px;
752 opacity: 0.75;
753 }
754 .diff-gutter-new { border-right: 1px solid var(--border); }
755
756 .diff-marker {
757 text-align: center;
758 color: var(--text-muted);
759 user-select: none;
760 background: var(--bg-elevated);
761 border-right: 1px solid var(--border);
762 }
763 .diff-code {
764 padding: 0 12px;
765 white-space: pre;
766 color: var(--text);
767 overflow-x: visible;
768 }
769
770 /* Row tints — additions / deletions / context */
771 .diff-row-add {
772 background: rgba(52,211,153,0.08);
773 }
774 .diff-row-add .diff-gutter,
775 .diff-row-add .diff-marker {
776 background: rgba(52,211,153,0.14);
777 color: #6ee7b7;
778 border-right-color: rgba(52,211,153,0.20);
779 }
780 .diff-row-add .diff-marker { color: #6ee7b7; font-weight: 600; }
781
782 .diff-row-del {
783 background: rgba(248,113,113,0.08);
784 }
785 .diff-row-del .diff-gutter,
786 .diff-row-del .diff-marker {
787 background: rgba(248,113,113,0.14);
788 color: #fca5a5;
789 border-right-color: rgba(248,113,113,0.20);
790 }
791 .diff-row-del .diff-marker { color: #fca5a5; font-weight: 600; }
792
793 .diff-row:hover .diff-gutter { opacity: 1; }
794
795 /* Highlight.js theme overrides so colors layer correctly on tints */
796 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
797 .diff-body.has-hljs .hljs-built_in,
798 .diff-body.has-hljs .hljs-type { color: #ffa657; }
799 .diff-body.has-hljs .hljs-literal,
800 .diff-body.has-hljs .hljs-number { color: #79c0ff; }
801 .diff-body.has-hljs .hljs-string { color: #a5d6ff; }
802 .diff-body.has-hljs .hljs-title,
803 .diff-body.has-hljs .hljs-title.function_ { color: #d2a8ff; }
804 .diff-body.has-hljs .hljs-comment { color: #8b949e; font-style: italic; }
805 .diff-body.has-hljs .hljs-attr,
806 .diff-body.has-hljs .hljs-attribute,
807 .diff-body.has-hljs .hljs-meta { color: #79c0ff; }
808 .diff-body.has-hljs .hljs-tag,
809 .diff-body.has-hljs .hljs-name { color: #7ee787; }
810 .diff-body.has-hljs .hljs-variable,
811 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
812
813 /* ─── Split-view scaffolding (phase 2 placeholder) ───
814 The .diff-body element is grid-friendly; a future toggle can swap to
815 'diff-body diff-split' and the per-row grid expands to two code panes. */
816 .diff-body.diff-split .diff-row {
817 grid-template-columns: 44px 1fr 44px 1fr;
818 }
819
820 @media (max-width: 720px) {
821 .diff-row {
822 grid-template-columns: 32px 32px 14px 1fr;
823 }
824 .diff-hunk-header { padding-left: 64px; }
825 .diff-file-blob-link { display: none; }
826 }
827`;
0828