mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 04:06:13 +08:00
422 lines
14 KiB
TypeScript
422 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 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 { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
|
import { useNodeMove } from '../hooks/use-node-move'
|
|
import { useNodeReorder } from '../hooks/use-node-reorder'
|
|
import { usePasteOperation } from '../hooks/use-paste-operation'
|
|
import { useRootFileDrop } from '../hooks/use-root-file-drop'
|
|
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
|
import { useSkillShortcuts } from '../hooks/use-skill-shortcuts'
|
|
import { useSkillTreeCollaboration } from '../hooks/use-skill-tree-collaboration'
|
|
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
|
import { isDescendantOf } from '../utils/tree-utils'
|
|
import DragActionTooltip from './drag-action-tooltip'
|
|
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 } = 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,
|
|
})
|
|
|
|
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="system-xs-regular text-text-tertiary">
|
|
{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">
|
|
<span className="i-custom-vender-knowledge-search-menu size-8 text-text-tertiary" aria-hidden="true" />
|
|
<span className="system-xs-regular text-text-secondary">
|
|
{t('skillSidebar.searchNoResults')}
|
|
</span>
|
|
<Button
|
|
variant="secondary-accent"
|
|
size="small"
|
|
onClick={() => storeApi.getState().setFileTreeSearchTerm('')}
|
|
>
|
|
{t('skillSidebar.resetFilter')}
|
|
</Button>
|
|
</div>
|
|
</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}
|
|
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)
|