From 31a7db26579e379daf98d12d9c085b2729334096 Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 23:04:18 +0800 Subject: [PATCH] refactor(skill): unify root/blank constants and eliminate magic strings - Add constants.ts with ROOT_ID, CONTEXT_MENU_TYPE, NODE_MENU_TYPE - Add root utilities to tree-utils.ts (isRootId, toApiParentId, etc.) - Replace '__root__' with ROOT_ID for consistent root identifier - Replace inline 'blank'/'root' strings with constants - Use NodeMenuType for type-safe menu type props - Remove duplicate ContextMenuType from types.ts, use from constants.ts --- .../components/workflow/skill/constants.ts | 23 +++++++++++ .../workflow/skill/file-tree/index.tsx | 5 ++- .../workflow/skill/file-tree/node-menu.tsx | 8 ++-- .../skill/file-tree/tree-context-menu.tsx | 11 ++---- .../workflow/skill/hooks/use-file-drop.ts | 5 ++- .../skill/hooks/use-file-operations.ts | 3 +- .../workflow/skill/sidebar-search-add.tsx | 3 +- .../workflow/skill/utils/tree-utils.ts | 38 +++++++++++++++++-- .../store/workflow/skill-editor/types.ts | 4 +- 9 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 web/app/components/workflow/skill/constants.ts diff --git a/web/app/components/workflow/skill/constants.ts b/web/app/components/workflow/skill/constants.ts new file mode 100644 index 0000000000..5124649a75 --- /dev/null +++ b/web/app/components/workflow/skill/constants.ts @@ -0,0 +1,23 @@ +/** + * File Tree Constants - Single source of truth for root/blank identifiers + */ + +// Root folder identifier (convert to null for API calls via toApiParentId) +export const ROOT_ID = 'root' as const + +// Context menu trigger types (describes WHERE user clicked) +export const CONTEXT_MENU_TYPE = { + BLANK: 'blank', + NODE: 'node', +} as const + +export type ContextMenuType = (typeof CONTEXT_MENU_TYPE)[keyof typeof CONTEXT_MENU_TYPE] + +// Node menu types (determines which menu options to show) +export const NODE_MENU_TYPE = { + ROOT: 'root', + FOLDER: 'folder', + FILE: 'file', +} as const + +export type NodeMenuType = (typeof NODE_MENU_TYPE)[keyof typeof NODE_MENU_TYPE] diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 654ae4e766..62d8b315bc 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' +import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants' import { useInlineCreateNode } from '../hooks/use-inline-create-node' import { useRootFileDrop } from '../hooks/use-root-file-drop' import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree' @@ -63,7 +64,7 @@ const FileTree: React.FC = ({ className, searchTerm = '' }) => { const storeApi = useWorkflowStore() // Root dropzone highlight (when dragging to root, not to a specific folder) - const isRootDropzone = dragOverFolderId === '__root__' + const isRootDropzone = dragOverFolderId === ROOT_ID useEffect(() => { if (!dragOverFolderId) @@ -114,7 +115,7 @@ const FileTree: React.FC = ({ className, searchTerm = '' }) => { storeApi.getState().setContextMenu({ top: e.clientY, left: e.clientX, - type: 'blank', + type: CONTEXT_MENU_TYPE.BLANK, }) }, [storeApi]) diff --git a/web/app/components/workflow/skill/file-tree/node-menu.tsx b/web/app/components/workflow/skill/file-tree/node-menu.tsx index 6c91285a1a..c9642490ac 100644 --- a/web/app/components/workflow/skill/file-tree/node-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/node-menu.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { NodeApi, TreeApi } from 'react-arborist' +import type { NodeMenuType } from '../constants' import type { TreeNodeData } from '../type' import { RiDeleteBinLine, @@ -15,6 +16,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import Confirm from '@/app/components/base/confirm' import { cn } from '@/utils/classnames' +import { NODE_MENU_TYPE } from '../constants' import { useFileOperations } from '../hooks/use-file-operations' import MenuItem from './menu-item' @@ -24,7 +26,7 @@ export const MENU_CONTAINER_STYLES = [ ] as const type NodeMenuProps = { - type: 'file' | 'folder' | 'root' + type: NodeMenuType nodeId?: string onClose: () => void className?: string @@ -41,8 +43,8 @@ const NodeMenu: FC = ({ node, }) => { const { t } = useTranslation('workflow') - const isRoot = type === 'root' - const isFolder = type === 'folder' || isRoot + const isRoot = type === NODE_MENU_TYPE.ROOT + const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot const { fileInputRef, diff --git a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx index 317dfcfa5a..6c3e5b0dee 100644 --- a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx @@ -7,18 +7,13 @@ import { useClickAway } from 'ahooks' import * as React from 'react' import { useCallback, useRef } from 'react' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { getMenuNodeId, getNodeMenuType } from '../utils/tree-utils' import NodeMenu from './node-menu' type TreeContextMenuProps = { treeRef: React.RefObject | null> } -function getMenuType(contextMenu: { type: string, isFolder?: boolean }): 'root' | 'folder' | 'file' { - if (contextMenu.type === 'blank') - return 'root' - return contextMenu.isFolder ? 'folder' : 'file' -} - const TreeContextMenu: FC = ({ treeRef }) => { const ref = useRef(null) const contextMenu = useStore(s => s.contextMenu) @@ -45,8 +40,8 @@ const TreeContextMenu: FC = ({ treeRef }) => { }} > diff --git a/web/app/components/workflow/skill/hooks/use-file-drop.ts b/web/app/components/workflow/skill/hooks/use-file-drop.ts index ecbd7cc7c8..11b9746567 100644 --- a/web/app/components/workflow/skill/hooks/use-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-file-drop.ts @@ -9,6 +9,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import Toast from '@/app/components/base/toast' import { useWorkflowStore } from '@/app/components/workflow/store' import { useCreateAppAssetFile } from '@/service/use-app-asset' +import { ROOT_ID } from '../constants' type FileDropTarget = { folderId: string | null @@ -32,8 +33,8 @@ export function useFileDrop() { e.dataTransfer.dropEffect = 'copy' - // Use '__root__' to indicate dragging over root (to distinguish from "not dragging") - storeApi.getState().setDragOverFolderId(target.folderId ?? '__root__') + // Use ROOT_ID to indicate dragging over root (to distinguish from null = "not dragging") + storeApi.getState().setDragOverFolderId(target.folderId ?? ROOT_ID) }, [storeApi]) const handleDragLeave = useCallback((e: React.DragEvent) => { 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 11c337abad..78e417e18f 100644 --- a/web/app/components/workflow/skill/hooks/use-file-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-file-operations.ts @@ -7,6 +7,7 @@ import type { NodeApi, TreeApi } from 'react-arborist' import type { TreeNodeData } from '../type' import { useStore as useAppStore } from '@/app/components/app/store' import { useWorkflowStore } from '@/app/components/workflow/store' +import { toApiParentId } from '../utils/tree-utils' import { useCreateOperations } from './use-create-operations' import { useModifyOperations } from './use-modify-operations' import { useSkillAssetTreeData } from './use-skill-asset-tree' @@ -31,7 +32,7 @@ export function useFileOperations({ const storeApi = useWorkflowStore() const { data: treeData } = useSkillAssetTreeData() - const parentId = nodeId === 'root' ? null : nodeId + const parentId = toApiParentId(nodeId) const createOps = useCreateOperations({ parentId, diff --git a/web/app/components/workflow/skill/sidebar-search-add.tsx b/web/app/components/workflow/skill/sidebar-search-add.tsx index 637601fbe0..a7680b3956 100644 --- a/web/app/components/workflow/skill/sidebar-search-add.tsx +++ b/web/app/components/workflow/skill/sidebar-search-add.tsx @@ -21,6 +21,7 @@ import { import SearchInput from '@/app/components/base/search-input' import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' +import { ROOT_ID } from './constants' import { useFileOperations } from './hooks/use-file-operations' import { useSkillAssetTreeData } from './hooks/use-skill-asset-tree' import { getTargetFolderIdFromSelection } from './utils/tree-utils' @@ -70,7 +71,7 @@ const SidebarSearchAdd: FC = ({ onSearchChange }) => { const targetFolderId = useMemo(() => { if (!treeChildren) - return 'root' + return ROOT_ID return getTargetFolderIdFromSelection(selectedTreeNodeId, treeChildren) }, [selectedTreeNodeId, treeChildren]) const menuOffset = useMemo(() => ({ mainAxis: 4 }), []) diff --git a/web/app/components/workflow/skill/utils/tree-utils.ts b/web/app/components/workflow/skill/utils/tree-utils.ts index 1b7db41f44..7f6886cddb 100644 --- a/web/app/components/workflow/skill/utils/tree-utils.ts +++ b/web/app/components/workflow/skill/utils/tree-utils.ts @@ -1,4 +1,36 @@ +import type { ContextMenuType, NodeMenuType } from '../constants' import type { AppAssetTreeView } from '@/types/app-asset' +import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../constants' + +// Root utilities + +export function isRootId(id: string | null | undefined): boolean { + return !id || id === ROOT_ID +} + +export function toApiParentId(folderId: string | null | undefined): string | null { + return isRootId(folderId) ? null : folderId! +} + +export function getNodeMenuType( + contextType: ContextMenuType, + isFolder?: boolean, +): NodeMenuType { + if (contextType === CONTEXT_MENU_TYPE.BLANK) + return NODE_MENU_TYPE.ROOT + return isFolder ? NODE_MENU_TYPE.FOLDER : NODE_MENU_TYPE.FILE +} + +export function getMenuNodeId( + contextType: ContextMenuType, + nodeId?: string, +): string { + return contextType === CONTEXT_MENU_TYPE.BLANK + ? ROOT_ID + : (nodeId ?? ROOT_ID) +} + +// Tree utilities export function buildNodeMap(nodes: AppAssetTreeView[]): Map { const map = new Map() @@ -90,17 +122,17 @@ export function getTargetFolderIdFromSelection( nodes: AppAssetTreeView[], ): string { if (!selectedId) - return 'root' + return ROOT_ID const selectedNode = findNodeById(nodes, selectedId) if (!selectedNode) - return 'root' + return ROOT_ID if (selectedNode.node_type === 'folder') return selectedNode.id const ancestors = getAncestorIds(selectedId, nodes) - return ancestors.length > 0 ? ancestors[ancestors.length - 1] : 'root' + return ancestors.length > 0 ? ancestors[ancestors.length - 1] : ROOT_ID } export type DraftTreeNodeOptions = { 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 31e239b024..dcc6e0bb94 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -1,3 +1,5 @@ +import type { ContextMenuType } from '@/app/components/workflow/skill/constants' + export type OpenTabOptions = { pinned?: boolean } @@ -56,8 +58,6 @@ export type MetadataSliceShape = { getFileMetadata: (fileId: string) => Record | undefined } -export type ContextMenuType = 'node' | 'blank' - export type ContextMenuState = { top: number left: number