Skip to content
2 changes: 1 addition & 1 deletion src/hooks/use-site-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function useSiteSize( siteId: string ) {
}

try {
const size = await getIpcApi().getWpContentSize( siteId );
const size = await getIpcApi().getDirectorySize( siteId, [ 'wp-content' ] );
setIsOverLimit( size > DEMO_SITE_SIZE_LIMIT_BYTES );
} catch ( error ) {
console.error( 'Error checking site size:', error );
Expand Down
12 changes: 10 additions & 2 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1273,12 +1273,20 @@ export function clearSyncOperation( event: IpcMainInvokeEvent, id: string ) {
ACTIVE_SYNC_OPERATIONS.delete( id );
}

export function getWpContentSize( _event: IpcMainInvokeEvent, siteId: string ) {
export function getDirectorySize( _event: IpcMainInvokeEvent, siteId: string, subdir: string[] ) {
const site = SiteServer.get( siteId );
if ( ! site ) {
throw new Error( 'Site not found.' );
}
return calculateDirectorySize( nodePath.join( site.details.path, 'wp-content' ) );
return calculateDirectorySize( nodePath.join( site.details.path, ...subdir ) );
}

export function getFileSize( _event: IpcMainInvokeEvent, siteId: string, filePath: string[] ) {
const site = SiteServer.get( siteId );
if ( ! site ) {
throw new Error( 'Site not found.' );
}
return fs.statSync( nodePath.join( site.details.path, ...filePath ) ).size;
}

export function openCertificate( _event: IpcMainInvokeEvent ) {
Expand Down
79 changes: 52 additions & 27 deletions src/modules/sync/components/sync-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SelectControl } from '@wordpress/components';
import { SelectControl, Notice } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { sprintf, __ } from '@wordpress/i18n';
import { useI18n } from '@wordpress/react-i18n';
Expand All @@ -10,14 +10,15 @@ import { RightArrowIcon } from 'src/components/icons/right-arrow';
import Modal from 'src/components/modal';
import { Tooltip } from 'src/components/tooltip';
import { TreeView, TreeNode, updateNodeById } from 'src/components/tree-view';
import { SYNC_OPTIONS } from 'src/constants';
import { SYNC_OPTIONS, SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constants';
import { useContentFolders } from 'src/hooks/use-content-folders';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { getLocalizedLink } from 'src/lib/get-localized-link';
import { SiteNameBox } from 'src/modules/sync/components/site-name-box';
import { GRANULAR_SYNC_FOLDERS } from 'src/modules/sync/constants';
import { useDefaultSyncTree } from 'src/modules/sync/hooks/use-default-sync-tree';
import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size';
import { useSyncDialogTexts } from 'src/modules/sync/hooks/use-sync-dialog-texts';
import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils';
import { useI18nLocale } from 'src/stores';
Expand Down Expand Up @@ -132,6 +133,11 @@ export function SyncDialog( {
const [ showAllFiles, setShowAllFiles ] = useState( false );
const [ treeState, setTreeState ] = useState< TreeNode[] >( defaultTree );
const isSubmitDisabled = treeState.every( ( node ) => ! node.checked && ! node.indeterminate );
const { isPushSelectionOverLimit, isLoading: isSizeCheckLoading } = useSelectedItemsPushSize(
localSite.id,
treeState,
type
);

const { fetchChildren, rewindId, isLoadingRewindId, isErrorRewindId } = useDynamicTreeState(
type,
Expand Down Expand Up @@ -206,7 +212,7 @@ export function SyncDialog( {
onRequestClose={ onRequestClose }
title={ syncTexts.title }
>
<div className="pb-[70px]">
<div className={ isPushSelectionOverLimit ? 'pb-[140px]' : 'pb-[70px]' }>
<div className="px-8 pb-6 pt-3">{ syncTexts.description }</div>
<div className="px-8">
<span className="sr-only">
Expand Down Expand Up @@ -298,32 +304,51 @@ export function SyncDialog( {
</div>
</Tooltip>

<div className="flex px-8 py-4 border-t border-a8c-gray-5 justify-between items-center absolute left-0 right-0 bottom-0 bg-white z-10">
<div>
{ createInterpolateElement( syncTexts.envSync, {
a: (
<div className="px-8 py-4 border-t border-a8c-gray-5 absolute left-0 right-0 bottom-0 bg-white z-10">
{ type === 'push' && isPushSelectionOverLimit && (
<Notice status="warning" isDismissible={ false } className="mb-4">
<p data-testid="push-selection-over-limit-notice">
{ sprintf(
__(
'The current selection exceeds the %d GB push limit. To continue, please change your selection to reduce the total size.'
),
SYNC_PUSH_SIZE_LIMIT_GB
) }
</p>
</Notice>
) }
<div className="flex justify-between items-center">
<div>
{ createInterpolateElement( syncTexts.envSync, {
a: (
<Button
variant="link"
onClick={ () =>
getIpcApi().openURL( getLocalizedLink( locale, 'docsSync' ) + '#' + type )
}
/>
),
ArrowIcon: <ArrowIcon />,
} ) }
</div>
<div>
<div className="flex gap-4 justify-end">
<Button variant="link" onClick={ onRequestClose }>
{ __( 'Cancel' ) }
</Button>
<Button
variant="link"
onClick={ () =>
getIpcApi().openURL( getLocalizedLink( locale, 'docsSync' ) + '#' + type )
variant="primary"
onClick={ handleSubmit }
disabled={
isSubmitDisabled ||
isLoadingRewindId ||
isPushSelectionOverLimit ||
isSizeCheckLoading
}
/>
),
ArrowIcon: <ArrowIcon />,
} ) }
</div>
<div>
<div className="flex gap-4 justify-end">
<Button variant="link" onClick={ onRequestClose }>
{ __( 'Cancel' ) }
</Button>
<Button
variant="primary"
onClick={ handleSubmit }
disabled={ isSubmitDisabled || isLoadingRewindId }
>
{ syncTexts.submit }
</Button>
>
{ syncTexts.submit }
</Button>
</div>
</div>
</div>
</div>
Expand Down
96 changes: 96 additions & 0 deletions src/modules/sync/hooks/use-selected-items-push-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState, useEffect, useCallback } from 'react';
import { TreeNode } from 'src/components/tree-view';
import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants';
import { getIpcApi } from 'src/lib/get-ipc-api';

export function useSelectedItemsPushSize(
siteId: string,
treeState: TreeNode[],
type: 'push' | 'pull'
) {
const [ isPushSelectionOverLimit, setIsPushSelectionOverLimit ] = useState( false );
const [ isLoading, setIsLoading ] = useState( false );

const checkSelectedItemsSize = useCallback( async () => {
if ( ! siteId || ! treeState.length || type !== 'push' ) {
setIsPushSelectionOverLimit( false );
return;
}

setIsLoading( true );

try {
const isEverythingSelected = treeState.every( ( node ) => node.checked );

if ( isEverythingSelected ) {
const size = await getIpcApi().getDirectorySize( siteId, [ 'wp-content' ] );
setIsPushSelectionOverLimit( size > SYNC_PUSH_SIZE_LIMIT_BYTES );
return;
}

const sizePromises: Promise< number >[] = [];

const databaseNode = treeState.find( ( node ) => node.id === 'sqls' );
if ( databaseNode?.checked ) {
sizePromises.push( getIpcApi().getDirectorySize( siteId, [ 'wp-content', 'database' ] ) );
}

const filesAndFoldersNode = treeState.find( ( node ) => node.id === 'filesAndFolders' );
const wpContentNode = filesAndFoldersNode?.children?.find(
( node ) => node.id === 'wp-content'
);

if ( wpContentNode?.checked ) {
sizePromises.push( getIpcApi().getDirectorySize( siteId, [ 'wp-content' ] ) );
} else if ( wpContentNode?.children ) {
for ( const child of wpContentNode.children ) {
if ( child.checked ) {
sizePromises.push(
getIpcApi().getDirectorySize( siteId, [ 'wp-content', child.name ] )
);
} else if ( child.indeterminate && child.children ) {
for ( const subChild of child.children ) {
if ( subChild.checked || subChild.indeterminate ) {
let sizePromise: Promise< number >;
if ( subChild.type === 'file' ) {
sizePromise = getIpcApi().getFileSize( siteId, [
'wp-content',
child.name,
subChild.name,
] );
} else {
sizePromise = getIpcApi().getDirectorySize( siteId, [
'wp-content',
child.name,
subChild.name,
] );
}
sizePromises.push( sizePromise );
}
}
}
}
}

if ( sizePromises.length > 0 ) {
const sizes = await Promise.all( sizePromises );
const totalSize = sizes.reduce( ( sum, size ) => sum + size, 0 );
setIsPushSelectionOverLimit( totalSize > SYNC_PUSH_SIZE_LIMIT_BYTES );
} else {
setIsPushSelectionOverLimit( false );
}
} catch ( error ) {
console.error( 'Error checking selected items size:', error );
setIsPushSelectionOverLimit( false );
} finally {
setIsLoading( false );
}
}, [ siteId, treeState, type ] );

// Check size when siteId or treeState changes
useEffect( () => {
void checkSelectedItemsSize();
}, [ checkSelectedItemsSize ] );

return { isPushSelectionOverLimit, isLoading };
}
79 changes: 79 additions & 0 deletions src/modules/sync/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAuth } from 'src/hooks/use-auth';
import { ContentTabsProvider } from 'src/hooks/use-content-tabs';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { ContentTabSync } from 'src/modules/sync';
import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size';
import { store } from 'src/stores';
import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync';

Expand All @@ -28,6 +29,8 @@ jest.mock( 'src/stores/sync', () => ( {
} ),
} ) );

jest.mock( 'src/modules/sync/hooks/use-selected-items-push-size' );

const createAuthMock = ( isAuthenticated: boolean = false ) => ( {
isAuthenticated,
authenticate: jest.fn(),
Expand Down Expand Up @@ -90,6 +93,11 @@ describe( 'ContentTabSync', () => {
showMessageBox: jest.fn(),
updateConnectedWpcomSites: jest.fn(),
getConnectedWpcomSites: jest.fn().mockResolvedValue( [] ),
getDirectorySize: jest.fn().mockResolvedValue( 0 ),
} );
( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( {
isPushSelectionOverLimit: false,
isLoading: false,
} );
( useSyncSites as jest.Mock ).mockReturnValue( mockSyncSites );
( useLatestRewindId as jest.Mock ).mockReturnValue( {
Expand Down Expand Up @@ -907,4 +915,75 @@ describe( 'ContentTabSync', () => {
const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ];
expect( dialogPushButton ).not.toBeDisabled();
} );

describe( 'Sync Dialog Push Selection Over Limit Notice', () => {
it( 'shows warning notice when push selection exceeds limit', async () => {
( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) );
( useSyncSites as jest.Mock ).mockReturnValue( {
...mockSyncSites,
connectedSites: [ fakeSyncSite ],
} );
( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( {
isPushSelectionOverLimit: true,
isLoading: false,
} );

renderWithProvider( <ContentTabSync selectedSite={ selectedSite } /> );

const pushButton = screen.getByRole( 'button', { name: /Push/i } );
fireEvent.click( pushButton );

await screen.findByText( 'Push to Production' );

const warningNotice = screen.getByTestId( 'push-selection-over-limit-notice' );
expect( warningNotice ).toBeInTheDocument();

const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ];
expect( dialogPushButton ).toBeDisabled();
} );

it( 'does not show warning notice when push selection is within limit', async () => {
( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) );
( useSyncSites as jest.Mock ).mockReturnValue( {
...mockSyncSites,
connectedSites: [ fakeSyncSite ],
} );
( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( {
isPushSelectionOverLimit: false,
isLoading: false,
} );

renderWithProvider( <ContentTabSync selectedSite={ selectedSite } /> );

const pushButton = screen.getByRole( 'button', { name: /Push/i } );
fireEvent.click( pushButton );

await screen.findByText( 'Push to Production' );

const warningNotice = screen.queryByTestId( 'push-selection-over-limit-notice' );
expect( warningNotice ).not.toBeInTheDocument();
} );

it( 'does not show warning notice for pull operations even when limit exceeded', async () => {
( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) );
( useSyncSites as jest.Mock ).mockReturnValue( {
...mockSyncSites,
connectedSites: [ fakeSyncSite ],
} );
( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( {
isPushSelectionOverLimit: true,
isLoading: false,
} );

renderWithProvider( <ContentTabSync selectedSite={ selectedSite } /> );

const pullButton = screen.getByRole( 'button', { name: /Pull/i } );
fireEvent.click( pullButton );

await screen.findByText( 'Pull from Production' );

const warningNotice = screen.queryByTestId( 'push-selection-over-limit-notice' );
expect( warningNotice ).not.toBeInTheDocument();
} );
} );
} );
Loading