refactor(skill): regroup skill body, file tree, and tree hooks

This commit is contained in:
yyh
2026-02-07 14:20:01 +08:00
parent e10996c368
commit 11d5efc13e
52 changed files with 119 additions and 119 deletions

View File

@ -0,0 +1,179 @@
'use client'
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import {
useBatchUpload,
useCreateAppAssetFolder,
useUploadFileWithPresignedUrl,
} from '@/service/use-app-asset'
import { prepareSkillUploadFile } from '../../../utils/skill-upload-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
type UseCreateOperationsOptions = {
parentId: string | null
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
onClose: () => void
}
const getRelativePath = (file: File) => {
return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
}
export function useCreateOperations({
parentId,
appId,
storeApi,
onClose,
}: UseCreateOperationsOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const createFolder = useCreateAppAssetFolder()
const uploadFile = useUploadFileWithPresignedUrl()
const batchUpload = useBatchUpload()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
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
}
const total = files.length
const progress = { uploaded: 0, failed: 0 }
storeApi.getState().setUploadStatus('uploading')
storeApi.getState().setUploadProgress({ uploaded: 0, total, failed: 0 })
try {
const uploadFiles = await Promise.all(files.map(file => prepareSkillUploadFile(file)))
await Promise.all(
uploadFiles.map(async (file) => {
try {
await uploadFile.mutateAsync({ appId, file, parentId })
progress.uploaded++
}
catch {
progress.failed++
}
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}),
)
storeApi.getState().setUploadStatus(progress.failed > 0 ? 'partial_error' : 'success')
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}
catch {
storeApi.getState().setUploadStatus('partial_error')
}
finally {
if (progress.uploaded > 0)
emitTreeUpdate()
e.target.value = ''
onClose()
}
}, [appId, uploadFile, onClose, parentId, storeApi, emitTreeUpdate])
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
onClose()
return
}
storeApi.getState().setUploadStatus('uploading')
storeApi.getState().setUploadProgress({ uploaded: 0, total: files.length, failed: 0 })
try {
const fileMap = new Map<string, File>()
const tree: BatchUploadNodeInput[] = []
const folderMap = new Map<string, BatchUploadNodeInput>()
const uploadFiles = await Promise.all(files.map(async (file) => {
const relativePath = getRelativePath(file)
const uploadFile = await prepareSkillUploadFile(file)
return { relativePath, uploadFile }
}))
for (const { relativePath, uploadFile } of uploadFiles) {
fileMap.set(relativePath, uploadFile)
const parts = relativePath.split('/')
let currentLevel = tree
let currentPath = ''
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isLastPart = i === parts.length - 1
currentPath = currentPath ? `${currentPath}/${part}` : part
if (isLastPart) {
currentLevel.push({
name: part,
node_type: 'file',
size: uploadFile.size,
})
}
else {
let folder = folderMap.get(currentPath)
if (!folder) {
folder = {
name: part,
node_type: 'folder',
children: [],
}
folderMap.set(currentPath, folder)
currentLevel.push(folder)
}
currentLevel = folder.children!
}
}
}
await batchUpload.mutateAsync({
appId,
tree,
files: fileMap,
parentId,
onProgress: (uploaded, total) => {
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
},
})
storeApi.getState().setUploadStatus('success')
storeApi.getState().setUploadProgress({ uploaded: files.length, total: files.length, failed: 0 })
emitTreeUpdate()
}
catch {
storeApi.getState().setUploadStatus('partial_error')
}
finally {
e.target.value = ''
onClose()
}
}, [appId, batchUpload, onClose, parentId, storeApi, emitTreeUpdate])
return {
fileInputRef,
folderInputRef,
isCreating: uploadFile.isPending || createFolder.isPending || batchUpload.isPending,
handleNewFile,
handleNewFolder,
handleFileChange,
handleFolderChange,
}
}

View File

@ -0,0 +1,54 @@
'use client'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { consoleClient } from '@/service/client'
import { downloadUrl } from '@/utils/download'
type UseDownloadOperationOptions = {
appId: string
nodeId: string
fileName?: string
onClose: () => void
}
export function useDownloadOperation({
appId,
nodeId,
fileName,
onClose,
}: UseDownloadOperationOptions) {
const { t } = useTranslation('workflow')
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = useCallback(async () => {
if (!nodeId || !appId)
return
onClose()
setIsDownloading(true)
try {
const { download_url } = await consoleClient.appAsset.getFileDownloadUrl({
params: { appId, nodeId },
})
downloadUrl({ url: download_url, fileName })
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.downloadError'),
})
}
finally {
setIsDownloading(false)
}
}, [appId, nodeId, fileName, onClose, t])
return {
handleDownload,
isDownloading,
}
}

View File

@ -0,0 +1,86 @@
'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 { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { toApiParentId } from '../../../utils/tree-utils'
import { useSkillAssetTreeData } from '../data/use-skill-asset-tree'
import { useCreateOperations } from './use-create-operations'
import { useDownloadOperation } from './use-download-operation'
import { useModifyOperations } from './use-modify-operations'
type UseFileOperationsOptions = {
nodeId?: string
onClose: () => void
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
}
export function useFileOperations({
nodeId: explicitNodeId,
onClose,
treeRef,
node,
}: UseFileOperationsOptions) {
const nodeId = node?.data.id ?? explicitNodeId ?? ''
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const storeApi = useWorkflowStore()
const { data: treeData } = useSkillAssetTreeData()
const parentId = toApiParentId(nodeId)
const createOps = useCreateOperations({
parentId,
appId,
storeApi,
onClose,
})
const modifyOps = useModifyOperations({
nodeId,
node,
treeRef,
appId,
storeApi,
treeData,
onClose,
})
const downloadOps = useDownloadOperation({
appId,
nodeId,
fileName: node?.data.name,
onClose,
})
return {
// 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,
// Download operation
handleDownload: downloadOps.handleDownload,
// Combined loading states
isLoading: createOps.isCreating || modifyOps.isDeleting || downloadOps.isDownloading,
isDeleting: modifyOps.isDeleting,
isDownloading: downloadOps.isDownloading,
}
}

View File

@ -0,0 +1,110 @@
'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'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
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 emitTreeUpdate = useSkillTreeUpdateEmitter()
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 })
emitTreeUpdate()
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, emitTreeUpdate])
const handleDeleteCancel = useCallback(() => {
setShowDeleteConfirm(false)
}, [])
return {
showDeleteConfirm,
isDeleting: deleteNode.isPending,
handleRename,
handleDeleteClick,
handleDeleteConfirm,
handleDeleteCancel,
}
}

View File

@ -0,0 +1,48 @@
'use client'
// Internal tree node move handler - API execution logic only
// Drag state syncing is handled by react-arborist + TreeNode useEffect
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { toApiParentId } from '../../../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
export function useNodeMove() {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const moveNode = useMoveAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
// Execute move API call - validation is handled by react-arborist's disableDrop callback
const executeMoveNode = useCallback(async (nodeId: string, targetFolderId: string | null) => {
try {
await moveNode.mutateAsync({
appId,
nodeId,
payload: { parent_id: toApiParentId(targetFolderId) },
})
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
})
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.moveError'),
})
}
}, [appId, moveNode, t, emitTreeUpdate])
return {
executeMoveNode,
isMoving: moveNode.isPending,
}
}

View File

@ -0,0 +1,45 @@
'use client'
// Internal tree node reorder handler - API execution logic only
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useReorderAppAssetNode } from '@/service/use-app-asset'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
export function useNodeReorder() {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const reorderNode = useReorderAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const executeReorderNode = useCallback(async (nodeId: string, afterNodeId: string | null) => {
try {
await reorderNode.mutateAsync({
appId,
nodeId,
payload: { after_node_id: afterNodeId },
})
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
})
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.moveError'),
})
}
}, [appId, reorderNode, t, emitTreeUpdate])
return {
executeReorderNode,
isReordering: reorderNode.isPending,
}
}

View File

@ -0,0 +1,126 @@
'use client'
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../../type'
import type { AppAssetTreeResponse } from '@/types/app-asset'
import { useCallback, useEffect, 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 { useMoveAppAssetNode } from '@/service/use-app-asset'
import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../../../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
type UsePasteOperationOptions = {
treeRef: RefObject<TreeApi<TreeNodeData> | null>
treeData?: AppAssetTreeResponse
enabled?: boolean
}
type UsePasteOperationReturn = {
isPasting: boolean
handlePaste: () => void
}
export function usePasteOperation({
treeRef,
treeData,
enabled = true,
}: UsePasteOperationOptions): UsePasteOperationReturn {
const { t } = useTranslation('workflow')
const storeApi = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const moveNode = useMoveAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const isPastingRef = useRef(false)
const handlePaste = useCallback(async () => {
if (isPastingRef.current)
return
const clipboard = storeApi.getState().clipboard
if (!clipboard || clipboard.nodeIds.size === 0)
return
const { operation, nodeIds } = clipboard
const tree = treeRef.current
const treeChildren = treeData?.children ?? []
const selectedId = tree?.selectedNodes[0]?.id ?? storeApi.getState().selectedTreeNodeId
const targetFolderId = getTargetFolderIdFromSelection(selectedId, treeChildren)
const targetParentId = toApiParentId(targetFolderId)
if (operation === 'cut') {
const nodeIdsArray = [...nodeIds]
const isMovingToSelf = nodeIdsArray.some((nodeId) => {
const node = findNodeById(treeChildren, nodeId)
if (!node)
return false
if (node.node_type === 'folder' && nodeId === targetFolderId)
return true
return false
})
if (isMovingToSelf) {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.cannotMoveToSelf'),
})
return
}
isPastingRef.current = true
try {
await Promise.all(
nodeIdsArray.map(nodeId =>
moveNode.mutateAsync({
appId,
nodeId,
payload: { parent_id: targetParentId },
}),
),
)
storeApi.getState().clearClipboard()
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
})
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.moveError'),
})
}
finally {
isPastingRef.current = false
}
}
}, [appId, moveNode, storeApi, t, treeData?.children, treeRef, emitTreeUpdate])
useEffect(() => {
if (!enabled)
return
const handlePasteEvent = () => {
handlePaste()
}
window.addEventListener('skill:paste', handlePasteEvent)
return () => {
window.removeEventListener('skill:paste', handlePasteEvent)
}
}, [enabled, handlePaste])
return {
isPasting: moveNode.isPending,
handlePaste,
}
}