mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
feat: paste operation for skill file tree
This commit is contained in:
@ -3,7 +3,7 @@ import { cn } from '@/utils/classnames'
|
||||
import { getKeyboardKeyNameBySystem } from './utils'
|
||||
|
||||
type ShortcutsNameProps = {
|
||||
keys: string[]
|
||||
keys: readonly string[]
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants'
|
||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||
import { usePasteOperation } from '../hooks/use-paste-operation'
|
||||
import { useRootFileDrop } from '../hooks/use-root-file-drop'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSkillShortcuts } from '../hooks/use-skill-shortcuts'
|
||||
@ -127,18 +128,20 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
}, [storeApi])
|
||||
|
||||
const handleBlankAreaClick = useCallback(() => {
|
||||
treeRef.current?.deselectAll()
|
||||
storeApi.getState().clearSelection()
|
||||
}, [storeApi])
|
||||
}, [storeApi, treeRef])
|
||||
|
||||
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
treeRef.current?.deselectAll()
|
||||
storeApi.getState().clearSelection()
|
||||
storeApi.getState().setContextMenu({
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
type: CONTEXT_MENU_TYPE.BLANK,
|
||||
})
|
||||
}, [storeApi])
|
||||
}, [storeApi, treeRef])
|
||||
|
||||
useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
@ -147,6 +150,11 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
|
||||
useSkillShortcuts({ treeRef })
|
||||
|
||||
usePasteOperation({
|
||||
treeRef,
|
||||
treeData: treeData ?? undefined,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
|
||||
@ -208,6 +216,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-skill-tree-container
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col',
|
||||
isMutating && 'pointer-events-none opacity-50',
|
||||
|
||||
@ -53,7 +53,7 @@ const labelVariants = cva('system-sm-regular text-text-secondary', {
|
||||
export type MenuItemProps = {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
kbd?: string[]
|
||||
kbd?: readonly string[]
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
disabled?: boolean
|
||||
} & VariantProps<typeof menuItemVariants>
|
||||
|
||||
@ -31,6 +31,10 @@ export const MENU_CONTAINER_STYLES = [
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
] as const
|
||||
|
||||
const KBD_COPY = ['ctrl', 'c'] as const
|
||||
const KBD_CUT = ['ctrl', 'x'] as const
|
||||
const KBD_PASTE = ['ctrl', 'v'] as const
|
||||
|
||||
type NodeMenuProps = {
|
||||
type: NodeMenuType
|
||||
nodeId?: string
|
||||
@ -176,14 +180,14 @@ const NodeMenu: FC<NodeMenuProps> = ({
|
||||
<MenuItem
|
||||
icon={RiFileCopyLine}
|
||||
label={t('skillSidebar.menu.copy')}
|
||||
kbd={['ctrl', 'c']}
|
||||
kbd={KBD_COPY}
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={RiScissorsLine}
|
||||
label={t('skillSidebar.menu.cut')}
|
||||
kbd={['ctrl', 'x']}
|
||||
kbd={KBD_CUT}
|
||||
onClick={handleCut}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@ -194,7 +198,7 @@ const NodeMenu: FC<NodeMenuProps> = ({
|
||||
<MenuItem
|
||||
icon={RiClipboardLine}
|
||||
label={t('skillSidebar.menu.paste')}
|
||||
kbd={['ctrl', 'v']}
|
||||
kbd={KBD_PASTE}
|
||||
onClick={handlePaste}
|
||||
disabled={isLoading || !hasClipboard}
|
||||
/>
|
||||
|
||||
127
web/app/components/workflow/skill/hooks/use-paste-operation.ts
Normal file
127
web/app/components/workflow/skill/hooks/use-paste-operation.ts
Normal file
@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useMoveAppAssetNode } from '@/service/use-app-asset'
|
||||
import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../utils/tree-utils'
|
||||
|
||||
type UsePasteOperationOptions = {
|
||||
treeRef: RefObject<TreeApi<TreeNodeData> | null>
|
||||
treeData?: AppAssetTreeResponse
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
type UsePasteOperationReturn = {
|
||||
isPasting: boolean
|
||||
handlePaste: () => void
|
||||
}
|
||||
|
||||
export function usePasteOperation({
|
||||
treeRef,
|
||||
treeData,
|
||||
enabled = true,
|
||||
}: UsePasteOperationOptions): UsePasteOperationReturn {
|
||||
const { t } = useTranslation('workflow')
|
||||
const storeApi = useWorkflowStore()
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const moveNode = useMoveAppAssetNode()
|
||||
const isPastingRef = useRef(false)
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (isPastingRef.current)
|
||||
return
|
||||
|
||||
const clipboard = storeApi.getState().clipboard
|
||||
if (!clipboard || clipboard.nodeIds.size === 0)
|
||||
return
|
||||
|
||||
const { operation, nodeIds } = clipboard
|
||||
const tree = treeRef.current
|
||||
const treeChildren = treeData?.children ?? []
|
||||
|
||||
const selectedId = tree?.selectedNodes[0]?.id ?? storeApi.getState().selectedTreeNodeId
|
||||
const targetFolderId = getTargetFolderIdFromSelection(selectedId, treeChildren)
|
||||
const targetParentId = toApiParentId(targetFolderId)
|
||||
|
||||
if (operation === 'cut') {
|
||||
const nodeIdsArray = [...nodeIds]
|
||||
const isMovingToSelf = nodeIdsArray.some((nodeId) => {
|
||||
const node = findNodeById(treeChildren, nodeId)
|
||||
if (!node)
|
||||
return false
|
||||
if (node.node_type === 'folder' && nodeId === targetFolderId)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (isMovingToSelf) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.cannotMoveToSelf'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isPastingRef.current = true
|
||||
|
||||
try {
|
||||
for (const nodeId of nodeIdsArray) {
|
||||
await moveNode.mutateAsync({
|
||||
appId,
|
||||
nodeId,
|
||||
payload: { parent_id: targetParentId },
|
||||
})
|
||||
}
|
||||
|
||||
storeApi.getState().clearClipboard()
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.moved'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.moveError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
isPastingRef.current = false
|
||||
}
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('skillSidebar.menu.copyNotSupported'),
|
||||
})
|
||||
}
|
||||
}, [appId, moveNode, storeApi, t, treeData?.children, treeRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled)
|
||||
return
|
||||
|
||||
const handlePasteEvent = () => {
|
||||
handlePaste()
|
||||
}
|
||||
|
||||
window.addEventListener('skill:paste', handlePasteEvent)
|
||||
return () => {
|
||||
window.removeEventListener('skill:paste', handlePasteEvent)
|
||||
}
|
||||
}, [enabled, handlePaste])
|
||||
|
||||
return {
|
||||
isPasting: moveNode.isPending,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
@ -16,19 +16,28 @@ type UseSkillShortcutsOptions = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const TREE_CONTAINER_SELECTOR = '[data-skill-tree-container]'
|
||||
|
||||
export function useSkillShortcuts({
|
||||
treeRef,
|
||||
enabled = true,
|
||||
}: UseSkillShortcutsOptions): void {
|
||||
const storeApi = useWorkflowStore()
|
||||
const enabledRef = useRef(enabled)
|
||||
useEffect(() => { enabledRef.current = enabled }, [enabled])
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled
|
||||
}, [enabled])
|
||||
|
||||
const shouldHandle = useCallback((e: KeyboardEvent) => {
|
||||
if (!enabledRef.current)
|
||||
return false
|
||||
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||
}, [])
|
||||
if (isEventTargetInputArea(e.target as HTMLElement))
|
||||
return false
|
||||
const target = e.target as HTMLElement
|
||||
const isInTreeContainer = target.closest(TREE_CONTAINER_SELECTOR) !== null
|
||||
const hasSelection = (treeRef.current?.selectedNodes.length ?? 0) > 0
|
||||
return isInTreeContainer || hasSelection
|
||||
}, [treeRef])
|
||||
|
||||
const getSelectedNodeIds = useCallback(() => {
|
||||
const tree = treeRef.current
|
||||
|
||||
@ -1048,6 +1048,10 @@
|
||||
"skillSidebar.menu.newFolder": "New Folder",
|
||||
"skillSidebar.menu.newFolderPrompt": "Enter folder name:",
|
||||
"skillSidebar.menu.paste": "Paste",
|
||||
"skillSidebar.menu.moved": "Moved successfully",
|
||||
"skillSidebar.menu.moveError": "Failed to move",
|
||||
"skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself",
|
||||
"skillSidebar.menu.copyNotSupported": "Copy is not supported yet",
|
||||
"skillSidebar.menu.rename": "Rename",
|
||||
"skillSidebar.menu.renameError": "Failed to rename",
|
||||
"skillSidebar.menu.renamed": "Renamed successfully",
|
||||
|
||||
@ -1039,6 +1039,10 @@
|
||||
"skillSidebar.menu.newFolder": "新建文件夹",
|
||||
"skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:",
|
||||
"skillSidebar.menu.paste": "粘贴",
|
||||
"skillSidebar.menu.moved": "移动成功",
|
||||
"skillSidebar.menu.moveError": "移动失败",
|
||||
"skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部",
|
||||
"skillSidebar.menu.copyNotSupported": "暂不支持复制功能",
|
||||
"skillSidebar.menu.rename": "重命名",
|
||||
"skillSidebar.menu.renameError": "重命名失败",
|
||||
"skillSidebar.menu.renamed": "重命名成功",
|
||||
|
||||
Reference in New Issue
Block a user