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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user