From 6584dc2480721e8220e0ab19a99f86145329e57f Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 13:43:29 +0800 Subject: [PATCH] feat: inline create nodes in skill file tree --- .../workflow/skill/file-tree/index.tsx | 49 ++--- .../skill/hooks/use-file-operations.ts | 74 +------- .../skill/hooks/use-inline-create-node.ts | 169 ++++++++++++++++++ .../workflow/skill/utils/tree-utils.ts | 67 +++++++ .../workflow/skill-editor/file-tree-slice.ts | 21 +++ .../store/workflow/skill-editor/index.ts | 1 + .../store/workflow/skill-editor/types.ts | 9 + 7 files changed, 290 insertions(+), 100 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-inline-create-node.ts diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index f393a840e6..81be17d851 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -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 = ({ className, searchTerm = '' }) => { const containerRef = useRef(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 = ({ 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(() => { return Object.fromEntries( @@ -73,24 +79,6 @@ const FileTree: React.FC = ({ 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 = ({ className, searchTerm = '' }) => { }) }, [storeApi]) - const searchMatch = useCallback( - (node: NodeApi, term: string) => { - return node.data.name.toLowerCase().includes(term.toLowerCase()) - }, - [], - ) - useSyncTreeWithActiveTab({ treeRef, activeTabId, @@ -130,7 +111,7 @@ const FileTree: React.FC = ({ className, searchTerm = '' }) => { ) } - if (!treeData?.children || treeData.children.length === 0) { + if (treeChildren.length === 0 && !hasPendingCreate) { return (
@@ -159,7 +140,7 @@ const FileTree: React.FC = ({ className, searchTerm = '' }) => { > ref={treeRef} - data={treeData.children} + data={treeNodes} idAccessor="id" childrenAccessor="children" width="100%" diff --git a/web/app/components/workflow/skill/hooks/use-file-operations.ts b/web/app/components/workflow/skill/hooks/use-file-operations.ts index d517dc8911..d5013460b6 100644 --- a/web/app/components/workflow/skill/hooks/use-file-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-file-operations.ts @@ -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) => { const files = Array.from(e.target.files || []) diff --git a/web/app/components/workflow/skill/hooks/use-inline-create-node.ts b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts new file mode 100644 index 0000000000..870b109e32 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts @@ -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 | 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, 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, + } +} diff --git a/web/app/components/workflow/skill/utils/tree-utils.ts b/web/app/components/workflow/skill/utils/tree-utils.ts index d71787f2b3..1b7db41f44 100644 --- a/web/app/components/workflow/skill/utils/tree-utils.ts +++ b/web/app/components/workflow/skill/utils/tree-utils.ts @@ -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 +} diff --git a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts index 50ef2e57c5..3335ced60e 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts @@ -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(), + pendingCreateNode: null, setExpandedFolderIds: (ids: Set) => { 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 }) + }, }) diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts index 247e503805..f4fc3b214b 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/index.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts @@ -27,6 +27,7 @@ export const createSkillEditorSlice: StateCreator = (...a activeTabId: null, previewTabId: null, expandedFolderIds: new Set(), + pendingCreateNode: null, dirtyContents: new Map(), fileMetadata: new Map>(), dirtyMetadataIds: new Set(), diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts index d1678a3b9c..e089037120 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -15,6 +15,12 @@ export type TabSliceShape = { export type OpensObject = Record +export type PendingCreateNode = { + id: string + parentId: string | null + nodeType: 'file' | 'folder' +} + export type FileTreeSliceShape = { expandedFolderIds: Set setExpandedFolderIds: (ids: Set) => 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 = {