mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 08:58:09 +08:00
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
'use client'
|
|
|
|
import type { NodeApi, TreeApi } from 'react-arborist'
|
|
import type { TreeNodeData } from '../type'
|
|
import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor/file-tree-slice'
|
|
import { RiDragDropLine } from '@remixicon/react'
|
|
import { useIsMutating } from '@tanstack/react-query'
|
|
import { useSize } from 'ahooks'
|
|
import * as React from 'react'
|
|
import { useCallback, useMemo, useRef } from 'react'
|
|
import { Tree } from 'react-arborist'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Loading from '@/app/components/base/loading'
|
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
|
import { cn } from '@/utils/classnames'
|
|
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
|
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
|
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
|
import TreeContextMenu from './tree-context-menu'
|
|
import TreeNode from './tree-node'
|
|
|
|
type FileTreeProps = {
|
|
className?: string
|
|
searchTerm?: string
|
|
}
|
|
|
|
const emptyTreeNodes: TreeNodeData[] = []
|
|
|
|
const DropTip = () => {
|
|
const { t } = useTranslation('workflow')
|
|
return (
|
|
<div className="flex shrink-0 items-center justify-center gap-2 py-4 text-text-quaternary">
|
|
<RiDragDropLine className="size-4" />
|
|
<span className="system-xs-regular">
|
|
{t('skillSidebar.dropTip')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
|
const { t } = useTranslation('workflow')
|
|
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const containerSize = useSize(containerRef)
|
|
|
|
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
|
|
const isMutating = useIsMutating() > 0
|
|
|
|
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
|
const activeTabId = useStore(s => s.activeTabId)
|
|
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
|
|
const storeApi = useWorkflowStore()
|
|
|
|
const treeChildren = treeData?.children ?? emptyTreeNodes
|
|
const {
|
|
treeNodes,
|
|
handleRename,
|
|
searchMatch,
|
|
hasPendingCreate,
|
|
} = useInlineCreateNode({
|
|
treeRef,
|
|
treeChildren,
|
|
})
|
|
|
|
const initialOpensObject = useMemo<OpensObject>(() => {
|
|
return Object.fromEntries(
|
|
[...expandedFolderIds].map(id => [id, true]),
|
|
)
|
|
}, [expandedFolderIds])
|
|
|
|
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>[]) => {
|
|
if (activeTabId) {
|
|
storeApi.getState().setSelectedTreeNodeId(activeTabId)
|
|
return
|
|
}
|
|
const selectedId = nodes[0]?.id ?? null
|
|
storeApi.getState().setSelectedTreeNodeId(selectedId)
|
|
storeApi.getState().setCreateTargetNodeId(selectedId)
|
|
}, [activeTabId, storeApi])
|
|
|
|
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
storeApi.getState().setContextMenu({
|
|
top: e.clientY,
|
|
left: e.clientX,
|
|
type: 'blank',
|
|
})
|
|
}, [storeApi])
|
|
|
|
useSyncTreeWithActiveTab({
|
|
treeRef,
|
|
activeTabId,
|
|
})
|
|
|
|
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-0 flex-1 flex-col', 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>
|
|
<DropTip />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'flex min-h-0 flex-1 flex-col',
|
|
isMutating && 'pointer-events-none opacity-50',
|
|
className,
|
|
)}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1"
|
|
onContextMenu={handleBlankAreaContextMenu}
|
|
>
|
|
<Tree<TreeNodeData>
|
|
ref={treeRef}
|
|
data={treeNodes}
|
|
idAccessor="id"
|
|
childrenAccessor="children"
|
|
width="100%"
|
|
height={containerSize?.height ?? 400}
|
|
rowHeight={24}
|
|
indent={20}
|
|
overscanCount={5}
|
|
openByDefault={false}
|
|
selection={activeTabId ?? selectedTreeNodeId ?? undefined}
|
|
initialOpenState={initialOpensObject}
|
|
onToggle={handleToggle}
|
|
onSelect={handleSelect}
|
|
onActivate={handleActivate}
|
|
onRename={handleRename}
|
|
searchTerm={searchTerm}
|
|
searchMatch={searchMatch}
|
|
disableDrag
|
|
disableDrop
|
|
>
|
|
{TreeNode}
|
|
</Tree>
|
|
</div>
|
|
</div>
|
|
<DropTip />
|
|
<TreeContextMenu treeRef={treeRef} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default React.memo(FileTree)
|