From a432fa5fcfca6eedb36f6db5c5f3fc148073d6ad Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 16:57:32 +0800 Subject: [PATCH] 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. --- .../workflow/skill/file-tree/index.tsx | 20 +++ .../workflow/skill/file-tree/tree-node.tsx | 32 +++++ .../workflow/skill/hooks/use-file-drop.ts | 122 ++++++++++++++++++ .../workflow/skill-editor/file-tree-slice.ts | 6 + .../store/workflow/skill-editor/types.ts | 2 + web/i18n/en-US/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + 7 files changed, 184 insertions(+) create mode 100644 web/app/components/workflow/skill/hooks/use-file-drop.ts diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index ae4593e687..8023e24b3b 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -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 = ({ 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 = ({ 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} > ref={treeRef} 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 c7f9f68243..9a5257dbb8 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -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) 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) 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) '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, + })} > {/* Main content area - isolated click/double-click handling */} diff --git a/web/app/components/workflow/skill/hooks/use-file-drop.ts b/web/app/components/workflow/skill/hooks/use-file-drop.ts new file mode 100644 index 0000000000..6bedadbff1 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-file-drop.ts @@ -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(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, + } +} 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 d134df518b..15e53cfc15 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 @@ -79,4 +79,10 @@ export const createFileTreeSlice: StateCreator< clearCreateNode: () => { set({ pendingCreateNode: null }) }, + + dragOverFolderId: null, + + setDragOverFolderId: (folderId) => { + set({ dragOverFolderId: folderId }) + }, }) 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 ad02f0267f..d1445ca150 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -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 = { diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 80f05b3f96..050d20e604 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 725bdedc4a..dbcba111ac 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -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):",