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
This commit is contained in:
yyh
2026-01-19 23:04:18 +08:00
parent 9080607028
commit 31a7db2657
9 changed files with 78 additions and 22 deletions

View File

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

View File

@ -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<FileTreeProps> = ({ 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<FileTreeProps> = ({ className, searchTerm = '' }) => {
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: 'blank',
type: CONTEXT_MENU_TYPE.BLANK,
})
}, [storeApi])

View File

@ -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<NodeMenuProps> = ({
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,

View File

@ -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<TreeApi<TreeNodeData> | 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<TreeContextMenuProps> = ({ treeRef }) => {
const ref = useRef<HTMLDivElement>(null)
const contextMenu = useStore(s => s.contextMenu)
@ -45,8 +40,8 @@ const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
}}
>
<NodeMenu
type={getMenuType(contextMenu)}
nodeId={contextMenu.type === 'blank' ? 'root' : contextMenu.nodeId}
type={getNodeMenuType(contextMenu.type, contextMenu.isFolder)}
nodeId={getMenuNodeId(contextMenu.type, contextMenu.nodeId)}
onClose={handleClose}
treeRef={treeRef}
/>

View File

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

View File

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

View File

@ -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<SidebarSearchAddProps> = ({ onSearchChange }) => {
const targetFolderId = useMemo(() => {
if (!treeChildren)
return 'root'
return ROOT_ID
return getTargetFolderIdFromSelection(selectedTreeNodeId, treeChildren)
}, [selectedTreeNodeId, treeChildren])
const menuOffset = useMemo(() => ({ mainAxis: 4 }), [])

View File

@ -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<string, AppAssetTreeView> {
const map = new Map<string, AppAssetTreeView>()
@ -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 = {

View File

@ -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<string, unknown> | undefined
}
export type ContextMenuType = 'node' | 'blank'
export type ContextMenuState = {
top: number
left: number