Files
dify/web/app/components/workflow/skill/file-tree/tree/node-menu.tsx
Novice 499d237b7e fix: pass all CI quality checks - ESLint, TypeScript, basedpyright, pyrefly, lint-imports
Frontend:
- Migrate deprecated imports: modal→dialog, toast→ui/toast, tooltip→tooltip-plus,
  portal-to-follow-elem→portal-to-follow-elem-plus, select→ui/select, confirm→alert-dialog
- Replace next/* with @/next/* wrapper modules
- Convert TypeScript enums to const objects (erasable-syntax-only)
- Replace all `any` types with `unknown` or specific types in workflow types
- Fix unused vars, react-hooks-extra, react-refresh/only-export-components
- Extract InteractionMode to separate module, tool-block commands to commands.ts

Backend:
- Fix pyrefly errors: type narrowing, null guards, getattr patterns
- Remove unused TYPE_CHECKING imports in LLM node
- Add ignore_imports entries to .importlinter for dify_graph boundary violations

Made-with: Cursor
2026-03-24 10:54:58 +08:00

265 lines
7.8 KiB
TypeScript

'use client'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { NodeMenuType } from '../../constants'
import type { TreeNodeData } from '../../type'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FileAdd, FolderAdd } from '@/app/components/base/icons/src/vender/line/files'
import { UploadCloud02 } from '@/app/components/base/icons/src/vender/line/general'
import { Download02 } from '@/app/components/base/icons/src/vender/solid/general'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import dynamic from '@/next/dynamic'
import { cn } from '@/utils/classnames'
import { NODE_MENU_TYPE } from '../../constants'
import { useFileOperations } from '../../hooks/file-tree/operations/use-file-operations'
import MenuItem from './menu-item'
const ImportSkillModal = dynamic(() => import('../../start-tab/import-skill-modal'), {
ssr: false,
})
const MENU_CONTAINER_STYLES = [
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
] as const
const KBD_CUT = ['ctrl', 'x'] as const
const KBD_PASTE = ['ctrl', 'v'] as const
type NodeMenuProps = {
type: NodeMenuType
nodeId?: string
onClose: () => void
className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
}
const NodeMenu = ({
type,
nodeId,
onClose,
className,
treeRef,
node,
}: NodeMenuProps) => {
const { t } = useTranslation('workflow')
const storeApi = useWorkflowStore()
const selectedNodeIds = useStore(s => s.selectedNodeIds)
const hasClipboard = useStore(s => s.hasClipboard())
const isRoot = type === NODE_MENU_TYPE.ROOT
const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const {
fileInputRef,
folderInputRef,
showDeleteConfirm,
isLoading,
isDeleting,
handleDownload,
handleNewFile,
handleNewFolder,
handleFileChange,
handleFolderChange,
handleRename,
handleDeleteClick,
handleDeleteConfirm,
handleDeleteCancel,
} = useFileOperations({ nodeId, onClose, treeRef, node })
const currentNodeId = node?.data.id ?? nodeId
const handleCut = useCallback(() => {
const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : [])
if (ids.length > 0) {
storeApi.getState().cutNodes(ids)
onClose()
}
}, [currentNodeId, onClose, selectedNodeIds, storeApi])
const handlePaste = useCallback(() => {
window.dispatchEvent(new CustomEvent('skill:paste'))
onClose()
}, [onClose])
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(MENU_CONTAINER_STYLES, className)}>
{isFolder && (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
aria-label={t('skillSidebar.menu.uploadFile')}
onChange={handleFileChange}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error webkitdirectory is a non-standard attribute
webkitdirectory=""
className="hidden"
aria-label={t('skillSidebar.menu.uploadFolder')}
onChange={handleFolderChange}
/>
<MenuItem
icon={FileAdd}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
disabled={isLoading}
/>
<MenuItem
icon={FolderAdd}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon={UploadCloud02}
label={t('skillSidebar.menu.uploadFile')}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
/>
<MenuItem
icon="i-ri-folder-upload-line"
label={t('skillSidebar.menu.uploadFolder')}
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
/>
{isRoot && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon="i-ri-upload-line"
label={t('skillSidebar.menu.importSkills')}
onClick={() => setIsImportModalOpen(true)}
disabled={isLoading}
tooltip={t('skill.startTab.importSkillDesc')}
/>
</>
)}
{(showRenameDelete || hasClipboard) && <div className="my-1 h-px bg-divider-subtle" />}
</>
)}
{!isFolder && (
<>
<MenuItem
icon={Download02}
label={t('skillSidebar.menu.download')}
onClick={handleDownload}
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
</>
)}
{!isRoot && (
<>
<MenuItem
icon="i-ri-scissors-line"
label={t('skillSidebar.menu.cut')}
kbd={KBD_CUT}
onClick={handleCut}
disabled={isLoading}
/>
</>
)}
{isFolder && hasClipboard && (
<MenuItem
icon="i-ri-clipboard-line"
label={t('skillSidebar.menu.paste')}
kbd={KBD_PASTE}
onClick={handlePaste}
disabled={isLoading}
/>
)}
{showRenameDelete && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon="i-ri-edit-2-line"
label={t('skillSidebar.menu.rename')}
onClick={handleRename}
disabled={isLoading}
/>
<MenuItem
icon="i-ri-delete-bin-line"
label={t('skillSidebar.menu.delete')}
onClick={handleDeleteClick}
disabled={isLoading}
variant="destructive"
/>
</>
)}
<AlertDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
if (!open)
handleDeleteCancel()
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{deleteConfirmTitle}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-secondary system-sm-regular">
{deleteConfirmContent}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={isDeleting}
onClick={() => {
void handleDeleteConfirm()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<ImportSkillModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
/>
</div>
)
}
export default React.memo(NodeMenu)