gabriel / musehub public
diff.ts typescript
460 lines 18.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 /**
2 * diff.ts — Muse semantic diff page.
3 *
4 * Unlike Git, Muse knows *what semantically changed* (structured_delta) and
5 * *which files truly changed* (snapshot_diff). We use both to render:
6 *
7 * 1. A stats bar — symbol counts, file counts (things Git cannot show)
8 * 2. Per-file cards — semantic symbol changes above the line diff
9 * 3. Real line diff — comparing parent vs current content, not just dumping
10 * the whole file green
11 */
12
13 import hljs from 'highlight.js/lib/core';
14 import { extToLang } from '../lang-detect.ts';
15 import python from 'highlight.js/lib/languages/python';
16 import typescript from 'highlight.js/lib/languages/typescript';
17 import javascript from 'highlight.js/lib/languages/javascript';
18 import rust from 'highlight.js/lib/languages/rust';
19 import go from 'highlight.js/lib/languages/go';
20 import swift from 'highlight.js/lib/languages/swift';
21 import kotlin from 'highlight.js/lib/languages/kotlin';
22 import java from 'highlight.js/lib/languages/java';
23 import ruby from 'highlight.js/lib/languages/ruby';
24 import cpp from 'highlight.js/lib/languages/cpp';
25 import json from 'highlight.js/lib/languages/json';
26 import yaml from 'highlight.js/lib/languages/yaml';
27 import toml from 'highlight.js/lib/languages/ini';
28 import bash from 'highlight.js/lib/languages/bash';
29 import xml from 'highlight.js/lib/languages/xml';
30 import css from 'highlight.js/lib/languages/css';
31 import sql from 'highlight.js/lib/languages/sql';
32 import markdown from 'highlight.js/lib/languages/markdown';
33 import plaintext from 'highlight.js/lib/languages/plaintext';
34
35 hljs.registerLanguage('python', python);
36 hljs.registerLanguage('typescript', typescript);
37 hljs.registerLanguage('javascript', javascript);
38 hljs.registerLanguage('rust', rust);
39 hljs.registerLanguage('go', go);
40 hljs.registerLanguage('swift', swift);
41 hljs.registerLanguage('kotlin', kotlin);
42 hljs.registerLanguage('java', java);
43 hljs.registerLanguage('ruby', ruby);
44 hljs.registerLanguage('cpp', cpp);
45 hljs.registerLanguage('json', json);
46 hljs.registerLanguage('yaml', yaml);
47 hljs.registerLanguage('toml', toml);
48 hljs.registerLanguage('bash', bash);
49 hljs.registerLanguage('xml', xml);
50 hljs.registerLanguage('css', css);
51 hljs.registerLanguage('sql', sql);
52 hljs.registerLanguage('markdown', markdown);
53 hljs.registerLanguage('plaintext', plaintext);
54
55 // ── Types ─────────────────────────────────────────────────────────────────────
56
57 interface ChildOp {
58 address: string;
59 op: string;
60 content_summary: string;
61 }
62
63 interface FileOp {
64 address: string;
65 op: string;
66 child_summary: string;
67 child_ops: ChildOp[];
68 }
69
70 interface StructuredDelta {
71 domain: string;
72 ops: FileOp[];
73 }
74
75 interface SnapshotDiff {
76 added: string[];
77 modified: string[];
78 removed: string[];
79 total_files: number;
80 }
81
82 interface CommitData {
83 commitId: string;
84 message: string;
85 author: string;
86 timestamp: string;
87 branch: string;
88 parentIds: string[];
89 }
90
91 interface PageData {
92 page: string;
93 repoId: string;
94 commitId: string;
95 shortId: string;
96 owner: string;
97 repoSlug: string;
98 baseUrl: string;
99 parentId: string | null;
100 structuredDelta: StructuredDelta | null;
101 snapshotDiff: SnapshotDiff;
102 commit: CommitData | null;
103 viewerType: string;
104 domainName: string;
105 }
106
107 // ── Helpers ───────────────────────────────────────────────────────────────────
108
109 function esc(s: string): string {
110 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
111 }
112
113
114 function highlight(code: string, lang: string): string {
115 try {
116 return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
117 } catch {
118 return esc(code);
119 }
120 }
121
122 /** Determine op class from operation string. */
123 function opClass(op: string): string {
124 if (op === 'insert') return 'df3-op-add';
125 if (op === 'delete') return 'df3-op-del';
126 return 'df3-op-mod';
127 }
128
129 function opSign(op: string): string {
130 if (op === 'insert') return '+';
131 if (op === 'delete') return '−';
132 return '~';
133 }
134
135 /** Detect kind from content_summary string. */
136 function kindChip(summary: string): string {
137 const s = summary.toLowerCase();
138 if (s.includes('class')) return '<span class="df3-kind df3-k-class">class</span>';
139 if (s.includes('method')) return '<span class="df3-kind df3-k-method">method</span>';
140 if (s.includes('function') || s.includes('func')) return '<span class="df3-kind df3-k-func">func</span>';
141 if (s.includes('import')) return '<span class="df3-kind df3-k-import">import</span>';
142 if (s.includes('variable') || s.includes('constant')) return '<span class="df3-kind df3-k-var">var</span>';
143 return '';
144 }
145
146 // ── Myers line diff ───────────────────────────────────────────────────────────
147 // Simple O(ND) diff — sufficient for typical commit sizes.
148
149 type LineKind = 'add' | 'del' | 'ctx';
150 interface DiffLine { kind: LineKind; text: string; oldN: number; newN: number; }
151
152 function diffLines(oldLines: string[], newLines: string[]): DiffLine[] {
153 const m = oldLines.length, n = newLines.length;
154 const max = m + n;
155 const v: number[] = new Array(2 * max + 1).fill(0);
156 const trace: number[][] = [];
157
158 outer: for (let d = 0; d <= max; d++) {
159 trace.push([...v]);
160 for (let k = -d; k <= d; k += 2) {
161 const ki = k + max;
162 let x: number;
163 if (k === -d || (k !== d && v[ki - 1] < v[ki + 1])) {
164 x = v[ki + 1];
165 } else {
166 x = v[ki - 1] + 1;
167 }
168 let y = x - k;
169 while (x < m && y < n && oldLines[x] === newLines[y]) { x++; y++; }
170 v[ki] = x;
171 if (x >= m && y >= n) break outer;
172 }
173 }
174
175 // Backtrack
176 const result: DiffLine[] = [];
177 let x = m, y = n;
178 for (let d = trace.length - 1; d >= 0; d--) {
179 const vd = trace[d];
180 const k = x - y;
181 const ki = k + max;
182 const prevK = (k === -d || (k !== d && vd[ki - 1] < vd[ki + 1])) ? k + 1 : k - 1;
183 const prevX = vd[prevK + max];
184 const prevY = prevX - prevK;
185 while (x > prevX && y > prevY) {
186 result.unshift({ kind: 'ctx', text: oldLines[x - 1], oldN: x, newN: y });
187 x--; y--;
188 }
189 if (d > 0) {
190 if (x === prevX) {
191 result.unshift({ kind: 'add', text: newLines[y - 1], oldN: 0, newN: y });
192 y--;
193 } else {
194 result.unshift({ kind: 'del', text: oldLines[x - 1], oldN: x, newN: 0 });
195 x--;
196 }
197 }
198 }
199 return result;
200 }
201
202 /** Render a diff as an HTML table with syntax-highlighted tokens. */
203 function renderDiffTable(lines: DiffLine[], lang: string): string {
204 // Highlight the full old and new texts then split, to preserve multi-line spans.
205 const oldText = lines.filter(l => l.kind !== 'add').map(l => l.text).join('\n');
206 const newText = lines.filter(l => l.kind !== 'del').map(l => l.text).join('\n');
207 const oldHl = highlight(oldText, lang).split('\n');
208 const newHl = highlight(newText, lang).split('\n');
209
210 let oldI = 0, newI = 0;
211 const rows = lines.map(l => {
212 let hlCode: string;
213 let rowCls: string;
214 let sign: string;
215 if (l.kind === 'del') {
216 hlCode = oldHl[oldI++] ?? esc(l.text);
217 rowCls = 'df3-dl-del'; sign = '−';
218 } else if (l.kind === 'add') {
219 hlCode = newHl[newI++] ?? esc(l.text);
220 rowCls = 'df3-dl-add'; sign = '+';
221 } else {
222 hlCode = oldHl[oldI++] ?? esc(l.text); newI++;
223 rowCls = 'df3-dl-ctx'; sign = ' ';
224 }
225 const ln = l.kind === 'del' ? l.oldN : l.kind === 'add' ? l.newN : l.oldN;
226 return `<tr class="${rowCls}"><td class="df3-ln-sign">${sign}</td><td class="df3-ln-num">${ln}</td><td class="df3-ln-code"><span>${hlCode}</span></td></tr>`;
227 });
228 return `<table class="df3-table hljs"><tbody>${rows.join('')}</tbody></table>`;
229 }
230
231 /** Collapse context lines — show only ±5 lines around changes. */
232 function collapseContext(lines: DiffLine[], ctx = 5): DiffLine[] {
233 const changed = new Set<number>();
234 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); } });
235 const result: DiffLine[] = [];
236 let skipped = 0;
237 lines.forEach((l, i) => {
238 if (changed.has(i)) {
239 if (skipped > 0) {
240 result.push({ kind: 'ctx', text: `⋯ ${skipped} unchanged lines`, oldN: 0, newN: 0 });
241 skipped = 0;
242 }
243 result.push(l);
244 } else {
245 skipped++;
246 }
247 });
248 if (skipped > 0) result.push({ kind: 'ctx', text: `⋯ ${skipped} unchanged lines`, oldN: 0, newN: 0 });
249 return result;
250 }
251
252 // ── Fetch file content ────────────────────────────────────────────────────────
253
254 async function fetchFile(owner: string, repoSlug: string, ref: string, path: string): Promise<string | null> {
255 try {
256 const resp = await fetch(`/${owner}/${repoSlug}/raw/${ref}/${path}`);
257 if (!resp.ok) return null;
258 return await resp.text();
259 } catch {
260 return null;
261 }
262 }
263
264 // ── Symbol changes sidebar ────────────────────────────────────────────────────
265
266 function renderSymbolChanges(fileOp: FileOp): string {
267 if (!fileOp.child_ops?.length) return '';
268
269 const rows = fileOp.child_ops.map(sym => {
270 const addr = sym.address.includes('::') ? sym.address.split('::').slice(1).join('::') : sym.address;
271 const isChild = addr.includes('.');
272 const label = isChild ? addr.split('.').pop()! : addr;
273 const indent = isChild ? 'df3-sym-child' : 'df3-sym-top';
274 const desc = sym.content_summary
275 ? sym.content_summary.replace(/^(added |modified |removed )/, '')
276 : '';
277 return `
278 <div class="df3-sym-row ${indent}">
279 ${isChild ? '<span class="df3-sym-indent"><svg width="8" height="8" aria-hidden="true"><use href="#icon-corner-right-down"></use></svg></span>' : ''}
280 <span class="df3-sym-dot ${opClass(sym.op)}">${opSign(sym.op)}</span>
281 <span class="df3-sym-name">${esc(label)}</span>
282 ${kindChip(sym.content_summary || '')}
283 ${desc ? `<span class="df3-sym-desc">${esc(desc)}</span>` : ''}
284 </div>`;
285 }).join('');
286
287 const total = fileOp.child_ops.length;
288 return `
289 <div class="df3-sym-panel">
290 <div class="df3-sym-hd">
291 <svg width="11" height="11" style="color:var(--color-accent)" aria-hidden="true"><use href="#icon-code"></use></svg>
292 <span class="df3-sym-title">Symbols</span>
293 <span class="df3-sym-count">${total} change${total !== 1 ? 's' : ''}</span>
294 </div>
295 <div class="df3-sym-body">${rows}</div>
296 </div>`;
297 }
298
299 // ── File card ─────────────────────────────────────────────────────────────────
300
301 async function renderFileCard(
302 path: string,
303 fileType: 'added' | 'modified' | 'removed',
304 pd: PageData,
305 fileOp: FileOp | null,
306 ): Promise<string> {
307 const lang = extToLang(path);
308 const ext = path.split('.').pop()?.toLowerCase() ?? '';
309 const fname = path.split('/').pop() ?? path;
310 const opLabel = fileType === 'added' ? 'added' : fileType === 'removed' ? 'removed' : 'modified';
311 const opDotCls = fileType === 'added' ? 'df3-op-add' : fileType === 'removed' ? 'df3-op-del' : 'df3-op-mod';
312 const opSign2 = fileType === 'added' ? '+' : fileType === 'removed' ? '−' : '~';
313 const blobUrl = `${pd.baseUrl}/blob/${pd.shortId}/${path}`;
314 const rawUrl = `/${pd.owner}/${pd.repoSlug}/raw/${pd.commitId}/${path}`;
315
316 // Fetch file content
317 let tableHtml = '';
318 let lineStats = '';
319
320 if (fileType === 'added') {
321 const text = await fetchFile(pd.owner, pd.repoSlug, pd.commitId, path);
322 if (text !== null) {
323 const lines = text.split('\n');
324 const hl = highlight(text, lang).split('\n');
325 const rows = lines.map((_, i) =>
326 `<tr class="df3-dl-add"><td class="df3-ln-sign">+</td><td class="df3-ln-num">${i + 1}</td><td class="df3-ln-code"><span>${hl[i] ?? ''}</span></td></tr>`
327 ).join('');
328 tableHtml = `<table class="df3-table hljs"><tbody>${rows}</tbody></table>`;
329 lineStats = `+${lines.length} lines`;
330 }
331 } else if (fileType === 'removed') {
332 const ref = pd.parentId ?? pd.commitId;
333 const text = await fetchFile(pd.owner, pd.repoSlug, ref, path);
334 if (text !== null) {
335 const lines = text.split('\n');
336 const hl = highlight(text, lang).split('\n');
337 const rows = lines.map((_, i) =>
338 `<tr class="df3-dl-del"><td class="df3-ln-sign">−</td><td class="df3-ln-num">${i + 1}</td><td class="df3-ln-code"><span>${hl[i] ?? ''}</span></td></tr>`
339 ).join('');
340 tableHtml = `<table class="df3-table hljs"><tbody>${rows}</tbody></table>`;
341 lineStats = `−${lines.length} lines`;
342 }
343 } else {
344 // Modified: compute real line diff
345 const [oldText, newText] = await Promise.all([
346 fetchFile(pd.owner, pd.repoSlug, pd.parentId ?? pd.commitId, path),
347 fetchFile(pd.owner, pd.repoSlug, pd.commitId, path),
348 ]);
349 if (oldText !== null && newText !== null) {
350 const oldLines = oldText.split('\n');
351 const newLines = newText.split('\n');
352 const rawDiff = diffLines(oldLines, newLines);
353 const collapsed = collapseContext(rawDiff);
354 const added = rawDiff.filter(l => l.kind === 'add').length;
355 const removed = rawDiff.filter(l => l.kind === 'del').length;
356 lineStats = `<span class="df3-stat-add">+${added}</span> <span class="df3-stat-del">−${removed}</span>`;
357 tableHtml = renderDiffTable(collapsed, lang);
358 }
359 }
360
361 const symHtml = fileOp ? renderSymbolChanges(fileOp) : '';
362
363 return `
364 <div class="df3-file-card df3-file-${fileType}" id="df3-file-${CSS.escape(path)}">
365 <div class="df3-file-hd">
366 <span class="df3-op-dot ${opDotCls}">${opSign2}</span>
367 <a href="${blobUrl}" class="df3-file-path">${esc(path)}</a>
368 ${ext ? `<span class="df3-ext">.${esc(ext)}</span>` : ''}
369 <div class="df3-file-hd-right">
370 ${lineStats ? `<span class="df3-line-stat">${lineStats}</span>` : ''}
371 <a href="${rawUrl}" class="df3-raw-link" target="_blank" rel="noopener">Raw ↗</a>
372 </div>
373 </div>
374 ${symHtml}
375 ${tableHtml ? `<div class="df3-code-wrap">${tableHtml}</div>` : '<p class="df3-no-content">Could not load file content.</p>'}
376 </div>`;
377 }
378
379 // ── Stats bar ─────────────────────────────────────────────────────────────────
380
381 function renderStatsBar(pd: PageData): string {
382 const { snapshotDiff: sd, structuredDelta: delta } = pd;
383
384 let symAdded = 0, symModified = 0, symRemoved = 0;
385 if (delta) {
386 for (const fop of delta.ops ?? []) {
387 for (const cop of fop.child_ops ?? []) {
388 if (cop.op === 'insert') symAdded++;
389 else if (cop.op === 'delete') symRemoved++;
390 else symModified++;
391 }
392 }
393 }
394
395 const filesChanged = sd.added.length + sd.modified.length + sd.removed.length;
396 const isRoot = !pd.parentId;
397
398 const stats: string[] = [];
399 if (symAdded) stats.push(`<div class="df3-stat df3-stat-add"><div class="df3-stat-n">+${symAdded}</div><div class="df3-stat-l">symbol${symAdded !== 1 ? 's' : ''} added</div></div>`);
400 if (symModified) stats.push(`<div class="df3-stat df3-stat-mod"><div class="df3-stat-n">~${symModified}</div><div class="df3-stat-l">symbol${symModified !== 1 ? 's' : ''} modified</div></div>`);
401 if (symRemoved) stats.push(`<div class="df3-stat df3-stat-del"><div class="df3-stat-n">−${symRemoved}</div><div class="df3-stat-l">symbol${symRemoved !== 1 ? 's' : ''} removed</div></div>`);
402 if (filesChanged) stats.push(`<div class="df3-stat df3-stat-files"><div class="df3-stat-n">${filesChanged}</div><div class="df3-stat-l">file${filesChanged !== 1 ? 's' : ''} changed</div></div>`);
403 if (sd.total_files) stats.push(`<div class="df3-stat df3-stat-snap"><div class="df3-stat-n">${sd.total_files}</div><div class="df3-stat-l">in snapshot</div></div>`);
404 if (symAdded + symModified + symRemoved > 0) stats.push(`<div class="df3-stat df3-stat-clean"><div class="df3-stat-n">0</div><div class="df3-stat-l">dead code</div></div>`);
405
406 const parentHtml = isRoot
407 ? `<span class="df3-root-pill">root commit</span>`
408 : `<span class="df3-vs">vs parent <a href="${pd.baseUrl}/commits/${pd.parentId}" class="df3-parent-sha">${(pd.parentId ?? '').slice(0, 8)}</a></span>`;
409
410 return `
411 <div class="df3-stats-bar">
412 <div class="df3-stats-row">${stats.join('')}</div>
413 <div class="df3-stats-meta">${parentHtml}</div>
414 </div>`;
415 }
416
417 // ── Entry point ───────────────────────────────────────────────────────────────
418
419 export async function initDiff(): Promise<void> {
420 const el = document.getElementById('page-data');
421 if (!el) return;
422 let pd: PageData;
423 try { pd = JSON.parse(el.textContent ?? '{}') as PageData; } catch { return; }
424 if (pd.page !== 'diff') return;
425
426 const container = document.getElementById('df3-content');
427 if (!container) return;
428
429 const { snapshotDiff: sd, structuredDelta: delta } = pd;
430 const allFiles: Array<[string, 'added' | 'modified' | 'removed']> = [
431 ...sd.added.map(p => [p, 'added'] as [string, 'added']),
432 ...sd.modified.map(p => [p, 'modified'] as [string, 'modified']),
433 ...sd.removed.map(p => [p, 'removed'] as [string, 'removed']),
434 ];
435
436 if (allFiles.length === 0) {
437 container.innerHTML = `<p class="df3-empty">No file changes in this commit.</p>`;
438 return;
439 }
440
441 // Build lookup: file path → FileOp from structured delta
442 const deltaByFile = new Map<string, FileOp>();
443 for (const fop of delta?.ops ?? []) {
444 deltaByFile.set(fop.address, fop);
445 }
446
447 // Render stats bar immediately
448 container.innerHTML = renderStatsBar(pd) +
449 `<div id="df3-files" class="df3-files"><div class="df3-loading"><span class="spinner"></span> Loading file diffs…</div></div>`;
450
451 // Render file cards async (in parallel, then stitch in order)
452 const filesDiv = document.getElementById('df3-files');
453 if (!filesDiv) return;
454
455 const cards = await Promise.all(
456 allFiles.map(([path, ft]) => renderFileCard(path, ft, pd, deltaByFile.get(path) ?? null))
457 );
458
459 filesDiv.innerHTML = cards.join('');
460 }
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago