import { memo, useState, useCallback, useMemo, useRef } from "react"; import { Plus, FolderSimplePlus } from "@phosphor-icons/react"; import { buildTree, sortChildren, isActiveInSubtree, ContextMenu, InlineInput, DeleteConfirm, TreeFile, TreeFolder, type ContextMenuState, type InlineInputState, } from "./FileTreeNodes"; // ── Types ── interface FileTreeProps { files: string[]; activeFile: string | null; onSelectFile: (path: string) => void; onCreateFile?: (path: string) => void; onCreateFolder?: (path: string) => void; onDeleteFile?: (path: string) => void; onRenameFile?: (oldPath: string, newPath: string) => void; onDuplicateFile?: (path: string) => void; onMoveFile?: (oldPath: string, newPath: string) => void; onImportFiles?: (files: FileList, dir?: string) => void; lintFindingsByFile?: Map; } // ── Main FileTree Component ── export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile, onCreateFile, onCreateFolder, onDeleteFile, onRenameFile, onDuplicateFile, onMoveFile, onImportFiles, lintFindingsByFile, }: FileTreeProps) { const tree = useMemo(() => buildTree(files), [files]); const children = useMemo(() => sortChildren(tree.children), [tree]); const [contextMenu, setContextMenu] = useState(null); const [inlineInput, setInlineInput] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); const dragSourceRef = useRef(null); const hasFileOps = !!( onCreateFile || onCreateFolder || onDeleteFile || onRenameFile || onDuplicateFile ); // ── Context Menu handlers ── const handleContextMenu = useCallback( (e: React.MouseEvent, path: string, isFolder: boolean) => { if (!hasFileOps) return; e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, targetPath: path, targetIsFolder: isFolder }); }, [hasFileOps], ); const handleCloseContextMenu = useCallback(() => setContextMenu(null), []); // ── New File ── const handleNewFile = useCallback( (parentPath: string) => { setInlineInput({ parentPath, mode: "new-file", onCommit: (name: string) => { const fullPath = parentPath ? `${parentPath}/${name}` : name; onCreateFile?.(fullPath); setInlineInput(null); }, onCancel: () => setInlineInput(null), }); }, [onCreateFile], ); // ── New Folder ── const handleNewFolder = useCallback( (parentPath: string) => { setInlineInput({ parentPath, mode: "new-folder", onCommit: (name: string) => { const fullPath = parentPath ? `${parentPath}/${name}` : name; onCreateFolder?.(fullPath); setInlineInput(null); }, onCancel: () => setInlineInput(null), }); }, [onCreateFolder], ); // ── Rename ── const handleRename = useCallback( (path: string) => { const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path; const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; setInlineInput({ parentPath, mode: "rename", originalPath: path, originalName: name, onCommit: (newName: string) => { if (newName !== name) { const newPath = parentPath ? `${parentPath}/${newName}` : newName; onRenameFile?.(path, newPath); } setInlineInput(null); }, onCancel: () => setInlineInput(null), }); }, [onRenameFile], ); // ── Duplicate ── const handleDuplicate = useCallback( (path: string) => { onDuplicateFile?.(path); }, [onDuplicateFile], ); // ── Delete ── const handleDelete = useCallback((path: string) => { setDeleteTarget(path); }, []); const handleDeleteConfirm = useCallback(() => { if (deleteTarget) { onDeleteFile?.(deleteTarget); setDeleteTarget(null); } }, [deleteTarget, onDeleteFile]); const handleDeleteCancel = useCallback(() => { setDeleteTarget(null); }, []); // ── Drag and Drop ── const handleDragStart = useCallback((e: React.DragEvent, path: string) => { dragSourceRef.current = path; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", path); }, []); const handleDragOver = useCallback((_e: React.DragEvent, folderPath: string) => { setDragOverFolder(folderPath); }, []); const handleDrop = useCallback( (e: React.DragEvent, folderPath: string) => { if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) { e.preventDefault(); onImportFiles?.(e.dataTransfer.files, folderPath || undefined); setDragOverFolder(null); return; } const sourcePath = dragSourceRef.current; if (!sourcePath || !onMoveFile) { setDragOverFolder(null); return; } const fileName = sourcePath.includes("/") ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath; const newPath = folderPath ? `${folderPath}/${fileName}` : fileName; if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) { onMoveFile(sourcePath, newPath); } setDragOverFolder(null); dragSourceRef.current = null; }, [onMoveFile, onImportFiles], ); const handleDragLeave = useCallback(() => { setDragOverFolder(null); }, []); // ── Root-level context menu (right-click on empty space) ── const handleRootContextMenu = useCallback( (e: React.MouseEvent) => { if (!hasFileOps) return; if (e.target === e.currentTarget) { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true }); } }, [hasFileOps], ); return (
{/* FILES header with action buttons */} {hasFileOps && (
Files
)}
{ e.preventDefault(); if (e.target === e.currentTarget) setDragOverFolder(""); }} onDragLeave={(e) => { if (e.target === e.currentTarget) setDragOverFolder(null); }} onDrop={(e) => { e.preventDefault(); handleDrop(e, ""); }} > {/* Root-level inline input for new file/folder */} {inlineInput && (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && inlineInput.parentPath === "" && ( inlineInput.onCommit?.(name)} onCancel={() => inlineInput.onCancel?.()} /> )} {children.map((child) => child.isFile && child.children.size === 0 ? ( ) : ( ), )}
{/* Delete confirmation overlay */} {deleteTarget && (
)} {/* Context menu */} {contextMenu && ( )}
); });