feat: paste operation for skill file tree

This commit is contained in:
yyh
2026-01-20 15:42:39 +08:00
parent 357489d444
commit 53f828f00e
8 changed files with 167 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View 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,
}
}

View File

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

View File

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

View File

@ -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": "重命名成功",