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
+122 -93
View File
@@ -10,7 +10,14 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} 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';
@@ -373,8 +380,10 @@ interface FileRowProps {
canReveal: boolean;
};
downloadFile?: (path: string) => Promise<void>;
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => 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;
@@ -392,8 +401,10 @@ const FileRow: React.FC<FileRowProps> = ({
badge,
permissions,
downloadFile,
contextMenuPath,
setContextMenuPath,
contextMenuPath,
setContextMenuPath,
rightClickMenuPath,
setRightClickMenuPath,
onSelect,
onToggle,
onRevealPath,
@@ -406,10 +417,10 @@ 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]);
}
event?.preventDefault();
setRightClickMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]);
const handleInteraction = React.useCallback(() => {
if (isDir) {
@@ -420,15 +431,94 @@ const FileRow: React.FC<FileRowProps> = ({
}, [isDir, node, onSelect, onToggle]);
const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setContextMenuPath(node.path);
}, [node.path, setContextMenuPath]);
event.stopPropagation();
setRightClickMenuPath(null);
setContextMenuPath(node.path);
}, [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 size-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 size-4" /> {t('sidebarFilesTree.menu.copyPath')}
</Item>
<Item onClick={(e: React.MouseEvent) => {
e.stopPropagation();
const relativePath = getDisplayPath(root, node.path) || node.path;
void copyTextToClipboard(relativePath).then((result) => {
if (result.ok) {
toast.success(t('filesView.toast.relativePathCopied'));
return;
}
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<Icon name="file-copy-2" className="mr-2 size-4" /> {t('filesView.tree.menu.copyRelativePath')}
</Item>
{!isDir && downloadFile && (
<Item onClick={(e: React.MouseEvent) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
</Item>
)}
{canReveal && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 size-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 size-4" /> {t('sidebarFilesTree.menu.newFile')}
</Item>
)}
{canCreateFolder && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<Icon name="folder-add" className="mr-2 size-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 size-4" /> {t('sidebarFilesTree.menu.delete')}
</Item>
</>
)}
</>
);
return (
<div
className="group relative flex items-center"
onContextMenu={!isMobile ? handleContextMenu : undefined}
>
<ContextMenu open={rightClickMenuPath === node.path} onOpenChange={(open) => setRightClickMenuPath(open ? node.path : null)}>
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={!isMobile ? handleContextMenu : undefined} />}>
<button
type="button"
onClick={handleInteraction}
@@ -480,83 +570,19 @@ const FileRow: React.FC<FileRowProps> = ({
<Icon name="more-2-fill" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side={isMobile ? "bottom" : "bottom"} onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<Icon name="edit" className="mr-2 size-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 size-4" /> {t('sidebarFilesTree.menu.copyPath')}
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
const relativePath = getDisplayPath(root, node.path) || node.path;
void copyTextToClipboard(relativePath).then((result) => {
if (result.ok) {
toast.success(t('filesView.toast.relativePathCopied'));
return;
}
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<Icon name="file-copy-2" className="mr-2 size-4" /> {t('filesView.tree.menu.copyRelativePath')}
</DropdownMenuItem>
{!isDir && downloadFile && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
</DropdownMenuItem>
)}
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<Icon name="folder-received" className="mr-2 size-4" /> {t(getRevealLabelKey())}
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
<>
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<Icon name="file-add" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.newFile')}
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<Icon name="folder-add" className="mr-2 size-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 size-4" /> {t('sidebarFilesTree.menu.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
<DropdownMenuContent align="end" side={isMobile ? "bottom" : "bottom"} onCloseAutoFocus={() => setContextMenuPath(null)}>
{renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
};
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })}
</ContextMenuContent>
</ContextMenu>
);
};
interface DialogsProps {
activeDialog: 'createFile' | 'createFolder' | 'rename' | 'delete' | null;
@@ -840,7 +866,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
const [dialogInputValue, setDialogInputValue] = React.useState('');
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [rightClickMenuPath, setRightClickMenuPath] = React.useState<string | null>(null);
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
@@ -2122,8 +2149,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
badge={isDir ? getFolderBadge(node.path) : undefined}
permissions={fileRowPermissions}
downloadFile={files.downloadFile}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
rightClickMenuPath={rightClickMenuPath}
setRightClickMenuPath={setRightClickMenuPath}
onSelect={handleSelectFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}