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' ? (
+
@@ -542,11 +546,18 @@ function SiteSection( {
const { status } = deriveSiteStatus( site, isStarting, isStopping, useSiteOperation( site ) );
const agentActivity = useSiteAgentActivity( row.sessionIds );
const syncActivity = useSiteSyncActivity( site.id );
- const isLiveSyncPending =
- syncActivity?.kind === 'pending' &&
- ( syncActivity.direction === 'push' || syncActivity.direction === 'pull' );
- const displayActivity = isLiveSyncPending
- ? 'sync'
+ // Import gets a row indicator of its own alongside push/pull: it is the only
+ // way to tell which site a long-running import belongs to when several are in
+ // flight, and it is a local operation, so it doesn't read as "syncing".
+ const pendingDirection = syncActivity?.kind === 'pending' ? syncActivity.direction : undefined;
+ const siteActivity =
+ pendingDirection === 'import'
+ ? 'import'
+ : pendingDirection === 'push' || pendingDirection === 'pull'
+ ? 'sync'
+ : undefined;
+ const displayActivity = siteActivity
+ ? siteActivity
: agentActivity !== 'idle'
? agentActivity
: hasUnreadUpdate
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 4b2c011477..02144fe8e2 100644
--- a/apps/ui/src/components/site-overview-view/index.test.tsx
+++ b/apps/ui/src/components/site-overview-view/index.test.tsx
@@ -1,4 +1,6 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { BackupExtractEvents } from '@studio/common/lib/import-export-events';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { Tooltip } from '@wordpress/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
@@ -30,9 +32,12 @@ import type {
SupportedEditor,
UserPreferences,
} from '@/data/core';
+import type { ImportEventTuple } from '@studio/common/lib/import-export-events';
const navigateMock = vi.fn();
const siteDropdownMock = vi.hoisted( () => vi.fn() );
+const importSiteFromBackup = vi.hoisted( () => vi.fn() );
+const reportSyncProgressMock = vi.hoisted( () => vi.fn() );
const useSidebarCollapsedMock = vi.hoisted( () => vi.fn() );
const useTrafficLightSpaceMock = vi.hoisted( () => vi.fn() );
@@ -86,6 +91,7 @@ vi.mock( '@/data/queries/use-create-site-helpers', () => ( {
} ) );
vi.mock( '@/data/queries/use-sites', () => ( {
+ SITES_QUERY_KEY: [ 'sites' ],
COPY_SITE_MUTATION_KEY: [ 'copySite' ],
EXPORT_DATABASE_MUTATION_KEY: [ 'exportDatabase' ],
EXPORT_FULL_SITE_MUTATION_KEY: [ 'exportFullSite' ],
@@ -103,10 +109,12 @@ vi.mock( '@/data/queries/use-sites', () => ( {
} ) );
vi.mock( '@/data/queries/use-site-thumbnail', () => ( {
+ siteThumbnailQueryKey: ( siteId: string ) => [ 'site-thumbnail', siteId ],
useSiteThumbnail: vi.fn(),
} ) );
vi.mock( '@/data/queries/use-site-storage-usage', () => ( {
+ siteStorageUsageQueryKey: ( siteId: string ) => [ 'site-storage-usage', siteId ],
useSiteStorageUsage: vi.fn(),
} ) );
@@ -115,6 +123,7 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( {
} ) );
vi.mock( '@/data/queries/use-wordpress-versions', () => ( {
+ WP_VERSION_QUERY_KEY: [ 'wp-version' ],
useWordPressVersions: vi.fn(),
useWpVersion: vi.fn(),
} ) );
@@ -123,6 +132,11 @@ vi.mock( '@/hooks/use-offline', () => ( {
useOffline: vi.fn(),
} ) );
+vi.mock( '@/data/sync-activity', async ( importOriginal ) => ( {
+ ...( await importOriginal< typeof import('@/data/sync-activity') >() ),
+ reportSyncProgress: reportSyncProgressMock,
+} ) );
+
vi.mock( '@/hooks/use-sidebar-collapsed', () => ( {
useSidebarCollapsed: useSidebarCollapsedMock,
} ) );
@@ -164,20 +178,30 @@ describe( 'SiteOverviewView', () => {
const exportDatabase = vi.fn();
const onTabChange = vi.fn();
+ const getFilePath = vi.fn().mockResolvedValue( '/tmp/backup.tar.gz' );
+
const connectorStub = ( openInOS = true ) => ( {
openSiteUrl,
openSiteFolder,
openSiteInEditor,
openSiteInTerminal,
trackEvent,
+ getFilePath,
+ importSiteFromBackup,
capabilities: { openInOS } as ConnectorCapabilities,
} );
const preferencesStub = ( editor: SupportedEditor | null ) =>
( { editor, terminal: 'terminal' } ) as UserPreferences;
+ let queryClient: QueryClient;
+
beforeEach( () => {
vi.clearAllMocks();
+ queryClient = new QueryClient( {
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ } );
+ importSiteFromBackup.mockResolvedValue( undefined );
useSidebarCollapsedMock.mockReturnValue( false );
useTrafficLightSpaceMock.mockReturnValue( { start: false, end: false } );
vi.stubGlobal( 'ResizeObserver', ResizeObserverMock );
@@ -243,18 +267,38 @@ describe( 'SiteOverviewView', () => {
function renderView(
activeTab: 'overview' | 'general' | 'debugging' = 'overview',
- openSiteDropdown = false
+ openSiteDropdown = false,
+ siteId = 'site-1'
) {
- return render(
-
-
-
+ const view = (
+
+
+
+
+
);
+ const rendered = render( view );
+ return {
+ ...rendered,
+ showSite: ( nextSiteId: string ) =>
+ rendered.rerender(
+
+
+
+
+
+ ),
+ };
}
it( 'renders the tab strip with the about, customize, and manage sections', () => {
@@ -274,6 +318,7 @@ describe( 'SiteOverviewView', () => {
expect( screen.getByText( 'Media Library' ) ).toBeVisible();
expect( screen.queryByText( 'Customizer' ) ).not.toBeInTheDocument();
expect( screen.getByText( 'Duplicate' ) ).toBeVisible();
+ expect( screen.getByText( 'Import' ) ).toBeVisible();
expect( screen.getByText( 'Export entire site' ) ).toBeVisible();
expect( screen.getByText( 'Export database' ) ).toBeVisible();
expect( screen.getByText( 'Delete' ) ).toBeVisible();
@@ -454,7 +499,7 @@ describe( 'SiteOverviewView', () => {
isLoading: false,
} );
- const { rerender } = renderView( 'general' );
+ const { showSite } = renderView( 'general' );
fireEvent.change( screen.getByLabelText( 'WordPress version' ), {
target: { value: '6.7.2' },
@@ -465,11 +510,7 @@ describe( 'SiteOverviewView', () => {
data: [ createSite( { running: false, isWpAutoUpdating: false } ) ],
isLoading: false,
} );
- rerender(
-
-
-
- );
+ showSite( 'site-1' );
expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( '6.7.2' );
} );
@@ -672,6 +713,144 @@ describe( 'SiteOverviewView', () => {
expect( screen.getByRole( 'dialog' ) ).toBeVisible();
} );
+ function selectBackup( name: string ) {
+ const input = screen.getByTestId( 'import-backup-file' );
+ Object.defineProperty( input, 'files', {
+ configurable: true,
+ value: [ new File( [ 'backup' ], name ) ],
+ } );
+ fireEvent.change( input );
+ }
+
+ it( 'imports a backup after confirming the overwrite', async () => {
+ renderView();
+
+ selectBackup( 'demo-site.tar.gz' );
+
+ const dialog = screen.getByRole( 'alertdialog' );
+ expect( dialog ).toHaveTextContent( 'Overwrite Demo Site?' );
+ expect( dialog ).toHaveTextContent( 'demo-site.tar.gz' );
+ fireEvent.click( within( dialog ).getByRole( 'button', { name: 'Import' } ) );
+
+ await waitFor( () =>
+ expect( importSiteFromBackup ).toHaveBeenCalledWith(
+ 'site-1',
+ '/tmp/backup.tar.gz',
+ expect.any( Function )
+ )
+ );
+ } );
+
+ // An import replaces the site wholesale, so everything read off it is stale.
+ // Disk usage caches for five minutes and the overview never unmounts, so
+ // without an explicit invalidation it keeps showing pre-import numbers.
+ it( 'refetches the site details an import invalidates', async () => {
+ const staleKeys = [
+ [ 'sites' ],
+ [ 'wp-version', 'site-1' ],
+ [ 'site-storage-usage', 'site-1' ],
+ [ 'site-thumbnail', 'site-1' ],
+ ];
+ renderView();
+ staleKeys.forEach( ( key ) => queryClient.setQueryData( key, 'before-import' ) );
+
+ selectBackup( 'demo-site.tar.gz' );
+ fireEvent.click(
+ within( screen.getByRole( 'alertdialog' ) ).getByRole( 'button', { name: 'Import' } )
+ );
+
+ await waitFor( () =>
+ staleKeys.forEach( ( key ) =>
+ expect( queryClient.getQueryState( key )?.isInvalidated ).toBe( true )
+ )
+ );
+ } );
+
+ it( 'rejects an unsupported backup file without opening the overwrite dialog', () => {
+ renderView();
+
+ selectBackup( 'notes.txt' );
+
+ expect( screen.queryByRole( 'alertdialog' ) ).not.toBeInTheDocument();
+ expect( importSiteFromBackup ).not.toHaveBeenCalled();
+ } );
+
+ // The buttons mark themselves with `aria-disabled` rather than the native
+ // attribute, so they stay focusable.
+ function isManageButtonDisabled( label: string ) {
+ const heading = screen.getByRole( 'heading', { name: 'Manage' } );
+ const button = within( heading.closest( 'section' )! ).getByText( label ).closest( 'button' )!;
+ return button.getAttribute( 'aria-disabled' ) === 'true';
+ }
+
+ // The overview stays mounted across sites — only the `$siteId` route param
+ // changes — so import progress must be tracked per site, not per component.
+ it( 'keeps the import indicator on the importing site when switching sites', async () => {
+ useSitesMock.mockReturnValue( {
+ data: [
+ createSite( { running: true } ),
+ createSite( { id: 'site-2', name: 'Other Site', running: true } ),
+ ],
+ isLoading: false,
+ } );
+ let finishImport = () => {};
+ importSiteFromBackup.mockReturnValue(
+ new Promise< void >( ( resolve ) => {
+ finishImport = resolve;
+ } )
+ );
+ const { showSite } = renderView();
+
+ selectBackup( 'demo-site.tar.gz' );
+ fireEvent.click(
+ within( screen.getByRole( 'alertdialog' ) ).getByRole( 'button', { name: 'Import' } )
+ );
+ await waitFor( () => expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( true ) );
+
+ showSite( 'site-2' );
+
+ expect( isManageButtonDisabled( 'Import' ) ).toBe( false );
+ expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( false );
+
+ showSite( 'site-1' );
+
+ expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( true );
+
+ // Settle it so the shared activity store doesn't stay pending for site-1
+ // and bleed into the tests that follow.
+ finishImport();
+ await waitFor( () => expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( false ) );
+ } );
+
+ // Extraction emits one progress event per stream chunk, so a large backup
+ // would otherwise notify every activity subscriber thousands of times a
+ // second and the app stops responding to clicks.
+ it( 'only reports progress when the status text changes', async () => {
+ let emitProgress: ( ( event: ImportEventTuple ) => void ) | undefined;
+ importSiteFromBackup.mockImplementation( async ( _siteId, _path, onProgress ) => {
+ emitProgress = onProgress;
+ } );
+ renderView();
+
+ selectBackup( 'demo-site.tar.gz' );
+ fireEvent.click(
+ within( screen.getByRole( 'alertdialog' ) ).getByRole( 'button', { name: 'Import' } )
+ );
+ await waitFor( () => expect( emitProgress ).toBeDefined() );
+
+ for ( let processedFiles = 1; processedFiles <= 500; processedFiles++ ) {
+ emitProgress?.( [
+ BackupExtractEvents.BACKUP_EXTRACT_PROGRESS,
+ { processedFiles: processedFiles <= 250 ? 1 : 2, totalFiles: 10 },
+ ] as ImportEventTuple );
+ }
+
+ expect( reportSyncProgressMock.mock.calls ).toEqual( [
+ [ 'site-1', 'import', { message: '10% · Extracting…' } ],
+ [ 'site-1', 'import', { message: '20% · Extracting…' } ],
+ ] );
+ } );
+
it( 'shows a sign-in banner with a login action when signed out', () => {
const loginMutate = vi.fn();
useAgenticFeaturesMock.mockReturnValue( {
diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx
index cc15b51280..a526f32afe 100644
--- a/apps/ui/src/components/site-overview-view/index.tsx
+++ b/apps/ui/src/components/site-overview-view/index.tsx
@@ -17,9 +17,14 @@ import {
widget,
} from '@wordpress/icons';
import { Button } from '@wordpress/ui';
-import { useState } from 'react';
+import { useRef, useState } from 'react';
import { AgenticSigninBanner } from '@/components/agentic-signin-banner';
import { DeleteSiteDialog } from '@/components/delete-site-dialog';
+import {
+ ImportSiteDialog,
+ IMPORT_FILE_ACCEPT,
+ useSiteBackupImport,
+} from '@/components/import-site-dialog';
import { OfflineBanner } from '@/components/offline-banner';
import { useOpenInDestinations } from '@/components/open-in-menu/use-open-in-destinations';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
@@ -231,8 +236,11 @@ function SiteOverviewBody( {
const navigate = useNavigate();
const connector = useConnector();
const [ deleteOpen, setDeleteOpen ] = useState( false );
+ const importInputRef = useRef< HTMLInputElement >( null );
+ const backupImport = useSiteBackupImport( site );
const managementActions = useSiteManagementActions( site, {
onDelete: () => setDeleteOpen( true ),
+ onImport: () => importInputRef.current?.click(),
} );
const busy = useIsSiteBusy( site );
@@ -414,6 +422,25 @@ function SiteOverviewBody( {
+ {
+ backupImport.selectFile( event.target.files?.[ 0 ] );
+ // Lets the same file be picked again after a cancel or a failure.
+ event.target.value = '';
+ } }
+ />
+ void backupImport.confirm() }
+ />
;
// Imports a backup into an already-created site and starts the usable site.
- // `backupPath` comes from `getFilePath` for the current submission.
+ // `backupPath` comes from `getFilePath` for the currently selected file.
importSiteFromBackup(
siteId: string,
backupPath: string,
diff --git a/apps/ui/src/data/queries/use-import-site.ts b/apps/ui/src/data/queries/use-import-site.ts
index 4db7151606..6f284bc914 100644
--- a/apps/ui/src/data/queries/use-import-site.ts
+++ b/apps/ui/src/data/queries/use-import-site.ts
@@ -2,7 +2,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { __ } from '@wordpress/i18n';
import { toast } from '@/data/app-messages';
import { useConnector } from '@/data/core';
+import { siteStorageUsageQueryKey } from './use-site-storage-usage';
+import { siteThumbnailQueryKey } from './use-site-thumbnail';
import { SITES_QUERY_KEY } from './use-sites';
+import { WP_VERSION_QUERY_KEY } from './use-wordpress-versions';
import type { ImportEventTuple } from '@studio/common/lib/import-export-events';
export interface ImportSiteInput {
@@ -11,19 +14,28 @@ export interface ImportSiteInput {
onProgress?: ( event: ImportEventTuple ) => void;
}
-/**
- * Runs a backup import against a site that has just been created. The
- * The cached site list is invalidated so metadata changed by the importer is
- * picked up.
- */
+// Imports a backup over an existing site, whether it was just created by the
+// import flow or has been around. The cached site list is invalidated so
+// metadata changed by the importer is picked up.
export function useImportSite() {
const connector = useConnector();
const queryClient = useQueryClient();
return useMutation< void, Error, ImportSiteInput >( {
mutationFn: ( { siteId, backupPath, onProgress } ) =>
connector.importSiteFromBackup( siteId, backupPath, onProgress ),
- onSuccess: async () => {
+ onSuccess: async ( _result, { siteId } ) => {
+ // The importer replaces the site's files and database and restarts the
+ // server, so everything read off that site is stale. The site list
+ // alone isn't enough: disk usage in particular caches for five minutes,
+ // and the overview stays mounted, so nothing would refetch it.
await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } );
+ await Promise.all(
+ [
+ [ ...WP_VERSION_QUERY_KEY, siteId ],
+ siteStorageUsageQueryKey( siteId ),
+ siteThumbnailQueryKey( siteId ),
+ ].map( ( queryKey ) => queryClient.invalidateQueries( { queryKey } ) )
+ );
toast.success( __( 'Import finished' ) );
},
} );
diff --git a/apps/ui/src/data/sync-activity.ts b/apps/ui/src/data/sync-activity.ts
index 801ed43b8a..0b50dcfcab 100644
--- a/apps/ui/src/data/sync-activity.ts
+++ b/apps/ui/src/data/sync-activity.ts
@@ -1,6 +1,10 @@
import { useSyncExternalStore } from 'react';
import type { PullSiteProgress } from '@/data/core';
+// Structurally what `PullSiteProgress` already is, named for the wider set of
+// operations that report through here.
+export type ActivityProgress = PullSiteProgress;
+
// Tracks in-flight and recently completed live-site sync operations so the
// Site Details header can surface a cross-page indicator. Uses a module-
// level store (rather than React context) so the state survives component
@@ -10,7 +14,13 @@ import type { PullSiteProgress } from '@/data/core';
// `preview` covers creating or refreshing the WordPress.com-hosted preview
// snapshot. Grouped in here alongside push/pull so the dropdown's single
// activity indicator can surface any live-sync-like operation consistently.
-export type SyncDirection = 'push' | 'pull' | 'preview';
+//
+// `import` is not a live-site operation at all, but it is the same shape of
+// thing from the UI's point of view: long-running, scoped to one site, and it
+// rewrites that site underneath you. It lives here so a site being imported
+// reads the same way in the sidebar and dropdown as one being pulled — and so
+// two concurrent imports stay told apart by site, which a global toast can't do.
+export type SyncDirection = 'push' | 'pull' | 'preview' | 'import';
export type SyncActivity =
| { kind: 'pending'; direction: SyncDirection; message?: string; progress?: number }
@@ -62,8 +72,8 @@ export function reportSyncPending( siteId: string, direction: SyncDirection ): v
export function reportSyncProgress(
siteId: string,
- direction: Extract< SyncDirection, 'pull' >,
- progress: PullSiteProgress
+ direction: Extract< SyncDirection, 'pull' | 'import' >,
+ progress: ActivityProgress
): void {
clearExpiryTimer( siteId );
entries.set( siteId, { kind: 'pending', direction, ...progress } );
diff --git a/apps/ui/src/hooks/use-site-management-actions.test.tsx b/apps/ui/src/hooks/use-site-management-actions.test.tsx
index 774918eb26..816c5aee24 100644
--- a/apps/ui/src/hooks/use-site-management-actions.test.tsx
+++ b/apps/ui/src/hooks/use-site-management-actions.test.tsx
@@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { useExportFullSite } from '@/data/queries/use-sites';
+import { reportSyncPending, reportSyncSuccess } from '@/data/sync-activity';
import { useSiteManagementActions } from './use-site-management-actions';
import type { Connector, SiteDetails } from '@/data/core';
import type { ReactNode } from 'react';
@@ -72,6 +73,41 @@ describe( 'useSiteManagementActions', () => {
] );
} );
+ it( 'offers Import only where the surface can host the picker', () => {
+ expect( renderActions().result.current.map( ( action ) => action.id ) ).not.toContain(
+ 'import'
+ );
+
+ const { result } = renderHook(
+ () => useSiteManagementActions( site, { onDelete: vi.fn(), onImport: vi.fn() } ),
+ { wrapper }
+ );
+ expect( result.current.map( ( action ) => action.id ) ).toContain( 'import' );
+ } );
+
+ // Import is deliberately excluded from `SITE_OPERATIONS`, so `useIsSiteBusy`
+ // won't catch it and the CLI won't refuse the others. Duplicating a site
+ // mid-import would copy a half-replaced tree.
+ it( 'disables every action while the site is being imported into', () => {
+ reportSyncPending( site.id, 'import' );
+
+ const { result } = renderHook(
+ () => useSiteManagementActions( site, { onDelete: vi.fn(), onImport: vi.fn() } ),
+ { wrapper }
+ );
+
+ expect( result.current.map( ( action ) => action.disabled ) ).toEqual( [
+ true,
+ true,
+ true,
+ true,
+ true,
+ ] );
+ expect( result.current.find( ( action ) => action.id === 'import' )?.loading ).toBe( true );
+
+ reportSyncSuccess( site.id, 'import' );
+ } );
+
// The screen that starts an export can be navigated away from while it runs.
// Its `useMutation` observer dies with it, so progress has to come from the
// mutation cache or the spinner never comes back.
diff --git a/apps/ui/src/hooks/use-site-management-actions.ts b/apps/ui/src/hooks/use-site-management-actions.ts
index 8b446d95cb..905d17b772 100644
--- a/apps/ui/src/hooks/use-site-management-actions.ts
+++ b/apps/ui/src/hooks/use-site-management-actions.ts
@@ -1,5 +1,5 @@
import { __ } from '@wordpress/i18n';
-import { copy, download, grid, trash } from '@wordpress/icons';
+import { copy, download, grid, trash, upload } from '@wordpress/icons';
import {
COPY_SITE_MUTATION_KEY,
EXPORT_DATABASE_MUTATION_KEY,
@@ -10,10 +10,11 @@ import {
useIsSiteBusy,
useIsSiteMutating,
} from '@/data/queries/use-sites';
+import { useSiteSyncActivity } from '@/data/sync-activity';
import type { SiteDetails } from '@/data/core';
import type { ReactElement, SVGProps } from 'react';
-export type SiteManagementActionId = 'duplicate' | 'export' | 'export-db' | 'delete';
+export type SiteManagementActionId = 'duplicate' | 'import' | 'export' | 'export-db' | 'delete';
export interface SiteManagementAction {
id: SiteManagementActionId;
@@ -33,17 +34,19 @@ export interface SiteManagementAction {
}
/**
- * The canonical "manage this site" actions — Duplicate, Export entire site,
+ * The canonical "manage this site" actions — Duplicate, Import, Export entire site,
* Export database, Delete — shared by every surface that offers them, so labels, icons,
* order, and disabled logic don't drift apart between surfaces.
*
- * Delete needs a confirmation dialog whose "deleted" navigation differs per
- * surface, so this hook doesn't own the dialog: pass `onDelete` to open your
- * own `DeleteSiteDialog` and the Delete action's `run` calls it.
+ * Import and Delete both need surface-owned UI (a file picker plus an overwrite
+ * confirmation, and a confirmation dialog whose "deleted" navigation differs per
+ * surface), so this hook doesn't own either: pass `onImport` / `onDelete` and the
+ * matching action's `run` calls them. Import is omitted entirely on surfaces
+ * that don't pass `onImport`, since they have nowhere to host that UI.
*/
export function useSiteManagementActions(
site: SiteDetails,
- { onDelete }: { onDelete: () => void }
+ { onDelete, onImport }: { onDelete: () => void; onImport?: () => void }
): SiteManagementAction[] {
const copySite = useCopySite();
const exportFullSite = useExportFullSite();
@@ -66,6 +69,14 @@ export function useSiteManagementActions(
// here because reading a site mid-delete or mid-restart is not worth doing.
const isBusy = useIsSiteBusy( site );
+ // Import is deliberately not a `SITE_OPERATIONS` kind — a sync can hold a
+ // site for tens of minutes, which costs more than it protects — so the CLI
+ // won't refuse these. Guard the write window here instead: an import
+ // replaces the files and database the others read from.
+ const activity = useSiteSyncActivity( site.id );
+ const isImporting = activity?.kind === 'pending' && activity.direction === 'import';
+ const isWriting = isBusy || isImporting;
+
return [
{
id: 'duplicate',
@@ -73,17 +84,31 @@ export function useSiteManagementActions(
label: __( 'Duplicate' ),
loading: isDuplicating,
loadingAnnouncement: __( 'Duplicating site' ),
- disabled: isBusy,
+ disabled: isWriting,
destructive: false,
run: () => copySite.mutate( site.id ),
},
+ ...( onImport
+ ? [
+ {
+ id: 'import' as const,
+ icon: upload,
+ label: __( 'Import' ),
+ loading: isImporting,
+ loadingAnnouncement: __( 'Importing site' ),
+ disabled: isWriting || isExporting,
+ destructive: false,
+ run: onImport,
+ },
+ ]
+ : [] ),
{
id: 'export',
icon: download,
label: __( 'Export entire site' ),
loading: isExportingFullSite,
loadingAnnouncement: __( 'Exporting site' ),
- disabled: isBusy || isExporting,
+ disabled: isWriting || isExporting,
destructive: false,
run: () => exportFullSite.mutate( site.id ),
},
@@ -93,7 +118,7 @@ export function useSiteManagementActions(
label: __( 'Export database' ),
loading: isExportingDatabase,
loadingAnnouncement: __( 'Exporting database' ),
- disabled: isBusy || isExporting,
+ disabled: isWriting || isExporting,
destructive: false,
run: () => exportDatabase.mutate( site.id ),
},
@@ -104,7 +129,7 @@ export function useSiteManagementActions(
loading: false,
loadingAnnouncement: '',
// Also blocked mid-export: the archive is still being read off disk.
- disabled: isBusy || isExporting,
+ disabled: isWriting || isExporting,
destructive: true,
run: onDelete,
},
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx
index 97711744b8..206df77702 100644
--- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx
+++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx
@@ -79,15 +79,30 @@ describe( 'OnboardingHomePage', () => {
fireEvent.click( screen.getByRole( 'button', { name: /Import from a backup/ } ) );
expect( click ).toHaveBeenCalledOnce();
- expect( input.accept ).toContain( '.sql' );
+ expect( input.accept ).toContain( '.zip' );
expect( input.accept ).toContain( '.xml' );
+ // A database dump has no files to go with it, so it can only be imported
+ // over an existing site.
+ expect( input.accept ).not.toContain( '.sql' );
+ } );
+
+ it( 'rejects a .sql dump, which can only be imported over an existing site', () => {
+ const { container } = render( );
+ const input = container.querySelector< HTMLInputElement >( 'input[type="file"]' );
+ if ( ! input ) throw new Error( 'Backup input not found' );
+
+ fireEvent.change( input, { target: { files: [ new File( [ 'dump' ], 'client-site.sql' ) ] } } );
+
+ expect( peekPendingBackup() ).toBeNull();
+ expect( mocks.navigate ).not.toHaveBeenCalled();
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent( 'This file type is not supported' );
} );
it( 'hands a selected backup File to the import form', () => {
const { container } = render( );
const input = container.querySelector< HTMLInputElement >( 'input[type="file"]' );
if ( ! input ) throw new Error( 'Backup input not found' );
- const file = new File( [ 'backup' ], 'client-site.sql' );
+ const file = new File( [ 'backup' ], 'client-site.tar.gz' );
fireEvent.change( input, { target: { files: [ file ] } } );
@@ -97,7 +112,7 @@ describe( 'OnboardingHomePage', () => {
it( 'hands a dropped backup File to the import form', () => {
render( );
- const file = new File( [ 'backup' ], 'client-site.sql' );
+ const file = new File( [ 'backup' ], 'client-site.tar.gz' );
fireEvent.drop( screen.getByRole( 'button', { name: /Import from a backup/ } ), {
dataTransfer: { files: [ file ] },
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx
index 7882af481c..67d8275ed8 100644
--- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx
@@ -1,4 +1,4 @@
-import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
+import { ACCEPTED_ADD_SITE_FILE_TYPES } from '@studio/common/constants';
import { isSupportedBackupFilename } from '@studio/common/lib/backup-files';
import { createRoute, Link, useNavigate } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
@@ -30,10 +30,12 @@ function ImportBackupCard() {
const handleFile = useCallback(
( file?: File ) => {
if ( ! file ) return;
- if ( ! isSupportedBackupFilename( file.name ) ) {
+ // A .sql dump is a database without the files that go with it, so it can
+ // only be imported over an existing site — not used to create one.
+ if ( ! isSupportedBackupFilename( file.name, ACCEPTED_ADD_SITE_FILE_TYPES ) ) {
setError(
__(
- 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, .sql, or .xml file.'
+ 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, or .xml file.'
)
);
return;
@@ -51,7 +53,7 @@ function ImportBackupCard() {
{
handleFile( event.target.files?.[ 0 ] );
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx
index 177b233375..fc3992c2a4 100644
--- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx
+++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx
@@ -197,8 +197,8 @@ describe( 'OnboardingImportPage', () => {
fireEvent.click( screen.getByRole( 'button', { name: 'Import site' } ) );
await waitFor( () => {
- expect( mocks.setProgress ).toHaveBeenCalledWith( 'Extracting backup… (25%)' );
- expect( mocks.setProgress ).toHaveBeenCalledWith( 'Importing database… (50%)' );
+ expect( mocks.setProgress ).toHaveBeenCalledWith( '25% · Extracting…' );
+ expect( mocks.setProgress ).toHaveBeenCalledWith( '50% · Database…' );
} );
} );
diff --git a/packages/common/lib/import-progress.ts b/packages/common/lib/import-progress.ts
index 44c121cb04..cd4942153d 100644
--- a/packages/common/lib/import-progress.ts
+++ b/packages/common/lib/import-progress.ts
@@ -1,29 +1,36 @@
-import { __, sprintf } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import { BackupExtractEvents, ImporterEvents, type ImportEventTuple } from './import-export-events';
+import { formatProgressLabel } from './progress-label';
+// These land in a per-site activity row that is only ~166px wide at the
+// narrowest sidebar, so the labels that carry a percentage drop the verb to
+// make room for it. `formatProgressLabel` handles the leading, zero-padded
+// number that keeps the width steady as the import ticks.
const getWpContentTypeLabels = (): Record< string, string > => ( {
- plugins: __( 'Importing plugins…' ),
- themes: __( 'Importing themes…' ),
- uploads: __( 'Importing media uploads…' ),
- other: __( 'Importing other files…' ),
+ plugins: __( 'Plugins…' ),
+ themes: __( 'Themes…' ),
+ uploads: __( 'Media uploads…' ),
+ other: __( 'Other files…' ),
} );
+const percentOf = ( done: number, total: number ) => ( done / total ) * 100;
+
export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): string | undefined {
switch ( event ) {
case BackupExtractEvents.BACKUP_EXTRACT_START:
- return __( 'Extracting backup files…' );
+ return __( 'Extracting backup…' );
case BackupExtractEvents.BACKUP_EXTRACT_PROGRESS:
if (
data.processedFiles !== undefined &&
data.totalFiles !== undefined &&
data.totalFiles > 0
) {
- return sprintf(
- __( 'Extracting backup… (%d%%)' ),
- Math.round( ( data.processedFiles / data.totalFiles ) * 100 )
+ return formatProgressLabel(
+ __( 'Extracting…' ),
+ percentOf( data.processedFiles, data.totalFiles )
);
}
- return __( 'Extracting backup files…' );
+ return __( 'Extracting backup…' );
case ImporterEvents.IMPORT_START:
return __( 'Importing backup…' );
case ImporterEvents.IMPORT_DATABASE_START:
@@ -34,14 +41,14 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str
data.totalFiles !== undefined &&
data.totalFiles > 0
) {
- return sprintf(
- __( 'Importing database… (%d%%)' ),
- Math.round( ( data.processedFiles / data.totalFiles ) * 100 )
+ return formatProgressLabel(
+ __( 'Database…' ),
+ percentOf( data.processedFiles, data.totalFiles )
);
}
return __( 'Importing database…' );
case ImporterEvents.IMPORT_WP_CONTENT_START:
- return __( 'Importing WordPress content…' );
+ return __( 'Importing content…' );
case ImporterEvents.IMPORT_WP_CONTENT_PROGRESS:
if (
data.type &&
@@ -49,13 +56,12 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str
data.totalItems !== undefined &&
data.totalItems > 0
) {
- return sprintf(
- __( '%1$s (%2$d%%)' ),
- getWpContentTypeLabels()[ data.type ] || __( 'Importing files…' ),
- Math.round( ( data.processedItems / data.totalItems ) * 100 )
+ return formatProgressLabel(
+ getWpContentTypeLabels()[ data.type ] || __( 'Files…' ),
+ percentOf( data.processedItems, data.totalItems )
);
}
- return __( 'Importing WordPress content…' );
+ return __( 'Importing content…' );
case ImporterEvents.IMPORT_COMPLETE:
return __( 'Importing completed' );
}
diff --git a/packages/common/lib/progress-label.ts b/packages/common/lib/progress-label.ts
new file mode 100644
index 0000000000..5eede88fbd
--- /dev/null
+++ b/packages/common/lib/progress-label.ts
@@ -0,0 +1,16 @@
+import { __, sprintf } from '@wordpress/i18n';
+
+// Progress labels are rewritten in place as an operation ticks — in the CLI's
+// spinner line, and in the agentic UI's per-site activity row. Leading with the
+// percentage keeps the number in the same spot as the label around it changes
+// length, and zero-padding single digits stops the text shifting on every
+// update. `formatProgressLabel( 'Creating remote backup…', 3 )` → `03% ·
+// Creating remote backup…`.
+export function formatProgressLabel( label: string, percent: number ): string {
+ return sprintf(
+ /* translators: %1$s: percentage complete, zero-padded to two digits. %2$s: what is in progress. */
+ __( '%1$s%% · %2$s' ),
+ String( Math.round( percent ) ).padStart( 2, '0' ),
+ label
+ );
+}
diff --git a/packages/common/lib/tests/import-progress.test.ts b/packages/common/lib/tests/import-progress.test.ts
index e89d0df1f2..35ca347686 100644
--- a/packages/common/lib/tests/import-progress.test.ts
+++ b/packages/common/lib/tests/import-progress.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { BackupExtractEvents, ImporterEvents } from '../import-export-events';
import { getImportStatusMessage } from '../import-progress';
+import type { ImportEventTuple } from '../import-export-events';
describe( 'getImportStatusMessage', () => {
it( 'formats extraction progress', () => {
@@ -9,7 +10,7 @@ describe( 'getImportStatusMessage', () => {
BackupExtractEvents.BACKUP_EXTRACT_PROGRESS,
{ processedFiles: 1, totalFiles: 4 },
] )
- ).toBe( 'Extracting backup… (25%)' );
+ ).toBe( '25% · Extracting…' );
} );
it( 'formats database progress', () => {
@@ -18,7 +19,7 @@ describe( 'getImportStatusMessage', () => {
ImporterEvents.IMPORT_DATABASE_PROGRESS,
{ processedFiles: 3, totalFiles: 4 },
] )
- ).toBe( 'Importing database… (75%)' );
+ ).toBe( '75% · Database…' );
} );
it( 'formats WordPress content progress by type', () => {
@@ -27,7 +28,49 @@ describe( 'getImportStatusMessage', () => {
ImporterEvents.IMPORT_WP_CONTENT_PROGRESS,
{ type: 'uploads', processedItems: 1, totalItems: 2 },
] )
- ).toBe( 'Importing media uploads… (50%)' );
+ ).toBe( '50% · Media uploads…' );
+ } );
+
+ // The toast rewrites this message in place as the import runs. Padding single
+ // digits keeps its width steady so it doesn't reflow on every tick.
+ it( 'pads the percentage to two digits', () => {
+ const at = ( done: number, total: number ) =>
+ getImportStatusMessage( [
+ BackupExtractEvents.BACKUP_EXTRACT_PROGRESS,
+ { processedFiles: done, totalFiles: total },
+ ] );
+
+ expect( at( 0, 100 ) ).toBe( '00% · Extracting…' );
+ expect( at( 5, 100 ) ).toBe( '05% · Extracting…' );
+ expect( at( 9, 100 ) ).toBe( '09% · Extracting…' );
+ expect( at( 10, 100 ) ).toBe( '10% · Extracting…' );
+ expect( at( 100, 100 ) ).toBe( '100% · Extracting…' );
+ } );
+
+ // The sidebar is 240px at its narrowest, leaving ~166px of text — about 30
+ // characters at 13px. English stays on one line; a longer translation may
+ // wrap to a second, which is fine as long as the width doesn't jitter.
+ it( 'keeps every status message short enough for one line in the toast', () => {
+ const progress = { processedFiles: 1, totalFiles: 3 };
+ const events: ImportEventTuple[] = [
+ [ BackupExtractEvents.BACKUP_EXTRACT_START, undefined ],
+ [ BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, progress ],
+ [ ImporterEvents.IMPORT_START, 'jetpack' ],
+ [ ImporterEvents.IMPORT_DATABASE_START, undefined ],
+ [ ImporterEvents.IMPORT_DATABASE_PROGRESS, progress ],
+ [ ImporterEvents.IMPORT_WP_CONTENT_START, undefined ],
+ [ ImporterEvents.IMPORT_COMPLETE, 'jetpack' ],
+ ...( [ 'plugins', 'themes', 'uploads', 'other', 'unknown' ].map( ( type ) => [
+ ImporterEvents.IMPORT_WP_CONTENT_PROGRESS,
+ { type, processedItems: 1, totalItems: 3 },
+ ] ) as ImportEventTuple[] ),
+ ];
+
+ for ( const event of events ) {
+ const message = getImportStatusMessage( event );
+ expect( message ).toBeDefined();
+ expect( message!.length ).toBeLessThanOrEqual( 30 );
+ }
} );
it( 'ignores events that do not change the status message', () => {
diff --git a/packages/common/lib/tests/progress-label.test.ts b/packages/common/lib/tests/progress-label.test.ts
new file mode 100644
index 0000000000..1434fe4ef9
--- /dev/null
+++ b/packages/common/lib/tests/progress-label.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { formatProgressLabel } from '../progress-label';
+
+describe( 'formatProgressLabel', () => {
+ it( 'leads with the percentage so the number stays put as the label changes', () => {
+ expect( formatProgressLabel( 'Creating remote backup…', 3 ) ).toBe(
+ '03% · Creating remote backup…'
+ );
+ expect( formatProgressLabel( 'Downloading backup…', 50 ) ).toBe( '50% · Downloading backup…' );
+ } );
+
+ // The label is rewritten in place while an operation runs, so a single digit
+ // growing to two would shift everything after it.
+ it( 'pads single digits to a fixed two', () => {
+ expect( formatProgressLabel( 'Extracting…', 0 ) ).toBe( '00% · Extracting…' );
+ expect( formatProgressLabel( 'Extracting…', 9 ) ).toBe( '09% · Extracting…' );
+ expect( formatProgressLabel( 'Extracting…', 10 ) ).toBe( '10% · Extracting…' );
+ expect( formatProgressLabel( 'Extracting…', 100 ) ).toBe( '100% · Extracting…' );
+ } );
+
+ it( 'rounds fractional progress', () => {
+ expect( formatProgressLabel( 'Uploading archive…', 24.6 ) ).toBe( '25% · Uploading archive…' );
+ } );
+} );