From b527921f3fcf6e7da1a75f95d1537cb6d50385ba Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 20 Jan 2026 16:27:05 +0800 Subject: [PATCH] feat: unified drag-and-drop for skill file tree Implement unified drag system that supports both internal node moves and external file uploads with consistent UI feedback. Uses native HTML5 drag API with shared visual states (isDragOver, isBlinking, DragActionTooltip showing 'Move to' or 'Upload to'). --- .../components/workflow/skill/constants.ts | 3 + .../workflow/skill/file-tree/index.tsx | 20 +-- .../workflow/skill/file-tree/tree-node.tsx | 24 +++- .../workflow/skill/hooks/use-file-drop.ts | 3 + .../skill/hooks/use-folder-file-drop.ts | 20 +-- .../workflow/skill/hooks/use-node-move.ts | 119 ++++++++++++++++++ .../skill/hooks/use-root-file-drop.ts | 17 ++- .../workflow/skill/hooks/use-unified-drag.ts | 62 +++++++++ .../workflow/skill/utils/drag-utils.ts | 22 ++++ .../workflow/skill/utils/tree-utils.ts | 13 ++ .../workflow/skill-editor/file-tree-slice.ts | 6 + .../store/workflow/skill-editor/types.ts | 4 + web/i18n/en-US/workflow.json | 9 +- web/i18n/zh-Hans/workflow.json | 9 +- 14 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-node-move.ts create mode 100644 web/app/components/workflow/skill/hooks/use-unified-drag.ts diff --git a/web/app/components/workflow/skill/constants.ts b/web/app/components/workflow/skill/constants.ts index 5124649a75..0779b1d8fb 100644 --- a/web/app/components/workflow/skill/constants.ts +++ b/web/app/components/workflow/skill/constants.ts @@ -5,6 +5,9 @@ // Root folder identifier (convert to null for API calls via toApiParentId) export const ROOT_ID = 'root' as const +// Drag type identifier for internal tree node dragging +export const INTERNAL_NODE_DRAG_TYPE = 'application/x-dify-tree-node' + // Context menu trigger types (describes WHERE user clicked) export const CONTEXT_MENU_TYPE = { BLANK: 'blank', diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 7a3e811cbb..c004acaa0a 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -54,19 +54,22 @@ const FileTree: React.FC = ({ className }) => { const { data: treeData, isLoading, error } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 + const expandedFolderIds = useStore(s => s.expandedFolderIds) + const activeTabId = useStore(s => s.activeTabId) + const dragOverFolderId = useStore(s => s.dragOverFolderId) + const currentDragType = useStore(s => s.currentDragType) + const searchTerm = useStore(s => s.fileTreeSearchTerm) + const storeApi = useWorkflowStore() + + const treeChildren = treeData?.children ?? emptyTreeNodes + const { handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop, resetRootDragCounter, - } = useRootFileDrop() - - const expandedFolderIds = useStore(s => s.expandedFolderIds) - const activeTabId = useStore(s => s.activeTabId) - const dragOverFolderId = useStore(s => s.dragOverFolderId) - const searchTerm = useStore(s => s.fileTreeSearchTerm) - const storeApi = useWorkflowStore() + } = useRootFileDrop({ treeChildren }) // Root dropzone highlight (when dragging to root, not to a specific folder) const isRootDropzone = dragOverFolderId === ROOT_ID @@ -76,7 +79,6 @@ const FileTree: React.FC = ({ className }) => { resetRootDragCounter() }, [dragOverFolderId, resetRootDragCounter]) - const treeChildren = treeData?.children ?? emptyTreeNodes const { treeNodes, handleRename, @@ -263,7 +265,7 @@ const FileTree: React.FC = ({ className }) => { {dragOverFolderId - ? + ? : } 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 fc61d8906b..01fe16f9bd 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -4,7 +4,7 @@ import type { NodeRendererProps } from 'react-arborist' import type { TreeNodeData } from '../type' import { RiMoreFill } from '@remixicon/react' import * as React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { PortalToFollowElem, @@ -14,13 +14,17 @@ import { import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' import { useFolderFileDrop } from '../hooks/use-folder-file-drop' +import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers' +import { useUnifiedDrag } from '../hooks/use-unified-drag' import NodeMenu from './node-menu' import TreeEditInput from './tree-edit-input' import TreeGuideLines from './tree-guide-lines' import { TreeNodeIcon } from './tree-node-icon' -const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) => { +const emptyTreeChildren: TreeNodeData[] = [] + +const TreeNode = ({ node, style }: NodeRendererProps) => { const { t } = useTranslation('workflow') const isFolder = node.data.node_type === 'folder' const isSelected = node.isSelected @@ -31,6 +35,10 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const [showDropdown, setShowDropdown] = useState(false) + // Get tree data from TanStack Query cache (no extra request) + const { data: treeData } = useSkillAssetTreeData() + const treeChildren = useMemo(() => treeData?.children ?? emptyTreeChildren, [treeData?.children]) + const { handleClick, handleDoubleClick, @@ -39,7 +47,13 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) handleKeyDown, } = useTreeNodeHandlers({ node }) - const { isDragOver, isBlinking, dragHandlers } = useFolderFileDrop(node) + const { isDragOver, isBlinking, dragHandlers } = useFolderFileDrop({ node, treeChildren }) + const { handleNodeDragStart, handleNodeDragEnd } = useUnifiedDrag({ treeChildren }) + + // Currently only supports single node drag + const handleDragStart = useCallback((e: React.DragEvent) => { + handleNodeDragStart(e, node.data.id) + }, [handleNodeDragStart, node.data.id]) const handleMoreClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() @@ -48,12 +62,14 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) return (
): UseFolderFileDropReturn { +type UseFolderFileDropOptions = { + node: NodeApi + treeChildren: AppAssetTreeView[] +} + +export function useFolderFileDrop({ node, treeChildren }: UseFolderFileDropOptions): UseFolderFileDropReturn { const isFolder = node.data.node_type === 'folder' const dragOverFolderId = useStore(s => s.dragOverFolderId) const isDragOver = isFolder && dragOverFolderId === node.data.id - const { handleDragOver, handleDrop } = useFileDrop() + const { handleDragOver, handleDrop } = useUnifiedDrag({ treeChildren }) const expandTimerRef = useRef(null) const blinkTimerRef = useRef(null) @@ -80,7 +86,7 @@ export function useFolderFileDrop(node: NodeApi): UseFolderFileDro }, [clearExpandTimer]) const handleFolderDragEnter = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return dragCounterRef.current += 1 if (dragCounterRef.current === 1) @@ -88,13 +94,13 @@ export function useFolderFileDrop(node: NodeApi): UseFolderFileDro }, [isFolder, scheduleAutoExpand]) const handleFolderDragOver = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return handleDragOver(e, { folderId: node.data.id, isFolder: true }) }, [handleDragOver, isFolder, node.data.id]) const handleFolderDragLeave = useCallback((e: React.DragEvent) => { - if (!isFolder || !isFileDrag(e)) + if (!isFolder || !isDragEvent(e)) return dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0) if (dragCounterRef.current === 0) diff --git a/web/app/components/workflow/skill/hooks/use-node-move.ts b/web/app/components/workflow/skill/hooks/use-node-move.ts new file mode 100644 index 0000000000..d50a2aff1a --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-node-move.ts @@ -0,0 +1,119 @@ +'use client' + +// Internal tree node move handler (drag-and-drop within tree) + +import type { AppAssetTreeView } from '@/types/app-asset' +import { useCallback } 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 { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../constants' +import { findNodeById, isDescendantOf, toApiParentId } from '../utils/tree-utils' + +type NodeMoveTarget = { + folderId: string | null + isFolder: boolean +} + +type UseNodeMoveOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useNodeMove({ treeChildren }: UseNodeMoveOptions) { + const { t } = useTranslation('workflow') + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const storeApi = useWorkflowStore() + const moveNode = useMoveAppAssetNode() + + const handleDragStart = useCallback((e: React.DragEvent, nodeId: string) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData(INTERNAL_NODE_DRAG_TYPE, nodeId) + storeApi.getState().setCurrentDragType('move') + }, [storeApi]) + + const handleDragEnd = useCallback(() => { + storeApi.getState().setCurrentDragType(null) + storeApi.getState().setDragOverFolderId(null) + }, [storeApi]) + + const handleDragOver = useCallback((e: React.DragEvent, target: NodeMoveTarget) => { + e.preventDefault() + e.stopPropagation() + + if (!e.dataTransfer.types.includes(INTERNAL_NODE_DRAG_TYPE)) + return + + e.dataTransfer.dropEffect = 'move' + storeApi.getState().setDragOverFolderId(target.folderId ?? ROOT_ID) + }, [storeApi]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + storeApi.getState().setDragOverFolderId(null) + }, [storeApi]) + + const handleDrop = useCallback(async (e: React.DragEvent, targetFolderId: string | null) => { + e.preventDefault() + e.stopPropagation() + + storeApi.getState().setDragOverFolderId(null) + storeApi.getState().setCurrentDragType(null) + + const nodeId = e.dataTransfer.getData(INTERNAL_NODE_DRAG_TYPE) + if (!nodeId) + return + + // Prevent dropping node into itself + if (nodeId === targetFolderId) { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.cannotMoveToSelf'), + }) + return + } + + // Prevent circular move (dropping folder into its descendant) + const draggedNode = findNodeById(treeChildren, nodeId) + if (draggedNode?.node_type === 'folder' && targetFolderId) { + if (isDescendantOf(targetFolderId, nodeId, treeChildren)) { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.cannotMoveToDescendant'), + }) + return + } + } + + try { + await moveNode.mutateAsync({ + appId, + nodeId, + payload: { parent_id: toApiParentId(targetFolderId) }, + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.moved'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.moveError'), + }) + } + }, [appId, moveNode, t, storeApi, treeChildren]) + + return { + handleDragStart, + handleDragEnd, + handleDragOver, + handleDragLeave, + handleDrop, + isMoving: moveNode.isPending, + } +} diff --git a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts index f7bb8b4d72..7ac6284c0f 100644 --- a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts @@ -2,9 +2,10 @@ // Root-level file drop handler with drag counter to handle nested DOM events +import type { AppAssetTreeView } from '@/types/app-asset' import { useCallback, useRef } from 'react' -import { isFileDrag } from '../utils/drag-utils' -import { useFileDrop } from './use-file-drop' +import { isDragEvent } from '../utils/drag-utils' +import { useUnifiedDrag } from './use-unified-drag' type UseRootFileDropReturn = { handleRootDragEnter: (e: React.DragEvent) => void @@ -14,12 +15,16 @@ type UseRootFileDropReturn = { resetRootDragCounter: () => void } -export function useRootFileDrop(): UseRootFileDropReturn { - const { handleDragOver, handleDragLeave, handleDrop } = useFileDrop() +type UseRootFileDropOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useRootFileDrop({ treeChildren }: UseRootFileDropOptions): UseRootFileDropReturn { + const { handleDragOver, handleDragLeave, handleDrop } = useUnifiedDrag({ treeChildren }) const dragCounterRef = useRef(0) const handleRootDragEnter = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e)) + if (!isDragEvent(e)) return dragCounterRef.current += 1 }, []) @@ -29,7 +34,7 @@ export function useRootFileDrop(): UseRootFileDropReturn { }, [handleDragOver]) const handleRootDragLeave = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e)) + if (!isDragEvent(e)) return dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0) if (dragCounterRef.current === 0) diff --git a/web/app/components/workflow/skill/hooks/use-unified-drag.ts b/web/app/components/workflow/skill/hooks/use-unified-drag.ts new file mode 100644 index 0000000000..54aef387d6 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-unified-drag.ts @@ -0,0 +1,62 @@ +'use client' + +// Unified drag handler that routes to file upload or node move based on drag type + +import type { AppAssetTreeView } from '@/types/app-asset' +import { useCallback } from 'react' +import { getDragActionType, isFileDrag, isNodeDrag } from '../utils/drag-utils' +import { useFileDrop } from './use-file-drop' +import { useNodeMove } from './use-node-move' + +type DragTarget = { + folderId: string | null + isFolder: boolean +} + +type UseUnifiedDragOptions = { + treeChildren: AppAssetTreeView[] +} + +export function useUnifiedDrag({ treeChildren }: UseUnifiedDragOptions) { + const fileDrop = useFileDrop() + const nodeMove = useNodeMove({ treeChildren }) + + const handleDragOver = useCallback((e: React.DragEvent, target: DragTarget) => { + const actionType = getDragActionType(e) + if (actionType === 'upload') { + fileDrop.handleDragOver(e, target) + } + else if (actionType === 'move') { + nodeMove.handleDragOver(e, target) + } + }, [fileDrop, nodeMove]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (isFileDrag(e)) { + fileDrop.handleDragLeave(e) + } + else if (isNodeDrag(e)) { + nodeMove.handleDragLeave(e) + } + }, [fileDrop, nodeMove]) + + const handleDrop = useCallback((e: React.DragEvent, targetFolderId: string | null) => { + if (isFileDrag(e)) { + return fileDrop.handleDrop(e, targetFolderId) + } + else if (isNodeDrag(e)) { + return nodeMove.handleDrop(e, targetFolderId) + } + }, [fileDrop, nodeMove]) + + return { + handleDragOver, + handleDragLeave, + handleDrop, + // Expose individual handlers for specific needs + handleNodeDragStart: nodeMove.handleDragStart, + handleNodeDragEnd: nodeMove.handleDragEnd, + isUploading: fileDrop.isUploading, + isMoving: nodeMove.isMoving, + } +} diff --git a/web/app/components/workflow/skill/utils/drag-utils.ts b/web/app/components/workflow/skill/utils/drag-utils.ts index f8db5df7ed..bc23aafbec 100644 --- a/web/app/components/workflow/skill/utils/drag-utils.ts +++ b/web/app/components/workflow/skill/utils/drag-utils.ts @@ -1,5 +1,27 @@ import type * as React from 'react' +import { INTERNAL_NODE_DRAG_TYPE } from '../constants' +// Check if dragging external files from OS export const isFileDrag = (e: React.DragEvent): boolean => { return e.dataTransfer.types.includes('Files') } + +// Check if dragging internal tree node +export const isNodeDrag = (e: React.DragEvent): boolean => { + return e.dataTransfer.types.includes(INTERNAL_NODE_DRAG_TYPE) +} + +// Check if any supported drag type +export const isDragEvent = (e: React.DragEvent): boolean => { + return isFileDrag(e) || isNodeDrag(e) +} + +// Get drag action type for tooltip display +export type DragActionType = 'upload' | 'move' +export const getDragActionType = (e: React.DragEvent): DragActionType | null => { + if (isFileDrag(e)) + return 'upload' + if (isNodeDrag(e)) + return 'move' + return null +} diff --git a/web/app/components/workflow/skill/utils/tree-utils.ts b/web/app/components/workflow/skill/utils/tree-utils.ts index 7f6886cddb..9836a4632a 100644 --- a/web/app/components/workflow/skill/utils/tree-utils.ts +++ b/web/app/components/workflow/skill/utils/tree-utils.ts @@ -117,6 +117,19 @@ export function getAllDescendantFileIds( return fileIds } +export function isDescendantOf( + potentialDescendantId: string | null | undefined, + ancestorId: string | null | undefined, + nodes: AppAssetTreeView[], +): boolean { + if (!potentialDescendantId || !ancestorId) + return false + if (potentialDescendantId === ancestorId) + return true + const ancestors = getAncestorIds(potentialDescendantId, nodes) + return ancestors.includes(ancestorId) +} + export function getTargetFolderIdFromSelection( selectedId: string | null, nodes: AppAssetTreeView[], diff --git a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts index cfe3ec4c84..9cd9f6b3f0 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts @@ -97,6 +97,12 @@ export const createFileTreeSlice: StateCreator< set({ dragOverFolderId: folderId }) }, + currentDragType: null, + + setCurrentDragType: (type) => { + set({ currentDragType: type }) + }, + fileTreeSearchTerm: '', setFileTreeSearchTerm: (term) => { diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts index 89a47905cc..b706da8dba 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -23,6 +23,8 @@ export type PendingCreateNode = { nodeType: 'file' | 'folder' } +export type DragActionType = 'upload' | 'move' + export type FileTreeSliceShape = { expandedFolderIds: Set setExpandedFolderIds: (ids: Set) => void @@ -40,6 +42,8 @@ export type FileTreeSliceShape = { clearCreateNode: () => void dragOverFolderId: string | null setDragOverFolderId: (folderId: string | null) => void + currentDragType: DragActionType | null + setCurrentDragType: (type: DragActionType | null) => void fileTreeSearchTerm: string setFileTreeSearchTerm: (term: string) => void } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index bf81a1607f..cad36d593f 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1023,7 +1023,10 @@ "skillSidebar.folderName": "Folder name", "skillSidebar.folderNamePlaceholder": "Folder name", "skillSidebar.loadError": "Failed to load files", + "skillSidebar.menu.cannotMoveToDescendant": "Cannot move a folder into its descendant", + "skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself", "skillSidebar.menu.copy": "Copy", + "skillSidebar.menu.copyNotSupported": "Copy is not supported yet", "skillSidebar.menu.createError": "Failed to create item", "skillSidebar.menu.cut": "Cut", "skillSidebar.menu.delete": "Delete", @@ -1043,15 +1046,13 @@ "skillSidebar.menu.folderDropNotSupported": "Folder upload via drag-drop is not supported yet. Please use the upload folder option.", "skillSidebar.menu.folderUploaded": "Folder uploaded successfully", "skillSidebar.menu.moreActions": "More actions", + "skillSidebar.menu.moveError": "Failed to move", + "skillSidebar.menu.moved": "Moved successfully", "skillSidebar.menu.newFile": "New File", "skillSidebar.menu.newFilePrompt": "Enter file name (with extension, e.g., script.py):", "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", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index f0c47b410c..aef9a33e03 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1015,7 +1015,10 @@ "skillSidebar.folderName": "文件夹名称", "skillSidebar.folderNamePlaceholder": "文件夹名称", "skillSidebar.loadError": "加载文件失败", + "skillSidebar.menu.cannotMoveToDescendant": "无法将文件夹移动到其子文件夹中", + "skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部", "skillSidebar.menu.copy": "复制", + "skillSidebar.menu.copyNotSupported": "暂不支持复制功能", "skillSidebar.menu.createError": "创建失败", "skillSidebar.menu.cut": "剪切", "skillSidebar.menu.delete": "删除", @@ -1034,15 +1037,13 @@ "skillSidebar.menu.folderCreated": "文件夹创建成功", "skillSidebar.menu.folderDropNotSupported": "暂不支持拖拽上传文件夹,请使用上传文件夹选项。", "skillSidebar.menu.folderUploaded": "文件夹上传成功", + "skillSidebar.menu.moveError": "移动失败", + "skillSidebar.menu.moved": "移动成功", "skillSidebar.menu.newFile": "新建文件", "skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):", "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": "重命名成功",