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 aea36cef8f..9ab384fd9e 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 @@ -3,7 +3,7 @@ import { useCurrentUrl } from '../../lib/state/url/router-hooks'; import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage'; import { OPFSSitesLoaded, - selectSiteBySlug, + selectSiteByUrlSlug, setTemporarySiteSpec, deriveSiteNameFromSlug, } from '../../lib/state/redux/slice-sites'; @@ -42,13 +42,20 @@ export function EnsurePlaygroundSiteIsSelected({ const url = useCurrentUrl(); const requestedSiteSlug = url.searchParams.get('site-slug'); const requestedSiteObject = useAppSelector((state) => - selectSiteBySlug(state, requestedSiteSlug!) - ); - const requestedClientInfo = useAppSelector( - (state) => - requestedSiteSlug && - selectClientBySiteSlug(state, requestedSiteSlug) + requestedSiteSlug + ? selectSiteByUrlSlug(state, requestedSiteSlug) + : undefined ); + const requestedClientInfo = useAppSelector((state) => { + if (!requestedSiteSlug) { + return undefined; + } + const siteMatch = selectSiteByUrlSlug(state, requestedSiteSlug); + if (!siteMatch) { + return undefined; + } + return selectClientBySiteSlug(state, siteMatch.slug); + }); const [needMissingSitePromptForSlug, setNeedMissingSitePromptForSlug] = useState(false); @@ -108,7 +115,7 @@ export function EnsurePlaygroundSiteIsSelected({ } } - dispatch(setActiveSite(requestedSiteSlug)); + dispatch(setActiveSite(requestedSiteObject.slug)); return; } 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 c16f68db80..41f0dde849 100644 --- a/packages/playground/website/src/components/rename-site-modal/index.tsx +++ b/packages/playground/website/src/components/rename-site-modal/index.tsx @@ -1,8 +1,13 @@ -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 } from '../../lib/state/redux/slice-ui'; -import { updateSiteMetadata } from '../../lib/state/redux/slice-sites'; +import { + updateSiteMetadata, + deriveSlugFromSiteName, + type SiteInfo, +} from '../../lib/state/redux/slice-sites'; +import { PlaygroundRoute, redirectTo } from '../../lib/state/url/router'; import { Modal } from '../modal'; import ModalButtons from '../modal/modal-buttons'; @@ -32,12 +37,20 @@ export function RenameSiteModal() { } try { setIsSubmitting(true); + const newUrlSlug = deriveSlugFromSiteName(trimmed); await dispatch( updateSiteMetadata({ slug: site.slug, changes: { name: trimmed }, + urlSlug: newUrlSlug, }) as any ); + const updatedSite: SiteInfo = { + ...site, + urlSlug: newUrlSlug, + metadata: { ...site.metadata, name: trimmed }, + }; + redirectTo(PlaygroundRoute.site(updatedSite)); closeModal(); } finally { setIsSubmitting(false); diff --git a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts index 54dedc9f48..4f7cc6e49c 100644 --- a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts +++ b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts @@ -39,6 +39,7 @@ export const legacyOpfsPathSymbol = Symbol('legacyOpfsPath'); */ export interface StoredSiteMetadata extends SiteMetadata { slug: string; + urlSlug?: string; } let opfsSitesRoot: FileSystemDirectoryHandle | undefined = undefined; @@ -77,15 +78,21 @@ class OpfsSiteStorage { ); } - async update(slug: string, metadata: SiteMetadata): Promise { + async update( + slug: string, + metadata: SiteMetadata, + urlSlug?: string + ): Promise { const newSiteDirName = getDirectoryNameForSlug(slug); if (!(await opfsChildExists(this.root, newSiteDirName))) { throw new Error(`Site with slug '${slug}' does not exist.`); } + const existingMetadata = await this.readRawMetadata(newSiteDirName); + const finalUrlSlug = urlSlug ?? existingMetadata?.urlSlug ?? slug; await opfsWriteFile( joinPaths(ROOT_PATH, newSiteDirName, SITE_METADATA_FILENAME), - await metadataToStoredFormat(slug, metadata) + await metadataToStoredFormat(slug, metadata, finalUrlSlug) ); } @@ -138,6 +145,29 @@ class OpfsSiteStorage { const siteDirName = getDirectoryNameForSlug(slug); await this.root.removeEntry(siteDirName, { recursive: true }); } + + private async readRawMetadata( + siteDirName: string + ): Promise<(StoredSiteMetadata & { urlSlug?: string }) | undefined> { + try { + const siteDirectory = await this.root.getDirectoryHandle( + siteDirName + ); + const siteInfoFileHandle = await siteDirectory.getFileHandle( + SITE_METADATA_FILENAME + ); + const file = await siteInfoFileHandle.getFile(); + return JSON.parse(await file.text()) as StoredSiteMetadata & { + urlSlug?: string; + }; + } catch (error) { + logger.error( + `Error reading raw metadata for site ${siteDirName}:`, + error + ); + return undefined; + } + } } export const opfsSiteStorage: OpfsSiteStorage | undefined = opfsSitesRoot @@ -156,11 +186,13 @@ export function getDirectoryNameForSlug(slug: string) { async function metadataToStoredFormat( slug: string, - { originalBlueprint, ...metadata }: SiteMetadata + { originalBlueprint, ...metadata }: SiteMetadata, + urlSlug: string = slug ): Promise { return JSON.stringify( { slug, + urlSlug, originalBlueprint: await getBlueprintDeclaration(originalBlueprint), ...metadata, }, @@ -170,7 +202,9 @@ async function metadataToStoredFormat( } function storedFormatToMetadata(data: string) { - const { slug, ...metadata } = JSON.parse(data) as StoredSiteMetadata; + const { slug, urlSlug, ...metadata } = JSON.parse( + data + ) as StoredSiteMetadata; /** * Migrate the legacy runtimeConfiguration data format to the new, flat one. @@ -221,6 +255,7 @@ function storedFormatToMetadata(data: string) { return { slug, + urlSlug: urlSlug ?? slug, metadata, }; } diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b776f5d137..6ea796a04c 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -26,6 +26,7 @@ import { logger } from '@php-wasm/logger'; */ export interface SiteInfo { slug: string; + urlSlug?: string; originalUrlParams?: { searchParams?: Record; hash?: string; @@ -65,12 +66,16 @@ const sitesSlice = createSlice({ action: PayloadAction<{ slug: string; metadata: Partial; + urlSlug?: string; }> ) => { - const { slug, metadata } = action.payload; + const { slug, metadata, urlSlug } = action.payload; const site = state.entities[slug]; if (site) { site.metadata = { ...site.metadata, ...metadata }; + if (urlSlug) { + site.urlSlug = urlSlug; + } } }, @@ -95,7 +100,10 @@ export const OPFSSitesLoaded = (sites: SiteInfo[]) => { const currentSites = getState().sites.entities; const allSites = { ...currentSites }; sites.forEach((site) => { - allSites[site.slug] = site; + allSites[site.slug] = { + ...site, + urlSlug: site.urlSlug ?? site.slug, + }; }); dispatch(sitesSlice.actions.setSites(allSites)); dispatch(setOPFSSitesLoadingState('loaded')); @@ -123,15 +131,18 @@ export function deriveSiteNameFromSlug(slug: string) { export function updateSiteMetadata({ slug, changes, + urlSlug, }: { slug: string; changes: Partial; + urlSlug?: string; }) { return async ( dispatch: PlaygroundDispatch, getState: () => PlaygroundReduxState ) => { const storedSite = selectSiteBySlug(getState(), slug); + const nextUrlSlug = urlSlug ?? storedSite.urlSlug ?? slug; await dispatch( updateSite({ slug, @@ -140,6 +151,7 @@ export function updateSiteMetadata({ ...storedSite.metadata, ...changes, }, + ...(urlSlug ? { urlSlug: nextUrlSlug } : {}), }, }) ); @@ -176,7 +188,8 @@ export function updateSite({ if (updatedSite.metadata.storage !== 'none') { await opfsSiteStorage?.update( updatedSite.slug, - updatedSite.metadata + updatedSite.metadata, + updatedSite.urlSlug ?? updatedSite.slug ); } }; @@ -193,13 +206,19 @@ export function addSite(siteInfo: SiteInfo) { dispatch: PlaygroundDispatch, getState: () => PlaygroundReduxState ) => { + const urlSlug = siteInfo.urlSlug ?? siteInfo.slug; if (siteInfo.metadata.storage === 'none') { throw new Error( 'Cannot add a temporary site. Use setTemporarySiteSpec instead.' ); } await opfsSiteStorage?.create(siteInfo.slug, siteInfo.metadata); - dispatch(sitesSlice.actions.addSite(siteInfo)); + dispatch( + sitesSlice.actions.addSite({ + ...siteInfo, + urlSlug, + }) + ); }; } @@ -308,8 +327,10 @@ export function setTemporarySiteSpec( } // Compute the runtime configuration based on the resolved Blueprint: + const derivedSlug = deriveSlugFromSiteName(siteName); const newSiteInfo: SiteInfo = { - slug: deriveSlugFromSiteName(siteName), + slug: derivedSlug, + urlSlug: derivedSlug, originalUrlParams: newSiteUrlParams, metadata: { name: siteName, @@ -391,6 +412,15 @@ export const { (state: { sites: ReturnType }) => state.sites ); +export function selectSiteByUrlSlug( + state: { sites: ReturnType }, + urlSlug: string +) { + return selectAllSites(state).find( + (site) => (site.urlSlug ?? site.slug) === urlSlug + ); +} + export const selectSortedSites = createSelector( [selectAllSites], (sites: SiteInfo[]) => diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index efed33e00c..3300c2918f 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -41,6 +41,7 @@ export class PlaygroundRoute { if (site.metadata.storage === 'none') { return updateUrl(baseUrl, site.originalUrlParams || {}); } else { + const slugForUrl = site.urlSlug ?? site.slug; const baseParams = new URLSearchParams(baseUrl.split('?')[1]); const preserveParamsKeys = ['mode', 'networking', 'login', 'url']; const preserveParams: Record = {}; @@ -50,7 +51,7 @@ export class PlaygroundRoute { } } return updateUrl(baseUrl, { - searchParams: { 'site-slug': site.slug, ...preserveParams }, + searchParams: { 'site-slug': slugForUrl, ...preserveParams }, hash: '', }); }