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):",