Spaces:
Sleeping
Sleeping
| import { useState, useCallback, useEffect } from "react"; | |
| import { cn } from "@/lib/utils"; | |
| import { | |
| X, | |
| Folder, | |
| File, | |
| RefreshCw, | |
| Plus, | |
| Trash2, | |
| Edit3, | |
| FolderPlus, | |
| Copy, | |
| Check, | |
| Loader2, | |
| Home, | |
| ChevronUp, | |
| Save, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { toast } from "sonner"; | |
| interface FileNode { | |
| name: string; | |
| path: string; | |
| isDirectory: boolean; | |
| size?: number; | |
| } | |
| interface FileManagerPanelProps { | |
| open: boolean; | |
| onClose: () => void; | |
| } | |
| // Language detection for syntax highlighting class | |
| function getLanguage(name: string): string { | |
| const ext = name.split(".").pop()?.toLowerCase() || ""; | |
| const langMap: Record<string, string> = { | |
| ts: "typescript", | |
| tsx: "tsx", | |
| js: "javascript", | |
| jsx: "jsx", | |
| json: "json", | |
| md: "markdown", | |
| css: "css", | |
| html: "html", | |
| py: "python", | |
| sh: "bash", | |
| yml: "yaml", | |
| yaml: "yaml", | |
| sql: "sql", | |
| env: "bash", | |
| toml: "toml", | |
| xml: "xml", | |
| svg: "xml", | |
| rs: "rust", | |
| go: "go", | |
| rb: "ruby", | |
| java: "java", | |
| c: "c", | |
| cpp: "cpp", | |
| h: "c", | |
| hpp: "cpp", | |
| }; | |
| return langMap[ext] || "text"; | |
| } | |
| // File icon color based on extension | |
| function getFileColor(name: string) { | |
| const ext = name.split(".").pop()?.toLowerCase() || ""; | |
| const colors: Record<string, string> = { | |
| ts: "text-blue-400", | |
| tsx: "text-blue-400", | |
| js: "text-yellow-400", | |
| jsx: "text-yellow-400", | |
| json: "text-green-400", | |
| md: "text-gray-400", | |
| css: "text-pink-400", | |
| html: "text-orange-400", | |
| py: "text-green-500", | |
| sh: "text-green-300", | |
| yml: "text-red-400", | |
| yaml: "text-red-400", | |
| sql: "text-cyan-400", | |
| env: "text-yellow-300", | |
| lock: "text-gray-500", | |
| }; | |
| return colors[ext] || "text-muted-foreground"; | |
| } | |
| export function FileManagerPanel({ open, onClose }: FileManagerPanelProps) { | |
| const [currentPath, setCurrentPath] = useState("/home/ubuntu"); | |
| const [files, setFiles] = useState<FileNode[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [selectedFile, setSelectedFile] = useState<string | null>(null); | |
| const [fileContent, setFileContent] = useState<string | null>(null); | |
| const [contentLoading, setContentLoading] = useState(false); | |
| const [creating, setCreating] = useState<"file" | "folder" | null>(null); | |
| const [newName, setNewName] = useState(""); | |
| const [copied, setCopied] = useState(false); | |
| const [renaming, setRenaming] = useState<string | null>(null); | |
| const [renameValue, setRenameValue] = useState(""); | |
| const [editing, setEditing] = useState(false); | |
| const [editContent, setEditContent] = useState(""); | |
| // Fetch directory listing | |
| const fetchFiles = useCallback(async (dirPath: string) => { | |
| setLoading(true); | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/files", | |
| args: `list ${dirPath}`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (data.files) { | |
| setFiles(data.files); | |
| } else if (data.result) { | |
| const lines = data.result.split("\n").filter((l: string) => l.trim()); | |
| const parsed: FileNode[] = lines | |
| .map((line: string) => { | |
| const isDir = line.endsWith("/"); | |
| const name = isDir ? line.slice(0, -1) : line; | |
| return { | |
| name: name.split("/").pop() || name, | |
| path: `${dirPath}/${name.split("/").pop() || name}`, | |
| isDirectory: isDir, | |
| }; | |
| }) | |
| .filter((f: FileNode) => f.name); | |
| setFiles(parsed); | |
| } | |
| } catch { | |
| toast.error("Failed to load files"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| // Fetch file content | |
| const fetchFileContent = useCallback(async (filePath: string) => { | |
| setContentLoading(true); | |
| setSelectedFile(filePath); | |
| setEditing(false); | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/files", | |
| args: `read ${filePath}`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| const content = data.result || data.content || "(empty)"; | |
| setFileContent(content); | |
| setEditContent(content); | |
| } catch { | |
| setFileContent("Error reading file"); | |
| } finally { | |
| setContentLoading(false); | |
| } | |
| }, []); | |
| // Create file or folder | |
| const handleCreate = useCallback(async () => { | |
| if (!newName.trim()) return; | |
| const fullPath = `${currentPath}/${newName.trim()}`; | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/files", | |
| args: `${creating === "folder" ? "mkdir" : "touch"} ${fullPath}`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| toast.success(data.result || `Created ${newName}`); | |
| setCreating(null); | |
| setNewName(""); | |
| fetchFiles(currentPath); | |
| } catch { | |
| toast.error("Failed to create"); | |
| } | |
| }, [newName, currentPath, creating, fetchFiles]); | |
| // Delete file | |
| const handleDelete = useCallback( | |
| async (filePath: string) => { | |
| if (!confirm(`Delete ${filePath}?`)) return; | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/files", | |
| args: `delete ${filePath}`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| toast.success(data.result || "Deleted"); | |
| fetchFiles(currentPath); | |
| if (selectedFile === filePath) { | |
| setSelectedFile(null); | |
| setFileContent(null); | |
| } | |
| } catch { | |
| toast.error("Failed to delete"); | |
| } | |
| }, | |
| [currentPath, selectedFile, fetchFiles] | |
| ); | |
| // Rename file | |
| const handleRename = useCallback( | |
| async (oldPath: string) => { | |
| if (!renameValue.trim()) { | |
| setRenaming(null); | |
| return; | |
| } | |
| const dir = oldPath.substring(0, oldPath.lastIndexOf("/")); | |
| const newPath = `${dir}/${renameValue.trim()}`; | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/terminal", | |
| args: `${dir} mv "${oldPath}" "${newPath}"`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (data.error) { | |
| toast.error(data.error); | |
| } else { | |
| toast.success(`Renamed to ${renameValue}`); | |
| fetchFiles(currentPath); | |
| if (selectedFile === oldPath) { | |
| setSelectedFile(newPath); | |
| } | |
| } | |
| } catch { | |
| toast.error("Failed to rename"); | |
| } | |
| setRenaming(null); | |
| }, | |
| [renameValue, currentPath, selectedFile, fetchFiles] | |
| ); | |
| // Save edited file | |
| const handleSaveFile = useCallback(async () => { | |
| if (!selectedFile) return; | |
| try { | |
| const response = await fetch("/api/chat/slash", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| command: "/terminal", | |
| args: `/home/ubuntu cat > "${selectedFile}" << 'CLAW_EOF'\n${editContent}\nCLAW_EOF`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (data.error) { | |
| toast.error(data.error); | |
| } else { | |
| toast.success("File saved!"); | |
| setFileContent(editContent); | |
| setEditing(false); | |
| } | |
| } catch { | |
| toast.error("Failed to save file"); | |
| } | |
| }, [selectedFile, editContent]); | |
| useEffect(() => { | |
| if (open) { | |
| fetchFiles(currentPath); | |
| } | |
| }, [open, currentPath, fetchFiles]); | |
| const copyContent = () => { | |
| if (fileContent) { | |
| navigator.clipboard.writeText(fileContent); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| } | |
| }; | |
| const navigateUp = () => { | |
| const parent = currentPath.split("/").slice(0, -1).join("/") || "/"; | |
| setCurrentPath(parent); | |
| }; | |
| // Breadcrumbs | |
| const pathParts = currentPath.split("/").filter(Boolean); | |
| if (!open) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex"> | |
| <div className="absolute inset-0 bg-black/50" onClick={onClose} /> | |
| <div className="relative ml-auto w-full max-w-3xl bg-background border-l border-border flex flex-col h-full shadow-2xl"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-4 py-3 border-b border-border"> | |
| <div className="flex items-center gap-2"> | |
| <Folder className="size-4 text-primary" /> | |
| <h2 className="text-sm font-semibold">File Manager</h2> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="size-7 rounded-md hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors" | |
| > | |
| <X className="size-4" /> | |
| </button> | |
| </div> | |
| {/* Breadcrumb path bar */} | |
| <div className="flex items-center gap-1 px-4 py-2 border-b border-border bg-secondary/20 overflow-x-auto"> | |
| <button | |
| onClick={() => setCurrentPath("/")} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground shrink-0" | |
| > | |
| <Home className="size-3.5" /> | |
| </button> | |
| {pathParts.map((part, i) => { | |
| const path = "/" + pathParts.slice(0, i + 1).join("/"); | |
| return ( | |
| <div key={path} className="flex items-center gap-1"> | |
| <span className="text-muted-foreground/40 text-xs">/</span> | |
| <button | |
| onClick={() => setCurrentPath(path)} | |
| className={cn( | |
| "text-xs font-mono px-1 py-0.5 rounded hover:bg-accent transition-colors", | |
| i === pathParts.length - 1 | |
| ? "text-foreground font-medium" | |
| : "text-muted-foreground" | |
| )} | |
| > | |
| {part} | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| <div className="flex-1" /> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| <button | |
| onClick={navigateUp} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="Go up" | |
| > | |
| <ChevronUp className="size-3.5" /> | |
| </button> | |
| <button | |
| onClick={() => setCreating("file")} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="New file" | |
| > | |
| <Plus className="size-3.5" /> | |
| </button> | |
| <button | |
| onClick={() => setCreating("folder")} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="New folder" | |
| > | |
| <FolderPlus className="size-3.5" /> | |
| </button> | |
| <button | |
| onClick={() => fetchFiles(currentPath)} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="Refresh" | |
| > | |
| <RefreshCw className="size-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Create form */} | |
| {creating && ( | |
| <div className="px-4 py-2 border-b border-border bg-accent/20 flex items-center gap-2"> | |
| <span className="text-xs text-muted-foreground"> | |
| New {creating}: | |
| </span> | |
| <Input | |
| value={newName} | |
| onChange={(e) => setNewName(e.target.value)} | |
| placeholder={creating === "folder" ? "folder-name" : "file.txt"} | |
| className="h-7 text-xs font-mono flex-1" | |
| autoFocus | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") handleCreate(); | |
| if (e.key === "Escape") { | |
| setCreating(null); | |
| setNewName(""); | |
| } | |
| }} | |
| /> | |
| <Button size="sm" className="h-7 text-xs" onClick={handleCreate}> | |
| Create | |
| </Button> | |
| <button | |
| onClick={() => { | |
| setCreating(null); | |
| setNewName(""); | |
| }} | |
| className="text-muted-foreground hover:text-foreground" | |
| > | |
| <X className="size-3.5" /> | |
| </button> | |
| </div> | |
| )} | |
| {/* Content area */} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* File list */} | |
| <div className="w-1/3 border-r border-border overflow-y-auto"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-8"> | |
| <Loader2 className="size-4 animate-spin text-muted-foreground" /> | |
| </div> | |
| ) : files.length === 0 ? ( | |
| <div className="text-xs text-muted-foreground text-center py-8"> | |
| Empty directory | |
| </div> | |
| ) : ( | |
| <div className="py-1"> | |
| {files | |
| .sort((a, b) => { | |
| if (a.isDirectory && !b.isDirectory) return -1; | |
| if (!a.isDirectory && b.isDirectory) return 1; | |
| return a.name.localeCompare(b.name); | |
| }) | |
| .map((file) => ( | |
| <div | |
| key={file.path} | |
| className={cn( | |
| "group flex items-center gap-2 px-3 py-1.5 cursor-pointer transition-colors", | |
| selectedFile === file.path | |
| ? "bg-accent text-accent-foreground" | |
| : "hover:bg-accent/50" | |
| )} | |
| onClick={() => { | |
| if (file.isDirectory) { | |
| setCurrentPath(file.path); | |
| setSelectedFile(null); | |
| setFileContent(null); | |
| } else { | |
| fetchFileContent(file.path); | |
| } | |
| }} | |
| > | |
| {file.isDirectory ? ( | |
| <Folder className="size-3.5 text-yellow-400 shrink-0" /> | |
| ) : ( | |
| <File | |
| className={cn( | |
| "size-3.5 shrink-0", | |
| getFileColor(file.name) | |
| )} | |
| /> | |
| )} | |
| {renaming === file.path ? ( | |
| <input | |
| value={renameValue} | |
| onChange={(e) => setRenameValue(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") handleRename(file.path); | |
| if (e.key === "Escape") setRenaming(null); | |
| }} | |
| onBlur={() => handleRename(file.path)} | |
| className="flex-1 bg-background text-xs rounded px-1.5 py-0.5 outline-none border border-border font-mono" | |
| autoFocus | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| ) : ( | |
| <span className="text-xs truncate flex-1 font-mono"> | |
| {file.name} | |
| </span> | |
| )} | |
| <div className="hidden group-hover:flex items-center gap-0.5 shrink-0"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setRenaming(file.path); | |
| setRenameValue(file.name); | |
| }} | |
| className="size-5 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="Rename" | |
| > | |
| <Edit3 className="size-3" /> | |
| </button> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDelete(file.path); | |
| }} | |
| className="size-5 rounded hover:bg-destructive/20 flex items-center justify-center text-destructive" | |
| title="Delete" | |
| > | |
| <Trash2 className="size-3" /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* File content viewer */} | |
| <div className="flex-1 flex flex-col overflow-hidden"> | |
| {selectedFile ? ( | |
| <> | |
| <div className="flex items-center justify-between px-3 py-2 border-b border-border bg-secondary/20"> | |
| <div className="flex items-center gap-2 min-w-0"> | |
| <span className="text-xs font-mono text-muted-foreground truncate"> | |
| {selectedFile.split("/").pop()} | |
| </span> | |
| <span className="text-[10px] text-muted-foreground/50 bg-secondary/50 px-1.5 py-0.5 rounded"> | |
| {getLanguage(selectedFile)} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| {editing ? ( | |
| <button | |
| onClick={handleSaveFile} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-green-400 hover:text-green-300" | |
| title="Save" | |
| > | |
| <Save className="size-3.5" /> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={() => { | |
| setEditing(true); | |
| setEditContent(fileContent || ""); | |
| }} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| title="Edit" | |
| > | |
| <Edit3 className="size-3.5" /> | |
| </button> | |
| )} | |
| <button | |
| onClick={copyContent} | |
| className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors" | |
| title="Copy" | |
| > | |
| {copied ? ( | |
| <Check className="size-3.5 text-green-400" /> | |
| ) : ( | |
| <Copy className="size-3.5" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex-1 overflow-auto"> | |
| {contentLoading ? ( | |
| <div className="flex items-center justify-center py-8"> | |
| <Loader2 className="size-4 animate-spin text-muted-foreground" /> | |
| </div> | |
| ) : editing ? ( | |
| <textarea | |
| value={editContent} | |
| onChange={(e) => setEditContent(e.target.value)} | |
| className="w-full h-full bg-background text-xs font-mono text-foreground/90 p-3 outline-none resize-none leading-relaxed" | |
| spellCheck={false} | |
| /> | |
| ) : ( | |
| <div className="relative"> | |
| {/* Line numbers + content */} | |
| <div className="flex text-xs font-mono leading-relaxed"> | |
| <div className="select-none text-right pr-3 pl-2 py-3 text-muted-foreground/30 border-r border-border/50 bg-secondary/10 shrink-0"> | |
| {(fileContent || "") | |
| .split("\n") | |
| .map((_, i) => ( | |
| <div key={i}>{i + 1}</div> | |
| ))} | |
| </div> | |
| <pre className="flex-1 p-3 text-foreground/80 whitespace-pre-wrap break-all overflow-x-auto"> | |
| {fileContent} | |
| </pre> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2"> | |
| <File className="size-8 opacity-20" /> | |
| <span className="text-xs">Select a file to view</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |