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.
This commit is contained in:
viettranx committed 2026-03-08 13:18:18 +07:00
1 parent 85e7198f6b
commit d9cdbb8e6e
16 files changed
+1031 -505

No files matched your search

+3
View File
@@ -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)
+9
View File
@@ -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 }
+275
View File
@@ -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"})
}
+2
View File
@@ -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) {
<SidebarItem to={ROUTES.DELEGATIONS} icon={ArrowRightLeft} label="Delegations" collapsed={collapsed} />
<SidebarItem to={ROUTES.USAGE} icon={BarChart3} label="Usage" collapsed={collapsed} />
<SidebarItem to={ROUTES.LOGS} icon={Terminal} label="Logs" collapsed={collapsed} />
<SidebarItem to={ROUTES.STORAGE} icon={HardDrive} label="Storage" collapsed={collapsed} />
</SidebarGroup>
<SidebarGroup label="System" collapsed={collapsed}>
@@ -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<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col border rounded-md overflow-hidden min-h-0">
{mobileShowTree ? (
<div className="flex-1 overflow-y-auto bg-muted/20 py-1">
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
</div>
) : (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center gap-2 text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<button
type="button"
onClick={() => setMobileShowTree(true)}
className="text-primary hover:underline cursor-pointer shrink-0"
>
&larr; Files
</button>
{fileContent && (
<>
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-auto">{formatSize(fileContent.size)}</span>
</>
)}
</div>
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
)}
</div>
);
}
// Desktop: side-by-side with resizable divider
return (
<div ref={containerRef} className="flex-1 flex border rounded-md overflow-hidden min-h-0">
<div className="overflow-y-auto bg-muted/20 py-1 shrink-0" style={{ width: treeWidth }}>
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
</div>
<div
className="w-1 cursor-col-resize bg-border hover:bg-primary/30 active:bg-primary/50 shrink-0"
onMouseDown={onMouseDown}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{fileContent && (
<div className="flex items-center justify-between text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-2">{formatSize(fileContent.size)}</span>
</div>
)}
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
</div>
);
}
+139
View File
@@ -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 <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" />;
}
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 && (
<button
type="button"
className="ml-auto shrink-0 opacity-0 group-hover/tree-item:opacity-100 text-destructive hover:text-destructive/80 transition-opacity cursor-pointer p-0.5"
title={`Delete ${node.isDir ? "folder" : "file"}`}
onClick={(e) => { e.stopPropagation(); onDelete(node.path, node.isDir); }}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
);
const sizeLabel = showSize && (node.isDir ? (node.totalSize ?? 0) : node.size) > 0 && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground tabular-nums">
{formatSize(node.isDir ? (node.totalSize ?? 0) : node.size)}
</span>
);
if (node.isDir) {
return (
<div>
<div
className="group/tree-item 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>
{sizeLabel}
{deleteBtn}
</div>
{expanded && node.children.map((child) => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
activePath={activePath}
onSelect={onSelect}
onDelete={onDelete}
showSize={showSize}
/>
))}
</div>
);
}
return (
<div
className={`group/tree-item 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>
{sizeLabel}
{deleteBtn}
</div>
);
}
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 (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>;
}
return (
<>
{tree.map((node) => (
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} showSize={showSize} />
))}
</>
);
}
@@ -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<HTMLElement>(null);
const highlighted = useMemo(() => {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch { /* fallback */ }
}
return null;
}, [content, language]);
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 p-4 text-sm hljs">
{highlighted ? (
<code ref={codeRef} dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<code>{content}</code>
)}
</pre>
</div>
);
}
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 <pre className="text-sm p-4">{content}</pre>;
const body = rows.slice(1);
return (
<div className="group relative rounded-md border flex flex-col overflow-hidden">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground shrink-0">
<span>csv ({body.length} rows)</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>
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-muted">
{header.map((col, i) => (
<th key={i} className="px-3 py-2 text-left font-medium border-b whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
{header.map((_, j) => (
<td key={j} className="px-3 py-1.5 border-r last:border-r-0">
{row[j] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function FileContentBody({ path, content }: { path: string; content: string }) {
const ext = extOf(path);
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
if (ext === "csv") return <CsvViewer content={displayContent} />;
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
return (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
);
}
export function FileContentPanel({
fileContent,
contentLoading,
}: {
fileContent: { content: string; path: string; size: number } | null;
contentLoading: boolean;
}) {
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (fileContent) {
return <FileContentBody path={fileContent.path} content={fileContent.content} />;
}
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
</div>
);
}
+1
View File
@@ -28,6 +28,7 @@ export const ROUTES = {
BUILTIN_TOOLS: "/builtin-tools",
MCP: "/mcp",
TTS: "/tts",
STORAGE: "/storage",
SETUP: "/setup",
} as const;
+96
View File
@@ -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<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;
}
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<string, TreeNode>();
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`;
}
+2 -124
View File
@@ -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<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col border rounded-md overflow-hidden min-h-0">
{mobileShowTree ? (
<div className="flex-1 overflow-y-auto bg-muted/20 py-1">
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} />
</div>
) : (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center gap-2 text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<button
type="button"
onClick={() => setMobileShowTree(true)}
className="text-primary hover:underline cursor-pointer shrink-0"
>
&larr; Files
</button>
{fileContent && (
<>
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-auto">{formatSize(fileContent.size)}</span>
</>
)}
</div>
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
)}
</div>
);
}
// Desktop: side-by-side with resizable divider
return (
<div ref={containerRef} className="flex-1 flex border rounded-md overflow-hidden min-h-0">
<div className="overflow-y-auto bg-muted/20 py-1 shrink-0" style={{ width: treeWidth }}>
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} />
</div>
<div
className="w-1 cursor-col-resize bg-border hover:bg-primary/30 active:bg-primary/50 shrink-0"
onMouseDown={onMouseDown}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{fileContent && (
<div className="flex items-center justify-between text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-2">{formatSize(fileContent.size)}</span>
</div>
)}
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
</div>
);
}
// Re-export from shared location for backward compatibility
export { FileBrowser } from "@/components/shared/file-browser";
+10 -92
View File
@@ -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<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;
}
export function buildTree(files: SkillFile[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
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";
+2 -109
View File
@@ -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 <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" />;
}
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 (
<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>
);
}
export function FileTreePanel({
tree,
filesLoading,
activePath,
onSelect,
}: {
tree: TreeNode[];
filesLoading: boolean;
activePath: string | null;
onSelect: (path: string) => void;
}) {
if (filesLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>;
}
return (
<>
{tree.map((node) => (
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} />
))}
</>
);
}
// Re-export from shared location for backward compatibility
export { TreeItem, FileTreePanel } from "@/components/shared/file-tree";
+2 -180
View File
@@ -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<HTMLElement>(null);
const highlighted = useMemo(() => {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch { /* fallback */ }
}
return null;
}, [content, language]);
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 p-4 text-sm hljs">
{highlighted ? (
<code ref={codeRef} dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<code>{content}</code>
)}
</pre>
</div>
);
}
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 <pre className="text-sm p-4">{content}</pre>;
const body = rows.slice(1);
return (
<div className="group relative rounded-md border flex flex-col overflow-hidden">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground shrink-0">
<span>csv ({body.length} rows)</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>
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-muted">
{header.map((col, i) => (
<th key={i} className="px-3 py-2 text-left font-medium border-b whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
{header.map((_, j) => (
<td key={j} className="px-3 py-1.5 border-r last:border-r-0">
{row[j] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function FileContentBody({ path, content }: { path: string; content: string }) {
const ext = extOf(path);
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
if (ext === "csv") return <CsvViewer content={displayContent} />;
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
return (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
);
}
export function FileContentPanel({
fileContent,
contentLoading,
}: {
fileContent: { content: string; path: string; size: number } | null;
contentLoading: boolean;
}) {
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (fileContent) {
return <FileContentBody path={fileContent.path} content={fileContent.content} />;
}
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
</div>
);
}
// Re-export from shared location for backward compatibility
export { FileContentBody, FileContentPanel } from "@/components/shared/file-viewers";
@@ -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<StorageFile[]>([]);
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<StorageListResponse>("/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<StorageFileContent>(
`/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 };
}
+115
View File
@@ -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<string | null>(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 (
<div className="flex flex-col h-full p-4 sm:p-6">
<PageHeader
title="Storage"
description={baseDir ? `${baseDir} — ${formatSize(totalSize)}` : "Workspace file browser"}
actions={
<Button variant="outline" size="sm" onClick={listFiles} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
}
/>
<div className="mt-4 flex-1 flex flex-col min-h-0">
<FileBrowser
tree={tree}
filesLoading={loading}
activePath={activePath}
onSelect={handleSelect}
contentLoading={contentLoading}
fileContent={fileContent}
onDelete={handleDeleteRequest}
showSize
/>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {deleteTarget?.isDir ? "folder" : "file"}</DialogTitle>
<DialogDescription>
Are you sure you want to delete <span className="font-semibold text-foreground">{deleteName}</span>?
{deleteTarget?.isDir && " All contents will be removed recursively."}
{" "}This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+4
View File
@@ -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() {
<Route path={ROUTES.BUILTIN_TOOLS} element={<BuiltinToolsPage />} />
<Route path={ROUTES.MCP} element={<MCPPage />} />
<Route path={ROUTES.TTS} element={<TtsPage />} />
<Route path={ROUTES.STORAGE} element={<StoragePage />} />
</Route>
{/* Catch-all → overview */}