molecule-core/canvas/src/components/tabs/FilesTab.tsx
Hongming Wang d9fb964797 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>
2026-04-13 18:00:20 -07:00

241 lines
7.9 KiB
TypeScript

"use client";
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;
}
export function FilesTab({ workspaceId }: Props) {
const [root, setRoot] = useState("/configs");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState("");
const [editContent, setEditContent] = useState("");
const [loadingFile, setLoadingFile] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showNewFile, setShowNewFile] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => clearTimeout(successTimerRef.current);
}, []);
const {
files,
loading,
loadFiles,
expandedDirs,
loadingDir,
toggleDir,
readFile,
writeFile,
deleteFile,
downloadAllFiles,
uploadFiles,
deleteAllFiles,
} = useFilesApi(workspaceId, root);
const tree = useMemo(() => buildTree(files), [files]);
const openFile = async (path: string) => {
setLoadingFile(true);
setError(null);
setSuccess(null);
try {
const res = await readFile(path);
setSelectedFile(path);
setFileContent(res.content);
setEditContent(res.content);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to read file");
} finally {
setLoadingFile(false);
}
};
const saveFile = async () => {
if (!selectedFile) return;
setSaving(true);
setError(null);
try {
await writeFile(selectedFile, editContent);
setFileContent(editContent);
setSuccess("Saved");
clearTimeout(successTimerRef.current);
successTimerRef.current = setTimeout(() => setSuccess(null), 2000);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
};
const confirmDeleteFile = async () => {
if (!confirmDelete) return;
setError(null);
try {
await deleteFile(confirmDelete);
if (selectedFile === confirmDelete) {
setSelectedFile(null);
setFileContent("");
setEditContent("");
}
loadFiles();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete");
} finally {
setConfirmDelete(null);
}
};
const createFile = async () => {
if (!newFileName.trim()) return;
setError(null);
try {
await writeFile(newFileName.trim(), "");
setShowNewFile(false);
setNewFileName("");
loadFiles();
openFile(newFileName.trim());
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create");
}
};
const handleDownloadFile = () => {
if (!selectedFile || !fileContent) return;
const blob = new Blob([editContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = selectedFile.split("/").pop() || "file";
a.click();
URL.revokeObjectURL(url);
showToast("Downloaded", "success");
};
const handleDeleteAll = async () => {
setError(null);
await deleteAllFiles();
setSelectedFile(null);
setFileContent("");
setEditContent("");
};
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>;
}
return (
<div className="flex flex-col h-full">
<FilesToolbar
root={root}
setRoot={handleRootChange}
fileCount={files.filter((f) => !f.dir).length}
onNewFile={() => setShowNewFile(true)}
onUpload={uploadFiles}
onDownloadAll={downloadAllFiles}
onClearAll={() => setShowDeleteAll(true)}
onRefresh={() => loadFiles()}
/>
{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>
</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>
)}
{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>
</div>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* File tree */}
<div className="w-[180px] border-r border-zinc-800/40 overflow-y-auto shrink-0">
{/* New file input */}
{showNewFile && (
<div className="px-2 py-1 border-b border-zinc-800/40">
<input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createFile()}
placeholder="path/file.md"
autoFocus
className="w-full bg-zinc-800 border border-zinc-600 rounded px-1.5 py-0.5 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
/>
</div>
)}
{files.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-zinc-600 text-center">
No config files yet
</div>
) : (
<FileTree
nodes={tree}
selectedPath={selectedFile}
onSelect={openFile}
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
expandedDirs={expandedDirs}
onToggleDir={toggleDir}
loadingDir={loadingDir}
/>
)}
</div>
{/* Editor */}
<div className="flex-1 flex flex-col min-w-0">
<FileEditor
selectedFile={selectedFile}
fileContent={fileContent}
editContent={editContent}
setEditContent={setEditContent}
loadingFile={loadingFile}
saving={saving}
success={success}
root={root}
onSave={saveFile}
onDownload={handleDownloadFile}
/>
</div>
</div>
</div>
);
}