mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
refactor(skill): unify node menu components with cva variants
Merge file-node-menu.tsx and folder-node-menu.tsx into a single declarative NodeMenu component that uses type prop to determine menu items. Add cva-based variant support to MenuItem for consistent destructive styling.
This commit is contained in:
@ -1,87 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEdit2Line,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileOperations } from '../hooks/use-file-operations'
|
||||
import MenuItem from './menu-item'
|
||||
|
||||
type FileItemMenuProps = {
|
||||
nodeId?: string
|
||||
onClose: () => void
|
||||
className?: string
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const FileItemMenu: FC<FileItemMenuProps> = ({
|
||||
nodeId,
|
||||
onClose,
|
||||
className,
|
||||
treeRef,
|
||||
node,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
|
||||
const {
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MenuItem
|
||||
icon={RiEdit2Line}
|
||||
label={t('skillSidebar.menu.rename')}
|
||||
onClick={handleRename}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-destructive-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'group',
|
||||
)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
|
||||
{t('skillSidebar.menu.delete')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
type="danger"
|
||||
title={t('skillSidebar.menu.fileDeleteConfirmTitle')}
|
||||
content={t('skillSidebar.menu.fileDeleteConfirmContent')}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileItemMenu)
|
||||
@ -1,147 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEdit2Line,
|
||||
RiFileAddLine,
|
||||
RiFolderAddLine,
|
||||
RiFolderUploadLine,
|
||||
RiUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileOperations } from '../hooks/use-file-operations'
|
||||
import MenuItem from './menu-item'
|
||||
|
||||
type FileOperationsMenuProps = {
|
||||
nodeId?: string
|
||||
onClose: () => void
|
||||
className?: string
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
nodeId,
|
||||
onClose,
|
||||
className,
|
||||
treeRef,
|
||||
node,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
|
||||
const {
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
handleNewFile,
|
||||
handleNewFolder,
|
||||
handleFileChange,
|
||||
handleFolderChange,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
||||
webkitdirectory=""
|
||||
className="hidden"
|
||||
onChange={handleFolderChange}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
icon={RiFileAddLine}
|
||||
label={t('skillSidebar.menu.newFile')}
|
||||
onClick={handleNewFile}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiFolderAddLine}
|
||||
label={t('skillSidebar.menu.newFolder')}
|
||||
onClick={handleNewFolder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
|
||||
<MenuItem
|
||||
icon={RiUploadLine}
|
||||
label={t('skillSidebar.menu.uploadFile')}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiFolderUploadLine}
|
||||
label={t('skillSidebar.menu.uploadFolder')}
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{nodeId !== 'root' && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
|
||||
<MenuItem
|
||||
icon={RiEdit2Line}
|
||||
label={t('skillSidebar.menu.rename')}
|
||||
onClick={handleRename}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-destructive-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'group',
|
||||
)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
|
||||
{t('skillSidebar.menu.delete')}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
type="danger"
|
||||
title={t('skillSidebar.menu.deleteConfirmTitle')}
|
||||
content={t('skillSidebar.menu.deleteConfirmContent')}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileOperationsMenu)
|
||||
@ -1,31 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { FC } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const menuItemVariants = cva(
|
||||
[
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-state-base-hover',
|
||||
destructive: 'group hover:bg-state-destructive-hover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const iconVariants = cva('size-4 text-text-tertiary', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
destructive: 'group-hover:text-text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
const labelVariants = cva('system-sm-regular text-text-secondary', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
destructive: 'group-hover:text-text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export type MenuItemProps = {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
} & VariantProps<typeof menuItemVariants>
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
|
||||
const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled, variant }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
className={cn(menuItemVariants({ variant }))}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={cn(iconVariants({ variant }))} aria-hidden="true" />
|
||||
<span className={cn(labelVariants({ variant }))}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
|
||||
155
web/app/components/workflow/skill/file-tree/node-menu.tsx
Normal file
155
web/app/components/workflow/skill/file-tree/node-menu.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEdit2Line,
|
||||
RiFileAddLine,
|
||||
RiFolderAddLine,
|
||||
RiFolderUploadLine,
|
||||
RiUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileOperations } from '../hooks/use-file-operations'
|
||||
import MenuItem from './menu-item'
|
||||
|
||||
type NodeMenuProps = {
|
||||
type: 'file' | 'folder'
|
||||
nodeId?: string
|
||||
onClose: () => void
|
||||
className?: string
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const NodeMenu: FC<NodeMenuProps> = ({
|
||||
type,
|
||||
nodeId,
|
||||
onClose,
|
||||
className,
|
||||
treeRef,
|
||||
node,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const isFolder = type === 'folder'
|
||||
const derivedNodeId = node?.data.id ?? nodeId ?? ''
|
||||
const isRoot = derivedNodeId === 'root'
|
||||
|
||||
const {
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
handleNewFile,
|
||||
handleNewFolder,
|
||||
handleFileChange,
|
||||
handleFolderChange,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||
|
||||
const showRenameDelete = isFolder ? !isRoot : true
|
||||
const deleteConfirmTitle = isFolder
|
||||
? t('skillSidebar.menu.deleteConfirmTitle')
|
||||
: t('skillSidebar.menu.fileDeleteConfirmTitle')
|
||||
const deleteConfirmContent = isFolder
|
||||
? t('skillSidebar.menu.deleteConfirmContent')
|
||||
: t('skillSidebar.menu.fileDeleteConfirmContent')
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isFolder && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
||||
webkitdirectory=""
|
||||
className="hidden"
|
||||
onChange={handleFolderChange}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
icon={RiFileAddLine}
|
||||
label={t('skillSidebar.menu.newFile')}
|
||||
onClick={handleNewFile}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiFolderAddLine}
|
||||
label={t('skillSidebar.menu.newFolder')}
|
||||
onClick={handleNewFolder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
|
||||
<MenuItem
|
||||
icon={RiUploadLine}
|
||||
label={t('skillSidebar.menu.uploadFile')}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiFolderUploadLine}
|
||||
label={t('skillSidebar.menu.uploadFolder')}
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{showRenameDelete && <div className="my-1 h-px bg-divider-subtle" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showRenameDelete && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={RiEdit2Line}
|
||||
label={t('skillSidebar.menu.rename')}
|
||||
onClick={handleRename}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiDeleteBinLine}
|
||||
label={t('skillSidebar.menu.delete')}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
type="danger"
|
||||
title={deleteConfirmTitle}
|
||||
content={deleteConfirmContent}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NodeMenu)
|
||||
@ -9,8 +9,7 @@ import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
|
||||
import { findNodeById } from '../utils/tree-utils'
|
||||
import FileNodeMenu from './file-node-menu'
|
||||
import FolderNodeMenu from './folder-node-menu'
|
||||
import NodeMenu from './node-menu'
|
||||
|
||||
type TreeContextMenuProps = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
@ -50,21 +49,12 @@ const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
|
||||
left: contextMenu.left,
|
||||
}}
|
||||
>
|
||||
{isFolder
|
||||
? (
|
||||
<FolderNodeMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FileNodeMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
)}
|
||||
<NodeMenu
|
||||
type={isFolder ? 'folder' : 'file'}
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -18,8 +18,7 @@ import { cn } from '@/utils/classnames'
|
||||
import { useDelayedClick } from '../hooks/use-delayed-click'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
|
||||
import { getFileIconType } from '../utils/file-utils'
|
||||
import FileNodeMenu from './file-node-menu'
|
||||
import FolderNodeMenu from './folder-node-menu'
|
||||
import NodeMenu from './node-menu'
|
||||
import TreeEditInput from './tree-edit-input'
|
||||
import TreeGuideLines from './tree-guide-lines'
|
||||
|
||||
@ -192,19 +191,11 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
{isFolder
|
||||
? (
|
||||
<FolderNodeMenu
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FileNodeMenu
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)}
|
||||
<NodeMenu
|
||||
type={isFolder ? 'folder' : 'file'}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user