refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view

This commit is contained in:
Haileyesus
2026-03-05 13:24:09 +03:00
parent 11d72a3318
commit 9d8b759250
3 changed files with 313 additions and 313 deletions

View File

@@ -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 }) => (
<button
role="menuitem"
tabIndex={disabled ? -1 : 0}
disabled={disabled || isLoading}
onClick={() => handleAction(onClick)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
disabled
? 'opacity-50 cursor-not-allowed'
: danger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none'
)}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
)}
</button>
);
// Menu divider
const MenuDivider = () => (
<div className="h-px bg-border my-1 mx-2" />
);
// Build menu items based on context
const renderMenuItems = () => {
if (isFile) {
return (
<>
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
if (isDirectory) {
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.(item.path)}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.(item.path)}
/>
<MenuDivider />
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
// Background context (empty space)
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.('')}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.('')}
/>
<MenuDivider />
<MenuItem
icon={RefreshCw}
label={t('fileTree.context.refresh', 'Refresh')}
onClick={onRefresh}
/>
</>
);
};
return (
<>
{/* Trigger element */}
<div
ref={triggerRef}
onContextMenu={handleContextMenu}
className={cn('contents', className)}
>
{children}
</div>
{/* Context menu portal */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999
}}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t('fileTree.context.loading', 'Loading...')}
</span>
</div>
) : (
renderMenuItems()
)}
</div>
)}
</>
);
};
export default FileContextMenu;

View File

@@ -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<HTMLDivElement>(null);
const closeContextMenu = useCallback(() => {
setIsMenuOpen(false);
}, []);
const openContextMenuAtCursor = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuPosition(calculateViewportSafePosition(event.clientX, event.clientY));
setIsMenuOpen(true);
}, []);
const runMenuActionAndClose = useCallback((action?: () => void) => {
closeContextMenu();
action?.();
}, [closeContextMenu]);
const menuActions = useMemo<ContextMenuAction[]>(() => {
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<HTMLElement>('[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 (
<>
<div onContextMenu={openContextMenuAtCursor} className={cn('contents', className)}>
{children}
</div>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{ position: 'fixed', left: menuPosition.x, top: menuPosition.y, zIndex: 9999 }}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">{t('fileTree.context.loading', 'Loading...')}</span>
</div>
) : (
menuActions.map((action) => (
<Fragment key={action.key}>
{action.showDividerBefore && <div className="h-px bg-border my-1 mx-2" />}
<button
role="menuitem"
tabIndex={action.isDisabled ? -1 : 0}
disabled={isLoading || action.isDisabled}
onClick={() => runMenuActionAndClose(action.onSelect)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
action.isDisabled
? 'opacity-50 cursor-not-allowed'
: action.isDanger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none',
)}
>
{action.icon && <action.icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{action.label}</span>
{action.shortcut && <span className="text-xs text-muted-foreground font-mono">{action.shortcut}</span>}
</button>
</Fragment>
))
)}
</div>
)}
</>
);
}

View File

@@ -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';