From aea0181e39b1eca78dec30a46538235c9e8a25d3 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 21 Aug 2024 22:30:16 -0400 Subject: [PATCH] WebApp Redesign: Interact with sites list via redux (#1679) ## Motivation for the change, related issues This is a PR to start exploring site storage. In order to start from a concrete place, this PR starts by setting up the site manager sidebar to interact with the sites list via redux. Related to #1659 ## Implementation details This PR adds a `site-storage` module that provides functions for adding, removing, and listing sites. It currently only supports writing to sites stored in OPFS. Today, Safari only appears to support writing to OPFS files from worker threads, so this PR adds a `site-storage-metadata-worker.ts` module that the UI thread can spawn to write site metadata to OPFS. This PR adds a `siteListing` property to our web app's redux state. It looks like: ```ts { status: SiteListingStatus; sites: SiteInfo[]; } ``` `SiteListingStatus` can reflect whether the listing is loading, loaded, or in an error state. The site-manager-sidebar has been updated to select sites state from redux and interact with the sites list via redux actions. Currently, the loading status is ignored, but we should show it to the user in a follow-up PR. ## Testing Instructions (or ideally a Blueprint) Run `npm run dev` and interact with the sites list in Chrome and Safari. (Unfortunately, Firefox does not yet support loading Service Worker modules, and that feature is required for our current dev setup) --- .../playground/website/public/wordpress.html | 6 +- .../site-manager/add-site-button/index.tsx | 28 +- .../site-manager-sidebar/index.tsx | 103 ++---- .../playground/website/src/lib/redux-store.ts | 95 ++++++ .../src/lib/site-storage-metadata-worker.ts | 38 +++ .../website/src/lib/site-storage.ts | 321 ++++++++++++++++++ .../get-sqlite-database-plugin-details.ts | 2 - packages/playground/wordpress/src/index.ts | 34 +- 8 files changed, 531 insertions(+), 96 deletions(-) create mode 100644 packages/playground/website/src/lib/site-storage-metadata-worker.ts create mode 100644 packages/playground/website/src/lib/site-storage.ts diff --git a/packages/playground/website/public/wordpress.html b/packages/playground/website/public/wordpress.html index 76004670b7..929bccf227 100644 --- a/packages/playground/website/public/wordpress.html +++ b/packages/playground/website/public/wordpress.html @@ -219,9 +219,9 @@ landingPage: urlParams.get('url') || '/wp-admin', login: true, preferredVersions: { - php: "7.4", - wp: zipArtifactUrl - } + php: '7.4', + wp: zipArtifactUrl, + }, }; const encoded = JSON.stringify(blueprint); diff --git a/packages/playground/website/src/components/site-manager/add-site-button/index.tsx b/packages/playground/website/src/components/site-manager/add-site-button/index.tsx index 9e6c23db15..3a3dcda322 100644 --- a/packages/playground/website/src/components/site-manager/add-site-button/index.tsx +++ b/packages/playground/website/src/components/site-manager/add-site-button/index.tsx @@ -7,15 +7,25 @@ import { } from '@wordpress/components'; import css from './style.module.css'; import { useEffect, useRef, useState } from '@wordpress/element'; -import { Site } from '../site-manager-sidebar'; +import { type SiteInfo } from '../../../lib/site-storage'; import classNames from 'classnames'; +function generateUniqueName(defaultName: string, sites: SiteInfo[]) { + const numberOfSitesStartingWithDefaultName = sites.filter((site) => + site.name.startsWith(defaultName) + ).length; + if (numberOfSitesStartingWithDefaultName === 0) { + return defaultName; + } + return `${defaultName} ${numberOfSitesStartingWithDefaultName}`; +} + export function AddSiteButton({ onAddSite, sites, }: { onAddSite: (siteName: string) => void; - sites: Site[]; + sites: SiteInfo[]; }) { const defaultName = 'My Site'; const [isModalOpen, setModalOpen] = useState(false); @@ -23,23 +33,13 @@ export function AddSiteButton({ const addSiteButtonRef = useRef(null); const [error, setError] = useState(undefined); - const generateUniqueName = () => { - const numberOfSitesStartingWithDefaultName = sites.filter((site) => - site.name.startsWith(defaultName) - ).length; - if (numberOfSitesStartingWithDefaultName === 0) { - return defaultName; - } - return `${defaultName} ${numberOfSitesStartingWithDefaultName}`; - }; - useEffect(() => { - setSiteName(generateUniqueName()); + setSiteName(generateUniqueName(defaultName, sites)); }, [sites]); const openModal = () => setModalOpen(true); const closeModal = () => { - setSiteName(generateUniqueName()); + setSiteName(generateUniqueName(defaultName, sites)); setModalOpen(false); }; diff --git a/packages/playground/website/src/components/site-manager/site-manager-sidebar/index.tsx b/packages/playground/website/src/components/site-manager/site-manager-sidebar/index.tsx index 6c5c5d826f..f72355ca05 100644 --- a/packages/playground/website/src/components/site-manager/site-manager-sidebar/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-manager-sidebar/index.tsx @@ -1,9 +1,6 @@ -import { Blueprint } from '@wp-playground/blueprints'; -import { StorageType } from '../../../types'; - import css from './style.module.css'; import classNames from 'classnames'; -import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { __experimentalHeading as Heading, NavigableMenu, @@ -15,21 +12,14 @@ import { __experimentalItem as Item, } from '@wordpress/components'; import { Logo, TemporaryStorageIcon, WordPressIcon } from '../icons'; +import store, { + PlaygroundReduxState, + addSite as addSiteToStore, +} from '../../../lib/redux-store'; +import { type SiteLogo, createNewSiteInfo } from '../../../lib/site-storage'; import { AddSiteButton } from '../add-site-button'; - -// TODO: move types to site storage -// TODO: Explore better ways of obtaining site logos -type SiteLogo = { - mime: string; - data: string; -}; -export type Site = { - slug: string; - name: string; - logo?: SiteLogo; - blueprint?: Blueprint; - storage?: StorageType; -}; +import { LatestSupportedPHPVersion } from '@php-wasm/universal'; +import { LatestMinifiedWordPressVersion } from '@wp-playground/wordpress-builds'; export function SiteManagerSidebar({ className, @@ -40,65 +30,25 @@ export function SiteManagerSidebar({ siteSlug?: string; onSiteClick: (siteSlug: string) => void; }) { - const [sites, setSites] = useState([]); - - const generateSiteFromSlug = (slug: string): Site => { - let name = slug.replaceAll('-', ' '); - name = name.charAt(0).toUpperCase() + name.slice(1); - /** - * Ensure WordPress is spelt correctly in the UI. - */ - name = name.replace(/wordpress/i, 'WordPress'); + const unsortedSites = useSelector( + (state: PlaygroundReduxState) => state.siteListing.sites + ); + const sites = unsortedSites + .concat() + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); - return { - slug, + const addSite = async (name: string) => { + const newSiteInfo = createNewSiteInfo({ name, - storage: 'browser', - }; - }; - - /** - * TODO: This is a temporary solution to get the sites from the OPFS. - * This will be removed when Site storage is implemented. - */ - useEffect(() => { - const getVirtualOpfsRoot = async () => { - const virtualOpfsRoot = await navigator.storage.getDirectory(); - const opfsSites: Site[] = []; - for await (const entry of virtualOpfsRoot.values()) { - if (entry.kind === 'directory') { - /** - * Sites stored in browser storage are prefixed with "site-" - * so we need to remove the prefix to get the slug. - * - * The default site is stored in the `wordpress` directory - * and it doesn't have a prefix. - */ - const slug = entry.name.replace(/^site-/, ''); - opfsSites.push(generateSiteFromSlug(slug)); - } - } - setSites(opfsSites); - }; - getVirtualOpfsRoot(); - }, []); - - const addSite = (newName: string) => { - /** - * Generate a slug from the site name. - * TODO: remove this when site storage is implemented. - * In site storage slugs will be generated automatically. - */ - const newSlug = newName.replaceAll(' ', '-'); - /** - * If the site name already exists, we won't need to add it again. - * TODO: remove this check when site storage is implemented. - * In site storage we won't be limited to having unique site names. - */ - if (!sites.some((site) => site.slug === newSlug)) { - setSites([...sites, generateSiteFromSlug(newSlug)]); - } - onSiteClick(newSlug); + storage: 'opfs', + wpVersion: LatestMinifiedWordPressVersion, + phpVersion: LatestSupportedPHPVersion, + phpExtensionBundle: 'kitchen-sink', + }); + await store.dispatch(addSiteToStore(newSiteInfo)); + onSiteClick(newSiteInfo.slug); }; const resources = [ @@ -161,7 +111,8 @@ export function SiteManagerSidebar({ isSelected={isSelected} role="menuitemradio" icon={ - site.storage === 'none' || !site.storage ? ( + site.storage === 'temporary' || + !site.storage ? ( ) => { state.offline = action.payload; }, + setSiteListingLoaded: (state, action: PayloadAction) => { + state.siteListing = { + status: { type: 'loaded' }, + sites: action.payload, + }; + }, + setSiteListingError: (state, action: PayloadAction) => { + state.siteListing = { + status: { + type: 'error', + error: action.payload, + }, + sites: [], + }; + }, + addSite: (state, action: PayloadAction) => { + state.siteListing.sites.push(action.payload); + }, + removeSite: (state, action: PayloadAction) => { + const idToRemove = action.payload.id; + const siteIndex = state.siteListing.sites.findIndex( + (siteInfo) => siteInfo.id === idToRemove + ); + if (siteIndex !== undefined) { + state.siteListing.sites.splice(siteIndex, 1); + } + }, setOpfsMountDescriptor: ( state, action: PayloadAction @@ -75,6 +137,26 @@ const slice = createSlice({ // Export actions export const { setActiveModal, setOpfsMountDescriptor } = slice.actions; +// Redux thunk for adding a site +export function addSite(siteInfo: SiteInfo) { + return async (dispatch: typeof store.dispatch) => { + // TODO: Handle errors + // TODO: Possibly reflect addition in progress + await addSiteToStorage(siteInfo); + dispatch(slice.actions.addSite(siteInfo)); + }; +} + +// Redux thunk for removing a site +export function removeSite(site: SiteInfo) { + return async (dispatch: typeof store.dispatch) => { + // TODO: Handle errors + // TODO: Possibly reflect removal in progress + await removeSiteFromStorage(site); + dispatch(slice.actions.removeSite(site)); + }; +} + export function selectSite(siteSlug: string) { return async (dispatch: typeof store.dispatch) => { const opfsRoot = await navigator.storage.getDirectory(); @@ -122,6 +204,19 @@ function setupOnlineOfflineListeners(dispatch: PlaygroundDispatch) { } setupOnlineOfflineListeners(store.dispatch); +// NOTE: We will likely want to configure and list sites someplace else, +// but for now, it seems fine to just kick off loading from OPFS +// after the store is created. +listSites().then( + (sites) => store.dispatch(slice.actions.setSiteListingLoaded(sites)), + (error) => + store.dispatch( + slice.actions.setSiteListingError( + error instanceof Error ? error.message : 'Unknown error' + ) + ) +); + // Define RootState type export type PlaygroundReduxState = ReturnType; diff --git a/packages/playground/website/src/lib/site-storage-metadata-worker.ts b/packages/playground/website/src/lib/site-storage-metadata-worker.ts new file mode 100644 index 0000000000..1a0c303331 --- /dev/null +++ b/packages/playground/website/src/lib/site-storage-metadata-worker.ts @@ -0,0 +1,38 @@ +/** + * This worker module exists to allow writing file content to OPFS from the + * main browser thread. Today (2024-08-17), Safari only appears to support + * writing to OPFS via createSyncAccessHandle(), and that is only supported + * within dedicated workers. + * + * This worker exists so non-worker threads can trigger writing to OPFS files. + */ +onmessage = async function (event: MessageEvent) { + const filePath: string = event.data.path; + const content: string = event.data.content; + + const pathParts = filePath.split('/').filter((p) => p.length > 0); + + const fileName = pathParts.pop(); + if (fileName === undefined) { + throw new Error(`Invalid path: '${filePath}'`); + } + + let parentDirHandle = await navigator.storage.getDirectory(); + for (const part of pathParts) { + parentDirHandle = await parentDirHandle.getDirectoryHandle(part); + } + + const fileHandle = await parentDirHandle.getFileHandle(fileName, { + create: true, + }); + + const syncAccessHandle = await fileHandle.createSyncAccessHandle(); + try { + const encodedContent = new TextEncoder().encode(content); + syncAccessHandle.write(encodedContent); + postMessage('done'); + } finally { + syncAccessHandle.close(); + } +}; +postMessage('ready'); diff --git a/packages/playground/website/src/lib/site-storage.ts b/packages/playground/website/src/lib/site-storage.ts new file mode 100644 index 0000000000..c133310df6 --- /dev/null +++ b/packages/playground/website/src/lib/site-storage.ts @@ -0,0 +1,321 @@ +/** + * NOTE: This module should probably become a separate package + * or be added to an existing separate package like @playground/storage, + * but while we are iterating on the web app redesign, + * let's keep this module with the web app. + */ + +import { LatestMinifiedWordPressVersion } from '@wp-playground/wordpress-builds'; +import { + LatestSupportedPHPVersion, + SupportedPHPVersion, +} from '@php-wasm/universal'; +import { type Blueprint } from '@wp-playground/blueprints'; +import metadataWorkerUrl from './site-storage-metadata-worker?worker&url'; + +// TODO: Decide on metadata filename +const SITE_METADATA_FILENAME = 'playground-site-metadata.json'; + +/** + * The supported site storage types. + * + * NOTE: We are using different storage terms than our query API in order + * to be more explicit about storage medium in the site metadata format. + */ +export type SiteStorageType = 'temporary' | 'opfs' | 'local-fs'; + +/** + * The site logo data. + */ +export type SiteLogo = { + mime: string; + data: string; +}; + +/** + * The supported PHP extension bundles. + */ +export type PhpExtensionBundle = 'light' | 'kitchen-sink'; + +// TODO: Create a schema for this as the design matures +/** + * The Site metadata that is persisted. + */ +interface SiteMetadata { + id: string; + name: string; + logo?: SiteLogo; + wpVersion: string; + phpVersion: SupportedPHPVersion; + phpExtensionBundle: PhpExtensionBundle; + + // TODO: The designs show keeping admin username and password. Why do we want that? + + // TODO: Consider keeping timestamps. + // For a user, timestamps might be useful to disambiguate identically-named sites. + // For playground, we might choose to sort by most recently used. + //whenCreated: number; + //whenLastLoaded: number; + + originalBlueprint?: Blueprint; +} + +/** + * The Site model used to represent a site within Playground. + */ +export interface SiteInfo extends SiteMetadata { + storage: SiteStorageType; + slug: string; +} + +/** + * The initial information used to create a new site. + */ +export type InitialSiteInfo = Omit; + +/** + * Create a new site info structure from initial configuration. + * + * @param initialInfo The starting configuration for the site. + * @returns SiteInfo The new site info structure. + */ +export function createNewSiteInfo(initialInfo: InitialSiteInfo): SiteInfo { + return { + id: crypto.randomUUID(), + slug: deriveSlugFromSiteName(initialInfo.name), + ...initialInfo, + }; +} + +/** + * Adds a new site to the Playground site storage. + * + * This function creates a new site directory and writes the site metadata. + * Currently, only 'opfs' sites are supported. + * + * @param initialInfo - The information about the site to be added. + * @throws {Error} If a site with the given slug already exists. + * @returns {Promise} A promise that resolves when the site is added. + */ +export async function addSite(newSiteInfo: SiteInfo): Promise { + const newSiteDirName = getDirectoryNameForSlug(newSiteInfo.slug); + await createTopLevelDirectory(newSiteDirName); + + await writeSiteMetadata(newSiteInfo); + + return newSiteInfo; +} + +/** + * Creates a top-level directory with the given name. + * + * @param newDirName - The name of the new directory to be created. + * @throws {Error} If the directory already exists. + * @returns {Promise} A promise that resolves when the directory is created. + */ +async function createTopLevelDirectory(newDirName: string) { + const root = await navigator.storage.getDirectory(); + + let directoryAlreadyExists; + try { + await root.getDirectoryHandle(newDirName); + directoryAlreadyExists = true; + } catch (e: any) { + if (e?.name === 'NotFoundError') { + directoryAlreadyExists = false; + } else { + throw e; + } + } + + if (directoryAlreadyExists) { + throw new Error(`Directory already exists: '${newDirName}'.`); + } + + await root.getDirectoryHandle(newDirName, { create: true }); +} + +/** + * Removes a site from the Playground site storage. + * + * This function deletes the directory associated with the given site from OPFS. + * + * @param site - The information about the site to be removed. + * @throws {Error} If the directory cannot be found or removed. + * @returns {Promise} A promise that resolves when the site is removed. + */ +export async function removeSite(site: SiteInfo) { + const opfsRoot = await navigator.storage.getDirectory(); + const siteDirectoryName = getDirectoryNameForSlug(site.slug); + await opfsRoot.removeEntry(siteDirectoryName, { recursive: true }); +} + +/** + * List all sites from client storage. + * + * @returns {Promise} A promise for the list of sites from client storage. + * @throws {Error} If there is an issue accessing the OPFS or reading site information. + * @returns {Promise} A promise for a list of SiteInfo objects. + */ +export async function listSites(): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + const opfsSites: SiteInfo[] = []; + for await (const entry of opfsRoot.values()) { + if (entry.kind !== 'directory') { + continue; + } + + // To give us flexibility for the future, + // let's not assume all top-level OPFS dirs are sites. + if (!looksLikeSiteDirectory(entry.name)) { + continue; + } + + const site = await readSiteFromDirectory(entry); + if (site) { + opfsSites.push(site); + } + } + + return opfsSites; +} + +/** + * Reads information for a single site from a given directory. + * + * @param dir - The directory handle from which to read the site information. + * @returns {Promise} A promise for the site information. + * @throws {Error} If there is an issue accessing the metadata file or parsing its contents. + */ +async function readSiteFromDirectory( + dir: FileSystemDirectoryHandle +): Promise { + const slug = getSlugFromDirectoryName(dir.name); + if (slug === undefined) { + throw new Error(`Invalid site directory name: '${dir.name}'.`); + } + + try { + const metadataFileHandle = await dir.getFileHandle( + SITE_METADATA_FILENAME + ); + const file = await metadataFileHandle.getFile(); + const metadataContents = await file.text(); + + // TODO: Read metadata file and parse and validate via JSON schema + // TODO: Backfill site info file if missing, detecting actual WP version if possible + const metadata = JSON.parse(metadataContents) as SiteMetadata; + + return { + storage: 'opfs', + slug, + ...metadata, + }; + } catch (e: any) { + if (e?.name === 'NotFoundError') { + // TODO: Warn + return deriveDefaultSite(slug); + } else if (e?.name === 'SyntaxError') { + // TODO: Warn + return deriveDefaultSite(slug); + } else { + throw e; + } + } +} + +function looksLikeSiteDirectory(name: string) { + return name === 'wordpress' || name.startsWith('site-'); +} + +function getDirectoryNameForSlug(slug: string) { + return slug === 'wordpress' ? slug : `site-${slug}`; +} + +function getSlugFromDirectoryName(dirName: string) { + if (dirName === 'wordpress') { + return dirName; + } + + return looksLikeSiteDirectory(dirName) + ? dirName.substring('site-'.length) + : undefined; +} + +function deriveSlugFromSiteName(name: string) { + return name.toLowerCase().replaceAll(' ', '-'); +} + +function getFallbackSiteNameFromSlug(slug: string) { + return ( + slug + .replaceAll('-', ' ') + /* capital P dangit */ + .replace(/wordpress/i, 'WordPress') + .replaceAll(/\b\w/g, (s) => s.toUpperCase()) + ); +} + +function deriveDefaultSite(slug: string): SiteInfo { + return { + id: crypto.randomUUID(), + slug, + name: getFallbackSiteNameFromSlug(slug), + storage: 'opfs', + // TODO: Backfill site info file if missing, detecting actual WP version if possible + wpVersion: LatestMinifiedWordPressVersion, + phpVersion: LatestSupportedPHPVersion, + phpExtensionBundle: 'kitchen-sink', + }; +} + +async function writeSiteMetadata(site: SiteInfo) { + const metadata = getSiteMetadataFromSiteInfo(site); + const metadataJson = JSON.stringify(metadata, undefined, ' '); + const siteDirName = getDirectoryNameForSlug(site.slug); + await writeOpfsContent( + `/${siteDirName}/${SITE_METADATA_FILENAME}`, + metadataJson + ); +} + +function writeOpfsContent(path: string, content: string): Promise { + const worker = new Worker(metadataWorkerUrl, { type: 'module' }); + + const promiseToWrite = new Promise((resolve, reject) => { + worker.onmessage = function (event: MessageEvent) { + if (event.data === 'ready') { + worker.postMessage({ path, content }); + } else if (event.data === 'done') { + resolve(); + } + }; + worker.onerror = reject; + }); + const promiseToTimeout = new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('timeout')), 5000); + }); + + return Promise.race([promiseToWrite, promiseToTimeout]).finally(() => + worker.terminate() + ); +} + +function getSiteMetadataFromSiteInfo(site: SiteInfo): SiteMetadata { + const metadata: SiteMetadata = { + id: site.id, + name: site.name, + wpVersion: site.wpVersion, + phpVersion: site.phpVersion, + phpExtensionBundle: site.phpExtensionBundle, + }; + + if (site.logo !== undefined) { + metadata.logo = site.logo; + } + if (site.originalBlueprint !== undefined) { + metadata.originalBlueprint = site.originalBlueprint; + } + + return metadata; +} diff --git a/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-database-plugin-details.ts b/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-database-plugin-details.ts index fe070ea623..5f228ec734 100644 --- a/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-database-plugin-details.ts +++ b/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-database-plugin-details.ts @@ -1,4 +1,3 @@ - // @ts-ignore import url from './sqlite-database-integration.zip?url'; @@ -10,4 +9,3 @@ import url from './sqlite-database-integration.zip?url'; */ export const size = 86871; export { url }; - diff --git a/packages/playground/wordpress/src/index.ts b/packages/playground/wordpress/src/index.ts index 3efd2cfd88..f1e9134d79 100644 --- a/packages/playground/wordpress/src/index.ts +++ b/packages/playground/wordpress/src/index.ts @@ -335,7 +335,22 @@ export async function unzipWordPress(php: PHP, wpZip: File) { ? '/tmp/unzipped-wordpress/build' : '/tmp/unzipped-wordpress'; - php.mv(wpPath, php.documentRoot); + if ( + php.isDir(php.documentRoot) && + isCleanDirContainingSiteMetadata(php.documentRoot, php) + ) { + // We cannot mv the directory over a non-empty directory, + // but we can move the children one by one. + for (const file of php.listFiles(wpPath)) { + const sourcePath = joinPaths(wpPath, file); + const targetPath = joinPaths(php.documentRoot, file); + php.mv(sourcePath, targetPath); + } + php.rmdir(wpPath, { recursive: true }); + } else { + php.mv(wpPath, php.documentRoot); + } + if ( !php.fileExists(joinPaths(php.documentRoot, 'wp-config.php')) && php.fileExists(joinPaths(php.documentRoot, 'wp-config-sample.php')) @@ -348,3 +363,20 @@ export async function unzipWordPress(php: PHP, wpZip: File) { ); } } + +function isCleanDirContainingSiteMetadata(path: string, php: PHP) { + const files = php.listFiles(path); + if (files.length === 0) { + return true; + } + + if ( + files.length === 1 && + // TODO: use a constant from a site storage package + files[0] === 'playground-site-metadata.json' + ) { + return true; + } + + return false; +}