'use client' import type { MoveHandler, NodeApi, NodeRendererProps, TreeApi } from 'react-arborist' import type { TreeNodeData } from '../../type' import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor/file-tree-slice' import type { AppAssetTreeView } from '@/types/app-asset' import { useIsMutating } from '@tanstack/react-query' import { useSize } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react' import { Tree } from 'react-arborist' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import SearchMenu from '@/app/components/base/icons/src/vender/knowledge/SearchMenu' import Loading from '@/app/components/base/loading' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { CONTEXT_MENU_TYPE, ROOT_ID } from '../../constants' import { useSkillAssetTreeData } from '../../hooks/file-tree/data/use-skill-asset-tree' import { useSkillTreeCollaboration } from '../../hooks/file-tree/data/use-skill-tree-collaboration' import { useRootFileDrop } from '../../hooks/file-tree/dnd/use-root-file-drop' import { useInlineCreateNode } from '../../hooks/file-tree/interaction/use-inline-create-node' import { useSkillShortcuts } from '../../hooks/file-tree/interaction/use-skill-shortcuts' import { useSyncTreeWithActiveTab } from '../../hooks/file-tree/interaction/use-sync-tree-with-active-tab' import { useNodeMove } from '../../hooks/file-tree/operations/use-node-move' import { useNodeReorder } from '../../hooks/file-tree/operations/use-node-reorder' import { usePasteOperation } from '../../hooks/file-tree/operations/use-paste-operation' import { isDescendantOf } from '../../utils/tree-utils' import DragActionTooltip from './drag-action-tooltip' import SearchResultList from './search-result-list' import TreeContextMenu from './tree-context-menu' import TreeNode from './tree-node' import UploadStatusTooltip from './upload-status-tooltip' type FileTreeProps = { className?: string } const emptyTreeNodes: TreeNodeData[] = [] type DragInsertTarget = { parentId: string | null index: number } const normalizeParentId = (node: NodeApi | null | undefined) => { if (!node || node.isRoot) return null return node.id } const getSiblingIds = ( parentNode: NodeApi | null | undefined, tree: TreeApi | null, ): string[] => { const children = parentNode?.children ?? tree?.root.children ?? [] return children.map(child => child.id) } const getAfterNodeIdForReorder = ( siblingIds: string[], draggedId: string, targetIndex: number, ): string | null | undefined => { const originalIndex = siblingIds.indexOf(draggedId) if (originalIndex === -1) return undefined let adjustedIndex = targetIndex if (targetIndex > originalIndex) adjustedIndex -= 1 const siblingsWithoutDragged = siblingIds.filter(id => id !== draggedId) const insertIndex = Math.min(Math.max(adjustedIndex, 0), siblingsWithoutDragged.length) if (insertIndex === 0) return null return siblingsWithoutDragged[insertIndex - 1] ?? null } const DropTip = () => { const { t } = useTranslation('workflow') return (
) } const FileTree = ({ className }: FileTreeProps) => { const { t } = useTranslation('workflow') const treeRef = useRef>(null) const containerRef = useRef(null) const containerSize = useSize(containerRef) const dragInsertTargetRef = useRef(null) const { data: treeData, isLoading, error, dataUpdatedAt } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 const expandedFolderIds = useStore(s => s.expandedFolderIds) const activeTabId = useStore(s => s.activeTabId) const dragOverFolderId = useStore(s => s.dragOverFolderId) const currentDragType = useStore(s => s.currentDragType) const searchTerm = useStore(s => s.fileTreeSearchTerm) const storeApi = useWorkflowStore() const treeChildren = treeData?.children ?? emptyTreeNodes useSkillTreeCollaboration() const { handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop, resetRootDragCounter, } = useRootFileDrop({ treeChildren }) // Root dropzone highlight (when dragging to root, not to a specific folder) const isRootDropzone = dragOverFolderId === ROOT_ID useEffect(() => { if (!dragOverFolderId) resetRootDragCounter() }, [dragOverFolderId, resetRootDragCounter]) const { treeNodes, handleRename, searchMatch, hasPendingCreate, } = useInlineCreateNode({ treeRef, treeChildren, }) const initialOpensObject = useMemo(() => { return Object.fromEntries( [...expandedFolderIds].map(id => [id, true]), ) }, [expandedFolderIds]) // Check if search has no results (has search term but no matches) const hasSearchNoResults = useMemo(() => { if (!searchTerm || treeChildren.length === 0) return false const lowerSearchTerm = searchTerm.toLowerCase() const checkMatch = (nodes: TreeNodeData[]): boolean => { for (const node of nodes) { if (node.name.toLowerCase().includes(lowerSearchTerm)) return true if (node.children && checkMatch(node.children)) return true } return false } return !checkMatch(treeChildren) }, [searchTerm, treeChildren]) const handleToggle = useCallback((id: string) => { storeApi.getState().toggleFolder(id) }, [storeApi]) const handleActivate = useCallback((node: NodeApi) => { if (node.data.node_type === 'file') storeApi.getState().openTab(node.data.id, { pinned: true }) else node.toggle() }, [storeApi]) const handleSelect = useCallback((nodes: NodeApi[]) => { storeApi.getState().setSelectedNodeIds(nodes.map(n => n.id)) }, [storeApi]) const handleBlankAreaClick = useCallback(() => { treeRef.current?.deselectAll() storeApi.getState().clearSelection() }, [storeApi, treeRef]) const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault() treeRef.current?.deselectAll() storeApi.getState().clearSelection() storeApi.getState().setContextMenu({ top: e.clientY, left: e.clientX, type: CONTEXT_MENU_TYPE.BLANK, }) }, [storeApi, treeRef]) // Node move API (for internal drag-drop) const { executeMoveNode } = useNodeMove() const { executeReorderNode } = useNodeReorder() const syncDragInsertTarget = useCallback(() => { const tree = treeRef.current if (!tree) return const { id, destinationParentId, destinationIndex } = tree.state.nodes.drag if (!id || destinationIndex === null) { if (dragInsertTargetRef.current) { dragInsertTargetRef.current = null storeApi.getState().setDragInsertTarget(null) } return } const normalizedParentId = destinationParentId === tree.root.id ? null : destinationParentId const nextTarget = { parentId: normalizedParentId, index: destinationIndex } const prevTarget = dragInsertTargetRef.current if (prevTarget?.parentId === nextTarget.parentId && prevTarget?.index === nextTarget.index) return dragInsertTargetRef.current = nextTarget storeApi.getState().setDragInsertTarget(nextTarget) }, [storeApi]) useEffect(() => { const tree = treeRef.current if (!tree) return syncDragInsertTarget() const unsubscribe = tree.store.subscribe(syncDragInsertTarget) return () => { unsubscribe() dragInsertTargetRef.current = null storeApi.getState().setDragInsertTarget(null) } }, [syncDragInsertTarget, storeApi, treeNodes.length]) // react-arborist onMove callback - called when internal drag completes const handleMove = useCallback>(({ dragIds, parentId, index, dragNodes, parentNode }) => { // Only support single node drag for now const nodeId = dragIds[0] const draggedNode = dragNodes[0] if (!nodeId || !draggedNode) return const tree = treeRef.current const destinationIndex = tree?.dragDestinationIndex const isInsertLine = destinationIndex !== null && destinationIndex !== undefined const targetParentId = parentId ?? null const sourceParentId = normalizeParentId(draggedNode.parent) if (isInsertLine && sourceParentId === targetParentId) { const siblingIds = getSiblingIds(parentNode, tree) const afterNodeId = getAfterNodeIdForReorder( siblingIds, nodeId, destinationIndex ?? index, ) if (afterNodeId !== undefined) { executeReorderNode(nodeId, afterNodeId) return } } // parentId from react-arborist is null for root, otherwise folder ID executeMoveNode(nodeId, targetParentId) }, [executeMoveNode, executeReorderNode, treeRef]) // react-arborist disableDrop callback - returns true to prevent drop const handleDisableDrop = useCallback((args: { parentNode: NodeApi dragNodes: NodeApi[] index: number }) => { const { dragNodes, parentNode } = args // 1. Files cannot be drop targets - only folders can receive drops if (parentNode.data.node_type === 'file') return true // 2. Cannot drop node into itself const draggedNode = dragNodes[0] if (!draggedNode) return true if (draggedNode.id === parentNode.id) return true // 3. Prevent circular move (folder into its descendant) if (draggedNode.data.node_type === 'folder') { const treeChildrenTyped = treeChildren as AppAssetTreeView[] if (isDescendantOf(parentNode.id, draggedNode.id, treeChildrenTyped)) return true } // Note: We don't prevent dropping to same parent (no-op move) // The API handles this gracefully return false }, [treeChildren]) const renderTreeNode = useCallback((props: NodeRendererProps) => { return }, [treeChildren]) useSyncTreeWithActiveTab({ treeRef, activeTabId, syncSignal: dataUpdatedAt, isTreeLoading: isLoading, }) useSkillShortcuts({ treeRef }) usePasteOperation({ treeRef, treeData: treeData ?? undefined, }) if (isLoading) { return (
) } if (error) { return (
{t('skillSidebar.loadError')}
) } if (treeChildren.length === 0 && !hasPendingCreate) { return (
{t('skillSidebar.empty')}
} />
) } if (hasSearchNoResults) { return (
) } if (searchTerm) { return (
) } return ( <>
ref={treeRef} data={treeNodes} idAccessor="id" childrenAccessor="children" width="100%" height={containerSize?.height ?? 400} rowHeight={24} indent={20} overscanCount={5} openByDefault={false} initialOpenState={initialOpensObject} rowClassName="outline-none" onToggle={handleToggle} onSelect={handleSelect} onActivate={handleActivate} onRename={handleRename} onMove={handleMove} searchTerm={searchTerm} searchMatch={searchMatch} disableDrop={handleDisableDrop} > {renderTreeNode}
{dragOverFolderId ? : } />}
) } export default React.memo(FileTree)