From 220e8130d60c2366b2ebe6954c08bb7e34201aec Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Tue, 11 Nov 2025 17:34:39 +0100 Subject: [PATCH 1/6] Initial commit. --- common/types/stats.ts | 1 + src/components/content-tab-settings.tsx | 4 +- src/components/copy-site.tsx | 33 ++ src/components/site-form.tsx | 4 + src/components/site-menu.tsx | 3 + src/constants.ts | 1 + src/hooks/use-clone-site.ts | 73 +++++ src/hooks/use-site-details.tsx | 107 +++++++ src/ipc-handlers.ts | 283 ++++++++++++++++++ src/ipc-types.d.ts | 23 ++ src/ipc-utils.ts | 2 + src/modules/add-site/components/copy-site.tsx | 121 ++++++++ src/modules/add-site/components/options.tsx | 2 +- src/modules/add-site/components/stepper.tsx | 2 +- src/modules/add-site/hooks/use-stepper.ts | 18 ++ src/modules/add-site/index.tsx | 151 +++++++++- src/preload.ts | 2 + 17 files changed, 822 insertions(+), 8 deletions(-) create mode 100644 src/components/copy-site.tsx create mode 100644 src/hooks/use-clone-site.ts create mode 100644 src/modules/add-site/components/copy-site.tsx 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 5afcde32dd..7678393faf 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..a3461e6beb --- /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 { getIpcApi } from 'src/lib/get-ipc-api'; +import { useSiteDetails } from 'src/hooks/use-site-details'; + +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 9d433cfb1e..f214726564 100644 --- a/src/components/site-form.tsx +++ b/src/components/site-form.tsx @@ -54,6 +54,7 @@ interface SiteFormErrorProps { interface SiteFormProps { className?: string; children?: React.ReactNode; + beforeAdvancedSettings?: React.ReactNode; siteName: string; setSiteName: ( name: string ) => void; sitePath?: string; @@ -245,6 +246,7 @@ function FormImportComponent( { export const SiteForm = ( { className, children, + beforeAdvancedSettings, siteName, setSiteName, phpVersion, @@ -363,6 +365,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 3b937fb5eb..aa56862af7 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-clone-site.ts b/src/hooks/use-clone-site.ts new file mode 100644 index 0000000000..dd51a7d432 --- /dev/null +++ b/src/hooks/use-clone-site.ts @@ -0,0 +1,73 @@ +import { useI18n } from '@wordpress/react-i18n'; +import { useCallback, useState } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useSiteDetails } from 'src/hooks/use-site-details'; + +export function useCloneSite() { + const { __ } = useI18n(); + const { data: sites } = useSiteDetails(); + const [ isCloning, setIsCloning ] = useState( false ); + const [ progress, setProgress ] = useState< CloneProgress | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + + const siteWithPathAlreadyExists = useCallback( + async ( path: string ) => { + const results = await Promise.all( + sites.map( ( site ) => getIpcApi().comparePaths( site.path, path ) ) + ); + return results.some( Boolean ); + }, + [ sites ] + ); + + const cloneSite = useCallback( + async ( sourceId: string, config: CloneSiteConfig ): Promise< SiteDetails | null > => { + setIsCloning( true ); + setError( null ); + setProgress( null ); + + try { + // Validate path doesn't already exist + if ( await siteWithPathAlreadyExists( config.newPath ) ) { + throw new Error( + __( + 'The directory is already associated with another Studio site. Please choose a different path.' + ) + ); + } + + // Subscribe to progress events + const unsubscribe = window.ipcListener.subscribe( + 'cloneSiteProgress', + ( _event, progressData ) => { + setProgress( progressData ); + } + ); + + const result = await getIpcApi().cloneSite( sourceId, config ); + + // Cleanup listener + unsubscribe(); + + return result; + } catch ( err ) { + const errorMessage = + err instanceof Error ? err.message : __( 'Failed to clone site. Please try again.' ); + setError( errorMessage ); + return null; + } finally { + setIsCloning( false ); + setProgress( null ); + } + }, + [ __, siteWithPathAlreadyExists ] + ); + + return { + cloneSite, + isCloning, + progress, + error, + clearError: () => setError( null ), + }; +} diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index ca247ad208..2759570db5 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,97 @@ 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 ) { + // Error handling + 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 ) { + console.error( 'Error during site copy:', 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 +616,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { data, setSelectedSiteId, createSite, + copySite, updateSite, startServer, stopServer, @@ -530,6 +636,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 2222476ead..96db2c3971 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -27,6 +27,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,265 @@ 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 ); + + // Validate source site exists + const sourceSite = SiteServer.get( sourceId ); + if ( ! sourceSite ) { + throw new Error( 'Source site not found' ); + } + + // Get source WordPress version to determine if we need to install a different version + const sourceWpVersion = await getWordPressVersion( sourceSite.details.path ); + const targetWpVersion = wpVersion || sourceWpVersion; + const needsDifferentWpVersion = wpVersion && wpVersion !== sourceWpVersion; + + // Validate destination path + 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.' ); + } + + // Helper to send progress updates + 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 ); + + // Stop source site if running to prevent database corruption + 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; + + // Create the destination directory structure + await fsPromises.mkdir( nodePath.join( newPath, 'wp-content' ), { recursive: true } ); + + const sourcePath = sourceSite.details.path; + + // If a different WordPress version is requested, install it fresh + if ( needsDifferentWpVersion ) { + sendProgress( 'copying-core', __( 'Installing WordPress...' ), 10 ); + const tempServer = SiteServer.create( details, { wpVersion: targetWpVersion } ); + await createSiteWorkingDirectory( tempServer, targetWpVersion ); + } else { + // Copy WordPress core files (wp-admin, wp-includes, root files) + sendProgress( 'copying-core', __( 'Copying WordPress core files...' ), 10 ); + + const entries = await fsPromises.readdir( sourcePath, { withFileTypes: true } ); + + // Copy all files and directories except wp-content + 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 ); + } + } + } + + // Copy wp-content subdirectories based on copy options + const wpContentSource = nodePath.join( sourcePath, 'wp-content' ); + const wpContentDest = nodePath.join( newPath, 'wp-content' ); + + // Always copy mu-plugins (contains SQLite integration) + 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 ); + } + + // Copy plugins if selected + 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 { + // Create empty plugins directory + await fsPromises.mkdir( nodePath.join( wpContentDest, 'plugins' ), { recursive: true } ); + } + + // Copy themes if selected + 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 { + // Create empty themes directory + await fsPromises.mkdir( nodePath.join( wpContentDest, 'themes' ), { recursive: true } ); + } + + // Copy uploads if selected + 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 { + // Create empty uploads directory + await fsPromises.mkdir( nodePath.join( wpContentDest, 'uploads' ), { recursive: true } ); + } + + // Copy database if selected + if ( copyOptions.database ) { + sendProgress( 'copying-database', __( 'Copying database...' ), 70 ); + + // Copy the database directory + const dbSource = nodePath.join( wpContentSource, 'database' ); + const dbDest = nodePath.join( wpContentDest, 'database' ); + if ( await pathExists( dbSource ) ) { + await recursiveCopyDirectory( dbSource, dbDest ); + } + + // Copy db.php if it exists + const dbPhpSource = nodePath.join( wpContentSource, 'db.php' ); + const dbPhpDest = nodePath.join( wpContentDest, 'db.php' ); + if ( await pathExists( dbPhpSource ) ) { + await fsPromises.copyFile( dbPhpSource, dbPhpDest ); + } + } else { + // If not copying database, ensure SQLite integration is installed + await installSqliteIntegration( newPath ); + } + + // Create the new site server instance + const newServer = SiteServer.create( details, { + wpVersion: targetWpVersion, + } ); + + // If database was copied, update URLs + if ( copyOptions.database ) { + sendProgress( 'updating-urls', __( 'Updating site URLs...' ), 80 ); + + // Start the server temporarily to run WP-CLI commands + await newServer.start(); + + try { + const newUrl = getSiteUrl( details ); + await updateSiteUrl( newServer, newUrl ); + } finally { + // Stop the server after URL updates + await newServer.stop(); + } + } else { + // Initialize fresh WordPress installation if no database was copied + await getWordPressProvider().installWordPressWhenNoWpConfig( + newServer, + newName, + details.adminPassword + ); + } + + // Register the new site + 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(); + } + + // Start the copied site + sendProgress( 'finalizing', __( 'Starting copied site...' ), 95 ); + await newServer.start(); + + // Send theme details and generate thumbnail (same as startServer does) + 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 ) { + console.error( `Failed to update thumbnail for copied site ${ details.id }:`, error ); + } + } )(); + } + + sendProgress( 'finalizing', __( 'Copy complete!' ), 100 ); + + return newServer.details; + } catch ( error ) { + // Cleanup on failure + if ( await pathExists( newPath ) ) { + await shell.trashItem( newPath ); + } + throw error; + } finally { + // Restart source site if it was running + if ( wasSourceRunning ) { + await startServer( event, sourceId ); + } + } +} + export async function updateSite( event: IpcMainInvokeEvent, updatedSite: SiteDetails @@ -1634,6 +1894,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…' ), @@ -1657,6 +1934,12 @@ 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 9142f191cd..b67988d840 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -41,6 +41,29 @@ 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 6497461734..475246f757 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -20,7 +20,9 @@ type SnapshotKeyValueEventData = { export interface IpcEvents { 'add-site': [ void ]; 'add-site-blueprint': [ { blueprintPath: string } ]; + 'add-site-copy': [ { siteId: 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..fec385aa1f --- /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 8de226ec6f..31e1cbc820 100644 --- a/src/modules/add-site/components/options.tsx +++ b/src/modules/add-site/components/options.tsx @@ -12,7 +12,7 @@ import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; 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 c6dc5466dd..26cfb3b229 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 df2c09f3a3..f1dc36f500 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -1,7 +1,9 @@ +import * as Sentry from '@sentry/electron/renderer'; import { speak } from '@wordpress/a11y'; import { Navigator, useNavigator } from '@wordpress/components'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; +import crypto from 'crypto'; import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import Button, { ButtonVariant } from 'src/components/button'; import { FullscreenModal } from 'src/components/fullscreen-modal'; @@ -9,8 +11,11 @@ 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 { sortSites } from 'src/lib/sort-sites'; import { useRootSelector } from 'src/stores'; import { formatRtkError } from 'src/stores/format-rtk-error'; import { @@ -21,6 +26,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 +73,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 +97,15 @@ function NavigationContent( props: NavigationContentProps ) { setSelectedRemoteSite, blueprintError, setBlueprintError, + sourceSiteId, + copyDatabase, + setCopyDatabase, + copyPlugins, + setCopyPlugins, + copyThemes, + setCopyThemes, + copyUploads, + setCopyUploads, ...createSiteProps } = props; @@ -136,7 +160,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 +175,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 +300,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 +359,9 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro handleAddSiteClick, siteName, setSiteName, + phpVersion, setPhpVersion, + wpVersion, setWpVersion, setProposedSitePath, setSitePath, @@ -322,6 +372,7 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro setUseCustomDomain, setCustomDomain, setCustomDomainError, + enableHttps, setEnableHttps, setFileForImport, loadAllCustomDomains, @@ -354,6 +405,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, @@ -436,11 +492,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', () => { @@ -450,10 +558,34 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro openModal(); } ); + useIpcListener( 'add-site-copy', ( _, { 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 + setSiteName( `${ sourceSite.name } Copy` ); + setSourceSiteId( siteId ); + setPhpVersion( sourceSite.phpVersion ); + // 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 8207918a19..165f01de3c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -28,6 +28,7 @@ const api: IpcApi = { ipcRendererInvoke( 'pushArchive', remoteSiteId, archivePath, optionsToSync ), 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 ), @@ -130,6 +131,7 @@ const api: IpcApi = { showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), + triggerAddSiteCopy: ( siteId ) => ipcRendererSend( 'triggerAddSiteCopy', siteId ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); From e7eaa46f8c38457e0a983ecc52ed257cf5553391 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Tue, 11 Nov 2025 17:50:28 +0100 Subject: [PATCH 2/6] Adjust checkbox spacing. --- src/modules/add-site/components/copy-site.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/add-site/components/copy-site.tsx b/src/modules/add-site/components/copy-site.tsx index fec385aa1f..e1a23d8e34 100644 --- a/src/modules/add-site/components/copy-site.tsx +++ b/src/modules/add-site/components/copy-site.tsx @@ -92,7 +92,7 @@ export default function CopySite( { enableHttps={ enableHttps } setEnableHttps={ setEnableHttps } beforeAdvancedSettings={ - + Date: Tue, 11 Nov 2025 17:50:42 +0100 Subject: [PATCH 3/6] Use the name of the copied site. --- src/modules/add-site/index.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx index f1dc36f500..04e1cb4536 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -558,7 +558,7 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro openModal(); } ); - useIpcListener( 'add-site-copy', ( _, { siteId }: { siteId: string } ) => { + useIpcListener( 'add-site-copy', async ( _, { siteId }: { siteId: string } ) => { if ( isAnySiteProcessing ) { return; } @@ -568,9 +568,18 @@ export default function AddSite( { className, variant = 'outlined' }: AddSitePro return; } // Pre-fill the form with source site info - setSiteName( `${ sourceSite.name } Copy` ); + 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 ); } ); From 735b5e249668874f3c51d39fff341654d6f9d410 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Thu, 20 Nov 2025 13:27:08 -0600 Subject: [PATCH 4/6] Remove unused file. --- src/hooks/use-clone-site.ts | 73 ------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 src/hooks/use-clone-site.ts diff --git a/src/hooks/use-clone-site.ts b/src/hooks/use-clone-site.ts deleted file mode 100644 index dd51a7d432..0000000000 --- a/src/hooks/use-clone-site.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useState } from 'react'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useSiteDetails } from 'src/hooks/use-site-details'; - -export function useCloneSite() { - const { __ } = useI18n(); - const { data: sites } = useSiteDetails(); - const [ isCloning, setIsCloning ] = useState( false ); - const [ progress, setProgress ] = useState< CloneProgress | null >( null ); - const [ error, setError ] = useState< string | null >( null ); - - const siteWithPathAlreadyExists = useCallback( - async ( path: string ) => { - const results = await Promise.all( - sites.map( ( site ) => getIpcApi().comparePaths( site.path, path ) ) - ); - return results.some( Boolean ); - }, - [ sites ] - ); - - const cloneSite = useCallback( - async ( sourceId: string, config: CloneSiteConfig ): Promise< SiteDetails | null > => { - setIsCloning( true ); - setError( null ); - setProgress( null ); - - try { - // Validate path doesn't already exist - if ( await siteWithPathAlreadyExists( config.newPath ) ) { - throw new Error( - __( - 'The directory is already associated with another Studio site. Please choose a different path.' - ) - ); - } - - // Subscribe to progress events - const unsubscribe = window.ipcListener.subscribe( - 'cloneSiteProgress', - ( _event, progressData ) => { - setProgress( progressData ); - } - ); - - const result = await getIpcApi().cloneSite( sourceId, config ); - - // Cleanup listener - unsubscribe(); - - return result; - } catch ( err ) { - const errorMessage = - err instanceof Error ? err.message : __( 'Failed to clone site. Please try again.' ); - setError( errorMessage ); - return null; - } finally { - setIsCloning( false ); - setProgress( null ); - } - }, - [ __, siteWithPathAlreadyExists ] - ); - - return { - cloneSite, - isCloning, - progress, - error, - clearError: () => setError( null ), - }; -} From a7c473372b6346c07209b21800e8a125bbf81cb6 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Thu, 20 Nov 2025 13:27:21 -0600 Subject: [PATCH 5/6] Remove console logs and unnecessary comments. --- src/hooks/use-site-details.tsx | 2 -- src/ipc-handlers.ts | 34 +--------------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index 2759570db5..2d9df99351 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -378,7 +378,6 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { siteId: tempSiteId, } ); if ( ! newSite ) { - // Error handling setTimeout( () => { setAddingSiteIds( ( prev ) => prev.filter( ( id ) => id !== tempSiteId ) ); setData( ( prevData ) => @@ -414,7 +413,6 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { return newSite; } catch ( error ) { - console.error( 'Error during site copy:', error ); getIpcApi().showErrorMessageBox( { title: __( 'Failed to copy site' ), message: __( diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 420dafc808..dc72f32bab 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -314,18 +314,15 @@ export async function copySite( bumpStat( StatsGroup.STUDIO_SITE_CREATE, StatsMetric.SITE_COPIED ); - // Validate source site exists const sourceSite = SiteServer.get( sourceId ); if ( ! sourceSite ) { throw new Error( 'Source site not found' ); } - // Get source WordPress version to determine if we need to install a different version const sourceWpVersion = await getWordPressVersion( sourceSite.details.path ); const targetWpVersion = wpVersion || sourceWpVersion; const needsDifferentWpVersion = wpVersion && wpVersion !== sourceWpVersion; - // Validate destination path if ( ! ( await pathExists( newPath ) ) && newPath.startsWith( DEFAULT_SITE_PATH ) ) { fs.mkdirSync( newPath, { recursive: true } ); } @@ -340,7 +337,6 @@ export async function copySite( throw new Error( 'The destination directory is already in use.' ); } - // Helper to send progress updates const sendProgress = ( step: CopyProgress[ 'step' ], message: string, percentage: number ) => { const parentWindow = BrowserWindow.fromWebContents( event.sender ); sendIpcEventToRendererWithWindow( parentWindow, 'copySiteProgress', { @@ -353,7 +349,6 @@ export async function copySite( sendProgress( 'preparing', __( 'Preparing to copy site...' ), 0 ); - // Stop source site if running to prevent database corruption const wasSourceRunning = sourceSite.details.running; if ( wasSourceRunning ) { await stopServer( event, sourceId ); @@ -375,23 +370,19 @@ export async function copySite( enableHttps, } as const; - // Create the destination directory structure await fsPromises.mkdir( nodePath.join( newPath, 'wp-content' ), { recursive: true } ); const sourcePath = sourceSite.details.path; - // If a different WordPress version is requested, install it fresh if ( needsDifferentWpVersion ) { sendProgress( 'copying-core', __( 'Installing WordPress...' ), 10 ); const tempServer = SiteServer.create( details, { wpVersion: targetWpVersion } ); await createSiteWorkingDirectory( tempServer, targetWpVersion ); } else { - // Copy WordPress core files (wp-admin, wp-includes, root files) sendProgress( 'copying-core', __( 'Copying WordPress core files...' ), 10 ); const entries = await fsPromises.readdir( sourcePath, { withFileTypes: true } ); - // Copy all files and directories except wp-content for ( const entry of entries ) { if ( entry.name === 'wp-content' ) { continue; @@ -407,11 +398,9 @@ export async function copySite( } } - // Copy wp-content subdirectories based on copy options const wpContentSource = nodePath.join( sourcePath, 'wp-content' ); const wpContentDest = nodePath.join( newPath, 'wp-content' ); - // Always copy mu-plugins (contains SQLite integration) const muPluginsSource = nodePath.join( wpContentSource, 'mu-plugins' ); const muPluginsDest = nodePath.join( wpContentDest, 'mu-plugins' ); if ( await pathExists( muPluginsSource ) ) { @@ -419,7 +408,6 @@ export async function copySite( await recursiveCopyDirectory( muPluginsSource, muPluginsDest ); } - // Copy plugins if selected if ( copyOptions.plugins ) { const pluginsSource = nodePath.join( wpContentSource, 'plugins' ); const pluginsDest = nodePath.join( wpContentDest, 'plugins' ); @@ -428,11 +416,9 @@ export async function copySite( await recursiveCopyDirectory( pluginsSource, pluginsDest ); } } else { - // Create empty plugins directory await fsPromises.mkdir( nodePath.join( wpContentDest, 'plugins' ), { recursive: true } ); } - // Copy themes if selected if ( copyOptions.themes ) { const themesSource = nodePath.join( wpContentSource, 'themes' ); const themesDest = nodePath.join( wpContentDest, 'themes' ); @@ -441,11 +427,9 @@ export async function copySite( await recursiveCopyDirectory( themesSource, themesDest ); } } else { - // Create empty themes directory await fsPromises.mkdir( nodePath.join( wpContentDest, 'themes' ), { recursive: true } ); } - // Copy uploads if selected if ( copyOptions.uploads ) { const uploadsSource = nodePath.join( wpContentSource, 'uploads' ); const uploadsDest = nodePath.join( wpContentDest, 'uploads' ); @@ -454,53 +438,43 @@ export async function copySite( await recursiveCopyDirectory( uploadsSource, uploadsDest ); } } else { - // Create empty uploads directory await fsPromises.mkdir( nodePath.join( wpContentDest, 'uploads' ), { recursive: true } ); } - // Copy database if selected if ( copyOptions.database ) { sendProgress( 'copying-database', __( 'Copying database...' ), 70 ); - // Copy the database directory const dbSource = nodePath.join( wpContentSource, 'database' ); const dbDest = nodePath.join( wpContentDest, 'database' ); if ( await pathExists( dbSource ) ) { await recursiveCopyDirectory( dbSource, dbDest ); } - // Copy db.php if it exists const dbPhpSource = nodePath.join( wpContentSource, 'db.php' ); const dbPhpDest = nodePath.join( wpContentDest, 'db.php' ); if ( await pathExists( dbPhpSource ) ) { await fsPromises.copyFile( dbPhpSource, dbPhpDest ); } } else { - // If not copying database, ensure SQLite integration is installed await installSqliteIntegration( newPath ); } - // Create the new site server instance const newServer = SiteServer.create( details, { wpVersion: targetWpVersion, } ); - // If database was copied, update URLs if ( copyOptions.database ) { sendProgress( 'updating-urls', __( 'Updating site URLs...' ), 80 ); - // Start the server temporarily to run WP-CLI commands await newServer.start(); try { const newUrl = getSiteUrl( details ); await updateSiteUrl( newServer, newUrl ); } finally { - // Stop the server after URL updates await newServer.stop(); } } else { - // Initialize fresh WordPress installation if no database was copied await getWordPressProvider().installWordPressWhenNoWpConfig( newServer, newName, @@ -508,7 +482,6 @@ export async function copySite( ); } - // Register the new site sendProgress( 'finalizing', __( 'Finalizing copy...' ), 90 ); const parentWindow = BrowserWindow.fromWebContents( event.sender ); @@ -526,11 +499,9 @@ export async function copySite( await unlockAppdata(); } - // Start the copied site sendProgress( 'finalizing', __( 'Starting copied site...' ), 95 ); await newServer.start(); - // Send theme details and generate thumbnail (same as startServer does) sendIpcEventToRendererWithWindow( parentWindow, 'theme-details-changed', { id: details.id, details: newServer.details.themeDetails, @@ -541,8 +512,7 @@ export async function copySite( try { await newServer.updateCachedThumbnail(); await sendThumbnailChangedEvent( event, details.id ); - } catch ( error ) { - console.error( `Failed to update thumbnail for copied site ${ details.id }:`, error ); + } catch { } } )(); } @@ -551,13 +521,11 @@ export async function copySite( return newServer.details; } catch ( error ) { - // Cleanup on failure if ( await pathExists( newPath ) ) { await shell.trashItem( newPath ); } throw error; } finally { - // Restart source site if it was running if ( wasSourceRunning ) { await startServer( event, sourceId ); } From 85c836d6b02b14a99b527aca0d12c62be77f9ada Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Thu, 20 Nov 2025 14:30:39 -0600 Subject: [PATCH 6/6] Fix linting errors related to this branch. --- src/components/copy-site.tsx | 2 +- src/hooks/use-site-details.tsx | 2 +- src/ipc-handlers.ts | 24 +++++++++++++++++++----- src/ipc-types.d.ts | 10 +++++++++- src/ipc-utils.ts | 2 +- src/modules/add-site/index.tsx | 3 --- 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/components/copy-site.tsx b/src/components/copy-site.tsx index a3461e6beb..c636257392 100644 --- a/src/components/copy-site.tsx +++ b/src/components/copy-site.tsx @@ -1,8 +1,8 @@ import { MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { getIpcApi } from 'src/lib/get-ipc-api'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; type CopySiteProps = { onClose: () => void; diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index 2d9df99351..be869d0e3c 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -434,7 +434,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { ); } }, - [ selectedTab, setSelectedSiteId, setSelectedTab, __ ] + [ selectedTab, setSelectedSiteId, setSelectedTab ] ); const updateSite = useCallback( async ( site: SiteDetails ) => { diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index dc72f32bab..bb5b745c4f 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -310,7 +310,16 @@ export async function copySite( sourceId: string, config: CopySiteConfig ): Promise< SiteDetails > { - const { siteId: newSiteId, newName, newPath, copyOptions, phpVersion, wpVersion, customDomain, enableHttps } = config; + const { + siteId: newSiteId, + newName, + newPath, + copyOptions, + phpVersion, + wpVersion, + customDomain, + enableHttps, + } = config; bumpStat( StatsGroup.STUDIO_SITE_CREATE, StatsMetric.SITE_COPIED ); @@ -512,7 +521,8 @@ export async function copySite( try { await newServer.updateCachedThumbnail(); await sendThumbnailChangedEvent( event, details.id ); - } catch { + } catch ( error ) { + // Ignore thumbnail update errors as they are non-critical } } )(); } @@ -1913,9 +1923,13 @@ export function showSiteContextMenu( } export function triggerAddSiteCopy( event: IpcMainInvokeEvent, siteId: string ): void { - sendIpcEventToRendererWithWindow( BrowserWindow.fromWebContents( event.sender ), 'add-site-copy', { - siteId, - } ); + sendIpcEventToRendererWithWindow( + BrowserWindow.fromWebContents( event.sender ), + 'add-site-copy', + { + siteId, + } + ); } export async function getFileContent( event: IpcMainInvokeEvent, filePath: string ) { diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index d5d7cb9ccc..3c1729cbd8 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -59,7 +59,15 @@ interface CopySiteConfig { interface CopyProgress { siteId: string; - step: 'preparing' | 'copying-core' | 'copying-plugins' | 'copying-themes' | 'copying-uploads' | 'copying-database' | 'updating-urls' | 'finalizing'; + step: + | 'preparing' + | 'copying-core' + | 'copying-plugins' + | 'copying-themes' + | 'copying-uploads' + | 'copying-database' + | 'updating-urls' + | 'finalizing'; message: string; percentage: number; } diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index 5da8248318..df6d9c9d99 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -24,7 +24,7 @@ export interface IpcEvents { 'add-site-blueprint-from-url': [ { blueprintPath: string } ]; 'add-site-blueprint-from-base64': [ { blueprintJson: string } ]; 'auth-updated': [ { token: StoredToken } | { error: unknown } ]; - 'copySiteProgress': [ CopyProgress ]; + 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/index.tsx b/src/modules/add-site/index.tsx index 90f8a7a321..d968ac5493 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -1,9 +1,7 @@ -import * as Sentry from '@sentry/electron/renderer'; import { speak } from '@wordpress/a11y'; import { Navigator, useNavigator } from '@wordpress/components'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import crypto from 'crypto'; import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import Button, { ButtonVariant } from 'src/components/button'; import { FullscreenModal } from 'src/components/fullscreen-modal'; @@ -15,7 +13,6 @@ 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 { sortSites } from 'src/lib/sort-sites'; import { useRootSelector } from 'src/stores'; import { formatRtkError } from 'src/stores/format-rtk-error'; import {