Merge pull request #10 from Molecule-AI/refactor/split-files-tab
refactor(canvas): split 650-line FilesTab.tsx into focused components
This commit is contained in:
commit
0cb46be142
84
canvas/e2e/filestab-smoke.spec.ts
Normal file
84
canvas/e2e/filestab-smoke.spec.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke test for the PR #10 FilesTab split. Exercises the UI end-to-end:
|
||||
* - creates a workspace on the platform
|
||||
* - opens the detail panel
|
||||
* - switches to the Files tab
|
||||
* - confirms tree, toolbar, and editor panels render (the three extracted
|
||||
* sibling components: FileTree, FilesToolbar, FileEditor)
|
||||
* - saves a screenshot for visual review
|
||||
*
|
||||
* Requires platform on :8080 and canvas on :3000.
|
||||
*/
|
||||
test("FilesTab renders after split", async ({ page, request }) => {
|
||||
// Clean slate
|
||||
const { workspaces } = await request
|
||||
.get("http://localhost:8080/workspaces")
|
||||
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string }> }));
|
||||
for (const w of workspaces) {
|
||||
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`);
|
||||
}
|
||||
|
||||
// Create a workspace
|
||||
const created = await request
|
||||
.post("http://localhost:8080/workspaces", {
|
||||
data: { name: "FilesTab Smoke", tier: 1, runtime: "langgraph" },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
.then((r) => r.json());
|
||||
const wsId = created.id as string;
|
||||
|
||||
// Register so status flips online (so detail panel content loads cleanly)
|
||||
await request.post("http://localhost:8080/registry/register", {
|
||||
data: { id: wsId, url: "http://localhost:9999", agent_card: { name: "Smoke", skills: [] } },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/Molecule AI/);
|
||||
|
||||
// Screenshot: landing
|
||||
await page.screenshot({ path: "/tmp/filestab-1-landing.png", fullPage: false });
|
||||
|
||||
// Dismiss any onboarding overlay if present (best-effort)
|
||||
const skip = page.getByText(/skip guide/i).first();
|
||||
if (await skip.isVisible().catch(() => false)) await skip.click();
|
||||
|
||||
// Click the workspace node — title text is unique
|
||||
const node = page.getByText("FilesTab Smoke").first();
|
||||
await node.waitFor({ timeout: 10_000 });
|
||||
await node.click();
|
||||
|
||||
// Side panel should open
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: "/tmp/filestab-2-panel.png", fullPage: false });
|
||||
|
||||
// Switch to Files tab. The tab bar overflows-x and buttons off-screen
|
||||
// resist the usual click path. Use Playwright's force-click on the
|
||||
// hidden button; this fires a real React onClick.
|
||||
// Tab button text is "⊞ Files" (icon + label). Use hasText substring.
|
||||
const filesBtn = page.locator("button").filter({ hasText: "Files" });
|
||||
await filesBtn.first().scrollIntoViewIfNeeded();
|
||||
await filesBtn.first().click({ force: true });
|
||||
|
||||
await page.waitForTimeout(1200); // let files API load + render the 3 split components
|
||||
await page.screenshot({ path: "/tmp/filestab-3-files.png", fullPage: false });
|
||||
|
||||
// Hard assertion: all three split components are visible.
|
||||
// FilesToolbar: "+ New", "Upload", "Export", "Clear" buttons.
|
||||
// FileTree: the config.yaml file from the Go provisioner's default template.
|
||||
// FileEditor: the empty-state placeholder "Select a file to edit".
|
||||
const toolbarNew = page.getByRole("button", { name: /new/i });
|
||||
const toolbarUpload = page.getByRole("button", { name: /upload/i });
|
||||
const treeFile = page.getByText("config.yaml");
|
||||
const editorEmpty = page.getByText(/select a file/i);
|
||||
|
||||
await expect(toolbarNew.first()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(toolbarUpload.first()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(treeFile.first()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(editorEmpty.first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`);
|
||||
});
|
||||
65
canvas/package-lock.json
generated
65
canvas/package-lock.json
generated
@ -24,6 +24,7 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22.0.0",
|
||||
@ -488,6 +489,23 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
@ -3885,6 +3903,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"funding": [
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@ -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