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

@@ -11,14 +11,6 @@ export const START_TAB_ID = '__start__' as const
// Drag type identifier for internal tree node dragging
export const INTERNAL_NODE_DRAG_TYPE = 'application/x-dify-tree-node'
// Context menu trigger types (describes WHERE user clicked)
export const CONTEXT_MENU_TYPE = {
BLANK: 'blank',
NODE: 'node',
} as const
export type ContextMenuType = (typeof CONTEXT_MENU_TYPE)[keyof typeof CONTEXT_MENU_TYPE]
// Node menu types (determines which menu options to show)
export const NODE_MENU_TYPE = {
ROOT: 'root',

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

View File

@@ -6,11 +6,9 @@ import { useTreeNodeHandlers } from './use-tree-node-handlers'
const {
mockClearArtifactSelection,
mockOpenTab,
mockSetContextMenu,
} = vi.hoisted(() => ({
mockClearArtifactSelection: vi.fn(),
mockOpenTab: vi.fn(),
mockSetContextMenu: vi.fn(),
}))
vi.mock('es-toolkit/function', () => ({
@@ -22,7 +20,6 @@ vi.mock('@/app/components/workflow/store', () => ({
getState: () => ({
clearArtifactSelection: mockClearArtifactSelection,
openTab: mockOpenTab,
setContextMenu: mockSetContextMenu,
}),
}),
}))
@@ -54,8 +51,6 @@ const createMouseEvent = (params: {
shiftKey?: boolean
ctrlKey?: boolean
metaKey?: boolean
clientX?: number
clientY?: number
} = {}) => {
return {
stopPropagation: vi.fn(),
@@ -63,8 +58,6 @@ const createMouseEvent = (params: {
shiftKey: params.shiftKey ?? false,
ctrlKey: params.ctrlKey ?? false,
metaKey: params.metaKey ?? false,
clientX: params.clientX ?? 0,
clientY: params.clientY ?? 0,
} as unknown as React.MouseEvent
}
@@ -183,29 +176,8 @@ describe('useTreeNodeHandlers', () => {
})
})
// Scenario: context menu and keyboard handlers update menu state and open/toggle actions.
describe('context menu and keyboard', () => {
it('should select node and set context menu payload on right click', () => {
const node = createNode({ id: 'folder-1', nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent({ clientX: 120, clientY: 45 })
act(() => {
result.current.handleContextMenu(event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(node.select).toHaveBeenCalledTimes(1)
expect(mockSetContextMenu).toHaveBeenCalledWith({
top: 45,
left: 120,
type: 'node',
nodeId: 'folder-1',
isFolder: true,
})
})
// Scenario: keyboard handlers should toggle folders and open files without relying on overlay state.
describe('keyboard', () => {
it('should toggle folder on Enter key', () => {
const node = createNode({ nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))

View File

@@ -15,7 +15,6 @@ type UseTreeNodeHandlersReturn = {
handleClick: (e: React.MouseEvent) => void
handleDoubleClick: (e: React.MouseEvent) => void
handleToggle: (e: React.MouseEvent) => void
handleContextMenu: (e: React.MouseEvent) => void
handleKeyDown: (e: React.KeyboardEvent) => void
}
@@ -75,19 +74,6 @@ export function useTreeNodeHandlers({
throttledToggle()
}, [throttledToggle])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
node.select()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: 'node',
nodeId: node.data.id,
isFolder,
})
}, [isFolder, node, storeApi])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
@@ -105,7 +91,6 @@ export function useTreeNodeHandlers({
handleClick,
handleDoubleClick,
handleToggle,
handleContextMenu,
handleKeyDown,
}
}

View File

@@ -98,12 +98,14 @@ const SidebarSearchAdd = () => {
/>
<MenuItem
menuType="dropdown"
icon={FileAdd}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
disabled={isLoading}
/>
<MenuItem
menuType="dropdown"
icon={FolderAdd}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
@@ -113,12 +115,14 @@ const SidebarSearchAdd = () => {
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
menuType="dropdown"
icon={UploadCloud02}
label={t('skillSidebar.menu.uploadFile')}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
/>
<MenuItem
menuType="dropdown"
icon="i-ri-folder-upload-line"
label={t('skillSidebar.menu.uploadFolder')}
onClick={() => folderInputRef.current?.click()}
@@ -128,6 +132,7 @@ const SidebarSearchAdd = () => {
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
menuType="dropdown"
icon="i-ri-upload-line"
label={t('skillSidebar.menu.importSkills')}
onClick={() => setIsImportModalOpen(true)}

View File

@@ -1,6 +1,5 @@
import type { ContextMenuType, NodeMenuType } from '../constants'
import type { AppAssetTreeView } from '@/types/app-asset'
import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../constants'
import { ROOT_ID } from '../constants'
// Root utilities
@@ -12,24 +11,6 @@ export function toApiParentId(folderId: string | null | undefined): string | nul
return isRootId(folderId) ? null : folderId!
}
export function getNodeMenuType(
contextType: ContextMenuType,
isFolder?: boolean,
): NodeMenuType {
if (contextType === CONTEXT_MENU_TYPE.BLANK)
return NODE_MENU_TYPE.ROOT
return isFolder ? NODE_MENU_TYPE.FOLDER : NODE_MENU_TYPE.FILE
}
export function getMenuNodeId(
contextType: ContextMenuType,
nodeId?: string,
): string {
return contextType === CONTEXT_MENU_TYPE.BLANK
? ROOT_ID
: (nodeId ?? ROOT_ID)
}
// Tree utilities
export function buildNodeMap(nodes: AppAssetTreeView[]): Map<string, AppAssetTreeView> {

View File

@@ -1,17 +0,0 @@
import type { StateCreator } from 'zustand'
import type { FileOperationsMenuSliceShape, SkillEditorSliceShape } from './types'
export type { FileOperationsMenuSliceShape } from './types'
export const createFileOperationsMenuSlice: StateCreator<
SkillEditorSliceShape,
[],
[],
FileOperationsMenuSliceShape
> = set => ({
contextMenu: null,
setContextMenu: (contextMenu) => {
set({ contextMenu })
},
})

View File

@@ -4,7 +4,6 @@ import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
import { createArtifactSlice } from './artifact-slice'
import { createClipboardSlice } from './clipboard-slice'
import { createDirtySlice } from './dirty-slice'
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
import { createFileTreeSlice } from './file-tree-slice'
import { createMetadataSlice } from './metadata-slice'
import { createTabSlice } from './tab-slice'
@@ -13,7 +12,6 @@ import { createUploadSlice } from './upload-slice'
export type { ArtifactSliceShape } from './artifact-slice'
export type { ClipboardSliceShape } from './clipboard-slice'
export type { DirtySliceShape } from './dirty-slice'
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
export type { FileTreeSliceShape } from './file-tree-slice'
export type { MetadataSliceShape } from './metadata-slice'
export type { OpenTabOptions, TabSliceShape } from './tab-slice'
@@ -26,7 +24,6 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
...createClipboardSlice(...args),
...createDirtySlice(...args),
...createMetadataSlice(...args),
...createFileOperationsMenuSlice(...args),
...createUploadSlice(...args),
...createArtifactSlice(...args),
@@ -48,7 +45,6 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, unknown>>(),
dirtyMetadataIds: new Set<string>(),
contextMenu: null,
fileTreeSearchTerm: '',
uploadStatus: 'idle',
uploadProgress: { uploaded: 0, total: 0, failed: 0 },

View File

@@ -1,5 +1,3 @@
import type { ContextMenuType } from '@/app/components/workflow/skill/constants'
export type OpenTabOptions = {
pinned?: boolean
autoFocusEditor?: boolean
@@ -92,19 +90,6 @@ export type MetadataSliceShape = {
getFileMetadata: (fileId: string) => Record<string, unknown> | undefined
}
export type ContextMenuState = {
top: number
left: number
type: ContextMenuType
nodeId?: string
isFolder?: boolean
}
export type FileOperationsMenuSliceShape = {
contextMenu: ContextMenuState | null
setContextMenu: (menu: ContextMenuState | null) => void
}
export type UploadStatus = 'idle' | 'uploading' | 'success' | 'partial_error'
export type UploadProgress = {
@@ -133,7 +118,6 @@ export type SkillEditorSliceShape
& ClipboardSliceShape
& DirtySliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape
& UploadSliceShape
& ArtifactSliceShape
& {