diff --git a/apps/ui/src/components/connect-site-picker/index.tsx b/apps/ui/src/components/connect-site-picker/index.tsx
new file mode 100644
index 0000000000..a0c1d42573
--- /dev/null
+++ b/apps/ui/src/components/connect-site-picker/index.tsx
@@ -0,0 +1,336 @@
+import { Spinner, VisuallyHidden } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { external, search } from '@wordpress/icons';
+import { Badge, Button, Icon } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useMemo, useState } from 'react';
+import { useConnector } from '@/data/core';
+import { useUserLocale } from '@/data/queries/use-user-locale';
+import { useOffline } from '@/hooks/use-offline';
+import { getLocalizedLink } from '@/lib/docs-links';
+import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation';
+import styles from './style.module.css';
+import type { SyncSite } from '@/data/core';
+
+const createWpcomSiteUrl = new URL( 'https://wordpress.com/setup/new-hosted-site' );
+createWpcomSiteUrl.searchParams.set( 'ref', 'studio' );
+createWpcomSiteUrl.searchParams.set( 'section', 'studio-sync' );
+createWpcomSiteUrl.searchParams.set( 'showDomainStep', 'true' );
+
+function getEnvironmentLabel( site: SyncSite ): string {
+ if ( site.isPressable && site.environmentType === 'development' ) return __( 'Development' );
+ if ( site.isPressable && site.environmentType === 'staging' ) return __( 'Staging' );
+ if ( site.isStaging ) return __( 'Staging' );
+ return __( 'Production' );
+}
+
+function getEnvironmentIntent( site: SyncSite ) {
+ if ( site.isPressable && site.environmentType === 'development' ) return 'informational';
+ if ( site.isStaging || ( site.isPressable && site.environmentType === 'staging' ) )
+ return 'medium';
+ return 'stable';
+}
+
+function getSiteStatus( site: SyncSite, group: ConnectSiteGroup ): string {
+ if ( group === 'needs-transfer' ) {
+ return __( 'Enable hosting features on WordPress.com before connecting this site.' );
+ }
+ if ( group === 'needs-upgrade' ) {
+ return __( 'Upgrade this site to a supported plan before connecting it.' );
+ }
+ if ( site.syncSupport === 'missing-permissions' ) {
+ return __( "Your account doesn't have permission to manage this site." );
+ }
+ if ( site.syncSupport === 'deleted' ) return __( 'This site has been deleted.' );
+ return __( 'This site does not support pulling into Studio.' );
+}
+
+export function getSiteName( site: SyncSite ): string {
+ if ( site.name.trim() ) return site.name.trim();
+ try {
+ return new URL( site.url ).hostname;
+ } catch {
+ return __( 'WordPress site' );
+ }
+}
+
+function RemoteSiteCard( {
+ site,
+ group,
+ isSelected,
+ onSelect,
+}: ReturnType< typeof presentRemoteSites >[ number ] & {
+ isSelected: boolean;
+ onSelect: ( id: number ) => void;
+} ) {
+ const connector = useConnector();
+ const isAvailable = group === 'available';
+ const siteName = getSiteName( site );
+ const providerLabel = site.isPressable ? __( 'Pressable' ) : __( 'WP.com' );
+ const environmentLabel = getEnvironmentLabel( site );
+ const siteStatus = isAvailable ? '' : getSiteStatus( site, group );
+ const className = clsx(
+ styles.siteCard,
+ isSelected && styles.siteCardSelected,
+ ! isAvailable && styles.siteCardUnavailable
+ );
+
+ return (
+
+ isAvailable && onSelect( site.id ) }
+ >
+
+
+
+ { providerLabel }
+ { environmentLabel }
+
+
+
+ { siteName }
+ { site.url.replace( /^https?:\/\//, '' ) }
+ { siteStatus && { siteStatus } }
+
+
+ { group === 'needs-transfer' && (
+
+ void connector.openExternalUrl( `https://wordpress.com/hosting-features/${ site.id }` )
+ }
+ >
+ { __( 'Enable hosting features' ) }
+
+
+ ) }
+ { group === 'needs-upgrade' && (
+
+ void connector.openExternalUrl( `https://wordpress.com/plans/${ site.id }` )
+ }
+ >
+ { __( 'View plans' ) }
+
+
+ ) }
+
+ );
+}
+
+export type ConnectSitePickerProps = {
+ sites: SyncSite[] | undefined;
+ isLoading: boolean;
+ isFetching: boolean;
+ error: unknown;
+ onRefresh: () => void;
+ selectedId: number | null;
+ onSelect: ( id: number ) => void;
+ // Shown when the account has no sites this flow can use.
+ emptyTitle?: string;
+ emptyDescription?: string;
+};
+
+/**
+ * The list of WordPress.com and Pressable sites a Studio site can be wired to,
+ * with its search, its grouping into what can and can't be connected, and the
+ * states around loading them. Shared by onboarding, which uses it to bring a
+ * live site down into Studio, and by publishing, which uses it to send one up —
+ * the choice is the same either way, so it should look the same.
+ */
+export function ConnectSitePicker( {
+ sites,
+ isLoading,
+ isFetching,
+ error,
+ onRefresh,
+ selectedId,
+ onSelect,
+ emptyTitle = __( 'No sites found' ),
+ emptyDescription = __( 'This account has no WordPress.com or Pressable sites to show.' ),
+}: ConnectSitePickerProps ) {
+ const connector = useConnector();
+ const locale = useUserLocale();
+ const isOffline = useOffline();
+ const [ searchQuery, setSearchQuery ] = useState( '' );
+
+ const presentedSites = useMemo( () => presentRemoteSites( sites ?? [] ), [ sites ] );
+ const filteredSites = useMemo(
+ () => searchRemoteSites( presentedSites, searchQuery ),
+ [ presentedSites, searchQuery ]
+ );
+ const isSingleSite = presentedSites.length === 1 && searchQuery.trim() === '';
+ const isSingleAvailableSite = isSingleSite && presentedSites[ 0 ].group === 'available';
+
+ if ( isOffline ) {
+ return (
+
+
{ __( "You're offline" ) }
+
{ __( 'Reconnect to load your WordPress.com and Pressable sites.' ) }
+
+ );
+ }
+
+ if ( isLoading ) {
+ return (
+
+
+
{ __( 'Loading your sites…' ) }
+
+ );
+ }
+
+ if ( error ) {
+ return (
+
+
{ __( "We couldn't load your sites" ) }
+
{ __( 'Check your connection and try again.' ) }
+
+ { __( 'Retry' ) }
+
+
+ );
+ }
+
+ if ( presentedSites.length === 0 ) {
+ return (
+
+
{ emptyTitle }
+
{ emptyDescription }
+
void connector.openExternalUrl( createWpcomSiteUrl.toString() ) }
+ >
+ { __( 'Create a WordPress.com site' ) }
+
+
+
+ );
+ }
+
+ const sections = [
+ {
+ key: 'available',
+ title: __( 'Available to connect' ),
+ description: __( 'Select a site to create its local copy.' ),
+ sites: filteredSites.filter( ( entry ) => entry.group === 'available' ),
+ },
+ {
+ key: 'unavailable',
+ title: __( 'Unavailable' ),
+ description: __( 'These sites cannot currently be connected to Studio.' ),
+ sites: filteredSites.filter( ( entry ) => entry.group !== 'available' ),
+ },
+ ];
+
+ return (
+ <>
+
+ { ! isSingleSite && (
+
+
+ { __( 'Search sites' ) }
+ setSearchQuery( event.target.value ) }
+ />
+
+ ) }
+
+
+ { isFetching ? __( 'Refreshing…' ) : __( 'Refresh list' ) }
+
+ ·
+
+ void connector.openExternalUrl( getLocalizedLink( locale, 'docsSyncSupportedSites' ) )
+ }
+ >
+ { __( 'Supported sites' ) }
+
+
+
+
+
+ { filteredSites.length === 0 ? (
+
+
+ { sprintf(
+ // translators: %s is the site search query.
+ __( 'No sites match “%s”.' ),
+ searchQuery
+ ) }
+
+
+ ) : isSingleAvailableSite ? (
+
+ ) : (
+
+ { sections.map(
+ ( section ) =>
+ section.sites.length > 0 && (
+
+
+
{ section.title }
+
{ section.description }
+
+
+ { section.sites.map( ( entry ) => (
+
+ ) ) }
+
+
+ )
+ ) }
+
+ ) }
+ >
+ );
+}
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts b/apps/ui/src/components/connect-site-picker/site-presentation.test.ts
similarity index 100%
rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts
rename to apps/ui/src/components/connect-site-picker/site-presentation.test.ts
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts b/apps/ui/src/components/connect-site-picker/site-presentation.ts
similarity index 100%
rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts
rename to apps/ui/src/components/connect-site-picker/site-presentation.ts
diff --git a/apps/ui/src/components/connect-site-picker/style.module.css b/apps/ui/src/components/connect-site-picker/style.module.css
new file mode 100644
index 0000000000..39e8fbe5df
--- /dev/null
+++ b/apps/ui/src/components/connect-site-picker/style.module.css
@@ -0,0 +1,226 @@
+/* The site cards, search, and list states shared by onboarding and the
+ publish flow. */
+
+.state p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ max-width: 520px;
+ margin-inline: auto;
+ padding: 0 20px 32px;
+}
+
+.state h2 {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 500;
+}
+
+.siteControls {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 32px;
+}
+
+.search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: min(100%, 520px);
+ padding: 0 12px;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 6px;
+ background: var(--wpds-color-bg-surface-neutral);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.search:focus-within {
+ border-color: var(--wpds-color-stroke-focus-brand);
+ box-shadow: 0 0 0 1px var(--wpds-color-stroke-focus-brand);
+}
+
+.search input {
+ width: 100%;
+ height: 40px;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral);
+ font: inherit;
+}
+
+.search input::placeholder {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.helperLinks {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.helperLinks button {
+ padding-inline: 2px;
+}
+
+.sections {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ text-align: start;
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.sectionHeader {
+ text-align: center;
+}
+
+.sectionHeader h2 {
+ margin: 0 0 4px;
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 500;
+}
+
+.sectionHeader p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+}
+
+.siteGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
+ gap: 20px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.singleSiteGrid {
+ grid-template-columns: minmax(0, 440px);
+ justify-content: center;
+}
+
+.siteCardWrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.siteCard {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ width: 100%;
+ padding: 6px;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral);
+ cursor: pointer;
+ text-align: start;
+}
+
+.siteCardUnavailable,
+.siteCardUnavailable:hover {
+ cursor: default;
+}
+
+.siteCardSelected .siteThumb {
+ box-shadow: 0 0 0 1px var(--wpds-color-stroke-interactive-brand);
+}
+
+.siteCard:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.siteThumb {
+ position: relative;
+ display: block;
+ width: 100%;
+ aspect-ratio: 3 / 2;
+ overflow: hidden;
+ border-radius: 8px;
+ background: var(--wpds-color-bg-surface-neutral-strong);
+ box-shadow: 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-surface-neutral);
+ transition: box-shadow 0.15s ease;
+}
+
+.siteThumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.siteCardUnavailable .siteThumb {
+ opacity: 0.65;
+}
+
+.siteText {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ padding: 10px 8px 8px;
+}
+
+.siteName {
+ overflow: hidden;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.siteUrl {
+ overflow: hidden;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.siteStatus {
+ margin-top: 4px;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 1.4;
+}
+
+.badges {
+ position: absolute;
+ inset-inline-end: 8px;
+ inset-block-end: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.siteAction {
+ align-self: center;
+ margin-top: 4px;
+}
+
+
+@media (max-width: 600px) {
+ .siteGrid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/apps/ui/src/components/menu/index.tsx b/apps/ui/src/components/menu/index.tsx
index 647d8d9fec..31f5a1c98f 100644
--- a/apps/ui/src/components/menu/index.tsx
+++ b/apps/ui/src/components/menu/index.tsx
@@ -31,6 +31,8 @@ type PopupProps = {
align?: 'start' | 'center' | 'end';
sideOffset?: number;
alignOffset?: number;
+ /** Raises the menu above modal surfaces. Set when the trigger is inside a dialog. */
+ aboveOverlays?: boolean;
className?: string;
onClick?: MouseEventHandler< HTMLElement >;
onPointerDown?: PointerEventHandler< HTMLElement >;
@@ -47,6 +49,7 @@ export function Popup( {
align = 'start',
sideOffset = 4,
alignOffset,
+ aboveOverlays,
className,
onClick,
onPointerDown,
@@ -58,7 +61,9 @@ export function Popup( {
align={ align }
sideOffset={ sideOffset }
alignOffset={ alignOffset }
- className={ styles.positioner }
+ className={ `${ styles.positioner }${
+ aboveOverlays ? ` ${ styles.positionerAboveOverlays }` : ''
+ }` }
>
{ /* Portals mount into document.body, escaping the app-root
ThemeProvider's `data-wpds-density='compact'` wrapper and
diff --git a/apps/ui/src/components/menu/style.module.css b/apps/ui/src/components/menu/style.module.css
index c7bb94df48..4855365a55 100644
--- a/apps/ui/src/components/menu/style.module.css
+++ b/apps/ui/src/components/menu/style.module.css
@@ -10,6 +10,12 @@
outline: none;
}
+/* Above the dialog scrim (700) so a menu opened from inside a dialog isn't
+ trapped behind it — matches the select tier. */
+.positionerAboveOverlays {
+ z-index: var(--wp-ui-select-z-index, 750);
+}
+
.popup {
min-width: 160px;
padding: var(--wpds-dimension-padding-xs);
diff --git a/apps/ui/src/components/selective-sync/sync-dialog.tsx b/apps/ui/src/components/selective-sync/sync-dialog.tsx
index a47a5219db..2f06ec153e 100644
--- a/apps/ui/src/components/selective-sync/sync-dialog.tsx
+++ b/apps/ui/src/components/selective-sync/sync-dialog.tsx
@@ -322,7 +322,10 @@ export function SyncDialog( {
return (
diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css b/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css
deleted file mode 100644
index 81c84f9240..0000000000
--- a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css
+++ /dev/null
@@ -1,236 +0,0 @@
-.trigger {
- --site-dropdown-radius: var(--wpds-border-radius-lg);
- --site-menu-active-fill: var(--wpds-color-bg-interactive-neutral-strong);
- --site-menu-resting-fill: var(--wpds-color-bg-interactive-neutral-strong-active);
- --site-menu-foreground: var(--wpds-color-fg-interactive-neutral-strong);
- --wp-ui-button-background-color: var(--site-menu-resting-fill);
- --wp-ui-button-background-color-active: var(--site-menu-active-fill);
- --wp-ui-button-border-color: transparent;
- --wp-ui-button-border-color-active: transparent;
- --wp-ui-button-foreground-color: var(--site-menu-foreground);
- --wp-ui-button-foreground-color-active: var(--site-menu-foreground);
- --wp-ui-button-height: 44px;
- --wp-ui-button-padding-block: 0;
- --wp-ui-button-padding-inline: 0;
-
- gap: 12px;
- max-width: 100%;
- min-height: 44px;
- min-width: 0;
- align-items: center;
- backdrop-filter: blur(20px) saturate(115%);
- border-width: 0;
- border-radius: var(--site-dropdown-radius);
- box-shadow: 0 8px 18px rgb(0 0 0 / 18%);
- color: var(--site-menu-foreground);
- overflow: hidden;
- padding-inline: 0 12px;
- white-space: nowrap;
-}
-
-/* Non-floating placements (regular header rows) drop the floating-card
- shadow. */
-.trigger.triggerFlat {
- box-shadow: none;
-}
-
-.siteIconWrap {
- position: relative;
- display: inline-flex;
- flex: 0 0 auto;
- width: 44px;
- height: 44px;
-}
-
-.siteIcon {
- width: 100%;
- height: 100%;
- border-radius: 0;
-}
-
-.siteIcon_stopped {
- opacity: 0.72;
-}
-
-.identity {
- display: inline-flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 0;
- min-width: 0;
- max-width: min(420px, 56vw);
- padding-inline: 0 var(--wpds-dimension-padding-xs);
- text-align: left;
-}
-
-.site {
- flex: 0 1 auto;
- min-width: 0;
- color: var(--site-menu-foreground);
- overflow: hidden;
- font-weight: 600;
- line-height: var(--wpds-typography-line-height-sm);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.secondary {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- max-width: 100%;
- min-width: 0;
- color: color-mix(in srgb, var(--site-menu-foreground) 78%, transparent);
- font-size: 11px;
- font-weight: 350;
- line-height: 1.05;
-}
-
-.secondaryLabel {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.secondary_pending {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 70%,
- var(--wpds-color-fg-interactive-brand)
- );
-}
-
-.secondary_success {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 72%,
- var(--wpds-color-fg-content-success, var(--site-menu-foreground))
- );
-}
-
-.secondary_error {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 72%,
- var(--wpds-color-fg-content-error, var(--site-menu-foreground))
- );
-}
-
-.statusBadge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 8px;
- height: 8px;
- border-radius: 999px;
- flex-shrink: 0;
-}
-
-.statusBadge_overlay {
- position: absolute;
- right: -4px;
- bottom: 8px;
- z-index: 1;
- box-shadow: 0 0 0 2px var(--site-menu-resting-fill);
-}
-
-.statusBadge_live {
- background: #2563eb;
- color: var(--wpds-color-fg-content-inverted, #fff);
-}
-
-.statusBadge:not(.statusBadge_overlay).statusBadge_live {
- width: auto;
- height: 20px;
- gap: 4px;
- padding: 0 6px;
- background: color-mix(in srgb, #2563eb 9%, transparent);
- color: #2563eb;
-}
-
-.statusBadge_stopped {
- border-radius: 2px;
- background: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.statusBadge_transitioning {
- background: #2563eb;
- animation: siteDotBlink 1s ease-in-out infinite;
-}
-
-.dot {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.dot_running {
- background-color: var(--studio-color-status-running);
-}
-
-.dot_stopped {
- background-color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.dot_transitioning {
- background-color: #2563eb;
-}
-
-.dot_live {
- background-color: #2563eb;
-}
-
-.pauseMark,
-.pauseMark::before,
-.pauseMark::after {
- display: block;
- width: 1.5px;
- height: 4px;
- border-radius: 1px;
- background: var(--wpds-color-bg-surface-neutral-strong);
-}
-
-.pauseMark {
- position: relative;
- background: transparent;
-}
-
-.pauseMark::before,
-.pauseMark::after {
- position: absolute;
- top: 0;
- content: '';
-}
-
-.pauseMark::before {
- left: -2px;
-}
-
-.pauseMark::after {
- left: 1px;
-}
-
-.statusLabel {
- font-size: var(--wpds-typography-font-size-xs);
- font-weight: 500;
- line-height: 1;
-}
-
-@keyframes siteDotBlink {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.25;
- }
-}
-
-.chevron {
- flex: 0 0 auto;
- color: color-mix(in srgb, var(--site-menu-foreground) 72%, transparent);
- fill: currentColor;
-}
diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx b/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx
deleted file mode 100644
index 23a8a82f7d..0000000000
--- a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { __ } from '@wordpress/i18n';
-import { chevronDownSmall } from '@wordpress/icons';
-import { Button, Icon, Tooltip } from '@wordpress/ui';
-import { clsx } from 'clsx';
-import { forwardRef } from 'react';
-import { SiteIcon } from '@/components/site-icon';
-import styles from './dropdown-trigger.module.css';
-import type { TriggerSecondaryTone } from './trigger-secondary';
-import type { ComponentProps, ElementRef } from 'react';
-
-export type SiteStatus = 'running' | 'stopped' | 'transitioning';
-
-type Props = Omit< ComponentProps< typeof Button >, 'children' > & {
- siteName: string;
- siteUrl: string;
- status: SiteStatus;
- statusLabel: string;
- environment: 'local' | 'live';
- secondaryLabel: string;
- secondaryTone?: TriggerSecondaryTone;
- showSiteIcon?: boolean;
- showStatus?: boolean;
- siteIconSeed?: string;
- siteIconImage?: string | null;
- // The floating-card shadow suits placements where the trigger overlays
- // panel content (the chat header); regular header rows pass false.
- floating?: boolean;
-};
-
-export const DropdownTrigger = forwardRef< ElementRef< typeof Button >, Props >(
- function DropdownTrigger(
- {
- siteName,
- siteUrl,
- status,
- statusLabel,
- environment,
- secondaryLabel,
- secondaryTone = 'neutral',
- showSiteIcon = false,
- showStatus = true,
- siteIconSeed,
- siteIconImage,
- floating = true,
- className,
- ...props
- },
- ref
- ) {
- // In live mode the local server's running/stopped status is irrelevant
- // to what the agent targets; use a dedicated dot color so the trigger
- // still reflects the active target.
- const isLive = environment === 'live';
- const dotClass = environment === 'live' ? styles.dot_live : styles[ `dot_${ status }` ];
- const statusClass =
- environment === 'live' ? styles.statusBadge_live : styles[ `statusBadge_${ status }` ];
- const dotLabel = isLive ? __( 'Live site' ) : statusLabel;
- const statusBadge = showStatus ? (
-
- { status === 'stopped' && ! isLive ? (
-
- ) : (
-
- ) }
- { ! showSiteIcon && isLive ? (
- { __( 'Live' ) }
- ) : null }
-
- ) : null;
-
- return (
-
- }
- className={ clsx( styles.trigger, ! floating && styles.triggerFlat, className ) }
- >
- { showSiteIcon ? (
-
-
- { statusBadge }
-
- ) : null }
-
- { siteName }
-
- { secondaryLabel }
-
-
- { showSiteIcon ? null : statusBadge }
-
-
- }>
- { __( 'Publish, preview, and more' ) }
-
-
- );
- }
-);
diff --git a/apps/ui/src/components/site-dropdown/index.test.tsx b/apps/ui/src/components/site-dropdown/index.test.tsx
deleted file mode 100644
index 102f854038..0000000000
--- a/apps/ui/src/components/site-dropdown/index.test.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { SiteDropdown } from './index';
-import type { SiteDetails, SyncSite } from '@/data/core';
-
-// Drives the real wiring behind "confirming the sync dialog reopens the
-// dropdown": the dialog closes, an effect fires, and it clicks the trigger.
-// Clicking (rather than setting state) is what keeps Base UI from re-arming its
-// hover-close, so this asserts the click actually happens.
-
-const { connectedSites, pullMutate } = vi.hoisted( () => ( {
- connectedSites: [] as SyncSite[],
- pullMutate: vi.fn(),
-} ) );
-
-vi.mock( '@/data/core', () => ( { useConnector: () => ( {} ) } ) );
-vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( {
- useConnectedWpcomSites: () => ( { data: connectedSites } ),
-} ) );
-vi.mock( '@/data/queries/use-snapshots', () => ( { useSnapshots: () => ( { data: [] } ) } ) );
-vi.mock( '@/data/queries/use-sites', () => ( {
- useIsSiteStarting: () => false,
- useIsSiteStopping: () => false,
- useSiteOperation: () => undefined,
-} ) );
-vi.mock( '@/data/queries/use-sync-site', () => ( {
- usePullSiteFromLive: () => ( { mutate: pullMutate } ),
- usePushSiteToLive: () => ( { mutate: vi.fn() } ),
-} ) );
-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/selective-sync/lib/convert-tree-to-sync-options', () => ( {
- convertTreeToPullOptions: () => ( { optionsToSync: [ 'all' ], include_path_list: [] } ),
- convertTreeToPushOptions: () => ( { optionsToSync: [ 'all' ] } ),
-} ) );
-vi.mock( './publish-picker-view', () => ( { PublishPickerView: () => null } ) );
-
-// Stand-ins for the popup contents and the dialog, so the test can drive
-// "open the dialog" and "confirm it" without their real dependencies.
-vi.mock( './main-view', () => ( {
- MainView: ( { onPullClick }: { onPullClick: () => void } ) => (
- Pull from live
- ),
-} ) );
-vi.mock( '@/components/selective-sync/sync-dialog', () => ( {
- SyncDialog: ( { onPull }: { onPull: ( tree: unknown[] ) => void } ) => (
- onPull( [] ) }>Confirm pull
- ),
-} ) );
-
-const site: SiteDetails = {
- id: 'site-1',
- name: 'Demo Site',
- path: '/tmp/demo-site',
- port: 8881,
- running: true,
- phpVersion: '8.3',
-};
-
-const liveSite: SyncSite = {
- id: 123,
- localSiteId: 'site-1',
- name: 'Live Site',
- url: 'example.com',
- isStaging: false,
- isPressable: false,
- syncSupport: 'already-connected',
- lastPullTimestamp: null,
- lastPushTimestamp: null,
-};
-
-describe( 'SiteDropdown sync dialog', () => {
- beforeEach( () => {
- pullMutate.mockReset();
- connectedSites.splice( 0, connectedSites.length, liveSite );
- } );
-
- it( 'reopens the dropdown after the pull is confirmed', async () => {
- render( );
-
- fireEvent.click( screen.getByRole( 'button', { name: /Demo Site/ } ) );
- fireEvent.click( await screen.findByRole( 'button', { name: 'Pull from live' } ) );
-
- // The dialog replaces the dropdown, matching the disconnect dialog's rule.
- expect( screen.queryByRole( 'button', { name: 'Pull from live' } ) ).not.toBeInTheDocument();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Confirm pull' } ) );
-
- expect( pullMutate ).toHaveBeenCalledWith(
- expect.objectContaining( { siteId: site.id, remoteSiteId: liveSite.id } )
- );
- await waitFor( () =>
- expect( screen.getByRole( 'button', { name: 'Pull from live' } ) ).toBeInTheDocument()
- );
- } );
-
- it( 'leaves the dropdown closed when the dialog is dismissed without syncing', async () => {
- render( );
-
- fireEvent.click( screen.getByRole( 'button', { name: /Demo Site/ } ) );
- fireEvent.click( await screen.findByRole( 'button', { name: 'Pull from live' } ) );
-
- expect( screen.queryByRole( 'button', { name: 'Pull from live' } ) ).not.toBeInTheDocument();
- expect( pullMutate ).not.toHaveBeenCalled();
- } );
-} );
diff --git a/apps/ui/src/components/site-dropdown/index.tsx b/apps/ui/src/components/site-dropdown/index.tsx
deleted file mode 100644
index 3459363050..0000000000
--- a/apps/ui/src/components/site-dropdown/index.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-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 '@/components/selective-sync/selective-sync.css';
-import { useConnector } from '@/data/core';
-import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
-import { useIsSiteStarting, useIsSiteStopping, useSiteOperation } from '@/data/queries/use-sites';
-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';
-
-type Props = {
- site: SiteDetails;
- // Optional: when rendered inside a session view, the dropdown reflects the
- // session's active environment (local vs. live) rather than always reading
- // "Local". Outside a session context this defaults to local.
- activeEnvironment?: 'local' | 'live';
- showSiteIcon?: boolean;
- showStatus?: boolean;
- // The trigger casts a shadow when it floats over panel content (the chat
- // header). Pass false where it sits in a regular header row instead.
- floating?: boolean;
- defaultOpen?: boolean;
-};
-
-export function SiteDropdown( {
- site,
- activeEnvironment = 'local',
- showSiteIcon = false,
- showStatus = true,
- floating = true,
- defaultOpen = false,
-}: Props ) {
- const [ view, setView ] = useState< 'main' | 'picker' >( 'main' );
- const [ menuOpen, setMenuOpen ] = useState( defaultOpen );
- const rootRef = useRef< HTMLDivElement >( null );
- const reopenAfterDialogRef = useRef( false );
- const [ disconnectOpen, setDisconnectOpen ] = useState( false );
- const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null );
-
- const connector = useConnector();
- const pushSiteToLive = usePushSiteToLive();
- const pullSiteFromLive = usePullSiteFromLive();
-
- // The copied selective-sync modules resolve their data calls through the
- // active connector (see selective-sync/lib/get-ipc-api.ts).
- useEffect( () => {
- registerSelectiveSyncConnector( connector );
- }, [ connector ] );
-
- // The trigger needs the site status for its running/stopped/transitioning
- // dot — everything else about status lives inside MainView.
- const isStarting = useIsSiteStarting( site.id );
- const isStopping = useIsSiteStopping( site.id );
- const operation = useSiteOperation( site );
- const { status, statusLabel } = deriveSiteStatus( site, isStarting, isStopping, operation );
-
- // Only needed here so the disconnect dialog can reference the current live
- // site. MainView fetches the same data independently for its action row.
- const { data: connectedSites } = useConnectedWpcomSites( site.id );
- const { data: snapshots } = useSnapshots();
- const activity = useSiteSyncActivity( site.id );
- const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
- const previewSnapshot = useMemo(
- () => pickLatestSnapshot( snapshots, site.id ),
- [ snapshots, site.id ]
- );
- const secondary = useMemo(
- () =>
- getSiteDropdownSecondary( {
- activity,
- activeEnvironment,
- liveSite,
- previewSnapshot,
- } ),
- [ activity, activeEnvironment, liveSite, previewSnapshot ]
- );
-
- const handleDisconnectClick = () => {
- // Close the dropdown before showing the confirmation dialog so the two
- // overlays don't stack.
- setMenuOpen( false );
- setDisconnectOpen( true );
- };
-
- const openSyncDialog = ( type: 'push' | 'pull' ) => {
- // Same overlay rule as the disconnect dialog: dropdown closes first.
- setMenuOpen( false );
- setSyncDialogType( type );
- };
-
- const startSyncFromDialog = ( start: () => void ) => {
- start();
- reopenAfterDialogRef.current = true;
- setSyncDialogType( null );
- };
-
- // Reopen the dropdown once the dialog is gone, so the sync progress and its
- // cancel are in view. It clicks the trigger rather than setting state: Base UI
- // only clears its hover-close interaction on a real interaction, so a menu
- // opened via `setMenuOpen` dismisses itself the moment the pointer moves
- // outside it. Running in an effect (rather than a timer) guarantees the modal
- // has unmounted and returned focus first — cleanups run before this.
- useEffect( () => {
- if ( syncDialogType !== null || ! reopenAfterDialogRef.current ) {
- return;
- }
- reopenAfterDialogRef.current = false;
- rootRef.current?.querySelector< HTMLElement >( '[aria-haspopup="menu"]' )?.click();
- }, [ syncDialogType ] );
-
- const handleDialogPush = ( tree: TreeNode[] ) => {
- if ( ! liveSite ) return;
- const options = convertTreeToPushOptions( tree );
- startSyncFromDialog( () =>
- pushSiteToLive.mutate(
- { siteId: site.id, remoteSiteId: liveSite.id, options },
- { onSuccess: () => void connector.openExternalUrl( ensureProtocol( liveSite.url ) ) }
- )
- );
- };
-
- const handleDialogPull = ( tree: TreeNode[] ) => {
- if ( ! liveSite ) return;
- const { optionsToSync, include_path_list: includePathList } = convertTreeToPullOptions( tree );
- startSyncFromDialog( () =>
- pullSiteFromLive.mutate( {
- siteId: site.id,
- remoteSiteId: liveSite.id,
- options: { optionsToSync, includePathList },
- } )
- );
- };
-
- return (
-
-
{
- setMenuOpen( open );
- // Reset to the main view whenever the dropdown closes so the
- // next opening doesn't unexpectedly land in the picker state.
- if ( ! open ) {
- setView( 'main' );
- }
- } }
- >
-
- }
- />
-
- { view === 'main' ? (
- setView( 'picker' ) }
- onDisconnectClick={ handleDisconnectClick }
- onPullClick={ () => openSyncDialog( 'pull' ) }
- onPushClick={ () => openSyncDialog( 'push' ) }
- />
- ) : (
- setView( 'main' ) } />
- ) }
-
-
- { liveSite ? (
-
- ) : null }
- { liveSite && syncDialogType ? (
-
setSyncDialogType( null ) }
- />
- ) : null }
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/main-view.module.css b/apps/ui/src/components/site-dropdown/main-view.module.css
deleted file mode 100644
index a05e4119bc..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.module.css
+++ /dev/null
@@ -1,411 +0,0 @@
-.rows {
- display: flex;
- flex-direction: column;
-}
-
-.rows > * + * {
- border-top: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
-}
-
-.activityStatus {
- display: flex;
- /* Pin the cancel button to the top: centring drifts it down the block as the
- progress message and the "can't cancel" reason wrap. */
- align-items: flex-start;
- gap: var(--wpds-dimension-padding-md);
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
- color: var(--wpds-color-fg-content-neutral);
- font-size: var(--wpds-typography-font-size-sm);
- line-height: var(--wpds-typography-line-height-sm);
-}
-
-.activityStatusText {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
- flex: 1;
-}
-
-.activityStatusNote {
- color: var(--wpds-color-fg-content-neutral-weak);
- font-size: var(--wpds-typography-font-size-xs);
- /* Matches the smaller font size — inheriting the row's `sm` line height
- leaves this sentence too airy once it wraps onto a second line. */
- line-height: var(--wpds-typography-line-height-xs);
- text-wrap: pretty;
-}
-
-/* Matches .moreMenuTrigger: the popup is an inverted surface, so the design
- system's own disabled colour lands on the wrong background and the button
- reads as enabled. */
-.cancelSyncButton {
- flex: 0 0 auto;
- width: 28px;
- height: 28px;
- padding: 0;
- justify-content: center;
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.cancelSyncButton:is([data-disabled], :disabled) {
- color: var(--wpds-color-fg-interactive-neutral-disabled);
-}
-
-.cancelSyncButton svg {
- width: 14px;
- height: 14px;
- fill: currentColor;
-}
-
-/* The popup keeps `overflow: visible`, so this — the only row that fills to the
- popup edge — has to round its own top corners or it squares off the popup's.
- Inset by the border width so the curve nests inside it instead of cutting it. */
-.activityStatus:first-child {
- border-top-left-radius: calc(var(--site-dropdown-radius) - var(--wpds-border-width-xs));
- border-top-right-radius: calc(var(--site-dropdown-radius) - var(--wpds-border-width-xs));
-}
-
-.activityStatusPending {
- background: var(--wpds-color-bg-surface-neutral);
-}
-
-.activityStatusError {
- background: color-mix(
- in srgb,
- var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral)) 8%,
- transparent
- );
-}
-
-.activityStatusTitle {
- font-weight: 600;
-}
-
-.activityStatusError .activityStatusTitle {
- color: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground, var(--wpds-color-fg-content-neutral)) 72%,
- var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral))
- );
-}
-
-.activityStatusMessage {
- color: var(--wpds-color-fg-content-neutral-weak);
- white-space: pre-wrap;
- word-break: break-word;
- /* Rewritten in place as the percentage climbs, and this font's digits are
- not equal width — without this the text shifts on nearly every update. */
- font-variant-numeric: tabular-nums;
-}
-
-.xdebugBadge {
- display: inline-flex;
- align-items: center;
- color: var(--studio-color-status-running);
-}
-
-.xdebugBadge_stopped {
- color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.xdebugGlyph {
- width: 16px;
- height: 16px;
-}
-
-.rowActions {
- display: flex;
- align-items: center;
- gap: 2px;
-}
-
-/* Derived from the popup's remapped tokens so the control tracks the
- inverted menu surface in both color schemes; scheme media queries
- would key off the OS instead and land backwards here. */
-.localServerControl {
- --local-server-track-bg: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 14%,
- transparent
- );
- --local-server-track-bg-hover: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 22%,
- transparent
- );
- --local-server-track-border: color-mix(
- in srgb,
- var(--wpds-color-stroke-surface-neutral) 72%,
- transparent
- );
- --local-server-track-border-hover: var(--wpds-color-stroke-surface-neutral);
- --local-server-track-shadow: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 6%,
- transparent
- );
- --local-server-thumb-bg: var(--wpds-color-fg-content-neutral);
- --local-server-thumb-color: var(--wpds-color-bg-surface-neutral-strong);
- --local-server-thumb-ring: color-mix(
- in srgb,
- var(--wpds-color-stroke-surface-neutral) 52%,
- transparent
- );
- --local-server-thumb-shadow: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 14%,
- transparent
- );
-
- position: relative;
- display: inline-flex;
- align-items: center;
- box-sizing: border-box;
- width: 44px;
- height: 24px;
- margin: 0;
- padding: 0;
- border: 0;
- border-radius: 999px;
- appearance: none;
- background-color: var(--local-server-track-bg);
- color: var(--wpds-color-fg-content-neutral);
- cursor: var(--wpds-cursor-control);
- box-shadow:
- inset 0 0 0 var(--wpds-border-width-xs) var(--local-server-track-border),
- inset 0 1px 1px var(--local-server-track-shadow);
- transition:
- background-color 120ms ease,
- box-shadow 120ms ease;
-}
-
-.localServerControl_running {
- --local-server-track-bg: var(--studio-color-status-running);
- --local-server-track-bg-hover: #15803d;
- --local-server-track-border: #148a42;
- --local-server-track-border-hover: #137a3d;
- --local-server-track-shadow: color-mix(in srgb, #0d5f2c 26%, transparent);
- --local-server-thumb-bg: #fff;
- --local-server-thumb-color: #168a42;
- --local-server-thumb-ring: transparent;
- --local-server-thumb-shadow: color-mix(in srgb, #0d5f2c 22%, transparent);
-}
-
-.localServerControl:not([aria-disabled='true']):hover {
- background-color: var(--local-server-track-bg-hover);
- box-shadow:
- inset 0 0 0 var(--wpds-border-width-xs) var(--local-server-track-border-hover),
- inset 0 1px 1px var(--local-server-track-shadow);
-}
-
-.localServerControl:focus:not(:focus-visible):not(:hover) {
- background-color: var(--local-server-track-bg);
-}
-
-.localServerControl:focus-visible {
- background-color: var(--local-server-track-bg);
- outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
- outline-offset: 2px;
-}
-
-.localServerControl[aria-disabled='true'] {
- cursor: not-allowed;
- opacity: 0.72;
-}
-
-.localServerThumb {
- position: absolute;
- top: 2px;
- left: 2px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- width: 20px;
- height: 20px;
- border-radius: 999px;
- background-color: var(--local-server-thumb-bg);
- color: var(--local-server-thumb-color);
- line-height: 0;
- box-shadow:
- 0 1px 2px var(--local-server-thumb-shadow),
- 0 0 0 var(--wpds-border-width-xs) var(--local-server-thumb-ring);
- transition:
- transform 140ms ease,
- background-color 120ms ease,
- color 120ms ease,
- box-shadow 120ms ease;
-}
-
-.localServerControl_running .localServerThumb {
- transform: translateX(20px);
-}
-
-.localServerGlyph {
- display: block;
- transform-origin: center;
-}
-
-.localServerControl_pending .localServerGlyph {
- animation: localServerGlyphPulse 720ms ease-in-out infinite;
-}
-
-.pauseIcon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 2px;
- width: 11px;
- height: 11px;
-}
-
-.pauseIcon::before,
-.pauseIcon::after {
- content: '';
- width: 2px;
- height: 9px;
- border-radius: 1px;
- background: currentColor;
-}
-
-.playIcon {
- width: 9px;
- height: 10px;
- transform: translateX(1px);
- background: currentColor;
- clip-path: polygon(28% 18%, 28% 82%, 82% 50%);
-}
-
-.urlLink {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- max-width: 100%;
- margin: 0;
- padding: 0;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- line-height: inherit;
- text-align: left;
- cursor: var(--wpds-cursor-control);
-}
-
-.urlLink:hover {
- color: var(--wpds-color-fg-content-neutral);
- text-decoration: underline;
- text-underline-offset: 2px;
-}
-
-.urlLink span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.urlLink svg {
- flex: 0 0 auto;
- fill: currentColor;
-}
-
-.environmentActionRow {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-gap-md);
- min-height: 40px;
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
-}
-
-.environmentActionText {
- flex: 1 1 auto;
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.environmentActionTitle {
- font-size: var(--wpds-typography-font-size-md);
- line-height: 1.25;
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.environmentActionCopy {
- margin: 0;
- font-size: var(--wpds-typography-font-size-xs);
- line-height: 1.4;
- color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.environmentActionButton {
- flex: 0 0 auto;
- align-self: center;
-}
-
-.rowActionButton,
-.environmentActionButton_outline {
- border-radius: var(--wpds-border-radius-md);
-}
-
-.rowActionButton {
- height: 28px;
- width: 28px;
- padding: 0;
-}
-
-/* Only recolor enabled buttons — an unconditional `color` here sits outside
- the wp-ui layers and would override the design system's disabled dimming
- and the loading spinner's transparent-text state. */
-.rowActionButton:not([data-disabled]) {
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.rowActionButton svg {
- width: 14px;
- height: 14px;
- fill: currentColor;
-}
-
-.environmentActionButton_outline {
- --wp-ui-button-background-color: var(--wpds-color-bg-surface-neutral-strong);
- --wp-ui-button-background-color-active: var(--wpds-color-bg-surface-neutral);
- --wp-ui-button-border-color: var(--wpds-color-stroke-surface-neutral-weak);
- --wp-ui-button-border-color-active: var(--wpds-color-stroke-surface-neutral);
-}
-
-.moreMenuTrigger {
- width: 28px;
- height: 28px;
- padding: 0;
- justify-content: center;
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.moreMenuTrigger:is([data-disabled], :disabled) {
- color: var(--wpds-color-fg-interactive-neutral-disabled);
-}
-
-.moreMenuTrigger svg {
- fill: currentColor;
-}
-
-.moreMenuPopup {
- min-width: 148px;
-}
-
-@keyframes localServerGlyphPulse {
- 0%,
- 100% {
- opacity: 0.92;
- transform: scale(1);
- }
-
- 50% {
- opacity: 0.45;
- transform: scale(0.78);
- }
-}
diff --git a/apps/ui/src/components/site-dropdown/main-view.test.tsx b/apps/ui/src/components/site-dropdown/main-view.test.tsx
deleted file mode 100644
index a5521ecf05..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.test.tsx
+++ /dev/null
@@ -1,414 +0,0 @@
-import { useIsMutating } from '@tanstack/react-query';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import * as Menu from '@/components/menu';
-import { MainView } from './main-view';
-import type { SiteDetails, Snapshot, SyncSite } from '@/data/core';
-import type { SyncActivity } from '@/data/sync-activity';
-
-const {
- connector,
- snapshots,
- connectedSites,
- publishPreviewMutate,
- transitions,
- startSiteMutate,
- stopSiteMutate,
-} = vi.hoisted( () => ( {
- connector: {
- copyText: vi.fn(),
- openExternalUrl: vi.fn(),
- },
- snapshots: [] as Snapshot[],
- connectedSites: [] as SyncSite[],
- publishPreviewMutate: vi.fn(),
- transitions: { starting: false, stopping: false },
- startSiteMutate: vi.fn(),
- stopSiteMutate: vi.fn(),
-} ) );
-
-let snapshotUsage: {
- siteCount: number;
- siteLimit: number;
- siteCreationBlocked: boolean;
-} | null = null;
-
-vi.mock( '@tanstack/react-query', async ( importOriginal ) => {
- const actual = await importOriginal< typeof import('@tanstack/react-query') >();
- return {
- ...actual,
- useIsMutating: vi.fn( () => 0 ),
- };
-} );
-
-vi.mock( '@/data/core', () => ( {
- useConnector: () => connector,
-} ) );
-
-vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( {
- useConnectedWpcomSites: () => ( { data: connectedSites } ),
-} ) );
-
-vi.mock( '@/data/queries/use-agentic-features', () => ( {
- useAgenticFeatures: vi.fn( () => ( { enabled: true, reason: null, isReady: true } ) ),
-} ) );
-
-vi.mock( '@/data/queries/use-auth-user', () => ( {
- useLogin: () => ( { mutate: vi.fn(), isPending: false } ),
-} ) );
-
-vi.mock( '@/data/queries/use-preview-site', () => ( {
- usePublishPreviewSite: () => ( { isPending: false, mutate: publishPreviewMutate } ),
-} ) );
-
-vi.mock( '@/data/queries/use-sites', () => ( {
- useIsSiteBusy: () => transitions.starting || transitions.stopping,
- useIsSiteStarting: () => transitions.starting,
- useIsSiteStopping: () => transitions.stopping,
- useSiteOperation: () => null,
- useStartSite: () => ( { mutate: startSiteMutate } ),
- useStopSite: () => ( { mutate: stopSiteMutate } ),
-} ) );
-
-vi.mock( '@/data/queries/use-snapshots', () => ( {
- useSnapshots: () => ( { data: snapshots } ),
- useSnapshotUsage: () => ( { data: snapshotUsage } ),
-} ) );
-
-const cancelSyncMutate = vi.fn();
-
-vi.mock( '@/data/queries/use-sync-site', () => ( {
- PULL_FROM_LIVE_MUTATION_KEY: [ 'pull-site-from-live' ],
- PUSH_TO_LIVE_MUTATION_KEY: [ 'push-site-to-live' ],
- usePullSiteFromLive: () => ( { mutate: vi.fn() } ),
- usePushSiteToLive: () => ( { mutate: vi.fn() } ),
- useCancelSync: () => ( { mutate: cancelSyncMutate } ),
-} ) );
-
-const liveSite: SyncSite = {
- id: 123,
- localSiteId: 'site-1',
- name: 'Live Site',
- url: 'example.com',
- isStaging: false,
- isPressable: false,
- syncSupport: 'already-connected',
- lastPullTimestamp: null,
- lastPushTimestamp: null,
-};
-
-const site: SiteDetails = {
- id: 'site-1',
- name: 'Demo Site',
- path: '/tmp/demo-site',
- port: 8881,
- running: true,
- phpVersion: '8.3',
-};
-
-function renderMainView( {
- siteOverrides = {},
- activity = null,
-}: {
- siteOverrides?: Partial< SiteDetails >;
- activity?: SyncActivity | null;
-} = {} ) {
- // The live row's "more" submenu needs the Menu.Root + Popup contexts the
- // dropdown provides around MainView in the real app.
- return render(
-
-
-
-
-
- );
-}
-
-describe( 'MainView', () => {
- beforeEach( () => {
- vi.mocked( useIsMutating ).mockImplementation( () => 0 );
- connector.copyText.mockReset();
- connector.openExternalUrl.mockReset();
- cancelSyncMutate.mockReset();
- publishPreviewMutate.mockReset();
- startSiteMutate.mockReset();
- stopSiteMutate.mockReset();
- transitions.starting = false;
- transitions.stopping = false;
- snapshots.splice( 0, snapshots.length, {
- url: 'preview.example.com',
- atomicSiteId: 123,
- localSiteId: site.id,
- date: Date.now(),
- } );
- snapshotUsage = null;
- connectedSites.splice( 0, connectedSites.length );
- } );
-
- it( 'shows an Xdebug badge on the Studio row only when Xdebug is enabled', () => {
- const { unmount } = renderMainView( { siteOverrides: { enableXdebug: true } } );
-
- expect( screen.getByRole( 'img', { name: 'Xdebug enabled' } ) ).toBeInTheDocument();
-
- unmount();
- renderMainView();
-
- expect( screen.queryByRole( 'img', { name: 'Xdebug enabled' } ) ).not.toBeInTheDocument();
- } );
-
- it( 'labels the site status toggle with the status and the action it performs', () => {
- const { unmount } = renderMainView();
-
- const running = screen.getByRole( 'switch', { name: 'Site status: Running. Stop site' } );
- expect( running ).toBeChecked();
- fireEvent.click( running );
- expect( stopSiteMutate ).toHaveBeenCalledWith( site.id );
-
- unmount();
- renderMainView( { siteOverrides: { running: false } } );
-
- const stopped = screen.getByRole( 'switch', { name: 'Site status: Stopped. Start site' } );
- expect( stopped ).not.toBeChecked();
- fireEvent.click( stopped );
- expect( startSiteMutate ).toHaveBeenCalledWith( site.id );
- } );
-
- it( 'reports the pending status on the site status toggle without acting on clicks', () => {
- transitions.starting = true;
-
- const { unmount } = renderMainView( { siteOverrides: { running: false } } );
-
- const starting = screen.getByRole( 'switch', { name: 'Site status: Starting' } );
- expect( starting ).toHaveAttribute( 'aria-disabled', 'true' );
- expect( starting ).toBeChecked();
- fireEvent.click( starting );
- expect( startSiteMutate ).not.toHaveBeenCalled();
-
- unmount();
- transitions.starting = false;
- transitions.stopping = true;
- renderMainView();
-
- const stopping = screen.getByRole( 'switch', { name: 'Site status: Stopping' } );
- expect( stopping ).not.toBeChecked();
- fireEvent.click( stopping );
- expect( stopSiteMutate ).not.toHaveBeenCalled();
- } );
-
- it( 'handles preview URL copy failures', async () => {
- const error = new Error( 'Clipboard denied' );
- const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => undefined );
- connector.copyText.mockRejectedValueOnce( error );
-
- renderMainView();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Copy preview URL' } ) );
-
- await waitFor( () => {
- expect( connector.copyText ).toHaveBeenCalledWith( 'https://preview.example.com' );
- expect( consoleError ).toHaveBeenCalledWith( 'Failed to copy preview URL:', error );
- } );
-
- consoleError.mockRestore();
- } );
-
- it( 'shows detailed pull progress in the open site status', () => {
- renderMainView( {
- activity: {
- kind: 'pending',
- direction: 'pull',
- message: '24% · Creating remote backup…',
- progress: 24,
- },
- } );
-
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Pulling from live…' );
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( '24% · Creating remote backup…' );
- } );
-
- it( 'shows detailed import progress in the open site status', () => {
- renderMainView( {
- activity: { kind: 'pending', direction: 'import', message: '24% · Media uploads…' },
- } );
-
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Importing backup…' );
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( '24% · Media uploads…' );
- } );
-
- // An import replaces the site's files and database, so letting a sync run
- // alongside it would have them fighting over the same site.
- it( 'blocks the live sync actions while an import is running', () => {
- renderMainView( { activity: { kind: 'pending', direction: 'import' } } );
-
- expect(
- screen.getByRole( 'button', { name: 'Update preview site (sync in progress)' } )
- ).toHaveAttribute( 'aria-disabled', 'true' );
- } );
-
- it( 'keeps the update button busy when activity reports a pending preview', () => {
- renderMainView( { activity: { kind: 'pending', direction: 'preview' } } );
-
- expect( screen.getByRole( 'button', { name: 'Updating preview…' } ) ).toHaveAttribute(
- 'aria-disabled',
- 'true'
- );
- } );
-
- it( 'updates the existing preview site while the snapshot is fresh', () => {
- renderMainView();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Update preview site' } ) );
-
- expect( publishPreviewMutate ).toHaveBeenCalledWith(
- expect.objectContaining( {
- siteId: site.id,
- existingHostname: 'preview.example.com',
- } ),
- expect.anything()
- );
- } );
-
- it( 'offers to share a new preview once the snapshot expired', () => {
- snapshots[ 0 ].date = Date.now() - 8 * 24 * 60 * 60 * 1000;
-
- renderMainView();
-
- expect( screen.getByText( 'The previous preview has expired.' ) ).toBeInTheDocument();
- expect( screen.queryByRole( 'button', { name: 'Copy preview URL' } ) ).not.toBeInTheDocument();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Share a new one' } ) );
-
- expect( publishPreviewMutate ).toHaveBeenCalledWith(
- expect.objectContaining( {
- siteId: site.id,
- existingHostname: undefined,
- } ),
- expect.anything()
- );
- } );
-
- it( 'labels the live sync controls with plain actions while idle', () => {
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView();
-
- const pullButton = screen.getByRole( 'button', { name: 'Pull from live' } );
- expect( pullButton.getAttribute( 'aria-disabled' ) ).not.toBe( 'true' );
- expect( screen.getByRole( 'button', { name: 'Push to live' } ) ).toBeInTheDocument();
- } );
-
- it( 'offers to stop an in-flight push and reports the site being stopped', () => {
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView( {
- activity: { kind: 'pending', direction: 'push', phase: 'uploading' },
- } );
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Cancel push' } ) );
-
- expect( cancelSyncMutate ).toHaveBeenCalledWith( {
- siteId: site.id,
- remoteSiteId: liveSite.id,
- } );
- expect( screen.getByRole( 'status' ) ).not.toHaveTextContent( 'can not be cancelled' );
- } );
-
- it( 'stops offering to cancel a push once the remote import has started', () => {
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView( {
- activity: { kind: 'pending', direction: 'push', phase: 'applyingChanges' },
- } );
-
- const blocked = screen.getByRole( 'button', {
- name: 'Push can not be cancelled while applying changes to the remote site',
- } );
- fireEvent.click( blocked );
-
- expect( blocked ).toHaveAttribute( 'aria-disabled', 'true' );
- expect( cancelSyncMutate ).not.toHaveBeenCalled();
- } );
-
- it( 'disables the Share button when the preview site limit is reached', () => {
- snapshots.splice( 0, snapshots.length );
- snapshotUsage = { siteCount: 10, siteLimit: 10, siteCreationBlocked: false };
-
- renderMainView();
-
- expect(
- screen.getByText( "You've used all 10 preview sites available on your account." )
- ).toBeInTheDocument();
- expect( screen.getByRole( 'button', { name: 'Share' } ) ).toHaveAttribute(
- 'aria-disabled',
- 'true'
- );
- } );
-
- it( 'disables the Share button when preview site creation is blocked', () => {
- snapshots.splice( 0, snapshots.length );
- snapshotUsage = { siteCount: 0, siteLimit: 10, siteCreationBlocked: true };
-
- renderMainView();
-
- expect(
- screen.getByText( 'Preview sites are not available for your account.' )
- ).toBeInTheDocument();
- expect( screen.getByRole( 'button', { name: 'Share' } ) ).toHaveAttribute(
- 'aria-disabled',
- 'true'
- );
- } );
-
- it( 'stops offering to cancel a pull once the local import has started', () => {
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView( {
- activity: {
- kind: 'pending',
- direction: 'pull',
- action: 'import',
- message: 'Importing backup…',
- },
- } );
-
- // The reason doubles as the accessible name, so it reaches the tooltip and
- // screen readers instead of a bare "Cancel pull" that then does nothing.
- expect(
- screen.getByRole( 'button', {
- name: 'Pull can not be cancelled while importing changes to your local site',
- } )
- ).toHaveAttribute( 'aria-disabled', 'true' );
-
- // And stated in the panel itself — a tooltip on a disabled control is a
- // dead end, since nothing invites you to hover it.
- expect( screen.getByRole( 'status' ) ).toHaveTextContent(
- 'Pull can not be cancelled while importing changes to your local site'
- );
- } );
-
- it( 'reflects an in-flight pull on both live sync controls', () => {
- vi.mocked( useIsMutating ).mockImplementation( ( filters ) =>
- filters?.mutationKey?.[ 0 ] === 'pull-site-from-live' ? 1 : 0
- );
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView();
-
- const pullButton = screen.getByRole( 'button', { name: 'Pulling from live…' } );
- expect( pullButton ).toHaveAttribute( 'aria-disabled', 'true' );
-
- const pushButton = screen.getByRole( 'button', { name: 'Push to live (sync in progress)' } );
- expect( pushButton ).toHaveAttribute( 'aria-disabled', 'true' );
-
- expect(
- screen.getByRole( 'button', { name: 'Update preview site (sync in progress)' } )
- ).toBeInTheDocument();
- } );
-} );
diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx
deleted file mode 100644
index b45df7c02e..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.tsx
+++ /dev/null
@@ -1,677 +0,0 @@
-import { TRACKS_EVENTS } from '@studio/common/lib/record-tracks-event';
-import { type SiteOperationKind } from '@studio/common/lib/site-operation';
-import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels';
-import { isSnapshotExpired } from '@studio/common/lib/snapshots';
-import { useIsMutating } from '@tanstack/react-query';
-import { __, sprintf } from '@wordpress/i18n';
-import { arrowDown, arrowUp, close, copy, external, Icon, moreHorizontal } from '@wordpress/icons';
-import { Button, IconButton, Tooltip } from '@wordpress/ui';
-import { clsx } from 'clsx';
-import { useMemo } from 'react';
-import * as Menu from '@/components/menu';
-import { XdebugIcon } from '@/components/xdebug-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 { usePublishPreviewSite } from '@/data/queries/use-preview-site';
-import {
- useIsSiteBusy,
- useIsSiteStarting,
- useIsSiteStopping,
- useSiteOperation,
- useStartSite,
- useStopSite,
-} from '@/data/queries/use-sites';
-import { useSnapshotUsage, useSnapshots } from '@/data/queries/use-snapshots';
-import {
- PULL_FROM_LIVE_MUTATION_KEY,
- PUSH_TO_LIVE_MUTATION_KEY,
- useCancelSync,
-} from '@/data/queries/use-sync-site';
-import { canCancelSyncActivity, getSyncCancelLabels } from '@/data/sync-activity';
-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';
-
-type ButtonProps = ComponentProps< typeof Button >;
-
-type Props = {
- site: SiteDetails;
- activity: SyncActivity | null;
- // Switches the dropdown to the publish picker. Lives in the parent because
- // the picker is a sibling view at the popup level.
- onSetupClick: () => void;
- // Opens the disconnect-site confirmation dialog; owned by the parent so the
- // dialog persists after the dropdown closes.
- onDisconnectClick: () => void;
- // Open the selective-sync dialog for pull/push; owned by the parent for the
- // same reason as the disconnect dialog.
- onPullClick: () => void;
- onPushClick: () => void;
-};
-
-// Counts in-flight push/pull mutations for this site across hook instances.
-// Needed because the parent kicks off a push from the publish-picker flow via
-// its own mutation instance — this component's Push button would otherwise
-// report "idle" while the picker-initiated push is still running.
-function useIsSiteSyncing( siteId: string ): { push: boolean; pull: boolean } {
- const push =
- useIsMutating( {
- mutationKey: PUSH_TO_LIVE_MUTATION_KEY,
- predicate: ( mutation ) =>
- ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId,
- } ) > 0;
- const pull =
- useIsMutating( {
- mutationKey: PULL_FROM_LIVE_MUTATION_KEY,
- predicate: ( mutation ) =>
- ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId,
- } ) > 0;
- return { push, pull };
-}
-
-function getPreviewPanelCopy(
- agenticEnabled: boolean,
- isOffline: boolean,
- isPreviewExpired: boolean,
- snapshotUsage?: { siteCount: number; siteLimit: number; siteCreationBlocked: boolean } | null
-): string {
- if ( agenticEnabled ) {
- if ( snapshotUsage?.siteCreationBlocked ) {
- return __( 'Preview sites are not available for your account.' );
- }
- if ( snapshotUsage && snapshotUsage.siteCount >= snapshotUsage.siteLimit ) {
- return sprintf(
- /* translators: %d: maximum number of preview sites allowed */
- __( "You've used all %d preview sites available on your account." ),
- snapshotUsage.siteLimit
- );
- }
- return isPreviewExpired
- ? __( 'The previous preview has expired.' )
- : __( 'Share a review link for this version.' );
- }
- if ( isOffline ) {
- return __( 'Go online to share a review link.' );
- }
- return __( 'Sign in to share a review link.' );
-}
-
-function getLivePanelCopy( agenticEnabled: boolean, isOffline: boolean ): string {
- if ( agenticEnabled ) {
- return __( 'No connected site.' );
- }
- if ( isOffline ) {
- return __( 'Go online to publish your site.' );
- }
- return __( 'Sign in to publish your site.' );
-}
-
-export function MainView( {
- site,
- activity,
- onSetupClick,
- onDisconnectClick,
- onPullClick,
- onPushClick,
-}: Props ) {
- const connector = useConnector();
- const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures();
- const isOffline = agenticReason === 'offline';
- const login = useLogin( { source: 'site_header' } );
- const { data: snapshots } = useSnapshots();
- const { data: snapshotUsage } = useSnapshotUsage();
- const { data: connectedSites } = useConnectedWpcomSites( site.id );
-
- const previewSnapshot = useMemo(
- () => pickLatestSnapshot( snapshots, site.id ),
- [ snapshots, site.id ]
- );
- const isPreviewExpired = previewSnapshot !== undefined && isSnapshotExpired( previewSnapshot );
- const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
-
- const startSite = useStartSite();
- const stopSite = useStopSite();
- const publishPreviewSite = usePublishPreviewSite();
- const cancelSync = useCancelSync();
-
- const isStarting = useIsSiteStarting( site.id );
- const isStopping = useIsSiteStopping( site.id );
- const isOperationInProgress = useIsSiteBusy( site );
- const operation = useSiteOperation( site );
- const { push: isPushPending, pull: isPullPending } = useIsSiteSyncing( site.id );
- const isPreviewPending =
- publishPreviewSite.isPending ||
- ( activity?.kind === 'pending' && activity.direction === 'preview' );
- // Preview / push / pull all mutate the same local site; running them
- // concurrently would wedge the site runtime. An import replaces that site's
- // files and database outright, so it locks them out too — and the CLI won't
- // refuse it, since import is deliberately not a tracked site operation.
- const isImporting = activity?.kind === 'pending' && activity.direction === 'import';
- const isSyncing = isPreviewPending || isPushPending || isPullPending || isImporting;
- // …and none of them can run while the CLI holds the site either. Gate the
- // controls on both, so an operation the agent took disables them visibly rather
- // than leaving buttons that swallow the click.
- const isSiteBusy = isSyncing || isOperationInProgress;
-
- const isPreviewLimitReached =
- snapshotUsage?.siteCreationBlocked === true ||
- ( snapshotUsage?.siteCount ?? 0 ) >= ( snapshotUsage?.siteLimit ?? Infinity );
-
- const { localSublabel } = deriveSiteStatus( site, isStarting, isStopping, operation );
- const localSiteUrl = getSiteUrl( site );
- const canOpenLocalSite = site.running && ! isStopping;
-
- const openExternal = ( url: string ) => {
- void connector.openExternalUrl( url );
- };
-
- const getSyncActionLabel = ( idle: string, pending: string, isPending: boolean ): string => {
- if ( isPending ) {
- return pending;
- }
- if ( operation ) {
- return sprintf(
- /* translators: 1: a sync action, e.g. "Pull from live". 2: an operation in progress, e.g. "Saving settings". */
- __( '%1$s (%2$s)' ),
- idle,
- getSiteOperationLabel( operation )
- );
- }
- if ( isSyncing ) {
- // translators: %s: a sync action, e.g. "Pull from live".
- return sprintf( __( '%s (sync in progress)' ), idle );
- }
- if ( ! agenticEnabled ) {
- return isOffline
- ? // translators: %s: a sync action, e.g. "Pull from live".
- sprintf( __( '%s (offline)' ), idle )
- : // translators: %s: a sync action, e.g. "Pull from live".
- sprintf( __( '%s (sign in required)' ), idle );
- }
- return idle;
- };
-
- const handlePreviewClick = () => {
- if ( isPreviewPending ) return;
- publishPreviewSite.mutate(
- {
- siteId: site.id,
- // The CLI cannot update an expired preview site — create a new one.
- existingHostname:
- previewSnapshot && ! isPreviewExpired
- ? getSnapshotHostname( previewSnapshot )
- : undefined,
- },
- { onSuccess: ( { url } ) => openExternal( ensureProtocol( url ) ) }
- );
- };
-
- const handleCopyPreviewClick = ( url: string ) => {
- void connector.copyText( url ).catch( ( error ) => {
- console.error( 'Failed to copy preview URL:', error );
- } );
- };
-
- const handleStartLocalClick = () => {
- if ( isOperationInProgress || isSyncing || site.running ) return;
- startSite.mutate( site.id );
- };
-
- const handleStopLocalClick = () => {
- if ( isOperationInProgress || isSyncing || ! site.running ) return;
- stopSite.mutate( site.id );
- };
-
- const handlePullClick = () => {
- if ( ! liveSite || isSyncing || isOperationInProgress ) return;
- onPullClick();
- };
-
- const handlePushClick = () => {
- if ( ! liveSite || isSyncing || isOperationInProgress ) return;
- onPushClick();
- };
-
- const renderUrlLink = ( {
- text,
- url,
- label,
- onOpen,
- }: {
- text: string;
- url: string;
- label: string;
- onOpen?: () => void;
- } ) => (
-
- {
- onOpen?.();
- openExternal( url );
- } }
- >
- { text }
-
-
- }
- />
- }>{ label }
-
- );
-
- return (
-
- { activity?.kind === 'pending' || activity?.kind === 'error' ? (
-
cancelSync.mutate( { siteId: site.id, remoteSiteId: liveSite.id } )
- : undefined
- }
- canCancel={ canCancelSyncActivity( activity ) }
- />
- ) : null }
-
-
- { __( 'Studio' ) }
-
- >
- ) : (
- __( 'Studio' )
- )
- }
- sublabel={
- canOpenLocalSite
- ? renderUrlLink( {
- text: localSublabel,
- url: localSiteUrl,
- label: __( 'Open Studio site in your browser' ),
- onOpen: () =>
- void connector.trackEvent( TRACKS_EVENTS.SITE_OPEN_IN_BROWSER, {
- browser: 'external',
- } ),
- } )
- : localSublabel
- }
- action={
-
- }
- />
-
- { previewSnapshot && ! isPreviewExpired ? (
-
- handleCopyPreviewClick( ensureProtocol( previewSnapshot.url ) ) }
- />
-
-
- }
- />
- ) : (
-
- ) }
-
- { liveSite ? (
-
-
-
-
-
-
-
-
-
- { __( 'Disconnect' ) }
-
-
-
-
- }
- />
- ) : (
- login.mutate() }
- />
- ) }
-
- );
-}
-
-function XdebugBadge( { running }: { running: boolean } ) {
- const label = __( 'Xdebug enabled' );
-
- return (
-
-
- }
- >
-
-
- }>{ label }
-
- );
-}
-
-function SyncActivityDetails( {
- activity,
- onCancel,
- canCancel,
-}: {
- activity: Extract< SyncActivity, { kind: 'pending' | 'error' } >;
- onCancel?: () => void;
- canCancel: boolean;
-} ) {
- // Same wording as the classic renderer, and the same source the trigger's
- // always-visible cancel uses, so the two never disagree.
- const cancel = getSyncCancelLabels( activity );
- const blockedLabel = cancel && ! cancel.enabled ? cancel.label : null;
-
- return (
-
-
-
{ getSyncActivityLabel( activity ) }
-
- { activity.message ??
- ( activity.direction === 'import'
- ? __( 'Preparing the backup…' )
- : __( 'Preparing the live site…' ) ) }
-
- { blockedLabel ? (
- // Stating this inline rather than leaving it to the disabled
- // button's tooltip: nobody hovers a control that looks inert.
-
{ blockedLabel }
- ) : null }
-
- { cancel ? (
- // A plain Button, not an IconButton: IconButton always wraps itself in
- // a Tooltip, and tooltips never render inside this menu anyway.
-
onCancel?.() }
- >
-
-
- ) : null }
-
- );
-}
-
-// The toggle tracks where the site is heading, not where it is, so an in-flight
-// start reads as running before the server is actually up.
-function getTargetRunning( running: boolean, starting: boolean, stopping: boolean ): boolean {
- if ( starting ) {
- return true;
- }
- if ( stopping ) {
- return false;
- }
- return running;
-}
-
-function LocalServerControl( {
- running,
- starting,
- stopping,
- operation,
- disabled,
- onStart,
- onStop,
-}: {
- running: boolean;
- starting: boolean;
- stopping: boolean;
- // A CLI operation (an agent settings change, another window's delete). Blocks
- // the toggle and names itself in the tooltip, so a dead control explains why.
- operation: SiteOperationKind | null;
- disabled: boolean;
- onStart: () => void;
- onStop: () => void;
-} ) {
- const pending = starting || stopping || operation !== null;
- const targetRunning = getTargetRunning( running, starting, stopping );
- // aria-disabled rather than disabled: a natively disabled button suppresses
- // the pointer events the tooltip listens for, hiding the status exactly
- // while the site is transitioning.
- const inert = disabled || pending;
- const statusLabel = sprintf(
- __( 'Site status: %s' ),
- getSiteStatusName( { running, starting, stopping, operation } )
- );
- const actionLabel = running ? __( 'Stop site' ) : __( 'Start site' );
-
- return (
-
- {
- if ( inert ) {
- return;
- }
- if ( targetRunning ) {
- onStop();
- } else {
- onStart();
- }
- } }
- >
-
-
-
-
- }
- />
- }>
- { statusLabel }
-
-
- );
-}
-
-function EnvironmentActionPanel( {
- title,
- copy,
- buttonLabel,
- variant,
- tone,
- loading,
- loadingAnnouncement,
- disabled,
- onClick,
-}: {
- title: string;
- copy: string;
- buttonLabel: string;
- variant: ButtonProps[ 'variant' ];
- tone: ButtonProps[ 'tone' ];
- loading?: boolean;
- loadingAnnouncement?: string;
- disabled: boolean;
- onClick: () => void;
-} ) {
- return (
-
-
-
- { buttonLabel }
-
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/popover-row.module.css b/apps/ui/src/components/site-dropdown/popover-row.module.css
deleted file mode 100644
index af8ff09600..0000000000
--- a/apps/ui/src/components/site-dropdown/popover-row.module.css
+++ /dev/null
@@ -1,46 +0,0 @@
-.row {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-md);
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
- min-height: 40px;
-}
-
-.text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
-}
-
-.label {
- /* Allow callers to tuck an inline affordance (e.g. an "open external"
- IconButton) right next to the label text. A plain text label still
- renders correctly inside this flex row. */
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-xs);
- font-size: var(--wpds-typography-font-size-md);
- line-height: 1.25;
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.sublabel {
- margin-top: 4px;
- font-size: var(--wpds-typography-font-size-xs);
- line-height: 1.3;
- color: var(--wpds-color-fg-content-neutral-weak);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.action {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--wpds-color-fg-interactive-brand);
- font-size: var(--wpds-typography-font-size-sm);
-}
diff --git a/apps/ui/src/components/site-dropdown/popover-row.tsx b/apps/ui/src/components/site-dropdown/popover-row.tsx
deleted file mode 100644
index 79814466ca..0000000000
--- a/apps/ui/src/components/site-dropdown/popover-row.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import styles from './popover-row.module.css';
-import type { ReactNode } from 'react';
-
-type Props = {
- label: ReactNode;
- sublabel?: ReactNode;
- action?: ReactNode;
-};
-
-export function PopoverRow( { label, sublabel, action }: Props ) {
- return (
-
-
-
{ label }
- { sublabel ?
{ sublabel }
: null }
-
- { action ?
{ action }
: null }
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.module.css b/apps/ui/src/components/site-dropdown/publish-picker-view.module.css
deleted file mode 100644
index 29f93bc641..0000000000
--- a/apps/ui/src/components/site-dropdown/publish-picker-view.module.css
+++ /dev/null
@@ -1,94 +0,0 @@
-.picker {
- display: flex;
- flex-direction: column;
-}
-
-.header {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-sm);
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
- border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
-}
-
-.title {
- font-size: var(--wpds-typography-font-size-sm);
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.body {
- max-height: 240px;
- overflow-y: auto;
-}
-
-.status {
- padding: var(--wpds-dimension-padding-lg);
- color: var(--wpds-color-fg-content-neutral-weak);
- font-size: var(--wpds-typography-font-size-sm);
- text-align: center;
-}
-
-.list {
- list-style: none;
- margin: 0;
- padding: var(--wpds-dimension-padding-xs) 0;
-}
-
-.item {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- width: 100%;
- background: transparent;
- border: none;
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg);
- cursor: pointer;
- font: inherit;
- color: inherit;
- text-align: left;
-}
-
-.item:hover,
-.item:focus-visible {
- background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
- outline: none;
-}
-
-.itemName {
- font-size: var(--wpds-typography-font-size-sm);
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.itemUrl {
- font-size: var(--wpds-typography-font-size-sm);
- color: var(--wpds-color-fg-content-neutral-weak);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
-}
-
-.create {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-sm);
- width: 100%;
- background: transparent;
- border: none;
- border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg);
- cursor: pointer;
- font: inherit;
- font-size: var(--wpds-typography-font-size-sm);
- color: var(--wpds-color-fg-interactive-brand);
- text-align: left;
-}
-
-.create:hover,
-.create:focus-visible {
- background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
- outline: none;
-}
diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx b/apps/ui/src/components/site-dropdown/publish-picker-view.tsx
deleted file mode 100644
index 4f8df43205..0000000000
--- a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useQueryClient } from '@tanstack/react-query';
-import { __ } from '@wordpress/i18n';
-import { chevronLeft, plus } from '@wordpress/icons';
-import { Icon, IconButton } from '@wordpress/ui';
-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 styles from './publish-picker-view.module.css';
-import { stripProtocol } from './utils';
-import type { SiteDetails, SyncSite } from '@/data/core';
-
-type Props = {
- site: SiteDetails;
- // Fires after any action that ends the picker flow (site picked, checkout
- // link opened, or the back button pressed). The parent uses this to swap
- // back to the main dropdown view.
- onClose: () => void;
-};
-
-export function PublishPickerView( { site, onClose }: Props ) {
- const connector = useConnector();
- const queryClient = useQueryClient();
- const { data: authUser } = useAuthUser();
- const pickableSites = usePickableWpcomSites();
-
- const openExternal = ( url: string ) => {
- void connector.openExternalUrl( url );
- };
-
- const handlePickSite = async ( pickedSite: SyncSite ) => {
- try {
- await connector.connectWpcomSite( site.id, {
- ...pickedSite,
- localSiteId: site.id,
- syncSupport: 'already-connected',
- } );
- await queryClient.invalidateQueries( {
- queryKey: connectedWpcomSitesQueryKey( site.id ),
- } );
- onClose();
- } catch ( error ) {
- console.error( 'Failed to connect WordPress.com site:', error );
- }
- };
-
- const handleCreateNew = () => {
- const checkoutUrl = connector.getPublishCheckoutUrl( site );
- if ( checkoutUrl ) {
- // Desktop receives the new site via the wp-studio:// deep link; surfaces
- // that can't (the local web server) opt into a server-side watch instead.
- void connector.watchForPublishedSite?.( site.id );
- openExternal( checkoutUrl );
- }
- // The connect listener (deep link on desktop, sync-connect SSE on the local
- // server) handles the follow-up connection, so we just close the picker.
- onClose();
- };
-
- return (
-
-
-
- { __( 'Publish this site' ) }
-
- { authUser ? (
-
- { pickableSites.isLoading ? (
-
{ __( 'Loading sites…' ) }
- ) : pickableSites.data && pickableSites.data.length > 0 ? (
-
- { pickableSites.data.map( ( candidate ) => (
-
- void handlePickSite( candidate ) }
- >
- { candidate.name || candidate.url }
- { stripProtocol( candidate.url ) }
-
-
- ) ) }
-
- ) : (
-
- { __( 'No WordPress.com sites available to publish to.' ) }
-
- ) }
-
- ) : null }
-
-
- { __( 'Create a new WordPress.com site' ) }
-
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/style.module.css b/apps/ui/src/components/site-dropdown/style.module.css
deleted file mode 100644
index ab64f95d25..0000000000
--- a/apps/ui/src/components/site-dropdown/style.module.css
+++ /dev/null
@@ -1,92 +0,0 @@
-.root {
- display: inline-flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-lg);
- min-width: 0;
-}
-
-/* The menu renders on the same inverted surface as the trigger, so the two
- read as one connected control. Remapping the wpds tokens locally lets the
- existing token-based row styles adapt without per-element overrides. */
-.popup {
- --site-dropdown-radius: var(--wpds-border-radius-lg);
- --site-dropdown-menu-surface: var(--wpds-color-bg-interactive-neutral-strong-active);
- --site-dropdown-menu-surface-subtle: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 7%,
- var(--site-dropdown-menu-surface)
- );
- --site-dropdown-menu-surface-active: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 13%,
- var(--site-dropdown-menu-surface)
- );
- --site-dropdown-menu-foreground: var(--wpds-color-fg-interactive-neutral-strong);
- --site-dropdown-menu-foreground-weak: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 72%,
- transparent
- );
- --site-dropdown-menu-foreground-disabled: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 38%,
- transparent
- );
- --site-dropdown-menu-divider: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 16%,
- transparent
- );
- --site-dropdown-menu-border: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 11%,
- transparent
- );
- --site-dropdown-menu-control-border: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 48%,
- transparent
- );
- --site-dropdown-menu-control-border-active: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 68%,
- transparent
- );
- --site-dropdown-menu-hover: var(--site-dropdown-menu-surface-active);
- /* Brand fg flips with the theme while this surface is theme-inverted, so
- derive a link blue from the constant brand fill instead: periwinkle on
- the dark surface, royal navy on the light one. */
- --site-dropdown-menu-brand: color-mix(
- in srgb,
- var(--wpds-color-bg-interactive-brand-strong, #3858e9) 65%,
- var(--site-dropdown-menu-foreground)
- );
- --wpds-color-bg-surface-neutral: var(--site-dropdown-menu-surface-subtle);
- --wpds-color-bg-surface-neutral-strong: var(--site-dropdown-menu-surface);
- --wpds-color-bg-interactive-neutral-weak: transparent;
- --wpds-color-bg-interactive-neutral-weak-active: var(--site-dropdown-menu-hover);
- --wpds-color-bg-interactive-brand-weak-active: var(--site-dropdown-menu-hover);
- --wpds-color-fg-interactive-brand: var(--site-dropdown-menu-brand);
- --wpds-color-fg-content-neutral: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-content-neutral-weak: var(--site-dropdown-menu-foreground-weak);
- --wpds-color-fg-interactive-neutral: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-interactive-neutral-active: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-interactive-neutral-disabled: var(--site-dropdown-menu-foreground-disabled);
- --wpds-color-stroke-focus-brand: var(--site-dropdown-menu-control-border-active);
- --wpds-color-stroke-interactive-neutral: var(--site-dropdown-menu-control-border);
- --wpds-color-stroke-interactive-neutral-active: var(
- --site-dropdown-menu-control-border-active
- );
- --wpds-color-stroke-surface-neutral: var(--site-dropdown-menu-divider);
- --wpds-color-stroke-surface-neutral-weak: var(--site-dropdown-menu-border);
-
- width: min(336px, calc(100vw - 32px));
- padding: 0;
- border: var(--wpds-border-width-xs) solid var(--site-dropdown-menu-border);
- border-radius: var(--site-dropdown-radius);
- background: var(--site-dropdown-menu-surface);
- color: var(--site-dropdown-menu-foreground);
- gap: 0;
- overflow: visible;
- box-shadow: 0 18px 44px rgb(0 0 0 / 24%);
-}
diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts
deleted file mode 100644
index 21f421bf7f..0000000000
--- a/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { getSiteDropdownSecondary, getSyncActivityLabel } from './trigger-secondary';
-import type { Snapshot, SyncSite } from '@/data/core';
-
-const NOW = '2026-05-03T12:00:00.000Z';
-
-describe( 'getSiteDropdownSecondary', () => {
- beforeEach( () => {
- vi.useFakeTimers();
- vi.setSystemTime( new Date( NOW ) );
- } );
-
- afterEach( () => {
- vi.useRealTimers();
- } );
-
- it( 'prioritizes active sync activity over persisted site context', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: { kind: 'pending', direction: 'preview' },
- activeEnvironment: 'local',
- liveSite: createLiveSite( { lastPushTimestamp: '2026-05-03T11:00:00.000Z' } ),
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-05-03T10:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Publishing preview…',
- tone: 'pending',
- } );
- } );
-
- it( 'shows preview recency while working locally', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-05-03T10:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Preview updated 2h ago',
- tone: 'neutral',
- } );
- } );
-
- it( 'reports expiry instead of recency once the snapshot is too old', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-04-25T12:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Preview expired',
- tone: 'neutral',
- } );
- } );
-
- it( 'uses live push recency while the session targets live', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'live',
- liveSite: createLiveSite( { lastPushTimestamp: '2026-05-03T09:00:00.000Z' } ),
- } )
- ).toEqual( {
- label: 'Pushed 3h ago',
- tone: 'neutral',
- } );
- } );
-
- it( 'falls back to the active environment when no recency exists', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- } )
- ).toEqual( {
- label: 'Local preview',
- tone: 'neutral',
- } );
-
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'live',
- } )
- ).toEqual( {
- label: 'Live site',
- tone: 'neutral',
- } );
- } );
-} );
-
-describe( 'getSyncActivityLabel', () => {
- it( 'formats error labels by direction', () => {
- expect( getSyncActivityLabel( { kind: 'error', direction: 'push', message: 'Nope' } ) ).toBe(
- 'Pushing to live failed'
- );
- expect( getSyncActivityLabel( { kind: 'error', direction: 'pull', message: 'Nope' } ) ).toBe(
- 'Pulling from live failed'
- );
- expect( getSyncActivityLabel( { kind: 'error', direction: 'preview', message: 'Nope' } ) ).toBe(
- 'Publishing preview failed'
- );
- } );
-} );
-
-function createSnapshot( overrides: Partial< Snapshot > = {} ): Snapshot {
- return {
- atomicSiteId: 123,
- localSiteId: 'site-1',
- url: 'example.preview.wordpress.com',
- date: Date.parse( NOW ),
- ...overrides,
- };
-}
-
-function createLiveSite( overrides: Partial< SyncSite > = {} ): SyncSite {
- return {
- id: 123,
- localSiteId: 'site-1',
- name: 'Live Site',
- url: 'example.com',
- isStaging: false,
- isPressable: false,
- syncSupport: 'already-connected',
- lastPullTimestamp: null,
- lastPushTimestamp: null,
- ...overrides,
- };
-}
diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.ts
deleted file mode 100644
index a316890431..0000000000
--- a/apps/ui/src/components/site-dropdown/trigger-secondary.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { isSnapshotExpired } from '@studio/common/lib/snapshots';
-import { __, sprintf } from '@wordpress/i18n';
-import { formatRelativeTime } from '@/lib/format-relative-time';
-import type { Snapshot, SyncSite } from '@/data/core';
-import type { SyncActivity } from '@/data/sync-activity';
-
-const MINUTE_MS = 60_000;
-
-export type TriggerSecondaryTone = 'neutral' | 'pending' | 'success' | 'error';
-
-export type TriggerSecondary = {
- label: string;
- tone: TriggerSecondaryTone;
-};
-
-type TriggerSecondaryOptions = {
- activity: SyncActivity | null;
- activeEnvironment: 'local' | 'live';
- liveSite?: SyncSite;
- previewSnapshot?: Snapshot;
-};
-
-export function getSyncActivityLabel( activity: SyncActivity ): string {
- if ( activity.kind === 'pending' ) {
- if ( activity.direction === 'preview' ) {
- return __( 'Publishing preview…' );
- }
- if ( activity.direction === 'import' ) {
- return __( 'Importing backup…' );
- }
- return activity.direction === 'push' ? __( 'Pushing to live…' ) : __( 'Pulling from live…' );
- }
-
- if ( activity.kind === 'success' ) {
- if ( activity.direction === 'preview' ) {
- return __( 'Preview published' );
- }
- if ( activity.direction === 'import' ) {
- return __( 'Backup imported' );
- }
- return activity.direction === 'push' ? __( 'Pushed to live' ) : __( 'Pulled from live' );
- }
-
- if ( activity.kind === 'cancelled' ) {
- if ( activity.direction === 'preview' ) {
- return __( 'Preview publishing cancelled' );
- }
- return activity.direction === 'push' ? __( 'Push cancelled' ) : __( 'Pull cancelled' );
- }
-
- if ( activity.direction === 'preview' ) {
- return __( 'Publishing preview failed' );
- }
- if ( activity.direction === 'import' ) {
- return __( 'Importing backup failed' );
- }
- return activity.direction === 'push'
- ? __( 'Pushing to live failed' )
- : __( 'Pulling from live failed' );
-}
-
-function getSyncActivityTone( activity: SyncActivity ): TriggerSecondaryTone {
- if ( activity.kind === 'pending' ) {
- return 'pending';
- }
- if ( activity.kind === 'success' ) {
- return 'success';
- }
- // A cancel is the user's own doing, not a failure — the legacy renderer
- // likewise shows it without error styling.
- return activity.kind === 'cancelled' ? 'neutral' : 'error';
-}
-
-function formatTimestampPhrase(
- timestampMs: number,
- nowLabel: string,
- formatAgo: ( relativeTime: string ) => string
-): string | null {
- if ( ! Number.isFinite( timestampMs ) ) {
- return null;
- }
-
- const timestamp = new Date( timestampMs );
- if ( Number.isNaN( timestamp.getTime() ) ) {
- return null;
- }
-
- if ( Math.max( 0, Date.now() - timestampMs ) < MINUTE_MS ) {
- return nowLabel;
- }
-
- return formatAgo( formatRelativeTime( timestamp.toISOString() ) );
-}
-
-function formatIsoTimestampPhrase(
- isoTimestamp: string | null | undefined,
- nowLabel: string,
- formatAgo: ( relativeTime: string ) => string
-): string | null {
- if ( ! isoTimestamp ) {
- return null;
- }
-
- return formatTimestampPhrase( Date.parse( isoTimestamp ), nowLabel, formatAgo );
-}
-
-function getPreviewLabel( previewSnapshot: Snapshot | undefined ): string | null {
- if ( ! previewSnapshot ) {
- return null;
- }
-
- if ( isSnapshotExpired( previewSnapshot ) ) {
- return __( 'Preview expired' );
- }
-
- return formatTimestampPhrase(
- previewSnapshot.date,
- __( 'Preview updated now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Preview updated %s ago' ),
- relativeTime
- )
- );
-}
-
-function getPushLabel( liveSite: SyncSite | undefined ): string | null {
- return formatIsoTimestampPhrase(
- liveSite?.lastPushTimestamp,
- __( 'Pushed just now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Pushed %s ago' ),
- relativeTime
- )
- );
-}
-
-function getPullLabel( liveSite: SyncSite | undefined ): string | null {
- return formatIsoTimestampPhrase(
- liveSite?.lastPullTimestamp,
- __( 'Pulled just now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Pulled %s ago' ),
- relativeTime
- )
- );
-}
-
-export function getSiteDropdownSecondary( {
- activity,
- activeEnvironment,
- liveSite,
- previewSnapshot,
-}: TriggerSecondaryOptions ): TriggerSecondary {
- if ( activity ) {
- return {
- label: getSyncActivityLabel( activity ),
- tone: getSyncActivityTone( activity ),
- };
- }
-
- const liveSyncLabel = getPushLabel( liveSite ) ?? getPullLabel( liveSite );
-
- if ( activeEnvironment === 'live' ) {
- return {
- label: liveSyncLabel ?? __( 'Live site' ),
- tone: 'neutral',
- };
- }
-
- return {
- label: getPreviewLabel( previewSnapshot ) ?? liveSyncLabel ?? __( 'Local preview' ),
- tone: 'neutral',
- };
-}
diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx
index 5a6675798c..b2eef8c4c1 100644
--- a/apps/ui/src/components/site-list/index.tsx
+++ b/apps/ui/src/components/site-list/index.tsx
@@ -23,7 +23,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-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx
index 0d97d23925..6e6b7bba78 100644
--- a/apps/ui/src/components/site-overview-view/index.test.tsx
+++ b/apps/ui/src/components/site-overview-view/index.test.tsx
@@ -37,7 +37,7 @@ import type {
import type { ImportEventTuple } from '@studio/common/lib/import-export-events';
const navigateMock = vi.fn();
-const siteDropdownMock = vi.hoisted( () => vi.fn() );
+const siteToolbarMock = vi.hoisted( () => vi.fn() );
const importSiteFromBackup = vi.hoisted( () => vi.fn() );
const reportSyncProgressMock = vi.hoisted( () => vi.fn() );
const useSidebarCollapsedMock = vi.hoisted( () => vi.fn() );
@@ -64,14 +64,9 @@ vi.mock( '@/components/delete-site-dialog', () => ( {
open ? Delete dialog
: null,
} ) );
-vi.mock( '@/components/site-dropdown', () => ( {
- SiteDropdown: ( props: {
- site: SiteDetails;
- showSiteIcon?: boolean;
- showStatus?: boolean;
- defaultOpen?: boolean;
- } ) => {
- siteDropdownMock( props );
+vi.mock( '@/components/site-toolbar', () => ( {
+ SiteToolbar: ( props: { site: SiteDetails; className?: string; openPullOnLoad?: boolean } ) => {
+ siteToolbarMock( props );
return { props.site.name }
;
},
} ) );
@@ -278,7 +273,7 @@ describe( 'SiteOverviewView', () => {
function renderView(
activeTab: 'overview' | 'general' | 'debugging' = 'overview',
- openSiteDropdown = false,
+ openPullOnLoad = false,
siteId = 'site-1'
) {
const view = (
@@ -287,7 +282,7 @@ describe( 'SiteOverviewView', () => {
@@ -303,7 +298,7 @@ describe( 'SiteOverviewView', () => {
@@ -315,8 +310,8 @@ describe( 'SiteOverviewView', () => {
it( 'renders the tab strip with the about, shortcuts, and manage sections', () => {
renderView();
- expect( siteDropdownMock ).toHaveBeenCalledWith(
- expect.objectContaining( { showSiteIcon: true, showStatus: false } )
+ expect( siteToolbarMock ).toHaveBeenCalledWith(
+ expect.objectContaining( { site: expect.objectContaining( { id: 'site-1' } ) } )
);
expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible();
expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible();
@@ -410,11 +405,11 @@ describe( 'SiteOverviewView', () => {
expect( onTabChange ).toHaveBeenCalledWith( 'general' );
} );
- it( 'opens site status when requested by the route', () => {
+ it( 'opens the pull dialog when requested by the route', () => {
renderView( 'overview', true );
- expect( siteDropdownMock ).toHaveBeenCalledWith(
- expect.objectContaining( { defaultOpen: true } )
+ expect( siteToolbarMock ).toHaveBeenCalledWith(
+ expect.objectContaining( { openPullOnLoad: true } )
);
} );
diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx
index 97cd8d90d6..38fc4b5469 100644
--- a/apps/ui/src/components/site-overview-view/index.tsx
+++ b/apps/ui/src/components/site-overview-view/index.tsx
@@ -30,9 +30,9 @@ import { OfflineBanner } from '@/components/offline-banner';
import { useOpenInDestinations } from '@/components/open-in-menu/use-open-in-destinations';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
import { ProgressiveBlur } from '@/components/progressive-blur';
-import { SiteDropdown } from '@/components/site-dropdown';
import { DATABASE_HOME_PATH } from '@/components/site-preview/address-bar';
import { isSiteSettingsTab, SiteSettingsForm } from '@/components/site-settings-view';
+import { SiteToolbar } from '@/components/site-toolbar';
import * as Tabs from '@/components/tabs';
import { useConnector } from '@/data/core';
import { useIsSiteBusy, useSites } from '@/data/queries/use-sites';
@@ -55,7 +55,7 @@ import type { ReactNode } from 'react';
interface SiteOverviewViewProps {
siteId: string;
activeTab: SiteSettingsTabId;
- openSiteDropdown?: boolean;
+ openPullOnLoad?: boolean;
onTabChange: ( tab: SiteSettingsTabId ) => void;
}
@@ -73,10 +73,10 @@ interface OverviewButtonProps {
function OverviewHeader( {
site,
- openSiteDropdown,
+ openPullOnLoad,
}: {
site: SiteDetails;
- openSiteDropdown: boolean;
+ openPullOnLoad: boolean;
} ) {
const sidebarCollapsed = useSidebarCollapsed();
const reserveTrafficLightSpace = useTrafficLightSpace().start;
@@ -89,13 +89,7 @@ function OverviewHeader( {
: styles.header
}
>
-
+
);
}
@@ -222,7 +216,7 @@ function OpenInSection( {
export function SiteOverviewView( {
siteId,
activeTab,
- openSiteDropdown = false,
+ openPullOnLoad = false,
onTabChange,
}: SiteOverviewViewProps ) {
const { data: sites, isLoading: sitesLoading } = useSites();
@@ -245,7 +239,7 @@ export function SiteOverviewView( {
);
@@ -254,12 +248,12 @@ export function SiteOverviewView( {
function SiteOverviewBody( {
site,
activeTab,
- openSiteDropdown,
+ openPullOnLoad,
onTabChange,
}: {
site: SiteDetails;
activeTab: SiteSettingsTabId;
- openSiteDropdown: boolean;
+ openPullOnLoad: boolean;
onTabChange: ( tab: SiteSettingsTabId ) => void;
} ) {
const navigate = useNavigate();
@@ -292,7 +286,7 @@ function SiteOverviewBody( {
return (
-
+
;
+ isStarting: boolean;
+ isStopping: boolean;
+} ): SiteRunStatus {
+ if ( isStarting || isStopping ) {
+ return 'transitioning';
+ }
+ return site.running ? 'running' : 'stopped';
+}
+
+interface SiteStatusButtonProps {
+ site: SiteDetails;
+ isStarting: boolean;
+ isStopping: boolean;
+ className?: string;
+}
+
+/**
+ * The site's running state and its start/stop control in one 24px target: a
+ * dot while idle, crossfading to the action it triggers on hover. When Xdebug
+ * is on, its bug replaces the dot entirely — a persistent per-site setting
+ * worth spotting at a glance — while keeping the same state colors.
+ *
+ * Shared by the sidebar rows and the site toolbar so the two never drift.
+ */
+export function SiteStatusButton( {
+ site,
+ isStarting,
+ isStopping,
+ className,
+}: SiteStatusButtonProps ) {
+ const startSite = useStartSite();
+ const stopSite = useStopSite();
+ const operation = useSiteOperation( site );
+ const busy = useIsSiteBusy( site );
+ const { status } = deriveSiteStatus( site, isStarting, isStopping, operation );
+ const statusName = getSiteStatusName( {
+ running: site.running,
+ starting: isStarting,
+ stopping: isStopping,
+ operation,
+ } );
+ const xdebug = Boolean( site.enableXdebug );
+ const tooltipLabel = xdebug
+ ? sprintf( __( 'Site status: %s. Xdebug enabled' ), statusName )
+ : sprintf( __( 'Site status: %s' ), statusName );
+ const actionLabel = site.running ? __( 'Stop site' ) : __( 'Start site' );
+ const label = busy ? tooltipLabel : sprintf( __( '%1$s. %2$s' ), tooltipLabel, actionLabel );
+
+ const handleClick = ( event: MouseEvent< HTMLButtonElement > ) => {
+ event.stopPropagation();
+ if ( busy ) {
+ return;
+ }
+ if ( site.running ) {
+ stopSite.mutate( site.id );
+ } else {
+ startSite.mutate( site.id );
+ }
+ };
+
+ return (
+
+
+ { xdebug ? (
+
+ ) : (
+
+ { status === 'stopped' ? (
+
+ ) : (
+
+ ) }
+
+ ) }
+ { ! busy ? (
+ site.running ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : null }
+
+ }
+ />
+ }>
+ { tooltipLabel }
+
+
+ );
+}
diff --git a/apps/ui/src/components/site-status-button/style.module.css b/apps/ui/src/components/site-status-button/style.module.css
new file mode 100644
index 0000000000..e80c10c43a
--- /dev/null
+++ b/apps/ui/src/components/site-status-button/style.module.css
@@ -0,0 +1,168 @@
+.status {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 24px;
+ inline-size: 24px;
+ block-size: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: inherit;
+ cursor: var(--wpds-cursor-control);
+ transition: background-color 100ms ease, opacity 100ms ease;
+}
+
+.status:not([aria-disabled='true']):is(:hover, :focus-visible) {
+ background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
+}
+
+.status:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9);
+ outline-offset: -2px;
+}
+
+.status[aria-disabled='true'] {
+ cursor: default;
+}
+
+.status .glyph.glyph {
+ grid-row-start: 1;
+ grid-column-start: 1;
+ inline-size: 8px;
+ block-size: 8px;
+ width: 8px;
+ height: 8px;
+ flex-shrink: 0;
+ overflow: visible;
+ transition: opacity 100ms ease;
+}
+
+/* The Xdebug bug replaces the status dot; its color tracks the same state
+ palette the dot uses. */
+.status .xdebugGlyph.xdebugGlyph {
+ inline-size: 20px;
+ block-size: 20px;
+ width: 20px;
+ height: 20px;
+}
+
+.status[data-state='running'] .xdebugGlyph {
+ color: var(--studio-color-status-running);
+}
+
+.status[data-state='transitioning'] .xdebugGlyph {
+ color: var(--studio-color-status-transitioning);
+ animation: statusBlink 1s ease-in-out infinite;
+}
+
+.status[data-state='stopped'] .xdebugGlyph {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+/* Hovering the button crossfades the status dot into the action it triggers:
+ play when stopped, stop when running. The transitioning state keeps the
+ blinking dot — there's no action to take. */
+.status .actionGlyph.actionGlyph {
+ grid-row-start: 1;
+ grid-column-start: 1;
+ display: inline-grid;
+ place-items: center;
+ inline-size: 10px;
+ block-size: 10px;
+ width: 10px;
+ height: 10px;
+ color: var(--wpds-color-fg-content-neutral);
+ opacity: 0;
+ transition: opacity 100ms ease;
+}
+
+.status:not([data-state='transitioning']):is(:hover, :focus-visible) .glyph {
+ opacity: 0;
+}
+
+.status:not([data-state='transitioning']):is(:hover, :focus-visible) .actionGlyph {
+ opacity: 1;
+}
+
+.pauseMark,
+.pauseMark::before,
+.pauseMark::after {
+ display: block;
+ height: 8px;
+ border-radius: 1px;
+ background: currentColor;
+}
+
+.pauseMark {
+ position: relative;
+ width: 8px;
+ background: transparent;
+}
+
+.pauseMark::before,
+.pauseMark::after {
+ position: absolute;
+ top: 0;
+ width: 2px;
+ content: '';
+}
+
+.pauseMark::before {
+ left: 1px;
+}
+
+.pauseMark::after {
+ right: 1px;
+}
+
+.shape {
+ x: 0;
+ y: 0;
+ width: 8px;
+ height: 8px;
+ rx: 4px;
+ fill: var(--studio-color-status-running);
+ transition: fill 160ms ease, rx 160ms ease;
+}
+
+.playShape {
+ fill: var(--wpds-color-fg-content-neutral);
+}
+
+.status[data-state='running'] .shape {
+ fill: var(--studio-color-status-running);
+ rx: 4px;
+}
+
+.status[data-state='transitioning'] .shape {
+ fill: var(--studio-color-status-transitioning);
+ rx: 4px;
+ animation: statusBlink 1s ease-in-out infinite;
+}
+
+@keyframes statusBlink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.25;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .shape {
+ transition: none;
+ animation: none;
+ }
+
+ .glyph,
+ .actionGlyph {
+ transition: none;
+ }
+
+ .xdebugGlyph {
+ animation: none;
+ }
+}
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..e97a2b5c23
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/index.tsx
@@ -0,0 +1,309 @@
+import { TRACKS_EVENTS } from '@studio/common/lib/record-tracks-event';
+import { useIsMutating } from '@tanstack/react-query';
+import { __ } from '@wordpress/i18n';
+import { external, Icon, moreVertical } from '@wordpress/icons';
+import { Button, IconButton, Tooltip } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import * as Menu from '@/components/menu';
+import { SiteIcon } from '@/components/site-icon';
+import { SiteStatusButton } from '@/components/site-status-button';
+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 { useIsSiteStarting, useIsSiteStopping } from '@/data/queries/use-sites';
+import {
+ PULL_FROM_LIVE_MUTATION_KEY,
+ PUSH_TO_LIVE_MUTATION_KEY,
+ usePullSiteFromLive,
+ usePushSiteToLive,
+} from '@/data/queries/use-sync-site';
+import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
+import { getSiteDisplayUrl, getSiteUrl } from '@/lib/get-site-url';
+import { DisconnectSiteDialog } from './disconnect-site-dialog';
+import { PublishSiteDialog } from './publish-site-dialog';
+import { ShareDialog } from './share-dialog';
+import styles from './style.module.css';
+import { SyncDialog, type SyncDirection } from './sync-dialog';
+import { ensureProtocol, pickLiveSite, sortConnections } from './utils';
+import type { SiteDetails, SyncSite } from '@/data/core';
+import type { PullSyncOptions, PushSyncOptions } from '@studio/common/types/sync';
+
+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 push / pull mutations for this site across hook instances.
+// They mutate the same local runtime, so a push started elsewhere (the publish
+// flow) must still read as busy here and block a concurrent pull that would
+// wedge the site.
+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;
+ return push || pull;
+}
+
+/**
+ * The site's permanent header: who you're working on and what state it's in 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.
+ *
+ * Sync opens the dialog that chooses direction, destination, and what to
+ * carry; Publish connects a WordPress.com site when none is. Preview sharing
+ * returns with the Share button in a follow-up.
+ */
+export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteToolbarProps ) {
+ const connector = useConnector();
+ const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures();
+ // The sidebar's site rows already carry a run-state dot for every site,
+ // including this one. A second one in the header only earns its place once
+ // the sidebar is out of view.
+ const showRunState = useSidebarCollapsed();
+ const login = useLogin( { source: 'site_header' } );
+ const pushSiteToLive = usePushSiteToLive();
+ const pullSiteFromLive = usePullSiteFromLive();
+
+ const [ syncOpen, setSyncOpen ] = useState( false );
+ const [ publishOpen, setPublishOpen ] = useState( false );
+ const [ disconnectOpen, setDisconnectOpen ] = useState( false );
+ const [ shareOpen, setShareOpen ] = useState( false );
+
+ const isStarting = useIsSiteStarting( site.id );
+ const isStopping = useIsSiteStopping( site.id );
+ const isBusy = useIsSiteBusy( site.id );
+
+ const { data: connectedSites } = useConnectedWpcomSites( site.id );
+ // The dialog offers every connection; the header's connected/disconnect
+ // affordances key off whichever one is the primary (production) target.
+ const targets = useMemo( () => sortConnections( connectedSites ), [ connectedSites ] );
+ const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
+
+ // Honour the onboarding deep link once the connection is known: open the sync
+ // dialog (defaulting to Pull) so a freshly connected site can bring the live
+ // content down. Fires once.
+ const syncOpenedRef = useRef( false );
+ useEffect( () => {
+ if ( openPullOnLoad && targets.length > 0 && ! syncOpenedRef.current ) {
+ syncOpenedRef.current = true;
+ setSyncOpen( true );
+ }
+ }, [ openPullOnLoad, targets ] );
+
+ const isSignedOut = agenticReason === 'signed-out';
+ const isOffline = agenticReason === 'offline';
+ const syncDisabled = ! agenticEnabled || isBusy;
+
+ const openExternal = ( url: string ) => {
+ void connector.openExternalUrl( url );
+ };
+
+ const runSync = (
+ direction: SyncDirection,
+ target: SyncSite,
+ options: PushSyncOptions | PullSyncOptions | undefined
+ ) => {
+ if ( isBusy ) {
+ return;
+ }
+ if ( direction === 'pull' ) {
+ pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: target.id, options } );
+ return;
+ }
+ pushSiteToLive.mutate(
+ { siteId: site.id, remoteSiteId: target.id, options },
+ { onSuccess: () => openExternal( ensureProtocol( target.url ) ) }
+ );
+ };
+
+ const localSiteUrl = getSiteUrl( site );
+ const localSiteLabel = isStopping
+ ? __( 'Stopping…' )
+ : isStarting
+ ? __( 'Starting…' )
+ : getSiteDisplayUrl( site );
+ const canOpenLocalSite = site.running && ! isStopping;
+
+ return (
+
+
+
+
+ { site.name }
+
+ { showRunState ? (
+
+ ) : null }
+ { canOpenLocalSite ? (
+
+ {
+ void connector.trackEvent( TRACKS_EVENTS.SITE_OPEN_IN_BROWSER, {
+ browser: 'external',
+ } );
+ openExternal( localSiteUrl );
+ } }
+ >
+ { localSiteLabel }
+
+
+ }
+ />
+ }>
+ { __( 'Open Studio site in your browser' ) }
+
+
+ ) : (
+ { localSiteLabel }
+ ) }
+
+
+
+
+
+ { /* Sharing a preview isn't a sync — it publishes a throwaway copy —
+ so it sits beside the primary action, not inside its dialog. */ }
+ { ! isSignedOut ? (
+
+ setShareOpen( true ) }
+ >
+ { __( 'Share' ) }
+
+ }
+ />
+ }>
+ { agenticEnabled
+ ? __( 'Publish a preview link' )
+ : __( 'Go online to share a preview.' ) }
+
+
+ ) : null }
+ { isSignedOut ? (
+ login.mutate() }
+ >
+ { __( 'Log in' ) }
+
+ ) : liveSite ? (
+ <>
+ { /* One Sync action. Direction, destination, and selection are all
+ chosen inside the dialog. */ }
+ targets.length > 0 && setSyncOpen( true ) }
+ >
+ { __( 'Sync' ) }
+
+
+
+ }
+ />
+
+ setDisconnectOpen( true ) }>
+ { __( 'Disconnect' ) }
+
+
+
+ >
+ ) : (
+ setPublishOpen( true ) }
+ >
+ { __( 'Publish' ) }
+
+ ) }
+
+
+ { targets.length > 0 ? (
+
+ ) : null }
+
+ { liveSite ? (
+
+ ) : null }
+
+ { /* Mounted only while open: it loads the account's sites on mount. */ }
+ { publishOpen ? (
+
+ ) : null }
+
+ { shareOpen ?
: null }
+
+ );
+}
diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css
new file mode 100644
index 0000000000..3201fae9ce
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css
@@ -0,0 +1,25 @@
+.intro {
+ margin: 0 0 var(--wpds-dimension-padding-lg);
+ font-size: var(--wpds-typography-font-size-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.error {
+ margin: 0 0 var(--wpds-dimension-padding-md);
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-error);
+ border-radius: var(--wpds-border-radius-md);
+ background: var(--wpds-color-bg-surface-error-weak);
+ color: var(--wpds-color-fg-content-error);
+ font-size: var(--wpds-typography-font-size-sm);
+}
+
+/* Sits opposite Cancel and Connect: making a new site is a way out of this
+ list, not a step in it. */
+.createButton {
+ margin-inline-end: auto;
+}
+
+.createButton svg {
+ fill: currentColor;
+}
diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx
new file mode 100644
index 0000000000..5638ecda79
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx
@@ -0,0 +1,143 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { __ } from '@wordpress/i18n';
+import { external, Icon } from '@wordpress/icons';
+import { Button, Dialog } from '@wordpress/ui';
+import { useState } from 'react';
+import { ConnectSitePicker } from '@/components/connect-site-picker';
+import { useConnector } from '@/data/core';
+import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites';
+import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites';
+import styles from './publish-site-dialog.module.css';
+import type { SiteDetails } from '@/data/core';
+
+type Props = {
+ site: SiteDetails;
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+};
+
+/**
+ * Choosing where a Studio site goes live. The same picker onboarding uses to
+ * bring a site down into Studio, pointed the other way — one list of the
+ * WordPress.com and Pressable sites this account can reach, with room to see
+ * them rather than a popover to squint at.
+ */
+export function PublishSiteDialog( { site, open, onOpenChange }: Props ) {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ const pickableSites = usePickableWpcomSites();
+ const [ selectedId, setSelectedId ] = useState< number | null >( null );
+ const [ isConnecting, setIsConnecting ] = useState( false );
+ const [ error, setError ] = useState( '' );
+
+ const selectedSite = pickableSites.data?.find( ( candidate ) => candidate.id === selectedId );
+
+ const close = ( next: boolean ) => {
+ if ( isConnecting ) {
+ return;
+ }
+ onOpenChange( next );
+ if ( ! next ) {
+ setSelectedId( null );
+ setError( '' );
+ }
+ };
+
+ const handleConnect = async () => {
+ if ( ! selectedSite || isConnecting ) {
+ return;
+ }
+ setIsConnecting( true );
+ setError( '' );
+ try {
+ await connector.connectWpcomSite( site.id, {
+ ...selectedSite,
+ localSiteId: site.id,
+ syncSupport: 'already-connected',
+ } );
+ await queryClient.invalidateQueries( { queryKey: connectedWpcomSitesQueryKey( site.id ) } );
+ close( false );
+ } catch ( caught ) {
+ setError(
+ caught instanceof Error
+ ? caught.message
+ : __( 'Failed to connect the site. Please try again.' )
+ );
+ } finally {
+ setIsConnecting( false );
+ }
+ };
+
+ const handleCreateNew = () => {
+ const checkoutUrl = connector.getPublishCheckoutUrl( site );
+ if ( checkoutUrl ) {
+ // Desktop receives the new site via the wp-studio:// deep link; surfaces
+ // that can't (the local web server) opt into a server-side watch instead.
+ void connector.watchForPublishedSite?.( site.id );
+ void connector.openExternalUrl( checkoutUrl );
+ }
+ // The connect listener (deep link on desktop, sync-connect SSE on the local
+ // server) handles the follow-up connection, so we just get out of the way.
+ close( false );
+ };
+
+ return (
+
+
+
+ { __( 'Publish this site' ) }
+
+
+
+ { __(
+ 'Choose the WordPress.com or Pressable site to publish to. Pushing sends this Studio site’s files and database there.'
+ ) }
+
+ { error ? (
+
+ { error }
+
+ ) : null }
+ void pickableSites.refetch() }
+ selectedId={ selectedId }
+ onSelect={ setSelectedId }
+ emptyTitle={ __( 'No sites available' ) }
+ emptyDescription={ __(
+ 'Every site on this account is already connected to a Studio site, or cannot be published to.'
+ ) }
+ />
+
+
+
+ { __( 'Create a new site' ) }
+
+
+
+ { __( 'Cancel' ) }
+
+ void handleConnect() }
+ >
+ { __( 'Connect site' ) }
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/site-toolbar/share-dialog.module.css b/apps/ui/src/components/site-toolbar/share-dialog.module.css
new file mode 100644
index 0000000000..f19bd9b8d4
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/share-dialog.module.css
@@ -0,0 +1,149 @@
+/* 480px: wider than wpds `small` (400) and narrower than `medium` (560).
+ Unlayered, so it beats the size rule in wpds's `wp-ui-components` layer. */
+.popup {
+ max-width: 480px;
+}
+
+/* Block padding only — `Dialog.Content` supplies the inline gutter. */
+.section {
+ padding-block: var(--wpds-dimension-padding-md);
+}
+
+.section:first-child {
+ padding-block-start: 0;
+}
+
+.section:last-child {
+ padding-block-end: 0;
+}
+
+.section + .section {
+ border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.heading {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ font-weight: 600;
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.intro {
+ margin: 2px 0 0;
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.empty {
+ margin: var(--wpds-dimension-gap-sm) 0 0;
+ font-size: var(--wpds-typography-font-size-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.cards {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin: var(--wpds-dimension-gap-sm) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* Two lines: the hostname in full, then its expiry paired with the controls.
+ Flat rows flush with the section heading — no surface, no radius — divided by
+ a hairline so the list reads as a list, not a stack of boxes. */
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-block: var(--wpds-dimension-padding-sm);
+}
+
+.card + .card {
+ border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.rowSecond {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-gap-sm);
+}
+
+/* Wraps rather than truncates: the tail of a preview hostname is what tells
+ two of them apart. */
+.rowLink {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ color: var(--wpds-color-fg-content-neutral);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-align: start;
+ overflow-wrap: anywhere;
+ cursor: var(--wpds-cursor-control);
+}
+
+.rowLink:hover {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.rowLink:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+.rowMeta {
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+ flex: 0 0 auto;
+}
+
+/* A plain Button standing in for an IconButton (which can't be a menu
+ trigger): square it off so it lines up with its icon-button neighbour. */
+.overflowButton {
+ --wp-ui-button-aspect-ratio: 1;
+ --wp-ui-button-padding-inline: 0;
+ --wp-ui-button-min-width: unset;
+}
+
+/* `Dialog.Footer` right-aligns its children; the quota reads as a status for
+ the panel, so it stays on the leading edge. */
+.footer {
+ justify-content: space-between;
+}
+
+.quotaLabel {
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+
+
+/* Icon SVGs carry no fill of their own; without this they paint black in both
+ colour schemes.
+
+ The 16px is not a style choice — it's what the rest of the app renders. The
+ compact-density rule in `index.css` is scoped
+ `[data-wpds-density='compact'] [data-ui-mode='classic'] svg`, and the popover
+ portals into `document.body`, outside both wrappers. @wordpress/icons then
+ draws at its native 24px. Restated here for the surfaces that escape. */
+.actions svg {
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
diff --git a/apps/ui/src/components/site-toolbar/share-dialog.test.tsx b/apps/ui/src/components/site-toolbar/share-dialog.test.tsx
new file mode 100644
index 0000000000..f41492f939
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/share-dialog.test.tsx
@@ -0,0 +1,160 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useConnector } from '@/data/core';
+import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
+import { useDeletePreviewSite, usePublishPreviewSite } from '@/data/queries/use-preview-site';
+import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots';
+import { ShareDialog } from './share-dialog';
+import type { SiteDetails, Snapshot } from '@/data/core';
+
+vi.mock( '@/data/core', async ( importOriginal ) => ( {
+ ...( await importOriginal< object >() ),
+ useConnector: vi.fn(),
+} ) );
+vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( {
+ useConnectedWpcomSites: vi.fn(),
+} ) );
+vi.mock( '@/data/queries/use-preview-site', () => ( {
+ usePublishPreviewSite: vi.fn(),
+ useDeletePreviewSite: vi.fn(),
+} ) );
+vi.mock( '@/data/queries/use-snapshots', () => ( {
+ useSnapshots: vi.fn(),
+ useSnapshotUsage: vi.fn(),
+} ) );
+
+const SITE = { id: 'riff', name: 'Riff' } as unknown as SiteDetails;
+
+function snapshot( overrides: Partial< Snapshot > = {} ): Snapshot {
+ return {
+ url: 'https://riff-abcde-studio.wp.build',
+ localSiteId: 'riff',
+ atomicSiteId: 1,
+ date: Date.now(),
+ ...overrides,
+ } as Snapshot;
+}
+
+const publishMutate = vi.fn();
+const deleteMutate = vi.fn();
+const copyText = vi.fn().mockResolvedValue( undefined );
+
+function renderDialog( snapshots: Snapshot[] = [ snapshot() ], connections: unknown[] = [] ) {
+ vi.mocked( useSnapshots ).mockReturnValue( {
+ data: snapshots,
+ } as ReturnType< typeof useSnapshots > );
+ vi.mocked( useSnapshotUsage ).mockReturnValue( {
+ data: { siteCount: snapshots.length, siteLimit: 10, siteCreationBlocked: false },
+ } as ReturnType< typeof useSnapshotUsage > );
+ vi.mocked( useConnectedWpcomSites ).mockReturnValue( { data: connections } as never );
+ vi.mocked( useConnector ).mockReturnValue( {
+ copyText,
+ openExternalUrl: vi.fn(),
+ } as unknown as ReturnType< typeof useConnector > );
+ vi.mocked( usePublishPreviewSite ).mockReturnValue( {
+ mutate: publishMutate,
+ isPending: false,
+ } as unknown as ReturnType< typeof usePublishPreviewSite > );
+ vi.mocked( useDeletePreviewSite ).mockReturnValue( {
+ mutate: deleteMutate,
+ isPending: false,
+ variables: undefined,
+ } as unknown as ReturnType< typeof useDeletePreviewSite > );
+
+ return render( );
+}
+
+describe( 'ShareDialog', () => {
+ beforeEach( () => {
+ vi.clearAllMocks();
+ } );
+
+ it( 'lists each preview link with its expiry', () => {
+ renderDialog();
+
+ expect( screen.getByText( 'riff-abcde-studio.wp.build' ) ).toBeInTheDocument();
+ expect( screen.getByText( /Expires in \d+ days?/ ) ).toBeInTheDocument();
+ } );
+
+ it( 'offers Republish for an expired preview', async () => {
+ const user = userEvent.setup();
+ renderDialog( [ snapshot( { date: Date.now() - 30 * 24 * 60 * 60 * 1000 } ) ] );
+
+ expect( screen.getByText( 'Expired' ) ).toBeInTheDocument();
+
+ await user.click( screen.getByRole( 'button', { name: 'More options' } ) );
+
+ expect( await screen.findByRole( 'menuitem', { name: 'Republish' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'republishes from the overflow menu', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click( screen.getByRole( 'button', { name: 'More options' } ) );
+ await user.click(
+ await screen.findByRole( 'menuitem', { name: 'Update with current contents' } )
+ );
+
+ expect( publishMutate ).toHaveBeenCalledWith(
+ { siteId: 'riff', existingHostname: 'riff-abcde-studio.wp.build' },
+ expect.anything()
+ );
+ } );
+
+ it( 'opens the overflow menu and confirms before deleting', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click( screen.getByRole( 'button', { name: 'More options' } ) );
+ await user.click( await screen.findByRole( 'menuitem', { name: 'Delete preview link' } ) );
+
+ expect( screen.getByText( 'This link will stop working immediately.' ) ).toBeInTheDocument();
+ expect( deleteMutate ).not.toHaveBeenCalled();
+
+ await user.click( screen.getByRole( 'button', { name: 'Delete' } ) );
+
+ expect( deleteMutate ).toHaveBeenCalledWith(
+ { hostname: 'riff-abcde-studio.wp.build' },
+ expect.anything()
+ );
+ } );
+
+ it( 'lists connected live sites above the preview links', async () => {
+ const user = userEvent.setup();
+ renderDialog(
+ [ snapshot() ],
+ [ { id: 42, name: 'Riff', url: 'https://riff.com', isStaging: false } ]
+ );
+
+ expect( screen.getByText( 'riff.com' ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'heading', { name: 'Live' } ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'heading', { name: 'Preview links' } ) ).toBeInTheDocument();
+
+ await user.click( screen.getAllByRole( 'button', { name: 'Copy link' } )[ 0 ] );
+
+ expect( copyText ).toHaveBeenCalledWith( 'https://riff.com' );
+ } );
+
+ it( 'copies the preview link with its protocol', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click( screen.getByRole( 'button', { name: 'Copy link' } ) );
+
+ expect( copyText ).toHaveBeenCalledWith( 'https://riff-abcde-studio.wp.build' );
+ } );
+
+ it( 'publishes a brand-new preview with no existing hostname', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click( screen.getByRole( 'button', { name: 'New preview' } ) );
+
+ expect( publishMutate ).toHaveBeenCalledWith(
+ { siteId: 'riff', existingHostname: undefined },
+ expect.anything()
+ );
+ } );
+} );
diff --git a/apps/ui/src/components/site-toolbar/share-dialog.tsx b/apps/ui/src/components/site-toolbar/share-dialog.tsx
new file mode 100644
index 0000000000..615a4d0c68
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/share-dialog.tsx
@@ -0,0 +1,306 @@
+import { DAY_MS, DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants';
+import { isSnapshotExpired } from '@studio/common/lib/snapshots';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { copy, Icon, moreVertical } from '@wordpress/icons';
+import { Button, Dialog, IconButton } from '@wordpress/ui';
+import { useMemo, useState } from 'react';
+import * as Menu from '@/components/menu';
+import { showToast } from '@/data/app-messages';
+import { useConnector } from '@/data/core';
+import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
+import { useDeletePreviewSite, usePublishPreviewSite } from '@/data/queries/use-preview-site';
+import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots';
+import styles from './share-dialog.module.css';
+import {
+ ensureProtocol,
+ getConnectionLabel,
+ getSnapshotHostname,
+ sortConnections,
+ stripProtocol,
+} from './utils';
+import type { SiteDetails, Snapshot } from '@/data/core';
+
+function expirySummary( snapshot: Snapshot ): string {
+ if ( isSnapshotExpired( snapshot ) ) {
+ return __( 'Expired' );
+ }
+ const remainingDays = Math.max(
+ 1,
+ Math.ceil( ( snapshot.date + DEMO_SITE_EXPIRATION_DAYS * DAY_MS - Date.now() ) / DAY_MS )
+ );
+ return sprintf(
+ // translators: %d: number of days before a preview link expires.
+ _n( 'Expires in %d day', 'Expires in %d days', remainingDays ),
+ remainingDays
+ );
+}
+
+type Props = {
+ site: SiteDetails;
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+};
+
+// Marks the "new preview" action as the one in flight, since it has no
+// snapshot URL to key on.
+const NEW_PREVIEW = 'new-preview';
+
+/**
+ * Sharing a Studio site: the preview links it has published, where they point,
+ * how long they last, and the controls for refreshing, opening, copying and
+ * retiring each one.
+ *
+ * Anchored to the Share button rather than centred as a modal — publishing a
+ * preview is a small errand off the header, not a task worth blacking the app
+ * out for. Each row keeps only what it needs on the surface (the link, when it
+ * expires, copy); the rest sits in an overflow menu.
+ */
+export function ShareDialog( { site, open, onOpenChange }: Props ) {
+ const connector = useConnector();
+ const { data: snapshots } = useSnapshots();
+ const { data: usage } = useSnapshotUsage();
+ const { data: connectedSites } = useConnectedWpcomSites( site.id );
+ const publishPreviewSite = usePublishPreviewSite();
+ const deletePreviewSite = useDeletePreviewSite();
+ const [ pendingPublish, setPendingPublish ] = useState< string | null >( null );
+ const [ confirmingDelete, setConfirmingDelete ] = useState< string | null >( null );
+
+ const connections = useMemo( () => sortConnections( connectedSites ), [ connectedSites ] );
+
+ const previews = useMemo(
+ () =>
+ ( snapshots ?? [] )
+ .filter( ( snapshot ) => snapshot.localSiteId === site.id )
+ .sort( ( a, b ) => b.date - a.date ),
+ [ snapshots, site.id ]
+ );
+
+ const openExternal = ( url: string ) => {
+ void connector.openExternalUrl( ensureProtocol( url ) );
+ };
+
+ const copyLink = ( url: string ) => {
+ void connector
+ .copyText( ensureProtocol( url ) )
+ .then( () => showToast( { id: 'preview-link-copied', title: __( 'Preview link copied' ) } ) )
+ .catch( ( error ) => {
+ showToast( {
+ intent: 'error',
+ title: __( 'Failed to copy preview link' ),
+ description: error instanceof Error ? error.message : String( error ),
+ } );
+ } );
+ };
+
+ const publish = ( existing?: Snapshot ) => {
+ setPendingPublish( existing ? existing.url : NEW_PREVIEW );
+ publishPreviewSite.mutate(
+ {
+ siteId: site.id,
+ // The CLI cannot update an expired preview site — create a new one.
+ existingHostname:
+ existing && ! isSnapshotExpired( existing ) ? getSnapshotHostname( existing ) : undefined,
+ },
+ {
+ onSuccess: ( { url } ) => openExternal( url ),
+ onSettled: () => setPendingPublish( null ),
+ }
+ );
+ };
+
+ const isPublishing = publishPreviewSite.isPending;
+
+ return (
+ {
+ if ( ! next ) {
+ setConfirmingDelete( null );
+ }
+ onOpenChange( next );
+ } }
+ >
+
+
+ { __( 'Share this site' ) }
+
+
+
+ { connections.length > 0 ? (
+
+ { __( 'Live' ) }
+
+ { connections.map( ( connection ) => (
+
+ openExternal( connection.url ) }
+ >
+ { stripProtocol( connection.url ) }
+
+
+
{ getConnectionLabel( connection ) }
+
+ copyLink( connection.url ) }
+ />
+
+
+
+ ) ) }
+
+
+ ) : null }
+
+
+ { __( 'Preview links' ) }
+
+ { __( 'Temporary copies of this site, for sharing work before it goes live.' ) }
+
+
+ { previews.length === 0 ? (
+ { __( 'No preview links yet.' ) }
+ ) : (
+
+ { previews.map( ( snapshot ) => {
+ const hostname = getSnapshotHostname( snapshot );
+ const isDeleting =
+ deletePreviewSite.isPending &&
+ deletePreviewSite.variables?.hostname === hostname;
+ const isConfirming = confirmingDelete === snapshot.url;
+ return (
+
+ { /* The full hostname, unwrapped and unabbreviated: telling two
+ previews of the same site apart is the whole job of this
+ line. */ }
+ openExternal( snapshot.url ) }
+ >
+ { hostname }
+
+ { isConfirming ? (
+
+
+ { __( 'This link will stop working immediately.' ) }
+
+
+ setConfirmingDelete( null ) }
+ >
+ { __( 'Cancel' ) }
+
+
+ deletePreviewSite.mutate(
+ { hostname },
+ { onSuccess: () => setConfirmingDelete( null ) }
+ )
+ }
+ >
+ { __( 'Delete' ) }
+
+
+
+ ) : (
+
+
{ expirySummary( snapshot ) }
+
+ copyLink( snapshot.url ) }
+ />
+
+ { /* `IconButton` renders a tooltip provider, not a button,
+ so it can't take the trigger's props — the menu would
+ never open. */ }
+
+ }
+ >
+
+
+
+ openExternal( snapshot.url ) }>
+ { __( 'Open preview' ) }
+
+ publish( snapshot ) }
+ >
+ { isSnapshotExpired( snapshot )
+ ? __( 'Republish' )
+ : __( 'Update with current contents' ) }
+
+
+ setConfirmingDelete( snapshot.url ) }>
+ { __( 'Delete preview link' ) }
+
+
+
+
+
+ ) }
+
+ );
+ } ) }
+
+ ) }
+
+
+
+ { usage ? (
+
+ { sprintf(
+ // translators: 1: preview links used, 2: total allowed.
+ __( '%1$d of %2$d preview links used' ),
+ usage.siteCount,
+ usage.siteLimit
+ ) }
+
+ ) : (
+
+ ) }
+ publish() }
+ >
+ { __( 'New preview' ) }
+
+
+
+
+ );
+}
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..09a8117755
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/style.module.css
@@ -0,0 +1,238 @@
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-gap-md);
+ padding-inline: var(--wpds-dimension-padding-lg);
+ /* 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;
+}
+
+.siteStatusRow {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ line-height: 1.25;
+}
+
+/* Pull the status target back so its dot optically aligns with the site name
+ above rather than with the button's padded edge. Only when the button is
+ there — without it the URL would hang past the name. */
+.siteStatusRowWithButton {
+ margin-inline-start: -4px;
+}
+
+/* Doubled for specificity over the shared button's own 24px sizing: the
+ toolbar sits the control inside a line of text, where a 24px hit area
+ would overlap the site icon on hover. */
+.siteStatusButton.siteStatusButton {
+ flex-basis: 16px;
+ inline-size: 16px;
+ block-size: 16px;
+ -webkit-app-region: no-drag;
+}
+
+.siteUrl,
+.siteUrlStatic {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font-size: var(--wpds-typography-font-size-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.siteUrl {
+ -webkit-app-region: no-drag;
+ cursor: var(--wpds-cursor-control);
+ border-radius: 2px;
+ transition: color 100ms ease;
+}
+
+.siteUrl:hover {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.siteUrl:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand);
+ outline-offset: 2px;
+}
+
+.siteUrlIcon {
+ opacity: 0;
+ transition: opacity 100ms ease;
+}
+
+/* The icon SVGs carry no fill of their own, so they paint black in both colour
+ schemes unless told to follow the text. */
+.siteUrlIcon {
+ fill: currentColor;
+}
+
+.siteUrl:is(:hover, :focus-visible) .siteUrlIcon {
+ opacity: 1;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+ flex: 0 0 auto;
+ /* Pinned to the top of the header rather than centred against the site's
+ two-line identity block, and inset to line up with the preview pane's
+ toolbar buttons. Both panels start at the split frame's top edge; the
+ preview header pads 4px and then centres its controls in a 32px row. */
+ align-self: flex-start;
+ padding-block-start: var(--wpds-dimension-padding-md);
+ -webkit-app-region: no-drag;
+}
+
+/* --- 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;
+}
+
+.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,
+ .siteUrl,
+ .siteUrlIcon {
+ transition: none;
+ }
+
+ .actionLabel {
+ animation: none;
+ }
+}
+
diff --git a/apps/ui/src/components/site-toolbar/sync-dialog.module.css b/apps/ui/src/components/site-toolbar/sync-dialog.module.css
new file mode 100644
index 0000000000..f11fee1dd6
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/sync-dialog.module.css
@@ -0,0 +1,201 @@
+/* 480px: wider than wpds `small` (400) and narrower than `medium` (560).
+ Unlayered, so it beats the size rule in wpds's `wp-ui-components` layer. */
+.popup {
+ max-width: 480px;
+}
+
+/* Segmented control, matching Settings' appearance picker: a sliding
+ indicator behind two equal segments. */
+.directionPicker {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ align-items: center;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-interactive-neutral-weak);
+ line-height: 1;
+}
+
+.directionPicker::before {
+ content: '';
+ position: absolute;
+ inset-block: calc(var(--wpds-border-width-xs) * -1);
+ inset-inline-start: 0;
+ box-sizing: border-box;
+ inline-size: 50%;
+ border: 1px solid var(--wpds-color-stroke-interactive-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-interactive-neutral-weak);
+ pointer-events: none;
+ transform: translateX(
+ calc(var(--direction-active-index, 0) * 100% + var(--direction-edge-shift, 0px))
+ );
+}
+
+/* Physical translateX against a logical inset — flip the sign in RTL. */
+.directionPicker:dir(rtl)::before {
+ transform: translateX(
+ calc(var(--direction-active-index, 0) * -100% - var(--direction-edge-shift, 0px))
+ );
+}
+
+/* At either end, slide the indicator one border-width outward so its border
+ overlays the container's rather than doubling it. */
+.directionPicker[data-active-index='0'] {
+ --direction-active-index: 0;
+ --direction-edge-shift: calc(var(--wpds-border-width-xs) * -1);
+}
+
+.directionPicker[data-active-index='1'] {
+ --direction-active-index: 1;
+ --direction-edge-shift: var(--wpds-border-width-xs);
+}
+
+@media not (prefers-reduced-motion) {
+ .directionPicker::before {
+ transition: transform 180ms cubic-bezier(0.2, 0, 0, 1);
+ }
+}
+
+.directionButton {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--wpds-dimension-gap-xs);
+ min-height: 32px;
+ padding: 0 var(--wpds-dimension-padding-md);
+ border: 0;
+ border-radius: var(--wpds-border-radius-sm);
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font: inherit;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ cursor: var(--wpds-cursor-control);
+}
+
+.directionButton:hover,
+.directionButtonActive {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.directionButton:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.directionIcon {
+ flex: 0 0 auto;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+/* The URL identifies the connection; Production/Staging is a hint under it,
+ because that flag isn't always known when a connection is stored. */
+.destination {
+ min-width: 0;
+ margin-block-end: var(--wpds-dimension-padding-md);
+}
+
+.destinationTrigger {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-sm);
+ inline-size: 100%;
+ min-width: 0;
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: var(--wpds-border-radius-md);
+ background: transparent;
+ text-align: start;
+ cursor: var(--wpds-cursor-control);
+}
+
+.destinationTrigger:hover {
+ background: var(--wpds-color-bg-surface-neutral);
+}
+
+.destinationTrigger:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.destinationTriggerText {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ flex: 1;
+}
+
+.destinationChevron {
+ flex: 0 0 auto;
+ fill: var(--wpds-color-fg-content-neutral-weak);
+ width: 16px;
+ height: 16px;
+}
+
+.destinationStatic {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+/* Wide enough that a connection's URL isn't the thing that wraps. */
+.menu {
+ min-width: 280px;
+ max-width: 420px;
+}
+
+.menuItem {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.destinationUrl {
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ color: var(--wpds-color-fg-content-neutral);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.destinationMeta {
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.consequence {
+ margin: var(--wpds-dimension-gap-sm) 0 var(--wpds-dimension-padding-lg);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.whatToSync {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-gap-xs);
+}
+
+.legend {
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+/* A stable floor so Push and Pull are the same width and the footer doesn't
+ reflow when the direction changes. */
+.run {
+ min-width: 88px;
+ justify-content: center;
+}
diff --git a/apps/ui/src/components/site-toolbar/sync-dialog.tsx b/apps/ui/src/components/site-toolbar/sync-dialog.tsx
new file mode 100644
index 0000000000..08025636f4
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/sync-dialog.tsx
@@ -0,0 +1,259 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { arrowDown, arrowUp, chevronDown, Icon } from '@wordpress/icons';
+import { Button, Dialog } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useCallback, useEffect, useState } from 'react';
+import * as Menu from '@/components/menu';
+import { useConnector } from '@/data/core';
+import styles from './sync-dialog.module.css';
+import { createInitialTree, hasSelection, toPullOptions, toPushOptions } from './sync-selection';
+import { convertRawToTreeNodes, SyncTree, updateNodeById } from './sync-tree';
+import { formatSyncTimestamp, getConnectionLabel, stripProtocol } from './utils';
+import type { TreeNode } from './sync-tree';
+import type { SyncSite } from '@/data/core';
+import type { PullSyncOptions, PushSyncOptions } from '@studio/common/types/sync';
+
+export type SyncDirection = 'push' | 'pull';
+
+type Props = {
+ siteId: string;
+ connections: SyncSite[];
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+ // Which direction the dialog opens on. Defaults to push; onboarding opens it
+ // on pull to nudge a freshly connected site's first pull.
+ initialDirection?: SyncDirection;
+ onRun: (
+ direction: SyncDirection,
+ target: SyncSite,
+ options: PushSyncOptions | PullSyncOptions | undefined
+ ) => void;
+};
+
+// Just the age — "4h", "6d". The direction already says what happened then.
+function lastSyncAge( connection: SyncSite, direction: SyncDirection ): string | null {
+ return formatSyncTimestamp(
+ direction === 'push' ? connection.lastPushTimestamp : connection.lastPullTimestamp
+ );
+}
+
+/** The second line under a connection's URL: what kind it is, and how stale. */
+function describeConnection( connection: SyncSite, direction: SyncDirection ): string {
+ const age = lastSyncAge( connection, direction );
+ return [
+ getConnectionLabel( connection ),
+ age
+ ? sprintf(
+ // translators: %s: compact relative time, e.g. "6d".
+ direction === 'push' ? __( 'pushed %s ago' ) : __( 'pulled %s ago' ),
+ age
+ )
+ : null,
+ ]
+ .filter( Boolean )
+ .join( ' · ' );
+}
+
+/**
+ * One place to answer everything a sync needs: which way it goes, which
+ * connected site it touches, and what it carries.
+ *
+ * Connections are identified by URL rather than by their Production/Staging
+ * label: that label is derived from whether the site's id appears in some other
+ * site's `wpcom_staging_blog_ids`, which isn't always known at the time a
+ * connection is stored, so two connections can both read "Production". The URL
+ * is always right.
+ */
+export function SyncDialog( {
+ siteId,
+ connections,
+ open,
+ onOpenChange,
+ initialDirection = 'push',
+ onRun,
+}: Props ) {
+ const connector = useConnector();
+ const [ direction, setDirection ] = useState< SyncDirection >( initialDirection );
+ const [ targetId, setTargetId ] = useState< number | null >( null );
+ const [ tree, setTree ] = useState< TreeNode[] >( createInitialTree );
+
+ const target = connections.find( ( candidate ) => candidate.id === targetId ) ?? connections[ 0 ];
+
+ // Push browses the local site; pull browses the remote backup. Switching
+ // direction means the tree describes a different filesystem, so start over.
+ useEffect( () => {
+ setTree( createInitialTree() );
+ }, [ direction, targetId ] );
+
+ const expandNode = useCallback(
+ async ( node: TreeNode ) => {
+ const path = node.path ?? 'wp-content';
+ try {
+ if ( direction === 'push' ) {
+ const entries = await connector.listLocalFileTree( siteId, path, 1 );
+ setTree( ( prev ) =>
+ updateNodeById( prev, node.id, {
+ children: convertRawToTreeNodes( entries ),
+ checked: node.checked,
+ } )
+ );
+ return;
+ }
+ if ( ! target ) {
+ return;
+ }
+ const rewindId = await connector.getLatestRewindId( target.id );
+ if ( ! rewindId ) {
+ return;
+ }
+ const contents = await connector.listRemoteFileTree( target.id, rewindId, path );
+ const entries = Object.entries( contents ).map( ( [ name, raw ] ) => {
+ const item = raw as { type?: string; has_children?: boolean; id?: string };
+ const isDirectory = item.type === 'dir' || item.has_children === true;
+ return {
+ name,
+ isDirectory,
+ path: `${ path.replace( /\/$/, '' ) }/${ name }`,
+ };
+ } );
+ setTree( ( prev ) =>
+ updateNodeById( prev, node.id, {
+ children: convertRawToTreeNodes( entries ),
+ checked: node.checked,
+ } )
+ );
+ } catch ( error ) {
+ console.error( 'Failed to list sync tree:', error );
+ setTree( ( prev ) => updateNodeById( prev, node.id, { children: [] } ) );
+ }
+ },
+ [ connector, direction, siteId, target ]
+ );
+
+ const canRun = Boolean( target ) && hasSelection( tree );
+
+ // The label is a hint, not an identifier — see the note above. It sits with
+ // the age so the URL above it stands alone.
+ const destinationMeta = target ? describeConnection( target, direction ) : '';
+
+ return (
+
+
+
+ { __( 'Sync this site' ) }
+
+
+
+ { /* Which site first, then which way — the destination is the
+ thing most easily got wrong. */ }
+
+ { connections.length > 1 ? (
+
+
+
+
+ { target ? stripProtocol( target.url ) : __( 'Choose a site' ) }
+
+ { destinationMeta }
+
+
+
+ }
+ />
+
+ { connections.map( ( connection ) => (
+ setTargetId( connection.id ) }>
+
+
+ { stripProtocol( connection.url ) }
+
+
+ { describeConnection( connection, direction ) }
+
+
+
+ ) ) }
+
+
+ ) : target ? (
+
+ { stripProtocol( target.url ) }
+ { destinationMeta }
+
+ ) : null }
+
+
+
+ { ( [ 'push', 'pull' ] as const ).map( ( option ) => (
+ setDirection( option ) }
+ >
+
+ { option === 'push' ? __( 'Push' ) : __( 'Pull' ) }
+
+ ) ) }
+
+
+
+ { direction === 'push'
+ ? __( 'Replaces the live site with this one.' )
+ : __( 'Replaces this site with the live one.' ) }
+
+
+
+ { __( 'What to sync' ) }
+
+
+
+
+
+ { __( 'Cancel' ) }
+
+ {
+ if ( target && canRun ) {
+ onRun(
+ direction,
+ target,
+ direction === 'push' ? toPushOptions( tree ) : toPullOptions( tree )
+ );
+ onOpenChange( false );
+ }
+ } }
+ >
+ { direction === 'push' ? __( 'Push' ) : __( 'Pull' ) }
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/site-toolbar/sync-selection.ts b/apps/ui/src/components/site-toolbar/sync-selection.ts
new file mode 100644
index 0000000000..87a3c137df
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/sync-selection.ts
@@ -0,0 +1,155 @@
+import { categorizePath } from '@studio/common/lib/sync/tree-utils';
+import { __ } from '@wordpress/i18n';
+import type { TreeNode } from './sync-tree';
+import type { PullSyncOptions, PushSyncOptions, SyncOption } from '@studio/common/types/sync';
+
+// The tree's two roots. Everything a sync can carry is either the database or
+// something under wp-content.
+export const DATABASE_NODE_ID = 'sqls';
+export const FILES_NODE_ID = 'filesAndFolders';
+export const WP_CONTENT_NODE_ID = 'wp-content';
+
+/** The tree as it stands before any directory has been listed. */
+export function createInitialTree(): TreeNode[] {
+ return [
+ {
+ id: DATABASE_NODE_ID,
+ name: DATABASE_NODE_ID,
+ label: __( 'Database' ),
+ checked: true,
+ },
+ {
+ id: FILES_NODE_ID,
+ name: FILES_NODE_ID,
+ label: __( 'Files and folders' ),
+ checked: true,
+ expanded: true,
+ hideExpandButton: true,
+ children: [
+ {
+ id: WP_CONTENT_NODE_ID,
+ name: WP_CONTENT_NODE_ID,
+ label: 'wp-content',
+ checked: true,
+ type: 'folder',
+ expanded: false,
+ children: [],
+ },
+ ],
+ },
+ ];
+}
+
+/**
+ * The nodes a sync should carry: a checked node stands for its whole subtree,
+ * so recursion stops there. A mixed node contributes only its checked
+ * descendants.
+ */
+function collectChecked( nodes: TreeNode[] | undefined ): TreeNode[] {
+ if ( ! nodes?.length ) {
+ return [];
+ }
+ const result: TreeNode[] = [];
+ for ( const node of nodes ) {
+ if ( node.checked ) {
+ result.push( node );
+ } else if ( node.indeterminate && node.children?.length ) {
+ result.push( ...collectChecked( node.children ) );
+ }
+ }
+ return result;
+}
+
+function findNode( nodes: TreeNode[], id: string ): TreeNode | undefined {
+ for ( const node of nodes ) {
+ if ( node.id === id ) {
+ return node;
+ }
+ const found = node.children ? findNode( node.children, id ) : undefined;
+ if ( found ) {
+ return found;
+ }
+ }
+ return undefined;
+}
+
+function roots( tree: TreeNode[] ) {
+ return {
+ database: findNode( tree, DATABASE_NODE_ID ),
+ files: findNode( tree, FILES_NODE_ID ),
+ wpContent: findNode( tree, WP_CONTENT_NODE_ID ),
+ };
+}
+
+/** True when the whole site is selected, which both sides express as `all`. */
+export function isWholeSite( tree: TreeNode[] ): boolean {
+ const { database, files } = roots( tree );
+ return Boolean( database?.checked && files?.checked );
+}
+
+export function hasSelection( tree: TreeNode[] ): boolean {
+ const { database, files } = roots( tree );
+ return Boolean(
+ database?.checked || files?.checked || files?.indeterminate || database?.indeterminate
+ );
+}
+
+/**
+ * Push selects local paths. Each checked path also contributes the category it
+ * falls into, because the export layer decides what to archive from the
+ * categories and then narrows to the paths.
+ */
+export function toPushOptions( tree: TreeNode[] ): PushSyncOptions | undefined {
+ if ( isWholeSite( tree ) ) {
+ return undefined;
+ }
+
+ const { database, wpContent } = roots( tree );
+ const optionsToSync: SyncOption[] = [];
+ let specificSelectionPaths: string[] | undefined;
+
+ if ( database?.checked ) {
+ optionsToSync.push( 'sqls' );
+ }
+
+ const paths = new Set< string >();
+ const categories = new Set< SyncOption >();
+ for ( const node of collectChecked( wpContent?.children ) ) {
+ if ( ! node.path ) {
+ continue;
+ }
+ const relative = node.path.replace( /^\/?wp-content\//, '' );
+ paths.add( relative );
+ categories.add( categorizePath( relative ) );
+ }
+
+ if ( paths.size > 0 ) {
+ optionsToSync.push( ...categories );
+ specificSelectionPaths = [ ...paths ];
+ }
+
+ return { optionsToSync, ...( specificSelectionPaths ? { specificSelectionPaths } : {} ) };
+}
+
+/**
+ * Pull selects remote backup node ids rather than paths, and marks the run
+ * with `paths` so the CLI knows to read the include list.
+ */
+export function toPullOptions( tree: TreeNode[] ): PullSyncOptions | undefined {
+ if ( isWholeSite( tree ) ) {
+ return undefined;
+ }
+
+ const { database, wpContent } = roots( tree );
+ const optionsToSync: SyncOption[] = database?.checked ? [ 'sqls' ] : [];
+ const includePathList = collectChecked( wpContent?.children )
+ .map( ( node ) => node.pathId )
+ .filter( ( pathId ): pathId is string => Boolean( pathId ) );
+
+ if ( includePathList.length > 0 ) {
+ optionsToSync.unshift( 'paths' );
+ return { optionsToSync, includePathList };
+ }
+
+ return { optionsToSync };
+}
diff --git a/apps/ui/src/components/site-toolbar/sync-tree.module.css b/apps/ui/src/components/site-toolbar/sync-tree.module.css
new file mode 100644
index 0000000000..d288f994a7
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/sync-tree.module.css
@@ -0,0 +1,118 @@
+/* Tall enough to browse in, and it scrolls rather than pushing the dialog's
+ footer off screen once wp-content is opened. */
+.tree {
+ block-size: 260px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: var(--wpds-dimension-padding-xs);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: var(--wpds-border-radius-md);
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+ min-block-size: 28px;
+}
+
+.twisty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ inline-size: 20px;
+ block-size: 20px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--wpds-border-radius-sm);
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ cursor: var(--wpds-cursor-control);
+}
+
+.twisty:hover {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.twisty:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+}
+
+/* Keeps the row's text aligned with its siblings when a node can't be opened,
+ rather than letting leaves slide back under their folder's checkbox. */
+.twistyHidden {
+ visibility: hidden;
+}
+
+.twistyIcon {
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+@media not (prefers-reduced-motion) {
+ .twistyIcon {
+ transition: transform 120ms ease;
+ }
+}
+
+.twistyIconOpen {
+ transform: rotate(90deg);
+}
+
+.label {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+ min-width: 0;
+ flex: 1;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ color: var(--wpds-color-fg-content-neutral);
+ cursor: var(--wpds-cursor-control);
+}
+
+.labelDisabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.checkbox {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+.nodeIcon {
+ flex: 0 0 auto;
+ fill: var(--wpds-color-fg-content-neutral-weak);
+ width: 16px;
+ height: 16px;
+}
+
+.labelText {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.spinner {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+/* Indent one level, with a rule marking the branch the children belong to. */
+.group {
+ margin-inline-start: 10px;
+ padding-inline-start: var(--wpds-dimension-padding-md);
+ border-inline-start: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.empty {
+ margin: 0;
+ padding: var(--wpds-dimension-padding-xs) 0;
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
diff --git a/apps/ui/src/components/site-toolbar/sync-tree.tsx b/apps/ui/src/components/site-toolbar/sync-tree.tsx
new file mode 100644
index 0000000000..2e8c836425
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/sync-tree.tsx
@@ -0,0 +1,303 @@
+import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils';
+import { Spinner } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { brush, chevronRight, Icon, page, plugins, file as folder } from '@wordpress/icons';
+import { clsx } from 'clsx';
+import { useEffect, useRef } from 'react';
+import styles from './sync-tree.module.css';
+import type { RawDirectoryEntry } from '@studio/common/types/sync-tree';
+import type { Dispatch, SetStateAction } from 'react';
+
+export type TreeNodeType = 'folder' | 'file' | 'plugin' | 'theme';
+
+export type TreeNode = {
+ id: string;
+ name: string;
+ label: string;
+ checked: boolean;
+ indeterminate?: boolean;
+ expanded?: boolean;
+ hideExpandButton?: boolean;
+ children?: TreeNode[];
+ type?: TreeNodeType;
+ loading?: boolean;
+ // Remote backup node id, used by pull's `includePathList`.
+ pathId?: string;
+ // Path relative to the site root, used by push's `specificSelectionPaths`.
+ path?: string;
+};
+
+const NODE_ICONS: Record< TreeNodeType, typeof folder > = {
+ folder,
+ file: page,
+ plugin: plugins,
+ theme: brush,
+};
+
+/**
+ * Applies a patch to a node and reconciles the tri-state of everything around
+ * it: checking a folder checks its whole subtree, and every ancestor becomes
+ * checked, indeterminate, or clear depending on what its children ended up as.
+ */
+function updateNode( node: TreeNode, patch: Partial< TreeNode > ): TreeNode {
+ const updated = { ...node, ...patch };
+
+ if ( updated.children && updated.children.length > 0 ) {
+ updated.children = updated.children.map( ( child ) =>
+ 'checked' in patch ? updateNode( child, { checked: patch.checked } ) : child
+ );
+ const checkedCount = updated.children.filter( ( child ) => child.checked ).length;
+ updated.checked = checkedCount === updated.children.length;
+ updated.indeterminate = checkedCount > 0 && checkedCount < updated.children.length;
+ }
+
+ return updated;
+}
+
+export function updateNodeById(
+ nodes: TreeNode[],
+ id: string,
+ patch: Partial< TreeNode >
+): TreeNode[] {
+ return nodes.map( ( node ) => {
+ if ( node.id === id ) {
+ return updateNode( node, patch );
+ }
+ if ( node.children && node.children.length > 0 ) {
+ const children = updateNodeById( node.children, id, patch );
+ const checkedCount = children.filter( ( child ) => child.checked ).length;
+ return {
+ ...node,
+ checked: checkedCount === children.length,
+ indeterminate:
+ ( checkedCount > 0 && checkedCount < children.length ) ||
+ children.some( ( child ) => child.indeterminate ),
+ children,
+ };
+ }
+ return node;
+ } );
+}
+
+/** Turns a directory listing into tree nodes, folders first then alphabetical. */
+export function convertRawToTreeNodes( rawNodes: RawDirectoryEntry[] ): TreeNode[] {
+ const pluginPath = /^plugins\/[^/]+$/;
+ const themePath = /^themes\/[^/]+$/;
+
+ return rawNodes
+ .map( ( raw ): TreeNode => {
+ let type: TreeNodeType = raw.isDirectory ? 'folder' : 'file';
+ if ( raw.isDirectory ) {
+ const relative = raw.path.replace( /^wp-content\//, '' );
+ if ( pluginPath.test( relative ) ) {
+ type = 'plugin';
+ } else if ( themePath.test( relative ) ) {
+ type = 'theme';
+ }
+ }
+
+ return {
+ id: `local-${ raw.path.replace( /[^a-zA-Z0-9/]/g, '-' ) }`,
+ name: raw.name,
+ label: raw.name,
+ checked: false,
+ type,
+ path: raw.path,
+ pathId: raw.path,
+ children: raw.children
+ ? convertRawToTreeNodes( raw.children )
+ : raw.isDirectory
+ ? []
+ : undefined,
+ expanded: false,
+ // A plugin or theme syncs whole; there's nothing useful to pick
+ // inside one, so don't offer to open it.
+ hideExpandButton: shouldLimitDepth( raw.path ),
+ };
+ } )
+ .sort( ( a, b ) => {
+ if ( a.type !== b.type ) {
+ const order = { folder: 0, plugin: 1, theme: 2, file: 3 };
+ return order[ a.type as TreeNodeType ] - order[ b.type as TreeNodeType ];
+ }
+ return a.name.toLowerCase().localeCompare( b.name.toLowerCase() );
+ } );
+}
+
+/** Native checkbox so the indeterminate state can be set on the DOM node. */
+function TriStateCheckbox( {
+ checked,
+ indeterminate,
+ disabled,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ indeterminate?: boolean;
+ disabled?: boolean;
+ onChange: ( checked: boolean ) => void;
+ label: string;
+} ) {
+ const ref = useRef< HTMLInputElement >( null );
+
+ useEffect( () => {
+ if ( ref.current ) {
+ ref.current.indeterminate = Boolean( indeterminate ) && ! checked;
+ }
+ }, [ checked, indeterminate ] );
+
+ return (
+ onChange( event.target.checked ) }
+ />
+ );
+}
+
+function TreeItem( {
+ node,
+ level,
+ index,
+ siblingCount,
+ disabled,
+ onPatch,
+ onExpand,
+}: {
+ node: TreeNode;
+ level: number;
+ index: number;
+ siblingCount: number;
+ disabled?: boolean;
+ onPatch: ( id: string, patch: Partial< TreeNode > ) => void;
+ onExpand?: ( node: TreeNode ) => Promise< void >;
+} ) {
+ const expanded = node.expanded ?? true;
+ const canExpand = Boolean( node.children ) && ! node.hideExpandButton;
+
+ return (
+
+
+ {
+ if ( ! canExpand ) {
+ return;
+ }
+ // Children are fetched the first time a folder opens, not
+ // up front: a site's wp-content is far too big to walk.
+ if ( ! expanded && onExpand && node.children?.length === 0 ) {
+ onPatch( node.id, { loading: true } );
+ try {
+ await onExpand( node );
+ } finally {
+ onPatch( node.id, { loading: false } );
+ }
+ }
+ onPatch( node.id, { expanded: ! expanded } );
+ } }
+ >
+
+
+
+
+ onPatch( node.id, { checked } ) }
+ />
+ { node.type ? (
+
+ ) : null }
+ { node.label }
+
+
+ { node.loading ? : null }
+
+
+ { expanded && node.children ? (
+
+ { node.children.length === 0 ? (
+
{ node.loading ? __( 'Loading…' ) : __( 'Empty' ) }
+ ) : (
+ node.children.map( ( child, childIndex ) => (
+
+ ) )
+ ) }
+
+ ) : null }
+
+ );
+}
+
+/**
+ * The file tree behind "What to sync". Folders load their contents the first
+ * time they're opened, and a folder's checkbox reflects its subtree — checked,
+ * clear, or mixed.
+ */
+export function SyncTree( {
+ tree,
+ setTree,
+ onExpand,
+ disabled,
+}: {
+ tree: TreeNode[];
+ setTree: Dispatch< SetStateAction< TreeNode[] > >;
+ onExpand?: ( node: TreeNode ) => Promise< void >;
+ disabled?: boolean;
+} ) {
+ return (
+
+ { tree.map( ( node, index ) => (
+ setTree( ( prev ) => updateNodeById( prev, id, patch ) ) }
+ onExpand={ onExpand }
+ />
+ ) ) }
+
+ );
+}
diff --git a/apps/ui/src/components/site-dropdown/utils.test.ts b/apps/ui/src/components/site-toolbar/utils.test.ts
similarity index 94%
rename from apps/ui/src/components/site-dropdown/utils.test.ts
rename to apps/ui/src/components/site-toolbar/utils.test.ts
index ca71c84296..365e2eeb29 100644
--- a/apps/ui/src/components/site-dropdown/utils.test.ts
+++ b/apps/ui/src/components/site-toolbar/utils.test.ts
@@ -52,8 +52,8 @@ describe( 'deriveSiteStatus', () => {
} );
} );
-// Shared by the site dropdown's toggle and the sidebar's status button, so the
-// two can't drift on how a busy site is described.
+// Shared by the site toolbar and sidebar status controls so they cannot drift
+// on how a busy site is described.
describe( 'getSiteStatusName', () => {
const base = { running: false, starting: false, stopping: false, operation: null };
diff --git a/apps/ui/src/components/site-dropdown/utils.ts b/apps/ui/src/components/site-toolbar/utils.ts
similarity index 70%
rename from apps/ui/src/components/site-dropdown/utils.ts
rename to apps/ui/src/components/site-toolbar/utils.ts
index ad0bc046d9..b92b9e5af9 100644
--- a/apps/ui/src/components/site-dropdown/utils.ts
+++ b/apps/ui/src/components/site-toolbar/utils.ts
@@ -1,10 +1,37 @@
import { type SiteOperationKind } from '@studio/common/lib/site-operation';
import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels';
import { __, sprintf } from '@wordpress/i18n';
+import { formatRelativeTime } from '@/lib/format-relative-time';
import { getSiteDisplayUrl } from '@/lib/get-site-url';
-import type { SiteStatus } from './dropdown-trigger';
+import type { SiteRunStatus } from '@/components/site-status-button';
import type { SiteDetails, Snapshot, SyncSite } from '@/data/core';
+const MINUTE_MS = 60_000;
+
+/**
+ * The shortest readable age: "3s", "4m", "2h", "6d". Seconds matter here — a
+ * sync is often checked moments after it lands, and "just now" holds for a
+ * whole minute. Returns null for timestamps we can't read.
+ */
+export function formatSyncTimestamp( isoTimestamp: string | null | undefined ): string | null {
+ if ( ! isoTimestamp ) {
+ return null;
+ }
+ const timestampMs = Date.parse( isoTimestamp );
+ if ( ! Number.isFinite( timestampMs ) ) {
+ return null;
+ }
+ const elapsedMs = Math.max( 0, Date.now() - timestampMs );
+ if ( elapsedMs < MINUTE_MS ) {
+ return sprintf(
+ // translators: %d: number of seconds, compact relative time (e.g. "3s").
+ __( '%ds' ),
+ Math.max( 1, Math.floor( elapsedMs / 1000 ) )
+ );
+ }
+ return formatRelativeTime( new Date( timestampMs ).toISOString() ) || null;
+}
+
export function stripProtocol( url: string ): string {
return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' );
}
@@ -13,6 +40,26 @@ export function ensureProtocol( url: string ): string {
return /^https?:\/\//.test( url ) ? url : `https://${ url }`;
}
+/**
+ * Connections in the order a picker should list them: production first, then
+ * staging, each group alphabetical so the list doesn't reshuffle between
+ * fetches.
+ */
+export function sortConnections( connectedSites: SyncSite[] | undefined ): SyncSite[] {
+ return [ ...( connectedSites ?? [] ) ].sort( ( a, b ) => {
+ if ( a.isStaging !== b.isStaging ) {
+ return a.isStaging ? 1 : -1;
+ }
+ return a.name.localeCompare( b.name );
+ } );
+}
+
+/** What to call a connection in a list where its sibling is right beside it. */
+export function getConnectionLabel( connectedSite: SyncSite ): string {
+ return connectedSite.isStaging ? __( 'Staging' ) : __( 'Production' );
+}
+
+/** The single connection the header's sync and disconnect actions target. */
export function pickLiveSite( connectedSites: SyncSite[] | undefined ): SyncSite | undefined {
if ( ! connectedSites || connectedSites.length === 0 ) {
return undefined;
@@ -84,7 +131,7 @@ function getStatus(
isStarting: boolean,
isStopping: boolean,
operation: SiteOperationKind | null
-): SiteStatus {
+): SiteRunStatus {
if ( operation || isStarting || isStopping ) {
return 'transitioning';
}
@@ -93,7 +140,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 +159,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 {
@@ -127,7 +174,7 @@ function getLocalSublabel(
}
// Derives the running/transitioning/stopped status plus the user-visible
-// labels for the local-site row, so the dropdown consumes it in one line.
+// labels for local-site controls.
export function deriveSiteStatus(
site: SiteDetails,
isStarting: boolean,
@@ -136,7 +183,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/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts
index b8e7390228..4da95778df 100644
--- a/apps/ui/src/data/core/connectors/hosted/index.ts
+++ b/apps/ui/src/data/core/connectors/hosted/index.ts
@@ -252,6 +252,9 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
async deleteAllSnapshots() {
// No-op: hosted mode does not create WordPress.com preview sites.
},
+ async deletePreviewSite(): Promise< void > {
+ throw new UnsupportedError( 'deletePreviewSite' );
+ },
async publishPreviewSite(): Promise< { url: string } > {
throw new UnsupportedError( 'publishPreviewSite' );
},
diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts
index c5f68d5161..4cfdbb078d 100644
--- a/apps/ui/src/data/core/connectors/ipc/index.ts
+++ b/apps/ui/src/data/core/connectors/ipc/index.ts
@@ -214,6 +214,44 @@ export function createIpcConnector(): Connector {
} );
}
+ // Like awaitSnapshotOperation, but for commands that report no URL (delete):
+ // resolves on the matching success event, rejects on a fatal error.
+ function awaitSnapshotCompletion( operationId: string ): Promise< void > {
+ return new Promise( ( resolve, reject ) => {
+ const unsubscribes: Array< () => void > = [];
+ const cleanup = () => {
+ for ( const unsubscribe of unsubscribes ) {
+ unsubscribe();
+ }
+ };
+
+ unsubscribes.push(
+ ipcListener.subscribe(
+ 'snapshot-success',
+ ( _event: unknown, payload: { operationId: string } ) => {
+ if ( payload.operationId !== operationId ) {
+ return;
+ }
+ cleanup();
+ resolve();
+ }
+ )
+ );
+ unsubscribes.push(
+ ipcListener.subscribe(
+ 'snapshot-fatal-error',
+ ( _event: unknown, payload: { operationId: string; data: { message: string } } ) => {
+ if ( payload.operationId !== operationId ) {
+ return;
+ }
+ cleanup();
+ reject( new Error( payload.data.message ) );
+ }
+ )
+ );
+ } );
+ }
+
return {
async init() {
// Install the application menu (View > Toggle DevTools, etc.).
@@ -536,6 +574,13 @@ export function createIpcConnector(): Connector {
await ipcApi.deleteAllSnapshots();
},
+ async deletePreviewSite( hostname ): Promise< void > {
+ const { operationId } = ( await ipcApi.deleteSnapshot( hostname ) ) as {
+ operationId: string;
+ };
+ await awaitSnapshotCompletion( operationId );
+ },
+
async publishPreviewSite( siteId, existingHostname ): Promise< { url: string } > {
const siteFolder = await resolveSiteFolder( siteId );
// Reuses the desktop app's `createSnapshot`/`updateSnapshot` IPC
diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts
index c8d5f1dc10..d9f85c9b33 100644
--- a/apps/ui/src/data/core/connectors/local/index.ts
+++ b/apps/ui/src/data/core/connectors/local/index.ts
@@ -226,6 +226,26 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C
} );
}
+ // Resolve when a snapshot command that reports no URL (delete) finishes,
+ // correlating the SSE stream by operationId.
+ function awaitSnapshotCompletion( operationId: string ): Promise< void > {
+ return new Promise( ( resolve, reject ) => {
+ const listener = ( output: SnapshotSseOutput ) => {
+ if ( output.operationId !== operationId ) {
+ return;
+ }
+ if ( output.kind === 'success' ) {
+ snapshotListeners.delete( listener );
+ resolve();
+ } else if ( output.kind === 'fatal-error' ) {
+ snapshotListeners.delete( listener );
+ reject( new Error( output.data.message ) );
+ }
+ };
+ snapshotListeners.add( listener );
+ } );
+ }
+
return {
async init() {
// The browser's EventSource reconnects automatically.
@@ -553,6 +573,13 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C
async deleteAllSnapshots() {
// No-op: the local server has no delete-all route yet.
},
+ async deletePreviewSite( hostname ): Promise< void > {
+ const { operationId } = await api< { operationId: string } >(
+ `/snapshots/${ encodeURIComponent( hostname ) }`,
+ { method: 'DELETE' }
+ );
+ await awaitSnapshotCompletion( operationId );
+ },
async publishPreviewSite( siteId, existingHostname ): Promise< { url: string } > {
// A hostname means "refresh this preview"; otherwise create a new one.
// The server returns an operationId; progress + the final URL arrive on
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index 1e1634af81..bcbd1bc1b9 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -284,6 +284,8 @@ export interface Connector {
// quota source) so callers can fall back to static copy.
getStudioAssistantQuota(): Promise< StudioAssistantQuota | null >;
deleteAllSnapshots(): Promise< void >;
+ // Delete a single WordPress.com preview snapshot by its hostname.
+ deletePreviewSite( hostname: string ): Promise< void >;
// Asks the user to confirm deleting every preview site on their account.
// Resolves `true` only when they explicitly confirm.
confirmDeleteAllPreviewSites(): Promise< boolean >;
diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts
index 523388efa0..efb5267638 100644
--- a/apps/ui/src/data/queries/use-preview-site.ts
+++ b/apps/ui/src/data/queries/use-preview-site.ts
@@ -11,8 +11,8 @@ type PublishPreviewVariables = {
};
// 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();
@@ -34,3 +34,20 @@ export function usePublishPreviewSite() {
},
} );
}
+
+// Deletes a single WordPress.com-hosted preview by its hostname and refreshes
+// the snapshot list.
+export function useDeletePreviewSite() {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: ( { hostname }: { hostname: string } ) => connector.deletePreviewSite( hostname ),
+ onSuccess: () => {
+ void queryClient.invalidateQueries( { queryKey: SNAPSHOTS_QUERY_KEY } );
+ },
+ onError: ( error ) => {
+ const message = error instanceof Error ? error.message : String( error );
+ toast.error( message || __( 'Failed to delete preview link' ) );
+ },
+ } );
+}
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 feb32dd72c..6a65ff0c91 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' ) }
+
>
) }
-
);
}
diff --git a/apps/ui/src/ui-classic/components/session-view/style.module.css b/apps/ui/src/ui-classic/components/session-view/style.module.css
index 4e1b1a0a5e..2292fb4b77 100644
--- a/apps/ui/src/ui-classic/components/session-view/style.module.css
+++ b/apps/ui/src/ui-classic/components/session-view/style.module.css
@@ -42,7 +42,9 @@
align-items: center;
gap: var(--wpds-dimension-padding-sm);
padding-block: var(--wpds-dimension-padding-sm);
- padding-inline: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-2xl);
+ /* The toolbar owns its own inline padding (it pins its actions to the panel
+ edge); the host only pads the left for the no-site fallback. */
+ padding-inline: var(--wpds-dimension-padding-sm) 0;
min-height: 46px;
font-size: var(--wpds-typography-font-size-sm);
color: var(--wpds-color-fg-content-neutral);
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
index 303ff4b779..9413b7f1ee 100644
--- a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
@@ -6,6 +6,11 @@ import { Badge, Button, Icon } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AuthActions } from '@/components/auth-actions';
+import {
+ presentRemoteSites,
+ searchRemoteSites,
+ type ConnectSiteGroup,
+} from '@/components/connect-site-picker/site-presentation';
import { OnboardingFooter } from '@/components/onboarding-footer';
import { toast } from '@/data/app-messages';
import { useConnector } from '@/data/core';
@@ -19,7 +24,6 @@ import { getLocalizedLink } from '@/lib/docs-links';
import { onboardingLayoutRoute, useOnboardingProgress } from '../layout-onboarding';
import sharedStyles from '../layout-onboarding/style.module.css';
import { ConnectSiteLifecycleError, runConnectSiteLifecycle } from './connect-site';
-import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation';
import styles from './style.module.css';
import type { SyncSite } from '@/data/core';
diff --git a/apps/ui/src/ui-classic/router/route-site-overview/index.tsx b/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
index 546c5b387f..c1aa1231af 100644
--- a/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
@@ -39,7 +39,7 @@ function SiteOverviewPage() {
{
void connector.trackEvent( TRACKS_EVENTS.PANEL_OPENED, {
panel: siteSettingsTabToPanel( next ),