From 106cb8e373b5d695b5810241ee32d3f1b096b9f3 Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 16 Jan 2026 11:38:13 +0800 Subject: [PATCH] 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. --- .../skill/file-tree/file-node-menu.tsx | 87 ---------- .../skill/file-tree/folder-node-menu.tsx | 147 ----------------- .../workflow/skill/file-tree/menu-item.tsx | 61 +++++-- .../workflow/skill/file-tree/node-menu.tsx | 155 ++++++++++++++++++ .../skill/file-tree/tree-context-menu.tsx | 24 +-- .../workflow/skill/file-tree/tree-node.tsx | 21 +-- 6 files changed, 218 insertions(+), 277 deletions(-) delete mode 100644 web/app/components/workflow/skill/file-tree/file-node-menu.tsx delete mode 100644 web/app/components/workflow/skill/file-tree/folder-node-menu.tsx create mode 100644 web/app/components/workflow/skill/file-tree/node-menu.tsx diff --git a/web/app/components/workflow/skill/file-tree/file-node-menu.tsx b/web/app/components/workflow/skill/file-tree/file-node-menu.tsx deleted file mode 100644 index a8d85eaa90..0000000000 --- a/web/app/components/workflow/skill/file-tree/file-node-menu.tsx +++ /dev/null @@ -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 | null> - node?: NodeApi -} - -const FileItemMenu: FC = ({ - nodeId, - onClose, - className, - treeRef, - node, -}) => { - const { t } = useTranslation('workflow') - - const { - showDeleteConfirm, - isLoading, - isDeleting, - handleRename, - handleDeleteClick, - handleDeleteConfirm, - handleDeleteCancel, - } = useFileOperations({ nodeId, onClose, treeRef, node }) - - return ( -
- - - - -
- ) -} - -export default React.memo(FileItemMenu) diff --git a/web/app/components/workflow/skill/file-tree/folder-node-menu.tsx b/web/app/components/workflow/skill/file-tree/folder-node-menu.tsx deleted file mode 100644 index 9dee1a6772..0000000000 --- a/web/app/components/workflow/skill/file-tree/folder-node-menu.tsx +++ /dev/null @@ -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 | null> - node?: NodeApi -} - -const FileOperationsMenu: FC = ({ - 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 ( -
- - - - - - -
- - fileInputRef.current?.click()} - disabled={isLoading} - /> - folderInputRef.current?.click()} - disabled={isLoading} - /> - - {nodeId !== 'root' && ( - <> -
- - - - - )} - - -
- ) -} - -export default React.memo(FileOperationsMenu) diff --git a/web/app/components/workflow/skill/file-tree/menu-item.tsx b/web/app/components/workflow/skill/file-tree/menu-item.tsx index 56e7d3ab83..8a95268cdf 100644 --- a/web/app/components/workflow/skill/file-tree/menu-item.tsx +++ b/web/app/components/workflow/skill/file-tree/menu-item.tsx @@ -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 -const MenuItem: FC = ({ icon: Icon, label, onClick, disabled }) => ( +const MenuItem: FC = ({ icon: Icon, label, onClick, disabled, variant }) => ( ) diff --git a/web/app/components/workflow/skill/file-tree/node-menu.tsx b/web/app/components/workflow/skill/file-tree/node-menu.tsx new file mode 100644 index 0000000000..518a4c98b4 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/node-menu.tsx @@ -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 | null> + node?: NodeApi +} + +const NodeMenu: FC = ({ + 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 ( +
+ {isFolder && ( + <> + + + + + + +
+ + fileInputRef.current?.click()} + disabled={isLoading} + /> + folderInputRef.current?.click()} + disabled={isLoading} + /> + + {showRenameDelete &&
} + + )} + + {showRenameDelete && ( + <> + + + + )} + + +
+ ) +} + +export default React.memo(NodeMenu) diff --git a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx index 5d4233650e..9389d0c1aa 100644 --- a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx @@ -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 | null> @@ -50,21 +49,12 @@ const TreeContextMenu: FC = ({ treeRef }) => { left: contextMenu.left, }} > - {isFolder - ? ( - - ) - : ( - - )} +
) } diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index 1a52334d81..9cb44bf91a 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -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) - {isFolder - ? ( - setShowDropdown(false)} - node={node} - /> - ) - : ( - setShowDropdown(false)} - node={node} - /> - )} + setShowDropdown(false)} + node={node} + />