gabriel / musehub public
blob.ts typescript
561 lines 23.3 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 /**
2 * blob.ts — File blob viewer with syntax highlighting.
3 *
4 * Two rendering paths:
5 * 1. SSR path: server renders line-numbered table; we post-process with hljs.
6 * 2. Client path: we fetch via API and render with hljs ourselves.
7 *
8 * highlight.js is imported à la carte (same set as diff.ts) to keep the
9 * bundle lean — no auto-detection, language resolved from file extension.
10 */
11
12 import hljs from 'highlight.js/lib/core';
13 import { extToLang } from '../lang-detect.ts';
14
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 haskell from 'highlight.js/lib/languages/haskell';
26 import json from 'highlight.js/lib/languages/json';
27 import yaml from 'highlight.js/lib/languages/yaml';
28 import toml from 'highlight.js/lib/languages/ini';
29 import bash from 'highlight.js/lib/languages/bash';
30 import xml from 'highlight.js/lib/languages/xml';
31 import css from 'highlight.js/lib/languages/css';
32 import sql from 'highlight.js/lib/languages/sql';
33 import markdown from 'highlight.js/lib/languages/markdown';
34 import plaintext from 'highlight.js/lib/languages/plaintext';
35
36 hljs.registerLanguage('python', python);
37 hljs.registerLanguage('typescript', typescript);
38 hljs.registerLanguage('javascript', javascript);
39 hljs.registerLanguage('rust', rust);
40 hljs.registerLanguage('go', go);
41 hljs.registerLanguage('swift', swift);
42 hljs.registerLanguage('kotlin', kotlin);
43 hljs.registerLanguage('java', java);
44 hljs.registerLanguage('ruby', ruby);
45 hljs.registerLanguage('cpp', cpp);
46 hljs.registerLanguage('haskell', haskell);
47 hljs.registerLanguage('json', json);
48 hljs.registerLanguage('yaml', yaml);
49 hljs.registerLanguage('toml', toml);
50 hljs.registerLanguage('bash', bash);
51 hljs.registerLanguage('xml', xml);
52 hljs.registerLanguage('css', css);
53 hljs.registerLanguage('sql', sql);
54 hljs.registerLanguage('markdown', markdown);
55 hljs.registerLanguage('plaintext', plaintext);
56
57 // ── Types ─────────────────────────────────────────────────────────────────────
58
59 // Map of symbol display name → [startLine, endLine], populated from page_json.
60 // Used to resolve #S:SymbolName hash fragments to #Lstart-Lend range anchors.
61 type SymbolLineMap = Record<string, [number, number]>;
62
63 interface BlobCfg {
64 repoId: string;
65 ref: string;
66 filePath: string;
67 filename: string;
68 owner: string;
69 repoSlug: string;
70 base: string;
71 ssrBlobRendered: boolean;
72 hasOutline: boolean;
73 symbolLines: SymbolLineMap;
74 }
75
76 interface BlobData {
77 rawUrl?: string;
78 fileType?: string;
79 filename?: string;
80 sizeBytes?: number;
81 sha?: string;
82 createdAt?: string;
83 contentText?: string;
84 }
85
86 declare const escHtml: (s: unknown) => string;
87
88 // ── Core highlighter ──────────────────────────────────────────────────────────
89
90 /**
91 * Highlight `code` with hljs and return an array of HTML strings, one per
92 * line. We highlight the whole file at once so multi-line tokens (strings,
93 * comments, template literals) are coloured correctly, then split on newlines.
94 */
95 function highlightLines(code: string, lang: string): string[] {
96 let highlighted: string;
97 try {
98 highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
99 } catch {
100 highlighted = escHtml(code);
101 }
102 const lines = highlighted.split('\n');
103 // hljs adds a trailing \n which produces a spurious empty last element
104 if (lines.length && lines[lines.length - 1] === '') lines.pop();
105 return lines;
106 }
107
108 // ── SSR post-process — apply hljs to the server-rendered table ────────────────
109
110 /**
111 * The SSR blob template renders each line as a plain `<td class="blob-code">`.
112 * We re-collect all plain-text lines, highlight the full source, then inject
113 * the coloured HTML back cell by cell. This preserves the SSR line-number
114 * anchors (`id="L1"` etc.) while adding colour.
115 */
116 function applySsrHighlighting(filename: string): void {
117 // Support both old class names (blob-*) and new blob2-* names
118 const cells = Array.from(
119 document.querySelectorAll<HTMLTableCellElement>(
120 '.blob2-line-table td.blob2-code, .blob-line-table td.blob-code',
121 ),
122 );
123 if (cells.length === 0) return;
124
125 const lang = extToLang(filename);
126 if (lang === 'plaintext') return; // nothing useful to highlight
127
128 // Re-collect raw text: textContent strips any existing spans safely
129 const rawLines = cells.map(td => td.textContent ?? '');
130 const fullSrc = rawLines.join('\n');
131
132 const coloredLines = highlightLines(fullSrc, lang);
133
134 cells.forEach((td, i) => {
135 // Use innerHTML — the hljs output is safe HTML with <span> tokens only
136 td.innerHTML = coloredLines[i] ?? td.innerHTML;
137 td.classList.add('hljs');
138 });
139
140 // Mark the table so CSS can apply the hljs theme background
141 (document.querySelector('.blob2-line-table') ?? document.querySelector('.blob-line-table'))
142 ?.classList.add('hljs');
143 }
144
145 // ── Utility helpers ───────────────────────────────────────────────────────────
146
147 function fmtSize(bytes: number | null | undefined): string {
148 if (bytes == null) return '';
149 if (bytes < 1024) return bytes + '\u00a0B';
150 if (bytes < 1048576) return (bytes / 1024).toFixed(1) + '\u00a0KB';
151 return (bytes / 1048576).toFixed(1) + '\u00a0MB';
152 }
153
154 function fmtDate(iso: string | undefined): string {
155 if (!iso) return '';
156 try {
157 return new Date(iso).toLocaleDateString(undefined, {
158 year: 'numeric', month: 'short', day: 'numeric',
159 });
160 } catch { return iso; }
161 }
162
163 function shortSha(sha: string): string {
164 const idx = sha.indexOf(':');
165 const hex = idx >= 0 ? sha.slice(idx + 1) : sha;
166 return hex.slice(0, 12);
167 }
168
169 // ── Hex dump ──────────────────────────────────────────────────────────────────
170
171 function renderHexDump(arrayBuffer: ArrayBuffer): string {
172 const bytes = new Uint8Array(arrayBuffer);
173 const limit = Math.min(bytes.length, 512);
174 let out = '';
175 for (let i = 0; i < limit; i += 16) {
176 const chunk = bytes.slice(i, i + 16);
177 const offset = i.toString(16).padStart(8, '0');
178 const hexPart = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ').padEnd(47, ' ');
179 const asciiPart = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join('');
180 out += '<span class="hex-offset">' + offset + '</span>'
181 + '<span class="hex-bytes">' + escHtml(hexPart) + '</span>'
182 + '<span class="hex-ascii">' + escHtml(asciiPart) + '</span>\n';
183 }
184 return out;
185 }
186
187 // ── Action buttons ────────────────────────────────────────────────────────────
188
189 function buildActions(cfg: BlobCfg, data: BlobData, rawUrl: string): string {
190 let actions = '<a class="btn-blob btn-blob-secondary" href="' + escHtml(rawUrl) + '" download>'
191 + '⬇&#65039;&nbsp;Raw</a>';
192 if (data.fileType === 'midi') {
193 const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
194 actions += '&nbsp;<a class="btn-blob btn-blob-primary" href="' + escHtml(rollUrl) + '">'
195 + '🎹&nbsp;View in Piano Roll</a>';
196 } else if (data.fileType === 'audio') {
197 const listenUrl = cfg.base + '/listen/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
198 actions += '&nbsp;<a class="btn-blob btn-blob-primary" href="' + escHtml(listenUrl) + '">'
199 + '🎵&nbsp;Listen</a>';
200 }
201 return actions;
202 }
203
204 // ── Render highlighted code table (client-side path) ─────────────────────────
205
206 function buildCodeTable(code: string, lang: string): string {
207 const lines = highlightLines(code, lang);
208 const rows = lines.map((html, i) =>
209 `<tr id="L${i + 1}" class="blob2-line">`
210 + `<td class="blob2-ln"><a href="#L${i + 1}" class="blob2-ln-link" data-line="${i + 1}">${i + 1}</a></td>`
211 + `<td class="blob2-code hljs">${html}</td>`
212 + `</tr>`,
213 ).join('');
214 return `<div class="blob-viewer"><table class="blob2-line-table hljs"><tbody>${rows}</tbody></table></div>`;
215 }
216
217 // ── Body renderer (client-side fetch path) ────────────────────────────────────
218
219 function renderBlobBody(cfg: BlobCfg, data: BlobData, rawUrl: string): string | null {
220 const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
221
222 switch (data.fileType) {
223 case 'midi':
224 return '<div class="blob-midi-banner">'
225 + '<span class="blob-midi-icon">🎹</span>'
226 + '<div class="blob-midi-title">' + escHtml(data.filename ?? cfg.filename) + '</div>'
227 + '<div class="blob-midi-sub">MIDI file</div>'
228 + '<a class="btn-blob btn-blob-primary" href="' + escHtml(rollUrl) + '">🎹&nbsp;View in Piano Roll</a>'
229 + '</div>';
230 case 'audio':
231 return '<div class="blob-audio-wrap">'
232 + '<span class="blob-audio-icon">🎵</span>'
233 + '<div class="blob-audio-name">' + escHtml(data.filename ?? cfg.filename) + '</div>'
234 + '<audio class="blob-audio-player" controls preload="metadata" src="' + escHtml(rawUrl) + '">'
235 + 'Your browser does not support audio. <a href="' + escHtml(rawUrl) + '">Download</a></audio></div>';
236 case 'image':
237 return '<div class="blob-img-wrap">'
238 + '<img class="blob-img" src="' + escHtml(rawUrl) + '" alt="' + escHtml(data.filename ?? cfg.filename) + '">'
239 + '</div>';
240 default:
241 if (data.contentText != null) {
242 const lang = extToLang(cfg.filename);
243 return buildCodeTable(data.contentText, lang);
244 }
245 return null; // binary → async hex dump
246 }
247 }
248
249 // ── Hex preview for binary/unknown files ──────────────────────────────────────
250
251 async function fetchHexPreview(rawUrl: string, bodyEl: HTMLElement, sizeBytes: number): Promise<void> {
252 try {
253 const resp = await fetch(rawUrl, { headers: { Range: 'bytes=0-511' } });
254 if (resp.ok || resp.status === 206) {
255 const buf = await resp.arrayBuffer();
256 bodyEl.innerHTML =
257 '<div class="blob-hex-wrap"><pre class="blob-hex">' + renderHexDump(buf) + '</pre></div>'
258 + '<div class="blob-binary-notice">Showing first ' + Math.min(512, sizeBytes)
259 + ' bytes of ' + fmtSize(sizeBytes) + '. '
260 + '<a href="' + escHtml(rawUrl) + '" download>Download full file</a></div>';
261 } else {
262 bodyEl.innerHTML = '<div class="blob-binary-notice">Binary file — <a href="' + escHtml(rawUrl) + '" download>Download</a></div>';
263 }
264 } catch {
265 bodyEl.innerHTML = '<div class="blob-binary-notice">Binary file — <a href="' + escHtml(rawUrl) + '" download>Download</a></div>';
266 }
267 }
268
269 // ── Full client-side render ───────────────────────────────────────────────────
270
271 async function renderBlob(cfg: BlobCfg, data: BlobData): Promise<void> {
272 const rawUrl = data.rawUrl ?? (cfg.base + '/raw/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath);
273 const lang = extToLang(cfg.filename);
274
275 const metaHtml =
276 (data.sizeBytes != null ? '<span title="Size">📄&nbsp;' + fmtSize(data.sizeBytes) + '</span>' : '')
277 + (data.sha ? '<span title="SHA">🔑&nbsp;' + escHtml(shortSha(data.sha)) + '</span>' : '')
278 + (data.createdAt ? '<span title="Last pushed">📅&nbsp;' + escHtml(fmtDate(data.createdAt)) + '</span>' : '')
279 + (lang !== 'plaintext' ? '<span class="blob-lang-badge">' + escHtml(lang) + '</span>' : '');
280
281 const headerHtml =
282 '<div class="blob-header">'
283 + '<div class="blob-filename">📄&nbsp;<code>' + escHtml(data.filename ?? cfg.filename) + '</code></div>'
284 + '<div class="blob-meta">' + metaHtml + '</div>'
285 + '<div class="blob-actions">' + buildActions(cfg, data, rawUrl) + '</div>'
286 + '</div>';
287
288 const syncBody = renderBlobBody(cfg, data, rawUrl);
289 const bodyHtml = '<div class="blob-body" id="blob-body-inner">'
290 + (syncBody !== null ? syncBody : '<div class="blob-loading">Rendering…</div>')
291 + '</div>';
292
293 const contentEl = document.getElementById('content');
294 if (contentEl) contentEl.innerHTML = headerHtml + bodyHtml;
295
296 if (syncBody === null) {
297 const bodyEl = document.getElementById('blob-body-inner');
298 if (bodyEl) await fetchHexPreview(rawUrl, bodyEl, data.sizeBytes ?? 0);
299 }
300 }
301
302 // ── Load metadata from API (client path) ─────────────────────────────────────
303
304 async function loadBlob(cfg: BlobCfg): Promise<void> {
305 // SSR path: server already rendered the blob — add highlighting + line selection.
306 if (cfg.ssrBlobRendered && document.getElementById('blob-ssr-content')) {
307 applySsrHighlighting(cfg.filename);
308 initLineSelection();
309 initPermalinkButton();
310 if (cfg.hasOutline) initOutlinePanel();
311 initMarkdownAnchors();
312 resolveSymbolHash(cfg.symbolLines);
313 return;
314 }
315
316 // Client path: fetch metadata, then render.
317 const contentEl = document.getElementById('content');
318 if (!contentEl) return;
319 contentEl.innerHTML = '<div class="blob-loading">Loading…</div>';
320
321 try {
322 const headers: Record<string, string> = {};
323 const url = '/api/repos/' + cfg.repoId + '/blob/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
324 const resp = await fetch(url, { headers });
325
326 if (resp.status === 404) {
327 contentEl.innerHTML = '<div class="blob-error">❌ File not found: <code>' + escHtml(cfg.filePath) + '</code></div>';
328 return;
329 }
330 if (resp.status === 401) {
331 contentEl.innerHTML = '<div class="blob-error">🔒 Private repo — sign in to view this file.</div>';
332 return;
333 }
334 if (!resp.ok) {
335 contentEl.innerHTML = '<div class="blob-error">❌ Failed to load file (HTTP ' + resp.status + ').</div>';
336 return;
337 }
338
339 const data = await resp.json() as BlobData;
340 await renderBlob(cfg, data);
341 initLineSelection();
342 initPermalinkButton();
343 if (cfg.hasOutline) initOutlinePanel();
344 resolveSymbolHash(cfg.symbolLines);
345 } catch (err) {
346 const el = document.getElementById('content');
347 if (el) el.innerHTML = '<div class="blob-error">❌ ' + escHtml(String(err)) + '</div>';
348 }
349 }
350
351 // ── Phase 1: Multi-line selection & deep links ────────────────────────────────
352
353 /** Parse "#L10" or "#L10-L25" from location.hash → [start, end] (1-based, inclusive). */
354 function parseLineHash(): [number, number] | null {
355 const hash = location.hash;
356 const single = /^#L(\d+)$/.exec(hash);
357 if (single) { const n = parseInt(single[1], 10); return [n, n]; }
358 const range = /^#L(\d+)-L(\d+)$/.exec(hash);
359 if (range) return [parseInt(range[1], 10), parseInt(range[2], 10)];
360 return null;
361 }
362
363 /** Build URL hash string for a line range. */
364 function lineHash(start: number, end: number): string {
365 return start === end ? `#L${start}` : `#L${start}-L${end}`;
366 }
367
368 /** Apply/remove the selected class to rows in [start, end] (1-based). */
369 function applySelection(start: number, end: number): void {
370 const lo = Math.min(start, end);
371 const hi = Math.max(start, end);
372 document.querySelectorAll<HTMLTableRowElement>('tr.blob2-line').forEach(row => {
373 const id = parseInt(row.id.slice(1), 10); // "L12" → 12
374 if (id >= lo && id <= hi) {
375 row.classList.add('blob2-line--selected');
376 } else {
377 row.classList.remove('blob2-line--selected');
378 }
379 });
380 }
381
382 /** Show or hide the permalink float. */
383 function setPermalinkFloat(visible: boolean): void {
384 const el = document.getElementById('blob2-permalink-float');
385 if (!el) return;
386 if (visible) el.classList.add('is-visible');
387 else el.classList.remove('is-visible');
388 }
389
390 function initPermalinkButton(): void {
391 const btn = document.getElementById('blob2-permalink-btn');
392 if (!btn) return;
393 btn.addEventListener('click', () => {
394 void navigator.clipboard.writeText(location.href).then(() => {
395 const orig = btn.textContent ?? '';
396 btn.textContent = 'Copied!';
397 setTimeout(() => { btn.textContent = orig; }, 1500);
398 });
399 });
400 }
401
402 function initLineSelection(): void {
403 let anchorLine: number | null = null;
404
405 // Restore selection from URL on load.
406 const initial = parseLineHash();
407 if (initial) {
408 const [s, e] = initial;
409 anchorLine = s;
410 applySelection(s, e);
411 setPermalinkFloat(true);
412 // Scroll the anchor row into view.
413 const target = document.getElementById(`L${s}`);
414 if (target) target.scrollIntoView({ block: 'start' });
415 }
416
417 // Wire up line-number clicks.
418 document.querySelectorAll<HTMLAnchorElement>('a.blob2-ln-link').forEach(a => {
419 a.addEventListener('click', (e: MouseEvent) => {
420 e.preventDefault();
421 const line = parseInt(a.dataset['line'] ?? '0', 10);
422 if (!line) return;
423
424 if (e.shiftKey && anchorLine !== null) {
425 // Extend selection to range.
426 const lo = Math.min(anchorLine, line);
427 const hi = Math.max(anchorLine, line);
428 applySelection(lo, hi);
429 history.replaceState(null, '', lineHash(lo, hi));
430 setPermalinkFloat(true);
431 } else {
432 // New single-line anchor.
433 anchorLine = line;
434 applySelection(line, line);
435 history.replaceState(null, '', lineHash(line, line));
436 setPermalinkFloat(true);
437 a.scrollIntoView({ block: 'nearest' });
438 }
439 });
440 });
441 }
442
443 // ── Phase 2: Outline panel ────────────────────────────────────────────────────
444
445 /**
446 * Wire the outline toggle button and tab switching inside the panel.
447 * The panel element already exists in the DOM (SSR-rendered when has_outline=true).
448 */
449 /**
450 * Resolve a #S:SymbolName URL fragment to a line anchor.
451 *
452 * When a link arrives from the issue detail page as
453 * /blob/main/file.py#S:compute_snapshot_id, this function looks up the
454 * symbol name in the server-supplied symbolLines map, rewrites the hash to
455 * #Lnn, scrolls that line into view, and applies the line highlight.
456 */
457 function resolveSymbolHash(symbolLines: SymbolLineMap): void {
458 const hash = location.hash;
459 if (!hash.startsWith('#S:')) return;
460 const name = decodeURIComponent(hash.slice(3));
461 const range = symbolLines[name];
462 if (!range) return;
463 const [start, end] = range;
464 // Rewrite the fragment to a canonical range anchor.
465 history.replaceState(null, '', lineHash(start, end));
466 // Scroll the first line into view.
467 const target = document.getElementById(`L${start}`);
468 if (target) target.scrollIntoView({ block: 'start' });
469 // Highlight the full symbol range.
470 applySelection(start, end);
471 setPermalinkFloat(true);
472 }
473
474 function initMarkdownAnchors(): void {
475 const container = document.querySelector<HTMLElement>('.blob2-markdown');
476 if (!container) return;
477 container.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6').forEach(heading => {
478 const slug = heading.id;
479 if (!slug) return;
480 const anchor = document.createElement('a');
481 anchor.href = '#' + slug;
482 anchor.className = 'blob2-md-anchor';
483 anchor.textContent = '#';
484 anchor.setAttribute('aria-hidden', 'true');
485 heading.appendChild(anchor);
486 });
487 }
488
489 function initOutlinePanel(): void {
490 const layout = document.getElementById('blob2-layout');
491 const panel = document.getElementById('blob2-panel');
492 const toggleBtn = document.getElementById('blob2-outline-toggle');
493 if (!layout || !panel || !toggleBtn) return;
494
495 const ICON_MENU = '<svg width="12" height="12" aria-hidden="true"><use href="#icon-menu"></use></svg>';
496 const ICON_CLOSE = '<svg width="12" height="12" aria-hidden="true"><use href="#icon-x"></use></svg>';
497
498 // Toggle open/close.
499 toggleBtn.addEventListener('click', () => {
500 const open = layout.classList.toggle('blob2-panel-open');
501 toggleBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
502 toggleBtn.innerHTML = (open ? ICON_CLOSE + ' Close' : ICON_MENU + ' Outline');
503 });
504
505 // Tab switching.
506 const tabs = panel.querySelectorAll<HTMLButtonElement>('.blob2-panel-tab');
507 const panes = panel.querySelectorAll<HTMLElement>('.blob2-panel-pane');
508 tabs.forEach(tab => {
509 tab.addEventListener('click', () => {
510 const target = tab.dataset['tab'];
511 tabs.forEach(t => {
512 t.classList.toggle('blob2-panel-tab--active', t === tab);
513 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
514 });
515 panes.forEach(pane => {
516 const show = pane.id === `blob2-pane-${target}`;
517 pane.classList.toggle('blob2-panel-pane--hidden', !show);
518 });
519 });
520 });
521
522 // Copy address button in Info tab.
523 panel.querySelectorAll<HTMLButtonElement>('.blob2-copy-addr').forEach(btn => {
524 btn.addEventListener('click', () => {
525 const text = btn.dataset['copy'] ?? '';
526 if (!text) return;
527 void navigator.clipboard.writeText(text).then(() => {
528 const orig = btn.title;
529 btn.title = 'Copied!';
530 setTimeout(() => { btn.title = orig; }, 1500);
531 });
532 });
533 });
534 }
535
536 // ── Entry point ───────────────────────────────────────────────────────────────
537
538 export function initBlob(data: Record<string, unknown> = {}): void {
539 // Parse symbol name → [startLine, endLine] map from page_json.
540 // Shape: {"compute_snapshot_id": [322, 396], "diff_workdir_vs_snapshot": [397, 450], ...}
541 const rawSymbols = data['symbolLines'];
542 const symbolLines: SymbolLineMap =
543 rawSymbols != null && typeof rawSymbols === 'object' && !Array.isArray(rawSymbols)
544 ? (rawSymbols as SymbolLineMap)
545 : {};
546
547 const cfg: BlobCfg = {
548 repoId: String(data['repoId'] ?? ''),
549 ref: String(data['ref'] ?? ''),
550 filePath: String(data['filePath'] ?? ''),
551 filename: String(data['filename'] ?? ''),
552 owner: String(data['owner'] ?? ''),
553 repoSlug: String(data['repoSlug'] ?? ''),
554 base: String(data['base'] ?? ''),
555 ssrBlobRendered: data['ssrBlobRendered'] === true || data['ssrBlobRendered'] === 'true',
556 hasOutline: data['hasOutline'] === true || data['hasOutline'] === 'true',
557 symbolLines,
558 };
559 if (!cfg.repoId) return;
560 void loadBlob(cfg);
561 }
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago