fix: context menu

This commit is contained in:
yyh 2026-03-24 21:04:13 +08:00
parent 60e46854c6
commit 432e355dc4
No known key found for this signature in database
4 changed files with 222 additions and 189 deletions

View File

@ -5,6 +5,8 @@ import TreeContextMenu from './tree-context-menu'
const mocks = vi.hoisted(() => ({
clearSelection: vi.fn(),
deselectAll: vi.fn(),
getNode: vi.fn(),
selectNode: vi.fn(),
fileOperations: {
fileInputRef: { current: null },
folderInputRef: { current: null },
@ -73,6 +75,7 @@ vi.mock('./node-menu', () => ({
describe('TreeContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fileOperations.showDeleteConfirm = false
})
describe('Rendering', () => {
@ -90,8 +93,13 @@ describe('TreeContextMenu', () => {
describe('Interactions', () => {
it('should clear selection and open root menu when blank area is right clicked', () => {
render(
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>
<div>blank area</div>
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll, get: mocks.getNode } as never }}>
<div>
<div data-skill-tree-node-id="file-1" data-skill-tree-node-type="file" role="treeitem">
readme.md
</div>
<div>blank area</div>
</div>
</TreeContextMenu>,
)
@ -103,6 +111,30 @@ describe('TreeContextMenu', () => {
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', ROOT_ID)
})
it('should switch to item menu when a tree node is right clicked', () => {
mocks.getNode.mockReturnValue({ select: mocks.selectNode })
render(
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll, get: mocks.getNode } as never }}>
<div>
<div data-skill-tree-node-id="file-1" data-skill-tree-node-type="file" role="treeitem">
readme.md
</div>
<div>blank area</div>
</div>
</TreeContextMenu>,
)
fireEvent.contextMenu(screen.getByRole('treeitem'))
expect(mocks.getNode).toHaveBeenCalledWith('file-1')
expect(mocks.selectNode).toHaveBeenCalledTimes(1)
expect(mocks.clearSelection).not.toHaveBeenCalled()
expect(mocks.deselectAll).not.toHaveBeenCalled()
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'file')
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', 'file-1')
})
it('should keep import modal mounted after root menu requests it', () => {
render(
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>
@ -118,5 +150,22 @@ describe('TreeContextMenu', () => {
fireEvent.click(screen.getByText(/close-import-modal/i))
expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument()
})
it('should keep delete confirmation dialog mounted for item context actions', () => {
mocks.fileOperations.showDeleteConfirm = true
render(
<TreeContextMenu treeRef={{ current: { get: mocks.getNode } as never }}>
<div data-skill-tree-node-id="file-1" data-skill-tree-node-type="file" role="treeitem">
readme.md
</div>
</TreeContextMenu>,
)
fireEvent.contextMenu(screen.getByRole('treeitem'))
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
})
})
})

View File

@ -4,6 +4,16 @@ import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
ContextMenu,
ContextMenuContent,
@ -28,33 +38,68 @@ type TreeContextMenuProps = Omit<
children: React.ReactNode
}
type MenuTarget = {
nodeId: string
type: typeof NODE_MENU_TYPE.ROOT | typeof NODE_MENU_TYPE.FOLDER | typeof NODE_MENU_TYPE.FILE
}
const defaultMenuTarget: MenuTarget = {
nodeId: ROOT_ID,
type: NODE_MENU_TYPE.ROOT,
}
const TreeContextMenu = ({
treeRef,
triggerRef,
children,
...props
}: TreeContextMenuProps) => {
const { t } = useTranslation('workflow')
const storeApi = useWorkflowStore()
const [menuTarget, setMenuTarget] = useState<MenuTarget>(defaultMenuTarget)
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const handleContextMenu = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement
if (target.closest('[role="treeitem"]'))
const nodeElement = target.closest<HTMLElement>('[data-skill-tree-node-id]')
if (!nodeElement) {
setMenuTarget(defaultMenuTarget)
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
return
}
const nodeId = nodeElement.dataset.skillTreeNodeId
const nodeType = nodeElement.dataset.skillTreeNodeType
if (!nodeId || (nodeType !== NODE_MENU_TYPE.FILE && nodeType !== NODE_MENU_TYPE.FOLDER))
return
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
treeRef.current?.get(nodeId)?.select()
setMenuTarget({
nodeId,
type: nodeType,
})
}, [storeApi, treeRef])
const handleMenuClose = React.useCallback(() => {}, [])
const fileOperations = useFileOperations({
nodeId: ROOT_ID,
nodeId: menuTarget.nodeId,
treeRef,
onClose: handleMenuClose,
})
const handleOpenImportSkills = React.useCallback(() => {
setIsImportModalOpen(true)
}, [])
const isRootTarget = menuTarget.type === NODE_MENU_TYPE.ROOT
const isFolderTarget = menuTarget.type === NODE_MENU_TYPE.FOLDER
const deleteConfirmTitle = isFolderTarget
? t('skillSidebar.menu.deleteConfirmTitle')
: t('skillSidebar.menu.fileDeleteConfirmTitle')
const deleteConfirmContent = isFolderTarget
? t('skillSidebar.menu.deleteConfirmContent')
: t('skillSidebar.menu.fileDeleteConfirmContent')
return (
<>
@ -69,8 +114,8 @@ const TreeContextMenu = ({
<ContextMenuContent popupClassName="min-w-[180px]">
<NodeMenu
menuType="context"
type={NODE_MENU_TYPE.ROOT}
nodeId={ROOT_ID}
type={menuTarget.type}
nodeId={menuTarget.nodeId}
onClose={handleMenuClose}
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}
@ -82,10 +127,43 @@ const TreeContextMenu = ({
onFolderChange={fileOperations.handleFolderChange}
onRename={fileOperations.handleRename}
onDeleteClick={fileOperations.handleDeleteClick}
onImportSkills={handleOpenImportSkills}
onImportSkills={isRootTarget ? handleOpenImportSkills : undefined}
/>
</ContextMenuContent>
</ContextMenu>
{!isRootTarget && (
<AlertDialog
open={fileOperations.showDeleteConfirm}
onOpenChange={(open) => {
if (!open)
fileOperations.handleDeleteCancel()
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{deleteConfirmTitle}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-secondary system-sm-regular">
{deleteConfirmContent}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={fileOperations.isDeleting}
onClick={() => {
void fileOperations.handleDeleteConfirm()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
<ImportSkillModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}

View File

@ -241,17 +241,17 @@ describe('TreeNode', () => {
expect(handlerMocks.handleDoubleClick).toHaveBeenCalled()
})
it('should call keyboard handler and open context menu on tree item right click', () => {
it('should call keyboard handler and expose node metadata for the shared context menu host', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
const treeItem = screen.getByRole('treeitem')
fireEvent.keyDown(treeItem, { key: 'Enter' })
fireEvent.contextMenu(treeItem)
expect(handlerMocks.handleKeyDown).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'file')
expect(treeItem).toHaveAttribute('data-skill-tree-node-id', 'file-1')
expect(treeItem).toHaveAttribute('data-skill-tree-node-type', 'file')
})
it('should attach folder drag handlers only when node is a folder', () => {
@ -344,22 +344,4 @@ describe('TreeNode', () => {
expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith(null)
})
})
describe('Dialogs', () => {
it('should keep delete confirmation dialog mounted when requested by menu actions', () => {
fileOperationMocks.showDeleteConfirm = true
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
expect(fileOperationMocks.handleDeleteConfirm).toHaveBeenCalledTimes(1)
expect(fileOperationMocks.handleDeleteCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -5,20 +5,6 @@ import type { TreeNodeData } from '../../type'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
@ -26,6 +12,7 @@ import {
} from '@/app/components/base/ui/dropdown-menu'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { NODE_MENU_TYPE } from '../../constants'
import { useFolderFileDrop } from '../../hooks/file-tree/dnd/use-folder-file-drop'
import { useTreeNodeHandlers } from '../../hooks/file-tree/interaction/use-tree-node-handlers'
import { useFileOperations } from '../../hooks/file-tree/operations/use-file-operations'
@ -95,129 +82,97 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
e.stopPropagation()
}, [])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
node.select()
}, [node])
const handleMenuClose = useCallback(() => {}, [])
const fileOperations = useFileOperations({
nodeId: node.data.id,
node,
onClose: handleMenuClose,
})
const deleteConfirmTitle = isFolder
? t('skillSidebar.menu.deleteConfirmTitle')
: t('skillSidebar.menu.fileDeleteConfirmTitle')
const deleteConfirmContent = isFolder
? t('skillSidebar.menu.deleteConfirmContent')
: t('skillSidebar.menu.fileDeleteConfirmContent')
return (
<>
<ContextMenu>
<ContextMenuTrigger
ref={dragHandle}
style={style}
role="treeitem"
tabIndex={0}
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
className={cn(
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
'hover:bg-state-base-hover data-[popup-open]:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
isBlinking && 'animate-drag-blink',
(isCut || node.isDragging) && 'opacity-50',
)}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
{...(isFolder && {
onDragEnter: dragHandlers.onDragEnter,
onDragOver: dragHandlers.onDragOver,
onDrop: dragHandlers.onDrop,
onDragLeave: dragHandlers.onDragLeave,
})}
>
<TreeGuideLines level={node.level} />
<div
className="flex min-w-0 flex-1 items-center gap-2"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div className="flex size-5 shrink-0 items-center justify-center">
<TreeNodeIcon
isFolder={isFolder}
isOpen={node.isOpen}
fileName={node.data.name}
extension={node.data.extension}
isDirty={isDirty}
onToggle={handleToggle}
/>
</div>
<div
ref={dragHandle}
style={style}
role="treeitem"
tabIndex={0}
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
data-skill-tree-node-id={node.data.id}
data-skill-tree-node-type={isFolder ? NODE_MENU_TYPE.FOLDER : NODE_MENU_TYPE.FILE}
className={cn(
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
isBlinking && 'animate-drag-blink',
(isCut || node.isDragging) && 'opacity-50',
)}
onKeyDown={handleKeyDown}
{...(isFolder && {
onDragEnter: dragHandlers.onDragEnter,
onDragOver: dragHandlers.onDragOver,
onDrop: dragHandlers.onDrop,
onDragLeave: dragHandlers.onDragLeave,
})}
>
<TreeGuideLines level={node.level} />
<div
className="flex min-w-0 flex-1 items-center gap-2"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div className="flex size-5 shrink-0 items-center justify-center">
<TreeNodeIcon
isFolder={isFolder}
isOpen={node.isOpen}
fileName={node.data.name}
extension={node.data.extension}
isDirty={isDirty}
onToggle={handleToggle}
/>
</div>
{node.isEditing
? (
<TreeEditInput node={node} />
)
: (
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
>
{node.data.name}
</span>
{node.isEditing
? (
<TreeEditInput node={node} />
)
: (
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
</div>
>
{node.data.name}
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
type="button"
aria-label={t('skillSidebar.menu.moreActions')}
tabIndex={-1}
onClick={handleMoreClick}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded',
'invisible focus-visible:visible group-hover:visible data-[popup-open]:visible',
'hover:bg-state-base-hover-alt data-[popup-open]:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
)}
>
<span className="i-ri-more-fill size-4 text-text-tertiary" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="min-w-[180px]"
>
<NodeMenu
menuType="dropdown"
type={isFolder ? 'folder' : 'file'}
nodeId={node.data.id}
onClose={handleMenuClose}
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}
isLoading={fileOperations.isLoading}
onDownload={fileOperations.handleDownload}
onNewFile={fileOperations.handleNewFile}
onNewFolder={fileOperations.handleNewFolder}
onFileChange={fileOperations.handleFileChange}
onFolderChange={fileOperations.handleFolderChange}
onRename={fileOperations.handleRename}
onDeleteClick={fileOperations.handleDeleteClick}
/>
</DropdownMenuContent>
</DropdownMenu>
</ContextMenuTrigger>
<ContextMenuContent popupClassName="min-w-[180px]">
<DropdownMenu>
<DropdownMenuTrigger
type="button"
aria-label={t('skillSidebar.menu.moreActions')}
tabIndex={-1}
onClick={handleMoreClick}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded',
'invisible focus-visible:visible group-hover:visible data-[popup-open]:visible',
'hover:bg-state-base-hover-alt data-[popup-open]:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
)}
>
<span className="i-ri-more-fill size-4 text-text-tertiary" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="min-w-[180px]"
>
<NodeMenu
menuType="context"
menuType="dropdown"
type={isFolder ? 'folder' : 'file'}
nodeId={node.data.id}
onClose={handleMenuClose}
@ -232,40 +187,9 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
onRename={fileOperations.handleRename}
onDeleteClick={fileOperations.handleDeleteClick}
/>
</ContextMenuContent>
</ContextMenu>
<AlertDialog
open={fileOperations.showDeleteConfirm}
onOpenChange={(open) => {
if (!open)
fileOperations.handleDeleteCancel()
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{deleteConfirmTitle}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-secondary system-sm-regular">
{deleteConfirmContent}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={fileOperations.isDeleting}
onClick={() => {
void fileOperations.handleDeleteConfirm()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}