mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user