Skip to content

Commit 9963917

Browse files
Anant Tripathiclaude
authored andcommitted
Add Char Diff view: shows all lines with character-level highlights
Third view mode alongside Side by Side and Unified. Displays every line from both files in a split table — unchanged lines shown as-is, modified lines highlighted with per-character ins/del marks using diffChars. Includes dark mode support. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0424784 commit 9963917

3 files changed

Lines changed: 248 additions & 2 deletions

File tree

src/App.css

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,103 @@ body {
523523
background: #ffffff;
524524
}
525525

526+
/* ── Char Diff ── */
527+
.char-diff {
528+
background: #ffffff;
529+
border: 1px solid #e2e8f0;
530+
border-radius: 8px;
531+
overflow: auto;
532+
}
533+
534+
.char-diff-table {
535+
width: 100%;
536+
border-collapse: collapse;
537+
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
538+
font-size: 0.8rem;
539+
line-height: 1.6;
540+
table-layout: fixed;
541+
}
542+
543+
.char-diff-numcol {
544+
width: 52px;
545+
}
546+
547+
.char-num {
548+
width: 52px;
549+
min-width: 52px;
550+
padding: 1px 10px;
551+
text-align: right;
552+
color: #94a3b8;
553+
background: #f8fafc;
554+
border-right: 1px solid #e2e8f0;
555+
user-select: none;
556+
white-space: nowrap;
557+
}
558+
559+
.char-code {
560+
padding: 1px 12px;
561+
white-space: pre;
562+
overflow: hidden;
563+
text-overflow: ellipsis;
564+
}
565+
566+
.char-left {
567+
border-right: 1px solid #e2e8f0;
568+
}
569+
570+
/* unchanged rows */
571+
.char-row-unchanged .char-num,
572+
.char-row-unchanged .char-code {
573+
background: #ffffff;
574+
color: #1a1a2e;
575+
}
576+
577+
/* changed rows */
578+
.char-row-changed .char-left {
579+
background: #fff5f5;
580+
}
581+
582+
.char-row-changed .char-right {
583+
background: #f0fdf4;
584+
}
585+
586+
.char-row-changed .char-num:first-child {
587+
background: #fff0f0;
588+
}
589+
590+
.char-row-changed .char-num:last-of-type {
591+
background: #e8faf0;
592+
}
593+
594+
/* pure added rows */
595+
.char-row-added .char-num,
596+
.char-row-added .char-code {
597+
background: #f0fdf4;
598+
color: #15803d;
599+
}
600+
601+
/* pure removed rows */
602+
.char-row-removed .char-num,
603+
.char-row-removed .char-code {
604+
background: #fff5f5;
605+
color: #b91c1c;
606+
}
607+
608+
/* character-level highlights */
609+
.char-hl-del {
610+
background: #fca5a5;
611+
color: #7f1d1d;
612+
border-radius: 2px;
613+
padding: 0 1px;
614+
}
615+
616+
.char-hl-ins {
617+
background: #86efac;
618+
color: #14532d;
619+
border-radius: 2px;
620+
padding: 0 1px;
621+
}
622+
526623
/* ── Theme Toggle Button ── */
527624
.theme-toggle {
528625
width: 36px;
@@ -801,6 +898,65 @@ body {
801898
background: #13112b !important;
802899
}
803900

901+
[data-theme="dark"] .char-diff {
902+
background: #1a1829;
903+
border-color: #2a2740;
904+
}
905+
906+
[data-theme="dark"] .char-num {
907+
background: #13112b;
908+
border-right-color: #2a2740;
909+
color: #4a4a6a;
910+
}
911+
912+
[data-theme="dark"] .char-left {
913+
border-right-color: #2a2740;
914+
}
915+
916+
[data-theme="dark"] .char-row-unchanged .char-num,
917+
[data-theme="dark"] .char-row-unchanged .char-code {
918+
background: #1a1829;
919+
color: #e2e8f0;
920+
}
921+
922+
[data-theme="dark"] .char-row-changed .char-left {
923+
background: #2d1a1a;
924+
}
925+
926+
[data-theme="dark"] .char-row-changed .char-right {
927+
background: #0d2b1a;
928+
}
929+
930+
[data-theme="dark"] .char-row-changed .char-num:first-child {
931+
background: #250a0a;
932+
}
933+
934+
[data-theme="dark"] .char-row-changed .char-num:last-of-type {
935+
background: #0a2015;
936+
}
937+
938+
[data-theme="dark"] .char-row-added .char-num,
939+
[data-theme="dark"] .char-row-added .char-code {
940+
background: #0d2b1a;
941+
color: #86efac;
942+
}
943+
944+
[data-theme="dark"] .char-row-removed .char-num,
945+
[data-theme="dark"] .char-row-removed .char-code {
946+
background: #2d1a1a;
947+
color: #fca5a5;
948+
}
949+
950+
[data-theme="dark"] .char-hl-del {
951+
background: #7f1d1d;
952+
color: #fecaca;
953+
}
954+
955+
[data-theme="dark"] .char-hl-ins {
956+
background: #14532d;
957+
color: #bbf7d0;
958+
}
959+
804960
[data-theme="dark"] .app-footer {
805961
background: #1a1829;
806962
border-top-color: #2a2740;

src/App.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export default function App() {
171171
>
172172
Unified
173173
</button>
174+
<button
175+
className={`toggle-btn${viewType === 'char' ? ' active' : ''}`}
176+
onClick={() => setViewType('char')}
177+
>
178+
Char Diff
179+
</button>
174180
</div>
175181
</div>
176182
</div>

src/components/DiffViewer.jsx

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,93 @@
11
import { useEffect, useRef } from 'react'
2-
import { createPatch } from 'diff'
2+
import { createPatch, diffLines, diffChars } from 'diff'
33
import { html as diff2htmlHtml } from 'diff2html'
44
import 'diff2html/bundles/css/diff2html.min.css'
55

6+
function CharDiffViewer({ left, right }) {
7+
const rows = []
8+
const changes = diffLines(left, right)
9+
let leftNum = 1
10+
let rightNum = 1
11+
let i = 0
12+
13+
while (i < changes.length) {
14+
const change = changes[i]
15+
16+
if (!change.added && !change.removed) {
17+
const lines = change.value.replace(/\n$/, '').split('\n')
18+
for (const line of lines) {
19+
rows.push({ type: 'unchanged', leftNum: leftNum++, rightNum: rightNum++, left: line, right: line, chars: null })
20+
}
21+
} else if (change.removed) {
22+
const removedLines = change.value.replace(/\n$/, '').split('\n')
23+
let addedLines = []
24+
if (i + 1 < changes.length && changes[i + 1].added) {
25+
addedLines = changes[i + 1].value.replace(/\n$/, '').split('\n')
26+
i++
27+
}
28+
const maxLen = Math.max(removedLines.length, addedLines.length)
29+
for (let j = 0; j < maxLen; j++) {
30+
const l = j < removedLines.length ? removedLines[j] : null
31+
const r = j < addedLines.length ? addedLines[j] : null
32+
const chars = l !== null && r !== null ? diffChars(l, r) : null
33+
rows.push({ type: 'changed', leftNum: l !== null ? leftNum++ : null, rightNum: r !== null ? rightNum++ : null, left: l, right: r, chars })
34+
}
35+
} else if (change.added) {
36+
const lines = change.value.replace(/\n$/, '').split('\n')
37+
for (const line of lines) {
38+
rows.push({ type: 'added', leftNum: null, rightNum: rightNum++, left: null, right: line, chars: null })
39+
}
40+
}
41+
i++
42+
}
43+
44+
return (
45+
<div className="char-diff">
46+
<table className="char-diff-table">
47+
<colgroup>
48+
<col className="char-diff-numcol" />
49+
<col />
50+
<col className="char-diff-numcol" />
51+
<col />
52+
</colgroup>
53+
<tbody>
54+
{rows.map((row, idx) => (
55+
<tr key={idx} className={`char-row-${row.type}`}>
56+
<td className="char-num">{row.leftNum ?? ''}</td>
57+
<td className="char-code char-left">
58+
{row.left !== null && (row.chars
59+
? row.chars.filter(p => !p.added).map((p, k) =>
60+
p.removed
61+
? <mark key={k} className="char-hl-del">{p.value}</mark>
62+
: <span key={k}>{p.value}</span>
63+
)
64+
: row.left || ' '
65+
)}
66+
</td>
67+
<td className="char-num">{row.rightNum ?? ''}</td>
68+
<td className="char-code char-right">
69+
{row.right !== null && (row.chars
70+
? row.chars.filter(p => !p.removed).map((p, k) =>
71+
p.added
72+
? <mark key={k} className="char-hl-ins">{p.value}</mark>
73+
: <span key={k}>{p.value}</span>
74+
)
75+
: row.right || ' '
76+
)}
77+
</td>
78+
</tr>
79+
))}
80+
</tbody>
81+
</table>
82+
</div>
83+
)
84+
}
85+
686
export default function DiffViewer({ left, right, viewType, fileName }) {
787
const containerRef = useRef(null)
888

989
useEffect(() => {
10-
if (!containerRef.current) return
90+
if (viewType === 'char' || !containerRef.current) return
1191

1292
const patch = createPatch(fileName || 'file', left, right, '', '')
1393
const rendered = diff2htmlHtml(patch, {
@@ -18,5 +98,9 @@ export default function DiffViewer({ left, right, viewType, fileName }) {
1898
containerRef.current.innerHTML = rendered
1999
}, [left, right, viewType, fileName])
20100

101+
if (viewType === 'char') {
102+
return <CharDiffViewer left={left} right={right} />
103+
}
104+
21105
return <div className="diff-output" ref={containerRef} />
22106
}

0 commit comments

Comments
 (0)