Files
deerflow-code/frontend-web/src/components/workspace/skills/skill-file-tree.tsx
T
吉鑫升andClaude Opus 4.7 4410acaee8 技能展示配置持久化并适配引用来源,新增技能文件预览组件
- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配
- 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示
- 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:16:56 +08:00

153 lines
4.1 KiB
TypeScript

"use client";
import {
ChevronRightIcon,
FileCode2Icon,
FileIcon,
FileJsonIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
ImageIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { SkillFileNode } from "@/core/skills/api";
const CODE_EXTS = new Set([
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
"sh", "bash", "zsh", "ps1",
"html", "htm", "css", "scss", "sass", "xml", "svg",
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
]);
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
function fileIconFor(name: string) {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
if (name === "SKILL.md") return FileTextIcon;
if (IMAGE_EXTS.has(ext)) return ImageIcon;
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
if (CODE_EXTS.has(ext)) return FileCode2Icon;
return FileIcon;
}
function TreeRow({
node,
depth,
selectedPath,
onOpen,
}: {
node: SkillFileNode;
depth: number;
selectedPath: string | null;
onOpen: (path: string) => void;
}) {
// Top-level (root) auto-expands; nested directories start collapsed.
const [open, setOpen] = useState(depth === 0);
if (node.type === "dir") {
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={cn(
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
)}
style={{ paddingLeft: 6 + depth * 14 }}
>
<ChevronRightIcon
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
<span className="truncate font-medium">{node.name}</span>
</button>
{open && node.children && node.children.length > 0 && (
<div className="border-border/60 ml-[14px] border-l border-dashed">
{node.children.map((child) => (
<TreeRow
key={child.path || child.name}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
);
}
const Icon = fileIconFor(node.name);
const isSelected = selectedPath === node.path;
return (
<button
type="button"
onClick={() => onOpen(node.path)}
className={cn(
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
isSelected && "bg-primary/10 hover:bg-primary/15",
)}
style={{ paddingLeft: 6 + depth * 14 + 14 }}
title={node.path}
>
<Icon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate">{node.name}</span>
{typeof node.size === "number" && (
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
{formatBytes(node.size)}
</span>
)}
</button>
);
}
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`;
}
export function SkillFileTree({
root,
onOpen,
selectedPath,
emptyText,
}: {
root: SkillFileNode;
onOpen: (path: string) => void;
selectedPath: string | null;
emptyText: string;
}) {
const isEmpty = useMemo(
() => !root.children || root.children.length === 0,
[root],
);
if (isEmpty) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
{emptyText}
</div>
);
}
return (
<div className="font-mono">
<TreeRow
node={root}
depth={0}
selectedPath={selectedPath}
onOpen={onOpen}
/>
</div>
);
}