Files
dify/web/app/components/workflow/skill/file-tree/tree/file-tree.tsx
yyh 9e10b73b54 refactor(skill): replace @remixicon/react imports with CSS icon classes
Migrate all Remixicon component imports in workflow/skill to Tailwind CSS
icon utility classes (i-ri-*), reducing JS bundle size. Update MenuItem
to accept string icon classes alongside React components. Adjust test
selectors that relied on SVG element queries.
2026-02-09 19:51:05 +08:00

438 lines
14 KiB
TypeScript

'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<TreeNodeData> | null | undefined) => {
if (!node || node.isRoot)
return null
return node.id
}
const getSiblingIds = (
parentNode: NodeApi<TreeNodeData> | null | undefined,
tree: TreeApi<TreeNodeData> | 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 (
<div className="flex shrink-0 items-center justify-center gap-2 py-4 text-text-quaternary">
<span className="i-ri-drag-drop-line size-4" aria-hidden="true" />
<span className="system-xs-regular">
{t('skillSidebar.dropTip')}
</span>
</div>
)
}
const FileTree = ({ className }: FileTreeProps) => {
const { t } = useTranslation('workflow')
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
const containerRef = useRef<HTMLDivElement>(null)
const containerSize = useSize(containerRef)
const dragInsertTargetRef = useRef<DragInsertTarget | null>(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<OpensObject>(() => {
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<TreeNodeData>) => {
if (node.data.node_type === 'file')
storeApi.getState().openTab(node.data.id, { pinned: true })
else
node.toggle()
}, [storeApi])
const handleSelect = useCallback((nodes: NodeApi<TreeNodeData>[]) => {
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<MoveHandler<TreeNodeData>>(({ 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<TreeNodeData>
dragNodes: NodeApi<TreeNodeData>[]
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<TreeNodeData>) => {
return <TreeNode {...props} treeChildren={treeChildren} />
}, [treeChildren])
useSyncTreeWithActiveTab({
treeRef,
activeTabId,
syncSignal: dataUpdatedAt,
isTreeLoading: isLoading,
})
useSkillShortcuts({ treeRef })
usePasteOperation({
treeRef,
treeData: treeData ?? undefined,
})
if (isLoading) {
return (
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
<Loading type="area" />
</div>
)
}
if (error) {
return (
<div className={cn('flex min-h-0 flex-1 flex-col items-center justify-center gap-2 text-text-tertiary', className)}>
<span className="system-xs-regular">
{t('skillSidebar.loadError')}
</span>
</div>
)
}
if (treeChildren.length === 0 && !hasPendingCreate) {
return (
<div className={cn('flex min-h-[150px] flex-1 flex-col overflow-y-auto', className)}>
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-text-tertiary system-xs-regular">
{t('skillSidebar.empty')}
</span>
</div>
<UploadStatusTooltip fallback={<DropTip />} />
</div>
)
}
if (hasSearchNoResults) {
return (
<div className={cn('flex min-h-[150px] flex-1 flex-col overflow-y-auto', className)}>
<div className="flex flex-1 flex-col items-center justify-center gap-2 pb-20">
<SearchMenu className="size-8 text-text-tertiary" aria-hidden="true" />
<span className="text-text-secondary system-xs-regular">
{t('skillSidebar.searchNoResults')}
</span>
<Button
variant="secondary-accent"
size="small"
onClick={() => storeApi.getState().setFileTreeSearchTerm('')}
>
{t('skillSidebar.resetFilter')}
</Button>
</div>
</div>
)
}
if (searchTerm) {
return (
<div className={cn('flex min-h-[150px] flex-1 flex-col overflow-y-auto', className)}>
<SearchResultList
searchTerm={searchTerm}
treeChildren={treeChildren as AppAssetTreeView[]}
/>
</div>
)
}
return (
<>
<div
data-skill-tree-container
className={cn(
'flex min-h-[150px] flex-1 flex-col overflow-y-auto',
isMutating && 'pointer-events-none',
className,
)}
>
<div
ref={containerRef}
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1',
isRootDropzone && 'relative rounded-lg bg-state-accent-hover after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:border-[1.5px] after:border-dashed after:border-state-accent-solid after:content-[""]',
)}
onClick={handleBlankAreaClick}
onContextMenu={handleBlankAreaContextMenu}
onDragEnter={handleRootDragEnter}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
<Tree<TreeNodeData>
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}
</Tree>
</div>
{dragOverFolderId
? <DragActionTooltip action={currentDragType ?? 'upload'} />
: <UploadStatusTooltip fallback={<DropTip />} />}
</div>
<TreeContextMenu treeRef={treeRef} />
</>
)
}
export default React.memo(FileTree)