From 9d8b7592502be52b7bd0c1110b8e436ac061ba72 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Thu, 5 Mar 2026 13:24:09 +0300 Subject: [PATCH] refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view --- src/components/FileContextMenu.jsx | 312 ------------------ .../file-tree/view/FileContextMenu.tsx | 312 ++++++++++++++++++ .../file-tree/view/FileTreeNode.tsx | 2 +- 3 files changed, 313 insertions(+), 313 deletions(-) delete mode 100644 src/components/FileContextMenu.jsx create mode 100644 src/components/file-tree/view/FileContextMenu.tsx diff --git a/src/components/FileContextMenu.jsx b/src/components/FileContextMenu.jsx deleted file mode 100644 index 5fee1ef9..00000000 --- a/src/components/FileContextMenu.jsx +++ /dev/null @@ -1,312 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - FileText, - FolderPlus, - Pencil, - Trash2, - Copy, - Download, - RefreshCw -} from 'lucide-react'; -import { cn } from '../lib/utils'; - -/** - * FileContextMenu Component - * Right-click context menu for file/directory operations - */ -const FileContextMenu = ({ - children, - item, - onRename, - onDelete, - onNewFile, - onNewFolder, - onRefresh, - onCopyPath, - onDownload, - isLoading = false, - className = '' -}) => { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const menuRef = useRef(null); - const triggerRef = useRef(null); - - const isDirectory = item?.type === 'directory'; - const isFile = item?.type === 'file'; - const isBackground = !item; // Clicked on empty space - - // Handle right-click - const handleContextMenu = useCallback((e) => { - e.preventDefault(); - e.stopPropagation(); - - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX; - const y = e.clientY; - - // Adjust position if menu would go off screen - const menuWidth = 200; - const menuHeight = 300; - - let adjustedX = x; - let adjustedY = y; - - if (x + menuWidth > window.innerWidth) { - adjustedX = window.innerWidth - menuWidth - 10; - } - if (y + menuHeight > window.innerHeight) { - adjustedY = window.innerHeight - menuHeight - 10; - } - - setPosition({ x: adjustedX, y: adjustedY }); - setIsOpen(true); - }, []); - - // Close menu - const closeMenu = useCallback(() => { - setIsOpen(false); - }, []); - - // Close on click outside - useEffect(() => { - const handleClickOutside = (e) => { - if (menuRef.current && !menuRef.current.contains(e.target)) { - closeMenu(); - } - }; - - const handleEscape = (e) => { - if (e.key === 'Escape') { - closeMenu(); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); - }; - }, [isOpen, closeMenu]); - - // Handle keyboard navigation - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e) => { - const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]'); - if (!menuItems || menuItems.length === 0) return; - - const currentIndex = Array.from(menuItems).findIndex( - (item) => item === document.activeElement - ); - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0; - menuItems[nextIndex]?.focus(); - break; - case 'ArrowUp': - e.preventDefault(); - const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1; - menuItems[prevIndex]?.focus(); - break; - case 'Enter': - case ' ': - if (document.activeElement?.hasAttribute('role', 'menuitem')) { - e.preventDefault(); - document.activeElement.click(); - } - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen]); - - // Handle action click - const handleAction = (action, ...args) => { - closeMenu(); - action?.(...args); - }; - - // Menu item component - const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => ( - - ); - - // Menu divider - const MenuDivider = () => ( -
- ); - - // Build menu items based on context - const renderMenuItems = () => { - if (isFile) { - return ( - <> - onRename?.(item)} - /> - onDelete?.(item)} - danger - /> - - onCopyPath?.(item)} - /> - onDownload?.(item)} - /> - - ); - } - - if (isDirectory) { - return ( - <> - onNewFile?.(item.path)} - /> - onNewFolder?.(item.path)} - /> - - onRename?.(item)} - /> - onDelete?.(item)} - danger - /> - - onCopyPath?.(item)} - /> - onDownload?.(item)} - /> - - ); - } - - // Background context (empty space) - return ( - <> - onNewFile?.('')} - /> - onNewFolder?.('')} - /> - - - - ); - }; - - return ( - <> - {/* Trigger element */} -
- {children} -
- - {/* Context menu portal */} - {isOpen && ( -
- {isLoading ? ( -
- - - {t('fileTree.context.loading', 'Loading...')} - -
- ) : ( - renderMenuItems() - )} -
- )} - - ); -}; - -export default FileContextMenu; diff --git a/src/components/file-tree/view/FileContextMenu.tsx b/src/components/file-tree/view/FileContextMenu.tsx new file mode 100644 index 00000000..4cc1d103 --- /dev/null +++ b/src/components/file-tree/view/FileContextMenu.tsx @@ -0,0 +1,312 @@ +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 &&
} + + + )) + )} +
+ )} + + ); +} diff --git a/src/components/file-tree/view/FileTreeNode.tsx b/src/components/file-tree/view/FileTreeNode.tsx index 8e0501ab..7ba06beb 100644 --- a/src/components/file-tree/view/FileTreeNode.tsx +++ b/src/components/file-tree/view/FileTreeNode.tsx @@ -1,7 +1,7 @@ import type { ReactNode, RefObject } from 'react'; import { ChevronRight, Folder, FolderOpen } from 'lucide-react'; import { cn } from '../../../lib/utils'; -import FileContextMenu from '../../FileContextMenu'; +import FileContextMenu from './FileContextMenu'; import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types'; import { Input } from '../../../shared/view/ui';