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
@@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -32,6 +33,7 @@ export function InlineCommentCard({
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const draftText = typeof draft.text === 'string' ? draft.text : '';
// Check if content is long enough to warrant collapsing (rough estimate)
@@ -39,18 +41,27 @@ export function InlineCommentCard({
const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3;
return (
<div
className={cn(
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
className
)}
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
}}
data-comment-card="true"
>
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger
render={
<div
className={cn(
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
className
)}
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
}}
data-comment-card="true"
onContextMenu={(event) => {
event.preventDefault();
setIsContextMenuOpen(true);
}}
/>
}
>
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5">
@@ -117,6 +128,17 @@ export function InlineCommentCard({
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Icon name="edit" className="size-4 mr-2" />
{t('inlineComment.actions.editComment')}
</ContextMenuItem>
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="size-4 mr-2" />
{t('inlineComment.actions.deleteComment')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -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}
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
@@ -537,18 +538,37 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
const { t } = useI18n();
const extAgent = agent as Agent & { scope?: AgentScope };
const isMobile = isMobileDeviceViaCSS();
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
{onRename && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
)}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</Item>
{onReset && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onReset(); }}>
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</Item>
)}
{onDelete && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
)}
</>
);
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile ? (e) => { e.preventDefault(); setIsContextMenuOpen(true); } : undefined} />}>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
@@ -574,7 +594,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
)}
</button>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenu open={isMenuOpen} onOpenChange={(open) => { if (open) setIsContextMenuOpen(false); onMenuOpenChange(open); }}>
<DropdownMenuTrigger asChild>
<Button size="sm"
variant="ghost"
@@ -584,55 +604,14 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</DropdownMenuItem>
{onReset && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onReset();
}}
>
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
)}
{renderMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
{renderMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
};
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
@@ -398,17 +399,36 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
{onRename && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</Item>
{onReset && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onReset(); }}>
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</Item>
)}
{onDelete && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
)}
</>
);
return (
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile ? (e) => { e.preventDefault(); setIsContextMenuOpen(true); } : undefined} />}>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
@@ -433,7 +453,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
)}
</button>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenu open={isMenuOpen} onOpenChange={(open) => { if (open) setIsContextMenuOpen(false); onMenuOpenChange(open); }}>
<DropdownMenuTrigger asChild>
<Button size="sm"
variant="ghost"
@@ -443,55 +463,14 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</DropdownMenuItem>
{onReset && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onReset();
}}
>
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
)}
{renderMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
{renderMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
};
@@ -15,6 +15,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
@@ -248,6 +249,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
hasBorder,
}) => {
const { t } = useI18n();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const iconName = ICON_MAP[profile.icon || 'branch'] || 'git-branch';
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
@@ -260,17 +262,43 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
onEdit();
};
return (
<div
className={cn(
'group flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30 cursor-pointer',
hasBorder && 'border-b border-[var(--surface-subtle)]'
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onToggleDefault(); }}>
{isDefault ? t('settings.gitIdentities.page.actions.unsetDefault') : t('settings.gitIdentities.page.actions.setAsDefault')}
</Item>
{!isReadOnly && onDelete && (
<Item
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
)}
onClick={onEdit}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
</>
);
return (
<ContextMenu open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger
render={
<div
className={cn(
'group flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30 cursor-pointer',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
onClick={onEdit}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
onContextMenu={(event) => {
event.preventDefault();
setContextMenuOpen(true);
}}
/>
}
>
<div className="flex items-center gap-3 min-w-0">
<Icon name={iconName} className="w-4 h-4 shrink-0" style={{ color: iconColor }} />
<div className="min-w-0">
@@ -308,21 +336,14 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
{isDefault ? t('settings.gitIdentities.page.actions.unsetDefault') : t('settings.gitIdentities.page.actions.setAsDefault')}
</DropdownMenuItem>
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
)}
{renderMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-28">
{renderMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
};
@@ -23,6 +23,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useI18n } from '@/lib/i18n';
interface McpSidebarProps {
@@ -81,6 +82,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [openMenuMcp, setOpenMenuMcp] = React.useState<string | null>(null);
const [rightClickMenuMcp, setRightClickMenuMcp] = React.useState<string | null>(null);
const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false);
const projectServers = React.useMemo(
@@ -164,6 +166,19 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
setIsDeleting(false);
};
const renderMcpMenuItems = (server: McpServerConfig, Item: React.ElementType) => (
<Item
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
);
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
@@ -219,17 +234,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const isMobile = isMobileDeviceViaCSS();
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
>
<ContextMenu key={server.name} open={rightClickMenuMcp === server.name} onOpenChange={(open) => setRightClickMenuMcp(open ? server.name : null)}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile ? (e) => { e.preventDefault(); setRightClickMenuMcp(server.name); } : undefined} />}>
<button
onClick={() => {
setSelectedMcp(server.name);
@@ -259,26 +265,21 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</div>
</button>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => { if (open) setRightClickMenuMcp(null); setOpenMenuMcp(open ? server.name : null); }}>
<DropdownMenuTrigger asChild>
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
{renderMcpMenuItems(server, DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
{renderMcpMenuItems(server, ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
})}
</>
@@ -296,17 +297,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const isMobile = isMobileDeviceViaCSS();
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
>
<ContextMenu key={server.name} open={rightClickMenuMcp === server.name} onOpenChange={(open) => setRightClickMenuMcp(open ? server.name : null)}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile ? (e) => { e.preventDefault(); setRightClickMenuMcp(server.name); } : undefined} />}>
<button
onClick={() => {
setSelectedMcp(server.name);
@@ -336,26 +328,21 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</div>
</button>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => { if (open) setRightClickMenuMcp(null); setOpenMenuMcp(open ? server.name : null); }}>
<DropdownMenuTrigger asChild>
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
{renderMcpMenuItems(server, DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
{renderMcpMenuItems(server, ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
})}
</>
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
@@ -461,17 +462,26 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</Item>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
</>
);
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<ContextMenu open={!isBuiltIn && isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile && !isBuiltIn ? (e) => { e.preventDefault(); setIsContextMenuOpen(true); } : undefined} />}>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
@@ -489,7 +499,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
</div>
</button>
{!isBuiltIn ? <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
{!isBuiltIn ? <DropdownMenu open={isMenuOpen} onOpenChange={(open) => { if (open) setIsContextMenuOpen(false); onMenuOpenChange(open); }}>
<DropdownMenuTrigger asChild>
<Button size="sm"
variant="ghost"
@@ -499,39 +509,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
{renderMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu> : null}
</div>
</div>
</ContextMenuTrigger>
{!isBuiltIn ? (
<ContextMenuContent className="w-fit min-w-20">
{renderMenuItems(ContextMenuItem)}
</ContextMenuContent>
) : null}
</ContextMenu>
);
};
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -19,6 +20,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
const { t } = useI18n();
const [confirmDeleteSnippet, setConfirmDeleteSnippet] = React.useState<Snippet | null>(null);
const [openMenuName, setOpenMenuName] = React.useState<string | null>(null);
const [rightClickMenuName, setRightClickMenuName] = React.useState<string | null>(null);
const { selectedSnippetName, snippets, setSelectedSnippet, setSnippetDraft, deleteSnippet, loadSnippets } = useSnippetsStore(useShallow((s) => ({
selectedSnippetName: s.selectedSnippetName,
snippets: s.snippets,
@@ -71,7 +73,8 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{sortedSnippets.map((snippet) => (
<div key={`${snippet.source}:${snippet.filePath}`} className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', selectedSnippetName === snippet.name ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')}>
<ContextMenu key={`${snippet.source}:${snippet.filePath}`} open={rightClickMenuName === snippet.name} onOpenChange={(open) => setRightClickMenuName(open ? snippet.name : null)}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', selectedSnippetName === snippet.name ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={(event) => { event.preventDefault(); setRightClickMenuName(snippet.name); }} />}>
<button onClick={() => { setSelectedSnippet(snippet.name); onItemSelect?.(); }} className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50">
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal truncate text-foreground">#{snippet.name}</span>
@@ -81,7 +84,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
{snippet.description || snippet.content.replace(/\s+/g, ' ').substring(0, 80)}
</div>
</button>
<DropdownMenu open={openMenuName === snippet.name} onOpenChange={(open) => setOpenMenuName(open ? snippet.name : null)}>
<DropdownMenu open={openMenuName === snippet.name} onOpenChange={(open) => { if (open) setRightClickMenuName(null); setOpenMenuName(open ? snippet.name : null); }}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" aria-label={t('settings.snippets.sidebar.actions.more', { name: snippet.name })}>
<Icon name="more-2" className="h-3.5 w-3.5" />
@@ -94,7 +97,14 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
<ContextMenuItem onClick={(e) => { e.stopPropagation(); setConfirmDeleteSnippet(snippet); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</ScrollableOverlay>
@@ -1,5 +1,6 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { ContextMenu } from '@base-ui/react/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,6 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
@@ -352,6 +354,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
@@ -602,6 +606,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
: null;
const handleMenuOpenChange = (open: boolean) => {
if (open) {
setIsContextMenuOpen(false);
}
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
};
@@ -614,6 +621,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
};
const handleContextMenuOpenChange = (open: boolean) => {
setIsContextMenuOpen(open);
};
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -706,9 +717,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
};
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
<DropdownMenuItem
const renderSessionMenuItems = ({
Item,
Separator,
Sub,
SubTrigger,
SubContent,
}: {
Item: React.ElementType;
Separator: React.ElementType;
Sub: React.ElementType;
SubTrigger: React.ElementType;
SubContent: React.ElementType;
}) => (
<>
<Item
onClick={() => {
// Defer rename until dropdown close transition completes.
// onOpenChangeComplete fires after animation + focus cleanup are done,
@@ -719,38 +742,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
>
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
</Item>
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</DropdownMenuItem>
</Item>
{!resolvedSession.share ? (
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<Item onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<Icon name="share-2" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.share')}
</DropdownMenuItem>
</Item>
) : (
<>
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
<Item onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id
? <><Icon name="check" className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }}/>{t('sessions.sidebar.session.menu.copied')}</>
: <><Icon name="file-copy" className="mr-1 h-4 w-4" />{t('sessions.sidebar.session.menu.copyLink')}</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
</Item>
<Item onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<Icon name="link-unlink-m" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.unshare')}
</DropdownMenuItem>
</Item>
</>
)}
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<Item onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')}
</DropdownMenuItem>
</Item>
{isMultiRunLikeSession ? (
<DropdownMenuItem onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
<FusionIcon className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.runFusion')}
</DropdownMenuItem>
</Item>
) : null}
{sessionDirectory && !archivedBucket ? (() => {
@@ -758,39 +781,39 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<Separator />
<Sub>
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
<SubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</DropdownMenuItem>
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<span className="flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
</DropdownMenuItem>
</Item>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<Separator />
<Item onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<Icon name="add" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
</Item>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<Icon name="close" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
</Item>
) : null}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
</>
);
})() : null}
{!isVSCode ? (
<DropdownMenuItem
<Item
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
@@ -805,42 +828,108 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="chat-4" className="mr-1 h-4 w-4" />
<span className="truncate">{t('sessions.sidebar.session.menu.openInSidePanel')}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">{t('sessions.sidebar.session.menu.betaBadge')}</span>
</DropdownMenuItem>
</Item>
) : null}
{isElectron ? (
<DropdownMenuItem
<Item
disabled={!sessionDirectory}
onClick={handleOpenMiniChatWindow}
className="[&>svg]:mr-1"
>
<Icon name="window" className="mr-1 h-4 w-4" />
<span className="truncate">{t('sessions.sidebar.session.menu.openMiniChatWindow')}</span>
</DropdownMenuItem>
</Item>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<Separator />
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<Icon name={archivedBucket ? "delete-bin" : "archive"} className="mr-1 h-4 w-4" />
{archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem>
</Item>
</>
);
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
{renderSessionMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
})}
</DropdownMenuContent>
);
const contextMenuContent = (
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
}}
className={cn(dropdownMenuPopupClass, 'min-w-[180px]')}
>
{renderSessionMenuItems({
Item: ({ className, ...itemProps }: React.ComponentProps<typeof ContextMenu.Item>) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...itemProps} />
),
Separator: ({ className, ...separatorProps }: React.ComponentProps<typeof ContextMenu.Separator>) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...separatorProps} />
),
Sub: ContextMenu.SubmenuRoot,
SubTrigger: ({ className, children, ...triggerProps }: React.ComponentProps<typeof ContextMenu.SubmenuTrigger>) => (
<ContextMenu.SubmenuTrigger className={cn(dropdownMenuSubTriggerClass, className)} {...triggerProps}>
{children}
<Icon name="arrow-right-s" className="ml-auto size-3.5" />
</ContextMenu.SubmenuTrigger>
),
SubContent: ({ className, children, ...popupProps }: React.ComponentProps<typeof ContextMenu.Popup>) => (
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-sub-content"
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
}}
className={cn(dropdownMenuPopupClass, className)}
{...popupProps}
>
{children}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
),
})}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
);
return (
<React.Fragment key={session.id}>
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
<div
data-session-row={session.id}
data-session-scope={sessionDirectory ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
className={cn(
'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1',
isMissingDirectory ? 'opacity-75' : '',
depth > 0 && 'pl-[20px]',
isRowSelected && 'bg-primary/15',
)}
>
<ContextMenu.Root open={isContextMenuOpen} onOpenChange={handleContextMenuOpenChange} onOpenChangeComplete={handleMenuOpenChangeComplete}>
<ContextMenu.Trigger
render={
<div
data-session-row={session.id}
data-session-scope={sessionDirectory ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
className={cn(
'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1',
isMissingDirectory ? 'opacity-75' : '',
depth > 0 && 'pl-[20px]',
isRowSelected && 'bg-primary/15',
)}
/>
}
>
{leadingIndicators}
{subsessionChevron}
<div className="flex min-w-0 flex-1 items-center">
@@ -874,7 +963,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
isMenuOpen
isSessionMenuOpen
? 'opacity-0'
: hideOnHoverClass,
)}>
@@ -963,7 +1052,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<div className={cn(
'absolute right-0 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5 transition-opacity',
isMenuOpen
isSessionMenuOpen
? 'opacity-100'
: (alwaysShowActions && !isVSCode)
? 'opacity-100'
@@ -1022,7 +1111,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className={cn(
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMinimalMode && !alwaysShowActions
? (isMenuOpen
? (isSessionMenuOpen
? 'h-4 w-4 opacity-100'
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
@@ -1039,7 +1128,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
{sessionMenuContent}
</DropdownMenu>
</div>
</div>
</ContextMenu.Trigger>
{contextMenuContent}
</ContextMenu.Root>
</DraggableSessionRow>
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
@@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
@@ -89,14 +90,35 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
const suppressNextToggleRef = React.useRef(false);
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
const handleMenuOpenChange = React.useCallback((open: boolean) => {
if (open) setIsContextMenuOpen(false);
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
}, [menuInstanceKey, setOpenSidebarMenuKey]);
const renderProjectMenuItems = (Item: React.ElementType) => (
<>
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<Item onClick={onNewSession}>
<Icon name="add" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.newSession')}
</Item>
)}
<Item onClick={onRenameStart}>
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</Item>
<Item onClick={onClose} className="text-destructive focus:text-destructive">
<Icon name="close" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.closeProject')}
</Item>
</>
);
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
@@ -140,12 +162,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
/>
)}
<div
className={cn(
'w-full text-left group/project select-none',
)}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
>
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger
render={
<div
className={cn('w-full text-left group/project select-none')}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
onContextMenu={(event) => {
event.preventDefault();
setIsContextMenuOpen(true);
}}
/>
}
>
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
<Tooltip>
<TooltipTrigger asChild>
@@ -261,23 +290,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<DropdownMenuItem onClick={onNewSession}>
<Icon name="add" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.newSession')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onRenameStart}>
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Icon name="close" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.closeProject')}
</DropdownMenuItem>
{renderProjectMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -312,7 +325,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</div>
) : null}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderProjectMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
</>
) : null}
@@ -0,0 +1,99 @@
import * as React from "react";
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
import { Icon } from "@/components/icon/Icon";
import { cn } from "@/lib/utils";
import {
dropdownMenuItemClass,
dropdownMenuPopupClass,
dropdownMenuSeparatorClass,
dropdownMenuSubTriggerClass,
} from "./dropdown-menu.styles";
function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) {
return <BaseContextMenu.Root {...props} />;
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof BaseContextMenu.Trigger>) {
return <BaseContextMenu.Trigger {...props} />;
}
type ContentProps = {
className?: string;
positionerClassName?: string;
children?: React.ReactNode;
} & React.ComponentProps<typeof BaseContextMenu.Popup>;
function ContextMenuContent({ className, positionerClassName, children, style, ...props }: ContentProps) {
return (
<BaseContextMenu.Portal>
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}>
<BaseContextMenu.Popup
data-slot="dropdown-menu-content"
style={{
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
...style,
}}
className={cn(dropdownMenuPopupClass, className)}
{...props}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
);
}
function ContextMenuItem({ className, ...props }: React.ComponentProps<typeof BaseContextMenu.Item>) {
return <BaseContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...props} />;
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof BaseContextMenu.Separator>) {
return <BaseContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof BaseContextMenu.SubmenuRoot>) {
return <BaseContextMenu.SubmenuRoot {...props} />;
}
function ContextMenuSubTrigger({ className, children, ...props }: React.ComponentProps<typeof BaseContextMenu.SubmenuTrigger>) {
return (
<BaseContextMenu.SubmenuTrigger className={cn(dropdownMenuSubTriggerClass, className)} {...props}>
{children}
<Icon name="arrow-right-s" className="ml-auto size-3.5" />
</BaseContextMenu.SubmenuTrigger>
);
}
function ContextMenuSubContent({ className, positionerClassName, children, style, ...props }: ContentProps) {
return (
<BaseContextMenu.Portal>
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}>
<BaseContextMenu.Popup
data-slot="dropdown-menu-sub-content"
style={{
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
...style,
}}
className={cn(dropdownMenuPopupClass, className)}
{...props}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
};
@@ -0,0 +1,4 @@
export const dropdownMenuPopupClass = "app-region-no-drag transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 z-50 max-h-[var(--available-height)] min-w-[8rem] origin-[var(--transform-origin)] overflow-visible rounded-xl p-1 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]";
export const dropdownMenuItemClass = "data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover data-[variant=destructive]:text-destructive data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5";
export const dropdownMenuSubTriggerClass = "data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5";
export const dropdownMenuSeparatorClass = "bg-border -mx-1 my-0.5 h-px";
@@ -3,6 +3,7 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { Icon } from "@/components/icon/Icon";
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
type AsChildProps = { asChild?: boolean };
type AsChildRenderProps = {
@@ -134,7 +135,7 @@ function DropdownMenuContent({
...style,
}}
className={cn(
"app-region-no-drag transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 z-50 max-h-[var(--available-height)] min-w-[8rem] origin-[var(--transform-origin)] overflow-visible rounded-xl p-1 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
dropdownMenuPopupClass,
className
)}
{...props}
@@ -177,7 +178,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover data-[variant=destructive]:text-destructive data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
dropdownMenuItemClass,
className
)}
{...props}
@@ -270,7 +271,7 @@ function DropdownMenuSeparator({
return (
<BaseMenu.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-0.5 h-px", className)}
className={cn(dropdownMenuSeparatorClass, className)}
{...props}
/>
)
@@ -311,7 +312,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
dropdownMenuSubTriggerClass,
className
)}
{...props}
@@ -338,7 +339,7 @@ function DropdownMenuSubContent({
color: 'var(--surface-elevated-foreground)',
}}
className={cn(
"transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 z-50 min-w-[8rem] origin-[var(--transform-origin)] overflow-visible rounded-xl p-1 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
dropdownMenuPopupClass,
className
)}
{...props}
+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}
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
@@ -47,6 +48,7 @@ interface AgentGroupItemProps {
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBusy, onSelect }) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
@@ -66,16 +68,38 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
}, [deleteGroupSessions, group.name, group.sessions, isDeleting, t]);
const relativeTime = formatRelativeTime(group.lastActive);
const renderGroupMenuItems = (Item: React.ElementType) => (
<Item
className="text-destructive focus:text-destructive"
onSelect={(e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
setContextMenuOpen(false);
setConfirmOpen(true);
}}
>
{t('agentManager.sidebar.item.delete')}
</Item>
);
return (
<>
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onClick={onSelect}
>
<ContextMenu open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger
render={
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onClick={onSelect}
onContextMenu={(event) => {
event.preventDefault();
setContextMenuOpen(true);
}}
/>
}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
@@ -107,7 +131,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
</button>
<div className="flex items-center gap-1.5 self-stretch">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenu open={menuOpen} onOpenChange={(open) => { if (open) setContextMenuOpen(false); setMenuOpen(open); }}>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -123,21 +147,16 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
e.stopPropagation();
setMenuOpen(false);
setConfirmOpen(true);
}}
>
{t('agentManager.sidebar.item.delete')}
</DropdownMenuItem>
{renderGroupMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[140px]">
{renderGroupMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">