import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; import { filePreviewKind, officePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES, } from "@t3tools/shared/filePreview"; import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from "lucide-react"; import { Check, Code2, Copy, Eye, Table2 } from "lucide"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter"; import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { BinaryFilePreview } from "./BinaryFilePreview"; import { PreviewImage } from "./PreviewImage"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { FILE_SURFACE_SUBHEADER_CLASS, FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, FileSurfaceNotice, } from "./fileSurfaceChrome"; const LargeTextViewer = lazy(() => import("./LargeTextViewer")); const OfficeFilePreview = lazy(() => import("./OfficeFilePreview").then((module) => ({ default: module.OfficeFilePreview })), ); const FontFilePreview = lazy(() => import("./FontFilePreview").then((module) => ({ default: module.FontFilePreview })), ); const MermaidFilePreview = lazy(() => import("./MermaidFilePreview").then((module) => ({ default: module.MermaidFilePreview })), ); const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; const blobKeys = new WeakMap(); let nextBlobKey = 0; function blobKey(blob: Blob) { const key = blobKeys.get(blob) ?? `blob:${nextBlobKey++}`; blobKeys.set(blob, key); return key; } export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> ); } function renderedToggleLabel( mode: "markdown" | "mermaid" | "html" | "table", rendered: boolean, ): string { if (mode === "mermaid") return rendered ? "Show Mermaid source" : "Show rendered diagram"; if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown"; if (mode === "table") return rendered ? "Show source" : "Show table"; return rendered ? "Show HTML source" : "Show rendered page"; } export function AttachmentFilePreview(props: { name: string; mimeType: string; sizeBytes: number; file?: Blob | null; asset?: | { environmentId: EnvironmentId; attachmentId: string } | { environmentId: EnvironmentId; resource: Extract; }; htmlRender?: boolean; threadRef?: ScopedThreadRef; origin?: string; onRemove?: () => void; onClose?: () => void; }) { const kind = filePreviewKind(props); const officeKind = officePreviewKind(props); const delimiter = filePreviewDelimiter(props); const renderedMode = kind === "mermaid" ? "mermaid" : kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; const attachmentId = props.asset && "attachmentId" in props.asset ? props.asset.attachmentId : undefined; const { copyToClipboard, isCopied } = useCopyToClipboard({ target: props.name }); // useAssetUrlRefresh keys its callback on these objects, so they must stay cached. const resource = props.asset && "resource" in props.asset ? props.asset.resource : attachmentId ? { _tag: "attachment" as const, attachmentId, fileName: props.name, mimeType: props.mimeType, disposition: "inline" as const, } : null; const downloadResource = resource ? resource._tag === "draft-workspace-file" ? resource : { ...resource, disposition: "attachment" as const } : null; const refresh = useAssetUrlRefresh( props.file ? null : (props.asset?.environmentId ?? null), props.file ? null : resource, ); const prepareDownload = useAssetUrlRefresh(props.asset?.environmentId ?? null, downloadResource); const [saving, setSaving] = useState(false); const [localUrl, setLocalUrl] = useState(null); const [remoteUrl, setRemoteUrl] = useState(null); const [rendered, setRendered] = useState(true); const [revision, setRevision] = useState(0); const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null); const [error, setError] = useState(null); const [contentError, setContentError] = useState(null); const copyContents = useRef<(() => Promise) | null>(null); const authorizedAt = useRef(0); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local file and revokes it on cleanup useEffect(() => { if (!props.file) return; const url = URL.createObjectURL(props.file); // oxlint-disable-next-line react/set-state-in-effect -- Publish an object URL only after its cleanup is registered for this Blob. setLocalUrl(url); return () => URL.revokeObjectURL(url); }, [props.file]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- authorizes a remote attachment URL from the server and ignores the result after cleanup useEffect(() => { if (props.file) return; let cancelled = false; // oxlint-disable-next-line react/set-state-in-effect -- A new preview request clears its previous URL and error. setRemoteUrl(null); setError(null); void refresh() .then((url) => { if (cancelled) return; if (!url) throw new Error("Reconnect to the environment and try again."); authorizedAt.current = Date.now(); setRemoteUrl(url); }) .catch((cause: unknown) => { if (!cancelled) setError(cause instanceof Error ? cause.message : "The attachment is unavailable."); }); return () => { cancelled = true; }; // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file. }, [props.file, refresh, revision]); const url = props.file ? localUrl : remoteUrl; const streamsText = kind === "text" && (props.sizeBytes > FILE_TEXT_PREVIEW_MAX_BYTES || (props.sizeBytes === 0 && (!resource || resource._tag === "attachment"))); const needsText = kind === "text" || kind === "markdown" || kind === "mermaid" || (kind === "html" && !rendered); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches the file's text from its URL or Blob stream and aborts the request on cleanup useEffect(() => { if (!needsText || streamsText || !url) return; const controller = new AbortController(); // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading. setContent(null); setContentError(null); const file = props.file; void (async () => { if (!file && Date.now() - authorizedAt.current > STALE_URL_MS) { const target = await refresh(); if (controller.signal.aborted) return; if (!target) throw new Error("Reconnect to the environment and try again."); authorizedAt.current = Date.now(); if (target !== url) { setRemoteUrl(target); return; } } const response = file ? { ok: true, body: file.stream() } : await fetch(url, { signal: controller.signal, ...(props.sizeBytes > 0 ? { headers: { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } } : {}), cache: revision === 0 ? "default" : "reload", }); const result = await readFilePreviewResponse(response, controller.signal); if (!controller.signal.aborted) setContent(result); })().catch((cause: unknown) => { if (!controller.signal.aborted) setContentError(cause instanceof Error ? cause.message : "Could not load this file."); }); return () => controller.abort(); }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); const [wordWrapOverride, setWordWrapOverride] = useState(null); const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && (content !== null || streamsText) && !(delimiter && rendered) && !((kind === "markdown" || kind === "mermaid") && rendered); const save = () => { setSaving(true); void settlePromise(async () => { let file = props.file; if (!file) { const target = await prepareDownload(); if (!target) throw new Error("Reconnect to the environment and try again."); const response = await fetch(target); if (!response.ok) throw new Error("The file could not be loaded. Try again."); file = await response.blob(); } // Blob downloads keep desktop's custom app scheme from navigating to an external browser. const downloadUrl = URL.createObjectURL(file); // Scheduled before the click so the URL is revoked even if the click throws. setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000); const anchor = document.createElement("a"); anchor.href = downloadUrl; anchor.download = props.name; anchor.click(); }).then((result) => { if (!AsyncResult.isSuccess(result)) { const cause = Cause.squash(result.cause); toastManager.add({ type: "error", title: "Could not save file", description: cause instanceof Error ? cause.message : "Please try again.", }); } setSaving(false); }); }; const body = failure ? ( { setError(null); setRevision((value) => value + 1); }} /> ) : streamsText && url ? ( }> setRevision((value) => value + 1)} delimiter={rendered ? delimiter : null} copyContents={copyContents} wordWrap={wordWrap} /> ) : !url || (needsText && !content) ? ( ) : needsText && content ? ( delimiter && rendered ? ( ) : kind === "mermaid" && rendered ? ( }> ) : kind === "markdown" && rendered ? ( ) : ( ) ) : kind === "archive" || kind === "sqlite" ? ( setRevision((value) => value + 1)} /> ) : kind === "font" ? ( }> ) : officeKind ? ( }> ) : kind === "pdf" || kind === "html" ? ( ) : kind === "audio" ? ( setError("Unable to load audio.")} /> ) : kind === "video" ? (
) : kind === "image" ? ( setError("Unable to load image.")} /> ) : (

No preview for this file

Save it to open in an app that supports {props.name.split(".").at(-1) || "this format"}{" "} files.

); return (
{props.origin ?? "Attachment"} {props.name} {props.sizeBytes > 0 ? ( {formatAttachmentSize(props.sizeBytes)} ) : null}
{renderedMode ? ( setRendered((value) => !value)} > ) : null} {showsRawText ? ( setWordWrapOverride(!wordWrap)} > ) : null} {content || streamsText ? ( { if (content) { void copyToClipboard(content.text, undefined); return; } const copy = copyContents.current; if (!copy) { toastManager.add({ type: "error", title: "Could not copy file", description: "Wait for the file to finish loading before copying.", }); return; } void copy() .then((text) => copyToClipboard(text, undefined)) .catch((cause: unknown) => toastManager.add({ type: "error", title: "Could not copy file", description: cause instanceof Error ? cause.message : "Try again.", }), ); }} > ) : null} {url ? ( ) : null} {props.onRemove ? ( ) : null} {props.onClose ? ( ) : null}
{content?.truncated ? ( Preview limited to the first 1 MB {props.sizeBytes > 0 ? ` of a ${props.sizeBytes.toLocaleString()} byte file` : ""}. Save the file to read it in full. ) : null} {body}
); }