Skip to content

Add the ability to copy an existing site - #2107

Closed
ndiego wants to merge 7 commits into
trunkfrom
add/copy-site-functionality
Closed

Add the ability to copy an existing site#2107
ndiego wants to merge 7 commits into
trunkfrom
add/copy-site-functionality

Conversation

@ndiego

@ndiego ndiego commented Nov 20, 2025

Copy link
Copy Markdown
Contributor

Related issues

Proposed Changes

This PR adds the ability to copy (clone, duplicate, etc.) an existing Studio site from both the context menu and from the "More options" menu on the Settings tab. Users have granular control over what gets copied (database, plugins, themes, uploads). Users can also configure different PHP/WordPress versions (and all the other normal settings) for the copied site.

Features

  • Copy site menu option in site settings dropdown and context menu
  • Copy wizard with configurable options:
    • Database (content, settings, users)
    • Plugins
    • Themes
    • Uploads (media library)
    • Site name, path, PHP/WP version overrides
    • Custom domain and HTTPS settings
  • Real-time progress tracking during copy operation
Context menu Settings
image image
Copy screen
image

Implementation

Frontend:

  • New components: copy-site.tsx (menu trigger) and modules/add-site/components/copy-site.tsx (form)
  • useCloneSite() hook for managing clone state
  • Extended useSiteDetails hook with copySite() method
  • Integrated into add-site stepper with /copy route
  • Progress updates via copySiteProgress IPC events

Backend (copySite handler in ipc-handlers.ts:308-565):

  1. Validates source site and stops it if running (prevents DB corruption)
  2. Creates destination directory structure
  3. Copies WordPress core OR installs different WP version if requested
  4. Always copies mu-plugins (SQLite integration)
  5. Conditionally copies plugins/, themes/, uploads/, database/ based on options
  6. If database copied: Updates URLs via WP-CLI
  7. If no database: Fresh WordPress installation
  8. Registers new site, starts it, generates thumbnail
  9. Restarts source site

Telemetry: Added SITE_COPIED metric

How Copy Site Works

  1. User clicks "Copy site" from settings menu or context menu
  2. Copy wizard opens with source site name pre-filled
  3. User selects what to copy (all checked by default), customizes name/path/versions
  4. Click "Copy site" → progress bar shows current step
  5. New site appears in sidebar and starts automatically

Key features:

  • WordPress version flexibility (can copy to different WP version)
  • Automatic URL updates when copying database
  • Safe operation: stops source during copy, restarts after
  • Cleanup on failure: trashes partial copy, restarts source site
  • SQLite integration always copied for database compatibility

Testing Instructions

  • Copy with all options enabled
  • Copy with selective options (e.g., only plugins + themes)
  • Copy with different WordPress/PHP versions
  • Copy without database (fresh install)
  • Copy running site (verify restart)
  • Copy to existing directory (should fail)
  • Verify URL updates in copied database
  • Verify thumbnails generate correctly

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
@ndiego ndiego self-assigned this Nov 20, 2025
@ndiego
ndiego requested review from bcotrim and sejas November 20, 2025 19:27
@ndiego
ndiego marked this pull request as ready for review November 20, 2025 20:33
@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Test Results

Comparing 60ddd15 vs trunk

site-editor

Metric trunk 60ddd15 Diff Change
load 13352.00 ms 7608.00 ms -5744.00 ms 🟢 -43.0%

site-startup

Metric trunk 60ddd15 Diff Change
siteCreation 133252.00 ms 132835.00 ms -417.00 ms 🟢 -0.3%
siteStartup 44249.00 ms 52194.00 ms +7945.00 ms 🔴 18.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change

@bcotrim bcotrim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good and working great!
Thanks @ndiego 👍

I added some comments, mostly regarding code best practices and structure, but nothing major.

Comment on lines +390 to +393
setAddingSiteIds( ( prev ) => {
prev.push( newSite.id );
return prev;
} );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setAddingSiteIds( ( prev ) => {
prev.push( newSite.id );
return prev;
} );
setAddingSiteIds( ( prev ) => [ ...prev, newSite.id ] );
<MenuItem
aria-disabled={ isCopyDisabled }
onClick={ () => {
if ( isCopyDisabled || ! selectedSite ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isCopyDisabled is defined as ! selectedSite (line 15), so this condition checks the same thing twice

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to this PR, but I noticed the DeleteSite component has a similar issue.
It would be a nice time to address it in my opinion.

import { moreVertical } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { PropsWithChildren } from 'react';
import CopySite from 'src/components/copy-site';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move CopySite and DeleteSite to src/modules/site-settings.
AFAIK these aren't being used anywhere else and src/components should be used for generic, reusable components.

Comment thread src/ipc-handlers.ts
fs.mkdirSync( newPath, { recursive: true } );
}

if ( ! ( await isEmptyDir( newPath ) ) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good validation, but it should happen in the Copy Site form, preventing users from submitting an operation we know will fail.

The use-add-site hook already has similar checks that validate directory when the path is selected. Extend this pattern to the Copy Site flow to provide immediate feedback.

Keep the backend validation as a safety net.

Comment thread src/ipc-handlers.ts
sendProgress( 'preparing', __( 'Preparing to copy site...' ), 0 );

const wasSourceRunning = sourceSite.details.running;
if ( wasSourceRunning ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tempServer and newServer are both created with identical parameters (details and targetWpVersion). I think we can create the server once at the beginning and reuse it

Comment thread src/ipc-handlers.ts
await fsPromises.mkdir( nodePath.join( wpContentDest, 'themes' ), { recursive: true } );
}

if ( copyOptions.uploads ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce duplication let's extract this to a helper function for example copyWpContentDirectory.
We could then do something like:

const directories = [
      { name: 'plugins', enabled: copyOptions.plugins, step: 'copying-plugins', percentage: 30 },
      { name: 'themes', enabled: copyOptions.themes, step: 'copying-themes', percentage: 50 },
      { name: 'uploads', enabled: copyOptions.uploads, step: 'copying-uploads', percentage: 60 },
      ...
  ] as const;

  for ( const dir of directories ) {
      await copyWpContentDirectory( ... );
  }
Comment thread src/ipc-handlers.ts
await installSqliteIntegration( newPath );
}

const newServer = SiteServer.create( details, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could do this early on in the process and avoid creating the tempServer on the needsDifferentWpVersion check.

Comment thread src/ipc-handlers.ts

sendProgress( 'finalizing', __( 'Finalizing copy...' ), 90 );

const parentWindow = BrowserWindow.fromWebContents( event.sender );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably only get the parentWindow once at the start and re-use here and in the sendProgress function

@ndiego

ndiego commented Dec 12, 2025

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @bcotrim 🙏 I'll pick this up after the holiday break and implement these changes. I'll also simplify this to just copying a full site, and we can consider implementing more granular options in future iterations.

@sejas
sejas marked this pull request as draft December 17, 2025 10:31
@sejas

sejas commented Dec 17, 2025

Copy link
Copy Markdown
Member

I set this PR as draft until is ready for another review.

@gavande1

Copy link
Copy Markdown
Contributor

Hello @ndiego, are you planning to complete this task? If not, I can take over and bring it to completion so you can concentrate on more important matters.

@sejas

sejas commented Jan 23, 2026

Copy link
Copy Markdown
Member

I'll be happy to bring it over the finish line next week. 👌

@sejas

sejas commented Feb 3, 2026

Copy link
Copy Markdown
Member

Closing this PR in favor of a simpler alternative, see pfHvTO-Nd-p2#comment-721

@sejas sejas closed this Feb 3, 2026
@sejas
sejas deleted the add/copy-site-functionality branch February 3, 2026 15:24
@sejas

sejas commented Feb 3, 2026

Copy link
Copy Markdown
Member

New PR #2528

@sejas sejas mentioned this pull request Feb 19, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

4 participants