Files
dify/web/app/components/workflow/variable-inspect/artifacts-tab.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

193 lines
6.3 KiB
TypeScript

import type { FC } from 'react'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import {
RiDownloadLine,
RiMenuLine,
} from '@remixicon/react'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Loading from '@/app/components/base/loading'
import ArtifactsTree from '@/app/components/workflow/skill/file-tree/artifacts-tree'
import { useAppContext } from '@/context/app-context'
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
import { cn } from '@/utils/classnames'
import { useStore } from '../store'
const formatFileSize = (bytes: number | null): string => {
if (bytes === null || bytes === 0)
return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
type ArtifactsPreviewPaneProps = {
file: SandboxFileTreeNode | null
onDownload: (node: SandboxFileTreeNode) => void
isDownloading: boolean
onOpenMenu: () => void
}
const ArtifactsPreviewPane = memo<ArtifactsPreviewPaneProps>(({
file,
onDownload,
isDownloading,
onOpenMenu,
}) => {
const { t } = useTranslation('workflow')
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
if (!file) {
return (
<div className="flex h-full items-center justify-center p-2">
<p className="system-xs-regular text-text-tertiary">
{t('debug.variableInspect.tabArtifacts.selectFile')}
</p>
</div>
)
}
const pathParts = file.path.split('/')
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-between gap-1 px-2 pt-2">
{bottomPanelWidth < 488 && (
<ActionButton className="shrink-0" onClick={onOpenMenu} aria-label="Open menu">
<RiMenuLine className="h-4 w-4" />
</ActionButton>
)}
<div className="flex w-0 grow items-center gap-1">
<div className="flex items-center gap-1 truncate">
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="system-sm-regular text-text-quaternary">/</span>}
<span className={cn(
'system-sm-semibold truncate',
i === pathParts.length - 1 ? 'text-text-secondary' : 'text-text-tertiary',
)}
>
{part}
</span>
</span>
))}
</div>
<span className="system-xs-medium shrink-0 text-text-tertiary">
{formatFileSize(file.size)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<CopyFeedback content={file.path} />
<ActionButton
onClick={() => onDownload(file)}
disabled={isDownloading}
aria-label={`Download ${file.name}`}
>
<RiDownloadLine className="h-4 w-4" />
</ActionButton>
</div>
</div>
<div className="grow overflow-auto p-2">
<div className="flex h-full items-center justify-center rounded-xl bg-background-section">
<p className="system-xs-regular text-text-tertiary">
{t('debug.variableInspect.tabArtifacts.previewNotAvailable')}
</p>
</div>
</div>
</div>
)
})
const ArtifactsTab: FC = () => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const sandboxId = userProfile?.id
const bottomPanelWidth = useStore(s => s.bottomPanelWidth)
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(sandboxId, {
enabled: !!sandboxId,
})
const downloadMutation = useDownloadSandboxFile(sandboxId)
const [selectedFile, setSelectedFile] = useState<SandboxFileTreeNode | null>(null)
const [showLeftPanel, setShowLeftPanel] = useState(true)
const handleFileSelect = useCallback((node: SandboxFileTreeNode) => {
if (node.node_type === 'file')
setSelectedFile(node)
}, [])
const { mutateAsync: downloadFile } = downloadMutation
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await downloadFile(node.path)
window.open(ticket.download_url, '_blank')
}
catch (error) {
console.error('Download failed:', error)
}
}, [downloadFile])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
if (!hasFiles) {
return (
<div className="flex h-full items-center justify-center p-2">
<div className="rounded-lg bg-background-section p-3">
<p className="system-xs-regular text-text-tertiary">
{t('skillSidebar.artifacts.emptyState', { ns: 'workflow' })}
</p>
</div>
</div>
)
}
return (
<div className={cn('relative flex h-full')}>
{bottomPanelWidth < 488 && showLeftPanel && (
<div role="presentation" className="absolute left-0 top-0 h-full w-full" onClick={() => setShowLeftPanel(false)} />
)}
<div
className={cn(
'w-60 shrink-0 border-r border-divider-burn',
bottomPanelWidth < 488
? showLeftPanel
? 'absolute left-0 top-0 z-10 h-full w-[217px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm'
: 'hidden'
: 'block',
)}
>
<div className="flex h-full flex-col">
<div className="grow overflow-y-auto py-1">
<ArtifactsTree
data={treeData}
onDownload={handleDownload}
onSelect={handleFileSelect}
selectedPath={selectedFile?.path}
isDownloading={downloadMutation.isPending}
/>
</div>
</div>
</div>
<div className="w-0 grow">
<ArtifactsPreviewPane
file={selectedFile}
onDownload={handleDownload}
isDownloading={downloadMutation.isPending}
onOpenMenu={() => setShowLeftPanel(true)}
/>
</div>
</div>
)
}
export default ArtifactsTab