1import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
5import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
6import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from "lucide-react";
7import { Check, Code2, Copy, Eye, Table2 } from "lucide";
8import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
9
10import { useAssetUrlRefresh } from "~/assets/assetUrls";
11import ChatMarkdown from "~/components/ChatMarkdown";
12import { MorphIcon } from "~/components/MorphIcon";
13import { ScrollArea } from "~/components/ui/scroll-area";
14import { toastManager } from "~/components/ui/toast";
15import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
16import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
17import { cn } from "~/lib/utils";
18
19import { AudioPreview } from "./AudioPreview";
20import { BrowserDocumentFrame } from "./BrowserDocumentFrame";
21import { DelimitedTablePreview } from "./DelimitedTablePreview";
22import {
23 FILE_SURFACE_SUBHEADER_CLASS,
24 FileSurfaceAction,
25 FileSurfaceFailure,
26 FileSurfaceLoading,
27 FileSurfaceNotice,
28} from "./fileSurfaceChrome";
29
30const SourcePreview = lazy(() => import("./ReadOnlySourcePreview"));
31
32/** Signed asset URLs live for an hour; treat anything older than this as worth re-minting. */
33const STALE_URL_MS = 5 * 60_000;
34
35/** Highlighted read-only source, loaded on demand so message rendering never waits on the highlighter. */
36export function ReadOnlySourcePreview(props: { name: string; text: string }) {
37 return (
38 <Suspense fallback={<FileSurfaceLoading className="p-4" />}>
39 <SourcePreview {...props} />
40 </Suspense>
41 );
42}
43
44function renderedToggleLabel(mode: "markdown" | "html" | "table", rendered: boolean): string {
45 if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown";
46 if (mode === "table") return rendered ? "Show source" : "Show table";
47 return rendered ? "Show HTML source" : "Show rendered page";
48}
49
50/**
51 * A captured attachment shown with the same chrome as a workspace file: one
52 * header row with crumbs and icon actions, then the document. Captured bytes
53 * are viewed independently of similarly named files in the workspace.
54 */
55export function AttachmentFilePreview(props: {
56 name: string;
57 mimeType: string;
58 /** Zero when unknown. */
59 sizeBytes: number;
60 file?: Blob | null;
61 asset?: { environmentId: EnvironmentId; attachmentId: string };
62 /** An agent's HTML render, shown in the app theme. */
63 htmlRender?: boolean;
64 /** The thread an HTML render's links open beside. */
65 threadRef?: ScopedThreadRef;
66 /** First crumb: where the file comes from. */
67 origin?: string;
68 onRemove?: () => void;
69 onClose?: () => void;
70}) {
71 const kind = filePreviewKind(props);
72 const delimiter = filePreviewDelimiter(props);
73 const renderedMode =
74 kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null;
75 const attachmentId = props.asset?.attachmentId;
76 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: props.name });
77 const resource = useMemo(
78 () =>
79 attachmentId
80 ? {
81 _tag: "attachment" as const,
82 attachmentId,
83 fileName: props.name,
84 mimeType: props.mimeType,
85 disposition: "inline" as const,
86 }
87 : null,
88 [attachmentId, props.name, props.mimeType],
89 );
90 const refresh = useAssetUrlRefresh(
91 props.file ? null : (props.asset?.environmentId ?? null),
92 props.file ? null : resource,
93 );
94 const downloadResource = useMemo(
95 () => (resource ? { ...resource, disposition: "attachment" as const } : null),
96 [resource],
97 );
98 const prepareDownload = useAssetUrlRefresh(props.asset?.environmentId ?? null, downloadResource);
99 const [saving, setSaving] = useState(false);
100 const [localUrl, setLocalUrl] = useState<string | null>(null);
101 const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
102 const [rendered, setRendered] = useState(true);
103 const [revision, setRevision] = useState(0);
104 const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null);
105 const [error, setError] = useState<string | null>(null);
106 // Reading source is a separate failure from loading the file: a rendered HTML page can be
107 // fine while its bytes are not UTF-8, and switching back to the page must not stay stuck.
108 const [contentError, setContentError] = useState<string | null>(null);
109 const authorizedAt = useRef(0);
110 useEffect(() => {
111 if (!props.file) return;
112 const url = URL.createObjectURL(props.file);
113 // oxlint-disable-next-line react/set-state-in-effect -- Publish an object URL only after its cleanup is registered for this Blob.
114 setLocalUrl(url);
115 return () => URL.revokeObjectURL(url);
116 }, [props.file]);
117 useEffect(() => {
118 if (props.file) return;
119 let cancelled = false;
120 // Await a fresh signed URL: cached links can expire while the client is suspended.
121 // oxlint-disable-next-line react/set-state-in-effect -- A new preview request clears its previous URL and error.
122 setRemoteUrl(null);
123 setError(null);
124 void refresh()
125 .then((url) => {
126 if (cancelled) return;
127 if (!url) throw new Error("Reconnect to the environment and try again.");
128 authorizedAt.current = Date.now();
129 setRemoteUrl(url);
130 })
131 .catch((cause: unknown) => {
132 if (!cancelled)
133 setError(cause instanceof Error ? cause.message : "The attachment is unavailable.");
134 });
135 return () => {
136 cancelled = true;
137 };
138 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file.
139 }, [props.file, refresh, revision]);
140 const url = props.file ? localUrl : remoteUrl;
141 const needsText = kind === "text" || kind === "markdown" || (kind === "html" && !rendered);
142 useEffect(() => {
143 if (!needsText || !url) return;
144 const controller = new AbortController();
145 // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading.
146 setContent(null);
147 setContentError(null);
148 const file = props.file;
149 void (async () => {
150 // A signed URL minted when the file opened may have expired by the time the user
151 // switches to source. Publish its replacement so later mode switches use it too.
152 if (!file && Date.now() - authorizedAt.current > STALE_URL_MS) {
153 const target = await refresh();
154 if (controller.signal.aborted) return;
155 if (!target) throw new Error("Reconnect to the environment and try again.");
156 authorizedAt.current = Date.now();
157 if (target !== url) {
158 setRemoteUrl(target);
159 // The URL change restarts this effect; fetch once with the new authorization.
160 return;
161 }
162 }
163 const response = file
164 ? { ok: true, body: file.stream() }
165 : await fetch(url, {
166 signal: controller.signal,
167 ...(props.sizeBytes > 0
168 ? { headers: { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } }
169 : {}),
170 cache: revision === 0 ? "default" : "reload",
171 });
172 const result = await readFilePreviewResponse(response, controller.signal);
173 if (!controller.signal.aborted) setContent(result);
174 })().catch((cause: unknown) => {
175 if (!controller.signal.aborted)
176 setContentError(cause instanceof Error ? cause.message : "Could not load this file.");
177 });
178 return () => controller.abort();
179 }, [url, needsText, revision, props.sizeBytes, props.file, refresh]);
180 const failure = error ?? (needsText ? contentError : null);
181 const wordWrap = useClientSettings((settings) => settings.wordWrap);
182 const updateClientSettings = useUpdateClientSettings();
183 // Only the raw-text body honours word wrap. A rendered table or Markdown lays itself out,
184 // so offering the toggle there would be a control that visibly does nothing.
185 const showsRawText =
186 failure === null &&
187 needsText &&
188 content !== null &&
189 !(delimiter && rendered) &&
190 !(kind === "markdown" && rendered);
191
192 const save = () => {
193 setSaving(true);
194 void (async () => {
195 try {
196 let file = props.file;
197 if (!file) {
198 const target = await prepareDownload();
199 if (!target) throw new Error("Reconnect to the environment and try again.");
200 const response = await fetch(target);
201 if (!response.ok) throw new Error("The file could not be loaded. Try again.");
202 file = await response.blob();
203 }
204 // A Blob keeps cross-origin downloads inside the desktop client instead of
205 // navigating its custom app scheme to an external browser.
206 const downloadUrl = URL.createObjectURL(file);
207 try {
208 const anchor = document.createElement("a");
209 anchor.href = downloadUrl;
210 anchor.download = props.name;
211 anchor.click();
212 } finally {
213 setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000);
214 }
215 } catch (cause) {
216 toastManager.add({
217 type: "error",
218 title: "Could not save file",
219 description: cause instanceof Error ? cause.message : "Please try again.",
220 });
221 } finally {
222 setSaving(false);
223 }
224 })();
225 };
226
227 const body = failure ? (
228 <FileSurfaceFailure
229 message={failure}
230 onRetry={() => {
231 // Clearing first lets a local Blob preview remount: its URL never changes, so the
232 // revision bump alone would re-render the same failed element.
233 setError(null);
234 setRevision((value) => value + 1);
235 }}
236 />
237 ) : !url || (needsText && !content) ? (
238 <FileSurfaceLoading />
239 ) : needsText && content ? (
240 delimiter && rendered ? (
241 <DelimitedTablePreview name={props.name} text={content.text} delimiter={delimiter} />
242 ) : kind === "markdown" && rendered ? (
243 <ScrollArea className="min-h-0 flex-1">
244 <ChatMarkdown text={content.text} cwd={undefined} className="mx-auto max-w-4xl px-6 py-5" />
245 </ScrollArea>
246 ) : (
247 <ReadOnlySourcePreview name={props.name} text={content.text} />
248 )
249 ) : kind === "pdf" || kind === "html" ? (
250 <BrowserDocumentFrame
251 src={url}
252 title={props.name}
253 pdf={kind === "pdf"}
254 htmlRender={props.htmlRender === true}
255 threadRef={props.threadRef}
256 />
257 ) : kind === "audio" ? (
258 <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} />
259 ) : kind === "video" ? (
260 <div className="flex min-h-0 flex-1 items-center justify-center bg-black">
261 <video
262 controls
263 playsInline
264 src={url}
265 aria-label={props.name}
266 className="max-h-full max-w-full"
267 onError={() => setError("Unable to load video.")}
268 />
269 </div>
270 ) : kind === "image" ? (
271 // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
272 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
273 <img
274 src={url}
275 alt={props.name}
276 className="max-h-full max-w-full object-contain"
277 onError={() => setError("Unable to load image.")}
278 />
279 </div>
280 ) : (
281 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
282 <p className="text-sm font-medium">No preview for this file</p>
283 <p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
284 Save it to open in an app that supports {props.name.split(".").at(-1) || "this format"}{" "}
285 files.
286 </p>
287 </div>
288 );
289
290 return (
291 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
292 <div className={cn(FILE_SURFACE_SUBHEADER_CLASS)} data-surface-subheader>
293 <div className="flex min-w-0 flex-1 items-center text-xs">
294 <span className="shrink-0 px-0.5 text-muted-foreground">
295 {props.origin ?? "Attachment"}
296 </span>
297 <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
298 <span aria-current="page" className="min-w-0 truncate px-0.5 font-medium text-foreground">
299 {props.name}
300 </span>
301 {props.sizeBytes > 0 ? (
302 <span className="ml-2 shrink-0 text-muted-foreground">
303 {formatAttachmentSize(props.sizeBytes)}
304 </span>
305 ) : null}
306 </div>
307 {renderedMode ? (
308 <FileSurfaceAction
309 label={renderedToggleLabel(renderedMode, rendered)}
310 pressed={rendered}
311 onPress={() => setRendered((value) => !value)}
312 >
313 <MorphIcon
314 className="size-3.5"
315 icon={rendered ? Code2 : renderedMode === "table" ? Table2 : Eye}
316 />
317 </FileSurfaceAction>
318 ) : null}
319 {showsRawText ? (
320 <FileSurfaceAction
321 label={wordWrap ? "Disable word wrap" : "Enable word wrap"}
322 pressed={wordWrap}
323 onPress={() => updateClientSettings({ wordWrap: !wordWrap })}
324 >
325 <WrapTextIcon className="size-3.5" />
326 </FileSurfaceAction>
327 ) : null}
328 {content ? (
329 <FileSurfaceAction
330 label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"}
331 onPress={() => copyToClipboard(content.text, undefined)}
332 >
333 <MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} />
334 </FileSurfaceAction>
335 ) : null}
336 {url ? (
337 <FileSurfaceAction
338 label={saving ? "Preparing file…" : "Save file"}
339 disabled={saving}
340 onPress={save}
341 >
342 <DownloadIcon className="size-3.5" />
343 </FileSurfaceAction>
344 ) : null}
345 {props.onRemove ? (
346 <FileSurfaceAction label="Remove from draft" onPress={props.onRemove}>
347 <Trash2Icon className="size-3.5" />
348 </FileSurfaceAction>
349 ) : null}
350 {props.onClose ? (
351 <FileSurfaceAction label="Close" onPress={props.onClose}>
352 <XIcon className="size-3.5" />
353 </FileSurfaceAction>
354 ) : null}
355 </div>
356 {content?.truncated ? (
357 <FileSurfaceNotice>
358 Preview limited to the first 1 MB
359 {props.sizeBytes > 0 ? ` of a ${props.sizeBytes.toLocaleString()} byte file` : ""}. Save
360 the file to read it in full.
361 </FileSurfaceNotice>
362 ) : null}
363 {body}
364 </div>
365 );
366}