import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Copy, Download, FileText, FolderPlus, Pencil, RefreshCw, Trash2, type LucideIcon } from 'lucide-react'; import { cn } from '../../../lib/utils'; type FileContextItem = { name: string; type: 'file' | 'directory'; path: string; size?: number; modified?: string; permissionsRwx?: string; children?: FileContextItem[]; [key: string]: unknown; }; type ContextMenuAction = { key: string; label: string; icon?: LucideIcon; onSelect?: () => void; isDanger?: boolean; isDisabled?: boolean; shortcut?: string; showDividerBefore?: boolean; }; const CONTEXT_MENU_WIDTH = 200; const CONTEXT_MENU_HEIGHT = 300; const VIEWPORT_PADDING = 10; function calculateViewportSafePosition(clientX: number, clientY: number) { // Keep the context menu inside the visible viewport. const safeX = clientX + CONTEXT_MENU_WIDTH > window.innerWidth ? window.innerWidth - CONTEXT_MENU_WIDTH - VIEWPORT_PADDING : clientX; const safeY = clientY + CONTEXT_MENU_HEIGHT > window.innerHeight ? window.innerHeight - CONTEXT_MENU_HEIGHT - VIEWPORT_PADDING : clientY; return { x: Math.max(VIEWPORT_PADDING, safeX), y: Math.max(VIEWPORT_PADDING, safeY) }; } export default function FileContextMenu({ children, item, onRename, onDelete, onNewFile, onNewFolder, onRefresh, onCopyPath, onDownload, isLoading = false, className = '', }: { children: ReactNode; item?: FileContextItem | null; onRename?: (item: FileContextItem) => void; onDelete?: (item: FileContextItem) => void; onNewFile?: (path: string) => void; onNewFolder?: (path: string) => void; onRefresh?: () => void; onCopyPath?: (item: FileContextItem) => void; onDownload?: (item: FileContextItem) => void; isLoading?: boolean; className?: string; }) { const { t } = useTranslation(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const menuRef = useRef(null); const closeContextMenu = useCallback(() => { setIsMenuOpen(false); }, []); const openContextMenuAtCursor = useCallback((event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); setMenuPosition(calculateViewportSafePosition(event.clientX, event.clientY)); setIsMenuOpen(true); }, []); const runMenuActionAndClose = useCallback((action?: () => void) => { closeContextMenu(); action?.(); }, [closeContextMenu]); const menuActions = useMemo(() => { if (item?.type === 'file') { return [ { key: 'rename', icon: Pencil, label: t('fileTree.context.rename', 'Rename'), onSelect: () => onRename?.(item), }, { key: 'delete', icon: Trash2, label: t('fileTree.context.delete', 'Delete'), onSelect: () => onDelete?.(item), isDanger: true, }, { key: 'copyPath', icon: Copy, label: t('fileTree.context.copyPath', 'Copy Path'), onSelect: () => onCopyPath?.(item), showDividerBefore: true, }, { key: 'download', icon: Download, label: t('fileTree.context.download', 'Download'), onSelect: () => onDownload?.(item), }, ]; } if (item?.type === 'directory') { return [ { key: 'newFile', icon: FileText, label: t('fileTree.context.newFile', 'New File'), onSelect: () => onNewFile?.(item.path), }, { key: 'newFolder', icon: FolderPlus, label: t('fileTree.context.newFolder', 'New Folder'), onSelect: () => onNewFolder?.(item.path), }, { key: 'rename', icon: Pencil, label: t('fileTree.context.rename', 'Rename'), onSelect: () => onRename?.(item), showDividerBefore: true, }, { key: 'delete', icon: Trash2, label: t('fileTree.context.delete', 'Delete'), onSelect: () => onDelete?.(item), isDanger: true, }, { key: 'copyPath', icon: Copy, label: t('fileTree.context.copyPath', 'Copy Path'), onSelect: () => onCopyPath?.(item), showDividerBefore: true, }, { key: 'download', icon: Download, label: t('fileTree.context.download', 'Download'), onSelect: () => onDownload?.(item), }, ]; } return [ { key: 'newFile', icon: FileText, label: t('fileTree.context.newFile', 'New File'), onSelect: () => onNewFile?.(''), }, { key: 'newFolder', icon: FolderPlus, label: t('fileTree.context.newFolder', 'New Folder'), onSelect: () => onNewFolder?.(''), }, { key: 'refresh', icon: RefreshCw, label: t('fileTree.context.refresh', 'Refresh'), onSelect: onRefresh, showDividerBefore: true, }, ]; }, [item, onCopyPath, onDelete, onDownload, onNewFile, onNewFolder, onRefresh, onRename, t]); useEffect(() => { if (!isMenuOpen) { return; } const handleOutsideMouseDown = (event: MouseEvent) => { const menuElement = menuRef.current; if (menuElement && !menuElement.contains(event.target as Node)) { closeContextMenu(); } }; const handleEscapeKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeContextMenu(); } }; document.addEventListener('mousedown', handleOutsideMouseDown); document.addEventListener('keydown', handleEscapeKeyDown); return () => { document.removeEventListener('mousedown', handleOutsideMouseDown); document.removeEventListener('keydown', handleEscapeKeyDown); }; }, [closeContextMenu, isMenuOpen]); useEffect(() => { if (!isMenuOpen) { return; } // Arrow key support keeps the menu accessible without a mouse. const handleKeyboardMenuNavigation = (event: KeyboardEvent) => { const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])'); if (!menuItems || menuItems.length === 0) { return; } const activeElement = document.activeElement as HTMLElement | null; const currentIndex = Array.from(menuItems).findIndex((menuItem) => menuItem === activeElement); if (event.key === 'ArrowDown') { event.preventDefault(); const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0; menuItems[nextIndex]?.focus(); } else if (event.key === 'ArrowUp') { event.preventDefault(); const previousIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1; menuItems[previousIndex]?.focus(); } else if (event.key === 'Enter' || event.key === ' ') { if (activeElement?.hasAttribute('role')) { event.preventDefault(); activeElement.click(); } } }; document.addEventListener('keydown', handleKeyboardMenuNavigation); return () => { document.removeEventListener('keydown', handleKeyboardMenuNavigation); }; }, [isMenuOpen]); return ( <>
{children}
{isMenuOpen && (
{isLoading ? (
{t('fileTree.context.loading', 'Loading...')}
) : ( menuActions.map((action) => ( {action.showDividerBefore &&
} )) )}
)} ); }