From fb78a4450d8a99f8e18cbc3637cbe92164acce05 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 28 Jan 2026 19:38:41 +0800 Subject: [PATCH] feat: implement node reordering functionality in file tree component --- .../workflow/skill/file-tree/index.tsx | 123 ++++++++++++++++-- .../workflow/skill/hooks/use-node-reorder.ts | 42 ++++++ .../workflow/skill-editor/file-tree-slice.ts | 6 + .../store/workflow/skill-editor/index.ts | 3 + .../store/workflow/skill-editor/types.ts | 7 + 5 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-node-reorder.ts diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 1158c27a09..5bef103e35 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -19,6 +19,7 @@ import { cn } from '@/utils/classnames' import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants' import { useInlineCreateNode } from '../hooks/use-inline-create-node' import { useNodeMove } from '../hooks/use-node-move' +import { useNodeReorder } from '../hooks/use-node-reorder' import { usePasteOperation } from '../hooks/use-paste-operation' import { useRootFileDrop } from '../hooks/use-root-file-drop' import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' @@ -35,6 +36,45 @@ type FileTreeProps = { } const emptyTreeNodes: TreeNodeData[] = [] +type DragInsertTarget = { + parentId: string | null + index: number +} + +const normalizeParentId = (node: NodeApi | null | undefined) => { + if (!node || node.isRoot) + return null + return node.id +} + +const getSiblingIds = ( + parentNode: NodeApi | null | undefined, + tree: TreeApi | null, +): string[] => { + const children = parentNode?.children ?? tree?.root.children ?? [] + return children.map(child => child.id) +} + +const getAfterNodeIdForReorder = ( + siblingIds: string[], + draggedId: string, + targetIndex: number, +): string | null | undefined => { + const originalIndex = siblingIds.indexOf(draggedId) + if (originalIndex === -1) + return undefined + + let adjustedIndex = targetIndex + if (targetIndex > originalIndex) + adjustedIndex -= 1 + + const siblingsWithoutDragged = siblingIds.filter(id => id !== draggedId) + const insertIndex = Math.min(Math.max(adjustedIndex, 0), siblingsWithoutDragged.length) + if (insertIndex === 0) + return null + + return siblingsWithoutDragged[insertIndex - 1] ?? null +} const DropTip = () => { const { t } = useTranslation('workflow') @@ -53,6 +93,7 @@ const FileTree: React.FC = ({ className }) => { const treeRef = useRef>(null) const containerRef = useRef(null) const containerSize = useSize(containerRef) + const dragInsertTargetRef = useRef(null) const { data: treeData, isLoading, error } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 @@ -150,16 +191,76 @@ const FileTree: React.FC = ({ className }) => { // Node move API (for internal drag-drop) const { executeMoveNode } = useNodeMove() + const { executeReorderNode } = useNodeReorder() + + const syncDragInsertTarget = useCallback(() => { + const tree = treeRef.current + if (!tree) + return + + const { id, destinationParentId, destinationIndex } = tree.state.nodes.drag + if (!id || destinationIndex === null) { + if (dragInsertTargetRef.current) { + dragInsertTargetRef.current = null + storeApi.getState().setDragInsertTarget(null) + } + return + } + + const normalizedParentId = destinationParentId === tree.root.id ? null : destinationParentId + const nextTarget = { parentId: normalizedParentId, index: destinationIndex } + const prevTarget = dragInsertTargetRef.current + if (prevTarget?.parentId === nextTarget.parentId && prevTarget?.index === nextTarget.index) + return + + dragInsertTargetRef.current = nextTarget + storeApi.getState().setDragInsertTarget(nextTarget) + }, [storeApi]) + + useEffect(() => { + const tree = treeRef.current + if (!tree) + return + + syncDragInsertTarget() + const unsubscribe = tree.store.subscribe(syncDragInsertTarget) + return () => { + unsubscribe() + dragInsertTargetRef.current = null + storeApi.getState().setDragInsertTarget(null) + } + }, [syncDragInsertTarget, storeApi, treeNodes.length]) // react-arborist onMove callback - called when internal drag completes - const handleMove = useCallback>(({ dragIds, parentId }) => { + const handleMove = useCallback>(({ dragIds, parentId, index, dragNodes, parentNode }) => { // Only support single node drag for now const nodeId = dragIds[0] - if (!nodeId) + const draggedNode = dragNodes[0] + if (!nodeId || !draggedNode) return + + 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 + } + } + // parentId from react-arborist is null for root, otherwise folder ID - executeMoveNode(nodeId, parentId) - }, [executeMoveNode]) + executeMoveNode(nodeId, targetParentId) + }, [executeMoveNode, executeReorderNode, treeRef]) // react-arborist disableDrop callback - returns true to prevent drop const handleDisableDrop = useCallback((args: { @@ -167,26 +268,20 @@ const FileTree: React.FC = ({ className }) => { dragNodes: NodeApi[] index: number }) => { - const { dragNodes, parentNode, index } = args + const { dragNodes, parentNode } = args - // 1. Only allow dropping INTO folders (index = 0), not between items - // When index is not 0, it means dropping between items (reordering) - // We only want to allow dropping over the folder (willReceiveDrop) - if (index !== 0) - return true - - // 2. Files cannot be drop targets - only folders can receive drops + // 1. Files cannot be drop targets - only folders can receive drops if (parentNode.data.node_type === 'file') return true - // 3. Cannot drop node into itself + // 2. Cannot drop node into itself const draggedNode = dragNodes[0] if (!draggedNode) return true if (draggedNode.id === parentNode.id) return true - // 4. Prevent circular move (folder into its descendant) + // 3. Prevent circular move (folder into its descendant) if (draggedNode.data.node_type === 'folder') { const treeChildrenTyped = treeChildren as AppAssetTreeView[] if (isDescendantOf(parentNode.id, draggedNode.id, treeChildrenTyped)) diff --git a/web/app/components/workflow/skill/hooks/use-node-reorder.ts b/web/app/components/workflow/skill/hooks/use-node-reorder.ts new file mode 100644 index 0000000000..d2a155d454 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-node-reorder.ts @@ -0,0 +1,42 @@ +'use client' + +// Internal tree node reorder handler - API execution logic only + +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import Toast from '@/app/components/base/toast' +import { useReorderAppAssetNode } from '@/service/use-app-asset' + +export function useNodeReorder() { + const { t } = useTranslation('workflow') + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const reorderNode = useReorderAppAssetNode() + + const executeReorderNode = useCallback(async (nodeId: string, afterNodeId: string | null) => { + try { + await reorderNode.mutateAsync({ + appId, + nodeId, + payload: { after_node_id: afterNodeId }, + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.moved'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.moveError'), + }) + } + }, [appId, reorderNode, t]) + + return { + executeReorderNode, + isReordering: reorderNode.isPending, + } +} diff --git a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts index 9cd9f6b3f0..a915538e44 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts @@ -97,6 +97,12 @@ export const createFileTreeSlice: StateCreator< set({ dragOverFolderId: folderId }) }, + dragInsertTarget: null, + + setDragInsertTarget: (target) => { + set({ dragInsertTarget: target }) + }, + currentDragType: null, setCurrentDragType: (type) => { diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts index 087504c088..f75434af7d 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/index.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts @@ -37,6 +37,9 @@ export const createSkillEditorSlice: StateCreator = (...a selectedTreeNodeId: null, selectedNodeIds: new Set(), pendingCreateNode: null, + dragOverFolderId: null, + dragInsertTarget: null, + currentDragType: null, clipboard: null, dirtyContents: new Map(), fileMetadata: new Map>(), diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts index b3e6a16ec1..f5f9920d09 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -25,6 +25,11 @@ export type PendingCreateNode = { export type DragActionType = 'upload' | 'move' +export type DragInsertTarget = { + parentId: string | null + index: number +} + export type FileTreeSliceShape = { expandedFolderIds: Set setExpandedFolderIds: (ids: Set) => void @@ -42,6 +47,8 @@ export type FileTreeSliceShape = { clearCreateNode: () => void dragOverFolderId: string | null setDragOverFolderId: (folderId: string | null) => void + dragInsertTarget: DragInsertTarget | null + setDragInsertTarget: (target: DragInsertTarget | null) => void currentDragType: DragActionType | null setCurrentDragType: (type: DragActionType | null) => void fileTreeSearchTerm: string