Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame1009 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;
ea9ed4cClaude330}
331
47a7a0aClaude332export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl }) => {
ea9ed4cClaude333 const parsed = parseUnifiedDiff(raw);
334
47a7a0aClaude335 // Build a lookup map: "filePath:lineNumber" → inline comments
336 const commentsByLine = new Map<string, InlineDiffComment[]>();
337 for (const c of inlineComments ?? []) {
338 const key = `${c.filePath}:${c.lineNumber}`;
339 const arr = commentsByLine.get(key) ?? [];
340 arr.push(c);
341 commentsByLine.set(key, arr);
342 }
343
ea9ed4cClaude344 // Stat fallback: if --numstat gave us per-file counts they trump our
345 // hunk-based count (only --numstat sees binary deltas accurately).
346 const statByPath = new Map<string, { add: number; del: number }>();
347 for (const f of files) {
348 statByPath.set(f.path, { add: f.additions, del: f.deletions });
349 }
350 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
351 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
352
353 return (
354 <div class="diff-view">
355 <style dangerouslySetInnerHTML={{ __html: DIFF_VIEW_CSS }} />
356
357 <div class="diff-summary">
358 <span class="diff-summary-count">
359 <strong>{files.length || parsed.length}</strong>{" "}
360 changed file{(files.length || parsed.length) !== 1 ? "s" : ""}
361 </span>
362 <StatPills add={totalAdd} del={totalDel} />
363 </div>
364
365 {parsed.map((file, fIdx) => {
366 const counts =
367 statByPath.get(file.path) ??
368 statByPath.get(file.oldPath ?? "") ??
369 countAddsDels(file);
370 const id = `diff-file-${fIdx}`;
371 const tooBig = file.lineCount > BIG_FILE_LINES;
372 const showDeletedOnly = file.status === "deleted";
373 const blobHref = viewFileBase
374 ? `${viewFileBase}/${file.path}`
375 : null;
376
377 // Highlight the post-change view + (separately) the pre-change view
378 // for deletions, so syntax colors survive both sides of a diff.
379 const { perLine: addedHighlights, language } = file.binary || tooBig
380 ? { perLine: new Map<string, string>(), language: null }
381 : highlightFile(file.path, file.hunks);
382 const deletedHighlights = file.binary || tooBig
383 ? new Map<string, string>()
384 : highlightDeletedLines(file.path, file.hunks);
385
386 return (
387 <details class="diff-file" id={id} open>
388 <summary class="diff-file-summary">
389 <span class="diff-file-chevron" aria-hidden="true">{"▾"}</span>
390 <StatusPill status={file.status} />
391 <span class="diff-file-path" title={file.path}>
392 {file.oldPath ? (
393 <>
394 <span class="diff-file-old">{file.oldPath}</span>
395 <span class="diff-file-arrow" aria-hidden="true">{" → "}</span>
396 <span class="diff-file-new">{file.path}</span>
397 </>
398 ) : (
399 file.path
400 )}
401 </span>
402 <button
403 type="button"
404 class="diff-file-copy"
405 data-copy={file.path}
406 title="Copy path"
407 aria-label={`Copy path ${file.path}`}
408 >
409 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
410 <path
411 fill="currentColor"
412 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"
413 />
414 <path
415 fill="currentColor"
416 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"
417 />
418 </svg>
419 </button>
420 <span class="diff-file-spacer" />
421 <StatPills add={counts.add} del={counts.del} />
422 {blobHref && (
423 <a
424 href={blobHref}
425 class="diff-file-blob-link"
426 title="View file at this revision"
427 >
428 View file
429 </a>
430 )}
431 </summary>
432
433 {file.binary ? (
434 <div class="diff-empty">Binary file not shown.</div>
435 ) : tooBig ? (
436 <div class="diff-empty diff-empty-big">
437 Large file ({file.lineCount.toLocaleString()} lines).{" "}
438 {blobHref ? (
439 <a href={blobHref}>Load full file</a>
440 ) : (
441 "Skipped inline render."
442 )}
443 </div>
444 ) : file.hunks.length === 0 ? (
445 <div class="diff-empty">No textual changes.</div>
446 ) : (
447 <div class={`diff-body${language ? " has-hljs" : ""}`}>
448 {file.hunks.map((hunk, hIdx) => (
449 <>
450 {hIdx > 0 && <div class="diff-hunk-gap" aria-hidden="true" />}
451 <div class="diff-hunk-header" role="separator">
452 <span class="diff-hunk-header-text">{hunk.header}</span>
453 </div>
454 {hunk.lines.map((ln, lIdx) => {
455 const key =
456 ln.kind === "del"
457 ? `${hunk.oldStart}:${ln.oldNum ?? "x"}`
458 : `${hunk.newStart}:${ln.newNum ?? "x"}`;
459 // Lookup the highlight match. Our maps key with an
460 // index suffix, so iterate to find it (cheap — small).
461 let highlighted: string | null = null;
462 if (showDeletedOnly || ln.kind === "del") {
463 for (const [k, v] of deletedHighlights) {
464 if (k.startsWith(`${hunk.oldStart}:${ln.oldNum ?? "x"}:`)) {
465 highlighted = v;
466 deletedHighlights.delete(k);
467 break;
468 }
469 }
470 } else {
471 for (const [k, v] of addedHighlights) {
472 if (k.startsWith(`${hunk.newStart}:${ln.newNum ?? "x"}:`)) {
473 highlighted = v;
474 addedHighlights.delete(k);
475 break;
476 }
477 }
478 }
479 const marker =
480 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
47a7a0aClaude481 // Inline comments anchor to the new-file line number
482 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
483 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
484 const canComment = commentActionUrl && ln.kind !== "del" && ln.newNum != null;
ea9ed4cClaude485 return (
47a7a0aClaude486 <>
487 <div
488 class={`diff-row diff-row-${ln.kind}`}
489 data-line={key}
490 data-file={canComment ? file.path : undefined}
491 data-newline={canComment ? ln.newNum : undefined}
492 >
493 <span class="diff-gutter diff-gutter-old">
494 {ln.oldNum ?? ""}
495 </span>
496 <span class="diff-gutter diff-gutter-new">
497 {ln.newNum ?? ""}
498 {canComment && (
499 <button class="diff-comment-btn" title="Add comment" aria-label="Add inline comment">+</button>
500 )}
501 </span>
502 <span class="diff-marker" aria-hidden="true">
503 {marker}
504 </span>
505 <CodeSpan html={highlighted} text={ln.text} />
506 </div>
507 {lineComments.map(c => (
508 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
509 <div class="diff-inline-comment-head">
510 <strong>{c.authorUsername}</strong>
511 <span class="diff-inline-comment-meta">
512 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
513 {new Date(c.createdAt).toLocaleDateString()}
514 </span>
515 </div>
516 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
517 </div>
518 ))}
519 </>
ea9ed4cClaude520 );
521 })}
522 </>
523 ))}
524 </div>
525 )}
526 </details>
527 );
528 })}
529
47a7a0aClaude530 {commentActionUrl && (
531 <meta name="diff-comment-url" content={commentActionUrl} />
532 )}
ea9ed4cClaude533 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
534 </div>
535 );
536};
537
538// ─── Inline script: copy-path button ───────────────────────────────────
539
540const DIFF_VIEW_JS = `
541(function () {
47a7a0aClaude542 // Copy-path button
ea9ed4cClaude543 document.addEventListener('click', function (e) {
544 var t = e.target;
545 if (!t) return;
47a7a0aClaude546 // Copy path button
547 var copyBtn = t.closest && t.closest('.diff-file-copy');
548 if (copyBtn) {
549 e.preventDefault();
550 var path = copyBtn.getAttribute('data-copy') || '';
551 if (!navigator.clipboard) return;
552 navigator.clipboard.writeText(path).then(function () {
553 copyBtn.classList.add('is-copied');
554 setTimeout(function () { copyBtn.classList.remove('is-copied'); }, 1200);
555 }).catch(function () {});
556 return;
557 }
558 // Inline comment "+" button
559 var commentBtn = t.closest && t.closest('.diff-comment-btn');
560 if (commentBtn) {
561 e.preventDefault();
562 var row = commentBtn.closest('.diff-row');
563 if (!row) return;
564 var filePath = row.getAttribute('data-file');
565 var lineNum = row.getAttribute('data-newline');
566 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
567 if (!filePath || !lineNum || !actionUrl) return;
568 // Remove any existing open form
569 var existing = document.querySelector('.diff-inline-form-row');
570 if (existing) {
571 if (existing.previousSibling === row) { existing.remove(); return; }
572 existing.remove();
573 }
574 // Build a form row
575 var form = document.createElement('form');
576 form.method = 'POST';
577 form.action = actionUrl.getAttribute('content') || '';
578 form.className = 'diff-inline-form-row';
579 form.innerHTML =
580 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'&quot;') + '">' +
581 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
582 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
583 '<div class="diff-inline-form-actions">' +
584 '<button type="submit" class="diff-inline-submit">Comment</button>' +
585 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
586 '</div>';
587 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
588 row.insertAdjacentElement('afterend', form);
589 form.querySelector('textarea').focus();
590 }
ea9ed4cClaude591 });
592})();
593`;
594
595// ─── Inline CSS ────────────────────────────────────────────────────────
596
597const DIFF_VIEW_CSS = `
598 .diff-view {
599 margin-top: 16px;
600 font-family: var(--font-mono);
601 }
602 .diff-summary {
603 display: flex;
604 align-items: center;
605 gap: 12px;
606 margin-bottom: 16px;
607 padding: 10px 14px;
608 background: var(--bg-elevated);
609 border: 1px solid var(--border);
610 border-radius: var(--r-md);
611 font-family: var(--font-sans, inherit);
612 font-size: 13px;
613 color: var(--text-muted);
614 }
615 .diff-summary-count strong { color: var(--text); }
616
617 .diff-stat-pills {
618 display: inline-flex;
619 align-items: center;
620 gap: 4px;
621 font-variant-numeric: tabular-nums;
622 }
623 .diff-stat-pill {
624 display: inline-flex;
625 align-items: center;
626 padding: 2px 8px;
627 border-radius: 999px;
628 font-size: 12px;
629 font-weight: 600;
630 font-family: var(--font-mono);
631 line-height: 1.4;
632 }
633 .diff-stat-add {
634 color: #6ee7b7;
635 background: rgba(52,211,153,0.12);
636 border: 1px solid rgba(52,211,153,0.22);
637 }
638 .diff-stat-del {
639 color: #fca5a5;
640 background: rgba(248,113,113,0.10);
641 border: 1px solid rgba(248,113,113,0.22);
642 }
643
644 .diff-file {
645 margin-bottom: 18px;
646 border: 1px solid var(--border);
647 border-radius: var(--r-md);
648 background: var(--bg-elevated);
649 overflow: hidden;
650 position: relative;
651 }
652 .diff-file::before {
653 content: '';
654 position: absolute;
655 top: 0; left: 0; right: 0;
656 height: 1px;
657 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
658 opacity: 0.7;
659 pointer-events: none;
660 }
661
662 .diff-file-summary {
663 list-style: none;
664 display: flex;
665 align-items: center;
666 gap: 10px;
667 padding: 10px 14px;
668 background: var(--bg-secondary);
669 border-bottom: 1px solid var(--border);
670 cursor: pointer;
671 user-select: none;
672 font-family: var(--font-sans, inherit);
673 font-size: 13px;
674 }
675 .diff-file-summary::-webkit-details-marker { display: none; }
676 .diff-file[open] .diff-file-summary { border-bottom: 1px solid var(--border); }
677 .diff-file:not([open]) .diff-file-summary { border-bottom: 0; }
678
679 .diff-file-chevron {
680 display: inline-flex;
681 color: var(--text-muted);
682 transition: transform 120ms ease;
683 width: 12px;
684 text-align: center;
685 }
686 .diff-file:not([open]) .diff-file-chevron { transform: rotate(-90deg); }
687
688 .diff-file-path {
689 font-family: var(--font-mono);
690 color: var(--text);
691 font-weight: 500;
692 overflow: hidden;
693 text-overflow: ellipsis;
694 white-space: nowrap;
695 }
696 .diff-file-old { color: var(--text-muted); text-decoration: line-through; }
697 .diff-file-arrow { color: var(--text-muted); }
698 .diff-file-new { color: var(--text); }
699
700 .diff-file-copy {
701 background: transparent;
702 border: 1px solid transparent;
703 border-radius: 6px;
704 color: var(--text-muted);
705 width: 24px;
706 height: 24px;
707 display: inline-flex;
708 align-items: center;
709 justify-content: center;
710 cursor: pointer;
711 padding: 0;
712 transition: all 120ms ease;
713 }
714 .diff-file-copy:hover {
715 color: var(--text);
716 background: var(--bg-elevated);
717 border-color: var(--border);
718 }
719 .diff-file-copy.is-copied {
720 color: var(--green, #6ee7b7);
721 background: rgba(52,211,153,0.12);
722 border-color: rgba(52,211,153,0.30);
723 }
724 .diff-file-copy.is-copied::after {
725 content: 'Copied';
726 position: absolute;
727 margin-left: 28px;
728 font-size: 11px;
729 color: var(--green, #6ee7b7);
730 font-family: var(--font-sans, inherit);
731 }
732
733 .diff-file-spacer { flex: 1; }
734
735 .diff-file-blob-link {
736 font-family: var(--font-sans, inherit);
737 font-size: 12px;
738 color: var(--text-muted);
739 padding: 3px 10px;
740 border-radius: 6px;
741 border: 1px solid var(--border);
742 background: var(--bg-elevated);
743 text-decoration: none;
744 transition: all 120ms ease;
745 }
746 .diff-file-blob-link:hover {
747 color: var(--text);
748 border-color: rgba(140,109,255,0.45);
749 background: var(--accent-gradient-faint, var(--bg-elevated));
750 }
751
752 .diff-status {
753 display: inline-flex;
754 align-items: center;
755 padding: 2px 8px;
756 border-radius: 4px;
757 font-size: 11px;
758 font-weight: 600;
759 font-family: var(--font-sans, inherit);
760 text-transform: uppercase;
761 letter-spacing: 0.04em;
762 line-height: 1.5;
763 border: 1px solid transparent;
764 }
765 .diff-status-added {
766 color: #6ee7b7;
767 background: rgba(52,211,153,0.10);
768 border-color: rgba(52,211,153,0.22);
769 }
770 .diff-status-modified {
771 color: #fcd34d;
772 background: rgba(252,211,77,0.08);
773 border-color: rgba(252,211,77,0.22);
774 }
775 .diff-status-renamed {
776 color: #93c5fd;
777 background: rgba(147,197,253,0.10);
778 border-color: rgba(147,197,253,0.25);
779 }
780 .diff-status-deleted {
781 color: #fca5a5;
782 background: rgba(248,113,113,0.10);
783 border-color: rgba(248,113,113,0.22);
784 }
785 .diff-status-binary {
786 color: var(--text-muted);
787 background: var(--bg-elevated);
788 border-color: var(--border);
789 }
790
791 /* ─── Diff body ─── */
792 .diff-body {
793 font-family: var(--font-mono);
794 font-size: 12.5px;
795 line-height: 1.55;
796 overflow-x: auto;
797 }
798 .diff-empty {
799 padding: 18px 16px;
800 text-align: center;
801 color: var(--text-muted);
802 font-family: var(--font-sans, inherit);
803 font-size: 13px;
804 }
805 .diff-empty-big { background: rgba(140,109,255,0.04); }
806
807 .diff-hunk-gap {
808 height: 1px;
809 background: linear-gradient(90deg, transparent 0%, var(--border) 25%, var(--border) 75%, transparent 100%);
810 margin: 0;
811 opacity: 0.7;
812 }
813 .diff-hunk-header {
814 padding: 4px 16px 4px 86px;
815 background: rgba(140,109,255,0.05);
816 color: var(--text-muted);
817 font-size: 11.5px;
818 border-top: 1px solid rgba(140,109,255,0.18);
819 border-bottom: 1px solid rgba(140,109,255,0.18);
820 }
821 .diff-hunk-header-text { font-family: var(--font-mono); }
822
823 .diff-row {
824 display: grid;
825 grid-template-columns: 44px 44px 16px 1fr;
826 align-items: stretch;
827 min-height: 1.55em;
828 }
829 .diff-gutter {
830 text-align: right;
831 padding: 0 6px;
832 color: var(--text-muted);
833 background: var(--bg-elevated);
834 border-right: 1px solid var(--border);
835 user-select: none;
836 font-variant-numeric: tabular-nums;
837 font-size: 11.5px;
838 opacity: 0.75;
839 }
840 .diff-gutter-new { border-right: 1px solid var(--border); }
841
842 .diff-marker {
843 text-align: center;
844 color: var(--text-muted);
845 user-select: none;
846 background: var(--bg-elevated);
847 border-right: 1px solid var(--border);
848 }
849 .diff-code {
850 padding: 0 12px;
851 white-space: pre;
852 color: var(--text);
853 overflow-x: visible;
854 }
855
856 /* Row tints — additions / deletions / context */
857 .diff-row-add {
858 background: rgba(52,211,153,0.08);
859 }
860 .diff-row-add .diff-gutter,
861 .diff-row-add .diff-marker {
862 background: rgba(52,211,153,0.14);
863 color: #6ee7b7;
864 border-right-color: rgba(52,211,153,0.20);
865 }
866 .diff-row-add .diff-marker { color: #6ee7b7; font-weight: 600; }
867
868 .diff-row-del {
869 background: rgba(248,113,113,0.08);
870 }
871 .diff-row-del .diff-gutter,
872 .diff-row-del .diff-marker {
873 background: rgba(248,113,113,0.14);
874 color: #fca5a5;
875 border-right-color: rgba(248,113,113,0.20);
876 }
877 .diff-row-del .diff-marker { color: #fca5a5; font-weight: 600; }
878
879 .diff-row:hover .diff-gutter { opacity: 1; }
880
47a7a0aClaude881 /* ─── Inline comment "+" button ─── */
882 .diff-comment-btn {
883 display: none;
884 position: absolute;
885 right: 2px;
886 top: 50%;
887 transform: translateY(-50%);
888 width: 16px; height: 16px;
889 padding: 0;
890 background: var(--accent, #8c6dff);
891 color: #fff;
892 border: none;
893 border-radius: 3px;
894 font-size: 12px;
895 line-height: 1;
896 cursor: pointer;
897 z-index: 2;
898 }
899 .diff-gutter-new { position: relative; }
900 .diff-row:hover .diff-comment-btn { display: flex; align-items: center; justify-content: center; }
901
902 /* ─── Inline comments anchored to diff lines ─── */
903 .diff-inline-comment {
904 grid-column: 1 / -1;
905 display: block;
906 margin: 4px 0;
907 padding: 10px 14px;
908 background: var(--bg-elevated);
909 border-left: 3px solid var(--border);
910 border-radius: 0 4px 4px 0;
911 font-family: var(--font-sans, inherit);
912 font-size: 13px;
913 }
914 .diff-inline-comment-ai { border-left-color: #8c6dff; }
915 .diff-inline-comment-head {
916 display: flex; gap: 8px; align-items: center;
917 margin-bottom: 6px;
918 font-size: 12px;
919 color: var(--text-muted);
920 }
921 .diff-inline-comment-head strong { color: var(--text-strong); }
922 .diff-inline-ai-badge {
923 background: rgba(140,109,255,0.2);
924 color: #a78bfa;
925 border-radius: 3px;
926 padding: 1px 5px;
927 font-size: 10px;
928 font-weight: 600;
929 }
930 .diff-inline-comment-body { color: var(--text); line-height: 1.6; }
931
932 /* ─── Inline comment form ─── */
933 .diff-inline-form-row {
934 grid-column: 1 / -1;
935 display: block;
936 padding: 10px 14px;
937 background: var(--bg-elevated);
938 border-top: 1px solid var(--border);
939 border-bottom: 1px solid var(--border);
940 }
941 .diff-inline-textarea {
942 width: 100%;
943 min-height: 72px;
944 background: var(--bg);
945 color: var(--text);
946 border: 1px solid var(--border);
947 border-radius: 4px;
948 padding: 8px;
949 font-size: 13px;
950 font-family: var(--font-sans, inherit);
951 resize: vertical;
952 box-sizing: border-box;
953 }
954 .diff-inline-textarea:focus { outline: none; border-color: var(--accent, #8c6dff); }
955 .diff-inline-form-actions {
956 display: flex; gap: 8px; margin-top: 8px;
957 }
958 .diff-inline-submit {
959 background: var(--accent, #8c6dff);
960 color: #fff;
961 border: none;
962 border-radius: 5px;
963 padding: 6px 14px;
964 font-size: 13px;
965 cursor: pointer;
966 }
967 .diff-inline-cancel {
968 background: transparent;
969 color: var(--text-muted);
970 border: 1px solid var(--border);
971 border-radius: 5px;
972 padding: 6px 14px;
973 font-size: 13px;
974 cursor: pointer;
975 }
976
ea9ed4cClaude977 /* Highlight.js theme overrides so colors layer correctly on tints */
978 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
979 .diff-body.has-hljs .hljs-built_in,
980 .diff-body.has-hljs .hljs-type { color: #ffa657; }
981 .diff-body.has-hljs .hljs-literal,
982 .diff-body.has-hljs .hljs-number { color: #79c0ff; }
983 .diff-body.has-hljs .hljs-string { color: #a5d6ff; }
984 .diff-body.has-hljs .hljs-title,
985 .diff-body.has-hljs .hljs-title.function_ { color: #d2a8ff; }
986 .diff-body.has-hljs .hljs-comment { color: #8b949e; font-style: italic; }
987 .diff-body.has-hljs .hljs-attr,
988 .diff-body.has-hljs .hljs-attribute,
989 .diff-body.has-hljs .hljs-meta { color: #79c0ff; }
990 .diff-body.has-hljs .hljs-tag,
991 .diff-body.has-hljs .hljs-name { color: #7ee787; }
992 .diff-body.has-hljs .hljs-variable,
993 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
994
995 /* ─── Split-view scaffolding (phase 2 placeholder) ───
996 The .diff-body element is grid-friendly; a future toggle can swap to
997 'diff-body diff-split' and the per-row grid expands to two code panes. */
998 .diff-body.diff-split .diff-row {
999 grid-template-columns: 44px 1fr 44px 1fr;
1000 }
1001
1002 @media (max-width: 720px) {
1003 .diff-row {
1004 grid-template-columns: 32px 32px 14px 1fr;
1005 }
1006 .diff-hunk-header { padding-left: 64px; }
1007 .diff-file-blob-link { display: none; }
1008 }
1009`;