diff --git a/web/app/components/workflow/skill/constants.ts b/web/app/components/workflow/skill/constants.ts index 5124649a75..0779b1d8fb 100644 --- a/web/app/components/workflow/skill/constants.ts +++ b/web/app/components/workflow/skill/constants.ts @@ -5,6 +5,9 @@ // Root folder identifier (convert to null for API calls via toApiParentId) export const ROOT_ID = 'root' as const +// Drag type identifier for internal tree node dragging +export const INTERNAL_NODE_DRAG_TYPE = 'application/x-dify-tree-node' + // Context menu trigger types (describes WHERE user clicked) export const CONTEXT_MENU_TYPE = { BLANK: 'blank', diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 7a3e811cbb..c004acaa0a 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -54,19 +54,22 @@ const FileTree: React.FC = ({ className }) => { const { data: treeData, isLoading, error } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 + const expandedFolderIds = useStore(s => s.expandedFolderIds) + const activeTabId = useStore(s => s.activeTabId) + const dragOverFolderId = useStore(s => s.dragOverFolderId) + const currentDragType = useStore(s => s.currentDragType) + const searchTerm = useStore(s => s.fileTreeSearchTerm) + const storeApi = useWorkflowStore() + + const treeChildren = treeData?.children ?? emptyTreeNodes + const { handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop, resetRootDragCounter, - } = useRootFileDrop() - - const expandedFolderIds = useStore(s => s.expandedFolderIds) - const activeTabId = useStore(s => s.activeTabId) - const dragOverFolderId = useStore(s => s.dragOverFolderId) - const searchTerm = useStore(s => s.fileTreeSearchTerm) - const storeApi = useWorkflowStore() + } = useRootFileDrop({ treeChildren }) // Root dropzone highlight (when dragging to root, not to a specific folder) const isRootDropzone = dragOverFolderId === ROOT_ID @@ -76,7 +79,6 @@ const FileTree: React.FC = ({ className }) => { resetRootDragCounter() }, [dragOverFolderId, resetRootDragCounter]) - const treeChildren = treeData?.children ?? emptyTreeNodes const { treeNodes, handleRename, @@ -263,7 +265,7 @@ const FileTree: React.FC = ({ className }) => { {dragOverFolderId - ? + ? : } diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index fc61d8906b..01fe16f9bd 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -4,7 +4,7 @@ import type { NodeRendererProps } from 'react-arborist' import type { TreeNodeData } from '../type' import { RiMoreFill } from '@remixicon/react' import * as React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { PortalToFollowElem, @@ -14,13 +14,17 @@ import { import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { useFolderFileDrop } from '../hooks/use-folder-file-drop' +import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers' +import { useUnifiedDrag } from '../hooks/use-unified-drag' import NodeMenu from './node-menu' import TreeEditInput from './tree-edit-input' import TreeGuideLines from './tree-guide-lines' import { TreeNodeIcon } from './tree-node-icon' -const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) => { +const emptyTreeChildren: TreeNodeData[] = [] + +const TreeNode = ({ node, style }: NodeRendererProps) => { const { t } = useTranslation('workflow') const isFolder = node.data.node_type === 'folder' const isSelected = node.isSelected @@ -31,6 +35,10 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const [showDropdown, setShowDropdown] = useState(false) + // Get tree data from TanStack Query cache (no extra request) + const { data: treeData } = useSkillAssetTreeData() + const treeChildren = useMemo(() => treeData?.children ?? emptyTreeChildren, [treeData?.children]) + const { handleClick, handleDoubleClick, @@ -39,7 +47,13 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) handleKeyDown, } = useTreeNodeHandlers({ node }) - const { isDragOver, isBlinking, dragHandlers } = useFolderFileDrop(node) + const { isDragOver, isBlinking, dragHandlers } = useFolderFileDrop({ node, treeChildren }) + const { handleNodeDragStart, handleNodeDragEnd } = useUnifiedDrag({ treeChildren }) + + // Currently only supports single node drag + const handleDragStart = useCallback((e: React.DragEvent) => { + handleNodeDragStart(e, node.data.id) + }, [handleNodeDragStart, node.data.id]) const handleMoreClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() @@ -48,12 +62,14 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) return (
): UseFolderFileDropReturn { +type UseFolderFileDropOptions = { + node: NodeApi + treeChildren: AppAssetTreeView[] +} + +export function useFolderFileDrop({ node, treeChildren }: UseFolderFileDropOptions): UseFolderFileDropReturn { const isFolder = node.data.node_type === 'folder' const dragOverFolderId = useStore(s => s.dragOverFolderId) const isDragOver = isFolder && dragOverFolderId === node.data.id - const { handleDragOver, handleDrop } = useFileDrop() + const { handleDragOver, handleDrop } = useUnifiedDrag({ treeChildren }) const expandTimerRef = useRef(null) const blinkTimerRef = useRef(null) @@ -80,7 +86,7 @@ export function useFolderFileDrop(node: NodeApi): UseFolderFileDro }, [clearExpandTimer]) const handleFolderDragEnter = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return dragCounterRef.current += 1 if (dragCounterRef.current === 1) @@ -88,13 +94,13 @@ export function useFolderFileDrop(node: NodeApi): UseFolderFileDro }, [isFolder, scheduleAutoExpand]) const handleFolderDragOver = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return handleDragOver(e, { folderId: node.data.id, isFolder: true }) }, [handleDragOver, isFolder, node.data.id]) const handleFolderDragLeave = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0) if (dragCounterRef.current === 0) diff --git a/web/app/components/workflow/skill/hooks/use-node-move.ts b/web/app/components/workflow/skill/hooks/use-node-move.ts new file mode 100644 index 0000000000..d50a2aff1a --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-node-move.ts @@ -0,0 +1,119 @@ +'use client' + +// Internal tree node move handler (drag-and-drop within tree) + +import type { AppAssetTreeView } from '@/types/app-asset' +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 { useWorkflowStore } from '@/app/components/workflow/store' +import { useMoveAppAssetNode } from '@/service/use-app-asset' +import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../constants' +import { findNodeById, isDescendantOf, toApiParentId } from '../utils/tree-utils' + +type NodeMoveTarget = { + folderId: string | null + isFolder: boolean +} + +type UseNodeMoveOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useNodeMove({ treeChildren }: UseNodeMoveOptions) { + const { t } = useTranslation('workflow') + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const storeApi = useWorkflowStore() + const moveNode = useMoveAppAssetNode() + + const handleDragStart = useCallback((e: React.DragEvent, nodeId: string) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData(INTERNAL_NODE_DRAG_TYPE, nodeId) + storeApi.getState().setCurrentDragType('move') + }, [storeApi]) + + const handleDragEnd = useCallback(() => { + storeApi.getState().setCurrentDragType(null) + storeApi.getState().setDragOverFolderId(null) + }, [storeApi]) + + const handleDragOver = useCallback((e: React.DragEvent, target: NodeMoveTarget) => { + e.preventDefault() + e.stopPropagation() + + if (!e.dataTransfer.types.includes(INTERNAL_NODE_DRAG_TYPE)) + return + + e.dataTransfer.dropEffect = 'move' + storeApi.getState().setDragOverFolderId(target.folderId ?? ROOT_ID) + }, [storeApi]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + storeApi.getState().setDragOverFolderId(null) + }, [storeApi]) + + const handleDrop = useCallback(async (e: React.DragEvent, targetFolderId: string | null) => { + e.preventDefault() + e.stopPropagation() + + storeApi.getState().setDragOverFolderId(null) + storeApi.getState().setCurrentDragType(null) + + const nodeId = e.dataTransfer.getData(INTERNAL_NODE_DRAG_TYPE) + if (!nodeId) + return + + // Prevent dropping node into itself + if (nodeId === targetFolderId) { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.cannotMoveToSelf'), + }) + return + } + + // Prevent circular move (dropping folder into its descendant) + const draggedNode = findNodeById(treeChildren, nodeId) + if (draggedNode?.node_type === 'folder' && targetFolderId) { + if (isDescendantOf(targetFolderId, nodeId, treeChildren)) { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.cannotMoveToDescendant'), + }) + return + } + } + + try { + await moveNode.mutateAsync({ + appId, + nodeId, + payload: { parent_id: toApiParentId(targetFolderId) }, + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.moved'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.moveError'), + }) + } + }, [appId, moveNode, t, storeApi, treeChildren]) + + return { + handleDragStart, + handleDragEnd, + handleDragOver, + handleDragLeave, + handleDrop, + isMoving: moveNode.isPending, + } +} diff --git a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts index f7bb8b4d72..7ac6284c0f 100644 --- a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts @@ -2,9 +2,10 @@ // Root-level file drop handler with drag counter to handle nested DOM events +import type { AppAssetTreeView } from '@/types/app-asset' import { useCallback, useRef } from 'react' -import { isFileDrag } from '../utils/drag-utils' -import { useFileDrop } from './use-file-drop' +import { isDragEvent } from '../utils/drag-utils' +import { useUnifiedDrag } from './use-unified-drag' type UseRootFileDropReturn = { handleRootDragEnter: (e: React.DragEvent) => void @@ -14,12 +15,16 @@ type UseRootFileDropReturn = { resetRootDragCounter: () => void } -export function useRootFileDrop(): UseRootFileDropReturn { - const { handleDragOver, handleDragLeave, handleDrop } = useFileDrop() +type UseRootFileDropOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useRootFileDrop({ treeChildren }: UseRootFileDropOptions): UseRootFileDropReturn { + const { handleDragOver, handleDragLeave, handleDrop } = useUnifiedDrag({ treeChildren }) const dragCounterRef = useRef(0) const handleRootDragEnter = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e)) + if (!isDragEvent(e)) return dragCounterRef.current += 1 }, []) @@ -29,7 +34,7 @@ export function useRootFileDrop(): UseRootFileDropReturn { }, [handleDragOver]) const handleRootDragLeave = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e)) + if (!isDragEvent(e)) return dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0) if (dragCounterRef.current === 0) diff --git a/web/app/components/workflow/skill/hooks/use-unified-drag.ts b/web/app/components/workflow/skill/hooks/use-unified-drag.ts new file mode 100644 index 0000000000..54aef387d6 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-unified-drag.ts @@ -0,0 +1,62 @@ +'use client' + +// Unified drag handler that routes to file upload or node move based on drag type + +import type { AppAssetTreeView } from '@/types/app-asset' +import { useCallback } from 'react' +import { getDragActionType, isFileDrag, isNodeDrag } from '../utils/drag-utils' +import { useFileDrop } from './use-file-drop' +import { useNodeMove } from './use-node-move' + +type DragTarget = { + folderId: string | null + isFolder: boolean +} + +type UseUnifiedDragOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useUnifiedDrag({ treeChildren }: UseUnifiedDragOptions) { + const fileDrop = useFileDrop() + const nodeMove = useNodeMove({ treeChildren }) + + const handleDragOver = useCallback((e: React.DragEvent, target: DragTarget) => { + const actionType = getDragActionType(e) + if (actionType === 'upload') { + fileDrop.handleDragOver(e, target) + } + else if (actionType === 'move') { + nodeMove.handleDragOver(e, target) + } + }, [fileDrop, nodeMove]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (isFileDrag(e)) { + fileDrop.handleDragLeave(e) + } + else if (isNodeDrag(e)) { + nodeMove.handleDragLeave(e) + } + }, [fileDrop, nodeMove]) + + const handleDrop = useCallback((e: React.DragEvent, targetFolderId: string | null) => { + if (isFileDrag(e)) { + return fileDrop.handleDrop(e, targetFolderId) + } + else if (isNodeDrag(e)) { + return nodeMove.handleDrop(e, targetFolderId) + } + }, [fileDrop, nodeMove]) + + return { + handleDragOver, + handleDragLeave, + handleDrop, + // Expose individual handlers for specific needs + handleNodeDragStart: nodeMove.handleDragStart, + handleNodeDragEnd: nodeMove.handleDragEnd, + isUploading: fileDrop.isUploading, + isMoving: nodeMove.isMoving, + } +} diff --git a/web/app/components/workflow/skill/utils/drag-utils.ts b/web/app/components/workflow/skill/utils/drag-utils.ts index f8db5df7ed..bc23aafbec 100644 --- a/web/app/components/workflow/skill/utils/drag-utils.ts +++ b/web/app/components/workflow/skill/utils/drag-utils.ts @@ -1,5 +1,27 @@ import type * as React from 'react' +import { INTERNAL_NODE_DRAG_TYPE } from '../constants' +// Check if dragging external files from OS export const isFileDrag = (e: React.DragEvent): boolean => { return e.dataTransfer.types.includes('Files') } + +// Check if dragging internal tree node +export const isNodeDrag = (e: React.DragEvent): boolean => { + return e.dataTransfer.types.includes(INTERNAL_NODE_DRAG_TYPE) +} + +// Check if any supported drag type +export const isDragEvent = (e: React.DragEvent): boolean => { + return isFileDrag(e) || isNodeDrag(e) +} + +// Get drag action type for tooltip display +export type DragActionType = 'upload' | 'move' +export const getDragActionType = (e: React.DragEvent): DragActionType | null => { + if (isFileDrag(e)) + return 'upload' + if (isNodeDrag(e)) + return 'move' + return null +} diff --git a/web/app/components/workflow/skill/utils/tree-utils.ts b/web/app/components/workflow/skill/utils/tree-utils.ts index 7f6886cddb..9836a4632a 100644 --- a/web/app/components/workflow/skill/utils/tree-utils.ts +++ b/web/app/components/workflow/skill/utils/tree-utils.ts @@ -117,6 +117,19 @@ export function getAllDescendantFileIds( return fileIds } +export function isDescendantOf( + potentialDescendantId: string | null | undefined, + ancestorId: string | null | undefined, + nodes: AppAssetTreeView[], +): boolean { + if (!potentialDescendantId || !ancestorId) + return false + if (potentialDescendantId === ancestorId) + return true + const ancestors = getAncestorIds(potentialDescendantId, nodes) + return ancestors.includes(ancestorId) +} + export function getTargetFolderIdFromSelection( selectedId: string | null, nodes: AppAssetTreeView[], 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 cfe3ec4c84..9cd9f6b3f0 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 }) }, + currentDragType: null, + + setCurrentDragType: (type) => { + set({ currentDragType: type }) + }, + fileTreeSearchTerm: '', setFileTreeSearchTerm: (term) => { 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 89a47905cc..b706da8dba 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -23,6 +23,8 @@ export type PendingCreateNode = { nodeType: 'file' | 'folder' } +export type DragActionType = 'upload' | 'move' + export type FileTreeSliceShape = { expandedFolderIds: Set setExpandedFolderIds: (ids: Set) => void @@ -40,6 +42,8 @@ export type FileTreeSliceShape = { clearCreateNode: () => void dragOverFolderId: string | null setDragOverFolderId: (folderId: string | null) => void + currentDragType: DragActionType | null + setCurrentDragType: (type: DragActionType | null) => void fileTreeSearchTerm: string setFileTreeSearchTerm: (term: string) => void } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index bf81a1607f..cad36d593f 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1023,7 +1023,10 @@ "skillSidebar.folderName": "Folder name", "skillSidebar.folderNamePlaceholder": "Folder name", "skillSidebar.loadError": "Failed to load files", + "skillSidebar.menu.cannotMoveToDescendant": "Cannot move a folder into its descendant", + "skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself", "skillSidebar.menu.copy": "Copy", + "skillSidebar.menu.copyNotSupported": "Copy is not supported yet", "skillSidebar.menu.createError": "Failed to create item", "skillSidebar.menu.cut": "Cut", "skillSidebar.menu.delete": "Delete", @@ -1043,15 +1046,13 @@ "skillSidebar.menu.folderDropNotSupported": "Folder upload via drag-drop is not supported yet. Please use the upload folder option.", "skillSidebar.menu.folderUploaded": "Folder uploaded successfully", "skillSidebar.menu.moreActions": "More actions", + "skillSidebar.menu.moveError": "Failed to move", + "skillSidebar.menu.moved": "Moved successfully", "skillSidebar.menu.newFile": "New File", "skillSidebar.menu.newFilePrompt": "Enter file name (with extension, e.g., script.py):", "skillSidebar.menu.newFolder": "New Folder", "skillSidebar.menu.newFolderPrompt": "Enter folder name:", "skillSidebar.menu.paste": "Paste", - "skillSidebar.menu.moved": "Moved successfully", - "skillSidebar.menu.moveError": "Failed to move", - "skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself", - "skillSidebar.menu.copyNotSupported": "Copy is not supported yet", "skillSidebar.menu.rename": "Rename", "skillSidebar.menu.renameError": "Failed to rename", "skillSidebar.menu.renamed": "Renamed successfully", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index f0c47b410c..aef9a33e03 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1015,7 +1015,10 @@ "skillSidebar.folderName": "文件夹名称", "skillSidebar.folderNamePlaceholder": "文件夹名称", "skillSidebar.loadError": "加载文件失败", + "skillSidebar.menu.cannotMoveToDescendant": "无法将文件夹移动到其子文件夹中", + "skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部", "skillSidebar.menu.copy": "复制", + "skillSidebar.menu.copyNotSupported": "暂不支持复制功能", "skillSidebar.menu.createError": "创建失败", "skillSidebar.menu.cut": "剪切", "skillSidebar.menu.delete": "删除", @@ -1034,15 +1037,13 @@ "skillSidebar.menu.folderCreated": "文件夹创建成功", "skillSidebar.menu.folderDropNotSupported": "暂不支持拖拽上传文件夹,请使用上传文件夹选项。", "skillSidebar.menu.folderUploaded": "文件夹上传成功", + "skillSidebar.menu.moveError": "移动失败", + "skillSidebar.menu.moved": "移动成功", "skillSidebar.menu.newFile": "新建文件", "skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):", "skillSidebar.menu.newFolder": "新建文件夹", "skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:", "skillSidebar.menu.paste": "粘贴", - "skillSidebar.menu.moved": "移动成功", - "skillSidebar.menu.moveError": "移动失败", - "skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部", - "skillSidebar.menu.copyNotSupported": "暂不支持复制功能", "skillSidebar.menu.rename": "重命名", "skillSidebar.menu.renameError": "重命名失败", "skillSidebar.menu.renamed": "重命名成功",