mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
feat: add external file drag-and-drop upload to file tree
Enable users to drag files from their system directly into the file tree to upload them. Files can be dropped on the tree container (uploads to root) or on specific folders. Hovering over a closed folder for 2 seconds auto- expands it. Uses Zustand for drag state management instead of React Context for better performance.
This commit is contained in:
@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileDrop } from '../hooks/use-file-drop'
|
||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||
@ -47,6 +48,22 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
|
||||
const isMutating = useIsMutating() > 0
|
||||
|
||||
// External file drop handling
|
||||
const {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useFileDrop()
|
||||
|
||||
// Handle drag events on the container (drop to root)
|
||||
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
|
||||
handleDragOver(e, { folderId: null, isFolder: false })
|
||||
}, [handleDragOver])
|
||||
|
||||
const handleContainerDrop = useCallback((e: React.DragEvent) => {
|
||||
handleDrop(e, null)
|
||||
}, [handleDrop])
|
||||
|
||||
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
|
||||
@ -148,6 +165,9 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1"
|
||||
onContextMenu={handleBlankAreaContextMenu}
|
||||
onDragOver={handleContainerDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleContainerDrop}
|
||||
>
|
||||
<Tree<TreeNodeData>
|
||||
ref={treeRef}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileDrop } from '../hooks/use-file-drop'
|
||||
import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers'
|
||||
import { getFileIconType } from '../utils/file-utils'
|
||||
import NodeMenu from './node-menu'
|
||||
@ -29,6 +30,10 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||
|
||||
// Drag over state from Zustand store (only subscribe for folders)
|
||||
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
||||
const isDragOver = isFolder && dragOverFolderId === node.data.id
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
||||
@ -41,6 +46,26 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
handleKeyDown,
|
||||
} = useTreeNodeHandlers({ node })
|
||||
|
||||
// External file drop handlers
|
||||
const {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useFileDrop()
|
||||
|
||||
// Folder-specific drag handlers
|
||||
const handleFolderDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
handleDragOver(e, { folderId: node.data.id, isFolder: true })
|
||||
}, [isFolder, node.data.id, handleDragOver])
|
||||
|
||||
const handleFolderDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
handleDrop(e, node.data.id)
|
||||
}, [isFolder, node.data.id, handleDrop])
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDropdown(prev => !prev)
|
||||
@ -60,9 +85,16 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
isSelected && 'bg-state-base-active',
|
||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||
// Drag over highlight for folders (matching Figma design)
|
||||
isDragOver && 'border border-state-accent-solid bg-state-accent-hover',
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...(isFolder && {
|
||||
onDragOver: handleFolderDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleFolderDrop,
|
||||
})}
|
||||
>
|
||||
<TreeGuideLines level={node.level} />
|
||||
{/* Main content area - isolated click/double-click handling */}
|
||||
|
||||
122
web/app/components/workflow/skill/hooks/use-file-drop.ts
Normal file
122
web/app/components/workflow/skill/hooks/use-file-drop.ts
Normal file
@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } 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 { useCreateAppAssetFile } from '@/service/use-app-asset'
|
||||
|
||||
type FileDropTarget = {
|
||||
folderId: string | null
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
export function useFileDrop() {
|
||||
const { t } = useTranslation('workflow')
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
const createFile = useCreateAppAssetFile()
|
||||
|
||||
const expandTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearExpandTimer = useCallback(() => {
|
||||
if (expandTimerRef.current) {
|
||||
clearTimeout(expandTimerRef.current)
|
||||
expandTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, target: FileDropTarget) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only handle file drops from the system (not internal tree drags)
|
||||
if (!e.dataTransfer.types.includes('Files'))
|
||||
return
|
||||
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
|
||||
storeApi.getState().setDragOverFolderId(target.folderId)
|
||||
|
||||
// Auto-expand closed folder after 2 seconds of hovering
|
||||
if (target.isFolder && target.folderId) {
|
||||
clearExpandTimer()
|
||||
expandTimerRef.current = setTimeout(() => {
|
||||
const expandedFolders = storeApi.getState().expandedFolderIds
|
||||
if (!expandedFolders.has(target.folderId!))
|
||||
storeApi.getState().toggleFolder(target.folderId!)
|
||||
}, 2000)
|
||||
}
|
||||
}, [storeApi, clearExpandTimer])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
clearExpandTimer()
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
}, [clearExpandTimer, storeApi])
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
clearExpandTimer()
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
|
||||
// Get files from dataTransfer, filter out directories (which have no type)
|
||||
const items = Array.from(e.dataTransfer.items || [])
|
||||
const files: File[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
// Skip directories - they have isDirectory = true
|
||||
if (entry?.isDirectory) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.folderDropNotSupported'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
if (file)
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0)
|
||||
return
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: targetFolderId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
}, [appId, createFile, t, clearExpandTimer, storeApi])
|
||||
|
||||
return {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
isUploading: createFile.isPending,
|
||||
}
|
||||
}
|
||||
@ -79,4 +79,10 @@ export const createFileTreeSlice: StateCreator<
|
||||
clearCreateNode: () => {
|
||||
set({ pendingCreateNode: null })
|
||||
},
|
||||
|
||||
dragOverFolderId: null,
|
||||
|
||||
setDragOverFolderId: (folderId) => {
|
||||
set({ dragOverFolderId: folderId })
|
||||
},
|
||||
})
|
||||
|
||||
@ -35,6 +35,8 @@ export type FileTreeSliceShape = {
|
||||
pendingCreateNode: PendingCreateNode | null
|
||||
startCreateNode: (nodeType: PendingCreateNode['nodeType'], parentId: PendingCreateNode['parentId']) => void
|
||||
clearCreateNode: () => void
|
||||
dragOverFolderId: string | null
|
||||
setDragOverFolderId: (folderId: string | null) => void
|
||||
}
|
||||
|
||||
export type DirtySliceShape = {
|
||||
|
||||
@ -1018,6 +1018,7 @@
|
||||
"skillSidebar.menu.fileDeleted": "File deleted successfully",
|
||||
"skillSidebar.menu.filesUploaded": "{{count}} file(s) uploaded successfully",
|
||||
"skillSidebar.menu.folderCreated": "Folder created successfully",
|
||||
"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.newFile": "New File",
|
||||
|
||||
@ -1012,6 +1012,7 @@
|
||||
"skillSidebar.menu.fileDeleted": "文件删除成功",
|
||||
"skillSidebar.menu.filesUploaded": "成功上传 {{count}} 个文件",
|
||||
"skillSidebar.menu.folderCreated": "文件夹创建成功",
|
||||
"skillSidebar.menu.folderDropNotSupported": "暂不支持拖拽上传文件夹,请使用上传文件夹选项。",
|
||||
"skillSidebar.menu.folderUploaded": "文件夹上传成功",
|
||||
"skillSidebar.menu.newFile": "新建文件",
|
||||
"skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):",
|
||||
|
||||
Reference in New Issue
Block a user