mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -160,6 +160,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
<FileOperationsMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user