refactor(skill): extract tree node handlers into reusable hooks

Extract complex event handling and side effects from file tree components
into dedicated hooks for better separation of concerns and reusability.
This commit is contained in:
yyh
2026-01-16 14:15:21 +08:00
parent 38a2d2fe68
commit 76da178cc1
4 changed files with 164 additions and 86 deletions

View File

@ -7,7 +7,7 @@ import { RiDragDropLine } from '@remixicon/react'
import { useIsMutating } from '@tanstack/react-query'
import { useSize } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { Tree } from 'react-arborist'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -16,8 +16,8 @@ import Toast from '@/app/components/base/toast'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useRenameAppAssetNode } from '@/service/use-app-asset'
import { cn } from '@/utils/classnames'
import { useRevealActiveTab } from '../hooks/use-reveal-active-tab'
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
import { getAncestorIds } from '../utils/tree-utils'
import TreeContextMenu from './tree-context-menu'
import TreeNode from './tree-node'
@ -85,25 +85,11 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
})
}, [appId, renameNode, t])
useEffect(() => {
if (!activeTabId || !treeData?.children)
return
const tree = treeRef.current
if (!tree)
return
const ancestors = getAncestorIds(activeTabId, treeData.children)
if (ancestors.length > 0)
storeApi.getState().revealFile(ancestors)
requestAnimationFrame(() => {
const node = tree.get(activeTabId)
if (node) {
tree.openParents(node)
tree.scrollTo(activeTabId)
}
})
}, [activeTabId, treeData?.children, storeApi])
useRevealActiveTab({
treeRef,
activeTabId,
treeChildren: treeData?.children,
})
if (isLoading) {
return (

View File

@ -4,9 +4,8 @@ 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 { throttle } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import {
@ -14,9 +13,9 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { useDelayedClick } from '../hooks/use-delayed-click'
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'
@ -29,78 +28,24 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
const hasContextMenu = contextMenuNodeId === node.data.id
const storeApi = useWorkflowStore()
const [showDropdown, setShowDropdown] = useState(false)
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
const throttledToggle = useMemo(
() => throttle(() => node.toggle(), 300, { edges: ['leading'] }),
[node],
)
const openFilePreview = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: false })
}, [node.data.id, storeApi])
const openFilePinned = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: true })
}, [node.data.id, storeApi])
const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({
onSingleClick: openFilePreview,
onDoubleClick: openFilePinned,
})
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
node.select()
if (isFolder)
throttledToggle()
else
handleFileClick()
}
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (isFolder)
throttledToggle()
else
handleFileDoubleClick()
}
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation()
throttledToggle()
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
nodeId: node.data.id,
})
}, [node.data.id, storeApi])
const {
handleClick,
handleDoubleClick,
handleToggle,
handleContextMenu,
handleKeyDown,
} = useTreeNodeHandlers({ node })
const handleMoreClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
setShowDropdown(prev => !prev)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (isFolder)
node.toggle()
else
storeApi.getState().openTab(node.data.id, { pinned: true })
}
}, [isFolder, node, storeApi])
return (
<div
ref={dragHandle}

View File

@ -0,0 +1,47 @@
'use client'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import type { AppAssetTreeView } from '@/types/app-asset'
import { useEffect } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { getAncestorIds } from '../utils/tree-utils'
type UseRevealActiveTabOptions = {
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
activeTabId: string | null
treeChildren: AppAssetTreeView[] | undefined
}
/**
* Hook that handles revealing the active tab in the file tree.
* Expands ancestor folders and scrolls to the active node.
*/
export function useRevealActiveTab({
treeRef,
activeTabId,
treeChildren,
}: UseRevealActiveTabOptions): void {
const storeApi = useWorkflowStore()
useEffect(() => {
if (!activeTabId || !treeChildren)
return
const tree = treeRef.current
if (!tree)
return
const ancestors = getAncestorIds(activeTabId, treeChildren)
if (ancestors.length > 0)
storeApi.getState().revealFile(ancestors)
requestAnimationFrame(() => {
const node = tree.get(activeTabId)
if (node) {
tree.openParents(node)
tree.scrollTo(activeTabId)
}
})
}, [activeTabId, treeChildren, storeApi, treeRef])
}

View File

@ -0,0 +1,100 @@
'use client'
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import { throttle } from 'es-toolkit/function'
import { useCallback, useMemo } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useDelayedClick } from './use-delayed-click'
type UseTreeNodeHandlersOptions = {
node: NodeApi<TreeNodeData>
}
type UseTreeNodeHandlersReturn = {
handleClick: (e: React.MouseEvent) => void
handleDoubleClick: (e: React.MouseEvent) => void
handleToggle: (e: React.MouseEvent) => void
handleContextMenu: (e: React.MouseEvent) => void
handleKeyDown: (e: React.KeyboardEvent) => void
}
/**
* Hook that encapsulates all tree node interaction handlers.
* Handles click, double-click, toggle, context menu, and keyboard events.
*/
export function useTreeNodeHandlers({
node,
}: UseTreeNodeHandlersOptions): UseTreeNodeHandlersReturn {
const storeApi = useWorkflowStore()
const isFolder = node.data.node_type === 'folder'
const throttledToggle = useMemo(
() => throttle(() => node.toggle(), 300, { edges: ['leading'] }),
[node],
)
const openFilePreview = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: false })
}, [node.data.id, storeApi])
const openFilePinned = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: true })
}, [node.data.id, storeApi])
const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({
onSingleClick: openFilePreview,
onDoubleClick: openFilePinned,
})
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
node.select()
if (isFolder)
throttledToggle()
else
handleFileClick()
}, [isFolder, node, throttledToggle, handleFileClick])
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (isFolder)
throttledToggle()
else
handleFileDoubleClick()
}, [isFolder, throttledToggle, handleFileDoubleClick])
const handleToggle = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
throttledToggle()
}, [throttledToggle])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
nodeId: node.data.id,
})
}, [node.data.id, storeApi])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (isFolder)
node.toggle()
else
storeApi.getState().openTab(node.data.id, { pinned: true })
}
}, [isFolder, node, storeApi])
return {
handleClick,
handleDoubleClick,
handleToggle,
handleContextMenu,
handleKeyDown,
}
}