mirror of
https://github.com/langgenius/dify.git
synced 2026-04-26 05:35:58 +08:00
feat(skill): add file right-click/more menu and refactor naming
- Add right-click context menu and '...' more button for files - Files now support Rename and Delete operations - Created file-node-menu.tsx for file-specific menu - Refactor component naming for consistency - file-item-menu.tsx -> file-node-menu.tsx (unify 'node' terminology) - file-operations-menu.tsx -> folder-node-menu.tsx (clarify folder menu) - file-tree-context-menu.tsx -> tree-context-menu.tsx (simplify) - file-tree-node.tsx -> tree-node.tsx (simplify) - files.tsx -> file-tree.tsx (more descriptive) - Renamed internal components: FileTreeNode -> TreeNode, Files -> FileTree - Add context menu node highlight - When right-clicking a node, it now shows hover highlight - Subscribed to contextMenu.nodeId in TreeNode component
This commit is contained in:
109
web/app/components/workflow/skill/file-node-menu.tsx
Normal file
109
web/app/components/workflow/skill/file-node-menu.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from './type'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEdit2Line,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileOperations } from './hooks/use-file-operations'
|
||||
|
||||
type MenuItemProps = {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
type FileItemMenuProps = {
|
||||
nodeId: string
|
||||
onClose: () => void
|
||||
className?: string
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const FileItemMenu: FC<FileItemMenuProps> = ({
|
||||
nodeId,
|
||||
onClose,
|
||||
className,
|
||||
treeRef,
|
||||
node,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
|
||||
const {
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
type="danger"
|
||||
title={t('skillSidebar.menu.deleteConfirmTitle')}
|
||||
content={t('skillSidebar.menu.deleteConfirmContent')}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileItemMenu)
|
||||
@ -1,50 +0,0 @@
|
||||
'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>
|
||||
}
|
||||
|
||||
const FileTreeContextMenu: FC<FileTreeContextMenuProps> = ({ treeRef }) => {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[100]"
|
||||
style={{
|
||||
top: contextMenu.top,
|
||||
left: contextMenu.left,
|
||||
}}
|
||||
>
|
||||
<FileOperationsMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileTreeContextMenu)
|
||||
@ -13,12 +13,12 @@ import Loading from '@/app/components/base/loading'
|
||||
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'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
import TreeNode from './tree-node'
|
||||
import { getAncestorIds } from './utils/tree-utils'
|
||||
|
||||
type FilesProps = {
|
||||
type FileTreeProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ const DropTip = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
|
||||
|
||||
@ -151,13 +151,13 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
disableDrag
|
||||
disableDrop
|
||||
>
|
||||
{FileTreeNode}
|
||||
{TreeNode}
|
||||
</Tree>
|
||||
</div>
|
||||
<DropTip />
|
||||
<FileTreeContextMenu treeRef={treeRef} />
|
||||
<TreeContextMenu treeRef={treeRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Files)
|
||||
export default React.memo(FileTree)
|
||||
@ -5,7 +5,7 @@ import * as React from 'react'
|
||||
import EditorArea from './editor-area'
|
||||
import EditorBody from './editor-body'
|
||||
import EditorTabs from './editor-tabs'
|
||||
import Files from './files'
|
||||
import FileTree from './file-tree'
|
||||
import Sidebar from './sidebar'
|
||||
import SidebarSearchAdd from './sidebar-search-add'
|
||||
import SkillDocEditor from './skill-doc-editor'
|
||||
@ -17,7 +17,7 @@ const SkillMain: FC = () => {
|
||||
<SkillPageLayout>
|
||||
<Sidebar>
|
||||
<SidebarSearchAdd />
|
||||
<Files />
|
||||
<FileTree />
|
||||
</Sidebar>
|
||||
<EditorArea>
|
||||
<EditorTabs />
|
||||
|
||||
76
web/app/components/workflow/skill/tree-context-menu.tsx
Normal file
76
web/app/components/workflow/skill/tree-context-menu.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
'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, useMemo, useRef } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useGetAppAssetTree } from '@/service/use-app-asset'
|
||||
import FileNodeMenu from './file-node-menu'
|
||||
import FolderNodeMenu from './folder-node-menu'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { findNodeById } from './utils/tree-utils'
|
||||
|
||||
type TreeContextMenuProps = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
}
|
||||
|
||||
const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const contextMenu = useSkillEditorStore(s => s.contextMenu)
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const { data: treeData } = useGetAppAssetTree(appId)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
storeApi.getState().setContextMenu(null)
|
||||
}, [storeApi])
|
||||
|
||||
useClickAway(() => {
|
||||
handleClose()
|
||||
}, ref)
|
||||
|
||||
const targetNode = useMemo(() => {
|
||||
if (!contextMenu?.nodeId || !treeData?.children)
|
||||
return null
|
||||
return findNodeById(treeData.children, contextMenu.nodeId)
|
||||
}, [contextMenu?.nodeId, treeData?.children])
|
||||
|
||||
const isFolder = targetNode?.node_type === 'folder'
|
||||
|
||||
if (!contextMenu)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[100]"
|
||||
style={{
|
||||
top: contextMenu.top,
|
||||
left: contextMenu.left,
|
||||
}}
|
||||
>
|
||||
{isFolder
|
||||
? (
|
||||
<FolderNodeMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FileNodeMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TreeContextMenu)
|
||||
@ -13,14 +13,17 @@ import {
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import FileOperationsMenu from './file-operations-menu'
|
||||
import FileNodeMenu from './file-node-menu'
|
||||
import FolderNodeMenu from './folder-node-menu'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getFileIconType } from './utils/file-utils'
|
||||
|
||||
const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
|
||||
const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const isSelected = node.isSelected
|
||||
const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id))
|
||||
const contextMenuNodeId = useSkillEditorStore(s => s.contextMenu?.nodeId)
|
||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
@ -44,9 +47,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
}
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@ -55,7 +55,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
left: e.clientX,
|
||||
nodeId: node.data.id,
|
||||
})
|
||||
}, [isFolder, node.data.id, storeApi])
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -70,6 +70,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
'group flex h-6 cursor-pointer items-center gap-2 rounded-md px-2',
|
||||
'hover:bg-state-base-hover',
|
||||
isSelected && 'bg-state-base-active',
|
||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@ -109,39 +110,47 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
{node.data.name}
|
||||
</span>
|
||||
|
||||
{isFolder && (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
open={showDropdown}
|
||||
onOpenChange={setShowDropdown}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMoreClick}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
'invisible group-hover:visible',
|
||||
showDropdown && 'visible',
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
open={showDropdown}
|
||||
onOpenChange={setShowDropdown}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMoreClick}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
'invisible group-hover:visible',
|
||||
showDropdown && 'visible',
|
||||
)}
|
||||
aria-label="File operations"
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
{isFolder
|
||||
? (
|
||||
<FolderNodeMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FileNodeMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)}
|
||||
aria-label="File operations"
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<FileOperationsMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileTreeNode)
|
||||
export default React.memo(TreeNode)
|
||||
Reference in New Issue
Block a user