"use client"; import React, { useMemo, useState, useEffect, useRef, useCallback, } from "react"; import { Download, Loader2, Maximize2, Minimize2, FileText, Code, Pencil, Save, Trash2, Eye, ZoomIn, ZoomOut, RotateCcw, ChevronLeft, ChevronRight, } from "lucide-react"; import { TransformWrapper, TransformComponent, } from "react-zoom-pan-pinch"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { MarkdownContent } from "@/app/components/MarkdownContent"; import { DocxPreview } from "@/app/components/DocxPreview"; import { SpreadsheetPreview } from "@/app/components/SpreadsheetPreview"; import { IMAGE_EXTS, imageSiblings, type ImageSiblingNav, } from "@/lib/imageSiblings"; const LANGUAGE_MAP: Record = { js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", cpp: "cpp", c: "c", cs: "csharp", php: "php", swift: "swift", kt: "kotlin", sh: "bash", bash: "bash", zsh: "bash", json: "json", jsonl: "json", xml: "xml", html: "html", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml", ini: "ini", tex: "latex", bib: "latex", r: "r", }; const WORD_EXTS = new Set(["doc", "docx", "docm", "dotx", "dotm"]); const SPREADSHEET_EXTS = new Set([ "xls", "xlsx", "xlsm", "xlsb", "xltx", "xltm", "ods", ]); // Extensions we render as text. Anything not here and not an image/pdf is // treated as a binary download. const TEXT_EXTS = new Set([ ...Object.keys(LANGUAGE_MAP), "txt", "md", "markdown", "log", "csv", "tsv", "cfg", "conf", "env", "gitignore", ]); // Inline text preview is capped — bigger files are offered as a download so we // never pull tens of MB into the browser just to render it. const MAX_INLINE_TEXT_BYTES = 2 * 1024 * 1024; const MAX_OFFICE_PREVIEW_BYTES = 25 * 1024 * 1024; const SPREADJS_EXTS = new Set(["xlsx", "xlsm", "xltx", "xltm"]); export function workspaceFileUrl( threadId: string, path: string, download = false ): string { const qs = new URLSearchParams({ threadId, path }); if (download) qs.set("download", "1"); return `/api/workspace/file?${qs.toString()}`; } function workspacePreviewUrl(threadId: string, path: string): string { return `/api/workspace/preview?${new URLSearchParams({ threadId, path, }).toString()}`; } type Kind = "text" | "image" | "pdf" | "word" | "spreadsheet" | "binary"; type OfficePreview = | { kind: "pdf"; url: string } | { kind: "docx"; document: Blob } | { kind: "spreadsheet"; document: Blob }; type OfficePreviewStatus = | { status: "ready"; mode: "pdf"; previewUrl: string } | { status: "processing"; retryAfterMs?: number } | { status: "fallback" | "failed"; reason: string; message: string; }; function kindOf(ext: string): Kind { if (IMAGE_EXTS.has(ext)) return "image"; if (ext === "pdf") return "pdf"; if (WORD_EXTS.has(ext)) return "word"; if (SPREADSHEET_EXTS.has(ext)) return "spreadsheet"; if (TEXT_EXTS.has(ext)) return "text"; return "binary"; } export const WorkspaceFileDialog = React.memo<{ /** Path relative to the workspace root, or null to close. */ path: string | null; /** Owning conversation. Workspace files are never global in strict mode. */ threadId: string | null; /** Byte size from the listing — used to gate inline text preview. */ size?: number; onClose: () => void; /** Called after a successful save or delete so the listing can refresh. */ onChanged?: () => void; /** Switch the preview to another file (image prev/next within the directory). */ onNavigate?: (path: string, size?: number) => void; }>(({ path, threadId, size, onClose, onChanged, onNavigate }) => { const [content, setContent] = useState(null); const [officePreview, setOfficePreview] = useState( null ); const [docxPreviewFallbackKey, setDocxPreviewFallbackKey] = useState< string | null >(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Edit / save / delete state. const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const [saving, setSaving] = useState(false); const [actionError, setActionError] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); // HTML files only: rendered preview vs syntax-highlighted source. const [htmlView, setHtmlView] = useState<"preview" | "source">("preview"); // Bumped after a successful save so the preview iframe reloads new content. const [savedTick, setSavedTick] = useState(0); // Window-level maximize for the preview panel (toolbar stays usable). const [isMaximized, setIsMaximized] = useState(false); // Esc exits maximize; the dialog's own Esc close is suppressed while // maximized via onEscapeKeyDown below. useEffect(() => { if (!isMaximized) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setIsMaximized(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [isMaximized]); const [discardAction, setDiscardAction] = useState< "close" | "cancel-edit" | null >(null); // Suppress state updates after unmount — closing the inspector panel mid // save/delete unmounts this dialog while a request is still in flight. const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const name = path ? path.split("/").pop() || path : ""; const ext = useMemo( () => (name.includes(".") ? name.split(".").pop()!.toLowerCase() : ""), [name] ); const kind = kindOf(ext); const tooBigForText = kind === "text" && size != null && size > MAX_INLINE_TEXT_BYTES; const supportsOfficePreview = kind === "word" || kind === "spreadsheet"; const supportsSpreadJsPreview = SPREADJS_EXTS.has(ext); const tooBigForOffice = supportsOfficePreview && size != null && size > MAX_OFFICE_PREVIEW_BYTES; const editable = kind === "text" && !tooBigForText; const previewKey = `${threadId ?? ""}\0${path ?? ""}`; const docxPreviewFallback = docxPreviewFallbackKey === previewKey; const useDocxPreviewFallback = useCallback(() => { setDocxPreviewFallbackKey(previewKey); }, [previewKey]); useEffect(() => { setDocxPreviewFallbackKey(null); setHtmlView("preview"); }, [path, threadId]); // Prev/next image navigation within the current directory. Best-effort: // a failed listing just leaves both buttons disabled. const [siblingNav, setSiblingNav] = useState({ prev: null, next: null, }); const siblingSizesRef = useRef>(new Map()); useEffect(() => { setSiblingNav({ prev: null, next: null }); siblingSizesRef.current = new Map(); if (!path || !threadId || kind !== "image" || !onNavigate) return; const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; let cancelled = false; const controller = new AbortController(); const load = async () => { const res = await fetch( `/api/workspace?${new URLSearchParams({ threadId, path: dir })}`, { signal: controller.signal } ); const body = await res.json().catch(() => null); if (!res.ok || cancelled) return; const entries = (body?.entries ?? []) as Array<{ path: string; type: "dir" | "file"; ext: string; size: number; }>; if (cancelled) return; setSiblingNav(imageSiblings(entries, path)); siblingSizesRef.current = new Map(entries.map((e) => [e.path, e.size])); }; load().catch(() => {}); return () => { cancelled = true; controller.abort(); }; }, [path, threadId, kind, onNavigate]); const navigateTo = useCallback( (target: string | null) => { if (target && onNavigate) onNavigate(target, siblingSizesRef.current.get(target)); }, [onNavigate] ); useEffect(() => { const loadText = kind === "text" && !tooBigForText; const loadOffice = supportsOfficePreview; if (!path || !threadId || (!loadText && !loadOffice)) { setContent(null); setOfficePreview(null); return; } let cancelled = false; const controller = new AbortController(); setLoading(true); setError(null); setEditing(false); setActionError(null); setContent(null); setOfficePreview(null); const load = async () => { if (loadText) { const res = await fetch(workspaceFileUrl(threadId, path), { signal: controller.signal, }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error || `Failed to load file (${res.status})`); } const text = await res.text(); if (!cancelled) setContent(text); return; } if (tooBigForOffice) return; if (ext === "docx" && !docxPreviewFallback) { const res = await fetch(workspaceFileUrl(threadId, path), { signal: controller.signal, }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error || `Failed to load file (${res.status})`); } const document = await res.blob(); if (!cancelled) setOfficePreview({ kind: "docx", document }); return; } if (supportsSpreadJsPreview) { const res = await fetch(workspaceFileUrl(threadId, path), { signal: controller.signal, }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error || `Failed to load file (${res.status})`); } const document = await res.blob(); if (!cancelled) setOfficePreview({ kind: "spreadsheet", document }); return; } let serverPreview: OfficePreviewStatus; for (;;) { const res = await fetch(workspacePreviewUrl(threadId, path), { signal: controller.signal, }); const body = (await res .json() .catch(() => null)) as OfficePreviewStatus | null; if ( !body || (res.status !== 200 && res.status !== 202 && res.status !== 422) ) { throw new Error( (body as { error?: string } | null)?.error || `Failed to generate preview (${res.status})` ); } serverPreview = body; if (serverPreview.status !== "processing") break; const retryAfterMs = serverPreview.retryAfterMs ?? 1_000; await new Promise((resolve) => setTimeout(resolve, retryAfterMs)); if (cancelled) return; } if (serverPreview.status === "ready") { if (!cancelled) setOfficePreview({ kind: "pdf", url: serverPreview.previewUrl }); return; } throw new Error(serverPreview.message); }; void load() .catch((err: unknown) => { if ( !cancelled && !(err instanceof DOMException && err.name === "AbortError") ) { setError(err instanceof Error ? err.message : "Failed to load file."); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; controller.abort(); }; }, [ path, threadId, kind, ext, supportsOfficePreview, supportsSpreadJsPreview, tooBigForText, tooBigForOffice, docxPreviewFallback, ]); if (!path || !threadId) return null; const isMarkdown = ext === "md" || ext === "markdown"; const isHtml = ext === "html"; const showHtmlToggle = isHtml && kind === "text" && !tooBigForText && !editing; const language = LANGUAGE_MAP[ext] || "text"; const dirty = editing && content !== null && draft !== content; const runOrConfirmDiscard = (action: "close" | "cancel-edit") => { if (!dirty) { if (action === "close") onClose(); else setEditing(false); return; } setDiscardAction(action); }; const requestClose = () => { runOrConfirmDiscard("close"); }; const startEdit = () => { setDraft(content ?? ""); setActionError(null); setEditing(true); }; const save = async () => { setSaving(true); setActionError(null); try { const res = await fetch(workspaceFileUrl(threadId, path), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: draft }), }); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error || "Failed to save."); if (!mountedRef.current) return; setContent(draft); setEditing(false); setSavedTick((tick) => tick + 1); setHtmlView("preview"); onChanged?.(); } catch (e) { if (mountedRef.current) { setActionError(e instanceof Error ? e.message : "Failed to save."); } } finally { if (mountedRef.current) setSaving(false); } }; const remove = async () => { setDeleting(true); setActionError(null); try { const res = await fetch(workspaceFileUrl(threadId, path), { method: "DELETE", }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || "Failed to delete."); } onChanged?.(); onClose(); } catch (e) { if (mountedRef.current) { // Close the confirm dialog so the error (rendered in the main dialog) // isn't hidden behind the confirm overlay. setDeleteOpen(false); setActionError(e instanceof Error ? e.message : "Failed to delete."); } } finally { if (mountedRef.current) setDeleting(false); } }; const confirmDiscardChanges = () => { const action = discardAction; setDiscardAction(null); if (action === "close") onClose(); if (action === "cancel-edit") setEditing(false); }; return ( <> { if (!open) requestClose(); }} > { // While maximized, Esc only collapses the panel (handled by the // keydown listener) — it must not close the whole dialog. if (isMaximized) e.preventDefault(); }} > {path}
{editing ? ( <> ) : ( <> {showHtmlToggle && (