/** * filePreview — gorunum:v1-preview. * * The first lines of the REAL file, drawn inside the card's preview box when * there is nothing better to put there. * * ## Why this exists at all * * The backend already answers `thumb_url` for a `.ts` or a `.csv` — but what * it renders for them is `internal/thumb/generic.go`: a card tinted from a * hash of the extension with the letters "TS" drawn in the middle. Measured * on the local backend, `GET /api/files/thumb/6` (app.ts) is a 1,745-byte * JPEG of exactly that. So the card was already spending a request to be told * the same thing its own footer tile says, in a colour nobody chose. * * A code file's first five lines say what it is in a way no glyph can. That * is the whole idea: for the kinds we can read as text we skip the generic * thumbnail entirely and fetch a few kilobytes of the file instead — the same * number of requests, a preview that means something. * * ⚠ This never competes with a REAL thumbnail. The kinds it claims (code, * plain text, csv/tsv) are precisely the kinds this backend cannot render a * content-derived thumbnail for; images, video, audio and PDFs are untouched * and keep their ``. * * ## What bounds the cost * * Four independent limits, because any one of them alone is a promise rather * than a bound: * * 1. **Visibility.** Nothing is fetched until the card intersects the * viewport (IntersectionObserver, 150px of margin so a scroll does not * chase the reader). ⚠ `useThumbs` does NOT do this — it fires on first * render for every row the parent hands it — so there was nothing to * reuse here; a 400-file folder would have been 400 reads. * 2. **A ranged request.** `Range: bytes=0-8191`. The preview endpoint * answers 206 through `http.ServeContent` whenever the driver can seek * (`vfStream`, manager.go). * 3. **A bounded read.** ⚠ A driver that cannot seek answers 200 with the * WHOLE body and `Accept-Ranges: none` — the Range header is a request, * not a guarantee. So the body is consumed chunk by chunk and the reader * is cancelled the moment we have enough. This, not the header, is what * actually caps the bytes. * 4. **A file-size ceiling + a concurrency cap.** 4 MB and 3 in flight. * * Results are cached per path AND per version (etag, else mtime, else size), * so an edited file re-reads and an unchanged one never does. */ import { ref } from 'vue'; import type { FileNode } from '../types/FileNode'; import { iconFamilyFor } from './fileIcons'; import { resolveEndpoints } from '../composables/useFileApi'; /** What shape the preview takes. `null` = this node has no text preview. */ export type PreviewKind = 'code' | 'text' | 'table'; /** * One rendered line. Split in three so the view can print it WITHOUT * `v-html` — the content is somebody's file, and interpolation is the only * honest way to put it on screen. */ export interface PreviewLine { /** Leading whitespace, kept so nesting is visible. */ indent: string; /** The opening token when it is worth tinting ('' = leave it plain). */ tint: string; /** Everything after it, verbatim. */ text: string; } export interface FilePreview { kind: PreviewKind; /** `code` / `text`. Empty for a table. */ lines: PreviewLine[]; /** * `table` only: first rows × first columns, already clipped and PADDED to a * rectangle. The card lays them out as one CSS grid, so a short row has to * carry its empty cells or every row below it shifts a column left. */ rows: string[][]; /** `table` only: the grid's column count (0 otherwise). */ cols: number; } /* ---------------------------------------------------------------- limits */ /** Bytes asked for, and the hard cap on bytes actually read. */ const MAX_BYTES = 8 * 1024; /** Files bigger than this are left alone. See the header: the streaming * cancel is the real bound, this is the "never surprise the operator whose * driver cannot seek" guard. */ const MAX_FILE_BYTES = 4 * 1024 * 1024; /** Concurrent reads. Three keeps a fast scroll responsive without turning a * folder of scripts into a thundering herd against one S3 bucket. */ const MAX_INFLIGHT = 3; /** * How much is parsed — NOT how much is shown. * * ⚠ These used to be 10 lines / 6 rows / 4 columns, and those numbers were * chosen for one box: the 184×108 grid card. They are not that box's height * in any other view, and a fixed count is the wrong shape of answer anyway. * Measured on 2026-09-12, before this change: * * grid card 184×108 · 10 lines parsed, ~9 fit → close enough * gallery tile 220×220 · 10 lines parsed, 15 fit → the bottom * HALF of every * tile was empty * csv, either · 6 rows × 4 cols whatever the box was, and budget.csv has * six columns, so two of them were simply never shown * * The owner's instruction is "as much as we can fit" (2026-09-12), so the box * decides: the parser hands over more than any box can show and the box * clips what it cannot, which is what `overflow: hidden` and the bottom fade * on `.fe-fprev` have always been for. These are therefore CEILINGS on work * and DOM, not layout constants — big enough for the largest tile we draw * with room over it, small enough that a 400-file folder stays cheap. */ const MAX_LINES = 24; /** Characters kept per line. The box clips long lines anyway; this bounds * the DOM when a minified file arrives as one 8 KB line. */ const MAX_LINE_CHARS = 120; const MAX_ROWS = 16; /** Columns parsed. How many are DRAWN follows the box: the grid lays the * cells out on tracks with a readable minimum width and clips the overflow, * so a wide tile shows more of the table than a narrow one without either * view knowing anything the other does not. */ const MAX_COLS = 8; /** Cache ceiling, mirroring useThumbs' own. Parsed previews are a few hundred * bytes each, so this is generous. */ const MAX_CACHED = 400; /* ------------------------------------------------------------ can we read */ /** The two tabular extensions. `sheet` as a FAMILY also holds xls/xlsx/ods, * which are zip containers — reading their first bytes gives binary. */ const TABLE_EXTS = new Set(['csv', 'tsv']); /** * Is this row a deleted thing? * * ⚠ Two fields, because the two ways filex shows you a deleted file disagree. * A normal listing marks a soft-deleted row with `trashed`. The TRASH VIEW * does not: `FileExplorer.loadTrash` builds its rows from `TrashEntry` by hand * and that literal sets neither `trashed` nor `size` — it writes `file_size` * and stamps `extra_metadata.deleted_at`. * * Which means the size ceiling below was already excluding the whole trash * view, by accident, because `size` came back undefined. That is not a guard, * it is a coincidence one line in an unrelated file would end: the trash row's * `path` points at the trash KEY, not at the file it used to be, so a content * read there fetches a 404 for every row in the folder. */ function isTrashRow(node: FileNode): boolean { if (node.trashed) return true; const meta = node.extra_metadata; return !!meta && typeof meta === 'object' && 'deleted_at' in meta; } /** * Which preview this node could have, ignoring whether anything is wired up. * * Built on `iconFamilyFor` rather than on a second extension list: the * families ARE the taxonomy (filex lesson #67), so a new extension registered * for the icon set becomes previewable in the same edit. `svg` belongs to the * `image` family and is therefore excluded here — it is text, but it has a * real thumbnail, and the real one wins. */ export function previewKindFor(node: FileNode): PreviewKind | null { if (node.type !== 'file') return null; if (node.basename === '.trash' || node.mime_type === 'inode/storage') return null; if (isTrashRow(node)) return null; // ⚠ A node with NO size is excluded too, not just an empty one: the size is // what the 4 MB ceiling is checked against, and "we don't know" is not a // basis for deciding to read a file. Every row `projectFileNodes` emits // carries one. const size = node.size ?? 0; if (size <= 0 || size > MAX_FILE_BYTES) return null; const ext = (node.extension || '').trim().toLowerCase(); if (TABLE_EXTS.has(ext)) return 'table'; const family = iconFamilyFor(node); if (family === 'code') return 'code'; if (family === 'text') return 'text'; return null; } /** * Is this node's thumbnail a PAGE — the first page of a document, rather than * a picture that was already the shape it wanted to be? * * It matters because of where the card crops. A thumbnail box is landscape * (184×108 in the grid) and a page is portrait, so `object-fit: cover` has to * throw away most of the height, and WHICH part it throws away is the whole * question. Centred — the default, and what this did before — discards the * top, which on a document is the letterhead, the title and the date: the * only part that says which document it is. Measured on 2026-09-12 against a * reference deployment carrying the same defect, five different PDFs sharing * one template produced five cards nobody could tell apart, because the only * thing that differed between them was the title that had been cropped away. * * So pages anchor to the top and photographs stay centred, and this is the * one place that decides which is which. Built on `iconFamilyFor` rather than * a private extension list, for the same reason the preview kinds are: the * families ARE the taxonomy (filex lesson #67). */ export function drawsAsPage(node: FileNode): boolean { if (node.type !== 'file') return false; switch (iconFamilyFor(node)) { case 'pdf': case 'doc': case 'sheet': case 'slides': return true; default: return false; } } /** * Is this node's thumbnail a still taken from a MOVING picture? * * A frame lifted out of a video is, on a card, indistinguishable from a * photograph — which is the one thing a person needs to know before they * click it. The views draw a play badge over these, so the answer lives here * rather than in each of them. */ export function drawsAsVideo(node: FileNode): boolean { return node.type === 'file' && iconFamilyFor(node) === 'video'; } /* --------------------------------------------------------------- parsing */ /** * Openers worth a tint. Deliberately small and deliberately language-agnostic: * the brief's own instruction is that plain text beats fake colour, so a token * is tinted only when it really is a keyword. A JSON file opening on `{`, or a * YAML file opening on a key, gets no colour at all — which is the correct * answer, not a missing feature. */ const KEYWORDS = new Set([ 'import', 'export', 'from', 'require', 'include', 'use', 'using', 'package', 'module', 'namespace', 'const', 'let', 'var', 'function', 'func', 'fn', 'def', 'class', 'struct', 'enum', 'interface', 'type', 'impl', 'trait', 'public', 'private', 'protected', 'static', 'async', 'await', 'return', 'if', 'else', 'for', 'while', 'switch', 'case', 'try', 'catch', 'select', 'insert', 'update', 'delete', 'create', 'alter', 'drop', ]); /** Markdown-ish line markers — the only thing tinted in a `text` preview. */ const TEXT_MARKER = /^(#{1,6}|[-*+]|>|\d+\.)$/; /** Strip the punctuation a keyword is usually wearing (`const,` `if(` `def:`). */ function bareToken(token: string): string { return token.replace(/[(){}[\];:,.]+$/, '').toLowerCase(); } function toLine(raw: string, kind: PreviewKind): PreviewLine { const clipped = raw.length > MAX_LINE_CHARS ? raw.slice(0, MAX_LINE_CHARS) : raw; const m = /^(\s*)(\S+)([\s\S]*)$/.exec(clipped); if (!m) return { indent: '', tint: '', text: clipped }; const [, indent, first, rest] = m; const tintable = kind === 'code' ? KEYWORDS.has(bareToken(first)) : TEXT_MARKER.test(first); if (!tintable) return { indent: '', tint: '', text: clipped }; return { indent, tint: first, text: rest }; } /** * Split one delimited row into cells, honouring `"…"` quoting (and `""` as an * escaped quote) so a value containing the separator does not become two * columns. Small on purpose — this draws six rows on a 184px card, it is not * a CSV parser and must never grow into one. */ export function splitCells(line: string, sep: string): string[] { const out: string[] = []; let cur = ''; let quoted = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (quoted) { if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else { quoted = false; } } else { cur += c; } } else if (c === '"') { quoted = true; } else if (c === sep) { out.push(cur); cur = ''; } else { cur += c; } } out.push(cur); return out; } /** `,` normally; `\t` for a .tsv; `;` when the first row clearly uses it — * the shape Excel writes in most of Europe, Turkey included. */ function separatorFor(ext: string, firstLine: string): string { if (ext === 'tsv') return '\t'; if (!firstLine.includes(',') && firstLine.includes(';')) return ';'; return ','; } /** * Bytes → a drawable preview, or null when the bytes are not text after all * (a `.json` that is really a binary blob, ciphertext, a mislabelled file). * * `truncated` says the byte cap cut the stream, in which case the last line is * dropped: half a line of source looks like a bug, not like a preview. */ export function parsePreview( bytes: Uint8Array, kind: PreviewKind, ext: string, truncated: boolean, ): FilePreview | null { // A NUL in the first few KB is the cheapest reliable "this is not text". for (let i = 0; i < bytes.length; i++) { if (bytes[i] === 0) return null; } const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes); // A decode full of replacement characters is binary wearing a text // extension — or a file in an encoding we are not going to guess at. let bad = 0; for (const ch of text) if (ch === '�') bad++; if (text.length > 0 && bad / text.length > 0.1) return null; const lines = text.split(/\r\n|\r|\n/); if (truncated && lines.length > 1) lines.pop(); // Blank lines at either end waste a box that only holds eight — and every // text file that ends in a newline has one. Interior blanks stay: they are // part of how the file looks, which is the whole point. while (lines.length && lines[0].trim() === '') lines.shift(); while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); if (lines.length === 0) return null; if (kind === 'table') { const sep = separatorFor(ext, lines[0]); const rows = lines .filter((l) => l.trim() !== '') .slice(0, MAX_ROWS) .map((l) => splitCells(l, sep).slice(0, MAX_COLS).map((c) => c.trim().slice(0, 24))); if (rows.length === 0) return null; const cols = rows.reduce((n, r) => Math.max(n, r.length), 0); for (const r of rows) while (r.length < cols) r.push(''); return { kind, lines: [], rows, cols }; } return { kind, lines: lines.slice(0, MAX_LINES).map((l) => toLine(l, kind)), rows: [], cols: 0, }; } /* ---------------------------------------------------------------- loading */ export interface PreviewLoaderOptions { /** Same prefix the explorer hands the views. `undefined` = no API wired * (a legacy embedder on an explicit `endpoint`) → the loader stays off. */ apiBase?: string; /** * ⚠ ASYNC, and it is the whole reason this signature is a function and not * an object. Spreading a Promise into a headers literal yields `{}` — the * request goes out with no Authorization and 401s with nothing in the * console. Every call site here `await`s it. */ authHeaders?: () => Record | Promise>; credentials?: RequestCredentials; /** * Answered on every decision, not once at construction: "is reading file * bytes off right now?". The explorer passes its encrypted-folder state — * inside one, every body on the wire is ciphertext, and fetching 8 KB of it * to discover that it does not decode is a request spent to learn nothing. */ disabled?: () => boolean; } export interface FilePreviewLoader { /** False when nothing is wired — callers must then keep their old markup. */ readonly enabled: boolean; /** The kind this node would get, or null (including when disabled). */ kindFor: (node: FileNode) => PreviewKind | null; /** Reactive read. null = not loaded (yet), or never. */ get: (node: FileNode) => FilePreview | null; /** Template-ref sink: hand it the preview box and its node. */ bind: (el: Element | null, node: FileNode) => void; /** In-flight + queued, for tests and measurement. */ stats: () => { loaded: number; inflight: number; queued: number; failed: number }; dispose: () => void; } /** path + version: an edited file re-reads, an untouched one never does. */ function keyOf(node: FileNode): string { const etag = typeof node.etag === 'string' ? node.etag : ''; const version = etag || String(node.last_modified ?? '') || String(node.size ?? ''); return `${node.path}${version}`; } export function createFilePreviews(opts: PreviewLoaderOptions): FilePreviewLoader { const enabled = opts.apiBase !== undefined && typeof opts.authHeaders === 'function' && typeof IntersectionObserver !== 'undefined' && typeof fetch === 'function'; const cache = ref>({}); const order: string[] = []; const failed = new Set(); const queued = new Set(); const queue: FileNode[] = []; const controllers = new Set(); /** element → the key it is currently observed for, so a re-render of the * same card does not re-observe and a recycled row does. */ const bound = new WeakMap(); let inflight = 0; let observer: IntersectionObserver | null = null; /** The observed element's node, kept until the callback fires. */ const watching = new Map(); function previewUrl(path: string): string { // resolveEndpoints is the one place that knows how apiBase becomes a // manager URL; `?q=` + `&action=` is the pair every other caller sends. const manager = resolveEndpoints({ apiBase: opts.apiBase }).manager; const sep = manager.includes('?') ? '&' : '?'; const query = new URLSearchParams({ q: 'preview', action: 'preview', path }); return `${manager}${sep}${query.toString()}`; } function remember(key: string, value: FilePreview) { if (order.length >= MAX_CACHED) { const evict = order.shift(); if (evict) { const next = { ...cache.value }; delete next[evict]; cache.value = next; } } order.push(key); cache.value = { ...cache.value, [key]: value }; } /** * Read at most `MAX_BYTES` of the body, whatever the server decided to send. * A 206 is already short; a 200 from a driver that cannot seek is the whole * file, and cancelling the reader is what stops it. */ async function readHead(res: Response): Promise<{ bytes: Uint8Array; truncated: boolean }> { const body = res.body; if (!body) { const buf = new Uint8Array(await res.arrayBuffer()); return { bytes: buf.slice(0, MAX_BYTES), truncated: buf.length > MAX_BYTES }; } const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { while (total < MAX_BYTES) { const { done, value } = await reader.read(); if (done) break; if (value && value.length) { chunks.push(value); total += value.length; } } } finally { try { await reader.cancel(); } catch { /* already closed */ } } const joined = new Uint8Array(Math.min(total, MAX_BYTES)); let at = 0; for (const c of chunks) { if (at >= joined.length) break; joined.set(c.subarray(0, joined.length - at), at); at += c.length; } return { bytes: joined, truncated: total >= MAX_BYTES }; } async function load(node: FileNode, kind: PreviewKind): Promise { const key = keyOf(node); const ctrl = new AbortController(); controllers.add(ctrl); try { // ⚠ The await. Without it this object holds a Promise under // `authHeaders`, fetch drops it, and the request 401s silently. const base = opts.authHeaders ? await opts.authHeaders() : {}; const res = await fetch(previewUrl(node.path), { headers: { ...base, Accept: '*/*', Range: `bytes=0-${MAX_BYTES - 1}` }, credentials: opts.credentials, signal: ctrl.signal, }); if (!res.ok && res.status !== 206) throw new Error(String(res.status)); const { bytes, truncated } = await readHead(res); ctrl.abort(); const ext = (node.extension || '').trim().toLowerCase(); const parsed = parsePreview(bytes, kind, ext, truncated); if (!parsed) { failed.add(key); return; } remember(key, parsed); } catch { // 403/404/offline/not-really-text — the card keeps its type tile, and // we do not retry inside this session (same policy as useThumbs). failed.add(key); } finally { controllers.delete(ctrl); } } function pump() { while (inflight < MAX_INFLIGHT && queue.length > 0) { const node = queue.shift()!; const kind = previewKindFor(node); queued.delete(keyOf(node)); if (!kind) continue; inflight++; void load(node, kind).finally(() => { inflight--; pump(); }); } } function enqueue(node: FileNode) { const key = keyOf(node); if (cache.value[key] || failed.has(key) || queued.has(key)) return; queued.add(key); queue.push(node); pump(); } function ensureObserver(): IntersectionObserver | null { if (!enabled) return null; if (!observer) { observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; const node = watching.get(entry.target); observer?.unobserve(entry.target); watching.delete(entry.target); if (node) enqueue(node); } }, { rootMargin: '150px' }, ); } return observer; } function kindFor(node: FileNode): PreviewKind | null { if (!enabled) return null; if (opts.disabled?.()) return null; return previewKindFor(node); } return { enabled, kindFor, get(node: FileNode): FilePreview | null { if (!enabled) return null; return cache.value[keyOf(node)] ?? null; }, bind(el: Element | null, node: FileNode) { if (!el || !kindFor(node)) return; const key = keyOf(node); if (bound.get(el) === key) return; bound.set(el, key); if (cache.value[key] || failed.has(key)) return; const io = ensureObserver(); if (!io) return; watching.set(el, node); io.observe(el); }, stats: () => ({ loaded: order.length, inflight, queued: queue.length, failed: failed.size, }), dispose() { observer?.disconnect(); observer = null; watching.clear(); for (const c of controllers) c.abort(); controllers.clear(); queue.length = 0; queued.clear(); }, }; }