@@ -479,7 +552,16 @@ const SyncConnectedSitesSectionItem = ( {
}
placement="top-start"
>
- clearPushState( selectedSite.id, connectedSite.id ) }>
+
+ dispatch(
+ syncOperationsActions.clearPushState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId: connectedSite.id,
+ } )
+ )
+ }
+ >
{ pushState.status.message }
@@ -522,7 +604,6 @@ const SyncConnectedSiteSection = ( {
const { __ } = useI18n();
const dispatch = useAppDispatch();
const locale = useI18nLocale();
- const { clearPullState, isSiteIdPulling, isSiteIdPushing } = useSyncSites();
const isOffline = useOffline();
const handleDisconnectSite = async () => {
@@ -550,7 +631,12 @@ const SyncConnectedSiteSection = ( {
localStorage.setItem( 'dontShowDisconnectWarning', 'true' );
}
disconnectSite( connectedSite.id );
- clearPullState( selectedSite.id, connectedSite.id );
+ void dispatch(
+ syncOperationsActions.clearPullState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId: connectedSite.id,
+ } )
+ );
}
} else {
disconnectSite( connectedSite.id );
@@ -558,8 +644,12 @@ const SyncConnectedSiteSection = ( {
};
const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected';
- const isPulling = isSiteIdPulling( selectedSite.id, connectedSite.id );
- const isPushing = isSiteIdPushing( selectedSite.id, connectedSite.id );
+ const isPulling = useRootSelector(
+ syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, connectedSite.id )
+ );
+ const isPushing = useRootSelector(
+ syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, connectedSite.id )
+ );
let logo =
;
if ( hasConnectionErrors ) {
diff --git a/apps/studio/src/modules/sync/index.tsx b/apps/studio/src/modules/sync/index.tsx
index f04cc2caa3..476819a300 100644
--- a/apps/studio/src/modules/sync/index.tsx
+++ b/apps/studio/src/modules/sync/index.tsx
@@ -5,7 +5,6 @@ import { ArrowIcon } from 'src/components/arrow-icon';
import Button from 'src/components/button';
import offlineIcon from 'src/components/offline-icon';
import { Tooltip } from 'src/components/tooltip';
-import { useSyncSites } from 'src/hooks/sync-sites';
import { useAuth } from 'src/hooks/use-auth';
import { useOffline } from 'src/hooks/use-offline';
import { getIpcApi } from 'src/lib/get-ipc-api';
@@ -19,6 +18,7 @@ import {
convertTreeToPushOptions,
} from 'src/modules/sync/lib/convert-tree-to-sync-options';
import { useAppDispatch, useRootSelector } from 'src/stores';
+import { syncOperationsThunks } from 'src/stores/sync';
import {
connectedSitesActions,
connectedSitesSelectors,
@@ -134,9 +134,9 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
localSiteId: selectedSite.id,
userId: user?.id,
} );
+ const { client } = useAuth();
const [ connectSite ] = useConnectSiteMutation();
const [ disconnectSite ] = useDisconnectSiteMutation();
- const { pushSite, pullSite } = useSyncSites();
const connectedSiteIds = connectedSites.map( ( { id } ) => id );
const { data: syncSites = [] } = useGetWpComSitesQuery( {
@@ -246,12 +246,28 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
onPush={ async ( tree ) => {
await handleConnect( selectedRemoteSite );
const pushOptions = convertTreeToPushOptions( tree );
- void pushSite( selectedRemoteSite, selectedSite, pushOptions );
+ void dispatch(
+ syncOperationsThunks.pushSite( {
+ connectedSite: selectedRemoteSite,
+ selectedSite,
+ options: pushOptions,
+ } )
+ );
} }
onPull={ async ( tree ) => {
+ if ( ! client ) {
+ return;
+ }
await handleConnect( selectedRemoteSite );
const pullOptions = convertTreeToPullOptions( tree );
- void pullSite( selectedRemoteSite, selectedSite, pullOptions );
+ void dispatch(
+ syncOperationsThunks.pullSite( {
+ client,
+ connectedSite: selectedRemoteSite,
+ selectedSite,
+ options: pullOptions,
+ } )
+ );
} }
onRequestClose={ () => {
setSelectedRemoteSite( null );
diff --git a/apps/studio/src/modules/sync/lib/convert-tree-to-sync-options.ts b/apps/studio/src/modules/sync/lib/convert-tree-to-sync-options.ts
index f5599dc71f..1a4172a800 100644
--- a/apps/studio/src/modules/sync/lib/convert-tree-to-sync-options.ts
+++ b/apps/studio/src/modules/sync/lib/convert-tree-to-sync-options.ts
@@ -1,5 +1,5 @@
import { SYNC_OPTIONS } from 'src/constants';
-import { PullSiteOptions } from 'src/hooks/sync-sites/use-sync-pull';
+import { PullSiteOptions } from 'src/stores/sync';
import type { TreeNode } from 'src/components/tree-view';
import type { SyncOption } from 'src/types';
diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts
index fbffc2b499..277aaa935d 100644
--- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts
+++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts
@@ -1,9 +1,8 @@
import { app, IpcMainInvokeEvent } from 'electron';
import fs from 'fs';
import fsPromises from 'fs/promises';
+import { randomUUID } from 'node:crypto';
import path from 'node:path';
-import * as Sentry from '@sentry/electron/main';
-import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import { Upload } from 'tus-js-client';
@@ -25,12 +24,6 @@ import { SiteServer } from 'src/site-server';
import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data';
import { SyncOption } from 'src/types';
-const TEMP_DIR = path.join( app.getPath( 'temp' ), 'com.wordpress.studio' );
-
-if ( ! fs.existsSync( TEMP_DIR ) ) {
- fs.mkdirSync( TEMP_DIR );
-}
-
/**
* Registry to store AbortControllers for ongoing sync operations (push/pull).
* Key format: `${selectedSiteId}-${remoteSiteId}`
@@ -154,8 +147,10 @@ export async function exportSiteForPush(
if ( ! site ) {
throw new Error( 'Site not found.' );
}
- const extension = 'tar.gz';
- const archivePath = path.join( TEMP_DIR, `site_${ id }.${ extension }` );
+
+ const tempDir = path.join( app.getPath( 'temp' ), 'com.wordpress.studio', randomUUID() );
+ fs.mkdirSync( tempDir, { recursive: true } );
+ const archivePath = path.join( tempDir, `site_${ id }.tar.gz` );
const abortController = new AbortController();
SYNC_ABORT_CONTROLLERS.set( operationId, abortController );
@@ -209,20 +204,6 @@ export async function exportSiteForPush(
}
}
-export function removeExportedSiteTmpFile( event: IpcMainInvokeEvent, path: string ) {
- if ( ! path.includes( TEMP_DIR ) ) {
- throw new Error( 'The given path is not a temporary file' );
- }
- try {
- fs.unlinkSync( path );
- } catch ( error ) {
- if ( isErrnoException( error ) && error.code === 'ENOENT' ) {
- // Silently ignore if the temporary file doesn't exist
- Sentry.captureException( error );
- }
- }
-}
-
export async function pushArchive(
event: IpcMainInvokeEvent,
selectedSiteId: string,
@@ -338,7 +319,7 @@ export async function pushArchive(
abortController.signal.addEventListener( 'abort', () => {
void upload.abort();
- reject( new Error( 'Upload Aborted' ) );
+ reject( new Error( 'Export aborted' ) );
} );
const existingUploadState = SYNC_TUS_UPLOADS.get( uploadKey );
@@ -359,6 +340,7 @@ export async function pushArchive(
SYNC_TUS_UPLOADS.delete( uploadKey );
file.destroy();
file.close();
+ fs.unlinkSync( archivePath );
} );
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
@@ -385,6 +367,10 @@ export async function pushArchive(
return { success: true };
} catch ( error ) {
+ if ( abortController.signal.aborted ) {
+ throw error;
+ }
+
const parseResult = z.object( { error: z.string() } ).safeParse( error );
if ( parseResult.success ) {
diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx
index 743ee39f4b..e9e24023bd 100644
--- a/apps/studio/src/modules/sync/tests/index.test.tsx
+++ b/apps/studio/src/modules/sync/tests/index.test.tsx
@@ -2,8 +2,7 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { vi } from 'vitest';
-import { SyncSitesProvider, useSyncSites } from 'src/hooks/sync-sites';
-import { SyncPushState } from 'src/hooks/sync-sites/use-sync-push';
+import { SYNC_OPTIONS } from 'src/constants';
import { useAuth } from 'src/hooks/use-auth';
import { ContentTabsProvider } from 'src/hooks/use-content-tabs';
import { useFeatureFlags } from 'src/hooks/use-feature-flags';
@@ -12,12 +11,15 @@ import { ContentTabSync } from 'src/modules/sync';
import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size';
import { SyncSite } from 'src/modules/sync/types';
import { store } from 'src/stores';
-import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync';
+import { syncOperationsActions, useLatestRewindId, useRemoteFileTree } from 'src/stores/sync';
import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer';
store.replaceReducer( testReducer );
+const mockPullSiteThunk = vi.hoisted( () => vi.fn() );
+const mockPushSiteThunk = vi.hoisted( () => vi.fn() );
+
vi.mock( 'src/lib/get-ipc-api' );
vi.mock( 'src/hooks/use-auth' );
vi.mock( 'src/stores/sync/wpcom-sites', async () => {
@@ -28,13 +30,6 @@ vi.mock( 'src/stores/sync/wpcom-sites', async () => {
};
} );
vi.mock( 'src/hooks/use-feature-flags' );
-vi.mock( 'src/hooks/sync-sites/sync-sites-context', async () => {
- const actual = await vi.importActual( '../../../hooks/sync-sites/sync-sites-context' );
- return {
- ...actual,
- useSyncSites: vi.fn(),
- };
-} );
vi.mock( 'src/stores/sync', async () => {
const actual = await vi.importActual( 'src/stores/sync' );
@@ -61,6 +56,11 @@ vi.mock( 'src/stores/sync', async () => {
return { type: 'connectedSites/closeModal' };
} ),
},
+ syncOperationsThunks: {
+ ...actual.syncOperationsThunks,
+ pullSite: mockPullSiteThunk,
+ pushSite: mockPushSiteThunk,
+ },
};
} );
@@ -85,6 +85,7 @@ const createAuthMock = ( isAuthenticated: boolean = false ) => ( {
isAuthenticated,
authenticate: vi.fn(),
user: isAuthenticated ? { id: 123, email: 'user@example.com', displayName: 'user' } : undefined,
+ client: isAuthenticated ? ( {} as never ) : undefined,
} );
const selectedSite: SiteDetails = {
@@ -97,17 +98,6 @@ const selectedSite: SiteDetails = {
id: 'site-id',
};
-const inProgressPushState: SyncPushState = {
- remoteSiteId: 1,
- status: {
- key: 'creatingRemoteBackup',
- progress: 50,
- message: '',
- },
- selectedSite,
- remoteSiteUrl: 'https://example.com',
-};
-
const fakeSyncSite: SyncSite = {
id: 6,
name: 'My simple business site',
@@ -121,20 +111,6 @@ const fakeSyncSite: SyncSite = {
};
describe( 'ContentTabSync', () => {
- const mockSyncSites = {
- pullSite: vi.fn(),
- pushSite: vi.fn(),
- isAnySitePulling: false,
- isAnySitePushing: false,
- getPullState: vi.fn(),
- getPushState: vi.fn(),
- updateTimestamp: vi.fn(),
- getLastSyncTimeText: vi.fn().mockReturnValue( 'You have not pulled this site yet.' ),
- isSiteIdPulling: vi.fn().mockReturnValue( false ),
- isSiteIdPushing: vi.fn().mockReturnValue( false ),
- clearTimeout: vi.fn(),
- };
-
const setupConnectedSitesMocks = (
connectedSites: SyncSite[] = [],
syncSites: SyncSite[] = []
@@ -152,6 +128,14 @@ describe( 'ContentTabSync', () => {
beforeEach( () => {
vi.resetAllMocks();
+ mockPullSiteThunk.mockImplementation( ( payload ) => ( {
+ type: 'syncOperations/pullSite',
+ payload,
+ } ) );
+ mockPushSiteThunk.mockImplementation( ( payload ) => ( {
+ type: 'syncOperations/pushSite',
+ payload,
+ } ) );
store.dispatch( testActions.resetState() );
store.dispatch( { type: 'connectedSitesApi/resetApiState' } );
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( false ) );
@@ -164,6 +148,8 @@ describe( 'ContentTabSync', () => {
openURL: vi.fn(),
showMessageBox: vi.fn(),
updateConnectedWpcomSites: vi.fn(),
+ addSyncOperation: vi.fn(),
+ removeSyncOperation: vi.fn(),
getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ),
getDirectorySize: vi.fn().mockResolvedValue( 0 ),
connectWpcomSites: vi.fn(),
@@ -199,7 +185,6 @@ describe( 'ContentTabSync', () => {
isPushSelectionOverLimit: false,
isLoading: false,
} );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( mockSyncSites );
vi.mocked( useLatestRewindId, { partial: true } ).mockReturnValue( {
rewindId: '1704067200',
isLoading: false,
@@ -259,14 +244,14 @@ describe( 'ContentTabSync', () => {
const renderWithProvider = ( children: React.ReactElement ) => {
return render(
-
- { children }
-
+ { children }
);
};
it( 'renders the sync title and login buttons', () => {
+ const authMock = createAuthMock( false );
+ vi.mocked( useAuth, { partial: true } ).mockReturnValue( authMock );
renderWithProvider(
);
expect( screen.getByText( 'Sync with WordPress.com or Pressable' ) ).toBeInTheDocument();
@@ -274,7 +259,7 @@ describe( 'ContentTabSync', () => {
expect( loginButton ).toBeInTheDocument();
fireEvent.click( loginButton );
- expect( useAuth().authenticate ).toHaveBeenCalled();
+ expect( authMock.authenticate ).toHaveBeenCalled();
const freeAccountButton = screen.getByRole( 'button', { name: /Create a free account/i } );
expect( freeAccountButton ).toBeInTheDocument();
@@ -395,18 +380,27 @@ describe( 'ContentTabSync', () => {
it( 'displays the progress bar when the site is being pushed', async () => {
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- getPushState: vi.fn().mockReturnValue( inProgressPushState ),
- isSiteIdPushing: vi.fn().mockReturnValue( true ),
- } );
+ store.dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId: fakeSyncSite.id,
+ state: {
+ status: {
+ key: 'uploading',
+ progress: 40,
+ message: 'Uploading…',
+ },
+ selectedSite,
+ remoteSiteUrl: fakeSyncSite.url,
+ },
+ } )
+ );
renderWithProvider(
);
await screen.findByRole( 'progressbar' );
} );
it( 'opens sync pullSite dialog with development environment label', async () => {
- const mockPullSite = vi.fn();
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
const fakeDevelopmentSyncSite: SyncSite = {
...fakeSyncSite,
@@ -414,10 +408,6 @@ describe( 'ContentTabSync', () => {
environmentType: 'development',
};
setupConnectedSitesMocks( [ fakeDevelopmentSyncSite ], [ fakeDevelopmentSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- pullSite: mockPullSite,
- } );
renderWithProvider(
);
@@ -432,7 +422,6 @@ describe( 'ContentTabSync', () => {
} );
it( 'opens sync pullSite dialog and displays production when the environment is not supported', async () => {
- const mockPullSite = vi.fn();
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
const fakeDevelopmentSyncSite: SyncSite = {
...fakeSyncSite,
@@ -440,10 +429,6 @@ describe( 'ContentTabSync', () => {
environmentType: 'non-supported-environment-example-or-sandbox',
};
setupConnectedSitesMocks( [ fakeDevelopmentSyncSite ], [ fakeDevelopmentSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- pullSite: mockPullSite,
- } );
renderWithProvider(
);
@@ -458,13 +443,8 @@ describe( 'ContentTabSync', () => {
} );
it( 'calls pullSite with correct optionsToSync when all options are selected', async () => {
- const mockPullSite = vi.fn();
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- pullSite: mockPullSite,
- } );
renderWithProvider(
);
@@ -482,19 +462,18 @@ describe( 'ContentTabSync', () => {
const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' );
fireEvent.click( dialogPullButton );
- expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, {
- optionsToSync: [ 'all' ],
- } );
+ expect( mockPullSiteThunk ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ options: {
+ optionsToSync: [ SYNC_OPTIONS.all ],
+ },
+ } )
+ );
} );
it( 'calls pullSite with correct optionsToSync when only database is selected', async () => {
- const mockPullSite = vi.fn();
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- pullSite: mockPullSite,
- } );
renderWithProvider(
);
@@ -510,13 +489,16 @@ describe( 'ContentTabSync', () => {
const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' );
fireEvent.click( dialogPullButton );
- expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, {
- optionsToSync: [ 'sqls' ],
- } );
+ expect( mockPullSiteThunk ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ options: {
+ optionsToSync: [ SYNC_OPTIONS.sqls ],
+ },
+ } )
+ );
} );
it( 'calls pullSite with correct optionsToSync when options partially are selected', async () => {
- const mockPullSite = vi.fn();
vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) );
vi.mocked( useRemoteFileTree, { partial: true } ).mockReturnValue( {
fetchChildren: vi.fn().mockResolvedValue( [
@@ -585,10 +567,6 @@ describe( 'ContentTabSync', () => {
} );
setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] );
- vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( {
- ...mockSyncSites,
- pullSite: mockPullSite,
- } );
renderWithProvider(
);
@@ -614,10 +592,14 @@ describe( 'ContentTabSync', () => {
const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' );
fireEvent.click( dialogPullButton );
- expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, {
- optionsToSync: [ 'paths', 'sqls' ],
- include_path_list: [ 'cjI6,ZjI6Lw==', 'ZjM6Lw==' ],
- } );
+ expect( mockPullSiteThunk ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ options: {
+ optionsToSync: [ SYNC_OPTIONS.paths, SYNC_OPTIONS.sqls ],
+ include_path_list: [ 'cjI6,ZjI6Lw==', 'ZjM6Lw==' ],
+ },
+ } )
+ );
} );
it( 'disables the pull button when all checkboxes are unchecked, which is the initial state', async () => {
diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts
index e5f92d685f..2fd0971b0a 100644
--- a/apps/studio/src/preload.ts
+++ b/apps/studio/src/preload.ts
@@ -82,7 +82,6 @@ const api: IpcApi = {
stopAllServers: () => ipcRendererInvoke( 'stopAllServers' ),
copyText: ( text ) => ipcRendererInvoke( 'copyText', text ),
getAppGlobals: () => ipcRendererInvoke( 'getAppGlobals' ),
- removeExportedSiteTmpFile: ( path ) => ipcRendererInvoke( 'removeExportedSiteTmpFile', path ),
getWpVersion: ( id ) => ipcRendererInvoke( 'getWpVersion', id ),
generateProposedSitePath: ( siteName ) =>
ipcRendererInvoke( 'generateProposedSitePath', siteName ),
diff --git a/apps/studio/src/stores/beta-features-slice.ts b/apps/studio/src/stores/beta-features-slice.ts
index 8577e7640e..3106844b25 100644
--- a/apps/studio/src/stores/beta-features-slice.ts
+++ b/apps/studio/src/stores/beta-features-slice.ts
@@ -2,10 +2,10 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { store } from 'src/stores';
-interface BetaFeaturesState {
+type BetaFeaturesState = {
features: BetaFeatures;
loading: boolean;
-}
+};
const initialState: BetaFeaturesState = {
features: {},
diff --git a/apps/studio/src/stores/chat-slice.ts b/apps/studio/src/stores/chat-slice.ts
index 99d5cd3497..bc15bb037b 100644
--- a/apps/studio/src/stores/chat-slice.ts
+++ b/apps/studio/src/stores/chat-slice.ts
@@ -233,7 +233,7 @@ const sendFeedback = createTypedAsyncThunk(
const EMPTY_MESSAGES: readonly Message[] = Object.freeze( [] );
-interface ChatState {
+type ChatState = {
currentURL: string;
pluginListDict: Record< string, string[] >;
themeListDict: Record< string, string[] >;
@@ -250,7 +250,7 @@ interface ChatState {
chatInputBySite: { [ key: string ]: string };
isLoadingDict: Record< string, boolean >;
isLoadingUpdateFromSiteDict: Record< string, boolean >;
-}
+};
const getInitialState = (): ChatState => {
const storedMessages = localStorage.getItem( LOCAL_STORAGE_CHAT_MESSAGES_KEY );
diff --git a/apps/studio/src/stores/i18n-slice.ts b/apps/studio/src/stores/i18n-slice.ts
index c5ccfb6425..4a3af2df6f 100644
--- a/apps/studio/src/stores/i18n-slice.ts
+++ b/apps/studio/src/stores/i18n-slice.ts
@@ -8,9 +8,9 @@ import {
import { defaultI18n } from '@wordpress/i18n';
import { getIpcApi } from 'src/lib/get-ipc-api';
-interface I18nState {
+type I18nState = {
locale: SupportedLocale;
-}
+};
const initialState: I18nState = {
locale: DEFAULT_LOCALE,
diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts
index ba0eced546..1c23ef38ae 100644
--- a/apps/studio/src/stores/index.ts
+++ b/apps/studio/src/stores/index.ts
@@ -7,6 +7,11 @@ import {
import { setupListeners } from '@reduxjs/toolkit/query';
import { useDispatch, useSelector } from 'react-redux';
import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants';
+import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states';
+import {
+ PullStateProgressInfo,
+ PushStateProgressInfo,
+} from 'src/hooks/use-sync-states-progress-info';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { appVersionApi } from 'src/stores/app-version-api';
import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice';
@@ -21,11 +26,16 @@ import {
updateSnapshotLocally,
snapshotActions,
} from 'src/stores/snapshot-slice';
-import { syncReducer } from 'src/stores/sync';
+import { syncReducer, syncOperationsActions } from 'src/stores/sync';
import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites';
+import {
+ syncOperationsReducer,
+ syncOperationsSelectors,
+ syncOperationsThunks,
+} from 'src/stores/sync/sync-operations-slice';
import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites';
import uiReducer from 'src/stores/ui-slice';
-import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
+import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
import { wordpressVersionsApi } from './wordpress-versions-api';
import type { SupportedLocale } from '@studio/common/lib/locale';
@@ -40,6 +50,7 @@ export type RootState = {
sync: ReturnType< typeof syncReducer >;
connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >;
connectedSites: ReturnType< typeof connectedSitesReducer >;
+ syncOperations: ReturnType< typeof syncOperationsReducer >;
wpcomSitesApi: ReturnType< typeof wpcomSitesApi.reducer >;
wordpressVersionsApi: ReturnType< typeof wordpressVersionsApi.reducer >;
wpcomApi: ReturnType< typeof wpcomApi.reducer >;
@@ -49,10 +60,11 @@ export type RootState = {
ui: ReturnType< typeof uiReducer >;
};
-const listenerMiddleware = createListenerMiddleware< RootState >();
+const listenerMiddleware = createListenerMiddleware();
+const startAppListening = listenerMiddleware.startListening.withTypes< RootState, AppDispatch >();
// Save chat messages to local storage
-listenerMiddleware.startListening( {
+startAppListening( {
predicate( action, currentState, previousState ) {
return currentState.chat.messagesDict !== previousState.chat.messagesDict;
},
@@ -66,7 +78,7 @@ listenerMiddleware.startListening( {
} );
// Save chat API IDs to local storage
-listenerMiddleware.startListening( {
+startAppListening( {
predicate( action, currentState, previousState ) {
return currentState.chat.chatApiIdDict !== previousState.chat.chatApiIdDict;
},
@@ -80,7 +92,7 @@ listenerMiddleware.startListening( {
} );
// Save snapshots to user config
-listenerMiddleware.startListening( {
+startAppListening( {
matcher: isAnyOf( updateSnapshotLocally, snapshotActions.deleteSnapshotLocally ),
async effect( action, listenerApi ) {
const state = listenerApi.getState();
@@ -88,6 +100,228 @@ listenerMiddleware.startListening( {
},
} );
+const TERMINAL_STATUS_KEYS = [ 'failed', 'finished', 'cancelled' ];
+
+// Sync push/pull state updates to IPC (addSyncOperation / clearSyncOperation)
+function keepSyncOperationInSync(
+ stateId: string,
+ status?: PushStateProgressInfo | PullStateProgressInfo
+) {
+ if ( status?.key && TERMINAL_STATUS_KEYS.includes( status.key ) ) {
+ getIpcApi().clearSyncOperation( stateId );
+ } else if ( status ) {
+ getIpcApi().addSyncOperation( stateId, status );
+ }
+}
+startAppListening( {
+ actionCreator: syncOperationsActions.updatePushState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId, state } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ keepSyncOperationInSync( stateId, state.status );
+ },
+} );
+startAppListening( {
+ actionCreator: syncOperationsActions.updatePullState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId, state } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ keepSyncOperationInSync( stateId, state.status );
+ },
+} );
+
+// Sync push/pull state clears to IPC (clearSyncOperation)
+startAppListening( {
+ actionCreator: syncOperationsActions.clearPushState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ stopPushPoller( stateId );
+ getIpcApi().clearSyncOperation( stateId );
+ },
+} );
+startAppListening( {
+ actionCreator: syncOperationsActions.clearPullState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ stopPullPoller( stateId );
+ getIpcApi().clearSyncOperation( stateId );
+ },
+} );
+
+// Show error modals for rejected sync thunks
+function maybeShowErrorMessageBox(
+ params: { title: string; message: string; error?: unknown; showOpenLogs?: boolean } | undefined,
+ aborted: boolean
+) {
+ if ( params && ! aborted ) {
+ getIpcApi().showErrorMessageBox( params );
+ }
+}
+startAppListening( {
+ actionCreator: syncOperationsThunks.pushSite.rejected,
+ effect( action ) {
+ maybeShowErrorMessageBox( action.payload, action.meta.aborted );
+ },
+} );
+startAppListening( {
+ actionCreator: syncOperationsThunks.pullSite.rejected,
+ effect( action ) {
+ maybeShowErrorMessageBox( action.payload, action.meta.aborted );
+ },
+} );
+startAppListening( {
+ actionCreator: syncOperationsThunks.pollPushProgress.rejected,
+ effect( action ) {
+ maybeShowErrorMessageBox( action.payload, action.meta.aborted );
+ },
+} );
+startAppListening( {
+ actionCreator: syncOperationsThunks.pollPullBackup.rejected,
+ effect( action ) {
+ maybeShowErrorMessageBox( action.payload, action.meta.aborted );
+ },
+} );
+
+const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ];
+const SYNC_POLLING_INTERVAL = 3000;
+
+const PUSH_POLLERS = new Map< string, AbortController >();
+const PULL_POLLERS = new Map< string, AbortController >();
+
+function isPushPollable( selectedSiteId: string, remoteSiteId: number ) {
+ const pushState = syncOperationsSelectors.selectPushState(
+ selectedSiteId,
+ remoteSiteId
+ )( store.getState() );
+ return pushState && PUSH_POLLING_KEYS.includes( pushState.status.key );
+}
+
+function isPullPollable( selectedSiteId: string, remoteSiteId: number ) {
+ const pullState = syncOperationsSelectors.selectPullState(
+ selectedSiteId,
+ remoteSiteId
+ )( store.getState() );
+ return pullState?.status.key === 'in-progress' && !! pullState.backupId;
+}
+
+function stopPushPoller( stateId: string ) {
+ PUSH_POLLERS.get( stateId )?.abort();
+ PUSH_POLLERS.delete( stateId );
+}
+
+function stopPullPoller( stateId: string ) {
+ PULL_POLLERS.get( stateId )?.abort();
+ PULL_POLLERS.delete( stateId );
+}
+
+async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) {
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ if ( PUSH_POLLERS.has( stateId ) ) {
+ return;
+ }
+
+ const controller = new AbortController();
+ PUSH_POLLERS.set( stateId, controller );
+
+ try {
+ while ( ! controller.signal.aborted ) {
+ const client = getWpcomClient();
+ if ( ! client ) {
+ break;
+ }
+
+ await store.dispatch(
+ syncOperationsThunks.pollPushProgress( {
+ client,
+ signal: controller.signal,
+ selectedSiteId,
+ remoteSiteId,
+ } )
+ );
+
+ if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) {
+ break;
+ }
+
+ await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) );
+ }
+ } finally {
+ if ( PUSH_POLLERS.get( stateId ) === controller ) {
+ PUSH_POLLERS.delete( stateId );
+ }
+ }
+}
+
+async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) {
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ if ( PULL_POLLERS.has( stateId ) ) {
+ return;
+ }
+
+ const controller = new AbortController();
+ PULL_POLLERS.set( stateId, controller );
+
+ try {
+ while ( ! controller.signal.aborted ) {
+ const client = getWpcomClient();
+ if ( ! client ) {
+ break;
+ }
+
+ await store.dispatch(
+ syncOperationsThunks.pollPullBackup( {
+ client,
+ signal: controller.signal,
+ selectedSiteId,
+ remoteSiteId,
+ } )
+ );
+
+ if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) {
+ break;
+ }
+
+ await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) );
+ }
+ } finally {
+ if ( PULL_POLLERS.get( stateId ) === controller ) {
+ PULL_POLLERS.delete( stateId );
+ }
+ }
+}
+
+// Poll push progress when state enters a pollable status
+startAppListening( {
+ actionCreator: syncOperationsActions.updatePushState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+
+ if ( isPushPollable( selectedSiteId, remoteSiteId ) ) {
+ void startPushPoller( selectedSiteId, remoteSiteId );
+ } else {
+ stopPushPoller( stateId );
+ }
+ },
+} );
+
+// Poll pull backup when state has a backupId and is in-progress
+startAppListening( {
+ actionCreator: syncOperationsActions.updatePullState,
+ effect( action ) {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+
+ if ( isPullPollable( selectedSiteId, remoteSiteId ) ) {
+ void startPullPoller( selectedSiteId, remoteSiteId );
+ } else {
+ stopPullPoller( stateId );
+ }
+ },
+} );
+
export const rootReducer = combineReducers( {
appVersionApi: appVersionApi.reducer,
betaFeatures: betaFeaturesReducer,
@@ -100,6 +334,7 @@ export const rootReducer = combineReducers( {
providerConstants: providerConstantsReducer,
snapshot: snapshotReducer,
sync: syncReducer,
+ syncOperations: syncOperationsReducer,
wordpressVersionsApi: wordpressVersionsApi.reducer,
wpcomApi: wpcomApi.reducer,
wpcomPublicApi: wpcomPublicApi.reducer,
diff --git a/apps/studio/src/stores/onboarding-slice.ts b/apps/studio/src/stores/onboarding-slice.ts
index f6835f55c8..f661d106a1 100644
--- a/apps/studio/src/stores/onboarding-slice.ts
+++ b/apps/studio/src/stores/onboarding-slice.ts
@@ -2,10 +2,10 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { RootState } from 'src/stores';
-interface OnboardingState {
+type OnboardingState = {
completed: boolean;
loading: boolean;
-}
+};
const initialState: OnboardingState = {
completed: false,
diff --git a/apps/studio/src/stores/provider-constants-slice.ts b/apps/studio/src/stores/provider-constants-slice.ts
index b016936d91..14af2dd070 100644
--- a/apps/studio/src/stores/provider-constants-slice.ts
+++ b/apps/studio/src/stores/provider-constants-slice.ts
@@ -7,12 +7,12 @@ import {
import { SupportedPHPVersionsList } from '@studio/common/types/php-versions';
import { RootState } from 'src/stores';
-interface ProviderConstantsState {
+type ProviderConstantsState = {
defaultPhpVersion: string;
defaultWordPressVersion: string;
allowedPhpVersions: string[];
minimumWordPressVersion: string;
-}
+};
const initialState: ProviderConstantsState = {
defaultPhpVersion: DEFAULT_PHP_VERSION,
diff --git a/apps/studio/src/stores/sync/index.ts b/apps/studio/src/stores/sync/index.ts
index 2da749d5dc..df83eb1f79 100644
--- a/apps/studio/src/stores/sync/index.ts
+++ b/apps/studio/src/stores/sync/index.ts
@@ -1,3 +1,17 @@
export { syncReducer } from './sync-slice';
export { useLatestRewindId, useRemoteFileTree, useLocalFileTree } from './sync-hooks';
+export { useGetLatestRewindIdQuery, fetchRemoteFileTree } from './sync-api';
+export {
+ syncOperationsReducer,
+ syncOperationsActions,
+ syncOperationsSelectors,
+ syncOperationsThunks,
+} from './sync-operations-slice';
+export type {
+ SyncBackupState,
+ PullSiteOptions,
+ PullStates,
+ SyncPushState,
+ PushStates,
+} from './sync-operations-slice';
export * from './sync-types';
diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts
new file mode 100644
index 0000000000..25bc312bcd
--- /dev/null
+++ b/apps/studio/src/stores/sync/sync-operations-slice.ts
@@ -0,0 +1,1046 @@
+import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import * as Sentry from '@sentry/electron/renderer';
+import { __, sprintf } from '@wordpress/i18n';
+import { WPCOM } from 'wpcom/types';
+import { z } from 'zod';
+import { SYNC_PUSH_SIZE_LIMIT_BYTES, SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constants';
+import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { getHostnameFromUrl } from 'src/lib/url-utils';
+import { store } from 'src/stores';
+import { connectedSitesApi } from 'src/stores/sync/connected-sites';
+import type {
+ PullStateProgressInfo,
+ PushStateProgressInfo,
+} from 'src/hooks/use-sync-states-progress-info';
+import type { SyncSite } from 'src/modules/sync/types';
+import type { AppDispatch, RootState } from 'src/stores';
+import type { SyncOption } from 'src/types';
+
+export type SyncBackupState = {
+ remoteSiteId: number;
+ backupId: number | null;
+ status: PullStateProgressInfo;
+ downloadUrl: string | null;
+ selectedSite: SiteDetails;
+ remoteSiteUrl: string;
+};
+
+export type PullSiteOptions = {
+ optionsToSync: SyncOption[];
+ include_path_list?: string[];
+};
+
+export type PullStates = Record< string, SyncBackupState >;
+
+export type SyncPushState = {
+ remoteSiteId: number;
+ status: PushStateProgressInfo;
+ selectedSite: SiteDetails;
+ remoteSiteUrl: string;
+ uploadProgress?: number;
+};
+
+export type PushStates = Record< string, SyncPushState >;
+
+// Factory functions for progress info (canonical definitions, also used by useSyncStatesProgressInfo hook)
+export function getPushStatesProgressInfo(): Record<
+ PushStateProgressInfo[ 'key' ],
+ PushStateProgressInfo
+> {
+ return {
+ creatingBackup: { key: 'creatingBackup', progress: 20, message: __( 'Creating backup…' ) },
+ uploading: { key: 'uploading', progress: 40, message: __( 'Uploading site…' ) },
+ uploadingPaused: { key: 'uploadingPaused', progress: 45, message: __( 'Uploading paused' ) },
+ uploadingManuallyPaused: {
+ key: 'uploadingManuallyPaused',
+ progress: 45,
+ message: __( 'Uploading paused' ),
+ },
+ creatingRemoteBackup: {
+ key: 'creatingRemoteBackup',
+ progress: 50,
+ message: __( 'Backing up remote site…' ),
+ },
+ applyingChanges: { key: 'applyingChanges', progress: 60, message: __( 'Applying changes…' ) },
+ finishing: { key: 'finishing', progress: 99, message: __( 'Almost there…' ) },
+ finished: { key: 'finished', progress: 100, message: __( 'Push complete' ) },
+ failed: { key: 'failed', progress: 100, message: __( 'Error pushing changes' ) },
+ cancelled: { key: 'cancelled', progress: 0, message: __( 'Cancelled' ) },
+ };
+}
+
+export function getPullStatesProgressInfo(): Record<
+ PullStateProgressInfo[ 'key' ],
+ PullStateProgressInfo
+> {
+ return {
+ 'in-progress': {
+ key: 'in-progress',
+ progress: 30,
+ message: __( 'Initializing remote backup…' ),
+ },
+ downloading: { key: 'downloading', progress: 60, message: __( 'Downloading backup…' ) },
+ importing: { key: 'importing', progress: 80, message: __( 'Importing backup…' ) },
+ finished: { key: 'finished', progress: 100, message: __( 'Pull complete' ) },
+ failed: { key: 'failed', progress: 100, message: __( 'Error pulling changes' ) },
+ cancelled: { key: 'cancelled', progress: 0, message: __( 'Cancelled' ) },
+ };
+}
+
+type SyncOperationsState = {
+ pullStates: PullStates;
+ pushStates: PushStates;
+};
+
+const initialState: SyncOperationsState = {
+ pullStates: {},
+ pushStates: {},
+};
+
+type UpdatePullStatePayload = {
+ selectedSiteId: string;
+ remoteSiteId: number;
+ state: Partial< SyncBackupState >;
+};
+
+type UpdatePushStatePayload = {
+ selectedSiteId: string;
+ remoteSiteId: number;
+ state: Partial< SyncPushState >;
+};
+
+type ClearStatePayload = {
+ selectedSiteId: string;
+ remoteSiteId: number;
+};
+
+const syncOperationsSlice = createSlice( {
+ name: 'syncOperations',
+ initialState,
+ reducers: {
+ updatePullState: ( state, action: PayloadAction< UpdatePullStatePayload > ) => {
+ const { selectedSiteId, remoteSiteId, state: updateState } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+
+ state.pullStates[ stateId ] = {
+ ...state.pullStates[ stateId ],
+ ...updateState,
+ remoteSiteId,
+ };
+ },
+
+ clearPullState: ( state, action: PayloadAction< ClearStatePayload > ) => {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ delete state.pullStates[ stateId ];
+ },
+
+ updatePushState: ( state, action: PayloadAction< UpdatePushStatePayload > ) => {
+ const { selectedSiteId, remoteSiteId, state: updateState } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+
+ state.pushStates[ stateId ] = {
+ ...state.pushStates[ stateId ],
+ ...updateState,
+ remoteSiteId,
+ };
+ },
+
+ clearPushState: ( state, action: PayloadAction< ClearStatePayload > ) => {
+ const { selectedSiteId, remoteSiteId } = action.payload;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ delete state.pushStates[ stateId ];
+ },
+ },
+ extraReducers: ( builder ) => {
+ builder
+ .addCase( pushSiteThunk.rejected, ( state, action ) => {
+ const { connectedSite, selectedSite } = action.meta.arg;
+ const stateId = generateStateId( selectedSite.id, connectedSite.id );
+ if ( ! action.meta.aborted && state.pushStates[ stateId ] ) {
+ state.pushStates[ stateId ].status = getPushStatesProgressInfo().failed;
+ }
+ } )
+ .addCase( pollPushProgressThunk.rejected, ( state, action ) => {
+ const { remoteSiteId, selectedSiteId } = action.meta.arg;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ if ( ! action.meta.aborted && state.pushStates[ stateId ] ) {
+ state.pushStates[ stateId ].status = getPushStatesProgressInfo().failed;
+ }
+ } )
+ .addCase( pullSiteThunk.rejected, ( state, action ) => {
+ const { connectedSite, selectedSite } = action.meta.arg;
+ const stateId = generateStateId( selectedSite.id, connectedSite.id );
+ if ( ! action.meta.aborted && state.pullStates[ stateId ] ) {
+ state.pullStates[ stateId ].status = getPullStatesProgressInfo().failed;
+ }
+ } )
+ .addCase( pollPullBackupThunk.rejected, ( state, action ) => {
+ const { remoteSiteId, selectedSiteId } = action.meta.arg;
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ if ( ! action.meta.aborted && state.pullStates[ stateId ] ) {
+ state.pullStates[ stateId ].status = getPullStatesProgressInfo().failed;
+ }
+ } );
+ },
+} );
+
+export const syncOperationsActions = syncOperationsSlice.actions;
+export const syncOperationsReducer = syncOperationsSlice.reducer;
+
+/**
+ * Keep upload progress in sync with the renderer store.
+ *
+ * The main process emits upload progress via IPC while streaming the push backup
+ * to WordPress.com (TUS). The UI expects `pushState.uploadProgress` to be updated
+ * so it can render "Uploading site (%d%)…" and, optionally, a smoother progress
+ * bar during the upload phase.
+ */
+const UPLOADING_BASE_PROGRESS = 40;
+const CREATING_REMOTE_BACKUP_PROGRESS = 50;
+
+window.ipcListener.subscribe( 'sync-upload-network-paused', ( _event, payload ) => {
+ store.dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: payload.selectedSiteId,
+ remoteSiteId: payload.remoteSiteId,
+ state: {
+ status: getPushStatesProgressInfo().uploadingPaused,
+ },
+ } )
+ );
+} );
+
+window.ipcListener.subscribe( 'sync-upload-manually-paused', ( _event, payload ) => {
+ store.dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: payload.selectedSiteId,
+ remoteSiteId: payload.remoteSiteId,
+ state: {
+ status: getPushStatesProgressInfo().uploadingManuallyPaused,
+ },
+ } )
+ );
+} );
+
+window.ipcListener.subscribe( 'sync-upload-progress', ( _event, payload ) => {
+ const uploadProgress = Math.max( 0, Math.min( 100, payload.progress ) );
+ const uploadRange = CREATING_REMOTE_BACKUP_PROGRESS - UPLOADING_BASE_PROGRESS; // 10
+ const overallProgress = UPLOADING_BASE_PROGRESS + ( uploadProgress / 100 ) * uploadRange;
+
+ store.dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: payload.selectedSiteId,
+ remoteSiteId: payload.remoteSiteId,
+ state: {
+ uploadProgress,
+ status: {
+ ...getPushStatesProgressInfo().uploading,
+ progress: overallProgress,
+ },
+ },
+ } )
+ );
+} );
+
+window.ipcListener.subscribe( 'sync-upload-resumed', ( _event, payload ) => {
+ store.dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: payload.selectedSiteId,
+ remoteSiteId: payload.remoteSiteId,
+ state: {
+ status: getPushStatesProgressInfo().uploading,
+ },
+ } )
+ );
+} );
+
+const createTypedAsyncThunk = createAsyncThunk.withTypes< {
+ state: RootState;
+ dispatch: AppDispatch;
+ rejectValue: {
+ title: string;
+ message: string;
+ showOpenLogs?: boolean;
+ error?: unknown;
+ };
+} >();
+
+type CancelOperationPayload = {
+ selectedSiteId: string;
+ remoteSiteId: number;
+};
+
+const cancelPushThunk = createTypedAsyncThunk(
+ 'syncOperations/cancelPush',
+ async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => {
+ const operationId = generateStateId( selectedSiteId, remoteSiteId );
+ const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId );
+
+ abortCallback?.();
+ getIpcApi().cancelSyncOperation( operationId );
+
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: { status: getPushStatesProgressInfo().cancelled },
+ } )
+ );
+
+ getIpcApi().showNotification( {
+ title: __( 'Push cancelled' ),
+ body: __( 'The push operation has been cancelled.' ),
+ } );
+ }
+);
+
+const cancelPullThunk = createTypedAsyncThunk(
+ 'syncOperations/cancelPull',
+ async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => {
+ const operationId = generateStateId( selectedSiteId, remoteSiteId );
+ getIpcApi().cancelSyncOperation( operationId );
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: { status: getPullStatesProgressInfo().cancelled },
+ } )
+ );
+
+ getIpcApi()
+ .removeSyncBackup( remoteSiteId )
+ .catch( () => {
+ // Ignore errors if file doesn't exist
+ } );
+
+ getIpcApi().showNotification( {
+ title: __( 'Pull cancelled' ),
+ body: __( 'The pull operation has been cancelled.' ),
+ } );
+ }
+);
+
+const getErrorFromResponse = ( error: unknown ): string => {
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'error' in error &&
+ typeof error.error === 'string'
+ ) {
+ return error.error;
+ }
+ return __( 'Studio was unable to connect to WordPress.com. Please try again.' );
+};
+
+const PUSH_SITE_ABORT_CALLBACKS: Map< string, ( reason?: string | undefined ) => void > = new Map();
+
+type PushSitePayload = {
+ connectedSite: SyncSite;
+ selectedSite: SiteDetails;
+ options?: {
+ optionsToSync?: SyncOption[];
+ specificSelectionPaths?: string[];
+ };
+};
+
+const pushSiteThunk = createTypedAsyncThunk< void, PushSitePayload >(
+ 'syncOperations/pushSite',
+ async (
+ { connectedSite, selectedSite, options },
+ { abort, dispatch, signal, rejectWithValue }
+ ) => {
+ const pushStatesProgressInfo = getPushStatesProgressInfo();
+ const remoteSiteId = connectedSite.id;
+ const remoteSiteUrl = connectedSite.url;
+ const operationId = generateStateId( selectedSite.id, remoteSiteId );
+
+ try {
+ PUSH_SITE_ABORT_CALLBACKS.set( operationId, abort );
+
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId,
+ state: {
+ status: pushStatesProgressInfo.creatingBackup,
+ selectedSite,
+ remoteSiteUrl,
+ },
+ } )
+ );
+
+ const { archivePath, archiveSizeInBytes } = await getIpcApi().exportSiteForPush(
+ selectedSite.id,
+ operationId,
+ {
+ optionsToSync: options?.optionsToSync,
+ specificSelectionPaths: options?.specificSelectionPaths,
+ }
+ );
+
+ if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) {
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ),
+ message: __(
+ 'The site is too large to push. Please reduce the size of the site and try again.'
+ ),
+ } );
+ }
+
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId,
+ state: { status: pushStatesProgressInfo.uploading },
+ } )
+ );
+
+ const response = await getIpcApi().pushArchive(
+ selectedSite.id,
+ remoteSiteId,
+ archivePath,
+ options?.optionsToSync,
+ options?.specificSelectionPaths
+ );
+
+ if ( response.success ) {
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId,
+ state: {
+ status: pushStatesProgressInfo.creatingRemoteBackup,
+ selectedSite,
+ remoteSiteUrl,
+ },
+ } )
+ );
+ } else {
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ),
+ message: getErrorFromResponse( response ),
+ } );
+ }
+ } catch ( error ) {
+ if ( signal.aborted ) {
+ return;
+ }
+ Sentry.captureException( error );
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ),
+ message: getErrorFromResponse( error ),
+ } );
+ } finally {
+ PUSH_SITE_ABORT_CALLBACKS.delete( operationId );
+ }
+ }
+);
+
+// Thunk for pull operation
+type PullSitePayload = {
+ client: WPCOM;
+ connectedSite: SyncSite;
+ selectedSite: SiteDetails;
+ options: {
+ optionsToSync: SyncOption[];
+ include_path_list?: string[];
+ };
+};
+
+type PullSiteResult = {
+ backupId: number;
+ remoteSiteId: number;
+};
+
+const pullSiteResponseSchema = z.object( {
+ success: z.boolean(),
+ backup_id: z.number(),
+} );
+
+const importFailedResponseSchema = z.object( {
+ status: z.literal( 'failed' ),
+ success: z.boolean(),
+ error: z.string(),
+ error_data: z
+ .object( {
+ vp_restore_status: z.string().nullable(),
+ vp_restore_message: z.string().nullable(),
+ vp_rewind_id: z.string().nullable(),
+ } )
+ .nullable(),
+} );
+
+const importWorkingResponseSchema = z.object( {
+ status: z.enum( [
+ 'started',
+ 'initial_backup_started',
+ 'initial_backup_finished',
+ 'archive_import_started',
+ 'archive_import_finished',
+ 'finished',
+ ] ),
+ success: z.boolean(),
+ backup_progress: z.number().nullable(),
+ import_progress: z.number().nullable(),
+} );
+
+const importResponseSchema = z.discriminatedUnion( 'status', [
+ importWorkingResponseSchema,
+ importFailedResponseSchema,
+] );
+
+const syncBackupResponseSchema = z.object( {
+ status: z.enum( [ 'in-progress', 'finished', 'failed' ] ),
+ download_url: z.string().nullable().optional(),
+ percent: z.number(),
+} );
+
+export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >(
+ 'syncOperations/pullSite',
+ async ( { client, connectedSite, selectedSite, options }, { dispatch, rejectWithValue } ) => {
+ const pullStatesProgressInfo = getPullStatesProgressInfo();
+ const remoteSiteId = connectedSite.id;
+ const remoteSiteUrl = connectedSite.url;
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId,
+ state: {
+ backupId: null,
+ status: pullStatesProgressInfo[ 'in-progress' ],
+ downloadUrl: null,
+ remoteSiteUrl,
+ selectedSite,
+ },
+ } )
+ );
+
+ try {
+ // Initializing backup on remote
+ const requestBody: {
+ options: SyncOption[];
+ include_path_list?: string[];
+ } = {
+ options: options.optionsToSync,
+ include_path_list: options.include_path_list,
+ };
+
+ const rawResponse = await client.req.post( {
+ path: `/sites/${ remoteSiteId }/studio-app/sync/backup`,
+ apiNamespace: 'wpcom/v2',
+ body: requestBody,
+ } );
+ const response = pullSiteResponseSchema.parse( rawResponse );
+
+ if ( response.success ) {
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId: selectedSite.id,
+ remoteSiteId,
+ state: {
+ backupId: response.backup_id,
+ },
+ } )
+ );
+
+ return {
+ backupId: response.backup_id,
+ remoteSiteId,
+ };
+ } else {
+ throw new Error( 'Pull request failed' );
+ }
+ } catch ( error ) {
+ Sentry.captureException( error );
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ),
+ message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ),
+ } );
+ }
+ }
+);
+
+// Thunk for polling push progress
+type PollPushProgressPayload = {
+ client: WPCOM;
+ selectedSiteId: string;
+ signal: AbortSignal;
+ remoteSiteId: number;
+};
+
+type ImportResponse = z.infer< typeof importResponseSchema >;
+
+const pollPushProgressThunk = createTypedAsyncThunk(
+ 'syncOperations/pollPushProgress',
+ async (
+ { client, selectedSiteId, signal, remoteSiteId }: PollPushProgressPayload,
+ { dispatch, getState, rejectWithValue }
+ ) => {
+ const pushStatesProgressInfo = getPushStatesProgressInfo();
+ // condition guarantees currentPushState exists and is not cancelled
+ const currentPushState = syncOperationsSelectors.selectPushState(
+ selectedSiteId,
+ remoteSiteId
+ )( getState() );
+ if ( ! currentPushState ) {
+ return;
+ }
+
+ try {
+ const rawResponse = await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/import`, {
+ apiNamespace: 'wpcom/v2',
+ } );
+ const response = importResponseSchema.parse( rawResponse );
+
+ signal.throwIfAborted();
+
+ if ( ! response.success ) {
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ),
+ message: __(
+ 'An error occurred while pushing the site. If this problem persists, please contact support.'
+ ),
+ } );
+ }
+
+ let status: PushStateProgressInfo;
+ switch ( response.status ) {
+ case 'finished':
+ status = pushStatesProgressInfo.finished;
+ void dispatch( connectedSitesApi.util.invalidateTags( [ 'ConnectedSites' ] ) );
+ getIpcApi().showNotification( {
+ title: currentPushState.selectedSite.name,
+ body: sprintf(
+ // translators: %s is the site url without the protocol.
+ __( '%s has been updated' ),
+ getHostnameFromUrl( currentPushState.remoteSiteUrl )
+ ),
+ } );
+ break;
+ case 'failed': {
+ console.error( 'Push import failed:', {
+ remoteSiteId: currentPushState.remoteSiteId,
+ error: response.error,
+ error_data: response.error_data,
+ } );
+ // If the import fails due to a SQL import error, show a more specific message
+ const restoreMessage = response.error_data?.vp_restore_message || '';
+ const isSqlImportFailure = /importing sql dump/i.test( restoreMessage );
+ const isImportTimedOut = response.error === 'Import timed out';
+ let message: string;
+ if ( isSqlImportFailure ) {
+ message = __(
+ 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.'
+ );
+ } else if ( isImportTimedOut ) {
+ message = __(
+ "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support."
+ );
+ } else {
+ message = __(
+ 'An error occurred while pushing the site. If this problem persists, please contact support.'
+ );
+ }
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ),
+ message,
+ showOpenLogs: true,
+ } );
+ }
+ case 'started':
+ case 'initial_backup_started':
+ case 'initial_backup_finished':
+ status = pushStatesProgressInfo.creatingRemoteBackup;
+ if ( response.backup_progress ) {
+ const progressRange = pushStatesProgressInfo.applyingChanges.progress - status.progress;
+ status.progress = status.progress + progressRange * ( response.backup_progress / 100 );
+ }
+ break;
+ case 'archive_import_started':
+ status = pushStatesProgressInfo.applyingChanges;
+ if ( response.import_progress ) {
+ const progressRange = pushStatesProgressInfo.finishing.progress - status.progress;
+ status.progress = status.progress + progressRange * ( response.import_progress / 100 );
+ }
+ break;
+ case 'archive_import_finished':
+ status = pushStatesProgressInfo.finishing;
+ break;
+ }
+
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: { status },
+ } )
+ );
+ } catch ( error ) {
+ if ( signal.aborted ) {
+ return;
+ }
+
+ Sentry.captureException( error );
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pushing from %s' ), currentPushState.selectedSite.name ),
+ message: __( 'Failed to check backup file size. Please try again.' ),
+ } );
+ }
+ }
+);
+
+// Constants for pull progress calculation (from useSyncStatesProgressInfo)
+const IN_PROGRESS_INITIAL_VALUE = 30;
+const DOWNLOADING_INITIAL_VALUE = 60;
+const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE;
+
+// Thunk for polling pull backup status
+type PollPullBackupPayload = {
+ client: WPCOM;
+ selectedSiteId: string;
+ signal: AbortSignal;
+ remoteSiteId: number;
+};
+
+const pollPullBackupThunk = createTypedAsyncThunk(
+ 'syncOperations/pollPullBackup',
+ async (
+ { client, selectedSiteId, remoteSiteId, signal }: PollPullBackupPayload,
+ { dispatch, getState, rejectWithValue }
+ ) => {
+ const pullStatesProgressInfo = getPullStatesProgressInfo();
+ const currentPullState = syncOperationsSelectors.selectPullState(
+ selectedSiteId,
+ remoteSiteId
+ )( getState() );
+
+ if ( ! currentPullState ) {
+ return;
+ }
+
+ const backupId = currentPullState.backupId;
+ if ( ! backupId ) {
+ console.error( 'No backup ID found' );
+ return;
+ }
+
+ try {
+ const rawResponse = await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/backup`, {
+ apiNamespace: 'wpcom/v2',
+ backup_id: backupId,
+ } );
+ const response = syncBackupResponseSchema.parse( rawResponse );
+
+ signal.throwIfAborted();
+
+ if ( ! response.status ) {
+ throw new Error( 'Unexpected backup response: missing status' );
+ }
+
+ const hasBackupCompleted = response.status === 'finished';
+ const downloadUrl = hasBackupCompleted ? response.download_url : null;
+
+ if ( downloadUrl ) {
+ const { selectedSite, remoteSiteUrl } = currentPullState;
+
+ const fileSize = await getIpcApi().checkSyncBackupSize( downloadUrl );
+
+ if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) {
+ const CANCEL_ID = 1;
+
+ const { response: userChoice } = await getIpcApi().showMessageBox( {
+ type: 'warning',
+ message: __( "Large site's backup" ),
+ detail: sprintf(
+ __(
+ "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?"
+ ),
+ SYNC_PUSH_SIZE_LIMIT_GB
+ ),
+ buttons: [ __( 'Continue' ), __( 'Cancel' ) ],
+ defaultId: 0,
+ cancelId: CANCEL_ID,
+ } );
+
+ if ( userChoice === CANCEL_ID ) {
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: {
+ status: pullStatesProgressInfo.cancelled,
+ },
+ } )
+ );
+ void dispatch(
+ syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } )
+ );
+ return;
+ }
+ }
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: {
+ status: pullStatesProgressInfo.downloading,
+ downloadUrl,
+ },
+ } )
+ );
+
+ const operationId = generateStateId( selectedSiteId, remoteSiteId );
+ const filePath = await getIpcApi().downloadSyncBackup(
+ remoteSiteId,
+ downloadUrl,
+ operationId
+ );
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: {
+ status: pullStatesProgressInfo.importing,
+ },
+ } )
+ );
+
+ await getIpcApi().stopServer( selectedSiteId );
+ await getIpcApi().importSite( {
+ id: selectedSiteId,
+ backupFile: {
+ path: filePath,
+ type: 'application/tar+gzip',
+ },
+ } );
+ await getIpcApi().startServer( selectedSiteId );
+
+ await getIpcApi().removeSyncBackup( remoteSiteId );
+
+ void dispatch( connectedSitesApi.util.invalidateTags( [ 'ConnectedSites' ] ) );
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: {
+ status: pullStatesProgressInfo.finished,
+ },
+ } )
+ );
+
+ getIpcApi().showNotification( {
+ title: selectedSite.name,
+ body: sprintf(
+ // translators: %s is the site url without the protocol.
+ __( 'Studio site has been updated from %s' ),
+ getHostnameFromUrl( remoteSiteUrl )
+ ),
+ } );
+ } else {
+ // Calculate backup status with progress
+ const frontendStatus = hasBackupCompleted
+ ? pullStatesProgressInfo.downloading.key
+ : response.status;
+ let statusWithProgress = pullStatesProgressInfo[ frontendStatus ];
+ if ( response.status === 'in-progress' ) {
+ statusWithProgress = {
+ ...pullStatesProgressInfo[ frontendStatus ],
+ progress:
+ IN_PROGRESS_INITIAL_VALUE +
+ IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ),
+ };
+ }
+
+ dispatch(
+ syncOperationsActions.updatePullState( {
+ selectedSiteId,
+ remoteSiteId,
+ state: {
+ status: statusWithProgress,
+ },
+ } )
+ );
+ }
+ } catch ( error ) {
+ if ( signal.aborted ) {
+ return;
+ }
+
+ Sentry.captureException( error );
+ return rejectWithValue( {
+ title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ),
+ message: __( 'Failed to check backup file size. Please try again.' ),
+ } );
+ }
+ }
+);
+
+/**
+ * Maps an ImportResponse status to a PushStateProgressInfo object.
+ * Returns null if the operation is not in progress or unknown.
+ */
+function mapImportResponseToPushState( response: ImportResponse ): PushStateProgressInfo | null {
+ const pushStatesProgressInfo = getPushStatesProgressInfo();
+ switch ( response.status ) {
+ case 'initial_backup_started':
+ return pushStatesProgressInfo.creatingRemoteBackup;
+ case 'archive_import_started':
+ return pushStatesProgressInfo.applyingChanges;
+ case 'archive_import_finished':
+ return pushStatesProgressInfo.finishing;
+ default:
+ return null;
+ }
+}
+
+// Thunk to initialize push states from in-progress server operations on mount
+type InitializeSyncStatesPayload = {
+ client: WPCOM;
+};
+
+export const initializeSyncStatesThunk = createTypedAsyncThunk(
+ 'syncOperations/initializeSyncStates',
+ async ( { client }: InitializeSyncStatesPayload, { dispatch } ) => {
+ const allSites = await getIpcApi().getSiteDetails();
+ const allConnectedSites = await getIpcApi().getConnectedWpcomSites();
+
+ for ( const connectedSite of allConnectedSites ) {
+ try {
+ const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId );
+ const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected';
+
+ if ( ! localSite || hasConnectionErrors ) {
+ continue;
+ }
+
+ const rawResponse = await client.req.get(
+ `/sites/${ connectedSite.id }/studio-app/sync/import`,
+ { apiNamespace: 'wpcom/v2' }
+ );
+ const response = importResponseSchema.parse( rawResponse );
+
+ const status = mapImportResponseToPushState( response );
+
+ // Only restore the pushStates if the operation is still in progress
+ if ( status ) {
+ dispatch(
+ syncOperationsActions.updatePushState( {
+ selectedSiteId: connectedSite.localSiteId,
+ remoteSiteId: connectedSite.id,
+ state: {
+ status,
+ selectedSite: localSite,
+ remoteSiteUrl: connectedSite.url,
+ },
+ } )
+ );
+ }
+ } catch ( error ) {
+ // Continue checking other sites even if one fails
+ console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error );
+ }
+ }
+ }
+);
+
+// Export thunks object for convenience (must be after all thunk declarations)
+export const syncOperationsThunks = {
+ cancelPush: cancelPushThunk,
+ cancelPull: cancelPullThunk,
+ pushSite: pushSiteThunk,
+ pullSite: pullSiteThunk,
+ pollPushProgress: pollPushProgressThunk,
+ pollPullBackup: pollPullBackupThunk,
+ initializeSyncStates: initializeSyncStatesThunk,
+};
+
+// Helper functions for checking state keys (matching useSyncStatesProgressInfo logic)
+const isKeyPulling = ( key?: PullStateProgressInfo[ 'key' ] ): boolean => {
+ if ( ! key ) {
+ return false;
+ }
+ const pullingStateKeys = [ 'in-progress', 'downloading', 'importing' ];
+ return pullingStateKeys.includes( key );
+};
+
+const isKeyPushing = ( key?: PushStateProgressInfo[ 'key' ] ): boolean => {
+ if ( ! key ) {
+ return false;
+ }
+ const pushingStateKeys = [
+ 'creatingBackup',
+ 'uploading',
+ 'creatingRemoteBackup',
+ 'applyingChanges',
+ 'finishing',
+ ];
+ return pushingStateKeys.includes( key );
+};
+
+export const syncOperationsSelectors = {
+ selectPullStates: ( state: { syncOperations: SyncOperationsState } ) =>
+ state.syncOperations.pullStates,
+ selectPushStates: ( state: { syncOperations: SyncOperationsState } ) =>
+ state.syncOperations.pushStates,
+ selectPullState:
+ ( selectedSiteId: string, remoteSiteId: number ) =>
+ ( state: { syncOperations: SyncOperationsState } ): SyncBackupState | undefined => {
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ return state.syncOperations.pullStates[ stateId ] as SyncBackupState | undefined;
+ },
+ selectPushState:
+ ( selectedSiteId: string, remoteSiteId: number ) =>
+ ( state: { syncOperations: SyncOperationsState } ): SyncPushState | undefined => {
+ const stateId = generateStateId( selectedSiteId, remoteSiteId );
+ return state.syncOperations.pushStates[ stateId ] as SyncPushState | undefined;
+ },
+ selectIsAnySitePulling: ( state: { syncOperations: SyncOperationsState } ): boolean => {
+ return Object.values( state.syncOperations.pullStates ).some( ( pullState ) =>
+ isKeyPulling( pullState.status.key )
+ );
+ },
+ selectIsSiteIdPulling:
+ ( selectedSiteId: string, remoteSiteId?: number ) =>
+ ( state: { syncOperations: SyncOperationsState } ): boolean => {
+ return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => {
+ if ( ! pullState.selectedSite ) {
+ return false;
+ }
+ if ( pullState.selectedSite.id !== selectedSiteId ) {
+ return false;
+ }
+ if ( remoteSiteId !== undefined ) {
+ return isKeyPulling( pullState.status.key ) && pullState.remoteSiteId === remoteSiteId;
+ }
+ return pullState.status && isKeyPulling( pullState.status.key );
+ } );
+ },
+ selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => {
+ return Object.values( state.syncOperations.pushStates ).some( ( pushState ) =>
+ isKeyPushing( pushState.status.key )
+ );
+ },
+ selectIsSiteIdPushing:
+ ( selectedSiteId: string, remoteSiteId?: number ) =>
+ ( state: { syncOperations: SyncOperationsState } ): boolean => {
+ return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => {
+ if ( ! pushState.selectedSite ) {
+ return false;
+ }
+ if ( pushState.selectedSite.id !== selectedSiteId ) {
+ return false;
+ }
+ if ( remoteSiteId !== undefined ) {
+ return isKeyPushing( pushState.status.key ) && pushState.remoteSiteId === remoteSiteId;
+ }
+ return isKeyPushing( pushState.status.key );
+ } );
+ },
+};
diff --git a/apps/studio/src/stores/sync/sync-slice.ts b/apps/studio/src/stores/sync/sync-slice.ts
index e4bec24fb5..cc450453c8 100644
--- a/apps/studio/src/stores/sync/sync-slice.ts
+++ b/apps/studio/src/stores/sync/sync-slice.ts
@@ -2,15 +2,15 @@ import { createSlice } from '@reduxjs/toolkit';
import { TreeNode } from 'src/components/tree-view';
import { fetchRemoteFileTree } from './sync-api';
-interface RemoteFileTreeState {
+type RemoteFileTreeState = {
loading: boolean;
error: string | null;
cache: Record< string, TreeNode[] >;
-}
+};
-interface SyncState {
+type SyncState = {
remoteFileTrees: RemoteFileTreeState;
-}
+};
const initialState: SyncState = {
remoteFileTrees: {
diff --git a/apps/studio/src/stores/ui-slice.ts b/apps/studio/src/stores/ui-slice.ts
index 8607f0663a..bb043a4dad 100644
--- a/apps/studio/src/stores/ui-slice.ts
+++ b/apps/studio/src/stores/ui-slice.ts
@@ -1,9 +1,9 @@
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'src/stores';
-interface UiState {
+type UiState = {
isAddSiteModalOpen: boolean;
-}
+};
const initialState: UiState = {
isAddSiteModalOpen: false,