forked from molecule-ai/molecule-core
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:
parent
e920aaab8e
commit
c71cd39ee7
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
112
canvas/src/components/tabs/FilesTab/FileEditor.tsx
Normal file
112
canvas/src/components/tabs/FilesTab/FileEditor.tsx
Normal 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 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
canvas/src/components/tabs/FilesTab/FileTree.tsx
Normal file
115
canvas/src/components/tabs/FilesTab/FileTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
canvas/src/components/tabs/FilesTab/FilesToolbar.tsx
Normal file
77
canvas/src/components/tabs/FilesTab/FilesToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
canvas/src/components/tabs/FilesTab/tree.ts
Normal file
86
canvas/src/components/tabs/FilesTab/tree.ts
Normal 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;
|
||||
}
|
||||
172
canvas/src/components/tabs/FilesTab/useFilesApi.ts
Normal file
172
canvas/src/components/tabs/FilesTab/useFilesApi.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user