diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx index 0749154ad..509445c5d 100644 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ b/src/hooks/sync-sites/sync-sites-context.tsx @@ -79,24 +79,22 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) [ connectedSites, dispatch ] ); - const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState } = useSyncPull( - { + const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = + useSyncPull( { pullStates, setPullStates, onPullSuccess: ( remoteSiteId, localSiteId ) => updateSiteTimestamp( remoteSiteId, localSiteId, 'pull' ), - } - ); + } ); const [ pushStates, setPushStates ] = useState< PushStates >( {} ); - const { pushSite, isAnySitePushing, isSiteIdPushing, clearPushState, getPushState } = useSyncPush( - { + const { pushSite, isAnySitePushing, isSiteIdPushing, clearPushState, getPushState, cancelPush } = + useSyncPush( { pushStates, setPushStates, onPushSuccess: ( remoteSiteId, localSiteId ) => updateSiteTimestamp( remoteSiteId, localSiteId, 'push' ), - } - ); + } ); const { syncSites, isFetching, refetchSites } = useSyncSitesData(); useListenDeepLinkConnection( { refetchSites } ); @@ -108,6 +106,7 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) isAnySitePulling, isSiteIdPulling, clearPullState, + cancelPull, syncSites, refetchSites, isFetching, @@ -117,6 +116,7 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) isAnySitePushing, isSiteIdPushing, clearPushState, + cancelPush, getLastSyncTimeText, } } > diff --git a/src/hooks/sync-sites/use-pull-push-states.ts b/src/hooks/sync-sites/use-pull-push-states.ts index 3cdee814b..9080cfac4 100644 --- a/src/hooks/sync-sites/use-pull-push-states.ts +++ b/src/hooks/sync-sites/use-pull-push-states.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; export const generateStateId = ( selectedSiteId: string, remoteSiteId: number ) => `${ selectedSiteId }-${ remoteSiteId }`; @@ -22,24 +22,34 @@ export function usePullPushStates< T >( states: States< T >, setStates: React.Dispatch< React.SetStateAction< States< T > > > ): UsePullPushStates< T > { + const statesRef = useRef( states ); + + useEffect( () => { + statesRef.current = states; + }, [ states ] ); + const updateState = useCallback< UpdateState< T > >( ( selectedSiteId, remoteSiteId, state ) => { - setStates( ( prevStates ) => ( { - ...prevStates, - [ generateStateId( selectedSiteId, remoteSiteId ) ]: { - ...prevStates[ generateStateId( selectedSiteId, remoteSiteId ) ], - ...state, - }, - } ) ); + setStates( ( prevStates ) => { + const newStates = { + ...prevStates, + [ generateStateId( selectedSiteId, remoteSiteId ) ]: { + ...prevStates[ generateStateId( selectedSiteId, remoteSiteId ) ], + ...state, + }, + }; + statesRef.current = newStates; + return newStates; + } ); }, [ setStates ] ); const getState = useCallback< GetState< T > >( ( selectedSiteId, remoteSiteId ): T | undefined => { - return states[ generateStateId( selectedSiteId, remoteSiteId ) ]; + return statesRef.current[ generateStateId( selectedSiteId, remoteSiteId ) ]; }, - [ states ] + [] ); const clearState = useCallback< ClearState >( @@ -47,6 +57,7 @@ export function usePullPushStates< T >( setStates( ( prevStates ) => { const newStates = { ...prevStates }; delete newStates[ generateStateId( selectedSiteId, remoteSiteId ) ]; + statesRef.current = newStates; return newStates; } ); }, diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index dab98424d..767ea5d65 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -52,6 +52,8 @@ type UseSyncPullProps = { onPullSuccess?: OnPullSuccess; }; +type CancelPull = ( selectedSiteId: string, remoteSiteId: number ) => void; + export type UseSyncPull = { pullStates: PullStates; getPullState: GetState< SyncBackupState >; @@ -59,6 +61,7 @@ export type UseSyncPull = { isAnySitePulling: boolean; isSiteIdPulling: IsSiteIdPulling; clearPullState: ClearState; + cancelPull: CancelPull; }; export function useSyncPull( { @@ -74,6 +77,7 @@ export function useSyncPull( { isKeyPulling, isKeyFinished, isKeyFailed, + isKeyCancelled, getBackupStatusWithProgress, } = useSyncStatesProgressInfo(); const { @@ -87,13 +91,13 @@ export function useSyncPull( { updateState( selectedSiteId, remoteSiteId, state ); const statusKey = state.status?.key; - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) ) { + if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); } else { getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); } }, - [ isKeyFailed, isKeyFinished, updateState ] + [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] ); const clearPullState = useCallback< ClearState >( @@ -114,6 +118,8 @@ export function useSyncPull( { const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; + + clearPullState( selectedSite.id, remoteSiteId ); updatePullState( selectedSite.id, remoteSiteId, { backupId: null, status: pullStatesProgressInfo[ 'in-progress' ], @@ -161,7 +167,7 @@ export function useSyncPull( { } ); } }, - [ __, client, pullStatesProgressInfo, updatePullState ] + [ __, clearPullState, client, pullStatesProgressInfo, updatePullState ] ); const checkBackupFileSize = async ( downloadUrl: string ): Promise< number > => { @@ -213,7 +219,17 @@ export function useSyncPull( { downloadUrl, } ); - const filePath = await getIpcApi().downloadSyncBackup( remoteSiteId, 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, { @@ -252,6 +268,12 @@ export function useSyncPull( { 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, @@ -266,8 +288,10 @@ export function useSyncPull( { __, clearImportState, clearPullState, + getPullState, importFile, onPullSuccess, + isKeyCancelled, pullStatesProgressInfo.cancelled, pullStatesProgressInfo.downloading, pullStatesProgressInfo.failed, @@ -284,7 +308,12 @@ export function useSyncPull( { return; } - const backupId = getPullState( selectedSiteId, remoteSiteId )?.backupId; + 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; @@ -335,6 +364,7 @@ export function useSyncPull( { onBackupCompleted, pullStatesProgressInfo, updatePullState, + isKeyCancelled, ] ); @@ -342,6 +372,10 @@ export function useSyncPull( { const intervals: Record< string, NodeJS.Timeout > = {}; Object.entries( pullStates ).forEach( ( [ key, state ] ) => { + if ( isKeyCancelled( state.status.key ) ) { + return; + } + if ( state.backupId && state.status.key === 'in-progress' ) { intervals[ key ] = setTimeout( () => { void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); @@ -352,7 +386,7 @@ export function useSyncPull( { return () => { Object.values( intervals ).forEach( clearTimeout ); }; - }, [ pullStates, fetchAndUpdateBackup ] ); + }, [ pullStates, fetchAndUpdateBackup, isKeyCancelled ] ); const isAnySitePulling = useMemo< boolean >( () => { return Object.values( pullStates ).some( ( state ) => isKeyPulling( state.status.key ) ); @@ -361,6 +395,9 @@ export function useSyncPull( { const isSiteIdPulling = useCallback< IsSiteIdPulling >( ( selectedSiteId, remoteSiteId ) => { return Object.values( pullStates ).some( ( state ) => { + if ( ! state.selectedSite ) { + return false; + } if ( state.selectedSite.id !== selectedSiteId ) { return false; } @@ -373,5 +410,37 @@ export function useSyncPull( { [ pullStates, isKeyPulling ] ); - return { pullStates, getPullState, pullSite, isAnySitePulling, isSiteIdPulling, clearPullState }; + const cancelPull = useCallback< CancelPull >( + async ( selectedSiteId, remoteSiteId ) => { + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + + await 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/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index dd6a50cc9..76aee4e0a 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -52,6 +52,8 @@ type UseSyncPushProps = { onPushSuccess?: OnPushSuccess; }; +type CancelPush = ( selectedSiteId: string, remoteSiteId: number ) => void; + export type UseSyncPush = { pushStates: PushStates; getPushState: GetState< SyncPushState >; @@ -59,6 +61,7 @@ export type UseSyncPush = { isAnySitePushing: boolean; isSiteIdPushing: IsSiteIdPushing; clearPushState: ClearState; + cancelPush: CancelPush; }; export function useSyncPush( { @@ -79,6 +82,7 @@ export function useSyncPush( { isKeyImporting, isKeyFinished, isKeyFailed, + isKeyCancelled, getPushStatusWithProgress, } = useSyncStatesProgressInfo(); @@ -87,13 +91,13 @@ export function useSyncPush( { updateState( selectedSiteId, remoteSiteId, state ); const statusKey = state.status?.key; - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) ) { + if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); } else { getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); } }, - [ isKeyFailed, isKeyFinished, updateState ] + [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] ); const clearPushState = useCallback< ClearState >( @@ -109,6 +113,11 @@ export function useSyncPush( { if ( ! client ) { return; } + const currentState = getPushState( syncPushState.selectedSite.id, remoteSiteId ); + + if ( ! currentState || isKeyCancelled( currentState?.status.key ) ) { + return; + } const response = ( await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/import`, { apiNamespace: 'wpcom/v2', @@ -154,6 +163,7 @@ export function useSyncPush( { [ __, client, + getPushState, getPushStatusWithProgress, onPushSuccess, pushStatesProgressInfo.applyingChanges, @@ -162,6 +172,7 @@ export function useSyncPush( { pushStatesProgressInfo.failed, pushStatesProgressInfo.finished, updatePushState, + isKeyCancelled, ] ); @@ -188,6 +199,9 @@ export function useSyncPush( { } 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, @@ -198,12 +212,19 @@ export function useSyncPush( { let archiveContent, archivePath, archiveSizeInBytes; try { - const result = await getIpcApi().exportSiteToPush( selectedSite.id, { + const result = await getIpcApi().exportSiteToPush( selectedSite.id, operationId, { optionsToSync: options?.optionsToSync, specificSelections: options?.specificSelections, } ); ( { archiveContent, archivePath, archiveSizeInBytes } = result ); } catch ( error ) { + if ( error instanceof Error && error.message === 'Export aborted' ) { + updatePushState( selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.cancelled, + } ); + return; + } + Sentry.captureException( error ); updatePushState( selectedSite.id, remoteSiteId, { status: pushStatesProgressInfo.failed, @@ -232,6 +253,12 @@ export function useSyncPush( { return; } + const stateBeforeUpload = getPushState( selectedSite.id, remoteSiteId ); + + if ( ! stateBeforeUpload || isKeyCancelled( stateBeforeUpload?.status.key ) ) { + return; + } + updatePushState( selectedSite.id, remoteSiteId, { status: pushStatesProgressInfo.uploading, } ); @@ -256,6 +283,13 @@ export function useSyncPush( { } ) ) as { success: boolean; }; + + const stateAfterUpload = getPushState( selectedSite.id, remoteSiteId ); + + if ( isKeyCancelled( stateAfterUpload?.status.key ) ) { + return; + } + if ( response.success ) { updatePushState( selectedSite.id, remoteSiteId, { status: pushStatesProgressInfo.creatingRemoteBackup, @@ -277,13 +311,26 @@ export function useSyncPush( { await getIpcApi().removeTemporalFile( archivePath ); } }, - [ __, client, pushStatesProgressInfo, updatePushState, getErrorFromResponse ] + [ + __, + clearPushState, + client, + getPushState, + pushStatesProgressInfo, + updatePushState, + getErrorFromResponse, + isKeyCancelled, + ] ); useEffect( () => { const intervals: Record< string, NodeJS.Timeout > = {}; Object.entries( pushStates ).forEach( ( [ key, state ] ) => { + if ( isKeyCancelled( state.status.key ) ) { + return; + } + if ( isKeyImporting( state.status.key ) ) { intervals[ key ] = setTimeout( () => { void getPushProgressInfo( state.remoteSiteId, state ); @@ -300,6 +347,7 @@ export function useSyncPush( { pushStatesProgressInfo.creatingBackup.key, pushStatesProgressInfo.applyingChanges.key, isKeyImporting, + isKeyCancelled, ] ); const isAnySitePushing = useMemo< boolean >( () => { @@ -309,6 +357,9 @@ export function useSyncPush( { const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { return Object.values( pushStates ).some( ( state ) => { + if ( ! state.selectedSite ) { + return false; + } if ( state.selectedSite.id !== selectedSiteId ) { return false; } @@ -321,5 +372,30 @@ export function useSyncPush( { [ pushStates, isKeyPushing ] ); - return { pushStates, getPushState, pushSite, isAnySitePushing, isSiteIdPushing, clearPushState }; + const cancelPush = useCallback< CancelPush >( + async ( selectedSiteId, remoteSiteId ) => { + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + await getIpcApi().cancelSyncOperation( operationId ); + + updatePushState( selectedSiteId, remoteSiteId, { + status: pushStatesProgressInfo.cancelled, + } ); + + getIpcApi().showNotification( { + title: __( 'Push cancelled' ), + body: __( 'The push operation has been cancelled.' ), + } ); + }, + [ __, pushStatesProgressInfo.cancelled, updatePushState ] + ); + + return { + pushStates, + getPushState, + pushSite, + isAnySitePushing, + isSiteIdPushing, + clearPushState, + cancelPush, + }; } diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 6b9385b1e..8504c9a12 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -15,7 +15,8 @@ export type PushStateProgressInfo = { | 'applyingChanges' | 'finishing' | 'finished' - | 'failed'; + | 'failed' + | 'cancelled'; progress: number; message: string; }; @@ -122,6 +123,11 @@ export function useSyncStatesProgressInfo() { progress: 100, message: __( 'Error pushing changes' ), }, + cancelled: { + key: 'cancelled', + progress: 0, + message: __( 'Cancelled' ), + }, } as const satisfies PushStateProgressInfoValues; }, [ __ ] ); @@ -176,6 +182,13 @@ export function useSyncStatesProgressInfo() { [] ); + const isKeyCancelled = useCallback( + ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'cancelled'; + }, + [] + ); + const getBackupStatusWithProgress = useCallback( ( hasBackupCompleted: boolean, @@ -264,6 +277,22 @@ export function useSyncStatesProgressInfo() { ] ); + const canCancelPull = useCallback( ( key: PullStateProgressInfo[ 'key' ] | undefined ) => { + const cancellableStateKeys: PullStateProgressInfo[ 'key' ][] = [ 'in-progress', 'downloading' ]; + if ( ! key ) { + return false; + } + return cancellableStateKeys.includes( key ); + }, [] ); + + const canCancelPush = useCallback( ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { + const cancellableStateKeys: PushStateProgressInfo[ 'key' ][] = [ 'creatingBackup' ]; + if ( ! key ) { + return false; + } + return cancellableStateKeys.includes( key ); + }, [] ); + return { pullStatesProgressInfo, pushStatesProgressInfo, @@ -272,8 +301,11 @@ export function useSyncStatesProgressInfo() { isKeyImporting, isKeyFinished, isKeyFailed, + isKeyCancelled, getBackupStatusWithProgress, getPullStatusWithProgress, getPushStatusWithProgress, + canCancelPull, + canCancelPush, }; } diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index fe31022f2..113f2637d 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -94,6 +94,12 @@ import type { WpCliResult } from 'src/lib/wp-cli-process'; import type { GranularSyncFolders } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; +/** + * Registry to store AbortControllers for ongoing sync operations (push/pull). + * Key format: `${selectedSiteId}-${remoteSiteId}` + */ +const SYNC_ABORT_CONTROLLERS = new Map< string, AbortController >(); + const TEMP_DIR = nodePath.join( app.getPath( 'temp' ), 'com.wordpress.studio' ) + nodePath.sep; if ( ! fs.existsSync( TEMP_DIR ) ) { fs.mkdirSync( TEMP_DIR ); @@ -699,6 +705,7 @@ export async function archiveSite( event: IpcMainInvokeEvent, id: string, format export async function exportSiteToPush( event: IpcMainInvokeEvent, id: string, + operationId: string, configuration?: { optionsToSync?: SyncOption[]; specificSelections?: { @@ -715,36 +722,57 @@ export async function exportSiteToPush( const extension = 'tar.gz'; const archivePath = `${ TEMP_DIR }site_${ id }.${ extension }`; - const shouldIncludeSyncOption = ( - optionsToSync: SyncOption[] | undefined, - option: SyncOption - ): boolean => { - return optionsToSync?.includes( option ) || optionsToSync?.includes( 'all' ) || ! optionsToSync; - }; + const abortController = new AbortController(); + SYNC_ABORT_CONTROLLERS.set( operationId, abortController ); - const includes = { - database: shouldIncludeSyncOption( configuration?.optionsToSync, 'sqls' ), - uploads: shouldIncludeSyncOption( configuration?.optionsToSync, 'uploads' ), - plugins: shouldIncludeSyncOption( configuration?.optionsToSync, 'plugins' ), - themes: shouldIncludeSyncOption( configuration?.optionsToSync, 'themes' ), - muPlugins: shouldIncludeSyncOption( configuration?.optionsToSync, 'contents' ), - fonts: shouldIncludeSyncOption( configuration?.optionsToSync, 'contents' ), - }; + try { + if ( abortController.signal.aborted ) { + throw new Error( 'Export aborted' ); + } - const exportOptions: ExportOptions = { - site: site.details, - backupFile: archivePath, - includes, - phpVersion: site.details.phpVersion, - splitDatabaseDumpByTable: true, - specificSelections: configuration?.specificSelections, - }; - // eslint-disable-next-line @typescript-eslint/no-empty-function - const onEvent = () => {}; - await exportBackup( exportOptions, onEvent ); - const stats = fs.statSync( archivePath ); - const archiveContent = fs.readFileSync( archivePath ); - return { archivePath, archiveContent, archiveSizeInBytes: stats.size }; + const shouldIncludeSyncOption = ( + optionsToSync: SyncOption[] | undefined, + option: SyncOption + ): boolean => { + return ( + optionsToSync?.includes( option ) || optionsToSync?.includes( 'all' ) || ! optionsToSync + ); + }; + + const includes = { + database: shouldIncludeSyncOption( configuration?.optionsToSync, 'sqls' ), + uploads: shouldIncludeSyncOption( configuration?.optionsToSync, 'uploads' ), + plugins: shouldIncludeSyncOption( configuration?.optionsToSync, 'plugins' ), + themes: shouldIncludeSyncOption( configuration?.optionsToSync, 'themes' ), + muPlugins: shouldIncludeSyncOption( configuration?.optionsToSync, 'contents' ), + fonts: shouldIncludeSyncOption( configuration?.optionsToSync, 'contents' ), + }; + + const exportOptions: ExportOptions = { + site: site.details, + backupFile: archivePath, + includes, + phpVersion: site.details.phpVersion, + splitDatabaseDumpByTable: true, + specificSelections: configuration?.specificSelections, + }; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const onEvent = () => {}; + await exportBackup( exportOptions, onEvent ); + + if ( abortController.signal.aborted ) { + await fsPromises.unlink( archivePath ).catch( () => { + // Ignore cleanup errors + } ); + throw new Error( 'Export aborted' ); + } + + const stats = fs.statSync( archivePath ); + const archiveContent = fs.readFileSync( archivePath ); + return { archivePath, archiveContent, archiveSizeInBytes: stats.size }; + } finally { + SYNC_ABORT_CONTROLLERS.delete( operationId ); + } } export function removeTemporalFile( event: IpcMainInvokeEvent, path: string ) { @@ -1254,14 +1282,30 @@ export async function openFileInIDE( export async function downloadSyncBackup( event: Electron.IpcMainInvokeEvent, remoteSiteId: number, - downloadUrl: string + downloadUrl: string, + operationId: string ) { const tmpDir = nodePath.join( app.getPath( 'temp' ), 'wp-studio-backups' ); await fsPromises.mkdir( tmpDir, { recursive: true } ); const filePath = getSyncBackupTempPath( remoteSiteId ); - await download( downloadUrl, filePath ); - return filePath; + + const abortController = new AbortController(); + SYNC_ABORT_CONTROLLERS.set( operationId, abortController ); + + try { + await download( downloadUrl, filePath, false, '', abortController.signal ); + return filePath; + } catch ( error ) { + if ( error instanceof Error && error.name === 'AbortError' ) { + // Download was cancelled, throw the error + } else { + console.error( `[Download] Download failed for operation: ${ operationId }`, error ); + } + throw error; + } finally { + SYNC_ABORT_CONTROLLERS.delete( operationId ); + } } export async function removeSyncBackup( event: IpcMainInvokeEvent, remoteSiteId: number ) { @@ -1289,6 +1333,16 @@ export function addSyncOperation( event: IpcMainInvokeEvent, id: string ) { */ export function clearSyncOperation( event: IpcMainInvokeEvent, id: string ) { ACTIVE_SYNC_OPERATIONS.delete( id ); + SYNC_ABORT_CONTROLLERS.delete( id ); +} + +export function cancelSyncOperation( event: IpcMainInvokeEvent, id: string ) { + const abortController = SYNC_ABORT_CONTROLLERS.get( id ); + if ( abortController ) { + abortController.abort(); + SYNC_ABORT_CONTROLLERS.delete( id ); + } + ACTIVE_SYNC_OPERATIONS.delete( id ); } export function getDirectorySize( _event: IpcMainInvokeEvent, siteId: string, subdir: string[] ) { diff --git a/src/lib/download.ts b/src/lib/download.ts index bdf43bcd4..f618164dd 100644 --- a/src/lib/download.ts +++ b/src/lib/download.ts @@ -1,11 +1,17 @@ import { https } from 'follow-redirects'; import fs from 'fs-extra'; -export async function download( url: string, filePath: string, showProgress = false, name = '' ) { +export async function download( + url: string, + filePath: string, + showProgress = false, + name = '', + signal?: AbortSignal +) { const file = fs.createWriteStream( filePath ); await new Promise< void >( ( resolve, reject ) => { - https.get( url, ( response ) => { + const request = https.get( url, ( response ) => { if ( response.statusCode !== 200 ) { reject( new Error( `Request failed with status code: ${ response.statusCode }` ) ); return; @@ -34,5 +40,24 @@ export async function download( url: string, filePath: string, showProgress = fa } ); response.on( 'error', ( err ) => reject( err ) ); } ); + + if ( signal ) { + signal.addEventListener( 'abort', () => { + request.destroy(); + file.close(); + fs.remove( filePath ).catch( () => { + // Ignore errors during cleanup + } ); + reject( new Error( 'Download aborted' ) ); + } ); + } + + request.on( 'error', ( err ) => { + file.close(); + fs.remove( filePath ).catch( () => { + // Ignore errors during cleanup + } ); + reject( err ); + } ); } ); } diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 61cbee913..3311202af 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -1,7 +1,7 @@ import { Icon } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; -import { cloudUpload, cloudDownload, info } from '@wordpress/icons'; +import { cloudUpload, cloudDownload, info, close } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useState, useMemo } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; @@ -183,10 +183,19 @@ const SyncConnectedSitesList = ( { connectedSites, }: SyncConnectedSitesListProps ) => { const { __ } = useI18n(); - const { clearPullState, getPullState, getPushState, clearPushState } = useSyncSites(); + const { clearPullState, getPullState, getPushState, clearPushState, cancelPull, cancelPush } = + useSyncSites(); const { importState } = useImportExport(); - const { isKeyPulling, isKeyPushing, isKeyFinished, isKeyFailed, getPullStatusWithProgress } = - useSyncStatesProgressInfo(); + const { + isKeyPulling, + isKeyPushing, + isKeyFinished, + isKeyFailed, + isKeyCancelled, + getPullStatusWithProgress, + canCancelPull, + canCancelPush, + } = useSyncStatesProgressInfo(); return (