feat: add right-click menus across sidebar rows

Open row actions at the pointer position
Keep three-dot menus working separately
Reuse shared menu styling for consistent visuals
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 15:12:10 +03:00
parent 7a18e79bdb
commit 12c8cbf544
15 changed files with 774 additions and 500 deletions
@@ -16,6 +16,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -136,6 +143,8 @@ interface FileRowProps {
downloadFile?: (path: string) => Promise<void>;
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => void;
rightClickMenuPath: string | null;
setRightClickMenuPath: (path: string | null) => void;
onSelect: (node: FileNode) => void;
onToggle: (path: string) => void;
onRevealPath: (path: string) => void;
@@ -153,6 +162,8 @@ const FileRow: React.FC<FileRowProps> = ({
downloadFile,
contextMenuPath,
setContextMenuPath,
rightClickMenuPath,
setRightClickMenuPath,
onSelect,
onToggle,
onRevealPath,
@@ -165,8 +176,8 @@ const FileRow: React.FC<FileRowProps> = ({
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return;
event?.preventDefault();
setContextMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]);
setRightClickMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]);
const handleInteraction = React.useCallback(() => {
if (isDir) {
@@ -178,8 +189,76 @@ const FileRow: React.FC<FileRowProps> = ({
const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setRightClickMenuPath(null);
setContextMenuPath(node.path);
}, [node.path, setContextMenuPath]);
}, [node.path, setContextMenuPath, setRightClickMenuPath]);
const renderMenuItems = ({
Item,
Separator,
}: {
Item: React.ElementType;
Separator: React.ElementType;
}) => (
<>
{canRename && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<Icon name="edit" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.rename')}
</Item>
)}
<Item onClick={(e: React.MouseEvent) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success(t('sidebarFilesTree.toast.pathCopied'));
return;
}
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<Icon name="file-copy" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.copyPath')}
</Item>
{!isDir && downloadFile && (
<Item onClick={(e: React.MouseEvent) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
</Item>
)}
{canReveal && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</Item>
)}
{isDir && (canCreateFile || canCreateFolder) && (
<>
<Separator />
{canCreateFile && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<Icon name="file-add" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFile')}
</Item>
)}
{canCreateFolder && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<Icon name="folder-add" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFolder')}
</Item>
)}
</>
)}
{canDelete && (
<>
<Separator />
<Item
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.delete')}
</Item>
</>
)}
</>
);
const handleDragStart = React.useCallback((e: React.DragEvent) => {
const path = getRelativePath(root, node.path);
@@ -189,10 +268,8 @@ const FileRow: React.FC<FileRowProps> = ({
}, [node.path, root]);
return (
<div
className="group relative flex items-center"
onContextMenu={handleContextMenu}
>
<ContextMenu open={rightClickMenuPath === node.path} onOpenChange={(open) => setRightClickMenuPath(open ? node.path : null)}>
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={handleContextMenu} />}>
<button
type="button"
onClick={handleInteraction}
@@ -251,67 +328,16 @@ const FileRow: React.FC<FileRowProps> = ({
<TooltipContent side="bottom" sideOffset={6}>{t('sidebarFilesTree.actions.fileMenuTitle')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<Icon name="edit" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success(t('sidebarFilesTree.toast.pathCopied'));
return;
}
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<Icon name="file-copy" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.copyPath')}
</DropdownMenuItem>
{!isDir && downloadFile && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
</DropdownMenuItem>
)}
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
<>
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<Icon name="file-add" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFile')}
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<Icon name="folder-add" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFolder')}
</DropdownMenuItem>
)}
</>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.delete')}
</DropdownMenuItem>
</>
)}
{renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })}
</ContextMenuContent>
</ContextMenu>
);
};
@@ -357,6 +383,7 @@ export const SidebarFilesTree: React.FC = () => {
// Context menu state
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [rightClickMenuPath, setRightClickMenuPath] = React.useState<string | null>(null);
// Dialog state for CRUD operations
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
@@ -801,6 +828,8 @@ export const SidebarFilesTree: React.FC = () => {
downloadFile={files.downloadFile}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
rightClickMenuPath={rightClickMenuPath}
setRightClickMenuPath={setRightClickMenuPath}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}