diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index bf29fd458c..ccbe9574a2 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -2742,4 +2742,30 @@ export async function clearWebviewCache( await getOwnedWebviewContents( event, webContentsId ).session.clearCache(); } +export async function getWebviewNavigationHistory( + event: IpcMainInvokeEvent, + webContentsId: number +): Promise< { + activeIndex: number; + entries: { index: number; title: string; url: string }[]; +} > { + const history = getOwnedWebviewContents( event, webContentsId ).navigationHistory; + return { + activeIndex: history.getActiveIndex(), + entries: history.getAllEntries().map( ( entry, index ) => ( { + index, + title: entry.title, + url: entry.url, + } ) ), + }; +} + +export async function goToWebviewNavigationHistoryEntry( + event: IpcMainInvokeEvent, + webContentsId: number, + index: number +): Promise< void > { + getOwnedWebviewContents( event, webContentsId ).navigationHistory.goToIndex( index ); +} + export { showTextContextMenu } from 'src/text-context-menu'; diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index c250fa2752..4f1d1ca3fb 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -175,6 +175,10 @@ const api: IpcApi = { setWebviewViewport: ( webContentsId, viewport ) => ipcRendererInvoke( 'setWebviewViewport', webContentsId, viewport ), clearWebviewCache: ( webContentsId ) => ipcRendererInvoke( 'clearWebviewCache', webContentsId ), + getWebviewNavigationHistory: ( webContentsId ) => + ipcRendererInvoke( 'getWebviewNavigationHistory', webContentsId ), + goToWebviewNavigationHistoryEntry: ( webContentsId, index ) => + ipcRendererInvoke( 'goToWebviewNavigationHistoryEntry', webContentsId, index ), isFullscreen: () => ipcRendererInvoke( 'isFullscreen' ), getAllCustomDomains: () => ipcRendererInvoke( 'getAllCustomDomains' ), saveUserTerminal: ( preferredTerminal ) => diff --git a/apps/ui/src/components/open-in-menu/index.test.tsx b/apps/ui/src/components/open-in-menu/index.test.tsx index 3d3a6c327c..9e051d14d5 100644 --- a/apps/ui/src/components/open-in-menu/index.test.tsx +++ b/apps/ui/src/components/open-in-menu/index.test.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { captureException } from '@studio/common/lib/error-reporting'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; @@ -209,7 +209,9 @@ describe( 'OpenInMenu', () => { it( 'defaults the split action to the browser', () => { renderMenu( { running: true } ); - fireEvent.click( screen.getByRole( 'button', { name: 'Open in Browser' } ) ); + const defaultAction = screen.getByRole( 'button', { name: 'Open in Browser' } ); + expect( defaultAction ).toHaveTextContent( 'Browser' ); + fireEvent.click( defaultAction ); expect( openSiteUrl ).toHaveBeenCalledWith( 'site-1', BROWSER_PATH ); } ); @@ -245,7 +247,9 @@ describe( 'OpenInMenu', () => { 'terminal' ); - fireEvent.click( screen.getByRole( 'button', { name: 'Open in Terminal' } ) ); + const defaultAction = screen.getByRole( 'button', { name: 'Open in Terminal' } ); + expect( defaultAction ).toHaveTextContent( 'Terminal' ); + fireEvent.click( defaultAction ); expect( openSiteInTerminal ).toHaveBeenCalledTimes( 2 ); } ); @@ -280,7 +284,7 @@ function renderMenu( overrides: Partial< SiteDetails > = {}, browserPath: string } function destination( label: string | RegExp ): HTMLElement { - return screen.getByText( label ).closest( 'button' )!; + return within( screen.getByRole( 'menu' ) ).getByRole( 'button', { name: label } ); } function createSite( overrides: Partial< SiteDetails > = {} ): SiteDetails { diff --git a/apps/ui/src/components/open-in-menu/index.tsx b/apps/ui/src/components/open-in-menu/index.tsx index 2d48ea4692..949ec52f70 100644 --- a/apps/ui/src/components/open-in-menu/index.tsx +++ b/apps/ui/src/components/open-in-menu/index.tsx @@ -88,6 +88,7 @@ export function OpenInMenu( { } > + { lastUsedDestination.label } }> { actionLabel } diff --git a/apps/ui/src/components/site-preview/address-bar.module.css b/apps/ui/src/components/site-preview/address-bar.module.css index 3e372f69ea..a841e6deb9 100644 --- a/apps/ui/src/components/site-preview/address-bar.module.css +++ b/apps/ui/src/components/site-preview/address-bar.module.css @@ -1,264 +1,121 @@ -/* Segmented address pill: one segment per realm (front end / WP Admin / - database). The active segment carries the realm name and opens the - omnibox; inactive segments collapse to icon tabs that flip realms. A - filled indicator slides underneath the selected segment (positioned by - the component from measurements). The toolbar's `.header button` rule - opts the buttons out of the window drag region. */ -.segments { - position: relative; +.addressBar { + display: flex; box-sizing: border-box; - display: inline-flex; - align-items: center; - gap: 2px; - min-width: 0; - max-width: min(420px, 100%); + width: 100%; + flex: 1 1 auto; + min-width: 80px; height: 32px; - /* 2px + the 1px border = 3px visual inset around the selected fill. */ - padding: 2px; - border: 1px solid var(--wpds-color-stroke-surface-neutral); - border-radius: 999px; - background-color: var(--wpds-color-bg-interactive-neutral-weak); -} - -/* The selected-segment fill: solid, sliding between segments and stretching - as the active title expands. `left: 0` + translateX keeps the motion on - the compositor where possible. */ -.indicator { - position: absolute; - top: 2px; - bottom: 2px; - left: 0; - border-radius: 999px; - /* Neutral mid-gray fill that adapts to both color schemes (a solid - token here reads too heavy: near-black in light, near-white in dark). */ - background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent); - transition: - transform 200ms ease, - width 200ms ease; -} - -.segment { - position: relative; - z-index: 1; - box-sizing: border-box; - display: inline-flex; - flex: 0 1 auto; - min-width: 0; - align-items: center; - justify-content: center; - height: 26px; margin: 0; - padding: 0 10px; - appearance: none; + padding-inline: var(--wpds-dimension-padding-lg); + align-items: center; border: 0; - border-radius: 999px; - background: transparent; - color: var(--wpds-color-fg-content-neutral-weak); - font-family: inherit; - font-size: var(--wpds-typography-font-size-xs); - font-weight: 600; - line-height: 16px; - cursor: pointer; - transition: color 200ms ease; -} - -.segment:not([data-active='true']):hover { - color: var(--wpds-color-fg-content-neutral); -} - -/* Text on the indicator fill. */ -.segment[data-active='true'] { - color: var(--wpds-color-fg-content-neutral); + border-radius: var(--wpds-border-radius-lg); + background: var(--wpds-color-bg-surface-neutral-weak); + overflow: hidden; } -.segment:focus-visible { - outline: 2px solid var(--wpds-color-stroke-focus-brand); +.addressBar:focus-within { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); outline-offset: 1px; } -.segmentIcon { +.siteIcon { display: inline-flex; width: 16px; height: 16px; - flex-shrink: 0; + flex: 0 0 16px; align-items: center; justify-content: center; } -.segmentIcon svg { - display: block; - width: 100%; - height: 100%; +.wordpressIcon { + color: var(--wpds-color-fg-content-neutral); + fill: currentColor; } -/* Titles render in every segment but collapse when inactive, so selection - morphs the pill instead of popping between layouts. */ -.segmentTitle { +.input { + box-sizing: border-box; + width: 100%; min-width: 0; - max-width: 0; - margin-inline: 0; - overflow: hidden; - opacity: 0; - text-overflow: ellipsis; - white-space: nowrap; - transition: - max-width 200ms ease, - margin 200ms ease, - opacity 150ms ease; -} - -/* The extra inline-end margin pads the text away from the pill's rounded - right edge; living on the title, it collapses with it. */ -.segment[data-active='true'] .segmentTitle { - max-width: 200px; - margin-inline: 6px 3px; - opacity: 1; -} - -/* Narrow toolbars: every segment goes icon-only (the active pill keeps its - fill, just not its name) so the tabs never crowd the toolbar's buttons. */ -@container studio-preview-toolbar (max-width: 680px) { - .segment[data-active='true'] .segmentTitle { - max-width: 0; - margin-inline: 0; - opacity: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .indicator, - .segment, - .segmentTitle { - transition: none; - } + padding: 0 var(--wpds-dimension-padding-sm) 0 var(--wpds-dimension-padding-xs); + border: 0; + background: transparent; + color: var(--wpds-color-fg-content-neutral); + font: inherit; + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + outline: 0; } -/* Same stacking rationale as the shared menu popup: the portal competes - with app chrome that creates stacking contexts. */ -.positioner { +.shortcutsPositioner { z-index: 100; - outline: none; } -/* Popup chrome matches the shared menu popup (surface-strong, weak stroke, - hairline ring, elevation) at address-bar width. */ -.popup { +.shortcutsPopup { box-sizing: border-box; - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-padding-xs); - width: min(440px, calc(100vw - 32px)); + width: var(--anchor-width); + max-width: calc(100vw - 16px); padding: var(--wpds-dimension-padding-xs); - background: var(--wpds-color-bg-surface-neutral-strong); border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); border-radius: var(--wpds-border-radius-lg); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent), - var(--wpds-elevation-md); - color: var(--wpds-color-fg-content-neutral); -} - -.input { - box-sizing: border-box; - width: 100%; - padding: 6px var(--wpds-dimension-padding-sm); - border: 0; - border-radius: var(--wpds-border-radius-sm); - background: var(--wpds-color-bg-surface-neutral); + background: var(--wpds-color-bg-surface-neutral-strong); + box-shadow: var(--wpds-elevation-md); color: var(--wpds-color-fg-content-neutral); - font-family: inherit; - font-size: var(--wpds-typography-font-size-sm); - line-height: var(--wpds-typography-line-height-sm); - outline: none; } -.input::placeholder { - color: var(--wpds-color-fg-content-neutral-weak); -} - -.list { +.shortcutsList { display: flex; flex-direction: column; - gap: 2px; - max-height: 320px; - overflow-y: auto; + gap: var(--wpds-dimension-padding-xs); } -.group { +.shortcutsSection { display: flex; flex-direction: column; - gap: 2px; + margin-block-start: var(--wpds-dimension-padding-xs); + padding-block-start: var(--wpds-dimension-padding-xs); + border-block-start: var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-surface-neutral-weak); } -/* Section heading over a group of destinations (e.g. "Front end", - "WordPress"), matching the shared menu's group-label treatment. */ -.groupLabel { - padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0; +.shortcutsLabel { + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); color: var(--wpds-color-fg-content-neutral-weak); font-size: var(--wpds-typography-font-size-xs); - line-height: var(--wpds-typography-line-height-sm); - font-weight: var(--wpds-typography-font-weight-medium); + line-height: var(--wpds-typography-line-height-xs); } -.item { - display: flex; - align-items: center; +.shortcut { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; gap: var(--wpds-dimension-gap-sm); + width: 100%; padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); + align-items: center; + border: 0; border-radius: var(--wpds-border-radius-sm); + background: transparent; color: var(--wpds-color-fg-content-neutral); + font: inherit; font-size: var(--wpds-typography-font-size-sm); line-height: var(--wpds-typography-line-height-sm); + text-align: start; cursor: var(--wpds-cursor-control); - outline: none; - user-select: none; } -.item[data-highlighted], -.item:hover { +.shortcut:hover, +.shortcut:focus-visible { background: var(--wpds-color-bg-surface-neutral); + outline: none; } -.itemIcon { - display: inline-flex; - flex-shrink: 0; - color: var(--wpds-color-fg-content-neutral-weak); -} - -/* Destination rows carry WordPress-surface icons; a touch stronger than - content-result icons so the two kinds scan apart in mixed results. */ -.itemIconDestination { - color: var(--wpds-color-fg-content-neutral); -} - -.itemTitle { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.shortcutSiteIcon { + width: 18px; + height: 18px; } -.itemPath { - flex: 1 1 auto; - min-width: 0; +.shortcutUrl { overflow: hidden; - color: var(--wpds-color-fg-content-neutral-weak); - font-size: var(--wpds-typography-font-size-xs); - text-align: right; text-overflow: ellipsis; white-space: nowrap; } - -.status { - padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); - color: var(--wpds-color-fg-content-neutral-weak); - font-size: var(--wpds-typography-font-size-xs); -} - -/* The destination the preview is currently on — same fill language as the - active segment's indicator, held through hover. */ -.itemCurrent, -.itemCurrent:hover { - background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent); -} diff --git a/apps/ui/src/components/site-preview/address-bar.test.tsx b/apps/ui/src/components/site-preview/address-bar.test.tsx index 0476870ef6..f650c14b43 100644 --- a/apps/ui/src/components/site-preview/address-bar.test.tsx +++ b/apps/ui/src/components/site-preview/address-bar.test.tsx @@ -1,9 +1,7 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { fireEvent, render, screen, within } from '@testing-library/react'; -import { Tooltip } from '@wordpress/ui'; -import { describe, expect, it, vi } from 'vitest'; -import { useConnector } from '@/data/core'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + DATABASE_HOME_PATH, getPreviewRealm, getRealmNavigationPath, getRealmOpenEvent, @@ -13,14 +11,7 @@ import { import type { SiteDetails } from '@/data/core'; import type { Mock } from 'vitest'; -vi.mock( '@/data/core', () => ( { - useConnector: vi.fn(), -} ) ); - -const useConnectorMock = vi.mocked( useConnector ); - const SITE_URL = 'http://localhost:8881'; - const SITE = { id: 'site-1', name: 'Example Site', @@ -34,100 +25,47 @@ function autoLoginPath( target: string ) { return `/studio-auto-login?redirect_to=${ encodeURIComponent( `${ SITE_URL }${ target }` ) }`; } -function createSearchResponse( results: unknown[] ) { - return { - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( results ), - url: '', - }; -} - function renderAddressBar( { - fetchSiteRest = vi.fn().mockResolvedValue( createSearchResponse( [] ) ), - onNavigate = vi.fn(), - onSwitchRealm = vi.fn(), path = '/', - searchEnabled = true, site = SITE, + onNavigate = vi.fn< ( path: string ) => void >(), + onSwitchRealm = vi.fn< ( realm: 'frontend' | 'admin' | 'database' ) => void >(), }: { - fetchSiteRest?: Mock; - onNavigate?: Mock; - onSwitchRealm?: Mock; path?: string; - searchEnabled?: boolean; site?: SiteDetails; + onNavigate?: Mock< ( path: string ) => void >; + onSwitchRealm?: Mock< ( realm: 'frontend' | 'admin' | 'database' ) => void >; } = {} ) { - useConnectorMock.mockReturnValue( { fetchSiteRest } as never ); - const queryClient = new QueryClient( { - defaultOptions: { queries: { retry: false } }, - } ); - const renderResult = render( - - - - - + const result = render( + ); - return { fetchSiteRest, onNavigate, onSwitchRealm, ...renderResult }; -} - -async function openOmnibox( activeRealmTitle = 'Example Site' ) { - // Base UI gives the trigger button `role="combobox"`, whose accessible - // name ignores its contents — locate it by its visible realm name instead. - fireEvent.click( screen.getByText( activeRealmTitle ) ); - return ( await screen.findByLabelText( 'Address and search' ) ) as HTMLInputElement; + return { ...result, onNavigate, onSwitchRealm }; } describe( 'parseOmniboxInput', () => { it( 'returns null for empty input', () => { expect( parseOmniboxInput( '', SITE_URL ) ).toBeNull(); - expect( parseOmniboxInput( ' ', SITE_URL ) ).toBeNull(); } ); - it( 'extracts the path from same-origin urls', () => { - expect( parseOmniboxInput( 'http://localhost:8881/wp-admin/?page=1#top', SITE_URL ) ).toEqual( { + it( 'extracts same-origin paths and rejects cross-origin urls', () => { + expect( parseOmniboxInput( `${ SITE_URL }/wp-admin/?page=1#top`, SITE_URL ) ).toEqual( { type: 'path', path: '/wp-admin/?page=1#top', } ); - } ); - - it( 'returns null for cross-origin urls', () => { expect( parseOmniboxInput( 'https://example.com/about', SITE_URL ) ).toBeNull(); } ); - it( 'treats leading-slash input as a path', () => { - expect( parseOmniboxInput( '/sample-page', SITE_URL ) ).toEqual( { - type: 'path', - path: '/sample-page', - } ); - } ); - - it( 'adds a leading slash to path-like input', () => { + it( 'normalizes paths and treats words as searches', () => { expect( parseOmniboxInput( 'wp-admin/plugins.php', SITE_URL ) ).toEqual( { type: 'path', path: '/wp-admin/plugins.php', } ); - expect( parseOmniboxInput( '?p=123', SITE_URL ) ).toEqual( { - type: 'path', - path: '/?p=123', - } ); - } ); - - it( 'treats plain words and phrases as search terms', () => { - expect( parseOmniboxInput( 'pricing', SITE_URL ) ).toEqual( { - type: 'search', - term: 'pricing', - } ); expect( parseOmniboxInput( 'hello world', SITE_URL ) ).toEqual( { type: 'search', term: 'hello world', @@ -135,457 +73,135 @@ describe( 'parseOmniboxInput', () => { } ); } ); -describe( 'getPreviewRealm', () => { - it( 'classifies front-end paths', () => { +describe( 'preview realms', () => { + it( 'classifies regular and auto-login paths', () => { expect( getPreviewRealm( '/' ) ).toBe( 'frontend' ); - expect( getPreviewRealm( '/about/?preview=1' ) ).toBe( 'frontend' ); - } ); - - it( 'classifies wp-admin paths', () => { expect( getPreviewRealm( '/wp-admin/' ) ).toBe( 'admin' ); - expect( getPreviewRealm( '/wp-admin/site-editor.php?path=%2Fpatterns' ) ).toBe( 'admin' ); - } ); - - it( 'classifies phpMyAdmin paths', () => { - expect( getPreviewRealm( '/phpmyadmin/index.php?route=/database/structure' ) ).toBe( - 'database' - ); - } ); - - it( 'classifies auto-login hops by their redirect target', () => { + expect( getPreviewRealm( DATABASE_HOME_PATH ) ).toBe( 'database' ); expect( getPreviewRealm( autoLoginPath( '/wp-admin/plugins.php' ) ) ).toBe( 'admin' ); - expect( getPreviewRealm( autoLoginPath( '/phpmyadmin/index.php' ) ) ).toBe( 'database' ); - expect( getPreviewRealm( autoLoginPath( '/about/' ) ) ).toBe( 'frontend' ); } ); -} ); -describe( 'getRealmOpenEvent', () => { - it( 'maps each realm to its site-open Tracks event', () => { + it( 'maps realms to open events', () => { expect( getRealmOpenEvent( 'frontend' ) ).toBe( 'studio_site_open_in_browser' ); expect( getRealmOpenEvent( 'admin' ) ).toBe( 'studio_site_open_wp_admin' ); expect( getRealmOpenEvent( 'database' ) ).toBe( 'studio_site_open_phpmyadmin' ); } ); -} ); - -describe( 'getRealmNavigationPath', () => { - it( 'passes non-admin paths through untouched', () => { - expect( getRealmNavigationPath( '/about/', SITE_URL ) ).toBe( '/about/' ); - expect( getRealmNavigationPath( '/phpmyadmin/index.php', SITE_URL ) ).toBe( - '/phpmyadmin/index.php' - ); - } ); - it( 'routes wp-admin paths through auto-login', () => { + it( 'routes admin paths through auto-login', () => { expect( getRealmNavigationPath( '/wp-admin/plugins.php', SITE_URL ) ).toBe( autoLoginPath( '/wp-admin/plugins.php' ) ); + expect( getRealmNavigationPath( '/about/', SITE_URL ) ).toBe( '/about/' ); } ); } ); describe( 'PreviewAddressBar', () => { - it( 'shows the configured site icon in the front-end segment', () => { - const siteIcon = 'data:image/png;base64,c2l0ZS1pY29u'; - const { container } = renderAddressBar( { site: { ...SITE, siteIcon } } ); - - expect( container.querySelector( `img[src="${ siteIcon }"]` ) ).toBeInTheDocument(); - } ); - - it( 'shows the generated fallback when the site has no icon', () => { - const { container } = renderAddressBar( { site: { ...SITE, siteIcon: null } } ); - const frontEndSegment = screen.getByText( SITE.name ).closest( 'button' ); - - expect( frontEndSegment?.querySelector( 'img' ) ).not.toBeInTheDocument(); - expect( - frontEndSegment?.querySelector< HTMLElement >( '[style*="--site-icon-color-a"]' ) - ).toBeInTheDocument(); - expect( container.querySelectorAll( 'button' ) ).toHaveLength( 3 ); - } ); - - it( 'opens with the current path prefilled and selected', async () => { - renderAddressBar( { path: '/wp-admin/' } ); - - const input = ( await openOmnibox( 'WordPress' ) ) as HTMLInputElement; - - expect( input.value ).toBe( '/wp-admin/' ); - expect( input.selectionStart ).toBe( 0 ); - expect( input.selectionEnd ).toBe( '/wp-admin/'.length ); - } ); - - it( 'shows one segment per realm and switches realms from inactive segments', () => { - const { onSwitchRealm } = renderAddressBar( { path: '/' } ); - - // The front end is active (it carries the page title); the other two - // realms render as labelled icon segments. - fireEvent.click( screen.getByRole( 'button', { name: 'View WP Admin' } ) ); - expect( onSwitchRealm ).toHaveBeenCalledWith( 'admin' ); - - fireEvent.click( screen.getByRole( 'button', { name: 'View database' } ) ); - expect( onSwitchRealm ).toHaveBeenCalledWith( 'database' ); - - expect( - screen.queryByRole( 'button', { name: 'View site front end' } ) - ).not.toBeInTheDocument(); - } ); - - it( 'always offers the database segment', () => { - renderAddressBar( { path: '/' } ); - - expect( screen.getByRole( 'button', { name: 'View WP Admin' } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: 'View database' } ) ).toBeInTheDocument(); - } ); - - it( 'marks the segment matching the current path as active', () => { - renderAddressBar( { path: '/wp-admin/plugins.php' } ); - - expect( screen.getByRole( 'button', { name: 'View site front end' } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: 'View database' } ) ).toBeInTheDocument(); - expect( screen.queryByRole( 'button', { name: 'View WP Admin' } ) ).not.toBeInTheDocument(); - } ); - - it( 'rests on the WordPress destinations and navigates a picked one via auto-login', async () => { - const { onNavigate } = renderAddressBar( { path: '/' } ); - - await openOmnibox(); - - // Non-block-theme destinations: the Customizer group and the content - // group (WP Admin and the database live in the segments, not the list). - fireEvent.click( await screen.findByText( 'Customizer' ) ); - - expect( onNavigate ).toHaveBeenCalledWith( autoLoginPath( '/wp-admin/customize.php' ) ); - } ); - - it( 'groups the zero state into Front end and WordPress destinations', async () => { - renderAddressBar( { path: '/' } ); - - await openOmnibox(); - - // Scope to the dropdown: "WordPress" is also the WP Admin segment title. - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getByText( 'Front end' ) ).toBeInTheDocument(); - expect( within( list ).getByText( 'WordPress' ) ).toBeInTheDocument(); - // The static front-end rows are always offered. - expect( within( list ).getByText( 'Home' ) ).toBeInTheDocument(); - expect( within( list ).getByText( '404 page' ) ).toBeInTheDocument(); - } ); - - it( 'offers the latest post and a page as real front-end permalinks', async () => { - const fetchSiteRest = vi - .fn() - .mockImplementation( ( _siteId: string, request: { path: string } ) => { - if ( request.path.includes( '/wp/v2/posts' ) ) { - return Promise.resolve( - createSearchResponse( [ - { - id: 5, - link: 'http://127.0.0.1:8881/hello-world/', - title: { rendered: 'Hello World' }, - }, - ] ) - ); - } - if ( request.path.includes( '/wp/v2/pages' ) ) { - return Promise.resolve( - createSearchResponse( [ - { id: 2, link: 'http://127.0.0.1:8881/about/', title: { rendered: 'About' } }, - ] ) - ); - } - return Promise.resolve( createSearchResponse( [] ) ); - } ); - const { onNavigate } = renderAddressBar( { fetchSiteRest, path: '/' } ); - - await openOmnibox(); - fireEvent.click( await screen.findByText( 'Hello World' ) ); - - expect( onNavigate ).toHaveBeenCalledWith( '/hello-world/' ); - } ); - - it( 'dedupes content results that already appear as permalink rows', async () => { - const fetchSiteRest = vi - .fn() - .mockImplementation( ( _siteId: string, request: { path: string } ) => { - if ( request.path.includes( '/wp/v2/posts' ) ) { - return Promise.resolve( - createSearchResponse( [ - { - id: 5, - link: 'http://127.0.0.1:8881/hello-world/', - title: { rendered: 'Hello World' }, - }, - ] ) - ); - } - if ( request.path.includes( '/wp/v2/search' ) ) { - return Promise.resolve( - createSearchResponse( [ - { - id: 5, - title: 'Hello World', - url: 'http://127.0.0.1:8881/hello-world/', - type: 'post', - subtype: 'post', - }, - { - id: 6, - title: 'Hello Again', - url: 'http://127.0.0.1:8881/hello-again/', - type: 'post', - subtype: 'post', - }, - ] ) - ); - } - return Promise.resolve( createSearchResponse( [] ) ); - } ); - renderAddressBar( { fetchSiteRest, path: '/' } ); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: 'hello' } } ); - - // The latest post matches both as a permalink row and a search result. - await screen.findByText( 'Hello Again' ); - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getAllByRole( 'option', { name: /Hello World/ } ) ).toHaveLength( 1 ); - } ); - - it( 'marks the destination matching the current path as current', async () => { - renderAddressBar( { path: '/wp-admin/upload.php' } ); - - await openOmnibox( 'WordPress' ); - - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getByRole( 'option', { name: /Media Library/ } ) ).toHaveAttribute( - 'aria-current', - 'page' - ); - expect( within( list ).getByRole( 'option', { name: /Home/ } ) ).not.toHaveAttribute( - 'aria-current' - ); - } ); - - it( 'marks only the most specific destination when pathnames collide', async () => { - // Posts and Pages both live on edit.php; only Pages pins post_type. - renderAddressBar( { path: '/wp-admin/edit.php?post_type=page' } ); - - await openOmnibox( 'WordPress' ); - - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getByRole( 'option', { name: /Pages/ } ) ).toHaveAttribute( - 'aria-current', - 'page' - ); - expect( within( list ).getByRole( 'option', { name: /Posts/ } ) ).not.toHaveAttribute( - 'aria-current' + beforeEach( () => window.localStorage.clear() ); + + it( 'shows the current complete URL and follows path updates', () => { + const { rerender } = renderAddressBar( { path: '/about/?preview=1' } ); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); + expect( input ).toHaveValue( `${ SITE_URL }/about/?preview=1` ); + + rerender( + ); + expect( input ).toHaveValue( `${ SITE_URL }/contact/` ); } ); - it( 'matches site-editor destinations after WP Admin rewrites their URLs', async () => { - // The site editor renames its `path` param to `p` and appends extras. - renderAddressBar( { - path: '/wp-admin/site-editor.php?p=%2Fnavigation&canvas=edit', - site: { ...SITE, themeDetails: { isBlockTheme: true } } as SiteDetails, - } ); - - await openOmnibox( 'WordPress' ); - - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getByRole( 'option', { name: /Navigation/ } ) ).toHaveAttribute( - 'aria-current', - 'page' - ); - expect( within( list ).getByRole( 'option', { name: /Site Editor/ } ) ).not.toHaveAttribute( - 'aria-current' - ); - } ); - - it.each( [ - [ '/wp-admin/site-editor.php?p=%2Ftemplate', /Templates/ ], - [ '/wp-admin/site-editor.php?p=%2Fpattern', /Patterns/ ], - [ '/wp-admin/site-editor.php?p=%2Fstyles&canvas=edit', /Styles/ ], - ] )( - 'matches renamed site-editor route slugs (%s)', - async ( currentPath: string, rowName: RegExp ) => { - renderAddressBar( { - path: currentPath, - site: { ...SITE, themeDetails: { isBlockTheme: true } } as SiteDetails, - } ); - - await openOmnibox( 'WordPress' ); - - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).getByRole( 'option', { name: rowName } ) ).toHaveAttribute( - 'aria-current', - 'page' - ); - expect( within( list ).getByRole( 'option', { name: /Site Editor/ } ) ).not.toHaveAttribute( - 'aria-current' - ); - } - ); - - it( 'offers no WP Admin or Database rows — their segments cover those realms', async () => { - renderAddressBar( { path: '/' } ); - - await openOmnibox(); - - const list = await screen.findByRole( 'listbox' ); - expect( within( list ).queryByRole( 'option', { name: /WP Admin/ } ) ).not.toBeInTheDocument(); - expect( within( list ).queryByRole( 'option', { name: /Database/ } ) ).not.toBeInTheDocument(); - } ); - - it( 'keeps typing focus in the input through the empty-results window', async () => { + it( 'selects the URL on focus', () => { renderAddressBar(); - - const input = await openOmnibox(); - // jsdom doesn't move focus on click-open the way the browser does. - input.focus(); - expect( input ).toHaveFocus(); - fireEvent.change( input, { target: { value: 'a' } } ); - expect( input ).toHaveFocus(); - fireEvent.change( input, { target: { value: 'as' } } ); - expect( input ).toHaveFocus(); - // Let the debounced search fire and settle with zero results. - await screen.findByText( 'No matches' ); - expect( input ).toHaveFocus(); - } ); - - it( 'navigates to a typed path on Enter without querying search', async () => { - const { fetchSiteRest, onNavigate } = renderAddressBar(); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: '/sample-page' } } ); - fireEvent.keyDown( input, { key: 'Enter' } ); - - expect( onNavigate ).toHaveBeenCalledWith( '/sample-page' ); - // Opening the omnibox fetches front-end links, but typing a path must - // not trigger the content search endpoint. - expect( fetchSiteRest ).not.toHaveBeenCalledWith( - 'site-1', - expect.objectContaining( { path: expect.stringContaining( '/wp/v2/search' ) } ) - ); + const input = screen.getByRole( 'textbox', { name: 'Address' } ) as HTMLInputElement; + fireEvent.focus( input ); + expect( input.selectionStart ).toBe( 0 ); + expect( input.selectionEnd ).toBe( input.value.length ); } ); - it( 'routes typed wp-admin paths through auto-login', async () => { + it( 'navigates paths and searches submitted from the address field', () => { const { onNavigate } = renderAddressBar(); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); - const input = await openOmnibox(); fireEvent.change( input, { target: { value: '/wp-admin/plugins.php' } } ); - fireEvent.keyDown( input, { key: 'Enter' } ); + fireEvent.submit( input.closest( 'form' )! ); + expect( onNavigate ).toHaveBeenLastCalledWith( autoLoginPath( '/wp-admin/plugins.php' ) ); - expect( onNavigate ).toHaveBeenCalledWith( autoLoginPath( '/wp-admin/plugins.php' ) ); + fireEvent.change( input, { target: { value: 'hello world' } } ); + fireEvent.submit( input.closest( 'form' )! ); + expect( onNavigate ).toHaveBeenLastCalledWith( '/?s=hello%20world' ); } ); - it( 'treats a retyped current path as a path, not the zero state', async () => { - const { onNavigate } = renderAddressBar( { path: '/sample-page/' } ); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: '/sample-page' } } ); - fireEvent.change( input, { target: { value: '/sample-page/' } } ); - - // Typing the exact current path must not resurrect the destinations. - expect( screen.queryByText( 'Front end' ) ).not.toBeInTheDocument(); - - fireEvent.keyDown( input, { key: 'Enter' } ); - expect( onNavigate ).toHaveBeenCalledWith( '/sample-page/' ); - } ); - - it( 'suggests destinations matching a typed path', async () => { + it( 'does not navigate to a cross-origin URL', () => { const { onNavigate } = renderAddressBar(); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: '/wp-admin/upl' } } ); - - fireEvent.click( await screen.findByRole( 'option', { name: /Media Library/ } ) ); - expect( onNavigate ).toHaveBeenCalledWith( autoLoginPath( '/wp-admin/upload.php' ) ); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); + fireEvent.change( input, { target: { value: 'https://example.com/' } } ); + fireEvent.submit( input.closest( 'form' )! ); + expect( onNavigate ).not.toHaveBeenCalled(); } ); - it( 'keeps Enter on the literal path while path suggestions are visible', async () => { - const { fetchSiteRest, onNavigate } = renderAddressBar(); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: '/wp-admin/upl' } } ); - await screen.findByRole( 'option', { name: /Media Library/ } ); - - fireEvent.keyDown( input, { key: 'Enter' } ); - expect( onNavigate ).toHaveBeenCalledWith( autoLoginPath( '/wp-admin/upl' ) ); - // Paths never hit the content search endpoint. - expect( fetchSiteRest ).not.toHaveBeenCalledWith( - 'site-1', - expect.objectContaining( { path: expect.stringContaining( '/wp/v2/search' ) } ) + it( 'puts the configured site icon inside the address field', () => { + const siteIcon = 'data:image/png;base64,c2l0ZS1pY29u'; + const { container } = renderAddressBar( { site: { ...SITE, siteIcon } } ); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); + expect( + input.closest( 'form' )?.querySelector( `img[src="${ siteIcon }"]` ) + ).toBeInTheDocument(); + expect( container.querySelectorAll( 'button' ) ).toHaveLength( 0 ); + } ); + + it( 'changes the address icon for WP Admin and Database', () => { + const { container, rerender } = renderAddressBar( { path: '/wp-admin/' } ); + expect( container.querySelector( '[data-realm="admin"]' ) ).toBeInTheDocument(); + expect( container.querySelector( '[data-realm="admin"] svg' ) ).toBeInTheDocument(); + + rerender( + ); + expect( container.querySelector( '[data-realm="database"]' ) ).toBeInTheDocument(); } ); - it( 'matches destinations while typing alongside content results', async () => { - const { onNavigate } = renderAddressBar(); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: 'media' } } ); + it( 'shows preview shortcuts from the address field', async () => { + const siteIcon = 'data:image/png;base64,c2hvcnRjdXQtaWNvbg=='; + const { onSwitchRealm } = renderAddressBar( { site: { ...SITE, siteIcon } } ); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); + fireEvent.click( input ); - fireEvent.click( await screen.findByText( 'Media Library' ) ); - - expect( onNavigate ).toHaveBeenCalledWith( autoLoginPath( '/wp-admin/upload.php' ) ); - } ); + expect( await screen.findByRole( 'button', { name: /Front end/ } ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: /WP Admin/ } ) ).toBeVisible(); + expect( document.querySelectorAll( `img[src="${ siteIcon }"]` ) ).toHaveLength( 2 ); + fireEvent.click( screen.getByRole( 'button', { name: /Database/ } ) ); - it( 'searches the site for typed terms and navigates to a clicked result', async () => { - const fetchSiteRest = vi - .fn() - .mockImplementation( ( _siteId: string, request: { path: string } ) => { - // Only the content-search endpoint returns a match; front-end link - // lookups stay empty so they don't blend into the search results. - if ( request.path.includes( '/wp/v2/search' ) ) { - return Promise.resolve( - createSearchResponse( [ - { - id: 12, - title: 'About & Team', - url: 'http://127.0.0.1:8881/about/', - type: 'post', - subtype: 'page', - }, - ] ) - ); - } - return Promise.resolve( createSearchResponse( [] ) ); - } ); - const { onNavigate } = renderAddressBar( { fetchSiteRest } ); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: 'about' } } ); - - const result = await screen.findByText( 'About & Team', {}, { timeout: 2000 } ); - expect( fetchSiteRest ).toHaveBeenCalledWith( - 'site-1', - expect.objectContaining( { - path: expect.stringContaining( '/wp/v2/search?search=about' ), - } ) + expect( onSwitchRealm ).toHaveBeenCalledWith( 'database' ); + await waitFor( () => + expect( screen.queryByRole( 'button', { name: /Database/ } ) ).not.toBeInTheDocument() ); - - fireEvent.click( result ); - - expect( onNavigate ).toHaveBeenCalledWith( '/about/' ); - } ); - - it( 'falls back to the site search page on Enter when there are no results', async () => { - const { onNavigate } = renderAddressBar(); - - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: 'nothing' } } ); - - await screen.findByText( 'No matches', {}, { timeout: 2000 } ); - fireEvent.keyDown( input, { key: 'Enter' } ); - - expect( onNavigate ).toHaveBeenCalledWith( '/?s=nothing' ); + fireEvent.focus( input ); + expect( screen.queryByRole( 'button', { name: /Database/ } ) ).not.toBeInTheDocument(); } ); - it( 'hides search entirely when disabled but still navigates typed paths', async () => { - const { fetchSiteRest, onNavigate } = renderAddressBar( { searchEnabled: false } ); + it( 'remembers submitted addresses per site and lists them as recent destinations', async () => { + const { unmount } = renderAddressBar(); + const input = screen.getByRole( 'textbox', { name: 'Address' } ); + fireEvent.change( input, { target: { value: '/about/' } } ); + fireEvent.submit( input.closest( 'form' )! ); + unmount(); - const input = await openOmnibox(); - fireEvent.change( input, { target: { value: 'pricing' } } ); + renderAddressBar(); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + const popup = await screen.findByRole( 'dialog', { name: 'Preview shortcuts' } ); - fireEvent.keyDown( input, { key: 'Enter' } ); - expect( onNavigate ).toHaveBeenCalledWith( '/?s=pricing' ); - expect( fetchSiteRest ).not.toHaveBeenCalled(); - expect( screen.queryByText( 'No matches' ) ).not.toBeInTheDocument(); + expect( within( popup ).getByText( 'Recent' ) ).toBeVisible(); + expect( within( popup ).getByRole( 'button', { name: `${ SITE_URL }/about/` } ) ).toBeVisible(); } ); } ); diff --git a/apps/ui/src/components/site-preview/address-bar.tsx b/apps/ui/src/components/site-preview/address-bar.tsx index 62581dc15b..b65494ea0e 100644 --- a/apps/ui/src/components/site-preview/address-bar.tsx +++ b/apps/ui/src/components/site-preview/address-bar.tsx @@ -1,24 +1,13 @@ -import { Autocomplete } from '@base-ui/react/autocomplete'; import { TRACKS_EVENTS, type TracksEventName } from '@studio/common/lib/record-tracks-event'; import { __ } from '@wordpress/i18n'; -import { help, home, page as pageIcon, post as postIcon, wordpress } from '@wordpress/icons'; -import { ariaKeyShortcut, displayShortcut } from '@wordpress/keycodes'; -import { privateApis } from '@wordpress/theme'; -import { Icon, Tooltip } from '@wordpress/ui'; -import { clsx } from 'clsx'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import motionStyles from '@/components/floating-surface-motion/style.module.css'; +import { Icon, wordpress } from '@wordpress/icons'; +import { Popover, VisuallyHidden } from '@wordpress/ui'; +import { useEffect, useRef, useState } from 'react'; import { SiteIcon } from '@/components/site-icon'; -import { useSiteFrontLinks } from '@/data/queries/use-site-front-links'; -import { useSiteSearch } from '@/data/queries/use-site-search'; -import { useCustomizeLinks } from '@/hooks/use-customize-links'; import { databaseIcon } from '@/lib/icons'; -import { unlock } from '@/lock-unlock'; import styles from './address-bar.module.css'; import type { SiteDetails } from '@/data/core'; -import type { KeyboardEvent as ReactKeyboardEvent, ReactElement, RefObject, SVGProps } from 'react'; - -const { ThemeProvider } = unlock( privateApis ); +import type { FormEvent } from 'react'; export function getPathFromPreviewUrl( url: string, baseUrl: string ) { try { @@ -33,24 +22,10 @@ export function getPathFromPreviewUrl( url: string, baseUrl: string ) { } } -// The three surfaces the preview can show: the site itself, WP Admin, and -// the database (phpMyAdmin). Every path belongs to exactly one realm; the -// address bar renders one segment per realm. export type PreviewRealm = 'frontend' | 'admin' | 'database'; -// The phpMyAdmin landing the database segment opens by default: straight to -// the WordPress database. export const DATABASE_HOME_PATH = '/phpmyadmin/index.php?route=/database/structure&db=wordpress'; -// A deliberately nonexistent front-end path, offering a way to preview the -// theme's 404 template. -const FRONT_END_NOT_FOUND_PATH = '/this-page-does-not-exist'; - -/** - * Which realm a preview path shows. Auto-login hops classify as their - * redirect target so the active segment doesn't flicker to "front end" - * while the login redirect is in flight. - */ export function getPreviewRealm( path: string ): PreviewRealm { let target = path; if ( path.startsWith( '/studio-auto-login' ) ) { @@ -66,9 +41,6 @@ export function getPreviewRealm( path: string ): PreviewRealm { return 'frontend'; } -// The site-open Tracks event that corresponds to a preview realm. Shared by the preview realm -// switcher (which sends `browser: 'internal'`) and the "open in browser" button (`external`), so both -// describe the same destination the same way. const REALM_OPEN_EVENTS: Record< PreviewRealm, TracksEventName > = { frontend: TRACKS_EVENTS.SITE_OPEN_IN_BROWSER, admin: TRACKS_EVENTS.SITE_OPEN_WP_ADMIN, @@ -79,10 +51,6 @@ export function getRealmOpenEvent( realm: PreviewRealm ): TracksEventName { return REALM_OPEN_EVENTS[ realm ]; } -/** - * Routes a wp-admin path through the site's `/studio-auto-login` endpoint so - * it never lands on the login form. Non-admin paths pass through untouched. - */ export function getRealmNavigationPath( path: string, siteUrl: string ): string { if ( ! path.startsWith( '/wp-admin' ) ) { return path; @@ -97,11 +65,6 @@ export function getRealmNavigationPath( path: string, siteUrl: string ): string export type OmniboxIntent = { type: 'path'; path: string } | { type: 'search'; term: string }; -/** - * Classifies what the user typed into the omnibox: something navigable (a - * same-origin URL or a path) or a term to search the site's content for. - * Cross-origin URLs return null — they can't be shown in the preview. - */ export function parseOmniboxInput( raw: string, siteUrl: string ): OmniboxIntent | null { const value = raw.trim(); if ( ! value ) { @@ -132,529 +95,224 @@ export function useDebouncedValue< T >( value: T, delayMs: number ): T { return debounced; } -// The element type `Icon` accepts (React 19 defaults ReactElement props to -// `unknown`, which it rejects). -type IconElement = ReactElement< SVGProps< SVGSVGElement > >; - -// One row in the popover list: a WordPress destination (admin surface or the -// database) or a content match from the site search. -interface AddressItem { - kind: 'destination' | 'content'; - id: string; - icon: IconElement; - title: string; +interface PreviewAddressBarProps { + site: SiteDetails; + siteUrl: string; path: string; + onNavigate: ( path: string ) => void; + onSwitchRealm: ( realm: PreviewRealm ) => void; } -// A labeled group of address items for the dropdown (e.g. "Front end", -// "WordPress"). An empty label renders the rows without a heading. The `items` -// shape is what Base UI keys grouped rendering off (see `Autocomplete.Group`). -interface AddressGroup { - value: string; - items: AddressItem[]; -} - -// Primary-modifier number shortcuts for the realm segments (⌘1/⌘2/⌘3 on -// macOS, Ctrl elsewhere). The host document listener lives in SitePreview. -export const REALM_SHORTCUT_KEYS: Record< PreviewRealm, string > = { - frontend: '1', - admin: '2', - database: '3', -}; - -// The active frontend segment wears the site's name instead of a static -// title (resolved in the render), like a browser tab for the site itself. -const REALM_SEGMENTS: { - realm: PreviewRealm; - icon: IconElement | null; - title: string | null; +interface RecentPreviewLocation { + path: string; label: string; -}[] = [ - { realm: 'frontend', icon: null, title: null, label: __( 'View site front end' ) }, - { realm: 'admin', icon: wordpress, title: __( 'WordPress' ), label: __( 'View WP Admin' ) }, - { realm: 'database', icon: databaseIcon, title: __( 'Database' ), label: __( 'View database' ) }, -]; +} -// The site editor renamed its route slugs alongside the `path`→`p` param -// rename (`/wp_template` became `/template`, and so on); fold each family -// into one canonical route so destinations match on every WP version. -const SITE_EDITOR_ROUTE_ALIASES: Record< string, string > = { - '/wp_template': '/template', - '/wp_template_part': '/pattern', - '/patterns': '/pattern', - '/wp_global_styles': '/styles', - '/wp_navigation': '/navigation', -}; +const RECENT_LOCATIONS_VERSION = 1; +const RECENT_LOCATIONS_LIMIT = 8; -function canonicalSiteEditorRoute( value: string ): string { - return SITE_EDITOR_ROUTE_ALIASES[ value ] ?? value; +function getRecentLocationsStorageKey( siteId: string ): string { + return `studio-preview-recent-locations:${ siteId }`; } -/** - * How well a destination matches the preview's current path: -1 for no - * match, otherwise the number of query params the destination pins down — - * more params is more specific, so Pages beats Posts on their shared - * edit.php pathname. WP Admin rewrites its URLs after navigation (the site - * editor renamed its `path` param to `p`, renamed the route slugs, and - * appends extras like `canvas`), so declared params match under either - * name and canonical route, and extra current params are ignored. - */ -function destinationMatchScore( destinationPath: string, currentPath: string ): number { - let destination: URL; - let current: URL; +function loadRecentLocations( siteId: string ): RecentPreviewLocation[] { try { - // Dummy base (`.invalid` is RFC 2606-reserved): URL() needs an absolute - // base to parse path-only inputs; the host is never requested. - destination = new URL( destinationPath, 'http://preview.invalid' ); - current = new URL( currentPath, 'http://preview.invalid' ); + const raw = window.localStorage.getItem( getRecentLocationsStorageKey( siteId ) ); + if ( ! raw ) return []; + const stored = JSON.parse( raw ) as { version?: unknown; locations?: unknown }; + if ( stored.version !== RECENT_LOCATIONS_VERSION || ! Array.isArray( stored.locations ) ) { + return []; + } + return stored.locations.slice( 0, RECENT_LOCATIONS_LIMIT ).flatMap( ( value ) => { + if ( ! value || typeof value !== 'object' ) return []; + const location = value as { path?: unknown; label?: unknown }; + return typeof location.path === 'string' && typeof location.label === 'string' + ? [ { path: location.path, label: location.label } ] + : []; + } ); } catch { - return -1; - } - if ( destination.pathname !== current.pathname ) { - return -1; + return []; } - let score = 0; - for ( const [ key, value ] of destination.searchParams ) { - const isRouteParam = key === 'path' || key === 'p'; - const aliases = isRouteParam ? [ 'path', 'p' ] : [ key ]; - const matches = aliases.some( ( alias ) => { - const currentValue = current.searchParams.get( alias ); - if ( currentValue === null ) { - return false; - } - return isRouteParam - ? canonicalSiteEditorRoute( currentValue ) === canonicalSiteEditorRoute( value ) - : currentValue === value; - } ); - if ( ! matches ) { - return -1; - } - score += 1; +} + +function storeRecentLocation( + siteId: string, + location: RecentPreviewLocation +): RecentPreviewLocation[] { + const locations = [ + location, + ...loadRecentLocations( siteId ).filter( ( recent ) => recent.path !== location.path ), + ].slice( 0, RECENT_LOCATIONS_LIMIT ); + try { + window.localStorage.setItem( + getRecentLocationsStorageKey( siteId ), + JSON.stringify( { version: RECENT_LOCATIONS_VERSION, locations } ) + ); + } catch { + // The address bar remains usable when storage is unavailable or full. } - return score; + return locations; } -interface PreviewAddressBarProps { - site: SiteDetails; - siteUrl: string; - // Current preview path; determines the active segment and prefills the - // input on open. - path: string; - // Content search needs the site REST API, which is unavailable in the - // non-Electron iframe fallback; plain path navigation still works there. - searchEnabled: boolean; - // The popup anchors to this element (the toolbar's location slot) so it - // opens wide and centered like a browser address bar. - anchorRef: RefObject< HTMLElement | null >; - onNavigate: ( path: string ) => void; - // Called when the user clicks an inactive segment; the host navigates to - // its remembered path for that realm. - onSwitchRealm: ( realm: PreviewRealm ) => void; +function getDisplayUrl( siteUrl: string, path: string ): string { + try { + return new URL( path, siteUrl ).toString(); + } catch { + return `${ siteUrl }${ path }`; + } } -/** - * Segmented browser-style address control. One segment per realm (front - * end, WP Admin, database): the active segment wears the realm's name and - * opens an omnibox popover showing the current path — type a path, search - * pages and posts, or pick a WordPress destination — while clicking an - * inactive segment flips the preview to that realm's last visited path. - */ export function PreviewAddressBar( { site, siteUrl, path, - searchEnabled, - anchorRef, onNavigate, onSwitchRealm, }: PreviewAddressBarProps ) { - const [ open, setOpen ] = useState( false ); - const [ inputValue, setInputValue ] = useState( '' ); - // Whether the user edited the input since opening — the prefilled path - // alone must not count as typing, and string equality can't tell a - // prefill from a manually retyped current path. - const [ hasTyped, setHasTyped ] = useState( false ); - const [ highlightedItem, setHighlightedItem ] = useState< AddressItem | undefined >( undefined ); - const realm = getPreviewRealm( path ); - const { customizeLinks, contentLinks } = useCustomizeLinks( site ); - - // The selected-segment fill is a separate element that slides between - // segments. Its position comes from measuring the active button; the - // ResizeObserver keeps it honest while the title width animates. - const segmentsRef = useRef< HTMLDivElement | null >( null ); - const [ indicator, setIndicator ] = useState< { left: number; width: number } | null >( null ); - const measureIndicator = useCallback( () => { - const active = segmentsRef.current?.querySelector< HTMLElement >( - 'button[data-active="true"]' - ); - if ( ! active ) { - return; - } - const left = active.offsetLeft; - const width = active.offsetWidth; - setIndicator( ( current ) => - current && current.left === left && current.width === width ? current : { left, width } - ); - }, [] ); - useLayoutEffect( measureIndicator, [ measureIndicator, realm, site.name ] ); - useEffect( () => { - const root = segmentsRef.current; - if ( ! root || typeof ResizeObserver === 'undefined' ) { - return; - } - const observer = new ResizeObserver( measureIndicator ); - observer.observe( root ); - // Observe the segments themselves (not the indicator, which would - // feed back) so mid-animation width changes re-anchor the fill. - root.querySelectorAll( 'button' ).forEach( ( button ) => observer.observe( button ) ); - return () => observer.disconnect(); - }, [ measureIndicator ] ); - - // The WordPress destinations — the former "Open WordPress…" menu, folded - // into the address bar. WP Admin and the database are deliberately absent: - // their segments sit right next to the omnibox. - const wordpressItems = useMemo< AddressItem[] >( - () => - [ ...customizeLinks, ...contentLinks ].map( ( link ) => ( { - kind: 'destination' as const, - id: link.id, - icon: link.icon, - title: link.label, - path: link.url, - } ) ), - [ contentLinks, customizeLinks ] + const displayUrl = getDisplayUrl( siteUrl, path ); + const activeRealm = getPreviewRealm( path ); + const [ value, setValue ] = useState( displayUrl ); + const [ shortcutsOpen, setShortcutsOpen ] = useState( false ); + const [ recentLocations, setRecentLocations ] = useState< RecentPreviewLocation[] >( () => + loadRecentLocations( site.id ) ); + const addressBarRef = useRef< HTMLFormElement | null >( null ); - const intent = parseOmniboxInput( inputValue, siteUrl ); - const searchTerm = intent?.type === 'search' ? intent.term : ''; - const pathQuery = intent?.type === 'path' ? intent.path : ''; - const debouncedTerm = useDebouncedValue( searchTerm, 250 ); - const search = useSiteSearch( site.id, debouncedTerm, searchEnabled && open ); - // Real front-end permalinks (latest post + a page) for the zero state; only - // worth fetching while the popup is open and the REST transport is available. - const frontLinks = useSiteFrontLinks( site.id, searchEnabled && open ); - - // Front-end destinations: the home page and a 404 preview always, plus the - // latest post and a page once their permalinks resolve. - const frontendItems = useMemo< AddressItem[] >( () => { - const items: AddressItem[] = [ - { kind: 'destination', id: 'home', icon: home, title: __( 'Home' ), path: '/' }, - { - kind: 'destination', - id: 'not-found', - icon: help, - title: __( '404 page' ), - path: FRONT_END_NOT_FOUND_PATH, - }, - ]; - if ( frontLinks.data?.post ) { - items.push( { - kind: 'destination', - id: 'latest-post', - icon: postIcon, - title: frontLinks.data.post.title, - path: frontLinks.data.post.path, - } ); - } - if ( frontLinks.data?.page ) { - items.push( { - kind: 'destination', - id: 'published-page', - icon: pageIcon, - title: frontLinks.data.page.title, - path: frontLinks.data.page.path, - } ); - } - return items; - }, [ frontLinks.data ] ); + useEffect( () => setValue( displayUrl ), [ displayUrl ] ); + useEffect( () => setRecentLocations( loadRecentLocations( site.id ) ), [ site.id ] ); - // The destination the preview is currently showing, so the zero state - // answers "where am I" at a glance. Best-scoring match wins: destinations - // sharing a pathname (Posts and Pages both live on edit.php) resolve to - // the more specific one. - const currentDestinationId = useMemo( () => { - let bestId: string | null = null; - let bestScore = -1; - for ( const item of [ ...frontendItems, ...wordpressItems ] ) { - const score = destinationMatchScore( item.path, path ); - if ( score > bestScore ) { - bestScore = score; - bestId = item.id; - } - } - return bestScore >= 0 ? bestId : null; - }, [ frontendItems, wordpressItems, path ] ); - - // Until the user types (or after clearing), the input rests on the grouped - // destinations (Front end / WordPress); search terms blend destination - // matches with content results into a single unlabeled group; typed paths - // suggest destinations matching by path, while Enter keeps navigating the - // literal input (no auto-highlight in path mode). - const isZeroState = ! inputValue.trim() || ! hasTyped; - const groups = useMemo< AddressGroup[] >( () => { - if ( isZeroState ) { - return [ - { value: __( 'Front end' ), items: frontendItems }, - { value: __( 'WordPress' ), items: wordpressItems }, - ].filter( ( group ) => group.items.length > 0 ); - } - if ( pathQuery ) { - const query = pathQuery.toLowerCase(); - const matches = [ ...frontendItems, ...wordpressItems ].filter( ( destination ) => - destination.path.toLowerCase().includes( query ) - ); - return matches.length > 0 ? [ { value: '', items: matches } ] : []; - } - if ( ! searchTerm ) { - return []; - } - const term = searchTerm.toLowerCase(); - const destinationMatches = [ ...frontendItems, ...wordpressItems ].filter( ( destination ) => - destination.title.toLowerCase().includes( term ) - ); - // The latest-post/page permalink rows can also come back as content - // results — keep the instant destination row, drop the duplicate. - const destinationPaths = new Set( destinationMatches.map( ( match ) => match.path ) ); - const contentMatches = debouncedTerm - ? ( search.data ?? [] ) - .filter( ( result ) => ! destinationPaths.has( result.path ) ) - .map( ( result ) => ( { - kind: 'content' as const, - id: `content-${ result.id }`, - icon: result.subtype === 'page' ? pageIcon : postIcon, - title: result.title, - path: result.path, - } ) ) - : []; - const matches = [ ...destinationMatches, ...contentMatches ]; - return matches.length > 0 ? [ { value: '', items: matches } ] : []; - }, [ - isZeroState, - frontendItems, - wordpressItems, - pathQuery, - searchTerm, - debouncedTerm, - search.data, - ] ); - const flatItems = useMemo( () => groups.flatMap( ( group ) => group.items ), [ groups ] ); - - const navigateTo = useCallback( - ( nextPath: string ) => { - onNavigate( getRealmNavigationPath( nextPath, siteUrl ) ); - setOpen( false ); - }, - [ onNavigate, siteUrl ] - ); - - // Prefill with the current path and select it, like a browser address bar - // (the selection happens in the input's mount ref below). - const handleOpenChange = ( nextOpen: boolean ) => { - if ( nextOpen ) { - setInputValue( path ); - setHasTyped( false ); - setHighlightedItem( undefined ); - } - setOpen( nextOpen ); - }; - - const handleInputValueChange = ( value: string ) => { - setInputValue( value ); - setHasTyped( true ); - }; - - // The popup (and input) unmount when closed, so this runs once per open. - const selectOnMount = useCallback( ( input: HTMLInputElement | null ) => { - input?.select(); - }, [] ); - - const handleInputKeyDown = ( event: ReactKeyboardEvent< HTMLInputElement > ) => { - if ( event.key !== 'Enter' ) { - return; - } - if ( highlightedItem && flatItems.length > 0 ) { - event.preventDefault(); - navigateTo( highlightedItem.path ); - return; - } + const handleSubmit = ( event: FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + const intent = parseOmniboxInput( value, siteUrl ); if ( ! intent ) { return; } - event.preventDefault(); - if ( intent.type === 'path' ) { - navigateTo( intent.path ); - return; + const nextPath = + intent.type === 'path' + ? getRealmNavigationPath( intent.path, siteUrl ) + : `/?s=${ encodeURIComponent( intent.term ) }`; + const recentLabel = getDisplayUrl( siteUrl, intent.type === 'path' ? intent.path : nextPath ); + setRecentLocations( storeRecentLocation( site.id, { path: nextPath, label: recentLabel } ) ); + setShortcutsOpen( false ); + onNavigate( nextPath ); + }; + const chooseRealm = ( realm: PreviewRealm ) => { + setShortcutsOpen( false ); + onSwitchRealm( realm ); + }; + const chooseRecentLocation = ( recent: RecentPreviewLocation ) => { + setRecentLocations( storeRecentLocation( site.id, recent ) ); + setShortcutsOpen( false ); + onNavigate( recent.path ); + }; + const renderLocationIcon = ( locationPath: string ) => { + const realm = getPreviewRealm( locationPath ); + if ( realm === 'frontend' ) { + return ( + + ); } - // No result to pick — fall through to the site's own search page. - navigateTo( `/?s=${ encodeURIComponent( intent.term ) }` ); + return ( + + ); }; - const showsSearchUi = searchEnabled && ! isZeroState && intent?.type === 'search'; - const isSearching = - showsSearchUi && - flatItems.length === 0 && - ( search.isFetching || debouncedTerm !== searchTerm ); - const status = ! showsSearchUi - ? null - : search.isError - ? __( 'Search unavailable' ) - : isSearching - ? __( 'Searching…' ) - : flatItems.length === 0 && debouncedTerm - ? __( 'No matches' ) - : null; - return ( - item.path } + { + setShortcutsOpen( open ); + if ( ! open ) { + setValue( displayUrl ); + } + } } > -
-
+ ) : null } + +
); } diff --git a/apps/ui/src/components/site-preview/index.test.tsx b/apps/ui/src/components/site-preview/index.test.tsx index f4be39fd2e..cc1e2d1481 100644 --- a/apps/ui/src/components/site-preview/index.test.tsx +++ b/apps/ui/src/components/site-preview/index.test.tsx @@ -1,14 +1,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { displayShortcut } from '@wordpress/keycodes'; +import { ariaKeyShortcut } from '@wordpress/keycodes'; import { Tooltip } from '@wordpress/ui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { themeDetailsQueryKey } from '@/hooks/use-theme-details'; import { DATABASE_HOME_PATH } from './address-bar'; +import { INSPECTOR_BRIDGE_PREFIX } from './inspector-script'; import { getBrowserShortcutCommand, + getDirectionalHistoryEntries, isOffOriginRedirect, isThemeActivationUrl, getPathFromPreviewUrl, @@ -80,6 +82,23 @@ function createSite( overrides: Partial< SiteDetails > = {} ): SiteDetails { } describe( 'SitePreview', () => { + it( 'orders back and forward history from the current page outward', () => { + const entries = [ + { index: 0, title: 'Home', url: 'http://localhost/' }, + { index: 1, title: 'Posts', url: 'http://localhost/posts' }, + { index: 2, title: 'Editor', url: 'http://localhost/editor' }, + { index: 3, title: 'Settings', url: 'http://localhost/settings' }, + { index: 4, title: 'Themes', url: 'http://localhost/themes' }, + ]; + + expect( + getDirectionalHistoryEntries( entries, 2, 'back' ).map( ( entry ) => entry.index ) + ).toEqual( [ 1, 0 ] ); + expect( + getDirectionalHistoryEntries( entries, 2, 'forward' ).map( ( entry ) => entry.index ) + ).toEqual( [ 3, 4 ] ); + } ); + it( 'recognizes WordPress theme activation navigations', () => { expect( isThemeActivationUrl( 'http://localhost:8881/wp-admin/themes.php?activated=true' ) @@ -121,67 +140,6 @@ describe( 'SitePreview', () => { expect( queryClient.getQueryData( themeDetailsQueryKey( 'site-1' ) ) ).toEqual( themeDetails ); } ); - it( 'shows the active realm name with the same tooltip as when inactive', async () => { - useConnectorMock.mockReturnValue( { - startSite: vi.fn().mockResolvedValue( undefined ), - trackEvent: vi.fn().mockResolvedValue( undefined ), - capabilities: CAPABILITIES, - } as never ); - - renderPreview( - - ); - - // The active segment wears the realm name ("WordPress" for /wp-admin/). - const realmTitle = screen.getByText( 'WordPress' ); - expect( realmTitle ).toBeVisible(); - - // 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 } ); - - // 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( tooltip, {}, { timeout: 2000 } ) ).toBeVisible(); - } ); - - it( 'shows adjacent toolbar tooltips immediately while the delay group is active', async () => { - useConnectorMock.mockReturnValue( { - startSite: vi.fn().mockResolvedValue( undefined ), - trackEvent: vi.fn().mockResolvedValue( undefined ), - capabilities: CAPABILITIES, - } as never ); - - renderPreview( - - ); - - const addressTrigger = screen.getByText( 'WordPress' ).closest( 'button' ) as HTMLElement; - fireEvent.mouseEnter( addressTrigger ); - fireEvent.mouseMove( addressTrigger, { movementX: 1, movementY: 1 } ); - - 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( addressTrigger, { relatedTarget: refreshButton } ); - fireEvent.mouseEnter( refreshButton, { relatedTarget: addressTrigger } ); - fireEvent.mouseMove( refreshButton, { movementX: 1, movementY: 1 } ); - - const refreshTooltip = screen.getByText( /^Refresh/ ); - expect( refreshTooltip ).toBeInTheDocument(); - expect( refreshTooltip ).toHaveAttribute( 'data-instant', 'delay' ); - } ); - it( 'hides the browser controls and shows the stopped preview treatment when the site is not running', async () => { const getSiteThumbnail = vi.fn().mockResolvedValue( 'data:image/png;base64,thumbnail' ); useConnectorMock.mockReturnValue( { @@ -206,7 +164,7 @@ describe( 'SitePreview', () => { ).toHaveAttribute( 'src', 'data:image/png;base64,thumbnail' ); } ); - it( 'keeps the Open in… control in the toolbar while the site is stopped', () => { + it( 'does not render the chat-level Open in control in the preview toolbar', () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -215,7 +173,7 @@ describe( 'SitePreview', () => { renderPreview( ); - expect( screen.getByRole( 'button', { name: 'Open in…' } ) ).toBeVisible(); + expect( screen.queryByRole( 'button', { name: 'Open in…' } ) ).not.toBeInTheDocument(); } ); it( 'shows a refresh button that reloads the active preview surface', () => { @@ -230,19 +188,21 @@ describe( 'SitePreview', () => { ); const refreshButton = screen.getByRole( 'button', { name: 'Refresh' } ); + const backButton = screen.getByRole( 'button', { name: 'Back' } ); + const forwardButton = screen.getByRole( 'button', { name: 'Forward' } ); expect( refreshButton ).toBeEnabled(); expect( refreshButton ).toHaveAttribute( 'aria-keyshortcuts', expect.stringMatching( /\+R$/ ) ); + expect( backButton.compareDocumentPosition( forwardButton ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect( forwardButton.compareDocumentPosition( refreshButton ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); // jsdom reports a non-Apple platform: the navigation alias is Alt+arrow, // with the bracket chord kept as a secondary shortcut. - expect( screen.getByRole( 'button', { name: 'Back' } ) ).toHaveAttribute( - 'aria-keyshortcuts', - 'Alt+ArrowLeft Control+[' - ); - expect( screen.getByRole( 'button', { name: 'Forward' } ) ).toHaveAttribute( - 'aria-keyshortcuts', - 'Alt+ArrowRight Control+]' - ); + expect( backButton ).toHaveAttribute( 'aria-keyshortcuts', 'Alt+ArrowLeft Control+[' ); + expect( forwardButton ).toHaveAttribute( 'aria-keyshortcuts', 'Alt+ArrowRight Control+]' ); const initialIframe = container.querySelector( 'iframe' ); expect( initialIframe ).toBeInTheDocument(); @@ -281,7 +241,7 @@ describe( 'SitePreview', () => { expect( container.querySelector( 'iframe' ) ).toBe( aliasReloadedIframe ); } ); - it( 'switches realms on primary-modifier number shortcuts', () => { + it( 'switches realms from the address menu', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -298,19 +258,20 @@ describe( 'SitePreview', () => { /> ); - // jsdom reports a non-Apple platform, so the primary modifier is Ctrl. - fireEvent.keyDown( document.body, { key: '2', ctrlKey: true } ); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'WP Admin' } ) ); expect( onPathChange ).toHaveBeenCalledWith( `/studio-auto-login?redirect_to=${ encodeURIComponent( 'http://localhost:8881/wp-admin/' ) }` ); // Re-selecting the already-active realm is a no-op. onPathChange.mockClear(); - fireEvent.keyDown( document.body, { key: '1', ctrlKey: true } ); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'Front end' } ) ); expect( onPathChange ).not.toHaveBeenCalled(); } ); - it( 'records an internal-browser Tracks event when switching realms', () => { + it( 'records an internal-browser Tracks event when switching realms', async () => { const trackEvent = vi.fn().mockResolvedValue( undefined ); useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), @@ -327,13 +288,14 @@ describe( 'SitePreview', () => { /> ); - fireEvent.keyDown( document.body, { key: '2', ctrlKey: true } ); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'WP Admin' } ) ); expect( trackEvent ).toHaveBeenCalledWith( 'studio_site_open_wp_admin', { browser: 'internal', } ); } ); - it( 'does not record a realm switch when re-selecting the active realm', () => { + it( 'does not record a realm switch when re-selecting the active realm', async () => { const trackEvent = vi.fn().mockResolvedValue( undefined ); useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), @@ -350,12 +312,13 @@ describe( 'SitePreview', () => { /> ); - // Already on the admin realm; its shortcut is a no-op. - fireEvent.keyDown( document.body, { key: '2', ctrlKey: true } ); + // Already on the admin realm; selecting it again is a no-op. + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'WP Admin' } ) ); expect( trackEvent ).not.toHaveBeenCalled(); } ); - it( 'switches to the database realm on its shortcut', () => { + it( 'switches to the database realm from the address menu', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -372,7 +335,8 @@ describe( 'SitePreview', () => { /> ); - fireEvent.keyDown( document.body, { key: '3', ctrlKey: true } ); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'Database' } ) ); expect( onPathChange ).toHaveBeenCalledWith( '/phpmyadmin/index.php?route=/database/structure&db=wordpress' ); @@ -404,7 +368,7 @@ describe( 'SitePreview', () => { expect( container.querySelectorAll( 'iframe' ) ).toHaveLength( 1 ); } ); - it( 'gives the database its own surface and reveals it without reloading', () => { + it( 'gives the database its own surface and reveals it without reloading', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -445,7 +409,8 @@ describe( 'SitePreview', () => { expect( siteSurface?.closest( '[inert]' ) ).toBeNull(); onPathChange.mockClear(); - fireEvent.keyDown( document.body, { key: '3', ctrlKey: true } ); + fireEvent.click( screen.getByRole( 'textbox', { name: 'Address' } ) ); + fireEvent.click( await screen.findByRole( 'button', { name: 'Database' } ) ); expect( onPathChange ).toHaveBeenCalledWith( DATABASE_HOME_PATH ); rerender( ui( DATABASE_HOME_PATH ) ); @@ -482,6 +447,126 @@ describe( 'SitePreview', () => { expect( screen.getByRole( 'button', { name: 'Annotate' } ) ).toBeInTheDocument(); } ); + it( 'prioritizes annotation controls while picking', () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty( navigator, 'userAgent', { + configurable: true, + value: `${ originalUserAgent } Electron/40.0.0`, + } ); + useConnectorMock.mockReturnValue( { + startSite: vi.fn().mockResolvedValue( undefined ), + capabilities: { ...CAPABILITIES, annotatePreview: true }, + } as never ); + + const { container } = renderPreview( + + ); + const webview = container.querySelector( 'webview' ); + expect( webview ).toBeInTheDocument(); + + const stateEvent = new Event( 'console-message' ); + Object.defineProperty( stateEvent, 'message', { + value: `${ INSPECTOR_BRIDGE_PREFIX }${ JSON.stringify( { + type: 'state', + isPicking: true, + annotationCount: 0, + } ) }`, + } ); + fireEvent( webview as Element, stateEvent ); + + const cancelButton = screen.getByRole( 'button', { name: 'Cancel annotation' } ); + expect( cancelButton ).toBeVisible(); + expect( cancelButton.querySelector( 'svg' ) ).toBeNull(); + expect( cancelButton ).not.toHaveAttribute( 'aria-pressed' ); + expect( screen.queryByRole( 'button', { name: 'Back' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Forward' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Refresh' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'textbox', { name: 'Address' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ).toBeVisible(); + + Object.defineProperty( navigator, 'userAgent', { + configurable: true, + value: originalUserAgent, + } ); + } ); + + it( 'confirms before discarding an annotation session', async () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty( navigator, 'userAgent', { + configurable: true, + value: `${ originalUserAgent } Electron/40.0.0`, + } ); + useConnectorMock.mockReturnValue( { + startSite: vi.fn().mockResolvedValue( undefined ), + capabilities: { ...CAPABILITIES, annotatePreview: true }, + } as never ); + + const { container } = renderPreview( + + ); + const webview = container.querySelector( 'webview' ); + const stateEvent = new Event( 'console-message' ); + Object.defineProperty( stateEvent, 'message', { + value: `${ INSPECTOR_BRIDGE_PREFIX }${ JSON.stringify( { + type: 'state', + isPicking: true, + annotationCount: 0, + } ) }`, + } ); + fireEvent( webview as Element, stateEvent ); + + const emptyCancelRequest = new Event( 'console-message' ); + Object.defineProperty( emptyCancelRequest, 'message', { + value: `${ INSPECTOR_BRIDGE_PREFIX }${ JSON.stringify( { + type: 'cancel-requested', + } ) }`, + } ); + fireEvent( webview as Element, emptyCancelRequest ); + expect( + screen.queryByRole( 'dialog', { name: 'Cancel annotation?' } ) + ).not.toBeInTheDocument(); + + const draftStateEvent = new Event( 'console-message' ); + Object.defineProperty( draftStateEvent, 'message', { + value: `${ INSPECTOR_BRIDGE_PREFIX }${ JSON.stringify( { + type: 'state', + isPicking: true, + annotationCount: 0, + hasUnsavedDraft: true, + } ) }`, + } ); + fireEvent( webview as Element, draftStateEvent ); + + const draftCancelRequest = new Event( 'console-message' ); + Object.defineProperty( draftCancelRequest, 'message', { + value: `${ INSPECTOR_BRIDGE_PREFIX }${ JSON.stringify( { + type: 'cancel-requested', + } ) }`, + } ); + fireEvent( webview as Element, draftCancelRequest ); + expect( screen.getByRole( 'dialog', { name: 'Cancel annotation?' } ) ).toBeVisible(); + fireEvent.click( screen.getByRole( 'button', { name: 'Keep annotating' } ) ); + await waitFor( () => + expect( + screen.queryByRole( 'dialog', { name: 'Cancel annotation?' } ) + ).not.toBeInTheDocument() + ); + + fireEvent.keyDown( document, { key: 'Escape' } ); + expect( screen.getByRole( 'dialog', { name: 'Cancel annotation?' } ) ).toBeVisible(); + fireEvent.click( screen.getByRole( 'button', { name: 'Discard annotations' } ) ); + await waitFor( () => + expect( + screen.queryByRole( 'dialog', { name: 'Cancel annotation?' } ) + ).not.toBeInTheDocument() + ); + + Object.defineProperty( navigator, 'userAgent', { + configurable: true, + value: originalUserAgent, + } ); + } ); + it( 'shows a single annotate toggle while no notes are pending', () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), @@ -528,7 +613,7 @@ describe( 'SitePreview', () => { } ); } ); - it( 'offers responsive modes from the More options menu while running', async () => { + it( 'offers responsive modes from the responsive preview menu while running', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -539,15 +624,16 @@ describe( 'SitePreview', () => { ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); expect( await screen.findByText( 'Responsive mode' ) ).toBeVisible(); - expect( screen.getByRole( 'menuitemradio', { name: 'Fit pane' } ) ).toBeChecked(); + expect( screen.getByRole( 'menuitem', { name: 'Responsive' } ) ).toBeVisible(); + expect( screen.queryByRole( 'menuitemradio', { name: 'Responsive' } ) ).not.toBeInTheDocument(); // The orientation group only accompanies the phone frame. expect( screen.queryByText( 'Mobile orientation' ) ).not.toBeInTheDocument(); - // Radio items keep the menu open, so the orientation group appears in place. - fireEvent.click( screen.getByRole( 'menuitemradio', { name: 'Mobile · 390×844' } ) ); + fireEvent.click( screen.getByRole( 'menuitem', { name: 'Mobile · 390×844' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Mobile' } ) ); expect( await screen.findByText( 'Mobile orientation' ) ).toBeVisible(); expect( screen.getByRole( 'menuitemradio', { name: 'Portrait' } ) ).toBeChecked(); @@ -577,56 +663,23 @@ describe( 'SitePreview', () => { /> ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - - expect( await screen.findByText( 'Responsive mode' ) ).toBeVisible(); - const fitPane = screen.getByRole( 'menuitemradio', { name: 'Fit pane' } ); - expect( fitPane ).toHaveAttribute( 'aria-disabled', 'true' ); - - // Disabled radio items swallow the click — the mode doesn't change. - fireEvent.click( screen.getByRole( 'menuitemradio', { name: 'Mobile · 390×844' } ) ); - expect( fitPane ).toBeChecked(); - } ); - - it( 'toggles full preview from the More options menu', async () => { - useConnectorMock.mockReturnValue( { - startSite: vi.fn().mockResolvedValue( undefined ), - trackEvent: vi.fn().mockResolvedValue( undefined ), - capabilities: CAPABILITIES, - } as never ); - const onFullscreenChange = vi.fn(); - const queryClient = new QueryClient( { - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + const responsiveButton = screen.getByRole( 'button', { + name: 'Responsive mode: Responsive', } ); - const ui = ( fullscreen: boolean ) => ( - - - - - + expect( responsiveButton ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( responsiveButton ).toHaveAttribute( 'aria-description', 'Not available for Database' ); + expect( responsiveButton.parentElement ).toHaveAttribute( + 'title', + 'Not available for Database' ); - const { rerender } = render( ui( false ) ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Full preview' } ) ); - - expect( onFullscreenChange ).toHaveBeenCalledWith( true ); - - // While full, the same item offers the way back out. - rerender( ui( true ) ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Exit full preview' } ) ); - - expect( onFullscreenChange ).toHaveBeenLastCalledWith( false ); + fireEvent.click( responsiveButton ); + expect( + screen.queryByRole( 'menuitem', { name: 'Mobile · 390×844' } ) + ).not.toBeInTheDocument(); } ); - it( 'omits full preview when the host provides no toggle', async () => { + it( 'keeps full preview out of the responsive controls', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -637,13 +690,13 @@ describe( 'SitePreview', () => { ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); expect( await screen.findByText( 'Responsive mode' ) ).toBeVisible(); expect( screen.queryByRole( 'menuitem', { name: 'Full preview' } ) ).not.toBeInTheDocument(); } ); - it( 'asks for full preview when the Desktop + Mobile comparison is picked', async () => { + it( 'does not force full preview when the Desktop + Mobile comparison is picked', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -660,13 +713,13 @@ describe( 'SitePreview', () => { /> ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitemradio', { name: 'Desktop + Mobile' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); + fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Desktop + Mobile' } ) ); - expect( onFullscreenChange ).toHaveBeenCalledWith( true ); + expect( onFullscreenChange ).not.toHaveBeenCalled(); } ); - it( 'drops the comparison back to Fit pane when full preview ends', async () => { + it( 'keeps the comparison selected when full preview ends', async () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -690,15 +743,15 @@ describe( 'SitePreview', () => { ); const { rerender } = render( ui( true ) ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitemradio', { name: 'Desktop + Mobile' } ) ); - expect( screen.getByRole( 'menuitemradio', { name: 'Desktop + Mobile' } ) ).toBeChecked(); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); + fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Desktop + Mobile' } ) ); - // Two frames don't fit the panel, so the comparison doesn't survive the - // return to the split layout. rerender( ui( false ) ); - - expect( await screen.findByRole( 'menuitemradio', { name: 'Fit pane' } ) ).toBeChecked(); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Desktop + Mobile' } ) ); + expect( screen.getByRole( 'menuitem', { name: 'Desktop + Mobile' } ) ).toHaveAttribute( + 'aria-current', + 'true' + ); } ); it( 'toggles full preview with the keyboard shortcut', () => { @@ -744,7 +797,33 @@ describe( 'SitePreview', () => { expect( onFullscreenChange ).not.toHaveBeenCalled(); } ); - it( 'hides the More options menu when the site is not running', () => { + it( 'toggles full preview directly from the toolbar', () => { + useConnectorMock.mockReturnValue( { + startSite: vi.fn().mockResolvedValue( undefined ), + trackEvent: vi.fn().mockResolvedValue( undefined ), + capabilities: CAPABILITIES, + } as never ); + const onFullscreenChange = vi.fn(); + renderPreview( + + ); + + const fullPreviewButton = screen.getByRole( 'button', { name: 'Full preview' } ); + expect( fullPreviewButton ).toHaveAttribute( + 'aria-keyshortcuts', + ariaKeyShortcut.primaryShift( 'f' ) + ); + expect( fullPreviewButton ).toHaveAttribute( 'aria-pressed', 'false' ); + fireEvent.click( fullPreviewButton ); + expect( onFullscreenChange ).toHaveBeenCalledWith( true ); + } ); + + it( 'hides the responsive controls when the site is not running', () => { useConnectorMock.mockReturnValue( { startSite: vi.fn().mockResolvedValue( undefined ), trackEvent: vi.fn().mockResolvedValue( undefined ), @@ -753,7 +832,11 @@ describe( 'SitePreview', () => { renderPreview( ); - expect( screen.queryByRole( 'button', { name: 'More options' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Refresh' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'textbox', { name: 'Address' } ) ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: 'Responsive mode: Responsive' } ) + ).not.toBeInTheDocument(); } ); it( 'remembers the responsive mode per site during the session', async () => { @@ -777,18 +860,23 @@ describe( 'SitePreview', () => { const siteB = createSite( { id: 'site-b', running: true } ); const { rerender } = render( ui( siteA ) ); - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitemradio', { name: 'Mobile · 390×844' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); + fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Mobile · 390×844' } ) ); // A site without a remembered mode starts from the default… rerender( ui( siteB ) ); - expect( await screen.findByRole( 'menuitemradio', { name: 'Fit pane' } ) ).toBeChecked(); + fireEvent.click( screen.getByRole( 'button', { name: 'Responsive mode: Responsive' } ) ); + expect( screen.getByRole( 'menuitem', { name: 'Responsive' } ) ).toHaveAttribute( + 'aria-current', + 'true' + ); // …and returning to the first site restores its mode. rerender( ui( siteA ) ); - expect( - await screen.findByRole( 'menuitemradio', { name: 'Mobile · 390×844' } ) - ).toBeChecked(); + expect( screen.getByRole( 'menuitem', { name: 'Mobile · 390×844' } ) ).toHaveAttribute( + 'aria-current', + 'true' + ); } ); } ); @@ -1013,11 +1101,11 @@ function renderWebviewPreview( props: Partial< ComponentProps< typeof SitePrevie return { webview, clearWebviewCache, setWebviewViewport, update }; } -// Leaves "fit pane" for one of the simulated presets, which is what turns the +// Leaves "Responsive" for one of the simulated presets, which is what turns the // CDP emulation on. async function selectResponsiveMode( label: string ) { - fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) ); - fireEvent.click( await screen.findByRole( 'menuitemradio', { name: label } ) ); + fireEvent.click( screen.getByRole( 'button', { name: /Responsive mode:/ } ) ); + fireEvent.click( await screen.findByRole( 'menuitem', { name: label } ) ); } describe( 'SitePreview webview reload', () => { diff --git a/apps/ui/src/components/site-preview/index.tsx b/apps/ui/src/components/site-preview/index.tsx index 7d3b15bc93..311170d4d2 100644 --- a/apps/ui/src/components/site-preview/index.tsx +++ b/apps/ui/src/components/site-preview/index.tsx @@ -1,21 +1,13 @@ import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { __, sprintf } from '@wordpress/i18n'; -import { - chevronDown, - chevronLeft, - chevronRight, - Icon, - moreVertical, - pencil, -} from '@wordpress/icons'; +import { check, chevronDown, fullscreen as fullscreenIcon, Icon, pencil } from '@wordpress/icons'; import { ariaKeyShortcut, displayShortcut, isAppleOS, isKeyboardEvent } from '@wordpress/keycodes'; -import { Button, IconButton, Tooltip } from '@wordpress/ui'; +import { Button, Dialog, IconButton, Tooltip } from '@wordpress/ui'; import { clsx } from 'clsx'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DotGrid } from '@/components/dot-grid'; import * as Menu from '@/components/menu'; -import { OpenInMenu } from '@/components/open-in-menu'; import splitStyles from '@/components/split-button/style.module.css'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; @@ -28,7 +20,7 @@ import { import { refreshThemeDetails } from '@/hooks/use-theme-details'; import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import { getSiteUrl } from '@/lib/get-site-url'; -import { playIcon, refreshIcon } from '@/lib/icons'; +import { browserBackIcon, browserForwardIcon, playIcon, refreshIcon } from '@/lib/icons'; import { DATABASE_HOME_PATH, getPathFromPreviewUrl, @@ -36,7 +28,6 @@ import { getRealmNavigationPath, getRealmOpenEvent, PreviewAddressBar, - REALM_SHORTCUT_KEYS, useDebouncedValue, type PreviewRealm, } from './address-bar'; @@ -73,15 +64,16 @@ interface SitePreviewProps { collapsed?: boolean; // True while the preview fills the whole window (sidebar and chat hidden). fullscreen?: boolean; - // Enters/leaves full preview. The "•••" menu only offers it when provided. + // Enters/leaves full preview. The toolbar toggle only renders when provided. onFullscreenChange?: ( value: boolean ) => void; } interface InspectorEvent { - type: 'annotations-updated' | 'browser-command' | 'done' | 'state'; + type: 'annotations-updated' | 'browser-command' | 'cancel-requested' | 'done' | 'state'; annotations?: Annotation[]; isPicking?: boolean; annotationCount?: number; + hasUnsavedDraft?: boolean; command?: PreviewShortcutCommandType; } @@ -89,11 +81,18 @@ interface InspectorState { ready: boolean; isPicking: boolean; annotationCount: number; + hasUnsavedDraft: boolean; } interface InspectorCommand { id: number; - type: 'toggle-picking' | 'submit'; + type: 'cancel' | 'toggle-picking' | 'submit'; +} + +interface BrowserHistoryEntry { + index: number; + title: string; + url: string; } interface BrowserNavigationState { @@ -102,6 +101,8 @@ interface BrowserNavigationState { loading: boolean; progress: number; title: string | null; + historyEntries: BrowserHistoryEntry[]; + activeHistoryIndex: number; } type BrowserShortcutCommandType = 'back' | 'forward' | 'reload'; @@ -113,7 +114,8 @@ type PreviewShortcutCommandType = BrowserShortcutCommandType | 'full-preview'; interface BrowserCommand { id: number; - type: BrowserShortcutCommandType; + type: BrowserShortcutCommandType | 'go-to-history'; + historyIndex?: number; } // Electron's `` is a custom element with non-standard methods. Type @@ -247,6 +249,11 @@ interface PreviewWindow extends Window { viewport: PreviewViewport | null ) => Promise< void >; clearWebviewCache?: ( webContentsId: number ) => Promise< void >; + getWebviewNavigationHistory?: ( webContentsId: number ) => Promise< { + activeIndex: number; + entries: BrowserHistoryEntry[]; + } >; + goToWebviewNavigationHistoryEntry?: ( webContentsId: number, index: number ) => Promise< void >; }; } @@ -314,12 +321,15 @@ const EMPTY_BROWSER_STATE: BrowserNavigationState = { loading: false, progress: 0, title: null, + historyEntries: [], + activeHistoryIndex: -1, }; const EMPTY_INSPECTOR_STATE: InspectorState = { ready: false, isPicking: false, annotationCount: 0, + hasUnsavedDraft: false, }; const SITE_THUMBNAIL_QUERY_KEY = [ 'site-preview-thumbnail' ] as const; @@ -418,7 +428,10 @@ function getIframeStyle( viewport: PreviewViewport | null ): CSSProperties | und function areInspectorStatesEqual( a: InspectorState, b: InspectorState ) { return ( - a.ready === b.ready && a.isPicking === b.isPicking && a.annotationCount === b.annotationCount + a.ready === b.ready && + a.isPicking === b.isPicking && + a.annotationCount === b.annotationCount && + a.hasUnsavedDraft === b.hasUnsavedDraft ); } @@ -523,23 +536,17 @@ export function getBrowserShortcutCommand( return null; } -// ⌘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; -} - // ⇧⌘F (Ctrl+Shift+F elsewhere) toggles full preview. Listed in Settings → // Keyboard alongside the other preview shortcuts. const FULL_PREVIEW_SHORTCUT_KEY = 'f'; +function getFullPreviewShortcutDescriptor() { + return { + displayShortcut: displayShortcut.primaryShift( FULL_PREVIEW_SHORTCUT_KEY ), + ariaKeyShortcut: ariaKeyShortcut.primaryShift( FULL_PREVIEW_SHORTCUT_KEY ), + }; +} + function isFullPreviewShortcut( event: globalThis.KeyboardEvent ): boolean { if ( event.defaultPrevented || event.repeat ) { return false; @@ -556,33 +563,32 @@ function isPreviewShortcutCommand( command: unknown ): command is PreviewShortcu ); } -// Trailing "•••" menu holding the preview's environment controls: the -// responsive viewport controls and full preview. Other view options join it -// as they land. -function PreviewOverflowMenu( { +function PreviewResponsiveControls( { viewportMode, onViewportModeChange, viewportControlsDisabled, mobileOrientation, onMobileOrientationChange, - fullscreen, - onFullscreenChange, }: { viewportMode: ViewportMode; onViewportModeChange: ( mode: ViewportMode ) => void; - // Greys out the viewport controls for a surface that can't simulate one, + // Disables the viewport controls for a surface that can't simulate one, // keeping the chosen mode for when the preview returns to one that can. viewportControlsDisabled: boolean; mobileOrientation: MobileOrientation; onMobileOrientationChange: ( orientation: MobileOrientation ) => void; - fullscreen: boolean; - onFullscreenChange?: ( value: boolean ) => void; } ) { - const viewportLabels: Record< ViewportPreset[ 'id' ], string > = { + const viewportLabels: Record< ViewportMode, string > = { + fit: __( 'Responsive' ), mobile: __( 'Mobile' ), tablet: __( 'Tablet' ), desktop: __( 'Desktop' ), + split: __( 'Desktop + Mobile' ), }; + const selectedLabel = viewportLabels[ viewportMode ]; + const tooltipLabel = viewportControlsDisabled + ? __( 'Not available for Database' ) + : __( 'Preview size' ); const getPresetLabel = ( preset: ViewportPreset ) => sprintf( /* translators: 1: device name (e.g. Mobile), 2: viewport width, 3: viewport height in pixels */ @@ -591,42 +597,86 @@ function PreviewOverflowMenu( { preset.width, preset.height ); + const renderSelectedIndicator = ( mode: ViewportMode ) => ( + + ); return ( - // Unlike the app's other (non-modal) menus, this one floats over the - // webview, which swallows outside clicks before they reach the host - // document. Modal mode mounts a backdrop that catches them, so - // clicking the preview dismisses the menu like clicking anywhere else. - + + + } + > + { selectedLabel } + + + } /> - } - /> + }> + { tooltipLabel } + + + { __( 'Responsive mode' ) } - onViewportModeChange( next as ViewportMode ) } - disabled={ viewportControlsDisabled } - > - { __( 'Fit pane' ) } + + onViewportModeChange( 'fit' ) } + > + { renderSelectedIndicator( 'fit' ) } + { __( 'Responsive' ) } + { VIEWPORT_PRESETS.map( ( preset ) => ( - + onViewportModeChange( preset.id ) } + > + { renderSelectedIndicator( preset.id ) } { getPresetLabel( // Keep the advertised dimensions honest in landscape. preset.id === 'mobile' ? getMobilePreset( mobileOrientation ) : preset ) } - + ) ) } - { __( 'Desktop + Mobile' ) } - + onViewportModeChange( 'split' ) } + > + { renderSelectedIndicator( 'split' ) } + { __( 'Desktop + Mobile' ) } + + { viewportMode === 'mobile' || viewportMode === 'split' ? ( <> @@ -644,14 +694,6 @@ function PreviewOverflowMenu( { ) : null } - { onFullscreenChange ? ( - <> - - onFullscreenChange( ! fullscreen ) }> - { fullscreen ? __( 'Exit full preview' ) : __( 'Full preview' ) } - - - ) : null } ); @@ -665,32 +707,76 @@ function PreviewOverflowMenu( { function PreviewAnnotationControls( { isPicking, annotationCount, + hasUnsavedDraft, + cancelRequestId, disabled, + disabledReason, onCommand, }: { isPicking: boolean; annotationCount: number; + hasUnsavedDraft: boolean; + cancelRequestId: number; disabled: boolean; + disabledReason?: string; onCommand: ( type: InspectorCommand[ 'type' ] ) => void; } ) { - const toggleLabel = isPicking ? __( 'Stop annotating' ) : __( 'Annotate' ); + const [ cancelDialogOpen, setCancelDialogOpen ] = useState( false ); + const toggleLabel = isPicking ? __( 'Cancel annotation' ) : __( 'Annotate' ); const submitLabel = __( 'Send annotations to chat' ); const hasPending = annotationCount > 0; + const handledCancelRequestId = useRef( cancelRequestId ); + const requestCancel = useCallback( () => { + if ( hasPending || hasUnsavedDraft ) { + setCancelDialogOpen( true ); + } else { + onCommand( 'cancel' ); + } + }, [ hasPending, hasUnsavedDraft, onCommand ] ); + useEffect( () => { + if ( handledCancelRequestId.current === cancelRequestId ) return; + handledCancelRequestId.current = cancelRequestId; + if ( isPicking ) requestCancel(); + }, [ cancelRequestId, isPicking, requestCancel ] ); + const handleToggle = () => ( isPicking ? requestCancel() : onCommand( 'toggle-picking' ) ); + const handleCancel = () => { + setCancelDialogOpen( false ); + onCommand( 'cancel' ); + }; return ( <>
- onCommand( 'toggle-picking' ) } - /> + + + + { ! isPicking ? : null } + + { isPicking ? __( 'Cancel' ) : __( 'Annotate' ) } + + + } + /> + }> + { disabledReason ?? ( isPicking ? __( 'Cancel annotation' ) : __( 'Add notes' ) ) } + + + { hasPending ? ( } - > - - + /> }> { toggleLabel } @@ -764,23 +849,143 @@ function PreviewAnnotationControls( {
- onCommand( 'toggle-picking' ) }>{ toggleLabel } + { toggleLabel } onCommand( 'submit' ) }>{ submitLabel } ) : null } + + + + { __( 'Cancel annotation?' ) } + + + + { __( 'Your annotations and any unfinished note will be discarded.' ) } + + + + + { __( 'Keep annotating' ) } + + + + + ); } +export function getDirectionalHistoryEntries( + entries: BrowserHistoryEntry[], + activeIndex: number, + direction: 'back' | 'forward' +): BrowserHistoryEntry[] { + const directional = entries.filter( ( entry ) => + direction === 'back' ? entry.index < activeIndex : entry.index > activeIndex + ); + return direction === 'back' ? directional.reverse() : directional; +} + +function getHistoryEntryPath( url: string ) { + try { + const parsed = new URL( url ); + return `${ parsed.pathname }${ parsed.search }${ parsed.hash }`; + } catch { + return url; + } +} + +function BrowserHistoryButton( { + direction, + browserState, + shortcut, + onStep, + onJump, +}: { + direction: 'back' | 'forward'; + browserState: BrowserNavigationState; + shortcut: ReturnType< typeof getNavigationShortcutDescriptor >; + onStep: () => void; + onJump: ( index: number ) => void; +} ) { + const entries = getDirectionalHistoryEntries( + browserState.historyEntries, + browserState.activeHistoryIndex, + direction + ); + const back = direction === 'back'; + const label = back ? __( 'Back' ) : __( 'Forward' ); + const disabled = back ? ! browserState.canGoBack : ! browserState.canGoForward; + const [ menuOpen, setMenuOpen ] = useState( false ); + const trigger = ( + { + event.preventDefault(); + setMenuOpen( true ); + } } + /> + ); + + return ( + { + if ( ! open ) { + setMenuOpen( false ); + } + } } + > + + + { entries.length ? ( + entries.map( ( entry ) => ( + onJump( entry.index ) } + > + + + { entry.title || getHistoryEntryPath( entry.url ) } + + { getHistoryEntryPath( entry.url ) } + + + ) ) + ) : ( + + { back ? __( 'No back history' ) : __( 'No forward history' ) } + + ) } + + + ); +} + function areBrowserStatesEqual( a: BrowserNavigationState, b: BrowserNavigationState ) { return ( a.canGoBack === b.canGoBack && a.canGoForward === b.canGoForward && a.loading === b.loading && a.progress === b.progress && - a.title === b.title + a.title === b.title && + a.activeHistoryIndex === b.activeHistoryIndex && + a.historyEntries.length === b.historyEntries.length && + a.historyEntries.every( ( entry, index ) => { + const other = b.historyEntries[ index ]; + return other?.index === entry.index && other.title === entry.title && other.url === entry.url; + } ) ); } @@ -832,10 +1037,10 @@ export function SitePreview( { // Orientation of the phone frame, wherever it shows (mobile preset and // the split view's phone pane). const [ mobileOrientation, setMobileOrientation ] = useState< MobileOrientation >( 'portrait' ); + const [ annotationCancelRequestId, setAnnotationCancelRequestId ] = useState( 0 ); const [ paneSize, setPaneSize ] = useState< { width: number; height: number } | null >( null ); const rootRef = useRef< HTMLElement | null >( null ); const paneRef = useRef< HTMLDivElement | null >( null ); - const locationRef = useRef< HTMLDivElement | null >( null ); const commandIdRef = useRef( 0 ); const canAnnotate = canPreview && inspectorState.ready; const progress = browserState.loading @@ -947,6 +1152,19 @@ export function SitePreview( { }, [ activeSurfaceKey, patchSurface ] ); + const goToHistoryIndex = useCallback( + ( historyIndex: number ) => { + commandIdRef.current += 1; + patchSurface( activeSurfaceKey, { + browserCommand: { + id: commandIdRef.current, + type: 'go-to-history', + historyIndex, + }, + } ); + }, + [ activeSurfaceKey, patchSurface ] + ); const sendInspectorCommand = useCallback( ( type: InspectorCommand[ 'type' ] ) => { commandIdRef.current += 1; @@ -962,9 +1180,12 @@ export function SitePreview( { onFullscreenChange?.( ! fullscreen ); return; } + if ( inspectorState.isPicking ) { + return; + } sendBrowserCommand( command ); }, - [ fullscreen, onFullscreenChange, sendBrowserCommand ] + [ fullscreen, inspectorState.isPicking, onFullscreenChange, sendBrowserCommand ] ); // Point the active surface at whatever path the host is asking for, creating @@ -1061,13 +1282,8 @@ export function SitePreview( { ( mode: ViewportMode ) => { setViewportMode( mode ); viewportBySiteRef.current[ site.id ] = { ...viewportBySiteRef.current[ site.id ], mode }; - // Two frames side by side need the room — a desktop page beside a - // phone is unreadable in the narrow panel. - if ( mode === 'split' ) { - onFullscreenChange?.( true ); - } }, - [ onFullscreenChange, site.id ] + [ site.id ] ); const handleMobileOrientationChange = useCallback( ( orientation: MobileOrientation ) => { @@ -1080,16 +1296,6 @@ export function SitePreview( { [ site.id ] ); - // The comparison is a full-preview mode: leaving full preview (or landing - // on a site that remembered it) falls back to the single fit-to-pane view - // rather than squeezing both frames into the panel. Only when the host - // offers full preview at all — otherwise the mode could never be picked. - useEffect( () => { - if ( onFullscreenChange && ! fullscreen && viewportMode === 'split' ) { - handleViewportModeChange( 'fit' ); - } - }, [ fullscreen, handleViewportModeChange, onFullscreenChange, viewportMode ] ); - useEffect( () => { const remembered = viewportBySiteRef.current[ site.id ]; setViewportMode( remembered?.mode ?? 'fit' ); @@ -1119,7 +1325,7 @@ export function SitePreview( { return () => observer.disconnect(); }, [] ); - // Browser shortcuts (⌘R / ⌘[ / ⌘] / ⌘←/⌘→) and the ⌘1/⌘2/⌘3 realm switches + // 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. @@ -1128,13 +1334,15 @@ export function SitePreview( { return; } const handleKeyDown = ( event: globalThis.KeyboardEvent ) => { - const command = getBrowserShortcutCommand( event ); - const realm = command ? null : getRealmShortcut( event ); + const cancelAnnotation = + inspectorState.isPicking && + event.key === 'Escape' && + ! ( event.target instanceof Element && event.target.closest( '[role="dialog"]' ) ); + const command = inspectorState.isPicking ? null : getBrowserShortcutCommand( event ); // Only claim the full-preview chord when the host actually offers // the mode, so it stays available to the page otherwise. - const fullPreview = - ! command && ! realm && !! onFullscreenChange && isFullPreviewShortcut( event ); - if ( ! command && ! realm && ! fullPreview ) { + const fullPreview = ! command && !! onFullscreenChange && isFullPreviewShortcut( event ); + if ( ! cancelAnnotation && ! command && ! fullPreview ) { return; } const activeElement = document.activeElement; @@ -1147,10 +1355,10 @@ export function SitePreview( { } event.preventDefault(); event.stopPropagation(); - if ( command ) { + if ( cancelAnnotation ) { + setAnnotationCancelRequestId( ( current ) => current + 1 ); + } else if ( command ) { sendBrowserCommand( command ); - } else if ( realm ) { - handleSwitchRealm( realm ); } else { onFullscreenChange?.( ! fullscreen ); } @@ -1162,7 +1370,7 @@ export function SitePreview( { canPreview, collapsed, fullscreen, - handleSwitchRealm, + inspectorState.isPicking, onFullscreenChange, sendBrowserCommand, ] ); @@ -1174,10 +1382,12 @@ export function SitePreview( { aria-label={ __( 'Site preview' ) } >