diff --git a/apps/cli/commands/pull.ts b/apps/cli/commands/pull.ts index fd7ec65069..2f9c16b86e 100644 --- a/apps/cli/commands/pull.ts +++ b/apps/cli/commands/pull.ts @@ -6,6 +6,7 @@ import { addConnectedWpcomSite, markConnectedWpcomSiteSynced, } from '@studio/common/lib/connected-sites'; +import { formatProgressLabel } from '@studio/common/lib/progress-label'; import { readAuthToken } from '@studio/common/lib/shared-config'; import { SYNC_MAX_STALLED_ATTEMPTS, @@ -104,7 +105,7 @@ export async function runCommand( // Pull progress: Backup (0-50%) → Download (50-80%) → Import (80-100%) logger.reportStart( LoggerAction.INITIATE_BACKUP, - sprintf( __( 'Initializing remote backup… (%d%%)' ), 0 ) + formatProgressLabel( __( 'Initializing remote backup…' ), 0 ) ); const backupId = await initiateBackup( token.accessToken, remoteSite.id, { optionsToSync, @@ -137,7 +138,9 @@ export async function runCommand( // Backup phase: 0-50% const backupProgress = Math.round( status.percent * 0.5 ); - logger.reportProgress( sprintf( __( 'Creating remote backup… (%d%%)' ), backupProgress ) ); + logger.reportProgress( + formatProgressLabel( __( 'Creating remote backup…' ), backupProgress ) + ); await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLL_INTERVAL_MS ) ); } @@ -165,7 +168,7 @@ export async function runCommand( } // Download phase: 50-80% - logger.reportProgress( sprintf( __( 'Downloading backup… (%d%%)' ), 50 ) ); + logger.reportProgress( formatProgressLabel( __( 'Downloading backup…' ), 50 ) ); const tempDir = await fs.promises.mkdtemp( path.join( os.tmpdir(), 'studio-sync' ) ); try { diff --git a/apps/cli/commands/push.ts b/apps/cli/commands/push.ts index bad6972053..842ef4bcbc 100644 --- a/apps/cli/commands/push.ts +++ b/apps/cli/commands/push.ts @@ -6,6 +6,7 @@ import { markConnectedWpcomSiteSynced, } from '@studio/common/lib/connected-sites'; import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; +import { formatProgressLabel } from '@studio/common/lib/progress-label'; import { readAuthToken } from '@studio/common/lib/shared-config'; import { SYNC_IGNORE_DEFAULTS, @@ -155,7 +156,10 @@ export async function runCommand( return ( originalEmit as ( ...a: any[] ) => boolean )( event, ...args ); }; - logger.reportStart( LoggerAction.UPLOAD, sprintf( __( 'Uploading archive… (%d%%)' ), 20 ) ); + logger.reportStart( + LoggerAction.UPLOAD, + formatProgressLabel( __( 'Uploading archive…' ), 20 ) + ); const { promise: uploadPromise, abort: abortUpload } = createTusUpload( { token: token.accessToken, remoteSiteId: remoteSite.id, @@ -163,7 +167,7 @@ export async function runCommand( onProgress: ( percent ) => { // Upload phase: 20-40% const progress = Math.round( 20 + percent * 0.2 ); - logger.reportProgress( sprintf( __( 'Uploading archive… (%d%%)' ), progress ) ); + logger.reportProgress( formatProgressLabel( __( 'Uploading archive…' ), progress ) ); }, } ); @@ -190,7 +194,7 @@ export async function runCommand( } // Initiate import: 40% - logger.reportProgress( sprintf( __( 'Initiating import… (%d%%)' ), 40 ) ); + logger.reportProgress( formatProgressLabel( __( 'Initiating import…' ), 40 ) ); await initiateImport( token.accessToken, remoteSite.id, attachmentId, { optionsToSync, specificSelectionPaths, @@ -244,7 +248,7 @@ export async function runCommand( stalledAttempts++; } - logger.reportProgress( sprintf( '%s (%d%%)', statusMessage, roundedProgress ) ); + logger.reportProgress( formatProgressLabel( statusMessage, roundedProgress ) ); await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLL_INTERVAL_MS ) ); } diff --git a/apps/ui/src/components/app-toasts/style.module.css b/apps/ui/src/components/app-toasts/style.module.css index a29ced9d59..2be7e2e0da 100644 --- a/apps/ui/src/components/app-toasts/style.module.css +++ b/apps/ui/src/components/app-toasts/style.module.css @@ -57,6 +57,10 @@ width: 100%; box-sizing: border-box; text-wrap: pretty; + /* Toasts rewrite themselves in place — import progress ticks through a + percentage — and this font's digits are not equal width, so a counter + would shift the text by a few pixels on nearly every update. */ + font-variant-numeric: tabular-nums; --wp-ui-notice-background-color: var(--wpds-color-bg-surface-neutral-strong); --wp-ui-notice-border-color: transparent; --wp-ui-notice-text-color: var(--wpds-color-fg-content-neutral); diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx new file mode 100644 index 0000000000..4b09bb7b55 --- /dev/null +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -0,0 +1,162 @@ +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; +import { getErrorMessage } from '@studio/common/lib/error-formatting'; +import { getImportStatusMessage } from '@studio/common/lib/import-progress'; +import { __, sprintf } from '@wordpress/i18n'; +import { AlertDialog } from '@wordpress/ui'; +import { useState } from 'react'; +import { toast } from '@/data/app-messages'; +import { useConnector } from '@/data/core'; +import { useImportSite } from '@/data/queries/use-import-site'; +import { + reportSyncError, + reportSyncPending, + reportSyncProgress, + reportSyncSuccess, + useSiteSyncActivity, +} from '@/data/sync-activity'; +import styles from './style.module.css'; +import type { SiteDetails } from '@/data/core'; + +export const IMPORT_FILE_ACCEPT = ACCEPTED_IMPORT_FILE_TYPES.join( ',' ); + +// `confirming` is tracked alongside the file rather than derived from it because +// the popup stays mounted through its closing animation — dropping the file to +// close would shrink the dialog mid-fade. +interface PendingImport { + siteId: string; + file: File; + confirming: boolean; +} + +export function useSiteBackupImport( site: SiteDetails ) { + const connector = useConnector(); + const importSite = useImportSite(); + // Everything here is stamped with a site id: the overview stays mounted when + // the user switches sites (the route only swaps the `$siteId` param), so a + // plain boolean would follow them and light up the next site's Import button. + const [ pending, setPending ] = useState< PendingImport | null >( null ); + + // The activity store is keyed by site and lives outside React, so progress + // survives navigating away and shows on whichever surface renders this site. + const activity = useSiteSyncActivity( site.id ); + + const active = pending?.siteId === site.id ? pending : null; + const isImporting = activity?.kind === 'pending' && activity.direction === 'import'; + + const selectFile = ( picked?: File ) => { + if ( ! picked ) { + return; + } + // The input's `accept` filter is advisory — a drag or an "All files" + // pick can still hand us something unsupported. + if ( ! isSupportedBackupFilename( picked.name ) ) { + toast.error( + __( + 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, .sql, or .xml file.' + ) + ); + return; + } + setPending( { siteId: site.id, file: picked, confirming: true } ); + }; + + const closeDialog = () => + setPending( ( current ) => + current?.siteId === site.id ? { ...current, confirming: false } : current + ); + + const confirm = async () => { + const file = active?.file; + if ( ! file || isImporting ) { + return; + } + const { id: siteId } = site; + closeDialog(); + reportSyncPending( siteId, 'import' ); + // Extraction reports progress once per stream chunk, so a large backup + // fires thousands of events a second. Only report when the rendered text + // actually changes — otherwise the store notifies its subscribers that + // fast and the app stops responding to clicks. + let lastMessage = ''; + try { + const backupPath = await connector.getFilePath( file ); + if ( ! backupPath ) { + throw new Error( __( 'Unable to access the selected backup. Please try again.' ) ); + } + await importSite.mutateAsync( { + siteId, + backupPath, + onProgress: ( event ) => { + const message = getImportStatusMessage( event ); + if ( message && message !== lastMessage ) { + lastMessage = message; + reportSyncProgress( siteId, 'import', { message } ); + } + }, + } ); + reportSyncSuccess( siteId, 'import' ); + } catch ( error ) { + // Matches push/pull: the activity store carries the detail on the site + // itself, and a toast says so wherever the user has navigated to. + const message = + getErrorMessage( error ) ?? __( 'Failed to import the backup. Please try again.' ); + reportSyncError( siteId, 'import', message ); + toast.error( __( "Import didn't complete" ), { description: message } ); + } finally { + // Drop the File so a large backup isn't held in memory for the session. + setPending( ( current ) => ( current?.siteId === siteId ? null : current ) ); + } + }; + + return { + file: active?.file ?? null, + isConfirming: active?.confirming ?? false, + selectFile, + cancel: closeDialog, + confirm, + isImporting, + }; +} + +interface ImportSiteDialogProps { + site: SiteDetails; + file: File | null; + open: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function ImportSiteDialog( { + site, + file, + open, + onCancel, + onConfirm, +}: ImportSiteDialogProps ) { + return ( + { + if ( ! next ) { + onCancel(); + } + } } + // Returns synchronously so the dialog closes and the import runs in the + // background — an async handler would hold it open for the whole import. + onConfirm={ onConfirm } + > + { /* Deliberately not `intent="irreversible"`: the importer moves the site's + existing wp-content and database to the trash, not straight to deletion. */ } + + { file ?

{ file.name }

: null } +
+
+ ); +} diff --git a/apps/ui/src/components/import-site-dialog/style.module.css b/apps/ui/src/components/import-site-dialog/style.module.css new file mode 100644 index 0000000000..5f03ebe65a --- /dev/null +++ b/apps/ui/src/components/import-site-dialog/style.module.css @@ -0,0 +1,7 @@ +.fileName { + margin: var(--wpds-dimension-padding-sm) 0 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); + overflow-wrap: anywhere; +} diff --git a/apps/ui/src/components/site-dropdown/main-view.module.css b/apps/ui/src/components/site-dropdown/main-view.module.css index f32c9de582..ca37c42dfa 100644 --- a/apps/ui/src/components/site-dropdown/main-view.module.css +++ b/apps/ui/src/components/site-dropdown/main-view.module.css @@ -45,6 +45,9 @@ 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 { diff --git a/apps/ui/src/components/site-dropdown/main-view.test.tsx b/apps/ui/src/components/site-dropdown/main-view.test.tsx index e634b9f2b9..c1fc18b062 100644 --- a/apps/ui/src/components/site-dropdown/main-view.test.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.test.tsx @@ -212,13 +212,32 @@ describe( 'MainView', () => { activity: { kind: 'pending', direction: 'pull', - message: 'Creating remote backup… (24%)', + message: '24% · Creating remote backup…', progress: 24, }, } ); expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Pulling from live…' ); - expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Creating remote backup… (24%)' ); + 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( 'updates the existing preview site while the snapshot is fresh', () => { diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx index 425a1ca2b9..8e6f22dd7f 100644 --- a/apps/ui/src/components/site-dropdown/main-view.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.tsx @@ -141,8 +141,11 @@ export function MainView( { const { push: isPushPending, pull: isPullPending } = useIsSiteSyncing( site.id ); const isPreviewPending = publishPreviewSite.isPending; // Preview / push / pull all mutate the same local site; running them - // concurrently would wedge the site runtime. - const isSyncing = isPreviewPending || isPushPending || isPullPending; + // 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. @@ -485,7 +488,10 @@ function SyncActivityDetails( { >
{ getSyncActivityLabel( activity ) }
- { activity.message ?? __( 'Preparing the live site…' ) } + { activity.message ?? + ( activity.direction === 'import' + ? __( 'Preparing the backup…' ) + : __( 'Preparing the live site…' ) ) }
); diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.ts index 658935bfed..d3ca87d9c9 100644 --- a/apps/ui/src/components/site-dropdown/trigger-secondary.ts +++ b/apps/ui/src/components/site-dropdown/trigger-secondary.ts @@ -25,6 +25,9 @@ export function getSyncActivityLabel( activity: SyncActivity ): string { if ( activity.direction === 'preview' ) { return __( 'Publishing preview…' ); } + if ( activity.direction === 'import' ) { + return __( 'Importing backup…' ); + } return activity.direction === 'push' ? __( 'Pushing to live…' ) : __( 'Pulling from live…' ); } @@ -32,12 +35,18 @@ export function getSyncActivityLabel( activity: SyncActivity ): string { 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.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' ); diff --git a/apps/ui/src/components/site-list/index.test.tsx b/apps/ui/src/components/site-list/index.test.tsx index 6017249cae..0d4987a92e 100644 --- a/apps/ui/src/components/site-list/index.test.tsx +++ b/apps/ui/src/components/site-list/index.test.tsx @@ -699,6 +699,25 @@ describe( 'SiteList', () => { ); } ); + // Without a per-row indicator there is nothing to tell two concurrent imports + // apart — the toast that used to carry this named no site. + it( 'shows activity on the importing row only', () => { + useSiteAgentActivityMock.mockReturnValue( 'idle' ); + useSiteSyncActivityMock.mockImplementation( ( siteId ) => + siteId === 'running-site' ? { kind: 'pending', direction: 'import' } : null + ); + + render( ); + + const importingRow = screen.getByText( 'Running Site' ).closest( 'section' )!; + const otherRow = screen.getByText( 'Stopped Site' ).closest( 'section' )!; + + expect( + within( importingRow ).getByRole( 'status', { name: 'Importing backup' } ) + ).toBeInTheDocument(); + expect( within( otherRow ).queryByRole( 'status' ) ).not.toBeInTheDocument(); + } ); + it( 'shows live sync activity before the site name while a site is syncing', () => { useSiteAgentActivityMock.mockReturnValue( 'working' ); useSiteSyncActivityMock.mockImplementation( ( siteId ) => diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx index ac19f7762c..5a6675798c 100644 --- a/apps/ui/src/components/site-list/index.tsx +++ b/apps/ui/src/components/site-list/index.tsx @@ -56,7 +56,7 @@ type SiteRow = { sessionIds: string[]; }; -type SiteRowActivity = SiteAgentActivity | 'new-message' | 'sync'; +type SiteRowActivity = SiteAgentActivity | 'new-message' | 'sync' | 'import'; const ACTIVITY_EXIT_DURATION_MS = 180; @@ -118,6 +118,7 @@ function SiteAgentActivityIndicator( { activity }: { activity: SiteRowActivity } const pendingQuestionAriaLabel = __( 'Studio needs an answer.' ); const newMessageLabel = __( 'New message' ); const syncLabel = __( 'Syncing live site' ); + const importLabel = __( 'Importing backup' ); return ( ) : null } - { renderedActivity === 'sync' ? ( - + { renderedActivity === 'sync' || renderedActivity === 'import' ? ( +