mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
feat: inline create nodes in skill file tree
This commit is contained in:
@ -10,12 +10,10 @@ import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { Tree } from 'react-arborist'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useRenameAppAssetNode } from '@/service/use-app-asset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
@ -26,6 +24,8 @@ type FileTreeProps = {
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
const emptyTreeNodes: TreeNodeData[] = []
|
||||
|
||||
const DropTip = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
return (
|
||||
@ -44,9 +44,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const containerSize = useSize(containerRef)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
|
||||
const isMutating = useIsMutating() > 0
|
||||
|
||||
@ -54,7 +51,16 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const renameNode = useRenameAppAssetNode()
|
||||
const treeChildren = treeData?.children ?? emptyTreeNodes
|
||||
const {
|
||||
treeNodes,
|
||||
handleRename,
|
||||
searchMatch,
|
||||
hasPendingCreate,
|
||||
} = useInlineCreateNode({
|
||||
treeRef,
|
||||
treeChildren,
|
||||
})
|
||||
|
||||
const initialOpensObject = useMemo<OpensObject>(() => {
|
||||
return Object.fromEntries(
|
||||
@ -73,24 +79,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
node.toggle()
|
||||
}, [storeApi])
|
||||
|
||||
const handleRename = useCallback(({ id, name }: { id: string, name: string }) => {
|
||||
renameNode.mutateAsync({
|
||||
appId,
|
||||
nodeId: id,
|
||||
payload: { name },
|
||||
}).then(() => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.renamed'),
|
||||
})
|
||||
}).catch(() => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.renameError'),
|
||||
})
|
||||
})
|
||||
}, [appId, renameNode, t])
|
||||
|
||||
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
storeApi.getState().setContextMenu({
|
||||
@ -100,13 +88,6 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
})
|
||||
}, [storeApi])
|
||||
|
||||
const searchMatch = useCallback(
|
||||
(node: NodeApi<TreeNodeData>, term: string) => {
|
||||
return node.data.name.toLowerCase().includes(term.toLowerCase())
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId,
|
||||
@ -130,7 +111,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (!treeData?.children || treeData.children.length === 0) {
|
||||
if (treeChildren.length === 0 && !hasPendingCreate) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
@ -159,7 +140,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
>
|
||||
<Tree<TreeNodeData>
|
||||
ref={treeRef}
|
||||
data={treeData.children}
|
||||
data={treeNodes}
|
||||
idAccessor="id"
|
||||
childrenAccessor="children"
|
||||
width="100%"
|
||||
|
||||
@ -45,73 +45,15 @@ export function useFileOperations({
|
||||
|
||||
const parentId = nodeId === 'root' ? null : nodeId
|
||||
|
||||
const handleNewFile = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity
|
||||
const fileName = window.prompt(t('skillSidebar.menu.newFilePrompt'))
|
||||
if (!fileName || !fileName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
const handleNewFile = useCallback(() => {
|
||||
storeApi.getState().startCreateNode('file', parentId)
|
||||
onClose()
|
||||
}, [onClose, parentId, storeApi])
|
||||
|
||||
try {
|
||||
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])
|
||||
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity
|
||||
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])
|
||||
const handleNewFolder = useCallback(() => {
|
||||
storeApi.getState().startCreateNode('folder', parentId)
|
||||
onClose()
|
||||
}, [onClose, parentId, storeApi])
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useCreateAppAssetFile,
|
||||
useCreateAppAssetFolder,
|
||||
useRenameAppAssetNode,
|
||||
} from '@/service/use-app-asset'
|
||||
import { createDraftTreeNode, insertDraftTreeNode } from '../utils/tree-utils'
|
||||
|
||||
type UseInlineCreateNodeOptions = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
treeChildren: TreeNodeData[]
|
||||
}
|
||||
|
||||
type RenamePayload = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useInlineCreateNode({
|
||||
treeRef,
|
||||
treeChildren,
|
||||
}: UseInlineCreateNodeOptions) {
|
||||
const { t } = useTranslation('workflow')
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const pendingCreateNode = useStore(s => s.pendingCreateNode)
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const createFile = useCreateAppAssetFile()
|
||||
const createFolder = useCreateAppAssetFolder()
|
||||
const renameNode = useRenameAppAssetNode()
|
||||
|
||||
const pendingCreateId = pendingCreateNode?.id ?? null
|
||||
const pendingCreateType = pendingCreateNode?.nodeType ?? null
|
||||
const pendingCreateParentId = pendingCreateNode?.parentId ?? null
|
||||
const hasPendingCreate = !!pendingCreateNode
|
||||
|
||||
const treeNodes = useMemo(() => {
|
||||
if (!pendingCreateNode)
|
||||
return treeChildren
|
||||
const draftNode = createDraftTreeNode({
|
||||
id: pendingCreateNode.id,
|
||||
nodeType: pendingCreateNode.nodeType,
|
||||
})
|
||||
return insertDraftTreeNode(treeChildren, pendingCreateNode.parentId, draftNode)
|
||||
}, [pendingCreateNode, treeChildren])
|
||||
|
||||
const handleRename = useCallback(async ({ id, name }: RenamePayload) => {
|
||||
if (pendingCreateId && id === pendingCreateId) {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
storeApi.getState().clearCreateNode()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (pendingCreateType === 'folder') {
|
||||
await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: trimmedName,
|
||||
parent_id: pendingCreateParentId,
|
||||
},
|
||||
})
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderCreated'),
|
||||
})
|
||||
}
|
||||
else {
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], trimmedName)
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: trimmedName,
|
||||
file,
|
||||
parentId: pendingCreateParentId,
|
||||
})
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.fileCreated'),
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
storeApi.getState().clearCreateNode()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renameNode.mutateAsync({
|
||||
appId,
|
||||
nodeId: id,
|
||||
payload: { name },
|
||||
}).then(() => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.renamed'),
|
||||
})
|
||||
}).catch(() => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.renameError'),
|
||||
})
|
||||
})
|
||||
}, [
|
||||
appId,
|
||||
createFile,
|
||||
createFolder,
|
||||
pendingCreateId,
|
||||
pendingCreateParentId,
|
||||
pendingCreateType,
|
||||
renameNode,
|
||||
storeApi,
|
||||
t,
|
||||
])
|
||||
|
||||
const searchMatch = useCallback(
|
||||
(node: NodeApi<TreeNodeData>, term: string) => {
|
||||
if (pendingCreateId && node.data.id === pendingCreateId)
|
||||
return true
|
||||
return node.data.name.toLowerCase().includes(term.toLowerCase())
|
||||
},
|
||||
[pendingCreateId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCreateId)
|
||||
return
|
||||
|
||||
const tree = treeRef.current
|
||||
if (!tree)
|
||||
return
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const currentTree = treeRef.current
|
||||
if (!currentTree)
|
||||
return
|
||||
currentTree.openParents(pendingCreateId)
|
||||
currentTree.edit(pendingCreateId).then((result) => {
|
||||
if (result.cancelled && storeApi.getState().pendingCreateNode?.id === pendingCreateId)
|
||||
storeApi.getState().clearCreateNode()
|
||||
})
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [pendingCreateId, storeApi, treeRef])
|
||||
|
||||
return {
|
||||
treeNodes,
|
||||
handleRename,
|
||||
searchMatch,
|
||||
hasPendingCreate,
|
||||
}
|
||||
}
|
||||
@ -102,3 +102,70 @@ export function getTargetFolderIdFromSelection(
|
||||
const ancestors = getAncestorIds(selectedId, nodes)
|
||||
return ancestors.length > 0 ? ancestors[ancestors.length - 1] : 'root'
|
||||
}
|
||||
|
||||
export type DraftTreeNodeOptions = {
|
||||
id: string
|
||||
nodeType: AppAssetTreeView['node_type']
|
||||
}
|
||||
|
||||
export function createDraftTreeNode(options: DraftTreeNodeOptions): AppAssetTreeView {
|
||||
return {
|
||||
id: options.id,
|
||||
node_type: options.nodeType,
|
||||
name: '',
|
||||
path: '',
|
||||
extension: '',
|
||||
size: 0,
|
||||
checksum: '',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
type InsertDraftNodeResult = {
|
||||
nodes: AppAssetTreeView[]
|
||||
inserted: boolean
|
||||
}
|
||||
|
||||
function insertDraftNodeAtParent(
|
||||
nodes: AppAssetTreeView[],
|
||||
parentId: string,
|
||||
draftNode: AppAssetTreeView,
|
||||
): InsertDraftNodeResult {
|
||||
let inserted = false
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
inserted = true
|
||||
return {
|
||||
...node,
|
||||
children: [draftNode, ...node.children],
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
const result = insertDraftNodeAtParent(node.children, parentId, draftNode)
|
||||
if (result.inserted) {
|
||||
inserted = true
|
||||
return {
|
||||
...node,
|
||||
children: result.nodes,
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
return { nodes: inserted ? nextNodes : nodes, inserted }
|
||||
}
|
||||
|
||||
export function insertDraftTreeNode(
|
||||
nodes: AppAssetTreeView[],
|
||||
parentId: string | null,
|
||||
draftNode: AppAssetTreeView,
|
||||
): AppAssetTreeView[] {
|
||||
if (!parentId)
|
||||
return [draftNode, ...nodes]
|
||||
|
||||
const result = insertDraftNodeAtParent(nodes, parentId, draftNode)
|
||||
if (!result.inserted)
|
||||
return [draftNode, ...nodes]
|
||||
|
||||
return result.nodes
|
||||
}
|
||||
|
||||
@ -3,6 +3,12 @@ import type { FileTreeSliceShape, OpensObject, SkillEditorSliceShape } from './t
|
||||
|
||||
export type { FileTreeSliceShape, OpensObject } from './types'
|
||||
|
||||
const createDraftId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
return `draft-${crypto.randomUUID()}`
|
||||
return `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
export const createFileTreeSlice: StateCreator<
|
||||
SkillEditorSliceShape,
|
||||
[],
|
||||
@ -10,6 +16,7 @@ export const createFileTreeSlice: StateCreator<
|
||||
FileTreeSliceShape
|
||||
> = (set, get) => ({
|
||||
expandedFolderIds: new Set<string>(),
|
||||
pendingCreateNode: null,
|
||||
|
||||
setExpandedFolderIds: (ids: Set<string>) => {
|
||||
set({ expandedFolderIds: ids })
|
||||
@ -48,4 +55,18 @@ export const createFileTreeSlice: StateCreator<
|
||||
[...expandedFolderIds].map(id => [id, true]),
|
||||
)
|
||||
},
|
||||
|
||||
startCreateNode: (nodeType, parentId) => {
|
||||
set({
|
||||
pendingCreateNode: {
|
||||
id: createDraftId(),
|
||||
parentId,
|
||||
nodeType,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
clearCreateNode: () => {
|
||||
set({ pendingCreateNode: null })
|
||||
},
|
||||
})
|
||||
|
||||
@ -27,6 +27,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
activeTabId: null,
|
||||
previewTabId: null,
|
||||
expandedFolderIds: new Set<string>(),
|
||||
pendingCreateNode: null,
|
||||
dirtyContents: new Map<string, string>(),
|
||||
fileMetadata: new Map<string, Record<string, unknown>>(),
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
|
||||
@ -15,6 +15,12 @@ export type TabSliceShape = {
|
||||
|
||||
export type OpensObject = Record<string, boolean>
|
||||
|
||||
export type PendingCreateNode = {
|
||||
id: string
|
||||
parentId: string | null
|
||||
nodeType: 'file' | 'folder'
|
||||
}
|
||||
|
||||
export type FileTreeSliceShape = {
|
||||
expandedFolderIds: Set<string>
|
||||
setExpandedFolderIds: (ids: Set<string>) => void
|
||||
@ -22,6 +28,9 @@ export type FileTreeSliceShape = {
|
||||
revealFile: (ancestorFolderIds: string[]) => void
|
||||
setExpandedFromOpens: (opens: OpensObject) => void
|
||||
getOpensObject: () => OpensObject
|
||||
pendingCreateNode: PendingCreateNode | null
|
||||
startCreateNode: (nodeType: PendingCreateNode['nodeType'], parentId: PendingCreateNode['parentId']) => void
|
||||
clearCreateNode: () => void
|
||||
}
|
||||
|
||||
export type DirtySliceShape = {
|
||||
|
||||
Reference in New Issue
Block a user