fix(skill): stabilize file tree local operations

This commit is contained in:
yyh
2026-03-25 12:21:32 +08:00
parent 21bdb6da47
commit ab63df602a
12 changed files with 218 additions and 37 deletions

View File

@@ -14,7 +14,6 @@ import { NODE_MENU_TYPE } from '../../constants'
import NodeMenu from './node-menu'
type MockWorkflowState = {
selectedNodeIds: Set<string>
hasClipboard: () => boolean
}
@@ -39,6 +38,7 @@ type RenderNodeMenuProps = {
type?: 'root' | 'folder' | 'file'
menuType?: 'dropdown' | 'context'
nodeId?: string
actionNodeIds?: string[]
onClose?: () => void
onImportSkills?: () => void
}
@@ -64,10 +64,10 @@ function createFileOperationsMock(): MockFileOperations {
const mocks = vi.hoisted(() => ({
storeState: {
selectedNodeIds: new Set<string>(),
hasClipboard: () => false,
} as MockWorkflowState,
cutNodes: vi.fn(),
setFileTreeSearchTerm: vi.fn(),
fileOperations: createFileOperationsMock(),
}))
@@ -76,6 +76,7 @@ vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
cutNodes: mocks.cutNodes,
setFileTreeSearchTerm: mocks.setFileTreeSearchTerm,
}),
}),
}))
@@ -84,6 +85,7 @@ const renderNodeMenu = ({
type = NODE_MENU_TYPE.FOLDER,
menuType = 'dropdown',
nodeId = 'node-1',
actionNodeIds,
onClose = vi.fn(),
onImportSkills,
}: RenderNodeMenuProps = {}) => {
@@ -96,6 +98,7 @@ const renderNodeMenu = ({
type={type}
menuType={menuType}
nodeId={nodeId}
actionNodeIds={actionNodeIds}
onClose={onClose}
fileInputRef={mocks.fileOperations.fileInputRef}
folderInputRef={mocks.fileOperations.folderInputRef}
@@ -120,6 +123,7 @@ const renderNodeMenu = ({
type={type}
menuType={menuType}
nodeId={nodeId}
actionNodeIds={actionNodeIds}
onClose={onClose}
fileInputRef={mocks.fileOperations.fileInputRef}
folderInputRef={mocks.fileOperations.folderInputRef}
@@ -147,7 +151,6 @@ const renderNodeMenu = ({
describe('NodeMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.selectedNodeIds = new Set<string>()
mocks.storeState.hasClipboard = () => false
mocks.fileOperations = createFileOperationsMock()
})
@@ -195,6 +198,8 @@ describe('NodeMenu', () => {
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i }))
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(1, '')
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(2, '')
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
})
@@ -209,9 +214,12 @@ describe('NodeMenu', () => {
expect(clickSpy).toHaveBeenCalledTimes(2)
})
it('should cut selected nodes and close menu when cut is clicked', () => {
mocks.storeState.selectedNodeIds = new Set(['file-1', 'file-2'])
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'fallback-id' })
it('should cut explicit action node ids and close menu when cut is clicked', () => {
const { onClose } = renderNodeMenu({
type: NODE_MENU_TYPE.FILE,
nodeId: 'fallback-id',
actionNodeIds: ['file-1', 'file-2'],
})
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i }))

View File

@@ -24,6 +24,7 @@ type NodeMenuProps = {
type: NodeMenuType
menuType: 'dropdown' | 'context'
nodeId?: string
actionNodeIds?: string[]
onClose: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
folderInputRef: React.RefObject<HTMLInputElement | null>
@@ -42,6 +43,7 @@ const NodeMenu = ({
type,
menuType,
nodeId,
actionNodeIds,
onClose,
fileInputRef,
folderInputRef,
@@ -57,7 +59,6 @@ const NodeMenu = ({
}: NodeMenuProps) => {
const { t } = useTranslation('workflow')
const storeApi = useWorkflowStore()
const selectedNodeIds = useStore(s => s.selectedNodeIds)
const hasClipboard = useStore(s => s.hasClipboard())
const isRoot = type === NODE_MENU_TYPE.ROOT
const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
@@ -65,18 +66,30 @@ const NodeMenu = ({
const currentNodeId = nodeId
const handleCut = useCallback(() => {
const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : [])
const ids = actionNodeIds && actionNodeIds.length > 0
? actionNodeIds
: (currentNodeId ? [currentNodeId] : [])
if (ids.length > 0) {
storeApi.getState().cutNodes(ids)
onClose()
}
}, [currentNodeId, onClose, selectedNodeIds, storeApi])
}, [actionNodeIds, currentNodeId, onClose, storeApi])
const handlePaste = useCallback(() => {
window.dispatchEvent(new CustomEvent('skill:paste'))
onClose()
}, [onClose])
const handleCreateFile = useCallback(() => {
storeApi.getState().setFileTreeSearchTerm('')
onNewFile()
}, [onNewFile, storeApi])
const handleCreateFolder = useCallback(() => {
storeApi.getState().setFileTreeSearchTerm('')
onNewFolder()
}, [onNewFolder, storeApi])
const showRenameDelete = isFolder ? !isRoot : true
const Separator = menuType === 'dropdown' ? DropdownMenuSeparator : ContextMenuSeparator
@@ -106,14 +119,14 @@ const NodeMenu = ({
menuType={menuType}
icon={FileAdd}
label={t('skillSidebar.menu.newFile')}
onClick={onNewFile}
onClick={handleCreateFile}
disabled={isLoading}
/>
<MenuItem
menuType={menuType}
icon={FolderAdd}
label={t('skillSidebar.menu.newFolder')}
onClick={onNewFolder}
onClick={handleCreateFolder}
disabled={isLoading}
/>

View File

@@ -3,6 +3,7 @@ import { ROOT_ID } from '../../constants'
import TreeContextMenu from './tree-context-menu'
const mocks = vi.hoisted(() => ({
selectedNodeIds: new Set<string>(),
clearSelection: vi.fn(),
setSelectedNodeIds: vi.fn(),
deselectAll: vi.fn(),
@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
selectedNodeIds: mocks.selectedNodeIds,
clearSelection: mocks.clearSelection,
setSelectedNodeIds: mocks.setSelectedNodeIds,
}),
@@ -63,11 +65,12 @@ vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
}))
vi.mock('./node-menu', () => ({
default: ({ type, menuType, nodeId, onImportSkills }: { type: string, menuType: string, nodeId?: string, onImportSkills?: () => void }) => (
default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void }) => (
<div
data-testid={`node-menu-${menuType}`}
data-type={type}
data-node-id={nodeId ?? ''}
data-action-node-ids={(actionNodeIds ?? []).join(',')}
>
{onImportSkills && (
<button type="button" onClick={onImportSkills}>
@@ -81,6 +84,7 @@ vi.mock('./node-menu', () => ({
describe('TreeContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedNodeIds = new Set<string>()
mocks.fileOperations.showDeleteConfirm = false
})
@@ -143,6 +147,7 @@ describe('TreeContextMenu', () => {
expect(mocks.clearSelection).not.toHaveBeenCalled()
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'file')
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', 'file-1')
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-action-node-ids', 'file-1')
expect(mocks.useFileOperations).toHaveBeenLastCalledWith(expect.objectContaining({
nodeId: 'file-1',
nodeType: 'file',
@@ -150,6 +155,31 @@ describe('TreeContextMenu', () => {
}))
})
it('should preserve multi-selection when right-click target is already selected', () => {
mocks.selectedNodeIds = new Set(['file-1', 'file-2'])
mocks.getNode.mockReturnValue({
select: mocks.selectNode,
data: { name: 'readme.md' },
})
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>
</TreeContextMenu>,
)
fireEvent.contextMenu(screen.getByRole('treeitem'))
expect(mocks.deselectAll).not.toHaveBeenCalled()
expect(mocks.selectNode).not.toHaveBeenCalled()
expect(mocks.setSelectedNodeIds).not.toHaveBeenCalled()
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-action-node-ids', 'file-1,file-2')
})
it('should keep import modal mounted after root menu requests it', () => {
render(
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>

View File

@@ -33,11 +33,13 @@ type MenuTarget = {
nodeId: string
type: typeof NODE_MENU_TYPE.ROOT | typeof NODE_MENU_TYPE.FOLDER | typeof NODE_MENU_TYPE.FILE
fileName?: string
actionNodeIds: string[]
}
const defaultMenuTarget: MenuTarget = {
nodeId: ROOT_ID,
type: NODE_MENU_TYPE.ROOT,
actionNodeIds: [],
}
const TreeContextMenu = ({
@@ -68,13 +70,23 @@ const TreeContextMenu = ({
return
const targetNode = treeRef.current?.get(nodeId)
treeRef.current?.deselectAll?.()
targetNode?.select()
storeApi.getState().setSelectedNodeIds([nodeId])
const selectedNodeIds = storeApi.getState().selectedNodeIds
const targetIsInSelection = selectedNodeIds.has(nodeId)
const actionNodeIds = targetIsInSelection && selectedNodeIds.size > 0
? [...selectedNodeIds]
: [nodeId]
if (!targetIsInSelection) {
treeRef.current?.deselectAll?.()
targetNode?.select()
storeApi.getState().setSelectedNodeIds([nodeId])
}
setMenuTarget({
nodeId,
type: nodeType,
fileName: targetNode?.data.name,
actionNodeIds,
})
}, [storeApi, treeRef])
@@ -107,6 +119,7 @@ const TreeContextMenu = ({
menuType="context"
type={menuTarget.type}
nodeId={menuTarget.nodeId}
actionNodeIds={menuTarget.actionNodeIds}
onClose={handleMenuClose}
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}

View File

@@ -6,6 +6,7 @@ import TreeNode from './tree-node'
type MockWorkflowSelectorState = {
dirtyContents: Set<string>
isCutNode: (nodeId: string) => boolean
selectedNodeIds: Set<string>
}
type NodeState = {
@@ -24,6 +25,7 @@ type NodeState = {
const workflowState = vi.hoisted(() => ({
dirtyContents: new Set<string>(),
cutNodeIds: new Set<string>(),
selectedNodeIds: new Set<string>(),
dragOverFolderId: null as string | null,
}))
@@ -69,6 +71,7 @@ vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({
dirtyContents: workflowState.dirtyContents,
isCutNode: (nodeId: string) => workflowState.cutNodeIds.has(nodeId),
selectedNodeIds: workflowState.selectedNodeIds,
}),
useWorkflowStore: () => ({
getState: () => ({
@@ -114,15 +117,17 @@ vi.mock('./node-menu', () => ({
default: ({
type,
menuType,
actionNodeIds,
onClose,
onDeleteClick,
}: {
type: string
menuType: string
actionNodeIds?: string[]
onClose: () => void
onDeleteClick?: () => void
}) => (
<div data-testid={`node-menu-${menuType}`} data-type={type}>
<div data-testid={`node-menu-${menuType}`} data-type={type} data-action-node-ids={(actionNodeIds ?? []).join(',')}>
<button type="button" onClick={onClose}>close-menu</button>
<button type="button" onClick={onDeleteClick}>delete-item</button>
</div>
@@ -179,6 +184,7 @@ describe('TreeNode', () => {
workflowState.dirtyContents.clear()
workflowState.cutNodeIds.clear()
workflowState.selectedNodeIds = new Set<string>()
workflowState.dragOverFolderId = null
dndMocks.isDragOver = false
@@ -202,6 +208,7 @@ describe('TreeNode', () => {
})
it('should render selected open folder with folder expansion aria state', () => {
workflowState.selectedNodeIds = new Set(['folder-1', 'folder-2'])
const props = buildProps({
id: 'folder-1',
name: 'src',
@@ -216,6 +223,8 @@ describe('TreeNode', () => {
expect(treeItem).toHaveAttribute('aria-selected', 'true')
expect(treeItem).toHaveAttribute('aria-expanded', 'true')
expect(treeItem).toHaveClass('bg-state-base-active')
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i }))
expect(screen.getByTestId('node-menu-dropdown')).toHaveAttribute('data-action-node-ids', 'folder-1,folder-2')
})
it('should apply drag-over, blinking, and cut styles when states are active', () => {

View File

@@ -3,7 +3,7 @@
import type { NodeRendererProps } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
@@ -32,7 +32,13 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
const isSelected = node.isSelected
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
const isCut = useStore(s => s.isCutNode(node.data.id))
const selectedNodeIds = useStore(s => s.selectedNodeIds)
const storeApi = useWorkflowStore()
const actionNodeIds = useMemo(() => {
if (node.isSelected && selectedNodeIds.size > 0)
return [...selectedNodeIds]
return [node.data.id]
}, [node.data.id, node.isSelected, selectedNodeIds])
// Sync react-arborist drag state to Zustand for DragActionTooltip
const prevIsDraggingRef = useRef(node.isDragging)
@@ -181,6 +187,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
menuType="dropdown"
type={isFolder ? 'folder' : 'file'}
nodeId={node.data.id}
actionNodeIds={actionNodeIds}
onClose={handleMenuClose}
fileInputRef={fileOperations.fileInputRef}
folderInputRef={fileOperations.folderInputRef}

View File

@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
getAllDescendantFileIds: vi.fn<(nodeId: string, nodes: TreeNodeData[]) => string[]>(),
isDescendantOf: vi.fn<(potentialDescendantId: string | null | undefined, ancestorId: string | null | undefined, nodes: TreeNodeData[]) => boolean>(),
}))
vi.mock('@/service/use-app-asset', () => ({
@@ -34,6 +35,7 @@ vi.mock('../data/use-skill-tree-collaboration', () => ({
vi.mock('../../../utils/tree-utils', () => ({
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
isDescendantOf: mocks.isDescendantOf,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
@@ -75,19 +77,27 @@ const createTreeRef = (targetNode: NodeApi<TreeNodeData> | null) => {
const createStoreApi = () => {
const closeTab = vi.fn<(fileId: string) => void>()
const clearDraftContent = vi.fn<(fileId: string) => void>()
const clearFileMetadata = vi.fn<(fileId: string) => void>()
const clearClipboard = vi.fn<() => void>()
const state = {
clipboard: null,
closeTab,
clearDraftContent,
} as Pick<SkillEditorSliceShape, 'closeTab' | 'clearDraftContent'>
clearFileMetadata,
clearClipboard,
} as Pick<SkillEditorSliceShape, 'clipboard' | 'closeTab' | 'clearDraftContent' | 'clearFileMetadata' | 'clearClipboard'>
const storeApi = {
getState: () => state,
} as unknown as StoreApi<SkillEditorSliceShape>
return {
state,
storeApi,
closeTab,
clearDraftContent,
clearFileMetadata,
clearClipboard,
}
}
@@ -97,6 +107,7 @@ describe('useModifyOperations', () => {
mocks.deletePending = false
mocks.deleteMutateAsync.mockResolvedValue(undefined)
mocks.getAllDescendantFileIds.mockReturnValue([])
mocks.isDescendantOf.mockReturnValue(false)
})
// Scenario: loading state should match mutation pending status.
@@ -197,13 +208,17 @@ describe('useModifyOperations', () => {
// Scenario: successful deletes should close tabs/drafts and emit collaboration updates.
describe('Delete success', () => {
it('should delete file node, clear descendants and current file tabs, and show file success toast', async () => {
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard, state } = createStoreApi()
const onClose = vi.fn()
const { node } = createNodeApi('file', 'file-7')
const treeData: AppAssetTreeResponse = {
children: [createTreeNodeData('root-folder', 'folder')],
}
mocks.getAllDescendantFileIds.mockReturnValue(['desc-1', 'desc-2'])
state.clipboard = {
operation: 'cut',
nodeIds: new Set(['file-7']),
}
const { result } = renderHook(() => useModifyOperations({
nodeId: 'file-7',
@@ -235,6 +250,10 @@ describe('useModifyOperations', () => {
expect(clearDraftContent).toHaveBeenNthCalledWith(1, 'desc-1')
expect(clearDraftContent).toHaveBeenNthCalledWith(2, 'desc-2')
expect(clearDraftContent).toHaveBeenNthCalledWith(3, 'file-7')
expect(clearFileMetadata).toHaveBeenNthCalledWith(1, 'desc-1')
expect(clearFileMetadata).toHaveBeenNthCalledWith(2, 'desc-2')
expect(clearFileMetadata).toHaveBeenNthCalledWith(3, 'file-7')
expect(clearClipboard).toHaveBeenCalledTimes(1)
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.fileDeleted')
expect(result.current.showDeleteConfirm).toBe(false)
@@ -242,12 +261,17 @@ describe('useModifyOperations', () => {
})
it('should delete folder node and skip closing the folder tab itself', async () => {
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard, state } = createStoreApi()
const { node } = createNodeApi('folder', 'folder-9')
const treeData: AppAssetTreeResponse = {
children: [createTreeNodeData('root-folder', 'folder')],
}
mocks.getAllDescendantFileIds.mockReturnValue(['file-in-folder'])
mocks.isDescendantOf.mockReturnValueOnce(true)
state.clipboard = {
operation: 'cut',
nodeIds: new Set(['nested-folder']),
}
const { result } = renderHook(() => useModifyOperations({
nodeId: 'folder-9',
@@ -266,6 +290,9 @@ describe('useModifyOperations', () => {
expect(closeTab).toHaveBeenCalledWith('file-in-folder')
expect(clearDraftContent).toHaveBeenCalledTimes(1)
expect(clearDraftContent).toHaveBeenCalledWith('file-in-folder')
expect(clearFileMetadata).toHaveBeenCalledTimes(1)
expect(clearFileMetadata).toHaveBeenCalledWith('file-in-folder')
expect(clearClipboard).toHaveBeenCalledTimes(1)
expect(closeTab).not.toHaveBeenCalledWith('folder-9')
expect(clearDraftContent).not.toHaveBeenCalledWith('folder-9')
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.deleted')
@@ -276,7 +303,7 @@ describe('useModifyOperations', () => {
describe('Delete errors', () => {
it('should show folder delete error toast on failure', async () => {
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard } = createStoreApi()
const onClose = vi.fn()
const { node } = createNodeApi('folder', 'folder-err')
const treeData: AppAssetTreeResponse = {
@@ -299,6 +326,8 @@ describe('useModifyOperations', () => {
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(closeTab).not.toHaveBeenCalled()
expect(clearDraftContent).not.toHaveBeenCalled()
expect(clearFileMetadata).not.toHaveBeenCalled()
expect(clearClipboard).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.deleteError')
expect(result.current.showDeleteConfirm).toBe(false)
expect(onClose).toHaveBeenCalledTimes(1)

View File

@@ -11,7 +11,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useDeleteAppAssetNode } from '@/service/use-app-asset'
import { getAllDescendantFileIds } from '../../../utils/tree-utils'
import { getAllDescendantFileIds, isDescendantOf } from '../../../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
type UseModifyOperationsOptions = {
@@ -61,19 +61,31 @@ export function useModifyOperations({
const descendantFileIds = treeData?.children
? getAllDescendantFileIds(nodeId, treeData.children)
: []
const affectedFileIds = Array.from(new Set(
isFolder ? descendantFileIds : [...descendantFileIds, nodeId],
))
await deleteNodeAsync({ appId, nodeId })
emitTreeUpdate()
descendantFileIds.forEach((fileId) => {
affectedFileIds.forEach((fileId) => {
storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent(fileId)
storeApi.getState().clearFileMetadata(fileId)
})
// Also close and clear the node itself if it's a file
if (!isFolder) {
storeApi.getState().closeTab(nodeId)
storeApi.getState().clearDraftContent(nodeId)
const clipboard = storeApi.getState().clipboard
if (clipboard) {
const shouldClearClipboard = [...clipboard.nodeIds].some((clipboardNodeId) => {
if (clipboardNodeId === nodeId)
return true
if (!isFolder || !treeData?.children)
return false
return isDescendantOf(clipboardNodeId, nodeId, treeData.children)
})
if (shouldClearClipboard)
storeApi.getState().clearClipboard()
}
toast.success(

View File

@@ -35,6 +35,7 @@ type WorkflowStoreState = {
nodeIds: Set<string>
} | null
selectedTreeNodeId: string | null
cutNodes: (nodeIds: string[]) => void
clearClipboard: () => void
}
@@ -48,6 +49,7 @@ const mocks = vi.hoisted(() => ({
workflowState: {
clipboard: null,
selectedTreeNodeId: null,
cutNodes: vi.fn<(nodeIds: string[]) => void>(),
clearClipboard: vi.fn<() => void>(),
} as WorkflowStoreState,
appStoreState: {
@@ -123,6 +125,7 @@ describe('usePasteOperation', () => {
vi.clearAllMocks()
mocks.workflowState.clipboard = null
mocks.workflowState.selectedTreeNodeId = null
mocks.workflowState.cutNodes = vi.fn()
mocks.appStoreState.appDetail = { id: 'app-1' }
mocks.movePending = false
mocks.moveMutateAsync.mockResolvedValue(undefined)
@@ -303,10 +306,37 @@ describe('usePasteOperation', () => {
})
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
expect(mocks.workflowState.cutNodes).not.toHaveBeenCalled()
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.moveError')
})
it('should keep failed node ids in clipboard and still refresh tree when paste partially succeeds', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-ok', 'node-fail']),
}
mocks.moveMutateAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('move failed'))
const treeRef = createTreeRef('target')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-ok', 'file'), createTreeNode('node-fail', 'file')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(2)
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
expect(mocks.workflowState.cutNodes).toHaveBeenCalledWith(['node-fail'])
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.moveError')
})
it('should prevent re-entrant paste while a paste is in progress', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',

View File

@@ -84,20 +84,37 @@ export function usePasteOperation({
isPastingRef.current = true
try {
await Promise.all(
nodeIdsArray.map(nodeId =>
moveNodeAsync({
const results = await Promise.allSettled(
nodeIdsArray.map(async (nodeId) => {
await moveNodeAsync({
appId,
nodeId,
payload: { parent_id: targetParentId },
}),
),
})
return nodeId
}),
)
storeApi.getState().clearClipboard()
emitTreeUpdate()
const succeededNodeIds = results.flatMap(result =>
result.status === 'fulfilled' ? [result.value] : [],
)
const failedNodeIds = results.flatMap((result, index) =>
result.status === 'rejected' ? [nodeIdsArray[index]] : [],
)
toast.success(t('skillSidebar.menu.moved'))
if (succeededNodeIds.length > 0)
emitTreeUpdate()
if (failedNodeIds.length === 0) {
storeApi.getState().clearClipboard()
toast.success(t('skillSidebar.menu.moved'))
return
}
if (succeededNodeIds.length > 0)
storeApi.getState().cutNodes(failedNodeIds)
toast.error(t('skillSidebar.menu.moveError'))
}
catch {
toast.error(t('skillSidebar.menu.moveError'))

View File

@@ -133,6 +133,7 @@ describe('SidebarSearchAdd', () => {
it('should call create handlers when clicking new file and new folder actions', () => {
// Arrange
mocks.storeState.fileTreeSearchTerm = 'agent'
render(<SidebarSearchAdd />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
@@ -141,6 +142,8 @@ describe('SidebarSearchAdd', () => {
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
// Assert
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(1, '')
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(2, '')
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
})

View File

@@ -57,6 +57,16 @@ const SidebarSearchAdd = () => {
onClose: handleMenuClose,
})
const handleCreateFile = useCallback(() => {
storeApi.getState().setFileTreeSearchTerm('')
handleNewFile()
}, [handleNewFile, storeApi])
const handleCreateFolder = useCallback(() => {
storeApi.getState().setFileTreeSearchTerm('')
handleNewFolder()
}, [handleNewFolder, storeApi])
return (
<div className="flex items-center gap-1 p-2">
<SearchInput
@@ -101,14 +111,14 @@ const SidebarSearchAdd = () => {
menuType="dropdown"
icon={FileAdd}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
onClick={handleCreateFile}
disabled={isLoading}
/>
<MenuItem
menuType="dropdown"
icon={FolderAdd}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
onClick={handleCreateFolder}
disabled={isLoading}
/>