diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index 9dde26ae..f25bb1da 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -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 = { - ".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([]); - const [loading, setLoading] = useState(true); + const [root, setRoot] = useState("/configs"); const [selectedFile, setSelectedFile] = useState(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(null); - const [root, setRoot] = useState("/configs"); + const [showDeleteAll, setShowDeleteAll] = useState(false); const successTimerRef = useRef>(undefined); - const editorRef = useRef(null); useEffect(() => { return () => clearTimeout(successTimerRef.current); }, []); - const [expandedDirs, setExpandedDirs] = useState>(new Set()); - const [loadingDir, setLoadingDir] = useState(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(`/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(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 = {}; - 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
Loading files...
; @@ -270,91 +150,37 @@ export function FilesTab({ workspaceId }: Props) { return (
- {/* Toolbar */} -
-
- - {files.filter((f) => !f.dir).length} files -
-
- {root === "/configs" && ( - <> - - e.target.files && handleUploadFiles(e.target.files)} - /> - - - )} - - {root === "/configs" && ( - - )} - -
-
+ !f.dir).length} + onNewFile={() => setShowNewFile(true)} + onUpload={uploadFiles} + onDownloadAll={downloadAllFiles} + onClearAll={() => setShowDeleteAll(true)} + onRefresh={() => loadFiles()} + /> - {/* Delete all confirmation */} {showDeleteAll && (

Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.

- - + +
)} {error && ( -
- {error} -
+
{error}
)} {confirmDelete && (

Delete {confirmDelete}{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?

- - + +
)} @@ -381,11 +207,11 @@ export function FilesTab({ workspaceId }: Props) { No config files yet
) : ( - {}} + onDelete={root === "/configs" ? setConfirmDelete : () => {}} expandedDirs={expandedDirs} onToggleDir={toggleDir} loadingDir={loadingDir} @@ -395,256 +221,20 @@ export function FilesTab({ workspaceId }: Props) { {/* Editor */}
- {selectedFile ? ( - <> - {/* File header */} -
-
- {getIcon(selectedFile, false)} - {selectedFile} - {isDirty && modified} -
-
- {success && {success}} - - {root === "/configs" && ( - - )} -
-
- - {/* Editor area */} - {loadingFile ? ( -
Loading...
- ) : ( -