Files
EvoScientist-WebUI/src/app/components/WorkspaceFileDialog.tsx
T

991 lines
34 KiB
TypeScript

"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<string, string> = {
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<string | null>(null);
const [officePreview, setOfficePreview] = useState<OfficePreview | null>(
null
);
const [docxPreviewFallbackKey, setDocxPreviewFallbackKey] = useState<
string | null
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Edit / save / delete state.
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [actionError, setActionError] = useState<string | null>(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<ImageSiblingNav>({
prev: null,
next: null,
});
const siblingSizesRef = useRef<Map<string, number>>(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 (
<>
<Dialog
open={true}
onOpenChange={(open) => {
if (!open) requestClose();
}}
>
<DialogContent
aria-describedby={undefined}
className={
isMaximized
? // Override the centered-dialog positioning (fixed 50%/50% +
// translate) so the whole dialog fills the browser window.
"fixed left-0 top-0 flex h-screen max-h-none w-screen max-w-none translate-x-0 translate-y-0 flex-col rounded-none p-6 sm:max-w-none"
: "flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6"
}
onEscapeKeyDown={(e) => {
// While maximized, Esc only collapses the panel (handled by the
// keydown listener) — it must not close the whole dialog.
if (isMaximized) e.preventDefault();
}}
>
<DialogTitle className="sr-only">{path}</DialogTitle>
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText
className="h-5 w-5 shrink-0 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{path}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => {
runOrConfirmDiscard("cancel-edit");
}}
disabled={saving}
>
<Eye
size={16}
className="mr-1"
aria-hidden="true"
/>
Cancel
</Button>
<Button
size="sm"
className="h-8 bg-[var(--brand-solid)] px-3 text-[var(--brand-foreground)] hover:opacity-90"
onClick={save}
disabled={saving || !dirty}
>
{saving ? (
<Loader2
size={16}
className="mr-1 animate-spin"
aria-hidden="true"
/>
) : (
<Save
size={16}
className="mr-1"
aria-hidden="true"
/>
)}
{saving ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
{showHtmlToggle && (
<div
role="group"
aria-label="HTML view"
className="flex items-center rounded-md border border-border"
>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-r-none px-2 ${
htmlView === "preview" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("preview")}
aria-pressed={htmlView === "preview"}
>
<Eye size={16} className="mr-1" aria-hidden="true" />
Preview
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-l-none px-2 ${
htmlView === "source" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("source")}
aria-pressed={htmlView === "source"}
>
<Code size={16} className="mr-1" aria-hidden="true" />
Source
</Button>
</div>
)}
{showHtmlToggle && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => setIsMaximized((v) => !v)}
aria-label="Toggle maximize"
aria-pressed={isMaximized}
title="Maximize"
>
{isMaximized ? (
<Minimize2 size={16} aria-hidden="true" />
) : (
<Maximize2 size={16} aria-hidden="true" />
)}
</Button>
)}
{editable && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={startEdit}
disabled={loading || content === null}
aria-label="Edit file"
>
<Pencil
size={16}
className="mr-1"
aria-hidden="true"
/>
Edit
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download
</a>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleting}
aria-label="Delete file"
title="Delete file"
>
{deleting ? (
<Loader2
size={16}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Trash2
size={16}
aria-hidden="true"
/>
)}
</Button>
</>
)}
</div>
</div>
{actionError && (
<p
role="alert"
className="mb-2 text-sm text-destructive"
>
{actionError}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
aria-label="File content"
className="h-full w-full resize-none rounded-md border border-border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
placeholder="File is empty…"
/>
) : kind === "image" ? (
<div className="relative h-full overflow-hidden rounded-md bg-[var(--color-surface)]">
{/* key on path: switching files resets the zoom state. */}
<TransformWrapper
key={path}
initialScale={1}
minScale={0.2}
maxScale={8}
centerOnInit
smooth={false}
wheel={{ step: 0.05 }}
doubleClick={{ mode: "reset" }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="absolute right-2 top-2 z-10 flex gap-0.5 rounded-md border border-border bg-background/90 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomOut()}
aria-label="Zoom out"
title="Zoom out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomIn()}
aria-label="Zoom in"
title="Zoom in"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => resetTransform()}
aria-label="Reset zoom"
title="Fit to window"
>
<RotateCcw className="size-4" />
</Button>
</div>
<TransformComponent
wrapperClass="!h-full !w-full"
contentClass="!h-full !w-full flex items-center justify-center"
>
<img
src={workspaceFileUrl(threadId, path)}
alt={name}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
</TransformComponent>
{onNavigate && (siblingNav.prev || siblingNav.next) && (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.prev)}
disabled={!siblingNav.prev}
aria-label="Previous image"
title="Previous image"
>
<ChevronLeft className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.next)}
disabled={!siblingNav.next}
aria-label="Next image"
title="Next image"
>
<ChevronRight className="size-5" />
</Button>
</>
)}
</>
)}
</TransformWrapper>
</div>
) : kind === "pdf" ? (
<iframe
src={workspaceFileUrl(threadId, path)}
title={name}
className="h-full w-full rounded-md border border-border"
/>
) : kind === "binary" || tooBigForText || tooBigForOffice ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-muted-foreground">
{tooBigForText
? "This file is too large to preview inline."
: tooBigForOffice
? "Office files larger than 25 MB can't be previewed inline."
: "This file type can't be previewed."}
</p>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download file
</a>
</Button>
</div>
) : loading ? (
<div className="flex h-full items-center justify-center">
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-hidden="true"
/>
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download file
</a>
</Button>
</div>
) : officePreview?.kind === "pdf" ? (
<iframe
src={officePreview.url}
title={`${name} preview`}
className="h-full w-full rounded-md border border-border"
/>
) : officePreview?.kind === "docx" ? (
<DocxPreview
document={officePreview.document}
onError={useDocxPreviewFallback}
/>
) : officePreview?.kind === "spreadsheet" ? (
<SpreadsheetPreview
document={officePreview.document}
onError={setError}
/>
) : isHtml && htmlView === "preview" && !tooBigForText ? (
<iframe
key={savedTick}
src={`${workspaceFileUrl(threadId, path)}&render=1`}
sandbox="allow-scripts"
title={name}
className="h-full w-full rounded-md border border-border bg-white"
/>
) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="p-4">
{content && content.length > 0 ? (
isMarkdown ? (
<div className="rounded-md p-6">
<MarkdownContent content={content} />
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
showLineNumbers
wrapLines={true}
lineProps={{ style: { whiteSpace: "pre-wrap" } }}
>
{content}
</SyntaxHighlighter>
)
) : (
<div className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
File is empty
</p>
</div>
)}
</div>
</ScrollArea>
)}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={discardAction !== null}
onOpenChange={(open) => {
if (!open) setDiscardAction(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Discard unsaved changes?</DialogTitle>
<DialogDescription>
Your edits to “{name}” have not been saved.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDiscardAction(null)}
>
Keep Editing
</Button>
<Button
onClick={confirmDiscardChanges}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Discard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete file?</DialogTitle>
<DialogDescription>
“{name}” will be permanently removed from the workspace. This
can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
onClick={remove}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
WorkspaceFileDialog.displayName = "WorkspaceFileDialog";