blob.ts
typescript
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 | + '⬇️ Raw</a>'; |
| 192 | if (data.fileType === 'midi') { |
| 193 | const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; |
| 194 | actions += ' <a class="btn-blob btn-blob-primary" href="' + escHtml(rollUrl) + '">' |
| 195 | + '🎹 View in Piano Roll</a>'; |
| 196 | } else if (data.fileType === 'audio') { |
| 197 | const listenUrl = cfg.base + '/listen/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; |
| 198 | actions += ' <a class="btn-blob btn-blob-primary" href="' + escHtml(listenUrl) + '">' |
| 199 | + '🎵 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) + '">🎹 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">📄 ' + fmtSize(data.sizeBytes) + '</span>' : '') |
| 277 | + (data.sha ? '<span title="SHA">🔑 ' + escHtml(shortSha(data.sha)) + '</span>' : '') |
| 278 | + (data.createdAt ? '<span title="Last pushed">📅 ' + 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">📄 <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