1/* oxlint-disable react/no-array-index-key -- Read-only cells retain their database row and column positions. */
2import {
3 decodeFilePreviewText,
4 filePreviewKind,
5 hostPreviewMimeTypeFromExtension,
6} from "@t3tools/shared/filePreview";
7import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
8import { ChevronLeftIcon, ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react";
9import { lazy, Suspense, useEffect, useRef, useState } from "react";
10
11import {
12 Select,
13 SelectTrigger,
14 SelectValue,
15 SelectPopup,
16 SelectItem,
17} from "~/components/ui/select";
18
19import {
20 FileSurfaceAction,
21 FileSurfaceFailure,
22 FileSurfaceLoading,
23 FileSurfaceNotice,
24} from "./fileSurfaceChrome";
25import {
26 SQLITE_PAGE_SIZE,
27 type BinaryWorkerRequest,
28 type BinaryWorkerResult,
29} from "./binaryPreviewProtocol";
30import { PreviewImage } from "./PreviewImage";
31import { readBinaryPreview } from "./readBinaryPreview";
32
33const SourcePreview = lazy(() => import("./ReadOnlySourcePreview"));
34type Archive = Extract<BinaryWorkerResult, { type: "archive" }>;
35type Database = Extract<BinaryWorkerResult, { type: "sqlite" }>;
36type Rows = Extract<BinaryWorkerResult, { type: "rows" }>;
37
38function ArchiveFile(props: { readonly path: string; readonly bytes: Uint8Array<ArrayBuffer> }) {
39 const [url, setUrl] = useState<string | null>(null);
40 const kind = filePreviewKind({ name: props.path });
41 const text = (() => {
42 if (!["text", "markdown", "html", "mermaid"].includes(kind)) return null;
43 try {
44 return decodeFilePreviewText(props.bytes);
45 } catch {
46 return null;
47 }
48 })();
49 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Create an object URL for the extracted image and revoke it when the entry changes or unmounts.
50 useEffect(() => {
51 if (kind !== "image") return;
52 const extension = props.path.slice(props.path.lastIndexOf("."));
53 const objectUrl = URL.createObjectURL(
54 new Blob([props.bytes], {
55 type: hostPreviewMimeTypeFromExtension(extension) ?? "application/octet-stream",
56 }),
57 );
58 // oxlint-disable-next-line react/set-state-in-effect -- Publish a Blob URL only after registering its cleanup.
59 setUrl(objectUrl);
60 return () => URL.revokeObjectURL(objectUrl);
61 }, [props.bytes, props.path, kind]);
62 if (kind === "image")
63 return url ? <PreviewImage key={url} src={url} name={props.path} /> : <FileSurfaceLoading />;
64 if (!text)
65 return (
66 <FileSurfaceFailure message="Only UTF-8 text and images can be opened inside an archive." />
67 );
68 return (
69 <>
70 {text.truncated ? (
71 <FileSurfaceNotice>Preview limited to the first 1 MB.</FileSurfaceNotice>
72 ) : null}
73 <Suspense fallback={<FileSurfaceLoading />}>
74 <SourcePreview name={props.path} text={text.text} />
75 </Suspense>
76 </>
77 );
78}
79
80export default function BinaryViewer(props: {
81 readonly src: string;
82 readonly name: string;
83 readonly kind: "archive" | "sqlite";
84 readonly onRetry?: () => void;
85}) {
86 const [sizeBytes, setSizeBytes] = useState<number | null>(null);
87 const [archive, setArchive] = useState<Archive | null>(null);
88 const [database, setDatabase] = useState<Database | null>(null);
89 const [rows, setRows] = useState<Rows | null>(null);
90 const [entry, setEntry] = useState<Extract<BinaryWorkerResult, { type: "entry" }> | null>(null);
91 const [folder, setFolder] = useState("");
92 const [selectedPath, setSelectedPath] = useState<string | null>(null);
93 const [selectedTable, setSelectedTable] = useState("");
94 const [page, setPage] = useState(0);
95 const [busy, setBusy] = useState(true);
96 const [error, setError] = useState<string | null>(null);
97 const workerRef = useRef<Worker | null>(null);
98 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
99 const send = (request: BinaryWorkerRequest) => {
100 setBusy(true);
101 setError(null);
102 if (timerRef.current) clearTimeout(timerRef.current);
103 timerRef.current = setTimeout(() => {
104 setError("This file took too long to preview. Close it and try again.");
105 setBusy(false);
106 workerRef.current?.terminate();
107 workerRef.current = null;
108 }, 15_000);
109 workerRef.current?.postMessage(request);
110 };
111 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the binary fetch, archive or database worker, message subscription, and request timeout for this file.
112 useEffect(() => {
113 const controller = new AbortController();
114 const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), {
115 type: "module",
116 });
117 workerRef.current = worker;
118 const fail = (message: string) => {
119 if (controller.signal.aborted) return;
120 setError(message);
121 setBusy(false);
122 controller.abort();
123 worker.terminate();
124 workerRef.current = null;
125 if (timerRef.current) clearTimeout(timerRef.current);
126 };
127 timerRef.current = setTimeout(
128 () => fail("This file took too long to preview. Close it and try again."),
129 30_000,
130 );
131 worker.addEventListener("error", () =>
132 fail("Could not load the file viewer. Close it and try again."),
133 );
134 worker.addEventListener("message", (event: MessageEvent<BinaryWorkerResult>) => {
135 if (controller.signal.aborted) return;
136 if (timerRef.current) clearTimeout(timerRef.current);
137 const result = event.data;
138 setBusy(false);
139 if (result.type === "error") setError(result.message);
140 else if (result.type === "archive") setArchive(result);
141 else if (result.type === "entry") setEntry(result);
142 else if (result.type === "rows") setRows(result);
143 else {
144 setDatabase(result);
145 const table = result.tables[0];
146 if (table) {
147 setSelectedTable(table.name);
148 send({ type: "rows", table: table.name, page: 0 });
149 }
150 }
151 });
152 void readBinaryPreview(props.src, controller.signal)
153 .then((bytes) => {
154 if (!controller.signal.aborted) {
155 setSizeBytes(bytes.byteLength);
156 worker.postMessage({ type: "open", name: props.name, kind: props.kind, bytes }, [bytes]);
157 }
158 })
159 .catch((cause: unknown) =>
160 fail(cause instanceof Error ? cause.message : "Could not read this file."),
161 );
162 return () => {
163 controller.abort();
164 worker.terminate();
165 workerRef.current = null;
166 if (timerRef.current) clearTimeout(timerRef.current);
167 };
168 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- One worker owns this keyed file and its message stream.
169 }, [props.src, props.kind, props.name]);
170 const table = database?.tables.find((item) => item.name === selectedTable);
171 const selectPage = (next: number, name = selectedTable) => {
172 setSelectedTable(name);
173 setPage(next);
174 setRows(null);
175 send({ type: "rows", table: name, page: next });
176 };
177 const children = new Map<
178 string,
179 { path: string; directory: boolean; size: number; modified: string | null }
180 >();
181 for (const item of archive?.entries ?? []) {
182 if (!item.path.startsWith(folder)) continue;
183 const rest = item.path.slice(folder.length).replace(/\/$/, "");
184 if (!rest) continue;
185 const slash = rest.indexOf("/");
186 const path = slash < 0 ? item.path.replace(/\/$/, "") : `${folder}${rest.slice(0, slash)}`;
187 const directory = slash >= 0 || item.directory;
188 const key = `${path}${directory ? "/" : ""}`;
189 const previous = children.get(key);
190 if (previous) previous.size += item.size;
191 else children.set(key, { path, directory, size: item.size, modified: item.modified });
192 }
193 return (
194 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
195 {archive ? (
196 <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/60 px-3 text-xs">
197 <FileSurfaceAction
198 label={selectedPath ? "Back to archive" : "Parent folder"}
199 disabled={!selectedPath && !folder}
200 onPress={() => {
201 setError(null);
202 setEntry(null);
203 if (selectedPath) setSelectedPath(null);
204 else setFolder(folder.replace(/(?:[^/]+)?\/$/, ""));
205 }}
206 >
207 <ChevronLeftIcon className="size-3.5" />
208 </FileSurfaceAction>
209 <span className="min-w-0 flex-1 truncate">
210 {selectedPath ?? (folder || "Archive contents")}
211 </span>
212 <span className="shrink-0 text-muted-foreground">
213 {archive.entries.length.toLocaleString()} entries
214 {sizeBytes !== null ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}
215 </span>
216 </div>
217 ) : null}
218 {database && table ? (
219 <>
220 <div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-1 text-xs">
221 <div className="min-w-32 flex-1">
222 <Select
223 value={selectedTable}
224 onValueChange={(value) => {
225 if (!busy && value !== null) selectPage(0, value);
226 }}
227 >
228 <SelectTrigger aria-label="Table" aria-disabled={busy} aria-busy={busy} size="xs">
229 <SelectValue>
230 {table.name} ({table.count.toLocaleString()} rows)
231 </SelectValue>
232 </SelectTrigger>
233 <SelectPopup>
234 {database.tables.map((item) => (
235 <SelectItem key={item.name} value={item.name}>
236 {item.name} ({item.count.toLocaleString()} rows)
237 </SelectItem>
238 ))}
239 </SelectPopup>
240 </Select>
241 </div>
242 <FileSurfaceAction
243 label="Previous rows"
244 disabled={busy || page === 0}
245 onPress={() => selectPage(page - 1)}
246 >
247 <ChevronLeftIcon className="size-3.5" />
248 </FileSurfaceAction>
249 <span className="shrink-0 tabular-nums" aria-live="polite">
250 {table.count === 0
251 ? "0 rows"
252 : `${page * SQLITE_PAGE_SIZE + 1}–${Math.min(table.count, (page + 1) * SQLITE_PAGE_SIZE)} of ${table.count.toLocaleString()}`}
253 </span>
254 <FileSurfaceAction
255 label="Next rows"
256 disabled={busy || (page + 1) * SQLITE_PAGE_SIZE >= table.count}
257 onPress={() => selectPage(page + 1)}
258 >
259 <ChevronRightIcon className="size-3.5" />
260 </FileSurfaceAction>
261 </div>
262 <div
263 className="border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums"
264 role="status"
265 >
266 {table.count.toLocaleString()} rows · {rows?.columns.length ?? "…"} columns
267 {sizeBytes !== null ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}
268 </div>
269 <FileSurfaceNotice>
270 Read-only. Up to 50 columns and 1,024 characters per cell. Blob contents are omitted.
271 </FileSurfaceNotice>
272 </>
273 ) : null}
274 {error ? (
275 <FileSurfaceFailure
276 message={error}
277 {...(props.onRetry ? { onRetry: props.onRetry } : {})}
278 />
279 ) : busy ? (
280 <FileSurfaceLoading />
281 ) : selectedPath ? (
282 entry ? (
283 <ArchiveFile key={entry.path} path={entry.path} bytes={entry.bytes} />
284 ) : null
285 ) : archive ? (
286 <div className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
287 <table
288 aria-label="Archive entries"
289 className="w-full border-separate border-spacing-0 text-xs"
290 >
291 <thead className="sticky top-0 bg-background text-muted-foreground">
292 <tr>
293 <th scope="col" className="px-3 py-2 text-left font-normal">
294 Name
295 </th>
296 <th scope="col" className="px-3 py-2 text-right font-normal">
297 Size
298 </th>
299 <th scope="col" className="px-3 py-2 text-right font-normal">
300 Modified
301 </th>
302 </tr>
303 </thead>
304 <tbody>
305 {[...children.values()]
306 .sort(
307 (a, b) =>
308 Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path),
309 )
310 .map((item) => (
311 <tr
312 key={`${item.path}${item.directory ? "/" : ""}`}
313 className="hover:bg-muted/40"
314 >
315 <td className="max-w-0 border-b border-border/60">
316 <button
317 type="button"
318 className="flex w-full items-center gap-2 px-3 py-2 text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
319 onClick={() => {
320 if (item.directory) setFolder(`${item.path}/`);
321 else {
322 setSelectedPath(item.path);
323 setEntry(null);
324 send({ type: "entry", path: item.path });
325 }
326 }}
327 >
328 {item.directory ? (
329 <FolderIcon className="size-4 shrink-0 text-muted-foreground" />
330 ) : (
331 <FileIcon className="size-4 shrink-0 text-muted-foreground" />
332 )}
333 <span className="truncate">{item.path.slice(folder.length)}</span>
334 </button>
335 </td>
336 <td className="w-20 border-b border-border/60 px-3 py-2 text-right whitespace-nowrap text-muted-foreground tabular-nums">
337 {formatAttachmentSize(item.size)}
338 </td>
339 <td className="w-28 border-b border-border/60 px-3 py-2 text-right whitespace-nowrap text-muted-foreground">
340 {item.modified ? new Date(item.modified).toLocaleDateString() : "-"}
341 </td>
342 </tr>
343 ))}
344 </tbody>
345 </table>
346 {children.size === 0 ? (
347 <p className="p-6 text-xs text-muted-foreground">This folder is empty.</p>
348 ) : null}
349 </div>
350 ) : rows ? (
351 <div className="scrollbar-gutter-both min-h-0 flex-1 overflow-auto">
352 <table
353 aria-label={`${props.name}: ${rows.table}`}
354 className="min-w-full border-separate border-spacing-0 text-xs"
355 >
356 <thead className="sticky top-0 bg-background">
357 <tr>
358 {rows.columns.map((name) => (
359 <th
360 key={name}
361 scope="col"
362 className="border-r border-b border-border/60 px-3 py-2 text-left font-medium whitespace-nowrap"
363 >
364 {name}
365 </th>
366 ))}
367 </tr>
368 </thead>
369 <tbody>
370 {rows.rows.map((row, index) => (
371 <tr key={index} className="even:bg-muted/30">
372 {row.map((cell, column) => (
373 <td
374 key={column}
375 className="max-w-80 min-w-32 border-r border-b border-border/60 px-3 py-1.5 align-top whitespace-pre-wrap break-words tabular-nums"
376 >
377 {cell}
378 </td>
379 ))}
380 </tr>
381 ))}
382 </tbody>
383 </table>
384 {rows.rows.length === 0 ? (
385 <p className="p-6 text-xs text-muted-foreground">This table is empty.</p>
386 ) : null}
387 </div>
388 ) : (
389 <p className="p-6 text-xs text-muted-foreground">This database has no readable tables.</p>
390 )}
391 </div>
392 );
393}