feat(skills): add file browser, version selector, and security hardening

- Add skill file browser with tree panel and content viewer (syntax highlight per file type)
- Add version selector to browse all skill versions on disk
- Add 3 new API endpoints: GET /v1/skills/{id}/versions, files, files/{path}
- Make skill name clickable to open detail dialog (remove redundant View button)
- Filter system artifacts (__MACOSX, .DS_Store, Thumbs.db) from ZIP extraction and file listing
- Add symlink protection in extraction, file listing, and file reading
- Strengthen path traversal prevention with prefix validation
- Strip frontmatter from .md files in file viewer
- Fix double scrollbar in file content panel
This commit is contained in:
viettranx committed 2026-03-08 10:19:09 +07:00
1 parent 7652fe214d
commit 2a4db0da94
7 files changed
+711 -35

No files matched your search

+215
View File
@@ -11,6 +11,8 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
@@ -61,6 +63,9 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.authMiddleware(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/user/{userID}", h.authMiddleware(h.handleRevokeUser))
mux.HandleFunc("GET /v1/agents/{agentID}/skills", h.authMiddleware(h.handleListAgentSkills))
mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions))
mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile))
mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles))
}
func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -239,6 +244,10 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
if f.FileInfo().IsDir() {
continue
}
// Skip symlinks in ZIP — prevent directory escape attacks
if f.Mode()&os.ModeSymlink != 0 {
continue
}
// Strip wrapper directory prefix if ZIP had one
entryName := strings.TrimPrefix(f.Name, "./")
if stripPrefix != "" {
@@ -247,12 +256,19 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
continue
}
}
// Skip macOS/system artifacts
if isSystemArtifact(entryName) {
continue
}
// Security: prevent path traversal
name := filepath.Clean(entryName)
if strings.Contains(name, "..") {
continue
}
destPath := filepath.Join(destDir, name)
if !strings.HasPrefix(destPath, destDir+string(filepath.Separator)) {
continue
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
continue
}
@@ -312,3 +328,202 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills})
}
// handleListVersions returns all available version numbers for a skill.
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
return
}
slugDir := filepath.Join(h.baseDir, slug)
entries, err := os.ReadDir(slugDir)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"versions": []int{currentVersion},
"current": currentVersion,
})
return
}
var versions []int
for _, e := range entries {
if !e.IsDir() {
continue
}
v, err := strconv.Atoi(e.Name())
if err != nil || v < 1 {
continue
}
versions = append(versions, v)
}
sort.Ints(versions)
if len(versions) == 0 {
versions = []int{currentVersion}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"versions": versions,
"current": currentVersion,
})
}
// handleListFiles returns all files in a skill version directory.
func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
return
}
version := currentVersion
if v := r.URL.Query().Get("version"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
return
}
version = parsed
}
versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
if _, err := os.Stat(versionDir); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"})
return
}
type fileEntry struct {
Path string `json:"path"`
Name string `json:"name"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
}
var files []fileEntry
filepath.WalkDir(versionDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(versionDir, path)
if rel == "." {
return nil
}
// Skip system artifacts (__MACOSX, .DS_Store, etc.)
if isSystemArtifact(rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip symlinks — prevent escape from skill directory
if d.Type()&os.ModeSymlink != 0 {
return nil
}
entry := fileEntry{
Path: rel,
Name: d.Name(),
IsDir: d.IsDir(),
}
if !d.IsDir() {
if info, err := d.Info(); err == nil {
entry.Size = info.Size()
}
}
files = append(files, entry)
return nil
})
if files == nil {
files = []fileEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"files": files})
}
// handleReadFile reads a single file from a skill version directory.
func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
return
}
relPath := r.PathValue("path")
if relPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
return
}
if strings.Contains(relPath, "..") {
slog.Warn("security.skill_files_traversal", "path", relPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
return
}
version := currentVersion
if v := r.URL.Query().Get("version"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
return
}
version = parsed
}
versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
absPath := filepath.Join(versionDir, filepath.Clean(relPath))
// Verify resolved path is within the version directory
if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return
}
// Use Lstat to detect symlinks — reject them to prevent directory escape
info, err := os.Lstat(absPath)
if err != nil || info.IsDir() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
return
}
if info.Mode()&os.ModeSymlink != 0 {
slog.Warn("security.skill_files_symlink", "path", absPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return
}
// Skip system artifacts
if isSystemArtifact(relPath) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
return
}
data, err := os.ReadFile(absPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"content": string(data),
"path": relPath,
"size": info.Size(),
})
}
+23
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/google/uuid"
@@ -188,6 +189,28 @@ func parseSkillFrontmatter(content string) (name, description, slug string, allF
return
}
// isSystemArtifact returns true for OS-generated junk that should be skipped
// during ZIP extraction and file listing (e.g. __MACOSX, .DS_Store, Thumbs.db).
func isSystemArtifact(name string) bool {
base := filepath.Base(name)
// macOS resource fork / metadata folders and files
if base == "__MACOSX" || strings.HasPrefix(base, "._") {
return true
}
// Check if any path component is __MACOSX
for _, part := range strings.Split(filepath.ToSlash(name), "/") {
if part == "__MACOSX" {
return true
}
}
// Common OS junk files
switch base {
case ".DS_Store", "Thumbs.db", "desktop.ini", ".Spotlight-V100", ".Trashes", ".fseventsd":
return true
}
return false
}
func slugify(name string) string {
s := strings.ToLower(name)
s = strings.Map(func(r rune) rune {
+8
View File
@@ -301,6 +301,14 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara
return id, err
}
// GetSkillFilePath returns the filesystem path and version for a skill by UUID.
func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) {
err := s.db.QueryRow(
"SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id,
).Scan(&filePath, &slug, &version)
return filePath, slug, version, err == nil
}
// GetNextVersion returns the next version number for a skill slug.
func (s *PGSkillStore) GetNextVersion(slug string) int {
var maxVersion int
+33 -3
View File
@@ -4,9 +4,9 @@ import { useWs, useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SkillInfo } from "@/types/skill";
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
export type { SkillInfo };
export type { SkillInfo, SkillFile, SkillVersions };
export function useSkills() {
const ws = useWs();
@@ -69,5 +69,35 @@ export function useSkills() {
[http, invalidate],
);
return { skills, loading, refresh: invalidate, getSkill, uploadSkill, updateSkill, deleteSkill };
const getSkillVersions = useCallback(
async (id: string) => {
return http.get<SkillVersions>(`/v1/skills/${id}/versions`);
},
[http],
);
const getSkillFiles = useCallback(
async (id: string, version?: number) => {
const q = version != null ? `?version=${version}` : "";
const res = await http.get<{ files: SkillFile[] }>(`/v1/skills/${id}/files${q}`);
return res.files ?? [];
},
[http],
);
const getSkillFileContent = useCallback(
async (id: string, path: string, version?: number) => {
const q = version != null ? `?version=${version}` : "";
return http.get<{ content: string; path: string; size: number }>(
`/v1/skills/${id}/files/${encodeURIComponent(path)}${q}`,
);
},
[http],
);
return {
skills, loading, refresh: invalidate, getSkill,
uploadSkill, updateSkill, deleteSkill,
getSkillVersions, getSkillFiles, getSkillFileContent,
};
}
+403 -12
View File
@@ -1,24 +1,338 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import type { SkillInfo } from "./hooks/use-skills";
import { useClipboard } from "@/hooks/use-clipboard";
import {
Folder,
FolderOpen,
FileText,
FileCode2,
File,
ChevronRight,
Check,
Copy,
Loader2,
} from "lucide-react";
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
// --- Types ---
interface SkillDetailDialogProps {
skill: SkillInfo & { content: string };
onClose: () => void;
getSkillVersions: (id: string) => Promise<SkillVersions>;
getSkillFiles: (id: string, version?: number) => Promise<SkillFile[]>;
getSkillFileContent: (id: string, path: string, version?: number) => Promise<{ content: string; path: string; size: number }>;
}
export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) {
interface TreeNode {
name: string;
path: string;
isDir: boolean;
size: number;
children: TreeNode[];
}
// --- Helpers ---
const CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "py", "go", "json", "yaml", "yml", "toml",
"sh", "bash", "css", "html", "sql", "rs", "rb", "java", "kt", "swift",
"c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env",
]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
function langFor(ext: string): string {
const map: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
py: "python", go: "go", rs: "rust", rb: "ruby", sh: "bash", bash: "bash",
yml: "yaml", yaml: "yaml", json: "json", toml: "toml",
css: "css", html: "html", sql: "sql", xml: "xml",
java: "java", kt: "kotlin", swift: "swift",
c: "c", cpp: "cpp", h: "c", graphql: "graphql",
proto: "protobuf", lua: "lua", zig: "zig",
};
return map[ext] ?? ext;
}
function buildTree(files: SkillFile[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
// Sort: dirs first, then alphabetically
const sorted = [...files].sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const node: TreeNode = {
name: f.name,
path: f.path,
isDir: f.isDir,
size: f.size,
children: [],
};
if (f.isDir) {
dirMap.set(f.path, node);
}
// Find parent dir
const parentPath = f.path.includes("/")
? f.path.slice(0, f.path.lastIndexOf("/"))
: "";
if (parentPath && dirMap.has(parentPath)) {
dirMap.get(parentPath)!.children.push(node);
} else {
root.push(node);
}
}
// Sort children: dirs first, then alphabetically
const sortChildren = (nodes: TreeNode[]) => {
nodes.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) {
if (n.children.length > 0) sortChildren(n.children);
}
};
sortChildren(root);
return root;
}
function stripFrontmatter(content: string): string {
if (!content.startsWith("---")) return content;
const end = content.indexOf("---", 3);
if (end < 0) return content;
return content.slice(end + 3).replace(/^\n+/, "");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
if (ext === "md") return <FileText className="h-4 w-4 shrink-0 text-blue-500" />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className="h-4 w-4 shrink-0 text-orange-500" />;
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
// --- Components ---
function TreeItem({
node,
depth,
activePath,
onSelect,
}: {
node: TreeNode;
depth: number;
activePath: string | null;
onSelect: (path: string) => void;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const isActive = activePath === node.path;
if (node.isDir) {
return (
<div>
<button
type="button"
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm hover:bg-accent cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
{expanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
) : (
<Folder className="h-4 w-4 shrink-0 text-yellow-600" />
)}
<span className="truncate">{node.name}</span>
</button>
{expanded && node.children.map((child) => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
activePath={activePath}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<button
type="button"
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm cursor-pointer ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
}`}
style={{ paddingLeft: `${depth * 16 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<FileIcon name={node.name} />
<span className="truncate">{node.name}</span>
</button>
);
}
function CodeViewer({ content, language }: { content: string; language: string }) {
const { copied, copy } = useClipboard();
return (
<div className="group relative overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground">
<span>{language || "text"}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-auto bg-muted/50 p-4 text-sm text-foreground max-h-[60vh]">
<code className={language ? `language-${language}` : ""}>{content}</code>
</pre>
</div>
);
}
function FileContentViewer({ path, content, size }: { path: string; content: string; size: number }) {
const ext = extOf(path);
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-xs text-muted-foreground border-b pb-2">
<span className="font-mono">{path}</span>
<span>{formatSize(size)}</span>
</div>
{ext === "md" ? (
<MarkdownRenderer content={displayContent} />
) : CODE_EXTENSIONS.has(ext) ? (
<CodeViewer content={displayContent} language={langFor(ext)} />
) : (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
)}
</div>
);
}
// --- Main Component ---
export function SkillDetailDialog({
skill,
onClose,
getSkillVersions,
getSkillFiles,
getSkillFileContent,
}: SkillDetailDialogProps) {
const hasFiles = !!skill.id;
// Version state
const [versions, setVersions] = useState<SkillVersions | null>(null);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
// File tree state
const [files, setFiles] = useState<SkillFile[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [activePath, setActivePath] = useState<string | null>(null);
// File content state
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const tree = useMemo(() => buildTree(files), [files]);
// Load versions when Files tab is first opened
const loadVersions = useCallback(async () => {
if (!skill.id || versions) return;
const v = await getSkillVersions(skill.id);
setVersions(v);
setSelectedVersion(v.current);
}, [skill.id, versions, getSkillVersions]);
// Load files for selected version
const loadFiles = useCallback(async (version?: number) => {
if (!skill.id) return;
setFilesLoading(true);
try {
const f = await getSkillFiles(skill.id, version);
setFiles(f);
setActivePath(null);
setFileContent(null);
} finally {
setFilesLoading(false);
}
}, [skill.id, getSkillFiles]);
// Load file content
const loadFileContent = useCallback(async (path: string) => {
if (!skill.id) return;
setActivePath(path);
setContentLoading(true);
try {
const c = await getSkillFileContent(skill.id, path, selectedVersion ?? undefined);
setFileContent(c);
} finally {
setContentLoading(false);
}
}, [skill.id, selectedVersion, getSkillFileContent]);
// On version change
useEffect(() => {
if (selectedVersion != null) {
loadFiles(selectedVersion);
}
}, [selectedVersion, loadFiles]);
const handleTabChange = (tab: string) => {
if (tab === "files" && hasFiles) {
loadVersions();
if (files.length === 0 && !filesLoading) {
loadFiles(selectedVersion ?? undefined);
}
}
};
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[85vh] overflow-hidden flex flex-col sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogTitle className="flex items-center gap-2 flex-wrap">
{skill.name}
<Badge variant="outline">{skill.source || "file"}</Badge>
{skill.visibility && (
@@ -39,15 +353,92 @@ export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) {
</div>
)}
</DialogHeader>
<div className="mt-2">
{skill.content ? (
<div className="overflow-hidden rounded-md border bg-muted/30 p-4">
<MarkdownRenderer content={skill.content} />
</div>
) : (
<p className="text-sm text-muted-foreground">No content available.</p>
<Tabs defaultValue="content" className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="content">Content</TabsTrigger>
{hasFiles && <TabsTrigger value="files">Files</TabsTrigger>}
</TabsList>
<TabsContent value="content" className="flex-1 overflow-y-auto mt-2">
{skill.content ? (
<div className="overflow-hidden rounded-md border bg-muted/30 p-4">
<MarkdownRenderer content={skill.content} />
</div>
) : (
<p className="text-sm text-muted-foreground">No content available.</p>
)}
</TabsContent>
{hasFiles && (
<TabsContent value="files" className="flex-1 overflow-hidden flex flex-col mt-2 gap-2">
{/* Version selector */}
{versions && versions.versions.length > 1 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Version:</span>
<Select
value={String(selectedVersion ?? versions.current)}
onValueChange={(v) => setSelectedVersion(Number(v))}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{versions.versions.map((v) => (
<SelectItem key={v} value={String(v)}>
v{v}{v === versions.current ? " (current)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* File browser */}
<div className="flex-1 flex border rounded-md overflow-hidden min-h-0">
{/* Tree panel */}
<div className="w-[35%] border-r overflow-y-auto bg-muted/20 py-1">
{filesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tree.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>
) : (
tree.map((node) => (
<TreeItem
key={node.path}
node={node}
depth={0}
activePath={activePath}
onSelect={loadFileContent}
/>
))
)}
</div>
{/* Content panel */}
<div className="w-[65%] overflow-y-auto p-3">
{contentLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : fileContent ? (
<FileContentViewer
path={fileContent.path}
content={fileContent.content}
size={fileContent.size}
/>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
</div>
)}
</div>
</div>
</TabsContent>
)}
</div>
</Tabs>
</DialogContent>
</Dialog>
);
+17 -20
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Zap, Eye, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react";
import { Zap, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
@@ -23,12 +23,14 @@ const visibilityColor: Record<string, string> = {
};
export function SkillsPage() {
const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill } = useSkills();
const {
skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill,
getSkillVersions, getSkillFiles, getSkillFileContent,
} = useSkills();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
const [search, setSearch] = useState("");
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SkillInfo | null>(null);
@@ -45,13 +47,8 @@ export function SkillsPage() {
useEffect(() => { resetPage(); }, [search, resetPage]);
const handleViewSkill = async (name: string) => {
setDetailLoading(true);
try {
const detail = await getSkill(name);
if (detail) setSelectedSkill(detail);
} finally {
setDetailLoading(false);
}
const detail = await getSkill(name);
if (detail) setSelectedSkill(detail);
};
const handleUpload = async (file: File) => {
@@ -124,7 +121,13 @@ export function SkillsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{skill.name}</span>
<button
type="button"
className="font-medium text-left hover:underline cursor-pointer"
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
>
{skill.name}
</button>
{skill.version ? (
<span className="text-xs text-muted-foreground">v{skill.version}</span>
) : null}
@@ -145,15 +148,6 @@ export function SkillsPage() {
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
disabled={detailLoading}
className="gap-1"
>
<Eye className="h-3.5 w-3.5" /> View
</Button>
{skill.id && (
<>
<Button
@@ -196,6 +190,9 @@ export function SkillsPage() {
<SkillDetailDialog
skill={selectedSkill}
onClose={() => setSelectedSkill(null)}
getSkillVersions={getSkillVersions}
getSkillFiles={getSkillFiles}
getSkillFileContent={getSkillFileContent}
/>
)}
+12
View File
@@ -9,6 +9,18 @@ export interface SkillInfo {
version?: number;
}
export interface SkillFile {
path: string;
name: string;
isDir: boolean;
size: number;
}
export interface SkillVersions {
versions: number[];
current: number;
}
export interface SkillWithGrant {
id: string;
name: string;