From 799d0c0d0b31adae7b3d76216a2ca122312eeacc Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 6 Feb 2026 14:21:16 +0800 Subject: [PATCH] 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. --- .../skill/editor/code-file-editor.tsx | 12 +- .../skill/editor/markdown-file-editor.tsx | 6 + .../skill/editor/skill-editor/index.tsx | 26 +++ .../workflow/skill/file-content-panel.tsx | 16 +- .../workflow/skill/file-tree/index.tsx | 4 +- .../workflow/skill/file-tree/tree-node.tsx | 2 + .../hooks/use-inline-create-node.spec.tsx | 123 +++++++++++++ .../skill/hooks/use-inline-create-node.ts | 2 +- .../use-sync-tree-with-active-tab.spec.tsx | 170 ++++++++++++++++++ .../hooks/use-sync-tree-with-active-tab.ts | 46 +++-- .../skill/viewer/pdf-file-preview.tsx | 5 +- .../store/workflow/skill-editor/index.ts | 1 + .../workflow/skill-editor/tab-slice.spec.ts | 54 ++++++ .../store/workflow/skill-editor/tab-slice.ts | 35 +++- .../store/workflow/skill-editor/types.ts | 3 + web/eslint-suppressions.json | 3 - 16 files changed, 477 insertions(+), 31 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-inline-create-node.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.spec.tsx create mode 100644 web/app/components/workflow/store/workflow/skill-editor/tab-slice.spec.ts diff --git a/web/app/components/workflow/skill/editor/code-file-editor.tsx b/web/app/components/workflow/skill/editor/code-file-editor.tsx index 216576e1d4..2f58f571e6 100644 --- a/web/app/components/workflow/skill/editor/code-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/code-file-editor.tsx @@ -10,6 +10,8 @@ type CodeFileEditorProps = { value: string onChange: (value: string | undefined) => void onMount: OnMount + autoFocus?: boolean + onAutoFocus?: () => void fileId?: string | null collaborationEnabled?: boolean readOnly?: boolean @@ -21,6 +23,8 @@ const CodeFileEditor = ({ value, onChange, onMount, + autoFocus = false, + onAutoFocus, fileId, collaborationEnabled, readOnly, @@ -34,7 +38,13 @@ const CodeFileEditor = ({ const handleMount = React.useCallback((editor, monaco) => { setEditorInstance(editor) onMount(editor, monaco) - }, [onMount]) + if (autoFocus && !readOnly) { + requestAnimationFrame(() => { + editor.focus() + onAutoFocus?.() + }) + } + }, [autoFocus, onAutoFocus, onMount, readOnly]) return (
diff --git a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx index a61f139123..9f3e3fe43f 100644 --- a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx @@ -6,6 +6,8 @@ type MarkdownFileEditorProps = { instanceId?: string value: string onChange: (value: string) => void + autoFocus?: boolean + onAutoFocus?: () => void collaborationEnabled?: boolean readOnly?: boolean } @@ -14,6 +16,8 @@ const MarkdownFileEditor = ({ instanceId, value, onChange, + autoFocus = false, + onAutoFocus, collaborationEnabled, readOnly, }: MarkdownFileEditorProps) => { @@ -31,6 +35,8 @@ const MarkdownFileEditor = ({ value={value} onChange={handleChange} editable={!readOnly} + autoFocus={!readOnly && autoFocus} + onAutoFocus={onAutoFocus} collaborationEnabled={readOnly ? false : collaborationEnabled} showLineNumbers className="h-full" diff --git a/web/app/components/workflow/skill/editor/skill-editor/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/index.tsx index dc32ff8f0d..834c019ca9 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/index.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/index.tsx @@ -3,6 +3,7 @@ import type { EditorState } from 'lexical' import { CodeNode } from '@lexical/code' import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' @@ -45,13 +46,35 @@ export type SkillEditorProps = { style?: React.CSSProperties value?: string editable?: boolean + autoFocus?: boolean collaborationEnabled?: boolean onChange?: (text: string) => void onBlur?: () => void onFocus?: () => void + onAutoFocus?: () => void toolPickerScope?: string } +type EditorAutoFocusPluginProps = { + onAutoFocus?: () => void +} + +const EditorAutoFocusPlugin = ({ onAutoFocus }: EditorAutoFocusPluginProps) => { + const [editor] = useLexicalComposerContext() + + React.useEffect(() => { + editor.focus(() => { + const activeElement = document.activeElement + const rootElement = editor.getRootElement() + if (rootElement !== null && (activeElement === null || !rootElement.contains(activeElement))) + rootElement.focus({ preventScroll: true }) + onAutoFocus?.() + }) + }, [editor, onAutoFocus]) + + return null +} + const SkillEditor = ({ instanceId, compact, @@ -63,10 +86,12 @@ const SkillEditor = ({ style, value, editable = true, + autoFocus = false, collaborationEnabled, onChange, onBlur, onFocus, + onAutoFocus, toolPickerScope = 'all', }: SkillEditorProps) => { const initialConfig = { @@ -137,6 +162,7 @@ const SkillEditor = ({ {editable && } + {editable && autoFocus && } diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index ec2f0825a5..60ed079486 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -62,12 +62,12 @@ const FileContentPanel = () => { const { t } = useTranslation('workflow') const { theme: appTheme } = useTheme() const [isMounted, setIsMounted] = useState(false) - const editorRef = useRef[0] | null>(null) const appDetail = useAppStore(s => s.appDetail) const appId = appDetail?.id || '' const activeTabId = useStore(s => s.activeTabId) + const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId) const storeApi = useWorkflowStore() const { data: nodeMap } = useSkillAssetNodeMap() @@ -79,6 +79,7 @@ const FileContentPanel = () => { const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false) const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined + const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId) const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(currentFileNode) @@ -199,8 +200,13 @@ const FileContentPanel = () => { } }, [fileTabId, isEditable]) - const handleEditorDidMount: OnMount = useCallback((editor, monaco) => { - editorRef.current = editor + const handleEditorAutoFocus = useCallback(() => { + if (!fileTabId) + return + storeApi.getState().clearEditorAutoFocus(fileTabId) + }, [fileTabId, storeApi]) + + const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => { monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') setIsMounted(true) }, [appTheme]) @@ -273,6 +279,8 @@ const FileContentPanel = () => { instanceId={fileTabId || undefined} value={currentContent} onChange={handleMarkdownCollaborativeChange} + autoFocus={shouldAutoFocusEditor} + onAutoFocus={handleEditorAutoFocus} collaborationEnabled={canInitCollaboration} /> ) @@ -286,6 +294,8 @@ const FileContentPanel = () => { value={currentContent} onChange={handleCodeCollaborativeChange} onMount={handleEditorDidMount} + autoFocus={shouldAutoFocusEditor} + onAutoFocus={handleEditorAutoFocus} fileId={fileTabId} collaborationEnabled={canInitCollaboration} /> diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index d801b907b2..24b4c8b3ea 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -96,7 +96,7 @@ const FileTree = ({ className }: FileTreeProps) => { const containerSize = useSize(containerRef) const dragInsertTargetRef = useRef(null) - const { data: treeData, isLoading, error } = useSkillAssetTreeData() + const { data: treeData, isLoading, error, dataUpdatedAt } = useSkillAssetTreeData() const isMutating = useIsMutating() > 0 const expandedFolderIds = useStore(s => s.expandedFolderIds) @@ -304,6 +304,8 @@ const FileTree = ({ className }: FileTreeProps) => { useSyncTreeWithActiveTab({ treeRef, activeTabId, + syncSignal: dataUpdatedAt, + isTreeLoading: isLoading, }) useSkillShortcuts({ treeRef }) diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index fada40afe1..3ee6d36d4b 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -28,6 +28,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => { const { t } = useTranslation('workflow') const isFolder = node.data.node_type === 'folder' const isSelected = node.isSelected + const isFocused = node.isFocused const isDirty = useStore(s => s.dirtyContents.has(node.data.id)) const isCut = useStore(s => s.isCutNode(node.data.id)) const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId) @@ -100,6 +101,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => { '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', + isFocused && 'ring-2 ring-inset ring-components-input-border-active', hasContextMenu && !isSelected && 'bg-state-base-hover', isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid', isBlinking && 'animate-drag-blink', diff --git a/web/app/components/workflow/skill/hooks/use-inline-create-node.spec.tsx b/web/app/components/workflow/skill/hooks/use-inline-create-node.spec.tsx new file mode 100644 index 0000000000..b917465434 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-inline-create-node.spec.tsx @@ -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) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useInlineCreateNode', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + }) + + it('should open created text file tab with editor auto focus intent', async () => { + const store = createWorkflowStore({}) + const treeRef = { current: null } as React.RefObject | 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 | 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() + }) +}) diff --git a/web/app/components/workflow/skill/hooks/use-inline-create-node.ts b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts index af78fa1631..c689ecf53f 100644 --- a/web/app/components/workflow/skill/hooks/use-inline-create-node.ts +++ b/web/app/components/workflow/skill/hooks/use-inline-create-node.ts @@ -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'), diff --git a/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.spec.tsx b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.spec.tsx new file mode 100644 index 0000000000..686aaf0c39 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.spec.tsx @@ -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) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const createTreeRef = (tree: unknown): RefObject | null> => { + return { current: tree as TreeApi } +} + +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['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') + }) +}) diff --git a/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts index 96dccd4bf8..d628668787 100644 --- a/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts +++ b/web/app/components/workflow/skill/hooks/use-sync-tree-with-active-tab.ts @@ -9,6 +9,8 @@ import { useWorkflowStore } from '@/app/components/workflow/store' type UseSyncTreeWithActiveTabOptions = { treeRef: React.RefObject | 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]) } diff --git a/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx b/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx index 5cb7b95874..161937858a 100644 --- a/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx +++ b/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx @@ -51,7 +51,10 @@ const PdfFilePreview = ({ downloadUrl }: PdfFilePreviewProps) => {
= (...a openTabIds: [], activeTabId: START_TAB_ID, previewTabId: null, + editorAutoFocusFileId: null, expandedFolderIds: new Set(), selectedTreeNodeId: null, selectedNodeIds: new Set(), diff --git a/web/app/components/workflow/store/workflow/skill-editor/tab-slice.spec.ts b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.spec.ts new file mode 100644 index 0000000000..888f421933 --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.spec.ts @@ -0,0 +1,54 @@ +import type { SkillEditorSliceShape } from './types' +import { createStore } from 'zustand/vanilla' +import { START_TAB_ID } from '@/app/components/workflow/skill/constants' +import { createSkillEditorSlice } from './index' + +const createSkillEditorStore = () => { + return createStore()((...args) => ({ + ...createSkillEditorSlice(...args), + })) +} + +describe('tab slice editor auto focus intent', () => { + it('should set editorAutoFocusFileId when opening a tab with autoFocusEditor', () => { + const store = createSkillEditorStore() + + store.getState().openTab('file-1', { pinned: true, autoFocusEditor: true }) + + expect(store.getState().activeTabId).toBe('file-1') + expect(store.getState().openTabIds).toEqual(['file-1']) + expect(store.getState().editorAutoFocusFileId).toBe('file-1') + }) + + it('should preserve existing editor auto focus intent when opening another tab without auto focus', () => { + const store = createSkillEditorStore() + + store.getState().openTab('file-1', { pinned: true, autoFocusEditor: true }) + store.getState().openTab('file-2', { pinned: true }) + + expect(store.getState().activeTabId).toBe('file-2') + expect(store.getState().openTabIds).toEqual(['file-1', 'file-2']) + expect(store.getState().editorAutoFocusFileId).toBe('file-1') + }) + + it('should clear editor auto focus intent only for matching file id', () => { + const store = createSkillEditorStore() + + store.getState().openTab('file-1', { pinned: true, autoFocusEditor: true }) + store.getState().clearEditorAutoFocus('file-2') + expect(store.getState().editorAutoFocusFileId).toBe('file-1') + + store.getState().clearEditorAutoFocus('file-1') + expect(store.getState().editorAutoFocusFileId).toBeNull() + }) + + it('should clear editor auto focus intent when the focused file tab is closed', () => { + const store = createSkillEditorStore() + + store.getState().openTab('file-1', { pinned: true, autoFocusEditor: true }) + store.getState().closeTab('file-1') + + expect(store.getState().activeTabId).toBe(START_TAB_ID) + expect(store.getState().editorAutoFocusFileId).toBeNull() + }) +}) diff --git a/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts index fc7f6fec0c..7488498afd 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts @@ -13,16 +13,28 @@ export const createTabSlice: StateCreator< openTabIds: [], activeTabId: START_TAB_ID, previewTabId: null, + editorAutoFocusFileId: null, openTab: (fileId: string, options?: OpenTabOptions) => { - const { openTabIds, activeTabId, previewTabId } = get() + const { openTabIds, activeTabId, previewTabId, editorAutoFocusFileId } = get() const isPinned = options?.pinned ?? false + const autoFocusEditor = options?.autoFocusEditor ?? false if (openTabIds.includes(fileId)) { - if (isPinned && previewTabId === fileId) - set({ activeTabId: fileId, previewTabId: null }) - else if (activeTabId !== fileId) - set({ activeTabId: fileId }) + const nextState: Partial = {} + if (isPinned && previewTabId === fileId) { + nextState.activeTabId = fileId + nextState.previewTabId = null + } + else if (activeTabId !== fileId) { + nextState.activeTabId = fileId + } + + if (autoFocusEditor) + nextState.editorAutoFocusFileId = fileId + + if (Object.keys(nextState).length > 0) + set(nextState) return } @@ -35,18 +47,20 @@ export const createTabSlice: StateCreator< openTabIds: [...newOpenTabIds, fileId], activeTabId: fileId, previewTabId: fileId, + editorAutoFocusFileId: autoFocusEditor ? fileId : editorAutoFocusFileId, }) } else { set({ openTabIds: [...newOpenTabIds, fileId], activeTabId: fileId, + editorAutoFocusFileId: autoFocusEditor ? fileId : editorAutoFocusFileId, }) } }, closeTab: (fileId: string) => { - const { openTabIds, activeTabId, previewTabId } = get() + const { openTabIds, activeTabId, previewTabId, editorAutoFocusFileId } = get() const newOpenTabIds = openTabIds.filter(id => id !== fileId) let newActiveTabId = activeTabId @@ -66,6 +80,7 @@ export const createTabSlice: StateCreator< openTabIds: newOpenTabIds, activeTabId: newActiveTabId, previewTabId: newPreviewTabId, + editorAutoFocusFileId: editorAutoFocusFileId === fileId ? null : editorAutoFocusFileId, }) }, @@ -83,6 +98,14 @@ export const createTabSlice: StateCreator< set({ previewTabId: null }) }, + clearEditorAutoFocus: (fileId?: string) => { + const { editorAutoFocusFileId } = get() + if (!editorAutoFocusFileId) + return + if (!fileId || editorAutoFocusFileId === fileId) + set({ editorAutoFocusFileId: null }) + }, + isPreviewTab: (fileId: string) => { return get().previewTabId === fileId }, diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts index f79cd9e834..0806d0558d 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -2,16 +2,19 @@ import type { ContextMenuType } from '@/app/components/workflow/skill/constants' export type OpenTabOptions = { pinned?: boolean + autoFocusEditor?: boolean } export type TabSliceShape = { openTabIds: string[] activeTabId: string | null previewTabId: string | null + editorAutoFocusFileId: string | null openTab: (fileId: string, options?: OpenTabOptions) => void closeTab: (fileId: string) => void activateTab: (fileId: string) => void pinTab: (fileId: string) => void + clearEditorAutoFocus: (fileId?: string) => void isPreviewTab: (fileId: string) => boolean } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index bf0805206f..e799e7b7e3 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3328,9 +3328,6 @@ } }, "app/components/workflow/nodes/code/use-config.ts": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 2 - }, "regexp/no-useless-assertions": { "count": 2 },