mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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:
parent
92c3656fe5
commit
799d0c0d0b
@ -10,6 +10,8 @@ type CodeFileEditorProps = {
|
|||||||
value: string
|
value: string
|
||||||
onChange: (value: string | undefined) => void
|
onChange: (value: string | undefined) => void
|
||||||
onMount: OnMount
|
onMount: OnMount
|
||||||
|
autoFocus?: boolean
|
||||||
|
onAutoFocus?: () => void
|
||||||
fileId?: string | null
|
fileId?: string | null
|
||||||
collaborationEnabled?: boolean
|
collaborationEnabled?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
@ -21,6 +23,8 @@ const CodeFileEditor = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onMount,
|
onMount,
|
||||||
|
autoFocus = false,
|
||||||
|
onAutoFocus,
|
||||||
fileId,
|
fileId,
|
||||||
collaborationEnabled,
|
collaborationEnabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
@ -34,7 +38,13 @@ const CodeFileEditor = ({
|
|||||||
const handleMount = React.useCallback<OnMount>((editor, monaco) => {
|
const handleMount = React.useCallback<OnMount>((editor, monaco) => {
|
||||||
setEditorInstance(editor)
|
setEditorInstance(editor)
|
||||||
onMount(editor, monaco)
|
onMount(editor, monaco)
|
||||||
}, [onMount])
|
if (autoFocus && !readOnly) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor.focus()
|
||||||
|
onAutoFocus?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [autoFocus, onAutoFocus, onMount, readOnly])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
|
|||||||
@ -6,6 +6,8 @@ type MarkdownFileEditorProps = {
|
|||||||
instanceId?: string
|
instanceId?: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
autoFocus?: boolean
|
||||||
|
onAutoFocus?: () => void
|
||||||
collaborationEnabled?: boolean
|
collaborationEnabled?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
@ -14,6 +16,8 @@ const MarkdownFileEditor = ({
|
|||||||
instanceId,
|
instanceId,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
autoFocus = false,
|
||||||
|
onAutoFocus,
|
||||||
collaborationEnabled,
|
collaborationEnabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: MarkdownFileEditorProps) => {
|
}: MarkdownFileEditorProps) => {
|
||||||
@ -31,6 +35,8 @@ const MarkdownFileEditor = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
editable={!readOnly}
|
editable={!readOnly}
|
||||||
|
autoFocus={!readOnly && autoFocus}
|
||||||
|
onAutoFocus={onAutoFocus}
|
||||||
collaborationEnabled={readOnly ? false : collaborationEnabled}
|
collaborationEnabled={readOnly ? false : collaborationEnabled}
|
||||||
showLineNumbers
|
showLineNumbers
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import type { EditorState } from 'lexical'
|
import type { EditorState } from 'lexical'
|
||||||
import { CodeNode } from '@lexical/code'
|
import { CodeNode } from '@lexical/code'
|
||||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||||
@ -45,13 +46,35 @@ export type SkillEditorProps = {
|
|||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
value?: string
|
value?: string
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
collaborationEnabled?: boolean
|
collaborationEnabled?: boolean
|
||||||
onChange?: (text: string) => void
|
onChange?: (text: string) => void
|
||||||
onBlur?: () => void
|
onBlur?: () => void
|
||||||
onFocus?: () => void
|
onFocus?: () => void
|
||||||
|
onAutoFocus?: () => void
|
||||||
toolPickerScope?: string
|
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 = ({
|
const SkillEditor = ({
|
||||||
instanceId,
|
instanceId,
|
||||||
compact,
|
compact,
|
||||||
@ -63,10 +86,12 @@ const SkillEditor = ({
|
|||||||
style,
|
style,
|
||||||
value,
|
value,
|
||||||
editable = true,
|
editable = true,
|
||||||
|
autoFocus = false,
|
||||||
collaborationEnabled,
|
collaborationEnabled,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
onAutoFocus,
|
||||||
toolPickerScope = 'all',
|
toolPickerScope = 'all',
|
||||||
}: SkillEditorProps) => {
|
}: SkillEditorProps) => {
|
||||||
const initialConfig = {
|
const initialConfig = {
|
||||||
@ -137,6 +162,7 @@ const SkillEditor = ({
|
|||||||
{editable && <ToolPickerBlock scope={toolPickerScope} />}
|
{editable && <ToolPickerBlock scope={toolPickerScope} />}
|
||||||
</>
|
</>
|
||||||
<OnChangePlugin onChange={handleEditorChange} />
|
<OnChangePlugin onChange={handleEditorChange} />
|
||||||
|
{editable && autoFocus && <EditorAutoFocusPlugin onAutoFocus={onAutoFocus} />}
|
||||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||||
<UpdateBlock instanceId={instanceId} />
|
<UpdateBlock instanceId={instanceId} />
|
||||||
<LocalCursorPlugin fileId={instanceId} enabled={collaborationEnabled} />
|
<LocalCursorPlugin fileId={instanceId} enabled={collaborationEnabled} />
|
||||||
|
|||||||
@ -62,12 +62,12 @@ const FileContentPanel = () => {
|
|||||||
const { t } = useTranslation('workflow')
|
const { t } = useTranslation('workflow')
|
||||||
const { theme: appTheme } = useTheme()
|
const { theme: appTheme } = useTheme()
|
||||||
const [isMounted, setIsMounted] = useState(false)
|
const [isMounted, setIsMounted] = useState(false)
|
||||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null)
|
|
||||||
|
|
||||||
const appDetail = useAppStore(s => s.appDetail)
|
const appDetail = useAppStore(s => s.appDetail)
|
||||||
const appId = appDetail?.id || ''
|
const appId = appDetail?.id || ''
|
||||||
|
|
||||||
const activeTabId = useStore(s => s.activeTabId)
|
const activeTabId = useStore(s => s.activeTabId)
|
||||||
|
const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId)
|
||||||
const storeApi = useWorkflowStore()
|
const storeApi = useWorkflowStore()
|
||||||
const { data: nodeMap } = useSkillAssetNodeMap()
|
const { data: nodeMap } = useSkillAssetNodeMap()
|
||||||
|
|
||||||
@ -79,6 +79,7 @@ const FileContentPanel = () => {
|
|||||||
const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false)
|
const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false)
|
||||||
|
|
||||||
const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined
|
const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined
|
||||||
|
const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId)
|
||||||
|
|
||||||
const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(currentFileNode)
|
const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(currentFileNode)
|
||||||
|
|
||||||
@ -199,8 +200,13 @@ const FileContentPanel = () => {
|
|||||||
}
|
}
|
||||||
}, [fileTabId, isEditable])
|
}, [fileTabId, isEditable])
|
||||||
|
|
||||||
const handleEditorDidMount: OnMount = useCallback((editor, monaco) => {
|
const handleEditorAutoFocus = useCallback(() => {
|
||||||
editorRef.current = editor
|
if (!fileTabId)
|
||||||
|
return
|
||||||
|
storeApi.getState().clearEditorAutoFocus(fileTabId)
|
||||||
|
}, [fileTabId, storeApi])
|
||||||
|
|
||||||
|
const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => {
|
||||||
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
||||||
setIsMounted(true)
|
setIsMounted(true)
|
||||||
}, [appTheme])
|
}, [appTheme])
|
||||||
@ -273,6 +279,8 @@ const FileContentPanel = () => {
|
|||||||
instanceId={fileTabId || undefined}
|
instanceId={fileTabId || undefined}
|
||||||
value={currentContent}
|
value={currentContent}
|
||||||
onChange={handleMarkdownCollaborativeChange}
|
onChange={handleMarkdownCollaborativeChange}
|
||||||
|
autoFocus={shouldAutoFocusEditor}
|
||||||
|
onAutoFocus={handleEditorAutoFocus}
|
||||||
collaborationEnabled={canInitCollaboration}
|
collaborationEnabled={canInitCollaboration}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -286,6 +294,8 @@ const FileContentPanel = () => {
|
|||||||
value={currentContent}
|
value={currentContent}
|
||||||
onChange={handleCodeCollaborativeChange}
|
onChange={handleCodeCollaborativeChange}
|
||||||
onMount={handleEditorDidMount}
|
onMount={handleEditorDidMount}
|
||||||
|
autoFocus={shouldAutoFocusEditor}
|
||||||
|
onAutoFocus={handleEditorAutoFocus}
|
||||||
fileId={fileTabId}
|
fileId={fileTabId}
|
||||||
collaborationEnabled={canInitCollaboration}
|
collaborationEnabled={canInitCollaboration}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ const FileTree = ({ className }: FileTreeProps) => {
|
|||||||
const containerSize = useSize(containerRef)
|
const containerSize = useSize(containerRef)
|
||||||
const dragInsertTargetRef = useRef<DragInsertTarget | null>(null)
|
const dragInsertTargetRef = useRef<DragInsertTarget | null>(null)
|
||||||
|
|
||||||
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
|
const { data: treeData, isLoading, error, dataUpdatedAt } = useSkillAssetTreeData()
|
||||||
const isMutating = useIsMutating() > 0
|
const isMutating = useIsMutating() > 0
|
||||||
|
|
||||||
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
||||||
@ -304,6 +304,8 @@ const FileTree = ({ className }: FileTreeProps) => {
|
|||||||
useSyncTreeWithActiveTab({
|
useSyncTreeWithActiveTab({
|
||||||
treeRef,
|
treeRef,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
|
syncSignal: dataUpdatedAt,
|
||||||
|
isTreeLoading: isLoading,
|
||||||
})
|
})
|
||||||
|
|
||||||
useSkillShortcuts({ treeRef })
|
useSkillShortcuts({ treeRef })
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
|||||||
const { t } = useTranslation('workflow')
|
const { t } = useTranslation('workflow')
|
||||||
const isFolder = node.data.node_type === 'folder'
|
const isFolder = node.data.node_type === 'folder'
|
||||||
const isSelected = node.isSelected
|
const isSelected = node.isSelected
|
||||||
|
const isFocused = node.isFocused
|
||||||
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
|
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
|
||||||
const isCut = useStore(s => s.isCutNode(node.data.id))
|
const isCut = useStore(s => s.isCutNode(node.data.id))
|
||||||
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
||||||
@ -100,6 +101,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
|||||||
'hover:bg-state-base-hover',
|
'hover:bg-state-base-hover',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||||
isSelected && 'bg-state-base-active',
|
isSelected && 'bg-state-base-active',
|
||||||
|
isFocused && 'ring-2 ring-inset ring-components-input-border-active',
|
||||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||||
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
||||||
isBlinking && 'animate-drag-blink',
|
isBlinking && 'animate-drag-blink',
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -90,7 +90,7 @@ export function useInlineCreateNode({
|
|||||||
emitTreeUpdate()
|
emitTreeUpdate()
|
||||||
const extension = getFileExtension(trimmedName, createdFile.extension)
|
const extension = getFileExtension(trimmedName, createdFile.extension)
|
||||||
if (isTextLikeFile(extension))
|
if (isTextLikeFile(extension))
|
||||||
storeApi.getState().openTab(createdFile.id, { pinned: true })
|
storeApi.getState().openTab(createdFile.id, { pinned: true, autoFocusEditor: true })
|
||||||
Toast.notify({
|
Toast.notify({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: t('skillSidebar.menu.fileCreated'),
|
message: t('skillSidebar.menu.fileCreated'),
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -9,6 +9,8 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
|||||||
type UseSyncTreeWithActiveTabOptions = {
|
type UseSyncTreeWithActiveTabOptions = {
|
||||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||||
activeTabId: string | null
|
activeTabId: string | null
|
||||||
|
syncSignal?: number
|
||||||
|
isTreeLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,25 +23,26 @@ type UseSyncTreeWithActiveTabOptions = {
|
|||||||
export function useSyncTreeWithActiveTab({
|
export function useSyncTreeWithActiveTab({
|
||||||
treeRef,
|
treeRef,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
|
syncSignal,
|
||||||
|
isTreeLoading,
|
||||||
}: UseSyncTreeWithActiveTabOptions): void {
|
}: UseSyncTreeWithActiveTabOptions): void {
|
||||||
const storeApi = useWorkflowStore()
|
const storeApi = useWorkflowStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTabId)
|
if (!activeTabId || isTreeLoading)
|
||||||
return
|
return
|
||||||
|
|
||||||
const tree = treeRef.current
|
const frame = requestAnimationFrame(() => {
|
||||||
if (!tree)
|
const tree = treeRef.current
|
||||||
return
|
if (!tree)
|
||||||
|
return
|
||||||
|
|
||||||
if (activeTabId === START_TAB_ID || isArtifactTab(activeTabId)) {
|
if (activeTabId === START_TAB_ID || isArtifactTab(activeTabId)) {
|
||||||
requestAnimationFrame(() => {
|
if (tree.selectedNodes.length > 0)
|
||||||
tree.deselectAll()
|
tree.deselectAll()
|
||||||
})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const node = tree.get(activeTabId)
|
const node = tree.get(activeTabId)
|
||||||
if (!node)
|
if (!node)
|
||||||
return
|
return
|
||||||
@ -54,9 +57,22 @@ export function useSyncTreeWithActiveTab({
|
|||||||
if (ancestors.length > 0)
|
if (ancestors.length > 0)
|
||||||
storeApi.getState().revealFile(ancestors)
|
storeApi.getState().revealFile(ancestors)
|
||||||
|
|
||||||
tree.openParents(node)
|
let hasClosedAncestor = false
|
||||||
tree.select(activeTabId)
|
current = node.parent
|
||||||
tree.scrollTo(activeTabId)
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,10 @@ const PdfFilePreview = ({ downloadUrl }: PdfFilePreviewProps) => {
|
|||||||
<div className="h-full w-full overflow-auto">
|
<div className="h-full w-full overflow-auto">
|
||||||
<div
|
<div
|
||||||
className="min-h-full p-6"
|
className="min-h-full p-6"
|
||||||
style={{ transform: `scale(${scale})`, transformOrigin: 'top center' }}
|
style={{
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PdfLoader
|
<PdfLoader
|
||||||
workerSrc="/pdf.worker.min.mjs"
|
workerSrc="/pdf.worker.min.mjs"
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
|||||||
openTabIds: [],
|
openTabIds: [],
|
||||||
activeTabId: START_TAB_ID,
|
activeTabId: START_TAB_ID,
|
||||||
previewTabId: null,
|
previewTabId: null,
|
||||||
|
editorAutoFocusFileId: null,
|
||||||
expandedFolderIds: new Set<string>(),
|
expandedFolderIds: new Set<string>(),
|
||||||
selectedTreeNodeId: null,
|
selectedTreeNodeId: null,
|
||||||
selectedNodeIds: new Set<string>(),
|
selectedNodeIds: new Set<string>(),
|
||||||
|
|||||||
@ -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<SkillEditorSliceShape>()((...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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -13,16 +13,28 @@ export const createTabSlice: StateCreator<
|
|||||||
openTabIds: [],
|
openTabIds: [],
|
||||||
activeTabId: START_TAB_ID,
|
activeTabId: START_TAB_ID,
|
||||||
previewTabId: null,
|
previewTabId: null,
|
||||||
|
editorAutoFocusFileId: null,
|
||||||
|
|
||||||
openTab: (fileId: string, options?: OpenTabOptions) => {
|
openTab: (fileId: string, options?: OpenTabOptions) => {
|
||||||
const { openTabIds, activeTabId, previewTabId } = get()
|
const { openTabIds, activeTabId, previewTabId, editorAutoFocusFileId } = get()
|
||||||
const isPinned = options?.pinned ?? false
|
const isPinned = options?.pinned ?? false
|
||||||
|
const autoFocusEditor = options?.autoFocusEditor ?? false
|
||||||
|
|
||||||
if (openTabIds.includes(fileId)) {
|
if (openTabIds.includes(fileId)) {
|
||||||
if (isPinned && previewTabId === fileId)
|
const nextState: Partial<TabSliceShape> = {}
|
||||||
set({ activeTabId: fileId, previewTabId: null })
|
if (isPinned && previewTabId === fileId) {
|
||||||
else if (activeTabId !== fileId)
|
nextState.activeTabId = fileId
|
||||||
set({ 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,18 +47,20 @@ export const createTabSlice: StateCreator<
|
|||||||
openTabIds: [...newOpenTabIds, fileId],
|
openTabIds: [...newOpenTabIds, fileId],
|
||||||
activeTabId: fileId,
|
activeTabId: fileId,
|
||||||
previewTabId: fileId,
|
previewTabId: fileId,
|
||||||
|
editorAutoFocusFileId: autoFocusEditor ? fileId : editorAutoFocusFileId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
set({
|
set({
|
||||||
openTabIds: [...newOpenTabIds, fileId],
|
openTabIds: [...newOpenTabIds, fileId],
|
||||||
activeTabId: fileId,
|
activeTabId: fileId,
|
||||||
|
editorAutoFocusFileId: autoFocusEditor ? fileId : editorAutoFocusFileId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTab: (fileId: string) => {
|
closeTab: (fileId: string) => {
|
||||||
const { openTabIds, activeTabId, previewTabId } = get()
|
const { openTabIds, activeTabId, previewTabId, editorAutoFocusFileId } = get()
|
||||||
const newOpenTabIds = openTabIds.filter(id => id !== fileId)
|
const newOpenTabIds = openTabIds.filter(id => id !== fileId)
|
||||||
|
|
||||||
let newActiveTabId = activeTabId
|
let newActiveTabId = activeTabId
|
||||||
@ -66,6 +80,7 @@ export const createTabSlice: StateCreator<
|
|||||||
openTabIds: newOpenTabIds,
|
openTabIds: newOpenTabIds,
|
||||||
activeTabId: newActiveTabId,
|
activeTabId: newActiveTabId,
|
||||||
previewTabId: newPreviewTabId,
|
previewTabId: newPreviewTabId,
|
||||||
|
editorAutoFocusFileId: editorAutoFocusFileId === fileId ? null : editorAutoFocusFileId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -83,6 +98,14 @@ export const createTabSlice: StateCreator<
|
|||||||
set({ previewTabId: null })
|
set({ previewTabId: null })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearEditorAutoFocus: (fileId?: string) => {
|
||||||
|
const { editorAutoFocusFileId } = get()
|
||||||
|
if (!editorAutoFocusFileId)
|
||||||
|
return
|
||||||
|
if (!fileId || editorAutoFocusFileId === fileId)
|
||||||
|
set({ editorAutoFocusFileId: null })
|
||||||
|
},
|
||||||
|
|
||||||
isPreviewTab: (fileId: string) => {
|
isPreviewTab: (fileId: string) => {
|
||||||
return get().previewTabId === fileId
|
return get().previewTabId === fileId
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,16 +2,19 @@ import type { ContextMenuType } from '@/app/components/workflow/skill/constants'
|
|||||||
|
|
||||||
export type OpenTabOptions = {
|
export type OpenTabOptions = {
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
|
autoFocusEditor?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabSliceShape = {
|
export type TabSliceShape = {
|
||||||
openTabIds: string[]
|
openTabIds: string[]
|
||||||
activeTabId: string | null
|
activeTabId: string | null
|
||||||
previewTabId: string | null
|
previewTabId: string | null
|
||||||
|
editorAutoFocusFileId: string | null
|
||||||
openTab: (fileId: string, options?: OpenTabOptions) => void
|
openTab: (fileId: string, options?: OpenTabOptions) => void
|
||||||
closeTab: (fileId: string) => void
|
closeTab: (fileId: string) => void
|
||||||
activateTab: (fileId: string) => void
|
activateTab: (fileId: string) => void
|
||||||
pinTab: (fileId: string) => void
|
pinTab: (fileId: string) => void
|
||||||
|
clearEditorAutoFocus: (fileId?: string) => void
|
||||||
isPreviewTab: (fileId: string) => boolean
|
isPreviewTab: (fileId: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3328,9 +3328,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/workflow/nodes/code/use-config.ts": {
|
"app/components/workflow/nodes/code/use-config.ts": {
|
||||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"regexp/no-useless-assertions": {
|
"regexp/no-useless-assertions": {
|
||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user