diff --git a/src/main/browser/document-preview/doc-preview-guest-policy.ts b/src/main/browser/document-preview/doc-preview-guest-policy.ts index fb6ff0a71f0..2795b9562dd 100644 --- a/src/main/browser/document-preview/doc-preview-guest-policy.ts +++ b/src/main/browser/document-preview/doc-preview-guest-policy.ts @@ -66,6 +66,12 @@ export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): st return previewGuests.get(guest)?.readBoundGrantId() ?? null } +/** Browser page of the guest's live grant; null before the grant binds or once it is revoked. */ +export function readDocPreviewGuestBoundPageId(guest: Electron.WebContents): string | null { + const grantId = readDocPreviewGuestBoundGrantId(guest) + return (grantId && getDocPreviewGrant(grantId)?.browserPageId) || null +} + /** URL of the guest's committed document; unlike getURL(), pushState cannot rewrite it. */ export function readDocPreviewGuestDocumentUrl(guest: Electron.WebContents): string { return previewGuests.get(guest)?.readDocumentUrl() ?? '' diff --git a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-policy.ts b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-policy.ts index 1c527c71636..2385d90a432 100644 --- a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-policy.ts +++ b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-policy.ts @@ -5,10 +5,11 @@ import { BrowserManagerGuestCleanup } from './browser-manager-guest-cleanup' import { allowPageUnload } from '../../navigation/browser-guest-unload-policy' import { installDocPreviewGuestPolicy, - readDocPreviewGuestBoundGrantId + readDocPreviewGuestBoundPageId } from '../../../document-preview/doc-preview-guest-policy' -import { getDocPreviewGrant } from '../../../document-preview/doc-preview-grant-registry' import { setupGuestEscapeForwarding } from '../../input/shortcuts/browser-guest-escape-forwarding' +import { setupGuestShortcutForwarding } from '../../input/shortcuts/browser-guest-shortcut-forwarding' +import { forwardGuestMousePress } from '../../input/browser-guest-mouse-press' import { installGuestDevToolsCloseShortcut } from '../../devtools/browser-guest-devtools-close-shortcut' export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestCleanup { @@ -92,15 +93,37 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean host: Electron.WebContents ): void { const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const resolveHost = (): Electron.WebContents | null => (host.isDestroyed() ? null : host) const disposeEscapeForwarding = setupGuestEscapeForwarding({ guest, resolveTarget: () => { - const grantId = readDocPreviewGuestBoundGrantId(guest) - const browserPageId = grantId ? getDocPreviewGrant(grantId)?.browserPageId : undefined + const browserPageId = readDocPreviewGuestBoundPageId(guest) return browserPageId ? { browserPageId, renderer: host } : null }, hasActiveGrabOp: (browserPageId) => this.hasActiveGrabOp(browserPageId) }) + // Why: a focused preview swallows keys, so Orca's window shortcuts (Ctrl+Tab, close tab) are + // forwarded to its own host as for plugin frames; browser-page chords stay with the document. + const disposeShortcutForwarding = setupGuestShortcutForwarding({ + browserTabId: `workspace-doc:${guest.id}`, + guest, + resolveRenderer: resolveHost, + getKeybindings: () => this.settingsResolver?.().keybindings, + browserPage: false + }) + // Why: a press in the preview never reaches the host DOM, so its split stays inactive and + // forwarded shortcuts like Close would act on another split (#22144). Presses only: previews + // have no history listener for the side buttons a release would send. + const reportPress = (_event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + if (mouse.type !== 'mouseDown') { + return + } + const browserPageId = readDocPreviewGuestBoundPageId(guest) + if (browserPageId) { + forwardGuestMousePress(guest, browserPageId, resolveHost, mouse) + } + } + guest.on('before-mouse-event', reportPress) const handleDestroyed = (): void => { this.cleanupGuestPolicyAttachment(guest.id) } @@ -109,9 +132,11 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean this.policyCleanupByGuestId.set(guest.id, () => { disposeDocPolicy() disposeEscapeForwarding() + disposeShortcutForwarding() try { guest.off('destroyed', handleDestroyed) guest.off('will-prevent-unload', allowPageUnload) + guest.off('before-mouse-event', reportPress) } catch { // guest may already be destroyed } diff --git a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-workspace-doc-shortcuts.test.ts b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-workspace-doc-shortcuts.test.ts new file mode 100644 index 00000000000..bab2aa74f57 --- /dev/null +++ b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-workspace-doc-shortcuts.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' + +vi.mock('electron', () => ({ + app: { getPath: () => '/downloads' }, + BrowserWindow: { fromWebContents: () => null }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: vi.fn() }, + Menu: { buildFromTemplate: vi.fn() }, + screen: { getCursorScreenPoint: () => ({ x: 0, y: 0 }) }, + webContents: { fromId: () => null } +})) +vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ + openPopupWithOriginBar: vi.fn() +})) + +import { buildDocPreviewUrl } from '../../../../../shared/document-preview/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants +} from '../../../document-preview/doc-preview-grant-registry' +import { + DestroyedGuestTestContents, + DestroyedGuestTestManager +} from './browser-manager-destroyed-guest-test-fixture' + +describe('workspace document guest input', () => { + const guests: DestroyedGuestTestContents[] = [] + + function attachDocumentGuest(): { + guest: DestroyedGuestTestContents + send: Mock + } { + const grant = mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:worktree-1', + worktreeRoot: '/repo' + }, + root: '/repo', + entryRelativePath: 'page.html', + browserPageId: 'doc-page-1' + }) + const url = buildDocPreviewUrl(grant.id, grant.entryRelativePath) + const guest = Object.assign(new DestroyedGuestTestContents(7), { getURL: () => url }) + const send = vi.fn() + const host = Object.assign(new DestroyedGuestTestContents(5001), { send }) + new DestroyedGuestTestManager().attachGuestPolicies(guest.asWebContents(), null, { + profile: 'workspace-doc', + host: host.asWebContents() + }) + guests.push(guest) + return { guest, send } + } + + afterEach(() => { + for (const guest of guests.splice(0)) { + guest.destroy() + } + revokeAllDocPreviewGrants() + }) + + it('forwards Ctrl+Tab from a focused document preview to its host window', () => { + const { guest, send } = attachDocumentGuest() + guest.emit( + 'before-input-event', + { preventDefault: vi.fn() }, + { + type: 'keyDown', + key: 'Tab', + code: 'Tab', + control: true, + shift: false, + alt: false, + meta: false + } + ) + + expect(send.mock.calls).toEqual([['ui:ctrlTabKeyDown', { shiftKey: false }]]) + }) + + it('reports a press in the preview so its split becomes active', () => { + const { guest, send } = attachDocumentGuest() + guest.emit('before-mouse-event', { preventDefault: vi.fn() }, { type: 'mouseDown', x: 1, y: 1 }) + + expect(send.mock.calls).toEqual([ + ['ui:browserPagePointerDown', { browserPageId: 'doc-page-1' }] + ]) + }) +})