601 lines
23 KiB
TypeScript
601 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CopyIcon,
|
|
EyeIcon,
|
|
FileCode2Icon,
|
|
FilesIcon,
|
|
FileTextIcon,
|
|
GlobeIcon,
|
|
Loader2,
|
|
PencilIcon,
|
|
SlidersHorizontalIcon,
|
|
SparklesIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
|
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
|
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
|
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
|
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import {
|
|
useGenerateSkillNameZh,
|
|
useSetSkillAlwaysOn,
|
|
useSetSkillDetail,
|
|
useSetSkillNameZh,
|
|
useSkill,
|
|
useSkillContent,
|
|
useSkillFiles,
|
|
} from "@/core/skills/hooks";
|
|
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
|
|
import type { Skill } from "@/core/skills/type";
|
|
import { reasoningPlugins } from "@/core/streamdown";
|
|
import { desensitize } from "@/lib/desensitize";
|
|
|
|
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 " +
|
|
"[&_table]:my-4 [&_table]:w-full [&_table]:min-w-max [&_table]:border-collapse " +
|
|
"[&_th]:border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:px-3 [&_td]:py-2";
|
|
|
|
function canManageSkill(skill: Skill | null | undefined): boolean {
|
|
if (!skill) return false;
|
|
const auth = getStoredAuth();
|
|
if (!auth) return false;
|
|
if (auth.system_role === "admin") return true;
|
|
return (
|
|
skill.category === "custom" &&
|
|
!!skill.owner_user_id &&
|
|
skill.owner_user_id === auth.user_id
|
|
);
|
|
}
|
|
|
|
function isSkillOwner(skill: Skill | null | undefined): boolean {
|
|
if (!skill) return false;
|
|
const auth = getStoredAuth();
|
|
if (!auth) return false;
|
|
return (
|
|
skill.category === "custom" &&
|
|
!skill.builtin &&
|
|
!!skill.owner_user_id &&
|
|
skill.owner_user_id === auth.user_id
|
|
);
|
|
}
|
|
|
|
function stripYamlFrontmatter(content: string): string {
|
|
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
|
}
|
|
|
|
export function SkillDetailDialog({
|
|
skillName,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
skillName: string | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const tx = t.settings.skills;
|
|
const activeName = open ? skillName : null;
|
|
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
|
const setDetail = useSetSkillDetail();
|
|
const setNameZh = useSetSkillNameZh();
|
|
const generateNameZh = useGenerateSkillNameZh();
|
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
|
const { data: compression } = useSkillCompressionSettings();
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const [editingNameZh, setEditingNameZh] = useState(false);
|
|
const [nameZhDraft, setNameZhDraft] = useState("");
|
|
// Optional model override for the "AI 生成" button (null = configured default).
|
|
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<
|
|
"detail" | "display" | "source" | "files" | null
|
|
>(null);
|
|
const [showRawSource, setShowRawSource] = useState(false);
|
|
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
|
|
|
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
|
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
|
// Admins may browse any skill's files (the backend authorizes this); owners
|
|
// may browse their own custom skill. Edit-ability is decided separately by
|
|
// the backend's `filesData.editable` flag.
|
|
const canViewFiles = useMemo(() => {
|
|
if (!skill) return false;
|
|
return isOwner || getStoredAuth()?.system_role === "admin";
|
|
}, [skill, isOwner]);
|
|
const {
|
|
data: contentData,
|
|
isLoading: isContentLoading,
|
|
error: contentError,
|
|
} = useSkillContent(open ? skillName : null);
|
|
|
|
const filesEnabled = open && canViewFiles && activeTab === "files";
|
|
const {
|
|
data: filesData,
|
|
isLoading: isFilesLoading,
|
|
error: filesError,
|
|
} = useSkillFiles(filesEnabled ? skillName : null);
|
|
|
|
useEffect(() => {
|
|
setEditing(false);
|
|
setEditingNameZh(false);
|
|
setActiveTab(null);
|
|
setShowRawSource(false);
|
|
setOpenedFilePath(null);
|
|
}, [open, skillName]);
|
|
|
|
useEffect(() => {
|
|
setNameZhDraft(skill?.name_zh ?? "");
|
|
}, [skill?.name, skill?.name_zh]);
|
|
|
|
const skillBody = useMemo(() => {
|
|
return stripYamlFrontmatter(contentData?.content ?? "");
|
|
}, [contentData]);
|
|
|
|
const defaultTab: "detail" = "detail";
|
|
const selectedTab = activeTab ?? defaultTab;
|
|
|
|
function startEditing() {
|
|
setDraft(skill?.detail ?? "");
|
|
setEditing(true);
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!skill) return;
|
|
try {
|
|
await setDetail.mutateAsync({ skillName: skill.name, detail: draft });
|
|
toast.success(tx.detailSaveSuccess);
|
|
setEditing(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
function startEditingNameZh() {
|
|
setNameZhDraft(skill?.name_zh ?? "");
|
|
setEditingNameZh(true);
|
|
}
|
|
|
|
async function handleSaveNameZh() {
|
|
if (!skill) return;
|
|
try {
|
|
await setNameZh.mutateAsync({
|
|
skillName: skill.name,
|
|
nameZh: nameZhDraft.trim(),
|
|
});
|
|
toast.success("中文名称已保存");
|
|
setEditingNameZh(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleGenerateNameZh() {
|
|
if (!skill) return;
|
|
try {
|
|
const result = await generateNameZh.mutateAsync({
|
|
skillName: skill.name,
|
|
modelName: nameZhModel,
|
|
});
|
|
setNameZhDraft(result.name_zh ?? "");
|
|
toast.success("已生成中文名称");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function copySource() {
|
|
await navigator.clipboard.writeText(contentData?.content ?? "");
|
|
toast.success("Copied SKILL.md content");
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className="flex flex-col gap-0 overflow-hidden p-0"
|
|
style={{
|
|
width: "min(48rem, calc(100vw - 2rem))",
|
|
maxWidth: "none",
|
|
height: "min(760px, calc(100dvh - 2rem))",
|
|
}}
|
|
>
|
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
|
<SparklesIcon className="size-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
{editingNameZh ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<DialogTitle className="sr-only">
|
|
{desensitize(skill?.name ?? skillName ?? "")}
|
|
</DialogTitle>
|
|
<Input
|
|
value={nameZhDraft}
|
|
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") void handleSaveNameZh();
|
|
if (event.key === "Escape") setEditingNameZh(false);
|
|
}}
|
|
placeholder={desensitize(skill?.name ?? skillName ?? "")}
|
|
className="h-8 max-w-[14rem] flex-1 text-sm"
|
|
autoFocus
|
|
disabled={generateNameZh.isPending || setNameZh.isPending}
|
|
/>
|
|
<ModelNameSelect
|
|
value={nameZhModel}
|
|
onChange={setNameZhModel}
|
|
disabled={generateNameZh.isPending || setNameZh.isPending}
|
|
triggerClassName="h-8 w-[8.5rem] shrink-0"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
|
onClick={() => void handleGenerateNameZh()}
|
|
disabled={generateNameZh.isPending || setNameZh.isPending}
|
|
title="用所选大模型自动生成中文名"
|
|
>
|
|
{generateNameZh.isPending ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<SparklesIcon className="size-3.5" />
|
|
)}
|
|
AI 生成
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-8 shrink-0 px-3 text-xs"
|
|
onClick={() => void handleSaveNameZh()}
|
|
disabled={setNameZh.isPending || generateNameZh.isPending}
|
|
>
|
|
{setNameZh.isPending && (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
)}
|
|
{t.common.save}
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground size-8 shrink-0"
|
|
onClick={() => setEditingNameZh(false)}
|
|
disabled={setNameZh.isPending}
|
|
title={t.common.cancel}
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="group flex flex-wrap items-center gap-2">
|
|
<DialogTitle className="truncate text-base">
|
|
{skill?.name_zh?.trim()
|
|
? skill.name_zh
|
|
: desensitize(skill?.name ?? skillName ?? "")}
|
|
</DialogTitle>
|
|
{skill?.name_zh?.trim() && (
|
|
<span className="text-muted-foreground truncate font-mono text-xs">
|
|
{desensitize(skill?.name ?? skillName ?? "")}
|
|
</span>
|
|
)}
|
|
{canManage && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground size-6 shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
|
onClick={startEditingNameZh}
|
|
title={skill?.name_zh?.trim() ? "编辑中文名" : "设置中文名"}
|
|
>
|
|
<PencilIcon className="size-3" />
|
|
</Button>
|
|
)}
|
|
{skill?.builtin && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{tx.badgeBuiltin}
|
|
</Badge>
|
|
)}
|
|
{skill?.published && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
|
|
>
|
|
<GlobeIcon className="size-3" />
|
|
{tx.badgePublished}
|
|
</Badge>
|
|
)}
|
|
{skill?.always_on && compression?.enabled && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-500/30 text-xs text-amber-700 dark:text-amber-300"
|
|
title="常驻(免压缩):本技能始终全文注入系统提示词"
|
|
>
|
|
常驻
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
|
{skill?.description ? desensitize(skill.description) : "-"}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{isSkillLoading || !skill ? (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-10">
|
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{t.common.loading}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Tabs
|
|
value={selectedTab}
|
|
onValueChange={(value) =>
|
|
setActiveTab(value as Exclude<typeof activeTab, null>)
|
|
}
|
|
className="flex min-h-0 flex-1 flex-col gap-0"
|
|
>
|
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
|
|
<TabsList className="h-8">
|
|
<TabsTrigger value="detail" className="text-xs">
|
|
<FileTextIcon className="mr-1 size-3.5" />
|
|
{tx.detailSectionTitle}
|
|
</TabsTrigger>
|
|
{canManage && (
|
|
<TabsTrigger value="display" className="text-xs">
|
|
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
|
展示适配
|
|
</TabsTrigger>
|
|
)}
|
|
{canViewFiles && (
|
|
<TabsTrigger value="files" className="text-xs">
|
|
<FilesIcon className="mr-1 size-3.5" />
|
|
{tx.filesSectionTitle}
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{canManage && compression?.enabled && skill && (
|
|
<div
|
|
className="mr-1 flex items-center gap-1.5"
|
|
title="开启后,即使启用了技能压缩,本技能仍会全文注入系统提示词并被正常检索(免压缩)"
|
|
>
|
|
<span className="text-muted-foreground text-xs">常驻(免压缩)</span>
|
|
<Switch
|
|
checked={!!skill.always_on}
|
|
disabled={setAlwaysOn.isPending}
|
|
onCheckedChange={(next) =>
|
|
setAlwaysOn.mutate(
|
|
{ skillName: skill.name, alwaysOn: next },
|
|
{
|
|
onSuccess: () =>
|
|
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻"),
|
|
onError: (e) =>
|
|
toast.error(
|
|
e instanceof Error ? e.message : "更新常驻状态失败",
|
|
),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{selectedTab === "detail" && canManage && !editing && (
|
|
<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.detailEditAction}
|
|
</Button>
|
|
)}
|
|
{selectedTab === "source" &&
|
|
!isContentLoading &&
|
|
!contentError && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
|
onClick={() => setShowRawSource((value) => !value)}
|
|
>
|
|
{showRawSource ? (
|
|
<>
|
|
<EyeIcon className="size-3.5" />
|
|
Rendered
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileCode2Icon className="size-3.5" />
|
|
Raw
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
|
onClick={() => void copySource()}
|
|
>
|
|
<CopyIcon className="size-3.5" />
|
|
Copy
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent
|
|
value="detail"
|
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
|
style={{ overflowY: "auto" }}
|
|
>
|
|
{editing ? (
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
placeholder={tx.detailPlaceholder}
|
|
className="max-h-[420px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
|
|
autoFocus
|
|
/>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setEditing(false)}
|
|
disabled={setDetail.isPending}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void handleSave()}
|
|
disabled={setDetail.isPending}
|
|
>
|
|
{setDetail.isPending && (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
)}
|
|
{t.common.save}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : skill?.detail ? (
|
|
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
|
|
<MarkdownContent
|
|
className={markdownClassName}
|
|
content={skill.detail}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
|
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
|
<p className="text-sm">{tx.detailEmpty}</p>
|
|
{isOwner && canManage ? (
|
|
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{canManage && (
|
|
<TabsContent
|
|
value="display"
|
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
|
style={{ overflowY: "auto" }}
|
|
>
|
|
<SkillDisplayAdapterPanel skill={skill} />
|
|
</TabsContent>
|
|
)}
|
|
|
|
{canViewFiles && (
|
|
<TabsContent
|
|
value="files"
|
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
|
style={{ overflowY: "auto" }}
|
|
>
|
|
{isFilesLoading ? (
|
|
<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>
|
|
) : filesError ? (
|
|
<div className="text-destructive py-6 text-sm">
|
|
{filesError instanceof Error
|
|
? filesError.message
|
|
: String(filesError)}
|
|
</div>
|
|
) : filesData ? (
|
|
<SkillFileTree
|
|
root={filesData.root}
|
|
selectedPath={openedFilePath}
|
|
onOpen={(path) => setOpenedFilePath(path)}
|
|
emptyText={tx.filesEmpty}
|
|
/>
|
|
) : null}
|
|
</TabsContent>
|
|
)}
|
|
|
|
<TabsContent
|
|
value="source"
|
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
|
style={{ overflowY: "auto" }}
|
|
>
|
|
{isContentLoading ? (
|
|
<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>
|
|
) : contentError ? (
|
|
<div className="text-destructive py-6 text-sm">
|
|
{contentError instanceof Error
|
|
? contentError.message
|
|
: String(contentError)}
|
|
</div>
|
|
) : showRawSource ? (
|
|
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
|
{desensitize(contentData?.content ?? "")}
|
|
</pre>
|
|
) : (
|
|
<MarkdownContent
|
|
className={markdownClassName}
|
|
content={skillBody || "(SKILL.md is empty)"}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
<SkillFilePreviewDialog
|
|
skillName={skill?.name ?? null}
|
|
path={openedFilePath}
|
|
treeEditable={!!filesData?.editable}
|
|
open={!!openedFilePath}
|
|
onOpenChange={(next) => {
|
|
if (!next) setOpenedFilePath(null);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|