refactor(skill): regroup skill body, file tree, and tree hooks

This commit is contained in:
yyh
2026-02-07 14:20:01 +08:00
parent e10996c368
commit 11d5efc13e
52 changed files with 119 additions and 119 deletions

View File

@ -0,0 +1,119 @@
'use client'
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { RiArrowDownSLine, RiArrowRightSLine, RiLoader2Line } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FolderSpark from '@/app/components/base/icons/src/vender/workflow/FolderSpark'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useDownloadSandboxFile, useSandboxFilesTree } from '@/service/use-sandbox-file'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import ArtifactsTree from './artifacts-tree'
type ArtifactsSectionProps = {
className?: string
}
const ArtifactsSection = ({ className }: ArtifactsSectionProps) => {
const { t } = useTranslation('workflow')
const appId = useStore(s => s.appId)
const [isExpanded, setIsExpanded] = useState(false)
const { data: treeData, hasFiles, isLoading } = useSandboxFilesTree(appId)
const { mutateAsync: fetchDownloadUrl, isPending: isDownloading } = useDownloadSandboxFile(appId)
const storeApi = useWorkflowStore()
const selectedArtifactPath = useStore(s => s.selectedArtifactPath)
const handleToggle = useCallback(() => {
setIsExpanded(prev => !prev)
}, [])
const handleSelect = useCallback((node: SandboxFileTreeNode) => {
storeApi.getState().selectArtifact(node.path)
}, [storeApi])
const handleDownload = useCallback(async (node: SandboxFileTreeNode) => {
try {
const ticket = await fetchDownloadUrl(node.path)
downloadUrl({ url: ticket.download_url, fileName: node.name })
}
catch (error) {
console.error('Download failed:', error)
}
}, [fetchDownloadUrl])
const showBlueDot = !isExpanded && hasFiles
const showSpinner = isLoading
return (
<div className={cn('flex max-h-[40%] flex-col border-t border-divider-regular', className)}>
<div className="shrink-0 p-1">
<button
type="button"
onClick={handleToggle}
className={cn(
'flex w-full items-center rounded-md py-1 pl-2 pr-1.5',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
aria-expanded={isExpanded}
aria-label={t('skillSidebar.artifacts.openArtifacts')}
>
<div className="flex flex-1 items-center gap-1 py-0.5">
<div className="flex size-5 items-center justify-center">
<FolderSpark className="size-4 text-text-secondary" aria-hidden="true" />
</div>
<span className="system-sm-semibold uppercase text-text-secondary">
{t('skillSidebar.artifacts.title')}
</span>
</div>
<div className="relative flex items-center">
{showSpinner
? <RiLoader2Line className="size-3.5 animate-spin text-text-tertiary" aria-hidden="true" />
: (
<>
{showBlueDot && (
<div className="absolute -left-2 size-[7px] rounded-full border border-white bg-state-accent-solid" />
)}
{isExpanded
? <RiArrowDownSLine className="size-4 text-text-tertiary" aria-hidden="true" />
: <RiArrowRightSLine className="size-4 text-text-tertiary" aria-hidden="true" />}
</>
)}
</div>
</button>
</div>
{isExpanded && !isLoading && (
<div className="min-h-0 flex-1 overflow-y-auto px-1 pb-1">
{hasFiles
? (
<ArtifactsTree
data={treeData}
onDownload={handleDownload}
onSelect={handleSelect}
selectedPath={selectedArtifactPath ?? undefined}
isDownloading={isDownloading}
/>
)
: (
<div className="px-1.5 pb-0.5">
<div className="rounded-lg bg-background-section p-3">
<p className="system-xs-regular text-text-tertiary">
{t('skillSidebar.artifacts.emptyState')}
</p>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default React.memo(ArtifactsSection)

View File

@ -0,0 +1,161 @@
'use client'
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/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 = ({
node,
depth,
onDownload,
onSelect,
selectedPath,
isDownloading,
}: ArtifactsTreeNodeProps) => {
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, node.extension) : 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 = ({
data,
onDownload,
onSelect,
selectedPath,
isDownloading,
}: ArtifactsTreeProps) => {
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)