import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Download } from "lucide-react"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; import { CsvPreview } from "@/components/CsvPreview"; import { isCsvFile } from "@/lib/csv-preview"; import { MarkdownBody } from "@/components/MarkdownBody"; import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview"; import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; import { isHtmlPreview } from "@/lib/html-preview"; import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments"; import { queryKeys } from "@/lib/queryKeys"; export const TEXT_PREVIEW_MAX_BYTES = 512 * 1024; /** Bound the actual response, not just producer-supplied attachment metadata. */ export async function readTextPreview(response: Response) { if (!response.ok) throw new Error(`Could not load file (${response.status}).`); if (!response.body) throw new Error("The file response is empty."); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8", { fatal: true }); let bytes = 0; let text = ""; try { while (true) { const chunk = await reader.read(); if (chunk.done) break; bytes += chunk.value.byteLength; if (bytes > TEXT_PREVIEW_MAX_BYTES) throw new Error("This file is too large to preview. Download it instead."); text += decoder.decode(chunk.value, { stream: true }); } text += decoder.decode(); if (text.includes("\0")) throw new Error("This file contains binary data. Download it instead."); return text; } finally { await reader.cancel(); reader.releaseLock(); } } export function TextAttachmentPreview({ title, text, markdown, html = false, csv = false, downloadUrl }: { title: string; text: string; markdown: boolean; html?: boolean; csv?: boolean; downloadUrl: string; }) { const [mode, setMode] = useState("rendered"); useEffect(() => setMode("rendered"), [title]); const renderCsv = csv && mode === "rendered" && text.length > 0; const renderHtml = html && mode === "rendered" && text.length > 0; return (

{title}

{markdown || html || csv ? : null}
{text.length === 0 ?

File is empty.

: renderCsv ? : renderHtml ? : markdown && mode === "rendered" ? {text} :
{text}
}
); } export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string; attachmentId: string }) { const attachments = useQuery({ queryKey: queryKeys.issues.attachments(issueId), queryFn: () => issuesApi.listAttachments(issueId), }); // Re-resolve against this task's authorized attachment list; never trust persisted URLs. const attachment = attachments.data?.find((item) => item.id === attachmentId); const eligible = attachment && isTextAttachment(attachment) && attachment.byteSize <= TEXT_PREVIEW_MAX_BYTES; const content = useQuery({ queryKey: ["task-text-attachment", issueId, attachmentId], queryFn: async ({ signal }) => readTextPreview(await fetch( `/api/attachments/${encodeURIComponent(attachmentId)}/content`, { signal, credentials: "same-origin" }, )), enabled: Boolean(eligible), retry: false, }); if (attachments.isLoading) return

Loading file…

; if (attachments.isError) return
Could not load attachment details.
; if (!attachment) return

File no longer available. Close this tab or choose another file.

; const downloadUrl = attachmentDownloadPath(attachment); if (!eligible || content.isError) { return (

{content.isError ? "Could not preview this file. Retry or download it." : "This file is too large or is not supported for text preview. Download it instead."}

{eligible ? : null}
); } if (content.data === undefined) return

Loading file…

; return ; }