/** * rgui lane source β€” folder tree (semantic-zoom outline). * * The tree is laid out as a **screen-space icicle**: the root fills the band * the {@link LaneView} maps for it, and every expanded folder steals a fixed * readable header then deals the rest to its children by cheap-local weight * shares (stick-breaking intervals). Zoom controls only how tall that band * is, so: * * β€’ zoom out β†’ a folder's band drops below `collapsePx` β†’ it renders as ONE * summary row ("πŸ“ name β€” 128 files Β· 4.2 MB"); its children never draw. * β€’ zoom in β†’ the band grows past `collapsePx` β†’ a header appears and the * children get room, each recursing the same way β€” files eventually get * tall enough to show size, kind, then a faux preview. * * Rows are always full-width (depth = x-indent), so width never changes with * zoom β€” the RG flow runs down the single axis. Nested layout is computed in * screen space, so an expanded header is always readable, never sub-pixel. */ import type { RgTheme } from "../core/theme.js"; import { withAlpha } from "../core/theme.js"; import type { LaneEnv, LaneSource } from "./lane.js"; import { KIND_COLOR, KIND_LABEL, KIND_ORDER, bucketWeight, chooseTreeFold, chunkRows, contentLevels, detectSchema, discloseLevel, heatRampColor, kindOf, srgbToOklch, updateSchemaRegistry, type SchemaRegistry, type SchemaRegistryColumn, type TreeFoldMode, } from "./treefold.js"; import { TreeListingStore, type TreeListingSnapshot, type TreeListingStatus, type TreeProvider, } from "./treeprovider.js"; import { screenToWorldY, worldToScreenY, type LaneView } from "./view.js"; /** host-supplied file/folder node. Folders have `children`; files have `size`. */ export interface FileNode { name: string; /** bytes; folders derive their total from children */ size?: number; children?: FileNode[]; /** file text; when present, zooming into the file reveals it line by line */ content?: string; /** repo-relative path β€” lets a host lazily fetch content on zoom */ path?: string; } /** optional hooks for lazy content loading (e.g. from a GitHub repo) */ /** a decoded image a host's loadImage hook hands the tree to draw */ export interface TreeImage { width: number; height: number; /** the frame to draw at `nowMs` β€” static images ignore the argument */ frame(nowMs: number): CanvasImageSource; /** animated (GIF): the tree schedules repaints while it is visible */ animated?: boolean; /** release decoder/bitmap resources when LRU-evicted */ close?(): void; } export interface TreeOptions { /** fetch a file's text by its `path`; null/throw β†’ leave it unloaded */ fetchContent?: (path: string) => Promise; /** * load an image file's pixels by `path` β€” host-supplied like fetchContent * (the lib never fetches). Requested lazily once the file's band is tall * enough to show a thumbnail; results are LRU-cached and drawn letterboxed. * Animated sources (GIFs) return `animated: true` and a time-indexed * `frame(nowMs)` β€” the tree repaints while one is visible. */ loadImage?: (path: string) => Promise; /** called after lazily-loaded content arrives (host wires it to invalidate) */ onUpdate?: () => void; /** * directory layout weight policy. "flat" (default) weighs every dir 1 β€” * navigation-first: siblings stay near-equal and overview stays a list. * "child-count" weighs a dir 1 + its immediate child count β€” density- * first: big dirs dominate and heterogeneous overviews fold into the * childΓ—kind heat table. Only valid on complete listings (a lazy * provider must keep unlisted dirs at 1 or pagination churns shares). */ dirWeight?: "flat" | "child-count"; } /** options for {@link createLazyTreeSource} */ export interface LazyTreeOptions extends TreeOptions { /** display name of the root directory (default "/") */ rootName?: string; /** provider key of the root listing (default "") β€” providers whose paths * aren't display-name joins (ids, absolute paths) anchor here */ rootPath?: string; /** entries requested per list page (default 64) */ pageLimit?: number; } /** cap on lazily-loaded content lines kept per file */ const MAX_FILE_UNITS = 120; const BYTES_PER_LINE = 45; // size β†’ estimated line count (drives zoom depth) interface TNode { name: string; ext: string; isDir: boolean; children: TNode[]; /** * CHEAP-LOCAL layout weight (see localWeight): file = own-size log2-KB * bucket, dir = 1 (or 1 + immediate child count under the opt-in * "child-count" policy). Never a subtree aggregate β€” deep content can't * move ancestors, and lazy loading never reshuffles layout. */ weight: number; depth: number; fileCount: number; // descendant files totalSize: number; // descendant bytes lines: string[]; // file content split into lines (empty until loaded) path: string; // repo-relative path (for lazy content fetch) tried: boolean; // content load attempted? loading: boolean; // fetch in flight? img?: TreeImage | null; // decoded image (media files; LRU-evicted) imgLoading?: boolean; imgTried?: boolean; /** * fraction of the PARENT's interval this node owns (stick-breaking * coordinates β€” TODO.mdγ€ŒεŒΊι–“εˆ†ε‰²εΊ§ζ¨™γ€). Derived from sibling weights, * but only ever renormalized WITHIN one parent, so a mutation deep in * the tree never shifts anything outside its parent's interval. */ share: number; shareFrom: number; // glide origin when the share was last re-dealt shareT0: number; // glide start timestamp (0 = settled) /** * tombstone: deleted but still gliding to share 0 so siblings expand * smoothly instead of snapping to full width. Excluded from every * aggregate/mode decision; reaped once its glide settles. */ dying?: boolean; /** * lazy-provider listing state for dirs (undefined = eager tree, which is * complete by construction). unknown+[] means NOT LISTED; complete+[] * means known-empty β€” the two must never render alike. */ listing?: TreeListingStatus; /** * the PROVIDER's key for listing this dir (TreeProviderEntry.path when * given, else the display-name join). Provider paths need not be * isomorphic to display paths β€” nodeByListKey maps them back. */ listKey?: string; /** bumped whenever this dir's children change (schema re-detection key) */ childEpoch?: number; /** the listing version this dir's children came from (schema epoch bound) */ listVersion?: unknown; /** any dir below (or self) is still unknown/partial β†’ aggregates are lower bounds */ aggLower?: boolean; } const PAD_X = 10; const INDENT = 15; const MAX_INDENT_DEPTH = 12; // clamp indent so deep trees keep row width const HEADER_PX = 22; // fixed readable folder header when expanded const CULL = 24; // off-screen margin (px) const REM_PX = 16; // readability unit for the fold grid (β‰₯1rem rule) const GRID_HEADER_PX = 12; // kind-column caption strip (like SMTWTFS) const GRID_GUTTER_PX = 42; // left gutter for chunk row labels const GLIDE_MS = 280; // share re-deal animation (matches timeline glide) const EXT_COLOR: Record = { ts: "#60a5fa", tsx: "#60a5fa", js: "#f7df1e", json: "#a3be8c", md: "#8b949e", html: "#e06c4b", css: "#c678dd", png: "#2dd4bf", gif: "#2dd4bf", jpg: "#2dd4bf", svg: "#2dd4bf", lock: "#5c6570", toml: "#d19a66", }; function extOf(name: string): string { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; } /** deal sibling shares from weights β€” the ONLY place shares are (re)set. * `now` animates each sibling from its currently displayed share. */ function assignShares(children: TNode[], now = 0) { let total = 0; for (const c of children) total += c.weight; for (const c of children) { const next = total > 0 ? c.weight / total : 1 / children.length; if (now > 0 && Math.abs(next - c.share) > 1e-9) { c.shareFrom = dispShare(c, now); c.shareT0 = now; } c.share = next; } } /** share currently on screen β€” eases toward the target over GLIDE_MS */ function dispShare(t: TNode, now: number): number { if (!t.shareT0) return t.share; const p = (now - t.shareT0) / GLIDE_MS; if (p >= 1) { t.shareT0 = 0; return t.share; } const e = 1 - Math.pow(1 - p, 3); // easeOutCubic return t.shareFrom + (t.share - t.shareFrom) * e; } /** * layout weight from CHEAP-LOCAL inputs only (the TODO.md contract): a * file weighs its own size's log2-KB bucket, a directory weighs 1 (flat, * default) or 1 + immediate child count (the opt-in "child-count" policy β€” * see TreeOptions.dirWeight). Subtree aggregates (fileCount/totalSize) * are display decoration and never feed layout. */ const localWeight = ( isDir: boolean, childCount: number, ownSize: number, dirCount: boolean, ): number => (isDir ? (dirCount ? 1 + childCount : 1) : bucketWeight(ownSize)); function build(node: FileNode, depth: number, dirCount: boolean): TNode { if (node.children) { const children = node.children.map((c) => build(c, depth + 1, dirCount)); let fileCount = 0; let totalSize = 0; for (const c of children) { fileCount += c.fileCount; totalSize += c.totalSize; } assignShares(children); return { name: node.name, ext: "", isDir: true, children, weight: localWeight(true, children.length, 0, dirCount), depth, fileCount, totalSize, lines: [], path: node.path ?? "", tried: true, loading: false, share: 1, shareFrom: 1, shareT0: 0, }; } const lines = node.content ? node.content.split("\n") : []; const size = node.size ?? node.content?.length ?? 0; return { name: node.name, ext: extOf(node.name), isDir: false, children: [], weight: localWeight(false, 0, size, dirCount), depth, fileCount: 1, totalSize: size, lines, path: node.path ?? "", tried: !!node.content, // already have content β†’ nothing to fetch loading: false, share: 1, shareFrom: 1, shareT0: 0, }; } function fmtBytes(n: number): string { if (n <= 0) return "0 B"; const u = ["B", "KB", "MB", "GB", "TB"]; const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024))); const v = n / Math.pow(1024, i); return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${u[i]}`; } function fmtCount(n: number): string { return `${n} item${n === 1 ? "" : "s"}`; } /** truncate text to fit `maxW` px, appending … when clipped. */ function fit(ctx: CanvasRenderingContext2D, text: string, maxW: number): string { if (maxW <= 0) return ""; if (ctx.measureText(text).width <= maxW) return text; let lo = 0; let hi = text.length; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (ctx.measureText(text.slice(0, mid) + "…").width <= maxW) lo = mid; else hi = mid - 1; } return lo === 0 ? "" : text.slice(0, lo) + "…"; } const indentX = (depth: number) => PAD_X + Math.min(depth, MAX_INDENT_DEPTH) * INDENT; /** cached schema-fold state for one directory (see nextSchemaState) */ export interface SchemaState { key: string; // childEpoch + column budget version: unknown; // the dir's listing version the registry belongs to registry: SchemaRegistry | null; /** render schema columns? false = kind fallback while registry persists */ active: boolean; } /** * schema-state transition β€” the single choke point for the three coupled * rules (pure, exported for tests): * - a NEW listing version starts a fresh epoch (prev registry dropped, * ghosts retired); within one version the registry is append-only. * - a transient detection failure (members dipped below quorum, listings * went partial) KEEPS the same-version registry but deactivates β€” column * order and ghost history must survive the wobble. * - the registry is active only while ALL its columns (ghosts included) * plus the 'Β·' rest fit the column budget; over budget β†’ kind fallback * with the registry intact, so re-widening restores the same order. */ export function nextSchemaState( prev: SchemaState | null, detected: ReturnType, version: unknown, maxColumns: number, key: string, ): SchemaState { const sameEpoch = prev !== null && prev.version === version; const carried = sameEpoch ? prev.registry : null; const registry = detected ? updateSchemaRegistry(carried, detected) : carried; const active = detected !== null && registry !== null && registry.columns.length <= maxColumns; return { key, version, registry, active }; } /** tree source with an optional lazy content loader (host wires setOnUpdate) */ export interface TreeSource extends LaneSource { setOnUpdate(fn: () => void): void; /** * fs-watch-style mutation: upsert (`node`) or delete (`null`) the entry at * `path` (slash-separated, root name excluded). Re-deals shares within the * parent only (with a glide) β€” nothing outside its interval moves. */ applyFsEvent(path: string, node: FileNode | null): boolean; /** * lazy sources: request one listing page for a PROVIDER list key * (default: the root's key) outside the viewport-driven scheduler β€” * hosts/tests use it to prime a subtree. Resolves after the page * settles; no-op on eager sources. */ ensureListed(path?: string): Promise; } const TEXT_EXT = new Set([ "ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "json5", "md", "markdown", "txt", "rst", "adoc", "tex", "css", "scss", "less", "styl", "html", "htm", "xml", "svg", "vue", "svelte", "astro", "yml", "yaml", "toml", "ini", "cfg", "conf", "properties", "env", "c", "h", "cc", "cpp", "hpp", "cxx", "hxx", "m", "mm", "cs", "go", "rs", "java", "kt", "kts", "scala", "clj", "py", "pyi", "rb", "php", "lua", "sh", "bash", "zsh", "fish", "ps1", "bat", "sql", "r", "pl", "pm", "swift", "dart", "ex", "exs", "erl", "hs", "ml", "fs", "vim", "asm", "s", "S", "dts", "dtsi", "cmake", "mk", "in", "ac", "am", "gradle", "proto", "graphql", "gql", "diff", "patch", "csv", "tsv", "log", "gitignore", "gitattributes", "editorconfig", "lock", "makefile", "dockerfile", "readme", "license", "authors", "changelog", "todo", "kbuild", "kconfig", "defconfig", ]); const isText = (t: TNode) => t.ext === "" || TEXT_EXT.has(t.ext) || TEXT_EXT.has(t.name.toLowerCase()); const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "ico"]); const isImage = (t: TNode) => IMAGE_EXT.has(t.ext); export function createTreeSource( root: FileNode, opts: TreeOptions = {}, ): TreeSource { return createTreeSourceImpl(root, opts, null); } /** * lazy tree source: nothing is known up front; listings materialize from a * {@link TreeProvider} driven by the viewport (a dir lists only once its * band affords an expansion, pages continue only while it stays on screen). * The provider is a host-supplied hook like fetchContent β€” the lib itself * still performs no transport. */ export function createLazyTreeSource( provider: TreeProvider, opts: LazyTreeOptions = {}, ): TreeSource { return createTreeSourceImpl( { name: opts.rootName ?? "/", children: [] }, opts, provider, ); } function createTreeSourceImpl( root: FileNode, opts: LazyTreeOptions, provider: TreeProvider | null, ): TreeSource { const dirCount = opts.dirWeight === "child-count"; const tree = build(root, 0, dirCount); if (provider) tree.listing = "unknown"; // built empty β‰  known-empty const fetchContent = opts.fetchContent ?? (provider?.read ? async (path: string) => { const r = await provider.read!(path, { signal: new AbortController().signal }); return typeof r === "string" ? r : r ? new TextDecoder().decode(r) : null; } : undefined); let onUpdate: () => void = opts.onUpdate ?? (() => {}); // Fetch a file's text on demand, capped so only a few load at once β€” combined // with viewport culling + a min-height gate, we only ever fetch the handful // of files actually being viewed at a readable scale. const MAX_CONCURRENT = 5; let inflight = 0; function loadContent(t: TNode) { if (t.tried || t.loading || !fetchContent || !t.path) return; if (inflight >= MAX_CONCURRENT) return; // wait for a slot (retried next frame) t.loading = true; inflight++; Promise.resolve(fetchContent(t.path)) .then((text) => { t.tried = true; if (text != null) { t.lines = text.split("\n").slice(0, MAX_FILE_UNITS * 2); zoomDirty = true; // real line count may be finer than the estimate } }) .catch(() => { t.tried = true; }) .finally(() => { t.loading = false; inflight--; onUpdate(); }); } // ── image previews: lazy load + LRU cache + repaint driver ──────────────── // Same discipline as text: only visible, readable-scale media files load // (min band height), few at a time, and a small LRU bounds decoded memory. const IMG_LRU_MAX = 24; const IMG_MAX_INFLIGHT = 3; const imgLru: TNode[] = []; // oldest first let imgInflight = 0; function loadImageFor(t: TNode) { if (t.imgTried || t.imgLoading || !opts.loadImage || !t.path) return; if (imgInflight >= IMG_MAX_INFLIGHT) return; // retried next frame t.imgLoading = true; imgInflight++; Promise.resolve(opts.loadImage(t.path)) .then((img) => { t.imgTried = true; t.img = img; if (img) { imgLru.push(t); while (imgLru.length > IMG_LRU_MAX) { const old = imgLru.shift()!; old.img?.close?.(); old.img = undefined; old.imgTried = false; // may reload if scrolled back to } } }) .catch(() => { t.imgTried = true; }) .finally(() => { t.imgLoading = false; imgInflight--; onUpdate(); }); } const touchImgLru = (t: TNode) => { const i = imgLru.indexOf(t); if (i >= 0 && i !== imgLru.length - 1) { imgLru.splice(i, 1); imgLru.push(t); } }; // one repaint per frame while an animated image is on screen let animScheduled = false; function scheduleAnimFrame() { if (animScheduled) return; animScheduled = true; requestAnimationFrame(() => { animScheduled = false; onUpdate(); }); } // ── shared layout (the treeFoldPos choke point) ─────────────────────── // Screen bands, fold-run grouping, and grid-vs-strip decisions all live // here, consumed identically by draw() and hitTest() β€” never compute a // position in one coordinate frame and consume it in another. let frameNow = 0; // stamped once per draw; hitTest reuses the last stamp let gliding = false; // any share still easing this frame β†’ keep redrawing function shareNow(c: TNode): number { const s = dispShare(c, frameNow); if (c.shareT0) gliding = true; return s; } /** reap settled tombstones β€” a splice here is visually a no-op because * the dead child's displayed share already reached 0 */ function reapDying(t: TNode) { for (let i = t.children.length - 1; i >= 0; i--) { const c = t.children[i]!; if (c.dying && !c.shareT0) { retireSubtree(c); // final: stop watchers, drop list keys t.children.splice(i, 1); } } } /** screen bands for an expanded dir's children: [child, a, b] */ function layoutChildren(t: TNode, top: number, inner: number): Array<[TNode, number, number]> { reapDying(t); let total = 0; for (const c of t.children) total += shareNow(c); const bounds: Array<[TNode, number, number]> = []; let y = top; for (const c of t.children) { const b = y + (inner * shareNow(c)) / (total || 1); bounds.push([c, y, b]); y = b; } return bounds; } // ── per-directory fold mode: the design's division ladder, hysteretic ── // list (every child row readable) β†’ grid (kind columns readable) β†’ strip. // chooseTreeFold gets a readability unit scaled by the previous mode // (0.8 to stay list, 1.25 to re-enter it) so zoom jitter can't flap the // mode at a boundary. Decisions use TARGET shares β€” the settled layout β€” // never mid-glide display shares. type DirMode = TreeFoldMode["mode"]; const modeCache = new WeakMap(); interface DirLayout { mode: DirMode; headerB: number; // header bottom = children top bounds: Array<[TNode, number, number]>; // list mode child bands gridRows: number; // grid mode row budget (from the hysteretic chooser) } function treeFoldLayout(t: TNode, sy0: number, sy1: number, width: number): DirLayout { const bandH = sy1 - sy0; const headerB = sy0 + Math.min(HEADER_PX, bandH); const contentH = sy1 - headerB; // mode decisions and counts consider LIVE children only; tombstones // still occupy screen space mid-glide but are already "not there" let n = 0; let total = 0; let min = Infinity; for (const c of t.children) { if (c.dying) continue; n++; total += c.share; if (c.share < min) min = c.share; } if (!n) return { mode: "strip", headerB, bounds: [], gridRows: 0 }; const minShare = total > 0 ? min / total : 1 / n; // first sight starts from the COARSEST mode so entering list/grid pays // the full 1.25Γ—rem re-entry price β€” defaulting to "list" would let a // dir skip the entry hysteresis and then squat down to 0.8Γ—rem const prev = modeCache.get(t) ?? "strip"; // per-boundary hysteresis: each transition needs its own asymmetric // unit, or grid↔strip would share one threshold and flap const listUnit = REM_PX * (prev === "list" ? 0.8 : 1.25); const gridUnit = REM_PX * (prev === "strip" ? 1.25 : 0.8); const gridW = width - PAD_X - indentX(t.depth + 1) - GRID_GUTTER_PX; // the caption header is part of the grid-mode contract: reserve it // BEFORE budgeting rows, so it can't shrink an approved row below unit const gridContentH = Math.max(0, contentH - GRID_HEADER_PX); const m = chooseTreeFold(n, contentH, gridW, listUnit, minShare, gridUnit, gridContentH); modeCache.set(t, m.mode); if (m.mode !== "list") { // grid/strip have no per-child bands to glide β€” drop tombstones now // so rows/counts/hit prefixes all see the same live set for (let i = t.children.length - 1; i >= 0; i--) { if (t.children[i]!.dying) { retireSubtree(t.children[i]!); t.children.splice(i, 1); } } } return { mode: m.mode, headerB, bounds: m.mode === "list" ? layoutChildren(t, headerB, contentH) : [], gridRows: m.mode === "grid" ? m.rows : 0, }; } function draw(ctx: CanvasRenderingContext2D, view: LaneView, env: LaneEnv) { const H = view.height; ctx.textBaseline = "middle"; ctx.lineWidth = 1; frameNow = performance.now(); gliding = false; wantList.length = 0; const sy0 = worldToScreenY(view, 0); const sy1 = worldToScreenY(view, 1); drawNode(ctx, tree, sy0, sy1, view, env, H); flushListQueue(); // shares still easing β†’ schedule another frame through the host if (gliding) requestAnimationFrame(() => onUpdate()); } function drawNode( ctx: CanvasRenderingContext2D, t: TNode, sy0: number, sy1: number, view: LaneView, env: LaneEnv, H: number, ) { if (sy1 < -CULL || sy0 > H + CULL) return; const bandH = sy1 - sy0; if (!t.isDir) { drawFileRow(ctx, t, sy0, sy1, env); return; } if (bandH < env.rule.collapsePx) { drawFolderSummary(ctx, t, sy0, sy1, env, false); return; } // expanded and lazily backed β†’ this dir has earned a listing request // (band β‰₯ collapsePx is the I/O gate, same discipline as loadContent) if (store && t.listKey !== undefined && (t.listing === "unknown" || t.listing === "partial")) { wantList.push(t.listKey); } // expanded: fixed readable header, children fill the remainder in the // directory's fold mode (list / grid / strip β€” one decision per dir) const L = treeFoldLayout(t, sy0, sy1, env.size.width); drawFolderSummary(ctx, t, sy0, L.headerB, env, true); if (!t.children.length) return; if (L.mode === "list") { for (const [c, a, b] of L.bounds) { if (b < -CULL) continue; if (a > H + CULL) break; drawNode(ctx, c, a, b, view, env, H); } } else if (L.mode === "grid") { drawFoldGrid(ctx, t, t.children, L.headerB, sy1, t.depth + 1, env, L.gridRows); } else { drawAggStrip(ctx, L.headerB, sy1, t.depth + 1, t.children.length, t.fileCount, t.totalSize, env); } } // ── row painters ────────────────────────────────────────────────────── function rowClip(a: number, b: number, H: number): [number, number] { return [Math.max(a, -2), Math.min(b, H + 2)]; } function drawFileRow( ctx: CanvasRenderingContext2D, t: TNode, sy0: number, sy1: number, env: LaneEnv, ) { const { theme } = env; const x = indentX(t.depth); const right = env.size.width - PAD_X; const h = sy1 - sy0; const [ca, cb] = rowClip(sy0, sy1, env.size.height); const color = EXT_COLOR[t.ext] ?? theme.textMuted; ctx.fillStyle = withAlpha(theme.nodeBg, h > 40 ? 0.5 : 0.28); ctx.fillRect(x, ca, right - x, cb - ca); ctx.fillStyle = color; ctx.fillRect(x, ca, 2, cb - ca); // ext spine if (h < 11) return; // too short for text // header strip: name + size, always at the band top const headerH = Math.min(h, HEADER_PX); const mid = sy0 + headerH / 2; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x + 10, mid, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.font = "10px ui-monospace, Menlo, monospace"; ctx.textAlign = "right"; ctx.fillStyle = theme.textMuted; const sizeTxt = t.totalSize ? fmtBytes(t.totalSize) : ""; if (sizeTxt) ctx.fillText(sizeTxt, right - 6, mid); const sizeW = sizeTxt ? ctx.measureText(sizeTxt).width + 12 : 0; ctx.font = "12px ui-monospace, Menlo, monospace"; ctx.textAlign = "left"; ctx.fillStyle = theme.text; ctx.fillText(fit(ctx, t.name, right - (x + 18) - sizeW), x + 18, mid); // content region below the header const top = sy0 + headerH; if (sy1 - top < 8) return; // media files: the readable abstraction of an image IS the image β€” load // lazily once the band affords a thumbnail, draw letterboxed if (isImage(t) && opts.loadImage) { if (!t.img && !t.imgTried && h >= 48) loadImageFor(t); if (t.img) { drawImageBox(ctx, t, x + 8, top + 4, right - 8, sy1 - 6, env); return; } if (t.imgLoading) { ctx.font = "10px ui-monospace, Menlo, monospace"; ctx.fillStyle = theme.textFaint; ctx.fillText("loading…", x + 18, top + 10); return; } // failed / too small yet β†’ generic block below } // lazily pull the file's real text once its row is tall enough to read it // (only visible files reach here, and only readable-scale ones fetch) if (!t.lines.length && !t.tried && h >= 60 && isText(t)) loadContent(t); if (t.lines.length) drawContent(ctx, t, top, sy1, x, right, color, env); else if (t.loading) { ctx.font = "10px ui-monospace, Menlo, monospace"; ctx.fillStyle = theme.textFaint; ctx.fillText("loading…", x + 18, top + 10); } else if (h >= 90) { drawPreview(ctx, x + 18, top + 6, right - 6, sy1 - 8, color, theme); } } /** letterboxed image thumbnail, clipped to the viewport slice of the band */ function drawImageBox( ctx: CanvasRenderingContext2D, t: TNode, x0: number, y0: number, x1: number, y1: number, env: LaneEnv, ) { const img = t.img!; touchImgLru(t); const w = x1 - x0; const h = y1 - y0; if (w < 8 || h < 8 || !img.width || !img.height) return; const scale = Math.min(w / img.width, h / img.height, 4); // cap upscale 4Γ— const dw = img.width * scale; const dh = img.height * scale; const dx = x0 + (w - dw) / 2; const dy = y0 + (h - dh) / 2; const H = env.size.height; if (dy > H + 2 || dy + dh < -2) return; // fully off-screen ctx.save(); // clip to the visible slice so huge zoomed bands don't paint off-canvas ctx.beginPath(); ctx.rect(x0, Math.max(y0, -2), w, Math.min(y1, H + 2) - Math.max(y0, -2)); ctx.clip(); try { ctx.drawImage(img.frame(performance.now()), dx, dy, dw, dh); } catch { /* decoder hiccup β€” skip this frame */ } ctx.restore(); if (img.animated) scheduleAnimFrame(); } /** * Render the file's text: lines map uniformly onto the content region, one * per world unit, so line height β‰ˆ zoomY. Readable lines get real text (with * a line-number gutter and comment tinting); sub-readable lines fall back to * a code-minimap of length-proportional bars. Only visible lines are drawn. */ function drawContent( ctx: CanvasRenderingContext2D, t: TNode, top: number, bottom: number, x: number, right: number, color: string, env: LaneEnv, ) { const { theme } = env; const H = env.size.height; const lines = t.lines; const lineH = (bottom - top) / lines.length; if (lineH < 0.5) { ctx.fillStyle = withAlpha(theme.textFaint, 0.4); const a = Math.max(top, -2); ctx.fillRect(x + 6, a, right - x - 12, Math.min(bottom, H + 2) - a); return; } // visible line window const i0 = Math.max(0, Math.floor((-2 - top) / lineH)); const i1 = Math.min(lines.length - 1, Math.ceil((H + 2 - top) / lineH)); // full text only once every line is genuinely readable (β‰₯ font height); // the 0.8–13px band belongs to the minimap + progressive disclosure const textMode = lineH >= 13; const gx = x + 8; const gutterW = textMode ? 30 : 0; const tx = gx + gutterW; const font = lineH >= 15 ? 12 : 11; if (textMode) ctx.font = `${font}px ui-monospace, Menlo, monospace`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; for (let i = i0; i <= i1; i++) { const ly = top + i * lineH; const line = lines[i]!; if (!textMode) { // minimap: bar length ∝ line length, indent ∝ leading spaces const trimmed = line.trimStart(); if (!trimmed) continue; const indent = line.length - trimmed.length; const charW = Math.max(1, lineH * 0.55); const isComment = /^(\/\/|#|\*|