refactor(canvas): split 650-line FilesTab.tsx into focused components

Pure restructure — no behavior change. Extracts FileTree, FileEditor,
FilesToolbar, useFilesApi hook, and tree utilities into sibling files
under canvas/src/components/tabs/FilesTab/. Top-level FilesTab.tsx is
now 240 lines (glue + confirmations); re-exports buildTree/TreeNode so
the existing import path and tests remain stable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-13 18:00:20 -07:00
parent e920aaab8e
commit c71cd39ee7
6 changed files with 629 additions and 477 deletions

View File

@ -1,43 +1,23 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { useState, useEffect, useRef, useMemo } from "react";
import { showToast } from "../Toaster";
import { FilesToolbar } from "./FilesTab/FilesToolbar";
import { FileTree } from "./FilesTab/FileTree";
import { FileEditor } from "./FilesTab/FileEditor";
import { useFilesApi } from "./FilesTab/useFilesApi";
import { buildTree } from "./FilesTab/tree";
// Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`)
export { buildTree } from "./FilesTab/tree";
export type { TreeNode } from "./FilesTab/tree";
interface Props {
workspaceId: string;
}
interface FileEntry {
path: string;
size: number;
dir: boolean;
}
const FILE_ICONS: Record<string, string> = {
".md": "📄",
".yaml": "⚙",
".yml": "⚙",
".py": "🐍",
".ts": "💠",
".tsx": "💠",
".js": "📜",
".json": "{}",
".html": "🌐",
".css": "🎨",
".sh": "▸",
};
function getIcon(path: string, isDir: boolean): string {
if (isDir) return "📁";
const ext = "." + path.split(".").pop();
return FILE_ICONS[ext] || "📄";
}
export function FilesTab({ workspaceId }: Props) {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [root, setRoot] = useState("/configs");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState("");
const [editContent, setEditContent] = useState("");
@ -48,79 +28,36 @@ export function FilesTab({ workspaceId }: Props) {
const [showNewFile, setShowNewFile] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [root, setRoot] = useState("/configs");
const [showDeleteAll, setShowDeleteAll] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const editorRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
return () => clearTimeout(successTimerRef.current);
}, []);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [loadingDir, setLoadingDir] = useState<string | null>(null);
const expandedDirsRef = useRef(expandedDirs);
expandedDirsRef.current = expandedDirs;
const {
files,
loading,
loadFiles,
expandedDirs,
loadingDir,
toggleDir,
readFile,
writeFile,
deleteFile,
downloadAllFiles,
uploadFiles,
deleteAllFiles,
} = useFilesApi(workspaceId, root);
const loadFiles = useCallback(async (subPath = "", depth = 1) => {
if (!subPath) setLoading(true);
else setLoadingDir(subPath);
try {
const params = new URLSearchParams({ root, depth: String(depth) });
if (subPath) params.set("path", subPath);
const data = await api.get<FileEntry[]>(`/workspaces/${workspaceId}/files?${params}`);
if (!subPath) {
// Root load — replace all
setFiles(data);
} else {
// Subfolder load — merge direct children only (preserve expanded grandchildren)
setFiles((prev) => {
const prefix = subPath + "/";
// Remove only direct children of this subPath (not deeper descendants)
const filtered = prev.filter((f) => {
if (!f.path.startsWith(prefix)) return true;
const remainder = f.path.slice(prefix.length);
// Keep entries that are nested deeper (grandchildren of other expanded dirs)
return remainder.includes("/");
});
const newFiles = data.map((f) => ({ ...f, path: subPath + "/" + f.path }));
return [...filtered, ...newFiles];
});
}
} catch {
if (!subPath) setFiles([]);
} finally {
setLoading(false);
setLoadingDir(null);
}
}, [workspaceId, root]);
const toggleDir = useCallback((dirPath: string) => {
const wasExpanded = expandedDirsRef.current.has(dirPath);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
}
return next;
});
if (!wasExpanded) {
loadFiles(dirPath, 1);
}
}, [loadFiles]);
useEffect(() => {
setExpandedDirs(new Set());
loadFiles();
}, [loadFiles]);
const tree = useMemo(() => buildTree(files), [files]);
const openFile = async (path: string) => {
setLoadingFile(true);
setError(null);
setSuccess(null);
try {
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`);
const res = await readFile(path);
setSelectedFile(path);
setFileContent(res.content);
setEditContent(res.content);
@ -136,8 +73,7 @@ export function FilesTab({ workspaceId }: Props) {
setSaving(true);
setError(null);
try {
await api.put(`/workspaces/${workspaceId}/files/${selectedFile}`, { content: editContent });
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
await writeFile(selectedFile, editContent);
setFileContent(editContent);
setSuccess("Saved");
clearTimeout(successTimerRef.current);
@ -149,16 +85,11 @@ export function FilesTab({ workspaceId }: Props) {
}
};
const requestDeleteFile = (path: string) => {
setConfirmDelete(path);
};
const confirmDeleteFile = async () => {
if (!confirmDelete) return;
setError(null);
try {
await api.del(`/workspaces/${workspaceId}/files/${confirmDelete}`);
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
await deleteFile(confirmDelete);
if (selectedFile === confirmDelete) {
setSelectedFile(null);
setFileContent("");
@ -176,8 +107,7 @@ export function FilesTab({ workspaceId }: Props) {
if (!newFileName.trim()) return;
setError(null);
try {
await api.put(`/workspaces/${workspaceId}/files/${newFileName.trim()}`, { content: "" });
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
await writeFile(newFileName.trim(), "");
setShowNewFile(false);
setNewFileName("");
loadFiles();
@ -187,8 +117,6 @@ export function FilesTab({ workspaceId }: Props) {
}
};
const uploadRef = useRef<HTMLInputElement>(null);
const handleDownloadFile = () => {
if (!selectedFile || !fileContent) return;
const blob = new Blob([editContent], { type: "text/plain" });
@ -201,68 +129,20 @@ export function FilesTab({ workspaceId }: Props) {
showToast("Downloaded", "success");
};
const handleDownloadAll = async () => {
const fileEntries = files.filter((f) => !f.dir);
const results = await Promise.allSettled(
fileEntries.map((f) => api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${f.path}`).then((res) => ({ path: f.path, content: res.content })))
);
const allFiles: Record<string, string> = {};
for (const r of results) {
if (r.status === "fulfilled") allFiles[r.value.path] = r.value.content;
}
const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "workspace-files.json";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${Object.keys(allFiles).length} files`, "success");
};
const handleUploadFiles = async (fileList: FileList) => {
setError(null);
let uploaded = 0;
for (const file of Array.from(fileList)) {
const path = file.webkitRelativePath || file.name;
const parts = path.split("/");
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
if (file.size > 1_000_000) continue;
try {
const content = await file.text();
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
uploaded++;
} catch { /* skip binary */ }
}
if (uploaded > 0) {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
showToast(`Uploaded ${uploaded} files`, "success");
loadFiles();
}
};
const handleDeleteAll = async () => {
setError(null);
let deleted = 0;
for (const f of files) {
if (f.dir) continue;
try {
await api.del(`/workspaces/${workspaceId}/files/${f.path}`);
deleted++;
} catch { /* skip */ }
}
await deleteAllFiles();
setSelectedFile(null);
setFileContent("");
setEditContent("");
showToast(`Deleted ${deleted} files`, "info");
loadFiles();
};
const [showDeleteAll, setShowDeleteAll] = useState(false);
const isDirty = editContent !== fileContent;
const tree = useMemo(() => buildTree(files), [files]);
const handleRootChange = (r: string) => {
setRoot(r);
setSelectedFile(null);
setFileContent("");
setEditContent("");
};
if (loading) {
return <div className="p-4 text-xs text-zinc-500">Loading files...</div>;
@ -270,91 +150,37 @@ export function FilesTab({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/40 bg-zinc-900/30">
<div className="flex items-center gap-2">
<select
value={root}
onChange={(e) => {
setRoot(e.target.value);
setSelectedFile(null);
setFileContent("");
setEditContent("");
}}
className="text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 rounded px-1.5 py-0.5 outline-none"
>
<option value="/configs">/configs</option>
<option value="/home">/home</option>
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
</select>
<span className="text-[10px] text-zinc-500">{files.filter((f) => !f.dir).length} files</span>
</div>
<div className="flex gap-1.5">
{root === "/configs" && (
<>
<button onClick={() => setShowNewFile(true)} className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
+ New
</button>
<input
ref={uploadRef}
type="file"
// @ts-expect-error webkitdirectory
webkitdirectory=""
multiple
className="hidden"
onChange={(e) => e.target.files && handleUploadFiles(e.target.files)}
/>
<button onClick={() => uploadRef.current?.click()} className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
Upload
</button>
</>
)}
<button onClick={handleDownloadAll} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
Export
</button>
{root === "/configs" && (
<button onClick={() => setShowDeleteAll(true)} className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
Clear
</button>
)}
<button onClick={() => loadFiles()} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
</button>
</div>
</div>
<FilesToolbar
root={root}
setRoot={handleRootChange}
fileCount={files.filter((f) => !f.dir).length}
onNewFile={() => setShowNewFile(true)}
onUpload={uploadFiles}
onDownloadAll={downloadAllFiles}
onClearAll={() => setShowDeleteAll(true)}
onRefresh={() => loadFiles()}
/>
{/* Delete all confirmation */}
{showDeleteAll && (
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p className="text-xs text-red-300">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<div className="flex gap-2">
<button onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">
Delete All
</button>
<button onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">
Cancel
</button>
<button onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
<button onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
</div>
</div>
)}
{error && (
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
{error}
</div>
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">{error}</div>
)}
{confirmDelete && (
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p className="text-xs text-amber-300">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div className="flex gap-2">
<button onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">
Delete
</button>
<button onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">
Cancel
</button>
<button onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
<button onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
</div>
</div>
)}
@ -381,11 +207,11 @@ export function FilesTab({ workspaceId }: Props) {
No config files yet
</div>
) : (
<TreeView
<FileTree
nodes={tree}
selectedPath={selectedFile}
onSelect={openFile}
onDelete={root === "/configs" ? requestDeleteFile : () => {}}
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
expandedDirs={expandedDirs}
onToggleDir={toggleDir}
loadingDir={loadingDir}
@ -395,256 +221,20 @@ export function FilesTab({ workspaceId }: Props) {
{/* Editor */}
<div className="flex-1 flex flex-col min-w-0">
{selectedFile ? (
<>
{/* File header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/20">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] font-mono text-zinc-300 truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-amber-400">modified</span>}
</div>
<div className="flex items-center gap-2">
{success && <span className="text-[9px] text-emerald-400">{success}</span>}
<button
onClick={handleDownloadFile}
className="text-[10px] text-zinc-500 hover:text-zinc-300"
title="Download file"
>
</button>
{root === "/configs" && (
<button
onClick={saveFile}
disabled={!isDirty || saving}
className="text-[10px] text-blue-400 hover:text-blue-300 disabled:opacity-30"
>
{saving ? "Saving..." : "Save"}
</button>
)}
</div>
</div>
{/* Editor area */}
{loadingFile ? (
<div className="p-4 text-xs text-zinc-500">Loading...</div>
) : (
<textarea
ref={editorRef}
value={editContent}
readOnly={root !== "/configs"}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
saveFile();
}
if (e.key === "Tab") {
e.preventDefault();
const el = editorRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const val = editContent;
const updated = val.substring(0, start) + " " + val.substring(end);
setEditContent(updated);
requestAnimationFrame(() => {
if (editorRef.current) {
editorRef.current.selectionStart = editorRef.current.selectionEnd = start + 2;
}
});
}
}}
spellCheck={false}
className="flex-1 w-full bg-zinc-950 p-3 text-[11px] font-mono text-zinc-200 leading-relaxed resize-none focus:outline-none"
style={{ tabSize: 2 }}
/>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-zinc-600">Select a file to edit</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
// Tree building utilities — exported for testing
export interface TreeNode {
name: string;
path: string;
isDir: boolean;
children: TreeNode[];
size: number;
}
export function buildTree(files: FileEntry[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
// Sort: dirs first, then alphabetical
const sorted = [...files].sort((a, b) => {
if (a.dir !== b.dir) return a.dir ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const file of sorted) {
const parts = file.path.split("/");
if (parts.length === 1) {
// Check if already exists in dirMap (e.g. created by a nested child earlier)
if (file.dir && dirMap.has(file.path)) continue;
const node: TreeNode = { name: parts[0], path: file.path, isDir: file.dir, children: [], size: file.size };
root.push(node);
if (file.dir) dirMap.set(file.path, node);
} else {
// Find or create parent dirs
let parentChildren = root;
for (let i = 0; i < parts.length - 1; i++) {
const dirPath = parts.slice(0, i + 1).join("/");
let dirNode = dirMap.get(dirPath);
if (!dirNode) {
dirNode = { name: parts[i], path: dirPath, isDir: true, children: [], size: 0 };
parentChildren.push(dirNode);
dirMap.set(dirPath, dirNode);
}
parentChildren = dirNode.children;
}
if (file.dir) {
const dirPath = file.path;
if (!dirMap.has(dirPath)) {
const dirNode: TreeNode = { name: parts[parts.length - 1], path: dirPath, isDir: true, children: [], size: 0 };
parentChildren.push(dirNode);
dirMap.set(dirPath, dirNode);
}
} else {
parentChildren.push({
name: parts[parts.length - 1],
path: file.path,
isDir: false,
children: [],
size: file.size,
});
}
}
}
return root;
}
interface TreeCallbacks {
selectedPath: string | null;
onSelect: (path: string) => void;
onDelete: (path: string) => void;
expandedDirs: Set<string>;
onToggleDir: (path: string) => void;
loadingDir: string | null;
}
function TreeView({
nodes,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth = 0,
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
return (
<div>
{nodes.map((node) => (
<TreeItem
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth}
/>
))}
</div>
);
}
function TreeItem({
node,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth,
}: TreeCallbacks & { node: TreeNode; depth: number }) {
const isSelected = selectedPath === node.path;
const expanded = expandedDirs.has(node.path);
const isLoading = loadingDir === node.path;
if (node.isDir) {
return (
<div>
<div
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-zinc-800/40 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
>
<span className="text-[9px] text-zinc-500 w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
<span className="text-[10px] text-zinc-400 flex-1">{node.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
>
</button>
</div>
{expanded && (
<TreeView
nodes={node.children}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth + 1}
<FileEditor
selectedFile={selectedFile}
fileContent={fileContent}
editContent={editContent}
setEditContent={setEditContent}
loadingFile={loadingFile}
saving={saving}
success={success}
root={root}
onSave={saveFile}
onDownload={handleDownloadFile}
/>
)}
</div>
</div>
);
}
return (
<div
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
isSelected ? "bg-blue-900/30 text-zinc-100" : "hover:bg-zinc-800/40 text-zinc-400"
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<span className="text-[9px]">{getIcon(node.name, false)}</span>
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
>
</button>
</div>
);
}

View File

@ -0,0 +1,112 @@
"use client";
import { useRef } from "react";
import { getIcon } from "./tree";
interface Props {
selectedFile: string | null;
fileContent: string;
editContent: string;
setEditContent: (v: string) => void;
loadingFile: boolean;
saving: boolean;
success: string | null;
root: string;
onSave: () => void;
onDownload: () => void;
}
export function FileEditor({
selectedFile,
fileContent,
editContent,
setEditContent,
loadingFile,
saving,
success,
root,
onSave,
onDownload,
}: Props) {
const editorRef = useRef<HTMLTextAreaElement>(null);
const isDirty = editContent !== fileContent;
if (!selectedFile) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-zinc-600">Select a file to edit</p>
</div>
</div>
);
}
return (
<>
{/* File header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/20">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] font-mono text-zinc-300 truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-amber-400">modified</span>}
</div>
<div className="flex items-center gap-2">
{success && <span className="text-[9px] text-emerald-400">{success}</span>}
<button
onClick={onDownload}
className="text-[10px] text-zinc-500 hover:text-zinc-300"
title="Download file"
>
</button>
{root === "/configs" && (
<button
onClick={onSave}
disabled={!isDirty || saving}
className="text-[10px] text-blue-400 hover:text-blue-300 disabled:opacity-30"
>
{saving ? "Saving..." : "Save"}
</button>
)}
</div>
</div>
{/* Editor area */}
{loadingFile ? (
<div className="p-4 text-xs text-zinc-500">Loading...</div>
) : (
<textarea
ref={editorRef}
value={editContent}
readOnly={root !== "/configs"}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onSave();
}
if (e.key === "Tab") {
e.preventDefault();
const el = editorRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const val = editContent;
const updated = val.substring(0, start) + " " + val.substring(end);
setEditContent(updated);
requestAnimationFrame(() => {
if (editorRef.current) {
editorRef.current.selectionStart = editorRef.current.selectionEnd = start + 2;
}
});
}
}}
spellCheck={false}
className="flex-1 w-full bg-zinc-950 p-3 text-[11px] font-mono text-zinc-200 leading-relaxed resize-none focus:outline-none"
style={{ tabSize: 2 }}
/>
)}
</>
);
}

View File

@ -0,0 +1,115 @@
"use client";
import { type TreeNode, getIcon } from "./tree";
interface TreeCallbacks {
selectedPath: string | null;
onSelect: (path: string) => void;
onDelete: (path: string) => void;
expandedDirs: Set<string>;
onToggleDir: (path: string) => void;
loadingDir: string | null;
}
export function FileTree({
nodes,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth = 0,
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
return (
<div>
{nodes.map((node) => (
<TreeItem
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth}
/>
))}
</div>
);
}
function TreeItem({
node,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth,
}: TreeCallbacks & { node: TreeNode; depth: number }) {
const isSelected = selectedPath === node.path;
const expanded = expandedDirs.has(node.path);
const isLoading = loadingDir === node.path;
if (node.isDir) {
return (
<div>
<div
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-zinc-800/40 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
>
<span className="text-[9px] text-zinc-500 w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
<span className="text-[10px] text-zinc-400 flex-1">{node.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
>
</button>
</div>
{expanded && (
<FileTree
nodes={node.children}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth + 1}
/>
)}
</div>
);
}
return (
<div
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
isSelected ? "bg-blue-900/30 text-zinc-100" : "hover:bg-zinc-800/40 text-zinc-400"
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<span className="text-[9px]">{getIcon(node.name, false)}</span>
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
>
</button>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import { useRef } from "react";
interface Props {
root: string;
setRoot: (r: string) => void;
fileCount: number;
onNewFile: () => void;
onUpload: (files: FileList) => void;
onDownloadAll: () => void;
onClearAll: () => void;
onRefresh: () => void;
}
export function FilesToolbar({
root,
setRoot,
fileCount,
onNewFile,
onUpload,
onDownloadAll,
onClearAll,
onRefresh,
}: Props) {
const uploadRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/40 bg-zinc-900/30">
<div className="flex items-center gap-2">
<select
value={root}
onChange={(e) => setRoot(e.target.value)}
className="text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 rounded px-1.5 py-0.5 outline-none"
>
<option value="/configs">/configs</option>
<option value="/home">/home</option>
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
</select>
<span className="text-[10px] text-zinc-500">{fileCount} files</span>
</div>
<div className="flex gap-1.5">
{root === "/configs" && (
<>
<button onClick={onNewFile} className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
+ New
</button>
<input
ref={uploadRef}
type="file"
// @ts-expect-error webkitdirectory
webkitdirectory=""
multiple
className="hidden"
onChange={(e) => e.target.files && onUpload(e.target.files)}
/>
<button onClick={() => uploadRef.current?.click()} className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
Upload
</button>
</>
)}
<button onClick={onDownloadAll} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
Export
</button>
{root === "/configs" && (
<button onClick={onClearAll} className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
Clear
</button>
)}
<button onClick={onRefresh} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
export interface FileEntry {
path: string;
size: number;
dir: boolean;
}
export interface TreeNode {
name: string;
path: string;
isDir: boolean;
children: TreeNode[];
size: number;
}
const FILE_ICONS: Record<string, string> = {
".md": "📄",
".yaml": "⚙",
".yml": "⚙",
".py": "🐍",
".ts": "💠",
".tsx": "💠",
".js": "📜",
".json": "{}",
".html": "🌐",
".css": "🎨",
".sh": "▸",
};
export function getIcon(path: string, isDir: boolean): string {
if (isDir) return "📁";
const ext = "." + path.split(".").pop();
return FILE_ICONS[ext] || "📄";
}
export function buildTree(files: FileEntry[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
// Sort: dirs first, then alphabetical
const sorted = [...files].sort((a, b) => {
if (a.dir !== b.dir) return a.dir ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const file of sorted) {
const parts = file.path.split("/");
if (parts.length === 1) {
// Check if already exists in dirMap (e.g. created by a nested child earlier)
if (file.dir && dirMap.has(file.path)) continue;
const node: TreeNode = { name: parts[0], path: file.path, isDir: file.dir, children: [], size: file.size };
root.push(node);
if (file.dir) dirMap.set(file.path, node);
} else {
// Find or create parent dirs
let parentChildren = root;
for (let i = 0; i < parts.length - 1; i++) {
const dirPath = parts.slice(0, i + 1).join("/");
let dirNode = dirMap.get(dirPath);
if (!dirNode) {
dirNode = { name: parts[i], path: dirPath, isDir: true, children: [], size: 0 };
parentChildren.push(dirNode);
dirMap.set(dirPath, dirNode);
}
parentChildren = dirNode.children;
}
if (file.dir) {
const dirPath = file.path;
if (!dirMap.has(dirPath)) {
const dirNode: TreeNode = { name: parts[parts.length - 1], path: dirPath, isDir: true, children: [], size: 0 };
parentChildren.push(dirNode);
dirMap.set(dirPath, dirNode);
}
} else {
parentChildren.push({
name: parts[parts.length - 1],
path: file.path,
isDir: false,
children: [],
size: file.size,
});
}
}
}
return root;
}

View File

@ -0,0 +1,172 @@
"use client";
import { useCallback, useRef, useState, useEffect } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../../Toaster";
import type { FileEntry } from "./tree";
export function useFilesApi(workspaceId: string, root: string) {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [loadingDir, setLoadingDir] = useState<string | null>(null);
const expandedDirsRef = useRef(expandedDirs);
expandedDirsRef.current = expandedDirs;
const loadFiles = useCallback(async (subPath = "", depth = 1) => {
if (!subPath) setLoading(true);
else setLoadingDir(subPath);
try {
const params = new URLSearchParams({ root, depth: String(depth) });
if (subPath) params.set("path", subPath);
const data = await api.get<FileEntry[]>(`/workspaces/${workspaceId}/files?${params}`);
if (!subPath) {
// Root load — replace all
setFiles(data);
} else {
// Subfolder load — merge direct children only (preserve expanded grandchildren)
setFiles((prev) => {
const prefix = subPath + "/";
// Remove only direct children of this subPath (not deeper descendants)
const filtered = prev.filter((f) => {
if (!f.path.startsWith(prefix)) return true;
const remainder = f.path.slice(prefix.length);
// Keep entries that are nested deeper (grandchildren of other expanded dirs)
return remainder.includes("/");
});
const newFiles = data.map((f) => ({ ...f, path: subPath + "/" + f.path }));
return [...filtered, ...newFiles];
});
}
} catch {
if (!subPath) setFiles([]);
} finally {
setLoading(false);
setLoadingDir(null);
}
}, [workspaceId, root]);
const toggleDir = useCallback((dirPath: string) => {
const wasExpanded = expandedDirsRef.current.has(dirPath);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
}
return next;
});
if (!wasExpanded) {
loadFiles(dirPath, 1);
}
}, [loadFiles]);
useEffect(() => {
setExpandedDirs(new Set());
loadFiles();
}, [loadFiles]);
const readFile = useCallback(
(path: string) =>
api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`),
[workspaceId, root]
);
const writeFile = useCallback(
async (path: string, content: string) => {
await api.put(`/workspaces/${workspaceId}/files/${path}`, { content });
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
},
[workspaceId]
);
const deleteFile = useCallback(
async (path: string) => {
await api.del(`/workspaces/${workspaceId}/files/${path}`);
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
},
[workspaceId]
);
const downloadAllFiles = useCallback(async () => {
const fileEntries = files.filter((f) => !f.dir);
const results = await Promise.allSettled(
fileEntries.map((f) =>
api
.get<{ content: string }>(`/workspaces/${workspaceId}/files/${f.path}`)
.then((res) => ({ path: f.path, content: res.content }))
)
);
const allFiles: Record<string, string> = {};
for (const r of results) {
if (r.status === "fulfilled") allFiles[r.value.path] = r.value.content;
}
const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "workspace-files.json";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${Object.keys(allFiles).length} files`, "success");
}, [files, workspaceId]);
const uploadFiles = useCallback(
async (fileList: FileList) => {
let uploaded = 0;
for (const file of Array.from(fileList)) {
const path = file.webkitRelativePath || file.name;
const parts = path.split("/");
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
if (file.size > 1_000_000) continue;
try {
const content = await file.text();
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
uploaded++;
} catch {
/* skip binary */
}
}
if (uploaded > 0) {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
showToast(`Uploaded ${uploaded} files`, "success");
loadFiles();
}
return uploaded;
},
[workspaceId, loadFiles]
);
const deleteAllFiles = useCallback(async () => {
let deleted = 0;
for (const f of files) {
if (f.dir) continue;
try {
await api.del(`/workspaces/${workspaceId}/files/${f.path}`);
deleted++;
} catch {
/* skip */
}
}
showToast(`Deleted ${deleted} files`, "info");
loadFiles();
return deleted;
}, [files, workspaceId, loadFiles]);
return {
files,
loading,
loadFiles,
expandedDirs,
loadingDir,
toggleDir,
readFile,
writeFile,
deleteFile,
downloadAllFiles,
uploadFiles,
deleteAllFiles,
};
}