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