diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx index 59c0bc71c7..a645180445 100644 --- a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx @@ -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( - -
blank area
+ +
+
+ readme.md +
+
blank area
+
, ) @@ -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( + +
+
+ readme.md +
+
blank area
+
+
, + ) + + 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( @@ -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( + +
+ readme.md +
+
, + ) + + fireEvent.contextMenu(screen.getByRole('treeitem')) + + expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument() + expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.tsx index 23bc4544b2..263e6c3565 100644 --- a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.tsx @@ -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(defaultMenuTarget) const [isImportModalOpen, setIsImportModalOpen] = useState(false) const handleContextMenu = React.useCallback((event: React.MouseEvent) => { const target = event.target as HTMLElement - if (target.closest('[role="treeitem"]')) + const nodeElement = target.closest('[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 = ({ + {!isRootTarget && ( + { + if (!open) + fileOperations.handleDeleteCancel() + }} + > + +
+ + {deleteConfirmTitle} + + + {deleteConfirmContent} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + { + void fileOperations.handleDeleteConfirm() + }} + > + {t('operation.confirm', { ns: 'common' })} + + +
+
+ )} setIsImportModalOpen(false)} diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx index 9547a4761c..282b923a06 100644 --- a/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx @@ -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() 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() - - 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) - }) - }) }) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-node.tsx index 3282fc33ac..834a05a2f7 100644 --- a/web/app/components/workflow/skill/file-tree/tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/tree-node.tsx @@ -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 ( - <> - - - -
-
- -
+
+ +
+
+ +
- {node.isEditing - ? ( - - ) - : ( - - {node.data.name} - + {node.isEditing + ? ( + + ) + : ( + + > + {node.data.name} + + )} +
- - - - - - - - - + + + + { onRename={fileOperations.handleRename} onDeleteClick={fileOperations.handleDeleteClick} /> - - - { - if (!open) - fileOperations.handleDeleteCancel() - }} - > - -
- - {deleteConfirmTitle} - - - {deleteConfirmContent} - -
- - - {t('operation.cancel', { ns: 'common' })} - - { - void fileOperations.handleDeleteConfirm() - }} - > - {t('operation.confirm', { ns: 'common' })} - - -
-
- + + +
) }