mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
refactor(skill): regroup skill body, file tree, and tree hooks
This commit is contained in:
@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
type UseDelayedClickOptions = {
|
||||
delay?: number
|
||||
onSingleClick: () => void
|
||||
onDoubleClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to distinguish between single-click and double-click events.
|
||||
* Single-click is delayed to allow double-click detection.
|
||||
* Double-click cancels any pending single-click.
|
||||
*/
|
||||
export function useDelayedClick({
|
||||
delay = 200,
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}: UseDelayedClickOptions) {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Cleanup timeout on unmount to prevent state updates on unmounted components
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current)
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (timeoutRef.current)
|
||||
clearTimeout(timeoutRef.current)
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onSingleClick()
|
||||
timeoutRef.current = null
|
||||
}, delay)
|
||||
}, [delay, onSingleClick])
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
onDoubleClick()
|
||||
}, [onDoubleClick])
|
||||
|
||||
return { handleClick, handleDoubleClick }
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { START_TAB_ID } from '../../../constants'
|
||||
import { useInlineCreateNode } from './use-inline-create-node'
|
||||
|
||||
const {
|
||||
mockUploadMutateAsync,
|
||||
mockCreateFolderMutateAsync,
|
||||
mockRenameMutateAsync,
|
||||
mockEmitTreeUpdate,
|
||||
mockToastNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
mockCreateFolderMutateAsync: vi.fn(),
|
||||
mockRenameMutateAsync: vi.fn(),
|
||||
mockEmitTreeUpdate: vi.fn(),
|
||||
mockToastNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useUploadFileWithPresignedUrl: () => ({
|
||||
mutateAsync: mockUploadMutateAsync,
|
||||
}),
|
||||
useCreateAppAssetFolder: () => ({
|
||||
mutateAsync: mockCreateFolderMutateAsync,
|
||||
}),
|
||||
useRenameAppAssetNode: () => ({
|
||||
mutateAsync: mockRenameMutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mockToastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useInlineCreateNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open created text file tab with editor auto focus intent', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const treeRef = { current: null } as React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
mockUploadMutateAsync.mockResolvedValue({
|
||||
id: 'file-1',
|
||||
extension: 'md',
|
||||
})
|
||||
|
||||
store.getState().startCreateNode('file', null)
|
||||
const pendingId = store.getState().pendingCreateNode?.id as string
|
||||
|
||||
const { result } = renderHook(() => useInlineCreateNode({
|
||||
treeRef,
|
||||
treeChildren: [],
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: pendingId,
|
||||
name: 'README.md',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().activeTabId).toBe('file-1')
|
||||
expect(store.getState().editorAutoFocusFileId).toBe('file-1')
|
||||
expect(store.getState().openTabIds).toEqual(['file-1'])
|
||||
expect(store.getState().pendingCreateNode).toBeNull()
|
||||
})
|
||||
|
||||
it('should not open tab for non-text-like created files', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const treeRef = { current: null } as React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
mockUploadMutateAsync.mockResolvedValue({
|
||||
id: 'file-2',
|
||||
extension: 'png',
|
||||
})
|
||||
|
||||
store.getState().startCreateNode('file', null)
|
||||
const pendingId = store.getState().pendingCreateNode?.id as string
|
||||
|
||||
const { result } = renderHook(() => useInlineCreateNode({
|
||||
treeRef,
|
||||
treeChildren: [],
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: pendingId,
|
||||
name: 'image.png',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().activeTabId).toBe(START_TAB_ID)
|
||||
expect(store.getState().editorAutoFocusFileId).toBeNull()
|
||||
expect(store.getState().openTabIds).toEqual([])
|
||||
expect(store.getState().pendingCreateNode).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,178 @@
|
||||
'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 {
|
||||
useCreateAppAssetFolder,
|
||||
useRenameAppAssetNode,
|
||||
useUploadFileWithPresignedUrl,
|
||||
} from '@/service/use-app-asset'
|
||||
import { getFileExtension, isTextLikeFile } from '../../../utils/file-utils'
|
||||
import { createDraftTreeNode, insertDraftTreeNode } from '../../../utils/tree-utils'
|
||||
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
|
||||
|
||||
type UseInlineCreateNodeOptions = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | 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 uploadFile = useUploadFileWithPresignedUrl()
|
||||
const createFolder = useCreateAppAssetFolder()
|
||||
const renameNode = useRenameAppAssetNode()
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
emitTreeUpdate()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderCreated'),
|
||||
})
|
||||
}
|
||||
else {
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], trimmedName)
|
||||
const createdFile = await uploadFile.mutateAsync({
|
||||
appId,
|
||||
file,
|
||||
parentId: pendingCreateParentId,
|
||||
})
|
||||
emitTreeUpdate()
|
||||
const extension = getFileExtension(trimmedName, createdFile.extension)
|
||||
if (isTextLikeFile(extension))
|
||||
storeApi.getState().openTab(createdFile.id, { pinned: true, autoFocusEditor: true })
|
||||
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(() => {
|
||||
emitTreeUpdate()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.renamed'),
|
||||
})
|
||||
}).catch(() => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.renameError'),
|
||||
})
|
||||
})
|
||||
}, [
|
||||
appId,
|
||||
uploadFile,
|
||||
createFolder,
|
||||
pendingCreateId,
|
||||
pendingCreateParentId,
|
||||
pendingCreateType,
|
||||
renameNode,
|
||||
storeApi,
|
||||
t,
|
||||
emitTreeUpdate,
|
||||
])
|
||||
|
||||
const searchMatch = useCallback(
|
||||
(node: NodeApi<TreeNodeData>, 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,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
isEventTargetInputArea,
|
||||
} from '@/app/components/workflow/utils/common'
|
||||
|
||||
type UseSkillShortcutsOptions = {
|
||||
treeRef: RefObject<TreeApi<TreeNodeData> | null>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const TREE_CONTAINER_SELECTOR = '[data-skill-tree-container]'
|
||||
|
||||
export function useSkillShortcuts({
|
||||
treeRef,
|
||||
enabled = true,
|
||||
}: UseSkillShortcutsOptions): void {
|
||||
const storeApi = useWorkflowStore()
|
||||
const enabledRef = useRef(enabled)
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled
|
||||
}, [enabled])
|
||||
|
||||
const shouldHandle = useCallback((e: KeyboardEvent) => {
|
||||
if (!enabledRef.current)
|
||||
return false
|
||||
if (isEventTargetInputArea(e.target as HTMLElement))
|
||||
return false
|
||||
const target = e.target as HTMLElement
|
||||
const isInTreeContainer = target.closest(TREE_CONTAINER_SELECTOR) !== null
|
||||
const hasSelection = (treeRef.current?.selectedNodes.length ?? 0) > 0
|
||||
return isInTreeContainer || hasSelection
|
||||
}, [treeRef])
|
||||
|
||||
const getSelectedNodeIds = useCallback(() => {
|
||||
const tree = treeRef.current
|
||||
if (!tree)
|
||||
return []
|
||||
return tree.selectedNodes.map(n => n.id)
|
||||
}, [treeRef])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.x`, (e) => {
|
||||
if (shouldHandle(e)) {
|
||||
const nodeIds = getSelectedNodeIds()
|
||||
if (nodeIds.length > 0) {
|
||||
e.preventDefault()
|
||||
storeApi.getState().cutNodes(nodeIds)
|
||||
}
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
|
||||
if (shouldHandle(e) && storeApi.getState().hasClipboard()) {
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new CustomEvent('skill:paste'))
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { START_TAB_ID } from '../../../constants'
|
||||
import { useSyncTreeWithActiveTab } from './use-sync-tree-with-active-tab'
|
||||
|
||||
type MockTreeNode = {
|
||||
id: string
|
||||
isRoot: boolean
|
||||
parent: MockTreeNode | null
|
||||
isOpen?: boolean
|
||||
isSelected?: boolean
|
||||
isFocused?: boolean
|
||||
}
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const createTreeRef = (tree: unknown): RefObject<TreeApi<TreeNodeData> | null> => {
|
||||
return { current: tree as TreeApi<TreeNodeData> }
|
||||
}
|
||||
|
||||
describe('useSyncTreeWithActiveTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('should clear tree selection when active tab is start tab', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const deselectAll = vi.fn()
|
||||
const selectedNodes = [{ id: 'file-1' }] as unknown as TreeApi<TreeNodeData>['selectedNodes']
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes,
|
||||
deselectAll,
|
||||
get: vi.fn(),
|
||||
openParents: vi.fn(),
|
||||
select: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: START_TAB_ID,
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(deselectAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reveal ancestors and select active file node when node exists', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const openParents = vi.fn()
|
||||
const select = vi.fn()
|
||||
|
||||
const root: MockTreeNode = { id: 'root', isRoot: true, parent: null }
|
||||
const folderA: MockTreeNode = { id: 'folder-a', isRoot: false, parent: root, isOpen: false }
|
||||
const folderB: MockTreeNode = { id: 'folder-b', isRoot: false, parent: folderA, isOpen: false }
|
||||
const fileNode: MockTreeNode = {
|
||||
id: 'file-1',
|
||||
isRoot: false,
|
||||
parent: folderB,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
}
|
||||
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(() => fileNode),
|
||||
openParents,
|
||||
select,
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'file-1',
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(openParents).toHaveBeenCalledWith(fileNode)
|
||||
expect(select).toHaveBeenCalledWith('file-1')
|
||||
expect(store.getState().expandedFolderIds.has('folder-a')).toBe(true)
|
||||
expect(store.getState().expandedFolderIds.has('folder-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('should skip select when node is already selected even when tree focus is lost', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const openParents = vi.fn()
|
||||
const select = vi.fn()
|
||||
|
||||
const root: MockTreeNode = { id: 'root', isRoot: true, parent: null }
|
||||
const fileNode: MockTreeNode = {
|
||||
id: 'file-1',
|
||||
isRoot: false,
|
||||
parent: root,
|
||||
isSelected: true,
|
||||
isFocused: false,
|
||||
}
|
||||
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(() => fileNode),
|
||||
openParents,
|
||||
select,
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'file-1',
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(openParents).not.toHaveBeenCalled()
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should retry syncing on syncSignal change when node appears later', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const select = vi.fn()
|
||||
let node: MockTreeNode | undefined
|
||||
|
||||
const root: MockTreeNode = { id: 'root', isRoot: true, parent: null }
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(() => node),
|
||||
openParents: vi.fn(),
|
||||
select,
|
||||
})
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ syncSignal }) => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'file-1',
|
||||
syncSignal,
|
||||
isTreeLoading: false,
|
||||
}),
|
||||
{
|
||||
initialProps: { syncSignal: 1 },
|
||||
wrapper: createWrapper(store),
|
||||
},
|
||||
)
|
||||
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
|
||||
node = {
|
||||
id: 'file-1',
|
||||
isRoot: false,
|
||||
parent: root,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
}
|
||||
rerender({ syncSignal: 2 })
|
||||
|
||||
expect(select).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { useEffect } from 'react'
|
||||
import { isArtifactTab, START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type UseSyncTreeWithActiveTabOptions = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
activeTabId: string | null
|
||||
syncSignal?: number
|
||||
isTreeLoading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that synchronizes the file tree with the active tab.
|
||||
* Expands ancestor folders and scrolls to the active node.
|
||||
*
|
||||
* Uses node.parent chain for efficient ancestor traversal instead of
|
||||
* re-traversing the tree data structure.
|
||||
*/
|
||||
export function useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId,
|
||||
syncSignal,
|
||||
isTreeLoading,
|
||||
}: UseSyncTreeWithActiveTabOptions): void {
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || isTreeLoading)
|
||||
return
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const tree = treeRef.current
|
||||
if (!tree)
|
||||
return
|
||||
|
||||
if (activeTabId === START_TAB_ID || isArtifactTab(activeTabId)) {
|
||||
if (tree.selectedNodes.length > 0)
|
||||
tree.deselectAll()
|
||||
return
|
||||
}
|
||||
|
||||
const node = tree.get(activeTabId)
|
||||
if (!node)
|
||||
return
|
||||
|
||||
const ancestors: string[] = []
|
||||
let current = node.parent
|
||||
while (current && !current.isRoot) {
|
||||
ancestors.push(current.id)
|
||||
current = current.parent
|
||||
}
|
||||
|
||||
if (ancestors.length > 0)
|
||||
storeApi.getState().revealFile(ancestors)
|
||||
|
||||
let hasClosedAncestor = false
|
||||
current = node.parent
|
||||
while (current && !current.isRoot) {
|
||||
if (!current.isOpen) {
|
||||
hasClosedAncestor = true
|
||||
break
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
if (hasClosedAncestor)
|
||||
tree.openParents(node)
|
||||
|
||||
if (!node.isSelected)
|
||||
tree.select(activeTabId)
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [activeTabId, isTreeLoading, storeApi, syncSignal, treeRef])
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { throttle } from 'es-toolkit/function'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useDelayedClick } from './use-delayed-click'
|
||||
|
||||
type UseTreeNodeHandlersOptions = {
|
||||
node: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
type UseTreeNodeHandlersReturn = {
|
||||
handleClick: (e: React.MouseEvent) => void
|
||||
handleDoubleClick: (e: React.MouseEvent) => void
|
||||
handleToggle: (e: React.MouseEvent) => void
|
||||
handleContextMenu: (e: React.MouseEvent) => void
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||
}
|
||||
|
||||
export function useTreeNodeHandlers({
|
||||
node,
|
||||
}: UseTreeNodeHandlersOptions): UseTreeNodeHandlersReturn {
|
||||
const storeApi = useWorkflowStore()
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const nodeRef = useRef(node)
|
||||
nodeRef.current = node
|
||||
|
||||
const throttledToggle = useMemo(
|
||||
() => throttle(() => nodeRef.current.toggle(), 300, { edges: ['leading'] }),
|
||||
[],
|
||||
)
|
||||
|
||||
const openFilePreview = useCallback(() => {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: false })
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
const openFilePinned = useCallback(() => {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: true })
|
||||
}, [node.data.id, storeApi])
|
||||
|
||||
const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({
|
||||
onSingleClick: openFilePreview,
|
||||
onDoubleClick: openFilePinned,
|
||||
})
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (e.shiftKey)
|
||||
node.selectContiguous()
|
||||
else if (e.metaKey || e.ctrlKey)
|
||||
node.selectMulti()
|
||||
else
|
||||
node.select()
|
||||
|
||||
if (isFolder)
|
||||
throttledToggle()
|
||||
else if (!e.metaKey && !e.ctrlKey && !e.shiftKey)
|
||||
handleFileClick()
|
||||
}, [handleFileClick, isFolder, node, throttledToggle])
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isFolder)
|
||||
throttledToggle()
|
||||
else
|
||||
handleFileDoubleClick()
|
||||
}, [isFolder, throttledToggle, handleFileDoubleClick])
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
throttledToggle()
|
||||
}, [throttledToggle])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
node.select()
|
||||
storeApi.getState().setContextMenu({
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
type: 'node',
|
||||
nodeId: node.data.id,
|
||||
isFolder,
|
||||
})
|
||||
}, [isFolder, node, storeApi])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
}
|
||||
else {
|
||||
storeApi.getState().clearArtifactSelection()
|
||||
storeApi.getState().openTab(node.data.id, { pinned: true })
|
||||
}
|
||||
}
|
||||
}, [isFolder, node, storeApi])
|
||||
|
||||
return {
|
||||
handleClick,
|
||||
handleDoubleClick,
|
||||
handleToggle,
|
||||
handleContextMenu,
|
||||
handleKeyDown,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user