/** * diff.ts — Muse semantic diff page. * * Unlike Git, Muse knows *what semantically changed* (structured_delta) and * *which files truly changed* (snapshot_diff). We use both to render: * * 1. A stats bar — symbol counts, file counts (things Git cannot show) * 2. Per-file cards — semantic symbol changes above the line diff * 3. Real line diff — comparing parent vs current content, not just dumping * the whole file green */ import hljs from 'highlight.js/lib/core'; import { extToLang } from '../lang-detect.ts'; import python from 'highlight.js/lib/languages/python'; import typescript from 'highlight.js/lib/languages/typescript'; import javascript from 'highlight.js/lib/languages/javascript'; import rust from 'highlight.js/lib/languages/rust'; import go from 'highlight.js/lib/languages/go'; import swift from 'highlight.js/lib/languages/swift'; import kotlin from 'highlight.js/lib/languages/kotlin'; import java from 'highlight.js/lib/languages/java'; import ruby from 'highlight.js/lib/languages/ruby'; import cpp from 'highlight.js/lib/languages/cpp'; import json from 'highlight.js/lib/languages/json'; import yaml from 'highlight.js/lib/languages/yaml'; import toml from 'highlight.js/lib/languages/ini'; import bash from 'highlight.js/lib/languages/bash'; import xml from 'highlight.js/lib/languages/xml'; import css from 'highlight.js/lib/languages/css'; import sql from 'highlight.js/lib/languages/sql'; import markdown from 'highlight.js/lib/languages/markdown'; import plaintext from 'highlight.js/lib/languages/plaintext'; hljs.registerLanguage('python', python); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('rust', rust); hljs.registerLanguage('go', go); hljs.registerLanguage('swift', swift); hljs.registerLanguage('kotlin', kotlin); hljs.registerLanguage('java', java); hljs.registerLanguage('ruby', ruby); hljs.registerLanguage('cpp', cpp); hljs.registerLanguage('json', json); hljs.registerLanguage('yaml', yaml); hljs.registerLanguage('toml', toml); hljs.registerLanguage('bash', bash); hljs.registerLanguage('xml', xml); hljs.registerLanguage('css', css); hljs.registerLanguage('sql', sql); hljs.registerLanguage('markdown', markdown); hljs.registerLanguage('plaintext', plaintext); // ── Types ───────────────────────────────────────────────────────────────────── interface ChildOp { address: string; op: string; content_summary: string; } interface FileOp { address: string; op: string; child_summary: string; child_ops: ChildOp[]; } interface StructuredDelta { domain: string; ops: FileOp[]; } interface SnapshotDiff { added: string[]; modified: string[]; removed: string[]; total_files: number; } interface CommitData { commitId: string; message: string; author: string; timestamp: string; branch: string; parentIds: string[]; } interface PageData { page: string; repoId: string; commitId: string; shortId: string; owner: string; repoSlug: string; baseUrl: string; parentId: string | null; structuredDelta: StructuredDelta | null; snapshotDiff: SnapshotDiff; commit: CommitData | null; viewerType: string; domainName: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function highlight(code: string, lang: string): string { try { return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value; } catch { return esc(code); } } /** Determine op class from operation string. */ function opClass(op: string): string { if (op === 'insert') return 'df3-op-add'; if (op === 'delete') return 'df3-op-del'; return 'df3-op-mod'; } function opSign(op: string): string { if (op === 'insert') return '+'; if (op === 'delete') return '−'; return '~'; } /** Detect kind from content_summary string. */ function kindChip(summary: string): string { const s = summary.toLowerCase(); if (s.includes('class')) return 'class'; if (s.includes('method')) return 'method'; if (s.includes('function') || s.includes('func')) return 'func'; if (s.includes('import')) return 'import'; if (s.includes('variable') || s.includes('constant')) return 'var'; return ''; } // ── Myers line diff ─────────────────────────────────────────────────────────── // Simple O(ND) diff — sufficient for typical commit sizes. type LineKind = 'add' | 'del' | 'ctx'; interface DiffLine { kind: LineKind; text: string; oldN: number; newN: number; } function diffLines(oldLines: string[], newLines: string[]): DiffLine[] { const m = oldLines.length, n = newLines.length; const max = m + n; const v: number[] = new Array(2 * max + 1).fill(0); const trace: number[][] = []; outer: for (let d = 0; d <= max; d++) { trace.push([...v]); for (let k = -d; k <= d; k += 2) { const ki = k + max; let x: number; if (k === -d || (k !== d && v[ki - 1] < v[ki + 1])) { x = v[ki + 1]; } else { x = v[ki - 1] + 1; } let y = x - k; while (x < m && y < n && oldLines[x] === newLines[y]) { x++; y++; } v[ki] = x; if (x >= m && y >= n) break outer; } } // Backtrack const result: DiffLine[] = []; let x = m, y = n; for (let d = trace.length - 1; d >= 0; d--) { const vd = trace[d]; const k = x - y; const ki = k + max; const prevK = (k === -d || (k !== d && vd[ki - 1] < vd[ki + 1])) ? k + 1 : k - 1; const prevX = vd[prevK + max]; const prevY = prevX - prevK; while (x > prevX && y > prevY) { result.unshift({ kind: 'ctx', text: oldLines[x - 1], oldN: x, newN: y }); x--; y--; } if (d > 0) { if (x === prevX) { result.unshift({ kind: 'add', text: newLines[y - 1], oldN: 0, newN: y }); y--; } else { result.unshift({ kind: 'del', text: oldLines[x - 1], oldN: x, newN: 0 }); x--; } } } return result; } /** Render a diff as an HTML table with syntax-highlighted tokens. */ function renderDiffTable(lines: DiffLine[], lang: string): string { // Highlight the full old and new texts then split, to preserve multi-line spans. const oldText = lines.filter(l => l.kind !== 'add').map(l => l.text).join('\n'); const newText = lines.filter(l => l.kind !== 'del').map(l => l.text).join('\n'); const oldHl = highlight(oldText, lang).split('\n'); const newHl = highlight(newText, lang).split('\n'); let oldI = 0, newI = 0; const rows = lines.map(l => { let hlCode: string; let rowCls: string; let sign: string; if (l.kind === 'del') { hlCode = oldHl[oldI++] ?? esc(l.text); rowCls = 'df3-dl-del'; sign = '−'; } else if (l.kind === 'add') { hlCode = newHl[newI++] ?? esc(l.text); rowCls = 'df3-dl-add'; sign = '+'; } else { hlCode = oldHl[oldI++] ?? esc(l.text); newI++; rowCls = 'df3-dl-ctx'; sign = ' '; } const ln = l.kind === 'del' ? l.oldN : l.kind === 'add' ? l.newN : l.oldN; return `${sign}${ln}${hlCode}`; }); return `${rows.join('')}
`; } /** Collapse context lines — show only ±5 lines around changes. */ function collapseContext(lines: DiffLine[], ctx = 5): DiffLine[] { const changed = new Set(); lines.forEach((l, i) => { if (l.kind !== 'ctx') { for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) changed.add(j); } }); const result: DiffLine[] = []; let skipped = 0; lines.forEach((l, i) => { if (changed.has(i)) { if (skipped > 0) { result.push({ kind: 'ctx', text: `⋯ ${skipped} unchanged lines`, oldN: 0, newN: 0 }); skipped = 0; } result.push(l); } else { skipped++; } }); if (skipped > 0) result.push({ kind: 'ctx', text: `⋯ ${skipped} unchanged lines`, oldN: 0, newN: 0 }); return result; } // ── Fetch file content ──────────────────────────────────────────────────────── async function fetchFile(owner: string, repoSlug: string, ref: string, path: string): Promise { try { const resp = await fetch(`/${owner}/${repoSlug}/raw/${ref}/${path}`); if (!resp.ok) return null; return await resp.text(); } catch { return null; } } // ── Symbol changes sidebar ──────────────────────────────────────────────────── function renderSymbolChanges(fileOp: FileOp): string { if (!fileOp.child_ops?.length) return ''; const rows = fileOp.child_ops.map(sym => { const addr = sym.address.includes('::') ? sym.address.split('::').slice(1).join('::') : sym.address; const isChild = addr.includes('.'); const label = isChild ? addr.split('.').pop()! : addr; const indent = isChild ? 'df3-sym-child' : 'df3-sym-top'; const desc = sym.content_summary ? sym.content_summary.replace(/^(added |modified |removed )/, '') : ''; return `
${isChild ? '' : ''} ${opSign(sym.op)} ${esc(label)} ${kindChip(sym.content_summary || '')} ${desc ? `${esc(desc)}` : ''}
`; }).join(''); const total = fileOp.child_ops.length; return `
Symbols ${total} change${total !== 1 ? 's' : ''}
${rows}
`; } // ── File card ───────────────────────────────────────────────────────────────── async function renderFileCard( path: string, fileType: 'added' | 'modified' | 'removed', pd: PageData, fileOp: FileOp | null, ): Promise { const lang = extToLang(path); const ext = path.split('.').pop()?.toLowerCase() ?? ''; const fname = path.split('/').pop() ?? path; const opLabel = fileType === 'added' ? 'added' : fileType === 'removed' ? 'removed' : 'modified'; const opDotCls = fileType === 'added' ? 'df3-op-add' : fileType === 'removed' ? 'df3-op-del' : 'df3-op-mod'; const opSign2 = fileType === 'added' ? '+' : fileType === 'removed' ? '−' : '~'; const blobUrl = `${pd.baseUrl}/blob/${pd.shortId}/${path}`; const rawUrl = `/${pd.owner}/${pd.repoSlug}/raw/${pd.commitId}/${path}`; // Fetch file content let tableHtml = ''; let lineStats = ''; if (fileType === 'added') { const text = await fetchFile(pd.owner, pd.repoSlug, pd.commitId, path); if (text !== null) { const lines = text.split('\n'); const hl = highlight(text, lang).split('\n'); const rows = lines.map((_, i) => `+${i + 1}${hl[i] ?? ''}` ).join(''); tableHtml = `${rows}
`; lineStats = `+${lines.length} lines`; } } else if (fileType === 'removed') { const ref = pd.parentId ?? pd.commitId; const text = await fetchFile(pd.owner, pd.repoSlug, ref, path); if (text !== null) { const lines = text.split('\n'); const hl = highlight(text, lang).split('\n'); const rows = lines.map((_, i) => `−${i + 1}${hl[i] ?? ''}` ).join(''); tableHtml = `${rows}
`; lineStats = `−${lines.length} lines`; } } else { // Modified: compute real line diff const [oldText, newText] = await Promise.all([ fetchFile(pd.owner, pd.repoSlug, pd.parentId ?? pd.commitId, path), fetchFile(pd.owner, pd.repoSlug, pd.commitId, path), ]); if (oldText !== null && newText !== null) { const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); const rawDiff = diffLines(oldLines, newLines); const collapsed = collapseContext(rawDiff); const added = rawDiff.filter(l => l.kind === 'add').length; const removed = rawDiff.filter(l => l.kind === 'del').length; lineStats = `+${added} −${removed}`; tableHtml = renderDiffTable(collapsed, lang); } } const symHtml = fileOp ? renderSymbolChanges(fileOp) : ''; return `
${opSign2} ${esc(path)} ${ext ? `.${esc(ext)}` : ''}
${lineStats ? `${lineStats}` : ''} Raw ↗
${symHtml} ${tableHtml ? `
${tableHtml}
` : '

Could not load file content.

'}
`; } // ── Stats bar ───────────────────────────────────────────────────────────────── function renderStatsBar(pd: PageData): string { const { snapshotDiff: sd, structuredDelta: delta } = pd; let symAdded = 0, symModified = 0, symRemoved = 0; if (delta) { for (const fop of delta.ops ?? []) { for (const cop of fop.child_ops ?? []) { if (cop.op === 'insert') symAdded++; else if (cop.op === 'delete') symRemoved++; else symModified++; } } } const filesChanged = sd.added.length + sd.modified.length + sd.removed.length; const isRoot = !pd.parentId; const stats: string[] = []; if (symAdded) stats.push(`
+${symAdded}
symbol${symAdded !== 1 ? 's' : ''} added
`); if (symModified) stats.push(`
~${symModified}
symbol${symModified !== 1 ? 's' : ''} modified
`); if (symRemoved) stats.push(`
−${symRemoved}
symbol${symRemoved !== 1 ? 's' : ''} removed
`); if (filesChanged) stats.push(`
${filesChanged}
file${filesChanged !== 1 ? 's' : ''} changed
`); if (sd.total_files) stats.push(`
${sd.total_files}
in snapshot
`); if (symAdded + symModified + symRemoved > 0) stats.push(`
0
dead code
`); const parentHtml = isRoot ? `root commit` : `vs parent ${(pd.parentId ?? '').slice(0, 8)}`; return `
${stats.join('')}
${parentHtml}
`; } // ── Entry point ─────────────────────────────────────────────────────────────── export async function initDiff(): Promise { const el = document.getElementById('page-data'); if (!el) return; let pd: PageData; try { pd = JSON.parse(el.textContent ?? '{}') as PageData; } catch { return; } if (pd.page !== 'diff') return; const container = document.getElementById('df3-content'); if (!container) return; const { snapshotDiff: sd, structuredDelta: delta } = pd; const allFiles: Array<[string, 'added' | 'modified' | 'removed']> = [ ...sd.added.map(p => [p, 'added'] as [string, 'added']), ...sd.modified.map(p => [p, 'modified'] as [string, 'modified']), ...sd.removed.map(p => [p, 'removed'] as [string, 'removed']), ]; if (allFiles.length === 0) { container.innerHTML = `

No file changes in this commit.

`; return; } // Build lookup: file path → FileOp from structured delta const deltaByFile = new Map(); for (const fop of delta?.ops ?? []) { deltaByFile.set(fop.address, fop); } // Render stats bar immediately container.innerHTML = renderStatsBar(pd) + `
Loading file diffs…
`; // Render file cards async (in parallel, then stitch in order) const filesDiv = document.getElementById('df3-files'); if (!filesDiv) return; const cards = await Promise.all( allFiles.map(([path, ft]) => renderFileCard(path, ft, pd, deltaByFile.get(path) ?? null)) ); filesDiv.innerHTML = cards.join(''); }