claw-web-v2 / client /src /components /FileManagerPanel.tsx
Claw Web
Claw Web v1.0 — AI Agent Web Interface with MiMo-V2-Flash
7540aea
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>
);
}