mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
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:
1 parent
7652fe214d
commit
2a4db0da94
7 files changed
+711
-35
No files matched your search
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user