mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
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:
190
web/app/components/workflow/skill/file-tree/tree-node.tsx
Normal file
190
web/app/components/workflow/skill/file-tree/tree-node.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeRendererProps } from 'react-arborist'
|
||||
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'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
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 TreeEditInput from './tree-edit-input'
|
||||
import TreeGuideLines from './tree-guide-lines'
|
||||
|
||||
const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const isSelected = node.isSelected
|
||||
const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id))
|
||||
const contextMenuNodeId = useSkillEditorStore(s => s.contextMenu?.nodeId)
|
||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.handleClick(e)
|
||||
}
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!isFolder)
|
||||
node.activate()
|
||||
}
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.toggle()
|
||||
}
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
storeApi.getState().setContextMenu({
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
nodeId: node.data.id,
|
||||
})
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDropdown(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (isFolder)
|
||||
node.toggle()
|
||||
else
|
||||
node.activate()
|
||||
}
|
||||
}, [isFolder, node])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
className={cn(
|
||||
'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',
|
||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<TreeGuideLines level={node.level} />
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isFolder
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleToggle}
|
||||
aria-label={t('skillSidebar.toggleFolder')}
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
{node.isOpen
|
||||
? <RiFolderOpenLine className="size-4 text-text-accent" aria-hidden="true" />
|
||||
: <RiFolderLine className="size-4 text-text-secondary" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<div className="relative flex size-full items-center justify-center">
|
||||
<FileTypeIcon type={fileIconType as FileAppearanceType} size="sm" />
|
||||
{isDirty && (
|
||||
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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"
|
||||
offset={4}
|
||||
open={showDropdown}
|
||||
onOpenChange={setShowDropdown}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleMoreClick}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
||||
'invisible focus-visible:visible group-hover:visible',
|
||||
showDropdown && 'visible',
|
||||
)}
|
||||
aria-label={t('skillSidebar.menu.moreActions')}
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
{isFolder
|
||||
? (
|
||||
<FolderNodeMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FileNodeMenu
|
||||
nodeId={node.data.id}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
node={node}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TreeNode)
|
||||
Reference in New Issue
Block a user