316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CopyIcon,
|
|
DownloadIcon,
|
|
Loader2,
|
|
PencilIcon,
|
|
Trash2Icon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
|
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
|
import {
|
|
useDeleteSkillFile,
|
|
useSaveSkillFile,
|
|
useSkillFileContent,
|
|
} from "@/core/skills/hooks";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { reasoningPlugins } from "@/core/streamdown";
|
|
|
|
const markdownClassName =
|
|
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
|
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
|
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
|
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
|
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
|
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
|
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
|
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
|
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
|
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
|
|
|
|
function isImageMime(mime: string): boolean {
|
|
return mime.startsWith("image/");
|
|
}
|
|
|
|
export function SkillFilePreviewDialog({
|
|
skillName,
|
|
path,
|
|
treeEditable,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
skillName: string | null;
|
|
path: string | null;
|
|
/** From the tree response: whether *any* file in this skill is editable for the caller. */
|
|
treeEditable: boolean;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const tx = t.settings.skills;
|
|
|
|
const { data, isLoading, error } = useSkillFileContent(
|
|
open ? skillName : null,
|
|
open ? path : null,
|
|
);
|
|
const save = useSaveSkillFile();
|
|
const remove = useDeleteSkillFile();
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
|
|
// Reset editor state whenever a different file is opened or the dialog closes.
|
|
useEffect(() => {
|
|
setEditing(false);
|
|
setDraft("");
|
|
}, [path, open]);
|
|
|
|
const fileEditable = !!data?.editable && treeEditable;
|
|
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
|
|
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
|
|
const isMarkdown = useMemo(() => {
|
|
if (!path) return false;
|
|
const lower = path.toLowerCase();
|
|
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
|
}, [path]);
|
|
|
|
const dirty = editing && data?.content !== undefined && draft !== data.content;
|
|
|
|
function requestClose(next: boolean) {
|
|
if (!next && dirty) {
|
|
if (!window.confirm(tx.filesDiscardConfirm)) return;
|
|
}
|
|
onOpenChange(next);
|
|
}
|
|
|
|
function startEditing() {
|
|
if (!data || data.content == null) return;
|
|
setDraft(data.content);
|
|
setEditing(true);
|
|
}
|
|
|
|
function cancelEditing() {
|
|
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
|
|
setEditing(false);
|
|
setDraft("");
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!skillName || !path) return;
|
|
try {
|
|
await save.mutateAsync({ skillName, path, content: draft });
|
|
toast.success(tx.filesSaveSuccess);
|
|
setEditing(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!skillName || !path) return;
|
|
if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return;
|
|
try {
|
|
await remove.mutateAsync({ skillName, path });
|
|
toast.success(tx.filesDeleteSuccess);
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleCopyPath() {
|
|
if (!path) return;
|
|
await navigator.clipboard.writeText(path);
|
|
toast.success(tx.filesCopyPathAction);
|
|
}
|
|
|
|
const downloadHref =
|
|
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={requestClose}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="flex flex-col gap-0 overflow-hidden p-0"
|
|
style={{
|
|
width: "min(56rem, calc(100vw - 2rem))",
|
|
maxWidth: "none",
|
|
minHeight: "min(420px, calc(100vh - 2rem))",
|
|
height: "min(720px, calc(100vh - 2rem))",
|
|
maxHeight: "min(720px, calc(100vh - 2rem))",
|
|
}}
|
|
>
|
|
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<DialogTitle className="truncate font-mono text-sm">
|
|
{path ?? ""}
|
|
</DialogTitle>
|
|
<DialogDescription className="mt-0.5 truncate text-xs">
|
|
{skillName}
|
|
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
|
|
</DialogDescription>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{fileEditable && !editing && data?.is_text && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
|
onClick={startEditing}
|
|
>
|
|
<PencilIcon className="size-3.5" />
|
|
{tx.filesEditAction}
|
|
</Button>
|
|
)}
|
|
{fileDeletable && !editing && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
|
onClick={() => void handleDelete()}
|
|
disabled={remove.isPending}
|
|
>
|
|
{remove.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
|
|
{tx.filesDeleteAction}
|
|
</Button>
|
|
)}
|
|
{editing && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={cancelEditing}
|
|
disabled={save.isPending}
|
|
>
|
|
{tx.filesCancelEditAction}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => void handleSave()}
|
|
disabled={save.isPending || !dirty}
|
|
>
|
|
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
|
{tx.filesSaveAction}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{!editing && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
|
onClick={() => void handleCopyPath()}
|
|
>
|
|
<CopyIcon className="size-3.5" />
|
|
</Button>
|
|
{downloadHref && (
|
|
<Button
|
|
asChild
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
|
>
|
|
<a href={downloadHref} download>
|
|
<DownloadIcon className="size-3.5" />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
|
|
onClick={() => requestClose(false)}
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
|
|
{isLoading ? (
|
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{t.common.loading}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-destructive py-6 text-sm">
|
|
{error instanceof Error ? error.message : String(error)}
|
|
</div>
|
|
) : data == null ? null : editing ? (
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
autoFocus
|
|
spellCheck={false}
|
|
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
|
|
/>
|
|
) : data.is_text ? (
|
|
isMarkdown ? (
|
|
<MarkdownContent
|
|
className={markdownClassName}
|
|
content={data.content ?? ""}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
/>
|
|
) : (
|
|
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
|
|
{data.content ?? ""}
|
|
</pre>
|
|
)
|
|
) : isImageMime(data.mime) ? (
|
|
<div className="flex items-center justify-center">
|
|
{downloadHref && (
|
|
<img
|
|
src={downloadHref}
|
|
alt={data.path}
|
|
className="max-h-full max-w-full rounded border"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
|
<p>{tx.filesPreviewBinary}</p>
|
|
{downloadHref && (
|
|
<Button asChild size="sm" variant="outline" className="mt-3">
|
|
<a href={downloadHref} download>
|
|
<DownloadIcon className="size-3.5" />
|
|
{tx.filesDownloadAction}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|