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:
yyh
2026-01-15 17:24:58 +08:00
parent 195cd2c898
commit f58f36fc8f
7 changed files with 240 additions and 96 deletions

View 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)

View File

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

View File

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

View File

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

View 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)

View File

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