feat(skill-editor): add rename and delete operations for folder context menu

- Add Rename using react-arborist native inline editing (node.edit())
- Add Delete with Confirm modal and automatic tab cleanup
- Add getAllDescendantFileIds utility for finding files to close on delete
- Add i18n strings for rename/delete operations (en-US, zh-Hans)
This commit is contained in:
yyh
2026-01-15 15:45:01 +08:00
parent 52215e9166
commit 1741fcf84d
7 changed files with 209 additions and 8 deletions

View File

@ -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<TreeApi<TreeNodeData> | null>
/** Node API for dropdown menu (to call node.edit()) */
node?: NodeApi<TreeNodeData>
}
const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
nodeId,
onClose,
className,
treeRef,
node,
}) => {
const { t } = useTranslation('workflow')
const fileInputRef = useRef<HTMLInputElement>(null)
@ -68,9 +91,19 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
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<FileOperationsMenuProps> = ({
}
}, [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 (
<div className={cn(
@ -322,6 +407,46 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
/>
{/* Divider before destructive actions */}
{nodeId !== 'root' && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon={RiEdit2Line}
label={t('skillSidebar.menu.rename')}
onClick={handleRename}
disabled={isLoading}
/>
<button
type="button"
onClick={handleDeleteClick}
disabled={isLoading}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
'hover:bg-state-destructive-hover disabled:cursor-not-allowed disabled:opacity-50',
'group',
)}
>
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" />
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
{t('skillSidebar.menu.delete')}
</span>
</button>
</>
)}
{/* Delete confirmation modal */}
<Confirm
isShow={showDeleteConfirm}
type="danger"
title={t('skillSidebar.menu.deleteConfirmTitle')}
content={t('skillSidebar.menu.deleteConfirmContent')}
onConfirm={handleDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
isLoading={deleteNode.isPending}
/>
</div>
)
}

View File

@ -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<TreeApi<TreeNodeData> | 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<FileTreeContextMenuProps> = ({ treeRef }) => {
const ref = useRef<HTMLDivElement>(null)
const contextMenu = useSkillEditorStore(s => s.contextMenu)
const storeApi = useSkillEditorStoreApi()
@ -41,6 +47,7 @@ const FileTreeContextMenu: FC = () => {
<FileOperationsMenu
nodeId={contextMenu.nodeId}
onClose={handleClose}
treeRef={treeRef}
/>
</div>
)

View File

@ -160,6 +160,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
<FileOperationsMenu
nodeId={node.data.id}
onClose={() => setShowDropdown(false)}
node={node}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -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<FilesProps> = ({ 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<FilesProps> = ({ 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<FilesProps> = ({ className }) => {
// Events
onToggle={handleToggle}
onActivate={handleActivate}
onRename={handleRename}
// Disable features not in MVP
disableDrag
disableDrop
disableEdit
>
{FileTreeNode}
</Tree>
</div>
<DropTip />
{/* Right-click context menu */}
<FileTreeContextMenu />
<FileTreeContextMenu treeRef={treeRef} />
</div>
)
}

View File

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