diff --git a/canvas/e2e/filestab-smoke.spec.ts b/canvas/e2e/filestab-smoke.spec.ts new file mode 100644 index 00000000..05e86610 --- /dev/null +++ b/canvas/e2e/filestab-smoke.spec.ts @@ -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`); +}); diff --git a/canvas/package-lock.json b/canvas/package-lock.json index 125a356a..37720b8a 100644 --- a/canvas/package-lock.json +++ b/canvas/package-lock.json @@ -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": [ diff --git a/canvas/package.json b/canvas/package.json index 77e9cf76..4fb7aeb6 100644 --- a/canvas/package.json +++ b/canvas/package.json @@ -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", diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index 9dde26ae..f25bb1da 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -1,43 +1,23 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { api } from "@/lib/api"; -import { useCanvasStore } from "@/store/canvas"; +import { useState, useEffect, useRef, useMemo } from "react"; import { showToast } from "../Toaster"; +import { FilesToolbar } from "./FilesTab/FilesToolbar"; +import { FileTree } from "./FilesTab/FileTree"; +import { FileEditor } from "./FilesTab/FileEditor"; +import { useFilesApi } from "./FilesTab/useFilesApi"; +import { buildTree } from "./FilesTab/tree"; + +// Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`) +export { buildTree } from "./FilesTab/tree"; +export type { TreeNode } from "./FilesTab/tree"; interface Props { workspaceId: string; } -interface FileEntry { - path: string; - size: number; - dir: boolean; -} - -const FILE_ICONS: Record = { - ".md": "📄", - ".yaml": "⚙", - ".yml": "⚙", - ".py": "🐍", - ".ts": "💠", - ".tsx": "💠", - ".js": "📜", - ".json": "{}", - ".html": "🌐", - ".css": "🎨", - ".sh": "▸", -}; - -function getIcon(path: string, isDir: boolean): string { - if (isDir) return "📁"; - const ext = "." + path.split(".").pop(); - return FILE_ICONS[ext] || "📄"; -} - export function FilesTab({ workspaceId }: Props) { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); + const [root, setRoot] = useState("/configs"); const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(""); const [editContent, setEditContent] = useState(""); @@ -48,79 +28,36 @@ export function FilesTab({ workspaceId }: Props) { const [showNewFile, setShowNewFile] = useState(false); const [newFileName, setNewFileName] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); - const [root, setRoot] = useState("/configs"); + const [showDeleteAll, setShowDeleteAll] = useState(false); const successTimerRef = useRef>(undefined); - const editorRef = useRef(null); useEffect(() => { return () => clearTimeout(successTimerRef.current); }, []); - const [expandedDirs, setExpandedDirs] = useState>(new Set()); - const [loadingDir, setLoadingDir] = useState(null); - const expandedDirsRef = useRef(expandedDirs); - expandedDirsRef.current = expandedDirs; + const { + files, + loading, + loadFiles, + expandedDirs, + loadingDir, + toggleDir, + readFile, + writeFile, + deleteFile, + downloadAllFiles, + uploadFiles, + deleteAllFiles, + } = useFilesApi(workspaceId, root); - const loadFiles = useCallback(async (subPath = "", depth = 1) => { - if (!subPath) setLoading(true); - else setLoadingDir(subPath); - try { - const params = new URLSearchParams({ root, depth: String(depth) }); - if (subPath) params.set("path", subPath); - const data = await api.get(`/workspaces/${workspaceId}/files?${params}`); - if (!subPath) { - // Root load — replace all - setFiles(data); - } else { - // Subfolder load — merge direct children only (preserve expanded grandchildren) - setFiles((prev) => { - const prefix = subPath + "/"; - // Remove only direct children of this subPath (not deeper descendants) - const filtered = prev.filter((f) => { - if (!f.path.startsWith(prefix)) return true; - const remainder = f.path.slice(prefix.length); - // Keep entries that are nested deeper (grandchildren of other expanded dirs) - return remainder.includes("/"); - }); - const newFiles = data.map((f) => ({ ...f, path: subPath + "/" + f.path })); - return [...filtered, ...newFiles]; - }); - } - } catch { - if (!subPath) setFiles([]); - } finally { - setLoading(false); - setLoadingDir(null); - } - }, [workspaceId, root]); - - const toggleDir = useCallback((dirPath: string) => { - const wasExpanded = expandedDirsRef.current.has(dirPath); - setExpandedDirs((prev) => { - const next = new Set(prev); - if (next.has(dirPath)) { - next.delete(dirPath); - } else { - next.add(dirPath); - } - return next; - }); - if (!wasExpanded) { - loadFiles(dirPath, 1); - } - }, [loadFiles]); - - useEffect(() => { - setExpandedDirs(new Set()); - loadFiles(); - }, [loadFiles]); + const tree = useMemo(() => buildTree(files), [files]); const openFile = async (path: string) => { setLoadingFile(true); setError(null); setSuccess(null); try { - const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`); + const res = await readFile(path); setSelectedFile(path); setFileContent(res.content); setEditContent(res.content); @@ -136,8 +73,7 @@ export function FilesTab({ workspaceId }: Props) { setSaving(true); setError(null); try { - await api.put(`/workspaces/${workspaceId}/files/${selectedFile}`, { content: editContent }); - useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true }); + await writeFile(selectedFile, editContent); setFileContent(editContent); setSuccess("Saved"); clearTimeout(successTimerRef.current); @@ -149,16 +85,11 @@ export function FilesTab({ workspaceId }: Props) { } }; - const requestDeleteFile = (path: string) => { - setConfirmDelete(path); - }; - const confirmDeleteFile = async () => { if (!confirmDelete) return; setError(null); try { - await api.del(`/workspaces/${workspaceId}/files/${confirmDelete}`); - useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true }); + await deleteFile(confirmDelete); if (selectedFile === confirmDelete) { setSelectedFile(null); setFileContent(""); @@ -176,8 +107,7 @@ export function FilesTab({ workspaceId }: Props) { if (!newFileName.trim()) return; setError(null); try { - await api.put(`/workspaces/${workspaceId}/files/${newFileName.trim()}`, { content: "" }); - useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true }); + await writeFile(newFileName.trim(), ""); setShowNewFile(false); setNewFileName(""); loadFiles(); @@ -187,8 +117,6 @@ export function FilesTab({ workspaceId }: Props) { } }; - const uploadRef = useRef(null); - const handleDownloadFile = () => { if (!selectedFile || !fileContent) return; const blob = new Blob([editContent], { type: "text/plain" }); @@ -201,68 +129,20 @@ export function FilesTab({ workspaceId }: Props) { showToast("Downloaded", "success"); }; - const handleDownloadAll = async () => { - const fileEntries = files.filter((f) => !f.dir); - const results = await Promise.allSettled( - fileEntries.map((f) => api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${f.path}`).then((res) => ({ path: f.path, content: res.content }))) - ); - const allFiles: Record = {}; - for (const r of results) { - if (r.status === "fulfilled") allFiles[r.value.path] = r.value.content; - } - const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "workspace-files.json"; - a.click(); - URL.revokeObjectURL(url); - showToast(`Downloaded ${Object.keys(allFiles).length} files`, "success"); - }; - - const handleUploadFiles = async (fileList: FileList) => { - setError(null); - let uploaded = 0; - for (const file of Array.from(fileList)) { - const path = file.webkitRelativePath || file.name; - const parts = path.split("/"); - const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0]; - if (file.size > 1_000_000) continue; - try { - const content = await file.text(); - await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content }); - uploaded++; - } catch { /* skip binary */ } - } - if (uploaded > 0) { - useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true }); - showToast(`Uploaded ${uploaded} files`, "success"); - loadFiles(); - } - }; - const handleDeleteAll = async () => { setError(null); - let deleted = 0; - for (const f of files) { - if (f.dir) continue; - try { - await api.del(`/workspaces/${workspaceId}/files/${f.path}`); - deleted++; - } catch { /* skip */ } - } + await deleteAllFiles(); setSelectedFile(null); setFileContent(""); setEditContent(""); - showToast(`Deleted ${deleted} files`, "info"); - loadFiles(); }; - const [showDeleteAll, setShowDeleteAll] = useState(false); - - const isDirty = editContent !== fileContent; - - const tree = useMemo(() => buildTree(files), [files]); + const handleRootChange = (r: string) => { + setRoot(r); + setSelectedFile(null); + setFileContent(""); + setEditContent(""); + }; if (loading) { return
Loading files...
; @@ -270,91 +150,37 @@ export function FilesTab({ workspaceId }: Props) { return (
- {/* Toolbar */} -
-
- - {files.filter((f) => !f.dir).length} files -
-
- {root === "/configs" && ( - <> - - e.target.files && handleUploadFiles(e.target.files)} - /> - - - )} - - {root === "/configs" && ( - - )} - -
-
+ !f.dir).length} + onNewFile={() => setShowNewFile(true)} + onUpload={uploadFiles} + onDownloadAll={downloadAllFiles} + onClearAll={() => setShowDeleteAll(true)} + onRefresh={() => loadFiles()} + /> - {/* Delete all confirmation */} {showDeleteAll && (

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

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

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

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