diff --git a/web/app/components/workflow/skill/file-operations-menu.tsx b/web/app/components/workflow/skill/file-operations-menu.tsx index b48558c0b0..e4ac5c6b42 100644 --- a/web/app/components/workflow/skill/file-operations-menu.tsx +++ b/web/app/components/workflow/skill/file-operations-menu.tsx @@ -1,14 +1,31 @@ 'use client' import type { FC } from 'react' -import { RiFileAddLine, RiFolderAddLine, RiFolderUploadLine, RiUploadLine } from '@remixicon/react' +import type { NodeApi, TreeApi } from 'react-arborist' +import type { TreeNodeData } from './type' +import { + RiDeleteBinLine, + RiEdit2Line, + RiFileAddLine, + RiFolderAddLine, + RiFolderUploadLine, + RiUploadLine, +} from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' +import Confirm from '@/app/components/base/confirm' import Toast from '@/app/components/base/toast' -import { useCreateAppAssetFile, useCreateAppAssetFolder } from '@/service/use-app-asset' +import { + useCreateAppAssetFile, + useCreateAppAssetFolder, + useDeleteAppAssetNode, + useGetAppAssetTree, +} from '@/service/use-app-asset' import { cn } from '@/utils/classnames' +import { useSkillEditorStoreApi } from './store' +import { getAllDescendantFileIds } from './type' /** * FileOperationsMenu - Menu content for file operations @@ -53,12 +70,18 @@ type FileOperationsMenuProps = { onClose: () => void /** Optional className */ className?: string + /** Tree API ref for context menu (to call node.edit()) */ + treeRef?: React.RefObject | null> + /** Node API for dropdown menu (to call node.edit()) */ + node?: NodeApi } const FileOperationsMenu: FC = ({ nodeId, onClose, className, + treeRef, + node, }) => { const { t } = useTranslation('workflow') const fileInputRef = useRef(null) @@ -68,9 +91,19 @@ const FileOperationsMenu: FC = ({ const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' + // Store API for tab cleanup + const storeApi = useSkillEditorStoreApi() + // Mutations const createFolder = useCreateAppAssetFolder() const createFile = useCreateAppAssetFile() + const deleteNode = useDeleteAppAssetNode() + + // Tree data for descendant lookup + const { data: treeData } = useGetAppAssetTree(appId) + + // Delete confirmation state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) // Determine parent_id (null for root) const parentId = nodeId === 'root' ? null : nodeId @@ -268,7 +301,59 @@ const FileOperationsMenu: FC = ({ } }, [appId, createFile, createFolder, onClose, parentId, t]) - const isLoading = createFile.isPending || createFolder.isPending + // Handle Rename - trigger react-arborist inline editing + const handleRename = useCallback(() => { + // Context menu: use treeRef + if (treeRef?.current) { + const targetNode = treeRef.current.get(nodeId) + targetNode?.edit() + } + // Dropdown: use node directly + else if (node) { + node.edit() + } + onClose() + }, [nodeId, node, onClose, treeRef]) + + // Handle Delete click - show confirmation + const handleDeleteClick = useCallback(() => { + setShowDeleteConfirm(true) + }, []) + + // Handle Delete confirm + const handleDeleteConfirm = useCallback(async () => { + try { + // Find descendant file IDs for tab cleanup + const descendantFileIds = treeData?.children + ? getAllDescendantFileIds(nodeId, treeData.children) + : [] + + await deleteNode.mutateAsync({ appId, nodeId }) + + // Close tabs for deleted files + descendantFileIds.forEach((fileId) => { + storeApi.getState().closeTab(fileId) + storeApi.getState().clearDraftContent(fileId) + }) + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.deleted'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.deleteError'), + }) + } + finally { + setShowDeleteConfirm(false) + onClose() + } + }, [appId, nodeId, deleteNode, storeApi, treeData?.children, onClose, t]) + + const isLoading = createFile.isPending || createFolder.isPending || deleteNode.isPending return (
= ({ onClick={() => folderInputRef.current?.click()} disabled={isLoading} /> + + {/* Divider before destructive actions */} + {nodeId !== 'root' && ( + <> +
+ + + + + )} + + {/* Delete confirmation modal */} + setShowDeleteConfirm(false)} + isLoading={deleteNode.isPending} + />
) } diff --git a/web/app/components/workflow/skill/file-tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree-context-menu.tsx index 785b3a127f..971218f2be 100644 --- a/web/app/components/workflow/skill/file-tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree-context-menu.tsx @@ -1,19 +1,25 @@ 'use client' import type { FC } from 'react' +import type { TreeApi } from 'react-arborist' +import type { TreeNodeData } from './type' 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' +type FileTreeContextMenuProps = { + treeRef: React.RefObject | null> +} + /** * 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 FileTreeContextMenu: FC = ({ treeRef }) => { const ref = useRef(null) const contextMenu = useSkillEditorStore(s => s.contextMenu) const storeApi = useSkillEditorStoreApi() @@ -41,6 +47,7 @@ const FileTreeContextMenu: FC = () => {
) diff --git a/web/app/components/workflow/skill/file-tree-node.tsx b/web/app/components/workflow/skill/file-tree-node.tsx index 37def8f5b9..35f0941168 100644 --- a/web/app/components/workflow/skill/file-tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree-node.tsx @@ -160,6 +160,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps setShowDropdown(false)} + node={node} /> diff --git a/web/app/components/workflow/skill/files.tsx b/web/app/components/workflow/skill/files.tsx index 7c285af93a..bbdfbcee28 100644 --- a/web/app/components/workflow/skill/files.tsx +++ b/web/app/components/workflow/skill/files.tsx @@ -9,7 +9,8 @@ import { Tree } from 'react-arborist' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' -import { useGetAppAssetTree } from '@/service/use-app-asset' +import Toast from '@/app/components/base/toast' +import { useGetAppAssetTree, useRenameAppAssetNode } from '@/service/use-app-asset' import { cn } from '@/utils/classnames' import FileTreeContextMenu from './file-tree-context-menu' import FileTreeNode from './file-tree-node' @@ -63,6 +64,9 @@ const Files: React.FC = ({ className }) => { const activeTabId = useSkillEditorStore(s => s.activeTabId) const storeApi = useSkillEditorStoreApi() + // Rename mutation for inline editing + const renameNode = useRenameAppAssetNode() + // Convert Set to react-arborist OpenMap for initial state const initialOpenState = useMemo(() => toOpensObject(expandedFolderIds), [expandedFolderIds]) @@ -83,6 +87,20 @@ const Files: React.FC = ({ className }) => { } }, [storeApi]) + // Handle rename from react-arborist inline editing + const handleRename = useCallback(({ id, name }: { id: string, name: string }) => { + renameNode.mutateAsync({ + appId, + nodeId: id, + payload: { name }, + }).catch(() => { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.renameError'), + }) + }) + }, [appId, renameNode, t]) + // Auto-reveal when activeTabId changes (sync from tab click to tree) useEffect(() => { if (!activeTabId || !treeData?.children || !treeRef.current) @@ -172,17 +190,17 @@ const Files: React.FC = ({ className }) => { // Events onToggle={handleToggle} onActivate={handleActivate} + onRename={handleRename} // Disable features not in MVP disableDrag disableDrop - disableEdit > {FileTreeNode} {/* Right-click context menu */} - + ) } diff --git a/web/app/components/workflow/skill/type.ts b/web/app/components/workflow/skill/type.ts index 222d3456eb..fb6ce18b29 100644 --- a/web/app/components/workflow/skill/type.ts +++ b/web/app/components/workflow/skill/type.ts @@ -126,3 +126,39 @@ export function findNodeById( } return null } + +/** + * Get all descendant file IDs recursively (for tab cleanup on node delete) + * @param nodeId - Target node ID (file or folder) + * @param nodes - Tree nodes from API + * @returns Array of file IDs to close (the node itself if file, or all descendants if folder) + */ +export function getAllDescendantFileIds( + nodeId: string, + nodes: AppAssetTreeView[], +): string[] { + const targetNode = findNodeById(nodes, nodeId) + if (!targetNode) + return [] + + // If deleting a file, return just that file's ID + if (targetNode.node_type === 'file') + return [targetNode.id] + + // For folders, collect all descendant files + const fileIds: string[] = [] + + function collectFileIds(nodeList: AppAssetTreeView[]) { + for (const node of nodeList) { + if (node.node_type === 'file') + fileIds.push(node.id) + if (node.children && node.children.length > 0) + collectFileIds(node.children) + } + } + + if (targetNode.children) + collectFileIds(targetNode.children) + + return fileIds +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 9045d7f52f..c3ee529b9a 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1005,6 +1005,11 @@ "skillSidebar.folderName": "Folder name", "skillSidebar.loadError": "Failed to load files", "skillSidebar.menu.createError": "Failed to create item", + "skillSidebar.menu.delete": "Delete", + "skillSidebar.menu.deleteConfirmContent": "This will permanently delete the folder and all its contents. Any open files from this folder will be closed.", + "skillSidebar.menu.deleteConfirmTitle": "Delete folder?", + "skillSidebar.menu.deleteError": "Failed to delete folder", + "skillSidebar.menu.deleted": "Folder deleted successfully", "skillSidebar.menu.fileCreated": "File created successfully", "skillSidebar.menu.filesUploaded": "{{count}} file(s) uploaded successfully", "skillSidebar.menu.folderCreated": "Folder created successfully", @@ -1013,6 +1018,8 @@ "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.rename": "Rename", + "skillSidebar.menu.renameError": "Failed to rename", "skillSidebar.menu.uploadError": "Failed to upload", "skillSidebar.menu.uploadFile": "Upload File", "skillSidebar.menu.uploadFolder": "Upload Folder", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index bbad15d020..c5bd74ad2c 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -999,6 +999,11 @@ "skillSidebar.folderName": "文件夹名称", "skillSidebar.loadError": "加载文件失败", "skillSidebar.menu.createError": "创建失败", + "skillSidebar.menu.delete": "删除", + "skillSidebar.menu.deleteConfirmContent": "这将永久删除该文件夹及其所有内容。该文件夹中已打开的文件标签将被关闭。", + "skillSidebar.menu.deleteConfirmTitle": "删除文件夹?", + "skillSidebar.menu.deleteError": "删除文件夹失败", + "skillSidebar.menu.deleted": "文件夹删除成功", "skillSidebar.menu.fileCreated": "文件创建成功", "skillSidebar.menu.filesUploaded": "成功上传 {{count}} 个文件", "skillSidebar.menu.folderCreated": "文件夹创建成功", @@ -1007,6 +1012,8 @@ "skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):", "skillSidebar.menu.newFolder": "新建文件夹", "skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:", + "skillSidebar.menu.rename": "重命名", + "skillSidebar.menu.renameError": "重命名失败", "skillSidebar.menu.uploadError": "上传失败", "skillSidebar.menu.uploadFile": "上传文件", "skillSidebar.menu.uploadFolder": "上传文件夹",