"use client"; import React, { useState, useCallback, useRef, useEffect } from "react"; import { Input } from "@kandev/ui/input"; import { IconPencil, IconTrash } from "@tabler/icons-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@kandev/ui/alert-dialog"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator, } from "@kandev/ui/context-menu"; import { cn } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; import type { FileTreeNode } from "@/lib/types/backend"; import type { FileInfo } from "@/lib/state/store"; import { removeNodeFromTree, renameNodeInTree, treeContainsPath, countFilesInTree, } from "./file-tree-utils"; type GitFileStatus = FileInfo["status"] | undefined; function deleteNodeOptimistically( tree: FileTreeNode | null, setTree: React.Dispatch>, path: string, onDeleteFile: (path: string) => Promise, ) { const snapshot = tree; setTree((prev) => (prev ? removeNodeFromTree(prev, path) : prev)); onDeleteFile(path) .then((ok) => { if (!ok) setTree(snapshot); }) .catch(() => setTree(snapshot)); } function DeleteConfirmDialog({ isBulk, selectedCount, node, fileCount, onConfirm, }: { isBulk: boolean; selectedCount: number; node: FileTreeNode; fileCount: number; onConfirm: () => void; }) { const title = isBulk ? `Delete ${selectedCount} items?` : "Delete folder?"; return ( {title} {isBulk ? ( `This will permanently delete ${selectedCount} selected items. This action cannot be undone.` ) : ( <> This will permanently delete {node.name} {fileCount > 0 && ( <> {" "} and {fileCount}{" "} {fileCount === 1 ? "file" : "files"} inside it )} . This action cannot be undone. )} Cancel Delete ); } /** Context menu for file nodes with Rename and Delete options */ export function FileContextMenu({ children, node, tree, setTree, onDeleteFile, onRenameFile, onStartRename, selectedCount = 0, selectedPaths, }: { children: React.ReactNode; node: FileTreeNode; tree: FileTreeNode | null; setTree: React.Dispatch>; onDeleteFile?: (path: string) => Promise; onRenameFile?: (oldPath: string, newPath: string) => Promise; onStartRename: () => void; selectedCount?: number; selectedPaths?: Set; }) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const isBulk = selectedCount > 1; const needsConfirmation = node.is_dir || isBulk; const handleConfirmDelete = useCallback(() => { setDeleteDialogOpen(false); if (!onDeleteFile) return; if (isBulk && selectedPaths) { // Remove all nodes optimistically, then call APIs. // Rollback entire tree only if any individual delete fails. const snapshot = tree; const paths = [...selectedPaths]; setTree((prev) => { let t = prev; for (const p of paths) t = t ? removeNodeFromTree(t, p) : t; return t; }); Promise.all(paths.map((p) => onDeleteFile(p))) .then((results) => { if (results.some((ok) => !ok)) setTree(snapshot); }) .catch(() => setTree(snapshot)); } else { deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile); } }, [tree, setTree, node.path, onDeleteFile, isBulk, selectedPaths]); const handleDelete = useCallback(() => { if (!onDeleteFile) return; if (needsConfirmation) { setDeleteDialogOpen(true); } else { deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile); } }, [tree, setTree, node.path, onDeleteFile, needsConfirmation]); if (!onDeleteFile && !onRenameFile) return <>{children}; const deleteLabel = isBulk ? `Delete ${selectedCount} items` : "Delete"; return ( <> {children} {onDeleteFile && ( {deleteLabel} )} {onRenameFile && onDeleteFile && !isBulk && } {onRenameFile && !isBulk && ( Rename )} {needsConfirmation && ( )} ); } /** Hook for managing inline file rename state */ export function useFileRename( node: FileTreeNode, tree: FileTreeNode | null, setTree: React.Dispatch>, onRenameFile?: (oldPath: string, newPath: string) => Promise, ) { const { toast } = useToast(); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(node.name); const handleStartRename = useCallback(() => { setRenameValue(node.name); setIsRenaming(true); }, [node.name]); const handleCancelRename = useCallback(() => { setIsRenaming(false); setRenameValue(node.name); }, [node.name]); const handleConfirmRename = useCallback(() => { const newName = renameValue.trim(); if (!newName || newName === node.name || !onRenameFile) { handleCancelRename(); return; } if (newName.includes("/") || newName.includes("\\")) { toast({ title: "Invalid name", description: "File names cannot contain path separators", variant: "error", }); handleCancelRename(); return; } const parentPath = node.path.includes("/") ? node.path.substring(0, node.path.lastIndexOf("/")) : ""; const newPath = parentPath ? `${parentPath}/${newName}` : newName; const snapshot = tree; setIsRenaming(false); if (tree && treeContainsPath(tree, newPath)) { toast({ title: "Failed to rename item", description: `Target already exists: ${newPath}`, variant: "error", }); handleCancelRename(); return; } setTree((prev) => (prev ? renameNodeInTree(prev, node.path, newPath) : prev)); onRenameFile(node.path, newPath) .then((ok) => { if (!ok) setTree(snapshot); }) .catch(() => { setTree(snapshot); }); }, [renameValue, node.name, node.path, onRenameFile, tree, setTree, handleCancelRename, toast]); const handleRenameKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleConfirmRename(); } else if (e.key === "Escape") { handleCancelRename(); } }, [handleConfirmRename, handleCancelRename], ); return { isRenaming, renameValue, setRenameValue, handleStartRename, handleConfirmRename, handleRenameKeyDown, }; } /** Inline rename input or static file name */ export function TreeNodeName({ node, isActive, gitStatus, rename, }: { node: FileTreeNode; isActive: boolean; gitStatus: GitFileStatus; rename: ReturnType; }) { const inputRef = useRef(null); const blurEnabledRef = useRef(false); useEffect(() => { if (rename.isRenaming) { blurEnabledRef.current = false; const focusTimer = setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 150); const blurTimer = setTimeout(() => { blurEnabledRef.current = true; }, 400); return () => { clearTimeout(focusTimer); clearTimeout(blurTimer); }; } }, [rename.isRenaming]); const handleBlur = useCallback(() => { if (blurEnabledRef.current) { rename.handleConfirmRename(); } }, [rename]); if (rename.isRenaming) { return ( rename.setRenameValue(e.target.value)} onKeyDown={rename.handleRenameKeyDown} onBlur={handleBlur} onClick={(e) => e.stopPropagation()} className="h-5 text-xs px-1 py-0 flex-1 min-w-0" /> ); } return ( {node.name} ); } export function getGitStatusTextClass(status: GitFileStatus): string { switch (status) { case "added": case "untracked": return "text-green-700 dark:text-green-600"; case "modified": return "text-yellow-600"; default: return ""; } }