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.tsxBlame1517 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
a2c10c5Claude286// ─── Split-view helpers ─────────────────────────────────────────────────
287
288interface SplitRow {
289 left: { kind: "ctx" | "add" | "del"; text: string; oldNum: number | null; newNum: number | null } | null;
290 right: { kind: "ctx" | "add" | "del"; text: string; oldNum: number | null; newNum: number | null } | null;
291 isHunk?: boolean;
292 hunkHeader?: string;
293}
294
295type ParsedLine = ParsedHunk["lines"][number];
296
297function toSplitRows(lines: ParsedLine[]): SplitRow[] {
298 const rows: SplitRow[] = [];
299 let i = 0;
300 while (i < lines.length) {
301 const ln = lines[i];
302 if (ln.kind === "ctx") {
303 rows.push({ left: ln, right: ln });
304 i++;
305 continue;
306 }
307 // Collect a run of del then add lines
308 const dels: ParsedLine[] = [];
309 const adds: ParsedLine[] = [];
310 while (i < lines.length && lines[i].kind === "del") { dels.push(lines[i++]); }
311 while (i < lines.length && lines[i].kind === "add") { adds.push(lines[i++]); }
312 const max = Math.max(dels.length, adds.length);
313 for (let j = 0; j < max; j++) {
314 rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
315 }
316 }
317 return rows;
318}
319
ea9ed4cClaude320// ─── File header pieces ─────────────────────────────────────────────────
321
322const StatusPill: FC<{ status: ParsedFile["status"] }> = ({ status }) => {
323 const label =
324 status === "added"
325 ? "Added"
326 : status === "deleted"
327 ? "Deleted"
328 : status === "renamed"
329 ? "Renamed"
330 : status === "binary"
331 ? "Binary"
332 : "Modified";
333 return <span class={`diff-status diff-status-${status}`}>{label}</span>;
334};
335
336const StatPills: FC<{ add: number; del: number }> = ({ add, del }) => (
337 <span class="diff-stat-pills" aria-label={`+${add} additions, -${del} deletions`}>
338 <span class="diff-stat-pill diff-stat-add">+{add}</span>
339 <span class="diff-stat-pill diff-stat-del">−{del}</span>
340 </span>
341);
342
343// ─── Public component ──────────────────────────────────────────────────
344
47a7a0aClaude345export interface InlineDiffComment {
346 id: string;
347 filePath: string;
348 lineNumber: number;
349 authorUsername: string;
350 body: string;
351 createdAt: string;
352 isAiReview?: boolean;
353}
354
ea9ed4cClaude355export interface DiffViewProps {
356 raw: string;
357 files: GitDiffFile[];
358 /** When set, file headers gain "View file" links to `${viewFileBase}/${path}`. */
359 viewFileBase?: string;
47a7a0aClaude360 /** Existing inline comments to render anchored to their file+line */
361 inlineComments?: InlineDiffComment[];
362 /** URL to POST a new inline comment to (shows gutter "+" buttons when set) */
363 commentActionUrl?: string;
b5dd694Claude364 /** If set, shows "Apply suggestion" button on suggestion blocks; POSTs to `${applySuggestionUrl}/${commentId}` */
365 applySuggestionUrl?: string;
a2c10c5Claude366 /** URL to POST pending review comment additions to */
367 pendingReviewUrl?: string;
368 /** URL to POST final review submission to */
369 submitReviewUrl?: string;
370 /** When true, render split (side-by-side) diff instead of unified */
371 isSplit?: boolean;
372 /** Number of pending review comments (shows sticky submit bar when > 0) */
373 pendingCount?: number;
374 /** Repo owner for building review URLs */
375 owner?: string;
376 /** Repo name for building review URLs */
377 repo?: string;
378 /** PR number for building review URLs */
379 prNumber?: number;
ea9ed4cClaude380}
381
a2c10c5Claude382export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl, applySuggestionUrl, pendingReviewUrl, submitReviewUrl, isSplit, pendingCount, owner, repo, prNumber }) => {
ea9ed4cClaude383 const parsed = parseUnifiedDiff(raw);
384
47a7a0aClaude385 // Build a lookup map: "filePath:lineNumber" → inline comments
386 const commentsByLine = new Map<string, InlineDiffComment[]>();
387 for (const c of inlineComments ?? []) {
388 const key = `${c.filePath}:${c.lineNumber}`;
389 const arr = commentsByLine.get(key) ?? [];
390 arr.push(c);
391 commentsByLine.set(key, arr);
392 }
393
ea9ed4cClaude394 // Stat fallback: if --numstat gave us per-file counts they trump our
395 // hunk-based count (only --numstat sees binary deltas accurately).
396 const statByPath = new Map<string, { add: number; del: number }>();
397 for (const f of files) {
398 statByPath.set(f.path, { add: f.additions, del: f.deletions });
399 }
400 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
401 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
402
516b91eClaude403 const fileCount = files.length || parsed.length;
404 const showJumpNav = fileCount > 3;
405
ea9ed4cClaude406 return (
407 <div class="diff-view">
408 <style dangerouslySetInnerHTML={{ __html: DIFF_VIEW_CSS }} />
409
410 <div class="diff-summary">
411 <span class="diff-summary-count">
516b91eClaude412 <strong>{fileCount}</strong>{" "}
413 changed file{fileCount !== 1 ? "s" : ""}
ea9ed4cClaude414 </span>
415 <StatPills add={totalAdd} del={totalDel} />
516b91eClaude416 {showJumpNav && (
417 <button
418 type="button"
419 class="diff-jump-toggle"
420 aria-expanded="false"
421 aria-controls="diff-jump-nav"
422 >
423 Jump to file ▾
424 </button>
425 )}
ea9ed4cClaude426 </div>
427
516b91eClaude428 {showJumpNav && (
429 <div id="diff-jump-nav" class="diff-jump-nav" hidden>
430 {parsed.map((file, fIdx) => {
431 const counts = statByPath.get(file.path) ?? statByPath.get(file.oldPath ?? "") ?? countAddsDels(file);
432 return (
433 <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')">
434 <span class="diff-jump-path">{file.path}</span>
435 <span class="diff-jump-pills">
436 {counts.add > 0 && <span class="diff-jump-add">+{counts.add}</span>}
437 {counts.del > 0 && <span class="diff-jump-del">-{counts.del}</span>}
438 </span>
439 </a>
440 );
441 })}
442 </div>
443 )}
444
ea9ed4cClaude445 {parsed.map((file, fIdx) => {
446 const counts =
447 statByPath.get(file.path) ??
448 statByPath.get(file.oldPath ?? "") ??
449 countAddsDels(file);
450 const id = `diff-file-${fIdx}`;
451 const tooBig = file.lineCount > BIG_FILE_LINES;
452 const showDeletedOnly = file.status === "deleted";
453 const blobHref = viewFileBase
454 ? `${viewFileBase}/${file.path}`
455 : null;
456
457 // Highlight the post-change view + (separately) the pre-change view
458 // for deletions, so syntax colors survive both sides of a diff.
459 const { perLine: addedHighlights, language } = file.binary || tooBig
460 ? { perLine: new Map<string, string>(), language: null }
461 : highlightFile(file.path, file.hunks);
462 const deletedHighlights = file.binary || tooBig
463 ? new Map<string, string>()
464 : highlightDeletedLines(file.path, file.hunks);
465
466 return (
467 <details class="diff-file" id={id} open>
468 <summary class="diff-file-summary">
469 <span class="diff-file-chevron" aria-hidden="true">{"▾"}</span>
470 <StatusPill status={file.status} />
471 <span class="diff-file-path" title={file.path}>
472 {file.oldPath ? (
473 <>
474 <span class="diff-file-old">{file.oldPath}</span>
475 <span class="diff-file-arrow" aria-hidden="true">{" → "}</span>
476 <span class="diff-file-new">{file.path}</span>
477 </>
478 ) : (
479 file.path
480 )}
481 </span>
482 <button
483 type="button"
484 class="diff-file-copy"
485 data-copy={file.path}
486 title="Copy path"
487 aria-label={`Copy path ${file.path}`}
488 >
489 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
490 <path
491 fill="currentColor"
492 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"
493 />
494 <path
495 fill="currentColor"
496 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"
497 />
498 </svg>
499 </button>
500 <span class="diff-file-spacer" />
501 <StatPills add={counts.add} del={counts.del} />
502 {blobHref && (
503 <a
504 href={blobHref}
505 class="diff-file-blob-link"
506 title="View file at this revision"
507 >
508 View file
509 </a>
510 )}
a2c10c5Claude511 <span style="margin-left:4px;display:flex;gap:2px;background:var(--bg-tertiary);border-radius:6px;padding:2px;">
512 <a href="?diffview=unified" class={`diff-view-tab${isSplit ? "" : " diff-view-tab-active"}`}>Unified</a>
513 <a href="?diffview=split" class={`diff-view-tab${isSplit ? " diff-view-tab-active" : ""}`}>Split</a>
514 </span>
ea9ed4cClaude515 </summary>
516
517 {file.binary ? (
518 <div class="diff-empty">Binary file not shown.</div>
519 ) : tooBig ? (
520 <div class="diff-empty diff-empty-big">
521 Large file ({file.lineCount.toLocaleString()} lines).{" "}
522 {blobHref ? (
523 <a href={blobHref}>Load full file</a>
524 ) : (
525 "Skipped inline render."
526 )}
527 </div>
528 ) : file.hunks.length === 0 ? (
529 <div class="diff-empty">No textual changes.</div>
a2c10c5Claude530 ) : isSplit ? (
531 <div class={`diff-body diff-body-split${language ? " has-hljs" : ""}`}>
532 <table class="diff-table-split" style="width:100%;border-collapse:collapse;">
533 <colgroup>
534 <col style="width:40px;" /><col style="width:50%;" />
535 <col style="width:40px;" /><col style="width:50%;" />
536 </colgroup>
537 <tbody>
538 {file.hunks.map((hunk) => {
539 const splitRows = toSplitRows(hunk.lines);
540 return (
541 <>
542 <tr class="diff-split-hunk-row">
543 <td colspan={4} class="diff-hunk-header" style="padding:4px 8px;font-size:11px;font-family:var(--font-mono);color:var(--text-muted);background:rgba(91,110,232,0.06);">
544 <span class="diff-hunk-header-text">{hunk.header}</span>
545 </td>
546 </tr>
547 {splitRows.map((row) => {
548 const leftBg = row.left?.kind === "del" ? "rgba(248,113,113,0.08)" : "transparent";
549 const rightBg = row.right?.kind === "add" ? "rgba(52,211,153,0.08)" : "transparent";
550 return (
551 <tr class="diff-split-row">
552 <td class="diff-ln" style={`background:${leftBg};color:var(--text-muted);padding:0 6px;font-size:11px;font-family:var(--font-mono);text-align:right;user-select:none;border-right:1px solid var(--border);`}>
553 {row.left?.oldNum ?? ""}
554 </td>
555 <td class="diff-split-cell" style={`background:${leftBg};padding:0 var(--space-2,8px);font-family:var(--font-mono);font-size:12.5px;white-space:pre;overflow:hidden;border-right:1px solid var(--border);`}>
556 {row.left ? (
557 <span>{(row.left.kind === "del" ? "- " : " ") + row.left.text}</span>
558 ) : null}
559 </td>
560 <td class="diff-ln" style={`background:${rightBg};color:var(--text-muted);padding:0 6px;font-size:11px;font-family:var(--font-mono);text-align:right;user-select:none;border-right:1px solid var(--border);`}>
561 {row.right?.newNum ?? ""}
562 </td>
563 <td class="diff-split-cell" style={`background:${rightBg};padding:0 var(--space-2,8px);font-family:var(--font-mono);font-size:12.5px;white-space:pre;overflow:hidden;`}>
564 {row.right ? (
565 <span>{(row.right.kind === "add" ? "+ " : " ") + row.right.text}</span>
566 ) : null}
567 </td>
568 </tr>
569 );
570 })}
571 </>
572 );
573 })}
574 </tbody>
575 </table>
576 </div>
ea9ed4cClaude577 ) : (
578 <div class={`diff-body${language ? " has-hljs" : ""}`}>
579 {file.hunks.map((hunk, hIdx) => (
580 <>
581 {hIdx > 0 && <div class="diff-hunk-gap" aria-hidden="true" />}
582 <div class="diff-hunk-header" role="separator">
583 <span class="diff-hunk-header-text">{hunk.header}</span>
584 </div>
585 {hunk.lines.map((ln, lIdx) => {
586 const key =
587 ln.kind === "del"
588 ? `${hunk.oldStart}:${ln.oldNum ?? "x"}`
589 : `${hunk.newStart}:${ln.newNum ?? "x"}`;
590 // Lookup the highlight match. Our maps key with an
591 // index suffix, so iterate to find it (cheap — small).
592 let highlighted: string | null = null;
593 if (showDeletedOnly || ln.kind === "del") {
594 for (const [k, v] of deletedHighlights) {
595 if (k.startsWith(`${hunk.oldStart}:${ln.oldNum ?? "x"}:`)) {
596 highlighted = v;
597 deletedHighlights.delete(k);
598 break;
599 }
600 }
601 } else {
602 for (const [k, v] of addedHighlights) {
603 if (k.startsWith(`${hunk.newStart}:${ln.newNum ?? "x"}:`)) {
604 highlighted = v;
605 addedHighlights.delete(k);
606 break;
607 }
608 }
609 }
610 const marker =
611 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
47a7a0aClaude612 // Inline comments anchor to the new-file line number
613 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
614 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
a2c10c5Claude615 const canComment = (commentActionUrl || pendingReviewUrl) && ln.kind !== "del" && ln.newNum != null;
ea9ed4cClaude616 return (
47a7a0aClaude617 <>
618 <div
619 class={`diff-row diff-row-${ln.kind}`}
620 data-line={key}
621 data-file={canComment ? file.path : undefined}
622 data-newline={canComment ? ln.newNum : undefined}
b5dd694Claude623 data-linetext={canComment ? ln.text : undefined}
47a7a0aClaude624 >
625 <span class="diff-gutter diff-gutter-old">
626 {ln.oldNum ?? ""}
627 </span>
628 <span class="diff-gutter diff-gutter-new">
629 {ln.newNum ?? ""}
630 {canComment && (
631 <button class="diff-comment-btn" title="Add comment" aria-label="Add inline comment">+</button>
632 )}
633 </span>
634 <span class="diff-marker" aria-hidden="true">
635 {marker}
636 </span>
637 <CodeSpan html={highlighted} text={ln.text} />
638 </div>
b5dd694Claude639 {lineComments.map(c => {
640 // Detect suggestion block: ```suggestion\n...\n```
641 const suggMatch = c.body.match(/^```suggestion\n([\s\S]*?)\n```/);
642 if (suggMatch) {
643 const suggCode = suggMatch[1];
644 // Any text after the closing ``` fence is treated as the comment prose
645 const afterFence = c.body.slice(suggMatch[0].length).trim();
646 return (
647 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
648 <div class="diff-inline-comment-head">
649 <strong>{c.authorUsername}</strong>
650 <span class="diff-inline-comment-meta">
651 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
652 {new Date(c.createdAt).toLocaleDateString()}
653 </span>
654 </div>
655 <div class="diff-suggestion-block">
656 <div class="diff-suggestion-header">
657 <span>Suggested change</span>
658 {applySuggestionUrl && (
f5b9ef5Claude659 <form method="post" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
b5dd694Claude660 <button type="submit" class="diff-apply-btn">Apply suggestion</button>
661 </form>
662 )}
663 </div>
664 <pre class="diff-suggestion-code">{suggCode}</pre>
665 </div>
666 {afterFence && (
667 <div class="diff-inline-comment-body" style="margin-top:6px;" dangerouslySetInnerHTML={{ __html: afterFence }} />
668 )}
669 </div>
670 );
671 }
672 return (
673 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
674 <div class="diff-inline-comment-head">
675 <strong>{c.authorUsername}</strong>
676 <span class="diff-inline-comment-meta">
677 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
678 {new Date(c.createdAt).toLocaleDateString()}
679 </span>
680 </div>
681 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
47a7a0aClaude682 </div>
b5dd694Claude683 );
684 })}
47a7a0aClaude685 </>
ea9ed4cClaude686 );
687 })}
688 </>
689 ))}
690 </div>
691 )}
692 </details>
693 );
694 })}
695
47a7a0aClaude696 {commentActionUrl && (
697 <meta name="diff-comment-url" content={commentActionUrl} />
698 )}
b5dd694Claude699 {applySuggestionUrl && (
700 <meta name="diff-apply-suggestion-url" content={applySuggestionUrl} />
701 )}
a2c10c5Claude702 {pendingReviewUrl && (
703 <meta name="diff-pending-review-url" content={pendingReviewUrl} />
704 )}
705
706 {pendingCount != null && pendingCount > 0 && submitReviewUrl && (
707 <>
708 <div class="diff-pending-bar">
709 <span>{pendingCount} pending comment{pendingCount !== 1 ? "s" : ""}</span>
710 <button
711 type="button"
712 onclick="document.getElementById('submit-review-dialog').style.display='flex'"
713 class="btn"
714 style="background:var(--accent,#5b6ee8);color:#fff;border:none;border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
715 >
716 Submit review
717 </button>
718 </div>
719 <div
720 id="submit-review-dialog"
721 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;"
722 >
723 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:var(--space-5,20px);width:min(520px,90vw);max-height:80vh;overflow-y:auto;">
724 <h3 style="margin:0 0 var(--space-3,12px);font-size:16px;color:var(--text);">Submit review</h3>
725 <form method="post" action={submitReviewUrl}>
726 <textarea
727 name="reviewBody"
728 placeholder="Leave a review summary (optional)..."
729 class="diff-inline-textarea"
730 rows={4}
731 style="width:100%;margin-bottom:var(--space-3,12px);"
732 />
733 <div style="display:flex;gap:var(--space-2,8px);margin-bottom:var(--space-3,12px);flex-wrap:wrap;">
734 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
735 <input type="radio" name="reviewState" value="comment" checked /> Comment
736 </label>
737 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
738 <input type="radio" name="reviewState" value="approve" /> Approve
739 </label>
740 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
741 <input type="radio" name="reviewState" value="request_changes" /> Request changes
742 </label>
743 </div>
744 <div style="display:flex;gap:var(--space-2,8px);justify-content:flex-end;">
745 <button
746 type="button"
747 onclick="document.getElementById('submit-review-dialog').style.display='none'"
748 style="background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
749 >
750 Cancel
751 </button>
752 <button
753 type="submit"
754 style="background:var(--accent,#5b6ee8);color:#fff;border:none;border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
755 >
756 Submit {pendingCount} comment{pendingCount !== 1 ? "s" : ""}
757 </button>
758 </div>
759 </form>
760 </div>
761 </div>
762 </>
763 )}
764
ea9ed4cClaude765 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
766 </div>
767 );
768};
769
770// ─── Inline script: copy-path button ───────────────────────────────────
771
772const DIFF_VIEW_JS = `
773(function () {
516b91eClaude774 // Jump-to-file nav toggle
775 var jumpToggle = document.querySelector('.diff-jump-toggle');
776 if (jumpToggle) {
777 jumpToggle.addEventListener('click', function () {
778 var nav = document.getElementById('diff-jump-nav');
779 if (!nav) return;
780 var open = nav.hidden;
781 nav.hidden = !open;
782 jumpToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
783 if (open) jumpToggle.classList.add('is-open');
784 else jumpToggle.classList.remove('is-open');
785 });
786 // Close on outside click
787 document.addEventListener('click', function (ev) {
788 var nav = document.getElementById('diff-jump-nav');
789 if (!nav || nav.hidden) return;
790 if (!jumpToggle.contains(ev.target) && !nav.contains(ev.target)) {
791 nav.hidden = true;
792 jumpToggle.setAttribute('aria-expanded', 'false');
793 jumpToggle.classList.remove('is-open');
794 }
795 });
796 }
797
47a7a0aClaude798 // Copy-path button
ea9ed4cClaude799 document.addEventListener('click', function (e) {
800 var t = e.target;
801 if (!t) return;
47a7a0aClaude802 // Copy path button
803 var copyBtn = t.closest && t.closest('.diff-file-copy');
804 if (copyBtn) {
805 e.preventDefault();
806 var path = copyBtn.getAttribute('data-copy') || '';
807 if (!navigator.clipboard) return;
808 navigator.clipboard.writeText(path).then(function () {
809 copyBtn.classList.add('is-copied');
810 setTimeout(function () { copyBtn.classList.remove('is-copied'); }, 1200);
811 }).catch(function () {});
812 return;
813 }
814 // Inline comment "+" button
815 var commentBtn = t.closest && t.closest('.diff-comment-btn');
816 if (commentBtn) {
817 e.preventDefault();
818 var row = commentBtn.closest('.diff-row');
819 if (!row) return;
820 var filePath = row.getAttribute('data-file');
821 var lineNum = row.getAttribute('data-newline');
b5dd694Claude822 var lineText = row.getAttribute('data-linetext') || '';
a2c10c5Claude823 var actionMeta = document.querySelector('meta[name="diff-comment-url"]');
824 var pendingMeta = document.querySelector('meta[name="diff-pending-review-url"]');
825 if (!filePath || !lineNum) return;
826 if (!actionMeta && !pendingMeta) return;
827 var directUrl = actionMeta ? (actionMeta.getAttribute('content') || '') : '';
828 var pendingUrl = pendingMeta ? (pendingMeta.getAttribute('content') || '') : '';
47a7a0aClaude829 // Remove any existing open form
830 var existing = document.querySelector('.diff-inline-form-row');
831 if (existing) {
832 if (existing.previousSibling === row) { existing.remove(); return; }
833 existing.remove();
834 }
a2c10c5Claude835 // Build a form row — if pendingUrl exists, show two-option UI
836 var wrapper = document.createElement('div');
837 wrapper.className = 'diff-inline-form-row';
838 if (pendingUrl) {
839 // Two-option UI: "Add to review" or "Comment directly"
840 wrapper.innerHTML =
841 '<div class="diff-comment-choice">' +
842 '<form method="POST" action="' + pendingUrl.replace(/"/g,'&quot;') + '" class="diff-pending-form">' +
843 '<input type="hidden" name="filePath" value="' + filePath.replace(/"/g,'&quot;') + '">' +
844 '<input type="hidden" name="lineNumber" value="' + lineNum + '">' +
845 '<textarea name="body" class="diff-inline-textarea" rows="3" placeholder="Leave a comment..." required></textarea>' +
846 '<div class="diff-comment-actions">' +
847 '<button type="submit" class="diff-inline-submit">Add to review</button>' +
848 '<span style="font-size:11px;color:var(--text-muted)">or</span>' +
849 (directUrl ? '<button type="button" class="diff-comment-direct-btn diff-inline-cancel">Comment directly</button>' : '') +
850 '<button type="button" class="diff-inline-cancel" style="margin-left:auto;">Cancel</button>' +
851 '</div>' +
852 '</form>' +
853 '</div>';
854 // "Comment directly" switches action to direct URL and hides pending-only fields
855 var directBtn = wrapper.querySelector('.diff-comment-direct-btn');
856 if (directBtn && directUrl) {
857 directBtn.addEventListener('click', function () {
858 var pForm = wrapper.querySelector('.diff-pending-form');
859 pForm.action = directUrl;
860 // swap hidden field names to match direct endpoint
861 var fpInput = pForm.querySelector('input[name="filePath"]');
862 var lnInput = pForm.querySelector('input[name="lineNumber"]');
863 if (fpInput) fpInput.name = 'file_path';
864 if (lnInput) lnInput.name = 'line_number';
865 directBtn.style.display = 'none';
866 pForm.querySelector('.diff-inline-submit').textContent = 'Comment';
867 });
b5dd694Claude868 }
a2c10c5Claude869 var cancelBtn = wrapper.querySelector('.diff-inline-cancel:last-child');
870 if (cancelBtn) cancelBtn.addEventListener('click', function () { wrapper.remove(); });
871 var bodyTA = wrapper.querySelector('textarea[name="body"]');
872 row.insertAdjacentElement('afterend', wrapper);
873 if (bodyTA) bodyTA.focus();
874 } else if (directUrl) {
875 // Original single-option form
876 var form = document.createElement('form');
877 form.method = 'POST';
878 form.action = directUrl;
879 form.innerHTML =
880 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'&quot;') + '">' +
881 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
882 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
883 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
884 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
885 '<div class="diff-inline-form-actions">' +
886 '<button type="submit" class="diff-inline-submit">Comment</button>' +
887 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
888 '</div>';
889 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
890 var suggTA = form.querySelector('.diff-suggestion-textarea');
891 var submitBtn = form.querySelector('.diff-inline-submit');
892 var bodyTA2 = form.querySelector('textarea[name="body"]');
893 var suggestionActive = false;
894 toggleBtn.addEventListener('click', function () {
895 suggestionActive = !suggestionActive;
896 if (suggestionActive) {
897 toggleBtn.classList.add('is-active');
898 suggTA.classList.add('is-visible');
899 suggTA.value = lineText;
900 submitBtn.textContent = 'Add suggestion & comment';
901 } else {
902 toggleBtn.classList.remove('is-active');
903 suggTA.classList.remove('is-visible');
904 submitBtn.textContent = 'Comment';
905 }
906 });
907 form.addEventListener('submit', function (ev) {
908 if (!suggestionActive) return;
909 ev.preventDefault();
910 var suggVal = suggTA.value;
911 var commentText = bodyTA2.value;
912 var wrapped = '\`\`\`suggestion\n' + suggVal + '\n\`\`\`';
913 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
914 bodyTA2.value = fullBody;
915 bodyTA2.removeAttribute('required');
916 suggestionActive = false;
917 form.submit();
918 });
919 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
920 wrapper.appendChild(form);
921 row.insertAdjacentElement('afterend', wrapper);
922 if (bodyTA2) bodyTA2.focus();
923 }
47a7a0aClaude924 }
ea9ed4cClaude925 });
926})();
927`;
928
929// ─── Inline CSS ────────────────────────────────────────────────────────
930
931const DIFF_VIEW_CSS = `
932 .diff-view {
933 margin-top: 16px;
934 font-family: var(--font-mono);
935 }
936 .diff-summary {
937 display: flex;
938 align-items: center;
939 gap: 12px;
940 margin-bottom: 16px;
941 padding: 10px 14px;
942 background: var(--bg-elevated);
943 border: 1px solid var(--border);
944 border-radius: var(--r-md);
945 font-family: var(--font-sans, inherit);
946 font-size: 13px;
947 color: var(--text-muted);
948 }
949 .diff-summary-count strong { color: var(--text); }
950
951 .diff-stat-pills {
952 display: inline-flex;
953 align-items: center;
954 gap: 4px;
955 font-variant-numeric: tabular-nums;
956 }
957 .diff-stat-pill {
958 display: inline-flex;
959 align-items: center;
960 padding: 2px 8px;
961 border-radius: 999px;
962 font-size: 12px;
963 font-weight: 600;
964 font-family: var(--font-mono);
965 line-height: 1.4;
966 }
967 .diff-stat-add {
968 color: #6ee7b7;
969 background: rgba(52,211,153,0.12);
970 border: 1px solid rgba(52,211,153,0.22);
971 }
972 .diff-stat-del {
973 color: #fca5a5;
974 background: rgba(248,113,113,0.10);
975 border: 1px solid rgba(248,113,113,0.22);
976 }
977
978 .diff-file {
979 margin-bottom: 18px;
980 border: 1px solid var(--border);
981 border-radius: var(--r-md);
982 background: var(--bg-elevated);
983 overflow: hidden;
984 position: relative;
985 }
986 .diff-file::before {
987 content: '';
988 position: absolute;
989 top: 0; left: 0; right: 0;
990 height: 1px;
6fd5915Claude991 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.55) 30%, rgba(95,143,160,0.55) 70%, transparent 100%);
ea9ed4cClaude992 opacity: 0.7;
993 pointer-events: none;
994 }
995
996 .diff-file-summary {
997 list-style: none;
998 display: flex;
999 align-items: center;
1000 gap: 10px;
1001 padding: 10px 14px;
1002 background: var(--bg-secondary);
1003 border-bottom: 1px solid var(--border);
1004 cursor: pointer;
1005 user-select: none;
1006 font-family: var(--font-sans, inherit);
1007 font-size: 13px;
1008 }
1009 .diff-file-summary::-webkit-details-marker { display: none; }
1010 .diff-file[open] .diff-file-summary { border-bottom: 1px solid var(--border); }
1011 .diff-file:not([open]) .diff-file-summary { border-bottom: 0; }
1012
1013 .diff-file-chevron {
1014 display: inline-flex;
1015 color: var(--text-muted);
1016 transition: transform 120ms ease;
1017 width: 12px;
1018 text-align: center;
1019 }
1020 .diff-file:not([open]) .diff-file-chevron { transform: rotate(-90deg); }
1021
1022 .diff-file-path {
1023 font-family: var(--font-mono);
1024 color: var(--text);
1025 font-weight: 500;
1026 overflow: hidden;
1027 text-overflow: ellipsis;
1028 white-space: nowrap;
1029 }
1030 .diff-file-old { color: var(--text-muted); text-decoration: line-through; }
1031 .diff-file-arrow { color: var(--text-muted); }
1032 .diff-file-new { color: var(--text); }
1033
1034 .diff-file-copy {
1035 background: transparent;
1036 border: 1px solid transparent;
1037 border-radius: 6px;
1038 color: var(--text-muted);
1039 width: 24px;
1040 height: 24px;
1041 display: inline-flex;
1042 align-items: center;
1043 justify-content: center;
1044 cursor: pointer;
1045 padding: 0;
1046 transition: all 120ms ease;
1047 }
1048 .diff-file-copy:hover {
1049 color: var(--text);
1050 background: var(--bg-elevated);
1051 border-color: var(--border);
1052 }
1053 .diff-file-copy.is-copied {
1054 color: var(--green, #6ee7b7);
1055 background: rgba(52,211,153,0.12);
1056 border-color: rgba(52,211,153,0.30);
1057 }
1058 .diff-file-copy.is-copied::after {
1059 content: 'Copied';
1060 position: absolute;
1061 margin-left: 28px;
1062 font-size: 11px;
1063 color: var(--green, #6ee7b7);
1064 font-family: var(--font-sans, inherit);
1065 }
1066
1067 .diff-file-spacer { flex: 1; }
1068
1069 .diff-file-blob-link {
1070 font-family: var(--font-sans, inherit);
1071 font-size: 12px;
1072 color: var(--text-muted);
1073 padding: 3px 10px;
1074 border-radius: 6px;
1075 border: 1px solid var(--border);
1076 background: var(--bg-elevated);
1077 text-decoration: none;
1078 transition: all 120ms ease;
1079 }
1080 .diff-file-blob-link:hover {
1081 color: var(--text);
6fd5915Claude1082 border-color: rgba(91,110,232,0.45);
ea9ed4cClaude1083 background: var(--accent-gradient-faint, var(--bg-elevated));
1084 }
1085
1086 .diff-status {
1087 display: inline-flex;
1088 align-items: center;
1089 padding: 2px 8px;
1090 border-radius: 4px;
1091 font-size: 11px;
1092 font-weight: 600;
1093 font-family: var(--font-sans, inherit);
1094 text-transform: uppercase;
1095 letter-spacing: 0.04em;
1096 line-height: 1.5;
1097 border: 1px solid transparent;
1098 }
1099 .diff-status-added {
1100 color: #6ee7b7;
1101 background: rgba(52,211,153,0.10);
1102 border-color: rgba(52,211,153,0.22);
1103 }
1104 .diff-status-modified {
1105 color: #fcd34d;
1106 background: rgba(252,211,77,0.08);
1107 border-color: rgba(252,211,77,0.22);
1108 }
1109 .diff-status-renamed {
1110 color: #93c5fd;
1111 background: rgba(147,197,253,0.10);
1112 border-color: rgba(147,197,253,0.25);
1113 }
1114 .diff-status-deleted {
1115 color: #fca5a5;
1116 background: rgba(248,113,113,0.10);
1117 border-color: rgba(248,113,113,0.22);
1118 }
1119 .diff-status-binary {
1120 color: var(--text-muted);
1121 background: var(--bg-elevated);
1122 border-color: var(--border);
1123 }
1124
1125 /* ─── Diff body ─── */
1126 .diff-body {
1127 font-family: var(--font-mono);
1128 font-size: 12.5px;
1129 line-height: 1.55;
1130 overflow-x: auto;
1131 }
1132 .diff-empty {
1133 padding: 18px 16px;
1134 text-align: center;
1135 color: var(--text-muted);
1136 font-family: var(--font-sans, inherit);
1137 font-size: 13px;
1138 }
6fd5915Claude1139 .diff-empty-big { background: rgba(91,110,232,0.04); }
ea9ed4cClaude1140
1141 .diff-hunk-gap {
1142 height: 1px;
1143 background: linear-gradient(90deg, transparent 0%, var(--border) 25%, var(--border) 75%, transparent 100%);
1144 margin: 0;
1145 opacity: 0.7;
1146 }
1147 .diff-hunk-header {
1148 padding: 4px 16px 4px 86px;
6fd5915Claude1149 background: rgba(91,110,232,0.05);
ea9ed4cClaude1150 color: var(--text-muted);
1151 font-size: 11.5px;
6fd5915Claude1152 border-top: 1px solid rgba(91,110,232,0.18);
1153 border-bottom: 1px solid rgba(91,110,232,0.18);
ea9ed4cClaude1154 }
1155 .diff-hunk-header-text { font-family: var(--font-mono); }
1156
1157 .diff-row {
1158 display: grid;
1159 grid-template-columns: 44px 44px 16px 1fr;
1160 align-items: stretch;
1161 min-height: 1.55em;
1162 }
1163 .diff-gutter {
1164 text-align: right;
1165 padding: 0 6px;
1166 color: var(--text-muted);
1167 background: var(--bg-elevated);
1168 border-right: 1px solid var(--border);
1169 user-select: none;
1170 font-variant-numeric: tabular-nums;
1171 font-size: 11.5px;
1172 opacity: 0.75;
1173 }
1174 .diff-gutter-new { border-right: 1px solid var(--border); }
1175
1176 .diff-marker {
1177 text-align: center;
1178 color: var(--text-muted);
1179 user-select: none;
1180 background: var(--bg-elevated);
1181 border-right: 1px solid var(--border);
1182 }
1183 .diff-code {
1184 padding: 0 12px;
1185 white-space: pre;
1186 color: var(--text);
1187 overflow-x: visible;
1188 }
1189
1190 /* Row tints — additions / deletions / context */
1191 .diff-row-add {
1192 background: rgba(52,211,153,0.08);
1193 }
1194 .diff-row-add .diff-gutter,
1195 .diff-row-add .diff-marker {
1196 background: rgba(52,211,153,0.14);
1197 color: #6ee7b7;
1198 border-right-color: rgba(52,211,153,0.20);
1199 }
1200 .diff-row-add .diff-marker { color: #6ee7b7; font-weight: 600; }
1201
1202 .diff-row-del {
1203 background: rgba(248,113,113,0.08);
1204 }
1205 .diff-row-del .diff-gutter,
1206 .diff-row-del .diff-marker {
1207 background: rgba(248,113,113,0.14);
1208 color: #fca5a5;
1209 border-right-color: rgba(248,113,113,0.20);
1210 }
1211 .diff-row-del .diff-marker { color: #fca5a5; font-weight: 600; }
1212
1213 .diff-row:hover .diff-gutter { opacity: 1; }
1214
47a7a0aClaude1215 /* ─── Inline comment "+" button ─── */
1216 .diff-comment-btn {
1217 display: none;
1218 position: absolute;
1219 right: 2px;
1220 top: 50%;
1221 transform: translateY(-50%);
1222 width: 16px; height: 16px;
1223 padding: 0;
6fd5915Claude1224 background: var(--accent, #5b6ee8);
47a7a0aClaude1225 color: #fff;
1226 border: none;
1227 border-radius: 3px;
1228 font-size: 12px;
1229 line-height: 1;
1230 cursor: pointer;
1231 z-index: 2;
1232 }
1233 .diff-gutter-new { position: relative; }
1234 .diff-row:hover .diff-comment-btn { display: flex; align-items: center; justify-content: center; }
1235
1236 /* ─── Inline comments anchored to diff lines ─── */
1237 .diff-inline-comment {
1238 grid-column: 1 / -1;
1239 display: block;
1240 margin: 4px 0;
1241 padding: 10px 14px;
1242 background: var(--bg-elevated);
1243 border-left: 3px solid var(--border);
1244 border-radius: 0 4px 4px 0;
1245 font-family: var(--font-sans, inherit);
1246 font-size: 13px;
1247 }
6fd5915Claude1248 .diff-inline-comment-ai { border-left-color: #5b6ee8; }
47a7a0aClaude1249 .diff-inline-comment-head {
1250 display: flex; gap: 8px; align-items: center;
1251 margin-bottom: 6px;
1252 font-size: 12px;
1253 color: var(--text-muted);
1254 }
1255 .diff-inline-comment-head strong { color: var(--text-strong); }
1256 .diff-inline-ai-badge {
6fd5915Claude1257 background: rgba(91,110,232,0.2);
47a7a0aClaude1258 color: #a78bfa;
1259 border-radius: 3px;
1260 padding: 1px 5px;
1261 font-size: 10px;
1262 font-weight: 600;
1263 }
1264 .diff-inline-comment-body { color: var(--text); line-height: 1.6; }
1265
1266 /* ─── Inline comment form ─── */
1267 .diff-inline-form-row {
1268 grid-column: 1 / -1;
1269 display: block;
1270 padding: 10px 14px;
1271 background: var(--bg-elevated);
1272 border-top: 1px solid var(--border);
1273 border-bottom: 1px solid var(--border);
1274 }
1275 .diff-inline-textarea {
1276 width: 100%;
1277 min-height: 72px;
1278 background: var(--bg);
1279 color: var(--text);
1280 border: 1px solid var(--border);
1281 border-radius: 4px;
1282 padding: 8px;
1283 font-size: 13px;
1284 font-family: var(--font-sans, inherit);
1285 resize: vertical;
1286 box-sizing: border-box;
1287 }
6fd5915Claude1288 .diff-inline-textarea:focus { outline: none; border-color: var(--accent, #5b6ee8); }
47a7a0aClaude1289 .diff-inline-form-actions {
1290 display: flex; gap: 8px; margin-top: 8px;
1291 }
1292 .diff-inline-submit {
6fd5915Claude1293 background: var(--accent, #5b6ee8);
47a7a0aClaude1294 color: #fff;
1295 border: none;
1296 border-radius: 5px;
1297 padding: 6px 14px;
1298 font-size: 13px;
1299 cursor: pointer;
1300 }
1301 .diff-inline-cancel {
1302 background: transparent;
1303 color: var(--text-muted);
1304 border: 1px solid var(--border);
1305 border-radius: 5px;
1306 padding: 6px 14px;
1307 font-size: 13px;
1308 cursor: pointer;
1309 }
1310
ea9ed4cClaude1311 /* Highlight.js theme overrides so colors layer correctly on tints */
1312 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
1313 .diff-body.has-hljs .hljs-built_in,
1314 .diff-body.has-hljs .hljs-type { color: #ffa657; }
1315 .diff-body.has-hljs .hljs-literal,
1316 .diff-body.has-hljs .hljs-number { color: #79c0ff; }
1317 .diff-body.has-hljs .hljs-string { color: #a5d6ff; }
1318 .diff-body.has-hljs .hljs-title,
1319 .diff-body.has-hljs .hljs-title.function_ { color: #d2a8ff; }
1320 .diff-body.has-hljs .hljs-comment { color: #8b949e; font-style: italic; }
1321 .diff-body.has-hljs .hljs-attr,
1322 .diff-body.has-hljs .hljs-attribute,
1323 .diff-body.has-hljs .hljs-meta { color: #79c0ff; }
1324 .diff-body.has-hljs .hljs-tag,
1325 .diff-body.has-hljs .hljs-name { color: #7ee787; }
1326 .diff-body.has-hljs .hljs-variable,
1327 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
1328
b5dd694Claude1329 /* ─── Suggestion blocks ─── */
1330 .diff-suggestion-block {
1331 margin: 8px 0;
1332 border: 1px solid rgba(52,211,153,0.3);
1333 border-radius: 6px;
1334 overflow: hidden;
1335 }
1336 .diff-suggestion-header {
1337 padding: 6px 12px;
1338 background: rgba(52,211,153,0.08);
1339 border-bottom: 1px solid rgba(52,211,153,0.2);
1340 font-size: 12px;
1341 color: #6ee7b7;
1342 display: flex;
1343 align-items: center;
1344 justify-content: space-between;
1345 }
1346 .diff-suggestion-code {
1347 padding: 8px 12px;
1348 background: rgba(52,211,153,0.05);
1349 font-family: var(--font-mono);
1350 font-size: 12.5px;
1351 white-space: pre;
1352 color: var(--text);
1353 margin: 0;
1354 }
1355 .diff-apply-btn {
1356 background: rgba(52,211,153,0.15);
1357 color: #6ee7b7;
1358 border: 1px solid rgba(52,211,153,0.35);
1359 border-radius: 5px;
1360 padding: 4px 12px;
1361 font-size: 12px;
1362 cursor: pointer;
1363 font-family: var(--font-sans, inherit);
1364 }
1365 .diff-apply-btn:hover {
1366 background: rgba(52,211,153,0.25);
1367 }
1368 .diff-suggestion-toggle {
1369 background: transparent;
1370 color: var(--text-muted);
1371 border: 1px solid var(--border);
1372 border-radius: 5px;
1373 padding: 4px 10px;
1374 font-size: 12px;
1375 cursor: pointer;
1376 font-family: var(--font-sans, inherit);
1377 margin-top: 6px;
1378 }
1379 .diff-suggestion-toggle.is-active {
1380 background: rgba(52,211,153,0.1);
1381 color: #6ee7b7;
1382 border-color: rgba(52,211,153,0.35);
1383 }
1384 .diff-suggestion-textarea {
1385 width: 100%;
1386 background: rgba(52,211,153,0.04);
1387 color: var(--text);
1388 border: 1px solid rgba(52,211,153,0.25);
1389 border-radius: 4px;
1390 padding: 8px;
1391 font-size: 12.5px;
1392 font-family: var(--font-mono);
1393 resize: vertical;
1394 box-sizing: border-box;
1395 margin-top: 6px;
1396 display: none;
1397 }
1398 .diff-suggestion-textarea.is-visible { display: block; }
1399
ea9ed4cClaude1400 /* ─── Split-view scaffolding (phase 2 placeholder) ───
1401 The .diff-body element is grid-friendly; a future toggle can swap to
1402 'diff-body diff-split' and the per-row grid expands to two code panes. */
1403 .diff-body.diff-split .diff-row {
1404 grid-template-columns: 44px 1fr 44px 1fr;
1405 }
1406
1407 @media (max-width: 720px) {
1408 .diff-row {
1409 grid-template-columns: 32px 32px 14px 1fr;
1410 }
1411 .diff-hunk-header { padding-left: 64px; }
1412 .diff-file-blob-link { display: none; }
1413 }
516b91eClaude1414
1415 /* ─── Jump-to-file nav ─── */
1416 .diff-jump-toggle {
1417 margin-left: auto;
1418 background: var(--bg-elevated);
1419 color: var(--text-muted);
1420 border: 1px solid var(--border);
1421 border-radius: 5px;
1422 padding: 4px 12px;
1423 font-size: 12px;
1424 cursor: pointer;
1425 font-family: var(--font-sans, inherit);
1426 white-space: nowrap;
1427 transition: all 120ms ease;
1428 }
1429 .diff-jump-toggle:hover,
1430 .diff-jump-toggle.is-open {
1431 color: var(--text);
6fd5915Claude1432 border-color: rgba(91,110,232,0.45);
516b91eClaude1433 background: var(--accent-gradient-faint, var(--bg-elevated));
1434 }
1435
1436 .diff-jump-nav {
1437 position: relative;
1438 margin-bottom: 12px;
1439 background: var(--bg-elevated);
1440 border: 1px solid var(--border);
1441 border-radius: var(--r-md);
1442 box-shadow: 0 4px 16px rgba(0,0,0,0.18);
1443 z-index: 20;
1444 max-height: 320px;
1445 overflow-y: auto;
1446 padding: 6px 0;
1447 }
1448 .diff-jump-item {
1449 display: flex;
1450 align-items: center;
1451 justify-content: space-between;
1452 gap: 8px;
1453 padding: 6px 14px;
1454 text-decoration: none;
1455 color: var(--text);
1456 font-size: 12.5px;
1457 font-family: var(--font-mono);
1458 transition: background 80ms ease;
1459 }
1460 .diff-jump-item:hover { background: var(--bg-secondary); }
1461 .diff-jump-path {
1462 overflow: hidden;
1463 text-overflow: ellipsis;
1464 white-space: nowrap;
1465 flex: 1;
1466 }
1467 .diff-jump-pills { display: flex; gap: 4px; flex-shrink: 0; }
1468 .diff-jump-add { color: #6ee7b7; font-size: 11px; }
1469 .diff-jump-del { color: #fca5a5; font-size: 11px; }
a2c10c5Claude1470
1471 /* ─── Unified/Split view toggle ─── */
1472 .diff-view-tab {
1473 padding: 3px 10px;
1474 font-size: 11.5px;
1475 border-radius: 4px;
1476 color: var(--text-muted);
1477 text-decoration: none;
1478 font-family: var(--font-sans, inherit);
1479 transition: all 80ms ease;
1480 }
1481 .diff-view-tab:hover { color: var(--text); }
1482 .diff-view-tab-active {
1483 background: var(--bg-elevated);
1484 color: var(--text-strong, var(--text));
1485 box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1486 }
1487
1488 /* ─── Pending review sticky bar ─── */
1489 .diff-pending-bar {
1490 position: fixed;
1491 bottom: var(--space-4, 16px);
1492 right: var(--space-4, 16px);
1493 background: var(--bg-elevated);
1494 border: 1px solid var(--accent, #5b6ee8);
1495 border-radius: 10px;
1496 padding: 10px 16px;
1497 display: flex;
1498 align-items: center;
1499 gap: var(--space-3, 12px);
1500 box-shadow: 0 4px 16px rgba(0,0,0,0.3);
1501 z-index: 100;
1502 font-size: 13px;
1503 font-family: var(--font-sans, inherit);
1504 color: var(--text);
1505 }
1506
1507 /* ─── Two-option comment choice ─── */
1508 .diff-comment-choice { display: flex; flex-direction: column; gap: 8px; }
1509 .diff-comment-actions { display: flex; align-items: center; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
1510
1511 /* ─── Split diff table ─── */
1512 .diff-body-split { overflow-x: auto; }
1513 .diff-table-split { table-layout: fixed; }
1514 .diff-split-row:hover { filter: brightness(1.05); }
1515 .diff-split-hunk-row td { border-top: 1px solid rgba(91,110,232,0.18); border-bottom: 1px solid rgba(91,110,232,0.18); }
ea9ed4cClaude1516`;
a2c10c5Claude1517