From d9cdbb8e6ebc385dfd639691440fe4d0c885ce00 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 8 Mar 2026 13:18:18 +0700 Subject: [PATCH] feat(storage): add workspace file browser with delete and size display Add Storage page under Monitoring menu to browse, view, and manage files inside ~/.goclaw/. Skills directories are visible but deletion-protected. Extract shared file browser components from skills page for reuse. --- cmd/gateway.go | 3 + internal/gateway/server.go | 9 + internal/http/storage.go | 275 ++++++++++++++++++ ui/web/src/components/layout/sidebar.tsx | 2 + ui/web/src/components/shared/file-browser.tsx | 128 ++++++++ ui/web/src/components/shared/file-tree.tsx | 139 +++++++++ ui/web/src/components/shared/file-viewers.tsx | 180 ++++++++++++ ui/web/src/lib/constants.ts | 1 + ui/web/src/lib/file-helpers.ts | 96 ++++++ .../src/pages/skills/skill-file-browser.tsx | 126 +------- ui/web/src/pages/skills/skill-file-helpers.ts | 102 +------ ui/web/src/pages/skills/skill-file-tree.tsx | 111 +------ .../src/pages/skills/skill-file-viewers.tsx | 182 +----------- ui/web/src/pages/storage/hooks/use-storage.ts | 63 ++++ ui/web/src/pages/storage/storage-page.tsx | 115 ++++++++ ui/web/src/routes.tsx | 4 + 16 files changed, 1031 insertions(+), 505 deletions(-) create mode 100644 internal/http/storage.go create mode 100644 ui/web/src/components/shared/file-browser.tsx create mode 100644 ui/web/src/components/shared/file-tree.tsx create mode 100644 ui/web/src/components/shared/file-viewers.tsx create mode 100644 ui/web/src/lib/file-helpers.ts create mode 100644 ui/web/src/pages/storage/hooks/use-storage.ts create mode 100644 ui/web/src/pages/storage/storage-page.tsx diff --git a/cmd/gateway.go b/cmd/gateway.go index e25b74d3..d4af2a42 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -630,6 +630,9 @@ func runGateway() { // Supports media from any agent workspace (each agent has its own workspace from DB). server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token)) + // Storage file management — browse/delete files under ~/.goclaw/ (excluding skills dirs). + server.SetStorageHandler(httpapi.NewStorageHandler(config.ExpandHome("~/.goclaw"), cfg.Gateway.Token)) + // Seed + apply builtin tool disables if pgStores.BuiltinTools != nil { seedBuiltinTools(context.Background(), pgStores.BuiltinTools) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 858b37d3..d8a50b4f 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -46,6 +46,7 @@ type Server struct { builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API oauthHandler *httpapi.OAuthHandler // OAuth endpoints filesHandler *httpapi.FilesHandler // workspace file serving + storageHandler *httpapi.StorageHandler // storage file management agentStore store.AgentStore // for context injection in tools_invoke upgrader websocket.Upgrader @@ -201,6 +202,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.filesHandler.RegisterRoutes(mux) } + // Storage file management (browse/delete workspace files) + if s.storageHandler != nil { + s.storageHandler.RegisterRoutes(mux) + } + // OAuth endpoints (available in all modes) if s.oauthHandler != nil { s.oauthHandler.RegisterRoutes(mux) @@ -347,6 +353,9 @@ func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h } // SetFilesHandler sets the workspace file serving handler. func (s *Server) SetFilesHandler(h *httpapi.FilesHandler) { s.filesHandler = h } +// SetStorageHandler sets the storage file management handler. +func (s *Server) SetStorageHandler(h *httpapi.StorageHandler) { s.storageHandler = h } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } diff --git a/internal/http/storage.go b/internal/http/storage.go new file mode 100644 index 00000000..dab3109b --- /dev/null +++ b/internal/http/storage.go @@ -0,0 +1,275 @@ +package http + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" +) + +// StorageHandler provides HTTP endpoints for browsing and managing +// files inside the ~/.goclaw/ data directory. +// Skills directories are browsable (read-only) but deletion is blocked. +type StorageHandler struct { + baseDir string // resolved absolute path to ~/.goclaw/ + token string +} + +// NewStorageHandler creates a handler for workspace storage management. +func NewStorageHandler(baseDir, token string) *StorageHandler { + return &StorageHandler{baseDir: baseDir, token: token} +} + +// RegisterRoutes registers storage management routes on the given mux. +func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/storage/files", h.auth(h.handleList)) + mux.HandleFunc("GET /v1/storage/files/{path...}", h.auth(h.handleRead)) + mux.HandleFunc("DELETE /v1/storage/files/{path...}", h.auth(h.handleDelete)) +} + +func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if h.token != "" { + provided := extractBearerToken(r) + if !tokenMatch(provided, h.token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + } + next(w, r) + } +} + +// protectedDirs are top-level directories where deletion is blocked +// (managed separately via the Skills page). +var protectedDirs = []string{"skills", "skills-store"} + +func isProtectedPath(rel string) bool { + top := rel + if i := strings.IndexByte(rel, filepath.Separator); i >= 0 { + top = rel[:i] + } + // Also handle forward slash on all platforms + if i := strings.IndexByte(top, '/'); i >= 0 { + top = top[:i] + } + for _, d := range protectedDirs { + if strings.EqualFold(top, d) { + return true + } + } + return false +} + +// handleList lists all files and directories under ~/.goclaw/. +// Optional query param ?path= scopes the listing to a subtree. +func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { + subPath := r.URL.Query().Get("path") + if strings.Contains(subPath, "..") { + slog.Warn("security.storage_traversal", "path", subPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + rootDir := h.baseDir + if subPath != "" { + rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath)) + if !strings.HasPrefix(rootDir, h.baseDir) { + slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + } + + type fileEntry struct { + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + TotalSize int64 `json:"totalSize"` // recursive size for directories + Protected bool `json:"protected"` // true if deletion is blocked + } + + // Compute directory sizes via a two-pass approach: + // 1. Walk and collect entries + accumulate sizes per dir path + var entries []fileEntry + dirSizes := make(map[string]int64) + + filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(h.baseDir, path) + if rel == "." { + return nil + } + + // Skip symlinks + if d.Type()&os.ModeSymlink != 0 { + return nil + } + + // Skip system artifacts + if isSystemArtifact(rel) { + if d.IsDir() { + return filepath.SkipDir + } + 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() + // Accumulate file size into all parent directories + parent := filepath.Dir(rel) + for parent != "." && parent != "" { + dirSizes[parent] += info.Size() + parent = filepath.Dir(parent) + } + // Also accumulate to root if listing from base + if subPath == "" { + dirSizes["."] += info.Size() + } + } + } + + entry.Protected = isProtectedPath(rel) + entries = append(entries, entry) + return nil + }) + + // 2. Assign totalSize to directory entries + for i := range entries { + if entries[i].IsDir { + entries[i].TotalSize = dirSizes[entries[i].Path] + } + } + + if entries == nil { + entries = []fileEntry{} + } + + // Calculate total size of the root being listed + var totalSize int64 + if subPath == "" { + totalSize = dirSizes["."] + } else { + rel, _ := filepath.Rel(h.baseDir, rootDir) + totalSize = dirSizes[rel] + // If rootDir is the listed subtree, also sum direct files + for _, e := range entries { + if !e.IsDir && filepath.Dir(e.Path) == rel { + totalSize += e.Size + } + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "files": entries, + "totalSize": totalSize, + "baseDir": h.baseDir, + }) +} + +// handleRead reads a single file's content by relative path. +func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) { + 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.storage_traversal", "path", relPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + 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.storage_symlink", "path", absPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + 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(), + }) +} + +// handleDelete removes a file or directory (recursively). +// Rejects deletion of the root dir and any path inside excluded directories. +func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + 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.storage_traversal", "path", relPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + if isProtectedPath(relPath) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete skills directories"}) + return + } + + absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + // Verify path exists + info, err := os.Lstat(absPath) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + + if info.Mode()&os.ModeSymlink != 0 { + // Remove symlink itself, not target + err = os.Remove(absPath) + } else if info.IsDir() { + err = os.RemoveAll(absPath) + } else { + err = os.Remove(absPath) + } + + if err != nil { + slog.Error("storage.delete_failed", "path", absPath, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete"}) + return + } + + slog.Info("storage.deleted", "path", relPath) + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index f5b18ba0..b7f02dee 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -20,6 +20,7 @@ import { Volume2, Cpu, ArrowRightLeft, + HardDrive, } from "lucide-react"; import { SidebarGroup } from "./sidebar-group"; import { SidebarItem } from "./sidebar-item"; @@ -86,6 +87,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + diff --git a/ui/web/src/components/shared/file-browser.tsx b/ui/web/src/components/shared/file-browser.tsx new file mode 100644 index 00000000..a0f990c6 --- /dev/null +++ b/ui/web/src/components/shared/file-browser.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { formatSize, type TreeNode } from "@/lib/file-helpers"; +import { FileTreePanel } from "@/components/shared/file-tree"; +import { FileContentPanel } from "@/components/shared/file-viewers"; + +function useIsMobile(breakpoint = 640) { + const [mobile, setMobile] = useState(window.innerWidth < breakpoint); + useEffect(() => { + const onResize = () => setMobile(window.innerWidth < breakpoint); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [breakpoint]); + return mobile; +} + +export function FileBrowser({ + tree, + filesLoading, + activePath, + onSelect, + contentLoading, + fileContent, + onDelete, + showSize, +}: { + tree: TreeNode[]; + filesLoading: boolean; + activePath: string | null; + onSelect: (path: string) => void; + contentLoading: boolean; + fileContent: { content: string; path: string; size: number } | null; + onDelete?: (path: string, isDir: boolean) => void; + showSize?: boolean; +}) { + const isMobile = useIsMobile(); + const containerRef = useRef(null); + const [treeWidth, setTreeWidth] = useState(220); + const [mobileShowTree, setMobileShowTree] = useState(true); + const dragging = useRef(false); + + const handleSelect = useCallback((path: string) => { + onSelect(path); + if (isMobile) setMobileShowTree(false); + }, [onSelect, isMobile]); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + const startX = e.clientX; + const startW = treeWidth; + + const onMove = (ev: MouseEvent) => { + if (!dragging.current) return; + const container = containerRef.current; + if (!container) return; + const maxW = container.offsetWidth * 0.5; + const newW = Math.max(140, Math.min(maxW, startW + ev.clientX - startX)); + setTreeWidth(newW); + }; + const onUp = () => { + dragging.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [treeWidth]); + + // Mobile: stacked layout — tree first, tap file -> content view + if (isMobile) { + return ( +
+ {mobileShowTree ? ( +
+ +
+ ) : ( +
+
+ + {fileContent && ( + <> + {fileContent.path} + {formatSize(fileContent.size)} + + )} +
+
+ +
+
+ )} +
+ ); + } + + // Desktop: side-by-side with resizable divider + return ( +
+
+ +
+ +
+ +
+ {fileContent && ( +
+ {fileContent.path} + {formatSize(fileContent.size)} +
+ )} +
+ +
+
+
+ ); +} diff --git a/ui/web/src/components/shared/file-tree.tsx b/ui/web/src/components/shared/file-tree.tsx new file mode 100644 index 00000000..65e7d442 --- /dev/null +++ b/ui/web/src/components/shared/file-tree.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { + Folder, + FolderOpen, + FileText, + FileCode2, + File, + ChevronRight, + Loader2, + Trash2, +} from "lucide-react"; +import { extOf, CODE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers"; + +function FileIcon({ name }: { name: string }) { + const ext = extOf(name); + if (ext === "md") return ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ; +} + +export function TreeItem({ + node, + depth, + activePath, + onSelect, + onDelete, + showSize, +}: { + node: TreeNode; + depth: number; + activePath: string | null; + onSelect: (path: string) => void; + onDelete?: (path: string, isDir: boolean) => void; + showSize?: boolean; +}) { + const [expanded, setExpanded] = useState(depth === 0); + const isActive = activePath === node.path; + + const deleteBtn = onDelete && !node.protected && ( + + ); + + const sizeLabel = showSize && (node.isDir ? (node.totalSize ?? 0) : node.size) > 0 && ( + + {formatSize(node.isDir ? (node.totalSize ?? 0) : node.size)} + + ); + + if (node.isDir) { + return ( +
+
setExpanded(!expanded)} + > + + {expanded ? ( + + ) : ( + + )} + {node.name} + {sizeLabel} + {deleteBtn} +
+ {expanded && node.children.map((child) => ( + + ))} +
+ ); + } + + return ( +
onSelect(node.path)} + > + + {node.name} + {sizeLabel} + {deleteBtn} +
+ ); +} + +export function FileTreePanel({ + tree, + filesLoading, + activePath, + onSelect, + onDelete, + showSize, +}: { + tree: TreeNode[]; + filesLoading: boolean; + activePath: string | null; + onSelect: (path: string) => void; + onDelete?: (path: string, isDir: boolean) => void; + showSize?: boolean; +}) { + if (filesLoading) { + return ( +
+ +
+ ); + } + if (tree.length === 0) { + return

No files found.

; + } + return ( + <> + {tree.map((node) => ( + + ))} + + ); +} diff --git a/ui/web/src/components/shared/file-viewers.tsx b/ui/web/src/components/shared/file-viewers.tsx new file mode 100644 index 00000000..daa121be --- /dev/null +++ b/ui/web/src/components/shared/file-viewers.tsx @@ -0,0 +1,180 @@ +import { useMemo, useRef } from "react"; +import { Check, Copy, Loader2 } from "lucide-react"; +import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { useClipboard } from "@/hooks/use-clipboard"; +import hljs from "highlight.js/lib/core"; +import typescript from "highlight.js/lib/languages/typescript"; +import javascript from "highlight.js/lib/languages/javascript"; +import python from "highlight.js/lib/languages/python"; +import go from "highlight.js/lib/languages/go"; +import bash from "highlight.js/lib/languages/bash"; +import json from "highlight.js/lib/languages/json"; +import yaml from "highlight.js/lib/languages/yaml"; +import css from "highlight.js/lib/languages/css"; +import xml from "highlight.js/lib/languages/xml"; +import sql from "highlight.js/lib/languages/sql"; +import rust from "highlight.js/lib/languages/rust"; +import ruby from "highlight.js/lib/languages/ruby"; +import java from "highlight.js/lib/languages/java"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import lua from "highlight.js/lib/languages/lua"; +import { extOf, langFor, stripFrontmatter, CODE_EXTENSIONS } from "@/lib/file-helpers"; + +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("go", go); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("json", json); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("css", css); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("java", java); +hljs.registerLanguage("c", c); +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("lua", lua); + +function CodeViewer({ content, language }: { content: string; language: string }) { + const { copied, copy } = useClipboard(); + const codeRef = useRef(null); + + const highlighted = useMemo(() => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(content, { language }).value; + } catch { /* fallback */ } + } + return null; + }, [content, language]); + + return ( +
+
+ {language || "text"} + +
+
+        {highlighted ? (
+          
+        ) : (
+          {content}
+        )}
+      
+
+ ); +} + +function CsvViewer({ content }: { content: string }) { + const { copied, copy } = useClipboard(); + const rows = useMemo(() => { + return content.split("\n").filter(Boolean).map((line) => { + const cols: string[] = []; + let cur = ""; + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { inQuote = !inQuote; continue; } + if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; } + cur += ch; + } + cols.push(cur.trim()); + return cols; + }); + }, [content]); + + const header = rows[0]; + if (!header || rows.length === 0) return
{content}
; + const body = rows.slice(1); + + return ( +
+
+ csv ({body.length} rows) + +
+
+ + + + {header.map((col, i) => ( + + ))} + + + + {body.map((row, i) => ( + + {header.map((_, j) => ( + + ))} + + ))} + +
+ {col} +
+ {row[j] ?? ""} +
+
+
+ ); +} + +export function FileContentBody({ path, content }: { path: string; content: string }) { + const ext = extOf(path); + const displayContent = ext === "md" ? stripFrontmatter(content) : content; + + if (ext === "md") return ; + if (ext === "csv") return ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ( +
+      {displayContent}
+    
+ ); +} + +export function FileContentPanel({ + fileContent, + contentLoading, +}: { + fileContent: { content: string; path: string; size: number } | null; + contentLoading: boolean; +}) { + if (contentLoading) { + return ( +
+ +
+ ); + } + if (fileContent) { + return ; + } + return ( +
+ Select a file to view its content +
+ ); +} diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index d6396562..8073de5a 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -28,6 +28,7 @@ export const ROUTES = { BUILTIN_TOOLS: "/builtin-tools", MCP: "/mcp", TTS: "/tts", + STORAGE: "/storage", SETUP: "/setup", } as const; diff --git a/ui/web/src/lib/file-helpers.ts b/ui/web/src/lib/file-helpers.ts new file mode 100644 index 00000000..2a450466 --- /dev/null +++ b/ui/web/src/lib/file-helpers.ts @@ -0,0 +1,96 @@ +export interface TreeNode { + name: string; + path: string; + isDir: boolean; + size: number; + totalSize?: number; + protected?: boolean; + children: TreeNode[]; +} + +export 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", +]); + +export function extOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +export function langFor(ext: string): string { + const map: Record = { + 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; +} + +interface TreeInput { path: string; name: string; isDir: boolean; size: number; totalSize?: number; protected?: boolean } + +export function buildTree(files: TreeInput[]): TreeNode[] { + const root: TreeNode[] = []; + const dirMap = new Map(); + + 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, + totalSize: f.totalSize, + protected: f.protected, + children: [], + }; + + if (f.isDir) { + dirMap.set(f.path, node); + } + + 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); + } + } + + 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; +} + +export 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+/, ""); +} + +export 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`; +} diff --git a/ui/web/src/pages/skills/skill-file-browser.tsx b/ui/web/src/pages/skills/skill-file-browser.tsx index ff6cc212..0681e58e 100644 --- a/ui/web/src/pages/skills/skill-file-browser.tsx +++ b/ui/web/src/pages/skills/skill-file-browser.tsx @@ -1,124 +1,2 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { formatSize, type TreeNode } from "./skill-file-helpers"; -import { FileTreePanel } from "./skill-file-tree"; -import { FileContentPanel } from "./skill-file-viewers"; - -function useIsMobile(breakpoint = 640) { - const [mobile, setMobile] = useState(window.innerWidth < breakpoint); - useEffect(() => { - const onResize = () => setMobile(window.innerWidth < breakpoint); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, [breakpoint]); - return mobile; -} - -export function FileBrowser({ - tree, - filesLoading, - activePath, - onSelect, - contentLoading, - fileContent, -}: { - tree: TreeNode[]; - filesLoading: boolean; - activePath: string | null; - onSelect: (path: string) => void; - contentLoading: boolean; - fileContent: { content: string; path: string; size: number } | null; -}) { - const isMobile = useIsMobile(); - const containerRef = useRef(null); - const [treeWidth, setTreeWidth] = useState(220); - const [mobileShowTree, setMobileShowTree] = useState(true); - const dragging = useRef(false); - - const handleSelect = useCallback((path: string) => { - onSelect(path); - if (isMobile) setMobileShowTree(false); - }, [onSelect, isMobile]); - - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - dragging.current = true; - const startX = e.clientX; - const startW = treeWidth; - - const onMove = (ev: MouseEvent) => { - if (!dragging.current) return; - const container = containerRef.current; - if (!container) return; - const maxW = container.offsetWidth * 0.5; - const newW = Math.max(140, Math.min(maxW, startW + ev.clientX - startX)); - setTreeWidth(newW); - }; - const onUp = () => { - dragging.current = false; - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, [treeWidth]); - - // Mobile: stacked layout — tree first, tap file → content view - if (isMobile) { - return ( -
- {mobileShowTree ? ( -
- -
- ) : ( -
-
- - {fileContent && ( - <> - {fileContent.path} - {formatSize(fileContent.size)} - - )} -
-
- -
-
- )} -
- ); - } - - // Desktop: side-by-side with resizable divider - return ( -
-
- -
- -
- -
- {fileContent && ( -
- {fileContent.path} - {formatSize(fileContent.size)} -
- )} -
- -
-
-
- ); -} +// Re-export from shared location for backward compatibility +export { FileBrowser } from "@/components/shared/file-browser"; diff --git a/ui/web/src/pages/skills/skill-file-helpers.ts b/ui/web/src/pages/skills/skill-file-helpers.ts index 7232ee3c..11adb2ac 100644 --- a/ui/web/src/pages/skills/skill-file-helpers.ts +++ b/ui/web/src/pages/skills/skill-file-helpers.ts @@ -1,92 +1,10 @@ -import type { SkillFile } from "@/types/skill"; - -export interface TreeNode { - name: string; - path: string; - isDir: boolean; - size: number; - children: TreeNode[]; -} - -export 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", -]); - -export function extOf(name: string): string { - const dot = name.lastIndexOf("."); - return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; -} - -export function langFor(ext: string): string { - const map: Record = { - 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; -} - -export function buildTree(files: SkillFile[]): TreeNode[] { - const root: TreeNode[] = []; - const dirMap = new Map(); - - 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); - } - - 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); - } - } - - 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; -} - -export 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+/, ""); -} - -export 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`; -} +// Re-export from shared location for backward compatibility +export { + type TreeNode, + CODE_EXTENSIONS, + extOf, + langFor, + buildTree, + stripFrontmatter, + formatSize, +} from "@/lib/file-helpers"; diff --git a/ui/web/src/pages/skills/skill-file-tree.tsx b/ui/web/src/pages/skills/skill-file-tree.tsx index 0ec3546f..4baff0be 100644 --- a/ui/web/src/pages/skills/skill-file-tree.tsx +++ b/ui/web/src/pages/skills/skill-file-tree.tsx @@ -1,109 +1,2 @@ -import { useState } from "react"; -import { - Folder, - FolderOpen, - FileText, - FileCode2, - File, - ChevronRight, - Loader2, -} from "lucide-react"; -import { extOf, CODE_EXTENSIONS, type TreeNode } from "./skill-file-helpers"; - -function FileIcon({ name }: { name: string }) { - const ext = extOf(name); - if (ext === "md") return ; - if (CODE_EXTENSIONS.has(ext)) return ; - return ; -} - -export 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 ( -
- - {expanded && node.children.map((child) => ( - - ))} -
- ); - } - - return ( - - ); -} - -export function FileTreePanel({ - tree, - filesLoading, - activePath, - onSelect, -}: { - tree: TreeNode[]; - filesLoading: boolean; - activePath: string | null; - onSelect: (path: string) => void; -}) { - if (filesLoading) { - return ( -
- -
- ); - } - if (tree.length === 0) { - return

No files found.

; - } - return ( - <> - {tree.map((node) => ( - - ))} - - ); -} +// Re-export from shared location for backward compatibility +export { TreeItem, FileTreePanel } from "@/components/shared/file-tree"; diff --git a/ui/web/src/pages/skills/skill-file-viewers.tsx b/ui/web/src/pages/skills/skill-file-viewers.tsx index 00988ccf..0e640779 100644 --- a/ui/web/src/pages/skills/skill-file-viewers.tsx +++ b/ui/web/src/pages/skills/skill-file-viewers.tsx @@ -1,180 +1,2 @@ -import { useMemo, useRef } from "react"; -import { Check, Copy, Loader2 } from "lucide-react"; -import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; -import { useClipboard } from "@/hooks/use-clipboard"; -import hljs from "highlight.js/lib/core"; -import typescript from "highlight.js/lib/languages/typescript"; -import javascript from "highlight.js/lib/languages/javascript"; -import python from "highlight.js/lib/languages/python"; -import go from "highlight.js/lib/languages/go"; -import bash from "highlight.js/lib/languages/bash"; -import json from "highlight.js/lib/languages/json"; -import yaml from "highlight.js/lib/languages/yaml"; -import css from "highlight.js/lib/languages/css"; -import xml from "highlight.js/lib/languages/xml"; -import sql from "highlight.js/lib/languages/sql"; -import rust from "highlight.js/lib/languages/rust"; -import ruby from "highlight.js/lib/languages/ruby"; -import java from "highlight.js/lib/languages/java"; -import c from "highlight.js/lib/languages/c"; -import cpp from "highlight.js/lib/languages/cpp"; -import lua from "highlight.js/lib/languages/lua"; -import { extOf, langFor, stripFrontmatter, CODE_EXTENSIONS } from "./skill-file-helpers"; - -hljs.registerLanguage("typescript", typescript); -hljs.registerLanguage("tsx", typescript); -hljs.registerLanguage("javascript", javascript); -hljs.registerLanguage("jsx", javascript); -hljs.registerLanguage("python", python); -hljs.registerLanguage("go", go); -hljs.registerLanguage("bash", bash); -hljs.registerLanguage("json", json); -hljs.registerLanguage("yaml", yaml); -hljs.registerLanguage("css", css); -hljs.registerLanguage("html", xml); -hljs.registerLanguage("xml", xml); -hljs.registerLanguage("sql", sql); -hljs.registerLanguage("rust", rust); -hljs.registerLanguage("ruby", ruby); -hljs.registerLanguage("java", java); -hljs.registerLanguage("c", c); -hljs.registerLanguage("cpp", cpp); -hljs.registerLanguage("lua", lua); - -function CodeViewer({ content, language }: { content: string; language: string }) { - const { copied, copy } = useClipboard(); - const codeRef = useRef(null); - - const highlighted = useMemo(() => { - if (language && hljs.getLanguage(language)) { - try { - return hljs.highlight(content, { language }).value; - } catch { /* fallback */ } - } - return null; - }, [content, language]); - - return ( -
-
- {language || "text"} - -
-
-        {highlighted ? (
-          
-        ) : (
-          {content}
-        )}
-      
-
- ); -} - -function CsvViewer({ content }: { content: string }) { - const { copied, copy } = useClipboard(); - const rows = useMemo(() => { - return content.split("\n").filter(Boolean).map((line) => { - const cols: string[] = []; - let cur = ""; - let inQuote = false; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (ch === '"') { inQuote = !inQuote; continue; } - if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; } - cur += ch; - } - cols.push(cur.trim()); - return cols; - }); - }, [content]); - - const header = rows[0]; - if (!header || rows.length === 0) return
{content}
; - const body = rows.slice(1); - - return ( -
-
- csv ({body.length} rows) - -
-
- - - - {header.map((col, i) => ( - - ))} - - - - {body.map((row, i) => ( - - {header.map((_, j) => ( - - ))} - - ))} - -
- {col} -
- {row[j] ?? ""} -
-
-
- ); -} - -export function FileContentBody({ path, content }: { path: string; content: string }) { - const ext = extOf(path); - const displayContent = ext === "md" ? stripFrontmatter(content) : content; - - if (ext === "md") return ; - if (ext === "csv") return ; - if (CODE_EXTENSIONS.has(ext)) return ; - return ( -
-      {displayContent}
-    
- ); -} - -export function FileContentPanel({ - fileContent, - contentLoading, -}: { - fileContent: { content: string; path: string; size: number } | null; - contentLoading: boolean; -}) { - if (contentLoading) { - return ( -
- -
- ); - } - if (fileContent) { - return ; - } - return ( -
- Select a file to view its content -
- ); -} +// Re-export from shared location for backward compatibility +export { FileContentBody, FileContentPanel } from "@/components/shared/file-viewers"; diff --git a/ui/web/src/pages/storage/hooks/use-storage.ts b/ui/web/src/pages/storage/hooks/use-storage.ts new file mode 100644 index 00000000..c1a5324b --- /dev/null +++ b/ui/web/src/pages/storage/hooks/use-storage.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; + +export interface StorageFile { + path: string; + name: string; + isDir: boolean; + size: number; + totalSize: number; + protected: boolean; +} + +interface StorageListResponse { + files: StorageFile[]; + totalSize: number; + baseDir: string; +} + +interface StorageFileContent { + content: string; + path: string; + size: number; +} + +export function useStorage() { + const http = useHttp(); + const [files, setFiles] = useState([]); + const [totalSize, setTotalSize] = useState(0); + const [baseDir, setBaseDir] = useState(""); + const [loading, setLoading] = useState(false); + + const listFiles = useCallback(async () => { + setLoading(true); + try { + const res = await http.get("/v1/storage/files"); + setFiles(res.files ?? []); + setTotalSize(res.totalSize ?? 0); + setBaseDir(res.baseDir ?? ""); + } finally { + setLoading(false); + } + }, [http]); + + const readFile = useCallback( + async (path: string) => { + return http.get( + `/v1/storage/files/${encodeURIComponent(path)}`, + ); + }, + [http], + ); + + const deleteFile = useCallback( + async (path: string) => { + await http.delete<{ status: string }>( + `/v1/storage/files/${encodeURIComponent(path)}`, + ); + }, + [http], + ); + + return { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile }; +} diff --git a/ui/web/src/pages/storage/storage-page.tsx b/ui/web/src/pages/storage/storage-page.tsx new file mode 100644 index 00000000..f78bf11e --- /dev/null +++ b/ui/web/src/pages/storage/storage-page.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { RefreshCw } from "lucide-react"; +import { PageHeader } from "@/components/shared/page-header"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { buildTree, formatSize } from "@/lib/file-helpers"; +import { FileBrowser } from "@/components/shared/file-browser"; +import { useStorage } from "./hooks/use-storage"; + +export function StoragePage() { + const { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile } = useStorage(); + + const [activePath, setActivePath] = useState(null); + const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null); + const [contentLoading, setContentLoading] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ path: string; isDir: boolean } | null>(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { listFiles(); }, [listFiles]); + + const tree = useMemo(() => buildTree(files), [files]); + + const handleSelect = useCallback(async (path: string) => { + setActivePath(path); + setContentLoading(true); + try { + const res = await readFile(path); + setFileContent(res); + } catch { + setFileContent(null); + } finally { + setContentLoading(false); + } + }, [readFile]); + + const handleDeleteRequest = useCallback((path: string, isDir: boolean) => { + setDeleteTarget({ path, isDir }); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await deleteFile(deleteTarget.path); + // Clear content if the deleted file was being viewed + if (activePath === deleteTarget.path || (deleteTarget.isDir && activePath?.startsWith(deleteTarget.path + "/"))) { + setActivePath(null); + setFileContent(null); + } + await listFiles(); + } finally { + setDeleting(false); + setDeleteTarget(null); + } + }, [deleteTarget, deleteFile, listFiles, activePath]); + + const deleteName = deleteTarget?.path.split("/").pop() ?? ""; + + return ( +
+ + + Refresh + + } + /> + +
+ +
+ + {/* Delete confirmation dialog */} + { if (!open) setDeleteTarget(null); }}> + + + Delete {deleteTarget?.isDir ? "folder" : "file"} + + Are you sure you want to delete {deleteName}? + {deleteTarget?.isDir && " All contents will be removed recursively."} + {" "}This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index e39a7e2a..9a4815c0 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -72,6 +72,9 @@ const EventsPage = lazy(() => const DelegationsPage = lazy(() => import("@/pages/delegations/delegations-page").then((m) => ({ default: m.DelegationsPage })), ); +const StoragePage = lazy(() => + import("@/pages/storage/storage-page").then((m) => ({ default: m.StoragePage })), +); const SetupPage = lazy(() => import("@/pages/setup/setup-page").then((m) => ({ default: m.SetupPage })), ); @@ -139,6 +142,7 @@ export function AppRoutes() { } /> } /> } /> + } /> {/* Catch-all → overview */}