Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/ui/src/components/app-toasts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ export function AppToasts( {
onMouseEnter={ () => pauseToastExpiry( item.id ) }
onMouseLeave={ () => resumeToastExpiry( item.id ) }
>
<Notice.Root intent={ item.intent } className={ styles.notice }>
{ /* 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. */ }
<Notice.Root
key={ `${ item.intent }:${ !! item.description }:${ !! item.action }` }
intent={ item.intent }
className={ styles.notice }
>
<Notice.Title>{ item.title }</Notice.Title>
{ item.description ? (
<Notice.Description>{ item.description }</Notice.Description>
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/data/app-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
19 changes: 17 additions & 2 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
},

Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/data/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
ProposedSitePath,
PullSiteProgress,
PullSyncOptions,
PushSiteProgress,
PushSyncOptions,
QuitSitesBehavior,
SelectedSiteFolder,
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/data/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Snapshot } from '@studio/common/types/snapshot';
import type {
PullSiteProgress,
PullSyncOptions,
PushSiteProgress,
PushSyncOptions,
SyncSite,
} from '@studio/common/types/sync';
Expand Down Expand Up @@ -43,6 +44,7 @@ export type { Snapshot } from '@studio/common/types/snapshot';
export type {
PullSiteProgress,
PullSyncOptions,
PushSiteProgress,
PushSyncOptions,
SyncSite,
} from '@studio/common/types/sync';
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions apps/ui/src/data/queries/use-preview-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' ) } );
},
} );
}
Expand Down
13 changes: 9 additions & 4 deletions apps/ui/src/data/queries/use-sync-site.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 );
Expand Down Expand Up @@ -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,
} );
} );
Expand Down
26 changes: 20 additions & 6 deletions apps/ui/src/data/queries/use-sync-site.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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" ) } );
},
} );
}
Expand Down Expand Up @@ -86,27 +94,33 @@ 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' );
// The CLI may have stopped/started the server during the import,
// 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,
} );
},
} );
}
145 changes: 145 additions & 0 deletions apps/ui/src/data/sync-toasts.ts
Original file line number Diff line number Diff line change
@@ -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 } );
}
Loading