diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.test.ts b/src/renderer/src/components/terminal-pane/pane-helpers.test.ts index 8e5987672eb..ee962bd49ae 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index 84715b241ca..94e4d96a162 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx new file mode 100644 index 00000000000..146c2fcd5bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx @@ -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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index c4c3a28c4ca..53ca7f58166 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 798d5feaf25..13532fb9f51 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts new file mode 100644 index 00000000000..b93656485fb --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts @@ -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, 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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts b/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts new file mode 100644 index 00000000000..e52c5caa3eb --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts @@ -0,0 +1,17 @@ +import { hasVisibleOverlay } from '../visible-overlay' +import type { ManagedPane } from './pane-manager-types' + +export function focusPanePreservingOverlays( + pane: Pick +): void { + if ( + typeof document !== 'undefined' && + hasVisibleOverlay({ + ignoreMatches: '[role="listbox"][data-worktree-sidebar]', + ignoreContaining: pane.container + }) + ) { + return + } + pane.terminal.focus() +} diff --git a/src/renderer/src/lib/visible-overlay.ts b/src/renderer/src/lib/visible-overlay.ts index 19a8315cccf..5e75995e8df 100644 --- a/src/renderer/src/lib/visible-overlay.ts +++ b/src/renderer/src/lib/visible-overlay.ts @@ -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' &&