feat: inline create nodes in skill file tree

This commit is contained in:
yyh
2026-01-19 13:43:29 +08:00
parent a922e844eb
commit 6584dc2480
7 changed files with 290 additions and 100 deletions

View File

@ -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%"

View File

@ -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 || [])

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {