From 0cb04a759c7aad7aa684f98f2df5cf4bf96cdbfd Mon Sep 17 00:00:00 2001 From: codexhange <304653718+codexhange@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:42:32 +0200 Subject: [PATCH] feat(web): implement side-by-side Monaco interactive code diff viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/diff/DiffViewer.tsx | 304 ++++++++++++++++++++ frontend/src/components/diff/index.ts | 2 + frontend/src/lib/diff/diff.worker.ts | 46 +-- frontend/src/lib/diff/diffUtils.ts | 193 +++++++++++++ 4 files changed, 523 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/diff/DiffViewer.tsx create mode 100644 frontend/src/components/diff/index.ts create mode 100644 frontend/src/lib/diff/diffUtils.ts 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 ( +
+
+ ); + } + + 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; +}