feat(skill-editor): auto-focus editor on file creation and improve tree-tab sync

Add editorAutoFocusFileId state to automatically focus the editor when
a new text file is created. Improve tree-tab synchronization by adding
syncSignal/isTreeLoading guards, deduplicating rAF calls, and skipping
redundant select/openParents operations when the node is already active.
This commit is contained in:
yyh
2026-02-06 14:21:16 +08:00
parent 92c3656fe5
commit 799d0c0d0b
16 changed files with 477 additions and 31 deletions

View File

@ -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('./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()
})
})

View File

@ -90,7 +90,7 @@ export function useInlineCreateNode({
emitTreeUpdate()
const extension = getFileExtension(trimmedName, createdFile.extension)
if (isTextLikeFile(extension))
storeApi.getState().openTab(createdFile.id, { pinned: true })
storeApi.getState().openTab(createdFile.id, { pinned: true, autoFocusEditor: true })
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.fileCreated'),

View File

@ -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')
})
})

View File

@ -9,6 +9,8 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
type UseSyncTreeWithActiveTabOptions = {
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
activeTabId: string | null
syncSignal?: number
isTreeLoading?: boolean
}
/**
@ -21,25 +23,26 @@ type UseSyncTreeWithActiveTabOptions = {
export function useSyncTreeWithActiveTab({
treeRef,
activeTabId,
syncSignal,
isTreeLoading,
}: UseSyncTreeWithActiveTabOptions): void {
const storeApi = useWorkflowStore()
useEffect(() => {
if (!activeTabId)
if (!activeTabId || isTreeLoading)
return
const tree = treeRef.current
if (!tree)
return
const frame = requestAnimationFrame(() => {
const tree = treeRef.current
if (!tree)
return
if (activeTabId === START_TAB_ID || isArtifactTab(activeTabId)) {
requestAnimationFrame(() => {
tree.deselectAll()
})
return
}
if (activeTabId === START_TAB_ID || isArtifactTab(activeTabId)) {
if (tree.selectedNodes.length > 0)
tree.deselectAll()
return
}
requestAnimationFrame(() => {
const node = tree.get(activeTabId)
if (!node)
return
@ -54,9 +57,22 @@ export function useSyncTreeWithActiveTab({
if (ancestors.length > 0)
storeApi.getState().revealFile(ancestors)
tree.openParents(node)
tree.select(activeTabId)
tree.scrollTo(activeTabId)
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)
})
}, [activeTabId, treeRef, storeApi])
return () => cancelAnimationFrame(frame)
}, [activeTabId, isTreeLoading, storeApi, syncSignal, treeRef])
}