diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 95186ee73c..3eb4a7f022 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -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 = ({ 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 ( 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 74e788266d..c7f9f68243 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -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) 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 (
| 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]) +} diff --git a/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts b/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts new file mode 100644 index 0000000000..e1f4c848f8 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-tree-node-handlers.ts @@ -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 +} + +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, + } +}