From 388ee087c09289488c8e45d16ee896b88da00b98 Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 15 Jan 2026 14:52:56 +0800 Subject: [PATCH] feat(skill-editor): add folder context menu with file operations Add right-click context menu and "..." dropdown button for folders in the file tree, enabling file operations within any folder: - New File: Create empty file via Blob upload - New Folder: Create subfolder - Upload File: Upload multiple files to folder - Upload Folder: Upload entire folder structure preserving hierarchy Implementation includes: - FileOperationsMenu: Shared menu component for both triggers - FileTreeContextMenu: Right-click menu with absolute positioning - FileTreeNode: Added context menu and dropdown button for folders - Store slice for context menu state management - i18n strings for en-US and zh-Hans --- .../workflow/skill/file-operations-menu.tsx | 353 ++++++++++++++++++ .../workflow/skill/file-tree-context-menu.tsx | 49 +++ .../workflow/skill/file-tree-node.tsx | 79 +++- web/app/components/workflow/skill/files.tsx | 3 + .../components/workflow/skill/store/index.ts | 27 ++ web/app/components/workflow/skill/type.ts | 22 ++ web/i18n/en-US/workflow.json | 12 + web/i18n/zh-Hans/workflow.json | 12 + 8 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 web/app/components/workflow/skill/file-operations-menu.tsx create mode 100644 web/app/components/workflow/skill/file-tree-context-menu.tsx diff --git a/web/app/components/workflow/skill/file-operations-menu.tsx b/web/app/components/workflow/skill/file-operations-menu.tsx new file mode 100644 index 0000000000..170a8484fe --- /dev/null +++ b/web/app/components/workflow/skill/file-operations-menu.tsx @@ -0,0 +1,353 @@ +'use client' + +import type { FC } from 'react' +import { RiFileAddLine, RiFolderAddLine, RiFolderUploadLine, RiUploadLine } from '@remixicon/react' +import * as React from 'react' +import { useCallback, 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 { useCreateAppAssetFile, useCreateAppAssetFolder } from '@/service/use-app-asset' +import { cn } from '@/utils/classnames' + +/** + * FileOperationsMenu - Menu content for file operations + * + * Shared by both context menu (right-click) and dropdown menu (... button) + * + * Features: + * - New File: Create empty file (via empty Blob upload) + * - New Folder: Create folder in target location + * - Upload File: Upload file(s) to target folder + * - Upload Folder: Upload entire folder structure (webkitdirectory) + */ + +type FileOperationsMenuProps = { + /** Target folder ID, or 'root' for root level */ + nodeId: string + /** Callback to close menu after action */ + onClose: () => void + /** Optional className */ + className?: string +} + +const FileOperationsMenu: FC = ({ + nodeId, + onClose, + className, +}) => { + const { t } = useTranslation('workflow') + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + // Get appId from app store + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + + // Mutations + const createFolder = useCreateAppAssetFolder() + const createFile = useCreateAppAssetFile() + + // Determine parent_id (null for root) + const parentId = nodeId === 'root' ? null : nodeId + + // Handle New File + const handleNewFile = useCallback(async () => { + // eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later + const fileName = window.prompt(t('skillSidebar.menu.newFilePrompt')) + if (!fileName || !fileName.trim()) { + onClose() + return + } + + try { + // Create empty Blob and upload as file + const emptyBlob = new Blob([''], { type: 'text/plain' }) + const file = new File([emptyBlob], fileName.trim()) + + await createFile.mutateAsync({ + appId, + name: fileName.trim(), + file, + parentId, + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.fileCreated'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.createError'), + }) + } + finally { + onClose() + } + }, [appId, createFile, onClose, parentId, t]) + + // Handle New Folder + const handleNewFolder = useCallback(async () => { + // eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later + const folderName = window.prompt(t('skillSidebar.menu.newFolderPrompt')) + if (!folderName || !folderName.trim()) { + onClose() + return + } + + try { + await createFolder.mutateAsync({ + appId, + payload: { + name: folderName.trim(), + parent_id: parentId, + }, + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.folderCreated'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.createError'), + }) + } + finally { + onClose() + } + }, [appId, createFolder, onClose, parentId, t]) + + // Handle Upload File button click + const handleUploadFileClick = useCallback(() => { + fileInputRef.current?.click() + }, []) + + // Handle Upload Folder button click + const handleUploadFolderClick = useCallback(() => { + folderInputRef.current?.click() + }, []) + + // Handle file input change (single or multiple files) + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) { + onClose() + return + } + + try { + // Upload files sequentially to avoid overwhelming the server + for (const file of files) { + await createFile.mutateAsync({ + appId, + name: file.name, + file, + parentId, + }) + } + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.filesUploaded', { count: files.length }), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.uploadError'), + }) + } + finally { + // Reset input to allow re-uploading same file + e.target.value = '' + onClose() + } + }, [appId, createFile, onClose, parentId, t]) + + // Handle folder input change (webkitdirectory) + const handleFolderChange = useCallback(async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) { + onClose() + return + } + + try { + // Collect all unique folder paths from file paths + const folders = new Set() + + for (const file of files) { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + const parts = relativePath.split('/') + + // Collect all folder paths (parent directories) + if (parts.length > 1) { + let folderPath = '' + for (let i = 0; i < parts.length - 1; i++) { + folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i] + folders.add(folderPath) + } + } + } + + // Sort folders by depth (parent before child) + const sortedFolders = Array.from(folders).sort((a, b) => { + return a.split('/').length - b.split('/').length + }) + + // Create folders and track their IDs + const folderIdMap = new Map() + folderIdMap.set('', parentId) // Root maps to target parent + + for (const folderPath of sortedFolders) { + const parts = folderPath.split('/') + const folderName = parts[parts.length - 1] + const parentPath = parts.slice(0, -1).join('/') + const parentFolderId = folderIdMap.get(parentPath) ?? parentId + + const result = await createFolder.mutateAsync({ + appId, + payload: { + name: folderName, + parent_id: parentFolderId, + }, + }) + + folderIdMap.set(folderPath, result.id) + } + + // Upload files to their respective folders + for (const file of files) { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + const parts = relativePath.split('/') + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : '' + const targetParentId = folderIdMap.get(parentPath) ?? parentId + + await createFile.mutateAsync({ + appId, + name: file.name, + file, + parentId: targetParentId, + }) + } + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.folderUploaded'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.uploadError'), + }) + } + finally { + // Reset input + e.target.value = '' + onClose() + } + }, [appId, createFile, createFolder, onClose, parentId, t]) + + const isLoading = createFile.isPending || createFolder.isPending + + return ( +
+ {/* Hidden file inputs */} + + + + {/* New File */} + + + {/* New Folder */} + + + {/* Divider */} +
+ + {/* Upload File */} + + + {/* Upload Folder */} + +
+ ) +} + +export default React.memo(FileOperationsMenu) diff --git a/web/app/components/workflow/skill/file-tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree-context-menu.tsx new file mode 100644 index 0000000000..785b3a127f --- /dev/null +++ b/web/app/components/workflow/skill/file-tree-context-menu.tsx @@ -0,0 +1,49 @@ +'use client' + +import type { FC } from 'react' +import { useClickAway } from 'ahooks' +import * as React from 'react' +import { useCallback, useRef } from 'react' +import FileOperationsMenu from './file-operations-menu' +import { useSkillEditorStore, useSkillEditorStoreApi } from './store' + +/** + * FileTreeContextMenu - Right-click context menu for file tree + * + * Renders at absolute position when contextMenu state is set. + * Uses useClickAway to close when clicking outside. + */ +const FileTreeContextMenu: FC = () => { + const ref = useRef(null) + const contextMenu = useSkillEditorStore(s => s.contextMenu) + const storeApi = useSkillEditorStoreApi() + + const handleClose = useCallback(() => { + storeApi.getState().setContextMenu(null) + }, [storeApi]) + + useClickAway(() => { + handleClose() + }, ref) + + if (!contextMenu) + return null + + return ( +
+ +
+ ) +} + +export default React.memo(FileTreeContextMenu) diff --git a/web/app/components/workflow/skill/file-tree-node.tsx b/web/app/components/workflow/skill/file-tree-node.tsx index 8cedc06bbb..abed6b878a 100644 --- a/web/app/components/workflow/skill/file-tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree-node.tsx @@ -3,11 +3,18 @@ import type { NodeRendererProps } from 'react-arborist' import type { TreeNodeData } from './type' import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' -import { RiFolderLine, RiFolderOpenLine } from '@remixicon/react' +import { RiFolderLine, RiFolderOpenLine, RiMoreFill } from '@remixicon/react' import * as React from 'react' +import { useCallback, useState } from 'react' import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' import { cn } from '@/utils/classnames' -import { useSkillEditorStore } from './store' +import FileOperationsMenu from './file-operations-menu' +import { useSkillEditorStore, useSkillEditorStoreApi } from './store' import { getFileIconType } from './utils' /** @@ -20,11 +27,19 @@ import { getFileIconType } from './utils' * - Colors: text-secondary (#354052), text-primary (#101828) for selected * - Hover bg: rgba(200,206,218,0.2), Active bg: rgba(200,206,218,0.4) * - Folder icon: blue (#155aef) when open + * + * Features: + * - Right-click context menu for folders + * - "..." button dropdown for folders (visible on hover) */ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps) => { const isFolder = node.data.node_type === 'folder' const isSelected = node.isSelected const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id)) + const storeApi = useSkillEditorStoreApi() + + // Dropdown menu state (for ... button) + const [showDropdown, setShowDropdown] = useState(false) // Get file icon type for files const fileIconType = !isFolder ? getFileIconType(node.data.name) : null @@ -46,6 +61,33 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps { + // Only show context menu for folders + if (!isFolder) + return + + e.preventDefault() + e.stopPropagation() + + storeApi.getState().setContextMenu({ + top: e.clientY, + left: e.clientX, + nodeId: node.data.id, + }) + }, [isFolder, node.data.id, storeApi]) + + // Dropdown close handler + const handleDropdownClose = useCallback(() => { + setShowDropdown(false) + }, []) + + // More button click handler + const handleMoreClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setShowDropdown(prev => !prev) + }, []) + return (
{/* Icon */}
@@ -94,6 +137,38 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps {node.data.name} + + {/* More button - only for folders, visible on hover */} + {isFolder && ( + + + + + + + + + )}
) } diff --git a/web/app/components/workflow/skill/files.tsx b/web/app/components/workflow/skill/files.tsx index e1a18c1264..133df0fe00 100644 --- a/web/app/components/workflow/skill/files.tsx +++ b/web/app/components/workflow/skill/files.tsx @@ -11,6 +11,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useGetAppAssetTree } from '@/service/use-app-asset' import { cn } from '@/utils/classnames' +import FileTreeContextMenu from './file-tree-context-menu' import FileTreeNode from './file-tree-node' import { useSkillEditorStore, useSkillEditorStoreApi } from './store' import { getAncestorIds, toOpensObject } from './type' @@ -180,6 +181,8 @@ const Files: React.FC = ({ className }) => {
+ {/* Right-click context menu */} +
) } diff --git a/web/app/components/workflow/skill/store/index.ts b/web/app/components/workflow/skill/store/index.ts index 7e184debdd..93765f14b4 100644 --- a/web/app/components/workflow/skill/store/index.ts +++ b/web/app/components/workflow/skill/store/index.ts @@ -172,6 +172,30 @@ export const createDirtySlice: StateCreator = (set, get) => ({ }, }) +// ============================================================================ +// File Operations Menu Slice +// ============================================================================ + +export type FileOperationsMenuSliceShape = { + /** Context menu state (right-click) - null when closed */ + contextMenu: { + top: number + left: number + nodeId: string + } | null + + /** Set or clear context menu */ + setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void +} + +export const createFileOperationsMenuSlice: StateCreator = set => ({ + contextMenu: null, + + setContextMenu: (contextMenu) => { + set({ contextMenu }) + }, +}) + // ============================================================================ // Combined Store Shape // ============================================================================ @@ -180,6 +204,7 @@ export type SkillEditorShape = TabSliceShape & FileTreeSliceShape & DirtySliceShape + & FileOperationsMenuSliceShape & { /** Reset all state (called when appId changes) */ reset: () => void @@ -194,6 +219,7 @@ export const createSkillEditorStore = (): StoreApi => { ...createTabSlice(...args), ...createFileTreeSlice(...args), ...createDirtySlice(...args), + ...createFileOperationsMenuSlice(...args), reset: () => { const [set] = args @@ -203,6 +229,7 @@ export const createSkillEditorStore = (): StoreApi => { previewTabId: null, expandedFolderIds: new Set(), dirtyContents: new Map(), + contextMenu: null, }) }, })) diff --git a/web/app/components/workflow/skill/type.ts b/web/app/components/workflow/skill/type.ts index 25e0ff5eab..9819c55e9a 100644 --- a/web/app/components/workflow/skill/type.ts +++ b/web/app/components/workflow/skill/type.ts @@ -108,3 +108,25 @@ export function toOpensObject(expandedIds: Set): Record }) return opens } + +/** + * Find a node by ID in the tree (recursive search) + * @param nodes - Tree nodes from API (nested structure) + * @param nodeId - Target node ID + * @returns Node if found, null otherwise + */ +export function findNodeById( + nodes: AppAssetTreeView[], + nodeId: string, +): AppAssetTreeView | null { + for (const node of nodes) { + if (node.id === nodeId) + return node + if (node.children && node.children.length > 0) { + const found = findNodeById(node.children, nodeId) + if (found) + return found + } + } + return null +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index a0c0fe9bbd..9045d7f52f 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1004,6 +1004,18 @@ "skillSidebar.empty": "No files yet", "skillSidebar.folderName": "Folder name", "skillSidebar.loadError": "Failed to load files", + "skillSidebar.menu.createError": "Failed to create item", + "skillSidebar.menu.fileCreated": "File created successfully", + "skillSidebar.menu.filesUploaded": "{{count}} file(s) uploaded successfully", + "skillSidebar.menu.folderCreated": "Folder created successfully", + "skillSidebar.menu.folderUploaded": "Folder uploaded 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.uploadError": "Failed to upload", + "skillSidebar.menu.uploadFile": "Upload File", + "skillSidebar.menu.uploadFolder": "Upload Folder", "skillSidebar.newFolder": "New folder", "skillSidebar.searchPlaceholder": "Search files...", "skillSidebar.uploading": "Uploading...", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 6bbb60dd14..bbad15d020 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -998,6 +998,18 @@ "skillSidebar.empty": "暂无文件", "skillSidebar.folderName": "文件夹名称", "skillSidebar.loadError": "加载文件失败", + "skillSidebar.menu.createError": "创建失败", + "skillSidebar.menu.fileCreated": "文件创建成功", + "skillSidebar.menu.filesUploaded": "成功上传 {{count}} 个文件", + "skillSidebar.menu.folderCreated": "文件夹创建成功", + "skillSidebar.menu.folderUploaded": "文件夹上传成功", + "skillSidebar.menu.newFile": "新建文件", + "skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):", + "skillSidebar.menu.newFolder": "新建文件夹", + "skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:", + "skillSidebar.menu.uploadError": "上传失败", + "skillSidebar.menu.uploadFile": "上传文件", + "skillSidebar.menu.uploadFolder": "上传文件夹", "skillSidebar.newFolder": "新建文件夹", "skillSidebar.searchPlaceholder": "搜索文件...", "skillSidebar.uploading": "上传中...",