mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 12:16:11 +08:00
feat(skill-editor): add folder context menu with file operations
Add right-click context menu and "..." dropdown button for folders in the file tree, enabling file operations within any folder: - New File: Create empty file via Blob upload - New Folder: Create subfolder - Upload File: Upload multiple files to folder - Upload Folder: Upload entire folder structure preserving hierarchy Implementation includes: - FileOperationsMenu: Shared menu component for both triggers - FileTreeContextMenu: Right-click menu with absolute positioning - FileTreeNode: Added context menu and dropdown button for folders - Store slice for context menu state management - i18n strings for en-US and zh-Hans
This commit is contained in:
353
web/app/components/workflow/skill/file-operations-menu.tsx
Normal file
353
web/app/components/workflow/skill/file-operations-menu.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { RiFileAddLine, RiFolderAddLine, RiFolderUploadLine, RiUploadLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, 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 { useCreateAppAssetFile, useCreateAppAssetFolder } from '@/service/use-app-asset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
/**
|
||||
* FileOperationsMenu - Menu content for file operations
|
||||
*
|
||||
* Shared by both context menu (right-click) and dropdown menu (... button)
|
||||
*
|
||||
* Features:
|
||||
* - New File: Create empty file (via empty Blob upload)
|
||||
* - New Folder: Create folder in target location
|
||||
* - Upload File: Upload file(s) to target folder
|
||||
* - Upload Folder: Upload entire folder structure (webkitdirectory)
|
||||
*/
|
||||
|
||||
type FileOperationsMenuProps = {
|
||||
/** Target folder ID, or 'root' for root level */
|
||||
nodeId: string
|
||||
/** Callback to close menu after action */
|
||||
onClose: () => void
|
||||
/** Optional className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
nodeId,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get appId from app store
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
// Mutations
|
||||
const createFolder = useCreateAppAssetFolder()
|
||||
const createFile = useCreateAppAssetFile()
|
||||
|
||||
// Determine parent_id (null for root)
|
||||
const parentId = nodeId === 'root' ? null : nodeId
|
||||
|
||||
// Handle New File
|
||||
const handleNewFile = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later
|
||||
const fileName = window.prompt(t('skillSidebar.menu.newFilePrompt'))
|
||||
if (!fileName || !fileName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create empty Blob and upload as file
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], fileName.trim())
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: fileName.trim(),
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.fileCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
// Handle New Folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later
|
||||
const folderName = window.prompt(t('skillSidebar.menu.newFolderPrompt'))
|
||||
if (!folderName || !folderName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName.trim(),
|
||||
parent_id: parentId,
|
||||
},
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFolder, onClose, parentId, t])
|
||||
|
||||
// Handle Upload File button click
|
||||
const handleUploadFileClick = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
// Handle Upload Folder button click
|
||||
const handleUploadFolderClick = useCallback(() => {
|
||||
folderInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
// Handle file input change (single or multiple files)
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload files sequentially to avoid overwhelming the server
|
||||
for (const file of files) {
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Reset input to allow re-uploading same file
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
// Handle folder input change (webkitdirectory)
|
||||
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect all unique folder paths from file paths
|
||||
const folders = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
|
||||
// Collect all folder paths (parent directories)
|
||||
if (parts.length > 1) {
|
||||
let folderPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i]
|
||||
folders.add(folderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort folders by depth (parent before child)
|
||||
const sortedFolders = Array.from(folders).sort((a, b) => {
|
||||
return a.split('/').length - b.split('/').length
|
||||
})
|
||||
|
||||
// Create folders and track their IDs
|
||||
const folderIdMap = new Map<string, string | null>()
|
||||
folderIdMap.set('', parentId) // Root maps to target parent
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
const parts = folderPath.split('/')
|
||||
const folderName = parts[parts.length - 1]
|
||||
const parentPath = parts.slice(0, -1).join('/')
|
||||
const parentFolderId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
const result = await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName,
|
||||
parent_id: parentFolderId,
|
||||
},
|
||||
})
|
||||
|
||||
folderIdMap.set(folderPath, result.id)
|
||||
}
|
||||
|
||||
// Upload files to their respective folders
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : ''
|
||||
const targetParentId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: targetParentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderUploaded'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Reset input
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, createFolder, onClose, parentId, t])
|
||||
|
||||
const isLoading = createFile.isPending || createFolder.isPending
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
||||
webkitdirectory=""
|
||||
className="hidden"
|
||||
onChange={handleFolderChange}
|
||||
/>
|
||||
|
||||
{/* New File */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewFile}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RiFileAddLine className="size-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.menu.newFile')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* New Folder */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewFolder}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RiFolderAddLine className="size-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.menu.newFolder')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
|
||||
{/* Upload File */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadFileClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RiUploadLine className="size-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.menu.uploadFile')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Upload Folder */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadFolderClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RiFolderUploadLine className="size-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.menu.uploadFolder')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileOperationsMenu)
|
||||
49
web/app/components/workflow/skill/file-tree-context-menu.tsx
Normal file
49
web/app/components/workflow/skill/file-tree-context-menu.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import FileOperationsMenu from './file-operations-menu'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
|
||||
/**
|
||||
* FileTreeContextMenu - Right-click context menu for file tree
|
||||
*
|
||||
* Renders at absolute position when contextMenu state is set.
|
||||
* Uses useClickAway to close when clicking outside.
|
||||
*/
|
||||
const FileTreeContextMenu: FC = () => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const contextMenu = useSkillEditorStore(s => s.contextMenu)
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
storeApi.getState().setContextMenu(null)
|
||||
}, [storeApi])
|
||||
|
||||
useClickAway(() => {
|
||||
handleClose()
|
||||
}, ref)
|
||||
|
||||
if (!contextMenu)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[100]"
|
||||
style={{
|
||||
top: contextMenu.top,
|
||||
left: contextMenu.left,
|
||||
}}
|
||||
>
|
||||
<FileOperationsMenu
|
||||
nodeId={contextMenu.nodeId}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileTreeContextMenu)
|
||||
@ -3,11 +3,18 @@
|
||||
import type { NodeRendererProps } from 'react-arborist'
|
||||
import type { TreeNodeData } from './type'
|
||||
import type { FileAppearanceType } from '@/app/components/base/file-uploader/types'
|
||||
import { RiFolderLine, RiFolderOpenLine } from '@remixicon/react'
|
||||
import { RiFolderLine, RiFolderOpenLine, RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSkillEditorStore } from './store'
|
||||
import FileOperationsMenu from './file-operations-menu'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getFileIconType } from './utils'
|
||||
|
||||
/**
|
||||
@ -20,11 +27,19 @@ import { getFileIconType } from './utils'
|
||||
* - Colors: text-secondary (#354052), text-primary (#101828) for selected
|
||||
* - Hover bg: rgba(200,206,218,0.2), Active bg: rgba(200,206,218,0.4)
|
||||
* - Folder icon: blue (#155aef) when open
|
||||
*
|
||||
* Features:
|
||||
* - Right-click context menu for folders
|
||||
* - "..." button dropdown for folders (visible on hover)
|
||||
*/
|
||||
const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const isSelected = node.isSelected
|
||||
const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id))
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
// Dropdown menu state (for ... button)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
// Get file icon type for files
|
||||
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
||||
@ -46,6 +61,33 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
node.toggle()
|
||||
}
|
||||
|
||||
// Right-click context menu handler (folders only)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
// Only show context menu for folders
|
||||
if (!isFolder)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
storeApi.getState().setContextMenu({
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
nodeId: node.data.id,
|
||||
})
|
||||
}, [isFolder, node.data.id, storeApi])
|
||||
|
||||
// Dropdown close handler
|
||||
const handleDropdownClose = useCallback(() => {
|
||||
setShowDropdown(false)
|
||||
}, [])
|
||||
|
||||
// More button click handler
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDropdown(prev => !prev)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
@ -57,6 +99,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
@ -94,6 +137,38 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
>
|
||||
{node.data.name}
|
||||
</span>
|
||||
|
||||
{/* More button - only for folders, visible on hover */}
|
||||
{isFolder && (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
open={showDropdown}
|
||||
onOpenChange={setShowDropdown}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMoreClick}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
'invisible group-hover:visible',
|
||||
showDropdown && 'visible',
|
||||
)}
|
||||
aria-label="File operations"
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<FileOperationsMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={handleDropdownClose}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useGetAppAssetTree } from '@/service/use-app-asset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import FileTreeContextMenu from './file-tree-context-menu'
|
||||
import FileTreeNode from './file-tree-node'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getAncestorIds, toOpensObject } from './type'
|
||||
@ -180,6 +181,8 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
</Tree>
|
||||
</div>
|
||||
<DropTip />
|
||||
{/* Right-click context menu */}
|
||||
<FileTreeContextMenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -172,6 +172,30 @@ export const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// File Operations Menu Slice
|
||||
// ============================================================================
|
||||
|
||||
export type FileOperationsMenuSliceShape = {
|
||||
/** Context menu state (right-click) - null when closed */
|
||||
contextMenu: {
|
||||
top: number
|
||||
left: number
|
||||
nodeId: string
|
||||
} | null
|
||||
|
||||
/** Set or clear context menu */
|
||||
setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void
|
||||
}
|
||||
|
||||
export const createFileOperationsMenuSlice: StateCreator<FileOperationsMenuSliceShape> = set => ({
|
||||
contextMenu: null,
|
||||
|
||||
setContextMenu: (contextMenu) => {
|
||||
set({ contextMenu })
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Combined Store Shape
|
||||
// ============================================================================
|
||||
@ -180,6 +204,7 @@ export type SkillEditorShape
|
||||
= TabSliceShape
|
||||
& FileTreeSliceShape
|
||||
& DirtySliceShape
|
||||
& FileOperationsMenuSliceShape
|
||||
& {
|
||||
/** Reset all state (called when appId changes) */
|
||||
reset: () => void
|
||||
@ -194,6 +219,7 @@ export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
|
||||
...createTabSlice(...args),
|
||||
...createFileTreeSlice(...args),
|
||||
...createDirtySlice(...args),
|
||||
...createFileOperationsMenuSlice(...args),
|
||||
|
||||
reset: () => {
|
||||
const [set] = args
|
||||
@ -203,6 +229,7 @@ export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
|
||||
previewTabId: null,
|
||||
expandedFolderIds: new Set<string>(),
|
||||
dirtyContents: new Map<string, string>(),
|
||||
contextMenu: null,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@ -108,3 +108,25 @@ export function toOpensObject(expandedIds: Set<string>): Record<string, boolean>
|
||||
})
|
||||
return opens
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node by ID in the tree (recursive search)
|
||||
* @param nodes - Tree nodes from API (nested structure)
|
||||
* @param nodeId - Target node ID
|
||||
* @returns Node if found, null otherwise
|
||||
*/
|
||||
export function findNodeById(
|
||||
nodes: AppAssetTreeView[],
|
||||
nodeId: string,
|
||||
): AppAssetTreeView | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId)
|
||||
return node
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1004,6 +1004,18 @@
|
||||
"skillSidebar.empty": "No files yet",
|
||||
"skillSidebar.folderName": "Folder name",
|
||||
"skillSidebar.loadError": "Failed to load files",
|
||||
"skillSidebar.menu.createError": "Failed to create item",
|
||||
"skillSidebar.menu.fileCreated": "File created successfully",
|
||||
"skillSidebar.menu.filesUploaded": "{{count}} file(s) uploaded successfully",
|
||||
"skillSidebar.menu.folderCreated": "Folder created successfully",
|
||||
"skillSidebar.menu.folderUploaded": "Folder uploaded successfully",
|
||||
"skillSidebar.menu.newFile": "New File",
|
||||
"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.uploadError": "Failed to upload",
|
||||
"skillSidebar.menu.uploadFile": "Upload File",
|
||||
"skillSidebar.menu.uploadFolder": "Upload Folder",
|
||||
"skillSidebar.newFolder": "New folder",
|
||||
"skillSidebar.searchPlaceholder": "Search files...",
|
||||
"skillSidebar.uploading": "Uploading...",
|
||||
|
||||
@ -998,6 +998,18 @@
|
||||
"skillSidebar.empty": "暂无文件",
|
||||
"skillSidebar.folderName": "文件夹名称",
|
||||
"skillSidebar.loadError": "加载文件失败",
|
||||
"skillSidebar.menu.createError": "创建失败",
|
||||
"skillSidebar.menu.fileCreated": "文件创建成功",
|
||||
"skillSidebar.menu.filesUploaded": "成功上传 {{count}} 个文件",
|
||||
"skillSidebar.menu.folderCreated": "文件夹创建成功",
|
||||
"skillSidebar.menu.folderUploaded": "文件夹上传成功",
|
||||
"skillSidebar.menu.newFile": "新建文件",
|
||||
"skillSidebar.menu.newFilePrompt": "请输入文件名(包含扩展名,如 script.py):",
|
||||
"skillSidebar.menu.newFolder": "新建文件夹",
|
||||
"skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:",
|
||||
"skillSidebar.menu.uploadError": "上传失败",
|
||||
"skillSidebar.menu.uploadFile": "上传文件",
|
||||
"skillSidebar.menu.uploadFolder": "上传文件夹",
|
||||
"skillSidebar.newFolder": "新建文件夹",
|
||||
"skillSidebar.searchPlaceholder": "搜索文件...",
|
||||
"skillSidebar.uploading": "上传中...",
|
||||
|
||||
Reference in New Issue
Block a user