mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 03:41:21 +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.
189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import type { NodeRendererProps } from 'react-arborist'
|
|
import type { TreeNodeData } from '../../type'
|
|
import * as React from 'react'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
PortalToFollowElem,
|
|
PortalToFollowElemContent,
|
|
PortalToFollowElemTrigger,
|
|
} from '@/app/components/base/portal-to-follow-elem'
|
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
|
import { cn } from '@/utils/classnames'
|
|
import { useFolderFileDrop } from '../../hooks/file-tree/dnd/use-folder-file-drop'
|
|
import { useTreeNodeHandlers } from '../../hooks/file-tree/interaction/use-tree-node-handlers'
|
|
import NodeMenu from './node-menu'
|
|
import TreeEditInput from './tree-edit-input'
|
|
import TreeGuideLines from './tree-guide-lines'
|
|
import { TreeNodeIcon } from './tree-node-icon'
|
|
|
|
type TreeNodeProps = NodeRendererProps<TreeNodeData> & {
|
|
treeChildren: TreeNodeData[]
|
|
}
|
|
|
|
const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
|
const { t } = useTranslation('workflow')
|
|
const isFolder = node.data.node_type === 'folder'
|
|
const isSelected = node.isSelected
|
|
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
|
|
const isCut = useStore(s => s.isCutNode(node.data.id))
|
|
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
|
const hasContextMenu = contextMenuNodeId === node.data.id
|
|
const storeApi = useWorkflowStore()
|
|
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
|
|
// Sync react-arborist drag state to Zustand for DragActionTooltip
|
|
const prevIsDragging = useRef(node.isDragging)
|
|
useEffect(() => {
|
|
// When drag starts
|
|
if (node.isDragging && !prevIsDragging.current)
|
|
storeApi.getState().setCurrentDragType('move')
|
|
|
|
// When drag ends
|
|
if (!node.isDragging && prevIsDragging.current) {
|
|
storeApi.getState().setCurrentDragType(null)
|
|
storeApi.getState().setDragOverFolderId(null)
|
|
}
|
|
prevIsDragging.current = node.isDragging
|
|
}, [node.isDragging, storeApi])
|
|
|
|
// Sync react-arborist willReceiveDrop to Zustand for DragActionTooltip
|
|
const prevWillReceiveDrop = useRef(node.willReceiveDrop)
|
|
useEffect(() => {
|
|
// When willReceiveDrop becomes true, set dragOverFolderId
|
|
if (isFolder && node.willReceiveDrop && !prevWillReceiveDrop.current)
|
|
storeApi.getState().setDragOverFolderId(node.data.id)
|
|
|
|
// When willReceiveDrop becomes false, clear if this node was the target
|
|
if (isFolder && !node.willReceiveDrop && prevWillReceiveDrop.current) {
|
|
const currentDragOverId = storeApi.getState().dragOverFolderId
|
|
if (currentDragOverId === node.data.id)
|
|
storeApi.getState().setDragOverFolderId(null)
|
|
}
|
|
|
|
prevWillReceiveDrop.current = node.willReceiveDrop
|
|
}, [isFolder, node.willReceiveDrop, node.data.id, storeApi])
|
|
|
|
const {
|
|
handleClick,
|
|
handleDoubleClick,
|
|
handleToggle,
|
|
handleContextMenu,
|
|
handleKeyDown,
|
|
} = useTreeNodeHandlers({ node })
|
|
|
|
// Get file drop visual state (for external file uploads)
|
|
const { isDragOver: isFileDragOver, isBlinking, dragHandlers } = useFolderFileDrop({ node, treeChildren })
|
|
|
|
// Combine internal drag target (willReceiveDrop) with external file drag (isFileDragOver)
|
|
const isDragOver = isFileDragOver || (isFolder && node.willReceiveDrop)
|
|
|
|
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setShowDropdown(prev => !prev)
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
ref={dragHandle}
|
|
style={style}
|
|
role="treeitem"
|
|
tabIndex={0}
|
|
aria-selected={isSelected}
|
|
aria-expanded={isFolder ? node.isOpen : undefined}
|
|
className={cn(
|
|
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
|
|
'hover:bg-state-base-hover',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
|
isSelected && 'bg-state-base-active',
|
|
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
|
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
|
isBlinking && 'animate-drag-blink',
|
|
(isCut || node.isDragging) && 'opacity-50',
|
|
)}
|
|
onKeyDown={handleKeyDown}
|
|
onContextMenu={handleContextMenu}
|
|
{...(isFolder && {
|
|
onDragEnter: dragHandlers.onDragEnter,
|
|
onDragOver: dragHandlers.onDragOver,
|
|
onDrop: dragHandlers.onDrop,
|
|
onDragLeave: dragHandlers.onDragLeave,
|
|
})}
|
|
>
|
|
<TreeGuideLines level={node.level} />
|
|
{/* Main content area - isolated click/double-click handling */}
|
|
<div
|
|
className="flex min-w-0 flex-1 items-center gap-2"
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<div className="flex size-5 shrink-0 items-center justify-center">
|
|
<TreeNodeIcon
|
|
isFolder={isFolder}
|
|
isOpen={node.isOpen}
|
|
fileName={node.data.name}
|
|
extension={node.data.extension}
|
|
isDirty={isDirty}
|
|
onToggle={handleToggle}
|
|
/>
|
|
</div>
|
|
|
|
{node.isEditing
|
|
? (
|
|
<TreeEditInput node={node} />
|
|
)
|
|
: (
|
|
<span
|
|
className={cn(
|
|
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
|
|
isSelected
|
|
? 'text-text-primary'
|
|
: 'text-text-secondary',
|
|
)}
|
|
>
|
|
{node.data.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* More button - separate from main content click handling */}
|
|
<PortalToFollowElem
|
|
placement="bottom-start"
|
|
offset={4}
|
|
open={showDropdown}
|
|
onOpenChange={setShowDropdown}
|
|
>
|
|
<PortalToFollowElemTrigger asChild>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={handleMoreClick}
|
|
className={cn(
|
|
'flex size-5 shrink-0 items-center justify-center rounded',
|
|
'hover:bg-state-base-hover-alt',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
|
'invisible focus-visible:visible group-hover:visible',
|
|
showDropdown && 'visible',
|
|
)}
|
|
aria-label={t('skillSidebar.menu.moreActions')}
|
|
>
|
|
<span className="i-ri-more-fill size-4 text-text-tertiary" aria-hidden="true" />
|
|
</button>
|
|
</PortalToFollowElemTrigger>
|
|
<PortalToFollowElemContent className="z-[100]">
|
|
<NodeMenu
|
|
type={isFolder ? 'folder' : 'file'}
|
|
onClose={() => setShowDropdown(false)}
|
|
node={node}
|
|
/>
|
|
</PortalToFollowElemContent>
|
|
</PortalToFollowElem>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(TreeNode)
|