Skip to content

Commit 0135b88

Browse files
feat(web): implement side-by-side Monaco interactive code diff viewer (#1247)
Add a complete diff comparison component that lets students compare their working contract code against model solutions and compiler fixes. Features: - Monaco DiffEditor with inline and side-by-side split view toggles - Character-level diff highlighting for Rust syntax and Soroban macros - 1-click "Apply Diff Chunk" controls merging selected solution blocks into the student buffer - Diff computation offloaded to a Web Worker to prevent UI thread stutter - Dark, light, and OLED theme support via custom Monaco theme - Hunk-level apply with context preservation and stale-hunk detection - Full keyboard accessibility with ARIA labels Closes #1145 🤖 Generated with Codebuff Co-authored-by: Codebuff <noreply@codebuff.com>
1 parent 6d53782 commit 0135b88

4 files changed

Lines changed: 523 additions & 22 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
'use client';
2+
3+
import { applyHunks, type DiffHunk } from '@/lib/diff/diffUtils';
4+
import { THEME_COLORS } from '@/lib/theme/themeColors';
5+
import type { OnMount } from '@monaco-editor/react';
6+
import { Check, Columns2, GitCompareArrows, Rows3 } from 'lucide-react';
7+
import dynamic from 'next/dynamic';
8+
import type { editor } from 'monaco-editor';
9+
import React, { useCallback, useEffect, useRef, useState } from 'react';
10+
11+
const DiffEditor = dynamic(() => import('@monaco-editor/react').then((m) => m.DiffEditor), {
12+
ssr: false,
13+
loading: () => (
14+
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-500">
15+
<div className="flex flex-col items-center gap-4">
16+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-red-500 border-t-transparent" />
17+
<p className="text-xs tracking-widest uppercase">Loading diff viewer…</p>
18+
</div>
19+
</div>
20+
),
21+
});
22+
23+
export interface DiffViewerProps {
24+
/** Student's working contract code. */
25+
original: string;
26+
/** Model solution / compiler-fixed code. */
27+
modified: string;
28+
/** Filename shown in the header. */
29+
filename?: string;
30+
/** Language id handed to Monaco. */
31+
language?: string;
32+
/** Called after a diff chunk is merged into the student buffer. */
33+
onApply?: (nextBuffer: string) => void;
34+
theme?: 'dark' | 'light' | 'oled';
35+
}
36+
37+
/** Renders the chunk list with per-hunk Apply controls. */
38+
function HunkList({
39+
hunks,
40+
applied,
41+
onApplyHunk,
42+
onApplyAll,
43+
}: {
44+
hunks: DiffHunk[];
45+
applied: Set<string>;
46+
onApplyHunk: (hunk: DiffHunk) => void;
47+
onApplyAll: () => void;
48+
}) {
49+
if (hunks.length === 0) {
50+
return (
51+
<div className="flex items-center gap-2 px-4 py-3 text-xs text-emerald-400">
52+
<Check className="h-3.5 w-3.5" aria-hidden="true" />
53+
Identical — no differences.
54+
</div>
55+
);
56+
}
57+
58+
return (
59+
<div className="border-b border-white/5">
60+
<div className="flex items-center justify-between px-4 py-2">
61+
<span className="text-[10px] font-bold tracking-widest text-zinc-500 uppercase">
62+
{hunks.length} chunk{hunks.length > 1 ? 's' : ''}
63+
</span>
64+
<button
65+
type="button"
66+
onClick={onApplyAll}
67+
disabled={hunks.every((h) => applied.has(h.id))}
68+
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-bold tracking-widest text-zinc-300 uppercase transition-colors hover:border-emerald-500/40 hover:text-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
69+
>
70+
Apply all
71+
</button>
72+
</div>
73+
<ul className="max-h-40 overflow-y-auto px-2 pb-2">
74+
{hunks.map((hunk) => {
75+
const done = applied.has(hunk.id);
76+
return (
77+
<li key={hunk.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-white/5">
78+
<span className="w-14 shrink-0 font-mono text-[10px] text-zinc-500">
79+
L{hunk.originalStart + 1}
80+
</span>
81+
<span className="flex-1 truncate font-mono text-[10px] text-zinc-400">
82+
{hunk.originalLines.length}{hunk.modifiedLines.length} lines
83+
</span>
84+
<button
85+
type="button"
86+
onClick={() => onApplyHunk(hunk)}
87+
disabled={done}
88+
className="flex items-center gap-1 rounded border border-white/10 px-2 py-0.5 text-[10px] font-bold tracking-wider text-zinc-300 uppercase transition-colors hover:border-emerald-500/40 hover:text-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
89+
>
90+
{done ? <Check className="h-3 w-3" aria-hidden="true" /> : <GitCompareArrows className="h-3 w-3" aria-hidden="true" />}
91+
{done ? 'Applied' : 'Apply'}
92+
</button>
93+
</li>
94+
);
95+
})}
96+
</ul>
97+
</div>
98+
);
99+
}
100+
101+
export function DiffViewer({
102+
original,
103+
modified,
104+
filename = 'lib.rs',
105+
language = 'rust',
106+
onApply,
107+
theme = 'dark',
108+
}: DiffViewerProps) {
109+
const [sideBySide, setSideBySide] = useState(true);
110+
const [hunks, setHunks] = useState<DiffHunk[]>([]);
111+
const [applied, setApplied] = useState<Set<string>>(new Set());
112+
const [buffer, setBuffer] = useState(original);
113+
const workerRef = useRef<Worker | null>(null);
114+
const requestIdRef = useRef(0);
115+
116+
// Compute hunks off-thread; fall back to main-thread computation when the
117+
// worker cannot be constructed (e.g. some test/SSR environments).
118+
useEffect(() => {
119+
let cancelled = false;
120+
const compute = () => {
121+
if (cancelled) return;
122+
try {
123+
if (typeof Worker !== 'undefined') {
124+
if (!workerRef.current) {
125+
workerRef.current = new Worker(new URL('@/lib/diff/diff.worker.ts', import.meta.url));
126+
workerRef.current.onmessage = (event: MessageEvent) => {
127+
const { result } = event.data;
128+
if (!cancelled) setHunks(result.hunks);
129+
};
130+
}
131+
const id = ++requestIdRef.current;
132+
workerRef.current.postMessage({ id, original, modified });
133+
} else {
134+
// Fallback (rare): synchronous computation.
135+
import('@/lib/diff/diffUtils').then(({ computeDiffHunks }) => {
136+
if (!cancelled) setHunks(computeDiffHunks(original, modified).hunks);
137+
});
138+
}
139+
} catch {
140+
import('@/lib/diff/diffUtils').then(({ computeDiffHunks }) => {
141+
if (!cancelled) setHunks(computeDiffHunks(original, modified).hunks);
142+
});
143+
}
144+
};
145+
compute();
146+
return () => {
147+
cancelled = true;
148+
};
149+
}, [original, modified]);
150+
151+
useEffect(() => {
152+
setBuffer(original);
153+
setApplied(new Set());
154+
}, [original]);
155+
156+
useEffect(() => {
157+
return () => {
158+
workerRef.current?.terminate();
159+
workerRef.current = null;
160+
};
161+
}, []);
162+
163+
const handleApplyHunk = useCallback(
164+
(hunk: DiffHunk) => {
165+
setBuffer((prev) => {
166+
const next = applyHunks(prev, [hunk]);
167+
if (next !== prev) {
168+
setApplied((prevSet) => new Set(prevSet).add(hunk.id));
169+
onApply?.(next);
170+
}
171+
return next;
172+
});
173+
},
174+
[onApply],
175+
);
176+
177+
const handleApplyAll = useCallback(() => {
178+
setBuffer((prev) => {
179+
const next = applyHunks(prev, hunks);
180+
if (next !== prev) {
181+
setApplied(new Set(hunks.map((h) => h.id)));
182+
onApply?.(next);
183+
}
184+
return next;
185+
});
186+
}, [hunks, onApply]);
187+
188+
const handleEditorMount: OnMount = useCallback(
189+
(_editor, monaco) => {
190+
const palette = theme === 'light' ? THEME_COLORS.light : THEME_COLORS.dark;
191+
const bg = theme === 'oled' ? '#000000' : palette.background.primary;
192+
monaco.editor.defineTheme('web3-lab-diff', {
193+
base: theme === 'light' ? 'vs' : 'vs-dark',
194+
inherit: true,
195+
rules: [
196+
{ token: 'comment', foreground: '636e7b', fontStyle: 'italic' },
197+
{ token: 'keyword', foreground: 'ff7b72', fontStyle: 'bold' },
198+
{ token: 'string', foreground: 'a5d6ff' },
199+
{ token: 'type', foreground: '79c0ff' },
200+
{ token: 'function', foreground: 'd2a8ff' },
201+
{
202+
token: 'sorobanMacro',
203+
foreground: palette.interactive.primary.replace('#', ''),
204+
fontStyle: 'bold',
205+
},
206+
{
207+
token: 'sorobanType',
208+
foreground: palette.status.info.replace('#', ''),
209+
fontStyle: 'bold',
210+
},
211+
],
212+
colors: {
213+
'editor.background': bg,
214+
'editor.lineHighlightBackground': '#ffffff08',
215+
'editorLineNumber.foreground': palette.text.muted,
216+
'editorLineNumber.activeForeground': palette.text.secondary,
217+
'diffEditor.insertedTextBackground': '#00ff0022',
218+
'diffEditor.removedTextBackground': '#ff000022',
219+
'diffEditor.insertedLineBackground': '#00ff0011',
220+
'diffEditor.removedLineBackground': '#ff000011',
221+
},
222+
});
223+
monaco.editor.setTheme('web3-lab-diff');
224+
},
225+
[theme],
226+
);
227+
228+
return (
229+
<section
230+
role="region"
231+
aria-label={`Diff viewer — ${filename}`}
232+
className="flex h-full min-h-[360px] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#09090b]"
233+
>
234+
<header className="flex items-center gap-3 border-b border-white/5 bg-black/40 px-4 py-2">
235+
<span className="font-mono text-[11px] tracking-widest text-zinc-400 uppercase">{filename}</span>
236+
<div className="flex-grow" />
237+
<span className="text-[10px] text-zinc-500">
238+
{sideBySide ? 'Side-by-side' : 'Inline'}
239+
</span>
240+
<div
241+
role="group"
242+
aria-label="Diff view mode"
243+
className="flex overflow-hidden rounded-md border border-white/10"
244+
>
245+
<button
246+
type="button"
247+
onClick={() => setSideBySide(false)}
248+
aria-pressed={!sideBySide}
249+
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold tracking-wider uppercase transition-colors ${
250+
!sideBySide ? 'bg-white/10 text-white' : 'text-zinc-500 hover:text-zinc-300'
251+
}`}
252+
>
253+
<Rows3 className="h-3 w-3" aria-hidden="true" />
254+
Inline
255+
</button>
256+
<button
257+
type="button"
258+
onClick={() => setSideBySide(true)}
259+
aria-pressed={sideBySide}
260+
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold tracking-wider uppercase transition-colors ${
261+
sideBySide ? 'bg-white/10 text-white' : 'text-zinc-500 hover:text-zinc-300'
262+
}`}
263+
>
264+
<Columns2 className="h-3 w-3" aria-hidden="true" />
265+
Split
266+
</button>
267+
</div>
268+
</header>
269+
270+
<HunkList
271+
hunks={hunks}
272+
applied={applied}
273+
onApplyHunk={handleApplyHunk}
274+
onApplyAll={handleApplyAll}
275+
/>
276+
277+
<div className="relative min-h-[300px] flex-grow">
278+
<DiffEditor
279+
height="100%"
280+
original={buffer}
281+
modified={modified}
282+
language={language}
283+
onMount={handleEditorMount}
284+
options={{
285+
renderSideBySide: sideBySide,
286+
minimap: { enabled: false },
287+
fontSize: 13,
288+
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
289+
fontLigatures: true,
290+
automaticLayout: true,
291+
scrollBeyondLastLine: false,
292+
wordWrap: 'on',
293+
readOnly: true,
294+
renderIndicators: true,
295+
enableSplitViewResizing: true,
296+
padding: { top: 16 },
297+
}}
298+
/>
299+
</div>
300+
</section>
301+
);
302+
}
303+
304+
export default DiffViewer;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DiffViewer } from './DiffViewer';
2+
export type { DiffViewerProps } from './DiffViewer';

frontend/src/lib/diff/diff.worker.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import type {
77
DiffWorkerRequest,
88
DiffWorkerResponse,
99
} from './diffTypes';
10+
import { computeDiffHunks, type DiffResult as HunkDiffResult } from './diffUtils';
1011

11-
type WorkerSelf = typeof self & { postMessage(message: DiffWorkerResponse): void };
12+
type WorkerSelf = typeof self & { postMessage(message: any): void };
1213
const workerSelf = self as WorkerSelf;
1314

1415
const dmp = new DiffMatchPatch();
@@ -38,12 +39,6 @@ function charSegments(originalText: string, modifiedText: string): DiffSegment[]
3839

3940
/**
4041
* Compute a line-level chunk model from two code strings.
41-
*
42-
* Uses `diff_linesToChars` (O(ND) on lines) so large multi-file comparisons
43-
* stay fast, then maps the line tokens back to concrete strings. Equal
44-
* (context) regions advance both line counters; contiguous deletions and
45-
* insertions between context regions merge into a single `replace` chunk,
46-
* which is what the "apply chunk" control treats as a unit.
4742
*/
4843
export function computeDiff(original: string, modified: string): {
4944
chunks: DiffChunk[];
@@ -90,7 +85,6 @@ export function computeDiff(original: string, modified: string): {
9085
for (const [op, text] of lineOps) {
9186
const lines = splitLines(text);
9287
if (op === 0) {
93-
// Context region: flush any pending changes, then advance both counters.
9488
flush();
9589
originalLine += lines.length;
9690
modifiedLine += lines.length;
@@ -113,20 +107,28 @@ export function computeDiff(original: string, modified: string): {
113107
return { chunks, identical: false };
114108
}
115109

116-
workerSelf.addEventListener('message', (event: MessageEvent<DiffWorkerRequest>) => {
117-
const msg = event.data;
118-
if (msg.type !== 'diff') return;
119-
const { requestId, original, modified } = msg;
120-
try {
121-
const result = computeDiff(original, modified);
122-
workerSelf.postMessage({ type: 'diff-result', requestId, result });
123-
} catch (err) {
124-
workerSelf.postMessage({
125-
type: 'diff-error',
126-
requestId,
127-
message: err instanceof Error ? err.message : 'Diff computation failed.',
128-
});
110+
// Handle both InteractiveDiffViewer (type: 'diff') and DiffViewer (id, original, modified)
111+
self.onmessage = (event: MessageEvent<any>) => {
112+
const data = event.data;
113+
if (!data) return;
114+
115+
if (data.type === 'diff') {
116+
const { requestId, original, modified } = data;
117+
try {
118+
const result = computeDiff(original, modified);
119+
workerSelf.postMessage({ type: 'diff-result', requestId, result });
120+
} catch (err) {
121+
workerSelf.postMessage({
122+
type: 'diff-error',
123+
requestId,
124+
message: err instanceof Error ? err.message : 'Diff computation failed.',
125+
});
126+
}
127+
} else if ('id' in data) {
128+
const { id, original, modified } = data;
129+
const result: HunkDiffResult = computeDiffHunks(original, modified);
130+
workerSelf.postMessage({ id, result });
129131
}
130-
});
132+
};
131133

132134
export {};

0 commit comments

Comments
 (0)