From d2f542d534f971f89f70e418fe379a8373375032 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 28 Jul 2026 11:36:26 +0100 Subject: [PATCH 1/4] Agentic UI: Add a segmented address bar with omnibox to the site preview --- apps/studio/src/ipc-handlers.ts | 2 + .../src/lib/tests/wordpress-rest-api.test.ts | 119 ++++ apps/studio/src/lib/wordpress-rest-api.ts | 32 + apps/studio/src/preload.ts | 1 + .../components/site-preview/index.test.tsx | 128 ++-- apps/ui/src/components/site-preview/index.tsx | 299 +++++---- .../site-preview/location-omnibox.module.css | 256 ++++++++ .../site-preview/location-omnibox.test.tsx | 389 ++++++++++++ .../site-preview/location-omnibox.tsx | 567 ++++++++++++++++++ .../components/site-preview/style.module.css | 53 +- .../src/data/core/connectors/hosted/index.ts | 4 + apps/ui/src/data/core/connectors/ipc/index.ts | 4 + .../src/data/core/connectors/local/index.ts | 6 + apps/ui/src/data/core/types.ts | 6 + .../src/data/queries/use-site-front-links.ts | 63 ++ apps/ui/src/data/queries/use-site-search.ts | 82 +++ apps/ui/src/hooks/use-customize-links.ts | 129 ++++ apps/ui/src/lib/icons.tsx | 18 + packages/common/lib/wordpress-rest.ts | 194 ++++++ packages/common/types/wordpress-rest.ts | 12 + 20 files changed, 2171 insertions(+), 193 deletions(-) create mode 100644 apps/studio/src/lib/tests/wordpress-rest-api.test.ts create mode 100644 apps/studio/src/lib/wordpress-rest-api.ts create mode 100644 apps/ui/src/components/site-preview/location-omnibox.module.css create mode 100644 apps/ui/src/components/site-preview/location-omnibox.test.tsx create mode 100644 apps/ui/src/components/site-preview/location-omnibox.tsx create mode 100644 apps/ui/src/data/queries/use-site-front-links.ts create mode 100644 apps/ui/src/data/queries/use-site-search.ts create mode 100644 apps/ui/src/hooks/use-customize-links.ts create mode 100644 packages/common/lib/wordpress-rest.ts create mode 100644 packages/common/types/wordpress-rest.ts diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 59aa476a8e..abd2f22f9e 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -257,6 +257,8 @@ export { getDefaultSiteDirectory, saveDefaultSiteDirectory }; export { importSite, exportSite } from 'src/modules/import-export/lib/ipc-handlers'; +export { fetchSiteRest as fetchSiteRestApi } from 'src/lib/wordpress-rest-api'; + export async function recordAnalyticsEvent( _event: IpcMainInvokeEvent, // Typed `string` because this crosses the IPC boundary from the (untrusted) renderer; validated diff --git a/apps/studio/src/lib/tests/wordpress-rest-api.test.ts b/apps/studio/src/lib/tests/wordpress-rest-api.test.ts new file mode 100644 index 0000000000..fe0b4a4c54 --- /dev/null +++ b/apps/studio/src/lib/tests/wordpress-rest-api.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; +import { SiteServer } from 'src/site-server'; +import { fetchSiteRest } from '../wordpress-rest-api'; +import type { IpcMainInvokeEvent } from 'electron'; + +vi.mock( 'src/site-server', () => ( { + SiteServer: { + get: vi.fn(), + }, +} ) ); + +const mockIpcMainInvokeEvent = {} as IpcMainInvokeEvent; + +function mockRunningSite( { + id = 'site-id', + port = 8903, + publicUrl = 'https://example.wp.local', +}: { + id?: string; + port?: number; + publicUrl?: string; +} = {} ) { + vi.mocked( SiteServer.get ).mockImplementation( ( requestedId ) => { + if ( requestedId !== id ) { + return undefined; + } + + return { + details: { + id, + name: 'Test Site', + path: '/test-site', + port, + phpVersion: '8.4', + running: true, + url: publicUrl, + customDomain: new URL( publicUrl ).hostname, + enableHttps: publicUrl.startsWith( 'https:' ), + }, + server: { + url: publicUrl, + }, + } as unknown as SiteServer; + } ); +} + +function mockRestFetch() { + const fetchMock = vi.fn( async ( input: Parameters< typeof fetch >[ 0 ] ) => { + const url = String( input ); + if ( url.includes( '/studio-auto-login' ) ) { + return new Response( '', { + status: 302, + headers: { + 'set-cookie': 'wordpress_logged_in_test=token; Path=/; HttpOnly', + }, + } ); + } + + if ( url.includes( '/wp-admin/admin-ajax.php' ) ) { + return new Response( 'test-nonce', { status: 200 } ); + } + + return new Response( JSON.stringify( { ok: true } ), { + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + }, + } ); + } ); + + vi.stubGlobal( 'fetch', fetchMock ); + return fetchMock; +} + +function getRequestedUrls( fetchMock: ReturnType< typeof mockRestFetch > ) { + return fetchMock.mock.calls.map( ( [ input ] ) => String( input ) ); +} + +describe( 'fetchSiteRest', () => { + beforeEach( () => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + } ); + + it( 'uses the loopback site port for internal REST requests', async () => { + mockRunningSite(); + const fetchMock = mockRestFetch(); + + const response = await fetchSiteRest( mockIpcMainInvokeEvent, 'site-id', { + path: '/wp/v2/pages?per_page=100', + } ); + + expect( response.status ).toBe( 200 ); + expect( getRequestedUrls( fetchMock ) ).toEqual( [ + 'http://127.0.0.1:8903/studio-auto-login?redirect_to=%2Fwp-admin%2F', + 'http://127.0.0.1:8903/wp-admin/admin-ajax.php?action=rest-nonce', + 'http://127.0.0.1:8903/wp-json/wp/v2/pages?per_page=100', + ] ); + } ); + + it( 'rejects paths that escape the site REST API', async () => { + mockRunningSite(); + const fetchMock = mockRestFetch(); + + // An absolute URL in `path` would override the REST root and carry the + // site's auth to an arbitrary host (SSRF) — it must be rejected. + const response = await fetchSiteRest( mockIpcMainInvokeEvent, 'site-id', { + path: 'https://evil.example/wp-json/wp/v2/pages', + } ); + + expect( response.status ).toBe( 400 ); + expect( response.body ).toContain( 'REST path must stay within the site REST API.' ); + expect( fetchMock ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/apps/studio/src/lib/wordpress-rest-api.ts b/apps/studio/src/lib/wordpress-rest-api.ts new file mode 100644 index 0000000000..60c05e5e23 --- /dev/null +++ b/apps/studio/src/lib/wordpress-rest-api.ts @@ -0,0 +1,32 @@ +import { + createJsonResponse, + fetchSiteRest as fetchSiteRestShared, +} from '@studio/common/lib/wordpress-rest'; +import { SiteServer } from 'src/site-server'; +import type { SiteRestRequest, SiteRestResponse } from '@studio/common/types/wordpress-rest'; +import type { IpcMainInvokeEvent } from 'electron'; + +export async function fetchSiteRest( + _event: IpcMainInvokeEvent, + siteId: string, + request: SiteRestRequest +): Promise< SiteRestResponse > { + const server = SiteServer.get( siteId ); + if ( ! server ) { + return createJsonResponse( 404, 'studio_site_not_found', `Site ${ siteId } not found.` ); + } + + const baseUrl = + server.details.port > 0 + ? `http://127.0.0.1:${ server.details.port }` + : server.server.url.replace( /\/+$/, '' ); + + return fetchSiteRestShared( + { + siteId, + running: server.details.running, + baseUrl, + }, + request + ); +} diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 76f6dbde6d..113da6f587 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -95,6 +95,7 @@ const api: IpcApi = { installAppUpdate: () => ipcRendererInvoke( 'installAppUpdate' ), getWpVersion: ( id ) => ipcRendererInvoke( 'getWpVersion', id ), getIsMultisite: ( id ) => ipcRendererInvoke( 'getIsMultisite', id ), + fetchSiteRestApi: ( siteId, request ) => ipcRendererInvoke( 'fetchSiteRestApi', siteId, request ), generateProposedSitePath: ( siteName ) => ipcRendererInvoke( 'generateProposedSitePath', siteName ), generateSiteNameFromList: ( usedSites ) => diff --git a/apps/ui/src/components/site-preview/index.test.tsx b/apps/ui/src/components/site-preview/index.test.tsx index a1244b0328..fc9fb3989f 100644 --- a/apps/ui/src/components/site-preview/index.test.tsx +++ b/apps/ui/src/components/site-preview/index.test.tsx @@ -1,14 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen } from '@testing-library/react'; +import { displayShortcut } from '@wordpress/keycodes'; import { Tooltip } from '@wordpress/ui'; import { describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; -import { - getBrowserShortcutCommand, - getPathFromPreviewUrl, - getToolbarPageTitle, - SitePreview, -} from './index'; +import { getBrowserShortcutCommand, getPathFromPreviewUrl, SitePreview } from './index'; import type { SiteDetails } from '@/data/core'; import type { ReactNode } from 'react'; @@ -59,7 +55,7 @@ function createSite( overrides: Partial< SiteDetails > = {} ): SiteDetails { } describe( 'SitePreview', () => { - it( 'shows the current page title and exposes the URL in a tooltip', async () => { + it( 'shows the active realm name with the same tooltip as when inactive', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), capabilities: CAPABILITIES, @@ -69,17 +65,21 @@ describe( 'SitePreview', () => { ); - const pageTitle = screen.getByText( 'Example Site' ); - expect( pageTitle ).toBeVisible(); + // The active segment wears the realm name ("WordPress" for /wp-admin/). + const realmTitle = screen.getByText( 'WordPress' ); + expect( realmTitle ).toBeVisible(); - fireEvent.mouseEnter( pageTitle ); - fireEvent.mouseMove( pageTitle, { movementX: 1, movementY: 1 } ); + // The title is a span inside the address trigger; tooltip hover events + // don't bubble, so target the button itself. + const addressTrigger = realmTitle.closest( 'button' ) as HTMLElement; + fireEvent.mouseEnter( addressTrigger ); + fireEvent.mouseMove( addressTrigger, { movementX: 1, movementY: 1 } ); - expect( screen.queryByText( 'http://localhost:8881/wp-admin/' ) ).not.toBeInTheDocument(); + // jsdom reports a non-Apple platform, so the shortcut renders as Ctrl+2. + const tooltip = `View WP Admin ${ displayShortcut.primary( '2' ) }`; + expect( screen.queryByText( tooltip ) ).not.toBeInTheDocument(); // Tooltips use Base UI's default open delay, so wait long enough for the popup to appear. - expect( - await screen.findByText( 'http://localhost:8881/wp-admin/', {}, { timeout: 2000 } ) - ).toBeVisible(); + expect( await screen.findByText( tooltip, {}, { timeout: 2000 } ) ).toBeVisible(); } ); it( 'shows adjacent toolbar tooltips immediately while the delay group is active', async () => { @@ -92,17 +92,21 @@ describe( 'SitePreview', () => { ); - const pageTitle = screen.getByText( 'Example Site' ); - fireEvent.mouseEnter( pageTitle ); - fireEvent.mouseMove( pageTitle, { movementX: 1, movementY: 1 } ); + const addressTrigger = screen.getByText( 'WordPress' ).closest( 'button' ) as HTMLElement; + fireEvent.mouseEnter( addressTrigger ); + fireEvent.mouseMove( addressTrigger, { movementX: 1, movementY: 1 } ); - await screen.findByText( 'http://localhost:8881/wp-admin/', {}, { timeout: 2000 } ); + await screen.findByText( + `View WP Admin ${ displayShortcut.primary( '2' ) }`, + {}, + { timeout: 2000 } + ); const refreshButton = screen.getByRole( 'button', { name: 'Refresh' } ); expect( screen.queryByText( /^Refresh/ ) ).not.toBeInTheDocument(); - fireEvent.mouseLeave( pageTitle, { relatedTarget: refreshButton } ); - fireEvent.mouseEnter( refreshButton, { relatedTarget: pageTitle } ); + fireEvent.mouseLeave( addressTrigger, { relatedTarget: refreshButton } ); + fireEvent.mouseEnter( refreshButton, { relatedTarget: addressTrigger } ); fireEvent.mouseMove( refreshButton, { movementX: 1, movementY: 1 } ); const refreshTooltip = screen.getByText( /^Refresh/ ); @@ -120,7 +124,7 @@ describe( 'SitePreview', () => { expect( screen.queryByRole( 'button', { name: 'Refresh' } ) ).not.toBeInTheDocument(); expect( screen.queryByRole( 'button', { name: 'Annotate' } ) ).not.toBeInTheDocument(); - expect( screen.queryByText( 'http://localhost:8881/wp-admin/' ) ).not.toBeInTheDocument(); + expect( screen.queryByText( 'WordPress' ) ).not.toBeInTheDocument(); expect( screen.getByRole( 'button', { name: 'Start site' } ) ).toBeVisible(); } ); @@ -180,6 +184,65 @@ describe( 'SitePreview', () => { expect( container.querySelector( 'iframe' ) ).toBe( reloadedIframe ); } ); + it( 'switches realms on primary-modifier number shortcuts', () => { + useConnectorMock.mockReturnValue( { + startSite: vi.fn().mockResolvedValue( undefined ), + capabilities: CAPABILITIES, + } as never ); + const onPathChange = vi.fn(); + + renderPreview( + + ); + + // jsdom reports a non-Apple platform, so the primary modifier is Ctrl. + fireEvent.keyDown( document.body, { key: '2', ctrlKey: true } ); + expect( onPathChange ).toHaveBeenCalledWith( + `/studio-auto-login?redirect_to=${ encodeURIComponent( 'http://localhost:8881/wp-admin/' ) }` + ); + + // The database tab is off by default, so ⌘3 is inert. + onPathChange.mockClear(); + fireEvent.keyDown( document.body, { key: '3', ctrlKey: true } ); + expect( onPathChange ).not.toHaveBeenCalled(); + + // Re-selecting the already-active realm is a no-op. + fireEvent.keyDown( document.body, { key: '1', ctrlKey: true } ); + expect( onPathChange ).not.toHaveBeenCalled(); + } ); + + it( 'switches to the database realm on its shortcut when the tab is enabled', () => { + window.localStorage.setItem( 'studio:preview-show-database-tab', 'true' ); + try { + useConnectorMock.mockReturnValue( { + startSite: vi.fn().mockResolvedValue( undefined ), + capabilities: CAPABILITIES, + } as never ); + const onPathChange = vi.fn(); + + renderPreview( + + ); + + fireEvent.keyDown( document.body, { key: '3', ctrlKey: true } ); + expect( onPathChange ).toHaveBeenCalledWith( + '/phpmyadmin/index.php?route=/database/structure&db=wordpress' + ); + } finally { + window.localStorage.removeItem( 'studio:preview-show-database-tab' ); + } + } ); + it( 'hides the Annotate control when the host cannot annotate the preview', () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), @@ -264,27 +327,6 @@ describe( 'getBrowserShortcutCommand', () => { } ); } ); -describe( 'getToolbarPageTitle', () => { - it( 'strips the WordPress admin suffix from document titles', () => { - expect( getToolbarPageTitle( 'Dashboard ‹ Example Site — WordPress', 'Example Site' ) ).toBe( - 'Dashboard' - ); - expect( getToolbarPageTitle( 'Posts ‹ My Blog — WordPress', 'My Blog' ) ).toBe( 'Posts' ); - } ); - - it( 'returns front-end titles unchanged', () => { - expect( getToolbarPageTitle( 'Example Site – Just another WordPress site', 'Example' ) ).toBe( - 'Example Site – Just another WordPress site' - ); - } ); - - it( 'falls back to the site name, then a generic label', () => { - expect( getToolbarPageTitle( null, 'Example Site' ) ).toBe( 'Example Site' ); - expect( getToolbarPageTitle( ' ', 'Example Site' ) ).toBe( 'Example Site' ); - expect( getToolbarPageTitle( null, '' ) ).toBe( 'Site preview' ); - } ); -} ); - describe( 'getPathFromPreviewUrl', () => { it( 'extracts the path, search, and hash for same-origin urls', () => { expect( diff --git a/apps/ui/src/components/site-preview/index.tsx b/apps/ui/src/components/site-preview/index.tsx index 52aa6f4158..19be4381ec 100644 --- a/apps/ui/src/components/site-preview/index.tsx +++ b/apps/ui/src/components/site-preview/index.tsx @@ -1,7 +1,8 @@ import { __ } from '@wordpress/i18n'; import { chevronLeft, chevronRight, external, pencil } from '@wordpress/icons'; import { ariaKeyShortcut, displayShortcut, isAppleOS, isKeyboardEvent } from '@wordpress/keycodes'; -import { Button, IconButton, Tooltip } from '@wordpress/ui'; +import { Button, IconButton } from '@wordpress/ui'; +import { clsx } from 'clsx'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useConnector } from '@/data/core'; import { useIsSiteStarting, useStartSite } from '@/data/queries/use-sites'; @@ -14,12 +15,21 @@ import { INSPECTOR_COMMAND_EVENT, INSPECTOR_PAGE_SCRIPT, } from './inspector-script'; +import { + DATABASE_HOME_PATH, + getPathFromPreviewUrl, + getPreviewRealm, + getRealmNavigationPath, + PreviewAddressBar, + REALM_SHORTCUT_KEYS, + type PreviewRealm, +} from './location-omnibox'; import styles from './style.module.css'; import type { Annotation } from './types'; import type { SiteDetails } from '@/data/core'; -import type { ReactElement } from 'react'; export type { Annotation } from './types'; +export { getPathFromPreviewUrl } from './location-omnibox'; interface SitePreviewProps { site: SiteDetails; @@ -119,6 +129,27 @@ const EMPTY_INSPECTOR_STATE: InspectorState = { annotationCount: 0, }; +// Where each realm segment lands before its per-realm memory has anything +// better: site root, WP Admin dashboard, and phpMyAdmin's WordPress database. +const DEFAULT_REALM_PATHS: Record< PreviewRealm, string > = { + frontend: '/', + admin: '/wp-admin/', + database: DATABASE_HOME_PATH, +}; + +// Whether the address bar shows the Database segment. Off unless explicitly +// enabled — the phpMyAdmin companion isn't available for every site. +const PREVIEW_SHOW_DATABASE_TAB_STORAGE_KEY = 'studio:preview-show-database-tab'; + +function getStoredShowDatabaseTab(): boolean { + try { + // Only an explicit "true" shows the tab; anything else hides it. + return window.localStorage.getItem( PREVIEW_SHOW_DATABASE_TAB_STORAGE_KEY ) === 'true'; + } catch { + return false; + } +} + function safeWebviewBoolean( webview: WebviewTag | null, method: 'canGoBack' | 'canGoForward' ) { try { return typeof webview?.[ method ] === 'function' ? Boolean( webview[ method ]() ) : false; @@ -208,23 +239,21 @@ export function getBrowserShortcutCommand( return null; } -function isBrowserShortcutCommand( command: unknown ): command is BrowserShortcutCommandType { - return command === 'back' || command === 'forward' || command === 'reload'; +// ⌘1/⌘2/⌘3 (Ctrl elsewhere) select the address bar's realm segments. +function getRealmShortcut( event: globalThis.KeyboardEvent ): PreviewRealm | null { + if ( event.defaultPrevented || event.repeat ) { + return null; + } + for ( const realm of Object.keys( REALM_SHORTCUT_KEYS ) as PreviewRealm[] ) { + if ( isKeyboardEvent.primary( event, REALM_SHORTCUT_KEYS[ realm ] ) ) { + return realm; + } + } + return null; } -function ToolbarTooltip( { - label, - children, -}: { - label: string; - children: ReactElement< Record< string, unknown > >; -} ) { - return ( - - - }>{ label } - - ); +function isBrowserShortcutCommand( command: unknown ): command is BrowserShortcutCommandType { + return command === 'back' || command === 'forward' || command === 'reload'; } function areBrowserStatesEqual( a: BrowserNavigationState, b: BrowserNavigationState ) { @@ -250,6 +279,7 @@ export function SitePreview( { const isStarting = useIsSiteStarting( site.id ); const siteUrl = getSiteUrl( site ); const canPreview = site.running; + const canUseWebview = isElectron(); const windowControls = useWindowControlsOverlay(); const trafficLightSpace = useTrafficLightSpace(); const previewUrl = `${ siteUrl }${ getSafePath( path ) }`; @@ -258,10 +288,13 @@ export function SitePreview( { const [ browserCommand, setBrowserCommand ] = useState< BrowserCommand | null >( null ); const [ inspectorState, setInspectorState ] = useState< InspectorState >( EMPTY_INSPECTOR_STATE ); const [ inspectorCommand, setInspectorCommand ] = useState< InspectorCommand | null >( null ); + // Whether the address bar shows the Database segment (global preference; + // the setting UI ships with the preview's view-settings menu). + const [ showDatabaseTab ] = useState( getStoredShowDatabaseTab ); const rootRef = useRef< HTMLElement | null >( null ); + const locationRef = useRef< HTMLDivElement | null >( null ); const commandIdRef = useRef( 0 ); const canAnnotate = canPreview && inspectorState.ready; - const pageTitle = getToolbarPageTitle( browserState.title, site.name ); const progress = browserState.loading ? Math.max( browserState.progress, 0.12 ) : browserState.progress; @@ -292,6 +325,43 @@ export function SitePreview( { setInspectorCommand( { id: commandIdRef.current, type } ); }, [] ); + // Realm segments (front end / WP Admin / database). Each realm remembers + // where you last were: flipping to WP Admin and back returns to the exact + // front-end page, and vice versa. Admin targets go through the site's + // /studio-auto-login endpoint so they never land on the login form. + const lastRealmPathsRef = useRef< Record< PreviewRealm, string > >( { + ...DEFAULT_REALM_PATHS, + } ); + useEffect( () => { + // Reset the per-realm memory when the preview moves to another site. + lastRealmPathsRef.current = { ...DEFAULT_REALM_PATHS }; + }, [ site.id ] ); + useEffect( () => { + const safePath = getSafePath( path ); + // Auto-login is a transient hop, not a place to return to. + if ( safePath.startsWith( '/studio-auto-login' ) ) { + return; + } + lastRealmPathsRef.current[ getPreviewRealm( safePath ) ] = safePath; + }, [ path ] ); + const handleSwitchRealm = useCallback( + ( realm: PreviewRealm ) => { + // The database realm is unreachable while its tab is hidden — ignore + // clicks (there is none) and the ⌘3 shortcut. + if ( realm === 'database' && ! showDatabaseTab ) { + return; + } + // Re-selecting the active realm (e.g. via its shortcut) is a no-op — + // don't bounce the current page through another auto-login hop. + if ( getPreviewRealm( getSafePath( path ) ) === realm ) { + return; + } + const target = lastRealmPathsRef.current[ realm ]; + onPathChange?.( getRealmNavigationPath( target, siteUrl ) ); + }, + [ onPathChange, path, showDatabaseTab, siteUrl ] + ); + const browserShortcuts = useMemo( () => ( { back: getNavigationShortcutDescriptor( 'back' ), @@ -306,16 +376,18 @@ export function SitePreview( { setInspectorState( EMPTY_INSPECTOR_STATE ); }, [ site.id ] ); - // Browser shortcuts (⌘R / ⌘[ / ⌘] / ⌘←/⌘→) pressed while focus is in the host - // document. Shortcuts pressed inside the guest page are forwarded by the - // inspector script through the console bridge instead. + // Browser shortcuts (⌘R / ⌘[ / ⌘] / ⌘←/⌘→) and the ⌘1/⌘2/⌘3 realm switches + // pressed while focus is in the host document. Shortcuts pressed inside the + // guest page are forwarded by the inspector script through the console + // bridge instead. useEffect( () => { if ( ! canPreview || collapsed ) { return; } const handleKeyDown = ( event: globalThis.KeyboardEvent ) => { const command = getBrowserShortcutCommand( event ); - if ( ! command ) { + const realm = command ? null : getRealmShortcut( event ); + if ( ! command && ! realm ) { return; } const activeElement = document.activeElement; @@ -328,12 +400,16 @@ export function SitePreview( { } event.preventDefault(); event.stopPropagation(); - sendBrowserCommand( command ); + if ( command ) { + sendBrowserCommand( command ); + } else if ( realm ) { + handleSwitchRealm( realm ); + } }; document.addEventListener( 'keydown', handleKeyDown, { capture: true } ); return () => document.removeEventListener( 'keydown', handleKeyDown, { capture: true } ); - }, [ canPreview, collapsed, sendBrowserCommand ] ); + }, [ canPreview, collapsed, handleSwitchRealm, sendBrowserCommand ] ); return (