dify/web/app/components/workflow/skill/file-tree/artifacts-tree.tsx
yyh d098e72c13
feat(variable-inspect): add Artifacts tab with sandbox file tree browser
Refactor the variable inspect panel into a tabbed layout with Variables
and Artifacts tabs. Extract variable logic into VariablesTab, add new
ArtifactsTab with sandbox file tree selection and preview pane, and
improve accessibility across tree nodes and interactive elements.
2026-01-27 15:05:11 +08:00

163 lines
4.8 KiB
TypeScript

'use client'
import type { FC } from 'react'
import type { FileAppearanceType } from '@/app/components/base/file-uploader/types'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { RiDownloadLine, RiFolderLine, RiFolderOpenLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import { cn } from '@/utils/classnames'
import { getFileIconType } from '../utils/file-utils'
import TreeGuideLines from './tree-guide-lines'
const INDENT_SIZE = 20
type ArtifactsTreeProps = {
data: SandboxFileTreeNode[] | undefined
onDownload: (node: SandboxFileTreeNode) => void
onSelect?: (node: SandboxFileTreeNode) => void
selectedPath?: string
isDownloading?: boolean
}
type ArtifactsTreeNodeProps = {
node: SandboxFileTreeNode
depth: number
onDownload: (node: SandboxFileTreeNode) => void
onSelect?: (node: SandboxFileTreeNode) => void
selectedPath?: string
isDownloading?: boolean
}
const ArtifactsTreeNode: FC<ArtifactsTreeNodeProps> = ({
node,
depth,
onDownload,
onSelect,
selectedPath,
isDownloading,
}) => {
const [isExpanded, setIsExpanded] = useState(false)
const isFolder = node.node_type === 'folder'
const hasChildren = isFolder && node.children.length > 0
const isSelected = !isFolder && selectedPath === node.path
const handleClick = useCallback(() => {
if (isFolder) {
setIsExpanded(prev => !prev)
}
else {
onSelect?.(node)
}
}, [isFolder, node, onSelect])
const handleDownload = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onDownload(node)
}, [node, onDownload])
const fileIconType = !isFolder ? getFileIconType(node.name) : null
return (
<div>
<div
role="button"
tabIndex={0}
aria-label={isFolder ? `${node.name} folder` : node.name}
aria-expanded={isFolder ? isExpanded : undefined}
aria-selected={isSelected}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
handleClick()
}}
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-1 focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-hover',
)}
style={{ paddingLeft: `${8 + depth * INDENT_SIZE}px` }}
>
<TreeGuideLines level={depth} lineOffset={2} />
<div className="flex size-5 shrink-0 items-center justify-center">
{isFolder
? (
isExpanded
? <RiFolderOpenLine className="size-4 text-text-accent" aria-hidden="true" />
: <RiFolderLine className="size-4 text-text-secondary" aria-hidden="true" />
)
: <FileTypeIcon type={fileIconType as FileAppearanceType} size="sm" />}
</div>
<span className="min-w-0 flex-1 truncate text-[13px] font-normal leading-4 text-text-secondary">
{node.name}
</span>
{!isFolder && (
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100',
'hover:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-active',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
aria-label={`Download ${node.name}`}
>
<RiDownloadLine className="size-3.5 text-text-tertiary" />
</button>
)}
</div>
{isFolder && isExpanded && hasChildren && (
<div>
{node.children.map(child => (
<ArtifactsTreeNode
key={child.id}
node={child}
depth={depth + 1}
onDownload={onDownload}
onSelect={onSelect}
selectedPath={selectedPath}
isDownloading={isDownloading}
/>
))}
</div>
)}
</div>
)
}
const ArtifactsTree: FC<ArtifactsTreeProps> = ({
data,
onDownload,
onSelect,
selectedPath,
isDownloading,
}) => {
if (!data || data.length === 0)
return null
return (
<div className="py-0.5">
{data.map(node => (
<ArtifactsTreeNode
key={node.id}
node={node}
depth={0}
onDownload={onDownload}
onSelect={onSelect}
selectedPath={selectedPath}
isDownloading={isDownloading}
/>
))}
</div>
)
}
export default React.memo(ArtifactsTree)