mirror of
https://github.com/langgenius/dify.git
synced 2026-02-22 19:15:47 +08:00
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:
@ -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 (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user