diff --git a/apps/ui/src/components/app-toasts/index.tsx b/apps/ui/src/components/app-toasts/index.tsx index 7de7111811..5acd5e8460 100644 --- a/apps/ui/src/components/app-toasts/index.tsx +++ b/apps/ui/src/components/app-toasts/index.tsx @@ -50,7 +50,15 @@ export function AppToasts( { onMouseEnter={ () => pauseToastExpiry( item.id ) } onMouseLeave={ () => resumeToastExpiry( item.id ) } > - + { /* Keyed on the notice's shape, not just its id: a toast + replaced in place can gain or lose a description (a running + sync becoming its result), and reusing the same Notice + across that change tears its internal hooks. */ } + { item.title } { item.description ? ( { item.description } diff --git a/apps/ui/src/data/app-messages.ts b/apps/ui/src/data/app-messages.ts index b4ea396b92..3d7e809eb4 100644 --- a/apps/ui/src/data/app-messages.ts +++ b/apps/ui/src/data/app-messages.ts @@ -73,6 +73,12 @@ function scheduleExpiry( toast: ToastMessage ) { if ( ! rendererMounted ) { return; } + // A non-positive duration means the toast stays until it's explicitly + // replaced or dismissed — used by the running sync toasts, which live for + // the whole operation and are swapped for their result at the end. + if ( toast.durationMs <= 0 ) { + return; + } const timer = setTimeout( () => { timers.delete( toast.id ); beginToastExit( toast.id ); diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 83de3a26d2..0f8eae3069 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -589,12 +589,27 @@ export function createIpcConnector(): Connector { ); }, - async pushSiteToLive( siteId, remoteSiteId, options ): Promise< void > { + async pushSiteToLive( siteId, remoteSiteId, options, onProgress ): Promise< void > { // The agentic UI pushes via the shared `pushSite` (export → TUS // upload → import) in both desktop and `studio ui`; the desktop runs // it behind this single IPC handler. Resolves once the import is // initiated (the remote import may still be running). - await ipcApi.pushSiteToLive( siteId, remoteSiteId, options ); + // The only progress a push reports is the upload byte fraction. + const unsubscribe = onProgress + ? ipcListener.subscribe( + 'sync-upload-progress', + ( _event: unknown, payload: { selectedSiteId: string; progress: number } ) => { + if ( payload.selectedSiteId === siteId ) { + onProgress( { phase: 'uploading', progress: payload.progress } ); + } + } + ) + : undefined; + try { + await ipcApi.pushSiteToLive( siteId, remoteSiteId, options ); + } finally { + unsubscribe?.(); + } await markConnectedWpcomSiteSynced( siteId, remoteSiteId, 'push' ); }, diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts index 51ad95b766..bb01c30f7a 100644 --- a/apps/ui/src/data/core/index.ts +++ b/apps/ui/src/data/core/index.ts @@ -16,6 +16,7 @@ export type { ProposedSitePath, PullSiteProgress, PullSyncOptions, + PushSiteProgress, PushSyncOptions, QuitSitesBehavior, SelectedSiteFolder, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index c239196cc4..3c19bac8ef 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -16,6 +16,7 @@ import type { Snapshot } from '@studio/common/types/snapshot'; import type { PullSiteProgress, PullSyncOptions, + PushSiteProgress, PushSyncOptions, SyncSite, } from '@studio/common/types/sync'; @@ -43,6 +44,7 @@ export type { Snapshot } from '@studio/common/types/snapshot'; export type { PullSiteProgress, PullSyncOptions, + PushSiteProgress, PushSyncOptions, SyncSite, } from '@studio/common/types/sync'; @@ -285,7 +287,8 @@ export interface Connector { pushSiteToLive( siteId: string, remoteSiteId: number, - options?: PushSyncOptions + options?: PushSyncOptions, + onProgress?: ( progress: PushSiteProgress ) => void ): Promise< void >; // Pulls the connected WordPress.com site's database + wp-content back // into the local Studio site, or only the selection described by diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts index efb5267638..bf07dcc0bf 100644 --- a/apps/ui/src/data/queries/use-preview-site.ts +++ b/apps/ui/src/data/queries/use-preview-site.ts @@ -4,6 +4,7 @@ import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; import { SNAPSHOTS_QUERY_KEY } from '@/data/queries/use-snapshots'; import { reportSyncError, reportSyncPending, reportSyncSuccess } from '@/data/sync-activity'; +import { finishSyncToast, startSyncToast } from '@/data/sync-toasts'; type PublishPreviewVariables = { siteId: string; @@ -21,16 +22,17 @@ export function usePublishPreviewSite() { connector.publishPreviewSite( siteId, existingHostname ), onMutate: ( { siteId } ) => { reportSyncPending( siteId, 'preview' ); + startSyncToast( siteId, 'preview' ); }, onSuccess: ( _result, { siteId } ) => { reportSyncSuccess( siteId, 'preview' ); void queryClient.invalidateQueries( { queryKey: SNAPSHOTS_QUERY_KEY } ); - toast.success( __( 'Preview site published' ) ); + finishSyncToast( siteId, { intent: 'success', title: __( 'Preview link published' ) } ); }, onError: ( error, { siteId } ) => { const message = error instanceof Error ? error.message : String( error ); reportSyncError( siteId, 'preview', message ); - toast.error( __( 'Failed to publish preview site' ) ); + finishSyncToast( siteId, { intent: 'error', title: __( 'Failed to publish preview link' ) } ); }, } ); } diff --git a/apps/ui/src/data/queries/use-sync-site.test.tsx b/apps/ui/src/data/queries/use-sync-site.test.tsx index a18a65ad44..55873751ec 100644 --- a/apps/ui/src/data/queries/use-sync-site.test.tsx +++ b/apps/ui/src/data/queries/use-sync-site.test.tsx @@ -1,9 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; import { useSiteSyncActivity } from '@/data/sync-activity'; +import { finishSyncToast } from '@/data/sync-toasts'; import { usePullSiteFromLive } from './use-sync-site'; import type { Connector } from '@/data/core'; @@ -12,8 +12,11 @@ vi.mock( '@/data/core', async ( importOriginal ) => { return { ...actual, useConnector: vi.fn() }; } ); -vi.mock( '@/data/app-messages', () => ( { - toast: { success: vi.fn(), error: vi.fn() }, +vi.mock( '@/data/sync-toasts', () => ( { + startSyncToast: vi.fn(), + updatePullToast: vi.fn(), + updatePushToast: vi.fn(), + finishSyncToast: vi.fn(), } ) ); const useConnectorMock = vi.mocked( useConnector ); @@ -95,7 +98,9 @@ describe( 'usePullSiteFromLive', () => { "Studio couldn't copy the live site. Try again. If the problem continues, check Studio Logs for details."; await waitFor( () => expect( screen.getByText( message ) ).toBeVisible() ); expect( screen.queryByText( /Error invoking remote method/ ) ).not.toBeInTheDocument(); - expect( toast.error ).toHaveBeenCalledWith( "Pull didn't complete", { + expect( finishSyncToast ).toHaveBeenCalledWith( 'site-1', { + intent: 'error', + title: "Pull didn't complete", description: message, } ); } ); diff --git a/apps/ui/src/data/queries/use-sync-site.ts b/apps/ui/src/data/queries/use-sync-site.ts index 79b6ae1d30..fde3a4e0bc 100644 --- a/apps/ui/src/data/queries/use-sync-site.ts +++ b/apps/ui/src/data/queries/use-sync-site.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { __ } from '@wordpress/i18n'; -import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites'; import { SITES_QUERY_KEY } from '@/data/queries/use-sites'; @@ -10,6 +9,12 @@ import { reportSyncProgress, reportSyncSuccess, } from '@/data/sync-activity'; +import { + finishSyncToast, + startSyncToast, + updatePullToast, + updatePushToast, +} from '@/data/sync-toasts'; import type { PullSiteProgress, PullSyncOptions, PushSyncOptions } from '@/data/core'; // Mutation keys are exported so downstream consumers (e.g. a cross-page @@ -30,21 +35,24 @@ export function usePushSiteToLive() { return useMutation( { mutationKey: PUSH_TO_LIVE_MUTATION_KEY, mutationFn: ( { siteId, remoteSiteId, options }: PushToLiveVariables ) => - connector.pushSiteToLive( siteId, remoteSiteId, options ), + connector.pushSiteToLive( siteId, remoteSiteId, options, ( progress ) => + updatePushToast( siteId, progress ) + ), onMutate: ( { siteId } ) => { reportSyncPending( siteId, 'push' ); + startSyncToast( siteId, 'push' ); }, onSuccess: ( _result, { siteId } ) => { reportSyncSuccess( siteId, 'push' ); void queryClient.invalidateQueries( { queryKey: connectedWpcomSitesQueryKey( siteId ), } ); - toast.success( __( 'Push complete' ) ); + finishSyncToast( siteId, { intent: 'success', title: __( 'Push complete' ) } ); }, onError: ( error, { siteId } ) => { const message = error instanceof Error ? error.message : String( error ); reportSyncError( siteId, 'push', message ); - toast.error( __( "Push didn't complete" ) ); + finishSyncToast( siteId, { intent: 'error', title: __( "Push didn't complete" ) } ); }, } ); } @@ -86,12 +94,14 @@ export function usePullSiteFromLive() { remoteSiteId, ( progress ) => { reportSyncProgress( siteId, 'pull', progress ); + updatePullToast( siteId, progress ); onProgress?.( progress ); }, options ), onMutate: ( { siteId } ) => { reportSyncPending( siteId, 'pull' ); + startSyncToast( siteId, 'pull' ); }, onSuccess: ( _result, { siteId } ) => { reportSyncSuccess( siteId, 'pull' ); @@ -99,14 +109,18 @@ export function usePullSiteFromLive() { // and the site's database + themes just changed — refresh the // site list so any downstream consumers see the new state. void queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); - toast.success( __( 'Pull complete' ) ); + finishSyncToast( siteId, { intent: 'success', title: __( 'Pull complete' ) } ); }, onError: ( _error, { siteId } ) => { const message = __( "Studio couldn't copy the live site. Try again. If the problem continues, check Studio Logs for details." ); reportSyncError( siteId, 'pull', message ); - toast.error( __( "Pull didn't complete" ), { description: message } ); + finishSyncToast( siteId, { + intent: 'error', + title: __( "Pull didn't complete" ), + description: message, + } ); }, } ); } diff --git a/apps/ui/src/data/sync-toasts.ts b/apps/ui/src/data/sync-toasts.ts new file mode 100644 index 0000000000..b6d880b2bd --- /dev/null +++ b/apps/ui/src/data/sync-toasts.ts @@ -0,0 +1,145 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { showToast } from '@/data/app-messages'; +import type { PullSiteProgress, PushSiteProgress } from '@/data/core'; +import type { SyncDirection } from '@/data/sync-activity'; + +// A push or pull can run for minutes, and the site header only has room to +// spin. The running toast is where the detail goes — it stays pinned open, +// rewrites itself as the work moves through its phases, and is finally +// replaced in place by the result, so one message covers the whole operation. + +function toastId( siteId: string ): string { + return `sync-${ siteId }`; +} + +// Push reports its upload byte-fraction many times a second; re-rendering the +// toast (and its aria-live announcer) that often thrashes. Coalesce progress +// updates to a steady cadence, always landing on the latest value. +const PROGRESS_THROTTLE_MS = 300; + +type ThrottleState = { last: number; timer?: ReturnType< typeof setTimeout > }; +const throttles = new Map< string, ThrottleState >(); + +function throttleProgress( siteId: string, run: () => void ): void { + const state = throttles.get( siteId ) ?? { last: 0 }; + const elapsed = Date.now() - state.last; + if ( state.timer ) { + clearTimeout( state.timer ); + state.timer = undefined; + } + if ( elapsed >= PROGRESS_THROTTLE_MS ) { + state.last = Date.now(); + throttles.set( siteId, state ); + run(); + return; + } + state.timer = setTimeout( () => { + state.last = Date.now(); + state.timer = undefined; + run(); + }, PROGRESS_THROTTLE_MS - elapsed ); + throttles.set( siteId, state ); +} + +// Cancels a pending throttled update so a stale progress frame can't land after +// the toast has already opened or been resolved. +function clearProgressThrottle( siteId: string ): void { + const state = throttles.get( siteId ); + if ( state?.timer ) { + clearTimeout( state.timer ); + } + throttles.delete( siteId ); +} + +function runningTitle( direction: SyncDirection ): string { + switch ( direction ) { + case 'pull': + return __( 'Pulling from live' ); + case 'preview': + return __( 'Publishing preview link' ); + default: + return __( 'Pushing to live' ); + } +} + +/** Opens the running toast, before any progress has been reported. */ +export function startSyncToast( siteId: string, direction: SyncDirection ): void { + clearProgressThrottle( siteId ); + showToast( { + id: toastId( siteId ), + intent: 'info', + title: runningTitle( direction ), + description: direction === 'push' ? __( 'Preparing your site' ) : undefined, + durationMs: 0, + } ); +} + +/** + * Push describes itself by phase rather than by a backend string, so the copy + * stays translatable and consistent with the rest of the app. + */ +export function updatePushToast( siteId: string, progress: PushSiteProgress ): void { + const description = ( () => { + if ( progress.phase === 'uploading' ) { + return progress.progress === undefined + ? __( 'Uploading…' ) + : sprintf( + // translators: %d: upload progress percentage. + __( 'Uploading… %d%%' ), + Math.round( progress.progress ) + ); + } + if ( progress.phase === 'paused' ) { + return __( 'Upload paused — waiting for the network' ); + } + if ( progress.phase === 'importing' ) { + return __( 'Applying changes on WordPress.com' ); + } + return __( 'Preparing your site' ); + } )(); + + throttleProgress( siteId, () => + showToast( { + id: toastId( siteId ), + intent: 'info', + title: runningTitle( 'push' ), + description, + durationMs: 0, + } ) + ); +} + +/** Pull has no phases of its own — the CLI narrates it. */ +export function updatePullToast( siteId: string, progress: PullSiteProgress ): void { + const description = + progress.progress === undefined + ? progress.message + : sprintf( + // translators: 1: what the pull is doing, 2: percentage complete. + __( '%1$s (%2$d%%)' ), + progress.message, + Math.round( progress.progress ) + ); + + throttleProgress( siteId, () => + showToast( { + id: toastId( siteId ), + intent: 'info', + title: runningTitle( 'pull' ), + description, + durationMs: 0, + } ) + ); +} + +/** + * Replaces the running toast with its outcome, in place, so the result appears + * where the user was already watching rather than as a second message. + */ +export function finishSyncToast( + siteId: string, + outcome: { intent: 'success' | 'error'; title: string; description?: string } +): void { + clearProgressThrottle( siteId ); + showToast( { id: toastId( siteId ), ...outcome } ); +} diff --git a/packages/common/types/sync.ts b/packages/common/types/sync.ts index f62da2c2ca..cd6e570cb3 100644 --- a/packages/common/types/sync.ts +++ b/packages/common/types/sync.ts @@ -99,6 +99,19 @@ export type PullSiteProgress = { progress?: number; }; +// A push moves through fixed phases; unlike pull it has no server-reported +// message, so the phase names the step. `progress` is the byte fraction of the +// upload. +export const pushSitePhases = [ 'exporting', 'uploading', 'paused', 'importing' ] as const; + +export type PushSitePhase = ( typeof pushSitePhases )[ number ]; + +export type PushSiteProgress = { + phase: PushSitePhase; + // 0–100, present during `uploading` and `paused`. + progress?: number; +}; + // Pull backup API schemas export const pullSiteResponseSchema = z.object( { success: z.boolean(),