feat: implement node reordering functionality in file tree component

This commit is contained in:
yyh
2026-01-28 19:38:41 +08:00
parent 209e4f8f7b
commit fb78a4450d
5 changed files with 167 additions and 14 deletions

View File

@ -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<TreeNodeData> | null | undefined) => {
if (!node || node.isRoot)
return null
return node.id
}
const getSiblingIds = (
parentNode: NodeApi<TreeNodeData> | null | undefined,
tree: TreeApi<TreeNodeData> | 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<FileTreeProps> = ({ className }) => {
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
const containerRef = useRef<HTMLDivElement>(null)
const containerSize = useSize(containerRef)
const dragInsertTargetRef = useRef<DragInsertTarget | null>(null)
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
const isMutating = useIsMutating() > 0
@ -150,16 +191,76 @@ const FileTree: React.FC<FileTreeProps> = ({ 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<MoveHandler<TreeNodeData>>(({ dragIds, parentId }) => {
const handleMove = useCallback<MoveHandler<TreeNodeData>>(({ 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<FileTreeProps> = ({ className }) => {
dragNodes: NodeApi<TreeNodeData>[]
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))

View File

@ -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,
}
}