diff --git a/packages/playground/website/src/components/delete-site-modal/index.tsx b/packages/playground/website/src/components/delete-site-modal/index.tsx
new file mode 100644
index 00000000000..1a1bcb48e58
--- /dev/null
+++ b/packages/playground/website/src/components/delete-site-modal/index.tsx
@@ -0,0 +1,90 @@
+import { useEffect, useState } from 'react';
+import { Notice, __experimentalText as Text } from '@wordpress/components';
+import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store';
+import {
+ setActiveModal,
+ setSiteSlugToDelete,
+} from '../../lib/state/redux/slice-ui';
+import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware';
+import { Modal } from '../modal';
+import ModalButtons from '../modal/modal-buttons';
+import css from '../modal/style.module.css';
+
+export function DeleteSiteModal() {
+ const dispatch = useAppDispatch();
+ const sitesAPI = useSitesAPI();
+ const siteSlugToDelete = useAppSelector(
+ (state) => state.ui.siteSlugToDelete
+ );
+ const site = useAppSelector((state) =>
+ siteSlugToDelete ? state.sites.entities[siteSlugToDelete] : undefined
+ );
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!site) {
+ dispatch(setActiveModal(null));
+ dispatch(setSiteSlugToDelete(undefined));
+ }
+ }, [site, dispatch]);
+
+ if (!site) {
+ return null;
+ }
+
+ const closeModal = () => {
+ dispatch(setActiveModal(null));
+ dispatch(setSiteSlugToDelete(undefined));
+ };
+
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true);
+ setError(null);
+ await sitesAPI.delete(site.slug);
+ closeModal();
+ } catch (e) {
+ setError(
+ e instanceof Error
+ ? e.message
+ : 'Deleting failed. Please try again.'
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx
index 3420ee95c1f..eb00c943b61 100644
--- a/packages/playground/website/src/components/layout/index.tsx
+++ b/packages/playground/website/src/components/layout/index.tsx
@@ -19,6 +19,7 @@ import {
} from '../playground-viewport';
import { MissingSiteModal } from '../missing-site-modal';
import { RenameSiteModal } from '../rename-site-modal';
+import { DeleteSiteModal } from '../delete-site-modal';
import { SaveSiteModal } from '../save-site-modal';
import { modalSlugs } from '../../lib/state/redux/slice-ui';
import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal';
@@ -167,6 +168,8 @@ function Modals() {
return ;
} else if (currentModal === modalSlugs.RENAME_SITE) {
return ;
+ } else if (currentModal === modalSlugs.DELETE_SITE) {
+ return ;
} else if (currentModal === modalSlugs.SAVE_SITE) {
return ;
} else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) {
diff --git a/packages/playground/website/src/components/modal/style.module.css b/packages/playground/website/src/components/modal/style.module.css
index 50a7bfb44a3..301332d8b3d 100644
--- a/packages/playground/website/src/components/modal/style.module.css
+++ b/packages/playground/website/src/components/modal/style.module.css
@@ -27,6 +27,12 @@
max-width: 350px;
}
+.modal-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
.modal-buttons {
margin-top: 1.5rem;
}
diff --git a/packages/playground/website/src/components/rename-site-modal/index.tsx b/packages/playground/website/src/components/rename-site-modal/index.tsx
index a7ae29a1791..ac2c2b656a3 100644
--- a/packages/playground/website/src/components/rename-site-modal/index.tsx
+++ b/packages/playground/website/src/components/rename-site-modal/index.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
-import { TextControl } from '@wordpress/components';
+import { Notice, TextControl } from '@wordpress/components';
import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store';
import {
setActiveModal,
@@ -8,6 +8,7 @@ import {
import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware';
import { Modal } from '../modal';
import ModalButtons from '../modal/modal-buttons';
+import css from '../modal/style.module.css';
export function RenameSiteModal() {
const dispatch = useAppDispatch();
@@ -22,6 +23,7 @@ export function RenameSiteModal() {
const initialName = useMemo(() => site?.metadata?.name ?? '', [site]);
const [name, setName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
if (!site || site.metadata.storage === 'none') {
// Nothing to rename
@@ -40,8 +42,15 @@ export function RenameSiteModal() {
}
try {
setIsSubmitting(true);
+ setError(null);
await sitesAPI.rename(trimmed);
closeModal();
+ } catch (e) {
+ setError(
+ e instanceof Error
+ ? e.message
+ : 'Renaming failed. Please try again.'
+ );
} finally {
setIsSubmitting(false);
}
@@ -59,7 +68,7 @@ export function RenameSiteModal() {
e.preventDefault();
handleSubmit();
}}
- style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
+ className={css.modalForm}
>
+ {error ? (
+
+ {error}
+
+ ) : null}
{directoryError ? (
- {directoryError}
+
+ {directoryError}
+
) : null}
)}
@@ -389,6 +391,11 @@ export function SaveSiteModal() {
)}
+ {submitError ? (
+
+ {submitError}
+
+ ) : null}
- {submitError ? (
- {submitError}
- ) : null}
);
diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx
index a0843491b1b..4bf32a992af 100644
--- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx
+++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx
@@ -31,6 +31,7 @@ import {
setSiteManagerOpen,
setSiteManagerSection,
setSiteSlugToRename,
+ setSiteSlugToDelete,
} from '../../lib/state/redux/slice-ui';
import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware';
import { WordPressIcon } from '@wp-playground/components';
@@ -247,14 +248,10 @@ export function SavedPlaygroundsOverlay({
return `data:${logo.mime};base64,${logo.data}`;
};
- const handleDeleteSite = async (site: SiteInfo, closeMenu: () => void) => {
- const proceed = window.confirm(
- `Are you sure you want to delete the site '${site.metadata.name}'?`
- );
- if (proceed) {
- await sitesAPI.delete(site.slug);
- closeMenu();
- }
+ const handleDeleteSite = (site: SiteInfo, closeMenu: () => void) => {
+ dispatch(setSiteSlugToDelete(site.slug));
+ modalDispatch(setActiveModal(modalSlugs.DELETE_SITE));
+ closeMenu();
};
const handleRenameSite = (site: SiteInfo, closeMenu: () => void) => {
diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx
index c77103f30a5..689d612a074 100644
--- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx
+++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx
@@ -19,11 +19,10 @@ import {
modalSlugs,
setActiveModal,
setSiteManagerOpen,
- setSiteManagerSection,
+ setSiteSlugToDelete,
setSiteSlugToRename,
} from '../../../lib/state/redux/slice-ui';
import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store';
-import { useSitesAPI } from '../../../lib/state/redux/site-management-api-middleware';
import { usePlaygroundClientInfo } from '../../../lib/use-playground-client';
import { SiteLogs } from '../../log-modal';
import { OfflineNotice } from '../../offline-notice';
@@ -85,8 +84,6 @@ export function SiteInfoPanel({
}) {
const offline = useAppSelector((state) => state.ui.offline);
const dispatch = useAppDispatch();
- const sitesAPI = useSitesAPI();
-
// Load the last active tab for this site
const [initialTabName] = useState(() => {
const lastTab = getSiteLastTab(site.slug);
@@ -103,16 +100,10 @@ export function SiteInfoPanel({
const isTemporary = site.metadata.storage === 'none';
- const removeSiteAndCloseMenu = async (onClose: () => void) => {
- // TODO: Replace with HTML-based dialog
- const proceed = window.confirm(
- `Are you sure you want to delete the site '${site.metadata.name}'?`
- );
- if (proceed) {
- await sitesAPI.delete(site.slug);
- dispatch(setSiteManagerSection('sidebar'));
- onClose();
- }
+ const removeSiteAndCloseMenu = (onClose: () => void) => {
+ dispatch(setSiteSlugToDelete(site.slug));
+ dispatch(setActiveModal(modalSlugs.DELETE_SITE));
+ onClose();
};
const clientInfo = useAppSelector((state) =>
selectClientInfoBySiteSlug(state, site.slug)
diff --git a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts
index 3c4365dd855..9c704202460 100644
--- a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts
+++ b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts
@@ -32,7 +32,6 @@ export function persistTemporarySite(
skipRenameModal?: boolean;
} = {}
) {
- // @TODO: Handle errors
return async (
dispatch: typeof store.dispatch,
getState: () => PlaygroundReduxState
@@ -135,21 +134,15 @@ export function persistTemporarySite(
} else if (storageType === 'local-fs') {
let dirHandle = options.localFsHandle;
if (!dirHandle) {
- try {
- // Request permission to access the directory.
- // https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker
- dirHandle = await (window as any).showDirectoryPicker({
- // By specifying an ID, the browser can remember different directories
- // for different IDs.If the same ID is used for another picker, the
- // picker opens in the same directory.
- id: 'playground-directory',
- mode: 'readwrite',
- });
- } catch (e) {
- // No directory selected but log the error just in case.
- logger.error(e);
- return;
- }
+ // Request permission to access the directory.
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker
+ dirHandle = await (window as any).showDirectoryPicker({
+ // By specifying an ID, the browser can remember different directories
+ // for different IDs.If the same ID is used for another picker, the
+ // picker opens in the same directory.
+ id: 'playground-directory',
+ mode: 'readwrite',
+ });
}
await saveDirectoryHandle(siteSlug, dirHandle!);
diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts
index 626180cabd7..4281c79b937 100644
--- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts
+++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts
@@ -1,12 +1,3 @@
-/**
- * Centralized Playground site management middleware.
- *
- * Provides a unified API for listing, renaming, saving, and opening
- * Playground sites. Used by the MCP bridge, the browser DevTools global
- * (`window.playgroundSites`), and any other part of the Playground
- * Website that needs programmatic site access.
- */
-
import { useMemo } from 'react';
import { useStore } from 'react-redux';
import { createListenerMiddleware } from '@reduxjs/toolkit';
@@ -37,24 +28,105 @@ export interface SiteSettings {
multisite?: boolean;
}
+/**
+ * API for listing, renaming, saving, and opening Playground
+ * sites. Used by the MCP bridge, the `window.playgroundSites`
+ * DevTools global, and UI components.
+ */
export interface PlaygroundSitesAPI {
+ /**
+ * Lists all known sites.
+ *
+ * @returns List of site info objects.
+ */
list(): Array<{
slug: string;
name: string;
storage: string;
isActive: boolean;
}>;
+
+ /**
+ * Returns the PlaygroundClient for the active site.
+ *
+ * @returns The client, or `undefined` if not yet booted.
+ * @throws When no site is selected.
+ */
getClient(): PlaygroundClient | undefined;
+
+ /**
+ * Renames the active site.
+ *
+ * @param newName The new display name.
+ * @throws When no site is selected or the site is
+ * temporary.
+ */
rename(newName: string): Promise;
+
+ /**
+ * Persists the active temporary site to OPFS.
+ *
+ * @param name Optional display name for the saved site.
+ * @returns The site's slug and storage type.
+ * @throws When no site is selected or saving fails.
+ */
saveInBrowser(name?: string): Promise<{ slug: string; storage: string }>;
+
+ /**
+ * Persists the active temporary site to a local directory.
+ *
+ * @param name Optional display name for the saved site.
+ * @param localFsHandle Directory handle. When omitted the
+ * browser prompts the user to pick one.
+ * @returns The site's slug and storage type.
+ * @throws When no site is selected or saving fails.
+ */
saveToLocalFileSystem(
name?: string,
localFsHandle?: FileSystemDirectoryHandle
): Promise<{ slug: string; storage: string }>;
+
+ /**
+ * Changes the PHP version for the active site and reboots it.
+ *
+ * @param version The PHP version to use (e.g. `"8.4"`).
+ * @throws When no site is selected or the site is temporary.
+ */
setPhpVersion(version: SupportedPHPVersion): Promise;
+
+ /**
+ * Enables or disables network access for the active site
+ * and reboots it.
+ *
+ * @param enabled Whether networking should be on.
+ * @throws When no site is selected or the site is temporary.
+ */
setNetworking(enabled: boolean): Promise;
+
+ /**
+ * Deletes a saved site by slug.
+ *
+ * @param siteSlug The slug of the site to delete.
+ * @throws When the site is not found or the site is temporary.
+ */
delete(siteSlug: string): Promise;
+
+ /**
+ * Switches to a different site and boots it.
+ *
+ * @param siteSlug The slug of the site to activate.
+ * @throws When the site is not found or fails to boot.
+ */
setActiveSite(siteSlug: string): Promise;
+
+ /**
+ * Creates a new temporary site and boots it.
+ *
+ * @param siteSlug Optional slug hint. A random name is
+ * generated when omitted.
+ * @param settings Optional site settings.
+ * @returns The new site's slug.
+ */
createNewTemporarySite(
siteSlug?: string,
settings?: SiteSettings
@@ -78,14 +150,6 @@ export function createSitesAPI(
getState: () => PlaygroundReduxState,
dispatch: PlaygroundDispatch
): PlaygroundSitesAPI {
- function getActiveSiteOrThrow() {
- const site = selectActiveSite(getState());
- if (!site) {
- throw new Error('No active site');
- }
- return site;
- }
-
const api: PlaygroundSitesAPI = {
list() {
const state = getState();
@@ -107,12 +171,18 @@ export function createSitesAPI(
},
getClient() {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
return selectClientBySiteSlug(getState(), site.slug);
},
async rename(newName: string) {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
if (site.metadata.storage === 'none') {
throw new Error(
'Cannot rename a temporary site. Save it first.'
@@ -127,7 +197,10 @@ export function createSitesAPI(
},
async saveInBrowser(name?: string) {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
if (site.metadata.storage !== 'none') {
return { slug: site.slug, storage: site.metadata.storage };
}
@@ -139,11 +212,6 @@ export function createSitesAPI(
);
const updatedSite = selectSiteBySlug(getState(), site.slug);
const storage = updatedSite?.metadata.storage ?? 'none';
- if (storage === 'none') {
- throw new Error(
- 'Failed to save the site — the storage is still temporary after persist.'
- );
- }
return { slug: site.slug, storage };
},
@@ -151,7 +219,10 @@ export function createSitesAPI(
name?: string,
localFsHandle?: FileSystemDirectoryHandle
) {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
if (site.metadata.storage !== 'none') {
return { slug: site.slug, storage: site.metadata.storage };
}
@@ -164,16 +235,14 @@ export function createSitesAPI(
);
const updatedSite = selectSiteBySlug(getState(), site.slug);
const storage = updatedSite?.metadata.storage ?? 'none';
- if (storage === 'none') {
- throw new Error(
- 'Failed to save the site — the storage is still temporary after persist.'
- );
- }
return { slug: site.slug, storage };
},
async setPhpVersion(version: SupportedPHPVersion) {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
if (site.metadata.storage === 'none') {
throw new Error(
'Cannot update settings on a temporary site. Save it first.'
@@ -193,7 +262,10 @@ export function createSitesAPI(
},
async setNetworking(enabled: boolean) {
- const site = getActiveSiteOrThrow();
+ const site = selectActiveSite(getState());
+ if (!site) {
+ throw new Error('No active site selected');
+ }
if (site.metadata.storage === 'none') {
throw new Error(
'Cannot update settings on a temporary site. Save it first.'
@@ -219,7 +291,7 @@ export function createSitesAPI(
}
if (site.metadata.storage === 'none') {
throw new Error(
- 'Cannot delete a temporary site. It will be removed automatically when you close the tab.'
+ 'Cannot delete a temporary site. It will be reset on the next page load.'
);
}
await dispatch(removeSite(siteSlug));
diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts
index 0d8a4436683..bd7588ec778 100644
--- a/packages/playground/website/src/lib/state/redux/slice-ui.ts
+++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts
@@ -31,6 +31,7 @@ export const modalSlugs = {
MISSING_SITE_PROMPT: 'missing-site-prompt',
RENAME_SITE: 'rename-site',
SAVE_SITE: 'save-site',
+ DELETE_SITE: 'delete-site',
BLUEPRINT_URL: 'blueprint-url',
} as const;
@@ -146,6 +147,7 @@ export interface UIState {
};
activeModal: string | null;
siteSlugToRename?: string;
+ siteSlugToDelete?: string;
githubAuthRepoUrl?: string;
offline: boolean;
siteManagerIsOpen: boolean;
@@ -164,11 +166,16 @@ const initialState: UIState = {
* not by loading a URL with the modal parameter.
* The github-private-repo-auth modal should only be triggered by authentication errors,
* not by loading a URL with the modal parameter.
+ * The delete-site and rename-site modals require Redux state (siteSlugToDelete /
+ * siteSlugToRename) that is not persisted in the URL, so they cannot be meaningfully
+ * restored from a URL parameter.
*/
activeModal:
query.get('modal') === 'error-report' ||
query.get('modal') === 'save-site' ||
- query.get('modal') === 'github-private-repo-auth'
+ query.get('modal') === 'github-private-repo-auth' ||
+ query.get('modal') === 'delete-site' ||
+ query.get('modal') === 'rename-site'
? null
: query.get('modal') || null,
offline: !navigator.onLine,
@@ -263,6 +270,12 @@ const uiSlice = createSlice({
) => {
state.siteSlugToRename = action.payload;
},
+ setSiteSlugToDelete: (
+ state,
+ action: PayloadAction
+ ) => {
+ state.siteSlugToDelete = action.payload;
+ },
},
});
@@ -308,6 +321,7 @@ export const {
setSiteManagerOpen,
setSiteManagerSection,
setSiteSlugToRename,
+ setSiteSlugToDelete,
} = uiSlice.actions;
export default uiSlice.reducer;