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:
yyh
2026-01-16 11:38:13 +08:00
parent 9492eda5ef
commit 106cb8e373
6 changed files with 218 additions and 277 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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>
)

View 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)

View File

@ -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>
)
}

View File

@ -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>