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.
This commit is contained in:
yyh
2026-01-19 19:13:09 +08:00
parent 49effca35d
commit 9f444f1f6a
8 changed files with 386 additions and 243 deletions

View File

@ -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<SkillEditorSliceShape>
onClose: () => void
}
export function useCreateOperations({
parentId,
appId,
storeApi,
onClose,
}: UseCreateOperationsOptions) {
const { t } = useTranslation('workflow')
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
onClose()
return
}
try {
const folders = new Set<string>()
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<string, string | null>()
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,
}
}

View File

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

View File

@ -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<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
onClose()
return
}
try {
const folders = new Set<string>()
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<string, string | null>()
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,
}
}

View File

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

View File

@ -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<TreeNodeData>
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
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,
}
}

View File

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