Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit82c3a38

feat(diff): line numbers, split view, file jump bar, whitespace detection

feat(diff): line numbers, split view, file jump bar, whitespace detection

- Extract parseDiff + pairLines into src/lib/diff.ts (pure, no JSX dep)
- parseDiff now returns structured DiffLine[] with old/new line numbers,
  isBinary flag, and wsOnly detection for whitespace-only change pairs
- DiffView rewritten: inline/split toggle, collapsible files, file jump
  list, line-number gutters, ws badge on whitespace-only lines
- 15 new tests covering parseDiff (hunk parsing, line numbers, binary,
  ws detection, CRLF) and pairLines (pairing, orphan add/del, row count)
- CSS: table-based layout replaces flat span list; toolbar, jump chips,
  split table colouring, file chevron collapse animation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ccanty labs committed on June 25, 2026Parent: 82321e8
4 files changed+5626482c3a38c77abede2c395b9265612515af5161824
4 changed files+566−64
Addedsrc/__tests__/diff-view.test.ts+175−0View fileUnifiedSplit
1import { describe, it, expect } from "bun:test";
2import { parseDiff, pairLines } from "../lib/diff";
3
4const SIMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts
5index abc..def 100644
6--- a/src/foo.ts
7+++ b/src/foo.ts
8@@ -1,5 +1,5 @@
9 import { bar } from './bar';
10-const x = 1;
11+const x = 2;
12 export default x;
13`;
14
15const MULTIFILE_DIFF = `diff --git a/a.ts b/a.ts
16index 000..111 100644
17--- a/a.ts
18+++ b/a.ts
19@@ -1,2 +1,3 @@
20 line1
21+line2
22 line3
23diff --git a/b.ts b/b.ts
24index 222..333 100644
25--- a/b.ts
26+++ b/b.ts
27@@ -1,2 +1,1 @@
28 hello
29-world
30`;
31
32const BINARY_DIFF = `diff --git a/image.png b/image.png
33index abc..def 100644
34Binary files a/image.png and b/image.png differ
35`;
36
37const WS_DIFF = `diff --git a/x.ts b/x.ts
38index 000..111 100644
39--- a/x.ts
40+++ b/x.ts
41@@ -1,3 +1,3 @@
42 ctx
43- foo
44+ foo
45 ctx
46`;
47
48describe("parseDiff", () => {
49 it("parses a single-file diff with one del and one add", () => {
50 const files = parseDiff(SIMPLE_DIFF);
51 expect(files.length).toBe(1);
52 expect(files[0].path).toBe("src/foo.ts");
53 expect(files[0].additions).toBe(1);
54 expect(files[0].deletions).toBe(1);
55 expect(files[0].isBinary).toBe(false);
56 });
57
58 it("assigns correct line numbers", () => {
59 const files = parseDiff(SIMPLE_DIFF);
60 const lines = files[0].lines;
61 const hunk = lines.find((l) => l.type === "hunk");
62 expect(hunk).toBeTruthy();
63 const ctx1 = lines.find((l) => l.type === "ctx");
64 expect(ctx1?.oldLine).toBe(1);
65 expect(ctx1?.newLine).toBe(1);
66 const del = lines.find((l) => l.type === "del");
67 expect(del?.oldLine).toBe(2);
68 expect(del?.newLine).toBeNull();
69 const add = lines.find((l) => l.type === "add");
70 expect(add?.oldLine).toBeNull();
71 expect(add?.newLine).toBe(2);
72 });
73
74 it("parses multiple files", () => {
75 const files = parseDiff(MULTIFILE_DIFF);
76 expect(files.length).toBe(2);
77 expect(files[0].path).toBe("a.ts");
78 expect(files[1].path).toBe("b.ts");
79 expect(files[0].additions).toBe(1);
80 expect(files[0].deletions).toBe(0);
81 expect(files[1].additions).toBe(0);
82 expect(files[1].deletions).toBe(1);
83 });
84
85 it("marks binary files", () => {
86 const files = parseDiff(BINARY_DIFF);
87 expect(files.length).toBe(1);
88 expect(files[0].isBinary).toBe(true);
89 expect(files[0].lines.length).toBe(0);
90 });
91
92 it("detects whitespace-only changes", () => {
93 const files = parseDiff(WS_DIFF);
94 const lines = files[0].lines;
95 const del = lines.find((l) => l.type === "del");
96 const add = lines.find((l) => l.type === "add");
97 expect(del?.wsOnly).toBe(true);
98 expect(add?.wsOnly).toBe(true);
99 });
100
101 it("does NOT mark non-whitespace changes as wsOnly", () => {
102 const files = parseDiff(SIMPLE_DIFF);
103 const del = files[0].lines.find((l) => l.type === "del");
104 const add = files[0].lines.find((l) => l.type === "add");
105 expect(del?.wsOnly).toBeFalsy();
106 expect(add?.wsOnly).toBeFalsy();
107 });
108
109 it("returns empty array for empty input", () => {
110 expect(parseDiff("")).toEqual([]);
111 });
112
113 it("handles CRLF line endings", () => {
114 const crlf = SIMPLE_DIFF.replace(/\n/g, "\r\n");
115 const files = parseDiff(crlf);
116 expect(files.length).toBe(1);
117 expect(files[0].additions).toBe(1);
118 });
119
120 it("strips the +/-/space prefix from content", () => {
121 const files = parseDiff(SIMPLE_DIFF);
122 const add = files[0].lines.find((l) => l.type === "add")!;
123 const del = files[0].lines.find((l) => l.type === "del")!;
124 expect(add.content).not.toMatch(/^\+/);
125 expect(del.content).not.toMatch(/^-/);
126 expect(add.content).toBe("const x = 2;");
127 expect(del.content).toBe("const x = 1;");
128 });
129});
130
131describe("pairLines", () => {
132 it("pairs a del/add block into a single row", () => {
133 const files = parseDiff(SIMPLE_DIFF);
134 const rows = pairLines(files[0].lines);
135 const paired = rows.find((r) => r.left?.type === "del" && r.right?.type === "add");
136 expect(paired).toBeTruthy();
137 });
138
139 it("passes context lines as both left and right", () => {
140 const files = parseDiff(SIMPLE_DIFF);
141 const rows = pairLines(files[0].lines);
142 const ctx = rows.find((r) => r.left?.type === "ctx");
143 expect(ctx?.left).toBe(ctx?.right);
144 });
145
146 it("pairs hunk headers as both left and right", () => {
147 const files = parseDiff(SIMPLE_DIFF);
148 const rows = pairLines(files[0].lines);
149 const hunk = rows.find((r) => r.left?.type === "hunk");
150 expect(hunk?.left).toBe(hunk?.right);
151 });
152
153 it("creates null left for orphan add lines", () => {
154 const files = parseDiff(MULTIFILE_DIFF);
155 const rows = pairLines(files[0].lines);
156 const orphanAdd = rows.find((r) => r.left === null && r.right?.type === "add");
157 expect(orphanAdd).toBeTruthy();
158 });
159
160 it("creates null right for orphan del lines", () => {
161 const files = parseDiff(MULTIFILE_DIFF);
162 const rows = pairLines(files[1].lines);
163 const orphanDel = rows.find((r) => r.right === null && r.left?.type === "del");
164 expect(orphanDel).toBeTruthy();
165 });
166
167 it("total row count equals unique line count (not doubled)", () => {
168 const files = parseDiff(SIMPLE_DIFF);
169 const rows = pairLines(files[0].lines);
170 // 1 hunk + 1 ctx + 1 paired del/add + 1 ctx = 4 rows
171 expect(rows.length).toBe(4);
172 // source has 5 lines: hunk, ctx, del, add, ctx
173 expect(files[0].lines.length).toBe(5);
174 });
175});
Addedsrc/lib/diff.ts+122−0View fileUnifiedSplit
1export type DiffLine = {
2 type: "add" | "del" | "ctx" | "hunk";
3 content: string;
4 oldLine: number | null;
5 newLine: number | null;
6 wsOnly?: boolean;
7};
8
9export type ParsedFile = {
10 path: string;
11 additions: number;
12 deletions: number;
13 isBinary: boolean;
14 lines: DiffLine[];
15};
16
17export type SplitRow = { left: DiffLine | null; right: DiffLine | null };
18
19export function parseDiff(raw: string): ParsedFile[] {
20 const files: ParsedFile[] = [];
21 const diffRe = /^diff --git a\/(.+?) b\/.+$/;
22 const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
23
24 let current: ParsedFile | null = null;
25 let oldLine = 0;
26 let newLine = 0;
27
28 for (const line of raw.replace(/\r\n/g, "\n").split("\n")) {
29 if (line === "") continue; // skip trailing split artifacts
30
31 const dm = line.match(diffRe);
32 if (dm) {
33 if (current) files.push(current);
34 current = { path: dm[1], additions: 0, deletions: 0, isBinary: false, lines: [] };
35 oldLine = 0;
36 newLine = 0;
37 continue;
38 }
39 if (!current) continue;
40
41 if (
42 line.startsWith("index ") ||
43 line.startsWith("--- ") ||
44 line.startsWith("+++ ") ||
45 line.startsWith("new file") ||
46 line.startsWith("deleted file") ||
47 line.startsWith("old mode") ||
48 line.startsWith("new mode") ||
49 line.startsWith("rename ")
50 ) continue;
51
52 if (line.startsWith("Binary files")) {
53 current.isBinary = true;
54 continue;
55 }
56 if (current.isBinary) continue;
57
58 const hm = line.match(hunkRe);
59 if (hm) {
60 oldLine = parseInt(hm[1], 10);
61 newLine = parseInt(hm[2], 10);
62 current.lines.push({ type: "hunk", content: line, oldLine: null, newLine: null });
63 continue;
64 }
65
66 if (line.startsWith("+")) {
67 current.additions++;
68 current.lines.push({ type: "add", content: line.slice(1), oldLine: null, newLine: newLine++ });
69 } else if (line.startsWith("-")) {
70 current.deletions++;
71 current.lines.push({ type: "del", content: line.slice(1), oldLine: oldLine++, newLine: null });
72 } else {
73 current.lines.push({ type: "ctx", content: line.length > 0 ? line.slice(1) : "", oldLine: oldLine++, newLine: newLine++ });
74 }
75 }
76 if (current) files.push(current);
77
78 // Whitespace-only detection: scan adjacent del/add pairs
79 for (const file of files) {
80 let i = 0;
81 while (i < file.lines.length) {
82 if (file.lines[i].type !== "del") { i++; continue; }
83 const delStart = i;
84 while (i < file.lines.length && file.lines[i].type === "del") i++;
85 const addStart = i;
86 while (i < file.lines.length && file.lines[i].type === "add") i++;
87 const pairCount = Math.min(i - addStart, addStart - delStart);
88 for (let j = 0; j < pairCount; j++) {
89 const del = file.lines[delStart + j];
90 const add = file.lines[addStart + j];
91 if (del.content !== add.content && del.content.trim() === add.content.trim()) {
92 del.wsOnly = true;
93 add.wsOnly = true;
94 }
95 }
96 }
97 }
98
99 return files;
100}
101
102export function pairLines(lines: DiffLine[]): SplitRow[] {
103 const rows: SplitRow[] = [];
104 let i = 0;
105 while (i < lines.length) {
106 const line = lines[i];
107 if (line.type === "ctx" || line.type === "hunk") {
108 rows.push({ left: line, right: line });
109 i++;
110 } else if (line.type === "del") {
111 const dels: DiffLine[] = [];
112 const adds: DiffLine[] = [];
113 while (i < lines.length && lines[i].type === "del") dels.push(lines[i++]);
114 while (i < lines.length && lines[i].type === "add") adds.push(lines[i++]);
115 const len = Math.max(dels.length, adds.length);
116 for (let j = 0; j < len; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
117 } else {
118 rows.push({ left: null, right: lines[i++] });
119 }
120 }
121 return rows;
122}
Modifiedsrc/views/components.tsx+157−57View fileUnifiedSplit
22import { html } from "hono/html";
33import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
44import type { Repository } from "../db/schema";
5import { parseDiff, pairLines } from "../lib/diff";
6import type { DiffLine, ParsedFile, SplitRow } from "../lib/diff";
57
68/**
79 * Describes the most recent push to a repo, used by RepoHeader to render
538540 </div>
539541);
540542
543// ---- Diff viewer ----------------------------------------------------------
544
545const _diffScript = `
546function diffToggleView(id,mode){
547 var v=document.getElementById(id);if(!v)return;
548 v.querySelectorAll('.diff-table-inline').forEach(function(t){t.hidden=(mode==='split');});
549 v.querySelectorAll('.diff-table-split').forEach(function(t){t.hidden=(mode!=='split');});
550 v.querySelectorAll('.diff-view-btn').forEach(function(b){b.classList.remove('active');});
551 var btn=v.querySelector('.diff-view-btn-'+mode);if(btn)btn.classList.add('active');
552}
553function diffToggleFile(id){
554 var body=document.getElementById(id);if(!body)return;
555 body.hidden=!body.hidden;
556 var hdr=body.previousElementSibling;if(hdr)hdr.classList.toggle('collapsed',body.hidden);
557}
558`;
559
541560export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
542561 raw,
543562 files,
544563}) => {
545 const sections = parseDiff(raw);
564 const parsed = parseDiff(raw);
565 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
566 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
567
568 if (parsed.length === 0 && files.length === 0) {
569 return (
570 <div class="diff-view">
571 <p style="color:var(--text-muted);font-size:14px">No changes.</p>
572 </div>
573 );
574 }
546575
547576 return (
548 <div class="diff-view">
549 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
550 Showing{" "}
551 <strong style="color: var(--text)">{files.length}</strong> changed
552 file{files.length !== 1 ? "s" : ""} with{" "}
553 <span class="stat-add">
554 +{files.reduce((s, f) => s + f.additions, 0)}
555 </span>{" "}
556 and{" "}
557 <span class="stat-del">
558 -{files.reduce((s, f) => s + f.deletions, 0)}
559 </span>
577 <div class="diff-view" id="diff-view-main">
578 <script dangerouslySetInnerHTML={{ __html: _diffScript }} />
579
580 {/* Toolbar */}
581 <div class="diff-toolbar">
582 <div class="diff-summary">
583 <strong>{files.length}</strong> file{files.length !== 1 ? "s" : ""} changed,{" "}
584 <span class="stat-add">+{totalAdd}</span>{" "}
585 <span class="stat-del">-{totalDel}</span>
586 </div>
587 <div class="diff-view-toggle">
588 <button class="diff-view-btn diff-view-btn-inline active" onclick="diffToggleView('diff-view-main','inline')" type="button">Inline</button>
589 <button class="diff-view-btn diff-view-btn-split" onclick="diffToggleView('diff-view-main','split')" type="button">Split</button>
590 </div>
560591 </div>
561 {sections.map((section) => (
562 <div class="diff-file">
563 <div class="diff-file-header">{section.path}</div>
564 <div class="diff-content">
565 {section.lines.map((line) => {
566 let cls = "line";
567 if (line.startsWith("+")) cls += " line-add";
568 else if (line.startsWith("-")) cls += " line-del";
569 else if (line.startsWith("@@")) cls += " line-hunk";
570 return <span class={cls}>{line + "\n"}</span>;
571 })}
572 </div>
592
593 {/* File jump list (shown when > 1 file) */}
594 {parsed.length > 1 && (
595 <div class="diff-jump-list">
596 {parsed.map((f, i) => {
597 const isAdd = f.additions > 0 && f.deletions === 0;
598 const isDel = f.additions === 0 && f.deletions > 0;
599 return (
600 <a class="diff-jump-item" href={`#diff-file-${i}`}>
601 <span class={isAdd ? "stat-add" : isDel ? "stat-del" : "diff-jump-mod"}>
602 {isAdd ? "+" : isDel ? "−" : "~"}
603 </span>{" "}
604 {f.path.split("/").pop() || f.path}
605 </a>
606 );
607 })}
573608 </div>
574 ))}
609 )}
610
611 {/* Per-file blocks */}
612 {parsed.map((file, idx) => {
613 const bodyId = `diff-body-${idx}`;
614 const splitRows = pairLines(file.lines);
615 return (
616 <div class="diff-file" id={`diff-file-${idx}`}>
617 <div class="diff-file-header" onclick={`diffToggleFile('${bodyId}')`}>
618 <span class="diff-file-path" title={file.path}>{file.path}</span>
619 <span class="diff-file-meta">
620 {file.isBinary
621 ? <span style="color:var(--text-muted)">binary</span>
622 : <><span class="stat-add">+{file.additions}</span>{" "}<span class="stat-del">-{file.deletions}</span></>
623 }
624 <span class="diff-file-chevron"></span>
625 </span>
626 </div>
627 <div id={bodyId}>
628 {file.isBinary ? (
629 <div class="diff-binary">Binary file changed</div>
630 ) : (
631 <>
632 {/* Inline table (default) */}
633 <table class="diff-table diff-table-inline">
634 <tbody>
635 {file.lines.map((line) => {
636 if (line.type === "hunk") {
637 return (
638 <tr class="diff-row diff-row-hunk">
639 <td class="diff-ln diff-ln-old"></td>
640 <td class="diff-ln diff-ln-new"></td>
641 <td class="diff-cell">{line.content}</td>
642 </tr>
643 );
644 }
645 const sigil = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
646 return (
647 <tr class={`diff-row diff-row-${line.type}${line.wsOnly ? " diff-row-ws" : ""}`}>
648 <td class="diff-ln diff-ln-old">{line.oldLine ?? ""}</td>
649 <td class="diff-ln diff-ln-new">{line.newLine ?? ""}</td>
650 <td class="diff-cell">
651 <span class="diff-sigil">{sigil}</span>
652 {line.content}
653 {line.wsOnly && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
654 </td>
655 </tr>
656 );
657 })}
658 </tbody>
659 </table>
660 {/* Split table (hidden until toggled) */}
661 <table class="diff-table diff-table-split" hidden>
662 <tbody>
663 {splitRows.map((row) => {
664 const isHunk = row.left?.type === "hunk" || row.right?.type === "hunk";
665 if (isHunk) {
666 const hunk = row.left ?? row.right;
667 return (
668 <tr class="diff-row diff-row-hunk">
669 <td class="diff-ln diff-ln-old"></td>
670 <td class="diff-cell">{hunk?.content ?? ""}</td>
671 <td class="diff-ln diff-ln-new"></td>
672 <td class="diff-cell"></td>
673 </tr>
674 );
675 }
676 const isCtx = row.left?.type === "ctx";
677 const L = row.left;
678 const R = row.right;
679 const leftCls = `diff-cell${L ? (isCtx ? " diff-cell-ctx" : " diff-cell-del") : " diff-cell-empty"}${L?.wsOnly ? " diff-cell-ws" : ""}`;
680 const rightCls = `diff-cell${R ? (isCtx ? " diff-cell-ctx" : " diff-cell-add") : " diff-cell-empty"}${R?.wsOnly ? " diff-cell-ws" : ""}`;
681 return (
682 <tr class="diff-row diff-split-row">
683 <td class="diff-ln diff-ln-old">{L?.oldLine ?? ""}</td>
684 <td class={leftCls}>
685 {L && <span class="diff-sigil">{isCtx ? " " : "-"}</span>}
686 {L?.content ?? ""}
687 {L?.wsOnly && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
688 </td>
689 <td class="diff-ln diff-ln-new">{R?.newLine ?? ""}</td>
690 <td class={rightCls}>
691 {R && <span class="diff-sigil">{isCtx ? " " : "+"}</span>}
692 {isCtx ? L?.content ?? "" : R?.content ?? ""}
693 {R?.wsOnly && !isCtx && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
694 </td>
695 </tr>
696 );
697 })}
698 </tbody>
699 </table>
700 </>
701 )}
702 </div>
703 </div>
704 );
705 })}
575706 </div>
576707 );
577708};
595726 </div>
596727);
597728
598function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
599 const sections: Array<{ path: string; lines: string[] }> = [];
600 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
601 let current: { path: string; lines: string[] } | null = null;
602
603 for (const line of raw.split("\n")) {
604 const match = line.match(diffRegex);
605 if (match) {
606 if (current) sections.push(current);
607 current = { path: match[1], lines: [] };
608 continue;
609 }
610 if (current && !line.startsWith("diff --git")) {
611 if (
612 line.startsWith("index ") ||
613 line.startsWith("--- ") ||
614 line.startsWith("+++ ") ||
615 line.startsWith("new file") ||
616 line.startsWith("deleted file") ||
617 line.startsWith("old mode") ||
618 line.startsWith("new mode")
619 ) {
620 continue;
621 }
622 current.lines.push(line);
623 }
624 }
625 if (current) sections.push(current);
626 return sections;
627}
628
629729function formatSize(bytes: number): string {
630730 if (bytes < 1024) return `${bytes} B`;
631731 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
Modifiedsrc/views/layout.tsx+112−7View fileUnifiedSplit
26662666 text-decoration: none;
26672667 }
26682668
2669 /* Diff viewer */
26692670 .diff-view { margin-top: 16px; }
2671 .diff-toolbar {
2672 display: flex;
2673 align-items: center;
2674 justify-content: space-between;
2675 margin-bottom: 12px;
2676 gap: 12px;
2677 flex-wrap: wrap;
2678 }
2679 .diff-summary { font-size: var(--t-sm); color: var(--text-muted); }
2680 .diff-view-toggle {
2681 display: flex;
2682 border: 1px solid var(--border);
2683 border-radius: var(--r-sm);
2684 overflow: hidden;
2685 }
2686 .diff-view-btn {
2687 padding: 4px 12px;
2688 font-size: 12px;
2689 font-weight: 500;
2690 background: transparent;
2691 color: var(--text-muted);
2692 border: none;
2693 cursor: pointer;
2694 transition: all var(--t-fast) var(--ease);
2695 font-family: var(--font-sans);
2696 }
2697 .diff-view-btn:not(:first-child) { border-left: 1px solid var(--border); }
2698 .diff-view-btn:hover { color: var(--text); background: var(--bg-hover); }
2699 .diff-view-btn.active { background: var(--accent); color: #fff; }
2700 .diff-jump-list {
2701 display: flex;
2702 flex-wrap: wrap;
2703 gap: 6px;
2704 margin-bottom: 12px;
2705 padding: 8px 12px;
2706 background: var(--bg-secondary);
2707 border: 1px solid var(--border);
2708 border-radius: var(--r-sm);
2709 }
2710 .diff-jump-item {
2711 font-size: 11px;
2712 font-family: var(--font-mono);
2713 color: var(--text-muted);
2714 text-decoration: none;
2715 padding: 2px 6px;
2716 border-radius: 3px;
2717 transition: all var(--t-fast) var(--ease);
2718 }
2719 .diff-jump-item:hover { color: var(--text); background: var(--bg-hover); text-decoration: none; }
2720 .diff-jump-mod { color: var(--text-muted); }
26702721 .diff-file {
26712722 border: 1px solid var(--border);
26722723 border-radius: var(--r-md);
26752726 background: var(--bg-elevated);
26762727 }
26772728 .diff-file-header {
2729 display: flex;
2730 align-items: center;
2731 justify-content: space-between;
26782732 background: var(--bg-secondary);
2679 padding: 10px 16px;
2733 padding: 8px 14px;
26802734 border-bottom: 1px solid var(--border);
26812735 font-family: var(--font-mono);
26822736 font-size: var(--t-sm);
26832737 font-weight: 500;
2738 cursor: pointer;
2739 user-select: none;
2740 gap: 12px;
26842741 }
2685 .diff-content {
2686 overflow-x: auto;
2742 .diff-file-header:hover { background: var(--bg-hover); }
2743 .diff-file-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
2744 .diff-file-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: var(--font-sans); font-size: 12px; }
2745 .diff-file-chevron { color: var(--text-muted); font-size: 12px; transition: transform var(--t-fast) var(--ease); display: inline-block; }
2746 .diff-file-header.collapsed .diff-file-chevron { transform: rotate(-90deg); }
2747 .diff-table {
2748 width: 100%;
2749 border-collapse: collapse;
26872750 font-family: var(--font-mono);
26882751 font-size: var(--t-sm);
26892752 line-height: 1.65;
2753 table-layout: fixed;
2754 }
2755 .diff-ln {
2756 width: 44px;
2757 min-width: 44px;
2758 text-align: right;
2759 padding: 0 8px;
2760 color: var(--text-muted);
2761 user-select: none;
2762 border-right: 1px solid var(--border-subtle);
2763 vertical-align: top;
2764 font-size: 11px;
2765 white-space: nowrap;
2766 opacity: 0.55;
2767 }
2768 .diff-cell { padding: 0 4px 0 2px; white-space: pre; overflow: hidden; vertical-align: top; }
2769 .diff-sigil { display: inline-block; width: 14px; user-select: none; opacity: 0.7; text-align: center; }
2770 .diff-row-add .diff-ln, .diff-row-add .diff-cell { background: rgba(52,211,153,0.08); }
2771 .diff-row-add .diff-cell { color: var(--green); }
2772 .diff-row-add .diff-sigil { color: var(--green); opacity: 1; }
2773 .diff-row-del .diff-ln, .diff-row-del .diff-cell { background: rgba(248,113,113,0.07); }
2774 .diff-row-del .diff-cell { color: var(--red); }
2775 .diff-row-del .diff-sigil { color: var(--red); opacity: 1; }
2776 .diff-row-hunk .diff-ln, .diff-row-hunk .diff-cell { background: rgba(140,109,255,0.05); color: var(--text-link); font-size: 11px; }
2777 .diff-row-ws.diff-row-add .diff-cell { background: rgba(52,211,153,0.04); opacity: 0.75; }
2778 .diff-row-ws.diff-row-del .diff-cell { background: rgba(248,113,113,0.04); opacity: 0.75; }
2779 .diff-ws-badge {
2780 display: inline-block;
2781 margin-left: 8px;
2782 padding: 0 4px;
2783 font-size: 9px;
2784 font-family: var(--font-sans);
2785 color: var(--text-muted);
2786 border: 1px solid var(--border);
2787 border-radius: 3px;
2788 vertical-align: middle;
2789 opacity: 0.6;
2790 user-select: none;
26902791 }
2691 .diff-content .line-add { background: rgba(52,211,153,0.10); color: var(--green); }
2692 .diff-content .line-del { background: rgba(248,113,113,0.08); color: var(--red); }
2693 .diff-content .line-hunk { background: rgba(91,110,232,0.06); color: var(--text-link); }
2694 .diff-content .line { padding: 0 16px; white-space: pre; display: block; }
2792 .diff-table-split .diff-ln-old, .diff-table-split .diff-cell-del { background: rgba(248,113,113,0.07); }
2793 .diff-table-split .diff-cell-del { color: var(--red); }
2794 .diff-table-split .diff-ln-new, .diff-table-split .diff-cell-add { background: rgba(52,211,153,0.08); }
2795 .diff-table-split .diff-cell-add { color: var(--green); }
2796 .diff-table-split .diff-cell-empty { background: var(--bg-secondary); }
2797 .diff-table-split .diff-cell-ctx { color: var(--text); }
2798 .diff-table-split .diff-cell-ws { opacity: 0.6; }
2799 .diff-binary { padding: 16px; font-size: var(--t-sm); color: var(--text-muted); font-style: italic; }
26952800
26962801 .stat-add { color: var(--green); font-weight: 600; }
26972802 .stat-del { color: var(--red); font-weight: 600; }
26982803