refactor(skill): migrate file tree menus to base ui overlays

This commit is contained in:
yyh
2026-03-24 19:57:29 +08:00
parent 29469a8600
commit 70767f756c
18 changed files with 307 additions and 491 deletions

View File

@ -1,7 +1,7 @@
import type { ReactNode, Ref } from 'react'
import type { HTMLAttributes, ReactNode, Ref } from 'react'
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../../constants'
import { ROOT_ID } from '../../constants'
import FileTree from './file-tree'
type MockWorkflowState = {
@ -17,7 +17,6 @@ type MockWorkflowActions = {
openTab: (id: string, options: { pinned: boolean }) => void
setSelectedNodeIds: (ids: string[]) => void
clearSelection: () => void
setContextMenu: (menu: { top: number, left: number, type: string } | null) => void
setDragInsertTarget: (target: { parentId: string | null, index: number } | null) => void
setFileTreeSearchTerm: (term: string) => void
}
@ -142,7 +141,6 @@ const mocks = vi.hoisted(() => ({
openTab: vi.fn(),
setSelectedNodeIds: vi.fn(),
clearSelection: vi.fn(),
setContextMenu: vi.fn(),
setDragInsertTarget: vi.fn(),
setFileTreeSearchTerm: vi.fn(),
} as MockWorkflowActions,
@ -168,14 +166,14 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('react-arborist', async () => {
const React = await vi.importActual<typeof import('react')>('react')
type MockTreeComponentProps = {
children?: ReactNode
ref?: Ref<unknown>
} & Record<string, unknown>
const Tree = React.forwardRef((props: MockTreeComponentProps, ref: Ref<unknown>) => {
mocks.treeProps = props as unknown as CapturedTreeProps
const Tree = (props: MockTreeComponentProps) => {
const { ref, ...rest } = props
mocks.treeProps = rest as unknown as CapturedTreeProps
if (typeof ref === 'function')
ref(mocks.treeApi)
@ -183,7 +181,7 @@ vi.mock('react-arborist', async () => {
(ref as { current: unknown }).current = mocks.treeApi
return <div data-testid="arborist-tree" />
})
}
return { Tree }
})
@ -267,7 +265,27 @@ vi.mock('./upload-status-tooltip', () => ({
}))
vi.mock('./tree-context-menu', () => ({
default: () => <div data-testid="tree-context-menu" />,
default: ({
children,
treeRef,
onContextMenu,
...props
}: HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
treeRef?: { current: { deselectAll: () => void } | null }
}) => (
<div
data-testid="tree-context-menu"
onContextMenu={(event) => {
treeRef?.current?.deselectAll()
mocks.actions.clearSelection()
onContextMenu?.(event)
}}
{...props}
>
{children}
</div>
),
}))
function getCapturedTreeProps(): CapturedTreeProps {
@ -399,18 +417,13 @@ describe('FileTree', () => {
expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1)
})
it('should open blank context menu with pointer position on right click', () => {
it('should clear selection when blank area is right clicked', () => {
render(<FileTree />)
fireEvent.contextMenu(getTreeDropZone(), { clientX: 64, clientY: 128 })
expect(mocks.treeApi.deselectAll).toHaveBeenCalledTimes(1)
expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1)
expect(mocks.actions.setContextMenu).toHaveBeenCalledWith({
top: 128,
left: 64,
type: CONTEXT_MENU_TYPE.BLANK,
})
})
it('should forward root drag events to root file drop handlers', () => {

View File

@ -15,7 +15,7 @@ import SearchMenu from '@/app/components/base/icons/src/vender/knowledge/SearchM
import Loading from '@/app/components/base/loading'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../../constants'
import { ROOT_ID } from '../../constants'
import { useSkillAssetTreeData } from '../../hooks/file-tree/data/use-skill-asset-tree'
import { useSkillTreeCollaboration } from '../../hooks/file-tree/data/use-skill-tree-collaboration'
import { useRootFileDrop } from '../../hooks/file-tree/dnd/use-root-file-drop'
@ -181,17 +181,6 @@ const FileTree = ({ className }: FileTreeProps) => {
storeApi.getState().clearSelection()
}, [storeApi, treeRef])
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: CONTEXT_MENU_TYPE.BLANK,
})
}, [storeApi, treeRef])
// Node move API (for internal drag-drop)
const { executeMoveNode } = useNodeMove()
const { executeReorderNode } = useNodeReorder()
@ -387,14 +376,14 @@ const FileTree = ({ className }: FileTreeProps) => {
className,
)}
>
<div
ref={containerRef}
<TreeContextMenu
treeRef={treeRef}
triggerRef={containerRef}
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1',
isRootDropzone && 'relative rounded-lg bg-state-accent-hover after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:border-[1.5px] after:border-dashed after:border-state-accent-solid after:content-[""]',
)}
onClick={handleBlankAreaClick}
onContextMenu={handleBlankAreaContextMenu}
onDragEnter={handleRootDragEnter}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
@ -424,12 +413,11 @@ const FileTree = ({ className }: FileTreeProps) => {
>
{renderTreeNode}
</Tree>
</div>
</TreeContextMenu>
{dragOverFolderId
? <DragActionTooltip action={currentDragType ?? 'upload'} />
: <UploadStatusTooltip fallback={<DropTip />} />}
</div>
<TreeContextMenu treeRef={treeRef} />
</>
)
}

View File

@ -5,6 +5,7 @@ import MenuItem from './menu-item'
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const defaultProps: MenuItemProps = {
menuType: 'dropdown',
icon: MockIcon,
label: 'Rename',
onClick: vi.fn(),

View File

@ -3,7 +3,17 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import Tooltip from '@/app/components/base/tooltip-plus'
import {
ContextMenuItem,
} from '@/app/components/base/ui/context-menu'
import {
DropdownMenuItem,
} from '@/app/components/base/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { cn } from '@/utils/classnames'
@ -51,26 +61,33 @@ const labelVariants = cva('text-text-secondary system-sm-regular', {
})
export type MenuItemProps = {
menuType: 'dropdown' | 'context'
icon: React.ElementType | string
label: string
kbd?: readonly string[]
onClick: React.MouseEventHandler<HTMLButtonElement>
onClick: () => void
disabled?: boolean
tooltip?: string
} & VariantProps<typeof menuItemVariants>
const MenuItem = ({ icon: Icon, label, kbd, onClick, disabled, variant, tooltip }: MenuItemProps) => {
const handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onClick(event)
}, [onClick])
const MenuItem = ({
menuType,
icon: Icon,
label,
kbd,
onClick,
disabled,
variant,
tooltip,
}: MenuItemProps) => {
const ItemComponent = menuType === 'dropdown' ? DropdownMenuItem : ContextMenuItem
return (
<button
type="button"
onClick={handleClick}
<ItemComponent
onClick={onClick}
disabled={disabled}
className={cn(menuItemVariants({ variant }))}
destructive={variant === 'destructive'}
className={cn(menuItemVariants({ variant }), 'mx-0 h-auto w-full px-3 py-2')}
>
{typeof Icon === 'string'
? <span className={cn(Icon, iconVariants({ variant }))} aria-hidden="true" />
@ -78,22 +95,24 @@ const MenuItem = ({ icon: Icon, label, kbd, onClick, disabled, variant, tooltip
<span className={cn(labelVariants({ variant }), 'flex-1 text-left')}>{label}</span>
{kbd && kbd.length > 0 && <ShortcutsName keys={kbd} textColor="secondary" />}
{tooltip && (
<Tooltip
popupContent={tooltip}
position="right"
>
<span
className="flex shrink-0 items-center justify-center"
<Tooltip>
<TooltipTrigger
type="button"
aria-label={tooltip}
className="flex shrink-0 items-center justify-center rounded text-text-quaternary hover:text-text-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<span className="i-ri-question-line size-4 text-text-quaternary hover:text-text-tertiary" />
</span>
<span className="i-ri-question-line size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent placement="right">
{tooltip}
</TooltipContent>
</Tooltip>
)}
</button>
</ItemComponent>
)
}

View File

@ -29,6 +29,7 @@ type MockFileOperations = {
type RenderNodeMenuProps = {
type?: 'root' | 'folder' | 'file'
menuType?: 'dropdown' | 'context'
nodeId?: string
onClose?: () => void
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
@ -95,6 +96,7 @@ vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
const renderNodeMenu = ({
type = NODE_MENU_TYPE.FOLDER,
menuType = 'dropdown',
nodeId = 'node-1',
onClose = vi.fn(),
treeRef,
@ -103,6 +105,7 @@ const renderNodeMenu = ({
render(
<NodeMenu
type={type}
menuType={menuType}
nodeId={nodeId}
onClose={onClose}
treeRef={treeRef}

View File

@ -18,9 +18,14 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
ContextMenuSeparator,
} from '@/app/components/base/ui/context-menu'
import {
DropdownMenuSeparator,
} from '@/app/components/base/ui/dropdown-menu'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import dynamic from '@/next/dynamic'
import { cn } from '@/utils/classnames'
import { NODE_MENU_TYPE } from '../../constants'
import { useFileOperations } from '../../hooks/file-tree/operations/use-file-operations'
import MenuItem from './menu-item'
@ -29,28 +34,23 @@ const ImportSkillModal = dynamic(() => import('../../start-tab/import-skill-moda
ssr: false,
})
const MENU_CONTAINER_STYLES = [
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
] as const
const KBD_CUT = ['ctrl', 'x'] as const
const KBD_PASTE = ['ctrl', 'v'] as const
type NodeMenuProps = {
type: NodeMenuType
menuType: 'dropdown' | 'context'
nodeId?: string
onClose: () => void
className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
}
const NodeMenu = ({
type,
menuType,
nodeId,
onClose,
className,
treeRef,
node,
}: NodeMenuProps) => {
@ -101,9 +101,10 @@ const NodeMenu = ({
const deleteConfirmContent = isFolder
? t('skillSidebar.menu.deleteConfirmContent')
: t('skillSidebar.menu.fileDeleteConfirmContent')
const Separator = menuType === 'dropdown' ? DropdownMenuSeparator : ContextMenuSeparator
return (
<div className={cn(MENU_CONTAINER_STYLES, className)}>
<>
{isFolder && (
<>
<input
@ -125,27 +126,31 @@ const NodeMenu = ({
/>
<MenuItem
menuType={menuType}
icon={FileAdd}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
onClick={() => handleNewFile()}
disabled={isLoading}
/>
<MenuItem
menuType={menuType}
icon={FolderAdd}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
onClick={() => handleNewFolder()}
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
<Separator />
<MenuItem
menuType={menuType}
icon={UploadCloud02}
label={t('skillSidebar.menu.uploadFile')}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
/>
<MenuItem
menuType={menuType}
icon="i-ri-folder-upload-line"
label={t('skillSidebar.menu.uploadFolder')}
onClick={() => folderInputRef.current?.click()}
@ -154,8 +159,9 @@ const NodeMenu = ({
{isRoot && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<Separator />
<MenuItem
menuType={menuType}
icon="i-ri-upload-line"
label={t('skillSidebar.menu.importSkills')}
onClick={() => setIsImportModalOpen(true)}
@ -165,25 +171,27 @@ const NodeMenu = ({
</>
)}
{(showRenameDelete || hasClipboard) && <div className="my-1 h-px bg-divider-subtle" />}
{(showRenameDelete || hasClipboard) && <Separator />}
</>
)}
{!isFolder && (
<>
<MenuItem
menuType={menuType}
icon={Download02}
label={t('skillSidebar.menu.download')}
onClick={handleDownload}
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
<Separator />
</>
)}
{!isRoot && (
<>
<MenuItem
menuType={menuType}
icon="i-ri-scissors-line"
label={t('skillSidebar.menu.cut')}
kbd={KBD_CUT}
@ -195,6 +203,7 @@ const NodeMenu = ({
{isFolder && hasClipboard && (
<MenuItem
menuType={menuType}
icon="i-ri-clipboard-line"
label={t('skillSidebar.menu.paste')}
kbd={KBD_PASTE}
@ -205,17 +214,19 @@ const NodeMenu = ({
{showRenameDelete && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<Separator />
<MenuItem
menuType={menuType}
icon="i-ri-edit-2-line"
label={t('skillSidebar.menu.rename')}
onClick={handleRename}
onClick={() => handleRename()}
disabled={isLoading}
/>
<MenuItem
menuType={menuType}
icon="i-ri-delete-bin-line"
label={t('skillSidebar.menu.delete')}
onClick={handleDeleteClick}
onClick={() => handleDeleteClick()}
disabled={isLoading}
variant="destructive"
/>
@ -257,7 +268,7 @@ const NodeMenu = ({
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
/>
</div>
</>
)
}

View File

@ -1,174 +1,61 @@
import type { ReactNode } from 'react'
import type { ContextMenuState } from '@/app/components/workflow/store/workflow/skill-editor/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../../constants'
import { fireEvent, render, screen } from '@testing-library/react'
import { ROOT_ID } from '../../constants'
import TreeContextMenu from './tree-context-menu'
type MockWorkflowState = {
contextMenu: ContextMenuState | null
}
type FloatingOptions = {
open: boolean
onOpenChange: (open: boolean) => void
position: {
x: number
y: number
}
}
const mocks = vi.hoisted(() => ({
storeState: {
contextMenu: null,
} as MockWorkflowState,
setContextMenu: vi.fn(),
floatingOptions: null as FloatingOptions | null,
getFloatingProps: vi.fn(() => ({ 'data-floating-props': 'applied' })),
clearSelection: vi.fn(),
deselectAll: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
setContextMenu: mocks.setContextMenu,
clearSelection: mocks.clearSelection,
}),
}),
}))
vi.mock('@floating-ui/react', () => ({
FloatingPortal: ({ children }: { children: ReactNode }) => (
<div data-testid="floating-portal">{children}</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem-plus/use-context-menu-floating', () => ({
useContextMenuFloating: (options: FloatingOptions) => {
mocks.floatingOptions = options
return {
refs: {
setFloating: vi.fn(),
},
floatingStyles: {
left: `${options.position.x}px`,
top: `${options.position.y}px`,
},
getFloatingProps: mocks.getFloatingProps,
isPositioned: true,
}
},
}))
vi.mock('./node-menu', () => ({
default: ({
type,
nodeId,
onClose,
}: {
type: string
nodeId?: string
onClose: () => void
}) => (
<div data-testid="node-menu" data-type={type} data-node-id={nodeId ?? ''}>
<button type="button" onClick={onClose}>close</button>
</div>
default: ({ type, menuType, nodeId }: { type: string, menuType: string, nodeId?: string }) => (
<div
data-testid={`node-menu-${menuType}`}
data-type={type}
data-node-id={nodeId ?? ''}
/>
),
}))
const setContextMenuState = (contextMenu: ContextMenuState | null) => {
mocks.storeState.contextMenu = contextMenu
}
describe('TreeContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.floatingOptions = null
setContextMenuState(null)
})
// Rendering should depend on context-menu state in the workflow store.
describe('Rendering', () => {
it('should render nothing when context menu state is null', () => {
render(<TreeContextMenu treeRef={{ current: null }} />)
it('should render trigger children', () => {
render(
<TreeContextMenu treeRef={{ current: null }}>
<div>blank area</div>
</TreeContextMenu>,
)
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
expect(screen.queryByTestId('floating-portal')).not.toBeInTheDocument()
})
it('should render file menu with node id when node context is on a file', () => {
setContextMenuState({
top: 40,
left: 24,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
const menu = screen.getByTestId('node-menu')
expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.FILE)
expect(menu).toHaveAttribute('data-node-id', 'file-1')
expect(menu.parentElement).toHaveStyle({
left: '24px',
top: '40px',
visibility: 'visible',
})
expect(mocks.getFloatingProps).toHaveBeenCalledTimes(1)
expect(mocks.floatingOptions?.open).toBe(true)
expect(mocks.floatingOptions?.position).toEqual({ x: 24, y: 40 })
})
it('should render root menu with root id when context is blank area', () => {
setContextMenuState({
top: 100,
left: 80,
type: CONTEXT_MENU_TYPE.BLANK,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
const menu = screen.getByTestId('node-menu')
expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.ROOT)
expect(menu).toHaveAttribute('data-node-id', ROOT_ID)
expect(screen.getByText('blank area')).toBeInTheDocument()
})
})
// Close events from floating layer and menu should reset store context menu.
describe('Closing behavior', () => {
it('should clear context menu when floating layer requests close', () => {
setContextMenuState({
top: 12,
left: 16,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
describe('Interactions', () => {
it('should clear selection and open root menu when blank area is right clicked', () => {
render(
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>
<div>blank area</div>
</TreeContextMenu>,
)
render(<TreeContextMenu treeRef={{ current: null }} />)
fireEvent.contextMenu(screen.getByText('blank area'))
act(() => {
mocks.floatingOptions?.onOpenChange(false)
})
expect(mocks.setContextMenu).toHaveBeenCalledTimes(1)
expect(mocks.setContextMenu).toHaveBeenCalledWith(null)
})
it('should clear context menu when node menu closes', () => {
setContextMenuState({
top: 12,
left: 16,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
fireEvent.click(screen.getByRole('button', { name: 'close' }))
expect(mocks.setContextMenu).toHaveBeenCalledTimes(1)
expect(mocks.setContextMenu).toHaveBeenCalledWith(null)
expect(mocks.deselectAll).toHaveBeenCalledTimes(1)
expect(mocks.clearSelection).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'root')
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', ROOT_ID)
})
})
})

View File

@ -2,62 +2,63 @@
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import { FloatingPortal } from '@floating-ui/react'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useContextMenuFloating } from '@/app/components/base/portal-to-follow-elem-plus/use-context-menu-floating'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { getMenuNodeId, getNodeMenuType } from '../../utils/tree-utils'
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { NODE_MENU_TYPE, ROOT_ID } from '../../constants'
import NodeMenu from './node-menu'
type TreeContextMenuProps = {
type TreeContextMenuProps = Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuTrigger>,
'children' | 'onContextMenu'
> & {
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
triggerRef?: React.Ref<HTMLDivElement>
children: React.ReactNode
}
const TreeContextMenu = ({ treeRef }: TreeContextMenuProps) => {
const contextMenu = useStore(s => s.contextMenu)
const TreeContextMenu = ({
treeRef,
triggerRef,
children,
...props
}: TreeContextMenuProps) => {
const storeApi = useWorkflowStore()
const handleClose = useCallback(() => {
storeApi.getState().setContextMenu(null)
}, [storeApi])
const handleContextMenu = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement
if (target.closest('[role="treeitem"]'))
return
const position = useMemo(() => ({
x: contextMenu?.left ?? 0,
y: contextMenu?.top ?? 0,
}), [contextMenu?.left, contextMenu?.top])
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
}, [storeApi, treeRef])
const { refs, floatingStyles, getFloatingProps, isPositioned } = useContextMenuFloating({
open: !!contextMenu,
onOpenChange: (open) => {
if (!open)
handleClose()
},
position,
})
if (!contextMenu)
return null
const handleMenuClose = React.useCallback(() => {}, [])
return (
<FloatingPortal>
<div
ref={refs.setFloating}
className="z-[100]"
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
{...getFloatingProps()}
<ContextMenu>
<ContextMenuTrigger
ref={triggerRef}
onContextMenu={handleContextMenu}
{...props}
>
{children}
</ContextMenuTrigger>
<ContextMenuContent popupClassName="min-w-[180px]">
<NodeMenu
type={getNodeMenuType(contextMenu.type, contextMenu.isFolder)}
nodeId={getMenuNodeId(contextMenu.type, contextMenu.nodeId)}
onClose={handleClose}
menuType="context"
type={NODE_MENU_TYPE.ROOT}
nodeId={ROOT_ID}
onClose={handleMenuClose}
treeRef={treeRef}
/>
</div>
</FloatingPortal>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -5,9 +5,6 @@ import TreeNode from './tree-node'
type MockWorkflowSelectorState = {
dirtyContents: Set<string>
contextMenu: {
nodeId?: string
} | null
isCutNode: (nodeId: string) => boolean
}
@ -27,7 +24,6 @@ type NodeState = {
const workflowState = vi.hoisted(() => ({
dirtyContents: new Set<string>(),
cutNodeIds: new Set<string>(),
contextMenuNodeId: null as string | null,
dragOverFolderId: null as string | null,
}))
@ -40,7 +36,6 @@ const handlerMocks = vi.hoisted(() => ({
handleClick: vi.fn(),
handleDoubleClick: vi.fn(),
handleToggle: vi.fn(),
handleContextMenu: vi.fn(),
handleKeyDown: vi.fn(),
}))
@ -56,9 +51,6 @@ const dndMocks = vi.hoisted(() => ({
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({
dirtyContents: workflowState.dirtyContents,
contextMenu: workflowState.contextMenuNodeId
? { nodeId: workflowState.contextMenuNodeId }
: null,
isCutNode: (nodeId: string) => workflowState.cutNodeIds.has(nodeId),
}),
useWorkflowStore: () => ({
@ -80,7 +72,6 @@ vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
handleClick: handlerMocks.handleClick,
handleDoubleClick: handlerMocks.handleDoubleClick,
handleToggle: handlerMocks.handleToggle,
handleContextMenu: handlerMocks.handleContextMenu,
handleKeyDown: handlerMocks.handleKeyDown,
}),
}))
@ -99,8 +90,8 @@ vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
}))
vi.mock('./node-menu', () => ({
default: ({ type, onClose }: { type: string, onClose: () => void }) => (
<div data-testid="node-menu" data-type={type}>
default: ({ type, menuType, onClose }: { type: string, menuType: string, onClose: () => void }) => (
<div data-testid={`node-menu-${menuType}`} data-type={type}>
<button type="button" onClick={onClose}>close-menu</button>
</div>
),
@ -136,6 +127,7 @@ const createNode = (overrides: Partial<NodeState> = {}): NodeApi<TreeNodeData> =
willReceiveDrop: resolved.willReceiveDrop,
isEditing: resolved.isEditing,
level: resolved.level,
select: vi.fn(),
} as unknown as NodeApi<TreeNodeData>
}
@ -155,7 +147,6 @@ describe('TreeNode', () => {
workflowState.dirtyContents.clear()
workflowState.cutNodeIds.clear()
workflowState.contextMenuNodeId = null
workflowState.dragOverFolderId = null
dndMocks.isDragOver = false
@ -164,8 +155,7 @@ describe('TreeNode', () => {
// Core rendering should reflect selection, folder expansion, and store-driven visual states.
describe('Rendering', () => {
it('should render file node with context-menu highlight and action button label', () => {
workflowState.contextMenuNodeId = 'file-1'
it('should render file node with action button label', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
@ -173,7 +163,6 @@ describe('TreeNode', () => {
const treeItem = screen.getByRole('treeitem')
expect(treeItem).toHaveAttribute('aria-selected', 'false')
expect(treeItem).not.toHaveAttribute('aria-expanded')
expect(treeItem).toHaveClass('bg-state-base-hover')
expect(screen.getByText('readme.md')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i })).toBeInTheDocument()
})
@ -229,7 +218,7 @@ describe('TreeNode', () => {
expect(handlerMocks.handleDoubleClick).toHaveBeenCalled()
})
it('should call keyboard and context-menu handlers on tree item', () => {
it('should call keyboard handler and open context menu on tree item right click', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
@ -239,7 +228,7 @@ describe('TreeNode', () => {
fireEvent.contextMenu(treeItem)
expect(handlerMocks.handleKeyDown).toHaveBeenCalledTimes(1)
expect(handlerMocks.handleContextMenu).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'file')
})
it('should attach folder drag handlers only when node is a folder', () => {
@ -274,20 +263,16 @@ describe('TreeNode', () => {
expect(dndMocks.onDragLeave).not.toHaveBeenCalled()
})
it('should open and close dropdown menu when more actions button is toggled', () => {
it('should open dropdown menu when more actions button is clicked', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
expect(screen.queryByTestId('node-menu-dropdown')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i }))
expect(screen.getByTestId('node-menu')).toHaveAttribute('data-type', 'file')
fireEvent.click(screen.getByRole('button', { name: 'close-menu' }))
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
expect(screen.getByTestId('node-menu-dropdown')).toHaveAttribute('data-type', 'file')
})
})

View File

@ -3,13 +3,18 @@
import type { NodeRendererProps } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem-plus'
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { useFolderFileDrop } from '../../hooks/file-tree/dnd/use-folder-file-drop'
@ -29,49 +34,44 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
const isSelected = node.isSelected
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
const isCut = useStore(s => s.isCutNode(node.data.id))
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
const hasContextMenu = contextMenuNodeId === node.data.id
const storeApi = useWorkflowStore()
const [showDropdown, setShowDropdown] = useState(false)
// Sync react-arborist drag state to Zustand for DragActionTooltip
const prevIsDragging = useRef(node.isDragging)
const prevIsDraggingRef = useRef(node.isDragging)
useEffect(() => {
// When drag starts
if (node.isDragging && !prevIsDragging.current)
if (node.isDragging && !prevIsDraggingRef.current)
storeApi.getState().setCurrentDragType('move')
// When drag ends
if (!node.isDragging && prevIsDragging.current) {
if (!node.isDragging && prevIsDraggingRef.current) {
storeApi.getState().setCurrentDragType(null)
storeApi.getState().setDragOverFolderId(null)
}
prevIsDragging.current = node.isDragging
prevIsDraggingRef.current = node.isDragging
}, [node.isDragging, storeApi])
// Sync react-arborist willReceiveDrop to Zustand for DragActionTooltip
const prevWillReceiveDrop = useRef(node.willReceiveDrop)
const prevWillReceiveDropRef = useRef(node.willReceiveDrop)
useEffect(() => {
// When willReceiveDrop becomes true, set dragOverFolderId
if (isFolder && node.willReceiveDrop && !prevWillReceiveDrop.current)
if (isFolder && node.willReceiveDrop && !prevWillReceiveDropRef.current)
storeApi.getState().setDragOverFolderId(node.data.id)
// When willReceiveDrop becomes false, clear if this node was the target
if (isFolder && !node.willReceiveDrop && prevWillReceiveDrop.current) {
if (isFolder && !node.willReceiveDrop && prevWillReceiveDropRef.current) {
const currentDragOverId = storeApi.getState().dragOverFolderId
if (currentDragOverId === node.data.id)
storeApi.getState().setDragOverFolderId(null)
}
prevWillReceiveDrop.current = node.willReceiveDrop
prevWillReceiveDropRef.current = node.willReceiveDrop
}, [isFolder, node.willReceiveDrop, node.data.id, storeApi])
const {
handleClick,
handleDoubleClick,
handleToggle,
handleContextMenu,
handleKeyDown,
} = useTreeNodeHandlers({ node })
@ -83,105 +83,115 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
const handleMoreClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
setShowDropdown(prev => !prev)
}, [])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
node.select()
}, [node])
const handleMenuClose = useCallback(() => {}, [])
return (
<div
ref={dragHandle}
style={style}
role="treeitem"
tabIndex={0}
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
className={cn(
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
hasContextMenu && !isSelected && 'bg-state-base-hover',
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
isBlinking && 'animate-drag-blink',
(isCut || node.isDragging) && 'opacity-50',
)}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
{...(isFolder && {
onDragEnter: dragHandlers.onDragEnter,
onDragOver: dragHandlers.onDragOver,
onDrop: dragHandlers.onDrop,
onDragLeave: dragHandlers.onDragLeave,
})}
>
<TreeGuideLines level={node.level} />
{/* Main content area - isolated click/double-click handling */}
<div
className="flex min-w-0 flex-1 items-center gap-2"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
<ContextMenu>
<ContextMenuTrigger
ref={dragHandle}
style={style}
role="treeitem"
tabIndex={0}
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
className={cn(
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
'hover:bg-state-base-hover data-[popup-open]:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
isBlinking && 'animate-drag-blink',
(isCut || node.isDragging) && 'opacity-50',
)}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
{...(isFolder && {
onDragEnter: dragHandlers.onDragEnter,
onDragOver: dragHandlers.onDragOver,
onDrop: dragHandlers.onDrop,
onDragLeave: dragHandlers.onDragLeave,
})}
>
<div className="flex size-5 shrink-0 items-center justify-center">
<TreeNodeIcon
isFolder={isFolder}
isOpen={node.isOpen}
fileName={node.data.name}
extension={node.data.extension}
isDirty={isDirty}
onToggle={handleToggle}
/>
<TreeGuideLines level={node.level} />
<div
className="flex min-w-0 flex-1 items-center gap-2"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div className="flex size-5 shrink-0 items-center justify-center">
<TreeNodeIcon
isFolder={isFolder}
isOpen={node.isOpen}
fileName={node.data.name}
extension={node.data.extension}
isDirty={isDirty}
onToggle={handleToggle}
/>
</div>
{node.isEditing
? (
<TreeEditInput node={node} />
)
: (
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
>
{node.data.name}
</span>
)}
</div>
{node.isEditing
? (
<TreeEditInput node={node} />
)
: (
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
>
{node.data.name}
</span>
)}
</div>
{/* More button - separate from main content click handling */}
<PortalToFollowElem
placement="bottom-start"
offset={4}
open={showDropdown}
onOpenChange={setShowDropdown}
>
<PortalToFollowElemTrigger asChild>
<button
<DropdownMenu>
<DropdownMenuTrigger
type="button"
aria-label={t('skillSidebar.menu.moreActions')}
tabIndex={-1}
onClick={handleMoreClick}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded',
'hover:bg-state-base-hover-alt',
'invisible focus-visible:visible group-hover:visible data-[popup-open]:visible',
'hover:bg-state-base-hover-alt data-[popup-open]:bg-state-base-hover-alt',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
'invisible focus-visible:visible group-hover:visible',
showDropdown && 'visible',
)}
aria-label={t('skillSidebar.menu.moreActions')}
>
<span className="i-ri-more-fill size-4 text-text-tertiary" aria-hidden="true" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<NodeMenu
type={isFolder ? 'folder' : 'file'}
onClose={() => setShowDropdown(false)}
node={node}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="min-w-[180px]"
>
<NodeMenu
menuType="dropdown"
type={isFolder ? 'folder' : 'file'}
onClose={handleMenuClose}
node={node}
/>
</DropdownMenuContent>
</DropdownMenu>
</ContextMenuTrigger>
<ContextMenuContent popupClassName="min-w-[180px]">
<NodeMenu
menuType="context"
type={isFolder ? 'folder' : 'file'}
onClose={handleMenuClose}
node={node}
/>
</ContextMenuContent>
</ContextMenu>
)
}