From 60e46854c6c24eaf891ac24ba3f6c2f99eb7f823 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 24 Mar 2026 20:39:07 +0800 Subject: [PATCH] fix: context menu --- .../skill/file-tree/tree/node-menu.spec.tsx | 48 ++-- .../skill/file-tree/tree/node-menu.tsx | 107 ++----- .../file-tree/tree/tree-context-menu.spec.tsx | 20 ++ .../file-tree/tree/tree-context-menu.tsx | 17 +- .../skill/file-tree/tree/tree-node.spec.tsx | 41 +++ .../skill/file-tree/tree/tree-node.tsx | 264 +++++++++++------- 6 files changed, 294 insertions(+), 203 deletions(-) diff --git a/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx index 65e70c842a..7d1c419d45 100644 --- a/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx @@ -1,6 +1,4 @@ import type { RefObject } from 'react' -import type { NodeApi, TreeApi } from 'react-arborist' -import type { TreeNodeData } from '../../type' import { fireEvent, render, screen } from '@testing-library/react' import { ContextMenu, @@ -43,8 +41,6 @@ type RenderNodeMenuProps = { nodeId?: string onClose?: () => void onImportSkills?: () => void - treeRef?: RefObject | null> - node?: NodeApi } function createFileOperationsMock(): MockFileOperations { @@ -84,18 +80,12 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({ - useFileOperations: () => mocks.fileOperations, -})) - const renderNodeMenu = ({ type = NODE_MENU_TYPE.FOLDER, menuType = 'dropdown', nodeId = 'node-1', onClose = vi.fn(), onImportSkills, - treeRef, - node, }: RenderNodeMenuProps = {}) => { const ui = menuType === 'dropdown' ? ( @@ -107,9 +97,17 @@ const renderNodeMenu = ({ menuType={menuType} nodeId={nodeId} onClose={onClose} + fileInputRef={mocks.fileOperations.fileInputRef} + folderInputRef={mocks.fileOperations.folderInputRef} + isLoading={mocks.fileOperations.isLoading} + onDownload={mocks.fileOperations.handleDownload} + onNewFile={mocks.fileOperations.handleNewFile} + onNewFolder={mocks.fileOperations.handleNewFolder} + onFileChange={mocks.fileOperations.handleFileChange} + onFolderChange={mocks.fileOperations.handleFolderChange} + onRename={mocks.fileOperations.handleRename} + onDeleteClick={mocks.fileOperations.handleDeleteClick} onImportSkills={onImportSkills} - treeRef={treeRef} - node={node} /> @@ -123,9 +121,17 @@ const renderNodeMenu = ({ menuType={menuType} nodeId={nodeId} onClose={onClose} + fileInputRef={mocks.fileOperations.fileInputRef} + folderInputRef={mocks.fileOperations.folderInputRef} + isLoading={mocks.fileOperations.isLoading} + onDownload={mocks.fileOperations.handleDownload} + onNewFile={mocks.fileOperations.handleNewFile} + onNewFolder={mocks.fileOperations.handleNewFolder} + onFileChange={mocks.fileOperations.handleFileChange} + onFolderChange={mocks.fileOperations.handleFolderChange} + onRename={mocks.fileOperations.handleRename} + onDeleteClick={mocks.fileOperations.handleDeleteClick} onImportSkills={onImportSkills} - treeRef={treeRef} - node={node} /> @@ -257,19 +263,5 @@ describe('NodeMenu', () => { fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.importSkills/i })) expect(onImportSkills).toHaveBeenCalledTimes(1) }) - - it('should render delete confirmation content for files and forward confirm callbacks', () => { - mocks.fileOperations.showDeleteConfirm = true - renderNodeMenu({ type: NODE_MENU_TYPE.FILE }) - - 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(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1) - expect(mocks.fileOperations.handleDeleteCancel).toHaveBeenCalledTimes(1) - }) }) }) diff --git a/web/app/components/workflow/skill/file-tree/tree/node-menu.tsx b/web/app/components/workflow/skill/file-tree/tree/node-menu.tsx index 875cf90027..c6ae521466 100644 --- a/web/app/components/workflow/skill/file-tree/tree/node-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/node-menu.tsx @@ -1,23 +1,12 @@ 'use client' -import type { NodeApi, TreeApi } from 'react-arborist' import type { NodeMenuType } from '../../constants' -import type { TreeNodeData } from '../../type' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FileAdd, FolderAdd } from '@/app/components/base/icons/src/vender/line/files' import { UploadCloud02 } from '@/app/components/base/icons/src/vender/line/general' import { Download02 } from '@/app/components/base/icons/src/vender/solid/general' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@/app/components/base/ui/alert-dialog' import { ContextMenuSeparator, } from '@/app/components/base/ui/context-menu' @@ -26,7 +15,6 @@ import { } from '@/app/components/base/ui/dropdown-menu' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { NODE_MENU_TYPE } from '../../constants' -import { useFileOperations } from '../../hooks/file-tree/operations/use-file-operations' import MenuItem from './menu-item' const KBD_CUT = ['ctrl', 'x'] as const @@ -37,8 +25,16 @@ type NodeMenuProps = { menuType: 'dropdown' | 'context' nodeId?: string onClose: () => void - treeRef?: React.RefObject | null> - node?: NodeApi + fileInputRef: React.RefObject + folderInputRef: React.RefObject + isLoading: boolean + onDownload: () => void + onNewFile: () => void + onNewFolder: () => void + onFileChange: React.ChangeEventHandler + onFolderChange: React.ChangeEventHandler + onRename: () => void + onDeleteClick: () => void onImportSkills?: () => void } @@ -47,8 +43,16 @@ const NodeMenu = ({ menuType, nodeId, onClose, - treeRef, - node, + fileInputRef, + folderInputRef, + isLoading, + onDownload, + onNewFile, + onNewFolder, + onFileChange, + onFolderChange, + onRename, + onDeleteClick, onImportSkills, }: NodeMenuProps) => { const { t } = useTranslation('workflow') @@ -58,24 +62,7 @@ const NodeMenu = ({ const isRoot = type === NODE_MENU_TYPE.ROOT const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot - const { - fileInputRef, - folderInputRef, - showDeleteConfirm, - isLoading, - isDeleting, - handleDownload, - handleNewFile, - handleNewFolder, - handleFileChange, - handleFolderChange, - handleRename, - handleDeleteClick, - handleDeleteConfirm, - handleDeleteCancel, - } = useFileOperations({ nodeId, onClose, treeRef, node }) - - const currentNodeId = node?.data.id ?? nodeId + const currentNodeId = nodeId const handleCut = useCallback(() => { const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : []) @@ -91,12 +78,6 @@ const NodeMenu = ({ }, [onClose]) const showRenameDelete = isFolder ? !isRoot : true - const deleteConfirmTitle = isFolder - ? t('skillSidebar.menu.deleteConfirmTitle') - : t('skillSidebar.menu.fileDeleteConfirmTitle') - const deleteConfirmContent = isFolder - ? t('skillSidebar.menu.deleteConfirmContent') - : t('skillSidebar.menu.fileDeleteConfirmContent') const Separator = menuType === 'dropdown' ? DropdownMenuSeparator : ContextMenuSeparator return ( @@ -109,7 +90,7 @@ const NodeMenu = ({ multiple className="hidden" aria-label={t('skillSidebar.menu.uploadFile')} - onChange={handleFileChange} + onChange={onFileChange} /> handleNewFile()} + onClick={onNewFile} disabled={isLoading} /> handleNewFolder()} + onClick={onNewFolder} disabled={isLoading} /> @@ -177,7 +158,7 @@ const NodeMenu = ({ menuType={menuType} icon={Download02} label={t('skillSidebar.menu.download')} - onClick={handleDownload} + onClick={onDownload} disabled={isLoading} /> @@ -215,51 +196,19 @@ const NodeMenu = ({ menuType={menuType} icon="i-ri-edit-2-line" label={t('skillSidebar.menu.rename')} - onClick={() => handleRename()} + onClick={onRename} disabled={isLoading} /> handleDeleteClick()} + onClick={onDeleteClick} disabled={isLoading} variant="destructive" /> )} - - { - if (!open) - handleDeleteCancel() - }} - > - -
- - {deleteConfirmTitle} - - - {deleteConfirmContent} - -
- - - {t('operation.cancel', { ns: 'common' })} - - { - void handleDeleteConfirm() - }} - > - {t('operation.confirm', { ns: 'common' })} - - -
-
) } 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 7b58221de6..59c0bc71c7 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,22 @@ import TreeContextMenu from './tree-context-menu' const mocks = vi.hoisted(() => ({ clearSelection: vi.fn(), deselectAll: vi.fn(), + fileOperations: { + fileInputRef: { current: null }, + folderInputRef: { current: null }, + showDeleteConfirm: false, + isLoading: false, + isDeleting: false, + handleDownload: vi.fn(), + handleNewFile: vi.fn(), + handleNewFolder: vi.fn(), + handleFileChange: vi.fn(), + handleFolderChange: vi.fn(), + handleRename: vi.fn(), + handleDeleteClick: vi.fn(), + handleDeleteConfirm: vi.fn(), + handleDeleteCancel: vi.fn(), + }, })) vi.mock('@/app/components/workflow/store', () => ({ @@ -34,6 +50,10 @@ vi.mock('next/dynamic', () => ({ }, })) +vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({ + useFileOperations: () => mocks.fileOperations, +})) + vi.mock('./node-menu', () => ({ default: ({ type, menuType, nodeId, onImportSkills }: { type: string, menuType: string, nodeId?: string, onImportSkills?: () => void }) => (
import('../../start-tab/import-skill-modal'), { @@ -46,6 +47,11 @@ const TreeContextMenu = ({ }, [storeApi, treeRef]) const handleMenuClose = React.useCallback(() => {}, []) + const fileOperations = useFileOperations({ + nodeId: ROOT_ID, + treeRef, + onClose: handleMenuClose, + }) const handleOpenImportSkills = React.useCallback(() => { setIsImportModalOpen(true) }, []) @@ -66,7 +72,16 @@ const TreeContextMenu = ({ type={NODE_MENU_TYPE.ROOT} nodeId={ROOT_ID} onClose={handleMenuClose} - treeRef={treeRef} + 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} onImportSkills={handleOpenImportSkills} /> 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 c898143789..9547a4761c 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 @@ -48,6 +48,23 @@ const dndMocks = vi.hoisted(() => ({ onDragLeave: vi.fn(), })) +const fileOperationMocks = vi.hoisted(() => ({ + fileInputRef: { current: null }, + folderInputRef: { current: null }, + showDeleteConfirm: false, + isLoading: false, + isDeleting: false, + handleDownload: vi.fn(), + handleNewFile: vi.fn(), + handleNewFolder: vi.fn(), + handleFileChange: vi.fn(), + handleFolderChange: vi.fn(), + handleRename: vi.fn(), + handleDeleteClick: vi.fn(), + handleDeleteConfirm: vi.fn(async () => undefined), + handleDeleteCancel: vi.fn(), +})) + vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({ dirtyContents: workflowState.dirtyContents, @@ -89,6 +106,10 @@ vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({ }), })) +vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({ + useFileOperations: () => fileOperationMocks, +})) + vi.mock('./node-menu', () => ({ default: ({ type, menuType, onClose }: { type: string, menuType: string, onClose: () => void }) => (
@@ -151,6 +172,8 @@ describe('TreeNode', () => { dndMocks.isDragOver = false dndMocks.isBlinking = false + fileOperationMocks.showDeleteConfirm = false + fileOperationMocks.isDeleting = false }) // Core rendering should reflect selection, folder expansion, and store-driven visual states. @@ -321,4 +344,22 @@ 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 ede8cf0acb..3282fc33ac 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,6 +5,15 @@ 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, @@ -19,6 +28,7 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' 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' import NodeMenu from './node-menu' import TreeEditInput from './tree-edit-input' import TreeGuideLines from './tree-guide-lines' @@ -91,107 +101,171 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => { }, [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} - + + - - - - - - - - - - - - - + > + + + + + + + + + + + { + if (!open) + fileOperations.handleDeleteCancel() + }} + > + +
+ + {deleteConfirmTitle} + + + {deleteConfirmContent} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + { + void fileOperations.handleDeleteConfirm() + }} + > + {t('operation.confirm', { ns: 'common' })} + + +
+
+ ) }