diff --git a/common/types/stats.ts b/common/types/stats.ts index 286132de2f..b2c9be83a3 100644 --- a/common/types/stats.ts +++ b/common/types/stats.ts @@ -33,6 +33,7 @@ export enum StatsMetric { REMOTE_BLUEPRINT = 'remote-blueprint', FILE_BLUEPRINT = 'file-blueprint', NO_BLUEPRINT = 'no-blueprint', + SITE_COPIED = 'site-copied', } export type AggregateInterval = 'daily' | 'weekly' | 'monthly'; diff --git a/src/components/content-tab-settings.tsx b/src/components/content-tab-settings.tsx index ec5e2dc715..6870680d0b 100644 --- a/src/components/content-tab-settings.tsx +++ b/src/components/content-tab-settings.tsx @@ -2,6 +2,7 @@ import { DropdownMenu, MenuGroup, Button } from '@wordpress/components'; import { moreVertical } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { PropsWithChildren } from 'react'; +import CopySite from 'src/components/copy-site'; import { CopyTextButton } from 'src/components/copy-text-button'; import DeleteSite from 'src/components/delete-site'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; @@ -64,7 +65,8 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) className="flex items-center" > { ( { onClose }: { onClose: () => void } ) => ( - + + ) } diff --git a/src/components/copy-site.tsx b/src/components/copy-site.tsx new file mode 100644 index 0000000000..c636257392 --- /dev/null +++ b/src/components/copy-site.tsx @@ -0,0 +1,33 @@ +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useI18n } from '@wordpress/react-i18n'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; + +type CopySiteProps = { + onClose: () => void; +}; + +const CopySite = ( { onClose }: CopySiteProps ) => { + const { __ } = useI18n(); + const { selectedSite } = useSiteDetails(); + + const isCopyDisabled = ! selectedSite; + + return ( + { + if ( isCopyDisabled || ! selectedSite ) { + return; + } + onClose(); + getIpcApi().triggerAddSiteCopy( selectedSite.id ); + } } + disabled={ isCopyDisabled } + > + { __( 'Copy site' ) } + + ); +}; +export default CopySite; diff --git a/src/components/site-form.tsx b/src/components/site-form.tsx index e9212de486..b497eb5a10 100644 --- a/src/components/site-form.tsx +++ b/src/components/site-form.tsx @@ -55,6 +55,7 @@ interface SiteFormErrorProps { interface SiteFormProps { className?: string; children?: React.ReactNode; + beforeAdvancedSettings?: React.ReactNode; siteName: string; setSiteName: ( name: string ) => void; sitePath?: string; @@ -250,6 +251,7 @@ function FormImportComponent( { export const SiteForm = ( { className, children, + beforeAdvancedSettings, siteName, setSiteName, phpVersion, @@ -369,6 +371,8 @@ export const SiteForm = ( { ) } + { beforeAdvancedSettings } + { onSelectPath && ( <>
diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx index 4fab82f3ae..9090ee9021 100644 --- a/src/components/site-menu.tsx +++ b/src/components/site-menu.tsx @@ -249,6 +249,9 @@ export default function SiteMenu( { className }: SiteMenuProps ) { setSelectedTab( 'settings' ); setIsEditModalOpen( true ); break; + case 'copy-site': + ipcApi.triggerAddSiteCopy( site.id ); + break; case 'delete': await handleDeleteSite( site.id, site.name ); break; diff --git a/src/constants.ts b/src/constants.ts index b2f3fac1e7..a0cb272f75 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -98,6 +98,7 @@ export const IPC_VOID_HANDLERS = < const >[ 'showItemInFolder', 'showNotification', 'authenticate', + 'triggerAddSiteCopy', ]; // What's New diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index ca247ad208..be869d0e3c 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -34,6 +34,10 @@ interface SiteDetailsContext { blueprint?: Blueprint, callback?: ( site: SiteDetails ) => Promise< void > ) => Promise< SiteDetails | void >; + copySite: ( + sourceId: string, + config: Omit< CopySiteConfig, 'siteId' > + ) => Promise< SiteDetails | void >; startServer: ( id: string ) => Promise< void >; stopServer: ( id: string ) => Promise< void >; stopAllRunningSites: () => Promise< void >; @@ -55,6 +59,7 @@ const defaultContext: SiteDetailsContext = { siteCreationMessages: {}, setSelectedSiteId: () => undefined, createSite: async () => undefined, + copySite: async () => undefined, startServer: async () => undefined, stopServer: async () => undefined, stopAllRunningSites: async () => undefined, @@ -186,6 +191,15 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { } } ); + useIpcListener( 'copySiteProgress', ( _, { siteId, message } ) => { + if ( siteId && message ) { + setSiteCreationMessages( ( prev ) => ( { + ...prev, + [ siteId ]: message, + } ) ); + } + } ); + const toggleLoadingServerForSite = useCallback( ( siteId: string ) => { setLoadingServer( ( currentLoading ) => ( { ...currentLoading, @@ -334,6 +348,95 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { [ selectedTab, setSelectedSiteId, setSelectedTab ] ); + const copySite = useCallback( + async ( + sourceId: string, + config: Omit< CopySiteConfig, 'siteId' > + ): Promise< SiteDetails | void > => { + const tempSiteId = crypto.randomUUID(); + setAddingSiteIds( ( prev ) => [ ...prev, tempSiteId ] ); + setData( ( prevData ) => + sortSites( [ + ...prevData, + { + id: tempSiteId, + name: config.newName, + path: config.newPath, + port: -1, + running: false, + isAddingSite: true, + phpVersion: '', + }, + ] ) + ); + setSelectedSiteId( tempSiteId ); + + let newSite: SiteDetails; + try { + newSite = await getIpcApi().copySite( sourceId, { + ...config, + siteId: tempSiteId, + } ); + if ( ! newSite ) { + setTimeout( () => { + setAddingSiteIds( ( prev ) => prev.filter( ( id ) => id !== tempSiteId ) ); + setData( ( prevData ) => + sortSites( prevData.filter( ( site ) => site.id !== tempSiteId ) ) + ); + }, 2000 ); + return; + } + + setAddingSiteIds( ( prev ) => { + prev.push( newSite.id ); + return prev; + } ); + + setSelectedSiteId( ( prevSelectedSiteId ) => { + if ( prevSelectedSiteId === tempSiteId ) { + if ( selectedTab !== 'overview' ) { + setSelectedTab( 'overview' ); + } + return newSite.id; + } + return prevSelectedSiteId; + } ); + + setData( ( prevData ) => + prevData.map( ( site ) => ( site.id === tempSiteId ? newSite : site ) ) + ); + + setSiteCreationMessages( ( prev ) => { + const { [ newSite.id ]: _, ...rest } = prev; + return rest; + } ); + + return newSite; + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Failed to copy site' ), + message: __( + 'An error occurred while copying the site. Please try again. If this problem persists, please contact support.' + ), + error: simplifyErrorForDisplay( error ), + showOpenLogs: true, + } ); + + setTimeout( () => { + setAddingSiteIds( ( prev ) => prev.filter( ( id ) => id !== tempSiteId ) ); + setData( ( prevData ) => + sortSites( prevData.filter( ( site ) => site.id !== tempSiteId ) ) + ); + }, 2000 ); + } finally { + setAddingSiteIds( ( prev ) => + prev.filter( ( id ) => id !== tempSiteId && id !== newSite?.id ) + ); + } + }, + [ selectedTab, setSelectedSiteId, setSelectedTab ] + ); + const updateSite = useCallback( async ( site: SiteDetails ) => { await getIpcApi().updateSite( site ); const updatedSites = await getIpcApi().getSiteDetails(); @@ -511,6 +614,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { data, setSelectedSiteId, createSite, + copySite, updateSite, startServer, stopServer, @@ -530,6 +634,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { data, setSelectedSiteId, createSite, + copySite, updateSite, startServer, stopServer, diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 33c6dfdfde..bb5b745c4f 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -26,6 +26,7 @@ import { arePathsEqual, isEmptyDir, pathExists, + recursiveCopyDirectory, } from 'common/lib/fs-utils'; import { getWordPressVersion } from 'common/lib/get-wordpress-version'; import { isErrnoException } from 'common/lib/is-errno-exception'; @@ -304,6 +305,243 @@ export async function createSite( } } +export async function copySite( + event: IpcMainInvokeEvent, + sourceId: string, + config: CopySiteConfig +): Promise< SiteDetails > { + const { + siteId: newSiteId, + newName, + newPath, + copyOptions, + phpVersion, + wpVersion, + customDomain, + enableHttps, + } = config; + + bumpStat( StatsGroup.STUDIO_SITE_CREATE, StatsMetric.SITE_COPIED ); + + const sourceSite = SiteServer.get( sourceId ); + if ( ! sourceSite ) { + throw new Error( 'Source site not found' ); + } + + const sourceWpVersion = await getWordPressVersion( sourceSite.details.path ); + const targetWpVersion = wpVersion || sourceWpVersion; + const needsDifferentWpVersion = wpVersion && wpVersion !== sourceWpVersion; + + if ( ! ( await pathExists( newPath ) ) && newPath.startsWith( DEFAULT_SITE_PATH ) ) { + fs.mkdirSync( newPath, { recursive: true } ); + } + + if ( ! ( await isEmptyDir( newPath ) ) ) { + throw new Error( 'The destination directory is not empty.' ); + } + + const userData = await loadUserData(); + const allPaths = userData?.sites?.map( ( site ) => site.path ) || []; + if ( allPaths.includes( newPath ) ) { + throw new Error( 'The destination directory is already in use.' ); + } + + const sendProgress = ( step: CopyProgress[ 'step' ], message: string, percentage: number ) => { + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + sendIpcEventToRendererWithWindow( parentWindow, 'copySiteProgress', { + siteId: newSiteId, + step, + message, + percentage, + } ); + }; + + sendProgress( 'preparing', __( 'Preparing to copy site...' ), 0 ); + + const wasSourceRunning = sourceSite.details.running; + if ( wasSourceRunning ) { + await stopServer( event, sourceId ); + } + + try { + const port = await portFinder.getOpenPort(); + + const details = { + id: newSiteId, + name: newName, + path: newPath, + adminPassword: createPassword(), + port, + running: false, + phpVersion: phpVersion || sourceSite.details.phpVersion, + isWpAutoUpdating: targetWpVersion === getWordPressProvider().DEFAULT_WORDPRESS_VERSION, + customDomain, + enableHttps, + } as const; + + await fsPromises.mkdir( nodePath.join( newPath, 'wp-content' ), { recursive: true } ); + + const sourcePath = sourceSite.details.path; + + if ( needsDifferentWpVersion ) { + sendProgress( 'copying-core', __( 'Installing WordPress...' ), 10 ); + const tempServer = SiteServer.create( details, { wpVersion: targetWpVersion } ); + await createSiteWorkingDirectory( tempServer, targetWpVersion ); + } else { + sendProgress( 'copying-core', __( 'Copying WordPress core files...' ), 10 ); + + const entries = await fsPromises.readdir( sourcePath, { withFileTypes: true } ); + + for ( const entry of entries ) { + if ( entry.name === 'wp-content' ) { + continue; + } + const src = nodePath.join( sourcePath, entry.name ); + const dest = nodePath.join( newPath, entry.name ); + + if ( entry.isDirectory() ) { + await recursiveCopyDirectory( src, dest ); + } else if ( entry.isFile() ) { + await fsPromises.copyFile( src, dest ); + } + } + } + + const wpContentSource = nodePath.join( sourcePath, 'wp-content' ); + const wpContentDest = nodePath.join( newPath, 'wp-content' ); + + const muPluginsSource = nodePath.join( wpContentSource, 'mu-plugins' ); + const muPluginsDest = nodePath.join( wpContentDest, 'mu-plugins' ); + if ( await pathExists( muPluginsSource ) ) { + sendProgress( 'copying-core', __( 'Copying must-use plugins...' ), 20 ); + await recursiveCopyDirectory( muPluginsSource, muPluginsDest ); + } + + if ( copyOptions.plugins ) { + const pluginsSource = nodePath.join( wpContentSource, 'plugins' ); + const pluginsDest = nodePath.join( wpContentDest, 'plugins' ); + if ( await pathExists( pluginsSource ) ) { + sendProgress( 'copying-plugins', __( 'Copying plugins...' ), 30 ); + await recursiveCopyDirectory( pluginsSource, pluginsDest ); + } + } else { + await fsPromises.mkdir( nodePath.join( wpContentDest, 'plugins' ), { recursive: true } ); + } + + if ( copyOptions.themes ) { + const themesSource = nodePath.join( wpContentSource, 'themes' ); + const themesDest = nodePath.join( wpContentDest, 'themes' ); + if ( await pathExists( themesSource ) ) { + sendProgress( 'copying-themes', __( 'Copying themes...' ), 50 ); + await recursiveCopyDirectory( themesSource, themesDest ); + } + } else { + await fsPromises.mkdir( nodePath.join( wpContentDest, 'themes' ), { recursive: true } ); + } + + if ( copyOptions.uploads ) { + const uploadsSource = nodePath.join( wpContentSource, 'uploads' ); + const uploadsDest = nodePath.join( wpContentDest, 'uploads' ); + if ( await pathExists( uploadsSource ) ) { + sendProgress( 'copying-uploads', __( 'Copying uploads...' ), 60 ); + await recursiveCopyDirectory( uploadsSource, uploadsDest ); + } + } else { + await fsPromises.mkdir( nodePath.join( wpContentDest, 'uploads' ), { recursive: true } ); + } + + if ( copyOptions.database ) { + sendProgress( 'copying-database', __( 'Copying database...' ), 70 ); + + const dbSource = nodePath.join( wpContentSource, 'database' ); + const dbDest = nodePath.join( wpContentDest, 'database' ); + if ( await pathExists( dbSource ) ) { + await recursiveCopyDirectory( dbSource, dbDest ); + } + + const dbPhpSource = nodePath.join( wpContentSource, 'db.php' ); + const dbPhpDest = nodePath.join( wpContentDest, 'db.php' ); + if ( await pathExists( dbPhpSource ) ) { + await fsPromises.copyFile( dbPhpSource, dbPhpDest ); + } + } else { + await installSqliteIntegration( newPath ); + } + + const newServer = SiteServer.create( details, { + wpVersion: targetWpVersion, + } ); + + if ( copyOptions.database ) { + sendProgress( 'updating-urls', __( 'Updating site URLs...' ), 80 ); + + await newServer.start(); + + try { + const newUrl = getSiteUrl( details ); + await updateSiteUrl( newServer, newUrl ); + } finally { + await newServer.stop(); + } + } else { + await getWordPressProvider().installWordPressWhenNoWpConfig( + newServer, + newName, + details.adminPassword + ); + } + + sendProgress( 'finalizing', __( 'Finalizing copy...' ), 90 ); + + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + sendIpcEventToRendererWithWindow( parentWindow, 'theme-details-updating', { id: details.id } ); + + try { + await lockAppdata(); + const updatedUserData = await loadUserData(); + + updatedUserData.sites.push( newServer.details ); + sortSites( updatedUserData.sites ); + + await saveUserData( updatedUserData ); + } finally { + await unlockAppdata(); + } + + sendProgress( 'finalizing', __( 'Starting copied site...' ), 95 ); + await newServer.start(); + + sendIpcEventToRendererWithWindow( parentWindow, 'theme-details-changed', { + id: details.id, + details: newServer.details.themeDetails, + } ); + + if ( newServer.details.running ) { + void ( async () => { + try { + await newServer.updateCachedThumbnail(); + await sendThumbnailChangedEvent( event, details.id ); + } catch ( error ) { + // Ignore thumbnail update errors as they are non-critical + } + } )(); + } + + sendProgress( 'finalizing', __( 'Copy complete!' ), 100 ); + + return newServer.details; + } catch ( error ) { + if ( await pathExists( newPath ) ) { + await shell.trashItem( newPath ); + } + throw error; + } finally { + if ( wasSourceRunning ) { + await startServer( event, sourceId ); + } + } +} + export async function updateSite( event: IpcMainInvokeEvent, updatedSite: SiteDetails @@ -1644,6 +1882,23 @@ export function showSiteContextMenu( } ) ); + menu.append( + new MenuItem( { + label: __( 'Copy site…' ), + enabled: ! isAddingSite, + click: () => { + sendIpcEventToRendererWithWindow( + BrowserWindow.fromWebContents( event.sender ), + 'site-context-menu-action', + { + action: 'copy-site', + siteId, + } + ); + }, + } ) + ); + menu.append( new MenuItem( { label: __( 'Delete site…' ), @@ -1667,6 +1922,16 @@ export function showSiteContextMenu( } } +export function triggerAddSiteCopy( event: IpcMainInvokeEvent, siteId: string ): void { + sendIpcEventToRendererWithWindow( + BrowserWindow.fromWebContents( event.sender ), + 'add-site-copy', + { + siteId, + } + ); +} + export async function getFileContent( event: IpcMainInvokeEvent, filePath: string ) { if ( ! fs.existsSync( filePath ) ) { throw new Error( `File not found: ${ filePath }` ); diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index acf1b99345..3c1729cbd8 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -41,6 +41,37 @@ type SiteDetails = StartedSiteDetails | StoppedSiteDetails; type NewSiteDetails = Pick< SiteDetails, 'id' | 'path' | 'name' >; +interface CopySiteConfig { + siteId: string; + newName: string; + newPath: string; + copyOptions: { + database: boolean; + plugins: boolean; + themes: boolean; + uploads: boolean; + }; + phpVersion?: string; + wpVersion?: string; + customDomain?: string; + enableHttps?: boolean; +} + +interface CopyProgress { + siteId: string; + step: + | 'preparing' + | 'copying-core' + | 'copying-plugins' + | 'copying-themes' + | 'copying-uploads' + | 'copying-database' + | 'updating-urls' + | 'finalizing'; + message: string; + percentage: number; +} + type InstalledApps = { vscode: boolean; phpstorm: boolean; diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index 498f5f0587..df6d9c9d99 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -19,9 +19,12 @@ type SnapshotKeyValueEventData = { export interface IpcEvents { 'add-site': [ void ]; + 'add-site-blueprint': [ { blueprintPath: string } ]; + 'add-site-copy': [ { siteId: string } ]; 'add-site-blueprint-from-url': [ { blueprintPath: string } ]; 'add-site-blueprint-from-base64': [ { blueprintJson: string } ]; 'auth-updated': [ { token: StoredToken } | { error: unknown } ]; + copySiteProgress: [ CopyProgress ]; 'on-export': [ ImportExportEventData, string ]; 'on-import': [ ImportExportEventData, string ]; 'on-site-create-progress': [ { siteId: string; message: string } ]; diff --git a/src/modules/add-site/components/copy-site.tsx b/src/modules/add-site/components/copy-site.tsx new file mode 100644 index 0000000000..e1a23d8e34 --- /dev/null +++ b/src/modules/add-site/components/copy-site.tsx @@ -0,0 +1,121 @@ +import { + __experimentalVStack as VStack, + __experimentalHeading as Heading, + CheckboxControl, +} from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { FormEvent } from 'react'; +import { SiteForm } from 'src/components/site-form'; + +interface CopySiteProps { + siteName: string | null; + handleSiteNameChange: ( name: string ) => Promise< void >; + phpVersion: string; + setPhpVersion: ( version: string ) => void; + wpVersion: string; + setWpVersion: ( version: string ) => void; + sitePath: string; + handlePathSelectorClick: () => void; + error: string; + handleSubmit: ( event: FormEvent ) => void; + doesPathContainWordPress: boolean; + useCustomDomain: boolean; + setUseCustomDomain: ( use: boolean ) => void; + customDomain: string | null; + setCustomDomain: ( domain: string | null ) => void; + customDomainError: string; + enableHttps: boolean; + setEnableHttps: ( enable: boolean ) => void; + copyDatabase: boolean; + setCopyDatabase: ( copy: boolean ) => void; + copyPlugins: boolean; + setCopyPlugins: ( copy: boolean ) => void; + copyThemes: boolean; + setCopyThemes: ( copy: boolean ) => void; + copyUploads: boolean; + setCopyUploads: ( copy: boolean ) => void; +} + +export default function CopySite( { + siteName, + handleSiteNameChange, + phpVersion, + setPhpVersion, + wpVersion, + setWpVersion, + sitePath, + handlePathSelectorClick, + error, + handleSubmit, + doesPathContainWordPress, + useCustomDomain, + setUseCustomDomain, + customDomain, + setCustomDomain, + customDomainError, + enableHttps, + setEnableHttps, + copyDatabase, + setCopyDatabase, + copyPlugins, + setCopyPlugins, + copyThemes, + setCopyThemes, + copyUploads, + setCopyUploads, +}: CopySiteProps ) { + const { __ } = useI18n(); + + return ( + + + { __( 'Copy site' ) } + + + void handleSiteNameChange( name ) } + phpVersion={ phpVersion } + setPhpVersion={ setPhpVersion } + wpVersion={ wpVersion } + setWpVersion={ setWpVersion } + sitePath={ sitePath } + onSelectPath={ handlePathSelectorClick } + error={ error } + onSubmit={ handleSubmit } + doesPathContainWordPress={ doesPathContainWordPress } + useCustomDomain={ useCustomDomain } + setUseCustomDomain={ setUseCustomDomain } + customDomain={ customDomain } + setCustomDomain={ setCustomDomain } + customDomainError={ customDomainError } + enableHttps={ enableHttps } + setEnableHttps={ setEnableHttps } + beforeAdvancedSettings={ + + + + + + + } + /> + + ); +} diff --git a/src/modules/add-site/components/options.tsx b/src/modules/add-site/components/options.tsx index 8a2c6d89fc..635e13adab 100644 --- a/src/modules/add-site/components/options.tsx +++ b/src/modules/add-site/components/options.tsx @@ -13,7 +13,7 @@ import { cx } from 'src/lib/cx'; import { useRootSelector } from 'src/stores'; import { BlueprintIcon } from './blueprint-icon'; -export type AddSiteFlowType = 'create' | 'blueprint' | 'backup' | 'pullRemote'; +export type AddSiteFlowType = 'create' | 'blueprint' | 'backup' | 'pullRemote' | 'copy'; interface AddSiteOptionsProps { onOptionSelect: ( option: AddSiteFlowType ) => void; } diff --git a/src/modules/add-site/components/stepper.tsx b/src/modules/add-site/components/stepper.tsx index dc838d5f6a..73ac137958 100644 --- a/src/modules/add-site/components/stepper.tsx +++ b/src/modules/add-site/components/stepper.tsx @@ -85,7 +85,7 @@ export default function Stepper( {
- { currentPath && currentPath !== '/' && onBack && ( + { currentPath && currentPath !== '/' && currentPath !== '/copy' && onBack && ( diff --git a/src/modules/add-site/hooks/use-stepper.ts b/src/modules/add-site/hooks/use-stepper.ts index 7e7d50fd0e..2fe4615ad1 100644 --- a/src/modules/add-site/hooks/use-stepper.ts +++ b/src/modules/add-site/hooks/use-stepper.ts @@ -66,6 +66,10 @@ export function useStepper( config?: StepperConfig ): UseStepper { { id: 'site-details', label: __( 'Site name & details' ), path: '/pullRemote/create' }, ]; + const copySteps: StepperStep[] = [ + { id: 'copy-site', label: __( 'Copy site' ), path: '/copy' }, + ]; + if ( location.path?.startsWith( '/blueprint' ) ) { return { flow: 'blueprint', @@ -94,6 +98,13 @@ export function useStepper( config?: StepperConfig ): UseStepper { }; } + if ( location.path === '/copy' ) { + return { + flow: 'copy', + steps: copySteps, + }; + } + return null; }, [ location.path, __ ] ); @@ -163,6 +174,11 @@ export function useStepper( config?: StepperConfig ): UseStepper { label: __( 'Add site' ), isVisible: true, }; + case '/copy': + return { + label: __( 'Copy site' ), + isVisible: true, + }; default: return undefined; } @@ -186,6 +202,7 @@ export function useStepper( config?: StepperConfig ): UseStepper { case '/blueprint/create': case '/backup/create': case '/pullRemote/create': + case '/copy': config?.onCreateSubmit?.( { preventDefault: () => {} } as FormEvent ); break; } @@ -206,6 +223,7 @@ export function useStepper( config?: StepperConfig ): UseStepper { case '/blueprint/create': case '/backup/create': case '/pullRemote/create': + case '/copy': return config?.canSubmitCreate ?? false; default: return false; diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx index 18081aaed2..d968ac5493 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -9,6 +9,8 @@ import { useAddSite } from 'src/hooks/use-add-site'; import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import { useImportExport } from 'src/hooks/use-import-export'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { generateCustomDomainFromSiteName } from 'src/lib/domains'; import { generateSiteName } from 'src/lib/generate-site-name'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; @@ -21,6 +23,7 @@ import { import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import { useGetBlueprints, Blueprint } from 'src/stores/wpcom-api'; import { AddSiteBlueprintSelector } from './components/blueprints'; +import CopySite from './components/copy-site'; import CreateSite from './components/create-site'; import ImportBackup from './components/import-backup'; import AddSiteOptions, { type AddSiteFlowType } from './components/options'; @@ -67,6 +70,15 @@ interface NavigationContentProps { setSelectedRemoteSite: ( site?: SyncSite ) => void; blueprintError?: string | null; setBlueprintError: ( error: string | null ) => void; + sourceSiteId?: string; + copyDatabase: boolean; + setCopyDatabase: ( copy: boolean ) => void; + copyPlugins: boolean; + setCopyPlugins: ( copy: boolean ) => void; + copyThemes: boolean; + setCopyThemes: ( copy: boolean ) => void; + copyUploads: boolean; + setCopyUploads: ( copy: boolean ) => void; } function NavigationContent( props: NavigationContentProps ) { @@ -82,6 +94,15 @@ function NavigationContent( props: NavigationContentProps ) { setSelectedRemoteSite, blueprintError, setBlueprintError, + sourceSiteId, + copyDatabase, + setCopyDatabase, + copyPlugins, + setCopyPlugins, + copyThemes, + setCopyThemes, + copyUploads, + setCopyUploads, ...createSiteProps } = props; @@ -136,7 +157,8 @@ function NavigationContent( props: NavigationContentProps ) { location.path === '/create' || location.path === '/blueprint/create' || location.path === '/backup/create' || - location.path === '/pullRemote/create'; + location.path === '/pullRemote/create' || + location.path === '/copy'; const canSubmit = isOnCreatePath && createSiteProps.siteName?.trim() && @@ -150,6 +172,10 @@ function NavigationContent( props: NavigationContentProps ) { goTo( '/backup' ); } else if ( location.path === '/pullRemote/create' ) { goTo( '/pullRemote' ); + } else if ( location.path === '/copy' ) { + // Copy flow should close the modal, not go back to options + // The modal will close via the stepper's back button + goTo( '/' ); } else if ( location.path === '/backup' || location.path === '/blueprint' || @@ -271,6 +297,19 @@ function NavigationContent( props: NavigationContentProps ) { + + + (); + const [ sourceSiteId, setSourceSiteId ] = useState< string | undefined >(); + const [ copyDatabase, setCopyDatabase ] = useState( true ); + const [ copyPlugins, setCopyPlugins ] = useState( true ); + const [ copyThemes, setCopyThemes ] = useState( true ); + const [ copyUploads, setCopyUploads ] = useState( true ); const { data: blueprintsData, @@ -311,7 +356,9 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro handleAddSiteClick, siteName, setSiteName, + phpVersion, setPhpVersion, + wpVersion, setWpVersion, setProposedSitePath, setSitePath, @@ -322,6 +369,7 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro setUseCustomDomain, setCustomDomain, setCustomDomainError, + enableHttps, setEnableHttps, setFileForImport, loadAllCustomDomains, @@ -354,6 +402,11 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro setSelectedBlueprint( undefined ); setBlueprintPreferredVersions( undefined ); setSelectedRemoteSite( undefined ); + setSourceSiteId( undefined ); + setCopyDatabase( true ); + setCopyPlugins( true ); + setCopyThemes( true ); + setCopyUploads( true ); }, [ setSitePath, setDoesPathContainWordPress, @@ -435,11 +488,63 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro async ( event: FormEvent ) => { event.preventDefault(); closeModal(); - await handleAddSiteClick(); - speak( siteAddedMessage ); + + // Check if we're in copy mode + if ( sourceSiteId ) { + const path = addSiteProps.sitePath || addSiteProps.proposedSitePath; + let usedCustomDomain = + addSiteProps.useCustomDomain && addSiteProps.customDomain + ? addSiteProps.customDomain + : undefined; + if ( addSiteProps.useCustomDomain && ! addSiteProps.customDomain ) { + usedCustomDomain = generateCustomDomainFromSiteName( siteName ?? '' ); + } + + await copySiteFromContext( sourceSiteId, { + newName: siteName ?? '', + newPath: path, + copyOptions: { + database: copyDatabase, + plugins: copyPlugins, + themes: copyThemes, + uploads: copyUploads, + }, + phpVersion, + wpVersion, + customDomain: usedCustomDomain, + enableHttps: addSiteProps.useCustomDomain ? enableHttps : false, + } ); + + const copiedMessage = sprintf( + // translators: %s is the site name. + __( '%s site copied.' ), + siteName || '' + ); + speak( copiedMessage ); + } else { + await handleAddSiteClick(); + speak( siteAddedMessage ); + } + setNameSuggested( false ); }, - [ handleAddSiteClick, siteAddedMessage, closeModal ] + [ + handleAddSiteClick, + siteAddedMessage, + closeModal, + sourceSiteId, + siteName, + addSiteProps, + copyDatabase, + copyPlugins, + copyThemes, + copyUploads, + phpVersion, + wpVersion, + enableHttps, + copySiteFromContext, + __, + ] ); useIpcListener( 'add-site', () => { @@ -449,10 +554,43 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro openModal(); } ); + useIpcListener( 'add-site-copy', async ( _, { siteId }: { siteId: string } ) => { + if ( isAnySiteProcessing ) { + return; + } + // Get the source site details + const sourceSite = sites.find( ( site ) => site.id === siteId ); + if ( ! sourceSite ) { + return; + } + // Pre-fill the form with source site info + const copySiteName = `${ sourceSite.name } Copy`; + setSiteName( copySiteName ); + setNameSuggested( true ); // Prevent initializeForm from overwriting the name + setSourceSiteId( siteId ); + setPhpVersion( sourceSite.phpVersion ); + + // Generate a proposed path for the copy + const { path } = await getIpcApi().generateProposedSitePath( copySiteName ); + setProposedSitePath( path ); + setSitePath( '' ); + setError( '' ); + + // Open modal with copy flow + setShowModal( true ); + } ); + + const getInitialPath = useCallback( () => { + if ( sourceSiteId ) { + return '/copy'; + } + return initialNavigatorPath; + }, [ sourceSiteId, initialNavigatorPath ] ); + return ( <> - + diff --git a/src/preload.ts b/src/preload.ts index bd9059d2bd..8b44c0def7 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -34,6 +34,7 @@ const api: IpcApi = { ), deleteSite: ( id, deleteFiles ) => ipcRendererInvoke( 'deleteSite', id, deleteFiles ), createSite: ( path, config ) => ipcRendererInvoke( 'createSite', path, config ), + copySite: ( sourceId, config ) => ipcRendererInvoke( 'copySite', sourceId, config ), updateSite: ( updatedSite ) => ipcRendererInvoke( 'updateSite', updatedSite ), connectWpcomSites: ( ...args ) => ipcRendererInvoke( 'connectWpcomSites', ...args ), disconnectWpcomSites: ( ...args ) => ipcRendererInvoke( 'disconnectWpcomSites', ...args ), @@ -137,6 +138,7 @@ const api: IpcApi = { showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), + triggerAddSiteCopy: ( siteId ) => ipcRendererSend( 'triggerAddSiteCopy', siteId ), }; contextBridge.exposeInMainWorld( 'ipcApi', api );