mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix: preserve overlays during terminal mounting and layout
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user