From 9f444f1f6a7ae334cad3370bc198143dcd186590 Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 19:13:09 +0800 Subject: [PATCH] refactor(skill): split file operations hook and extract TreeNodeIcon component Split use-file-operations.ts (248 lines) into smaller focused hooks: - use-create-operations.ts for file/folder creation and upload - use-modify-operations.ts for rename and delete operations - use-file-operations.ts now serves as orchestrator maintaining backward compatibility Extract TreeNodeIcon component from tree-node.tsx for cleaner separation of concerns. Add brief comments to drag hooks explaining their purpose and relationships. --- .../skill/file-tree/tree-node-icon.tsx | 59 +++++ .../workflow/skill/file-tree/tree-node.tsx | 40 +-- .../skill/hooks/use-create-operations.ts | 166 ++++++++++++ .../workflow/skill/hooks/use-file-drop.ts | 3 + .../skill/hooks/use-file-operations.ts | 250 +++--------------- .../skill/hooks/use-folder-file-drop.ts | 2 + .../skill/hooks/use-modify-operations.ts | 107 ++++++++ .../skill/hooks/use-root-file-drop.ts | 2 + 8 files changed, 386 insertions(+), 243 deletions(-) create mode 100644 web/app/components/workflow/skill/file-tree/tree-node-icon.tsx create mode 100644 web/app/components/workflow/skill/hooks/use-create-operations.ts create mode 100644 web/app/components/workflow/skill/hooks/use-modify-operations.ts diff --git a/web/app/components/workflow/skill/file-tree/tree-node-icon.tsx b/web/app/components/workflow/skill/file-tree/tree-node-icon.tsx new file mode 100644 index 0000000000..173b756bc6 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree-node-icon.tsx @@ -0,0 +1,59 @@ +'use client' + +// Icon rendering for tree nodes (folder/file icons with dirty indicator) + +import type { FC } from 'react' +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import { RiFolderLine, RiFolderOpenLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import { cn } from '@/utils/classnames' +import { getFileIconType } from '../utils/file-utils' + +type TreeNodeIconProps = { + isFolder: boolean + isOpen: boolean + fileName: string + isDirty: boolean + onToggle?: (e: React.MouseEvent) => void +} + +export const TreeNodeIcon: FC = ({ + isFolder, + isOpen, + fileName, + isDirty, + onToggle, +}) => { + const { t } = useTranslation('workflow') + + if (isFolder) { + return ( + + ) + } + + const fileIconType = getFileIconType(fileName) + + return ( +
+ + {isDirty && ( + + )} +
+ ) +} 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 67efe17e15..351324b0b0 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -2,12 +2,10 @@ import type { NodeRendererProps } from 'react-arborist' import type { TreeNodeData } from '../type' -import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' -import { RiFolderLine, RiFolderOpenLine, RiMoreFill } from '@remixicon/react' +import { RiMoreFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' import { PortalToFollowElem, PortalToFollowElemContent, @@ -17,10 +15,10 @@ import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { useFolderFileDrop } from '../hooks/use-folder-file-drop' import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers' -import { getFileIconType } from '../utils/file-utils' 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 { t } = useTranslation('workflow') @@ -32,8 +30,6 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const [showDropdown, setShowDropdown] = useState(false) - const fileIconType = !isFolder ? getFileIconType(node.data.name) : null - const { handleClick, handleDoubleClick, @@ -85,31 +81,13 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) onDoubleClick={handleDoubleClick} >
- {isFolder - ? ( - - ) - : ( -
- - {isDirty && ( - - )} -
- )} +
{node.isEditing diff --git a/web/app/components/workflow/skill/hooks/use-create-operations.ts b/web/app/components/workflow/skill/hooks/use-create-operations.ts new file mode 100644 index 0000000000..e0a3dba105 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-create-operations.ts @@ -0,0 +1,166 @@ +'use client' + +// Handles file/folder creation and upload operations + +import type { StoreApi } from 'zustand' +import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types' +import { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { + useCreateAppAssetFile, + useCreateAppAssetFolder, +} from '@/service/use-app-asset' + +type UseCreateOperationsOptions = { + parentId: string | null + appId: string + storeApi: StoreApi + onClose: () => void +} + +export function useCreateOperations({ + parentId, + appId, + storeApi, + onClose, +}: UseCreateOperationsOptions) { + const { t } = useTranslation('workflow') + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + const createFolder = useCreateAppAssetFolder() + const createFile = useCreateAppAssetFile() + + const handleNewFile = useCallback(() => { + storeApi.getState().startCreateNode('file', parentId) + onClose() + }, [onClose, parentId, storeApi]) + + const handleNewFolder = useCallback(() => { + storeApi.getState().startCreateNode('folder', parentId) + onClose() + }, [onClose, parentId, storeApi]) + + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) { + onClose() + return + } + + try { + for (const file of files) { + await createFile.mutateAsync({ + appId, + name: file.name, + file, + parentId, + }) + } + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.filesUploaded', { count: files.length }), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.uploadError'), + }) + } + finally { + e.target.value = '' + onClose() + } + }, [appId, createFile, onClose, parentId, t]) + + const handleFolderChange = useCallback(async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) { + onClose() + return + } + + try { + const folders = new Set() + + for (const file of files) { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + const parts = relativePath.split('/') + + if (parts.length > 1) { + let folderPath = '' + for (let i = 0; i < parts.length - 1; i++) { + folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i] + folders.add(folderPath) + } + } + } + + const sortedFolders = Array.from(folders).sort((a, b) => { + return a.split('/').length - b.split('/').length + }) + + const folderIdMap = new Map() + folderIdMap.set('', parentId) + + for (const folderPath of sortedFolders) { + const parts = folderPath.split('/') + const folderName = parts[parts.length - 1] + const parentPath = parts.slice(0, -1).join('/') + const parentFolderId = folderIdMap.get(parentPath) ?? parentId + + const result = await createFolder.mutateAsync({ + appId, + payload: { + name: folderName, + parent_id: parentFolderId, + }, + }) + + folderIdMap.set(folderPath, result.id) + } + + for (const file of files) { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + const parts = relativePath.split('/') + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : '' + const targetParentId = folderIdMap.get(parentPath) ?? parentId + + await createFile.mutateAsync({ + appId, + name: file.name, + file, + parentId: targetParentId, + }) + } + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.folderUploaded'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.uploadError'), + }) + } + finally { + e.target.value = '' + onClose() + } + }, [appId, createFile, createFolder, onClose, parentId, t]) + + return { + fileInputRef, + folderInputRef, + isCreating: createFile.isPending || createFolder.isPending, + handleNewFile, + handleNewFolder, + handleFileChange, + handleFolderChange, + } +} diff --git a/web/app/components/workflow/skill/hooks/use-file-drop.ts b/web/app/components/workflow/skill/hooks/use-file-drop.ts index d4d5e072ba..ecbd7cc7c8 100644 --- a/web/app/components/workflow/skill/hooks/use-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-file-drop.ts @@ -1,5 +1,8 @@ 'use client' +// Base drag-and-drop handler for file uploads +// Used by use-root-file-drop and use-folder-file-drop + import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' diff --git a/web/app/components/workflow/skill/hooks/use-file-operations.ts b/web/app/components/workflow/skill/hooks/use-file-operations.ts index d5013460b6..11c337abad 100644 --- a/web/app/components/workflow/skill/hooks/use-file-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-file-operations.ts @@ -1,18 +1,14 @@ 'use client' +// Orchestrator hook for file operations - combines create and modify operations +// Maintains backward compatibility for existing consumers + import type { NodeApi, TreeApi } from 'react-arborist' import type { TreeNodeData } from '../type' -import { useCallback, useRef, useState } 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, - useCreateAppAssetFolder, - useDeleteAppAssetNode, -} from '@/service/use-app-asset' -import { getAllDescendantFileIds } from '../utils/tree-utils' +import { useCreateOperations } from './use-create-operations' +import { useModifyOperations } from './use-modify-operations' import { useSkillAssetTreeData } from './use-skill-asset-tree' type UseFileOperationsOptions = { @@ -29,219 +25,49 @@ export function useFileOperations({ node, }: UseFileOperationsOptions) { const nodeId = node?.data.id ?? explicitNodeId ?? '' - const { t } = useTranslation('workflow') - const fileInputRef = useRef(null) - const folderInputRef = useRef(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' const storeApi = useWorkflowStore() - - const createFolder = useCreateAppAssetFolder() - const createFile = useCreateAppAssetFile() - const deleteNode = useDeleteAppAssetNode() const { data: treeData } = useSkillAssetTreeData() const parentId = nodeId === 'root' ? null : nodeId - const handleNewFile = useCallback(() => { - storeApi.getState().startCreateNode('file', parentId) - onClose() - }, [onClose, parentId, storeApi]) + const createOps = useCreateOperations({ + parentId, + appId, + storeApi, + onClose, + }) - const handleNewFolder = useCallback(() => { - storeApi.getState().startCreateNode('folder', parentId) - onClose() - }, [onClose, parentId, storeApi]) - - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []) - if (files.length === 0) { - onClose() - return - } - - try { - for (const file of files) { - await createFile.mutateAsync({ - appId, - name: file.name, - file, - parentId, - }) - } - - Toast.notify({ - type: 'success', - message: t('skillSidebar.menu.filesUploaded', { count: files.length }), - }) - } - catch { - Toast.notify({ - type: 'error', - message: t('skillSidebar.menu.uploadError'), - }) - } - finally { - e.target.value = '' - onClose() - } - }, [appId, createFile, onClose, parentId, t]) - - const handleFolderChange = useCallback(async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []) - if (files.length === 0) { - onClose() - return - } - - try { - const folders = new Set() - - for (const file of files) { - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name - const parts = relativePath.split('/') - - if (parts.length > 1) { - let folderPath = '' - for (let i = 0; i < parts.length - 1; i++) { - folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i] - folders.add(folderPath) - } - } - } - - const sortedFolders = Array.from(folders).sort((a, b) => { - return a.split('/').length - b.split('/').length - }) - - const folderIdMap = new Map() - folderIdMap.set('', parentId) - - for (const folderPath of sortedFolders) { - const parts = folderPath.split('/') - const folderName = parts[parts.length - 1] - const parentPath = parts.slice(0, -1).join('/') - const parentFolderId = folderIdMap.get(parentPath) ?? parentId - - const result = await createFolder.mutateAsync({ - appId, - payload: { - name: folderName, - parent_id: parentFolderId, - }, - }) - - folderIdMap.set(folderPath, result.id) - } - - for (const file of files) { - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name - const parts = relativePath.split('/') - const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : '' - const targetParentId = folderIdMap.get(parentPath) ?? parentId - - await createFile.mutateAsync({ - appId, - name: file.name, - file, - parentId: targetParentId, - }) - } - - Toast.notify({ - type: 'success', - message: t('skillSidebar.menu.folderUploaded'), - }) - } - catch { - Toast.notify({ - type: 'error', - message: t('skillSidebar.menu.uploadError'), - }) - } - finally { - e.target.value = '' - onClose() - } - }, [appId, createFile, createFolder, onClose, parentId, t]) - - const handleRename = useCallback(() => { - if (treeRef?.current) { - const targetNode = treeRef.current.get(nodeId) - targetNode?.edit() - } - else if (node) { - node.edit() - } - onClose() - }, [nodeId, node, onClose, treeRef]) - - const handleDeleteClick = useCallback(() => { - setShowDeleteConfirm(true) - }, []) - - const handleDeleteConfirm = useCallback(async () => { - const isFolder = node?.data?.node_type === 'folder' - try { - const descendantFileIds = treeData?.children - ? getAllDescendantFileIds(nodeId, treeData.children) - : [] - - await deleteNode.mutateAsync({ appId, nodeId }) - - descendantFileIds.forEach((fileId) => { - storeApi.getState().closeTab(fileId) - storeApi.getState().clearDraftContent(fileId) - }) - - // Also close and clear the node itself if it's a file - if (!isFolder) { - storeApi.getState().closeTab(nodeId) - storeApi.getState().clearDraftContent(nodeId) - } - - Toast.notify({ - type: 'success', - message: isFolder - ? t('skillSidebar.menu.deleted') - : t('skillSidebar.menu.fileDeleted'), - }) - } - catch { - Toast.notify({ - type: 'error', - message: isFolder - ? t('skillSidebar.menu.deleteError') - : t('skillSidebar.menu.fileDeleteError'), - }) - } - finally { - setShowDeleteConfirm(false) - onClose() - } - }, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t]) - - const handleDeleteCancel = useCallback(() => { - setShowDeleteConfirm(false) - }, []) - - const isLoading = createFile.isPending || createFolder.isPending || deleteNode.isPending + const modifyOps = useModifyOperations({ + nodeId, + node, + treeRef, + appId, + storeApi, + treeData, + onClose, + }) return { - fileInputRef, - folderInputRef, - showDeleteConfirm, - isLoading, - isDeleting: deleteNode.isPending, - handleNewFile, - handleNewFolder, - handleFileChange, - handleFolderChange, - handleRename, - handleDeleteClick, - handleDeleteConfirm, - handleDeleteCancel, + // Create operations + fileInputRef: createOps.fileInputRef, + folderInputRef: createOps.folderInputRef, + handleNewFile: createOps.handleNewFile, + handleNewFolder: createOps.handleNewFolder, + handleFileChange: createOps.handleFileChange, + handleFolderChange: createOps.handleFolderChange, + + // Modify operations + showDeleteConfirm: modifyOps.showDeleteConfirm, + handleRename: modifyOps.handleRename, + handleDeleteClick: modifyOps.handleDeleteClick, + handleDeleteConfirm: modifyOps.handleDeleteConfirm, + handleDeleteCancel: modifyOps.handleDeleteCancel, + + // Combined loading states + isLoading: createOps.isCreating || modifyOps.isDeleting, + isDeleting: modifyOps.isDeleting, } } diff --git a/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts b/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts index b7a7997523..91ed916245 100644 --- a/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts @@ -1,5 +1,7 @@ 'use client' +// Folder node file drop handler with VSCode-style blink animation and auto-expand + import type { NodeApi } from 'react-arborist' import type { TreeNodeData } from '../type' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/web/app/components/workflow/skill/hooks/use-modify-operations.ts b/web/app/components/workflow/skill/hooks/use-modify-operations.ts new file mode 100644 index 0000000000..4d153e2312 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-modify-operations.ts @@ -0,0 +1,107 @@ +'use client' + +// Handles file/folder rename and delete operations + +import type { NodeApi, TreeApi } from 'react-arborist' +import type { StoreApi } from 'zustand' +import type { TreeNodeData } from '../type' +import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types' +import type { AppAssetTreeResponse } from '@/types/app-asset' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { useDeleteAppAssetNode } from '@/service/use-app-asset' +import { getAllDescendantFileIds } from '../utils/tree-utils' + +type UseModifyOperationsOptions = { + nodeId: string + node?: NodeApi + treeRef?: React.RefObject | null> + appId: string + storeApi: StoreApi + treeData?: AppAssetTreeResponse + onClose: () => void +} + +export function useModifyOperations({ + nodeId, + node, + treeRef, + appId, + storeApi, + treeData, + onClose, +}: UseModifyOperationsOptions) { + const { t } = useTranslation('workflow') + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const deleteNode = useDeleteAppAssetNode() + + const handleRename = useCallback(() => { + if (treeRef?.current) { + const targetNode = treeRef.current.get(nodeId) + targetNode?.edit() + } + else if (node) { + node.edit() + } + onClose() + }, [nodeId, node, onClose, treeRef]) + + const handleDeleteClick = useCallback(() => { + setShowDeleteConfirm(true) + }, []) + + const handleDeleteConfirm = useCallback(async () => { + const isFolder = node?.data?.node_type === 'folder' + try { + const descendantFileIds = treeData?.children + ? getAllDescendantFileIds(nodeId, treeData.children) + : [] + + await deleteNode.mutateAsync({ appId, nodeId }) + + descendantFileIds.forEach((fileId) => { + storeApi.getState().closeTab(fileId) + storeApi.getState().clearDraftContent(fileId) + }) + + // Also close and clear the node itself if it's a file + if (!isFolder) { + storeApi.getState().closeTab(nodeId) + storeApi.getState().clearDraftContent(nodeId) + } + + Toast.notify({ + type: 'success', + message: isFolder + ? t('skillSidebar.menu.deleted') + : t('skillSidebar.menu.fileDeleted'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: isFolder + ? t('skillSidebar.menu.deleteError') + : t('skillSidebar.menu.fileDeleteError'), + }) + } + finally { + setShowDeleteConfirm(false) + onClose() + } + }, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t]) + + const handleDeleteCancel = useCallback(() => { + setShowDeleteConfirm(false) + }, []) + + return { + showDeleteConfirm, + isDeleting: deleteNode.isPending, + handleRename, + handleDeleteClick, + handleDeleteConfirm, + handleDeleteCancel, + } +} 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 e2e9a31bbc..f7bb8b4d72 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 @@ -1,5 +1,7 @@ 'use client' +// Root-level file drop handler with drag counter to handle nested DOM events + import { useCallback, useRef } from 'react' import { isFileDrag } from '../utils/drag-utils' import { useFileDrop } from './use-file-drop'