2026-05-12 21:55:14 -05:00
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
2025-12-31 22:20:12 -06:00
|
|
|
import { cn } from "@/lib/utils.ts";
|
2025-10-01 15:40:10 -05:00
|
|
|
import {
|
|
|
|
|
Download,
|
|
|
|
|
Edit3,
|
|
|
|
|
Copy,
|
|
|
|
|
Scissors,
|
|
|
|
|
Trash2,
|
|
|
|
|
Info,
|
|
|
|
|
Upload,
|
|
|
|
|
FolderPlus,
|
|
|
|
|
FilePlus,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Clipboard,
|
|
|
|
|
Eye,
|
|
|
|
|
Terminal,
|
|
|
|
|
Play,
|
|
|
|
|
Star,
|
|
|
|
|
Bookmark,
|
2025-11-17 09:46:05 -06:00
|
|
|
FileArchive,
|
2025-10-01 15:40:10 -05:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-05-11 01:23:11 -05:00
|
|
|
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
2025-10-01 15:40:10 -05:00
|
|
|
|
|
|
|
|
interface FileItem {
|
|
|
|
|
name: string;
|
|
|
|
|
type: "file" | "directory" | "link";
|
|
|
|
|
path: string;
|
|
|
|
|
size?: number;
|
|
|
|
|
modified?: string;
|
|
|
|
|
permissions?: string;
|
|
|
|
|
owner?: string;
|
|
|
|
|
group?: string;
|
|
|
|
|
executable?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ContextMenuProps {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
files: FileItem[];
|
|
|
|
|
isVisible: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onDownload?: (files: FileItem[]) => void;
|
|
|
|
|
onRename?: (file: FileItem) => void;
|
|
|
|
|
onCopy?: (files: FileItem[]) => void;
|
|
|
|
|
onCut?: (files: FileItem[]) => void;
|
|
|
|
|
onDelete?: (files: FileItem[]) => void;
|
|
|
|
|
onProperties?: (file: FileItem) => void;
|
|
|
|
|
onUpload?: () => void;
|
|
|
|
|
onNewFolder?: () => void;
|
|
|
|
|
onNewFile?: () => void;
|
|
|
|
|
onRefresh?: () => void;
|
|
|
|
|
onPaste?: () => void;
|
|
|
|
|
onPreview?: (file: FileItem) => void;
|
|
|
|
|
hasClipboard?: boolean;
|
|
|
|
|
onDragToDesktop?: () => void;
|
|
|
|
|
onOpenTerminal?: (path: string) => void;
|
|
|
|
|
onRunExecutable?: (file: FileItem) => void;
|
|
|
|
|
onPinFile?: (file: FileItem) => void;
|
|
|
|
|
onUnpinFile?: (file: FileItem) => void;
|
|
|
|
|
onAddShortcut?: (path: string) => void;
|
|
|
|
|
isPinned?: (file: FileItem) => boolean;
|
|
|
|
|
currentPath?: string;
|
2025-11-17 09:46:05 -06:00
|
|
|
onExtractArchive?: (file: FileItem) => void;
|
|
|
|
|
onCompress?: (files: FileItem[]) => void;
|
|
|
|
|
onCopyPath?: (files: FileItem[]) => void;
|
2025-10-01 15:40:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MenuItem {
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
label: string;
|
|
|
|
|
action: () => void;
|
|
|
|
|
shortcut?: string;
|
|
|
|
|
separator?: boolean;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
danger?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 21:55:14 -05:00
|
|
|
const VIEWPORT_PADDING = 10;
|
|
|
|
|
|
|
|
|
|
function getClampedMenuPosition(
|
|
|
|
|
x: number,
|
|
|
|
|
y: number,
|
|
|
|
|
menuWidth: number,
|
|
|
|
|
menuHeight: number,
|
|
|
|
|
) {
|
|
|
|
|
const maxX = Math.max(
|
|
|
|
|
VIEWPORT_PADDING,
|
|
|
|
|
window.innerWidth - menuWidth - VIEWPORT_PADDING,
|
|
|
|
|
);
|
|
|
|
|
const maxY = Math.max(
|
|
|
|
|
VIEWPORT_PADDING,
|
|
|
|
|
window.innerHeight - menuHeight - VIEWPORT_PADDING,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX),
|
|
|
|
|
y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 15:40:10 -05:00
|
|
|
export function FileManagerContextMenu({
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
files,
|
|
|
|
|
isVisible,
|
|
|
|
|
onClose,
|
|
|
|
|
onDownload,
|
|
|
|
|
onRename,
|
|
|
|
|
onCopy,
|
|
|
|
|
onCut,
|
|
|
|
|
onDelete,
|
|
|
|
|
onProperties,
|
|
|
|
|
onUpload,
|
|
|
|
|
onNewFolder,
|
|
|
|
|
onNewFile,
|
|
|
|
|
onRefresh,
|
|
|
|
|
onPaste,
|
|
|
|
|
onPreview,
|
|
|
|
|
hasClipboard = false,
|
|
|
|
|
onDragToDesktop,
|
|
|
|
|
onOpenTerminal,
|
|
|
|
|
onRunExecutable,
|
|
|
|
|
onPinFile,
|
|
|
|
|
onUnpinFile,
|
|
|
|
|
onAddShortcut,
|
|
|
|
|
isPinned,
|
|
|
|
|
currentPath,
|
2025-11-17 09:46:05 -06:00
|
|
|
onExtractArchive,
|
|
|
|
|
onCompress,
|
|
|
|
|
onCopyPath,
|
2025-10-01 15:40:10 -05:00
|
|
|
}: ContextMenuProps) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-05-12 21:55:14 -05:00
|
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
2025-10-01 15:40:10 -05:00
|
|
|
const [menuPosition, setMenuPosition] = useState({ x, y });
|
2025-11-17 09:46:05 -06:00
|
|
|
const [isMounted, setIsMounted] = useState(false);
|
2025-10-01 15:40:10 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-11-17 09:46:05 -06:00
|
|
|
if (!isVisible) {
|
|
|
|
|
setIsMounted(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsMounted(true);
|
2025-10-01 15:40:10 -05:00
|
|
|
|
|
|
|
|
const adjustPosition = () => {
|
2026-05-12 21:55:14 -05:00
|
|
|
const menuWidth = menuRef.current?.offsetWidth ?? 260;
|
|
|
|
|
const menuHeight = menuRef.current?.offsetHeight ?? 400;
|
|
|
|
|
setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight));
|
2025-10-01 15:40:10 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
adjustPosition();
|
|
|
|
|
|
|
|
|
|
let cleanupFn: (() => void) | null = null;
|
|
|
|
|
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
|
|
|
const target = event.target as Element;
|
|
|
|
|
const menuElement = document.querySelector("[data-context-menu]");
|
|
|
|
|
|
|
|
|
|
if (!menuElement?.contains(target)) {
|
|
|
|
|
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);
|
|
|
|
|
if (cleanupFn) {
|
|
|
|
|
cleanupFn();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [isVisible, x, y, onClose]);
|
|
|
|
|
|
|
|
|
|
const isFileContext = files.length > 0;
|
|
|
|
|
const isSingleFile = files.length === 1;
|
|
|
|
|
const isMultipleFiles = files.length > 1;
|
|
|
|
|
const hasFiles = files.some((f) => f.type === "file");
|
|
|
|
|
const hasExecutableFiles = files.some(
|
|
|
|
|
(f) => f.type === "file" && f.executable,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const menuItems: MenuItem[] = [];
|
|
|
|
|
|
|
|
|
|
if (isFileContext) {
|
|
|
|
|
if (onOpenTerminal) {
|
|
|
|
|
const targetPath = isSingleFile
|
|
|
|
|
? files[0].type === "directory"
|
|
|
|
|
? files[0].path
|
|
|
|
|
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
|
|
|
|
|
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
|
|
|
|
|
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Terminal className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label:
|
|
|
|
|
files[0].type === "directory"
|
|
|
|
|
? t("fileManager.openTerminalInFolder")
|
|
|
|
|
: t("fileManager.openTerminalInFileLocation"),
|
|
|
|
|
action: () => onOpenTerminal(targetPath),
|
|
|
|
|
shortcut: "Ctrl+Shift+T",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Play className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.run"),
|
|
|
|
|
action: () => onRunExecutable(files[0]),
|
|
|
|
|
shortcut: "Enter",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
onOpenTerminal ||
|
|
|
|
|
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
|
|
|
|
) {
|
|
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasFiles && onPreview) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Eye className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.preview"),
|
|
|
|
|
action: () => onPreview(files[0]),
|
|
|
|
|
disabled: !isSingleFile || files[0].type !== "file",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasFiles && onDownload) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Download className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.downloadFiles", { count: files.length })
|
|
|
|
|
: t("fileManager.downloadFile"),
|
|
|
|
|
action: () => onDownload(files),
|
|
|
|
|
shortcut: "Ctrl+D",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 09:46:05 -06:00
|
|
|
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
|
|
|
|
const fileName = files[0].name.toLowerCase();
|
|
|
|
|
const isArchive =
|
|
|
|
|
fileName.endsWith(".zip") ||
|
|
|
|
|
fileName.endsWith(".tar") ||
|
|
|
|
|
fileName.endsWith(".tar.gz") ||
|
|
|
|
|
fileName.endsWith(".tgz") ||
|
|
|
|
|
fileName.endsWith(".tar.bz2") ||
|
|
|
|
|
fileName.endsWith(".tbz2") ||
|
|
|
|
|
fileName.endsWith(".tar.xz") ||
|
|
|
|
|
fileName.endsWith(".gz") ||
|
|
|
|
|
fileName.endsWith(".bz2") ||
|
|
|
|
|
fileName.endsWith(".xz") ||
|
|
|
|
|
fileName.endsWith(".7z") ||
|
|
|
|
|
fileName.endsWith(".rar");
|
|
|
|
|
|
|
|
|
|
if (isArchive) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <FileArchive className="size-3.5" />,
|
2025-11-17 09:46:05 -06:00
|
|
|
label: t("fileManager.extractArchive"),
|
|
|
|
|
action: () => onExtractArchive(files[0]),
|
|
|
|
|
shortcut: "Ctrl+E",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isFileContext && onCompress) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <FileArchive className="size-3.5" />,
|
2025-11-17 09:46:05 -06:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.compressFiles")
|
|
|
|
|
: t("fileManager.compressFile"),
|
|
|
|
|
action: () => onCompress(files),
|
|
|
|
|
shortcut: "Ctrl+Shift+C",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 15:40:10 -05:00
|
|
|
if (isSingleFile && files[0].type === "file") {
|
|
|
|
|
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
|
|
|
|
|
|
|
|
|
if (isCurrentlyPinned && onUnpinFile) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: (
|
|
|
|
|
<Star className="size-3.5 fill-accent-brand text-accent-brand" />
|
|
|
|
|
),
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.unpinFile"),
|
|
|
|
|
action: () => onUnpinFile(files[0]),
|
|
|
|
|
});
|
|
|
|
|
} else if (!isCurrentlyPinned && onPinFile) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Star className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.pinFile"),
|
|
|
|
|
action: () => onPinFile(files[0]),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Bookmark className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.addToShortcuts"),
|
|
|
|
|
action: () => onAddShortcut(files[0].path),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(hasFiles && (onPreview || onDragToDesktop)) ||
|
|
|
|
|
(isSingleFile &&
|
|
|
|
|
files[0].type === "file" &&
|
|
|
|
|
(onPinFile || onUnpinFile)) ||
|
|
|
|
|
(isSingleFile && files[0].type === "directory" && onAddShortcut)
|
|
|
|
|
) {
|
|
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSingleFile && onRename) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Edit3 className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.rename"),
|
|
|
|
|
action: () => onRename(files[0]),
|
|
|
|
|
shortcut: "F6",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onCopy) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Copy className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.copyFiles", { count: files.length })
|
|
|
|
|
: t("fileManager.copy"),
|
|
|
|
|
action: () => onCopy(files),
|
|
|
|
|
shortcut: "Ctrl+C",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onCut) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Scissors className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.cutFiles", { count: files.length })
|
|
|
|
|
: t("fileManager.cut"),
|
|
|
|
|
action: () => onCut(files),
|
|
|
|
|
shortcut: "Ctrl+X",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 09:46:05 -06:00
|
|
|
if (onCopyPath) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Clipboard className="size-3.5" />,
|
2025-11-17 09:46:05 -06:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.copyPaths")
|
|
|
|
|
: t("fileManager.copyPath"),
|
|
|
|
|
action: () => onCopyPath(files),
|
|
|
|
|
shortcut: "Ctrl+Shift+P",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
|
|
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSingleFile && onProperties) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Info className="size-3.5" />,
|
2025-11-17 09:46:05 -06:00
|
|
|
label: t("fileManager.properties"),
|
|
|
|
|
action: () => onProperties(files[0]),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((isSingleFile && onProperties) || onDelete) {
|
2025-10-01 15:40:10 -05:00
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onDelete) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Trash2 className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: isMultipleFiles
|
|
|
|
|
? t("fileManager.deleteFiles", { count: files.length })
|
|
|
|
|
: t("fileManager.delete"),
|
|
|
|
|
action: () => onDelete(files),
|
|
|
|
|
shortcut: "Delete",
|
|
|
|
|
danger: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (onOpenTerminal && currentPath) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Terminal className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.openTerminalHere"),
|
|
|
|
|
action: () => onOpenTerminal(currentPath),
|
|
|
|
|
shortcut: "Ctrl+Shift+T",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onUpload) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Upload className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.uploadFile"),
|
|
|
|
|
action: onUpload,
|
|
|
|
|
shortcut: "Ctrl+U",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((onOpenTerminal && currentPath) || onUpload) {
|
|
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onNewFolder) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <FolderPlus className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.newFolder"),
|
|
|
|
|
action: onNewFolder,
|
|
|
|
|
shortcut: "Ctrl+Shift+N",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onNewFile) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <FilePlus className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.newFile"),
|
|
|
|
|
action: onNewFile,
|
|
|
|
|
shortcut: "Ctrl+N",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onNewFolder || onNewFile) {
|
|
|
|
|
menuItems.push({ separator: true } as MenuItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onRefresh) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <RefreshCw className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.refresh"),
|
|
|
|
|
action: onRefresh,
|
|
|
|
|
shortcut: "Ctrl+Y",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasClipboard && onPaste) {
|
|
|
|
|
menuItems.push({
|
2026-05-11 01:23:11 -05:00
|
|
|
icon: <Clipboard className="size-3.5" />,
|
2025-10-01 15:40:10 -05:00
|
|
|
label: t("fileManager.paste"),
|
|
|
|
|
action: onPaste,
|
|
|
|
|
shortcut: "Ctrl+V",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredMenuItems = menuItems.filter((item, index) => {
|
|
|
|
|
if (!item.separator) return true;
|
|
|
|
|
|
|
|
|
|
const prevItem = index > 0 ? menuItems[index - 1] : null;
|
|
|
|
|
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
|
|
|
|
|
|
|
|
|
|
if (prevItem?.separator || nextItem?.separator) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
|
|
|
|
if (!item.separator) return true;
|
|
|
|
|
return index > 0 && index < filteredMenuItems.length - 1;
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 09:46:05 -06:00
|
|
|
const renderShortcut = (shortcut: string) => {
|
|
|
|
|
const keys = shortcut.split("+");
|
|
|
|
|
if (keys.length === 1) {
|
|
|
|
|
return <Kbd>{keys[0]}</Kbd>;
|
|
|
|
|
}
|
|
|
|
|
return (
|
2026-05-06 15:12:07 -05:00
|
|
|
<Kbd>
|
2025-11-17 09:46:05 -06:00
|
|
|
{keys.map((key, index) => (
|
2026-05-06 15:12:07 -05:00
|
|
|
<>
|
|
|
|
|
<KbdKey key={`key-${index}`}>{key}</KbdKey>
|
|
|
|
|
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
|
|
|
|
|
</>
|
2025-11-17 09:46:05 -06:00
|
|
|
))}
|
2026-05-06 15:12:07 -05:00
|
|
|
</Kbd>
|
2025-11-17 09:46:05 -06:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!isVisible && !isMounted) return null;
|
|
|
|
|
|
2025-10-01 15:40:10 -05:00
|
|
|
return (
|
|
|
|
|
<>
|
2025-11-17 09:46:05 -06:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
|
|
|
|
!isMounted && "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-10-01 15:40:10 -05:00
|
|
|
|
|
|
|
|
<div
|
2026-05-12 21:55:14 -05:00
|
|
|
ref={menuRef}
|
2025-10-01 15:40:10 -05:00
|
|
|
data-context-menu
|
2026-05-11 01:23:11 -05:00
|
|
|
<<<<<<< HEAD:src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx
|
2025-11-17 09:46:05 -06:00
|
|
|
className={cn(
|
2026-05-12 21:55:14 -05:00
|
|
|
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto",
|
2025-11-17 09:46:05 -06:00
|
|
|
)}
|
2025-10-01 15:40:10 -05:00
|
|
|
style={{
|
|
|
|
|
left: menuPosition.x,
|
|
|
|
|
top: menuPosition.y,
|
2026-05-12 21:55:14 -05:00
|
|
|
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
|
2025-10-01 15:40:10 -05:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{finalMenuItems.map((item, index) => {
|
|
|
|
|
if (item.separator) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={`separator-${index}`}
|
2026-05-11 01:23:11 -05:00
|
|
|
className="border-t border-border"
|
2025-10-01 15:40:10 -05:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={index}
|
|
|
|
|
className={cn(
|
2026-05-11 01:23:11 -05:00
|
|
|
"w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center justify-between",
|
|
|
|
|
"hover:bg-accent-brand/10 hover:text-accent-brand transition-colors cursor-pointer rounded-none",
|
|
|
|
|
item.disabled &&
|
|
|
|
|
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current",
|
|
|
|
|
item.danger &&
|
|
|
|
|
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
2025-10-01 15:40:10 -05:00
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!item.disabled) {
|
|
|
|
|
item.action();
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={item.disabled}
|
|
|
|
|
>
|
2026-05-11 01:23:11 -05:00
|
|
|
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
2025-10-01 15:40:10 -05:00
|
|
|
<div className="flex-shrink-0">{item.icon}</div>
|
2026-05-11 01:23:11 -05:00
|
|
|
<span className="flex-1 truncate">{item.label}</span>
|
2025-10-01 15:40:10 -05:00
|
|
|
</div>
|
|
|
|
|
{item.shortcut && (
|
2025-11-17 09:46:05 -06:00
|
|
|
<div className="ml-2 flex-shrink-0">
|
|
|
|
|
{renderShortcut(item.shortcut)}
|
|
|
|
|
</div>
|
2025-10-01 15:40:10 -05:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|