Skip to content
File

Blob: src/shared/web/format.ts

typescript307 lines
1// Utility formatting and content helpers
2 
3/**
4 * Escapes HTML special characters to prevent XSS
5 * @param s - String to escape
6 * @returns HTML-safe string
7 */
8export function escapeHtml(s: string): string {
9 return s.replace(
10 /[&<>"]/g,
11 (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string
12 );
13}
14 
15/**
16 * Detects if content is binary by checking for non-text bytes
17 * @param bytes - Content to check
18 * @returns True if content appears to be binary
19 * @note Checks first 8KB for null bytes or control characters
20 */
21export function detectBinary(bytes: Uint8Array): boolean {
22 // Check first 8KB for null bytes or non-text characters
23 const checkLength = Math.min(8192, bytes.length);
24 for (let i = 0; i < checkLength; i++) {
25 const byte = bytes[i];
26 // Null byte or control characters (except tab, newline, carriage return)
27 if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
28 return true;
29 }
30 }
31 return false;
32}
33 
34/**
35 * Formats byte size into human-readable string
36 * @param bytes - Size in bytes
37 * @returns Formatted string (e.g., "1.5 MB")
38 */
39export function formatSize(bytes: number): string {
40 if (bytes < 1024) return bytes + " bytes";
41 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
42 return (bytes / (1024 * 1024)).toFixed(1) + " MB";
43}
44 
45/**
46 * Converts byte array to text, handling various encodings
47 * @param bytes - Raw bytes to decode
48 * @returns Decoded text string
49 * @note Handles UTF-8, UTF-16 LE/BE with BOM detection
50 */
51export function bytesToText(bytes: Uint8Array): string {
52 if (!bytes || bytes.byteLength === 0) return "";
53 // UTF-8 BOM
54 if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
55 return new TextDecoder("utf-8").decode(bytes.subarray(3));
56 }
57 // UTF-16 LE BOM
58 if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
59 try {
60 return new TextDecoder("utf-16le").decode(bytes.subarray(2));
61 } catch {}
62 }
63 // UTF-16 BE BOM
64 if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
65 try {
66 return new TextDecoder("utf-16be").decode(bytes.subarray(2));
67 } catch {}
68 }
69 // Default to UTF-8
70 try {
71 return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
72 } catch {
73 return "(binary content)";
74 }
75}
76 
77export function formatWhen(epochSeconds: number, tz: string): string {
78 try {
79 const offsetMatch = tz.match(/^([+-])(\d{2})(\d{2})$/);
80 const utcDate = new Date(epochSeconds * 1000);
81 if (!offsetMatch) {
82 const iso = utcDate.toISOString();
83 const noMs = iso.replace(/\.\d{3}Z$/, "Z");
84 return noMs.replace("T", " ").replace("Z", " UTC");
85 }
86 
87 const sign = offsetMatch[1] === "-" ? -1 : 1;
88 const hours = Number(offsetMatch[2]);
89 const minutes = Number(offsetMatch[3]);
90 const offsetMinutes = sign * (hours * 60 + minutes);
91 const localDate = new Date(utcDate.getTime() + offsetMinutes * 60_000);
92 const pad = (value: number) => String(value).padStart(2, "0");
93 
94 return (
95 `${localDate.getUTCFullYear()}-${pad(localDate.getUTCMonth() + 1)}-` +
96 `${pad(localDate.getUTCDate())} ${pad(localDate.getUTCHours())}:` +
97 `${pad(localDate.getUTCMinutes())}:${pad(localDate.getUTCSeconds())} ${tz}`
98 );
99 } catch {
100 return String(epochSeconds);
101 }
102}
103 
104export type FileIconName =
105 | "code"
106 | "database"
107 | "diff"
108 | "file"
109 | "folder"
110 | "image"
111 | "spreadsheet"
112 | "symlink"
113 | "terminal"
114 | "text";
115 
116/**
117 * Determines the appropriate icon name for a file based on its extension
118 * @param filename - The name of the file
119 * @returns File icon name
120 */
121export function getFileIconName(filename: string): FileIconName {
122 const defaultIcon: FileIconName = "file";
123 
124 // Get file extension (lowercase, without dot)
125 const ext = filename.split(".").pop()?.toLowerCase() || "";
126 
127 // Map extensions to the small shared icon set used by tree and blob views.
128 // Keep this intentionally coarse so uncommon extensions still get stable,
129 // recognizable icons without expanding the client icon bundle.
130 const iconMap: Record<string, FileIconName> = {
131 // JavaScript/TypeScript - specific filetype icons
132 js: "code",
133 mjs: "code",
134 cjs: "code",
135 jsx: "code",
136 ts: "code",
137 tsx: "code",
138 
139 // Web technologies - specific filetype icons
140 html: "code",
141 htm: "code",
142 css: "code",
143 scss: "code",
144 sass: "code",
145 less: "code",
146 vue: "code",
147 svelte: "code",
148 
149 // Programming languages - specific filetype icons where available
150 py: "code",
151 rb: "code",
152 go: "code",
153 java: "code",
154 c: "code",
155 h: "code",
156 cc: "code",
157 cpp: "code",
158 cxx: "code",
159 hpp: "code",
160 cs: "code",
161 php: "code",
162 swift: "code",
163 kt: "code",
164 rs: "code",
165 lua: "code",
166 dart: "code",
167 mm: "code",
168 pl: "code",
169 
170 // Shell scripts - specific filetype icon
171 sh: "terminal",
172 bash: "terminal",
173 zsh: "terminal",
174 ps1: "terminal",
175 psm1: "terminal",
176 
177 // Config/data files - specific filetype icons where available
178 json: "code",
179 jsonc: "code",
180 xml: "code",
181 yaml: "code",
182 yml: "code",
183 sql: "database",
184 toml: "code",
185 ini: "code",
186 cfg: "code",
187 conf: "code",
188 diff: "diff",
189 gradle: "code",
190 proto: "code",
191 
192 // Text/Documentation files - specific filetype icons where available
193 txt: "text",
194 md: "text",
195 markdown: "text",
196 mdx: "text",
197 rst: "text",
198 tex: "text",
199 log: "text",
200 
201 // Image files - specific filetype icons where available
202 jpg: "image",
203 jpeg: "image",
204 png: "image",
205 gif: "image",
206 svg: "image",
207 webp: "image",
208 ico: "image",
209 bmp: "image",
210 tiff: "image",
211 tif: "image",
212 psd: "image",
213 ai: "image",
214 raw: "image",
215 
216 // Documents - specific filetype icons where available
217 pdf: "text",
218 doc: "text",
219 docx: "text",
220 xls: "spreadsheet",
221 xlsx: "spreadsheet",
222 csv: "spreadsheet",
223 ppt: "text",
224 pptx: "text",
225 odt: "text",
226 ods: "spreadsheet",
227 odp: "text",
228 
229 // Archives - file-earmark-zip for all
230 zip: "file",
231 rar: "file",
232 tar: "file",
233 gz: "file",
234 "7z": "file",
235 bz2: "file",
236 xz: "file",
237 
238 // Media files - specific filetype icons where available
239 mp3: "file",
240 mp4: "file",
241 wav: "file",
242 aac: "file",
243 m4p: "file",
244 m4a: "file",
245 ogg: "file",
246 flac: "file",
247 
248 avi: "file",
249 mkv: "file",
250 mov: "file",
251 wmv: "file",
252 webm: "file",
253 flv: "file",
254 
255 // Font files - specific filetype icons where available
256 ttf: "file",
257 otf: "file",
258 woff: "file",
259 woff2: "file",
260 eot: "file",
261 
262 // Binary/executable files
263 exe: "file",
264 dll: "file",
265 so: "file",
266 dylib: "file",
267 wasm: "file",
268 key: "file",
269 heic: "image",
270 bin: "file",
271 };
272 
273 // Special cases for files without extensions or with special names
274 const specialFiles: Record<string, FileIconName> = {
275 readme: "text",
276 license: "text",
277 dockerfile: "code",
278 makefile: "code",
279 gemfile: "code",
280 rakefile: "code",
281 guardfile: "code",
282 procfile: "code",
283 gitignore: "code",
284 gitattributes: "code",
285 editorconfig: "code",
286 "package-lock": "file",
287 "yarn.lock": "file",
288 "pnpm-lock": "file",
289 tsconfig: "code",
290 webpack: "code",
291 babel: "code",
292 eslint: "code",
293 prettier: "code",
294 };
295 
296 // Check special files first (case-insensitive)
297 const filenameLower = filename.toLowerCase();
298 for (const [pattern, icon] of Object.entries(specialFiles)) {
299 if (filenameLower.includes(pattern)) {
300 return icon;
301 }
302 }
303 
304 // Check extension mapping
305 return iconMap[ext] || defaultIcon;
306}