diff --git a/frontend/src/components/diff/DiffViewer.tsx b/frontend/src/components/diff/DiffViewer.tsx
new file mode 100644
index 00000000..c112ca56
--- /dev/null
+++ b/frontend/src/components/diff/DiffViewer.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { applyHunks, type DiffHunk } from '@/lib/diff/diffUtils';
+import { THEME_COLORS } from '@/lib/theme/themeColors';
+import type { OnMount } from '@monaco-editor/react';
+import { Check, Columns2, GitCompareArrows, Rows3 } from 'lucide-react';
+import dynamic from 'next/dynamic';
+import type { editor } from 'monaco-editor';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+const DiffEditor = dynamic(() => import('@monaco-editor/react').then((m) => m.DiffEditor), {
+ ssr: false,
+ loading: () => (
+
+
+
+
Loading diff viewer…
+
+
+ ),
+});
+
+export interface DiffViewerProps {
+ /** Student's working contract code. */
+ original: string;
+ /** Model solution / compiler-fixed code. */
+ modified: string;
+ /** Filename shown in the header. */
+ filename?: string;
+ /** Language id handed to Monaco. */
+ language?: string;
+ /** Called after a diff chunk is merged into the student buffer. */
+ onApply?: (nextBuffer: string) => void;
+ theme?: 'dark' | 'light' | 'oled';
+}
+
+/** Renders the chunk list with per-hunk Apply controls. */
+function HunkList({
+ hunks,
+ applied,
+ onApplyHunk,
+ onApplyAll,
+}: {
+ hunks: DiffHunk[];
+ applied: Set;
+ onApplyHunk: (hunk: DiffHunk) => void;
+ onApplyAll: () => void;
+}) {
+ if (hunks.length === 0) {
+ return (
+
+
+ Identical — no differences.
+
+ );
+ }
+
+ return (
+
+
+
+ {hunks.length} chunk{hunks.length > 1 ? 's' : ''}
+
+
+
+
+ {hunks.map((hunk) => {
+ const done = applied.has(hunk.id);
+ return (
+ -
+
+ L{hunk.originalStart + 1}
+
+
+ {hunk.originalLines.length} → {hunk.modifiedLines.length} lines
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+export function DiffViewer({
+ original,
+ modified,
+ filename = 'lib.rs',
+ language = 'rust',
+ onApply,
+ theme = 'dark',
+}: DiffViewerProps) {
+ const [sideBySide, setSideBySide] = useState(true);
+ const [hunks, setHunks] = useState([]);
+ const [applied, setApplied] = useState>(new Set());
+ const [buffer, setBuffer] = useState(original);
+ const workerRef = useRef(null);
+ const requestIdRef = useRef(0);
+
+ // Compute hunks off-thread; fall back to main-thread computation when the
+ // worker cannot be constructed (e.g. some test/SSR environments).
+ useEffect(() => {
+ let cancelled = false;
+ const compute = () => {
+ if (cancelled) return;
+ try {
+ if (typeof Worker !== 'undefined') {
+ if (!workerRef.current) {
+ workerRef.current = new Worker(new URL('@/lib/diff/diff.worker.ts', import.meta.url));
+ workerRef.current.onmessage = (event: MessageEvent) => {
+ const { result } = event.data;
+ if (!cancelled) setHunks(result.hunks);
+ };
+ }
+ const id = ++requestIdRef.current;
+ workerRef.current.postMessage({ id, original, modified });
+ } else {
+ // Fallback (rare): synchronous computation.
+ import('@/lib/diff/diffUtils').then(({ computeDiffHunks }) => {
+ if (!cancelled) setHunks(computeDiffHunks(original, modified).hunks);
+ });
+ }
+ } catch {
+ import('@/lib/diff/diffUtils').then(({ computeDiffHunks }) => {
+ if (!cancelled) setHunks(computeDiffHunks(original, modified).hunks);
+ });
+ }
+ };
+ compute();
+ return () => {
+ cancelled = true;
+ };
+ }, [original, modified]);
+
+ useEffect(() => {
+ setBuffer(original);
+ setApplied(new Set());
+ }, [original]);
+
+ useEffect(() => {
+ return () => {
+ workerRef.current?.terminate();
+ workerRef.current = null;
+ };
+ }, []);
+
+ const handleApplyHunk = useCallback(
+ (hunk: DiffHunk) => {
+ setBuffer((prev) => {
+ const next = applyHunks(prev, [hunk]);
+ if (next !== prev) {
+ setApplied((prevSet) => new Set(prevSet).add(hunk.id));
+ onApply?.(next);
+ }
+ return next;
+ });
+ },
+ [onApply],
+ );
+
+ const handleApplyAll = useCallback(() => {
+ setBuffer((prev) => {
+ const next = applyHunks(prev, hunks);
+ if (next !== prev) {
+ setApplied(new Set(hunks.map((h) => h.id)));
+ onApply?.(next);
+ }
+ return next;
+ });
+ }, [hunks, onApply]);
+
+ const handleEditorMount: OnMount = useCallback(
+ (_editor, monaco) => {
+ const palette = theme === 'light' ? THEME_COLORS.light : THEME_COLORS.dark;
+ const bg = theme === 'oled' ? '#000000' : palette.background.primary;
+ monaco.editor.defineTheme('web3-lab-diff', {
+ base: theme === 'light' ? 'vs' : 'vs-dark',
+ inherit: true,
+ rules: [
+ { token: 'comment', foreground: '636e7b', fontStyle: 'italic' },
+ { token: 'keyword', foreground: 'ff7b72', fontStyle: 'bold' },
+ { token: 'string', foreground: 'a5d6ff' },
+ { token: 'type', foreground: '79c0ff' },
+ { token: 'function', foreground: 'd2a8ff' },
+ {
+ token: 'sorobanMacro',
+ foreground: palette.interactive.primary.replace('#', ''),
+ fontStyle: 'bold',
+ },
+ {
+ token: 'sorobanType',
+ foreground: palette.status.info.replace('#', ''),
+ fontStyle: 'bold',
+ },
+ ],
+ colors: {
+ 'editor.background': bg,
+ 'editor.lineHighlightBackground': '#ffffff08',
+ 'editorLineNumber.foreground': palette.text.muted,
+ 'editorLineNumber.activeForeground': palette.text.secondary,
+ 'diffEditor.insertedTextBackground': '#00ff0022',
+ 'diffEditor.removedTextBackground': '#ff000022',
+ 'diffEditor.insertedLineBackground': '#00ff0011',
+ 'diffEditor.removedLineBackground': '#ff000011',
+ },
+ });
+ monaco.editor.setTheme('web3-lab-diff');
+ },
+ [theme],
+ );
+
+ return (
+
+
+ {filename}
+
+
+ {sideBySide ? 'Side-by-side' : 'Inline'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default DiffViewer;
diff --git a/frontend/src/components/diff/index.ts b/frontend/src/components/diff/index.ts
new file mode 100644
index 00000000..b49c9ac9
--- /dev/null
+++ b/frontend/src/components/diff/index.ts
@@ -0,0 +1,2 @@
+export { DiffViewer } from './DiffViewer';
+export type { DiffViewerProps } from './DiffViewer';
diff --git a/frontend/src/lib/diff/diff.worker.ts b/frontend/src/lib/diff/diff.worker.ts
index ced53022..0dd84357 100644
--- a/frontend/src/lib/diff/diff.worker.ts
+++ b/frontend/src/lib/diff/diff.worker.ts
@@ -7,8 +7,9 @@ import type {
DiffWorkerRequest,
DiffWorkerResponse,
} from './diffTypes';
+import { computeDiffHunks, type DiffResult as HunkDiffResult } from './diffUtils';
-type WorkerSelf = typeof self & { postMessage(message: DiffWorkerResponse): void };
+type WorkerSelf = typeof self & { postMessage(message: any): void };
const workerSelf = self as WorkerSelf;
const dmp = new DiffMatchPatch();
@@ -38,12 +39,6 @@ function charSegments(originalText: string, modifiedText: string): DiffSegment[]
/**
* Compute a line-level chunk model from two code strings.
- *
- * Uses `diff_linesToChars` (O(ND) on lines) so large multi-file comparisons
- * stay fast, then maps the line tokens back to concrete strings. Equal
- * (context) regions advance both line counters; contiguous deletions and
- * insertions between context regions merge into a single `replace` chunk,
- * which is what the "apply chunk" control treats as a unit.
*/
export function computeDiff(original: string, modified: string): {
chunks: DiffChunk[];
@@ -90,7 +85,6 @@ export function computeDiff(original: string, modified: string): {
for (const [op, text] of lineOps) {
const lines = splitLines(text);
if (op === 0) {
- // Context region: flush any pending changes, then advance both counters.
flush();
originalLine += lines.length;
modifiedLine += lines.length;
@@ -113,20 +107,28 @@ export function computeDiff(original: string, modified: string): {
return { chunks, identical: false };
}
-workerSelf.addEventListener('message', (event: MessageEvent) => {
- const msg = event.data;
- if (msg.type !== 'diff') return;
- const { requestId, original, modified } = msg;
- try {
- const result = computeDiff(original, modified);
- workerSelf.postMessage({ type: 'diff-result', requestId, result });
- } catch (err) {
- workerSelf.postMessage({
- type: 'diff-error',
- requestId,
- message: err instanceof Error ? err.message : 'Diff computation failed.',
- });
+// Handle both InteractiveDiffViewer (type: 'diff') and DiffViewer (id, original, modified)
+self.onmessage = (event: MessageEvent) => {
+ const data = event.data;
+ if (!data) return;
+
+ if (data.type === 'diff') {
+ const { requestId, original, modified } = data;
+ try {
+ const result = computeDiff(original, modified);
+ workerSelf.postMessage({ type: 'diff-result', requestId, result });
+ } catch (err) {
+ workerSelf.postMessage({
+ type: 'diff-error',
+ requestId,
+ message: err instanceof Error ? err.message : 'Diff computation failed.',
+ });
+ }
+ } else if ('id' in data) {
+ const { id, original, modified } = data;
+ const result: HunkDiffResult = computeDiffHunks(original, modified);
+ workerSelf.postMessage({ id, result });
}
-});
+};
export {};
diff --git a/frontend/src/lib/diff/diffUtils.ts b/frontend/src/lib/diff/diffUtils.ts
new file mode 100644
index 00000000..b319bf2f
--- /dev/null
+++ b/frontend/src/lib/diff/diffUtils.ts
@@ -0,0 +1,193 @@
+/**
+ * diffUtils.ts — Issue #1145
+ *
+ * Pure, framework-free diff utilities used by the Soroban diff viewer:
+ * - `computeDiffHunks` splits two documents into line-level change hunks
+ * (character-level granularity comes from diff-match-patch underneath).
+ * - `applyHunk` merges a single selected solution hunk into a student
+ * buffer, powering the "Apply Diff Chunk" controls.
+ *
+ * These functions run both in the main thread (tests, fallback) and inside
+ * the diff Web Worker, so they must stay free of DOM / Monaco imports.
+ */
+
+import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
+
+export interface DiffHunk {
+ /** Stable id for React keys and apply-bookkeeping. */
+ id: string;
+ /** 0-based first line of the hunk in the original (student) document. */
+ originalStart: number;
+ /** Lines in the original document that this hunk replaces. */
+ originalLines: string[];
+ /** 0-based first line of the hunk in the modified (solution) document. */
+ modifiedStart: number;
+ /** Lines in the modified document that this hunk inserts/replaces with. */
+ modifiedLines: string[];
+}
+
+export interface DiffResult {
+ hunks: DiffHunk[];
+ /** True when the two documents are character-identical. */
+ identical: boolean;
+}
+
+const dmp = new DiffMatchPatch();
+
+/** Number of equal context lines kept around each change run. */
+const CONTEXT_LINES = 2;
+
+/**
+ * Computes the set of hunks needed to transform `original` into `modified`.
+ * Uses diff-match-patch's line-mode diff (which internally performs
+ * character-level matching), then groups contiguous changes into hunks with
+ * a small equal-line context so the UI can offer chunk-by-chunk application.
+ */
+export function computeDiffHunks(original: string, modified: string): DiffResult {
+ const originalLines = splitLines(original);
+ const modifiedLines = splitLines(modified);
+
+ if (originalLines.length === 0 && modifiedLines.length === 0) {
+ return { hunks: [], identical: true };
+ }
+
+ // diff_linesToChars_ accepts full strings and internally splits on '\n';
+ // diff_charsToLines_ then rehydrates each op back to newline-terminated
+ // lines (except possibly the final unterminated line).
+ const { chars1, chars2, lineArray } = dmp.diff_linesToChars_(original, modified);
+ const diffs = dmp.diff_main(chars1, chars2, false);
+ dmp.diff_charsToLines_(diffs, lineArray);
+ dmp.diff_cleanupSemantic(diffs);
+
+ const hunks: DiffHunk[] = [];
+ // Cursors advance through both documents as ops are consumed, so the
+ // equal-run boundaries always sit at the true positions of the last
+ // unchanged block.
+ let originalCursor = 0;
+ let modifiedCursor = 0;
+ // [start, end) of the most recent equal run, used to draw context lines.
+ let equalOriginalStart = 0;
+ let equalOriginalEnd = 0;
+ let equalModifiedStart = 0;
+ let equalModifiedEnd = 0;
+
+ const flushChange = (change: { original: string[]; modified: string[] }) => {
+ if (change.original.length === 0 && change.modified.length === 0) {
+ return;
+ }
+ const originalStart = Math.max(equalOriginalStart, equalOriginalEnd - CONTEXT_LINES);
+ const contextBefore = originalLines.slice(originalStart, equalOriginalEnd);
+ const modifiedStart = Math.max(equalModifiedStart, equalModifiedEnd - CONTEXT_LINES);
+ const modifiedContextBefore = modifiedLines.slice(modifiedStart, equalModifiedEnd);
+
+ hunks.push({
+ id: `hunk-${hunks.length}`,
+ originalStart,
+ originalLines: [...contextBefore, ...change.original],
+ modifiedStart,
+ modifiedLines: [...modifiedContextBefore, ...change.modified],
+ });
+ };
+
+ let pending: { original: string[]; modified: string[] } = { original: [], modified: [] };
+
+ const consumeLines = (op: number, lines: string[]) => {
+ if (lines.length === 0) {
+ return;
+ }
+ if (op === 0) {
+ // Equal run: flush any pending change, then record the new equal-run
+ // boundaries from the current cursors.
+ flushChange(pending);
+ pending = { original: [], modified: [] };
+ equalOriginalStart = originalCursor;
+ equalModifiedStart = modifiedCursor;
+ originalCursor += lines.length;
+ modifiedCursor += lines.length;
+ equalOriginalEnd = originalCursor;
+ equalModifiedEnd = modifiedCursor;
+ } else if (op === -1) {
+ pending.original.push(...lines);
+ originalCursor += lines.length;
+ } else {
+ pending.modified.push(...lines);
+ modifiedCursor += lines.length;
+ }
+ };
+
+ for (const [op, text] of diffs) {
+ const lines = text === '' ? [] : text.split('\n');
+ // diff-match-patch terminates lines with '\n'; drop the trailing empty
+ // fragment produced by a trailing newline.
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
+ lines.pop();
+ }
+ consumeLines(op, lines);
+ }
+ flushChange(pending);
+
+ return { hunks, identical: hunks.length === 0 };
+}
+
+/**
+ * Applies a single solution hunk to the student buffer, replacing the
+ * original line range with the modified (solution) lines. Returns the new
+ * full buffer, or the original buffer unchanged when the hunk's source lines
+ * no longer match (stale hunk after earlier edits).
+ */
+export function applyHunk(buffer: string, hunk: DiffHunk): string {
+ const lines = splitLines(buffer);
+
+ // Hunk out of range — stale.
+ if (hunk.originalStart > lines.length) {
+ return buffer;
+ }
+
+ const actualSource = lines.slice(hunk.originalStart, hunk.originalStart + hunk.originalLines.length);
+
+ // The hunk's context must still match, otherwise the buffer has drifted.
+ if (!sameLines(actualSource, hunk.originalLines)) {
+ return buffer;
+ }
+
+ const next = [
+ ...lines.slice(0, hunk.originalStart),
+ ...hunk.modifiedLines,
+ ...lines.slice(hunk.originalStart + hunk.originalLines.length),
+ ];
+
+ return joinLines(next);
+}
+
+/** Applies several hunks bottom-up so line indices stay valid. */
+export function applyHunks(buffer: string, hunks: DiffHunk[]): string {
+ const sorted = [...hunks].sort((a, b) => b.originalStart - a.originalStart);
+ let next = buffer;
+ for (const hunk of sorted) {
+ next = applyHunk(next, hunk);
+ }
+ return next;
+}
+
+export function splitLines(text: string): string[] {
+ if (text === '') {
+ return [];
+ }
+ return text.split('\n');
+}
+
+export function joinLines(lines: string[]): string {
+ return lines.join('\n');
+}
+
+function sameLines(a: string[], b: string[]): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+}