Spaces:
Running
Running
File size: 5,348 Bytes
bc5920a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | // 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 {}
}
}
|