mirror of
https://github.com/langgenius/dify.git
synced 2026-03-05 15:47:06 +08:00
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.
438 lines
14 KiB
TypeScript
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)
|