feat(skill-editor): enhance + button with full operations and smart target folder

- Refactor sidebar-search-add to reuse useFileOperations hook
- Add getTargetFolderIdFromSelection utility for smart folder targeting
- Expand + button menu: New File, New Folder, Upload File, Upload Folder
- Target folder based on selection: file's parent, folder itself, or root
This commit is contained in:
yyh
2026-01-15 16:09:11 +08:00
parent e91fb94d0e
commit 7bc1390366
2 changed files with 121 additions and 95 deletions

View File

@ -1,92 +1,82 @@
'use client'
import type { FC } from 'react'
import { RiAddLine, RiFile3Line, RiFolderAddLine } from '@remixicon/react'
import {
RiAddLine,
RiFileAddLine,
RiFolderAddLine,
RiFolderUploadLine,
RiUploadLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import Toast from '@/app/components/base/toast'
import { useCreateAppAssetFile, useCreateAppAssetFolder } from '@/service/use-app-asset'
import { useGetAppAssetTree } from '@/service/use-app-asset'
import { cn } from '@/utils/classnames'
import { useFileOperations } from './hooks/use-file-operations'
import { useSkillEditorStore } from './store'
import { getTargetFolderIdFromSelection } from './utils/tree-utils'
type MenuItemProps = {
icon: React.ElementType
label: string
onClick: () => void
disabled?: boolean
}
const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
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',
)}
>
<Icon className="size-4 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{label}
</span>
</button>
)
const SidebarSearchAdd: FC = () => {
const { t } = useTranslation('workflow')
const [searchValue, setSearchValue] = useState('')
const [showMenu, setShowMenu] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const { data: treeData } = useGetAppAssetTree(appId)
const activeTabId = useSkillEditorStore(s => s.activeTabId)
const createFolder = useCreateAppAssetFolder()
const createFile = useCreateAppAssetFile()
const targetFolderId = useMemo(() => {
if (!treeData?.children)
return 'root'
return getTargetFolderIdFromSelection(activeTabId, treeData.children)
}, [activeTabId, treeData?.children])
const handleNewFolder = useCallback(async () => {
setShowMenu(false)
if (!appId)
return
const timestamp = Date.now()
const folderName = `${t('skillSidebar.newFolder')}-${timestamp}`
try {
await createFolder.mutateAsync({
appId,
payload: {
name: folderName,
parent_id: null,
},
})
Toast.notify({
type: 'success',
message: t('skillSidebar.addFolder'),
})
}
catch (error) {
Toast.notify({
type: 'error',
message: String(error),
})
}
}, [appId, createFolder, t])
const handleUploadClick = useCallback(() => {
setShowMenu(false)
fileInputRef.current?.click()
}, [])
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0 || !appId)
return
const file = files[0]
try {
await createFile.mutateAsync({
appId,
name: file.name,
file,
parentId: null,
})
Toast.notify({
type: 'success',
message: t('skillSidebar.addFile'),
})
}
catch (error) {
Toast.notify({
type: 'error',
message: String(error),
})
}
e.target.value = ''
}, [appId, createFile, t])
const {
fileInputRef,
folderInputRef,
isLoading,
handleNewFile,
handleNewFolder,
handleFileChange,
handleFolderChange,
} = useFileOperations({
nodeId: targetFolderId,
onClose: () => setShowMenu(false),
})
return (
<div className="flex items-center gap-1 bg-components-panel-bg p-2">
@ -113,35 +103,53 @@ const SidebarSearchAdd: FC = () => {
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[30]">
<div className="flex min-w-[160px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-state-base-hover"
<div className="flex min-w-[180px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<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}
/>
<MenuItem
icon={RiFileAddLine}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
disabled={isLoading}
/>
<MenuItem
icon={RiFolderAddLine}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
>
<RiFolderAddLine className="h-4 w-4 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('skillSidebar.addFolder')}
</span>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-state-base-hover"
onClick={handleUploadClick}
>
<RiFile3Line className="h-4 w-4 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('skillSidebar.addFile')}
</span>
</div>
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon={RiUploadLine}
label={t('skillSidebar.menu.uploadFile')}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
/>
<MenuItem
icon={RiFolderUploadLine}
label={t('skillSidebar.menu.uploadFolder')}
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileChange}
/>
</div>
)
}

View File

@ -84,3 +84,21 @@ export function getAllDescendantFileIds(
return fileIds
}
export function getTargetFolderIdFromSelection(
selectedId: string | null,
nodes: AppAssetTreeView[],
): string {
if (!selectedId)
return 'root'
const selectedNode = findNodeById(nodes, selectedId)
if (!selectedNode)
return 'root'
if (selectedNode.node_type === 'folder')
return selectedNode.id
const ancestors = getAncestorIds(selectedId, nodes)
return ancestors.length > 0 ? ancestors[ancestors.length - 1] : 'root'
}