diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index c0329b99d7..6683acda5c 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -9,6 +9,8 @@ import { NoStudioSites } from 'src/components/no-studio-sites'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; +import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; +import { useAuth } from 'src/hooks/use-auth'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -19,8 +21,9 @@ import { Onboarding } from 'src/modules/onboarding'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; import { UserSettings } from 'src/modules/user-settings'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; -import { useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; +import { syncOperationsThunks } from 'src/stores/sync'; import 'src/index.css'; export default function App() { @@ -32,6 +35,17 @@ export default function App() { const { sites: localSites, loadingSites } = useSiteDetails(); const isEmpty = ! loadingSites && ! localSites.length; const shouldShowWhatsNew = showWhatsNew && ! isEmpty; + const { client } = useAuth(); + const dispatch = useAppDispatch(); + + // Initialize sync states from in-progress server operations + useEffect( () => { + if ( client ) { + void dispatch( syncOperationsThunks.initializeSyncStates( { client } ) ); + } + }, [ client, dispatch ] ); + + useListenDeepLinkConnection(); useEffect( () => { void getIpcApi().setupAppMenu( { needsOnboarding } ); diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 534645ca08..6b58ea1950 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -12,7 +12,6 @@ import { LearnMoreLink } from 'src/components/learn-more'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { ACCEPTED_IMPORT_FILE_TYPES } from 'src/constants'; -import { useSyncSites } from 'src/hooks/sync-sites/sync-sites-context'; import { useAuth } from 'src/hooks/use-auth'; import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; @@ -20,6 +19,8 @@ import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; interface ContentTabImportExportProps { @@ -343,14 +344,21 @@ const ImportSite = ( { export function ContentTabImportExport( { selectedSite }: ContentTabImportExportProps ) { const { __ } = useI18n(); const [ isSupported, setIsSupported ] = useState< boolean | null >( null ); - const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); const { user } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); - const isPulling = connectedSites.some( ( site ) => isSiteIdPulling( selectedSite.id, site.id ) ); - const isPushing = connectedSites.some( ( site ) => isSiteIdPushing( selectedSite.id, site.id ) ); + const isPulling = useRootSelector( ( state ) => + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) + ) + ); + const isPushing = useRootSelector( ( state ) => + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) + ) + ); const isThisSiteSyncing = isPulling || isPushing; useEffect( () => { diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index f44ed174d9..0f049f42df 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -1,12 +1,13 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; -import { useSyncSites } from 'src/hooks/sync-sites'; import { useAuth } from 'src/hooks/use-auth'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { generateCheckoutUrl } from 'src/lib/generate-checkout-url'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; export const PublishSiteButton = () => { @@ -17,7 +18,8 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const { isAnySitePulling, isAnySitePushing } = useSyncSites(); + const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; const handlePublishClick = useCallback( () => { diff --git a/apps/studio/src/components/root.tsx b/apps/studio/src/components/root.tsx index b80d7f3a86..d23e6b0959 100644 --- a/apps/studio/src/components/root.tsx +++ b/apps/studio/src/components/root.tsx @@ -9,7 +9,6 @@ import AuthProvider from 'src/components/auth-provider'; import CrashTester from 'src/components/crash-tester'; import ErrorBoundary from 'src/components/error-boundary'; import { WordPressStyles } from 'src/components/wordpress-styles'; -import { SyncSitesProvider } from 'src/hooks/sync-sites/sync-sites-context'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { FeatureFlagsProvider } from 'src/hooks/use-feature-flags'; import { ImportExportProvider } from 'src/hooks/use-import-export'; @@ -43,9 +42,7 @@ const Root = () => { - - - + diff --git a/apps/studio/src/components/settings-site-menu.tsx b/apps/studio/src/components/settings-site-menu.tsx index b858f3e2e5..99a95edde0 100644 --- a/apps/studio/src/components/settings-site-menu.tsx +++ b/apps/studio/src/components/settings-site-menu.tsx @@ -1,6 +1,7 @@ import { MenuItem } from '@wordpress/components'; -import { useSyncSites } from 'src/hooks/sync-sites'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; type SettingsMenuItemProps = { onClick: () => void; @@ -14,12 +15,16 @@ export const SettingsMenuItem = ( { isDestructive = false, }: SettingsMenuItemProps ) => { const { isDeleting, sites, selectedSite } = useSiteDetails(); - const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); + const isPulling = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) + ); + const isPushing = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite?.id ?? '' ) + ); if ( ! selectedSite ) { return null; } - const isThisSiteSyncing = - isSiteIdPulling( selectedSite.id ) || isSiteIdPushing( selectedSite.id ); + const isThisSiteSyncing = isPulling || isPushing; const isAddingSite = sites.some( ( site ) => site.isAddingSite ); const isDisabled = isDeleting || isThisSiteSyncing || isAddingSite; diff --git a/apps/studio/src/components/site-management-actions.tsx b/apps/studio/src/components/site-management-actions.tsx index 91a75b6d14..7c150db4be 100644 --- a/apps/studio/src/components/site-management-actions.tsx +++ b/apps/studio/src/components/site-management-actions.tsx @@ -3,8 +3,9 @@ import { useI18n } from '@wordpress/react-i18n'; import { ActionButton } from 'src/components/action-button'; import { PublishSiteButton } from 'src/components/publish-site-button'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncSites } from 'src/hooks/sync-sites'; import { useImportExport } from 'src/hooks/use-import-export'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; export interface SiteManagementActionProps { onStop: ( id: string ) => Promise< void >; @@ -21,14 +22,15 @@ export const SiteManagementActions = ( { }: SiteManagementActionProps ) => { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const { isSiteIdPulling } = useSyncSites(); + const isPulling = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) + ); if ( ! selectedSite ) { return null; } const isImporting = isSiteImporting( selectedSite.id ); - const isPulling = isSiteIdPulling( selectedSite.id ); const disabled = isImporting || isPulling; let buttonLabelOnDisabled: string = __( 'Importing…' ); diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 7c3c1b44bb..e2ed09ad0d 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -5,7 +5,6 @@ import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useState } from 'react'; import { XDebugIcon } from 'src/components/icons/xdebug-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncSites } from 'src/hooks/sync-sites'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useImportExport } from 'src/hooks/use-import-export'; @@ -15,7 +14,9 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useRootSelector } from 'src/stores'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; +import { syncOperationsSelectors } from 'src/stores/sync'; interface SiteMenuProps { className?: string; @@ -158,13 +159,12 @@ function SiteItem( { useSiteDetails(); const isSelected = site === selectedSite; const { isSiteImporting, isSiteExporting } = useImportExport(); - const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); const { data: editor } = useGetUserEditorQuery(); const { data: terminal } = useGetUserTerminalQuery(); const isImporting = isSiteImporting( site.id ); const isExporting = isSiteExporting( site.id ); - const isPulling = isSiteIdPulling( site.id ); - const isPushing = isSiteIdPushing( site.id ); + const isPulling = useRootSelector( syncOperationsSelectors.selectIsSiteIdPulling( site.id ) ); + const isPushing = useRootSelector( syncOperationsSelectors.selectIsSiteIdPushing( site.id ) ); const isSyncing = isPulling || isPushing; const isDeleting = isSiteDeleting( site.id ); const showSpinner = diff --git a/apps/studio/src/components/tests/app.test.tsx b/apps/studio/src/components/tests/app.test.tsx index a2517fbf5c..e4dbe546c5 100644 --- a/apps/studio/src/components/tests/app.test.tsx +++ b/apps/studio/src/components/tests/app.test.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { vi, type Mock } from 'vitest'; import App from 'src/components/app'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; @@ -110,9 +109,7 @@ describe( 'App', () => { } ); return render( - - { component } - + { component } ); }; diff --git a/apps/studio/src/components/tests/content-tab-import-export.test.tsx b/apps/studio/src/components/tests/content-tab-import-export.test.tsx index 7789c71c14..2098e415a9 100644 --- a/apps/studio/src/components/tests/content-tab-import-export.test.tsx +++ b/apps/studio/src/components/tests/content-tab-import-export.test.tsx @@ -4,7 +4,6 @@ import { act } from 'react'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { ContentTabImportExport } from 'src/components/content-tab-import-export'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -49,9 +48,7 @@ beforeEach( () => { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/apps/studio/src/components/tests/header.test.tsx b/apps/studio/src/components/tests/header.test.tsx index dea742013e..0c4ac0e631 100644 --- a/apps/studio/src/components/tests/header.test.tsx +++ b/apps/studio/src/components/tests/header.test.tsx @@ -3,7 +3,6 @@ import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi, beforeAll, type Mock } from 'vitest'; import Header from 'src/components/header'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { SiteDetailsProvider } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -46,9 +45,7 @@ const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index d1fcd2200a..4b525ef100 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -3,7 +3,6 @@ import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import MainSidebar from 'src/components/main-sidebar'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; @@ -102,9 +101,7 @@ vi.mock( 'src/hooks/use-site-details', () => ( { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index d8777d6a10..ed701a3c35 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -2,7 +2,6 @@ import { act, render, screen } from '@testing-library/react'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { SiteContentTabs } from 'src/components/site-content-tabs'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { store } from 'src/stores'; @@ -76,9 +75,7 @@ describe( 'SiteContentTabs', () => { const renderWithProvider = ( component: React.ReactElement ) => { return render( - - { component } - + { component } ); }; diff --git a/apps/studio/src/components/tests/site-management-actions.test.tsx b/apps/studio/src/components/tests/site-management-actions.test.tsx index 6b66713ca1..603146436f 100644 --- a/apps/studio/src/components/tests/site-management-actions.test.tsx +++ b/apps/studio/src/components/tests/site-management-actions.test.tsx @@ -6,7 +6,6 @@ import { SiteManagementActionProps, SiteManagementActions, } from 'src/components/site-management-actions'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; @@ -55,9 +54,7 @@ describe( 'SiteManagementActions', () => { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/apps/studio/src/hooks/sync-sites/index.ts b/apps/studio/src/hooks/sync-sites/index.ts index 51d889db40..7196b0a333 100644 --- a/apps/studio/src/hooks/sync-sites/index.ts +++ b/apps/studio/src/hooks/sync-sites/index.ts @@ -1 +1,2 @@ -export * from './sync-sites-context'; +export { useLastSyncTimeText } from './use-last-sync-time-text'; +export type { GetLastSyncTimeText } from './use-last-sync-time-text'; diff --git a/apps/studio/src/hooks/sync-sites/sync-sites-context.tsx b/apps/studio/src/hooks/sync-sites/sync-sites-context.tsx deleted file mode 100644 index 220edcfd8f..0000000000 --- a/apps/studio/src/hooks/sync-sites/sync-sites-context.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; -import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; -import { PullStates, UseSyncPull, useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { - PushStates, - UseSyncPush, - useSyncPush, - mapImportResponseToPushState, -} from 'src/hooks/sync-sites/use-sync-push'; -import { useAuth } from 'src/hooks/use-auth'; -import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; -import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useUpdateSiteTimestampMutation } from 'src/stores/sync/connected-sites'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; - -type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; - -export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > & - Omit< UseSyncPush, 'pushStates' > & { - getLastSyncTimeText: GetLastSyncTimeText; - }; - -const SyncSitesContext = createContext< SyncSitesContextType | undefined >( undefined ); - -export function SyncSitesProvider( { children }: { children: React.ReactNode } ) { - const { formatRelativeTime } = useFormatLocalizedTimestamps(); - const [ pullStates, setPullStates ] = useState< PullStates >( {} ); - - const getLastSyncTimeText = useCallback< GetLastSyncTimeText >( - ( timestamp, type ) => { - if ( ! timestamp ) { - return type === 'pull' - ? __( 'You have not pulled this site yet.' ) - : __( 'You have not pushed this site yet.' ); - } - - return sprintf( - type === 'pull' - ? __( 'You pulled this site %s ago.' ) - : __( 'You pushed this site %s ago.' ), - formatRelativeTime( timestamp ) - ); - }, - [ formatRelativeTime ] - ); - - const [ updateSiteTimestamp ] = useUpdateSiteTimestampMutation(); - - const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = - useSyncPull( { - pullStates, - setPullStates, - onPullSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'pull' } ), - } ); - - const [ pushStates, setPushStates ] = useState< PushStates >( {} ); - const { - pushSite, - isAnySitePushing, - isSiteIdPushing, - clearPushState, - getPushState, - cancelPush, - pauseUpload, - resumeUpload, - } = useSyncPush( { - pushStates, - setPushStates, - onPushSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'push' } ), - } ); - - useListenDeepLinkConnection(); - - const { client } = useAuth(); - const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); - - // Initialize push states from in-progress server operations on mount - useEffect( () => { - if ( ! client ) { - return; - } - - const initializePushStates = async () => { - const allSites = await getIpcApi().getSiteDetails(); - const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); - - const restoredStates: PushStates = {}; - - 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 response = await client.req.get< ImportResponse >( - `/sites/${ connectedSite.id }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } - ); - - const status = mapImportResponseToPushState( response, pushStatesProgressInfo ); - - // Only restore the pushStates if the operation is still in progress - if ( status ) { - const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); - restoredStates[ stateId ] = { - remoteSiteId: connectedSite.id, - status, - selectedSite: localSite, - remoteSiteUrl: connectedSite.url, - }; - - getIpcApi().addSyncOperation( stateId, status ); - } - } catch ( error ) { - // Continue checking other sites even if one fails - console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); - } - } - - if ( Object.keys( restoredStates ).length > 0 ) { - setPushStates( ( prev ) => ( { ...prev, ...restoredStates } ) ); - } - }; - - initializePushStates().catch( ( error ) => { - // Initialization is not critical to app functionality, but log the error - console.error( 'Failed to initialize push states from server:', error ); - } ); - }, [ client, pushStatesProgressInfo ] ); - - return ( - - { children } - - ); -} - -export function useSyncSites() { - const context = useContext( SyncSitesContext ); - if ( context === undefined ) { - throw new Error( 'useSyncSites must be used within a SyncSitesProvider' ); - } - return context; -} diff --git a/apps/studio/src/hooks/sync-sites/use-last-sync-time-text.ts b/apps/studio/src/hooks/sync-sites/use-last-sync-time-text.ts new file mode 100644 index 0000000000..3dac586efa --- /dev/null +++ b/apps/studio/src/hooks/sync-sites/use-last-sync-time-text.ts @@ -0,0 +1,30 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { useCallback } from 'react'; +import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; + +export type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; + +/** + * Hook that returns a function to format the last sync time text. + */ +export function useLastSyncTimeText(): GetLastSyncTimeText { + const { formatRelativeTime } = useFormatLocalizedTimestamps(); + + return useCallback< GetLastSyncTimeText >( + ( timestamp, type ) => { + if ( ! timestamp ) { + return type === 'pull' + ? __( 'You have not pulled this site yet.' ) + : __( 'You have not pushed this site yet.' ); + } + + return sprintf( + type === 'pull' + ? __( 'You pulled this site %s ago.' ) + : __( 'You pushed this site %s ago.' ), + formatRelativeTime( timestamp ) + ); + }, + [ formatRelativeTime ] + ); +} diff --git a/apps/studio/src/hooks/sync-sites/use-sync-pull.ts b/apps/studio/src/hooks/sync-sites/use-sync-pull.ts deleted file mode 100644 index d67cc238f4..0000000000 --- a/apps/studio/src/hooks/sync-sites/use-sync-pull.ts +++ /dev/null @@ -1,454 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; -import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, - usePullPushStates, -} from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { useImportExport } from 'src/hooks/use-import-export'; -import { useSiteDetails } from 'src/hooks/use-site-details'; -import { - PullStateProgressInfo, - SyncBackupResponse, - useSyncStatesProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; -import type { SyncSite } from 'src/modules/sync/types'; -import type { SyncOption } from 'src/types'; - -type SyncBackupState = { - remoteSiteId: number; - backupId: string | 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 >; -type OnPullSuccess = ( siteId: number, localSiteId: string ) => void; -type PullSite = ( - connectedSite: SyncSite, - selectedSite: SiteDetails, - options: PullSiteOptions -) => void; -type IsSiteIdPulling = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type UseSyncPullProps = { - pullStates: PullStates; - setPullStates: React.Dispatch< React.SetStateAction< PullStates > >; - onPullSuccess?: OnPullSuccess; -}; - -type CancelPull = ( selectedSiteId: string, remoteSiteId: number ) => void; - -export type UseSyncPull = { - pullStates: PullStates; - getPullState: GetState< SyncBackupState >; - pullSite: PullSite; - isAnySitePulling: boolean; - isSiteIdPulling: IsSiteIdPulling; - clearPullState: ClearState; - cancelPull: CancelPull; -}; - -export function useSyncPull( { - pullStates, - setPullStates, - onPullSuccess, -}: UseSyncPullProps ): UseSyncPull { - const { __ } = useI18n(); - const { client } = useAuth(); - const { importFile, clearImportState } = useImportExport(); - const { - pullStatesProgressInfo, - isKeyPulling, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getBackupStatusWithProgress, - } = useSyncStatesProgressInfo(); - const { - updateState, - getState: getPullState, - clearState, - } = usePullPushStates< SyncBackupState >( pullStates, setPullStates ); - - const updatePullState = useCallback< UpdateState< SyncBackupState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else { - getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } - }, - [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] - ); - - const clearPullState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - }, - [ clearState ] - ); - - const { startServer } = useSiteDetails(); - - const pullSite = useCallback< PullSite >( - async ( connectedSite, selectedSite, options ) => { - if ( ! client ) { - return; - } - - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - - clearPullState( selectedSite.id, remoteSiteId ); - updatePullState( selectedSite.id, remoteSiteId, { - backupId: null, - status: pullStatesProgressInfo[ 'in-progress' ], - downloadUrl: null, - remoteSiteId, - remoteSiteUrl, - selectedSite, - } ); - - try { - // Initializing backup on remote - const requestBody: { - options: SyncOption[]; - include_path_list: PullSiteOptions[ 'include_path_list' ]; - } = { - options: options.optionsToSync, - include_path_list: options.include_path_list, - }; - - const response = await client.req.post< { success: boolean; backup_id: string } >( { - path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, - apiNamespace: 'wpcom/v2', - body: requestBody, - } ); - - if ( response.success ) { - updatePullState( selectedSite.id, remoteSiteId, { - backupId: response.backup_id, - } ); - } else { - console.error( response ); - throw new Error( 'Pull request failed' ); - } - } catch ( error ) { - console.error( 'Pull request failed:', error ); - - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), - message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), - } ); - } - }, - [ __, clearPullState, client, pullStatesProgressInfo, updatePullState ] - ); - - const checkBackupFileSize = async ( downloadUrl: string ): Promise< number > => { - try { - return await getIpcApi().checkSyncBackupSize( downloadUrl ); - } catch ( error ) { - console.error( 'Failed to check backup file size', error ); - Sentry.captureException( error ); - throw new Error( 'Failed to check backup file size' ); - } - }; - - const onBackupCompleted = useCallback( - async ( remoteSiteId: number, backupState: SyncBackupState & { downloadUrl: string } ) => { - const { downloadUrl, selectedSite, remoteSiteUrl } = backupState; - - try { - const fileSize = await checkBackupFileSize( 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 ) { - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.cancelled, - } ); - clearPullState( selectedSite.id, remoteSiteId ); - return; - } - } - - // Initiating backup file download - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.downloading, - downloadUrl, - } ); - - const operationId = generateStateId( selectedSite.id, remoteSiteId ); - const filePath = await getIpcApi().downloadSyncBackup( - remoteSiteId, - downloadUrl, - operationId - ); - - const stateAfterDownload = getPullState( selectedSite.id, remoteSiteId ); - if ( ! stateAfterDownload || isKeyCancelled( stateAfterDownload?.status.key ) ) { - return; - } - - // Starting import process - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.importing, - } ); - - await importFile( - { - path: filePath, - type: 'application/tar+gzip', - }, - selectedSite, - { showImportNotification: false } - ); - - await getIpcApi().removeSyncBackup( remoteSiteId ); - - await startServer( selectedSite ); - - clearImportState( selectedSite.id ); - - // Sync pull operation completed successfully - updatePullState( selectedSite.id, remoteSiteId, { - 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 ) - ), - } ); - - onPullSuccess?.( remoteSiteId, selectedSite.id ); - } catch ( error ) { - console.error( 'Backup completion failed:', error ); - - const currentState = getPullState( selectedSite.id, remoteSiteId ); - if ( currentState && isKeyCancelled( currentState?.status.key ) ) { - return; - } - - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), selectedSite.name ), - message: __( 'Failed to check backup file size. Please try again.' ), - } ); - } - }, - [ - __, - clearImportState, - clearPullState, - getPullState, - importFile, - onPullSuccess, - isKeyCancelled, - pullStatesProgressInfo.cancelled, - pullStatesProgressInfo.downloading, - pullStatesProgressInfo.failed, - pullStatesProgressInfo.finished, - pullStatesProgressInfo.importing, - startServer, - updatePullState, - ] - ); - - const fetchAndUpdateBackup = useCallback( - async ( remoteSiteId: number, selectedSiteId: string ) => { - if ( ! client ) { - return; - } - - const currentState = getPullState( selectedSiteId, remoteSiteId ); - if ( currentState && isKeyCancelled( currentState.status.key ) ) { - return; - } - - const backupId = currentState?.backupId; - if ( ! backupId ) { - console.error( 'No backup ID found' ); - return; - } - - try { - const response = await client.req.get< SyncBackupResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/backup`, - { - apiNamespace: 'wpcom/v2', - backup_id: backupId, - } - ); - - const hasBackupCompleted = response.status === 'finished'; - const downloadUrl = hasBackupCompleted ? response.download_url : null; - - if ( downloadUrl ) { - // Replacing the 'in-progress' status will stop the active listening for the backup completion - const backupState = getPullState( selectedSiteId, remoteSiteId ); - if ( backupState ) { - await onBackupCompleted( remoteSiteId, { - ...backupState, - downloadUrl, - } ); - } - } else { - const statusWithProgress = getBackupStatusWithProgress( - hasBackupCompleted, - pullStatesProgressInfo, - response - ); - - updatePullState( selectedSiteId, remoteSiteId, { - status: statusWithProgress, - downloadUrl, - } ); - } - } catch ( error ) { - console.error( 'Failed to fetch backup status:', error ); - throw error; - } - }, - [ - client, - getBackupStatusWithProgress, - getPullState, - onBackupCompleted, - pullStatesProgressInfo, - updatePullState, - isKeyCancelled, - ] - ); - - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pullStates ).forEach( ( [ key, state ] ) => { - if ( ! state.status ) { - Sentry.captureMessage( 'Pull state missing status', { - level: 'warning', - extra: { stateKey: key, stateKeys: Object.keys( state ) }, - } ); - return; - } - - if ( isKeyCancelled( state.status.key ) ) { - return; - } - - if ( state.backupId && state.status.key === 'in-progress' ) { - intervals[ key ] = setTimeout( () => { - void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); - }, 2000 ); - } - } ); - - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ pullStates, fetchAndUpdateBackup, isKeyCancelled ] ); - - const isAnySitePulling = useMemo< boolean >( () => { - return Object.values( pullStates ).some( ( state ) => isKeyPulling( state.status?.key ) ); - }, [ pullStates, isKeyPulling ] ); - - const isSiteIdPulling = useCallback< IsSiteIdPulling >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pullStates ).some( ( state ) => { - if ( ! state.selectedSite ) { - return false; - } - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPulling( state.status?.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPulling( state.status?.key ); - } ); - }, - [ pullStates, isKeyPulling ] - ); - - const cancelPull = useCallback< CancelPull >( - async ( selectedSiteId, remoteSiteId ) => { - const operationId = generateStateId( selectedSiteId, remoteSiteId ); - - getIpcApi().cancelSyncOperation( operationId ); - - updatePullState( selectedSiteId, remoteSiteId, { - status: pullStatesProgressInfo.cancelled, - } ); - - getIpcApi() - .removeSyncBackup( remoteSiteId ) - .catch( () => { - // Ignore errors if file doesn't exist - } ); - - getIpcApi().showNotification( { - title: __( 'Pull cancelled' ), - body: __( 'The pull operation has been cancelled.' ), - } ); - }, - [ __, pullStatesProgressInfo.cancelled, updatePullState ] - ); - - return { - pullStates, - getPullState, - pullSite, - isAnySitePulling, - isSiteIdPulling, - clearPullState, - cancelPull, - }; -} diff --git a/apps/studio/src/hooks/sync-sites/use-sync-push.ts b/apps/studio/src/hooks/sync-sites/use-sync-push.ts deleted file mode 100644 index 2b9a047492..0000000000 --- a/apps/studio/src/hooks/sync-sites/use-sync-push.ts +++ /dev/null @@ -1,529 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; -import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, - usePullPushStates, -} from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { useIpcListener } from 'src/hooks/use-ipc-listener'; -import { - useSyncStatesProgressInfo, - PushStateProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; -import type { SyncSite } from 'src/modules/sync/types'; -import type { SyncOption } from 'src/types'; - -export type SyncPushState = { - remoteSiteId: number; - status: PushStateProgressInfo; - selectedSite: SiteDetails; - remoteSiteUrl: string; - uploadProgress?: number; -}; - -type PushSiteOptions = { - optionsToSync?: SyncOption[]; - specificSelectionPaths?: string[]; -}; - -export type PushStates = Record< string, SyncPushState >; -type OnPushSuccess = ( siteId: number, localSiteId: string ) => void; -type PushSite = ( - connectedSite: SyncSite, - selectedSite: SiteDetails, - options?: PushSiteOptions -) => Promise< void >; -type IsSiteIdPushing = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type UseSyncPushProps = { - pushStates: PushStates; - setPushStates: React.Dispatch< React.SetStateAction< PushStates > >; - onPushSuccess?: OnPushSuccess; -}; - -type CancelPush = ( selectedSiteId: string, remoteSiteId: number ) => void; -type PauseUpload = ( selectedSiteId: string, remoteSiteId: number ) => Promise< boolean >; -type ResumeUpload = ( selectedSiteId: string, remoteSiteId: number ) => Promise< boolean >; - -export type UseSyncPush = { - pushStates: PushStates; - getPushState: GetState< SyncPushState >; - pushSite: PushSite; - isAnySitePushing: boolean; - isSiteIdPushing: IsSiteIdPushing; - clearPushState: ClearState; - cancelPush: CancelPush; - pauseUpload: PauseUpload; - resumeUpload: ResumeUpload; -}; - -/** - * Maps an ImportResponse status to a PushStateProgressInfo object. - * Returns null if the operation is not in progress or unknown. - */ -export function mapImportResponseToPushState( - response: ImportResponse, - pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo > -): PushStateProgressInfo | null { - if ( response.status === 'initial_backup_started' ) { - return pushStatesProgressInfo.creatingRemoteBackup; - } - - if ( response.status === 'archive_import_started' ) { - return pushStatesProgressInfo.applyingChanges; - } - - if ( response.status === 'archive_import_finished' ) { - return pushStatesProgressInfo.finishing; - } - - return null; -} - -export function useSyncPush( { - pushStates, - setPushStates, - onPushSuccess, -}: UseSyncPushProps ): UseSyncPush { - const { __ } = useI18n(); - const { client } = useAuth(); - const { - updateState, - getState: getPushState, - clearState, - } = usePullPushStates< SyncPushState >( pushStates, setPushStates ); - const { - pushStatesProgressInfo, - isKeyPushing, - isKeyUploading, - isKeyImporting, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getPushStatusWithProgress, - mapUploadProgressToOverallProgress, - } = useSyncStatesProgressInfo(); - - const updatePushState = useCallback< UpdateState< SyncPushState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else if ( state.status ) { - getIpcApi().addSyncOperation( - generateStateId( selectedSiteId, remoteSiteId ), - state.status - ); - } - }, - [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] - ); - - const clearPushState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - }, - [ clearState ] - ); - - const getPushProgressInfo = useCallback( - async ( remoteSiteId: number, syncPushState: SyncPushState ) => { - if ( ! client ) { - return; - } - const currentState = getPushState( syncPushState.selectedSite.id, remoteSiteId ); - - if ( ! currentState || isKeyCancelled( currentState?.status.key ) ) { - return; - } - - let response: ImportResponse; - try { - response = await client.req.get< ImportResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } - ); - } catch ( error ) { - // Skip Sentry reporting for expected network errors (crossDomain errors). The client throws this error - // when the user is offline. - if ( - error instanceof Error && - 'crossDomain' in error && - ( error as Error & { crossDomain?: boolean } ).crossDomain - ) { - return; - } - - Sentry.captureException( error ); - return; - } - - let status: PushStateProgressInfo = pushStatesProgressInfo.creatingRemoteBackup; - if ( response.success && response.status === 'finished' ) { - status = pushStatesProgressInfo.finished; - onPushSuccess?.( remoteSiteId, syncPushState.selectedSite.id ); - getIpcApi().showNotification( { - title: syncPushState.selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( '%s has been updated' ), - getHostnameFromUrl( syncPushState.remoteSiteUrl ) - ), - } ); - } else if ( response.success && response.status === 'failed' ) { - status = pushStatesProgressInfo.failed; - console.error( 'Push import failed:', { - remoteSiteId: syncPushState.remoteSiteId, - error: response.error, - error_data: response.error_data, - } ); - // If the impport 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.' - ); - } - - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), syncPushState.selectedSite.name ), - message, - showOpenLogs: true, - } ); - } else if ( response.success && response.status === 'archive_import_started' ) { - status = pushStatesProgressInfo.applyingChanges; - } else if ( response.success && response.status === 'archive_import_finished' ) { - status = pushStatesProgressInfo.finishing; - } - status = getPushStatusWithProgress( status, response ); - // Update state in any case to keep polling push state - updatePushState( syncPushState.selectedSite.id, syncPushState.remoteSiteId, { - status, - } ); - }, - [ - __, - client, - getPushState, - getPushStatusWithProgress, - onPushSuccess, - pushStatesProgressInfo.applyingChanges, - pushStatesProgressInfo.creatingRemoteBackup, - pushStatesProgressInfo.finishing, - pushStatesProgressInfo.failed, - pushStatesProgressInfo.finished, - updatePushState, - isKeyCancelled, - ] - ); - - const getErrorFromResponse = useCallback( - ( error: unknown ): string => { - if ( - typeof error === 'object' && - error !== null && - 'error' in error && - typeof ( error as { error: unknown } ).error === 'string' - ) { - return ( error as { error: string } ).error; - } - - return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); - }, - [ __ ] - ); - - const pushSite = useCallback< PushSite >( - async ( connectedSite, selectedSite, options ) => { - if ( ! client ) { - return; - } - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - const operationId = generateStateId( selectedSite.id, remoteSiteId ); - - clearPushState( selectedSite.id, remoteSiteId ); - updatePushState( selectedSite.id, remoteSiteId, { - remoteSiteId, - status: pushStatesProgressInfo.creatingBackup, - selectedSite, - remoteSiteUrl, - } ); - - let archivePath: string, archiveSizeInBytes: number; - - try { - const result = await getIpcApi().exportSiteForPush( selectedSite.id, operationId, { - optionsToSync: options?.optionsToSync, - specificSelectionPaths: options?.specificSelectionPaths, - } ); - ( { archivePath, archiveSizeInBytes } = result ); - } catch ( error ) { - if ( error instanceof Error && error.message.includes( 'Export aborted' ) ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); - return; - } - - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ), - error, - showOpenLogs: true, - } ); - return; - } - - if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - 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.' - ), - } ); - await getIpcApi().removeExportedSiteTmpFile( archivePath ); - return; - } - - const stateBeforeUpload = getPushState( selectedSite.id, remoteSiteId ); - - if ( ! stateBeforeUpload || isKeyCancelled( stateBeforeUpload?.status.key ) ) { - return; - } - - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.uploading, - } ); - - try { - const response = await getIpcApi().pushArchive( - selectedSite.id, - remoteSiteId, - archivePath, - options?.optionsToSync, - options?.specificSelectionPaths - ); - const stateAfterUpload = getPushState( selectedSite.id, remoteSiteId ); - - if ( isKeyCancelled( stateAfterUpload?.status.key ) ) { - return; - } - - if ( response.success ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.creatingRemoteBackup, - uploadProgress: undefined, // Clear upload progress when transitioning to next state - } ); - } else { - throw response; - } - } catch ( error ) { - if ( error instanceof Error && error.message.includes( 'Export aborted' ) ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); - return; - } - - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: getErrorFromResponse( error ), - } ); - } finally { - await getIpcApi().removeExportedSiteTmpFile( archivePath ); - } - }, - [ - __, - clearPushState, - client, - getPushState, - pushStatesProgressInfo, - updatePushState, - getErrorFromResponse, - isKeyCancelled, - ] - ); - - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pushStates ).forEach( ( [ key, state ] ) => { - if ( ! state.status ) { - Sentry.captureMessage( 'Push state missing status', { - level: 'warning', - extra: { stateKey: key, stateKeys: Object.keys( state ) }, - } ); - return; - } - - if ( isKeyCancelled( state.status.key ) ) { - return; - } - - if ( isKeyImporting( state.status.key ) ) { - intervals[ key ] = setTimeout( () => { - void getPushProgressInfo( state.remoteSiteId, state ); - }, 2000 ); - } - } ); - - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ - pushStates, - getPushProgressInfo, - pushStatesProgressInfo.creatingBackup.key, - pushStatesProgressInfo.applyingChanges.key, - isKeyImporting, - isKeyCancelled, - ] ); - - useIpcListener( - 'sync-upload-network-paused', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number; error: string } ) => { - updatePushState( payload.selectedSiteId, payload.remoteSiteId, { - status: pushStatesProgressInfo.uploadingPaused, - } ); - } - ); - - useIpcListener( - 'sync-upload-manually-paused', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number } ) => { - const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); - updatePushState( payload.selectedSiteId, payload.remoteSiteId, { - status: pushStatesProgressInfo.uploadingManuallyPaused, - uploadProgress: currentState?.uploadProgress, - } ); - } - ); - - useIpcListener( - 'sync-upload-resumed', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number } ) => { - const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); - updatePushState( payload.selectedSiteId, payload.remoteSiteId, { - status: pushStatesProgressInfo.uploading, - uploadProgress: currentState?.uploadProgress, - } ); - } - ); - - useIpcListener( - 'sync-upload-progress', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number; progress: number } ) => { - const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); - if ( currentState && isKeyUploading( currentState.status?.key ) ) { - const mappedProgress = mapUploadProgressToOverallProgress( payload.progress ); - - updatePushState( payload.selectedSiteId, payload.remoteSiteId, { - status: { - ...currentState.status, - progress: mappedProgress, - }, - uploadProgress: payload.progress, - } ); - } - } - ); - - const isAnySitePushing = useMemo< boolean >( () => { - return Object.values( pushStates ).some( ( state ) => isKeyPushing( state.status?.key ) ); - }, [ pushStates, isKeyPushing ] ); - - const isSiteIdPushing = useCallback< IsSiteIdPushing >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pushStates ).some( ( state ) => { - if ( ! state.selectedSite ) { - return false; - } - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPushing( state.status?.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPushing( state.status?.key ); - } ); - }, - [ pushStates, isKeyPushing ] - ); - - const cancelPush = useCallback< CancelPush >( - async ( selectedSiteId, remoteSiteId ) => { - const operationId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().cancelSyncOperation( operationId ); - - updatePushState( selectedSiteId, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); - - getIpcApi().showNotification( { - title: __( 'Push cancelled' ), - body: __( 'The push operation has been cancelled.' ), - } ); - }, - [ __, pushStatesProgressInfo.cancelled, updatePushState ] - ); - - const pauseUpload = useCallback< PauseUpload >( async ( selectedSiteId, remoteSiteId ) => { - return getIpcApi().pauseSyncUpload( selectedSiteId, remoteSiteId ); - }, [] ); - - const resumeUpload = useCallback< ResumeUpload >( async ( selectedSiteId, remoteSiteId ) => { - return getIpcApi().resumeSyncUpload( selectedSiteId, remoteSiteId ); - }, [] ); - - return { - pushStates, - getPushState, - pushSite, - isAnySitePushing, - isSiteIdPushing, - clearPushState, - cancelPush, - pauseUpload, - resumeUpload, - }; -} diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index ffa99d2306..d83ea3df51 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -3,19 +3,31 @@ import { renderHook, act } from '@testing-library/react'; import nock from 'nock'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; -import { useSyncSites } from 'src/hooks/sync-sites'; import { useAddSite, CreateSiteFormValues } from 'src/hooks/use-add-site'; +import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { store } from 'src/stores'; import { setProviderConstants } from 'src/stores/provider-constants-slice'; -import type { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context'; import type { SyncSite } from 'src/modules/sync/types'; vi.mock( 'src/hooks/use-site-details' ); vi.mock( 'src/hooks/use-feature-flags' ); -vi.mock( 'src/hooks/sync-sites' ); +vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/hooks/use-content-tabs' ); + +const mockPullSiteThunk = vi.hoisted( () => vi.fn() ); + +vi.mock( 'src/stores/sync', async () => { + const actual = await vi.importActual( 'src/stores/sync' ); + return { + ...actual, + syncOperationsThunks: { + ...actual.syncOperationsThunks, + pullSite: mockPullSiteThunk, + }, + }; +} ); vi.mock( 'src/hooks/use-import-export', () => ( { useImportExport: () => ( { importFile: vi.fn(), @@ -56,11 +68,14 @@ describe( 'useAddSite', () => { const mockCreateSite = vi.fn(); const mockUpdateSite = vi.fn(); const mockStartServer = vi.fn(); - const mockPullSite = vi.fn(); + const mockClient = { req: { get: vi.fn(), post: vi.fn() } }; const mockSetSelectedTab = vi.fn(); beforeEach( () => { vi.clearAllMocks(); + mockPullSiteThunk.mockImplementation( () => ( { + type: 'syncOperations/pullSite', + } ) ); // Prepopulate store with provider constants store.dispatch( @@ -87,24 +102,9 @@ describe( 'useAddSite', () => { startServer: mockStartServer, } ); - mockPullSite.mockReset(); - vi.mocked( useSyncSites, { partial: true } ).mockReturnValue( { - pullSite: mockPullSite, - isAnySitePulling: false, - isSiteIdPulling: vi.fn(), - clearPullState: vi.fn(), - cancelPull: vi.fn(), - getPullState: vi.fn(), - pushSite: vi.fn(), - isAnySitePushing: false, - isSiteIdPushing: vi.fn(), - clearPushState: vi.fn(), - getPushState: vi.fn(), - getLastSyncTimeText: vi.fn(), - cancelPush: vi.fn(), - pauseUpload: vi.fn(), - resumeUpload: vi.fn(), - } as SyncSitesContextType ); + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { + client: mockClient, + } ); mockSetSelectedTab.mockReset(); vi.mocked( useContentTabs, { partial: true } ).mockReturnValue( { @@ -273,8 +273,11 @@ describe( 'useAddSite', () => { localSiteId: createdSite.id, }, ] ); - expect( mockPullSite ).toHaveBeenCalledWith( remoteSite, createdSite, { - optionsToSync: [ 'all' ], + expect( mockPullSiteThunk ).toHaveBeenCalledWith( { + client: mockClient, + connectedSite: remoteSite, + selectedSite: createdSite, + options: { optionsToSync: [ 'all' ] }, } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 8ba7590196..04b0a0ca7c 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -4,16 +4,17 @@ import { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validat import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectDefaultPhpVersion, selectDefaultWordPressVersion, } from 'src/stores/provider-constants-slice'; +import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; import { Blueprint } from 'src/stores/wpcom-api'; import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation'; @@ -50,7 +51,8 @@ export function useAddSite() { const { createSite, sites } = useSiteDetails(); const { importFile, clearImportState, importState } = useImportExport(); const [ connectSite ] = useConnectSiteMutation(); - const { pullSite } = useSyncSites(); + const { client } = useAuth(); + const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); const defaultPhpVersion = useRootSelector( selectDefaultPhpVersion ); const defaultWordPressVersion = useRootSelector( selectDefaultWordPressVersion ); @@ -276,12 +278,17 @@ export function useAddSite() { title: newSite.name, body: __( 'Your new site was imported' ), } ); - } else if ( selectedRemoteSite ) { + } else if ( selectedRemoteSite && client ) { await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } ); const pullOptions: SyncOption[] = [ 'all' ]; - pullSite( selectedRemoteSite, newSite, { - optionsToSync: pullOptions, - } ); + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite: selectedRemoteSite, + selectedSite: newSite, + options: { optionsToSync: pullOptions }, + } ) + ); setSelectedTab( 'sync' ); } else { getIpcApi().showNotification( { @@ -299,12 +306,13 @@ export function useAddSite() { [ __, clearImportState, + client, createSite, + dispatch, fileForImport, importFile, selectedBlueprint, selectedRemoteSite, - pullSite, connectSite, setSelectedTab, ] diff --git a/apps/studio/src/hooks/use-sync-states-progress-info.ts b/apps/studio/src/hooks/use-sync-states-progress-info.ts index e3eb98db3e..9f88dd5d9c 100644 --- a/apps/studio/src/hooks/use-sync-states-progress-info.ts +++ b/apps/studio/src/hooks/use-sync-states-progress-info.ts @@ -1,7 +1,6 @@ import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo } from 'react'; -import { ImportProgressState } from './use-import-export'; export type PullStateProgressInfo = { key: 'in-progress' | 'downloading' | 'importing' | 'finished' | 'failed' | 'cancelled'; @@ -24,113 +23,11 @@ export type PushStateProgressInfo = { message: string; }; -type PullStateProgressInfoValues = Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; -type PushStateProgressInfoValues = Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo >; - -export type SyncBackupResponse = { - status: 'in-progress' | 'finished' | 'failed'; - download_url: string; - percent: number; -}; - -export type RestoreErrorData = { - vp_restore_status?: string; - vp_restore_message?: string; - vp_rewind_id?: string | null; -}; - -export type ImportResponse = { - status: - | 'finished' - | 'failed' - | 'initial_backup_started' - | 'archive_import_started' - | 'archive_import_finished'; - success: boolean; - backup_progress: number; - import_progress: number; - error?: string; - error_data?: RestoreErrorData | null; -}; - -const IN_PROGRESS_INITIAL_VALUE = 30; -const DOWNLOADING_INITIAL_VALUE = 60; -const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; -const PULL_IMPORTING_INITIAL_VALUE = 80; - -function isKeyPulling( key: PullStateProgressInfo[ 'key' ] | undefined ): boolean { - const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ - 'in-progress', - 'downloading', - 'importing', - ]; - if ( ! key ) { - return false; - } - return pullingStateKeys.includes( key ); -} - -function isKeyPushing( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingBackup', - 'uploading', - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyUploadingPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploadingPaused'; -} - -function isKeyUploadingManuallyPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploadingManuallyPaused'; -} - -function isKeyUploading( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploading'; -} - -function isKeyImporting( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyFinished( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'finished'; -} - -function isKeyFailed( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'failed'; -} - -function isKeyCancelled( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'cancelled'; -} - function getPushUploadPercentage( statusKey: PushStateProgressInfo[ 'key' ] | undefined, uploadProgress: number | undefined ): number | null { - if ( isKeyUploading( statusKey ) && uploadProgress !== undefined ) { + if ( statusKey === 'uploading' && uploadProgress !== undefined ) { return Math.round( uploadProgress ); } return null; @@ -138,187 +35,9 @@ function getPushUploadPercentage( export function useSyncStatesProgressInfo() { const { __ } = useI18n(); - const pullStatesProgressInfo = useMemo( () => { - return { - 'in-progress': { - key: 'in-progress', - progress: IN_PROGRESS_INITIAL_VALUE, - message: __( 'Initializing remote backup…' ), - }, - downloading: { - // On backend this key is called backup 'finished' - key: 'downloading', - progress: DOWNLOADING_INITIAL_VALUE, - message: __( 'Downloading backup…' ), - }, - importing: { - key: 'importing', - progress: PULL_IMPORTING_INITIAL_VALUE, - 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' ), - }, - } as const satisfies PullStateProgressInfoValues; - }, [ __ ] ); - - const pushStatesProgressInfo = useMemo( () => { - 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' ), - }, - } as const satisfies PushStateProgressInfoValues; - }, [ __ ] ); const uploadingProgressMessageTemplate = useMemo( () => __( 'Uploading site (%d%%)…' ), [ __ ] ); - const getBackupStatusWithProgress = useCallback( - ( - hasBackupCompleted: boolean, - pullStatesProgressInfo: PullStateProgressInfoValues, - response: SyncBackupResponse - ) => { - const frontendStatus = hasBackupCompleted - ? pullStatesProgressInfo.downloading.key - : response.status; - let newProgressInfo: PullStateProgressInfo | null = null; - if ( response.status === 'in-progress' ) { - newProgressInfo = pullStatesProgressInfo[ frontendStatus ]; - // Update progress from the initial value to the new step proportionally to the response.progress - // on every update of the response.progress - newProgressInfo.progress = - IN_PROGRESS_INITIAL_VALUE + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ); - } - const statusWithProgress = newProgressInfo || pullStatesProgressInfo[ frontendStatus ]; - - return statusWithProgress; - }, - [] - ); - - const getPullStatusWithProgress = useCallback( - ( sitePullState?: PullStateProgressInfo, importState?: ImportProgressState[ string ] ) => { - if ( importState ) { - if ( importState.progress === 100 ) { - return { message: __( 'Applying final details…' ), progress: 99 }; - } - const stepToProgress = 100 - PULL_IMPORTING_INITIAL_VALUE; - return { - message: importState.statusMessage, - // Update progress from the initial value to the new step proportionally to the importState.progress - // on every update of the importState.progress - progress: PULL_IMPORTING_INITIAL_VALUE + stepToProgress * ( importState.progress / 100 ), - }; - } - if ( sitePullState ) { - return { message: sitePullState.message, progress: sitePullState.progress }; - } - return { message: '', progress: 0 }; - }, - [ __ ] - ); - - const getPushStatusWithProgress = useCallback( - ( status: PushStateProgressInfo, response: ImportResponse ) => { - if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { - const progressRange = - pushStatesProgressInfo.applyingChanges.progress - - pushStatesProgressInfo.creatingRemoteBackup.progress; - - // This step will increase the progress to the next step progressively based on the backup_progress - return { - ...status, - progress: - pushStatesProgressInfo.creatingRemoteBackup.progress + - progressRange * ( response.backup_progress / 100 ), - }; - } - - // This step will increase the progress to the next step progressively based on the import_progress - if ( - status.key === pushStatesProgressInfo.applyingChanges.key && - response.import_progress < 100 - ) { - const progressRange = - pushStatesProgressInfo.finishing.progress - - pushStatesProgressInfo.applyingChanges.progress; - return { - ...status, - progress: - pushStatesProgressInfo.applyingChanges.progress + - progressRange * ( response.import_progress / 100 ), - }; - } - return status; - }, - [ - pushStatesProgressInfo.applyingChanges.key, - pushStatesProgressInfo.applyingChanges.progress, - pushStatesProgressInfo.creatingRemoteBackup.key, - pushStatesProgressInfo.creatingRemoteBackup.progress, - pushStatesProgressInfo.finishing.progress, - ] - ); - const getPushUploadMessage = useCallback( ( message: string, uploadPercentage: number | null ): string => { if ( uploadPercentage !== null ) { @@ -330,40 +49,8 @@ export function useSyncStatesProgressInfo() { [ uploadingProgressMessageTemplate ] ); - const mapUploadProgressToOverallProgress = useCallback( - ( uploadProgress: number ): number => { - // Map upload progress (0-100%) to the uploading state range (40-50%) - const uploadingProgressRange = - pushStatesProgressInfo.creatingRemoteBackup.progress - - pushStatesProgressInfo.uploading.progress; - return ( - pushStatesProgressInfo.uploading.progress + - ( uploadProgress / 100 ) * uploadingProgressRange - ); - }, - [ - pushStatesProgressInfo.creatingRemoteBackup.progress, - pushStatesProgressInfo.uploading.progress, - ] - ); - return { - pullStatesProgressInfo, - pushStatesProgressInfo, - isKeyPulling, - isKeyPushing, - isKeyImporting, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - isKeyUploading, - getBackupStatusWithProgress, - getPullStatusWithProgress, - getPushStatusWithProgress, getPushUploadPercentage, getPushUploadMessage, - mapUploadProgressToOverallProgress, - isKeyUploadingPaused, - isKeyUploadingManuallyPaused, }; } diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 15f0da1eca..81ce6b64e9 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -102,7 +102,6 @@ export { getConnectedWpcomSites, pauseSyncUpload, pushArchive, - removeExportedSiteTmpFile, removeSyncBackup, resumeSyncUpload, updateConnectedWpcomSites, diff --git a/apps/studio/src/modules/add-site/tests/add-site.test.tsx b/apps/studio/src/modules/add-site/tests/add-site.test.tsx index 01fde35bec..fde13ffd3b 100644 --- a/apps/studio/src/modules/add-site/tests/add-site.test.tsx +++ b/apps/studio/src/modules/add-site/tests/add-site.test.tsx @@ -7,7 +7,6 @@ import { FolderDialogResponse } from 'src/ipc-handlers'; import { createTestStore } from 'src/lib/test-utils'; import AddSite from 'src/modules/add-site'; import { useGetBlueprints } from 'src/stores/wpcom-api'; -import type { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context'; vi.mock( 'src/stores/certificate-trust-api', async () => { const actual = await vi.importActual( 'src/stores/certificate-trust-api' ); @@ -47,8 +46,6 @@ const mockShowOpenFolderDialog = const mockGenerateProposedSitePath = vi.fn< ( siteName: string ) => Promise< FolderDialogResponse > >(); const mockGetAllCustomDomains = vi.fn< () => Promise< string[] > >().mockResolvedValue( [] ); -const mockPullSite = vi.fn(); -const mockUseSyncSites = vi.fn(); const mockSetSelectedTab = vi.fn(); vi.mock( 'src/lib/get-ipc-api', () => ( { @@ -65,10 +62,6 @@ vi.mock( 'src/lib/get-ipc-api', () => ( { } ), } ) ); -vi.mock( 'src/hooks/sync-sites', () => ( { - useSyncSites: () => mockUseSyncSites(), -} ) ); - vi.mock( 'src/hooks/use-import-export', () => ( { useImportExport: () => ( { importState: {}, @@ -121,24 +114,6 @@ const renderWithProvider = ( children: React.ReactElement ) => { beforeEach( () => { vi.clearAllMocks(); - mockPullSite.mockReset(); - mockUseSyncSites.mockReturnValue( { - pullSite: mockPullSite, - isAnySitePulling: false, - isSiteIdPulling: vi.fn(), - clearPullState: vi.fn(), - cancelPull: vi.fn(), - getPullState: vi.fn(), - pushSite: vi.fn(), - isAnySitePushing: false, - isSiteIdPushing: vi.fn(), - clearPushState: vi.fn(), - getPushState: vi.fn(), - getLastSyncTimeText: vi.fn(), - cancelPush: vi.fn(), - pauseUpload: vi.fn(), - resumeUpload: vi.fn(), - } as SyncSitesContextType ); mockSetSelectedTab.mockReset(); mockShowOpenFolderDialog.mockResolvedValue( { diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index bffba5c613..69c58ecca6 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -15,7 +15,7 @@ import { PressableLogo } from 'src/components/pressable-logo'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip, DynamicTooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useLastSyncTimeText } from 'src/hooks/sync-sites/use-last-sync-time-text'; import { useAuth } from 'src/hooks/use-auth'; import { useImportExport } from 'src/hooks/use-import-export'; import { useOffline } from 'src/hooks/use-offline'; @@ -35,7 +35,12 @@ import { convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; -import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; +import { + syncOperationsSelectors, + syncOperationsThunks, + syncOperationsActions, +} from 'src/stores/sync'; import { connectedSitesActions, useGetConnectedSitesForLocalSiteQuery, @@ -51,24 +56,22 @@ const SyncConnectedSiteControls = ( { } ) => { const { __ } = useI18n(); const isOffline = useOffline(); + const dispatch = useAppDispatch(); const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null ); - const { - pullSite, - isAnySitePulling, - isAnySitePushing, - pushSite, - isSiteIdPulling, - isSiteIdPushing, - getLastSyncTimeText, - } = useSyncSites(); - const { user } = useAuth(); + const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); + const getLastSyncTimeText = useLastSyncTimeText(); + const { user, client } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); - const isAnyConnectedSiteSyncing = connectedSites.some( - ( site ) => - isSiteIdPulling( selectedSite.id, site.id ) || isSiteIdPushing( selectedSite.id, site.id ) + const isAnyConnectedSiteSyncing = useRootSelector( ( state ) => + connectedSites.some( + ( site ) => + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) || + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) + ) ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; @@ -169,11 +172,27 @@ const SyncConnectedSiteControls = ( { remoteSite={ connectedSite } onPush={ ( tree ) => { const pushOptions = convertTreeToPushOptions( tree ); - void pushSite( connectedSite, selectedSite, pushOptions ); + void dispatch( + syncOperationsThunks.pushSite( { + connectedSite, + selectedSite, + options: pushOptions, + } ) + ); } } onPull={ ( tree ) => { + if ( ! client ) { + return; + } const pullOptions = convertTreeToPullOptions( tree ); - pullSite( connectedSite, selectedSite, pullOptions ); + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options: pullOptions, + } ) + ); } } onRequestClose={ () => setSyncDialogType( null ) } /> @@ -193,56 +212,70 @@ const SyncConnectedSitesSectionItem = ( { connectedSite, }: SyncConnectedSitesListProps ) => { const { __ } = useI18n(); + const dispatch = useAppDispatch(); const isOffline = useOffline(); - const { - clearPullState, - getPullState, - getPushState, - clearPushState, - cancelPull, - cancelPush, - pauseUpload, - resumeUpload, - getLastSyncTimeText, - } = useSyncSites(); - const { importState } = useImportExport(); - const { - isKeyPulling, - isKeyPushing, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getPullStatusWithProgress, - getPushUploadPercentage, - getPushUploadMessage, - isKeyUploadingPaused, - isKeyUploadingManuallyPaused, - isKeyUploading, - } = useSyncStatesProgressInfo(); + const getLastSyncTimeText = useLastSyncTimeText(); + const { importState, clearImportState } = useImportExport(); + const { getPushUploadPercentage, getPushUploadMessage } = useSyncStatesProgressInfo(); - const sitePullState = getPullState( selectedSite.id, connectedSite.id ); - const isPulling = sitePullState && isKeyPulling( sitePullState.status.key ); - const isPullError = sitePullState && isKeyFailed( sitePullState.status.key ); - const hasPullFinished = sitePullState && isKeyFinished( sitePullState.status.key ); - const hasPullCancelled = sitePullState && isKeyCancelled( sitePullState.status.key ); - const { message: sitePullStatusMessage, progress: sitePullStatusProgress } = - getPullStatusWithProgress( sitePullState?.status, importState[ connectedSite.localSiteId ] ); + const sitePullState = useRootSelector( + syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id ) + ); + const isPulling = + sitePullState?.status.key === 'in-progress' || + sitePullState?.status.key === 'downloading' || + sitePullState?.status.key === 'importing'; + const isPullError = sitePullState?.status.key === 'failed'; + const hasPullFinished = sitePullState?.status.key === 'finished'; + const hasPullCancelled = sitePullState?.status.key === 'cancelled'; + const pullImportState = importState[ connectedSite.localSiteId ]; + let sitePullStatusMessage = ''; + let sitePullStatusProgress = 0; + if ( pullImportState ) { + if ( pullImportState.progress === 100 ) { + sitePullStatusMessage = __( 'Applying final details…' ); + sitePullStatusProgress = 99; + } else { + sitePullStatusMessage = pullImportState.statusMessage; + // Map import progress (0-100%) to the pull importing range (80-100%) + sitePullStatusProgress = 80 + 20 * ( pullImportState.progress / 100 ); + } + } else if ( sitePullState?.status ) { + sitePullStatusMessage = sitePullState.status.message; + sitePullStatusProgress = sitePullState.status.progress; + } - const pushState = getPushState( selectedSite.id, connectedSite.id ); - const isPushing = pushState && isKeyPushing( pushState.status.key ); - const isUploadingNetworkPaused = pushState && isKeyUploadingPaused( pushState.status.key ); - const isUploadingManuallyPaused = - pushState && isKeyUploadingManuallyPaused( pushState.status.key ); - const isUploading = pushState && isKeyUploading( pushState.status.key ); - const isPushError = pushState && isKeyFailed( pushState.status.key ); - const hasPushFinished = pushState && isKeyFinished( pushState.status.key ); - const hasPushCancelled = pushState && isKeyCancelled( pushState.status.key ); + const pushState = useRootSelector( + syncOperationsSelectors.selectPushState( selectedSite.id, connectedSite.id ) + ); + const isPushing = + pushState?.status.key === 'creatingBackup' || + pushState?.status.key === 'uploading' || + pushState?.status.key === 'creatingRemoteBackup' || + pushState?.status.key === 'applyingChanges' || + pushState?.status.key === 'finishing'; + const isUploading = pushState?.status.key === 'uploading'; + const isUploadingManuallyPaused = pushState?.status.key === 'uploadingManuallyPaused'; + const isUploadingNetworkPaused = pushState?.status.key === 'uploadingPaused'; + const isPushError = pushState?.status.key === 'failed'; + const hasPushFinished = pushState?.status.key === 'finished'; + const hasPushCancelled = pushState?.status.key === 'cancelled'; const uploadPercentage = getPushUploadPercentage( pushState?.status.key, pushState?.uploadProgress ); + function clearPullState( selectedSiteId: string, remoteSiteId: number ) { + clearImportState( selectedSiteId ); + dispatch( + syncOperationsActions.clearPullState( { + selectedSiteId, + remoteSiteId, + } ) + ); + } + const getPushProgressTooltip = () => { if ( isOffline ) { return __( @@ -295,7 +328,14 @@ const SyncConnectedSitesSectionItem = ( { >