Spaces:
Running
Running
Leandro von Werra
Files pane: previews for every file type, an editor that saves itself, traces, and create/delete (#15)
bc5920a unverified | // File previews for the Files pane: what kind of thing a file is, how to serve | |
| // it inline, and how to read the head of a text file without loading a gigabyte | |
| // into the process that is also pumping every terminal's PTY data. | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| // Text is read in full up to this size; beyond it the preview is truncated and | |
| // says so. Generous enough for real source files, small enough to stay cheap | |
| // on the object-storage bucket workspaces live on. | |
| export const TEXT_MAX = 512 * 1024; | |
| const IMAGE_MIME = { | |
| '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', | |
| '.webp': 'image/webp', '.avif': 'image/avif', '.bmp': 'image/bmp', '.ico': 'image/x-icon', | |
| '.svg': 'image/svg+xml', | |
| }; | |
| // Extensions we render as markdown rather than as plain text. | |
| const MARKDOWN_EXT = new Set(['.md', '.markdown', '.mdx']); | |
| const HTML_EXT = new Set(['.html', '.htm']); | |
| // Anything textual we're willing to show in the code viewer. Extension-based so | |
| // a 200 MB .csv doesn't get sniffed byte-by-byte; unknown extensions fall back | |
| // to a binary sniff below. | |
| const TEXT_EXT = new Set([ | |
| '.txt', '.text', '.log', '.json', '.jsonl', '.ndjson', '.yaml', '.yml', '.toml', '.ini', | |
| '.cfg', '.conf', '.env', '.properties', '.csv', '.tsv', '.xml', '.svgz', '.rst', '.org', | |
| '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.less', | |
| '.py', '.pyi', '.rb', '.go', '.rs', '.java', '.kt', '.kts', '.swift', '.c', '.h', '.cc', | |
| '.cpp', '.hpp', '.cs', '.m', '.mm', '.php', '.pl', '.lua', '.r', '.jl', '.scala', '.clj', | |
| '.ex', '.exs', '.erl', '.hs', '.ml', '.zig', '.nim', '.dart', '.vue', '.svelte', | |
| '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.sql', '.graphql', '.gql', | |
| '.dockerfile', '.gitignore', '.gitattributes', '.editorconfig', '.patch', '.diff', | |
| '.lock', '.mod', '.sum', '.makefile', '.mk', '.cmake', '.gradle', '.tf', '.tfvars', | |
| ]); | |
| // Files with no extension that are text by convention. | |
| const TEXT_NAMES = new Set([ | |
| 'dockerfile', 'makefile', 'license', 'licence', 'readme', 'notice', 'authors', | |
| 'changelog', 'contributing', 'codeowners', 'procfile', 'brewfile', 'justfile', | |
| 'rakefile', 'gemfile', 'pipfile', '.gitignore', '.gitattributes', '.editorconfig', | |
| '.bashrc', '.zshrc', '.profile', '.bash_profile', '.npmrc', '.nvmrc', '.env', | |
| ]); | |
| const MIME = { | |
| ...IMAGE_MIME, | |
| '.pdf': 'application/pdf', | |
| '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', | |
| '.json': 'application/json; charset=utf-8', | |
| '.css': 'text/css; charset=utf-8', | |
| '.csv': 'text/csv; charset=utf-8', | |
| '.xml': 'text/xml; charset=utf-8', | |
| }; | |
| const ext = (name) => path.extname(name).toLowerCase(); | |
| // The viewer kind for a name alone (no disk access): 'image' | 'markdown' | | |
| // 'html' | 'pdf' | 'text' | null. null = "ask the bytes" (see kindOfFile). | |
| export function kindOfName(name) { | |
| const e = ext(name); | |
| const base = name.toLowerCase(); | |
| if (IMAGE_MIME[e]) return 'image'; | |
| if (MARKDOWN_EXT.has(e)) return 'markdown'; | |
| if (HTML_EXT.has(e)) return 'html'; | |
| if (e === '.pdf') return 'pdf'; | |
| if (TEXT_EXT.has(e) || TEXT_NAMES.has(base) || TEXT_NAMES.has(base.replace(/^\./, ''))) return 'text'; | |
| return null; | |
| } | |
| // A NUL byte in the head is the classic "this is not text" tell — same heuristic | |
| // git uses. Also bail on a high share of other control bytes. | |
| function looksBinary(buf) { | |
| if (buf.includes(0)) return true; | |
| let odd = 0; | |
| for (const b of buf) if (b < 9 || (b > 13 && b < 32)) odd++; | |
| return buf.length > 0 && odd / buf.length > 0.1; | |
| } | |
| // Kind for a file on disk: extension first, then a 4 KB sniff for the | |
| // extensionless and the unknown, so `mysteryfile` still previews as text. | |
| export function kindOfFile(file) { | |
| const byName = kindOfName(path.basename(file)); | |
| if (byName) return byName; | |
| let fd; | |
| try { | |
| fd = fs.openSync(file, 'r'); | |
| const buf = Buffer.alloc(4096); | |
| const n = fs.readSync(fd, buf, 0, 4096, 0); | |
| return looksBinary(buf.subarray(0, n)) ? 'binary' : 'text'; | |
| } catch { | |
| return 'binary'; | |
| } finally { | |
| if (fd !== undefined) try { fs.closeSync(fd); } catch {} | |
| } | |
| } | |
| // Content type for serving a file inline. Text-ish things become text/plain so | |
| // the browser never sniffs something scriptable out of them; html/pdf/images | |
| // keep their real type because the viewer relies on it. Bytes we couldn't read | |
| // as anything are octet-stream — text/plain would be a lie the UI repeats. | |
| export function mimeOf(name, kind) { | |
| if (kind === 'binary') return 'application/octet-stream'; | |
| return MIME[ext(name)] || 'text/plain; charset=utf-8'; | |
| } | |
| // Read at most `max` bytes of a file as UTF-8. When truncated, cut back to the | |
| // last newline so the viewer never shows half a line (or half a code point). | |
| export function readTextHead(file, max = TEXT_MAX) { | |
| const size = fs.statSync(file).size; | |
| const fd = fs.openSync(file, 'r'); | |
| try { | |
| const want = Math.min(size, max); | |
| const buf = Buffer.alloc(want); | |
| const n = fs.readSync(fd, buf, 0, want, 0); | |
| let text = buf.subarray(0, n).toString('utf8'); | |
| const truncated = size > n; | |
| if (truncated) { | |
| const cut = text.lastIndexOf('\n'); | |
| if (cut > 0) text = text.slice(0, cut); | |
| } | |
| return { text, truncated, bytes: n }; | |
| } finally { | |
| try { fs.closeSync(fd); } catch {} | |
| } | |
| } | |