From 6312adaa9c4b1d580bd68b96b1afb94d8b5d5811 Mon Sep 17 00:00:00 2001 From: Bero Date: Tue, 31 Mar 2026 19:32:31 +0200 Subject: [PATCH] Add Playground site management API and window.playgroundSites global - Add site-management-api-middleware with sites API methods - Refactor MCP bridge to use PlaygroundBridgeConfig interface - Add WebMCP site management tools (list, save, rename) - Add sites-api e2e tests - Expose window.playgroundSites for external integrations --- packages/playground/mcp/src/bridge-client.ts | 38 +- packages/playground/mcp/src/bridge-server.ts | 8 +- packages/playground/mcp/src/client.ts | 3 +- packages/playground/mcp/src/mcp-server.ts | 4 +- .../src/tools/register-mcp-server-tools.ts | 23 +- .../mcp/src/tools/tool-definitions.ts | 14 +- packages/playground/mcp/src/webmcp.ts | 95 +++-- .../mcp/tests/e2e/mcp-tools.spec.ts | 21 +- .../playground/mcp/tests/e2e/webmcp.spec.ts | 14 +- .../website/playwright/e2e/sites-api.spec.ts | 86 +++++ .../ensure-playground-site-is-selected.tsx | 26 +- .../components/rename-site-modal/index.tsx | 12 +- .../src/components/save-site-modal/index.tsx | 19 +- .../saved-playgrounds-overlay/index.tsx | 20 +- .../site-error-modal/site-error-modal.tsx | 15 +- .../site-manager/site-info-panel/index.tsx | 5 +- .../stored-site-settings-form.tsx | 25 +- .../temporary-site-settings-form.tsx | 24 +- .../unconnected-site-settings-form.tsx | 24 +- .../src/lib/state/redux/boot-site-client.ts | 9 + .../src/lib/state/redux/init-mcp-bridge.ts | 71 +--- .../redux/site-management-api-middleware.ts | 331 ++++++++++++++++++ .../website/src/lib/state/redux/store.ts | 6 +- 23 files changed, 621 insertions(+), 272 deletions(-) create mode 100644 packages/playground/website/playwright/e2e/sites-api.spec.ts create mode 100644 packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts diff --git a/packages/playground/mcp/src/bridge-client.ts b/packages/playground/mcp/src/bridge-client.ts index 7e829cbefc0..40068bab4ce 100644 --- a/packages/playground/mcp/src/bridge-client.ts +++ b/packages/playground/mcp/src/bridge-client.ts @@ -3,21 +3,21 @@ import { createToolClient } from './tools/tool-executors'; import type { ToolClient } from './tools/tool-executors'; /** - * Shared configuration for the MCP bridge client and WebMCP. - * - * Both transports need the same callbacks to interact with - * the Playground site list and active client. + * Configuration accepted by `startMcpBridge`. Only includes the + * methods the bridge actually calls — callers can pass any wider + * object (e.g. the full site-management API) and TypeScript will + * accept it structurally. */ -export interface PlaygroundConfig { - getSites: () => Array<{ +export interface PlaygroundBridgeConfig { + list(): Array<{ slug: string; name: string; storage: string; isActive: boolean; }>; - getPlaygroundClient: (siteSlug: string) => PlaygroundClient | undefined; - renameSite?: (siteSlug: string, newName: string) => Promise; - saveSite?: (siteSlug: string) => Promise<{ slug: string; storage: string }>; + getClient(): PlaygroundClient | undefined; + rename(newName: string): Promise; + saveInBrowser(): Promise<{ slug: string; storage: string }>; onConnect?: () => void; } @@ -29,7 +29,7 @@ export interface McpBridgeHandle { const RECONNECT_INTERVAL_MS = 5000; export function startMcpBridge( - config: PlaygroundConfig, + config: PlaygroundBridgeConfig, port: number ): McpBridgeHandle { const tabId = crypto.randomUUID(); @@ -39,7 +39,7 @@ export function startMcpBridge( let stopped = false; function sendSitesRegistration(socket: WebSocket) { - const sites = config.getSites(); + const sites = config.list(); const serialized = JSON.stringify(sites); if (serialized === previousSitesSerialized) { return; @@ -149,13 +149,13 @@ export function startMcpBridge( } async function handleCommand( - config: PlaygroundConfig, + config: PlaygroundBridgeConfig, method: string, args: unknown[], siteSlug: string, port: number ): Promise { - if (method === '__open_site') { + if (method === '__open_site_in_new_tab') { const url = new URL(window.location.href); url.searchParams.set('mcp', 'yes'); url.searchParams.set('mcp-port', String(port)); @@ -171,22 +171,16 @@ async function handleCommand( } if (method === '__rename_site') { - if (!config.renameSite) { - throw new Error('renameSite not configured'); - } const [newName] = args as [string]; - await config.renameSite(siteSlug, newName); + await config.rename(newName); return true; } if (method === '__save_site') { - if (!config.saveSite) { - throw new Error('saveSite not configured'); - } - return await config.saveSite(siteSlug); + return await config.saveInBrowser(); } - const playgroundClient = config.getPlaygroundClient(siteSlug); + const playgroundClient = config.getClient(); if (!playgroundClient) { throw new Error(`No active client for site: ${siteSlug}`); } diff --git a/packages/playground/mcp/src/bridge-server.ts b/packages/playground/mcp/src/bridge-server.ts index 7befdd1300e..1915d819bfe 100644 --- a/packages/playground/mcp/src/bridge-server.ts +++ b/packages/playground/mcp/src/bridge-server.ts @@ -4,7 +4,7 @@ import { createServer as createHttpServer } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; import { randomUUID } from 'node:crypto'; -import { presentStorage } from './tools/tool-definitions'; +import { formatStorageLabel } from './tools/tool-definitions'; export interface SiteRegistration { slug: string; @@ -344,7 +344,7 @@ export class PlaygroundBridge { let targetTabId: string; if (isBrowserCommand) { - // Browser-level commands (e.g. __open_site, __rename_site) + // Browser-level commands (e.g. __open_site_in_new_tab, __rename_site) // don't require the site to be active — just any connected // tab, preferring one that reported this site. if (this.connections.size === 0) { @@ -362,7 +362,7 @@ export class PlaygroundBridge { return Promise.reject( new Error( `Site "${site.siteName}" (${siteId}) is not ` + - `active in any tab. Use open_site to ` + + `active in any tab. Use open_site_in_new_tab to ` + `activate it.` ) ); @@ -448,7 +448,7 @@ export class PlaygroundBridge { return [...this.sites.entries()].map(([siteId, site]) => ({ siteId, name: site.siteName, - storage: presentStorage(site.storage), + storage: formatStorageLabel(site.storage), isActive: site.activeInTabs.length > 0, })); } diff --git a/packages/playground/mcp/src/client.ts b/packages/playground/mcp/src/client.ts index e7a21c4a42e..f0d4a4238a1 100644 --- a/packages/playground/mcp/src/client.ts +++ b/packages/playground/mcp/src/client.ts @@ -1,4 +1,3 @@ export { startMcpBridge } from './bridge-client'; -export type { PlaygroundConfig } from './bridge-client'; -export type { McpBridgeHandle } from './bridge-client'; +export type { PlaygroundBridgeConfig, McpBridgeHandle } from './bridge-client'; export { registerWebMCPTools } from './webmcp'; diff --git a/packages/playground/mcp/src/mcp-server.ts b/packages/playground/mcp/src/mcp-server.ts index 37f6790059b..3e167308b05 100644 --- a/packages/playground/mcp/src/mcp-server.ts +++ b/packages/playground/mcp/src/mcp-server.ts @@ -19,13 +19,13 @@ export function createServer(): McpServer { or server required. You are automatically authenticated as an admin user.\n\n\ PREREQUISITE: Call playground_list_sites first. If no browser is connected, \ call playground_get_website_url to get the exact URL and ask the user to open it. \n\n\ - Typical workflow: playground_list_sites → playground_save_site \ + Typical workflow: playground_list_sites → playground_save_in_browser \ → filesystem/PHP operations → playground_navigate to verify results.\n\n\ Capabilities: execute arbitrary PHP with full WordPress access, read/write files in the virtual filesystem \ (WordPress root: /wordpress/), make HTTP requests to the site, navigate the browser, \ and manage multiple Playground sites simultaneously.\n\n\ Important: sites are temporary by default and not persisted between sessions. \ - Call playground_save_site early in any multi-step workflow where losing progress would be costly.\n\n\ + Call playground_save_in_browser early in any multi-step workflow where losing progress would be costly.\n\n\ Error handling: tool failures are returned as thrown exceptions with descriptive messages, \ not as silent failures.`, }); diff --git a/packages/playground/mcp/src/tools/register-mcp-server-tools.ts b/packages/playground/mcp/src/tools/register-mcp-server-tools.ts index 0f33153a8c8..8160b5835f3 100644 --- a/packages/playground/mcp/src/tools/register-mcp-server-tools.ts +++ b/packages/playground/mcp/src/tools/register-mcp-server-tools.ts @@ -8,7 +8,7 @@ import { toolDefinitions, getSiteToolDefinitions, playgroundUrl, - presentStorage, + formatStorageLabel, stringifyError, } from './tool-definitions'; import type { ToolParam } from './tool-definitions'; @@ -138,20 +138,21 @@ export function registerMcpServerTools( } ); - const openSite = siteToolDefinitions['playground_open_site']; + const openSiteInNewTab = + siteToolDefinitions['playground_open_site_in_new_tab']; server.registerTool( - 'playground_open_site', + 'playground_open_site_in_new_tab', { - title: openSite.title, - description: openSite.description, + title: openSiteInNewTab.title, + description: openSiteInNewTab.description, inputSchema: { siteId: siteIdSchema, }, - annotations: openSite.annotations, + annotations: openSiteInNewTab.annotations, }, async ({ siteId }) => { try { - await bridge.sendCommand(siteId, '__open_site'); + await bridge.sendCommand(siteId, '__open_site_in_new_tab'); const site = await bridge.waitForSiteActive(siteId, 30000); return { content: [ @@ -166,7 +167,7 @@ export function registerMcpServerTools( ], }; } catch (error) { - return errorResult(openSite.errorPrefix, error); + return errorResult(openSiteInNewTab.errorPrefix, error); } } ); @@ -220,9 +221,9 @@ export function registerMcpServerTools( }) ); - const saveSite = siteToolDefinitions['playground_save_site']; + const saveSite = siteToolDefinitions['playground_save_in_browser']; server.registerTool( - 'playground_save_site', + 'playground_save_in_browser', { title: saveSite.title, description: saveSite.description, @@ -270,7 +271,7 @@ export function registerMcpServerTools( alreadySaved: false, siteId, name: site.name, - storage: presentStorage(result.storage), + storage: formatStorageLabel(result.storage), }), }, ], diff --git a/packages/playground/mcp/src/tools/tool-definitions.ts b/packages/playground/mcp/src/tools/tool-definitions.ts index 2f048c8b0ae..5c0629096e0 100644 --- a/packages/playground/mcp/src/tools/tool-definitions.ts +++ b/packages/playground/mcp/src/tools/tool-definitions.ts @@ -418,7 +418,7 @@ export function getSiteToolDefinitions(): Record { Returns site names and storage type. "temporary" sites are lost on page reload, "opfs" sites persist - across reloads. Call playground_save_site to persist + across reloads. Call playground_save_in_browser to persist a temporary site.`, annotations: { readOnlyHint: true, @@ -426,9 +426,9 @@ export function getSiteToolDefinitions(): Record { }, params: [], }, - playground_open_site: { - title: 'Open Site in Browser', - errorPrefix: 'Error opening site', + playground_open_site_in_new_tab: { + title: 'Open Site in New Tab', + errorPrefix: 'Error opening site in new tab', description: `Open a WordPress Playground site in a new browser tab. The site must appear in playground_list_sites. @@ -461,8 +461,8 @@ export function getSiteToolDefinitions(): Record { }, ], }, - playground_save_site: { - title: 'Save Site', + playground_save_in_browser: { + title: 'Save in Browser', errorPrefix: 'Error saving site', description: `Save a temporary WordPress Playground site to browser storage so it survives page reloads. @@ -516,7 +516,7 @@ export function stringifyError(error: unknown): string { /** * Translate internal Playground storage types to user-facing names. */ -export function presentStorage(raw: string): string { +export function formatStorageLabel(raw: string): string { return raw === 'none' ? 'temporary' : raw; } diff --git a/packages/playground/mcp/src/webmcp.ts b/packages/playground/mcp/src/webmcp.ts index 6dbeae31e02..5ed284c1577 100644 --- a/packages/playground/mcp/src/webmcp.ts +++ b/packages/playground/mcp/src/webmcp.ts @@ -10,12 +10,12 @@ import type { PlaygroundClient } from '@wp-playground/remote'; import { toolDefinitions, getSiteToolDefinitions, - presentStorage, + formatStorageLabel, paramsToJsonSchema, stringifyError, } from './tools/tool-definitions'; import { toolExecutors, createToolClient } from './tools/tool-executors'; -import type { PlaygroundConfig } from './bridge-client'; +import type { PlaygroundBridgeConfig } from './bridge-client'; const siteToolDefinitions = getSiteToolDefinitions(); @@ -55,8 +55,8 @@ declare global { let registrationController: AbortController | null = null; -function getActiveSite(config: PlaygroundConfig) { - const sites = config.getSites(); +function getActiveSite(config: PlaygroundBridgeConfig) { + const sites = config.list(); const active = sites.find((s) => s.isActive); if (!active) { throw new Error('No active Playground site'); @@ -64,7 +64,7 @@ function getActiveSite(config: PlaygroundConfig) { return active; } -export function registerWebMCPTools(config: PlaygroundConfig): void { +export function registerWebMCPTools(config: PlaygroundBridgeConfig): void { if (typeof navigator === 'undefined' || !navigator.modelContext) { return; } @@ -75,10 +75,9 @@ export function registerWebMCPTools(config: PlaygroundConfig): void { const signal = registrationController.signal; function getActiveClient(): PlaygroundClient { - const site = getActiveSite(config); - const client = config.getPlaygroundClient(site.slug); + const client = config.getClient(); if (!client) { - throw new Error(`No client for active site: ${site.slug}`); + throw new Error('No client for active site'); } return client; } @@ -118,14 +117,14 @@ export function registerWebMCPTools(config: PlaygroundConfig): void { } function createSiteManagementTools( - config: PlaygroundConfig + config: PlaygroundBridgeConfig ): ModelContextTool[] { const listDef = siteToolDefinitions['playground_list_sites']; - const saveDef = siteToolDefinitions['playground_save_site']; + const saveDef = siteToolDefinitions['playground_save_in_browser']; const renameDef = siteToolDefinitions['playground_rename_site']; const websiteUrlDef = siteToolDefinitions['playground_get_website_url']; - const result: ModelContextTool[] = [ + return [ { name: 'playground_list_sites', description: listDef.description, @@ -134,10 +133,10 @@ function createSiteManagementTools( try { return { connectedTabs: 1, - sites: config.getSites().map((s) => ({ + sites: config.list().map((s) => ({ siteId: s.slug, name: s.name, - storage: presentStorage(s.storage), + storage: formatStorageLabel(s.storage), isActive: s.isActive, })), }; @@ -148,17 +147,14 @@ function createSiteManagementTools( } }, }, - ]; - - if (config.saveSite) { - result.push({ - name: 'playground_save_site', + { + name: 'playground_save_in_browser', description: saveDef.description, annotations: saveDef.annotations, execute: async () => { try { const site = getActiveSite(config); - const storage = presentStorage(site.storage); + const storage = formatStorageLabel(site.storage); if (storage !== 'temporary') { return { success: true, @@ -168,13 +164,13 @@ function createSiteManagementTools( storage, }; } - const saved = await config.saveSite!(site.slug); + const saved = await config.saveInBrowser(); return { success: true, alreadySaved: false, siteId: saved.slug, - name: site.name, - storage: presentStorage(saved.storage), + name: site.name ?? saved.slug, + storage: formatStorageLabel(saved.storage), }; } catch (error) { return { @@ -182,46 +178,45 @@ function createSiteManagementTools( }; } }, - }); - } - - if (config.renameSite) { - result.push({ + }, + { name: 'playground_rename_site', description: renameDef.description, inputSchema: paramsToJsonSchema(renameDef.params), annotations: renameDef.annotations, execute: async (input) => { try { - const site = getActiveSite(config); const newName = input['newName'] as string; - await config.renameSite!(site.slug, newName); - return { success: true, siteId: site.slug, newName }; + const sites = config.list(); + const activeSite = sites.find((s) => s.isActive); + await config.rename(newName); + return { + success: true, + siteId: activeSite?.slug, + newName, + }; } catch (error) { return { error: `${renameDef.errorPrefix}: ${stringifyError(error)}`, }; } }, - }); - } - - result.push({ - name: 'playground_get_website_url', - description: websiteUrlDef.description, - annotations: websiteUrlDef.annotations, - execute: async () => { - try { - return { - url: window.location.href, - }; - } catch (error) { - return { - error: `${websiteUrlDef.errorPrefix}: ${stringifyError(error)}`, - }; - } }, - }); - - return result; + { + name: 'playground_get_website_url', + description: websiteUrlDef.description, + annotations: websiteUrlDef.annotations, + execute: async () => { + try { + return { + url: window.location.href, + }; + } catch (error) { + return { + error: `${websiteUrlDef.errorPrefix}: ${stringifyError(error)}`, + }; + } + }, + }, + ]; } diff --git a/packages/playground/mcp/tests/e2e/mcp-tools.spec.ts b/packages/playground/mcp/tests/e2e/mcp-tools.spec.ts index 4563384bf2a..3ea8c126918 100644 --- a/packages/playground/mcp/tests/e2e/mcp-tools.spec.ts +++ b/packages/playground/mcp/tests/e2e/mcp-tools.spec.ts @@ -184,11 +184,11 @@ test('lists all registered tools', async ({ mcpClient }) => { 'playground_list_sites', 'playground_mkdir', 'playground_navigate', - 'playground_open_site', + 'playground_open_site_in_new_tab', 'playground_read_file', 'playground_rename_site', 'playground_request', - 'playground_save_site', + 'playground_save_in_browser', 'playground_write_file', ]); }); @@ -209,14 +209,14 @@ test('playground_list_sites includes playground url with mcp params', async ({ expect(site.url).toMatch(new RegExp(`\\?mcp=yes&mcp-port=${MCP_WS_PORT}$`)); }); -test('playground_open_site activates an inactive site in a new tab', async ({ +test('playground_open_site_in_new_tab activates an inactive site in a new tab', async ({ mcpClient, playgroundPage, siteId, }) => { // Save the site so it persists in OPFS across page reloads await mcpClient.callTool({ - name: 'playground_save_site', + name: 'playground_save_in_browser', arguments: { siteId }, }); @@ -254,7 +254,7 @@ test('playground_open_site activates an inactive site in a new tab', async ({ // Open the inactive site — the browser calls window.open(), // a new tab loads, and the site becomes active. await mcpClient.callTool({ - name: 'playground_open_site', + name: 'playground_open_site_in_new_tab', arguments: { siteId }, }); @@ -472,6 +472,13 @@ test('playground_rename_site renames an active site', async ({ mcpClient, siteId, }) => { + // Save the site first — temporary sites cannot be renamed. + const saveResult = await mcpClient.callTool({ + name: 'playground_save_in_browser', + arguments: { siteId }, + }); + expect(saveResult.isError).toBeFalsy(); + // Get the original name so we can restore it const listBefore = await mcpClient.callTool({ name: 'playground_list_sites', @@ -509,12 +516,12 @@ test('playground_rename_site renames an active site', async ({ } }); -test('playground_save_site persists a temporary site', async ({ +test('playground_save_in_browser persists a temporary site', async ({ mcpClient, siteId, }) => { const result = await mcpClient.callTool({ - name: 'playground_save_site', + name: 'playground_save_in_browser', arguments: { siteId }, }); expect(result.isError).toBeFalsy(); diff --git a/packages/playground/mcp/tests/e2e/webmcp.spec.ts b/packages/playground/mcp/tests/e2e/webmcp.spec.ts index 869c96284ac..f36e2b05d72 100644 --- a/packages/playground/mcp/tests/e2e/webmcp.spec.ts +++ b/packages/playground/mcp/tests/e2e/webmcp.spec.ts @@ -127,7 +127,7 @@ test('WebMCP registers all tools', async ({ webmcpPage }) => { 'playground_read_file', 'playground_rename_site', 'playground_request', - 'playground_save_site', + 'playground_save_in_browser', 'playground_write_file', ]); }); @@ -212,10 +212,12 @@ test('WebMCP playground_list_sites returns sites', async ({ webmcpPage }) => { expect(site.isActive).toBe(true); }); -test('WebMCP playground_save_site saves a site', async ({ webmcpPage }) => { +test('WebMCP playground_save_in_browser saves a site', async ({ + webmcpPage, +}) => { const result = await webmcpPage.evaluate(async () => { const executors = (window as any).__webmcpExecutors; - return await executors['playground_save_site']({}); + return await executors['playground_save_in_browser']({}); }); expect(result.success).toBe(true); expect(result.siteId).toBeTruthy(); @@ -225,6 +227,12 @@ test('WebMCP playground_save_site saves a site', async ({ webmcpPage }) => { }); test('WebMCP playground_rename_site renames a site', async ({ webmcpPage }) => { + // Save the site first — temporary sites cannot be renamed. + await webmcpPage.evaluate(async () => { + const executors = (window as any).__webmcpExecutors; + return await executors['playground_save_in_browser']({}); + }); + const result = await webmcpPage.evaluate(async () => { const executors = (window as any).__webmcpExecutors; return await executors['playground_rename_site']({ diff --git a/packages/playground/website/playwright/e2e/sites-api.spec.ts b/packages/playground/website/playwright/e2e/sites-api.spec.ts new file mode 100644 index 00000000000..d28a15b6a77 --- /dev/null +++ b/packages/playground/website/playwright/e2e/sites-api.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '../playground-fixtures'; + +test('window.playgroundSites is exposed after boot', async ({ website }) => { + await website.goto('./'); + await website.page.waitForFunction(() => + Boolean((window as any).playgroundSites?.getClient()) + ); +}); + +test('playgroundSites.list() returns the active site', async ({ website }) => { + await website.goto('./'); + await website.page.waitForFunction(() => + Boolean((window as any).playgroundSites?.getClient()) + ); + + const sites = await website.page.evaluate(() => + (window as any).playgroundSites.list() + ); + expect(sites.length).toBeGreaterThanOrEqual(1); + const active = sites.find((s: any) => s.isActive); + expect(active).toBeTruthy(); + expect(active.slug).toBeTruthy(); + expect(active.storage).toBe('temporary'); +}); + +test('playgroundSites.saveInBrowser() persists a temporary site', async ({ + website, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` + ); + + await website.goto('./'); + await website.page.waitForFunction(() => + Boolean((window as any).playgroundSites?.getClient()) + ); + + const result = await website.page.evaluate(() => + (window as any).playgroundSites.saveInBrowser() + ); + expect(result.slug).toBeTruthy(); + expect(result.storage).toBe('opfs'); +}); + +test('playgroundSites.rename() renames a saved site', async ({ + website, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` + ); + + await website.goto('./'); + await website.page.waitForFunction(() => + Boolean((window as any).playgroundSites?.getClient()) + ); + + const newName = await website.page.evaluate(async () => { + const api = (window as any).playgroundSites; + await api.saveInBrowser(); + const name = 'Renamed Via API'; + await api.rename(name); + const sites = api.list(); + const active = sites.find((s: any) => s.isActive); + return active?.name; + }); + expect(newName).toBe('Renamed Via API'); +}); + +test('playgroundSites.getClient() returns a playground client', async ({ + website, +}) => { + await website.goto('./'); + await website.page.waitForFunction(() => + Boolean((window as any).playgroundSites?.getClient()) + ); + + const hasClient = await website.page.evaluate(() => { + const client = (window as any).playgroundSites.getClient(); + return client != null; + }); + expect(hasClient).toBe(true); +}); diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index ee41824c4c9..f942ca1ba59 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -4,12 +4,9 @@ import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage'; import { OPFSSitesLoaded, selectSiteBySlug, - setTemporarySiteSpec, - deriveSiteNameFromSlug, } from '../../lib/state/redux/slice-sites'; import { selectActiveSite, - setActiveSite, useAppDispatch, useAppSelector, } from '../../lib/state/redux/store'; @@ -17,7 +14,7 @@ import { logger } from '@php-wasm/logger'; import { usePrevious } from '../../lib/hooks/use-previous'; import { modalSlugs, setActiveModal } from '../../lib/state/redux/slice-ui'; import { selectClientBySiteSlug } from '../../lib/state/redux/slice-clients'; -import { randomSiteName } from '../../lib/state/redux/random-site-name'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; /** * Ensures the redux store always has an activeSite value. @@ -37,6 +34,7 @@ export function EnsurePlaygroundSiteIsSelected({ ); const activeSite = useAppSelector((state) => selectActiveSite(state)); const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); const url = useCurrentUrl(); const requestedSiteSlug = url.searchParams.get('site-slug'); const requestedSiteObject = useAppSelector((state) => @@ -86,12 +84,12 @@ export function EnsurePlaygroundSiteIsSelected({ 'The requested site was not found. Creating a new temporary site.' ); - await createNewTemporarySite(dispatch, requestedSiteSlug); + await sitesAPI.createNewTemporarySite(requestedSiteSlug); setNeedMissingSitePromptForSlug(requestedSiteSlug); return; } - dispatch(setActiveSite(requestedSiteSlug)); + await sitesAPI.setActiveSite(requestedSiteSlug); return; } @@ -107,7 +105,7 @@ export function EnsurePlaygroundSiteIsSelected({ return; } - await createNewTemporarySite(dispatch); + await sitesAPI.createNewTemporarySite(); } ensureSiteIsSelected(); @@ -139,17 +137,3 @@ export function EnsurePlaygroundSiteIsSelected({ return children; } - -async function createNewTemporarySite( - dispatch: ReturnType, - requestedSiteSlug?: string -) { - // If the site slug is missing, create a new temporary site. - const siteName = requestedSiteSlug - ? deriveSiteNameFromSlug(requestedSiteSlug) - : randomSiteName(); - const newSiteInfo = await dispatch( - setTemporarySiteSpec(siteName, new URL(window.location.href)) - ); - await dispatch(setActiveSite(newSiteInfo.slug)); -} 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 75c971af7ed..a7ae29a1791 100644 --- a/packages/playground/website/src/components/rename-site-modal/index.tsx +++ b/packages/playground/website/src/components/rename-site-modal/index.tsx @@ -1,16 +1,17 @@ -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { TextControl } from '@wordpress/components'; import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; import { setActiveModal, setSiteSlugToRename, } from '../../lib/state/redux/slice-ui'; -import { updateSiteMetadata } from '../../lib/state/redux/slice-sites'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { Modal } from '../modal'; import ModalButtons from '../modal/modal-buttons'; export function RenameSiteModal() { const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); const siteSlugToRename = useAppSelector( (state) => state.ui.siteSlugToRename ); @@ -39,12 +40,7 @@ export function RenameSiteModal() { } try { setIsSubmitting(true); - await dispatch( - updateSiteMetadata({ - slug: site.slug, - changes: { name: trimmed }, - }) as any - ); + await sitesAPI.rename(trimmed); closeModal(); } finally { setIsSubmitting(false); diff --git a/packages/playground/website/src/components/save-site-modal/index.tsx b/packages/playground/website/src/components/save-site-modal/index.tsx index db82803e9e4..1bc8d24cc0c 100644 --- a/packages/playground/website/src/components/save-site-modal/index.tsx +++ b/packages/playground/website/src/components/save-site-modal/index.tsx @@ -15,9 +15,9 @@ import { Modal } from '../modal'; import ModalButtons from '../modal/modal-buttons'; import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { useLocalFsAvailability } from '../../lib/hooks/use-local-fs-availability'; import { selectClientInfoBySiteSlug } from '../../lib/state/redux/slice-clients'; -import { persistTemporarySite } from '../../lib/state/redux/persist-temporary-site'; import type { SiteStorageType } from '../../lib/state/redux/slice-sites'; import { logger } from '@php-wasm/logger'; import { isOpfsAvailable } from '../../lib/state/opfs/opfs-site-storage'; @@ -37,6 +37,7 @@ const errorTextStyle: CSSProperties = { export function SaveSiteModal() { const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); const site = useAppSelector((state) => state.ui.activeSite?.slug ? state.sites.entities[state.ui.activeSite.slug] @@ -249,20 +250,12 @@ export function SaveSiteModal() { ); return; } - await dispatch( - persistTemporarySite(site.slug, 'local-fs', { - siteName: trimmedName, - localFsHandle: directoryHandle, - skipRenameModal: true, - }) as any + await sitesAPI.saveToLocalFileSystem( + trimmedName, + directoryHandle ); } else { - await dispatch( - persistTemporarySite(site.slug, 'opfs', { - siteName: trimmedName, - skipRenameModal: true, - }) as any - ); + await sitesAPI.saveInBrowser(trimmedName); } // Don't close modal here - useEffect will close it when save completes 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 3c69163206c..a0843491b1b 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -15,17 +15,15 @@ import { usePlaygroundClient } from '../../lib/use-playground-client'; import { importWordPressFiles } from '@wp-playground/client'; import { logger } from '@php-wasm/logger'; import { - setActiveSite, useActiveSite, - useAppDispatch, useAppSelector, + useAppDispatch, } from '../../lib/state/redux/store'; import type { PlaygroundDispatch } from '../../lib/state/redux/store'; import type { SiteLogo, SiteInfo } from '../../lib/state/redux/slice-sites'; import { selectSortedSites, selectTemporarySite, - removeSite, } from '../../lib/state/redux/slice-sites'; import { modalSlugs, @@ -34,6 +32,7 @@ import { setSiteManagerSection, setSiteSlugToRename, } from '../../lib/state/redux/slice-ui'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { WordPressIcon } from '@wp-playground/components'; import useFetch from '../../lib/hooks/use-fetch'; import { PlaygroundRoute, redirectTo } from '../../lib/state/url/router'; @@ -89,6 +88,7 @@ export function SavedPlaygroundsOverlay({ const activeSite = useActiveSite(); const dispatch = useAppDispatch(); const modalDispatch: PlaygroundDispatch = useDispatch(); + const sitesAPI = useSitesAPI(); const playground = usePlaygroundClient(); const zipFileInputRef = useRef(null); @@ -131,9 +131,9 @@ export function SavedPlaygroundsOverlay({ doImport(); }, [pendingZipFile, isTemporarySite, playground, onClose]); - function switchToTemporarySite() { + async function switchToTemporarySite() { if (temporarySite) { - dispatch(setActiveSite(temporarySite.slug)); + await sitesAPI.setActiveSite(temporarySite.slug); } else { redirectTo(PlaygroundRoute.newTemporarySite()); } @@ -227,15 +227,15 @@ export function SavedPlaygroundsOverlay({ return matchesSearch && matchesTag; }); - const onSiteClick = (slug: string) => { - dispatch(setActiveSite(slug)); + const onSiteClick = async (slug: string) => { + await sitesAPI.setActiveSite(slug); dispatch(setSiteManagerSection('site-details')); onClose(); }; - const onTemporaryPlaygroundClick = () => { + const onTemporaryPlaygroundClick = async () => { if (temporarySite) { - dispatch(setActiveSite(temporarySite.slug)); + await sitesAPI.setActiveSite(temporarySite.slug); dispatch(setSiteManagerSection('site-details')); onClose(); } else { @@ -252,7 +252,7 @@ export function SavedPlaygroundsOverlay({ `Are you sure you want to delete the site '${site.metadata.name}'?` ); if (proceed) { - await dispatch(removeSite(site.slug)); + await sitesAPI.delete(site.slug); closeMenu(); } }; diff --git a/packages/playground/website/src/components/site-error-modal/site-error-modal.tsx b/packages/playground/website/src/components/site-error-modal/site-error-modal.tsx index 6b6bc7ae6a3..1914404f5d9 100644 --- a/packages/playground/website/src/components/site-error-modal/site-error-modal.tsx +++ b/packages/playground/website/src/components/site-error-modal/site-error-modal.tsx @@ -7,12 +7,12 @@ import { Modal } from '../modal'; import css from './style.module.css'; import { useAppDispatch } from '../../lib/state/redux/store'; import { removeClientInfo } from '../../lib/state/redux/slice-clients'; -import { removeSite } from '../../lib/state/redux/slice-sites'; import { clearActiveSiteError, type SerializedBlueprintStepErrorDetails, type SerializedSiteErrorDetails, } from '../../lib/state/redux/slice-ui'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import type { SiteErrorModalProps, PresentationHelpers, @@ -29,6 +29,7 @@ export function SiteErrorModal({ errorDetails, }: SiteErrorModalProps) { const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); const { isReporting, setIsReporting, @@ -42,10 +43,14 @@ export function SiteErrorModal({ const kapaAI = useKapaAI(); const helpers: PresentationHelpers = { - deleteSite: () => { - dispatch(removeSite(siteSlug)); - dispatch(removeClientInfo(siteSlug)); - dispatch(clearActiveSiteError()); + deleteSite: async () => { + try { + await sitesAPI.delete(siteSlug); + dispatch(removeClientInfo(siteSlug)); + dispatch(clearActiveSiteError()); + } catch (error) { + logger.error('Failed to delete site', error); + } }, restartWithoutPr: () => { const url = new URL(window.location.href); 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 71c759055d0..c77103f30a5 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 @@ -15,7 +15,6 @@ import { lazy, Suspense, useEffect, useState } from 'react'; import { getRelativeDate } from '../../../lib/get-relative-date'; import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; import type { SiteInfo } from '../../../lib/state/redux/slice-sites'; -import { removeSite } from '../../../lib/state/redux/slice-sites'; import { modalSlugs, setActiveModal, @@ -24,6 +23,7 @@ import { 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,6 +85,7 @@ 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(() => { @@ -108,7 +109,7 @@ export function SiteInfoPanel({ `Are you sure you want to delete the site '${site.metadata.name}'?` ); if (proceed) { - await dispatch(removeSite(site.slug)); + await sitesAPI.delete(site.slug); dispatch(setSiteManagerSection('sidebar')); onClose(); } diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/stored-site-settings-form.tsx b/packages/playground/website/src/components/site-manager/site-settings-form/stored-site-settings-form.tsx index b495ba28696..0f86f7db58a 100644 --- a/packages/playground/website/src/components/site-manager/site-settings-form/stored-site-settings-form.tsx +++ b/packages/playground/website/src/components/site-manager/site-settings-form/stored-site-settings-form.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store'; +import { useAppSelector } from '../../../lib/state/redux/store'; import css from './style.module.css'; import { Icon, @@ -8,12 +8,10 @@ import { __experimentalHStack as HStack, } from '@wordpress/components'; import { info } from '@wordpress/icons'; -import { - selectSiteBySlug, - updateSiteMetadata, -} from '../../../lib/state/redux/slice-sites'; +import { selectSiteBySlug } from '../../../lib/state/redux/slice-sites'; import type { SiteFormData } from './unconnected-site-settings-form'; import { UnconnectedSiteSettingsForm } from './unconnected-site-settings-form'; +import { useSitesAPI } from '../../../lib/state/redux/site-management-api-middleware'; export function StoredSiteSettingsForm({ siteSlug, @@ -25,22 +23,11 @@ export function StoredSiteSettingsForm({ const siteInfo = useAppSelector((state) => selectSiteBySlug(state, siteSlug) )!; - const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); const updateSite = async (data: SiteFormData) => { - await dispatch( - updateSiteMetadata({ - slug: siteSlug, - changes: { - runtimeConfiguration: { - ...siteInfo.metadata.runtimeConfiguration, - phpVersion: data.phpVersion, - networking: data.withNetworking, - }, - }, - }) - ); + await sitesAPI.setPhpVersion(data.phpVersion); + await sitesAPI.setNetworking(data.withNetworking); onSubmit?.(); - // @TODO: Display a notification "site updated" }; const defaultValues = useMemo>( diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/temporary-site-settings-form.tsx b/packages/playground/website/src/components/site-manager/site-settings-form/temporary-site-settings-form.tsx index d501ca825dc..a13c4b41e5d 100644 --- a/packages/playground/website/src/components/site-manager/site-settings-form/temporary-site-settings-form.tsx +++ b/packages/playground/website/src/components/site-manager/site-settings-form/temporary-site-settings-form.tsx @@ -3,9 +3,9 @@ import css from './style.module.css'; import { Button, __experimentalVStack as VStack } from '@wordpress/components'; import { useAppSelector } from '../../../lib/state/redux/store'; import { selectSiteBySlug } from '../../../lib/state/redux/slice-sites'; -import { redirectTo, PlaygroundRoute } from '../../../lib/state/url/router'; import type { SiteFormData } from './unconnected-site-settings-form'; import { UnconnectedSiteSettingsForm } from './unconnected-site-settings-form'; +import { useSitesAPI } from '../../../lib/state/redux/site-management-api-middleware'; export function TemporarySiteSettingsForm({ siteSlug, @@ -17,22 +17,16 @@ export function TemporarySiteSettingsForm({ const siteInfo = useAppSelector((state) => selectSiteBySlug(state, siteSlug) )!; + const sitesAPI = useSitesAPI(); const updateSite = async (data: SiteFormData) => { - redirectTo( - PlaygroundRoute.newTemporarySite({ - ...(siteInfo.originalUrlParams || {}), - query: { - ...(siteInfo.originalUrlParams?.searchParams || {}), - php: data.phpVersion, - wp: data.wpVersion, - networking: data.withNetworking ? 'yes' : 'no', - language: data.language, - multisite: data.multisite ? 'yes' : 'no', - }, - }) - ); + await sitesAPI.createNewTemporarySite(undefined, { + phpVersion: data.phpVersion, + wpVersion: data.wpVersion, + networking: data.withNetworking, + language: data.language, + multisite: data.multisite, + }); onSubmit?.(); - // @TODO: Display a notification of updated site or forked site }; const defaultValues = useMemo>(() => { const searchParams = siteInfo.originalUrlParams?.searchParams || {}; diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx b/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx index 5848a72e041..a1eccf6abbe 100644 --- a/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx +++ b/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx @@ -2,7 +2,7 @@ import type { SupportedPHPVersion } from '@php-wasm/universal'; import { SupportedPHPVersionsList } from '@php-wasm/universal'; import css from './style.module.css'; import { CheckboxControl, SelectControl } from '@wordpress/components'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import classNames from 'classnames'; import { __experimentalVStack as VStack } from '@wordpress/components'; @@ -43,14 +43,17 @@ export function UnconnectedSiteSettingsForm({ multisite: true, }, }: SiteSettingsFormProps) { - defaultValues = { - phpVersion: RecommendedPHPVersion, - wpVersion: 'latest', - language: '', - withNetworking: true, - multisite: false, - ...defaultValues, - }; + const mergedDefaults = useMemo( + () => ({ + phpVersion: RecommendedPHPVersion as SupportedPHPVersion, + wpVersion: 'latest', + language: '', + withNetworking: true, + multisite: false, + ...defaultValues, + }), + [defaultValues] + ); const { handleSubmit, setValue, @@ -58,7 +61,8 @@ export function UnconnectedSiteSettingsForm({ control, formState: { errors }, } = useForm({ - defaultValues, + defaultValues: mergedDefaults, + values: mergedDefaults, }); const { supportedWPVersions, latestWPVersion } = diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 2ea7c8fb620..b766d606df3 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -106,6 +106,15 @@ export function bootSiteClient( ); return; } + if (e instanceof DOMException && e.name === 'NotAllowedError') { + dispatch( + setActiveSiteError({ + error: 'directory-handle-permission-denied', + details: e, + }) + ); + return; + } dispatch( setActiveSiteError({ error: 'directory-handle-unknown-error', diff --git a/packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts b/packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts index fe23f700b71..8c9b2799228 100644 --- a/packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts +++ b/packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts @@ -1,23 +1,16 @@ import { createListenerMiddleware } from '@reduxjs/toolkit'; import type { PlaygroundReduxState, PlaygroundDispatch } from './store'; -import { selectActiveSite } from './store'; -import { - selectAllSites, - selectSiteBySlug, - setOPFSSitesLoadingState, - updateSiteMetadata, -} from './slice-sites'; -import { persistTemporarySite } from './persist-temporary-site'; -import { selectClientBySiteSlug } from './slice-clients'; +import { setOPFSSitesLoadingState } from './slice-sites'; +import { createSitesAPI } from './site-management-api-middleware'; import type { McpBridgeHandle } from '@wp-playground/mcp/client'; import { registerWebMCPTools, startMcpBridge } from '@wp-playground/mcp/client'; import { isMcpServerEnabled } from '../url/router'; import { logTrackingEvent } from '../../tracking'; import { logger } from '@php-wasm/logger'; -export const mcpListenerMiddleware = createListenerMiddleware(); +export const mcpBridgeMiddleware = createListenerMiddleware(); -const startListening = mcpListenerMiddleware.startListening.withTypes< +const startListening = mcpBridgeMiddleware.startListening.withTypes< PlaygroundReduxState, PlaygroundDispatch >(); @@ -25,59 +18,21 @@ const startListening = mcpListenerMiddleware.startListening.withTypes< startListening({ actionCreator: setOPFSSitesLoadingState, effect: (_action, listenerApi) => { - // Only start the bridge once. listenerApi.unsubscribe(); - const { getState, dispatch } = listenerApi; + const sitesAPI = createSitesAPI( + listenerApi.getState, + listenerApi.dispatch + ); const mcpConfig = { - getSites: () => { - const state = getState(); - const allSites = selectAllSites(state); - const active = selectActiveSite(state); - return allSites.map((s) => ({ - slug: s.slug, - name: s.metadata.name, - storage: s.metadata.storage, - isActive: s.slug === active?.slug, - })); - }, - getPlaygroundClient: (siteSlug: string) => - selectClientBySiteSlug(getState(), siteSlug), - renameSite: async (siteSlug: string, newName: string) => { - await dispatch( - updateSiteMetadata({ - slug: siteSlug, - changes: { name: newName }, - }) - ); - }, + list: sitesAPI.list, + getClient: sitesAPI.getClient, + rename: sitesAPI.rename, + saveInBrowser: sitesAPI.saveInBrowser, onConnect: () => { logTrackingEvent('mcpConnect'); }, - saveSite: async (siteSlug: string) => { - const state = getState(); - const site = selectSiteBySlug(state, siteSlug); - if (!site) { - throw new Error(`Site not found: ${siteSlug}`); - } - if (site.metadata.storage !== 'none') { - return { - slug: siteSlug, - storage: site.metadata.storage, - }; - } - await dispatch( - persistTemporarySite(siteSlug, 'opfs', { - skipRenameModal: true, - }) - ); - const updatedSite = selectSiteBySlug(getState(), siteSlug); - return { - slug: siteSlug, - storage: updatedSite?.metadata.storage ?? 'none', - }; - }, }; // Register WebMCP tools regardless of ?mcp=yes — they only @@ -112,8 +67,6 @@ startListening({ Number(mcpPort) ); - // Notify the bridge when site-related state changes so it - // can diff the site list and re-register when needed. startListening({ predicate: (action) => typeof action.type === 'string' && 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 new file mode 100644 index 00000000000..626180cabd7 --- /dev/null +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -0,0 +1,331 @@ +/** + * 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'; +import type { PlaygroundReduxState, PlaygroundDispatch } from './store'; +import { selectActiveSite, setActiveSite, useAppDispatch } from './store'; +import { setActiveSiteError } from './slice-ui'; +import { addClientInfo } from './slice-clients'; +import { + selectAllSites, + selectSiteBySlug, + setOPFSSitesLoadingState, + updateSiteMetadata, + removeSite, + setTemporarySiteSpec, + deriveSiteNameFromSlug, +} from './slice-sites'; +import { randomSiteName } from './random-site-name'; +import { persistTemporarySite } from './persist-temporary-site'; +import { selectClientBySiteSlug } from './slice-clients'; +import type { PlaygroundClient } from '@wp-playground/remote'; +import type { SupportedPHPVersion } from '@php-wasm/universal'; + +export interface SiteSettings { + phpVersion?: SupportedPHPVersion; + wpVersion?: string; + networking?: boolean; + language?: string; + multisite?: boolean; +} + +export interface PlaygroundSitesAPI { + list(): Array<{ + slug: string; + name: string; + storage: string; + isActive: boolean; + }>; + getClient(): PlaygroundClient | undefined; + rename(newName: string): Promise; + saveInBrowser(name?: string): Promise<{ slug: string; storage: string }>; + saveToLocalFileSystem( + name?: string, + localFsHandle?: FileSystemDirectoryHandle + ): Promise<{ slug: string; storage: string }>; + setPhpVersion(version: SupportedPHPVersion): Promise; + setNetworking(enabled: boolean): Promise; + delete(siteSlug: string): Promise; + setActiveSite(siteSlug: string): Promise; + createNewTemporarySite( + siteSlug?: string, + settings?: SiteSettings + ): Promise; +} + +export const siteManagementMiddleware = createListenerMiddleware(); + +export const startListening = siteManagementMiddleware.startListening.withTypes< + PlaygroundReduxState, + PlaygroundDispatch +>(); + +declare global { + interface Window { + playgroundSites?: PlaygroundSitesAPI; + } +} + +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(); + const allSites = selectAllSites(state); + const active = selectActiveSite(state); + /** + * We rename storage "none" to "temporary" in the API because the name temporary + * is more descriptive of the actual behavior of these sites. + */ + return allSites.map((s) => ({ + slug: s.slug, + name: s.metadata.name, + storage: + s.metadata.storage === 'none' + ? 'temporary' + : s.metadata.storage, + isActive: s.slug === active?.slug, + })); + }, + + getClient() { + const site = getActiveSiteOrThrow(); + return selectClientBySiteSlug(getState(), site.slug); + }, + + async rename(newName: string) { + const site = getActiveSiteOrThrow(); + if (site.metadata.storage === 'none') { + throw new Error( + 'Cannot rename a temporary site. Save it first.' + ); + } + await dispatch( + updateSiteMetadata({ + slug: site.slug, + changes: { name: newName }, + }) + ); + }, + + async saveInBrowser(name?: string) { + const site = getActiveSiteOrThrow(); + if (site.metadata.storage !== 'none') { + return { slug: site.slug, storage: site.metadata.storage }; + } + await dispatch( + persistTemporarySite(site.slug, 'opfs', { + siteName: name, + skipRenameModal: true, + }) + ); + 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 saveToLocalFileSystem( + name?: string, + localFsHandle?: FileSystemDirectoryHandle + ) { + const site = getActiveSiteOrThrow(); + if (site.metadata.storage !== 'none') { + return { slug: site.slug, storage: site.metadata.storage }; + } + await dispatch( + persistTemporarySite(site.slug, 'local-fs', { + siteName: name, + localFsHandle, + skipRenameModal: true, + }) + ); + 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(); + if (site.metadata.storage === 'none') { + throw new Error( + 'Cannot update settings on a temporary site. Save it first.' + ); + } + await dispatch( + updateSiteMetadata({ + slug: site.slug, + changes: { + runtimeConfiguration: { + ...site.metadata.runtimeConfiguration, + phpVersion: version, + }, + }, + }) + ); + }, + + async setNetworking(enabled: boolean) { + const site = getActiveSiteOrThrow(); + if (site.metadata.storage === 'none') { + throw new Error( + 'Cannot update settings on a temporary site. Save it first.' + ); + } + await dispatch( + updateSiteMetadata({ + slug: site.slug, + changes: { + runtimeConfiguration: { + ...site.metadata.runtimeConfiguration, + networking: enabled, + }, + }, + }) + ); + }, + + async delete(siteSlug: string) { + const site = selectSiteBySlug(getState(), siteSlug); + if (!site) { + throw new Error(`Site not found: ${siteSlug}`); + } + if (site.metadata.storage === 'none') { + throw new Error( + 'Cannot delete a temporary site. It will be removed automatically when you close the tab.' + ); + } + await dispatch(removeSite(siteSlug)); + }, + + async setActiveSite(siteSlug: string) { + const state = getState(); + const site = selectSiteBySlug(state, siteSlug); + if (!site) { + throw new Error(`Site not found: ${siteSlug}`); + } + // If the requested site is already active, avoid registering a + // listener that will never fire. The underlying setActiveSite + // thunk short-circuits in this case, so we can safely return. + const activeSite = selectActiveSite(state); + if (activeSite?.slug === siteSlug) { + return; + } + const bootPromise = new Promise((resolve, reject) => { + const unsubscribe = startListening({ + predicate: (action) => + (addClientInfo.match(action) && + action.payload.siteSlug === siteSlug) || + setActiveSiteError.match(action), + effect: (action) => { + unsubscribe(); + if (setActiveSiteError.match(action)) { + const details = action.payload.details; + const message = + typeof details === 'string' + ? details + : (details?.message ?? + action.payload.error); + reject(new Error(message)); + } else { + resolve(); + } + }, + }); + }); + dispatch(setActiveSite(siteSlug)); + await bootPromise; + }, + + async createNewTemporarySite( + requestedSiteSlug?: string, + settings?: SiteSettings + ) { + const siteName = requestedSiteSlug + ? deriveSiteNameFromSlug(requestedSiteSlug) + : randomSiteName(); + const url = new URL(window.location.href); + if (settings) { + if (settings.phpVersion !== undefined) { + url.searchParams.set('php', settings.phpVersion); + } + if (settings.wpVersion !== undefined) { + url.searchParams.set('wp', settings.wpVersion); + } + if (settings.networking !== undefined) { + url.searchParams.set( + 'networking', + settings.networking ? 'yes' : 'no' + ); + } + if (settings.language !== undefined) { + url.searchParams.set('language', settings.language); + } + if (settings.multisite !== undefined) { + url.searchParams.set( + 'multisite', + settings.multisite ? 'yes' : 'no' + ); + } + } + const newSiteInfo = await dispatch( + setTemporarySiteSpec(siteName, url) + ); + await api.setActiveSite(newSiteInfo.slug); + return newSiteInfo.slug; + }, + }; + return api; +} + +/** + * Once OPFS sites have loaded, expose the site management API on + * `window.playgroundSites` and, when the MCP query-arg is present, + * start the MCP bridge. + */ +startListening({ + actionCreator: setOPFSSitesLoadingState, + effect: (_action, listenerApi) => { + listenerApi.unsubscribe(); + window.playgroundSites = createSitesAPI( + listenerApi.getState, + listenerApi.dispatch + ); + }, +}); + +export function useSitesAPI(): PlaygroundSitesAPI { + const store = useStore(); + const dispatch = useAppDispatch(); + return useMemo( + () => createSitesAPI(store.getState, dispatch), + [store, dispatch] + ); +} diff --git a/packages/playground/website/src/lib/state/redux/store.ts b/packages/playground/website/src/lib/state/redux/store.ts index 3ef4ee9a512..ff39751a049 100644 --- a/packages/playground/website/src/lib/state/redux/store.ts +++ b/packages/playground/website/src/lib/state/redux/store.ts @@ -4,7 +4,8 @@ import uiReducer, { __internal_uiSlice, listenToOnlineOfflineEventsMiddleware, } from './slice-ui'; -import { mcpListenerMiddleware } from './init-mcp-bridge'; +import { siteManagementMiddleware } from './site-management-api-middleware'; +import { mcpBridgeMiddleware } from './init-mcp-bridge'; import type { SiteInfo } from './slice-sites'; import sitesReducer, { selectSiteBySlug, @@ -63,7 +64,8 @@ const store = configureStore({ middleware: (getDefaultMiddleware) => ignoreSerializableCheck(getDefaultMiddleware) .concat(listenToOnlineOfflineEventsMiddleware) - .concat(mcpListenerMiddleware.middleware), + .concat(siteManagementMiddleware.middleware) + .concat(mcpBridgeMiddleware.middleware), }); export type RootState = ReturnType;