Skip to content
Source
Sign inSign up
Open History to see the last commit

LargeTextViewer.tsx

264 lines9.9 KB

1import { useEffect, useImperativeHandle, useRef, useState, type RefObject } from "react";
2import { parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
3import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4import { useClientSettings } from "~/hooks/useSettings";
5import { FileSurfaceFailure, FileSurfaceLoading, FileSurfaceNotice } from "./fileSurfaceChrome";
6import { PREVIEW_PAGE_ROWS, type TextPreviewResult } from "./largeTextProtocol";
7import VirtualTablePreview from "./VirtualTablePreview";
8​
9const FIRST_SCREEN_BYTES = 16 * 1024;
10​
11function countLabel(count: number, noun: string) {
12 return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`;
14​
15function firstScreen(text: string, delimiter: "," | "\t" | null, complete: boolean) {
16 if (!text || text.includes("\0")) return [];
17 const rows = delimiter
18 ? parseDelimitedPreview(text, delimiter, {
19 maxRows: PREVIEW_PAGE_ROWS * 2 + 1,
20 maxColumns: 1024,
21 maxCellLength: FIRST_SCREEN_BYTES,
22 }).rows
23 : text
24 .replace(/\r?\n$|\r$/, "")
25 .split(/\r\n|\n|\r/)
26 .slice(0, PREVIEW_PAGE_ROWS * 2 + 1)
27 .map((line) => [line]);
28 // A byte range can end inside a quoted record or a UTF-8 character.
29 if (!complete) rows.pop();
30 return rows.slice(0, PREVIEW_PAGE_ROWS * 2);
32​
33export default function LargeTextViewer(props: {
34 readonly name: string;
35 readonly src?: string;
36 readonly text?: string;
37 readonly sizeBytes?: number;
38 readonly onRetry?: () => void;
39 readonly copyContents?: RefObject<(() => Promise<string>) | null>;
40 readonly delimiter: "," | "\t" | null;
41 readonly revealLine?: number | null;
42 readonly revealRequestId?: number;
43 /** A preview's own wrap toggle; omitted follows the word wrap setting. */
44 readonly wordWrap?: boolean;
45}) {
46 const [meta, setMeta] = useState<Extract<TextPreviewResult, { type: "meta" }> | null>(null);
47 const [pages, setPages] = useState(new Map<number, string[][]>());
48 const [error, setError] = useState<string | null>(null);
49 const worker = useRef<Worker | null>(null);
50 const copyPending = useRef<{
51 resolve: (text: string) => void;
52 reject: (cause: Error) => void;
53 } | null>(null);
54 const pending = useRef(new Set<number>());
55 const defaultWordWrap = useClientSettings((settings) => settings.wordWrap);
56 const wordWrap = props.wordWrap ?? defaultWordWrap;
57 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts.
58 useEffect(() => {
59 // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages.
60 setError(null);
61 setMeta(null);
62 setPages(new Map());
63 const controller = new AbortController();
64 let indexed = false;
65 let prefetched = false;
66 const paintFirstScreen = (text: string, complete: boolean) => {
67 if (indexed || controller.signal.aborted) return;
68 const rows = firstScreen(text, props.delimiter, complete);
69 if (!rows.length) return;
70 prefetched = true;
71 setPages(
72 new Map(
73 [0, PREVIEW_PAGE_ROWS]
74 .filter((start) => start < rows.length)
75 .map((start) => [
76 start / PREVIEW_PAGE_ROWS,
77 rows.slice(start, start + PREVIEW_PAGE_ROWS),
78 ]),
79 ),
80 );
81 setMeta({
82 type: "meta",
83 count: rows.length,
84 columns: rows.reduce((max, row) => Math.max(max, row.length), 1),
85 complete: false,
86 });
87 };
88 const startWorker = () => {
89 if (controller.signal.aborted) return;
90 const engine = new Worker(new URL("./largeText.worker.ts", import.meta.url), {
91 type: "module",
92 });
93 worker.current = engine;
94 engine.addEventListener("message", (event: MessageEvent<TextPreviewResult>) => {
95 const result = event.data;
96 if (result.type === "copy") {
97 copyPending.current?.resolve(result.text);
98 copyPending.current = null;
99 } else if (result.type === "error") {
100 copyPending.current?.reject(new Error(result.message));
101 copyPending.current = null;
102 if (result.operation !== "copy") setError(result.message);
103 } else if (result.type === "meta") {
104 indexed = true;
105 if (prefetched) {
106 setPages(new Map());
107 pending.current.clear();
108 prefetched = false;
109 }
110 setMeta(result);
111 } else if (result.type === "page") {
112 pending.current.delete(result.index);
113 setPages((old) => {
114 const previous = old.get(result.index);
115 if (
116 previous?.length === result.rows.length &&
117 previous.every(
118 (row, index) =>
119 row.length === result.rows[index]!.length &&
120 row.every((cell, column) => cell === result.rows[index]![column]),
121 )
122 )
123 return old;
124 const next = new Map(old);
125 next.delete(result.index);
126 next.set(result.index, result.rows);
127 if (next.size > 12) next.delete([...next.keys()].find((key) => key !== 0)!);
128 return next;
129 });
130 }
131 });
132 engine.addEventListener("error", () => {
133 copyPending.current?.reject(new Error("The text viewer stopped."));
134 copyPending.current = null;
135 setError("Could not load the text viewer. Close the file and try again.");
136 });
137 engine.postMessage(
138 {
139 type: "open",
140 src: props.src,
141 text: props.text,
142 sizeBytes: props.sizeBytes,
143 delimiter: props.delimiter,
144 },
145 [],
146 );
147 };
148 const scheduleWorker = () => {
149 if (prefetched) requestAnimationFrame(() => requestAnimationFrame(startWorker));
150 else startWorker();
151 };
152 // Only this bounded prefix is parsed on the main thread. Indexing the full
153 // file and fetching later pages remain in the worker.
154 if (props.sizeBytes === 0) {
155 startWorker();
156 } else if (props.text !== undefined) {
157 paintFirstScreen(
158 props.text.slice(0, FIRST_SCREEN_BYTES),
159 props.text.length <= FIRST_SCREEN_BYTES,
160 );
161 scheduleWorker();
162 } else if (props.src) {
163 void fetch(props.src, {
164 headers: { Range: `bytes=0-${FIRST_SCREEN_BYTES - 1}` },
165 cache: "no-store",
166 signal: controller.signal,
167 })
168 .then(async (response) => {
169 if (
170 !response.ok ||
171 Number(response.headers.get("content-length")) > FIRST_SCREEN_BYTES ||
172 response.status !== 206
173 ) {
174 await response.body?.cancel();
175 return;
176 }
177 const total = Number(response.headers.get("content-range")?.split("/")[1]);
178 paintFirstScreen(await response.text(), total <= FIRST_SCREEN_BYTES);
179 })
180 .catch(() => {
181 // The worker reports authorization, network and format failures.
182 })
183 .finally(scheduleWorker);
184 } else startWorker();
185 return () => {
186 controller.abort();
187 copyPending.current?.reject(new Error("Preview closed."));
188 copyPending.current = null;
189 worker.current?.terminate();
190 worker.current = null;
191 pending.current.clear();
192 };
193 }, [props.src, props.text, props.delimiter, props.sizeBytes]);
194 useImperativeHandle(
195 props.copyContents,
196 () => () =>
197 new Promise<string>((resolve, reject) => {
198 if (!worker.current) {
199 reject(new Error("The file is unavailable."));
200 return;
201 }
202 if (copyPending.current) {
203 reject(new Error("Another copy is in progress."));
204 return;
205 }
206 copyPending.current = { resolve, reject };
207 worker.current.postMessage({ type: "copy" }, []);
208 }),
209 [],
210 );
211 if (error)
212 return (
213 <FileSurfaceFailure message={error} {...(props.onRetry ? { onRetry: props.onRetry } : {})} />
214 );
215 if (!meta || (meta.count > 0 && !pages.has(0))) return <FileSurfaceLoading />;
216 const header = props.delimiter ? pages.get(0)?.[0] : undefined;
217 return (
218 <div className="relative flex min-h-0 flex-1 flex-col">
219 <div
220 className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-1 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums"
221 role="status"
222 >
223 {props.delimiter
224 ? `${countLabel(Math.max(0, meta.count - 1), "row")} · ${countLabel(meta.count ? meta.columns : 0, "column")}`
225 : countLabel(meta.count, "line")}
226 {meta.complete && meta.sizeBytes !== undefined
227 ? ` · ${meta.sizeBytes === 0 ? "0 bytes" : formatAttachmentSize(meta.sizeBytes)}`
228 : " · Indexing…"}
229 </div>
230 {!meta.complete ? (
231 <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20">
232 <FileSurfaceNotice>Loading the rest of the file…</FileSurfaceNotice>
233 </div>
234 ) : null}
235 {meta.columns === 1024 ? (
236 <FileSurfaceNotice>
237 Showing the first 1,024 columns. Source and Save retain every column.
238 </FileSurfaceNotice>
239 ) : null}
240 {meta.count > 0 ? (
241 <VirtualTablePreview
242 name={props.name}
243 count={meta.count}
244 columns={meta.columns}
245 header={header}
246 source={!props.delimiter}
247 wrap={wordWrap}
248 revealLine={meta.complete ? (props.revealLine ?? null) : null}
249 revealRequestId={props.revealRequestId ?? 0}
250 getRows={(page) => pages.get(page)}
251 loadPage={(page) => {
252 if (pending.current.has(page)) return;
253 pending.current.add(page);
254 worker.current?.postMessage({ type: "page", index: page });
255 }}
256 />
257 ) : (
258 <div className="flex flex-1 items-center justify-center p-6 text-xs text-muted-foreground">
259 This file is empty.
260 </div>
261 )}
262 </div>
263 );