import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@termix/plugin-sdk/ui"; import { Clipboard, Edit3, Eye, EyeOff, ExternalLink, FilePlus, FolderOpen, FolderPlus, RefreshCw, Trash2, Upload, } from "lucide-react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui"; import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; const VIEWPORT_PADDING = 16; export interface LocalFileContextMenuProps { x: number; y: number; /** Entries the menu acts on; empty means the pane background was clicked. */ entries: LocalFileEntry[]; isVisible: boolean; showHidden: boolean; canUpload: boolean; onClose: () => void; onOpen: (entry: LocalFileEntry) => void; onUploadToRemote: (entries: LocalFileEntry[]) => void; onReveal: (entry?: LocalFileEntry) => void; onRename: (entry: LocalFileEntry) => void; onCopyPath: (entries: LocalFileEntry[]) => void; onNewFolder: () => void; onNewFile: () => void; onRefresh: () => void; onToggleHidden: () => void; onDelete: (entries: LocalFileEntry[]) => void; } interface MenuItem { icon?: React.ReactNode; label?: string; action?: () => void; shortcut?: string; separator?: boolean; disabled?: boolean; danger?: boolean; } /** * Right-click menu for the local pane. Same chrome and dismissal rules as * FileManagerContextMenu, with the subset of actions that make sense for the * user's own disk. */ export function LocalFileContextMenu({ x, y, entries, isVisible, showHidden, canUpload, onClose, onOpen, onUploadToRemote, onReveal, onRename, onCopyPath, onNewFolder, onNewFile, onRefresh, onToggleHidden, onDelete, }: LocalFileContextMenuProps) { const { t } = useTranslation(); const menuRef = useRef(null); const [menuPosition, setMenuPosition] = useState({ x, y }); const [isMounted, setIsMounted] = useState(false); useEffect(() => { if (!isVisible) { setIsMounted(false); return; } setIsMounted(true); let cleanupFn: (() => void) | null = null; const timeoutId = setTimeout(() => { const handleClickOutside = (event: MouseEvent) => { if (!menuRef.current?.contains(event.target as Node)) onClose(); }; const handleRightClick = (event: MouseEvent) => { event.preventDefault(); onClose(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); } }; const handleBlur = () => onClose(); const handleScroll = () => onClose(); document.addEventListener("mousedown", handleClickOutside, true); document.addEventListener("contextmenu", handleRightClick); document.addEventListener("keydown", handleKeyDown); window.addEventListener("blur", handleBlur); window.addEventListener("scroll", handleScroll, true); cleanupFn = () => { document.removeEventListener("mousedown", handleClickOutside, true); document.removeEventListener("contextmenu", handleRightClick); document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("blur", handleBlur); window.removeEventListener("scroll", handleScroll, true); }; }, 50); return () => { clearTimeout(timeoutId); cleanupFn?.(); }; }, [isVisible, x, y, onClose]); useLayoutEffect(() => { if (!isVisible || !menuRef.current) return; const menuWidth = menuRef.current.offsetWidth; const menuHeight = menuRef.current.offsetHeight; let adjustedX = x; let adjustedY = y; if (x + menuWidth > window.innerWidth) { adjustedX = window.innerWidth - menuWidth - 10; } if (y + menuHeight > window.innerHeight) { adjustedY = Math.max(10, window.innerHeight - menuHeight - 10); } setMenuPosition({ x: Math.max(8, adjustedX), y: Math.max(8, adjustedY) }); }, [isVisible, x, y, entries.length]); const isEntryContext = entries.length > 0; const isSingle = entries.length === 1; const single = isSingle ? entries[0] : null; const menuItems: MenuItem[] = []; if (isEntryContext) { if (single) { menuItems.push({ icon: , label: single.type === "directory" ? t("fileManager.localOpenFolder") : t("fileManager.localOpen"), action: () => onOpen(single), shortcut: "Enter", }); } menuItems.push({ icon: , label: isSingle ? t("fileManager.localUploadToRemote") : t("fileManager.localUploadToRemoteMany", { count: entries.length }), action: () => onUploadToRemote(entries), disabled: !canUpload, }); menuItems.push({ icon: , label: t("fileManager.localRevealInFileManager"), action: () => onReveal(entries[0]), }); menuItems.push({ separator: true }); if (single) { menuItems.push({ icon: , label: t("fileManager.rename"), action: () => onRename(single), shortcut: "F2", }); } menuItems.push({ icon: , label: isSingle ? t("fileManager.copyPath") : t("fileManager.copyPaths"), action: () => onCopyPath(entries), }); menuItems.push({ separator: true }); menuItems.push({ icon: , label: isSingle ? t("fileManager.localMoveToTrash") : t("fileManager.localMoveToTrashMany", { count: entries.length }), action: () => onDelete(entries), shortcut: "Del", danger: true, }); } else { menuItems.push({ icon: , label: t("fileManager.newFolder"), action: onNewFolder, }); menuItems.push({ icon: , label: t("fileManager.newFile"), action: onNewFile, }); menuItems.push({ separator: true }); menuItems.push({ icon: , label: t("fileManager.localRevealInFileManager"), action: () => onReveal(), }); menuItems.push({ icon: showHidden ? ( ) : ( ), label: showHidden ? t("fileManager.localHideHidden") : t("fileManager.localShowHidden"), action: onToggleHidden, }); menuItems.push({ icon: , label: t("fileManager.refresh"), action: onRefresh, shortcut: "F5", }); } const renderShortcut = (shortcut: string) => { const keys = shortcut.split("+"); if (keys.length === 1) return {keys[0]}; return ( {keys.map((key, index) => ( {key} {index < keys.length - 1 && } ))} ); }; if (!isVisible && !isMounted) return null; return ( <>
{menuItems.map((item, index) => { if (item.separator) { return (
); } return ( ); })}
); }