dify/web/app/components/workflow/skill/file-tree.tsx
yyh f58f36fc8f
feat(skill): add file right-click/more menu and refactor naming
- Add right-click context menu and '...' more button for files
  - Files now support Rename and Delete operations
  - Created file-node-menu.tsx for file-specific menu

- Refactor component naming for consistency
  - file-item-menu.tsx -> file-node-menu.tsx (unify 'node' terminology)
  - file-operations-menu.tsx -> folder-node-menu.tsx (clarify folder menu)
  - file-tree-context-menu.tsx -> tree-context-menu.tsx (simplify)
  - file-tree-node.tsx -> tree-node.tsx (simplify)
  - files.tsx -> file-tree.tsx (more descriptive)
  - Renamed internal components: FileTreeNode -> TreeNode, Files -> FileTree

- Add context menu node highlight
  - When right-clicking a node, it now shows hover highlight
  - Subscribed to contextMenu.nodeId in TreeNode component
2026-01-15 17:26:12 +08:00

164 lines
4.8 KiB
TypeScript

'use client'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { OpensObject } from './store'
import type { TreeNodeData } from './type'
import { RiDragDropLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Tree } from 'react-arborist'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useGetAppAssetTree, useRenameAppAssetNode } from '@/service/use-app-asset'
import { cn } from '@/utils/classnames'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
import TreeContextMenu from './tree-context-menu'
import TreeNode from './tree-node'
import { getAncestorIds } from './utils/tree-utils'
type FileTreeProps = {
className?: string
}
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 }) => {
const { t } = useTranslation('workflow')
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const { data: treeData, isLoading, error } = useGetAppAssetTree(appId)
const expandedFolderIds = useSkillEditorStore(s => s.expandedFolderIds)
const activeTabId = useSkillEditorStore(s => s.activeTabId)
const storeApi = useSkillEditorStoreApi()
const renameNode = useRenameAppAssetNode()
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)
else
node.toggle()
}, [storeApi])
const handleRename = useCallback(({ id, name }: { id: string, name: string }) => {
renameNode.mutateAsync({
appId,
nodeId: id,
payload: { name },
}).catch(() => {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.renameError'),
})
})
}, [appId, renameNode, t])
useEffect(() => {
if (!activeTabId || !treeData?.children)
return
const tree = treeRef.current
if (!tree)
return
const ancestors = getAncestorIds(activeTabId, treeData.children)
if (ancestors.length > 0)
storeApi.getState().revealFile(ancestors)
requestAnimationFrame(() => {
const node = tree.get(activeTabId)
if (node) {
tree.openParents(node)
tree.scrollTo(activeTabId)
}
})
}, [activeTabId, treeData?.children, storeApi])
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 (!treeData?.children || treeData.children.length === 0) {
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', className)}>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1">
<Tree<TreeNodeData>
ref={treeRef}
data={treeData.children}
idAccessor="id"
childrenAccessor="children"
width="100%"
height={1000}
rowHeight={24}
indent={20}
overscanCount={5}
selection={activeTabId ?? undefined}
initialOpenState={initialOpensObject}
onToggle={handleToggle}
onActivate={handleActivate}
onRename={handleRename}
disableDrag
disableDrop
>
{TreeNode}
</Tree>
</div>
<DropTip />
<TreeContextMenu treeRef={treeRef} />
</div>
)
}
export default React.memo(FileTree)