Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

diff-view.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

diff-view.tsxBlame1265 lines · 1 contributor
ea9ed4cClaude1/**
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, "&amp;")
281 .replace(/</g, "&lt;")
282 .replace(/>/g, "&gt;")
283 .replace(/"/g, "&quot;");
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
47a7a0aClaude311export interface InlineDiffComment {
312 id: string;
313 filePath: string;
314 lineNumber: number;
315 authorUsername: string;
316 body: string;
317 createdAt: string;
318 isAiReview?: boolean;
319}
320
ea9ed4cClaude321export interface DiffViewProps {
322 raw: string;
323 files: GitDiffFile[];
324 /** When set, file headers gain "View file" links to `${viewFileBase}/${path}`. */
325 viewFileBase?: string;
47a7a0aClaude326 /** Existing inline comments to render anchored to their file+line */
327 inlineComments?: InlineDiffComment[];
328 /** URL to POST a new inline comment to (shows gutter "+" buttons when set) */
329 commentActionUrl?: string;
b5dd694Claude330 /** If set, shows "Apply suggestion" button on suggestion blocks; POSTs to `${applySuggestionUrl}/${commentId}` */
331 applySuggestionUrl?: string;
ea9ed4cClaude332}
333
b5dd694Claude334export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl, applySuggestionUrl }) => {
ea9ed4cClaude335 const parsed = parseUnifiedDiff(raw);
336
47a7a0aClaude337 // Build a lookup map: "filePath:lineNumber" → inline comments
338 const commentsByLine = new Map<string, InlineDiffComment[]>();
339 for (const c of inlineComments ?? []) {
340 const key = `${c.filePath}:${c.lineNumber}`;
341 const arr = commentsByLine.get(key) ?? [];
342 arr.push(c);
343 commentsByLine.set(key, arr);
344 }
345
ea9ed4cClaude346 // Stat fallback: if --numstat gave us per-file counts they trump our
347 // hunk-based count (only --numstat sees binary deltas accurately).
348 const statByPath = new Map<string, { add: number; del: number }>();
349 for (const f of files) {
350 statByPath.set(f.path, { add: f.additions, del: f.deletions });
351 }
352 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
353 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
354
516b91eClaude355 const fileCount = files.length || parsed.length;
356 const showJumpNav = fileCount > 3;
357
ea9ed4cClaude358 return (
359 <div class="diff-view">
360 <style dangerouslySetInnerHTML={{ __html: DIFF_VIEW_CSS }} />
361
362 <div class="diff-summary">
363 <span class="diff-summary-count">
516b91eClaude364 <strong>{fileCount}</strong>{" "}
365 changed file{fileCount !== 1 ? "s" : ""}
ea9ed4cClaude366 </span>
367 <StatPills add={totalAdd} del={totalDel} />
516b91eClaude368 {showJumpNav && (
369 <button
370 type="button"
371 class="diff-jump-toggle"
372 aria-expanded="false"
373 aria-controls="diff-jump-nav"
374 >
375 Jump to file ▾
376 </button>
377 )}
ea9ed4cClaude378 </div>
379
516b91eClaude380 {showJumpNav && (
381 <div id="diff-jump-nav" class="diff-jump-nav" hidden>
382 {parsed.map((file, fIdx) => {
383 const counts = statByPath.get(file.path) ?? statByPath.get(file.oldPath ?? "") ?? countAddsDels(file);
384 return (
385 <a href={`#diff-file-${fIdx}`} class="diff-jump-item" onclick="document.getElementById('diff-jump-nav').hidden=true;document.querySelector('.diff-jump-toggle').setAttribute('aria-expanded','false')">
386 <span class="diff-jump-path">{file.path}</span>
387 <span class="diff-jump-pills">
388 {counts.add > 0 && <span class="diff-jump-add">+{counts.add}</span>}
389 {counts.del > 0 && <span class="diff-jump-del">-{counts.del}</span>}
390 </span>
391 </a>
392 );
393 })}
394 </div>
395 )}
396
ea9ed4cClaude397 {parsed.map((file, fIdx) => {
398 const counts =
399 statByPath.get(file.path) ??
400 statByPath.get(file.oldPath ?? "") ??
401 countAddsDels(file);
402 const id = `diff-file-${fIdx}`;
403 const tooBig = file.lineCount > BIG_FILE_LINES;
404 const showDeletedOnly = file.status === "deleted";
405 const blobHref = viewFileBase
406 ? `${viewFileBase}/${file.path}`
407 : null;
408
409 // Highlight the post-change view + (separately) the pre-change view
410 // for deletions, so syntax colors survive both sides of a diff.
411 const { perLine: addedHighlights, language } = file.binary || tooBig
412 ? { perLine: new Map<string, string>(), language: null }
413 : highlightFile(file.path, file.hunks);
414 const deletedHighlights = file.binary || tooBig
415 ? new Map<string, string>()
416 : highlightDeletedLines(file.path, file.hunks);
417
418 return (
419 <details class="diff-file" id={id} open>
420 <summary class="diff-file-summary">
421 <span class="diff-file-chevron" aria-hidden="true">{"▾"}</span>
422 <StatusPill status={file.status} />
423 <span class="diff-file-path" title={file.path}>
424 {file.oldPath ? (
425 <>
426 <span class="diff-file-old">{file.oldPath}</span>
427 <span class="diff-file-arrow" aria-hidden="true">{" → "}</span>
428 <span class="diff-file-new">{file.path}</span>
429 </>
430 ) : (
431 file.path
432 )}
433 </span>
434 <button
435 type="button"
436 class="diff-file-copy"
437 data-copy={file.path}
438 title="Copy path"
439 aria-label={`Copy path ${file.path}`}
440 >
441 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
442 <path
443 fill="currentColor"
444 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"
445 />
446 <path
447 fill="currentColor"
448 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"
449 />
450 </svg>
451 </button>
452 <span class="diff-file-spacer" />
453 <StatPills add={counts.add} del={counts.del} />
454 {blobHref && (
455 <a
456 href={blobHref}
457 class="diff-file-blob-link"
458 title="View file at this revision"
459 >
460 View file
461 </a>
462 )}
463 </summary>
464
465 {file.binary ? (
466 <div class="diff-empty">Binary file not shown.</div>
467 ) : tooBig ? (
468 <div class="diff-empty diff-empty-big">
469 Large file ({file.lineCount.toLocaleString()} lines).{" "}
470 {blobHref ? (
471 <a href={blobHref}>Load full file</a>
472 ) : (
473 "Skipped inline render."
474 )}
475 </div>
476 ) : file.hunks.length === 0 ? (
477 <div class="diff-empty">No textual changes.</div>
478 ) : (
479 <div class={`diff-body${language ? " has-hljs" : ""}`}>
480 {file.hunks.map((hunk, hIdx) => (
481 <>
482 {hIdx > 0 && <div class="diff-hunk-gap" aria-hidden="true" />}
483 <div class="diff-hunk-header" role="separator">
484 <span class="diff-hunk-header-text">{hunk.header}</span>
485 </div>
486 {hunk.lines.map((ln, lIdx) => {
487 const key =
488 ln.kind === "del"
489 ? `${hunk.oldStart}:${ln.oldNum ?? "x"}`
490 : `${hunk.newStart}:${ln.newNum ?? "x"}`;
491 // Lookup the highlight match. Our maps key with an
492 // index suffix, so iterate to find it (cheap — small).
493 let highlighted: string | null = null;
494 if (showDeletedOnly || ln.kind === "del") {
495 for (const [k, v] of deletedHighlights) {
496 if (k.startsWith(`${hunk.oldStart}:${ln.oldNum ?? "x"}:`)) {
497 highlighted = v;
498 deletedHighlights.delete(k);
499 break;
500 }
501 }
502 } else {
503 for (const [k, v] of addedHighlights) {
504 if (k.startsWith(`${hunk.newStart}:${ln.newNum ?? "x"}:`)) {
505 highlighted = v;
506 addedHighlights.delete(k);
507 break;
508 }
509 }
510 }
511 const marker =
512 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
47a7a0aClaude513 // Inline comments anchor to the new-file line number
514 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
515 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
516 const canComment = commentActionUrl && ln.kind !== "del" && ln.newNum != null;
ea9ed4cClaude517 return (
47a7a0aClaude518 <>
519 <div
520 class={`diff-row diff-row-${ln.kind}`}
521 data-line={key}
522 data-file={canComment ? file.path : undefined}
523 data-newline={canComment ? ln.newNum : undefined}
b5dd694Claude524 data-linetext={canComment ? ln.text : undefined}
47a7a0aClaude525 >
526 <span class="diff-gutter diff-gutter-old">
527 {ln.oldNum ?? ""}
528 </span>
529 <span class="diff-gutter diff-gutter-new">
530 {ln.newNum ?? ""}
531 {canComment && (
532 <button class="diff-comment-btn" title="Add comment" aria-label="Add inline comment">+</button>
533 )}
534 </span>
535 <span class="diff-marker" aria-hidden="true">
536 {marker}
537 </span>
538 <CodeSpan html={highlighted} text={ln.text} />
539 </div>
b5dd694Claude540 {lineComments.map(c => {
541 // Detect suggestion block: ```suggestion\n...\n```
542 const suggMatch = c.body.match(/^```suggestion\n([\s\S]*?)\n```/);
543 if (suggMatch) {
544 const suggCode = suggMatch[1];
545 // Any text after the closing ``` fence is treated as the comment prose
546 const afterFence = c.body.slice(suggMatch[0].length).trim();
547 return (
548 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
549 <div class="diff-inline-comment-head">
550 <strong>{c.authorUsername}</strong>
551 <span class="diff-inline-comment-meta">
552 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
553 {new Date(c.createdAt).toLocaleDateString()}
554 </span>
555 </div>
556 <div class="diff-suggestion-block">
557 <div class="diff-suggestion-header">
558 <span>Suggested change</span>
559 {applySuggestionUrl && (
f5b9ef5Claude560 <form method="post" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
b5dd694Claude561 <button type="submit" class="diff-apply-btn">Apply suggestion</button>
562 </form>
563 )}
564 </div>
565 <pre class="diff-suggestion-code">{suggCode}</pre>
566 </div>
567 {afterFence && (
568 <div class="diff-inline-comment-body" style="margin-top:6px;" dangerouslySetInnerHTML={{ __html: afterFence }} />
569 )}
570 </div>
571 );
572 }
573 return (
574 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
575 <div class="diff-inline-comment-head">
576 <strong>{c.authorUsername}</strong>
577 <span class="diff-inline-comment-meta">
578 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
579 {new Date(c.createdAt).toLocaleDateString()}
580 </span>
581 </div>
582 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
47a7a0aClaude583 </div>
b5dd694Claude584 );
585 })}
47a7a0aClaude586 </>
ea9ed4cClaude587 );
588 })}
589 </>
590 ))}
591 </div>
592 )}
593 </details>
594 );
595 })}
596
47a7a0aClaude597 {commentActionUrl && (
598 <meta name="diff-comment-url" content={commentActionUrl} />
599 )}
b5dd694Claude600 {applySuggestionUrl && (
601 <meta name="diff-apply-suggestion-url" content={applySuggestionUrl} />
602 )}
ea9ed4cClaude603 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
604 </div>
605 );
606};
607
608// ─── Inline script: copy-path button ───────────────────────────────────
609
610const DIFF_VIEW_JS = `
611(function () {
516b91eClaude612 // Jump-to-file nav toggle
613 var jumpToggle = document.querySelector('.diff-jump-toggle');
614 if (jumpToggle) {
615 jumpToggle.addEventListener('click', function () {
616 var nav = document.getElementById('diff-jump-nav');
617 if (!nav) return;
618 var open = nav.hidden;
619 nav.hidden = !open;
620 jumpToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
621 if (open) jumpToggle.classList.add('is-open');
622 else jumpToggle.classList.remove('is-open');
623 });
624 // Close on outside click
625 document.addEventListener('click', function (ev) {
626 var nav = document.getElementById('diff-jump-nav');
627 if (!nav || nav.hidden) return;
628 if (!jumpToggle.contains(ev.target) && !nav.contains(ev.target)) {
629 nav.hidden = true;
630 jumpToggle.setAttribute('aria-expanded', 'false');
631 jumpToggle.classList.remove('is-open');
632 }
633 });
634 }
635
47a7a0aClaude636 // Copy-path button
ea9ed4cClaude637 document.addEventListener('click', function (e) {
638 var t = e.target;
639 if (!t) return;
47a7a0aClaude640 // Copy path button
641 var copyBtn = t.closest && t.closest('.diff-file-copy');
642 if (copyBtn) {
643 e.preventDefault();
644 var path = copyBtn.getAttribute('data-copy') || '';
645 if (!navigator.clipboard) return;
646 navigator.clipboard.writeText(path).then(function () {
647 copyBtn.classList.add('is-copied');
648 setTimeout(function () { copyBtn.classList.remove('is-copied'); }, 1200);
649 }).catch(function () {});
650 return;
651 }
652 // Inline comment "+" button
653 var commentBtn = t.closest && t.closest('.diff-comment-btn');
654 if (commentBtn) {
655 e.preventDefault();
656 var row = commentBtn.closest('.diff-row');
657 if (!row) return;
658 var filePath = row.getAttribute('data-file');
659 var lineNum = row.getAttribute('data-newline');
b5dd694Claude660 var lineText = row.getAttribute('data-linetext') || '';
47a7a0aClaude661 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
662 if (!filePath || !lineNum || !actionUrl) return;
663 // Remove any existing open form
664 var existing = document.querySelector('.diff-inline-form-row');
665 if (existing) {
666 if (existing.previousSibling === row) { existing.remove(); return; }
667 existing.remove();
668 }
669 // Build a form row
670 var form = document.createElement('form');
671 form.method = 'POST';
672 form.action = actionUrl.getAttribute('content') || '';
673 form.className = 'diff-inline-form-row';
674 form.innerHTML =
675 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'&quot;') + '">' +
676 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
677 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
b5dd694Claude678 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
679 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
47a7a0aClaude680 '<div class="diff-inline-form-actions">' +
681 '<button type="submit" class="diff-inline-submit">Comment</button>' +
682 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
683 '</div>';
b5dd694Claude684 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
685 var suggTA = form.querySelector('.diff-suggestion-textarea');
686 var submitBtn = form.querySelector('.diff-inline-submit');
687 var bodyTA = form.querySelector('textarea[name="body"]');
688 var suggestionActive = false;
689 toggleBtn.addEventListener('click', function () {
690 suggestionActive = !suggestionActive;
691 if (suggestionActive) {
692 toggleBtn.classList.add('is-active');
693 suggTA.classList.add('is-visible');
694 suggTA.value = lineText;
695 submitBtn.textContent = 'Add suggestion & comment';
696 } else {
697 toggleBtn.classList.remove('is-active');
698 suggTA.classList.remove('is-visible');
699 submitBtn.textContent = 'Comment';
700 }
701 });
702 form.addEventListener('submit', function (ev) {
703 if (!suggestionActive) return;
704 ev.preventDefault();
705 var suggVal = suggTA.value;
706 var commentText = bodyTA.value;
707 var wrapped = "\`\`\`suggestion\n" + suggVal + "\n\`\`\`";
708 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
709 // Replace the body textarea value with the combined body
710 bodyTA.value = fullBody;
711 bodyTA.removeAttribute('required');
712 // Re-submit without the suggestion logic
713 suggestionActive = false;
714 form.submit();
715 });
47a7a0aClaude716 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
717 row.insertAdjacentElement('afterend', form);
b5dd694Claude718 bodyTA.focus();
47a7a0aClaude719 }
ea9ed4cClaude720 });
721})();
722`;
723
724// ─── Inline CSS ────────────────────────────────────────────────────────
725
726const DIFF_VIEW_CSS = `
727 .diff-view {
728 margin-top: 16px;
729 font-family: var(--font-mono);
730 }
731 .diff-summary {
732 display: flex;
733 align-items: center;
734 gap: 12px;
735 margin-bottom: 16px;
736 padding: 10px 14px;
737 background: var(--bg-elevated);
738 border: 1px solid var(--border);
739 border-radius: var(--r-md);
740 font-family: var(--font-sans, inherit);
741 font-size: 13px;
742 color: var(--text-muted);
743 }
744 .diff-summary-count strong { color: var(--text); }
745
746 .diff-stat-pills {
747 display: inline-flex;
748 align-items: center;
749 gap: 4px;
750 font-variant-numeric: tabular-nums;
751 }
752 .diff-stat-pill {
753 display: inline-flex;
754 align-items: center;
755 padding: 2px 8px;
756 border-radius: 999px;
757 font-size: 12px;
758 font-weight: 600;
759 font-family: var(--font-mono);
760 line-height: 1.4;
761 }
762 .diff-stat-add {
763 color: #6ee7b7;
764 background: rgba(52,211,153,0.12);
765 border: 1px solid rgba(52,211,153,0.22);
766 }
767 .diff-stat-del {
768 color: #fca5a5;
769 background: rgba(248,113,113,0.10);
770 border: 1px solid rgba(248,113,113,0.22);
771 }
772
773 .diff-file {
774 margin-bottom: 18px;
775 border: 1px solid var(--border);
776 border-radius: var(--r-md);
777 background: var(--bg-elevated);
778 overflow: hidden;
779 position: relative;
780 }
781 .diff-file::before {
782 content: '';
783 position: absolute;
784 top: 0; left: 0; right: 0;
785 height: 1px;
786 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
787 opacity: 0.7;
788 pointer-events: none;
789 }
790
791 .diff-file-summary {
792 list-style: none;
793 display: flex;
794 align-items: center;
795 gap: 10px;
796 padding: 10px 14px;
797 background: var(--bg-secondary);
798 border-bottom: 1px solid var(--border);
799 cursor: pointer;
800 user-select: none;
801 font-family: var(--font-sans, inherit);
802 font-size: 13px;
803 }
804 .diff-file-summary::-webkit-details-marker { display: none; }
805 .diff-file[open] .diff-file-summary { border-bottom: 1px solid var(--border); }
806 .diff-file:not([open]) .diff-file-summary { border-bottom: 0; }
807
808 .diff-file-chevron {
809 display: inline-flex;
810 color: var(--text-muted);
811 transition: transform 120ms ease;
812 width: 12px;
813 text-align: center;
814 }
815 .diff-file:not([open]) .diff-file-chevron { transform: rotate(-90deg); }
816
817 .diff-file-path {
818 font-family: var(--font-mono);
819 color: var(--text);
820 font-weight: 500;
821 overflow: hidden;
822 text-overflow: ellipsis;
823 white-space: nowrap;
824 }
825 .diff-file-old { color: var(--text-muted); text-decoration: line-through; }
826 .diff-file-arrow { color: var(--text-muted); }
827 .diff-file-new { color: var(--text); }
828
829 .diff-file-copy {
830 background: transparent;
831 border: 1px solid transparent;
832 border-radius: 6px;
833 color: var(--text-muted);
834 width: 24px;
835 height: 24px;
836 display: inline-flex;
837 align-items: center;
838 justify-content: center;
839 cursor: pointer;
840 padding: 0;
841 transition: all 120ms ease;
842 }
843 .diff-file-copy:hover {
844 color: var(--text);
845 background: var(--bg-elevated);
846 border-color: var(--border);
847 }
848 .diff-file-copy.is-copied {
849 color: var(--green, #6ee7b7);
850 background: rgba(52,211,153,0.12);
851 border-color: rgba(52,211,153,0.30);
852 }
853 .diff-file-copy.is-copied::after {
854 content: 'Copied';
855 position: absolute;
856 margin-left: 28px;
857 font-size: 11px;
858 color: var(--green, #6ee7b7);
859 font-family: var(--font-sans, inherit);
860 }
861
862 .diff-file-spacer { flex: 1; }
863
864 .diff-file-blob-link {
865 font-family: var(--font-sans, inherit);
866 font-size: 12px;
867 color: var(--text-muted);
868 padding: 3px 10px;
869 border-radius: 6px;
870 border: 1px solid var(--border);
871 background: var(--bg-elevated);
872 text-decoration: none;
873 transition: all 120ms ease;
874 }
875 .diff-file-blob-link:hover {
876 color: var(--text);
877 border-color: rgba(140,109,255,0.45);
878 background: var(--accent-gradient-faint, var(--bg-elevated));
879 }
880
881 .diff-status {
882 display: inline-flex;
883 align-items: center;
884 padding: 2px 8px;
885 border-radius: 4px;
886 font-size: 11px;
887 font-weight: 600;
888 font-family: var(--font-sans, inherit);
889 text-transform: uppercase;
890 letter-spacing: 0.04em;
891 line-height: 1.5;
892 border: 1px solid transparent;
893 }
894 .diff-status-added {
895 color: #6ee7b7;
896 background: rgba(52,211,153,0.10);
897 border-color: rgba(52,211,153,0.22);
898 }
899 .diff-status-modified {
900 color: #fcd34d;
901 background: rgba(252,211,77,0.08);
902 border-color: rgba(252,211,77,0.22);
903 }
904 .diff-status-renamed {
905 color: #93c5fd;
906 background: rgba(147,197,253,0.10);
907 border-color: rgba(147,197,253,0.25);
908 }
909 .diff-status-deleted {
910 color: #fca5a5;
911 background: rgba(248,113,113,0.10);
912 border-color: rgba(248,113,113,0.22);
913 }
914 .diff-status-binary {
915 color: var(--text-muted);
916 background: var(--bg-elevated);
917 border-color: var(--border);
918 }
919
920 /* ─── Diff body ─── */
921 .diff-body {
922 font-family: var(--font-mono);
923 font-size: 12.5px;
924 line-height: 1.55;
925 overflow-x: auto;
926 }
927 .diff-empty {
928 padding: 18px 16px;
929 text-align: center;
930 color: var(--text-muted);
931 font-family: var(--font-sans, inherit);
932 font-size: 13px;
933 }
934 .diff-empty-big { background: rgba(140,109,255,0.04); }
935
936 .diff-hunk-gap {
937 height: 1px;
938 background: linear-gradient(90deg, transparent 0%, var(--border) 25%, var(--border) 75%, transparent 100%);
939 margin: 0;
940 opacity: 0.7;
941 }
942 .diff-hunk-header {
943 padding: 4px 16px 4px 86px;
944 background: rgba(140,109,255,0.05);
945 color: var(--text-muted);
946 font-size: 11.5px;
947 border-top: 1px solid rgba(140,109,255,0.18);
948 border-bottom: 1px solid rgba(140,109,255,0.18);
949 }
950 .diff-hunk-header-text { font-family: var(--font-mono); }
951
952 .diff-row {
953 display: grid;
954 grid-template-columns: 44px 44px 16px 1fr;
955 align-items: stretch;
956 min-height: 1.55em;
957 }
958 .diff-gutter {
959 text-align: right;
960 padding: 0 6px;
961 color: var(--text-muted);
962 background: var(--bg-elevated);
963 border-right: 1px solid var(--border);
964 user-select: none;
965 font-variant-numeric: tabular-nums;
966 font-size: 11.5px;
967 opacity: 0.75;
968 }
969 .diff-gutter-new { border-right: 1px solid var(--border); }
970
971 .diff-marker {
972 text-align: center;
973 color: var(--text-muted);
974 user-select: none;
975 background: var(--bg-elevated);
976 border-right: 1px solid var(--border);
977 }
978 .diff-code {
979 padding: 0 12px;
980 white-space: pre;
981 color: var(--text);
982 overflow-x: visible;
983 }
984
985 /* Row tints — additions / deletions / context */
986 .diff-row-add {
987 background: rgba(52,211,153,0.08);
988 }
989 .diff-row-add .diff-gutter,
990 .diff-row-add .diff-marker {
991 background: rgba(52,211,153,0.14);
992 color: #6ee7b7;
993 border-right-color: rgba(52,211,153,0.20);
994 }
995 .diff-row-add .diff-marker { color: #6ee7b7; font-weight: 600; }
996
997 .diff-row-del {
998 background: rgba(248,113,113,0.08);
999 }
1000 .diff-row-del .diff-gutter,
1001 .diff-row-del .diff-marker {
1002 background: rgba(248,113,113,0.14);
1003 color: #fca5a5;
1004 border-right-color: rgba(248,113,113,0.20);
1005 }
1006 .diff-row-del .diff-marker { color: #fca5a5; font-weight: 600; }
1007
1008 .diff-row:hover .diff-gutter { opacity: 1; }
1009
47a7a0aClaude1010 /* ─── Inline comment "+" button ─── */
1011 .diff-comment-btn {
1012 display: none;
1013 position: absolute;
1014 right: 2px;
1015 top: 50%;
1016 transform: translateY(-50%);
1017 width: 16px; height: 16px;
1018 padding: 0;
1019 background: var(--accent, #8c6dff);
1020 color: #fff;
1021 border: none;
1022 border-radius: 3px;
1023 font-size: 12px;
1024 line-height: 1;
1025 cursor: pointer;
1026 z-index: 2;
1027 }
1028 .diff-gutter-new { position: relative; }
1029 .diff-row:hover .diff-comment-btn { display: flex; align-items: center; justify-content: center; }
1030
1031 /* ─── Inline comments anchored to diff lines ─── */
1032 .diff-inline-comment {
1033 grid-column: 1 / -1;
1034 display: block;
1035 margin: 4px 0;
1036 padding: 10px 14px;
1037 background: var(--bg-elevated);
1038 border-left: 3px solid var(--border);
1039 border-radius: 0 4px 4px 0;
1040 font-family: var(--font-sans, inherit);
1041 font-size: 13px;
1042 }
1043 .diff-inline-comment-ai { border-left-color: #8c6dff; }
1044 .diff-inline-comment-head {
1045 display: flex; gap: 8px; align-items: center;
1046 margin-bottom: 6px;
1047 font-size: 12px;
1048 color: var(--text-muted);
1049 }
1050 .diff-inline-comment-head strong { color: var(--text-strong); }
1051 .diff-inline-ai-badge {
1052 background: rgba(140,109,255,0.2);
1053 color: #a78bfa;
1054 border-radius: 3px;
1055 padding: 1px 5px;
1056 font-size: 10px;
1057 font-weight: 600;
1058 }
1059 .diff-inline-comment-body { color: var(--text); line-height: 1.6; }
1060
1061 /* ─── Inline comment form ─── */
1062 .diff-inline-form-row {
1063 grid-column: 1 / -1;
1064 display: block;
1065 padding: 10px 14px;
1066 background: var(--bg-elevated);
1067 border-top: 1px solid var(--border);
1068 border-bottom: 1px solid var(--border);
1069 }
1070 .diff-inline-textarea {
1071 width: 100%;
1072 min-height: 72px;
1073 background: var(--bg);
1074 color: var(--text);
1075 border: 1px solid var(--border);
1076 border-radius: 4px;
1077 padding: 8px;
1078 font-size: 13px;
1079 font-family: var(--font-sans, inherit);
1080 resize: vertical;
1081 box-sizing: border-box;
1082 }
1083 .diff-inline-textarea:focus { outline: none; border-color: var(--accent, #8c6dff); }
1084 .diff-inline-form-actions {
1085 display: flex; gap: 8px; margin-top: 8px;
1086 }
1087 .diff-inline-submit {
1088 background: var(--accent, #8c6dff);
1089 color: #fff;
1090 border: none;
1091 border-radius: 5px;
1092 padding: 6px 14px;
1093 font-size: 13px;
1094 cursor: pointer;
1095 }
1096 .diff-inline-cancel {
1097 background: transparent;
1098 color: var(--text-muted);
1099 border: 1px solid var(--border);
1100 border-radius: 5px;
1101 padding: 6px 14px;
1102 font-size: 13px;
1103 cursor: pointer;
1104 }
1105
ea9ed4cClaude1106 /* Highlight.js theme overrides so colors layer correctly on tints */
1107 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
1108 .diff-body.has-hljs .hljs-built_in,
1109 .diff-body.has-hljs .hljs-type { color: #ffa657; }
1110 .diff-body.has-hljs .hljs-literal,
1111 .diff-body.has-hljs .hljs-number { color: #79c0ff; }
1112 .diff-body.has-hljs .hljs-string { color: #a5d6ff; }
1113 .diff-body.has-hljs .hljs-title,
1114 .diff-body.has-hljs .hljs-title.function_ { color: #d2a8ff; }
1115 .diff-body.has-hljs .hljs-comment { color: #8b949e; font-style: italic; }
1116 .diff-body.has-hljs .hljs-attr,
1117 .diff-body.has-hljs .hljs-attribute,
1118 .diff-body.has-hljs .hljs-meta { color: #79c0ff; }
1119 .diff-body.has-hljs .hljs-tag,
1120 .diff-body.has-hljs .hljs-name { color: #7ee787; }
1121 .diff-body.has-hljs .hljs-variable,
1122 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
1123
b5dd694Claude1124 /* ─── Suggestion blocks ─── */
1125 .diff-suggestion-block {
1126 margin: 8px 0;
1127 border: 1px solid rgba(52,211,153,0.3);
1128 border-radius: 6px;
1129 overflow: hidden;
1130 }
1131 .diff-suggestion-header {
1132 padding: 6px 12px;
1133 background: rgba(52,211,153,0.08);
1134 border-bottom: 1px solid rgba(52,211,153,0.2);
1135 font-size: 12px;
1136 color: #6ee7b7;
1137 display: flex;
1138 align-items: center;
1139 justify-content: space-between;
1140 }
1141 .diff-suggestion-code {
1142 padding: 8px 12px;
1143 background: rgba(52,211,153,0.05);
1144 font-family: var(--font-mono);
1145 font-size: 12.5px;
1146 white-space: pre;
1147 color: var(--text);
1148 margin: 0;
1149 }
1150 .diff-apply-btn {
1151 background: rgba(52,211,153,0.15);
1152 color: #6ee7b7;
1153 border: 1px solid rgba(52,211,153,0.35);
1154 border-radius: 5px;
1155 padding: 4px 12px;
1156 font-size: 12px;
1157 cursor: pointer;
1158 font-family: var(--font-sans, inherit);
1159 }
1160 .diff-apply-btn:hover {
1161 background: rgba(52,211,153,0.25);
1162 }
1163 .diff-suggestion-toggle {
1164 background: transparent;
1165 color: var(--text-muted);
1166 border: 1px solid var(--border);
1167 border-radius: 5px;
1168 padding: 4px 10px;
1169 font-size: 12px;
1170 cursor: pointer;
1171 font-family: var(--font-sans, inherit);
1172 margin-top: 6px;
1173 }
1174 .diff-suggestion-toggle.is-active {
1175 background: rgba(52,211,153,0.1);
1176 color: #6ee7b7;
1177 border-color: rgba(52,211,153,0.35);
1178 }
1179 .diff-suggestion-textarea {
1180 width: 100%;
1181 background: rgba(52,211,153,0.04);
1182 color: var(--text);
1183 border: 1px solid rgba(52,211,153,0.25);
1184 border-radius: 4px;
1185 padding: 8px;
1186 font-size: 12.5px;
1187 font-family: var(--font-mono);
1188 resize: vertical;
1189 box-sizing: border-box;
1190 margin-top: 6px;
1191 display: none;
1192 }
1193 .diff-suggestion-textarea.is-visible { display: block; }
1194
ea9ed4cClaude1195 /* ─── Split-view scaffolding (phase 2 placeholder) ───
1196 The .diff-body element is grid-friendly; a future toggle can swap to
1197 'diff-body diff-split' and the per-row grid expands to two code panes. */
1198 .diff-body.diff-split .diff-row {
1199 grid-template-columns: 44px 1fr 44px 1fr;
1200 }
1201
1202 @media (max-width: 720px) {
1203 .diff-row {
1204 grid-template-columns: 32px 32px 14px 1fr;
1205 }
1206 .diff-hunk-header { padding-left: 64px; }
1207 .diff-file-blob-link { display: none; }
1208 }
516b91eClaude1209
1210 /* ─── Jump-to-file nav ─── */
1211 .diff-jump-toggle {
1212 margin-left: auto;
1213 background: var(--bg-elevated);
1214 color: var(--text-muted);
1215 border: 1px solid var(--border);
1216 border-radius: 5px;
1217 padding: 4px 12px;
1218 font-size: 12px;
1219 cursor: pointer;
1220 font-family: var(--font-sans, inherit);
1221 white-space: nowrap;
1222 transition: all 120ms ease;
1223 }
1224 .diff-jump-toggle:hover,
1225 .diff-jump-toggle.is-open {
1226 color: var(--text);
1227 border-color: rgba(140,109,255,0.45);
1228 background: var(--accent-gradient-faint, var(--bg-elevated));
1229 }
1230
1231 .diff-jump-nav {
1232 position: relative;
1233 margin-bottom: 12px;
1234 background: var(--bg-elevated);
1235 border: 1px solid var(--border);
1236 border-radius: var(--r-md);
1237 box-shadow: 0 4px 16px rgba(0,0,0,0.18);
1238 z-index: 20;
1239 max-height: 320px;
1240 overflow-y: auto;
1241 padding: 6px 0;
1242 }
1243 .diff-jump-item {
1244 display: flex;
1245 align-items: center;
1246 justify-content: space-between;
1247 gap: 8px;
1248 padding: 6px 14px;
1249 text-decoration: none;
1250 color: var(--text);
1251 font-size: 12.5px;
1252 font-family: var(--font-mono);
1253 transition: background 80ms ease;
1254 }
1255 .diff-jump-item:hover { background: var(--bg-secondary); }
1256 .diff-jump-path {
1257 overflow: hidden;
1258 text-overflow: ellipsis;
1259 white-space: nowrap;
1260 flex: 1;
1261 }
1262 .diff-jump-pills { display: flex; gap: 4px; flex-shrink: 0; }
1263 .diff-jump-add { color: #6ee7b7; font-size: 11px; }
1264 .diff-jump-del { color: #fca5a5; font-size: 11px; }
ea9ed4cClaude1265`;