mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
feat: multi select for file tree & clipboard support
This commit is contained in:
parent
331c65fd1d
commit
357489d444
@ -19,6 +19,7 @@ import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants'
|
|||||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||||
import { useRootFileDrop } from '../hooks/use-root-file-drop'
|
import { useRootFileDrop } from '../hooks/use-root-file-drop'
|
||||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||||
|
import { useSkillShortcuts } from '../hooks/use-skill-shortcuts'
|
||||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||||
import ArtifactsSection from './artifacts-section'
|
import ArtifactsSection from './artifacts-section'
|
||||||
import DragActionTooltip from './drag-action-tooltip'
|
import DragActionTooltip from './drag-action-tooltip'
|
||||||
@ -62,7 +63,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
|||||||
|
|
||||||
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
||||||
const activeTabId = useStore(s => s.activeTabId)
|
const activeTabId = useStore(s => s.activeTabId)
|
||||||
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
|
|
||||||
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
||||||
const searchTerm = useStore(s => s.fileTreeSearchTerm)
|
const searchTerm = useStore(s => s.fileTreeSearchTerm)
|
||||||
const storeApi = useWorkflowStore()
|
const storeApi = useWorkflowStore()
|
||||||
@ -123,18 +123,16 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
|||||||
}, [storeApi])
|
}, [storeApi])
|
||||||
|
|
||||||
const handleSelect = useCallback((nodes: NodeApi<TreeNodeData>[]) => {
|
const handleSelect = useCallback((nodes: NodeApi<TreeNodeData>[]) => {
|
||||||
const selectedId = nodes[0]?.id ?? null
|
storeApi.getState().setSelectedNodeIds(nodes.map(n => n.id))
|
||||||
storeApi.getState().setSelectedTreeNodeId(selectedId)
|
|
||||||
}, [storeApi])
|
}, [storeApi])
|
||||||
|
|
||||||
// Clicking blank area clears selection for root-level creation
|
|
||||||
const handleBlankAreaClick = useCallback(() => {
|
const handleBlankAreaClick = useCallback(() => {
|
||||||
storeApi.getState().setSelectedTreeNodeId(null)
|
storeApi.getState().clearSelection()
|
||||||
}, [storeApi])
|
}, [storeApi])
|
||||||
|
|
||||||
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
|
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
storeApi.getState().setSelectedTreeNodeId(null)
|
storeApi.getState().clearSelection()
|
||||||
storeApi.getState().setContextMenu({
|
storeApi.getState().setContextMenu({
|
||||||
top: e.clientY,
|
top: e.clientY,
|
||||||
left: e.clientX,
|
left: e.clientX,
|
||||||
@ -147,6 +145,8 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useSkillShortcuts({ treeRef })
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
|
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
|
||||||
@ -239,7 +239,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
|||||||
indent={20}
|
indent={20}
|
||||||
overscanCount={5}
|
overscanCount={5}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
selection={selectedTreeNodeId ?? undefined}
|
|
||||||
initialOpenState={initialOpensObject}
|
initialOpenState={initialOpensObject}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { VariantProps } from 'class-variance-authority'
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { cva } from 'class-variance-authority'
|
import { cva } from 'class-variance-authority'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
|
|
||||||
const menuItemVariants = cva(
|
const menuItemVariants = cva(
|
||||||
@ -52,11 +53,12 @@ const labelVariants = cva('system-sm-regular text-text-secondary', {
|
|||||||
export type MenuItemProps = {
|
export type MenuItemProps = {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
|
kbd?: string[]
|
||||||
onClick: React.MouseEventHandler<HTMLButtonElement>
|
onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
} & VariantProps<typeof menuItemVariants>
|
} & VariantProps<typeof menuItemVariants>
|
||||||
|
|
||||||
const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled, variant }) => {
|
const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, kbd, onClick, disabled, variant }) => {
|
||||||
const handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onClick(event)
|
onClick(event)
|
||||||
@ -70,7 +72,8 @@ const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled, var
|
|||||||
className={cn(menuItemVariants({ variant }))}
|
className={cn(menuItemVariants({ variant }))}
|
||||||
>
|
>
|
||||||
<Icon className={cn(iconVariants({ variant }))} aria-hidden="true" />
|
<Icon className={cn(iconVariants({ variant }))} aria-hidden="true" />
|
||||||
<span className={cn(labelVariants({ variant }))}>{label}</span>
|
<span className={cn(labelVariants({ variant }), 'flex-1 text-left')}>{label}</span>
|
||||||
|
{kbd && kbd.length > 0 && <ShortcutsName keys={kbd} textColor="secondary" />}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,22 @@ import type { NodeApi, TreeApi } from 'react-arborist'
|
|||||||
import type { NodeMenuType } from '../constants'
|
import type { NodeMenuType } from '../constants'
|
||||||
import type { TreeNodeData } from '../type'
|
import type { TreeNodeData } from '../type'
|
||||||
import {
|
import {
|
||||||
|
RiClipboardLine,
|
||||||
RiDeleteBinLine,
|
RiDeleteBinLine,
|
||||||
RiEdit2Line,
|
RiEdit2Line,
|
||||||
RiFileAddLine,
|
RiFileAddLine,
|
||||||
|
RiFileCopyLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
RiFolderUploadLine,
|
RiFolderUploadLine,
|
||||||
|
RiScissorsLine,
|
||||||
RiUploadLine,
|
RiUploadLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Confirm from '@/app/components/base/confirm'
|
import Confirm from '@/app/components/base/confirm'
|
||||||
import { Download02 } from '@/app/components/base/icons/src/vender/solid/general'
|
import { Download02 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||||
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
import { NODE_MENU_TYPE } from '../constants'
|
import { NODE_MENU_TYPE } from '../constants'
|
||||||
import { useFileOperations } from '../hooks/use-file-operations'
|
import { useFileOperations } from '../hooks/use-file-operations'
|
||||||
@ -44,6 +49,9 @@ const NodeMenu: FC<NodeMenuProps> = ({
|
|||||||
node,
|
node,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('workflow')
|
const { t } = useTranslation('workflow')
|
||||||
|
const storeApi = useWorkflowStore()
|
||||||
|
const selectedNodeIds = useStore(s => s.selectedNodeIds)
|
||||||
|
const hasClipboard = useStore(s => s.hasClipboard())
|
||||||
const isRoot = type === NODE_MENU_TYPE.ROOT
|
const isRoot = type === NODE_MENU_TYPE.ROOT
|
||||||
const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
|
const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
|
||||||
|
|
||||||
@ -64,6 +72,29 @@ const NodeMenu: FC<NodeMenuProps> = ({
|
|||||||
handleDeleteCancel,
|
handleDeleteCancel,
|
||||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||||
|
|
||||||
|
const currentNodeId = node?.data.id ?? nodeId
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : [])
|
||||||
|
if (ids.length > 0) {
|
||||||
|
storeApi.getState().copyNodes(ids)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [currentNodeId, onClose, selectedNodeIds, storeApi])
|
||||||
|
|
||||||
|
const handleCut = useCallback(() => {
|
||||||
|
const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : [])
|
||||||
|
if (ids.length > 0) {
|
||||||
|
storeApi.getState().cutNodes(ids)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [currentNodeId, onClose, selectedNodeIds, storeApi])
|
||||||
|
|
||||||
|
const handlePaste = useCallback(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('skill:paste'))
|
||||||
|
onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
const showRenameDelete = isFolder ? !isRoot : true
|
const showRenameDelete = isFolder ? !isRoot : true
|
||||||
const deleteConfirmTitle = isFolder
|
const deleteConfirmTitle = isFolder
|
||||||
? t('skillSidebar.menu.deleteConfirmTitle')
|
? t('skillSidebar.menu.deleteConfirmTitle')
|
||||||
@ -140,8 +171,38 @@ const NodeMenu: FC<NodeMenuProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isRoot && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
icon={RiFileCopyLine}
|
||||||
|
label={t('skillSidebar.menu.copy')}
|
||||||
|
kbd={['ctrl', 'c']}
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={RiScissorsLine}
|
||||||
|
label={t('skillSidebar.menu.cut')}
|
||||||
|
kbd={['ctrl', 'x']}
|
||||||
|
onClick={handleCut}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFolder && (
|
||||||
|
<MenuItem
|
||||||
|
icon={RiClipboardLine}
|
||||||
|
label={t('skillSidebar.menu.paste')}
|
||||||
|
kbd={['ctrl', 'v']}
|
||||||
|
onClick={handlePaste}
|
||||||
|
disabled={isLoading || !hasClipboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showRenameDelete && (
|
{showRenameDelete && (
|
||||||
<>
|
<>
|
||||||
|
<div className="my-1 h-px bg-divider-subtle" />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={RiEdit2Line}
|
icon={RiEdit2Line}
|
||||||
label={t('skillSidebar.menu.rename')}
|
label={t('skillSidebar.menu.rename')}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
|||||||
const isFolder = node.data.node_type === 'folder'
|
const isFolder = node.data.node_type === 'folder'
|
||||||
const isSelected = node.isSelected
|
const isSelected = node.isSelected
|
||||||
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
|
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 contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
||||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||||
|
|
||||||
@ -59,10 +60,9 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
|||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||||
isSelected && 'bg-state-base-active',
|
isSelected && 'bg-state-base-active',
|
||||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||||
// Drag over highlight for folders
|
|
||||||
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
||||||
// Blink animation when about to auto-expand (VSCode-style)
|
|
||||||
isBlinking && 'animate-drag-blink',
|
isBlinking && 'animate-drag-blink',
|
||||||
|
isCut && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { RefObject } from 'react'
|
||||||
|
import type { TreeApi } from 'react-arborist'
|
||||||
|
import type { TreeNodeData } from '../type'
|
||||||
|
import { useKeyPress } from 'ahooks'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||||
|
import {
|
||||||
|
getKeyboardKeyCodeBySystem,
|
||||||
|
isEventTargetInputArea,
|
||||||
|
} from '@/app/components/workflow/utils/common'
|
||||||
|
|
||||||
|
type UseSkillShortcutsOptions = {
|
||||||
|
treeRef: RefObject<TreeApi<TreeNodeData> | null>
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillShortcuts({
|
||||||
|
treeRef,
|
||||||
|
enabled = true,
|
||||||
|
}: UseSkillShortcutsOptions): void {
|
||||||
|
const storeApi = useWorkflowStore()
|
||||||
|
const enabledRef = useRef(enabled)
|
||||||
|
useEffect(() => { enabledRef.current = enabled }, [enabled])
|
||||||
|
|
||||||
|
const shouldHandle = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (!enabledRef.current)
|
||||||
|
return false
|
||||||
|
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getSelectedNodeIds = useCallback(() => {
|
||||||
|
const tree = treeRef.current
|
||||||
|
if (!tree)
|
||||||
|
return []
|
||||||
|
return tree.selectedNodes.map(n => n.id)
|
||||||
|
}, [treeRef])
|
||||||
|
|
||||||
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||||
|
if (shouldHandle(e)) {
|
||||||
|
const nodeIds = getSelectedNodeIds()
|
||||||
|
if (nodeIds.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
storeApi.getState().copyNodes(nodeIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { exactMatch: true, useCapture: true })
|
||||||
|
|
||||||
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.x`, (e) => {
|
||||||
|
if (shouldHandle(e)) {
|
||||||
|
const nodeIds = getSelectedNodeIds()
|
||||||
|
if (nodeIds.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
storeApi.getState().cutNodes(nodeIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { exactMatch: true, useCapture: true })
|
||||||
|
|
||||||
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
|
||||||
|
if (shouldHandle(e) && storeApi.getState().hasClipboard()) {
|
||||||
|
e.preventDefault()
|
||||||
|
window.dispatchEvent(new CustomEvent('skill:paste'))
|
||||||
|
}
|
||||||
|
}, { exactMatch: true, useCapture: true })
|
||||||
|
}
|
||||||
@ -27,8 +27,6 @@ export function useSyncTreeWithActiveTab({
|
|||||||
if (!activeTabId)
|
if (!activeTabId)
|
||||||
return
|
return
|
||||||
|
|
||||||
storeApi.getState().setSelectedTreeNodeId(activeTabId)
|
|
||||||
|
|
||||||
const tree = treeRef.current
|
const tree = treeRef.current
|
||||||
if (!tree)
|
if (!tree)
|
||||||
return
|
return
|
||||||
@ -38,7 +36,6 @@ export function useSyncTreeWithActiveTab({
|
|||||||
if (!node)
|
if (!node)
|
||||||
return
|
return
|
||||||
|
|
||||||
// Traverse parent chain to collect ancestor folder IDs
|
|
||||||
const ancestors: string[] = []
|
const ancestors: string[] = []
|
||||||
let current = node.parent
|
let current = node.parent
|
||||||
while (current && !current.isRoot) {
|
while (current && !current.isRoot) {
|
||||||
@ -50,6 +47,7 @@ export function useSyncTreeWithActiveTab({
|
|||||||
storeApi.getState().revealFile(ancestors)
|
storeApi.getState().revealFile(ancestors)
|
||||||
|
|
||||||
tree.openParents(node)
|
tree.openParents(node)
|
||||||
|
tree.select(activeTabId)
|
||||||
tree.scrollTo(activeTabId)
|
tree.scrollTo(activeTabId)
|
||||||
})
|
})
|
||||||
}, [activeTabId, treeRef, storeApi])
|
}, [activeTabId, treeRef, storeApi])
|
||||||
|
|||||||
@ -19,10 +19,6 @@ type UseTreeNodeHandlersReturn = {
|
|||||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that encapsulates all tree node interaction handlers.
|
|
||||||
* Handles click, double-click, toggle, context menu, and keyboard events.
|
|
||||||
*/
|
|
||||||
export function useTreeNodeHandlers({
|
export function useTreeNodeHandlers({
|
||||||
node,
|
node,
|
||||||
}: UseTreeNodeHandlersOptions): UseTreeNodeHandlersReturn {
|
}: UseTreeNodeHandlersOptions): UseTreeNodeHandlersReturn {
|
||||||
@ -49,10 +45,16 @@ export function useTreeNodeHandlers({
|
|||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
node.select() // This triggers Tree's onSelect → setSelectedTreeNodeId
|
if (e.shiftKey)
|
||||||
|
node.selectContiguous()
|
||||||
|
else if (e.metaKey || e.ctrlKey)
|
||||||
|
node.selectMulti()
|
||||||
|
else
|
||||||
|
node.select()
|
||||||
|
|
||||||
if (isFolder)
|
if (isFolder)
|
||||||
throttledToggle()
|
throttledToggle()
|
||||||
else
|
else if (!e.metaKey && !e.ctrlKey && !e.shiftKey)
|
||||||
handleFileClick()
|
handleFileClick()
|
||||||
}, [handleFileClick, isFolder, node, throttledToggle])
|
}, [handleFileClick, isFolder, node, throttledToggle])
|
||||||
|
|
||||||
@ -72,9 +74,7 @@ export function useTreeNodeHandlers({
|
|||||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
node.select()
|
||||||
// Select the node for highlight + creation target
|
|
||||||
storeApi.getState().setSelectedTreeNodeId(node.data.id)
|
|
||||||
storeApi.getState().setContextMenu({
|
storeApi.getState().setContextMenu({
|
||||||
top: e.clientY,
|
top: e.clientY,
|
||||||
left: e.clientX,
|
left: e.clientX,
|
||||||
@ -82,7 +82,7 @@ export function useTreeNodeHandlers({
|
|||||||
nodeId: node.data.id,
|
nodeId: node.data.id,
|
||||||
isFolder,
|
isFolder,
|
||||||
})
|
})
|
||||||
}, [isFolder, node.data.id, storeApi])
|
}, [isFolder, node, storeApi])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { StateCreator } from 'zustand'
|
||||||
|
import type { ClipboardSliceShape, SkillEditorSliceShape } from './types'
|
||||||
|
|
||||||
|
export type { ClipboardSliceShape } from './types'
|
||||||
|
|
||||||
|
export const createClipboardSlice: StateCreator<
|
||||||
|
SkillEditorSliceShape,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ClipboardSliceShape
|
||||||
|
> = (set, get) => ({
|
||||||
|
clipboard: null,
|
||||||
|
|
||||||
|
copyNodes: (nodeIds) => {
|
||||||
|
if (nodeIds.length === 0)
|
||||||
|
return
|
||||||
|
set({ clipboard: { operation: 'copy', nodeIds: new Set(nodeIds) } })
|
||||||
|
},
|
||||||
|
|
||||||
|
cutNodes: (nodeIds) => {
|
||||||
|
if (nodeIds.length === 0)
|
||||||
|
return
|
||||||
|
set({ clipboard: { operation: 'cut', nodeIds: new Set(nodeIds) } })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearClipboard: () => {
|
||||||
|
set({ clipboard: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
isCutNode: (nodeId) => {
|
||||||
|
const { clipboard } = get()
|
||||||
|
return clipboard?.operation === 'cut' && clipboard.nodeIds.has(nodeId)
|
||||||
|
},
|
||||||
|
|
||||||
|
hasClipboard: () => {
|
||||||
|
return get().clipboard !== null
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -17,6 +17,7 @@ export const createFileTreeSlice: StateCreator<
|
|||||||
> = (set, get) => ({
|
> = (set, get) => ({
|
||||||
expandedFolderIds: new Set<string>(),
|
expandedFolderIds: new Set<string>(),
|
||||||
selectedTreeNodeId: null,
|
selectedTreeNodeId: null,
|
||||||
|
selectedNodeIds: new Set<string>(),
|
||||||
pendingCreateNode: null,
|
pendingCreateNode: null,
|
||||||
|
|
||||||
setExpandedFolderIds: (ids: Set<string>) => {
|
setExpandedFolderIds: (ids: Set<string>) => {
|
||||||
@ -61,6 +62,21 @@ export const createFileTreeSlice: StateCreator<
|
|||||||
set({ selectedTreeNodeId: nodeId })
|
set({ selectedTreeNodeId: nodeId })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSelectedNodeIds: (nodeIds) => {
|
||||||
|
const lastId = nodeIds.length > 0 ? nodeIds[nodeIds.length - 1] : null
|
||||||
|
set({
|
||||||
|
selectedNodeIds: new Set(nodeIds),
|
||||||
|
selectedTreeNodeId: lastId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
set({
|
||||||
|
selectedNodeIds: new Set<string>(),
|
||||||
|
selectedTreeNodeId: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
startCreateNode: (nodeType, parentId) => {
|
startCreateNode: (nodeType, parentId) => {
|
||||||
set({
|
set({
|
||||||
pendingCreateNode: {
|
pendingCreateNode: {
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import type { StateCreator } from 'zustand'
|
import type { StateCreator } from 'zustand'
|
||||||
import type { SkillEditorSliceShape } from './types'
|
import type { SkillEditorSliceShape } from './types'
|
||||||
|
import { createClipboardSlice } from './clipboard-slice'
|
||||||
import { createDirtySlice } from './dirty-slice'
|
import { createDirtySlice } from './dirty-slice'
|
||||||
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
|
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
|
||||||
import { createFileTreeSlice } from './file-tree-slice'
|
import { createFileTreeSlice } from './file-tree-slice'
|
||||||
import { createMetadataSlice } from './metadata-slice'
|
import { createMetadataSlice } from './metadata-slice'
|
||||||
import { createTabSlice } from './tab-slice'
|
import { createTabSlice } from './tab-slice'
|
||||||
|
|
||||||
|
export type { ClipboardSliceShape } from './clipboard-slice'
|
||||||
export type { DirtySliceShape } from './dirty-slice'
|
export type { DirtySliceShape } from './dirty-slice'
|
||||||
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
|
export type { FileOperationsMenuSliceShape } from './file-operations-menu-slice'
|
||||||
export type { FileTreeSliceShape } from './file-tree-slice'
|
export type { FileTreeSliceShape } from './file-tree-slice'
|
||||||
@ -16,6 +18,7 @@ export type { SkillEditorSliceShape } from './types'
|
|||||||
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...args) => ({
|
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...args) => ({
|
||||||
...createTabSlice(...args),
|
...createTabSlice(...args),
|
||||||
...createFileTreeSlice(...args),
|
...createFileTreeSlice(...args),
|
||||||
|
...createClipboardSlice(...args),
|
||||||
...createDirtySlice(...args),
|
...createDirtySlice(...args),
|
||||||
...createMetadataSlice(...args),
|
...createMetadataSlice(...args),
|
||||||
...createFileOperationsMenuSlice(...args),
|
...createFileOperationsMenuSlice(...args),
|
||||||
@ -28,7 +31,9 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
|||||||
previewTabId: null,
|
previewTabId: null,
|
||||||
expandedFolderIds: new Set<string>(),
|
expandedFolderIds: new Set<string>(),
|
||||||
selectedTreeNodeId: null,
|
selectedTreeNodeId: null,
|
||||||
|
selectedNodeIds: new Set<string>(),
|
||||||
pendingCreateNode: null,
|
pendingCreateNode: null,
|
||||||
|
clipboard: null,
|
||||||
dirtyContents: new Map<string, string>(),
|
dirtyContents: new Map<string, string>(),
|
||||||
fileMetadata: new Map<string, Record<string, unknown>>(),
|
fileMetadata: new Map<string, Record<string, unknown>>(),
|
||||||
dirtyMetadataIds: new Set<string>(),
|
dirtyMetadataIds: new Set<string>(),
|
||||||
|
|||||||
@ -32,6 +32,9 @@ export type FileTreeSliceShape = {
|
|||||||
getOpensObject: () => OpensObject
|
getOpensObject: () => OpensObject
|
||||||
selectedTreeNodeId: string | null
|
selectedTreeNodeId: string | null
|
||||||
setSelectedTreeNodeId: (nodeId: string | null) => void
|
setSelectedTreeNodeId: (nodeId: string | null) => void
|
||||||
|
selectedNodeIds: Set<string>
|
||||||
|
setSelectedNodeIds: (nodeIds: string[]) => void
|
||||||
|
clearSelection: () => void
|
||||||
pendingCreateNode: PendingCreateNode | null
|
pendingCreateNode: PendingCreateNode | null
|
||||||
startCreateNode: (nodeType: PendingCreateNode['nodeType'], parentId: PendingCreateNode['parentId']) => void
|
startCreateNode: (nodeType: PendingCreateNode['nodeType'], parentId: PendingCreateNode['parentId']) => void
|
||||||
clearCreateNode: () => void
|
clearCreateNode: () => void
|
||||||
@ -41,6 +44,22 @@ export type FileTreeSliceShape = {
|
|||||||
setFileTreeSearchTerm: (term: string) => void
|
setFileTreeSearchTerm: (term: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClipboardOperation = 'copy' | 'cut'
|
||||||
|
|
||||||
|
export type ClipboardItem = {
|
||||||
|
operation: ClipboardOperation
|
||||||
|
nodeIds: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClipboardSliceShape = {
|
||||||
|
clipboard: ClipboardItem | null
|
||||||
|
copyNodes: (nodeIds: string[]) => void
|
||||||
|
cutNodes: (nodeIds: string[]) => void
|
||||||
|
clearClipboard: () => void
|
||||||
|
isCutNode: (nodeId: string) => boolean
|
||||||
|
hasClipboard: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type DirtySliceShape = {
|
export type DirtySliceShape = {
|
||||||
dirtyContents: Map<string, string>
|
dirtyContents: Map<string, string>
|
||||||
setDraftContent: (fileId: string, content: string) => void
|
setDraftContent: (fileId: string, content: string) => void
|
||||||
@ -76,6 +95,7 @@ export type FileOperationsMenuSliceShape = {
|
|||||||
export type SkillEditorSliceShape
|
export type SkillEditorSliceShape
|
||||||
= TabSliceShape
|
= TabSliceShape
|
||||||
& FileTreeSliceShape
|
& FileTreeSliceShape
|
||||||
|
& ClipboardSliceShape
|
||||||
& DirtySliceShape
|
& DirtySliceShape
|
||||||
& MetadataSliceShape
|
& MetadataSliceShape
|
||||||
& FileOperationsMenuSliceShape
|
& FileOperationsMenuSliceShape
|
||||||
|
|||||||
@ -1023,7 +1023,9 @@
|
|||||||
"skillSidebar.folderName": "Folder name",
|
"skillSidebar.folderName": "Folder name",
|
||||||
"skillSidebar.folderNamePlaceholder": "Folder name",
|
"skillSidebar.folderNamePlaceholder": "Folder name",
|
||||||
"skillSidebar.loadError": "Failed to load files",
|
"skillSidebar.loadError": "Failed to load files",
|
||||||
|
"skillSidebar.menu.copy": "Copy",
|
||||||
"skillSidebar.menu.createError": "Failed to create item",
|
"skillSidebar.menu.createError": "Failed to create item",
|
||||||
|
"skillSidebar.menu.cut": "Cut",
|
||||||
"skillSidebar.menu.delete": "Delete",
|
"skillSidebar.menu.delete": "Delete",
|
||||||
"skillSidebar.menu.deleteConfirmContent": "This will permanently delete the folder and all its contents. Any open files from this folder will be closed.",
|
"skillSidebar.menu.deleteConfirmContent": "This will permanently delete the folder and all its contents. Any open files from this folder will be closed.",
|
||||||
"skillSidebar.menu.deleteConfirmTitle": "Delete folder?",
|
"skillSidebar.menu.deleteConfirmTitle": "Delete folder?",
|
||||||
@ -1045,6 +1047,7 @@
|
|||||||
"skillSidebar.menu.newFilePrompt": "Enter file name (with extension, e.g., script.py):",
|
"skillSidebar.menu.newFilePrompt": "Enter file name (with extension, e.g., script.py):",
|
||||||
"skillSidebar.menu.newFolder": "New Folder",
|
"skillSidebar.menu.newFolder": "New Folder",
|
||||||
"skillSidebar.menu.newFolderPrompt": "Enter folder name:",
|
"skillSidebar.menu.newFolderPrompt": "Enter folder name:",
|
||||||
|
"skillSidebar.menu.paste": "Paste",
|
||||||
"skillSidebar.menu.rename": "Rename",
|
"skillSidebar.menu.rename": "Rename",
|
||||||
"skillSidebar.menu.renameError": "Failed to rename",
|
"skillSidebar.menu.renameError": "Failed to rename",
|
||||||
"skillSidebar.menu.renamed": "Renamed successfully",
|
"skillSidebar.menu.renamed": "Renamed successfully",
|
||||||
|
|||||||
@ -1015,7 +1015,9 @@
|
|||||||
"skillSidebar.folderName": "文件夹名称",
|
"skillSidebar.folderName": "文件夹名称",
|
||||||
"skillSidebar.folderNamePlaceholder": "文件夹名称",
|
"skillSidebar.folderNamePlaceholder": "文件夹名称",
|
||||||
"skillSidebar.loadError": "加载文件失败",
|
"skillSidebar.loadError": "加载文件失败",
|
||||||
|
"skillSidebar.menu.copy": "复制",
|
||||||
"skillSidebar.menu.createError": "创建失败",
|
"skillSidebar.menu.createError": "创建失败",
|
||||||
|
"skillSidebar.menu.cut": "剪切",
|
||||||
"skillSidebar.menu.delete": "删除",
|
"skillSidebar.menu.delete": "删除",
|
||||||
"skillSidebar.menu.deleteConfirmContent": "这将永久删除该文件夹及其所有内容。该文件夹中已打开的文件标签将被关闭。",
|
"skillSidebar.menu.deleteConfirmContent": "这将永久删除该文件夹及其所有内容。该文件夹中已打开的文件标签将被关闭。",
|
||||||
"skillSidebar.menu.deleteConfirmTitle": "删除文件夹?",
|
"skillSidebar.menu.deleteConfirmTitle": "删除文件夹?",
|
||||||
@ -1036,6 +1038,7 @@
|
|||||||
"skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):",
|
"skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):",
|
||||||
"skillSidebar.menu.newFolder": "新建文件夹",
|
"skillSidebar.menu.newFolder": "新建文件夹",
|
||||||
"skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:",
|
"skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:",
|
||||||
|
"skillSidebar.menu.paste": "粘贴",
|
||||||
"skillSidebar.menu.rename": "重命名",
|
"skillSidebar.menu.rename": "重命名",
|
||||||
"skillSidebar.menu.renameError": "重命名失败",
|
"skillSidebar.menu.renameError": "重命名失败",
|
||||||
"skillSidebar.menu.renamed": "重命名成功",
|
"skillSidebar.menu.renamed": "重命名成功",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user