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:
yyh
2026-01-19 16:57:32 +08:00
parent 4b67008dba
commit a432fa5fcf
7 changed files with 184 additions and 0 deletions

View File

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

View File

@ -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 */}

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

View File

@ -79,4 +79,10 @@ export const createFileTreeSlice: StateCreator<
clearCreateNode: () => {
set({ pendingCreateNode: null })
},
dragOverFolderId: null,
setDragOverFolderId: (folderId) => {
set({ dragOverFolderId: folderId })
},
})

View File

@ -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 = {

View File

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

View 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",