From 09b3e53c433ab4f71b1f8d6d83ebdbc8e6d991e0 Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 30 Mar 2026 16:30:01 +0800 Subject: [PATCH] fix: support dropping file tree nodes to root blank area --- .../skill/file-tree/tree/file-tree.spec.tsx | 30 ++++++ .../skill/file-tree/tree/file-tree.tsx | 91 +++++++++++++++---- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx index db5e67badf..fa6a60bfd8 100644 --- a/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx @@ -37,6 +37,11 @@ type MockInlineCreateNodeResult = { type MockTreeApi = { deselectAll: () => void + dragNode?: { + id: string + parent: { id: string, isRoot?: boolean } | null + } | null + get: (id: string | null) => { id: string, children?: Array<{ id: string }> } | undefined state: { nodes: { drag: { @@ -89,6 +94,12 @@ function createNode(overrides: Partial = {}): AppAssetTreeView function createTreeApiMock(): MockTreeApi { return { deselectAll: vi.fn(), + dragNode: null, + get: vi.fn((id: string | null) => { + if (id === 'root') + return undefined + return id ? { id, children: [] } : undefined + }), state: { nodes: { drag: { @@ -440,6 +451,25 @@ describe('FileTree', () => { expect(mocks.rootDropHandlers.handleRootDragLeave).toHaveBeenCalledTimes(1) expect(mocks.rootDropHandlers.handleRootDrop).toHaveBeenCalledTimes(1) }) + + it('should commit internal move when dropping in root blank area', () => { + mocks.treeApi.dragDestinationIndex = 2 + mocks.treeApi.dragNode = { + id: 'file-1', + parent: { id: 'folder-1', isRoot: false }, + } + mocks.treeApi.state.nodes.drag = { + id: 'file-1', + destinationParentId: 'root', + destinationIndex: 2, + } + + render() + + fireEvent.drop(getTreeDropZone()) + + expect(mocks.executeMoveNode).toHaveBeenCalledWith('file-1', null) + }) }) describe('Tree callbacks', () => { diff --git a/web/app/components/workflow/skill/file-tree/tree/file-tree.tsx b/web/app/components/workflow/skill/file-tree/tree/file-tree.tsx index 6b415043f1..1a5d54d559 100644 --- a/web/app/components/workflow/skill/file-tree/tree/file-tree.tsx +++ b/web/app/components/workflow/skill/file-tree/tree/file-tree.tsx @@ -41,6 +41,13 @@ type DragInsertTarget = { parentId: string | null index: number } +type CommitInternalMoveArgs = { + nodeId: string + draggedNode: NodeApi + parentId: string | null + index: number + parentNode?: NodeApi | null +} const normalizeParentId = (node: NodeApi | null | undefined) => { if (!node || node.isRoot) @@ -185,6 +192,35 @@ const FileTree = ({ className }: FileTreeProps) => { const { executeMoveNode } = useNodeMove() const { executeReorderNode } = useNodeReorder() + const commitInternalMove = useCallback(({ + nodeId, + draggedNode, + parentId, + index, + parentNode, + }: CommitInternalMoveArgs) => { + const tree = treeRef.current + const destinationIndex = tree?.dragDestinationIndex + const isInsertLine = destinationIndex !== null && destinationIndex !== undefined + const targetParentId = parentId ?? null + const sourceParentId = normalizeParentId(draggedNode.parent) + + if (isInsertLine && sourceParentId === targetParentId) { + const siblingIds = getSiblingIds(parentNode, tree) + const afterNodeId = getAfterNodeIdForReorder( + siblingIds, + nodeId, + destinationIndex ?? index, + ) + if (afterNodeId !== undefined) { + executeReorderNode(nodeId, afterNodeId) + return + } + } + + executeMoveNode(nodeId, targetParentId) + }, [executeMoveNode, executeReorderNode]) + const syncDragInsertTarget = useCallback(() => { const tree = treeRef.current if (!tree) @@ -231,28 +267,43 @@ const FileTree = ({ className }: FileTreeProps) => { if (!nodeId || !draggedNode) return + commitInternalMove({ + nodeId, + draggedNode, + parentId, + index, + parentNode, + }) + }, [commitInternalMove]) + + const handleTreeDrop = useCallback((e: React.DragEvent) => { + handleRootDrop(e) + const tree = treeRef.current - const destinationIndex = tree?.dragDestinationIndex - const isInsertLine = destinationIndex !== null && destinationIndex !== undefined - const targetParentId = parentId ?? null - const sourceParentId = normalizeParentId(draggedNode.parent) + const dragTarget = dragInsertTargetRef.current + if (!tree || !dragTarget) + return - if (isInsertLine && sourceParentId === targetParentId) { - const siblingIds = getSiblingIds(parentNode, tree) - const afterNodeId = getAfterNodeIdForReorder( - siblingIds, - nodeId, - destinationIndex ?? index, - ) - if (afterNodeId !== undefined) { - executeReorderNode(nodeId, afterNodeId) - return - } - } + const targetElement = e.target as HTMLElement | null + if (targetElement?.closest('[role="treeitem"]')) + return - // parentId from react-arborist is null for root, otherwise folder ID - executeMoveNode(nodeId, targetParentId) - }, [executeMoveNode, executeReorderNode, treeRef]) + const draggedNode = tree.dragNode + if (!draggedNode) + return + + const parentNode = dragTarget.parentId === null + ? tree.root + : tree.get(dragTarget.parentId) + + commitInternalMove({ + nodeId: draggedNode.id, + draggedNode, + parentId: dragTarget.parentId, + index: dragTarget.index, + parentNode, + }) + }, [commitInternalMove, handleRootDrop]) // react-arborist disableDrop callback - returns true to prevent drop const handleDisableDrop = useCallback((args: { @@ -387,7 +438,7 @@ const FileTree = ({ className }: FileTreeProps) => { onDragEnter={handleRootDragEnter} onDragOver={handleRootDragOver} onDragLeave={handleRootDragLeave} - onDrop={handleRootDrop} + onDrop={handleTreeDrop} > ref={treeRef}