/** * isomorphic-git wrapper for in-browser repo browsing. * * Uses LightningFS (IndexedDB-backed) for persistence and NIP-98 auth * for the relay's smart HTTP git transport. */ // isomorphic-git expects a global Buffer (Node API) for pack-file parsing, // tree serialization, etc. The `buffer` package (feross/buffer) is the // standard browser polyfill — we install it before any git imports run. import { Buffer } from "buffer"; if (typeof (globalThis as Record).Buffer === "undefined") { (globalThis as Record).Buffer = Buffer; } import LightningFS from "@isomorphic-git/lightning-fs"; import { clone, fetch, log, readBlob, readTree, resolveRef, } from "isomorphic-git"; import http from "isomorphic-git/http/web"; import { makeNip98AuthHeader } from "@/shared/lib/nip98"; import { relayHttpBaseUrl } from "@/shared/lib/relay-url"; /** Get a repo-specific LightningFS instance backed by IndexedDB. */ export function getFs(owner: string, repoName: string): LightningFS { return new LightningFS(`buzz-git-${owner}-${repoName}`); } /** Working directory inside the virtual FS. */ export function getDir(owner: string, repoName: string): string { return `/${owner}/${repoName}`; } function repoGitUrl(owner: string, repoName: string): string { return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`; } /** * The NIP-98 `u` tag URL — must match what transport.rs expects after * stripping `/info/refs`, `/git-upload-pack`, `/git-receive-pack`. * That means the full path including `.git`. */ function repoAuthUrl(owner: string, repoName: string): string { return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`; } async function authHeaders( owner: string, repoName: string, ): Promise> { return { Authorization: await makeNip98AuthHeader( repoAuthUrl(owner, repoName), "GET", ), }; } /** * Ensure a shallow clone exists in IndexedDB. If it already exists, fetch * the latest for the given ref. */ export async function ensureClone( owner: string, repoName: string, ref: string, ): Promise<{ fs: LightningFS; dir: string }> { const fs = getFs(owner, repoName); const dir = getDir(owner, repoName); const url = repoGitUrl(owner, repoName); const headers = await authHeaders(owner, repoName); let exists = false; try { await fs.promises.stat(`${dir}/.git`); exists = true; } catch { // repo not cloned yet } if (exists) { try { await fetch({ fs, http, dir, url, ref, depth: 1, singleBranch: true, headers, }); } catch { // fetch may fail if ref hasn't changed — that's fine } } else { await clone({ fs, http, dir, url, ref, depth: 1, singleBranch: true, noTags: true, headers, }); } return { fs, dir }; } export interface TreeEntry { name: string; type: "blob" | "tree"; mode: string; oid: string; } /** Read tree entries at a given path (or root if no filepath). */ export async function readTreeEntries( fs: LightningFS, dir: string, oid: string, filepath?: string, ): Promise { const result = await readTree({ fs, dir, oid, filepath }); return result.tree.map((entry) => ({ name: entry.path, type: entry.type as "blob" | "tree", mode: entry.mode, oid: entry.oid, })); } export interface FileContent { content: string; isBinary: boolean; } /** Read a blob and decode as text. Detects binary by checking for NUL bytes. */ export async function readFileContent( fs: LightningFS, dir: string, oid: string, filepath: string, ): Promise { const { blob } = await readBlob({ fs, dir, oid, filepath }); const checkLength = Math.min(blob.length, 512); for (let i = 0; i < checkLength; i++) { if (blob[i] === 0) { return { content: "", isBinary: true }; } } const content = new TextDecoder().decode(blob); return { content, isBinary: false }; } /** * Inline-preview caps. Different ceilings per kind: * - Text: a 1 MiB string is already big to render in the DOM. Over → download. * - Image: raster decoders handle this cheaply; cap is a sanity ceiling, not * a perf brake. Normal screenshots (≤ ~few MB) preview just fine. * - Binary: no preview cap — we always offer download, regardless of size. * * The clone in IndexedDB always holds full bytes; these are display caps only. */ export const TEXT_PREVIEW_LIMIT_BYTES = 1 * 1024 * 1024; export const IMAGE_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024; /** * Discriminated view of a blob, suitable for rendering. The viewer component * is responsible for `URL.createObjectURL` / `revokeObjectURL` over `bytes` — * we deliberately do NOT create object URLs inside the React Query cache. * * Image-by-extension is restricted to raster formats. SVG is intentionally * absent: it can carry active content; we render SVG via the text path * (where applicable) instead. * * `too-large` carries the cap that was hit, so the viewer can explain which * limit applied without re-computing it. */ export type BlobView = | { kind: "text"; content: string; sizeBytes: number } | { kind: "markdown"; content: string; sizeBytes: number } | { kind: "html"; content: string; sizeBytes: number } | { kind: "image"; bytes: Uint8Array; contentType: string; sizeBytes: number } | { kind: "binary"; bytes: Uint8Array; sizeBytes: number } | { kind: "too-large"; bytes: Uint8Array; sizeBytes: number; limitBytes: number; }; const RASTER_IMAGE_MIME: Readonly> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", }; const MARKDOWN_EXTS = new Set(["md", "markdown"]); const HTML_EXTS = new Set(["html", "htm"]); function extOf(filepath: string): string { const base = filepath.split("/").pop() ?? ""; const dot = base.lastIndexOf("."); if (dot <= 0) return ""; return base.slice(dot + 1).toLowerCase(); } function hasNulByte(bytes: Uint8Array): boolean { const n = Math.min(bytes.length, 512); for (let i = 0; i < n; i++) { if (bytes[i] === 0) return true; } return false; } /** * Classify a blob into a `BlobView`. Applies per-kind preview caps. * * Order: image-by-extension first (so a 2 MiB PNG isn't rejected as oversized * text), then binary detection, then text/markdown decode. */ export async function readBlobView( fs: LightningFS, dir: string, oid: string, filepath: string, ): Promise { const { blob } = await readBlob({ fs, dir, oid, filepath }); const bytes = blob as Uint8Array; const sizeBytes = bytes.length; const ext = extOf(filepath); const mime = RASTER_IMAGE_MIME[ext]; if (mime) { if (sizeBytes > IMAGE_PREVIEW_LIMIT_BYTES) { return { kind: "too-large", bytes, sizeBytes, limitBytes: IMAGE_PREVIEW_LIMIT_BYTES, }; } return { kind: "image", bytes, contentType: mime, sizeBytes }; } if (hasNulByte(bytes)) { // Binary: no preview cap. Always download. return { kind: "binary", bytes, sizeBytes }; } if (sizeBytes > TEXT_PREVIEW_LIMIT_BYTES) { return { kind: "too-large", bytes, sizeBytes, limitBytes: TEXT_PREVIEW_LIMIT_BYTES, }; } // Fatal decode: anything that *looks* binary-ish but slipped past the NUL // sniff (rare-but-real for non-UTF formats without an early 0x00) falls // through to the binary path instead of being rendered as mojibake. let content: string; try { content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return { kind: "binary", bytes, sizeBytes }; } if (MARKDOWN_EXTS.has(ext)) { return { kind: "markdown", content, sizeBytes }; } if (HTML_EXTS.has(ext)) { return { kind: "html", content, sizeBytes }; } return { kind: "text", content, sizeBytes }; } /** * Inline a repo's same-repo relative assets into one self-contained HTML * string, suitable for rendering inside a sandboxed iframe (which has no * notion of the repo's directory tree and cannot fetch siblings). * * Only *relative* `