/** * blob.ts — File blob viewer with syntax highlighting. * * Two rendering paths: * 1. SSR path: server renders line-numbered table; we post-process with hljs. * 2. Client path: we fetch via API and render with hljs ourselves. * * highlight.js is imported à la carte (same set as diff.ts) to keep the * bundle lean — no auto-detection, language resolved from file extension. */ 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 haskell from 'highlight.js/lib/languages/haskell'; 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('haskell', haskell); 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 ───────────────────────────────────────────────────────────────────── // Map of symbol display name → [startLine, endLine], populated from page_json. // Used to resolve #S:SymbolName hash fragments to #Lstart-Lend range anchors. type SymbolLineMap = Record; interface BlobCfg { repoId: string; ref: string; filePath: string; filename: string; owner: string; repoSlug: string; base: string; ssrBlobRendered: boolean; hasOutline: boolean; symbolLines: SymbolLineMap; } interface BlobData { rawUrl?: string; fileType?: string; filename?: string; sizeBytes?: number; sha?: string; createdAt?: string; contentText?: string; } declare const escHtml: (s: unknown) => string; // ── Core highlighter ────────────────────────────────────────────────────────── /** * Highlight `code` with hljs and return an array of HTML strings, one per * line. We highlight the whole file at once so multi-line tokens (strings, * comments, template literals) are coloured correctly, then split on newlines. */ function highlightLines(code: string, lang: string): string[] { let highlighted: string; try { highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value; } catch { highlighted = escHtml(code); } const lines = highlighted.split('\n'); // hljs adds a trailing \n which produces a spurious empty last element if (lines.length && lines[lines.length - 1] === '') lines.pop(); return lines; } // ── SSR post-process — apply hljs to the server-rendered table ──────────────── /** * The SSR blob template renders each line as a plain ``. * We re-collect all plain-text lines, highlight the full source, then inject * the coloured HTML back cell by cell. This preserves the SSR line-number * anchors (`id="L1"` etc.) while adding colour. */ function applySsrHighlighting(filename: string): void { // Support both old class names (blob-*) and new blob2-* names const cells = Array.from( document.querySelectorAll( '.blob2-line-table td.blob2-code, .blob-line-table td.blob-code', ), ); if (cells.length === 0) return; const lang = extToLang(filename); if (lang === 'plaintext') return; // nothing useful to highlight // Re-collect raw text: textContent strips any existing spans safely const rawLines = cells.map(td => td.textContent ?? ''); const fullSrc = rawLines.join('\n'); const coloredLines = highlightLines(fullSrc, lang); cells.forEach((td, i) => { // Use innerHTML — the hljs output is safe HTML with tokens only td.innerHTML = coloredLines[i] ?? td.innerHTML; td.classList.add('hljs'); }); // Mark the table so CSS can apply the hljs theme background (document.querySelector('.blob2-line-table') ?? document.querySelector('.blob-line-table')) ?.classList.add('hljs'); } // ── Utility helpers ─────────────────────────────────────────────────────────── function fmtSize(bytes: number | null | undefined): string { if (bytes == null) return ''; if (bytes < 1024) return bytes + '\u00a0B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + '\u00a0KB'; return (bytes / 1048576).toFixed(1) + '\u00a0MB'; } function fmtDate(iso: string | undefined): string { if (!iso) return ''; try { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } catch { return iso; } } function shortSha(sha: string): string { const idx = sha.indexOf(':'); const hex = idx >= 0 ? sha.slice(idx + 1) : sha; return hex.slice(0, 12); } // ── Hex dump ────────────────────────────────────────────────────────────────── function renderHexDump(arrayBuffer: ArrayBuffer): string { const bytes = new Uint8Array(arrayBuffer); const limit = Math.min(bytes.length, 512); let out = ''; for (let i = 0; i < limit; i += 16) { const chunk = bytes.slice(i, i + 16); const offset = i.toString(16).padStart(8, '0'); const hexPart = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ').padEnd(47, ' '); const asciiPart = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join(''); out += '' + offset + '' + '' + escHtml(hexPart) + '' + '' + escHtml(asciiPart) + '\n'; } return out; } // ── Action buttons ──────────────────────────────────────────────────────────── function buildActions(cfg: BlobCfg, data: BlobData, rawUrl: string): string { let actions = '' + '⬇️ Raw'; if (data.fileType === 'midi') { const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; actions += ' ' + '🎹 View in Piano Roll'; } else if (data.fileType === 'audio') { const listenUrl = cfg.base + '/listen/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; actions += ' ' + '🎵 Listen'; } return actions; } // ── Render highlighted code table (client-side path) ───────────────────────── function buildCodeTable(code: string, lang: string): string { const lines = highlightLines(code, lang); const rows = lines.map((html, i) => `` + `${i + 1}` + `${html}` + ``, ).join(''); return `
${rows}
`; } // ── Body renderer (client-side fetch path) ──────────────────────────────────── function renderBlobBody(cfg: BlobCfg, data: BlobData, rawUrl: string): string | null { const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; switch (data.fileType) { case 'midi': return '
' + '🎹' + '
' + escHtml(data.filename ?? cfg.filename) + '
' + '
MIDI file
' + '🎹 View in Piano Roll' + '
'; case 'audio': return '
' + '🎵' + '
' + escHtml(data.filename ?? cfg.filename) + '
' + '
'; case 'image': return '
' + '' + escHtml(data.filename ?? cfg.filename) + '' + '
'; default: if (data.contentText != null) { const lang = extToLang(cfg.filename); return buildCodeTable(data.contentText, lang); } return null; // binary → async hex dump } } // ── Hex preview for binary/unknown files ────────────────────────────────────── async function fetchHexPreview(rawUrl: string, bodyEl: HTMLElement, sizeBytes: number): Promise { try { const resp = await fetch(rawUrl, { headers: { Range: 'bytes=0-511' } }); if (resp.ok || resp.status === 206) { const buf = await resp.arrayBuffer(); bodyEl.innerHTML = '
' + renderHexDump(buf) + '
' + '
Showing first ' + Math.min(512, sizeBytes) + ' bytes of ' + fmtSize(sizeBytes) + '. ' + 'Download full file
'; } else { bodyEl.innerHTML = '
Binary file — Download
'; } } catch { bodyEl.innerHTML = '
Binary file — Download
'; } } // ── Full client-side render ─────────────────────────────────────────────────── async function renderBlob(cfg: BlobCfg, data: BlobData): Promise { const rawUrl = data.rawUrl ?? (cfg.base + '/raw/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath); const lang = extToLang(cfg.filename); const metaHtml = (data.sizeBytes != null ? '📄 ' + fmtSize(data.sizeBytes) + '' : '') + (data.sha ? '🔑 ' + escHtml(shortSha(data.sha)) + '' : '') + (data.createdAt ? '📅 ' + escHtml(fmtDate(data.createdAt)) + '' : '') + (lang !== 'plaintext' ? '' + escHtml(lang) + '' : ''); const headerHtml = '
' + '
📄 ' + escHtml(data.filename ?? cfg.filename) + '
' + '
' + metaHtml + '
' + '
' + buildActions(cfg, data, rawUrl) + '
' + '
'; const syncBody = renderBlobBody(cfg, data, rawUrl); const bodyHtml = '
' + (syncBody !== null ? syncBody : '
Rendering…
') + '
'; const contentEl = document.getElementById('content'); if (contentEl) contentEl.innerHTML = headerHtml + bodyHtml; if (syncBody === null) { const bodyEl = document.getElementById('blob-body-inner'); if (bodyEl) await fetchHexPreview(rawUrl, bodyEl, data.sizeBytes ?? 0); } } // ── Load metadata from API (client path) ───────────────────────────────────── async function loadBlob(cfg: BlobCfg): Promise { // SSR path: server already rendered the blob — add highlighting + line selection. if (cfg.ssrBlobRendered && document.getElementById('blob-ssr-content')) { applySsrHighlighting(cfg.filename); initLineSelection(); initPermalinkButton(); if (cfg.hasOutline) initOutlinePanel(); initMarkdownAnchors(); resolveSymbolHash(cfg.symbolLines); return; } // Client path: fetch metadata, then render. const contentEl = document.getElementById('content'); if (!contentEl) return; contentEl.innerHTML = '
Loading…
'; try { const headers: Record = {}; const url = '/api/repos/' + cfg.repoId + '/blob/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath; const resp = await fetch(url, { headers }); if (resp.status === 404) { contentEl.innerHTML = '
❌ File not found: ' + escHtml(cfg.filePath) + '
'; return; } if (resp.status === 401) { contentEl.innerHTML = '
🔒 Private repo — sign in to view this file.
'; return; } if (!resp.ok) { contentEl.innerHTML = '
❌ Failed to load file (HTTP ' + resp.status + ').
'; return; } const data = await resp.json() as BlobData; await renderBlob(cfg, data); initLineSelection(); initPermalinkButton(); if (cfg.hasOutline) initOutlinePanel(); resolveSymbolHash(cfg.symbolLines); } catch (err) { const el = document.getElementById('content'); if (el) el.innerHTML = '
❌ ' + escHtml(String(err)) + '
'; } } // ── Phase 1: Multi-line selection & deep links ──────────────────────────────── /** Parse "#L10" or "#L10-L25" from location.hash → [start, end] (1-based, inclusive). */ function parseLineHash(): [number, number] | null { const hash = location.hash; const single = /^#L(\d+)$/.exec(hash); if (single) { const n = parseInt(single[1], 10); return [n, n]; } const range = /^#L(\d+)-L(\d+)$/.exec(hash); if (range) return [parseInt(range[1], 10), parseInt(range[2], 10)]; return null; } /** Build URL hash string for a line range. */ function lineHash(start: number, end: number): string { return start === end ? `#L${start}` : `#L${start}-L${end}`; } /** Apply/remove the selected class to rows in [start, end] (1-based). */ function applySelection(start: number, end: number): void { const lo = Math.min(start, end); const hi = Math.max(start, end); document.querySelectorAll('tr.blob2-line').forEach(row => { const id = parseInt(row.id.slice(1), 10); // "L12" → 12 if (id >= lo && id <= hi) { row.classList.add('blob2-line--selected'); } else { row.classList.remove('blob2-line--selected'); } }); } /** Show or hide the permalink float. */ function setPermalinkFloat(visible: boolean): void { const el = document.getElementById('blob2-permalink-float'); if (!el) return; if (visible) el.classList.add('is-visible'); else el.classList.remove('is-visible'); } function initPermalinkButton(): void { const btn = document.getElementById('blob2-permalink-btn'); if (!btn) return; btn.addEventListener('click', () => { void navigator.clipboard.writeText(location.href).then(() => { const orig = btn.textContent ?? ''; btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); }); }); } function initLineSelection(): void { let anchorLine: number | null = null; // Restore selection from URL on load. const initial = parseLineHash(); if (initial) { const [s, e] = initial; anchorLine = s; applySelection(s, e); setPermalinkFloat(true); // Scroll the anchor row into view. const target = document.getElementById(`L${s}`); if (target) target.scrollIntoView({ block: 'start' }); } // Wire up line-number clicks. document.querySelectorAll('a.blob2-ln-link').forEach(a => { a.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); const line = parseInt(a.dataset['line'] ?? '0', 10); if (!line) return; if (e.shiftKey && anchorLine !== null) { // Extend selection to range. const lo = Math.min(anchorLine, line); const hi = Math.max(anchorLine, line); applySelection(lo, hi); history.replaceState(null, '', lineHash(lo, hi)); setPermalinkFloat(true); } else { // New single-line anchor. anchorLine = line; applySelection(line, line); history.replaceState(null, '', lineHash(line, line)); setPermalinkFloat(true); a.scrollIntoView({ block: 'nearest' }); } }); }); } // ── Phase 2: Outline panel ──────────────────────────────────────────────────── /** * Wire the outline toggle button and tab switching inside the panel. * The panel element already exists in the DOM (SSR-rendered when has_outline=true). */ /** * Resolve a #S:SymbolName URL fragment to a line anchor. * * When a link arrives from the issue detail page as * /blob/main/file.py#S:compute_snapshot_id, this function looks up the * symbol name in the server-supplied symbolLines map, rewrites the hash to * #Lnn, scrolls that line into view, and applies the line highlight. */ function resolveSymbolHash(symbolLines: SymbolLineMap): void { const hash = location.hash; if (!hash.startsWith('#S:')) return; const name = decodeURIComponent(hash.slice(3)); const range = symbolLines[name]; if (!range) return; const [start, end] = range; // Rewrite the fragment to a canonical range anchor. history.replaceState(null, '', lineHash(start, end)); // Scroll the first line into view. const target = document.getElementById(`L${start}`); if (target) target.scrollIntoView({ block: 'start' }); // Highlight the full symbol range. applySelection(start, end); setPermalinkFloat(true); } function initMarkdownAnchors(): void { const container = document.querySelector('.blob2-markdown'); if (!container) return; container.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(heading => { const slug = heading.id; if (!slug) return; const anchor = document.createElement('a'); anchor.href = '#' + slug; anchor.className = 'blob2-md-anchor'; anchor.textContent = '#'; anchor.setAttribute('aria-hidden', 'true'); heading.appendChild(anchor); }); } function initOutlinePanel(): void { const layout = document.getElementById('blob2-layout'); const panel = document.getElementById('blob2-panel'); const toggleBtn = document.getElementById('blob2-outline-toggle'); if (!layout || !panel || !toggleBtn) return; const ICON_MENU = ''; const ICON_CLOSE = ''; // Toggle open/close. toggleBtn.addEventListener('click', () => { const open = layout.classList.toggle('blob2-panel-open'); toggleBtn.setAttribute('aria-pressed', open ? 'true' : 'false'); toggleBtn.innerHTML = (open ? ICON_CLOSE + ' Close' : ICON_MENU + ' Outline'); }); // Tab switching. const tabs = panel.querySelectorAll('.blob2-panel-tab'); const panes = panel.querySelectorAll('.blob2-panel-pane'); tabs.forEach(tab => { tab.addEventListener('click', () => { const target = tab.dataset['tab']; tabs.forEach(t => { t.classList.toggle('blob2-panel-tab--active', t === tab); t.setAttribute('aria-selected', t === tab ? 'true' : 'false'); }); panes.forEach(pane => { const show = pane.id === `blob2-pane-${target}`; pane.classList.toggle('blob2-panel-pane--hidden', !show); }); }); }); // Copy address button in Info tab. panel.querySelectorAll('.blob2-copy-addr').forEach(btn => { btn.addEventListener('click', () => { const text = btn.dataset['copy'] ?? ''; if (!text) return; void navigator.clipboard.writeText(text).then(() => { const orig = btn.title; btn.title = 'Copied!'; setTimeout(() => { btn.title = orig; }, 1500); }); }); }); } // ── Entry point ─────────────────────────────────────────────────────────────── export function initBlob(data: Record = {}): void { // Parse symbol name → [startLine, endLine] map from page_json. // Shape: {"compute_snapshot_id": [322, 396], "diff_workdir_vs_snapshot": [397, 450], ...} const rawSymbols = data['symbolLines']; const symbolLines: SymbolLineMap = rawSymbols != null && typeof rawSymbols === 'object' && !Array.isArray(rawSymbols) ? (rawSymbols as SymbolLineMap) : {}; const cfg: BlobCfg = { repoId: String(data['repoId'] ?? ''), ref: String(data['ref'] ?? ''), filePath: String(data['filePath'] ?? ''), filename: String(data['filename'] ?? ''), owner: String(data['owner'] ?? ''), repoSlug: String(data['repoSlug'] ?? ''), base: String(data['base'] ?? ''), ssrBlobRendered: data['ssrBlobRendered'] === true || data['ssrBlobRendered'] === 'true', hasOutline: data['hasOutline'] === true || data['hasOutline'] === 'true', symbolLines, }; if (!cfg.repoId) return; void loadBlob(cfg); }