Merge remote-tracking branch 'origin/feat/support-agent-sandbox' into feat/support-agent-sandbox

This commit is contained in:
zhsama
2026-01-20 16:37:04 +08:00
19 changed files with 436 additions and 53 deletions

View File

@ -549,7 +549,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
result=delta_text,
tool_elapsed_time=tool_elapsed_time,
)
self._task_state.answer += delta_text
case _:
pass
yield self._message_cycle_manager.message_to_stream_response(

View File

@ -3,7 +3,7 @@ import { cn } from '@/utils/classnames'
import { getKeyboardKeyNameBySystem } from './utils'
type ShortcutsNameProps = {
keys: string[]
keys: readonly string[]
className?: string
textColor?: 'default' | 'secondary'
}

View File

@ -17,8 +17,10 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants'
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
import { usePasteOperation } from '../hooks/use-paste-operation'
import { useRootFileDrop } from '../hooks/use-root-file-drop'
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 ArtifactsSection from './artifacts-section'
import DragActionTooltip from './drag-action-tooltip'
@ -62,7 +64,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
const expandedFolderIds = useStore(s => s.expandedFolderIds)
const activeTabId = useStore(s => s.activeTabId)
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
const dragOverFolderId = useStore(s => s.dragOverFolderId)
const searchTerm = useStore(s => s.fileTreeSearchTerm)
const storeApi = useWorkflowStore()
@ -123,30 +124,37 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
}, [storeApi])
const handleSelect = useCallback((nodes: NodeApi<TreeNodeData>[]) => {
const selectedId = nodes[0]?.id ?? null
storeApi.getState().setSelectedTreeNodeId(selectedId)
storeApi.getState().setSelectedNodeIds(nodes.map(n => n.id))
}, [storeApi])
// Clicking blank area clears selection for root-level creation
const handleBlankAreaClick = useCallback(() => {
storeApi.getState().setSelectedTreeNodeId(null)
}, [storeApi])
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
}, [storeApi, treeRef])
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
storeApi.getState().setSelectedTreeNodeId(null)
treeRef.current?.deselectAll()
storeApi.getState().clearSelection()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: CONTEXT_MENU_TYPE.BLANK,
})
}, [storeApi])
}, [storeApi, treeRef])
useSyncTreeWithActiveTab({
treeRef,
activeTabId,
})
useSkillShortcuts({ treeRef })
usePasteOperation({
treeRef,
treeData: treeData ?? undefined,
})
if (isLoading) {
return (
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
@ -208,6 +216,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
return (
<>
<div
data-skill-tree-container
className={cn(
'flex min-h-0 flex-1 flex-col',
isMutating && 'pointer-events-none opacity-50',
@ -239,7 +248,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
indent={20}
overscanCount={5}
openByDefault={false}
selection={selectedTreeNodeId ?? undefined}
initialOpenState={initialOpensObject}
onToggle={handleToggle}
onSelect={handleSelect}

View File

@ -4,6 +4,7 @@ import type { VariantProps } from 'class-variance-authority'
import type { FC } from 'react'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { cn } from '@/utils/classnames'
const menuItemVariants = cva(
@ -52,11 +53,12 @@ const labelVariants = cva('system-sm-regular text-text-secondary', {
export type MenuItemProps = {
icon: React.ElementType
label: string
kbd?: readonly string[]
onClick: React.MouseEventHandler<HTMLButtonElement>
disabled?: boolean
} & 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>) => {
event.stopPropagation()
onClick(event)
@ -70,7 +72,8 @@ const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled, var
className={cn(menuItemVariants({ variant }))}
>
<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>
)
}

View File

@ -5,17 +5,22 @@ import type { NodeApi, TreeApi } from 'react-arborist'
import type { NodeMenuType } from '../constants'
import type { TreeNodeData } from '../type'
import {
RiClipboardLine,
RiDeleteBinLine,
RiEdit2Line,
RiFileAddLine,
RiFileCopyLine,
RiFolderAddLine,
RiFolderUploadLine,
RiScissorsLine,
RiUploadLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
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 { NODE_MENU_TYPE } from '../constants'
import { useFileOperations } from '../hooks/use-file-operations'
@ -26,6 +31,10 @@ export const MENU_CONTAINER_STYLES = [
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
] as const
const KBD_COPY = ['ctrl', 'c'] as const
const KBD_CUT = ['ctrl', 'x'] as const
const KBD_PASTE = ['ctrl', 'v'] as const
type NodeMenuProps = {
type: NodeMenuType
nodeId?: string
@ -44,6 +53,9 @@ const NodeMenu: FC<NodeMenuProps> = ({
node,
}) => {
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 isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
@ -64,6 +76,29 @@ const NodeMenu: FC<NodeMenuProps> = ({
handleDeleteCancel,
} = 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 deleteConfirmTitle = isFolder
? t('skillSidebar.menu.deleteConfirmTitle')
@ -140,8 +175,38 @@ const NodeMenu: FC<NodeMenuProps> = ({
</>
)}
{!isRoot && (
<>
<MenuItem
icon={RiFileCopyLine}
label={t('skillSidebar.menu.copy')}
kbd={KBD_COPY}
onClick={handleCopy}
disabled={isLoading}
/>
<MenuItem
icon={RiScissorsLine}
label={t('skillSidebar.menu.cut')}
kbd={KBD_CUT}
onClick={handleCut}
disabled={isLoading}
/>
</>
)}
{isFolder && (
<MenuItem
icon={RiClipboardLine}
label={t('skillSidebar.menu.paste')}
kbd={KBD_PASTE}
onClick={handlePaste}
disabled={isLoading || !hasClipboard}
/>
)}
{showRenameDelete && (
<>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon={RiEdit2Line}
label={t('skillSidebar.menu.rename')}

View File

@ -1,6 +1,7 @@
'use client'
import * as React from 'react'
import { useMemo } from 'react'
type TreeGuideLinesProps = {
level: number
@ -16,20 +17,20 @@ const TreeGuideLines: React.FC<TreeGuideLinesProps> = ({
indentSize = INDENT_SIZE,
lineOffset = DEFAULT_LINE_OFFSET,
}) => {
if (level === 0)
return null
const guides = useMemo(() => {
if (level === 0)
return null
return (
<>
{Array.from({ length: level }).map((_, i) => (
<div
key={`guide-${i}`}
className="absolute bottom-0 top-0 border-l border-divider-subtle"
style={{ left: `${(i + 1) * indentSize - lineOffset}px` }}
/>
))}
</>
)
return Array.from({ length: level }, (_, i) => (
<div
key={`guide-${i}`}
className="absolute bottom-0 top-0 border-l border-divider-subtle"
style={{ left: `${(i + 1) * indentSize - lineOffset}px` }}
/>
))
}, [level, indentSize, lineOffset])
return guides
}
export default React.memo(TreeGuideLines)

View File

@ -25,6 +25,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
const isFolder = node.data.node_type === 'folder'
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
@ -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',
isSelected && 'bg-state-base-active',
hasContextMenu && !isSelected && 'bg-state-base-hover',
// Drag over highlight for folders
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',
isCut && 'opacity-50',
)}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}

View File

@ -50,14 +50,16 @@ export function useCreateOperations({
}
try {
for (const file of files) {
await createFile.mutateAsync({
appId,
name: file.name,
file,
parentId,
})
}
await Promise.all(
files.map(file =>
createFile.mutateAsync({
appId,
name: file.name,
file,
parentId,
}),
),
)
Toast.notify({
type: 'success',

View File

@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
type UseDelayedClickOptions = {
delay?: number
@ -18,6 +18,14 @@ export function useDelayedClick({
}: UseDelayedClickOptions) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// Cleanup timeout on unmount to prevent state updates on unmounted components
useEffect(() => {
return () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current)
}
}, [])
const handleClick = useCallback(() => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current)

View File

@ -0,0 +1,129 @@
'use client'
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import type { AppAssetTreeResponse } from '@/types/app-asset'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../utils/tree-utils'
type UsePasteOperationOptions = {
treeRef: RefObject<TreeApi<TreeNodeData> | null>
treeData?: AppAssetTreeResponse
enabled?: boolean
}
type UsePasteOperationReturn = {
isPasting: boolean
handlePaste: () => void
}
export function usePasteOperation({
treeRef,
treeData,
enabled = true,
}: UsePasteOperationOptions): UsePasteOperationReturn {
const { t } = useTranslation('workflow')
const storeApi = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const moveNode = useMoveAppAssetNode()
const isPastingRef = useRef(false)
const handlePaste = useCallback(async () => {
if (isPastingRef.current)
return
const clipboard = storeApi.getState().clipboard
if (!clipboard || clipboard.nodeIds.size === 0)
return
const { operation, nodeIds } = clipboard
const tree = treeRef.current
const treeChildren = treeData?.children ?? []
const selectedId = tree?.selectedNodes[0]?.id ?? storeApi.getState().selectedTreeNodeId
const targetFolderId = getTargetFolderIdFromSelection(selectedId, treeChildren)
const targetParentId = toApiParentId(targetFolderId)
if (operation === 'cut') {
const nodeIdsArray = [...nodeIds]
const isMovingToSelf = nodeIdsArray.some((nodeId) => {
const node = findNodeById(treeChildren, nodeId)
if (!node)
return false
if (node.node_type === 'folder' && nodeId === targetFolderId)
return true
return false
})
if (isMovingToSelf) {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.cannotMoveToSelf'),
})
return
}
isPastingRef.current = true
try {
await Promise.all(
nodeIdsArray.map(nodeId =>
moveNode.mutateAsync({
appId,
nodeId,
payload: { parent_id: targetParentId },
}),
),
)
storeApi.getState().clearClipboard()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
})
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.moveError'),
})
}
finally {
isPastingRef.current = false
}
}
else {
Toast.notify({
type: 'info',
message: t('skillSidebar.menu.copyNotSupported'),
})
}
}, [appId, moveNode, storeApi, t, treeData?.children, treeRef])
useEffect(() => {
if (!enabled)
return
const handlePasteEvent = () => {
handlePaste()
}
window.addEventListener('skill:paste', handlePasteEvent)
return () => {
window.removeEventListener('skill:paste', handlePasteEvent)
}
}, [enabled, handlePaste])
return {
isPasting: moveNode.isPending,
handlePaste,
}
}

View File

@ -0,0 +1,75 @@
'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
}
const TREE_CONTAINER_SELECTOR = '[data-skill-tree-container]'
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
if (isEventTargetInputArea(e.target as HTMLElement))
return false
const target = e.target as HTMLElement
const isInTreeContainer = target.closest(TREE_CONTAINER_SELECTOR) !== null
const hasSelection = (treeRef.current?.selectedNodes.length ?? 0) > 0
return isInTreeContainer || hasSelection
}, [treeRef])
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 })
}

View File

@ -27,8 +27,6 @@ export function useSyncTreeWithActiveTab({
if (!activeTabId)
return
storeApi.getState().setSelectedTreeNodeId(activeTabId)
const tree = treeRef.current
if (!tree)
return
@ -38,7 +36,6 @@ export function useSyncTreeWithActiveTab({
if (!node)
return
// Traverse parent chain to collect ancestor folder IDs
const ancestors: string[] = []
let current = node.parent
while (current && !current.isRoot) {
@ -50,6 +47,7 @@ export function useSyncTreeWithActiveTab({
storeApi.getState().revealFile(ancestors)
tree.openParents(node)
tree.select(activeTabId)
tree.scrollTo(activeTabId)
})
}, [activeTabId, treeRef, storeApi])

View File

@ -3,7 +3,7 @@
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import { throttle } from 'es-toolkit/function'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useDelayedClick } from './use-delayed-click'
@ -19,19 +19,17 @@ type UseTreeNodeHandlersReturn = {
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({
node,
}: UseTreeNodeHandlersOptions): UseTreeNodeHandlersReturn {
const storeApi = useWorkflowStore()
const isFolder = node.data.node_type === 'folder'
const nodeRef = useRef(node)
nodeRef.current = node
const throttledToggle = useMemo(
() => throttle(() => node.toggle(), 300, { edges: ['leading'] }),
[node],
() => throttle(() => nodeRef.current.toggle(), 300, { edges: ['leading'] }),
[],
)
const openFilePreview = useCallback(() => {
@ -49,10 +47,16 @@ export function useTreeNodeHandlers({
const handleClick = useCallback((e: React.MouseEvent) => {
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)
throttledToggle()
else
else if (!e.metaKey && !e.ctrlKey && !e.shiftKey)
handleFileClick()
}, [handleFileClick, isFolder, node, throttledToggle])
@ -72,9 +76,7 @@ export function useTreeNodeHandlers({
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Select the node for highlight + creation target
storeApi.getState().setSelectedTreeNodeId(node.data.id)
node.select()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
@ -82,7 +84,7 @@ export function useTreeNodeHandlers({
nodeId: node.data.id,
isFolder,
})
}, [isFolder, node.data.id, storeApi])
}, [isFolder, node, storeApi])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {

View File

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

View File

@ -17,6 +17,7 @@ export const createFileTreeSlice: StateCreator<
> = (set, get) => ({
expandedFolderIds: new Set<string>(),
selectedTreeNodeId: null,
selectedNodeIds: new Set<string>(),
pendingCreateNode: null,
setExpandedFolderIds: (ids: Set<string>) => {
@ -61,6 +62,21 @@ export const createFileTreeSlice: StateCreator<
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) => {
set({
pendingCreateNode: {

View File

@ -1,11 +1,13 @@
import type { StateCreator } from 'zustand'
import type { SkillEditorSliceShape } from './types'
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'
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'
@ -16,6 +18,7 @@ export type { SkillEditorSliceShape } from './types'
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...args) => ({
...createTabSlice(...args),
...createFileTreeSlice(...args),
...createClipboardSlice(...args),
...createDirtySlice(...args),
...createMetadataSlice(...args),
...createFileOperationsMenuSlice(...args),
@ -28,7 +31,9 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
previewTabId: null,
expandedFolderIds: new Set<string>(),
selectedTreeNodeId: null,
selectedNodeIds: new Set<string>(),
pendingCreateNode: null,
clipboard: null,
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, unknown>>(),
dirtyMetadataIds: new Set<string>(),

View File

@ -32,6 +32,9 @@ export type FileTreeSliceShape = {
getOpensObject: () => OpensObject
selectedTreeNodeId: string | null
setSelectedTreeNodeId: (nodeId: string | null) => void
selectedNodeIds: Set<string>
setSelectedNodeIds: (nodeIds: string[]) => void
clearSelection: () => void
pendingCreateNode: PendingCreateNode | null
startCreateNode: (nodeType: PendingCreateNode['nodeType'], parentId: PendingCreateNode['parentId']) => void
clearCreateNode: () => void
@ -41,6 +44,22 @@ export type FileTreeSliceShape = {
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 = {
dirtyContents: Map<string, string>
setDraftContent: (fileId: string, content: string) => void
@ -76,6 +95,7 @@ export type FileOperationsMenuSliceShape = {
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
& ClipboardSliceShape
& DirtySliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape

View File

@ -1023,7 +1023,9 @@
"skillSidebar.folderName": "Folder name",
"skillSidebar.folderNamePlaceholder": "Folder name",
"skillSidebar.loadError": "Failed to load files",
"skillSidebar.menu.copy": "Copy",
"skillSidebar.menu.createError": "Failed to create item",
"skillSidebar.menu.cut": "Cut",
"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.deleteConfirmTitle": "Delete folder?",
@ -1045,6 +1047,11 @@
"skillSidebar.menu.newFilePrompt": "Enter file name (with extension, e.g., script.py):",
"skillSidebar.menu.newFolder": "New Folder",
"skillSidebar.menu.newFolderPrompt": "Enter folder name:",
"skillSidebar.menu.paste": "Paste",
"skillSidebar.menu.moved": "Moved successfully",
"skillSidebar.menu.moveError": "Failed to move",
"skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself",
"skillSidebar.menu.copyNotSupported": "Copy is not supported yet",
"skillSidebar.menu.rename": "Rename",
"skillSidebar.menu.renameError": "Failed to rename",
"skillSidebar.menu.renamed": "Renamed successfully",

View File

@ -1015,7 +1015,9 @@
"skillSidebar.folderName": "文件夹名称",
"skillSidebar.folderNamePlaceholder": "文件夹名称",
"skillSidebar.loadError": "加载文件失败",
"skillSidebar.menu.copy": "复制",
"skillSidebar.menu.createError": "创建失败",
"skillSidebar.menu.cut": "剪切",
"skillSidebar.menu.delete": "删除",
"skillSidebar.menu.deleteConfirmContent": "这将永久删除该文件夹及其所有内容。该文件夹中已打开的文件标签将被关闭。",
"skillSidebar.menu.deleteConfirmTitle": "删除文件夹?",
@ -1036,6 +1038,11 @@
"skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py",
"skillSidebar.menu.newFolder": "新建文件夹",
"skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:",
"skillSidebar.menu.paste": "粘贴",
"skillSidebar.menu.moved": "移动成功",
"skillSidebar.menu.moveError": "移动失败",
"skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部",
"skillSidebar.menu.copyNotSupported": "暂不支持复制功能",
"skillSidebar.menu.rename": "重命名",
"skillSidebar.menu.renameError": "重命名失败",
"skillSidebar.menu.renamed": "重命名成功",