fix: preserve overlays during terminal mounting and layout

This commit is contained in:
Neil
2026-09-05 23:51:54 -07:00
parent e73f8dfa0f
commit 3366dbf7fb
8 changed files with 253 additions and 3 deletions
@@ -90,6 +90,7 @@ describe('fitAndFocusPanes', () => {
vi.stubGlobal('HTMLElement', FakeHTMLElement)
vi.stubGlobal('document', {
activeElement,
querySelectorAll: vi.fn(() => []),
querySelector: vi.fn((selector: string) =>
selector === '[data-tab-rename-input="true"]' && renameInputMounted
? (new FakeHTMLElement({ tagName: 'INPUT' }) as unknown as Element)
@@ -1,4 +1,5 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { focusPanePreservingOverlays } from '@/lib/pane-manager/pane-overlay-focus'
export function fitPanes(manager: PaneManager): void {
manager.fitAllPanes()
@@ -16,7 +17,9 @@ export function focusActivePane(manager: PaneManager): void {
}
const panes = manager.getPanes()
const activePane = manager.getActivePane() ?? panes[0]
activePane?.terminal.focus()
if (activePane) {
focusPanePreservingOverlays(activePane)
}
}
export function fitAndFocusPanes(manager: PaneManager): void {
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { fitAndFocusPanes } from './pane-helpers'
function createLayoutFixture() {
const textarea = document.createElement('textarea')
textarea.className = 'xterm-helper-textarea'
document.body.append(textarea)
textarea.focus()
const terminal = { focus: vi.fn(() => textarea.focus()) }
const manager = {
fitAllPanes: vi.fn(),
getActivePane: () => ({ terminal }),
getPanes: () => [{ terminal }]
} as unknown as PaneManager
return { manager, terminal, textarea }
}
function mountOverlay(role: string) {
const overlay = document.createElement('div')
overlay.setAttribute('role', role)
overlay.tabIndex = -1
vi.spyOn(overlay, 'getClientRects').mockReturnValue([
new DOMRect(0, 0, 100, 100)
] as unknown as DOMRectList)
document.body.append(overlay)
return overlay
}
afterEach(() => {
document.body.replaceChildren()
vi.restoreAllMocks()
})
describe('terminal layout preserves overlay focus', () => {
it.each(['menu', 'dialog', 'alertdialog', 'listbox'])(
'does not blur an open %s during a queued fit',
(role) => {
const { manager, terminal } = createLayoutFixture()
const overlay = mountOverlay(role)
overlay.focus()
const blurred = vi.fn()
overlay.addEventListener('blur', blurred)
fitAndFocusPanes(manager)
expect(manager.fitAllPanes).toHaveBeenCalledOnce()
expect(terminal.focus).not.toHaveBeenCalled()
expect(document.activeElement).toBe(overlay)
expect(blurred).not.toHaveBeenCalled()
}
)
it('leaves a mounted menu time to acquire focus', () => {
const { manager, terminal, textarea } = createLayoutFixture()
textarea.blur()
mountOverlay('menu')
fitAndFocusPanes(manager)
expect(terminal.focus).not.toHaveBeenCalled()
expect(document.activeElement).toBe(document.body)
})
it('allows focus after the menu closes', () => {
const { manager, terminal, textarea } = createLayoutFixture()
const overlay = mountOverlay('menu')
overlay.focus()
overlay.remove()
fitAndFocusPanes(manager)
expect(terminal.focus).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(textarea)
})
it('does not treat the workspace sidebar as a focus-owning overlay', () => {
const { manager, terminal } = createLayoutFixture()
const sidebar = mountOverlay('listbox')
sidebar.setAttribute('data-worktree-sidebar', '')
fitAndFocusPanes(manager)
expect(terminal.focus).toHaveBeenCalledOnce()
})
})
@@ -1,3 +1,4 @@
import { focusPanePreservingOverlays } from './pane-overlay-focus'
import type { ManagedPane, ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
import type { PaneManagerHost } from './pane-manager-host'
import { applyPaneOpacity } from './pane-divider'
@@ -23,7 +24,7 @@ export function createInitialManagedPane(
applyPaneOpacity(host.panes.values(), host.getActivePaneId(), host.getStyleOptions())
if (opts?.focus !== false) {
pane.terminal.focus()
focusPanePreservingOverlays(pane)
}
host.publishPaneCreated(pane)
@@ -1,3 +1,4 @@
import { focusPanePreservingOverlays } from './pane-overlay-focus'
import type {
PaneManagerOptions,
PaneStyleOptions,
@@ -235,7 +236,7 @@ export class PaneManager {
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
if (opts?.focus !== false) {
pane.terminal.focus()
focusPanePreservingOverlays(pane)
}
if (changed) {
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneManager } from './pane-manager'
import { createInitialManagedPane } from './pane-manager-pane-creation'
import type { PaneManagerHost } from './pane-manager-host'
import type { ManagedPaneInternal } from './pane-manager-types'
vi.mock('./pane-lifecycle', () => ({
openTerminal: vi.fn(),
createPaneDOM: vi.fn(),
disposePane: vi.fn(),
setLigaturesEnabled: vi.fn()
}))
afterEach(() => {
document.body.innerHTML = ''
})
function fixture() {
const root = document.createElement('div')
document.body.append(root)
const container = document.createElement('div')
const textarea = document.createElement('textarea')
container.append(textarea)
const pane = {
id: 1,
container,
terminal: { focus: vi.fn(() => textarea.focus()) }
} as unknown as ManagedPaneInternal
const panes = new Map([[pane.id, pane]])
const publishPaneCreated = vi.fn()
const onActivePaneChange = vi.fn()
const manager = Object.create(PaneManager.prototype) as PaneManager
Object.assign(manager, {
panes,
activePaneId: null,
styleOptions: {},
options: { onActivePaneChange }
})
const host = {
root,
panes,
createPaneInternal: () => pane,
setActivePaneId: vi.fn(),
getActivePaneId: () => pane.id,
getStyleOptions: () => ({}),
publishPaneCreated
} as unknown as PaneManagerHost
return { root, container, textarea, pane, host, manager, publishPaneCreated, onActivePaneChange }
}
function overlay(role: string) {
const element = document.createElement('div')
element.setAttribute('role', role)
element.tabIndex = -1
document.body.append(element)
element.focus()
return element
}
describe.each(['initial', 'active'] as const)('%s pane focus', (operation) => {
function focus(f: ReturnType<typeof fixture>, requested = true) {
if (operation === 'initial') {
createInitialManagedPane(f.host, { focus: requested })
expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane)
} else {
f.root.append(f.container)
f.manager.setActivePane(f.pane.id, { focus: requested })
expect(f.manager.getActivePane()?.id).toBe(f.pane.id)
expect(f.onActivePaneChange).toHaveBeenCalledTimes(1)
}
}
it.each(['menu', 'dialog', 'alertdialog', 'listbox'])('preserves a visible %s', (role) => {
const f = fixture()
const popup = overlay(role)
focus(f)
expect(document.activeElement).toBe(popup)
expect(f.pane.terminal.focus).not.toHaveBeenCalled()
})
it('focuses a terminal hosted inside a dialog', () => {
const f = fixture()
overlay('dialog').append(f.root)
focus(f)
expect(document.activeElement).toBe(f.textarea)
})
it('preserves a nested popup over a dialog-hosted terminal', () => {
const f = fixture()
const dialog = overlay('dialog')
dialog.append(f.root)
const popup = overlay('menu')
dialog.append(popup)
popup.focus()
focus(f)
expect(document.activeElement).toBe(popup)
})
it('allows focus with only persistent sidebar chrome', () => {
const f = fixture()
overlay('listbox').setAttribute('data-worktree-sidebar', '')
focus(f)
expect(document.activeElement).toBe(f.textarea)
})
it('allows focus after the overlay closes', () => {
const f = fixture()
overlay('menu').style.display = 'none'
focus(f)
expect(document.activeElement).toBe(f.textarea)
})
it('preserves a popup nested inside sidebar chrome', () => {
const f = fixture()
const sidebar = overlay('listbox')
sidebar.setAttribute('data-worktree-sidebar', '')
const popup = overlay('menu')
sidebar.append(popup)
popup.focus()
focus(f)
expect(document.activeElement).toBe(popup)
})
it('honors an explicit no-focus request', () => {
const f = fixture()
focus(f, false)
expect(f.pane.terminal.focus).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,17 @@
import { hasVisibleOverlay } from '../visible-overlay'
import type { ManagedPane } from './pane-manager-types'
export function focusPanePreservingOverlays(
pane: Pick<ManagedPane, 'container' | 'terminal'>
): void {
if (
typeof document !== 'undefined' &&
hasVisibleOverlay({
ignoreMatches: '[role="listbox"][data-worktree-sidebar]',
ignoreContaining: pane.container
})
) {
return
}
pane.terminal.focus()
}
+10
View File
@@ -3,6 +3,10 @@ const OVERLAY_SELECTOR = '[role="dialog"], [role="alertdialog"], [role="listbox"
type VisibleOverlayOptions = {
/** Overlays inside a match are treated as page content, not as a layer above it. */
ignoreSelector?: string
/** Ignore matching chrome itself while retaining overlays nested within it. */
ignoreMatches?: string
/** A terminal hosted inside an overlay may still take focus within that overlay. */
ignoreContaining?: Element
}
/**
@@ -21,6 +25,12 @@ export function hasVisibleOverlay(options?: VisibleOverlayOptions): boolean {
if (options?.ignoreSelector && element.closest(options.ignoreSelector)) {
return false
}
if (options?.ignoreMatches && element.matches(options.ignoreMatches)) {
return false
}
if (options?.ignoreContaining && element.contains(options.ignoreContaining)) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&