diff --git a/apps/ui/src/components/site-dropdown/index.test.tsx b/apps/ui/src/components/site-dropdown/index.test.tsx index 102f854038..204ed7a1dc 100644 --- a/apps/ui/src/components/site-dropdown/index.test.tsx +++ b/apps/ui/src/components/site-dropdown/index.test.tsx @@ -31,7 +31,9 @@ vi.mock( '@/data/sync-activity', () => ( { useSiteSyncActivity: () => null } ) ) vi.mock( '@/components/selective-sync/lib/get-ipc-api', () => ( { registerSelectiveSyncConnector: vi.fn(), } ) ); -vi.mock( './disconnect-site-dialog', () => ( { DisconnectSiteDialog: () => null } ) ); +vi.mock( '@/components/site-toolbar/disconnect-site-dialog', () => ( { + DisconnectSiteDialog: () => null, +} ) ); vi.mock( '@/components/selective-sync/lib/convert-tree-to-sync-options', () => ( { convertTreeToPullOptions: () => ( { optionsToSync: [ 'all' ], include_path_list: [] } ), convertTreeToPushOptions: () => ( { optionsToSync: [ 'all' ] } ), diff --git a/apps/ui/src/components/site-dropdown/index.tsx b/apps/ui/src/components/site-dropdown/index.tsx index 3459363050..5f3cb69670 100644 --- a/apps/ui/src/components/site-dropdown/index.tsx +++ b/apps/ui/src/components/site-dropdown/index.tsx @@ -7,6 +7,13 @@ import { import { registerSelectiveSyncConnector } from '@/components/selective-sync/lib/get-ipc-api'; import { SyncDialog } from '@/components/selective-sync/sync-dialog'; import '@/components/selective-sync/selective-sync.css'; +import { DisconnectSiteDialog } from '@/components/site-toolbar/disconnect-site-dialog'; +import { + deriveSiteStatus, + ensureProtocol, + pickLatestSnapshot, + pickLiveSite, +} from '@/components/site-toolbar/utils'; import { useConnector } from '@/data/core'; import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; import { useIsSiteStarting, useIsSiteStopping, useSiteOperation } from '@/data/queries/use-sites'; @@ -14,13 +21,11 @@ import { useSnapshots } from '@/data/queries/use-snapshots'; import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site'; import { useSiteSyncActivity } from '@/data/sync-activity'; import { getSiteDisplayUrl } from '@/lib/get-site-url'; -import { DisconnectSiteDialog } from './disconnect-site-dialog'; import { DropdownTrigger } from './dropdown-trigger'; import { MainView } from './main-view'; import { PublishPickerView } from './publish-picker-view'; import styles from './style.module.css'; import { getSiteDropdownSecondary } from './trigger-secondary'; -import { deriveSiteStatus, ensureProtocol, pickLatestSnapshot, pickLiveSite } from './utils'; import type { TreeNode } from '@/components/selective-sync/tree-view'; import type { SiteDetails } from '@/data/core'; diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx index b45df7c02e..39f65c6a42 100644 --- a/apps/ui/src/components/site-dropdown/main-view.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.tsx @@ -9,6 +9,15 @@ import { Button, IconButton, Tooltip } from '@wordpress/ui'; import { clsx } from 'clsx'; import { useMemo } from 'react'; import * as Menu from '@/components/menu'; +import { + deriveSiteStatus, + getSiteStatusName, + ensureProtocol, + getSnapshotHostname, + pickLatestSnapshot, + pickLiveSite, + stripProtocol, +} from '@/components/site-toolbar/utils'; import { XdebugIcon } from '@/components/xdebug-icon'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; @@ -34,15 +43,6 @@ import { getSiteUrl } from '@/lib/get-site-url'; import styles from './main-view.module.css'; import { PopoverRow } from './popover-row'; import { getSyncActivityLabel } from './trigger-secondary'; -import { - deriveSiteStatus, - getSiteStatusName, - ensureProtocol, - getSnapshotHostname, - pickLatestSnapshot, - pickLiveSite, - stripProtocol, -} from './utils'; import type { SiteDetails } from '@/data/core'; import type { SyncActivity } from '@/data/sync-activity'; import type { ComponentProps } from 'react'; diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx b/apps/ui/src/components/site-dropdown/publish-picker-view.tsx index 9bfe92bc9e..dc38d47c39 100644 --- a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx +++ b/apps/ui/src/components/site-dropdown/publish-picker-view.tsx @@ -3,13 +3,13 @@ import { __ } from '@wordpress/i18n'; import { chevronLeft, plus } from '@wordpress/icons'; import { Icon, IconButton } from '@wordpress/ui'; import { useEffect, useState } from 'react'; +import { stripProtocol } from '@/components/site-toolbar/utils'; import { useConnector } from '@/data/core'; import { useAuthUser } from '@/data/queries/use-auth-user'; import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites'; import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites'; import { getWpcomLoadErrorDetail } from '@/lib/wpcom-load-error'; import styles from './publish-picker-view.module.css'; -import { stripProtocol } from './utils'; import type { SiteDetails, SyncSite } from '@/data/core'; type Props = { diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx index c417fcd223..84fdb040b5 100644 --- a/apps/ui/src/components/site-list/index.tsx +++ b/apps/ui/src/components/site-list/index.tsx @@ -28,7 +28,7 @@ import { DeleteSiteDialog } from '@/components/delete-site-dialog'; import * as Menu from '@/components/menu'; import { ReorderableList } from '@/components/reorderable-list'; import { SidebarButton } from '@/components/sidebar-button'; -import { deriveSiteStatus, getSiteStatusName } from '@/components/site-dropdown/utils'; +import { deriveSiteStatus, getSiteStatusName } from '@/components/site-toolbar/utils'; import { XdebugIcon } from '@/components/xdebug-icon'; import { useConnector } from '@/data/core'; import { useSiteAgentActivity, type SiteAgentActivity } from '@/data/queries/use-agent-run'; diff --git a/apps/ui/src/components/site-dropdown/disconnect-site-dialog.module.css b/apps/ui/src/components/site-toolbar/disconnect-site-dialog.module.css similarity index 100% rename from apps/ui/src/components/site-dropdown/disconnect-site-dialog.module.css rename to apps/ui/src/components/site-toolbar/disconnect-site-dialog.module.css diff --git a/apps/ui/src/components/site-dropdown/disconnect-site-dialog.tsx b/apps/ui/src/components/site-toolbar/disconnect-site-dialog.tsx similarity index 100% rename from apps/ui/src/components/site-dropdown/disconnect-site-dialog.tsx rename to apps/ui/src/components/site-toolbar/disconnect-site-dialog.tsx diff --git a/apps/ui/src/components/site-toolbar/index.tsx b/apps/ui/src/components/site-toolbar/index.tsx new file mode 100644 index 0000000000..8f1aee26e5 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/index.tsx @@ -0,0 +1,397 @@ +import { isSnapshotExpired } from '@studio/common/lib/snapshots'; +import { useIsMutating } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import { chevronDownSmall, moreVertical } from '@wordpress/icons'; +import { Button, Icon, IconButton } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CopyButton } from '@/components/copy-button'; +import * as Menu from '@/components/menu'; +import { + convertTreeToPullOptions, + convertTreeToPushOptions, +} from '@/components/selective-sync/lib/convert-tree-to-sync-options'; +import { registerSelectiveSyncConnector } from '@/components/selective-sync/lib/get-ipc-api'; +import { SyncDialog } from '@/components/selective-sync/sync-dialog'; +import { SiteIcon } from '@/components/site-icon'; +import { useConnector } from '@/data/core'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; +import { useLogin } from '@/data/queries/use-auth-user'; +import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; +import { + PUBLISH_PREVIEW_MUTATION_KEY, + usePublishPreviewSite, +} from '@/data/queries/use-preview-site'; +import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots'; +import { + PULL_FROM_LIVE_MUTATION_KEY, + PUSH_TO_LIVE_MUTATION_KEY, + usePullSiteFromLive, + usePushSiteToLive, +} from '@/data/queries/use-sync-site'; +import { useSiteSyncActivity } from '@/data/sync-activity'; +import { getSiteDisplayUrl } from '@/lib/get-site-url'; +import { DisconnectSiteDialog } from './disconnect-site-dialog'; +import { PublishPickerView } from './publish-picker-view'; +import styles from './style.module.css'; +import { + ensureProtocol, + getSnapshotHostname, + pickLatestSnapshot, + pickLiveSite, + stripProtocol, +} from './utils'; +import '@/components/selective-sync/selective-sync.css'; +import type { TreeNode } from '@/components/selective-sync/tree-view'; +import type { SiteDetails } from '@/data/core'; + +interface SiteToolbarProps { + site: SiteDetails; + className?: string; + // Opens the Pull dialog once the connection loads. Set by the deep link + // onboarding follows after connecting a site, to nudge the first pull. + openPullOnLoad?: boolean; +} + +// Counts in-flight operations that export or mutate this site's local files. +// A mutation started from another surface must still disable this header so +// preview publishing, push, and pull cannot overlap. +function useIsSiteBusy( siteId: string ): boolean { + const forSite = ( mutation: { state: { variables?: unknown } } ) => + ( mutation.state.variables as { siteId?: string } | undefined )?.siteId === siteId; + const push = useIsMutating( { mutationKey: PUSH_TO_LIVE_MUTATION_KEY, predicate: forSite } ) > 0; + const pull = + useIsMutating( { mutationKey: PULL_FROM_LIVE_MUTATION_KEY, predicate: forSite } ) > 0; + const preview = + useIsMutating( { mutationKey: PUBLISH_PREVIEW_MUTATION_KEY, predicate: forSite } ) > 0; + return push || pull || preview; +} + +/** + * The site's permanent header: who you're working on on the left, its actions + * on the right. Replaces the old site dropdown, whose actions were hidden + * behind a trigger that read as a status indicator. + * + * A connected site gets a Sync menu (push/pull) and Share; an unconnected one + * gets Publish to connect a WordPress.com site. Signed-out users see Log in. + */ +export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteToolbarProps ) { + const connector = useConnector(); + const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures(); + const login = useLogin( { source: 'site_header' } ); + const pushSiteToLive = usePushSiteToLive(); + const pullSiteFromLive = usePullSiteFromLive(); + const publishPreviewSite = usePublishPreviewSite(); + + const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null ); + const [ publishOpen, setPublishOpen ] = useState( false ); + const [ disconnectOpen, setDisconnectOpen ] = useState( false ); + const [ shareMenuOpen, setShareMenuOpen ] = useState( false ); + const [ publishedPreviewUrl, setPublishedPreviewUrl ] = useState< string | undefined >(); + + const isBusy = useIsSiteBusy( site.id ); + const syncActivity = useSiteSyncActivity( site.id ); + const isPreviewPending = + publishPreviewSite.isPending || + ( syncActivity?.kind === 'pending' && syncActivity.direction === 'preview' ); + + const { data: connectedSites } = useConnectedWpcomSites( site.id ); + const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] ); + const { data: snapshots } = useSnapshots(); + const { data: snapshotUsage } = useSnapshotUsage(); + const previewSnapshot = useMemo( + () => pickLatestSnapshot( snapshots, site.id ), + [ snapshots, site.id ] + ); + const currentPreview = + previewSnapshot && ! isSnapshotExpired( previewSnapshot ) ? previewSnapshot : undefined; + const previewUrl = currentPreview?.url ?? publishedPreviewUrl; + const previewCreationBlocked = + snapshotUsage?.siteCreationBlocked === true || + ( snapshotUsage?.siteCount ?? 0 ) >= ( snapshotUsage?.siteLimit ?? Infinity ); + + // The ported selective-sync modules resolve their data calls through the + // active connector (see selective-sync/lib/get-ipc-api.ts). + useEffect( () => { + registerSelectiveSyncConnector( connector ); + }, [ connector ] ); + + // Honour the onboarding deep link once the connection is known: open Pull so + // a freshly connected site can bring the live content down. Fires once. + const pullOpenedRef = useRef( false ); + useEffect( () => { + if ( openPullOnLoad && liveSite && ! pullOpenedRef.current ) { + pullOpenedRef.current = true; + setSyncDialogType( 'pull' ); + } + }, [ openPullOnLoad, liveSite ] ); + + const isSignedOut = agenticReason === 'signed-out'; + const isOffline = agenticReason === 'offline'; + const syncDisabled = ! agenticEnabled || isBusy; + + const openExternal = ( url: string ) => { + void connector.openExternalUrl( url ); + }; + + const handleDialogPush = ( tree: TreeNode[] ) => { + if ( ! liveSite ) { + return; + } + const options = convertTreeToPushOptions( tree ); + pushSiteToLive.mutate( + { siteId: site.id, remoteSiteId: liveSite.id, options }, + { onSuccess: () => openExternal( ensureProtocol( liveSite.url ) ) } + ); + setSyncDialogType( null ); + }; + + const handleDialogPull = ( tree: TreeNode[] ) => { + if ( ! liveSite ) { + return; + } + const { optionsToSync, include_path_list: includePathList } = convertTreeToPullOptions( tree ); + pullSiteFromLive.mutate( { + siteId: site.id, + remoteSiteId: liveSite.id, + options: { optionsToSync, includePathList }, + } ); + setSyncDialogType( null ); + }; + + const publishPreview = () => { + if ( syncDisabled ) { + return; + } + publishPreviewSite.mutate( + { + siteId: site.id, + // Pass the existing hostname so the CLI updates that preview instead of + // creating a new one (see getSnapshotHostname). + existingHostname: currentPreview + ? getSnapshotHostname( currentPreview ) + : previewUrl + ? stripProtocol( previewUrl ) + : undefined, + }, + { + onSuccess: ( { url } ) => { + setPublishedPreviewUrl( url ); + setShareMenuOpen( true ); + }, + } + ); + }; + + return ( +
+
+ +
+ { site.name } + { getSiteDisplayUrl( site ) } +
+
+ +
+ { ! isSignedOut ? ( + + + + { __( 'Share' ) } + + + + } + /> + + { previewUrl ? ( + <> + + +
+ { stripProtocol( previewUrl ) } + +
+
+
+ +
+ openExternal( ensureProtocol( previewUrl ) ) } + > + { __( 'Open in browser' ) } + + + { __( 'Push' ) } + +
+ + ) : ( + <> +

+ { __( 'Create a temporary share link that expires in 7 days.' ) } +

+ + { __( 'Create preview' ) } + + + ) } +
+
+ ) : null } + { isSignedOut ? ( + + ) : liveSite ? ( + <> + + + + { __( 'Sync' ) } + + + + } + /> + +

+ { __( + 'Move files and database content between this local site and its connected live site.' + ) } +

+
+ setSyncDialogType( 'push' ) } + > + { __( 'Push…' ) } + + setSyncDialogType( 'pull' ) } + > + { __( 'Pull…' ) } + +
+
+
+ + + } + /> + + setDisconnectOpen( true ) }> + { __( 'Disconnect' ) } + + + + + ) : ( + + + + { __( 'Publish' ) } + + + + } + /> + { publishOpen ? ( + + setPublishOpen( false ) } /> + + ) : null } + + ) } +
+ + { liveSite && syncDialogType ? ( + setSyncDialogType( null ) } + /> + ) : null } + + { liveSite ? ( + + ) : null } +
+ ); +} diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.module.css b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css new file mode 100644 index 0000000000..5a609468a0 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css @@ -0,0 +1,125 @@ +.status, +.statusError { + margin: 0; + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.35; +} + +.status { + color: var(--wpds-color-fg-content-neutral-weak); +} + +.statusError { + color: var(--wpds-color-fg-content-error); +} + +.emptyState { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs); + margin: 0; + padding: var(--wpds-dimension-padding-sm); + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.4; +} + +.emptyState strong { + color: var(--wpds-color-fg-content-neutral); + font-weight: 600; +} + +.loadingStatus { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--wpds-dimension-gap-sm); + padding: var(--wpds-dimension-padding-md); + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + text-align: center; +} + +.loadingLine { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--wpds-dimension-gap-sm); +} + +.spinner.spinner { + flex: 0 0 auto; + width: 16px; + height: 16px; + margin: 0; +} + +.loadingHint { + line-height: 1.35; +} + +.loadError { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs); + padding: var(--wpds-dimension-padding-sm); + color: var(--wpds-color-fg-content-error); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.4; +} + +.loadError strong { + font-weight: 600; +} + +.siteList { + max-height: min(320px, calc(100vh - 160px)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.sectionLabel { + margin: 0; + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-sm); +} + +.siteItem { + min-width: 0; +} + +.siteText { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; +} + +.siteName, +.siteUrl { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteName { + color: var(--wpds-color-fg-content-neutral); + font-weight: 600; +} + +.siteUrl { + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); +} + +.createItem { + justify-content: space-between; +} + +.menuIcon { + fill: currentColor; +} diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx new file mode 100644 index 0000000000..0a7cff218c --- /dev/null +++ b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx @@ -0,0 +1,151 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { external, Icon } from '@wordpress/icons'; +import { useEffect, useState } from 'react'; +import * as Menu from '@/components/menu'; +import { useConnector } from '@/data/core'; +import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites'; +import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites'; +import { getWpcomLoadErrorDetail } from '@/lib/wpcom-load-error'; +import styles from './publish-picker-view.module.css'; +import { stripProtocol } from './utils'; +import type { SiteDetails, SyncSite } from '@/data/core'; + +type Props = { + site: SiteDetails; + onClose: () => void; +}; + +export function PublishPickerView( { site, onClose }: Props ) { + const connector = useConnector(); + const queryClient = useQueryClient(); + const pickableSites = usePickableWpcomSites(); + const sites = pickableSites.data; + const isLoading = sites === undefined && ( pickableSites.isLoading || pickableSites.isFetching ); + const loadFailed = !! pickableSites.error && pickableSites.data === undefined; + const [ connectingId, setConnectingId ] = useState< number | null >( null ); + const [ error, setError ] = useState( '' ); + const [ slowLoading, setSlowLoading ] = useState( false ); + const loadErrorDetail = getWpcomLoadErrorDetail( pickableSites.error ); + + useEffect( () => { + if ( ! isLoading ) { + setSlowLoading( false ); + return; + } + const timer = window.setTimeout( () => setSlowLoading( true ), 6_000 ); + return () => window.clearTimeout( timer ); + }, [ isLoading ] ); + + const handlePickSite = async ( pickedSite: SyncSite ) => { + if ( connectingId !== null ) { + return; + } + setConnectingId( pickedSite.id ); + setError( '' ); + try { + // Picking an existing site links it directly, so it's connected without a + // sync-eligibility check. + await connector.connectWpcomSite( site.id, { + ...pickedSite, + localSiteId: site.id, + syncSupport: 'already-connected', + } ); + await queryClient.invalidateQueries( { + queryKey: connectedWpcomSitesQueryKey( site.id ), + } ); + onClose(); + } catch ( caught ) { + setError( + caught instanceof Error + ? caught.message + : __( 'Failed to connect the site. Please try again.' ) + ); + } finally { + setConnectingId( null ); + } + }; + + const handleCreateNew = () => { + const checkoutUrl = connector.getPublishCheckoutUrl( site ); + if ( checkoutUrl ) { + void connector.watchForPublishedSite?.( site.id ); + void connector.openExternalUrl( checkoutUrl ); + } + onClose(); + }; + + return ( + <> + { error ? ( +

+ { error } +

+ ) : null } + { isLoading ? ( +
+ + + { __( 'Loading WordPress.com sites…' ) } + + { slowLoading ? ( + + { __( 'Large accounts can take a little longer.' ) } + + ) : null } +
+ ) : loadFailed ? ( + <> +
+ { __( 'Couldn’t load your WordPress.com sites.' ) } + { loadErrorDetail } +
+ void pickableSites.refetch() }> + { __( 'Retry' ) } + + + ) : sites && sites.length > 0 ? ( + <> +

{ __( 'Available sites' ) }

+
+ + { sites.map( ( candidate ) => ( + void handlePickSite( candidate ) } + > + + + { connectingId === candidate.id + ? __( 'Connecting…' ) + : candidate.name || candidate.url } + + { stripProtocol( candidate.url ) } + + + ) ) } + +
+ + ) : ( +
+ { __( 'Publish to WordPress.com' ) } + { __( 'Create and connect a site for future pushes and pulls.' ) } +
+ ) } + + + { __( 'Create a site…' ) } + + + ); +} diff --git a/apps/ui/src/components/site-toolbar/style.module.css b/apps/ui/src/components/site-toolbar/style.module.css new file mode 100644 index 0000000000..b41cfbcf34 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/style.module.css @@ -0,0 +1,254 @@ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wpds-dimension-gap-md); + padding-inline-start: 7px; + /* Fills whatever header row hosts it, so the action stays pinned to the + panel's right edge, and takes the row's full height so the actions have a + top to align to. */ + flex: 1; + align-self: stretch; + min-width: 0; + /* The window drags by the toolbar's empty space; its controls opt back out + below so clicks reach them. */ + -webkit-app-region: drag; +} + +.identity { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-md); + min-width: 0; +} + +.siteIcon { + flex: 0 0 auto; + inline-size: 32px; + block-size: 32px; + border-radius: var(--wpds-border-radius-sm); +} + +.identityText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.siteName { + font-size: var(--wpds-typography-font-size-md); + font-weight: 600; + line-height: 1.25; + color: var(--wpds-color-fg-content-neutral); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.siteUrlStatic { + min-width: 0; + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.25; + color: var(--wpds-color-fg-content-neutral-weak); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-lg); + flex: 0 0 auto; + /* Pinned to the top of the header rather than centred against the site's + two-line identity block so it lines up with the preview toolbar. */ + align-self: flex-start; + padding-block-start: 3px; + -webkit-app-region: no-drag; +} + +.syncMenu { + width: 240px; +} + +.menuIntro { + margin: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) + var(--wpds-dimension-padding-sm); + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.4; +} + +.primaryAction { + min-width: 72px; +} + +/* Pairs a button's label with a menu-indicator chevron, close enough to read + as one unit rather than a separate icon. Flips 180° when the menu it opens + is open — Base UI stamps data-popup-open on the trigger itself, so this + needs no extra state. */ +.actionLabelWithChevron { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.menuChevron { + fill: currentColor; + transition: transform 150ms ease; +} + +[data-popup-open] .menuChevron { + transform: rotate(180deg); +} + +.publishMenu { + min-width: 0; + width: 240px; +} + +/* --- Primary action ------------------------------------------------------ */ + +.action { + position: relative; + /* A stable floor so the label can change without the toolbar reflowing + around it. */ + min-width: 64px; + justify-content: center; + /* Keeps the progress fill inside the button's rounded edge. */ + overflow: hidden; + transition: min-width 200ms ease; +} + +/* Fills from the leading edge as the sync advances. Drawn from the button's + own foreground so it works on the solid and the outline button alike. */ +.actionProgress { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + inline-size: var(--action-progress, 0%); + background: color-mix(in srgb, currentColor 22%, transparent); + transition: inline-size 400ms ease; + pointer-events: none; +} + +.actionLabel { + display: inline-flex; + align-items: center; + animation: actionLabelIn 200ms ease both; +} + +@keyframes actionLabelIn { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: none; + } +} + +/* --- Details menu -------------------------------------------------------- */ + +.menu { + min-width: 240px; + max-width: 320px; +} + +.previewUrlRow { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); +} + +.previewUrl { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + font-size: var(--wpds-typography-font-size-sm); +} + +.shareActions { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); +} + +.shareAction { + flex: 1; + justify-content: center; +} + +.shareCopy { + display: flex; + flex: 0 0 auto; +} + +.previewDescription { + margin: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.35; +} + +.shareMenu { + width: max-content; + min-width: 220px; + max-width: 420px; +} + +.menuDetail { + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); + font-size: var(--wpds-typography-font-size-xs); + line-height: 1.5; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.menuDetailError { + color: var(--wpds-color-fg-content-error); +} + +/* Names the list of connections a push or pull could run against. */ +.menuGroupLabel { + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md) + var(--wpds-dimension-padding-xs); + font-size: var(--wpds-typography-font-size-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.menuItemStack { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.menuItemSub { + font-size: var(--wpds-typography-font-size-xs); + color: var(--wpds-color-fg-content-neutral-weak); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menuItemMeta { + margin-inline-start: auto; + padding-inline-start: var(--wpds-dimension-padding-md); + color: var(--wpds-color-fg-content-neutral-weak); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (prefers-reduced-motion: reduce) { + .action, + .actionProgress, + .menuChevron { + transition: none; + } + + .actionLabel { + animation: none; + } +} diff --git a/apps/ui/src/components/site-dropdown/utils.test.ts b/apps/ui/src/components/site-toolbar/utils.test.ts similarity index 100% rename from apps/ui/src/components/site-dropdown/utils.test.ts rename to apps/ui/src/components/site-toolbar/utils.test.ts diff --git a/apps/ui/src/components/site-dropdown/utils.ts b/apps/ui/src/components/site-toolbar/utils.ts similarity index 95% rename from apps/ui/src/components/site-dropdown/utils.ts rename to apps/ui/src/components/site-toolbar/utils.ts index ad0bc046d9..e4ec6bc837 100644 --- a/apps/ui/src/components/site-dropdown/utils.ts +++ b/apps/ui/src/components/site-toolbar/utils.ts @@ -2,9 +2,10 @@ import { type SiteOperationKind } from '@studio/common/lib/site-operation'; import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels'; import { __, sprintf } from '@wordpress/i18n'; import { getSiteDisplayUrl } from '@/lib/get-site-url'; -import type { SiteStatus } from './dropdown-trigger'; import type { SiteDetails, Snapshot, SyncSite } from '@/data/core'; +export type SiteRunStatus = 'running' | 'stopped' | 'transitioning'; + export function stripProtocol( url: string ): string { return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' ); } @@ -84,7 +85,7 @@ function getStatus( isStarting: boolean, isStopping: boolean, operation: SiteOperationKind | null -): SiteStatus { +): SiteRunStatus { if ( operation || isStarting || isStopping ) { return 'transitioning'; } @@ -93,7 +94,7 @@ function getStatus( // Sentence form, read out by the status dot's aria-label. function getStatusLabel( - status: SiteStatus, + status: SiteRunStatus, isStopping: boolean, operation: SiteOperationKind | null ): string { @@ -112,7 +113,7 @@ function getStatusLabel( // The local-site row's second line: what's happening, or where the site lives. function getLocalSublabel( site: SiteDetails, - status: SiteStatus, + status: SiteRunStatus, isStopping: boolean, operation: SiteOperationKind | null ): string { @@ -136,7 +137,7 @@ export function deriveSiteStatus( // replace the two flags above. Passed in rather than derived here because // it's react-query state and this stays a pure function. operation: SiteOperationKind | null -): { status: SiteStatus; statusLabel: string; localSublabel: string } { +): { status: SiteRunStatus; statusLabel: string; localSublabel: string } { const status = getStatus( site, isStarting, isStopping, operation ); return { diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts index 523388efa0..cd81e78948 100644 --- a/apps/ui/src/data/queries/use-preview-site.ts +++ b/apps/ui/src/data/queries/use-preview-site.ts @@ -10,13 +10,16 @@ type PublishPreviewVariables = { existingHostname?: string; }; +export const PUBLISH_PREVIEW_MUTATION_KEY = [ 'publishPreviewSite' ] as const; + // Creates or refreshes the WordPress.com-hosted preview snapshot for a -// local site. Reports lifecycle into the shared sync-activity store so the -// site-dropdown indicator can render the pending / success / error states. +// local site. Reports lifecycle into the shared sync-activity store so +// sync-activity consumers can render the pending / success / error states. export function usePublishPreviewSite() { const connector = useConnector(); const queryClient = useQueryClient(); return useMutation( { + mutationKey: PUBLISH_PREVIEW_MUTATION_KEY, mutationFn: ( { siteId, existingHostname }: PublishPreviewVariables ) => connector.publishPreviewSite( siteId, existingHostname ), onMutate: ( { siteId } ) => { diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx index dc66e1bc68..1ddc11288d 100644 --- a/apps/ui/src/ui-classic/components/session-view/index.tsx +++ b/apps/ui/src/ui-classic/components/session-view/index.tsx @@ -18,9 +18,9 @@ import { } from 'react'; import { PreviewToggleButton } from '@/components/preview-toggle-button'; import { ProgressiveBlur } from '@/components/progressive-blur'; -import { SiteDropdown } from '@/components/site-dropdown'; import { SiteIcon } from '@/components/site-icon'; import { type Annotation } from '@/components/site-preview/types'; +import { SiteToolbar } from '@/components/site-toolbar'; import { useAgentRun } from '@/data/queries/use-agent-run'; import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota'; import { @@ -83,12 +83,7 @@ function SessionHeader( { summary }: SessionHeaderProps ) { ) } > { site ? ( - + ) : ( <> @@ -97,9 +92,9 @@ function SessionHeader( { summary }: SessionHeaderProps ) { { effectiveEnvironment === 'live' ? __( 'Live' ) : __( 'Local' ) } +