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.tsxBlame1155 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
355 return (
356 <div class="diff-view">
357 <style dangerouslySetInnerHTML={{ __html: DIFF_VIEW_CSS }} />
358
359 <div class="diff-summary">
360 <span class="diff-summary-count">
361 <strong>{files.length || parsed.length}</strong>{" "}
362 changed file{(files.length || parsed.length) !== 1 ? "s" : ""}
363 </span>
364 <StatPills add={totalAdd} del={totalDel} />
365 </div>
366
367 {parsed.map((file, fIdx) => {
368 const counts =
369 statByPath.get(file.path) ??
370 statByPath.get(file.oldPath ?? "") ??
371 countAddsDels(file);
372 const id = `diff-file-${fIdx}`;
373 const tooBig = file.lineCount > BIG_FILE_LINES;
374 const showDeletedOnly = file.status === "deleted";
375 const blobHref = viewFileBase
376 ? `${viewFileBase}/${file.path}`
377 : null;
378
379 // Highlight the post-change view + (separately) the pre-change view
380 // for deletions, so syntax colors survive both sides of a diff.
381 const { perLine: addedHighlights, language } = file.binary || tooBig
382 ? { perLine: new Map<string, string>(), language: null }
383 : highlightFile(file.path, file.hunks);
384 const deletedHighlights = file.binary || tooBig
385 ? new Map<string, string>()
386 : highlightDeletedLines(file.path, file.hunks);
387
388 return (
389 <details class="diff-file" id={id} open>
390 <summary class="diff-file-summary">
391 <span class="diff-file-chevron" aria-hidden="true">{"▾"}</span>
392 <StatusPill status={file.status} />
393 <span class="diff-file-path" title={file.path}>
394 {file.oldPath ? (
395 <>
396 <span class="diff-file-old">{file.oldPath}</span>
397 <span class="diff-file-arrow" aria-hidden="true">{" → "}</span>
398 <span class="diff-file-new">{file.path}</span>
399 </>
400 ) : (
401 file.path
402 )}
403 </span>
404 <button
405 type="button"
406 class="diff-file-copy"
407 data-copy={file.path}
408 title="Copy path"
409 aria-label={`Copy path ${file.path}`}
410 >
411 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
412 <path
413 fill="currentColor"
414 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"
415 />
416 <path
417 fill="currentColor"
418 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"
419 />
420 </svg>
421 </button>
422 <span class="diff-file-spacer" />
423 <StatPills add={counts.add} del={counts.del} />
424 {blobHref && (
425 <a
426 href={blobHref}
427 class="diff-file-blob-link"
428 title="View file at this revision"
429 >
430 View file
431 </a>
432 )}
433 </summary>
434
435 {file.binary ? (
436 <div class="diff-empty">Binary file not shown.</div>
437 ) : tooBig ? (
438 <div class="diff-empty diff-empty-big">
439 Large file ({file.lineCount.toLocaleString()} lines).{" "}
440 {blobHref ? (
441 <a href={blobHref}>Load full file</a>
442 ) : (
443 "Skipped inline render."
444 )}
445 </div>
446 ) : file.hunks.length === 0 ? (
447 <div class="diff-empty">No textual changes.</div>
448 ) : (
449 <div class={`diff-body${language ? " has-hljs" : ""}`}>
450 {file.hunks.map((hunk, hIdx) => (
451 <>
452 {hIdx > 0 && <div class="diff-hunk-gap" aria-hidden="true" />}
453 <div class="diff-hunk-header" role="separator">
454 <span class="diff-hunk-header-text">{hunk.header}</span>
455 </div>
456 {hunk.lines.map((ln, lIdx) => {
457 const key =
458 ln.kind === "del"
459 ? `${hunk.oldStart}:${ln.oldNum ?? "x"}`
460 : `${hunk.newStart}:${ln.newNum ?? "x"}`;
461 // Lookup the highlight match. Our maps key with an
462 // index suffix, so iterate to find it (cheap — small).
463 let highlighted: string | null = null;
464 if (showDeletedOnly || ln.kind === "del") {
465 for (const [k, v] of deletedHighlights) {
466 if (k.startsWith(`${hunk.oldStart}:${ln.oldNum ?? "x"}:`)) {
467 highlighted = v;
468 deletedHighlights.delete(k);
469 break;
470 }
471 }
472 } else {
473 for (const [k, v] of addedHighlights) {
474 if (k.startsWith(`${hunk.newStart}:${ln.newNum ?? "x"}:`)) {
475 highlighted = v;
476 addedHighlights.delete(k);
477 break;
478 }
479 }
480 }
481 const marker =
482 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
47a7a0aClaude483 // Inline comments anchor to the new-file line number
484 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
485 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
486 const canComment = commentActionUrl && ln.kind !== "del" && ln.newNum != null;
ea9ed4cClaude487 return (
47a7a0aClaude488 <>
489 <div
490 class={`diff-row diff-row-${ln.kind}`}
491 data-line={key}
492 data-file={canComment ? file.path : undefined}
493 data-newline={canComment ? ln.newNum : undefined}
b5dd694Claude494 data-linetext={canComment ? ln.text : undefined}
47a7a0aClaude495 >
496 <span class="diff-gutter diff-gutter-old">
497 {ln.oldNum ?? ""}
498 </span>
499 <span class="diff-gutter diff-gutter-new">
500 {ln.newNum ?? ""}
501 {canComment && (
502 <button class="diff-comment-btn" title="Add comment" aria-label="Add inline comment">+</button>
503 )}
504 </span>
505 <span class="diff-marker" aria-hidden="true">
506 {marker}
507 </span>
508 <CodeSpan html={highlighted} text={ln.text} />
509 </div>
b5dd694Claude510 {lineComments.map(c => {
511 // Detect suggestion block: ```suggestion\n...\n```
512 const suggMatch = c.body.match(/^```suggestion\n([\s\S]*?)\n```/);
513 if (suggMatch) {
514 const suggCode = suggMatch[1];
515 // Any text after the closing ``` fence is treated as the comment prose
516 const afterFence = c.body.slice(suggMatch[0].length).trim();
517 return (
518 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
519 <div class="diff-inline-comment-head">
520 <strong>{c.authorUsername}</strong>
521 <span class="diff-inline-comment-meta">
522 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
523 {new Date(c.createdAt).toLocaleDateString()}
524 </span>
525 </div>
526 <div class="diff-suggestion-block">
527 <div class="diff-suggestion-header">
528 <span>Suggested change</span>
529 {applySuggestionUrl && (
530 <form method="POST" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
531 <button type="submit" class="diff-apply-btn">Apply suggestion</button>
532 </form>
533 )}
534 </div>
535 <pre class="diff-suggestion-code">{suggCode}</pre>
536 </div>
537 {afterFence && (
538 <div class="diff-inline-comment-body" style="margin-top:6px;" dangerouslySetInnerHTML={{ __html: afterFence }} />
539 )}
540 </div>
541 );
542 }
543 return (
544 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
545 <div class="diff-inline-comment-head">
546 <strong>{c.authorUsername}</strong>
547 <span class="diff-inline-comment-meta">
548 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
549 {new Date(c.createdAt).toLocaleDateString()}
550 </span>
551 </div>
552 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
47a7a0aClaude553 </div>
b5dd694Claude554 );
555 })}
47a7a0aClaude556 </>
ea9ed4cClaude557 );
558 })}
559 </>
560 ))}
561 </div>
562 )}
563 </details>
564 );
565 })}
566
47a7a0aClaude567 {commentActionUrl && (
568 <meta name="diff-comment-url" content={commentActionUrl} />
569 )}
b5dd694Claude570 {applySuggestionUrl && (
571 <meta name="diff-apply-suggestion-url" content={applySuggestionUrl} />
572 )}
ea9ed4cClaude573 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
574 </div>
575 );
576};
577
578// ─── Inline script: copy-path button ───────────────────────────────────
579
580const DIFF_VIEW_JS = `
581(function () {
47a7a0aClaude582 // Copy-path button
ea9ed4cClaude583 document.addEventListener('click', function (e) {
584 var t = e.target;
585 if (!t) return;
47a7a0aClaude586 // Copy path button
587 var copyBtn = t.closest && t.closest('.diff-file-copy');
588 if (copyBtn) {
589 e.preventDefault();
590 var path = copyBtn.getAttribute('data-copy') || '';
591 if (!navigator.clipboard) return;
592 navigator.clipboard.writeText(path).then(function () {
593 copyBtn.classList.add('is-copied');
594 setTimeout(function () { copyBtn.classList.remove('is-copied'); }, 1200);
595 }).catch(function () {});
596 return;
597 }
598 // Inline comment "+" button
599 var commentBtn = t.closest && t.closest('.diff-comment-btn');
600 if (commentBtn) {
601 e.preventDefault();
602 var row = commentBtn.closest('.diff-row');
603 if (!row) return;
604 var filePath = row.getAttribute('data-file');
605 var lineNum = row.getAttribute('data-newline');
b5dd694Claude606 var lineText = row.getAttribute('data-linetext') || '';
47a7a0aClaude607 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
608 if (!filePath || !lineNum || !actionUrl) return;
609 // Remove any existing open form
610 var existing = document.querySelector('.diff-inline-form-row');
611 if (existing) {
612 if (existing.previousSibling === row) { existing.remove(); return; }
613 existing.remove();
614 }
615 // Build a form row
616 var form = document.createElement('form');
617 form.method = 'POST';
618 form.action = actionUrl.getAttribute('content') || '';
619 form.className = 'diff-inline-form-row';
620 form.innerHTML =
621 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'&quot;') + '">' +
622 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
623 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
b5dd694Claude624 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
625 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
47a7a0aClaude626 '<div class="diff-inline-form-actions">' +
627 '<button type="submit" class="diff-inline-submit">Comment</button>' +
628 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
629 '</div>';
b5dd694Claude630 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
631 var suggTA = form.querySelector('.diff-suggestion-textarea');
632 var submitBtn = form.querySelector('.diff-inline-submit');
633 var bodyTA = form.querySelector('textarea[name="body"]');
634 var suggestionActive = false;
635 toggleBtn.addEventListener('click', function () {
636 suggestionActive = !suggestionActive;
637 if (suggestionActive) {
638 toggleBtn.classList.add('is-active');
639 suggTA.classList.add('is-visible');
640 suggTA.value = lineText;
641 submitBtn.textContent = 'Add suggestion & comment';
642 } else {
643 toggleBtn.classList.remove('is-active');
644 suggTA.classList.remove('is-visible');
645 submitBtn.textContent = 'Comment';
646 }
647 });
648 form.addEventListener('submit', function (ev) {
649 if (!suggestionActive) return;
650 ev.preventDefault();
651 var suggVal = suggTA.value;
652 var commentText = bodyTA.value;
653 var wrapped = "\`\`\`suggestion\n" + suggVal + "\n\`\`\`";
654 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
655 // Replace the body textarea value with the combined body
656 bodyTA.value = fullBody;
657 bodyTA.removeAttribute('required');
658 // Re-submit without the suggestion logic
659 suggestionActive = false;
660 form.submit();
661 });
47a7a0aClaude662 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
663 row.insertAdjacentElement('afterend', form);
b5dd694Claude664 bodyTA.focus();
47a7a0aClaude665 }
ea9ed4cClaude666 });
667})();
668`;
669
670// ─── Inline CSS ────────────────────────────────────────────────────────
671
672const DIFF_VIEW_CSS = `
673 .diff-view {
674 margin-top: 16px;
675 font-family: var(--font-mono);
676 }
677 .diff-summary {
678 display: flex;
679 align-items: center;
680 gap: 12px;
681 margin-bottom: 16px;
682 padding: 10px 14px;
683 background: var(--bg-elevated);
684 border: 1px solid var(--border);
685 border-radius: var(--r-md);
686 font-family: var(--font-sans, inherit);
687 font-size: 13px;
688 color: var(--text-muted);
689 }
690 .diff-summary-count strong { color: var(--text); }
691
692 .diff-stat-pills {
693 display: inline-flex;
694 align-items: center;
695 gap: 4px;
696 font-variant-numeric: tabular-nums;
697 }
698 .diff-stat-pill {
699 display: inline-flex;
700 align-items: center;
701 padding: 2px 8px;
702 border-radius: 999px;
703 font-size: 12px;
704 font-weight: 600;
705 font-family: var(--font-mono);
706 line-height: 1.4;
707 }
708 .diff-stat-add {
709 color: #6ee7b7;
710 background: rgba(52,211,153,0.12);
711 border: 1px solid rgba(52,211,153,0.22);
712 }
713 .diff-stat-del {
714 color: #fca5a5;
715 background: rgba(248,113,113,0.10);
716 border: 1px solid rgba(248,113,113,0.22);
717 }
718
719 .diff-file {
720 margin-bottom: 18px;
721 border: 1px solid var(--border);
722 border-radius: var(--r-md);
723 background: var(--bg-elevated);
724 overflow: hidden;
725 position: relative;
726 }
727 .diff-file::before {
728 content: '';
729 position: absolute;
730 top: 0; left: 0; right: 0;
731 height: 1px;
732 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
733 opacity: 0.7;
734 pointer-events: none;
735 }
736
737 .diff-file-summary {
738 list-style: none;
739 display: flex;
740 align-items: center;
741 gap: 10px;
742 padding: 10px 14px;
743 background: var(--bg-secondary);
744 border-bottom: 1px solid var(--border);
745 cursor: pointer;
746 user-select: none;
747 font-family: var(--font-sans, inherit);
748 font-size: 13px;
749 }
750 .diff-file-summary::-webkit-details-marker { display: none; }
751 .diff-file[open] .diff-file-summary { border-bottom: 1px solid var(--border); }
752 .diff-file:not([open]) .diff-file-summary { border-bottom: 0; }
753
754 .diff-file-chevron {
755 display: inline-flex;
756 color: var(--text-muted);
757 transition: transform 120ms ease;
758 width: 12px;
759 text-align: center;
760 }
761 .diff-file:not([open]) .diff-file-chevron { transform: rotate(-90deg); }
762
763 .diff-file-path {
764 font-family: var(--font-mono);
765 color: var(--text);
766 font-weight: 500;
767 overflow: hidden;
768 text-overflow: ellipsis;
769 white-space: nowrap;
770 }
771 .diff-file-old { color: var(--text-muted); text-decoration: line-through; }
772 .diff-file-arrow { color: var(--text-muted); }
773 .diff-file-new { color: var(--text); }
774
775 .diff-file-copy {
776 background: transparent;
777 border: 1px solid transparent;
778 border-radius: 6px;
779 color: var(--text-muted);
780 width: 24px;
781 height: 24px;
782 display: inline-flex;
783 align-items: center;
784 justify-content: center;
785 cursor: pointer;
786 padding: 0;
787 transition: all 120ms ease;
788 }
789 .diff-file-copy:hover {
790 color: var(--text);
791 background: var(--bg-elevated);
792 border-color: var(--border);
793 }
794 .diff-file-copy.is-copied {
795 color: var(--green, #6ee7b7);
796 background: rgba(52,211,153,0.12);
797 border-color: rgba(52,211,153,0.30);
798 }
799 .diff-file-copy.is-copied::after {
800 content: 'Copied';
801 position: absolute;
802 margin-left: 28px;
803 font-size: 11px;
804 color: var(--green, #6ee7b7);
805 font-family: var(--font-sans, inherit);
806 }
807
808 .diff-file-spacer { flex: 1; }
809
810 .diff-file-blob-link {
811 font-family: var(--font-sans, inherit);
812 font-size: 12px;
813 color: var(--text-muted);
814 padding: 3px 10px;
815 border-radius: 6px;
816 border: 1px solid var(--border);
817 background: var(--bg-elevated);
818 text-decoration: none;
819 transition: all 120ms ease;
820 }
821 .diff-file-blob-link:hover {
822 color: var(--text);
823 border-color: rgba(140,109,255,0.45);
824 background: var(--accent-gradient-faint, var(--bg-elevated));
825 }
826
827 .diff-status {
828 display: inline-flex;
829 align-items: center;
830 padding: 2px 8px;
831 border-radius: 4px;
832 font-size: 11px;
833 font-weight: 600;
834 font-family: var(--font-sans, inherit);
835 text-transform: uppercase;
836 letter-spacing: 0.04em;
837 line-height: 1.5;
838 border: 1px solid transparent;
839 }
840 .diff-status-added {
841 color: #6ee7b7;
842 background: rgba(52,211,153,0.10);
843 border-color: rgba(52,211,153,0.22);
844 }
845 .diff-status-modified {
846 color: #fcd34d;
847 background: rgba(252,211,77,0.08);
848 border-color: rgba(252,211,77,0.22);
849 }
850 .diff-status-renamed {
851 color: #93c5fd;
852 background: rgba(147,197,253,0.10);
853 border-color: rgba(147,197,253,0.25);
854 }
855 .diff-status-deleted {
856 color: #fca5a5;
857 background: rgba(248,113,113,0.10);
858 border-color: rgba(248,113,113,0.22);
859 }
860 .diff-status-binary {
861 color: var(--text-muted);
862 background: var(--bg-elevated);
863 border-color: var(--border);
864 }
865
866 /* ─── Diff body ─── */
867 .diff-body {
868 font-family: var(--font-mono);
869 font-size: 12.5px;
870 line-height: 1.55;
871 overflow-x: auto;
872 }
873 .diff-empty {
874 padding: 18px 16px;
875 text-align: center;
876 color: var(--text-muted);
877 font-family: var(--font-sans, inherit);
878 font-size: 13px;
879 }
880 .diff-empty-big { background: rgba(140,109,255,0.04); }
881
882 .diff-hunk-gap {
883 height: 1px;
884 background: linear-gradient(90deg, transparent 0%, var(--border) 25%, var(--border) 75%, transparent 100%);
885 margin: 0;
886 opacity: 0.7;
887 }
888 .diff-hunk-header {
889 padding: 4px 16px 4px 86px;
890 background: rgba(140,109,255,0.05);
891 color: var(--text-muted);
892 font-size: 11.5px;
893 border-top: 1px solid rgba(140,109,255,0.18);
894 border-bottom: 1px solid rgba(140,109,255,0.18);
895 }
896 .diff-hunk-header-text { font-family: var(--font-mono); }
897
898 .diff-row {
899 display: grid;
900 grid-template-columns: 44px 44px 16px 1fr;
901 align-items: stretch;
902 min-height: 1.55em;
903 }
904 .diff-gutter {
905 text-align: right;
906 padding: 0 6px;
907 color: var(--text-muted);
908 background: var(--bg-elevated);
909 border-right: 1px solid var(--border);
910 user-select: none;
911 font-variant-numeric: tabular-nums;
912 font-size: 11.5px;
913 opacity: 0.75;
914 }
915 .diff-gutter-new { border-right: 1px solid var(--border); }
916
917 .diff-marker {
918 text-align: center;
919 color: var(--text-muted);
920 user-select: none;
921 background: var(--bg-elevated);
922 border-right: 1px solid var(--border);
923 }
924 .diff-code {
925 padding: 0 12px;
926 white-space: pre;
927 color: var(--text);
928 overflow-x: visible;
929 }
930
931 /* Row tints — additions / deletions / context */
932 .diff-row-add {
933 background: rgba(52,211,153,0.08);
934 }
935 .diff-row-add .diff-gutter,
936 .diff-row-add .diff-marker {
937 background: rgba(52,211,153,0.14);
938 color: #6ee7b7;
939 border-right-color: rgba(52,211,153,0.20);
940 }
941 .diff-row-add .diff-marker { color: #6ee7b7; font-weight: 600; }
942
943 .diff-row-del {
944 background: rgba(248,113,113,0.08);
945 }
946 .diff-row-del .diff-gutter,
947 .diff-row-del .diff-marker {
948 background: rgba(248,113,113,0.14);
949 color: #fca5a5;
950 border-right-color: rgba(248,113,113,0.20);
951 }
952 .diff-row-del .diff-marker { color: #fca5a5; font-weight: 600; }
953
954 .diff-row:hover .diff-gutter { opacity: 1; }
955
47a7a0aClaude956 /* ─── Inline comment "+" button ─── */
957 .diff-comment-btn {
958 display: none;
959 position: absolute;
960 right: 2px;
961 top: 50%;
962 transform: translateY(-50%);
963 width: 16px; height: 16px;
964 padding: 0;
965 background: var(--accent, #8c6dff);
966 color: #fff;
967 border: none;
968 border-radius: 3px;
969 font-size: 12px;
970 line-height: 1;
971 cursor: pointer;
972 z-index: 2;
973 }
974 .diff-gutter-new { position: relative; }
975 .diff-row:hover .diff-comment-btn { display: flex; align-items: center; justify-content: center; }
976
977 /* ─── Inline comments anchored to diff lines ─── */
978 .diff-inline-comment {
979 grid-column: 1 / -1;
980 display: block;
981 margin: 4px 0;
982 padding: 10px 14px;
983 background: var(--bg-elevated);
984 border-left: 3px solid var(--border);
985 border-radius: 0 4px 4px 0;
986 font-family: var(--font-sans, inherit);
987 font-size: 13px;
988 }
989 .diff-inline-comment-ai { border-left-color: #8c6dff; }
990 .diff-inline-comment-head {
991 display: flex; gap: 8px; align-items: center;
992 margin-bottom: 6px;
993 font-size: 12px;
994 color: var(--text-muted);
995 }
996 .diff-inline-comment-head strong { color: var(--text-strong); }
997 .diff-inline-ai-badge {
998 background: rgba(140,109,255,0.2);
999 color: #a78bfa;
1000 border-radius: 3px;
1001 padding: 1px 5px;
1002 font-size: 10px;
1003 font-weight: 600;
1004 }
1005 .diff-inline-comment-body { color: var(--text); line-height: 1.6; }
1006
1007 /* ─── Inline comment form ─── */
1008 .diff-inline-form-row {
1009 grid-column: 1 / -1;
1010 display: block;
1011 padding: 10px 14px;
1012 background: var(--bg-elevated);
1013 border-top: 1px solid var(--border);
1014 border-bottom: 1px solid var(--border);
1015 }
1016 .diff-inline-textarea {
1017 width: 100%;
1018 min-height: 72px;
1019 background: var(--bg);
1020 color: var(--text);
1021 border: 1px solid var(--border);
1022 border-radius: 4px;
1023 padding: 8px;
1024 font-size: 13px;
1025 font-family: var(--font-sans, inherit);
1026 resize: vertical;
1027 box-sizing: border-box;
1028 }
1029 .diff-inline-textarea:focus { outline: none; border-color: var(--accent, #8c6dff); }
1030 .diff-inline-form-actions {
1031 display: flex; gap: 8px; margin-top: 8px;
1032 }
1033 .diff-inline-submit {
1034 background: var(--accent, #8c6dff);
1035 color: #fff;
1036 border: none;
1037 border-radius: 5px;
1038 padding: 6px 14px;
1039 font-size: 13px;
1040 cursor: pointer;
1041 }
1042 .diff-inline-cancel {
1043 background: transparent;
1044 color: var(--text-muted);
1045 border: 1px solid var(--border);
1046 border-radius: 5px;
1047 padding: 6px 14px;
1048 font-size: 13px;
1049 cursor: pointer;
1050 }
1051
ea9ed4cClaude1052 /* Highlight.js theme overrides so colors layer correctly on tints */
1053 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
1054 .diff-body.has-hljs .hljs-built_in,
1055 .diff-body.has-hljs .hljs-type { color: #ffa657; }
1056 .diff-body.has-hljs .hljs-literal,
1057 .diff-body.has-hljs .hljs-number { color: #79c0ff; }
1058 .diff-body.has-hljs .hljs-string { color: #a5d6ff; }
1059 .diff-body.has-hljs .hljs-title,
1060 .diff-body.has-hljs .hljs-title.function_ { color: #d2a8ff; }
1061 .diff-body.has-hljs .hljs-comment { color: #8b949e; font-style: italic; }
1062 .diff-body.has-hljs .hljs-attr,
1063 .diff-body.has-hljs .hljs-attribute,
1064 .diff-body.has-hljs .hljs-meta { color: #79c0ff; }
1065 .diff-body.has-hljs .hljs-tag,
1066 .diff-body.has-hljs .hljs-name { color: #7ee787; }
1067 .diff-body.has-hljs .hljs-variable,
1068 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
1069
b5dd694Claude1070 /* ─── Suggestion blocks ─── */
1071 .diff-suggestion-block {
1072 margin: 8px 0;
1073 border: 1px solid rgba(52,211,153,0.3);
1074 border-radius: 6px;
1075 overflow: hidden;
1076 }
1077 .diff-suggestion-header {
1078 padding: 6px 12px;
1079 background: rgba(52,211,153,0.08);
1080 border-bottom: 1px solid rgba(52,211,153,0.2);
1081 font-size: 12px;
1082 color: #6ee7b7;
1083 display: flex;
1084 align-items: center;
1085 justify-content: space-between;
1086 }
1087 .diff-suggestion-code {
1088 padding: 8px 12px;
1089 background: rgba(52,211,153,0.05);
1090 font-family: var(--font-mono);
1091 font-size: 12.5px;
1092 white-space: pre;
1093 color: var(--text);
1094 margin: 0;
1095 }
1096 .diff-apply-btn {
1097 background: rgba(52,211,153,0.15);
1098 color: #6ee7b7;
1099 border: 1px solid rgba(52,211,153,0.35);
1100 border-radius: 5px;
1101 padding: 4px 12px;
1102 font-size: 12px;
1103 cursor: pointer;
1104 font-family: var(--font-sans, inherit);
1105 }
1106 .diff-apply-btn:hover {
1107 background: rgba(52,211,153,0.25);
1108 }
1109 .diff-suggestion-toggle {
1110 background: transparent;
1111 color: var(--text-muted);
1112 border: 1px solid var(--border);
1113 border-radius: 5px;
1114 padding: 4px 10px;
1115 font-size: 12px;
1116 cursor: pointer;
1117 font-family: var(--font-sans, inherit);
1118 margin-top: 6px;
1119 }
1120 .diff-suggestion-toggle.is-active {
1121 background: rgba(52,211,153,0.1);
1122 color: #6ee7b7;
1123 border-color: rgba(52,211,153,0.35);
1124 }
1125 .diff-suggestion-textarea {
1126 width: 100%;
1127 background: rgba(52,211,153,0.04);
1128 color: var(--text);
1129 border: 1px solid rgba(52,211,153,0.25);
1130 border-radius: 4px;
1131 padding: 8px;
1132 font-size: 12.5px;
1133 font-family: var(--font-mono);
1134 resize: vertical;
1135 box-sizing: border-box;
1136 margin-top: 6px;
1137 display: none;
1138 }
1139 .diff-suggestion-textarea.is-visible { display: block; }
1140
ea9ed4cClaude1141 /* ─── Split-view scaffolding (phase 2 placeholder) ───
1142 The .diff-body element is grid-friendly; a future toggle can swap to
1143 'diff-body diff-split' and the per-row grid expands to two code panes. */
1144 .diff-body.diff-split .diff-row {
1145 grid-template-columns: 44px 1fr 44px 1fr;
1146 }
1147
1148 @media (max-width: 720px) {
1149 .diff-row {
1150 grid-template-columns: 32px 32px 14px 1fr;
1151 }
1152 .diff-hunk-header { padding-left: 64px; }
1153 .diff-file-blob-link { display: none; }
1154 }
1155`;