Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
be62a33
Add backup import to the Agentic UI site overview
bcotrim Aug 10, 2026
c4ca566
Exclude .sql dumps from Agentic UI site creation
bcotrim Aug 10, 2026
01c1766
Merge branch 'trunk' into stu-2008-add-import-export-to-agentic-ui
bcotrim Aug 11, 2026
9050b9d
Correct import comments that assumed a freshly created site
bcotrim Aug 11, 2026
e0efb72
Merge remote-tracking branch 'origin/stu-2008-add-import-export-to-ag…
bcotrim Aug 11, 2026
d0abc31
Use AlertDialog for the import overwrite confirmation
bcotrim Aug 11, 2026
9d27c4b
Throttle import progress toasts and soften the overwrite confirm button
bcotrim Aug 12, 2026
b1a5fed
Track import progress per site so it doesn't follow navigation
bcotrim Aug 12, 2026
221b5b1
Shorten import status messages so the toast stays on one line
bcotrim Aug 12, 2026
c08c2c7
Clamp the import progress toast to one line for longer translations
bcotrim Aug 12, 2026
ef6b27b
Pad the import percentage to two digits instead of clamping the toast
bcotrim Aug 12, 2026
5004ebb
Use tabular figures in toasts so the import percentage doesn't shift
bcotrim Aug 12, 2026
eb95315
Show import progress per site instead of in a global toast
bcotrim Aug 12, 2026
2c2fb67
Lead sync and import progress labels with a padded percentage
bcotrim Aug 12, 2026
a92fa5c
Refetch site details invalidated by an import
bcotrim Aug 12, 2026
52f2d56
Merge remote-tracking branch 'origin/trunk' into stu-2008-add-import-…
bcotrim Aug 13, 2026
4ba390e
Drop redundant comments from the site overview tests
bcotrim Aug 13, 2026
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
9 changes: 6 additions & 3 deletions apps/cli/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ) );
}
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions apps/cli/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -155,15 +156,18 @@ 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,
archivePath,
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 ) );
},
} );

Expand All @@ -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,
Expand Down Expand Up @@ -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 ) );
}
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/components/app-toasts/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
162 changes: 162 additions & 0 deletions apps/ui/src/components/import-site-dialog/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertDialog.Root
open={ open }
onOpenChange={ ( next ) => {
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. */ }
<AlertDialog.Popup
title={ sprintf( __( 'Overwrite %s?' ), site.name ) }
description={ __(
'Importing a backup will replace the existing files and database for your site.'
) }
confirmButtonText={ __( 'Import' ) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure whether this button needs to have destructive styling:

Image

It seems a bit intense there, what do you think? Although can be left as is if that's the intention

>
{ file ? <p className={ styles.fileName }>{ file.name }</p> : null }
</AlertDialog.Popup>
</AlertDialog.Root>
);
}
7 changes: 7 additions & 0 deletions apps/ui/src/components/import-site-dialog/style.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions apps/ui/src/components/site-dropdown/main-view.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 21 additions & 2 deletions apps/ui/src/components/site-dropdown/main-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 9 additions & 3 deletions apps/ui/src/components/site-dropdown/main-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -485,7 +488,10 @@ function SyncActivityDetails( {
>
<div className={ styles.activityStatusTitle }>{ getSyncActivityLabel( activity ) }</div>
<div className={ styles.activityStatusMessage }>
{ activity.message ?? __( 'Preparing the live site…' ) }
{ activity.message ??
( activity.direction === 'import'
? __( 'Preparing the backup…' )
: __( 'Preparing the live site…' ) ) }
</div>
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/components/site-dropdown/trigger-secondary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,28 @@ 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…' );
}

if ( activity.kind === 'success' ) {
if ( activity.direction === 'preview' ) {
return __( 'Preview published' );
}
if ( activity.direction === 'import' ) {
return __( 'Backup imported' );
}
return activity.direction === 'push' ? __( 'Pushed to live' ) : __( 'Pulled from live' );
}

if ( activity.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' );
Expand Down
19 changes: 19 additions & 0 deletions apps/ui/src/components/site-list/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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( <SiteList /> );

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 ) =>
Expand Down
Loading