/** * fileIcons — extension/type → inline SVG icon mapping. * * Replaces the old emoji maps in GridView/ListView with a minimalist * line-icon set. Icons are hand-drawn 24×24 strokes on `currentColor`; * each family gets an accent via `--fe-icon-` (variables.css), * applied through the `fe-ficon--` class in base.css. * * Scope: what a listing row IS — its file type, plus the one STATE that * changes the picture rather than the caption (an encrypted folder, bottom of * this file). What a row DOES is `lib/actionIcons.ts`. * * ⚠ `.trash` and STORAGE rows used to be the documented exception here, and * they were drawn as 💾 and 🗑 by three separate copies of the same line in * GridView, GalleryView and ListView. On the multi-storage "My files" root — * the screen the sidebar now lands everybody on — that made a pixelated * floppy disk the first thing on the page, in a shape that changes between * Windows, macOS and Linux because it is a system font's idea of the glyph * rather than ours. They are families like any other now, so the card, the * tile and the row all get one definition (filex lesson #67). */ import { isTextualMime } from './serverRules'; export type IconFamily = | 'folder' | 'image' | 'video' | 'audio' | 'pdf' | 'doc' | 'sheet' | 'slides' | 'archive' | 'code' | 'text' // Not file types: the two rows a listing can hold that are PLACES rather // than things. They carry no extension, so `iconFamilyFor` recognises them // from the node instead. | 'storage' | 'trash' | 'unknown'; const EXT_FAMILIES: Record = {}; function reg(family: IconFamily, exts: string[]) { for (const e of exts) EXT_FAMILIES[e] = family; } reg('image', ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'heic', 'svg', 'ico', 'tiff', 'tif']); reg('video', ['mp4', 'webm', 'mov', 'mkv', 'avi', 'ogv', 'm4v']); reg('audio', ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus']); reg('pdf', ['pdf']); reg('doc', ['doc', 'docx', 'odt', 'rtf']); /* gorunum:v1-preview — `tsv` joins its twin. It was the one tabular extension * with no family at all (so: the "unknown" glyph, and invisible to the * Spreadsheet filter), while the text viewer has read it as a table since * v0.5 (`viewers/CsvViewer.vue` branches on `ext === 'tsv'`). */ reg('sheet', ['xls', 'xlsx', 'ods', 'csv', 'tsv']); reg('slides', ['ppt', 'pptx', 'odp']); reg('archive', ['zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'xz', 'zst']); /** * Packages: archives with a purpose (a Java archive, an Android or Python * package, an editor or browser extension, an installer). They take the * archive family - its icon, its colour, its filter - but keep their own name * in the Type column (`JAR`, `APK`), which says more than "Archive" would * (`typeLabelKey`). * * ⚠ 0.50: an app that lists what a package holds (pkglist) draws these as a * page, and with no family of their own they were the white square on a * white row the list's type badge is there to prevent, badge-less. */ const PACKAGE_EXTS: ReadonlySet = new Set([ 'jar', 'war', 'ear', 'aar', 'apk', 'aab', 'apks', 'xapk', 'ipa', 'whl', 'egg', 'vsix', 'nupkg', 'snupkg', 'xpi', 'crx', 'appx', 'appxbundle', 'msix', 'msixbundle', 'deb', 'rpm', 'snap', 'gem', 'crate', ]); reg('archive', [...PACKAGE_EXTS]); reg('code', [ 'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs', 'vue', 'py', 'go', 'rs', 'php', 'rb', 'java', 'kt', 'swift', 'c', 'cpp', 'h', 'hpp', 'cs', 'css', 'scss', 'less', 'html', 'htm', 'json', 'yml', 'yaml', 'xml', 'sh', 'bash', 'ps1', 'sql', 'toml', ]); reg('text', ['txt', 'md', 'markdown', 'log', 'ini', 'conf', 'cfg', 'env']); /** * The synthetic "a storage" row a listing can carry. * * The backend never sends `inode/storage`; FileExplorer mints these rows for * the multi-storage overview, and they are `type: 'dir'` with no folder behind * them — every mutation would 4xx. The test was written out longhand in three * view components, which is how a fourth surface came to be written without * it, so it is a function now and this file is its one home. */ export function isStorageRow(node: { mime_type?: string | null }): boolean { return node.mime_type === 'inode/storage'; } /** Pick the icon family for a listing node. */ export function iconFamilyFor(node: { type?: string; extension?: string | null; mime_type?: string | null; basename?: string | null; }): IconFamily { // ⚠ Both of these are also `type: 'dir'`, so they have to be answered // BEFORE the folder branch or they fall through to a plain folder — which // is what `HomeView` still does by asking for `{ type: 'dir' }` by hand. if (isStorageRow(node)) return 'storage'; if (node.basename === '.trash') return 'trash'; if (node.type === 'dir') return 'folder'; const ext = (node.extension || '').toLowerCase(); // #56: `LICENSE`, `Makefile` — no extension to go on, so the server's mime is // the fact that is left. Only then: an unmapped extension keeps its tier-3 // name ("ZIG") rather than being relabelled by a guessed mime. if (!ext) return isTextualMime(node.mime_type) ? 'text' : 'unknown'; return EXT_FAMILIES[ext] ?? 'unknown'; } // Shared fragments. The document families reuse one sheet-with-fold base so // the set reads as one system; media families get standalone shapes. const FILE_BASE = '' + ''; const GLYPHS: Record = { // Folder is the one "filled" member — a soft currentColor wash keeps it // visually anchored without breaking the line style. /** * gorunum:v1 — the folder is SOLID, and it is the one glyph that never * gets a tile. A folder is the most common thing in a listing; drawn as an * outline in the same weight as the file glyphs it disappears into them, * and the eye has to read the label to tell a folder from a document. Filled * and in its own colour it is recognisable before the name is read. */ folder: '', image: '' + '' + '', video: '' + '', audio: '' + '' + '', pdf: FILE_BASE + '', doc: FILE_BASE + '', sheet: FILE_BASE + '', slides: FILE_BASE + '', archive: FILE_BASE + '' + '', code: FILE_BASE + '', text: FILE_BASE + '', // The two-bay drive the sidebar already draws for the same storages, so a // drive is one shape across the panel rather than one per surface. storage: '' + '' + '', trash: '' + '' + '' + '', unknown: FILE_BASE + '' + '', }; const SVG_CACHE = new Map(); /** Inline SVG markup for a family (safe static strings — v-html friendly). */ export function iconSvg(family: IconFamily): string { let svg = SVG_CACHE.get(family); if (!svg) { svg = ``; SVG_CACHE.set(family, svg); } return svg; } /** Convenience: node → SVG markup in one call. */ export function fileIconSvg(node: Parameters[0]): string { return iconSvg(iconFamilyFor(node)); } const TILE_CACHE = new Map(); /** * gorunum:v1 — the listing badge: a filled, rounded square in the family's * colour with the glyph knocked out in white. * * Why not the bare coloured glyph it replaces: at 16px a line drawing in a * pale accent is a smudge, and twelve of them down a column read as noise * rather than as twelve kinds of file. A solid chip of colour is legible at a * glance, holds its shape at any density, and gives the row a consistent left * edge whether the file has a thumbnail or not. * * The folder is the exception and returns its solid glyph untiled — a folder * is not a file type, and boxing it makes a listing look like a colour chart. */ export function iconTile(family: IconFamily): string { if (family === 'folder') { return `${iconSvg(family)}`; } let tile = TILE_CACHE.get(family); if (!tile) { tile = `${iconSvg(family)}`; TILE_CACHE.set(family, tile); } return tile; } /** Convenience: node → tile markup in one call. */ export function fileIconTile(node: Parameters[0]): string { /* wiring:e2 fxe — a single encrypted file: the padlock is its tile. */ if (isEncryptedFile(node)) return encryptedFileTile(); return iconTile(iconFamilyFor(node)); } /* wiring:e2 fxe --------------------------------------------------------- * A single encrypted file (`.fxe`, docs/E2E-ENCRYPTION.md → "Single encrypted * files") is a state of a file, like the encrypted folder is a state of a * folder: it keeps the sheet-with-fold of every document and carries the * padlock cut out of it. Not an IconFamily, for the same reason as the folder * (lib/fileFilters filters by family). */ /** True for a row that is a single encrypted file. */ export function isEncryptedFile(node: { type?: string; extension?: string | null; basename?: string | null }): boolean { if (node.type === 'dir') return false; if ((node.extension || '').toLowerCase() === 'fxe') return true; return typeof node.basename === 'string' && node.basename.toLowerCase().endsWith('.fxe') && node.basename.length > 4; } const LOCKED_FILE = ''; let LOCKED_FILE_TILE = ''; /** Tile markup for a single encrypted file. */ export function encryptedFileTile(): string { if (!LOCKED_FILE_TILE) { LOCKED_FILE_TILE = '' + '`; } return LOCKED_FILE_TILE; } /* ====================================================================== * ikon:emoji — the one listing marker that is a STATE, not a type. * * An end-to-end encrypted folder (wiring:e2) is a folder like any other; what * is special about it is a fact, not a kind, so it cannot be an `IconFamily` * — `iconFamilyFor` is the taxonomy `lib/fileFilters` filters by, and moving * these rows out of `folder` would quietly drop them from the Folders filter. * It lives beside the families instead, as one definition. * * ⚠ It was three. `specialEmojiFor(n) { if (n.type === 'dir' && n.e2e) return * '🔒' }` was copy-pasted verbatim into ListView, GridView and GalleryView — * the exact shape of the thing the duplicate-code gate exists to stop, and * the second time this file has had to absorb it (the first was `.trash` and * the storage row, drawn as 🗑 and 💾 by the same three copies). * ====================================================================== */ /** True for a folder that is end-to-end encrypted. */ export function isEncryptedFolder(node: { type?: string; e2e?: boolean }): boolean { return node.type === 'dir' && node.e2e === true; } /** * The solid folder with a padlock cut OUT of it. * * One path, `fill-rule="evenodd"`: the shackle and the body are subpaths * inside the folder outline, so they become holes and show whatever the tile * sits on. That is deliberate — the alternative (drawing the lock in a * background colour) picks one background and is wrong on the other two * surfaces this tile appears on. * * ⚠ It replaces a 🔒 that replaced the folder entirely, so the row lost the * one thing it had always said: that it is a folder. Both facts are here now, * and the callers give the span an `aria-label` for the half a shape cannot * carry. */ const LOCKED_FOLDER = ''; let LOCKED_FOLDER_TILE = ''; /** * Tile markup for an encrypted folder — `fe-ftile--folder`'s own box, so it * is the same size, colour and (lack of) chip as every other folder in the * column; only the shape differs. */ export function encryptedFolderTile(): string { if (!LOCKED_FOLDER_TILE) { LOCKED_FOLDER_TILE = '' + '`; } return LOCKED_FOLDER_TILE; } /* ====================================================================== * gorunum:v1-preview — what the thing IS, said in words. * * The Type column used to print the raw extension in caps: `TS`, `CSV`, * `JPG`. That is not a kind, it is a suffix — it answers "what did the file * get called" when the question the column asks is "what is this". The * reference shell prints `TypeScript`, `Spreadsheet`, `Image`. * * ⚠ ONE table, here, beside the extension → family map it is built on top of * (filex lesson #67). Every surface that names a kind — the Type column * today, an info panel tomorrow — calls `typeLabelFor` and gets the same * answer; a second table beside it would disagree the first week somebody * added an extension to only one of them. * * Three tiers, in order: * 1. the extension has a name of its own → `ftype.typescript` * 2. otherwise its FAMILY has one → `ftype.code`, `ftype.image` * 3. neither (family `unknown`) → the uppercased extension * * Tier 3 is the deliberate fallback the brief asks for: an unmapped `.zig` * reads "ZIG", which is at least the truth, rather than an empty cell or a * confident lie like "File". * ====================================================================== */ /** Extension → its own i18n key. Only extensions whose NAME differs from * what the family would say need an entry here. */ const EXT_TYPE_KEYS: Record = { // Languages the person reading the column thinks of by name. ts: 'ftype.typescript', tsx: 'ftype.typescript', mts: 'ftype.typescript', cts: 'ftype.typescript', js: 'ftype.javascript', jsx: 'ftype.javascript', mjs: 'ftype.javascript', cjs: 'ftype.javascript', vue: 'ftype.vue', py: 'ftype.python', go: 'ftype.go', rs: 'ftype.rust', php: 'ftype.php', rb: 'ftype.ruby', java: 'ftype.java', kt: 'ftype.kotlin', swift: 'ftype.swift', c: 'ftype.c', h: 'ftype.c', cpp: 'ftype.cpp', hpp: 'ftype.cpp', cs: 'ftype.csharp', // Structured text that is code by family but not a language. css: 'ftype.stylesheet', scss: 'ftype.stylesheet', less: 'ftype.stylesheet', html: 'ftype.html', htm: 'ftype.html', json: 'ftype.json', yml: 'ftype.yaml', yaml: 'ftype.yaml', xml: 'ftype.xml', toml: 'ftype.toml', sql: 'ftype.sql', sh: 'ftype.shell', bash: 'ftype.shell', zsh: 'ftype.shell', ps1: 'ftype.powershell', // Plain-text family members that are each their own thing. md: 'ftype.markdown', markdown: 'ftype.markdown', txt: 'ftype.plaintext', log: 'ftype.log', ini: 'ftype.config', conf: 'ftype.config', cfg: 'ftype.config', env: 'ftype.config', // Tabular: a `.csv` IS a spreadsheet to the person looking at the column, // whatever the program that wrote it was. csv: 'ftype.sheet', tsv: 'ftype.sheet', // Design files. No icon family of their own (they fall to `unknown`, which // is a decision for the GLYPH, not for the word) — naming them still beats // printing "FIG". fig: 'ftype.figma', // ⚠ Named, not "DRAWIO": the uppercased extension was a fourth spelling of // draw.io beside "Drawio", "drawio" and "diagrams.net" (QA, 2026-09-21). drawio: 'ftype.drawio', dio: 'ftype.drawio', sketch: 'ftype.design', xd: 'ftype.design', psd: 'ftype.design', ai: 'ftype.design', }; /** Family → i18n key, the second tier. `unknown` is absent on purpose: it is * what sends a node to the uppercased-extension fallback. */ const FAMILY_TYPE_KEYS: Partial> = { folder: 'node.folder', image: 'ftype.image', video: 'ftype.video', audio: 'ftype.audio', pdf: 'ftype.pdf', doc: 'ftype.document', sheet: 'ftype.sheet', slides: 'ftype.slides', archive: 'ftype.archive', code: 'ftype.code', text: 'ftype.plaintext', }; /** * The i18n key naming this node's kind, or null when nothing maps it and the * caller should fall back to the extension. Directories answer * `node.folder` — the key the grid card's caption already prints, not a * second string saying the same word. */ export function typeLabelKey(node: { type?: string; extension?: string | null; mime_type?: string | null; }): string | null { if (node.type === 'dir') return 'node.folder'; const ext = (node.extension || '').trim().toLowerCase(); if (ext === 'fxe') return 'e2e.fxe.type'; /* wiring:e2 fxe */ const own = EXT_TYPE_KEYS[ext]; if (own) return own; // A package is an archive by family, but its own name says more (PACKAGE_EXTS). if (PACKAGE_EXTS.has(ext)) return null; return FAMILY_TYPE_KEYS[iconFamilyFor(node)] ?? null; } /** * The finished words for the Type column and anything else that names a * kind: the mapped name, else the uppercased extension, else an em dash. * * ⚠ The fallback lives HERE rather than at each call site. Two callers each * writing `key ? t(key) : ext.toUpperCase()` is two chances to render an * empty cell, and the first one to forget the `|| '—'` prints nothing at all * for a file with no extension. */ export function typeLabelFor( node: { type?: string; extension?: string | null; mime_type?: string | null }, t: (key: string) => string, ): string { const key = typeLabelKey(node); if (key) return t(key); return extensionLabel(node) || '-'; } /** * The extension in capitals (`DOCX`, `PDF`), or '' when there is none: the * Type column's third-tier fallback above and the list view's type badge on a * page thumbnail (lib/filePreview `thumbTypeBadge`) both read it here, so the * two never spell a kind differently. */ export function extensionLabel(node: { extension?: string | null }): string { return (node.extension || '').trim().toUpperCase(); }