feat(skill): add inline rename and guide lines to file tree

Add TreeEditInput component for inline file/folder renaming with keyboard
support (Enter to submit, Escape to cancel). Add TreeGuideLines component
to render vertical indent lines based on node depth for better visual
hierarchy in the tree view.

Reorganize file tree components into dedicated `file-tree` subdirectory
for better code organization.
This commit is contained in:
yyh
2026-01-15 21:21:58 +08:00
parent 2de17cb1a4
commit 783cdb1357
7 changed files with 112 additions and 27 deletions

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { TreeNodeData } from './type'
import type { TreeNodeData } from '../type'
import {
RiDeleteBinLine,
RiEdit2Line,
@ -11,7 +11,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames'
import { useFileOperations } from './hooks/use-file-operations'
import { useFileOperations } from '../hooks/use-file-operations'
type MenuItemProps = {
icon: React.ElementType

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { TreeNodeData } from './type'
import type { TreeNodeData } from '../type'
import {
RiDeleteBinLine,
RiEdit2Line,
@ -15,7 +15,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames'
import { useFileOperations } from './hooks/use-file-operations'
import { useFileOperations } from '../hooks/use-file-operations'
type MenuItemProps = {
icon: React.ElementType

View File

@ -1,8 +1,8 @@
'use client'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { OpensObject } from './store'
import type { TreeNodeData } from './type'
import type { OpensObject } from '../store'
import type { TreeNodeData } from '../type'
import { RiDragDropLine } from '@remixicon/react'
import { useIsMutating } from '@tanstack/react-query'
import { useSize } from 'ahooks'
@ -15,11 +15,11 @@ import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useRenameAppAssetNode } from '@/service/use-app-asset'
import { cn } from '@/utils/classnames'
import { useSkillAssetTreeData } from './hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
import { getAncestorIds } from '../utils/tree-utils'
import TreeContextMenu from './tree-context-menu'
import TreeNode from './tree-node'
import { getAncestorIds } from './utils/tree-utils'
type FileTreeProps = {
className?: string

View File

@ -2,15 +2,15 @@
import type { FC } from 'react'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from './type'
import type { TreeNodeData } from '../type'
import { useClickAway } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
import { findNodeById } from '../utils/tree-utils'
import FileNodeMenu from './file-node-menu'
import FolderNodeMenu from './folder-node-menu'
import { useSkillAssetTreeData } from './hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
import { findNodeById } from './utils/tree-utils'
type TreeContextMenuProps = {
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>

View File

@ -0,0 +1,48 @@
'use client'
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import * as React from 'react'
import { useEffect, useRef } from 'react'
type TreeEditInputProps = {
node: NodeApi<TreeNodeData>
}
const TreeEditInput: React.FC<TreeEditInputProps> = ({ node }) => {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation()
if (e.key === 'Escape') {
node.reset()
}
else if (e.key === 'Enter') {
e.preventDefault()
node.submit(inputRef.current?.value || '')
}
}
const handleBlur = () => {
node.reset()
}
return (
<input
ref={inputRef}
type="text"
defaultValue={node.data.name}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onClick={e => e.stopPropagation()}
className="min-w-0 flex-1 rounded border border-components-input-border-active bg-transparent px-1 text-[13px] font-normal leading-4 text-text-primary outline-none"
/>
)
}
export default React.memo(TreeEditInput)

View File

@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
const INDENT_SIZE = 20
type TreeGuideLinesProps = {
level: number
}
const TreeGuideLines: React.FC<TreeGuideLinesProps> = ({ level }) => {
if (level === 0)
return null
return (
<>
{Array.from({ length: level }).map((_, i) => (
<div
key={`guide-${i}`}
className="absolute bottom-0 top-0 border-l border-divider-subtle"
style={{ left: `${(i + 1) * INDENT_SIZE - 10}px` }}
/>
))}
</>
)
}
export default React.memo(TreeGuideLines)

View File

@ -1,7 +1,7 @@
'use client'
import type { NodeRendererProps } from 'react-arborist'
import type { TreeNodeData } from './type'
import type { TreeNodeData } from '../type'
import type { FileAppearanceType } from '@/app/components/base/file-uploader/types'
import { RiFolderLine, RiFolderOpenLine, RiMoreFill } from '@remixicon/react'
import * as React from 'react'
@ -14,10 +14,12 @@ import {
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
import { getFileIconType } from '../utils/file-utils'
import FileNodeMenu from './file-node-menu'
import FolderNodeMenu from './folder-node-menu'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
import { getFileIconType } from './utils/file-utils'
import TreeEditInput from './tree-edit-input'
import TreeGuideLines from './tree-guide-lines'
const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
const { t } = useTranslation('workflow')
@ -83,7 +85,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
aria-selected={isSelected}
aria-expanded={isFolder ? node.isOpen : undefined}
className={cn(
'group flex h-6 cursor-pointer items-center gap-2 rounded-md px-2',
'group relative flex h-6 cursor-pointer items-center gap-2 rounded-md px-2',
'hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isSelected && 'bg-state-base-active',
@ -94,6 +96,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
>
<TreeGuideLines level={node.level} />
<div className="flex size-5 shrink-0 items-center justify-center">
{isFolder
? (
@ -122,16 +125,22 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
)}
</div>
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
>
{node.data.name}
</span>
{node.isEditing
? (
<TreeEditInput node={node} />
)
: (
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
isSelected
? 'text-text-primary'
: 'text-text-secondary',
)}
>
{node.data.name}
</span>
)}
<PortalToFollowElem
placement="bottom-start"