fix(editor): preserve Markdown focus handoffs (#10618)
* fix(editor): preserve Markdown focus handoffs * fix(editor): scope focus requests to panes via viewStateId When opening a file to focus it, tag the pending request with the pane's viewStateId. This prevents split siblings from claiming each other's requests and stops later remounts from stealing focus. Both Monaco and rich-markdown editors now retire requests on mount.
This commit is contained in:
parent
97175ed92b
commit
56d3e2cb2e
|
|
@ -7,7 +7,12 @@ const lifecycle = vi.hoisted(() => ({
|
|||
events: [] as string[],
|
||||
diffModelKeys: [] as string[],
|
||||
models: new Map<string, { content: string; undo: string[] }>(),
|
||||
mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[]
|
||||
mountedProps: [] as {
|
||||
filePath: string
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
viewStateId?: string
|
||||
}[]
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
|
|
@ -37,6 +42,7 @@ vi.mock('@/lib/lazy-with-retry', async () => {
|
|||
content: string
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
viewStateId?: string
|
||||
}) {
|
||||
/* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
|
||||
React.useEffect(() => {
|
||||
|
|
@ -44,7 +50,8 @@ vi.mock('@/lib/lazy-with-retry', async () => {
|
|||
lifecycle.mountedProps.push({
|
||||
filePath: props.filePath,
|
||||
readOnly: props.readOnly,
|
||||
liveTail: props.liveTail
|
||||
liveTail: props.liveTail,
|
||||
viewStateId: props.viewStateId
|
||||
})
|
||||
const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] }
|
||||
const model = {
|
||||
|
|
@ -241,7 +248,17 @@ describe('EditorContent Monaco lifecycle boundary', () => {
|
|||
render(<EditorContent {...props(liveLog, 'session content')} />)
|
||||
|
||||
expect(lifecycle.mountedProps).toEqual([
|
||||
{ filePath: liveLog.filePath, readOnly: true, liveTail: true }
|
||||
{ filePath: liveLog.filePath, readOnly: true, liveTail: true, viewStateId: 'same-pane' }
|
||||
])
|
||||
})
|
||||
|
||||
it('tells Monaco which pane it is so it can retire an explicit focus handoff', () => {
|
||||
const source = file('/repo/main.ts')
|
||||
|
||||
render(<EditorContent {...props(source, 'source content')} />)
|
||||
|
||||
// Why: without the pane id Monaco cannot match the handoff, so it silently leaves the request
|
||||
// armed and a later rich-mode remount of this pane steals focus back.
|
||||
expect(lifecycle.mountedProps.at(0)?.viewStateId).toBe('same-pane')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ export function EditorContent({
|
|||
fileId={activeFile.id}
|
||||
filePath={activeFile.filePath}
|
||||
viewStateKey={editorViewStateKey}
|
||||
viewStateId={viewStateScopeId}
|
||||
relativePath={activeFile.relativePath}
|
||||
content={editBuffers[activeFile.id] ?? fc.content}
|
||||
language={monacoLanguage}
|
||||
|
|
@ -386,6 +387,7 @@ export function EditorContent({
|
|||
<RichMarkdownErrorBoundary key={viewStateScopeId} fileId={activeFile.id}>
|
||||
<RichMarkdownEditor
|
||||
fileId={activeFile.id}
|
||||
viewStateId={viewStateScopeId}
|
||||
content={editorContent}
|
||||
filePath={activeFile.filePath}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
|
|
|
|||
|
|
@ -66,11 +66,14 @@ import {
|
|||
} from './monaco-auto-height'
|
||||
import { installMonacoE2EProbe } from './monaco-e2e-probe'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
import { matchesPendingEditorFocusRequest } from './pending-editor-focus-request'
|
||||
|
||||
type MonacoEditorProps = {
|
||||
fileId: string
|
||||
filePath: string
|
||||
viewStateKey: string
|
||||
// Why: identifies the pane for explicit open focus handoffs; omit on surfaces that never receive one.
|
||||
viewStateId?: string
|
||||
relativePath: string
|
||||
content: string
|
||||
language: string
|
||||
|
|
@ -96,6 +99,7 @@ export default function MonacoEditor({
|
|||
fileId,
|
||||
filePath,
|
||||
viewStateKey,
|
||||
viewStateId,
|
||||
relativePath,
|
||||
content,
|
||||
language,
|
||||
|
|
@ -559,6 +563,16 @@ export default function MonacoEditor({
|
|||
editorInstance.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every mount path above focuses, so an explicit open handoff is already satisfied here.
|
||||
// Retiring it stops a later rich-mode remount of this same pane from stealing focus back.
|
||||
const focusRequest = useAppStore.getState().pendingEditorFocusRequest
|
||||
if (
|
||||
focusRequest &&
|
||||
matchesPendingEditorFocusRequest(focusRequest, { fileId, worktreeId, viewStateId })
|
||||
) {
|
||||
useAppStore.getState().consumeEditorFocusRequest(focusRequest.token)
|
||||
}
|
||||
},
|
||||
[
|
||||
queueReveal,
|
||||
|
|
@ -568,6 +582,7 @@ export default function MonacoEditor({
|
|||
setEditorCursorLine,
|
||||
updateMarkdownCompletionDocuments,
|
||||
viewStateKey,
|
||||
viewStateId,
|
||||
autoHeight,
|
||||
autoHeightLineHeight,
|
||||
worktreeId
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type { RichMarkdownEditorProps } from './rich-markdown-editor-props'
|
|||
|
||||
export default function RichMarkdownEditor({
|
||||
fileId,
|
||||
viewStateId,
|
||||
content,
|
||||
filePath,
|
||||
worktreeId,
|
||||
|
|
@ -240,7 +241,14 @@ export default function RichMarkdownEditor({
|
|||
setDocLinkMenu: menu.setDocLinkMenu
|
||||
})
|
||||
|
||||
useRichMarkdownPendingFocus({ editor, fileId, worktreeId, rootRef, cancelAutoFocusRef })
|
||||
useRichMarkdownPendingFocus({
|
||||
editor,
|
||||
fileId,
|
||||
viewStateId,
|
||||
worktreeId,
|
||||
rootRef,
|
||||
cancelAutoFocusRef
|
||||
})
|
||||
|
||||
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
|
||||
// citation NodeSelections would leave aria status stale without useEditorState.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { PendingEditorFocusRequest } from '@/store/slices/editor'
|
||||
import { matchesPendingEditorFocusRequest } from './pending-editor-focus-request'
|
||||
|
||||
const request: PendingEditorFocusRequest = {
|
||||
fileId: 'file-1',
|
||||
worktreeId: 'worktree-1',
|
||||
viewStateId: 'view-1',
|
||||
expiresAt: 0,
|
||||
token: 3
|
||||
}
|
||||
|
||||
const pane = { fileId: 'file-1', worktreeId: 'worktree-1', viewStateId: 'view-1' }
|
||||
|
||||
describe('matchesPendingEditorFocusRequest', () => {
|
||||
it('matches the pane the handoff was opened into', () => {
|
||||
expect(matchesPendingEditorFocusRequest(request, pane)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a missing request', () => {
|
||||
expect(matchesPendingEditorFocusRequest(null, pane)).toBe(false)
|
||||
expect(matchesPendingEditorFocusRequest(undefined, pane)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a split sibling showing the same file', () => {
|
||||
expect(matchesPendingEditorFocusRequest(request, { ...pane, viewStateId: 'view-2' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects the same pane id in another file or worktree', () => {
|
||||
expect(matchesPendingEditorFocusRequest(request, { ...pane, fileId: 'file-2' })).toBe(false)
|
||||
expect(matchesPendingEditorFocusRequest(request, { ...pane, worktreeId: 'worktree-2' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a pane that cannot identify itself', () => {
|
||||
// Why: surfaces that omit the props must never swallow another pane's handoff.
|
||||
expect(matchesPendingEditorFocusRequest(request, { ...pane, viewStateId: undefined })).toBe(
|
||||
false
|
||||
)
|
||||
expect(matchesPendingEditorFocusRequest(request, { ...pane, worktreeId: undefined })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { PendingEditorFocusRequest } from '@/store/slices/editor'
|
||||
|
||||
/**
|
||||
* True when an explicit open focus handoff belongs to this editor pane. Both the rich Markdown and
|
||||
* Monaco surfaces gate on this, so a handoff is claimed (and retired) by exactly one pane — split
|
||||
* siblings share a file id, and only `viewStateId` tells them apart.
|
||||
*/
|
||||
export function matchesPendingEditorFocusRequest(
|
||||
request: PendingEditorFocusRequest | null | undefined,
|
||||
pane: { fileId: string; worktreeId: string | undefined; viewStateId: string | undefined }
|
||||
): boolean {
|
||||
if (!request || pane.worktreeId === undefined || pane.viewStateId === undefined) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
request.fileId === pane.fileId &&
|
||||
request.worktreeId === pane.worktreeId &&
|
||||
request.viewStateId === pane.viewStateId
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
// @vitest-environment happy-dom
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
|
||||
|
||||
function createEditor(focus = vi.fn()): Editor {
|
||||
function createEditor(
|
||||
focus = vi.fn(),
|
||||
domFocus: (options?: FocusOptions) => void = vi.fn()
|
||||
): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
commands: { focus }
|
||||
commands: { focus },
|
||||
view: { dom: { focus: domFocus } }
|
||||
} as unknown as Editor
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +42,7 @@ function setupScheduledFocus(
|
|||
describe('autoFocusRichEditor', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('returns cleanup that cancels the pending focus frame', () => {
|
||||
|
|
@ -69,6 +75,53 @@ describe('autoFocusRichEditor', () => {
|
|||
expect(focus).toHaveBeenCalledWith('start', { scrollIntoView: false })
|
||||
})
|
||||
|
||||
it('claims DOM focus synchronously on an explicit handoff', () => {
|
||||
const root = document.createElement('div')
|
||||
const editorDom = document.createElement('div')
|
||||
editorDom.tabIndex = -1
|
||||
root.append(editorDom)
|
||||
document.body.append(root)
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 11)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
autoFocusRichEditor(createEditor(vi.fn(), editorDom.focus.bind(editorDom)), root, true)
|
||||
|
||||
expect(root.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves DOM focus alone for an ordinary lazy mount', () => {
|
||||
const domFocus = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 12)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
autoFocusRichEditor(createEditor(vi.fn(), domFocus), null, false)
|
||||
|
||||
expect(domFocus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run deferred focus after an explicit handoff expires', () => {
|
||||
let runFrame: FrameRequestCallback = () => {}
|
||||
let requestActive = true
|
||||
const focus = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
runFrame = callback
|
||||
return 13
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
autoFocusRichEditor(createEditor(focus), null, true, () => requestActive)
|
||||
requestActive = false
|
||||
runFrame(0)
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not steal focus from other controls outside the editor', () => {
|
||||
const { focus, runFrame } = setupScheduledFocus({})
|
||||
runFrame()
|
||||
|
|
|
|||
|
|
@ -5,15 +5,25 @@ import type { Editor } from '@tiptap/react'
|
|||
* immediately (matching MonacoEditor's behavior). Guards against focus theft
|
||||
* from modals/dialogs and skips scrollIntoView to avoid racing with
|
||||
* useEditorScrollRestore.
|
||||
*
|
||||
* `force` marks an explicit user handoff (Explorer open): it bypasses the theft
|
||||
* guard and claims DOM focus in this tick, because `commands.focus()` defers the
|
||||
* real `view.focus()` by a further frame. `shouldFocus` lets the caller retire a
|
||||
* handoff that expired while the frame was pending.
|
||||
*/
|
||||
export function autoFocusRichEditor(
|
||||
nextEditor: Editor,
|
||||
rootEl: HTMLElement | null,
|
||||
force = false
|
||||
force = false,
|
||||
shouldFocus: () => boolean = () => true
|
||||
): () => void {
|
||||
// Why: Tiptap can recreate the instance before its deferred focus lands, losing explicit handoffs.
|
||||
if (force && !nextEditor.isDestroyed && shouldFocus()) {
|
||||
nextEditor.view?.dom?.focus?.({ preventScroll: true })
|
||||
}
|
||||
let frameId: number | null = requestAnimationFrame(() => {
|
||||
frameId = null
|
||||
if (nextEditor.isDestroyed) {
|
||||
if (nextEditor.isDestroyed || !shouldFocus()) {
|
||||
return
|
||||
}
|
||||
const active = document.activeElement
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { MarkdownDocument } from '../../../../shared/types'
|
|||
|
||||
export type RichMarkdownEditorProps = {
|
||||
fileId: string
|
||||
viewStateId: string
|
||||
content: string
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
// @vitest-environment happy-dom
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PendingEditorFocusRequest } from '@/store/slices/editor'
|
||||
|
||||
type StoreFixture = {
|
||||
pendingEditorFocusRequest: PendingEditorFocusRequest | null
|
||||
consumeEditorFocusRequest: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
store: {
|
||||
pendingEditorFocusRequest: null,
|
||||
consumeEditorFocusRequest: vi.fn()
|
||||
} as StoreFixture,
|
||||
autoFocusRichEditor: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: StoreFixture) => unknown) => selector(fixture.store)
|
||||
}))
|
||||
|
||||
vi.mock('./rich-markdown-auto-focus', () => ({
|
||||
autoFocusRichEditor: fixture.autoFocusRichEditor
|
||||
}))
|
||||
|
||||
import { useRichMarkdownPendingFocus } from './useRichMarkdownPendingFocus'
|
||||
|
||||
type EditorFixture = {
|
||||
editor: Editor
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
function createEditorFixture(destroyed = false): EditorFixture {
|
||||
const focusListeners = new Set<() => void>()
|
||||
let focused = false
|
||||
return {
|
||||
editor: {
|
||||
isDestroyed: destroyed,
|
||||
get isFocused() {
|
||||
return focused
|
||||
},
|
||||
on: vi.fn((event: string, listener: () => void) => {
|
||||
if (event === 'focus') {
|
||||
focusListeners.add(listener)
|
||||
}
|
||||
}),
|
||||
off: vi.fn((event: string, listener: () => void) => {
|
||||
if (event === 'focus') {
|
||||
focusListeners.delete(listener)
|
||||
}
|
||||
})
|
||||
} as unknown as Editor,
|
||||
focus: () => {
|
||||
focused = true
|
||||
for (const listener of focusListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pendingRequest(overrides: Partial<PendingEditorFocusRequest> = {}) {
|
||||
return {
|
||||
fileId: 'file-1',
|
||||
worktreeId: 'worktree-1',
|
||||
viewStateId: 'view-1',
|
||||
expiresAt: Date.now() + 30_000,
|
||||
token: 7,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingFocus(editor: Editor | null, viewStateId = 'view-1') {
|
||||
return renderHook(
|
||||
({ nextEditor }) =>
|
||||
useRichMarkdownPendingFocus({
|
||||
editor: nextEditor,
|
||||
fileId: 'file-1',
|
||||
viewStateId,
|
||||
worktreeId: 'worktree-1',
|
||||
rootRef: { current: null },
|
||||
cancelAutoFocusRef: { current: null }
|
||||
}),
|
||||
{ initialProps: { nextEditor: editor } }
|
||||
)
|
||||
}
|
||||
|
||||
describe('useRichMarkdownPendingFocus', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
fixture.store.pendingEditorFocusRequest = null
|
||||
fixture.store.consumeEditorFocusRequest.mockReset()
|
||||
fixture.autoFocusRichEditor.mockReset()
|
||||
fixture.autoFocusRichEditor.mockReturnValue(vi.fn())
|
||||
})
|
||||
|
||||
it('consumes the request only after delayed editor focus lands', () => {
|
||||
const editor = createEditorFixture()
|
||||
fixture.store.pendingEditorFocusRequest = pendingRequest()
|
||||
|
||||
const hook = renderPendingFocus(editor.editor)
|
||||
|
||||
expect(fixture.store.consumeEditorFocusRequest).not.toHaveBeenCalled()
|
||||
act(editor.focus)
|
||||
expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7)
|
||||
|
||||
hook.unmount()
|
||||
expect(editor.editor.off).toHaveBeenCalledWith('focus', expect.any(Function))
|
||||
})
|
||||
|
||||
it('retries a request when Tiptap replaces a destroyed editor', () => {
|
||||
const destroyed = createEditorFixture(true)
|
||||
const replacement = createEditorFixture()
|
||||
fixture.store.pendingEditorFocusRequest = pendingRequest()
|
||||
fixture.autoFocusRichEditor.mockImplementationOnce(() => {
|
||||
replacement.focus()
|
||||
return vi.fn()
|
||||
})
|
||||
|
||||
const hook = renderPendingFocus(destroyed.editor)
|
||||
expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled()
|
||||
|
||||
hook.rerender({ nextEditor: replacement.editor })
|
||||
|
||||
expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('does not let another split pane claim the request', () => {
|
||||
fixture.store.pendingEditorFocusRequest = pendingRequest()
|
||||
|
||||
renderPendingFocus(createEditorFixture().editor, 'view-2')
|
||||
|
||||
expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled()
|
||||
expect(fixture.store.consumeEditorFocusRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retires an expired request without stealing focus', () => {
|
||||
fixture.store.pendingEditorFocusRequest = pendingRequest({ expiresAt: Date.now() - 1 })
|
||||
|
||||
renderPendingFocus(null)
|
||||
|
||||
expect(fixture.autoFocusRichEditor).not.toHaveBeenCalled()
|
||||
expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('cancels a pending forced focus when its request expires', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-25T00:00:00Z'))
|
||||
const cancelFocus = vi.fn()
|
||||
fixture.store.pendingEditorFocusRequest = pendingRequest({ expiresAt: Date.now() + 1_000 })
|
||||
fixture.autoFocusRichEditor.mockReturnValue(cancelFocus)
|
||||
|
||||
renderPendingFocus(createEditorFixture().editor)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000)
|
||||
})
|
||||
|
||||
expect(cancelFocus).toHaveBeenCalledOnce()
|
||||
expect(fixture.store.consumeEditorFocusRequest).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,38 +2,78 @@ import { useEffect, type RefObject } from 'react'
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
|
||||
import { matchesPendingEditorFocusRequest } from './pending-editor-focus-request'
|
||||
|
||||
type PendingFocusOptions = {
|
||||
editor: Editor | null
|
||||
fileId: string
|
||||
viewStateId: string
|
||||
worktreeId: string
|
||||
rootRef: RefObject<HTMLDivElement | null>
|
||||
cancelAutoFocusRef: RefObject<(() => void) | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the editor when the Explorer opens this document for find (issue #8083), then consumes
|
||||
* the request so a later remount of the same file does not steal focus again.
|
||||
* Focuses the editor when the Explorer opens this document for find (issue #8083). The request is
|
||||
* scoped to one pane (`viewStateId`) so split siblings can't claim it, and stays armed until focus
|
||||
* actually lands — Tiptap can replace the instance first — or until its TTL retires it, so a later
|
||||
* unrelated remount of the same file never steals focus.
|
||||
*/
|
||||
export function useRichMarkdownPendingFocus({
|
||||
editor,
|
||||
fileId,
|
||||
viewStateId,
|
||||
worktreeId,
|
||||
rootRef,
|
||||
cancelAutoFocusRef
|
||||
}: PendingFocusOptions): void {
|
||||
const pendingEditorFocusRequest = useAppStore((s) => {
|
||||
const request = s.pendingEditorFocusRequest
|
||||
return request?.fileId === fileId && request.worktreeId === worktreeId ? request : null
|
||||
return matchesPendingEditorFocusRequest(request, { fileId, worktreeId, viewStateId })
|
||||
? request
|
||||
: null
|
||||
})
|
||||
const consumeEditorFocusRequest = useAppStore((s) => s.consumeEditorFocusRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !pendingEditorFocusRequest) {
|
||||
if (!pendingEditorFocusRequest) {
|
||||
return
|
||||
}
|
||||
if (pendingEditorFocusRequest.expiresAt <= Date.now()) {
|
||||
consumeEditorFocusRequest(pendingEditorFocusRequest.token)
|
||||
return
|
||||
}
|
||||
if (!editor || editor.isDestroyed) {
|
||||
return
|
||||
}
|
||||
let consumed = false
|
||||
const consumeIfFocused = (): void => {
|
||||
if (
|
||||
consumed ||
|
||||
(rootRef.current?.contains(document.activeElement) !== true && !editor.isFocused)
|
||||
) {
|
||||
return
|
||||
}
|
||||
consumed = true
|
||||
consumeEditorFocusRequest(pendingEditorFocusRequest.token)
|
||||
}
|
||||
editor.on('focus', consumeIfFocused)
|
||||
cancelAutoFocusRef.current?.()
|
||||
cancelAutoFocusRef.current = autoFocusRichEditor(editor, rootRef.current, true)
|
||||
consumeEditorFocusRequest(pendingEditorFocusRequest.token)
|
||||
cancelAutoFocusRef.current = autoFocusRichEditor(
|
||||
editor,
|
||||
rootRef.current,
|
||||
true,
|
||||
() => pendingEditorFocusRequest.expiresAt > Date.now()
|
||||
)
|
||||
const expiryTimer = window.setTimeout(() => {
|
||||
cancelAutoFocusRef.current?.()
|
||||
cancelAutoFocusRef.current = null
|
||||
consumeEditorFocusRequest(pendingEditorFocusRequest.token)
|
||||
}, pendingEditorFocusRequest.expiresAt - Date.now())
|
||||
consumeIfFocused()
|
||||
return () => {
|
||||
window.clearTimeout(expiryTimer)
|
||||
editor.off('focus', consumeIfFocused)
|
||||
}
|
||||
}, [cancelAutoFocusRef, consumeEditorFocusRequest, editor, pendingEditorFocusRequest, rootRef])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,12 @@ describe('createEditorSlice right sidebar state', () => {
|
|||
)
|
||||
|
||||
const request = store.getState().pendingEditorFocusRequest
|
||||
expect(request).toMatchObject({ fileId: '/repo/README.md', worktreeId: 'wt-1' })
|
||||
expect(request).toMatchObject({
|
||||
fileId: '/repo/README.md',
|
||||
worktreeId: 'wt-1',
|
||||
viewStateId: expect.any(String),
|
||||
expiresAt: expect.any(Number)
|
||||
})
|
||||
|
||||
store.getState().consumeEditorFocusRequest((request?.token ?? 0) + 1)
|
||||
expect(store.getState().pendingEditorFocusRequest).toBe(request)
|
||||
|
|
@ -130,6 +135,33 @@ describe('createEditorSlice right sidebar state', () => {
|
|||
expect(store.getState().pendingEditorFocusRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('scopes the focus request to the unified tab that will render the file', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const sourceTab = store.getState().createUnifiedTab('wt-1', 'terminal', { id: 'terminal-1' })
|
||||
const targetGroupId = store.getState().createEmptySplitGroup('wt-1', sourceTab.groupId, 'right')
|
||||
if (!targetGroupId) {
|
||||
throw new Error('expected split group')
|
||||
}
|
||||
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
},
|
||||
{ focusEditor: true, targetGroupId }
|
||||
)
|
||||
|
||||
const editorTab = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree['wt-1']?.find((tab) => tab.contentType === 'editor')
|
||||
expect(editorTab?.groupId).toBe(targetGroupId)
|
||||
// Why: the pane matches the handoff on its own tab id, so a drifting id silently drops it.
|
||||
expect(store.getState().pendingEditorFocusRequest?.viewStateId).toBe(editorTab?.id)
|
||||
})
|
||||
|
||||
it('does not record markdown-file-created when opening an existing markdown file', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
|
|
|
|||
|
|
@ -316,9 +316,13 @@ export type PendingEditorReveal = {
|
|||
export type PendingEditorFocusRequest = {
|
||||
fileId: string
|
||||
worktreeId: string
|
||||
viewStateId: string
|
||||
expiresAt: number
|
||||
token: number
|
||||
}
|
||||
|
||||
// Why: allow slow SSH mounts without leaving an unrelated future remount armed indefinitely.
|
||||
const EDITOR_FOCUS_REQUEST_TTL_MS = 30_000
|
||||
let nextEditorFocusRequestToken = 0
|
||||
|
||||
const pendingEditorLineRevealFrameIds = new Set<number>()
|
||||
|
|
@ -1660,18 +1664,6 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined
|
||||
editorItemTargetGroupId = targetGroupId
|
||||
const activeResult = buildEditorActiveResult(s, worktreeId, id)
|
||||
// Why: the renderer may mount asynchronously after the opening control
|
||||
// receives DOM focus, so carry the user's explicit focus handoff by file.
|
||||
const focusRequestUpdate = options?.focusEditor
|
||||
? {
|
||||
pendingEditorFocusRequest: {
|
||||
fileId: id,
|
||||
worktreeId,
|
||||
token: ++nextEditorFocusRequestToken
|
||||
}
|
||||
}
|
||||
: {}
|
||||
|
||||
if (existing) {
|
||||
// If opening as non-preview, also pin the existing tab
|
||||
const updatedPreview = isPreview ? existing.isPreview : false
|
||||
|
|
@ -1702,7 +1694,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
refreshExternalSshProvenance ||
|
||||
existing.fileContentReloadNonce !== fileContentReloadNonce
|
||||
if (!needsExistingUpdate) {
|
||||
return { ...activeResult, ...focusRequestUpdate }
|
||||
return activeResult
|
||||
}
|
||||
// Why: `readOnly` is intentionally NOT in this override map — it's sticky, so `...f` preserves the tab's own read-only state.
|
||||
return {
|
||||
|
|
@ -1734,8 +1726,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
: f
|
||||
),
|
||||
...activeResult,
|
||||
...focusRequestUpdate
|
||||
...activeResult
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1813,8 +1804,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds,
|
||||
...previewTabBarUpdate,
|
||||
...activeResult,
|
||||
...focusRequestUpdate
|
||||
...activeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1854,11 +1844,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
],
|
||||
...tabBarUpdate,
|
||||
...activeResult,
|
||||
...focusRequestUpdate
|
||||
...activeResult
|
||||
}
|
||||
})
|
||||
void openWorkspaceEditorItem(
|
||||
const editorItemViewStateId = openWorkspaceEditorItem(
|
||||
get(),
|
||||
editorItemFileId,
|
||||
editorItemWorktreeId,
|
||||
|
|
@ -1867,6 +1856,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
options?.preview ?? false,
|
||||
editorItemTargetGroupId
|
||||
)
|
||||
if (options?.focusEditor) {
|
||||
set({
|
||||
pendingEditorFocusRequest: {
|
||||
fileId: editorItemFileId,
|
||||
worktreeId: editorItemWorktreeId,
|
||||
viewStateId: editorItemViewStateId,
|
||||
expiresAt: Date.now() + EDITOR_FOCUS_REQUEST_TTL_MS,
|
||||
token: ++nextEditorFocusRequestToken
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
openNewMarkdownInActiveWorkspace: async (groupId) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue