diff --git a/package-lock.json b/package-lock.json index 78bab6283ee..91aed14fe01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54299,7 +54299,9 @@ "version": "3.1.34", "license": "GPL-2.0-or-later", "dependencies": { - "pako": "^1.0.10" + "crc-32": "^1.2.0", + "pako": "^1.0.10", + "sha.js": "^2.4.12" } }, "packages/playground/sync": { diff --git a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md index 83af47ae45c..a0c11373dea 100644 --- a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md +++ b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md @@ -38,7 +38,8 @@ You can go ahead and try it out. The Playground will automatically install the t | `multisite` | `no` | Enables the WordPress multisite mode. Accepts `yes` or `no`. | | `import-site` | | Imports site files and database from a ZIP file specified by a URL. | | `import-wxr` | | Imports site content from a WXR file specified by a URL. It uses the WordPress Importer plugin, so the default admin user must be logged in. | -| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, the user will be prompted to save a new site with the specified slug. | +| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, Playground creates a new browser-saved site with the specified slug unless temporary storage is requested. | +| `storage` | | Controls whether the Playground is saved by default. Use `storage=temp` to create an unsaved temporary Playground that is reset when the page is refreshed or closed. | | `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. | | `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. | | `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. | diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts index 40dd54a077e..f9a6fb26e88 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest'; import { __private__dont__use, type PHP } from '@php-wasm/universal'; import { Semaphore } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; -import { journalFSEventsToOpfs } from './directory-handle-mount'; +import { + copyMemfsToOpfs, + createDirectoryHandleMountHandler, + journalFSEventsToOpfs, +} from './directory-handle-mount'; class MemoryFileHandle { kind = 'file' as const; @@ -355,9 +359,151 @@ describe('journalFSEventsToOpfs', () => { }); }); +describe('createDirectoryHandleMountHandler', () => { + it('flushes changes made while the initial MEMFS to OPFS sync is still running', async () => { + let changedDuringInitialSync = false; + let mount: { flush(): Promise } | undefined; + const { FS, files, php } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root', () => { + if (changedDuringInitialSync) { + return; + } + changedDuringInitialSync = true; + files.set('/wordpress/database.sqlite', encode('changed')); + FS.write({ path: '/wordpress/database.sqlite' }); + }); + files.set('/wordpress/database.sqlite', encode('initial')); + + const mountHandler = createDirectoryHandleMountHandler( + opfsRoot as unknown as FileSystemDirectoryHandle, + { + initialSync: { + direction: 'memfs-to-opfs', + }, + onMount: (createdMount) => { + mount = createdMount; + }, + } + ); + + await mountHandler(php, FS as any, '/wordpress'); + await mount!.flush(); + + expect(decode(opfsRoot.files.get('database.sqlite')!.bytes)).toBe( + 'changed' + ); + }); + + it('does not block the initial MEMFS to OPFS sync on the final flush', async () => { + let mount: { flush(): Promise } | undefined; + let changedDuringInitialSync = false; + const { FS, files, php } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root', () => { + if (changedDuringInitialSync) { + return; + } + changedDuringInitialSync = true; + FS.write({ path: '/wordpress/database.sqlite' }); + }); + files.set('/wordpress/database.sqlite', encode('initial')); + const releaseSemaphore = await php.semaphore.acquire(); + + const mountHandler = createDirectoryHandleMountHandler( + opfsRoot as unknown as FileSystemDirectoryHandle, + { + initialSync: { + direction: 'memfs-to-opfs', + }, + onMount: (createdMount) => { + mount = createdMount; + }, + } + ); + + await mountHandler(php, FS as any, '/wordpress'); + releaseSemaphore(); + await mount!.flush(); + + expect(decode(opfsRoot.files.get('database.sqlite')!.bytes)).toBe( + 'initial' + ); + }); + + it('reports a flushing phase after the initial MEMFS to OPFS copy', async () => { + const progressEvents: Array<{ + files: number; + total: number; + phase?: 'copying' | 'flushing'; + }> = []; + const { FS, files, php } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root'); + files.set('/wordpress/database.sqlite', encode('initial')); + + const mountHandler = createDirectoryHandleMountHandler( + opfsRoot as unknown as FileSystemDirectoryHandle, + { + initialSync: { + direction: 'memfs-to-opfs', + onProgress: (progress) => { + progressEvents.push(progress); + }, + }, + } + ); + + await mountHandler(php, FS as any, '/wordpress'); + + expect(progressEvents[0]).toEqual({ + files: 0, + total: 1, + phase: 'copying', + }); + expect(progressEvents).toContainEqual({ + files: 1, + total: 1, + phase: 'flushing', + }); + }); + + it('does not emit stale copy progress after the final progress event', async () => { + vi.useFakeTimers(); + + try { + const progressEvents: Array<{ files: number; total: number }> = []; + const { FS, files } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root'); + FS.readdir.mockReturnValue(['.', '..', 'first.txt', 'second.txt']); + files.set('/wordpress/first.txt', encode('first')); + files.set('/wordpress/second.txt', encode('second')); + + await copyMemfsToOpfs( + FS as any, + opfsRoot as unknown as FileSystemDirectoryHandle, + '/wordpress', + (progress) => { + progressEvents.push(progress); + } + ); + + const progressEventCount = progressEvents.length; + await vi.advanceTimersByTimeAsync(1000); + + expect(progressEvents).toHaveLength(progressEventCount); + expect(progressEvents.at(-1)).toEqual({ + files: 2, + total: 2, + }); + } finally { + vi.useRealTimers(); + } + }); +}); + function createFakePhp() { const files = new Map(); const FS = { + mkdirTree: vi.fn(), + readdir: vi.fn(() => ['.', '..', 'database.sqlite']), write: vi.fn(), truncate: vi.fn(), unlink: vi.fn(), diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.ts index 1e1cb33f122..b8b3de2acde 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.ts @@ -46,8 +46,12 @@ export type SyncProgress = { files: number; /** The number of all files that need to be synced. */ total: number; + /** The current stage of the initial sync. */ + phase?: 'copying' | 'flushing'; }; -export type SyncProgressCallback = (progress: SyncProgress) => void; +export type SyncProgressCallback = ( + progress: SyncProgress +) => void | Promise; interface JournalFSEventsToOpfsOptions { maxFlushPasses?: number; @@ -74,17 +78,40 @@ export function createDirectoryHandleMountHandler( } FSHelpers.mkdir(FS, vfsMountPoint); await copyOpfsToMemfs(FS, handle, vfsMountPoint); + const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); + options.onMount?.(mount); + return mount.unmount; } else { - await copyMemfsToOpfs( - FS, - handle, - vfsMountPoint, - options.initialSync.onProgress - ); + const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); + options.onMount?.(mount); + let lastProgress: SyncProgress | undefined; + try { + await copyMemfsToOpfs( + FS, + handle, + vfsMountPoint, + async (progress) => { + lastProgress = { + ...progress, + phase: 'copying', + }; + await options.initialSync.onProgress?.(lastProgress); + } + ); + await options.initialSync.onProgress?.({ + files: lastProgress?.total ?? 0, + total: lastProgress?.total ?? 0, + phase: 'flushing', + }); + void mount.flush().catch((error) => { + logger.error(error); + }); + } catch (error) { + await mount.unmount(); + throw error; + } + return mount.unmount; } - const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); - options.onMount?.(mount); - return mount.unmount; }; } @@ -195,6 +222,10 @@ export async function copyMemfsToOpfs( // so we report progress. Throttle the progress callback to avoid flooding // the main thread with excessive updates. let numFilesCompleted = 0; + await onProgress?.({ + files: numFilesCompleted, + total: filesToCreate.length, + }); const throttledProgressCallback = onProgress && throttle(onProgress, 100); // Limit max concurrent writes because Safari may otherwise encounter @@ -240,6 +271,11 @@ export async function copyMemfsToOpfs( // to a conflict with writes from the earlier attempt. await Promise.allSettled(concurrentWrites); } + throttledProgressCallback?.cancel(); + await onProgress?.({ + files: filesToCreate.length, + total: filesToCreate.length, + }); } function isMemfsDir(FS: Emscripten.RootFS, path: string) { @@ -532,15 +568,21 @@ async function resolveParent( return handle as any; } +type CancelableThrottledFunction any> = T & { + cancel(): void; +}; + function throttle any>( fn: T, debounceMs: number -): T { +): CancelableThrottledFunction { let lastCallTime = 0; let timeoutId: ReturnType | undefined; let pendingArgs: Parameters | undefined; - return function throttledCallback(...args: Parameters) { + const throttledCallback = function throttledCallback( + ...args: Parameters + ) { pendingArgs = args; const timeSinceLastCall = Date.now() - lastCallTime; @@ -552,5 +594,15 @@ function throttle any>( fn(...pendingArgs!); }, delay); } - } as T; + } as CancelableThrottledFunction; + + throttledCallback.cancel = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + timeoutId = undefined; + pendingArgs = undefined; + }; + + return throttledCallback; } diff --git a/packages/playground/storage/package.json b/packages/playground/storage/package.json index 59b12809e97..291349826ca 100644 --- a/packages/playground/storage/package.json +++ b/packages/playground/storage/package.json @@ -36,6 +36,8 @@ "type": "module", "types": "index.d.ts", "dependencies": { - "pako": "^1.0.10" + "crc-32": "^1.2.0", + "pako": "^1.0.10", + "sha.js": "^2.4.12" } } diff --git a/packages/playground/storage/src/lib/git-create-dotgit-directory.ts b/packages/playground/storage/src/lib/git-create-dotgit-directory.ts index 21ae6d4b356..e43e5399ef6 100644 --- a/packages/playground/storage/src/lib/git-create-dotgit-directory.ts +++ b/packages/playground/storage/src/lib/git-create-dotgit-directory.ts @@ -1,4 +1,3 @@ -import { GitIndex } from 'isomorphic-git/src/models/GitIndex.js'; import type { SparseCheckoutObject } from './git-sparse-checkout'; import pako from 'pako'; const deflate = pako.deflate; @@ -12,6 +11,23 @@ type GitHeadInfo = { tagName?: string; }; +type GitIndexEntry = { + filepath: string; + oid: string; + stats: { + ctimeSeconds: number; + ctimeNanoseconds: number; + mtimeSeconds: number; + mtimeNanoseconds: number; + dev: number; + ino: number; + mode: number; + uid: number; + gid: number; + size: number; + }; +}; + const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; /** @@ -181,12 +197,10 @@ export async function createDotGitDirectory({ if (headInfo.branchRef && headInfo.branchName) { gitFiles['.git/logs/HEAD'] = `ref: ${headInfo.branchRef}\n`; gitFiles[`.git/${headInfo.branchRef}`] = `${commitHash}\n`; - gitFiles[ - `.git/refs/remotes/origin/${headInfo.branchName}` - ] = `${commitHash}\n`; - gitFiles[ - '.git/refs/remotes/origin/HEAD' - ] = `ref: refs/remotes/origin/${headInfo.branchName}\n`; + gitFiles[`.git/refs/remotes/origin/${headInfo.branchName}`] = + `${commitHash}\n`; + gitFiles['.git/refs/remotes/origin/HEAD'] = + `ref: refs/remotes/origin/${headInfo.branchName}\n`; } if (headInfo.tagName) { @@ -196,33 +210,91 @@ export async function createDotGitDirectory({ // Use loose objects only, no packfiles Object.assign(gitFiles, await createLooseGitObjectFiles(objects)); - // Create the git index - const index = new GitIndex(); - for (const [path, oid] of Object.entries(fileOids)) { - // Remove the path prefix to get the working tree relative path - const workingTreePath = path - .substring(pathPrefix.length) - .replace(/^\/+/, ''); - index.insert({ - filepath: workingTreePath, - oid, - stats: { - ctimeSeconds: 0, - ctimeNanoseconds: 0, - mtimeSeconds: 0, - mtimeNanoseconds: 0, - dev: 0, - ino: 0, - mode: 0o100644, // Regular file - uid: 0, - gid: 0, - size: 0, - }, - }); - } - const indexBuffer = await index.toObject(); - // Convert Buffer to Uint8Array - copy the data to ensure it's a proper Uint8Array - gitFiles['.git/index'] = Uint8Array.from(indexBuffer); + const indexEntries = Object.entries(fileOids).map(([path, oid]) => ({ + filepath: path.substring(pathPrefix.length).replace(/^\/+/, ''), + oid, + stats: { + ctimeSeconds: 0, + ctimeNanoseconds: 0, + mtimeSeconds: 0, + mtimeNanoseconds: 0, + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + size: 0, + }, + })); + gitFiles['.git/index'] = await createGitIndex(indexEntries); return gitFiles; } + +async function createGitIndex(entries: GitIndexEntry[]) { + const sortedEntryBuffers = entries + .sort((a, b) => a.filepath.localeCompare(b.filepath)) + .map(createGitIndexEntry); + const header = new Uint8Array(12); + const headerView = new DataView(header.buffer); + header.set(new TextEncoder().encode('DIRC'), 0); + headerView.setUint32(4, 2); + headerView.setUint32(8, entries.length); + const body = concatUint8Arrays([header, ...sortedEntryBuffers]); + const checksum = new Uint8Array(await crypto.subtle.digest('SHA-1', body)); + return concatUint8Arrays([body, checksum]); +} + +function createGitIndexEntry({ filepath, oid, stats }: GitIndexEntry) { + const pathBytes = new TextEncoder().encode(filepath); + const length = Math.ceil((62 + pathBytes.length + 1) / 8) * 8; + const entry = new Uint8Array(length); + const view = new DataView(entry.buffer); + + view.setUint32(0, stats.ctimeSeconds); + view.setUint32(4, stats.ctimeNanoseconds); + view.setUint32(8, stats.mtimeSeconds); + view.setUint32(12, stats.mtimeNanoseconds); + view.setUint32(16, stats.dev); + view.setUint32(20, stats.ino); + view.setUint32(24, normalizeGitFileMode(stats.mode)); + view.setUint32(28, stats.uid); + view.setUint32(32, stats.gid); + view.setUint32(36, stats.size); + entry.set(hexToBytes(oid), 40); + view.setUint16(60, Math.min(pathBytes.length, 0xfff)); + entry.set(pathBytes, 62); + return entry; +} + +function normalizeGitFileMode(mode: number) { + let type = mode > 0 ? mode >> 12 : 0; + if (![0b0100, 0b1000, 0b1010, 0b1110].includes(type)) { + type = 0b1000; + } + let permissions = mode & 0o777; + permissions = permissions & 0b001001001 ? 0o755 : 0o644; + if (type !== 0b1000) { + permissions = 0; + } + return (type << 12) + permissions; +} + +function hexToBytes(hex: string) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function concatUint8Arrays(arrays: Uint8Array[]) { + const length = arrays.reduce((sum, array) => sum + array.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index 66f9e905100..38741dc667b 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -1,4 +1,5 @@ /* eslint-disable comment-length/limit-multi-line-comments */ +// @ts-nocheck /* * Import internal data parsers and structures from isomorphic-git. These @@ -8,15 +9,16 @@ * This file heavily relies on isomorphic-git internals to parse Git data formats * such as PACK, trees, deltas, etc. */ -import './isomorphic-git.d.ts'; -import { GitPktLine } from 'isomorphic-git/src/models/GitPktLine.js'; -import { GitTree } from 'isomorphic-git/src/models/GitTree.js'; -import { GitAnnotatedTag } from 'isomorphic-git/src/models/GitAnnotatedTag.js'; -import { GitCommit } from 'isomorphic-git/src/models/GitCommit.js'; -import { GitPackIndex } from 'isomorphic-git/src/models/GitPackIndex.js'; -import { collect } from 'isomorphic-git/src/internal-apis.js'; -import { parseUploadPackResponse } from 'isomorphic-git/src/wire/parseUploadPackResponse.js'; -import { ObjectTypeError } from 'isomorphic-git/src/errors/ObjectTypeError.js'; +import { + GitPktLine, + GitTree, + GitAnnotatedTag, + GitCommit, + GitPackIndex, + collect, + parseUploadPackResponse, + ObjectTypeError, +} from './isomorphic-git-internals'; import { Buffer as BufferPolyfill } from 'buffer'; /** diff --git a/packages/playground/storage/src/lib/isomorphic-git-internals.ts b/packages/playground/storage/src/lib/isomorphic-git-internals.ts new file mode 100644 index 00000000000..3c4b498578e --- /dev/null +++ b/packages/playground/storage/src/lib/isomorphic-git-internals.ts @@ -0,0 +1,1949 @@ +/* eslint-disable */ +// @ts-nocheck +/** + * Local copy of the small subset of isomorphic-git internals used by + * git-sparse-checkout.ts. The npm package does not publish these modules + * under isomorphic-git/src/*, so importing them directly breaks Vite builds. + * + * Source: isomorphic-git 1.37.6 (MIT). + */ +import crc32 from 'crc-32'; +import pako from 'pako'; +import Hash from 'sha.js/sha1.js'; + +// webpack://git/./src/utils/fromValue.js +// Convert a value to an Async Iterator +// This will be easier with async generator functions. +export function fromValue(value) { + let queue = [value]; + return { + next() { + return Promise.resolve({ + done: queue.length === 0, + value: queue.pop(), + }); + }, + return() { + queue = []; + return {}; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +// webpack://git/./src/utils/getIterator.js +export function getIterator(iterable) { + if (iterable[Symbol.asyncIterator]) { + return iterable[Symbol.asyncIterator](); + } + if (iterable[Symbol.iterator]) { + return iterable[Symbol.iterator](); + } + if (iterable.next) { + return iterable; + } + return fromValue(iterable); +} + +// webpack://git/./src/utils/StreamReader.js +// inspired by 'gartal' but lighter-weight and more battle-tested. +export class StreamReader { + constructor(stream) { + // TODO: fix usage in bundlers before Buffer dependency is removed #1855 + if (typeof Buffer === 'undefined') { + throw new Error('Missing Buffer dependency'); + } + this.stream = getIterator(stream); + this.buffer = null; + this.cursor = 0; + this.undoCursor = 0; + this.started = false; + this._ended = false; + this._discardedBytes = 0; + } + + eof() { + return this._ended && this.cursor === this.buffer.length; + } + + tell() { + return this._discardedBytes + this.cursor; + } + + async byte() { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor === this.buffer.length) { + await this._loadnext(); + if (this._ended) return; + } + this._moveCursor(1); + return this.buffer[this.undoCursor]; + } + + async chunk() { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor === this.buffer.length) { + await this._loadnext(); + if (this._ended) return; + } + this._moveCursor(this.buffer.length); + return this.buffer.slice(this.undoCursor, this.cursor); + } + + async read(n) { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor + n > this.buffer.length) { + this._trim(); + await this._accumulate(n); + } + this._moveCursor(n); + return this.buffer.slice(this.undoCursor, this.cursor); + } + + async skip(n) { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor + n > this.buffer.length) { + this._trim(); + await this._accumulate(n); + } + this._moveCursor(n); + } + + async undo() { + this.cursor = this.undoCursor; + } + + async _next() { + this.started = true; + let { done, value } = await this.stream.next(); + if (done) { + this._ended = true; + if (!value) return Buffer.alloc(0); + } + if (value) { + value = Buffer.from(value); + } + return value; + } + + _trim() { + // Throw away parts of the buffer we don't need anymore + // assert(this.cursor <= this.buffer.length) + this.buffer = this.buffer.slice(this.undoCursor); + this.cursor -= this.undoCursor; + this._discardedBytes += this.undoCursor; + this.undoCursor = 0; + } + + _moveCursor(n) { + this.undoCursor = this.cursor; + this.cursor += n; + if (this.cursor > this.buffer.length) { + this.cursor = this.buffer.length; + } + } + + async _accumulate(n) { + if (this._ended) return; + // Expand the buffer until we have N bytes of data + // or we've reached the end of the stream + const buffers = [this.buffer]; + while (this.cursor + n > lengthBuffers(buffers)) { + const nextbuffer = await this._next(); + if (this._ended) break; + buffers.push(nextbuffer); + } + this.buffer = Buffer.concat(buffers); + } + + async _loadnext() { + this._discardedBytes += this.buffer.length; + this.undoCursor = 0; + this.cursor = 0; + this.buffer = await this._next(); + } + + async _init() { + this.buffer = await this._next(); + } +} + +// This helper function helps us postpone concatenating buffers, which +// would create intermediate buffer objects, +function lengthBuffers(buffers) { + return buffers.reduce((acc, buffer) => acc + buffer.length, 0); +} + +// webpack://git/./src/utils/padHex.js +export function padHex(b, n) { + const s = n.toString(16); + return '0'.repeat(b - s.length) + s; +} + +// webpack://git/./src/models/GitPktLine.js +/** +pkt-line Format +--------------- + +Much (but not all) of the payload is described around pkt-lines. + +A pkt-line is a variable length binary string. The first four bytes +of the line, the pkt-len, indicates the total length of the line, +in hexadecimal. The pkt-len includes the 4 bytes used to contain +the length's hexadecimal representation. + +A pkt-line MAY contain binary data, so implementers MUST ensure +pkt-line parsing/formatting routines are 8-bit clean. + +A non-binary line SHOULD BE terminated by an LF, which if present +MUST be included in the total length. Receivers MUST treat pkt-lines +with non-binary data the same whether or not they contain the trailing +LF (stripping the LF if present, and not complaining when it is +missing). + +The maximum length of a pkt-line's data component is 65516 bytes. +Implementations MUST NOT send pkt-line whose length exceeds 65520 +(65516 bytes of payload + 4 bytes of length data). + +Implementations SHOULD NOT send an empty pkt-line ("0004"). + +A pkt-line with a length field of 0 ("0000"), called a flush-pkt, +is a special case and MUST be handled differently than an empty +pkt-line ("0004"). + +---- + pkt-line = data-pkt / flush-pkt + + data-pkt = pkt-len pkt-payload + pkt-len = 4*(HEXDIG) + pkt-payload = (pkt-len - 4)*(OCTET) + + flush-pkt = "0000" +---- + +Examples (as C-style strings): + +---- + pkt-line actual value + --------------------------------- + "0006a\n" "a\n" + "0005a" "a" + "000bfoobar\n" "foobar\n" + "0004" "" +---- +*/ + +// I'm really using this more as a namespace. +// There's not a lot of "state" in a pkt-line + +export class GitPktLine { + static flush() { + return Buffer.from('0000', 'utf8'); + } + + static delim() { + return Buffer.from('0001', 'utf8'); + } + + static encode(line) { + if (typeof line === 'string') { + line = Buffer.from(line); + } + const length = line.length + 4; + const hexlength = padHex(4, length); + return Buffer.concat([Buffer.from(hexlength, 'utf8'), line]); + } + + static streamReader(stream) { + const reader = new StreamReader(stream); + return async function read() { + try { + let length = await reader.read(4); + if (length == null) return true; + length = parseInt(length.toString('utf8'), 16); + if (length === 0) return null; + if (length === 1) return null; // delim packets + const buffer = await reader.read(length - 4); + if (buffer == null) return true; + return buffer; + } catch (err) { + stream.error = err; + return true; + } + }; + } +} + +// webpack://git/./src/errors/BaseError.js +export class BaseError extends Error { + constructor(message) { + super(message); + // Setting this here allows TS to infer that all git errors have a `caller` property and + // that its type is string. + this.caller = ''; + } + + toJSON() { + // Error objects aren't normally serializable. So we do something about that. + return { + code: this.code, + data: this.data, + caller: this.caller, + message: this.message, + stack: this.stack, + }; + } + + fromJSON(json) { + const e = new BaseError(json.message); + e.code = json.code; + e.data = json.data; + e.caller = json.caller; + e.stack = json.stack; + return e; + } + + get isIsomorphicGitError() { + return true; + } +} + +// webpack://git/./src/errors/InternalError.js +export class InternalError extends BaseError { + /** + * @param {string} message + */ + constructor(message) { + super( + `An internal error caused this command to fail.\n\nIf you're not a developer, report the bug to the developers of the application you're using. If this is a bug in isomorphic-git then you should create a proper bug yourselves. The bug should include a minimal reproduction and details about the version and environment.\n\nPlease file a bug report at https://github.com/isomorphic-git/isomorphic-git/issues with this error message: ${message}` + ); + this.code = this.name = InternalError.code; + this.data = { message }; + } +} +/** @type {'InternalError'} */ +InternalError.code = 'InternalError'; + +// webpack://git/./src/errors/UnsafeFilepathError.js +export class UnsafeFilepathError extends BaseError { + /** + * @param {string} filepath + */ + constructor(filepath) { + super(`The filepath "${filepath}" contains unsafe character sequences`); + this.code = this.name = UnsafeFilepathError.code; + this.data = { filepath }; + } +} +/** @type {'UnsafeFilepathError'} */ +UnsafeFilepathError.code = 'UnsafeFilepathError'; + +// webpack://git/./src/utils/compareStrings.js +export function compareStrings(a, b) { + // https://stackoverflow.com/a/40355107/2168416 + return -(a < b) || +(a > b); +} + +// webpack://git/./src/utils/comparePath.js +export function comparePath(a, b) { + // https://stackoverflow.com/a/40355107/2168416 + return compareStrings(a.path, b.path); +} + +// webpack://git/./src/utils/compareTreeEntryPath.js +export function compareTreeEntryPath(a, b) { + // Git sorts tree entries as if there is a trailing slash on directory names. + return compareStrings(appendSlashIfDir(a), appendSlashIfDir(b)); +} + +function appendSlashIfDir(entry) { + return entry.mode === '040000' ? entry.path + '/' : entry.path; +} + +// webpack://git/./src/models/GitTree.js +/** + * + * @typedef {Object} TreeEntry + * @property {string} mode - the 6 digit hexadecimal mode + * @property {string} path - the name of the file or directory + * @property {string} oid - the SHA-1 object id of the blob or tree + * @property {'commit'|'blob'|'tree'} type - the type of object + */ + +function mode2type(mode) { + // prettier-ignore + switch (mode) { + case '040000': return 'tree' + case '100644': return 'blob' + case '100755': return 'blob' + case '120000': return 'blob' + case '160000': return 'commit' + } + throw new InternalError(`Unexpected GitTree entry mode: ${mode}`); +} + +function parseBuffer(buffer) { + const _entries = []; + let cursor = 0; + while (cursor < buffer.length) { + const space = buffer.indexOf(32, cursor); + if (space === -1) { + throw new InternalError( + `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next space character.` + ); + } + const nullchar = buffer.indexOf(0, cursor); + if (nullchar === -1) { + throw new InternalError( + `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next null character.` + ); + } + let mode = buffer.slice(cursor, space).toString('utf8'); + if (mode === '40000') mode = '040000'; // makes it line up neater in printed output + const type = mode2type(mode); + const path = buffer.slice(space + 1, nullchar).toString('utf8'); + + // Prevent malicious git repos from writing to "..\foo" on clone etc + if (path.includes('\\') || path.includes('/')) { + throw new UnsafeFilepathError(path); + } + + const oid = buffer.slice(nullchar + 1, nullchar + 21).toString('hex'); + cursor = nullchar + 21; + _entries.push({ mode, path, oid, type }); + } + return _entries; +} + +function limitModeToAllowed(mode) { + if (typeof mode === 'number') { + mode = mode.toString(8); + } + // tree + if (mode.match(/^0?4.*/)) return '040000'; // Directory + if (mode.match(/^1006.*/)) return '100644'; // Regular non-executable file + if (mode.match(/^1007.*/)) return '100755'; // Regular executable file + if (mode.match(/^120.*/)) return '120000'; // Symbolic link + if (mode.match(/^160.*/)) return '160000'; // Commit (git submodule reference) + throw new InternalError(`Could not understand file mode: ${mode}`); +} + +function nudgeIntoShape(entry) { + if (!entry.oid && entry.sha) { + entry.oid = entry.sha; // Github + } + entry.mode = limitModeToAllowed(entry.mode); // index + if (!entry.type) { + entry.type = mode2type(entry.mode); // index + } + return entry; +} + +export class GitTree { + constructor(entries) { + if (Buffer.isBuffer(entries)) { + this._entries = parseBuffer(entries); + } else if (Array.isArray(entries)) { + this._entries = entries.map(nudgeIntoShape); + } else { + throw new InternalError( + 'invalid type passed to GitTree constructor' + ); + } + // Tree entries are not sorted alphabetically in the usual sense (see `compareTreeEntryPath`) + // but it is important later on that these be sorted in the same order as they would be returned from readdir. + this._entries.sort(comparePath); + } + + static from(tree) { + return new GitTree(tree); + } + + render() { + return this._entries + .map( + (entry) => + `${entry.mode} ${entry.type} ${entry.oid} ${entry.path}` + ) + .join('\n'); + } + + toObject() { + // Adjust the sort order to match git's + const entries = [...this._entries]; + entries.sort(compareTreeEntryPath); + return Buffer.concat( + entries.map((entry) => { + const mode = Buffer.from(entry.mode.replace(/^0/, '')); + const space = Buffer.from(' '); + const path = Buffer.from(entry.path, 'utf8'); + const nullchar = Buffer.from([0]); + const oid = Buffer.from(entry.oid, 'hex'); + return Buffer.concat([mode, space, path, nullchar, oid]); + }) + ); + } + + /** + * @returns {TreeEntry[]} + */ + entries() { + return this._entries; + } + + *[Symbol.iterator]() { + for (const entry of this._entries) { + yield entry; + } + } +} + +// webpack://git/./src/utils/formatAuthor.js +export function formatAuthor({ name, email, timestamp, timezoneOffset }) { + timezoneOffset = formatTimezoneOffset(timezoneOffset); + return `${name} <${email}> ${timestamp} ${timezoneOffset}`; +} + +// The amount of effort that went into crafting these cases to handle +// -0 (just so we don't lose that information when parsing and reconstructing) +// but can also default to +0 was extraordinary. + +function formatTimezoneOffset(minutes) { + const sign = simpleSign(negateExceptForZero(minutes)); + minutes = Math.abs(minutes); + const hours = Math.floor(minutes / 60); + minutes -= hours * 60; + let strHours = String(hours); + let strMinutes = String(minutes); + if (strHours.length < 2) strHours = '0' + strHours; + if (strMinutes.length < 2) strMinutes = '0' + strMinutes; + return (sign === -1 ? '-' : '+') + strHours + strMinutes; +} + +function simpleSign(n) { + return Math.sign(n) || (Object.is(n, -0) ? -1 : 1); +} + +function negateExceptForZero(n) { + return n === 0 ? n : -n; +} + +// webpack://git/./src/utils/indent.js +export function indent(str) { + return ( + str + .trim() + .split('\n') + .map((x) => ' ' + x) + .join('\n') + '\n' + ); +} + +// webpack://git/./src/utils/normalizeNewlines.js +export function normalizeNewlines(str) { + // remove all + str = str.replace(/\r/g, ''); + // no extra newlines up front + str = str.replace(/^\n+/, ''); + // and a single newline at the end + str = str.replace(/\n+$/, '') + '\n'; + return str; +} + +// webpack://git/./src/utils/outdent.js +export function outdent(str) { + return str + .split('\n') + .map((x) => x.replace(/^ /, '')) + .join('\n'); +} + +// webpack://git/./src/utils/parseAuthor.js +export function parseAuthor(author) { + const [, name, email, timestamp, offset] = author.match( + /^(.*) <(.*)> (.*) (.*)$/ + ); + return { + name, + email, + timestamp: Number(timestamp), + timezoneOffset: parseTimezoneOffset(offset), + }; +} + +// The amount of effort that went into crafting these cases to handle +// -0 (just so we don't lose that information when parsing and reconstructing) +// but can also default to +0 was extraordinary. + +function parseTimezoneOffset(offset) { + let [, sign, hours, minutes] = offset.match(/(\+|-)(\d\d)(\d\d)/); + minutes = (sign === '+' ? 1 : -1) * (Number(hours) * 60 + Number(minutes)); + return negateExceptForZeroForParse(minutes); +} + +function negateExceptForZeroForParse(n) { + return n === 0 ? n : -n; +} + +// webpack://git/./src/models/GitCommit.js +export class GitCommit { + constructor(commit) { + if (typeof commit === 'string') { + this._commit = commit; + } else if (Buffer.isBuffer(commit)) { + this._commit = commit.toString('utf8'); + } else if (typeof commit === 'object') { + this._commit = GitCommit.render(commit); + } else { + throw new InternalError( + 'invalid type passed to GitCommit constructor' + ); + } + } + + static fromPayloadSignature({ payload, signature }) { + const headers = GitCommit.justHeaders(payload); + const message = GitCommit.justMessage(payload); + const commit = normalizeNewlines( + headers + '\ngpgsig' + indent(signature) + '\n' + message + ); + return new GitCommit(commit); + } + + static from(commit) { + return new GitCommit(commit); + } + + toObject() { + return Buffer.from(this._commit, 'utf8'); + } + + // Todo: allow setting the headers and message + headers() { + return this.parseHeaders(); + } + + // Todo: allow setting the headers and message + message() { + return GitCommit.justMessage(this._commit); + } + + parse() { + return Object.assign({ message: this.message() }, this.headers()); + } + + static justMessage(commit) { + return normalizeNewlines(commit.slice(commit.indexOf('\n\n') + 2)); + } + + static justHeaders(commit) { + return commit.slice(0, commit.indexOf('\n\n')); + } + + parseHeaders() { + const headers = GitCommit.justHeaders(this._commit).split('\n'); + const hs = []; + for (const h of headers) { + if (h[0] === ' ') { + // combine with previous header (without space indent) + hs[hs.length - 1] += '\n' + h.slice(1); + } else { + hs.push(h); + } + } + const obj = { + parent: [], + }; + for (const h of hs) { + const key = h.slice(0, h.indexOf(' ')); + const value = h.slice(h.indexOf(' ') + 1); + if (Array.isArray(obj[key])) { + obj[key].push(value); + } else { + obj[key] = value; + } + } + if (obj.author) { + obj.author = parseAuthor(obj.author); + } + if (obj.committer) { + obj.committer = parseAuthor(obj.committer); + } + return obj; + } + + static renderHeaders(obj) { + let headers = ''; + if (obj.tree) { + headers += `tree ${obj.tree}\n`; + } else { + headers += `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n`; // the null tree + } + if (obj.parent) { + if (obj.parent.length === undefined) { + throw new InternalError( + `commit 'parent' property should be an array` + ); + } + for (const p of obj.parent) { + headers += `parent ${p}\n`; + } + } + const author = obj.author; + headers += `author ${formatAuthor(author)}\n`; + const committer = obj.committer || obj.author; + headers += `committer ${formatAuthor(committer)}\n`; + if (obj.gpgsig) { + headers += 'gpgsig' + indent(obj.gpgsig); + } + return headers; + } + + static render(obj) { + return ( + GitCommit.renderHeaders(obj) + '\n' + normalizeNewlines(obj.message) + ); + } + + render() { + return this._commit; + } + + withoutSignature() { + const commit = normalizeNewlines(this._commit); + if (commit.indexOf('\ngpgsig') === -1) return commit; + const headers = commit.slice(0, commit.indexOf('\ngpgsig')); + const message = commit.slice( + commit.indexOf('-----END PGP SIGNATURE-----\n') + + '-----END PGP SIGNATURE-----\n'.length + ); + return normalizeNewlines(headers + '\n' + message); + } + + isolateSignature() { + const signature = this._commit.slice( + this._commit.indexOf('-----BEGIN PGP SIGNATURE-----'), + this._commit.indexOf('-----END PGP SIGNATURE-----') + + '-----END PGP SIGNATURE-----'.length + ); + return outdent(signature); + } + + static async sign(commit, sign, secretKey) { + const payload = commit.withoutSignature(); + const message = GitCommit.justMessage(commit._commit); + let { signature } = await sign({ payload, secretKey }); + // renormalize the line endings to the one true line-ending + signature = normalizeNewlines(signature); + const headers = GitCommit.justHeaders(commit._commit); + const signedCommit = + headers + '\n' + 'gpgsig' + indent(signature) + '\n' + message; + // return a new commit object + return GitCommit.from(signedCommit); + } +} + +// webpack://git/./src/models/GitAnnotatedTag.js +export class GitAnnotatedTag { + constructor(tag) { + if (typeof tag === 'string') { + this._tag = tag; + } else if (Buffer.isBuffer(tag)) { + this._tag = tag.toString('utf8'); + } else if (typeof tag === 'object') { + this._tag = GitAnnotatedTag.render(tag); + } else { + throw new InternalError( + 'invalid type passed to GitAnnotatedTag constructor' + ); + } + } + + static from(tag) { + return new GitAnnotatedTag(tag); + } + + static render(obj) { + return `object ${obj.object} +type ${obj.type} +tag ${obj.tag} +tagger ${formatAuthor(obj.tagger)} + +${obj.message} +${obj.gpgsig ? obj.gpgsig : ''}`; + } + + justHeaders() { + return this._tag.slice(0, this._tag.indexOf('\n\n')); + } + + message() { + const tag = this.withoutSignature(); + return tag.slice(tag.indexOf('\n\n') + 2); + } + + parse() { + return Object.assign(this.headers(), { + message: this.message(), + gpgsig: this.gpgsig(), + }); + } + + render() { + return this._tag; + } + + headers() { + const headers = this.justHeaders().split('\n'); + const hs = []; + for (const h of headers) { + if (h[0] === ' ') { + // combine with previous header (without space indent) + hs[hs.length - 1] += '\n' + h.slice(1); + } else { + hs.push(h); + } + } + const obj = {}; + for (const h of hs) { + const key = h.slice(0, h.indexOf(' ')); + const value = h.slice(h.indexOf(' ') + 1); + if (Array.isArray(obj[key])) { + obj[key].push(value); + } else { + obj[key] = value; + } + } + if (obj.tagger) { + obj.tagger = parseAuthor(obj.tagger); + } + if (obj.committer) { + obj.committer = parseAuthor(obj.committer); + } + return obj; + } + + withoutSignature() { + const tag = normalizeNewlines(this._tag); + if (tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return tag; + return tag.slice(0, tag.lastIndexOf('\n-----BEGIN PGP SIGNATURE-----')); + } + + gpgsig() { + if (this._tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return; + const signature = this._tag.slice( + this._tag.indexOf('-----BEGIN PGP SIGNATURE-----'), + this._tag.indexOf('-----END PGP SIGNATURE-----') + + '-----END PGP SIGNATURE-----'.length + ); + return normalizeNewlines(signature); + } + + payload() { + return this.withoutSignature() + '\n'; + } + + toObject() { + return Buffer.from(this._tag, 'utf8'); + } + + static async sign(tag, sign, secretKey) { + const payload = tag.payload(); + let { signature } = await sign({ payload, secretKey }); + // renormalize the line endings to the one true line-ending + signature = normalizeNewlines(signature); + const signedTag = payload + signature; + // return a new tag object + return GitAnnotatedTag.from(signedTag); + } +} + +// webpack://git/./src/models/GitObject.js +/** + * Represents a Git object and provides methods to wrap and unwrap Git objects + * according to the Git object format. + */ +export class GitObject { + /** + * Wraps a raw object with a Git header. + * + * @param {Object} params - The parameters for wrapping. + * @param {string} params.type - The type of the Git object (e.g., 'blob', 'tree', 'commit'). + * @param {Uint8Array} params.object - The raw object data to wrap. + * @returns {Uint8Array} The wrapped Git object as a single buffer. + */ + static wrap({ type, object }) { + const header = `${type} ${object.length}\x00`; + const headerLen = header.length; + const totalLength = headerLen + object.length; + + // Allocate a single buffer for the header and object, rather than create multiple buffers + const wrappedObject = new Uint8Array(totalLength); + for (let i = 0; i < headerLen; i++) { + wrappedObject[i] = header.charCodeAt(i); + } + wrappedObject.set(object, headerLen); + + return wrappedObject; + } + + /** + * Unwraps a Git object buffer into its type and raw object data. + * + * @param {Buffer|Uint8Array} buffer - The buffer containing the wrapped Git object. + * @returns {{ type: string, object: Buffer }} An object containing the type and the raw object data. + * @throws {InternalError} If the length specified in the header does not match the actual object length. + */ + static unwrap(buffer) { + const s = buffer.indexOf(32); // first space + const i = buffer.indexOf(0); // first null value + const type = buffer.slice(0, s).toString('utf8'); // get type of object + const length = buffer.slice(s + 1, i).toString('utf8'); // get type of object + const actualLength = buffer.length - (i + 1); + // verify length + if (parseInt(length) !== actualLength) { + throw new InternalError( + `Length mismatch: expected ${length} bytes but got ${actualLength} instead.` + ); + } + return { + type, + object: Buffer.from(buffer.slice(i + 1)), + }; + } +} + +// webpack://git/./src/utils/BufferCursor.js +// Modeled after https://github.com/tjfontaine/node-buffercursor +// but with the goal of being much lighter weight. +export class BufferCursor { + constructor(buffer) { + this.buffer = buffer; + this._start = 0; + } + + eof() { + return this._start >= this.buffer.length; + } + + tell() { + return this._start; + } + + seek(n) { + this._start = n; + } + + slice(n) { + const r = this.buffer.slice(this._start, this._start + n); + this._start += n; + return r; + } + + toString(enc, length) { + const r = this.buffer.toString(enc, this._start, this._start + length); + this._start += length; + return r; + } + + write(value, length, enc) { + const r = this.buffer.write(value, this._start, length, enc); + this._start += length; + return r; + } + + copy(source, start, end) { + const r = source.copy(this.buffer, this._start, start, end); + this._start += r; + return r; + } + + readUInt8() { + const r = this.buffer.readUInt8(this._start); + this._start += 1; + return r; + } + + writeUInt8(value) { + const r = this.buffer.writeUInt8(value, this._start); + this._start += 1; + return r; + } + + readUInt16BE() { + const r = this.buffer.readUInt16BE(this._start); + this._start += 2; + return r; + } + + writeUInt16BE(value) { + const r = this.buffer.writeUInt16BE(value, this._start); + this._start += 2; + return r; + } + + readUInt32BE() { + const r = this.buffer.readUInt32BE(this._start); + this._start += 4; + return r; + } + + writeUInt32BE(value) { + const r = this.buffer.writeUInt32BE(value, this._start); + this._start += 4; + return r; + } +} + +// webpack://git/./src/utils/applyDelta.js +/** + * @param {Buffer} delta + * @param {Buffer} source + * @returns {Buffer} + */ +export function applyDelta(delta, source) { + const reader = new BufferCursor(delta); + const sourceSize = readVarIntLE(reader); + + if (sourceSize !== source.byteLength) { + throw new InternalError( + `applyDelta expected source buffer to be ${sourceSize} bytes but the provided buffer was ${source.length} bytes` + ); + } + const targetSize = readVarIntLE(reader); + let target; + + const firstOp = readOp(reader, source); + // Speed optimization - return raw buffer if it's just single simple copy + if (firstOp.byteLength === targetSize) { + target = firstOp; + } else { + // Otherwise, allocate a fresh buffer and slices + target = Buffer.alloc(targetSize); + const writer = new BufferCursor(target); + writer.copy(firstOp); + + while (!reader.eof()) { + writer.copy(readOp(reader, source)); + } + + const tell = writer.tell(); + if (targetSize !== tell) { + throw new InternalError( + `applyDelta expected target buffer to be ${targetSize} bytes but the resulting buffer was ${tell} bytes` + ); + } + } + return target; +} + +function readVarIntLE(reader) { + let result = 0; + let shift = 0; + let byte = null; + do { + byte = reader.readUInt8(); + result |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + return result; +} + +function readCompactLE(reader, flags, size) { + let result = 0; + let shift = 0; + while (size--) { + if (flags & 0b00000001) { + result |= reader.readUInt8() << shift; + } + flags >>= 1; + shift += 8; + } + return result; +} + +function readOp(reader, source) { + /** @type {number} */ + const byte = reader.readUInt8(); + const COPY = 0b10000000; + const OFFS = 0b00001111; + const SIZE = 0b01110000; + if (byte & COPY) { + // copy consists of 4 byte offset, 3 byte size (in LE order) + const offset = readCompactLE(reader, byte & OFFS, 4); + let size = readCompactLE(reader, (byte & SIZE) >> 4, 3); + // Yup. They really did this optimization. + if (size === 0) size = 0x10000; + return source.slice(offset, offset + size); + } else { + // insert + return reader.slice(byte); + } +} + +// webpack://git/./src/utils/git-list-pack.js +// My version of git-list-pack - roughly 15x faster than the original +// It's used slightly differently - instead of returning a through stream it wraps a stream. +// (I tried to make it API identical, but that ended up being 2x slower than this version.) + +export async function listpack(stream, onData) { + const reader = new StreamReader(stream); + let PACK = await reader.read(4); + PACK = PACK.toString('utf8'); + if (PACK !== 'PACK') { + throw new InternalError(`Invalid PACK header '${PACK}'`); + } + + let version = await reader.read(4); + version = version.readUInt32BE(0); + if (version !== 2) { + throw new InternalError(`Invalid packfile version: ${version}`); + } + + let numObjects = await reader.read(4); + numObjects = numObjects.readUInt32BE(0); + // If (for some godforsaken reason) this is an empty packfile, abort now. + if (numObjects < 1) return; + + while (!reader.eof() && numObjects--) { + const offset = reader.tell(); + const { type, length, ofs, reference } = await parseHeader(reader); + const inflator = new pako.Inflate(); + while (!inflator.result) { + const chunk = await reader.chunk(); + if (!chunk) break; + inflator.push(chunk, false); + if (inflator.err) { + throw new InternalError(`Pako error: ${inflator.msg}`); + } + if (inflator.result) { + if (inflator.result.length !== length) { + throw new InternalError( + `Inflated object size is different from that stated in packfile.` + ); + } + + // Backtrack parser to where deflated data ends + await reader.undo(); + await reader.read(chunk.length - inflator.strm.avail_in); + const end = reader.tell(); + await onData({ + data: inflator.result, + type, + num: numObjects, + offset, + end, + reference, + ofs, + }); + } + } + } +} + +async function parseHeader(reader) { + // Object type is encoded in bits 654 + let byte = await reader.byte(); + const type = (byte >> 4) & 0b111; + // The length encoding get complicated. + // Last four bits of length is encoded in bits 3210 + let length = byte & 0b1111; + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + if (byte & 0b10000000) { + let shift = 4; + do { + byte = await reader.byte(); + length |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + } + // Handle deltified objects + let ofs; + let reference; + if (type === 6) { + let shift = 0; + ofs = 0; + const bytes = []; + do { + byte = await reader.byte(); + ofs |= (byte & 0b01111111) << shift; + shift += 7; + bytes.push(byte); + } while (byte & 0b10000000); + reference = Buffer.from(bytes); + } + if (type === 7) { + const buf = await reader.read(20); + reference = buf; + } + return { type, length, ofs, reference }; +} + +// webpack://git/./src/utils/inflate.js +/* eslint-env node, browser */ +/* global DecompressionStream */ + +let supportsDecompressionStream = false; + +export async function inflate(buffer) { + if (supportsDecompressionStream === null) { + supportsDecompressionStream = testDecompressionStream(); + } + return supportsDecompressionStream + ? browserInflate(buffer) + : pako.inflate(buffer); +} + +async function browserInflate(buffer) { + const ds = new DecompressionStream('deflate'); + const d = new Blob([buffer]).stream().pipeThrough(ds); + return new Uint8Array(await new Response(d).arrayBuffer()); +} + +function testDecompressionStream() { + try { + const ds = new DecompressionStream('deflate'); + if (ds) return true; + } catch (_) { + // no bother + } + return false; +} + +// webpack://git/./src/utils/toHex.js +export function toHex(buffer) { + let hex = ''; + for (const byte of new Uint8Array(buffer)) { + if (byte < 16) hex += '0'; + hex += byte.toString(16); + } + return hex; +} + +// webpack://git/./src/utils/shasum.js +/* eslint-env node, browser */ + +let supportsSubtleSHA1 = null; + +export async function shasum(buffer) { + if (supportsSubtleSHA1 === null) { + supportsSubtleSHA1 = await testSubtleSHA1(); + } + return supportsSubtleSHA1 ? subtleSHA1(buffer) : shasumSync(buffer); +} + +// This is modeled after @dominictarr's "shasum" module, +// but without the 'json-stable-stringify' dependency and +// extra type-casting features. +function shasumSync(buffer) { + return new Hash().update(buffer).digest('hex'); +} + +async function subtleSHA1(buffer) { + const hash = await crypto.subtle.digest('SHA-1', buffer); + return toHex(hash); +} + +async function testSubtleSHA1() { + // I'm using a rather crude method of progressive enhancement, because + // some browsers that have crypto.subtle.digest don't actually implement SHA-1. + try { + const hash = await subtleSHA1(new Uint8Array([])); + return hash === 'da39a3ee5e6b4b0d3255bfef95601890afd80709'; + } catch (_) { + // no bother + } + return false; +} + +// webpack://git/./src/models/GitPackIndex.js +function decodeVarInt(reader) { + const bytes = []; + let byte = 0; + let multibyte = 0; + do { + byte = reader.readUInt8(); + // We keep bits 6543210 + const lastSeven = byte & 0b01111111; + bytes.push(lastSeven); + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + multibyte = byte & 0b10000000; + } while (multibyte); + // Now that all the bytes are in big-endian order, + // alternate shifting the bits left by 7 and OR-ing the next byte. + // And... do a weird increment-by-one thing that I don't quite understand. + return bytes.reduce((a, b) => ((a + 1) << 7) | b, -1); +} + +// I'm pretty much copying this one from the git C source code, +// because it makes no sense. +function otherVarIntDecode(reader, startWith) { + let result = startWith; + let shift = 4; + let byte = null; + do { + byte = reader.readUInt8(); + result |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + return result; +} + +export class GitPackIndex { + constructor(stuff) { + Object.assign(this, stuff); + this.offsetCache = {}; + } + + static async fromIdx({ idx, getExternalRefDelta }) { + const reader = new BufferCursor(idx); + const magic = reader.slice(4).toString('hex'); + // Check for IDX v2 magic number + if (magic !== 'ff744f63') { + return; // undefined + } + const version = reader.readUInt32BE(); + if (version !== 2) { + throw new InternalError( + `Unable to read version ${version} packfile IDX. (Only version 2 supported)` + ); + } + if (idx.byteLength > 2048 * 1024 * 1024) { + throw new InternalError( + `To keep implementation simple, I haven't implemented the layer 5 feature needed to support packfiles > 2GB in size.` + ); + } + // Skip over fanout table + reader.seek(reader.tell() + 4 * 255); + // Get hashes + const size = reader.readUInt32BE(); + const hashes = []; + for (let i = 0; i < size; i++) { + const hash = reader.slice(20).toString('hex'); + hashes[i] = hash; + } + reader.seek(reader.tell() + 4 * size); + // Skip over CRCs + // Get offsets + const offsets = new Map(); + for (let i = 0; i < size; i++) { + offsets.set(hashes[i], reader.readUInt32BE()); + } + const packfileSha = reader.slice(20).toString('hex'); + return new GitPackIndex({ + hashes, + crcs: {}, + offsets, + packfileSha, + getExternalRefDelta, + }); + } + + static async fromPack({ pack, getExternalRefDelta, onProgress }) { + const listpackTypes = { + 1: 'commit', + 2: 'tree', + 3: 'blob', + 4: 'tag', + 6: 'ofs-delta', + 7: 'ref-delta', + }; + const offsetToObject = {}; + + // Older packfiles do NOT use the shasum of the pack itself, + // so it is recommended to just use whatever bytes are in the trailer. + // Source: https://github.com/git/git/commit/1190a1acf800acdcfd7569f87ac1560e2d077414 + const packfileSha = pack.slice(-20).toString('hex'); + + const hashes = []; + const crcs = {}; + const offsets = new Map(); + let totalObjectCount = null; + let lastPercent = null; + + await listpack( + [pack], + async ({ data, type, reference, offset, num }) => { + if (totalObjectCount === null) totalObjectCount = num; + const percent = Math.floor( + ((totalObjectCount - num) * 100) / totalObjectCount + ); + if (percent !== lastPercent) { + if (onProgress) { + await onProgress({ + phase: 'Receiving objects', + loaded: totalObjectCount - num, + total: totalObjectCount, + }); + } + } + lastPercent = percent; + // Change type from a number to a meaningful string + type = listpackTypes[type]; + + if (['commit', 'tree', 'blob', 'tag'].includes(type)) { + offsetToObject[offset] = { + type, + offset, + }; + } else if (type === 'ofs-delta') { + offsetToObject[offset] = { + type, + offset, + }; + } else if (type === 'ref-delta') { + offsetToObject[offset] = { + type, + offset, + }; + } + } + ); + + // We need to know the lengths of the slices to compute the CRCs. + const offsetArray = Object.keys(offsetToObject).map(Number); + for (const [i, start] of offsetArray.entries()) { + const end = + i + 1 === offsetArray.length + ? pack.byteLength - 20 + : offsetArray[i + 1]; + const o = offsetToObject[start]; + const crc = crc32.buf(pack.slice(start, end)) >>> 0; + o.end = end; + o.crc = crc; + } + + // We don't have the hashes yet. But we can generate them using the .readSlice function! + const p = new GitPackIndex({ + pack: Promise.resolve(pack), + packfileSha, + crcs, + hashes, + offsets, + getExternalRefDelta, + }); + + // Resolve deltas and compute the oids + lastPercent = null; + let count = 0; + const objectsByDepth = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (let offset in offsetToObject) { + offset = Number(offset); + const percent = Math.floor((count * 100) / totalObjectCount); + if (percent !== lastPercent) { + if (onProgress) { + await onProgress({ + phase: 'Resolving deltas', + loaded: count, + total: totalObjectCount, + }); + } + } + count++; + lastPercent = percent; + + const o = offsetToObject[offset]; + if (o.oid) continue; + try { + p.readDepth = 0; + p.externalReadDepth = 0; + const { type, object } = await p.readSlice({ start: offset }); + objectsByDepth[p.readDepth] += 1; + const oid = await shasum(GitObject.wrap({ type, object })); + o.oid = oid; + hashes.push(oid); + offsets.set(oid, offset); + crcs[oid] = o.crc; + } catch (err) { + continue; + } + } + + hashes.sort(); + return p; + } + + async toBuffer() { + const buffers = []; + const write = (str, encoding) => { + buffers.push(Buffer.from(str, encoding)); + }; + // Write out IDX v2 magic number + write('ff744f63', 'hex'); + // Write out version number 2 + write('00000002', 'hex'); + // Write fanout table + const fanoutBuffer = new BufferCursor(Buffer.alloc(256 * 4)); + for (let i = 0; i < 256; i++) { + let count = 0; + for (const hash of this.hashes) { + if (parseInt(hash.slice(0, 2), 16) <= i) count++; + } + fanoutBuffer.writeUInt32BE(count); + } + buffers.push(fanoutBuffer.buffer); + // Write out hashes + for (const hash of this.hashes) { + write(hash, 'hex'); + } + // Write out crcs + const crcsBuffer = new BufferCursor( + Buffer.alloc(this.hashes.length * 4) + ); + for (const hash of this.hashes) { + crcsBuffer.writeUInt32BE(this.crcs[hash]); + } + buffers.push(crcsBuffer.buffer); + // Write out offsets + const offsetsBuffer = new BufferCursor( + Buffer.alloc(this.hashes.length * 4) + ); + for (const hash of this.hashes) { + offsetsBuffer.writeUInt32BE(this.offsets.get(hash)); + } + buffers.push(offsetsBuffer.buffer); + // Write out packfile checksum + write(this.packfileSha, 'hex'); + // Write out shasum + const totalBuffer = Buffer.concat(buffers); + const sha = await shasum(totalBuffer); + const shaBuffer = Buffer.alloc(20); + shaBuffer.write(sha, 'hex'); + return Buffer.concat([totalBuffer, shaBuffer]); + } + + async load({ pack }) { + this.pack = pack; + } + + async unload() { + this.pack = null; + } + + async read({ oid }) { + if (!this.offsets.get(oid)) { + if (this.getExternalRefDelta) { + this.externalReadDepth++; + return this.getExternalRefDelta(oid); + } else { + throw new InternalError( + `Could not read object ${oid} from packfile` + ); + } + } + const start = this.offsets.get(oid); + return this.readSlice({ start }); + } + + async readSlice({ start }) { + if (this.offsetCache[start]) { + return Object.assign({}, this.offsetCache[start]); + } + this.readDepth++; + const types = { + 0b0010000: 'commit', + 0b0100000: 'tree', + 0b0110000: 'blob', + 0b1000000: 'tag', + 0b1100000: 'ofs_delta', + 0b1110000: 'ref_delta', + }; + const pack = await this.pack; + if (!pack) { + throw new InternalError( + 'Could not read packfile data. The packfile may be missing, corrupted, or too large to read into memory.' + ); + } + const raw = pack.slice(start); + const reader = new BufferCursor(raw); + const byte = reader.readUInt8(); + // Object type is encoded in bits 654 + const btype = byte & 0b1110000; + let type = types[btype]; + if (type === undefined) { + throw new InternalError( + 'Unrecognized type: 0b' + btype.toString(2) + ); + } + // The length encoding get complicated. + // Last four bits of length is encoded in bits 3210 + const lastFour = byte & 0b1111; + let length = lastFour; + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + const multibyte = byte & 0b10000000; + if (multibyte) { + length = otherVarIntDecode(reader, lastFour); + } + let base = null; + let object = null; + // Handle deltified objects + if (type === 'ofs_delta') { + const offset = decodeVarInt(reader); + const baseOffset = start - offset; + ({ object: base, type } = await this.readSlice({ + start: baseOffset, + })); + } + if (type === 'ref_delta') { + const oid = reader.slice(20).toString('hex'); + ({ object: base, type } = await this.read({ oid })); + } + // Handle undeltified objects + const buffer = raw.slice(reader.tell()); + object = Buffer.from(await inflate(buffer)); + // Assert that the object length is as expected. + if (object.byteLength !== length) { + throw new InternalError( + `Packfile told us object would have length ${length} but it had length ${object.byteLength}` + ); + } + if (base) { + object = Buffer.from(applyDelta(object, base)); + } + // Cache the result based on depth. + if (this.readDepth > 3) { + // hand tuned for speed / memory usage tradeoff + this.offsetCache[start] = { type, object }; + } + return { type, format: 'content', object }; + } +} + +// webpack://git/./src/errors/InvalidOidError.js +export class InvalidOidError extends BaseError { + /** + * @param {string} value + */ + constructor(value) { + super(`Expected a 40-char hex object id but saw "${value}".`); + this.code = this.name = InvalidOidError.code; + this.data = { value }; + } +} +/** @type {'InvalidOidError'} */ +InvalidOidError.code = 'InvalidOidError'; + +// webpack://git/./src/utils/FIFO.js +export class FIFO { + constructor() { + this._queue = []; + } + + write(chunk) { + if (this._ended) { + throw Error( + 'You cannot write to a FIFO that has already been ended!' + ); + } + if (this._waiting) { + const resolve = this._waiting; + this._waiting = null; + resolve({ value: chunk }); + } else { + this._queue.push(chunk); + } + } + + end() { + this._ended = true; + if (this._waiting) { + const resolve = this._waiting; + this._waiting = null; + resolve({ done: true }); + } + } + + destroy(err) { + this.error = err; + this.end(); + } + + async next() { + if (this._queue.length > 0) { + return { value: this._queue.shift() }; + } + if (this._ended) { + return { done: true }; + } + if (this._waiting) { + throw Error( + 'You cannot call read until the previous call to read has returned!' + ); + } + return new Promise((resolve) => { + this._waiting = resolve; + }); + } +} + +// webpack://git/./src/models/GitSideBand.js +/* +If 'side-band' or 'side-band-64k' capabilities have been specified by +the client, the server will send the packfile data multiplexed. + +Each packet starting with the packet-line length of the amount of data +that follows, followed by a single byte specifying the sideband the +following data is coming in on. + +In 'side-band' mode, it will send up to 999 data bytes plus 1 control +code, for a total of up to 1000 bytes in a pkt-line. In 'side-band-64k' +mode it will send up to 65519 data bytes plus 1 control code, for a +total of up to 65520 bytes in a pkt-line. + +The sideband byte will be a '1', '2' or a '3'. Sideband '1' will contain +packfile data, sideband '2' will be used for progress information that the +client will generally print to stderr and sideband '3' is used for error +information. + +If no 'side-band' capability was specified, the server will stream the +entire packfile without multiplexing. +*/ + +export class GitSideBand { + static demux(input) { + const read = GitPktLine.streamReader(input); + // And now for the ridiculous side-band or side-band-64k protocol + const packetlines = new FIFO(); + const packfile = new FIFO(); + const progress = new FIFO(); + // TODO: Use a proper through stream? + const nextBit = async function () { + const line = await read(); + // Skip over flush packets + if (line === null) return nextBit(); + // A made up convention to signal there's no more to read. + if (line === true) { + packetlines.end(); + progress.end(); + input.error ? packfile.destroy(input.error) : packfile.end(); + return; + } + // Examine first byte to determine which output "stream" to use + switch (line[0]) { + case 1: { + // pack data + packfile.write(line.slice(1)); + break; + } + case 2: { + // progress message + progress.write(line.slice(1)); + break; + } + case 3: { + // fatal error message just before stream aborts + const error = line.slice(1); + progress.write(error); + packetlines.end(); + progress.end(); + packfile.destroy(new Error(error.toString('utf8'))); + return; + } + default: { + // Not part of the side-band-64k protocol + packetlines.write(line); + } + } + // Careful not to blow up the stack. + // I think Promises in a tail-call position should be OK. + nextBit(); + }; + nextBit(); + return { + packetlines, + packfile, + progress, + }; + } + // static mux ({ + // protocol, // 'side-band' or 'side-band-64k' + // packetlines, + // packfile, + // progress, + // error + // }) { + // const MAX_PACKET_LENGTH = protocol === 'side-band-64k' ? 999 : 65519 + // let output = new PassThrough() + // packetlines.on('data', data => { + // if (data === null) { + // output.write(GitPktLine.flush()) + // } else { + // output.write(GitPktLine.encode(data)) + // } + // }) + // let packfileWasEmpty = true + // let packfileEnded = false + // let progressEnded = false + // let errorEnded = false + // let goodbye = Buffer.concat([ + // GitPktLine.encode(Buffer.from('010A', 'hex')), + // GitPktLine.flush() + // ]) + // packfile + // .on('data', data => { + // packfileWasEmpty = false + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('01', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // packfileEnded = true + // if (!packfileWasEmpty) output.write(goodbye) + // if (progressEnded && errorEnded) output.end() + // }) + // progress + // .on('data', data => { + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('02', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // progressEnded = true + // if (packfileEnded && errorEnded) output.end() + // }) + // error + // .on('data', data => { + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('03', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // errorEnded = true + // if (progressEnded && packfileEnded) output.end() + // }) + // return output + // } +} + +// webpack://git/./src/utils/forAwait.js +// Currently 'for await' upsets my linters. +export async function forAwait(iterable, cb) { + const iter = getIterator(iterable); + while (true) { + const { value, done } = await iter.next(); + if (value) await cb(value); + if (done) break; + } + if (iter.return) iter.return(); +} + +// webpack://git/./src/wire/parseUploadPackResponse.js +export async function parseUploadPackResponse(stream) { + const { packetlines, packfile, progress } = GitSideBand.demux(stream); + const shallows = []; + const unshallows = []; + const acks = []; + let nak = false; + let done = false; + return new Promise((resolve, reject) => { + // Parse the response + forAwait(packetlines, (data) => { + const line = data.toString('utf8').trim(); + if (line.startsWith('shallow')) { + const oid = line.slice(-41).trim(); + if (oid.length !== 40) { + reject(new InvalidOidError(oid)); + } + shallows.push(oid); + } else if (line.startsWith('unshallow')) { + const oid = line.slice(-41).trim(); + if (oid.length !== 40) { + reject(new InvalidOidError(oid)); + } + unshallows.push(oid); + } else if (line.startsWith('ACK')) { + const [, oid, status] = line.split(' '); + acks.push({ oid, status }); + if (!status) done = true; + } else if (line.startsWith('NAK')) { + nak = true; + done = true; + } else { + done = true; + nak = true; + } + if (done) { + stream.error + ? reject(stream.error) + : resolve({ + shallows, + unshallows, + acks, + nak, + packfile, + progress, + }); + } + }).finally(() => { + if (!done) { + stream.error + ? reject(stream.error) + : resolve({ + shallows, + unshallows, + acks, + nak, + packfile, + progress, + }); + } + }); + }); +} + +// webpack://git/./src/errors/ObjectTypeError.js +export class ObjectTypeError extends BaseError { + /** + * @param {string} oid + * @param {'blob'|'commit'|'tag'|'tree'} actual + * @param {'blob'|'commit'|'tag'|'tree'} expected + * @param {string} [filepath] + */ + constructor(oid, actual, expected, filepath) { + super( + `Object ${oid} ${ + filepath ? `at ${filepath}` : '' + }was anticipated to be a ${expected} but it is a ${actual}.` + ); + this.code = this.name = ObjectTypeError.code; + this.data = { oid, actual, expected, filepath }; + } +} +/** @type {'ObjectTypeError'} */ +ObjectTypeError.code = 'ObjectTypeError'; + +// webpack://git/./src/utils/collect.js +export async function collect(iterable) { + let size = 0; + const buffers = []; + // This will be easier once `for await ... of` loops are available. + await forAwait(iterable, (value) => { + buffers.push(value); + size += value.byteLength; + }); + const result = new Uint8Array(size); + let nextIndex = 0; + for (const buffer of buffers) { + result.set(buffer, nextIndex); + nextIndex += buffer.byteLength; + } + return result; +} diff --git a/packages/playground/storage/src/lib/isomorphic-git.d.ts b/packages/playground/storage/src/lib/isomorphic-git.d.ts deleted file mode 100644 index f8c0857a816..00000000000 --- a/packages/playground/storage/src/lib/isomorphic-git.d.ts +++ /dev/null @@ -1,127 +0,0 @@ -declare module 'isomorphic-git/src/models/GitIndex.js' { - export class GitIndex { - constructor(entries?: Map, unmergedPaths?: Set); - insert(entry: { - filepath: string; - oid: string; - stats: { - ctimeSeconds: number; - ctimeNanoseconds: number; - mtimeSeconds: number; - mtimeNanoseconds: number; - dev: number; - ino: number; - mode: number; - uid: number; - gid: number; - size: number; - }; - }): void; - toObject(): Promise; - } -} - -declare module 'isomorphic-git/src/models/GitPktLine.js' { - export class GitPktLine { - static encode(data: string): Buffer; - static decode(data: Buffer): string; - static flush(): Buffer; - static delim(): Buffer; - } -} - -declare module 'isomorphic-git/src/models/GitTree.js' { - export class GitTree { - static from(buffer: Buffer): GitTree; - type: 'tree' | 'blob'; - oid: string; - format: 'content'; - object: Array<{ - mode: string; - path: string; - oid: string; - type?: 'blob' | 'tree'; - object?: GitTree; - }>; - } -} - -declare module 'isomorphic-git/src/models/GitAnnotatedTag.js' { - export class GitAnnotatedTag { - static from(buffer: Buffer): GitAnnotatedTag; - parse(): { - object: { - object: GitTree; - }; - type: string; - tag: string; - tagger: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - message: string; - signature?: string; - }; - } -} - -declare module 'isomorphic-git/src/models/GitCommit.js' { - export class GitCommit { - static from(buffer: Buffer): GitCommit; - parse(): { - tree: string; - parent: string[]; - author: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - committer: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - message: string; - gpgsig?: string; - }; - } -} - -declare module 'isomorphic-git/src/models/GitPackIndex.js' { - export class GitPackIndex { - static fromPack({ pack }: { pack: Buffer }): Promise; - read({ oid }: { oid: string }): Promise; - toBuffer(): Promise; - packfileSha: string; - hashes?: string[]; - offsets: Map; - readSlice({ start }: { start: number }): Promise<{ - type: - | 'blob' - | 'tree' - | 'commit' - | 'tag' - | 'ofs_delta' - | 'ref_delta'; - object?: Buffer | Uint8Array; - }>; - } -} - -declare module 'isomorphic-git/src/internal-apis.js' { - export function collect(data: any[]): Promise; -} - -declare module 'isomorphic-git/src/wire/parseUploadPackResponse.js' { - export function parseUploadPackResponse(data: Buffer): any; // Replace 'any' with a more specific type if known -} - -declare module 'isomorphic-git/src/errors/ObjectTypeError.js' { - export class ObjectTypeError extends Error { - constructor(message: string, expected: string, actual: string); - } -} diff --git a/packages/playground/website/playwright/e2e/blueprints.spec.ts b/packages/playground/website/playwright/e2e/blueprints.spec.ts index ee1696c40d5..a6ef14a7649 100644 --- a/packages/playground/website/playwright/e2e/blueprints.spec.ts +++ b/packages/playground/website/playwright/e2e/blueprints.spec.ts @@ -114,12 +114,13 @@ test('?blueprint-url=... should work with simple blueprints', async ({ browserName === 'webkit', 'This test is flaky in WebKit. It seems like a GitHub CI issue rather than an actual flakiness since it is reliable locally.' ); - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint-simple.json` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint-simple.json', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -130,11 +131,11 @@ test('?blueprint-url=... should accept data URLs', async ({ website, wordpress, }) => { - await website.goto('/'); + await website.goto('./?storage=temp'); const blueprintUrl = encodeURIComponent( `data:application/json;base64,eyJsYW5kaW5nUGFnZSI6Ii9weWdtYWxpb24udHh0Iiwic3RlcHMiOlt7InN0ZXAiOiJ3cml0ZUZpbGUiLCJwYXRoIjoiL3dvcmRwcmVzcy9weWdtYWxpb24udHh0IiwiZGF0YSI6IlBSRUZBQ0UgVE8gUFlHTUFMSU9OIn1dfQ==` ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -145,12 +146,13 @@ test('?blueprint-url=... should work with ZIP bundles', async ({ website, wordpress, }) => { - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint.zip` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint.zip', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -161,12 +163,13 @@ test('?blueprint-url=... should work with JSON blueprints referring bundled reso website, wordpress, }) => { - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint-with-bundled-resources.json` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint-with-bundled-resources.json', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); diff --git a/packages/playground/website/playwright/e2e/client-side-media.spec.ts b/packages/playground/website/playwright/e2e/client-side-media.spec.ts index 7707da67447..5410e9a3797 100644 --- a/packages/playground/website/playwright/e2e/client-side-media.spec.ts +++ b/packages/playground/website/playwright/e2e/client-side-media.spec.ts @@ -68,7 +68,9 @@ test('Post editor should be cross-origin isolated with SharedArrayBuffer availab website, wordpress, }) => { - await website.goto(`./#${JSON.stringify(clientSideMediaBlueprint)}`); + await website.goto( + `./?storage=temp#${JSON.stringify(clientSideMediaBlueprint)}` + ); // Wait for the block editor to fully load. The editor header is visible in both // fullscreen and non-fullscreen modes. @@ -96,7 +98,9 @@ test('Gutenberg should report client-side media processing as enabled', async ({ website, wordpress, }) => { - await website.goto(`./#${JSON.stringify(clientSideMediaBlueprint)}`); + await website.goto( + `./?storage=temp#${JSON.stringify(clientSideMediaBlueprint)}` + ); await expect( wordpress.locator('.edit-post-header, .editor-header') diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts b/packages/playground/website/playwright/e2e/deployment.spec.ts index aff7b4dc172..8349e4417d6 100644 --- a/packages/playground/website/playwright/e2e/deployment.spec.ts +++ b/packages/playground/website/playwright/e2e/deployment.spec.ts @@ -15,6 +15,7 @@ const url = new URL(`http://localhost:${port}`); // disable auto-login, the old Playground build encounters // a boot error. url.searchParams.set('login', 'no'); +url.searchParams.set('storage', 'temp'); // Specify the theme so we can assert against expected default content. // This theme is also what the reference screenshots are based on. url.searchParams.set('theme', 'twentytwentyfour'); diff --git a/packages/playground/website/playwright/e2e/error-handling.spec.ts b/packages/playground/website/playwright/e2e/error-handling.spec.ts index a490dc06a1d..b376f4233cc 100644 --- a/packages/playground/website/playwright/e2e/error-handling.spec.ts +++ b/packages/playground/website/playwright/e2e/error-handling.spec.ts @@ -44,7 +44,7 @@ test('should show download error modal when a resource download fails', async ({ // fetches the zip from downloads.wordpress.org via the CORS // proxy, triggering the resource-download-failed error through // the normal pipeline. - await page.goto('./?plugin=hello-dolly'); + await page.goto('./?storage=temp&plugin=hello-dolly'); const title = page.getByText('Could not download required files'); await expect(title).toBeVisible(); diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index ee187e372b1..92ebcbefb14 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -77,12 +77,13 @@ test('should switch between sites', async ({ website, browserName }) => { `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Save the temporary site using the modal - await saveSiteViaModal(website.page); + const firstSiteName = 'Switching Test Site'; + await saveSiteViaModal(website.page, { customName: firstSiteName }); await expect(website.page.getByLabel('Playground title')).not.toContainText( 'Unsaved Playground', @@ -91,19 +92,46 @@ test('should switch between sites', async ({ website, browserName }) => { timeout: 90000, } ); + await expect(website.page.getByLabel('Playground title')).toContainText( + firstSiteName + ); // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Click on Temporary Playground in the overlay's site list + // Start another saved Playground, then switch back to the first one. + await website.page.getByRole('button', { name: 'New Playground' }).click(); + await website.waitForNestedIframes(); + await website.ensureSiteManagerIsOpen(); + + await expect(website.page.getByLabel('Playground title')).not.toContainText( + firstSiteName + ); + await expect( + website.page.getByText('Autosaved in this browser') + ).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => + website.page.evaluate(() => { + const activeSite = (window as any).playgroundSites + .list() + .find((site: any) => site.isActive); + return activeSite + ? `${activeSite.storage}:${activeSite.persistence}` + : null; + }) + ) + .toBe('opfs:autosave'); + + await website.openSavedPlaygroundsOverlay(); await website.page .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }) + .filter({ hasText: firstSiteName }) .click(); + await website.ensureSiteManagerIsOpen(); - // The overlay closes and site manager opens with the selected site await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground' + firstSiteName ); }); @@ -129,7 +157,7 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await website.ensureSiteManagerIsOpen(); @@ -153,11 +181,9 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Switch to Temporary Playground - await website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }) - .click(); + // Create another Playground, then switch back. + await website.page.getByRole('button', { name: 'New Playground' }).click(); + await website.waitForNestedIframes(); // Open the overlay again to switch back to the stored site await website.openSavedPlaygroundsOverlay(); @@ -180,7 +206,7 @@ test('should rename a saved Playground and persist after reload', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Save the temporary site to OPFS so rename is available @@ -238,7 +264,7 @@ test('should show save site modal with correct elements', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Click the Save button in the site manager panel @@ -282,7 +308,7 @@ test('should close save site modal without saving', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the modal @@ -328,7 +354,7 @@ test('should have playground name input text selected by default', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the modal @@ -363,7 +389,7 @@ test('should save site with custom name', async ({ website, browserName }) => { `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); const customName = 'My Custom Playground Name'; @@ -396,7 +422,7 @@ test('should not persist save site modal through page refresh', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the save modal @@ -433,7 +459,7 @@ test('should display OPFS storage option as selected by default', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the save modal @@ -455,7 +481,7 @@ test('should display OPFS storage option as selected by default', async ({ await dialog.getByRole('button', { name: 'Cancel' }).click(); }); -test('should import ZIP into temporary site when a saved site exists', async ({ +test('should import ZIP into a new saved site when a saved site exists', async ({ website, wordpress, browserName, @@ -477,7 +503,7 @@ test('should import ZIP into temporary site when a saved site exists', async ({ }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); // Verify the marker is present await expect(wordpress.locator('body')).toContainText(savedSiteMarker); @@ -518,12 +544,14 @@ test('should import ZIP into temporary site when a saved site exists', async ({ buffer: zipBuffer, }); - // The import should switch us to a temporary playground. - // Wait for the site title to show "Temporary Playground" - await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground', + // The import should switch us to a new saved Playground by default. + await expect(website.page.getByLabel('Playground title')).not.toContainText( + savedSiteName, { timeout: 30000 } ); + await expect(website.page.getByLabel('Playground title')).not.toContainText( + 'Unsaved Playground' + ); // Now verify the saved site still has the original content. // Open the saved playgrounds overlay and switch to the saved site @@ -533,16 +561,17 @@ test('should import ZIP into temporary site when a saved site exists', async ({ .locator('[class*="siteRowContent"]') .filter({ hasText: savedSiteName }) .click(); + await website.ensureSiteManagerIsOpen(); // Wait for the saved site to load - this verifies the saved site wasn't overwritten - // by the ZIP import (which went to a temporary site instead) + // by the ZIP import (which went to a new saved site instead) await expect(website.page.getByLabel('Playground title')).toContainText( savedSiteName, { timeout: 30000 } ); }); -test('should create temporary site when importing ZIP while on a saved site with no existing temporary site', async ({ +test('should create a saved site when importing ZIP while on a saved site with no existing temporary site', async ({ website, wordpress, browserName, @@ -564,7 +593,7 @@ test('should create temporary site when importing ZIP while on a saved site with }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await expect(wordpress.locator('body')).toContainText(savedSiteMarker); await website.ensureSiteManagerIsOpen(); @@ -598,14 +627,10 @@ test('should create temporary site when importing ZIP while on a saved site with // Open the saved playgrounds overlay await website.openSavedPlaygroundsOverlay(); - // Verify there's no "Temporary Playground" in the list initially - // (the temporary site row should show but clicking it would create one) - const tempPlaygroundRow = website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }); - - // The row exists but it's for creating a new temporary playground - await expect(tempPlaygroundRow).toBeVisible(); + const importZipButton = website.page.getByRole('button', { + name: 'Import a .zip', + }); + await expect(importZipButton).toBeVisible(); // Create a test ZIP const importedMarker = 'FRESH_IMPORT_MARKER_BBBBB'; @@ -628,12 +653,14 @@ test('should create temporary site when importing ZIP while on a saved site with buffer: zipBuffer, }); - // The import should trigger creation of a new temporary site. - // Wait for the site title to show "Temporary Playground" - await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground', + // The import should trigger creation of a new saved site by default. + await expect(website.page.getByLabel('Playground title')).not.toContainText( + savedSiteName, { timeout: 30000 } ); + await expect(website.page.getByLabel('Playground title')).not.toContainText( + 'Unsaved Playground' + ); // Verify the saved site is still intact by switching to it await website.openSavedPlaygroundsOverlay(); @@ -642,9 +669,10 @@ test('should create temporary site when importing ZIP while on a saved site with .locator('[class*="siteRowContent"]') .filter({ hasText: savedSiteName }) .click(); + await website.ensureSiteManagerIsOpen(); // Wait for the saved site to load - this verifies the saved site wasn't overwritten - // by the ZIP import (which went to a temporary site instead) + // by the ZIP import (which went to a new saved site instead) await expect(website.page.getByLabel('Playground title')).toContainText( savedSiteName, { timeout: 30000 } @@ -672,7 +700,7 @@ test.describe('Missing site modal', () => { // Use a unique slug that definitely doesn't exist const uniqueSlug = `missing-modal-test-${Date.now()}`; - await website.goto(`./?site-slug=${uniqueSlug}`); + await website.goto(`./?site-slug=${uniqueSlug}&storage=temp`); // The modal should appear early, even before WordPress fully loads await expect( @@ -697,7 +725,7 @@ test.describe('Missing site modal', () => { await context.clearCookies(); const uniqueSlug = `dismiss-modal-test-${Date.now()}`; - await website.goto(`./?site-slug=${uniqueSlug}`); + await website.goto(`./?site-slug=${uniqueSlug}&storage=temp`); // Wait for modal const dialog = website.page.getByRole('dialog', { diff --git a/packages/playground/website/playwright/e2e/query-api.spec.ts b/packages/playground/website/playwright/e2e/query-api.spec.ts index 56a574889cb..3d3a9dbd5a4 100644 --- a/packages/playground/website/playwright/e2e/query-api.spec.ts +++ b/packages/playground/website/playwright/e2e/query-api.spec.ts @@ -17,7 +17,7 @@ const LatestSupportedWordPressVersion = Object.keys( test('should load PHP 8.3 by default', async ({ website, wordpress }) => { // Navigate to the website - await website.goto('./?url=/phpinfo.php'); + await website.goto('./?storage=temp&url=/phpinfo.php'); await expect(wordpress.locator('h1.p').first()).toContainText( 'PHP Version 8.3' ); @@ -157,7 +157,7 @@ test('should load WordPress latest by default', async ({ website, wordpress, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); const expectedBodyClass = 'branch-' + LatestSupportedWordPressVersion.replace('.', '-'); @@ -170,7 +170,7 @@ test('should load WordPress 6.3 when requested', async ({ website, wordpress, }) => { - await website.goto('./?wp=6.3&url=/wp-admin/'); + await website.goto('./?storage=temp&wp=6.3&url=/wp-admin/'); await expect(wordpress.locator(`body.branch-6-3`)).toContainText( 'Dashboard' ); @@ -180,7 +180,9 @@ test('should disable networking when requested', async ({ website, wordpress, }) => { - await website.goto('./?networking=no&url=/wp-admin/plugin-install.php'); + await website.goto( + './?storage=temp&networking=no&url=/wp-admin/plugin-install.php' + ); await expect(wordpress.locator('.notice.error')).toContainText( 'Network access is an experimental, opt-in feature' ); @@ -190,12 +192,16 @@ test('should enable networking when requested', async ({ website, wordpress, }) => { - await website.goto('./?networking=yes&url=/wp-admin/plugin-install.php'); + await website.goto( + './?storage=temp&networking=yes&url=/wp-admin/plugin-install.php' + ); await expect(wordpress.locator('body')).toContainText('Install Now'); }); test('should install the specified plugin', async ({ website, wordpress }) => { - await website.goto('./?plugin=gutenberg&url=/wp-admin/plugins.php'); + await website.goto( + './?storage=temp&plugin=gutenberg&url=/wp-admin/plugins.php' + ); await expect(wordpress.locator('#deactivate-gutenberg')).toContainText( 'Deactivate' ); @@ -205,7 +211,7 @@ test('should login the user in by default if no login query parameter is provide website, wordpress, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Dashboard'); }); @@ -213,7 +219,7 @@ test('should login the user in if the login query parameter is set to yes', asyn website, wordpress, }) => { - await website.goto('./?login=yes&url=/wp-admin/'); + await website.goto('./?storage=temp&login=yes&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Dashboard'); }); @@ -221,7 +227,7 @@ test('should not login the user in if the login query parameter is set to no', a website, wordpress, }) => { - await website.goto('./?login=no&url=/wp-admin/'); + await website.goto('./?storage=temp&login=no&url=/wp-admin/'); await expect(wordpress.locator('input[type="submit"]')).toContainText( 'Log In' ); @@ -232,7 +238,7 @@ test('should not login the user in if the login query parameter is set to no', a ['/wp-admin/post.php?post=1&action=edit', 'should redirect to post editor'], ].forEach(([path, description]) => { test(description, async ({ website, wordpress }) => { - await website.goto(`./?url=${encodeURIComponent(path)}`); + await website.goto(`./?storage=temp&url=${encodeURIComponent(path)}`); expect( await wordpress .locator('body') @@ -251,7 +257,7 @@ test('should translate WP-admin to Spanish using the language query parameter', `It's unclear why this test fails on Safari. The root cause of the failure is unknown as the feature ` + `seems to be working in manual testing.` ); - await website.goto('./?language=es_ES&url=/wp-admin/'); + await website.goto('./?storage=temp&language=es_ES&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Escritorio'); }); @@ -318,7 +324,7 @@ test('should retain encoded control characters in the URL', async ({ // most wp-admin pages enforce a redirect to a sanitized (broken) // version of the URL. await website.goto( - `./?url=${encodeURIComponent( + `./?storage=temp&url=${encodeURIComponent( path )}&plugin=html-api-debugger#${JSON.stringify(blueprint)}` ); @@ -398,6 +404,7 @@ async function gotoPHPOnlyPlayground( ) { const query = new URLSearchParams({ php: '8.3', + storage: 'temp', ...queryParams, }); const blueprint: Blueprint = { diff --git a/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts b/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts index 5220e3eb76a..3eadf4f74aa 100644 --- a/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts +++ b/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts @@ -72,7 +72,7 @@ add_action( 'shutdown', function() { // Navigate without waiting for nested iframes. We only need the boot // process to run prefetchUpdateChecks(); we don't need wp-admin to render. await website.page.goto( - `./?networking=yes&url=/wp-admin/#${JSON.stringify(blueprint)}` + `./?storage=temp&networking=yes&url=/wp-admin/#${JSON.stringify(blueprint)}` ); // Wait for the playground client to be available (set before prefetch runs). @@ -114,7 +114,8 @@ echo (string) get_option('${optName}', ''); expect(parsed.kind).toBe('wp_error'); if (parsed.kind === 'wp_error') { expect(parsed.code).toBe('http_request_block'); - expect(parsed.message).toBe('Loopback requests are not to be pre-fetched'); + expect(parsed.message).toBe( + 'Loopback requests are not to be pre-fetched' + ); } }); - diff --git a/packages/playground/website/playwright/e2e/sites-api.spec.ts b/packages/playground/website/playwright/e2e/sites-api.spec.ts index d28a15b6a77..c22e134d774 100644 --- a/packages/playground/website/playwright/e2e/sites-api.spec.ts +++ b/packages/playground/website/playwright/e2e/sites-api.spec.ts @@ -20,7 +20,7 @@ test('playgroundSites.list() returns the active site', async ({ website }) => { const active = sites.find((s: any) => s.isActive); expect(active).toBeTruthy(); expect(active.slug).toBeTruthy(); - expect(active.storage).toBe('temporary'); + expect(['opfs', 'local-fs', 'temporary']).toContain(active.storage); }); test('playgroundSites.saveInBrowser() persists a temporary site', async ({ @@ -32,7 +32,7 @@ test('playgroundSites.saveInBrowser() persists a temporary site', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.page.waitForFunction(() => Boolean((window as any).playgroundSites?.getClient()) ); @@ -60,7 +60,6 @@ test('playgroundSites.rename() renames a saved site', async ({ 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(); diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index df4c3df7aa8..a33354db4f9 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../playground-fixtures.ts'; import type { Blueprint } from '@wp-playground/blueprints'; +import type { Page } from '@playwright/test'; // We can't import the SupportedPHPVersions versions directly from the remote package // because of ESModules vs CommonJS incompatibilities. Let's just import the @@ -9,10 +10,61 @@ import { SupportedPHPVersions } from '../../../../php-wasm/universal/src/lib/sup // eslint-disable-next-line @nx/enforce-module-boundaries import * as MinifiedWordPressVersions from '../../../wordpress-builds/src/wordpress/wp-versions.json'; +function getUniqueSavedPlaygroundSetupUrl( + label: string, + params: Record = {} +) { + const searchParams = new URLSearchParams({ + name: `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ...params, + }); + return `./?${searchParams}`; +} + +async function runPHPAndFlushOpfs(page: Page, code: string) { + await expect + .poll( + () => + page.evaluate(async (phpCode: string) => { + try { + const playground = (window as any).playground; + await playground.run({ code: phpCode }); + await playground.flushOpfs('/wordpress'); + return 'ok'; + } catch (error) { + return String( + error instanceof Error ? error.message : error + ); + } + }, code), + { timeout: 120000 } + ) + .toBe('ok'); +} + +function updateBlogNameCode(blogName: string) { + return ` + (window as any).playgroundSites + .list() + .find((site: any) => site.isActive) + ); +} + +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + test('should reflect the URL update from the navigation bar in the WordPress site', async ({ website, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); await website.ensureSiteManagerIsClosed(); await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( '/wp-admin/' @@ -27,7 +79,7 @@ test('should correctly load /wp-admin without the trailing slash', async ({ browserName === 'webkit', 'This test is flaky in WebKit. It seems like a GitHub CI issue rather than an actual flakiness since it is reliable locally.' ); - await website.goto('./?url=/wp-admin'); + await website.goto('./?storage=temp&url=/wp-admin'); await website.ensureSiteManagerIsClosed(); await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( '/wp-admin/' @@ -36,7 +88,7 @@ test('should correctly load /wp-admin without the trailing slash', async ({ SupportedPHPVersions.forEach(async (version) => { test(`should switch PHP version to ${version}`, async ({ website }) => { - await website.goto(`./`); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('PHP version').selectOption(version); await website.page @@ -58,7 +110,7 @@ Object.keys(MinifiedWordPressVersions) test(`should switch WordPress version to ${version}`, async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page .getByLabel('WordPress version') @@ -76,7 +128,7 @@ Object.keys(MinifiedWordPressVersions) }); test('should display networking as active by default', async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await expect(website.page.getByLabel('Network access')).toBeChecked(); }); @@ -84,13 +136,13 @@ test('should display networking as active by default', async ({ website }) => { test('should display networking as active when networking is enabled', async ({ website, }) => { - await website.goto('./?networking=yes'); + await website.goto('./?storage=temp&networking=yes'); await website.ensureSiteManagerIsOpen(); await expect(website.page.getByLabel('Network access')).toBeChecked(); }); test('should enable networking when requested', async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('Network access').check(); @@ -102,7 +154,7 @@ test('should enable networking when requested', async ({ website }) => { }); test('should disable networking when requested', async ({ website }) => { - await website.goto('./?networking=yes'); + await website.goto('./?storage=temp&networking=yes'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('Network access').uncheck(); @@ -128,7 +180,7 @@ test('should display PHP output even when a fatal error is hit', async ({ }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await expect(wordpress.locator('body')).toContainText( 'This is a fatal error' @@ -139,9 +191,16 @@ test('should keep query arguments when updating settings', async ({ website, wordpress, }) => { - await website.goto('./?url=/wp-admin/&php=8.0&wp=6.6'); + await website.goto( + './?storage=temp&url=/wp-admin/&php=8.0&wp=6.6&networking=no' + ); - expect(website.page.url()).toContain('?url=%2Fwp-admin%2F&php=8.0&wp=6.6'); + const initialParams = new URL(website.page.url()).searchParams; + expect(initialParams.get('storage')).toBe('temp'); + expect(initialParams.get('url')).toBe('/wp-admin/'); + expect(initialParams.get('php')).toBe('8.0'); + expect(initialParams.get('wp')).toBe('6.6'); + expect(initialParams.get('networking')).toBe('no'); expect( await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); @@ -151,9 +210,12 @@ test('should keep query arguments when updating settings', async ({ await website.page.getByText('Apply Settings & Reset Playground').click(); await website.waitForNestedIframes(); - expect(website.page.url()).toMatch( - '?url=%2Fwp-admin%2F&php=8.0&wp=6.6&networking=yes' - ); + const updatedParams = new URL(website.page.url()).searchParams; + expect(updatedParams.get('storage')).toBe('temp'); + expect(updatedParams.get('url')).toBe('/wp-admin/'); + expect(updatedParams.get('php')).toBe('8.0'); + expect(updatedParams.get('wp')).toBe('6.6'); + expect(updatedParams.get('networking')).toBe('yes'); expect( await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); @@ -163,7 +225,7 @@ test('should edit a file in the code editor and see changes in the viewport', as website, wordpress, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -243,7 +305,7 @@ test('should edit a blueprint in the blueprint editor and recreate the playgroun website, wordpress, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -331,7 +393,7 @@ test('should copy blueprint link to clipboard when share button is clicked', asy // Grant clipboard permissions await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -377,12 +439,13 @@ test('should copy blueprint link to clipboard when share button is clicked', asy Uint8Array.from(atob(base64Part), (c) => c.charCodeAt(0)) ) ); - expect(decodedBlueprint).toHaveProperty('landingPage'); + expect(decodedBlueprint).toHaveProperty('steps'); + expect(Array.isArray(decodedBlueprint.steps)).toBe(true); }); test.describe('Database panel', () => { test.beforeEach(async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Navigate to Database tab @@ -573,7 +636,7 @@ test.describe('Database panel', () => { .getByRole('link', { name: 'SQL' }) .click(); await newPage.waitForLoadState(); - await newPage.locator('.CodeMirror').click(); + await newPage.locator('.CodeMirror.cm-s-default').click(); await newPage.keyboard.type('SHOW TABLES'); await newPage.getByRole('button', { name: 'Go' }).click(); await newPage.waitForLoadState(); @@ -583,23 +646,638 @@ test.describe('Database panel', () => { }); }); -// Test saving playgrounds by default and when the "can-save" URL parameter is set to "no". -test.describe('Save Status Indicator', () => { - test('should show "Unsaved Playground" status for temporary playgrounds', async ({ +// Test browser-saved Playgrounds by default and explicit temporary opt-outs. +test.describe('Default Playground storage', () => { + test.describe.configure({ mode: 'serial' }); + + test('should create and finish autosaving a Playground from the root URL', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.page.addInitScript(() => { + (window as any).__saveStatusSamples = []; + let installed = false; + const sampleStatus = () => { + const statusButton = [ + ...document.querySelectorAll('[role="status"], button'), + ].find((node) => { + const label = (node.textContent || '').trim(); + return ( + label === 'Autosaving' || + label === 'Saving' || + label === 'Autosaved' || + label === 'Saved Playground' || + label === 'Unsaved' + ); + }); + if (!statusButton) { + return; + } + (window as any).__saveStatusSamples.push({ + text: (statusButton.textContent || '').trim(), + ariaLabel: statusButton.getAttribute('aria-label'), + color: getComputedStyle(statusButton).color, + }); + }; + const installObserver = () => { + if (installed) { + return; + } + if (!document.documentElement) { + requestAnimationFrame(installObserver); + return; + } + installed = true; + new MutationObserver(sampleStatus).observe( + document.documentElement, + { + attributes: true, + characterData: true, + childList: true, + subtree: true, + } + ); + window.setInterval(sampleStatus, 25); + sampleStatus(); + }; + installObserver(); + }); + await website.page.goto('./'); + await expect( + website.page.getByRole('button', { name: /Site Manager/ }) + ).toBeVisible(); + await website.ensureSiteManagerIsClosed(); + + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ + timeout: 120000, + }); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + await expect( + website.page.getByText(/Autosaving|Finalizing autosave/) + ).toHaveCount(0); + await expect( + website.page.getByRole('button', { name: 'Unsaved' }) + ).toHaveCount(0); + const saveStatusSamples = await website.page.evaluate(() => + ((window as any).__saveStatusSamples || []).filter( + ( + sample: { + text: string; + ariaLabel: string | null; + color: string; + }, + index: number, + all: { + text: string; + ariaLabel: string | null; + color: string; + }[] + ) => { + const previous = all[index - 1]; + return ( + !previous || + previous.text !== sample.text || + previous.ariaLabel !== sample.ariaLabel || + previous.color !== sample.color + ); + } + ) + ); + const autosavingIndex = saveStatusSamples.findIndex( + ({ text }) => text === 'Autosaving' + ); + const autosavedIndex = saveStatusSamples.findIndex( + ({ text }) => text === 'Autosaved' + ); + expect(autosavingIndex).toBeGreaterThan(-1); + expect(autosavedIndex).toBeGreaterThan(autosavingIndex); + expect( + saveStatusSamples.some(({ ariaLabel }) => + /^Autosaving [1-9]\d*%$/.test(ariaLabel ?? '') + ) + ).toBe(true); + }); + + test('should show intent-driven creation actions in the overlay', async ({ website, + browserName, }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto( + getUniqueSavedPlaygroundSetupUrl('creation-actions') + ); + const siteSlugBeforeGitHubImport = new URL( + website.page.url() + ).searchParams.get('site-slug'); + await website.openSavedPlaygroundsOverlay(); + await expect( + website.page.getByRole('button', { name: 'New Playground' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Preview a WordPress PR', + }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Preview a Gutenberg PR', + }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Import from GitHub' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Open a Blueprint URL', + }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Import a .zip' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Unsaved Playground' }) + ).toHaveCount(0); + + await website.page + .getByRole('button', { name: 'Import from GitHub' }) + .click(); + await expect( + website.page.getByRole('dialog', { name: 'Import from GitHub' }) + ).toBeVisible(); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + siteSlugBeforeGitHubImport + ); + }); + + test('should treat New Playground as an explicit fresh start', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto(getUniqueSavedPlaygroundSetupUrl('explicit-new')); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + const firstSite = await getActivePlaygroundSite(website.page); + + await website.openSavedPlaygroundsOverlay(); + await website.page + .getByRole('button', { name: 'New Playground' }) + .click(); + const overlay = website.page + .locator('[class*="overlay"]') + .filter({ hasText: 'Playground' }); + await expect(overlay).not.toBeVisible({ timeout: 1000 }); + await expect + .poll(() => getActivePlaygroundSite(website.page), { + timeout: 120000, + }) + .not.toMatchObject({ slug: firstSite.slug }); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + const firstBlankSite = await getActivePlaygroundSite(website.page); + + await website.openSavedPlaygroundsOverlay(); + await website.page + .getByRole('button', { name: 'New Playground' }) + .click(); + await expect(overlay).not.toBeVisible({ timeout: 1000 }); + await expect + .poll(() => getActivePlaygroundSite(website.page), { + timeout: 120000, + }) + .not.toMatchObject({ slug: firstBlankSite.slug }); + await expect( + website.page.getByText('Recent autosave available') + ).toHaveCount(0); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + + await website.openSavedPlaygroundsOverlay(); + await website.page.evaluate(() => { + (window as any).__siteSwitchStatusSamples = []; + const sampleStatus = () => { + const status = [ + ...document.querySelectorAll('[role="status"], button'), + ] + .map((node) => (node.textContent || '').trim()) + .find((text) => + [ + 'Autosaving', + 'Saving', + 'Autosaved', + 'Saved Playground', + 'Unsaved', + ].includes(text) + ); + if (status) { + (window as any).__siteSwitchStatusSamples.push(status); + } + }; + const observer = new MutationObserver(sampleStatus); + observer.observe(document.documentElement, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + (window as any).__siteSwitchStatusObserver = observer; + (window as any).__siteSwitchStatusInterval = window.setInterval( + sampleStatus, + 25 + ); + sampleStatus(); + }); + await website.page + .getByRole('button', { + name: new RegExp(`^${escapeRegExp(firstSite.name)}`), + }) + .click(); + await expect(overlay).not.toBeVisible({ timeout: 1000 }); + await expect + .poll(() => getActivePlaygroundSite(website.page), { + timeout: 120000, + }) + .toMatchObject({ slug: firstSite.slug }); + const switchStatusSamples = await website.page.evaluate(() => { + window.clearInterval((window as any).__siteSwitchStatusInterval); + (window as any).__siteSwitchStatusObserver?.disconnect(); + return (window as any).__siteSwitchStatusSamples; + }); + expect(switchStatusSamples).not.toContain('Autosaving'); + }); + + test('should show autosave browser storage details in the Site Manager by default', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto(getUniqueSavedPlaygroundSetupUrl('storage-details')); + await website.ensureSiteManagerIsOpen(); + + await expect( + website.page.getByText('Autosaved in this browser') + ).toBeVisible(); + await expect( + website.page.getByText( + 'Removed after 5 newer autosaves unless saved.' + ) + ).toBeVisible(); + const siteInfoPanel = website.page.locator( + 'section[class*="site-info-panel"]' + ); + await expect( + siteInfoPanel.getByRole('button', { name: 'Store permanently' }) + ).toBeVisible(); + await expect( + website.page.getByText( + 'This is an Unsaved Playground. Your changes will be lost on page refresh.' + ) + ).toHaveCount(0); + }); + + test('should promote a default autosaved Playground when kept', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto(getUniqueSavedPlaygroundSetupUrl('promote')); + await website.ensureSiteManagerIsClosed(); + const statusButton = website.page.getByRole('button', { + name: 'Autosaved', + }); + await expect(statusButton).toBeVisible({ timeout: 120000 }); + await statusButton.click(); + await website.page + .getByRole('button', { name: 'Store permanently' }) + .click(); + + await expect + .poll(() => + website.page.evaluate(() => { + const sites = (window as any).playgroundSites.list(); + return sites.find((site: any) => site.isActive) + ?.persistence; + }) + ) + .toBe('explicit'); + await expect( + website.page.getByText(/Autosaved|Autosaving|Finalizing autosave/) + ).toHaveCount(0); + await expect( + website.page.getByText(/Saved Playground|Saving|Finalizing save/) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toHaveCount(0); + }); + + test('should persist WordPress changes after refreshing the default Playground', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto(getUniqueSavedPlaygroundSetupUrl('restore')); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + + const expectedBlogName = `Saved Playground ${Date.now()}`; + await runPHPAndFlushOpfs( + website.page, + updateBlogNameCode(expectedBlogName) + ); + + await website.page.reload(); + await expect( + website.page.getByText('Recent autosave available') + ).toBeVisible(); + await expect( + website.page.getByText( + /Another Playground was created .* from the same URL\./ + ) + ).toBeVisible(); + await website.waitForNestedIframes(); + await expect( + website.page.getByRole('button', { name: 'Unsaved' }) + ).toBeVisible(); + await website.page + .getByRole('button', { name: 'Restore Autosave' }) + .click(); + await website.waitForNestedIframes(); + await expect + .poll(() => + new URL(website.page.url()).searchParams.get('site-slug') + ) + .toBeTruthy(); + + const blogName = await website.page.evaluate(async () => { + const playground = (window as any).playground; + const result = await playground.run({ + code: ` { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + const setupName = `fresh-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + await website.goto(`./?php=8.3&name=${setupName}&random=first`); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + + const firstBlogName = `Restored Playground ${Date.now()}`; + await runPHPAndFlushOpfs( + website.page, + updateBlogNameCode(firstBlogName) + ); + + await website.page.goto(`./?php=8.3&name=${setupName}&cb=cache-buster`); + await expect( + website.page.getByText('Recent autosave available') + ).toBeVisible(); + await website.waitForNestedIframes(); + await expect( + website.page.getByRole('button', { name: 'Unsaved' }) + ).toBeVisible(); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + await expect + .poll(() => + website.page.evaluate(() => { + const activeSite = (window as any).playgroundSites + .list() + .find((site: any) => site.isActive); + return { + storage: activeSite?.storage, + persistence: activeSite?.persistence, + }; + }) + ) + .toEqual({ storage: 'temporary', persistence: 'explicit' }); + + const freshBlogName = await website.page.evaluate(async () => { + const playground = (window as any).playground; + const result = await playground.run({ + code: ` { + (iframe.contentWindow as any).__playgroundIframeToken = token; + }, iframeToken); + + await website.page.evaluate(() => { + (window as any).__keepNewStatusSamples = []; + const sampleStatus = () => { + const status = [ + ...document.querySelectorAll('[role="status"], button'), + ] + .map((node) => (node.textContent || '').trim()) + .find((text) => + [ + 'Autosaving', + 'Saving', + 'Autosaved', + 'Saved Playground', + 'Unsaved', + ].includes(text) + ); + if (status) { + (window as any).__keepNewStatusSamples.push(status); + } + }; + const observer = new MutationObserver(sampleStatus); + observer.observe(document.documentElement, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + (window as any).__keepNewStatusObserver = observer; + (window as any).__keepNewStatusInterval = window.setInterval( + sampleStatus, + 25 + ); + sampleStatus(); + }); + await website.page.getByRole('button', { name: 'No, thanks' }).click(); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + const keepNewStatusSamples = await website.page.evaluate(() => { + window.clearInterval((window as any).__keepNewStatusInterval); + (window as any).__keepNewStatusObserver?.disconnect(); + return (window as any).__keepNewStatusSamples; + }); + expect(keepNewStatusSamples).toContain('Autosaving'); + expect(keepNewStatusSamples).not.toContain('Saving'); + await expect + .poll(() => + website.page.evaluate(() => { + const activeSite = (window as any).playgroundSites + .list() + .find((site: any) => site.isActive); + return { + storage: activeSite?.storage, + persistence: activeSite?.persistence, + }; + }) + ) + .toEqual({ storage: 'opfs', persistence: 'autosave' }); + await expect + .poll(() => + website.page + .locator( + '#playground-viewport:visible,.playground-viewport:visible' + ) + .evaluate( + (iframe: HTMLIFrameElement) => + (iframe.contentWindow as any) + .__playgroundIframeToken + ) + ) + .toBe(iframeToken); + }); + + test('should fall back to an unsaved Playground when browser storage is unavailable', async ({ + website, + }) => { + await website.page.addInitScript(() => { + Object.defineProperty(navigator.storage, 'getDirectory', { + value: undefined, + configurable: true, + }); + Object.defineProperty(window, 'showDirectoryPicker', { + value: undefined, + configurable: true, + }); + }); + await website.goto('./'); await website.ensureSiteManagerIsClosed(); - const indicator = website.page.getByText('Unsaved Playground'); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + await expect( + website.page.getByRole('button', { name: 'Unsaved' }) + ).toBeVisible(); + await website.page.getByRole('button', { name: 'Unsaved' }).click(); + await expect( + website.page.getByRole('button', { name: 'Store permanently' }) + ).toHaveCount(0); + }); + + test('should show "Unsaved" status for storage=temp Playgrounds', async ({ + website, + }) => { + await website.goto('./?storage=temp'); + await website.ensureSiteManagerIsClosed(); + + const indicator = website.page.getByRole('button', { + name: 'Unsaved', + }); await expect(indicator).toBeVisible(); await expect(indicator).toHaveCount(1); + await indicator.click(); + const popoverDescription = website.page.getByText( + 'This Playground is not stored anywhere. Changes are lost when this page is refreshed or closed.' + ); + await expect(popoverDescription).toBeVisible(); + await indicator.click(); + await expect(popoverDescription).toHaveCount(0); + await indicator.click(); + await expect(popoverDescription).toBeVisible(); + const storePermanentlyButton = website.page.getByRole('button', { + name: 'Store permanently', + }); + const canStorePermanently = await website.page.evaluate(async () => { + try { + await navigator.storage.getDirectory(); + return true; + } catch { + return Boolean((window as any).showDirectoryPicker); + } + }); + if (canStorePermanently) { + await storePermanentlyButton.click(); + await expect( + website.page.getByRole('dialog', { name: 'Save Playground' }) + ).toBeVisible(); + } else { + await expect(storePermanentlyButton).toHaveCount(0); + } + expect(new URL(website.page.url()).searchParams.get('storage')).toBe( + 'temp' + ); }); - test('should see save playground message in the Site Manager', async ({ + test('should see save playground message in the Site Manager for storage=temp Playgrounds', async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); const indicator = website.page.getByText( @@ -610,13 +1288,15 @@ test.describe('Save Status Indicator', () => { await expect(indicator).toHaveCount(1); }); - test('should not show "Unsaved Playground" status when "can-save=no" is set', async ({ + test('should not show "Unsaved" status when "can-save=no" is set', async ({ website, }) => { await website.goto('./?can-save=no'); await website.ensureSiteManagerIsClosed(); - const indicator = website.page.getByText('Unsaved Playground'); + const indicator = website.page.getByRole('button', { + name: 'Unsaved', + }); await expect(indicator).toHaveCount(0); }); @@ -636,7 +1316,7 @@ test.describe('Save Status Indicator', () => { test('should not include Google Analytics when VITE_GOOGLE_ANALYTICS_ID is not set', async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); const gtmScripts = await website.page .locator('script[src*="googletagmanager.com"]') .count(); diff --git a/packages/playground/website/playwright/website-page.ts b/packages/playground/website/playwright/website-page.ts index 4b5510378e8..fdc5f3e95d6 100644 --- a/packages/playground/website/playwright/website-page.ts +++ b/packages/playground/website/playwright/website-page.ts @@ -71,7 +71,7 @@ export class WebsitePage { async openSavedPlaygroundsOverlay() { await this.page - .getByRole('button', { name: 'Saved Playgrounds' }) + .getByRole('button', { name: 'Your Playgrounds' }) .click(); await expect( this.page diff --git a/packages/playground/website/src/components/blueprint-url-modal/index.tsx b/packages/playground/website/src/components/blueprint-url-modal/index.tsx index cbdb41565b2..536c4c1255c 100644 --- a/packages/playground/website/src/components/blueprint-url-modal/index.tsx +++ b/packages/playground/website/src/components/blueprint-url-modal/index.tsx @@ -23,7 +23,7 @@ export function BlueprintUrlModal() { dispatch(setSiteManagerOpen(false)); closeModal(); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { 'blueprint-url': trimmed, }, diff --git a/packages/playground/website/src/components/browser-chrome/index.tsx b/packages/playground/website/src/components/browser-chrome/index.tsx index 1c3e271d739..4e9dccc8054 100644 --- a/packages/playground/website/src/components/browser-chrome/index.tsx +++ b/packages/playground/website/src/components/browser-chrome/index.tsx @@ -98,7 +98,7 @@ export default function BrowserChrome({
+ {isPopoverOpen && ( + setIsPopoverOpen(false)} + anchor={statusButtonRef.current} + focusOnMount="firstElement" + className={css.popover} + > +
+
Autosaved
+

+ This Playground is saved in this browser with + your recent autosaves. It will be deleted after + 5 newer autosaves unless you store it + permanently. +

+ +
+
+ )} + + ); + } + if (status === 'saving') { const progress = - opfsSync?.status === 'syncing' - ? (opfsSync as any).progress - : undefined; + opfsSync?.status === 'syncing' ? opfsSync.progress : undefined; + const progressPercent = getProgressPercent(progress); return ( -
- +
+
); @@ -76,23 +218,59 @@ export function SaveStatusIndicator() { type="button" > - Save failed + + {opfsSync?.operation === 'autosave' || isAutosaved + ? 'Autosave failed' + : 'Save failed'} + ); } // Unsaved - temporary playground that will be lost on refresh return ( -
- - Unsaved Playground + <> -
+ {isPopoverOpen && ( + setIsPopoverOpen(false)} + anchor={statusButtonRef.current} + focusOnMount="firstElement" + className={css.popover} + > +
+
Unsaved
+

+ This Playground is not stored anywhere. Changes are + lost when this page is refreshed or closed. +

+ {canStorePermanently && ( + + )} +
+
+ )} + ); } diff --git a/packages/playground/website/src/components/browser-chrome/style.module.css b/packages/playground/website/src/components/browser-chrome/style.module.css index d31a7e7c79c..0c3b3363477 100644 --- a/packages/playground/website/src/components/browser-chrome/style.module.css +++ b/packages/playground/website/src/components/browser-chrome/style.module.css @@ -99,6 +99,10 @@ body.is-embedded .fake-window-wrapper { border: none; cursor: pointer; flex-shrink: 0; + color: #fff; + font-size: 13px; + font-weight: 500; + line-height: 16px; } .saved-playgrounds-button svg { 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 f942ca1ba59..fb66ab156d8 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 @@ -1,9 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@wordpress/components'; +import css from './restore-autosave-nudge.module.css'; import { useCurrentUrl } from '../../lib/state/url/router-hooks'; +import { + isSaveDisabledByQueryParam, + isTemporaryStorageRequested, +} from '../../lib/state/url/router'; import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage'; import { OPFSSitesLoaded, + isAutosavedSite, selectSiteBySlug, + selectSortedSites, + type SiteInfo, + wasSiteRecentlyInteractedWith, } from '../../lib/state/redux/slice-sites'; import { selectActiveSite, @@ -15,6 +25,11 @@ 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 { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; +import { + getSetupUrlFingerprint, + getSetupUrlFingerprintFromSite, +} from '../../lib/state/url/setup-url'; +import { getRelativeDate } from '../../lib/get-relative-date'; /** * Ensures the redux store always has an activeSite value. @@ -33,13 +48,19 @@ export function EnsurePlaygroundSiteIsSelected({ (state) => state.sites.opfsSitesLoadingState ); const activeSite = useAppSelector((state) => selectActiveSite(state)); + const sortedSites = useAppSelector(selectSortedSites); const dispatch = useAppDispatch(); const sitesAPI = useSitesAPI(); const url = useCurrentUrl(); + const initialUrlHref = useRef(window.location.href); const requestedSiteSlug = url.searchParams.get('site-slug'); const requestedSiteObject = useAppSelector((state) => selectSiteBySlug(state, requestedSiteSlug!) ); + const shouldUseTemporarySite = + isTemporaryStorageRequested(url.href) || + isSaveDisabledByQueryParam() || + !opfsSiteStorage; const requestedClientInfo = useAppSelector( (state) => requestedSiteSlug && @@ -47,6 +68,17 @@ export function EnsurePlaygroundSiteIsSelected({ ); const [needMissingSitePromptForSlug, setNeedMissingSitePromptForSlug] = useState(false); + const [autosaveNudge, setAutosaveNudge] = useState<{ + site: SiteInfo; + setupUrlFingerprint: string; + }>(); + const [freshSetupFingerprints, setFreshSetupFingerprints] = useState< + string[] + >([]); + const currentSetupUrlFingerprint = useMemo( + () => getSetupUrlFingerprint(url), + [url.href] + ); const prevUrl = usePrevious(url); @@ -68,6 +100,11 @@ export function EnsurePlaygroundSiteIsSelected({ useEffect(() => { async function ensureSiteIsSelected() { + const isInitialPageLoadUrl = url.href === initialUrlHref.current; + if (!isInitialPageLoadUrl) { + setAutosaveNudge(undefined); + } + // Don't create a new temporary site until the site listing settles. // Otherwise, the status change from "loading" to "loaded" would // re-run this entire effect, potentially leading to multiple @@ -78,14 +115,37 @@ export function EnsurePlaygroundSiteIsSelected({ // If the site slug is provided, try to load the site. if (requestedSiteSlug) { - // If the site does not exist, create a new temporary site and prompt the user to save it. + // If the site does not exist, create it. Saved browser + // storage is the default unless the URL explicitly asks for + // a temporary site or saving is unavailable. if (!requestedSiteObject) { logger.log( - 'The requested site was not found. Creating a new temporary site.' + 'The requested site was not found. Creating a new site.' ); - await sitesAPI.createNewTemporarySite(requestedSiteSlug); - setNeedMissingSitePromptForSlug(requestedSiteSlug); + if (shouldUseTemporarySite) { + await sitesAPI.createNewTemporarySite( + requestedSiteSlug + ); + if (!isSaveDisabledByQueryParam()) { + setNeedMissingSitePromptForSlug(requestedSiteSlug); + } + } else { + try { + await sitesAPI.createNewSavedSite( + requestedSiteSlug + ); + } catch (error) { + logger.error( + 'Error creating saved site. Falling back to a temporary site.', + error + ); + await sitesAPI.createNewTemporarySite( + requestedSiteSlug + ); + setNeedMissingSitePromptForSlug(requestedSiteSlug); + } + } return; } @@ -105,12 +165,54 @@ export function EnsurePlaygroundSiteIsSelected({ return; } - await sitesAPI.createNewTemporarySite(); + if (shouldUseTemporarySite) { + await sitesAPI.createNewTemporarySite(); + } else { + const matchingAutosave = sortedSites + .filter(isAutosavedSite) + .find( + (site) => + getSetupUrlFingerprintFromSite(site) === + currentSetupUrlFingerprint + ); + if ( + matchingAutosave && + isInitialPageLoadUrl && + !freshSetupFingerprints.includes( + currentSetupUrlFingerprint + ) && + wasSiteRecentlyInteractedWith(matchingAutosave) + ) { + setAutosaveNudge({ + site: matchingAutosave, + setupUrlFingerprint: currentSetupUrlFingerprint, + }); + await sitesAPI.createNewTemporarySite(); + return; + } + + try { + await sitesAPI.createNewSavedSite(undefined, undefined, { + updateUrl: false, + }); + } catch (error) { + logger.error( + 'Error creating saved site. Falling back to a temporary site.', + error + ); + await sitesAPI.createNewTemporarySite(); + } + } } ensureSiteIsSelected(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url.href, requestedSiteSlug, siteListingStatus]); + }, [ + url.href, + requestedSiteSlug, + siteListingStatus, + freshSetupFingerprints, + ]); useEffect(() => { if ( @@ -135,5 +237,61 @@ export function EnsurePlaygroundSiteIsSelected({ } }, [url.searchParams]); - return children; + return ( + <> + {children} + {autosaveNudge && ( + { + void sitesAPI.setActiveSite(autosaveNudge.site.slug); + setAutosaveNudge(undefined); + }} + onKeepNew={() => { + setFreshSetupFingerprints((fingerprints) => [ + ...fingerprints, + autosaveNudge.setupUrlFingerprint, + ]); + void sitesAPI.autosaveTemporarySite(undefined, { + updateUrl: false, + excludeFromPruning: [autosaveNudge.site.slug], + }); + setAutosaveNudge(undefined); + }} + /> + )} + + ); +} + +function RestoreAutosaveNudge({ + site, + onRestore, + onKeepNew, +}: { + site: SiteInfo; + onRestore: () => void; + onKeepNew: () => void; +}) { + const createdAt = new Date((site.metadata.whenCreated ?? Date.now()) - 2); + + return ( + + ); } diff --git a/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css b/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css new file mode 100644 index 00000000000..359370b2c86 --- /dev/null +++ b/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css @@ -0,0 +1,54 @@ +.nudge { + position: fixed; + z-index: 30; + top: 62px; + right: 16px; + display: flex; + align-items: center; + gap: 16px; + max-width: min(520px, calc(100vw - 32px)); + padding: 12px; + background: #fff; + border: 1px solid #dcdcde; + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 14%); + color: #1e1e1e; +} + +.copy { + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.description { + margin-top: 2px; + color: #50575e; + font-size: 12px; + line-height: 17px; +} + +.actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +@media (max-width: 600px) { + .nudge { + top: 108px; + left: 12px; + right: 12px; + align-items: stretch; + flex-direction: column; + } + + .actions { + justify-content: flex-end; + } +} diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 76c8429df18..118e242e273 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -188,10 +188,16 @@ function Modals() { ); - } else if (currentModal === modalSlugs.GITHUB_IMPORT) { + } else if ( + currentModal === modalSlugs.GITHUB_IMPORT || + currentModal === modalSlugs.GITHUB_IMPORT_NEW_SITE + ) { return ( 0 && + savingProgress.files >= savingProgress.total) + ? 'Finalizing save...' + : savingProgress + ? `Saving ${savingProgress.files} / ${savingProgress.total} files...` + : 'Preparing to save...'; const handleRequestClose = () => { if (!isSaving) { @@ -304,7 +313,8 @@ export function SaveSiteModal() { >

This Playground is temporary and will be lost when you - refresh or close this page. Save it to keep your work. + refresh or close this page. Save it to keep your work and + find it later in Your Playgrounds.

- {savingProgress - ? `Saving ${savingProgress.files} / ${savingProgress.total} files...` - : 'Preparing to save...'} + {savingProgressLabel}

)} 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 4bf32a992af..c38ac977447 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -6,7 +6,7 @@ import { MenuGroup, MenuItem, } from '@wordpress/components'; -import { moreVertical, upload, link } from '@wordpress/icons'; +import { moreVertical, plus, upload, link } from '@wordpress/icons'; import { Icon } from '@wordpress/icons'; import { GitHubIcon } from '../../github/github'; import { useDispatch } from 'react-redux'; @@ -22,6 +22,9 @@ import { import type { PlaygroundDispatch } from '../../lib/state/redux/store'; import type { SiteLogo, SiteInfo } from '../../lib/state/redux/slice-sites'; import { + isAutosavedSite, + isExplicitlySavedSite, + MAX_AUTOSAVED_SITES, selectSortedSites, selectTemporarySite, } from '../../lib/state/redux/slice-sites'; @@ -44,6 +47,8 @@ import { OverlaySection, } from '../overlay'; +const MAX_VISIBLE_SAVED_SITES = 8; + type BlueprintsIndexEntry = { title: string; description: string; @@ -85,6 +90,10 @@ export function SavedPlaygroundsOverlay({ const storedSites = useAppSelector(selectSortedSites).filter( (site) => site.metadata.storage !== 'none' ); + const explicitlySavedSites = storedSites.filter(isExplicitlySavedSite); + const autosavedSites = storedSites + .filter(isAutosavedSite) + .slice(0, MAX_AUTOSAVED_SITES); const temporarySite = useAppSelector(selectTemporarySite); const activeSite = useActiveSite(); const dispatch = useAppDispatch(); @@ -96,12 +105,19 @@ export function SavedPlaygroundsOverlay({ const [viewMode, setViewMode] = useState(initialViewMode); const [searchQuery, setSearchQuery] = useState(''); const [selectedTag, setSelectedTag] = useState(null); + const [showAllSavedSites, setShowAllSavedSites] = useState(false); const [pendingZipFile, setPendingZipFile] = useState(null); - - const isTemporarySite = activeSite?.metadata.storage === 'none'; + const [pendingZipTargetSlug, setPendingZipTargetSlug] = useState< + string | null + >(null); useEffect(() => { - if (!pendingZipFile || !isTemporarySite || !playground) { + if ( + !pendingZipFile || + !playground || + !activeSite || + activeSite.slug !== pendingZipTargetSlug + ) { return; } @@ -124,19 +140,24 @@ export function SavedPlaygroundsOverlay({ ); } finally { setPendingZipFile(null); + setPendingZipTargetSlug(null); if (zipFileInputRef.current) { zipFileInputRef.current.value = ''; } } }; doImport(); - }, [pendingZipFile, isTemporarySite, playground, onClose]); + }, [pendingZipFile, pendingZipTargetSlug, activeSite, playground, onClose]); - async function switchToTemporarySite() { - if (temporarySite) { - await sitesAPI.setActiveSite(temporarySite.slug); - } else { - redirectTo(PlaygroundRoute.newTemporarySite()); + async function createSiteForImport() { + try { + return await sitesAPI.createNewSavedSite(); + } catch { + if (temporarySite) { + await sitesAPI.setActiveSite(temporarySite.slug); + return temporarySite.slug; + } + return await sitesAPI.createNewTemporarySite(); } } @@ -144,37 +165,18 @@ export function SavedPlaygroundsOverlay({ const file = e.target.files?.[0]; if (!file) return; - if (!isTemporarySite) { - setPendingZipFile(file); - switchToTemporarySite(); - return; - } - - if (!playground) { - alert( - 'No active Playground to import into. Please create one first.' - ); - return; - } - try { - await importWordPressFiles(playground, { wordPressFilesZip: file }); - setTimeout(async () => { - await playground.goTo('/'); - }, 200); - alert( - 'File imported! This Playground instance has been updated and will refresh shortly.' - ); - onClose(); + const targetSlug = await createSiteForImport(); + setPendingZipTargetSlug(targetSlug); + setPendingZipFile(file); } catch (error) { logger.error(error); alert( - 'Unable to import file. Is it a valid WordPress Playground export?' + 'No active Playground to import into. Please create one first.' ); - } - - if (zipFileInputRef.current) { - zipFileInputRef.current.value = ''; + if (zipFileInputRef.current) { + zipFileInputRef.current.value = ''; + } } }; @@ -228,20 +230,12 @@ export function SavedPlaygroundsOverlay({ return matchesSearch && matchesTag; }); - const onSiteClick = async (slug: string) => { - await sitesAPI.setActiveSite(slug); + const onSiteClick = (slug: string) => { dispatch(setSiteManagerSection('site-details')); onClose(); - }; - - const onTemporaryPlaygroundClick = async () => { - if (temporarySite) { - await sitesAPI.setActiveSite(temporarySite.slug); - dispatch(setSiteManagerSection('site-details')); - onClose(); - } else { - createVanillaSite(); - } + void sitesAPI.setActiveSite(slug).catch((error) => { + logger.error('Error opening saved Playground', error); + }); }; const getLogoDataURL = (logo: SiteLogo): string => { @@ -260,10 +254,25 @@ export function SavedPlaygroundsOverlay({ closeMenu(); }; + const handleKeepSite = async (site: SiteInfo, closeMenu?: () => void) => { + await sitesAPI.keep(site.slug); + closeMenu?.(); + }; + + const getStoredSiteDetails = (site: SiteInfo) => { + if (isAutosavedSite(site)) { + return 'Recovery copy'; + } + if (site.metadata.storage === 'local-fs') { + return 'Saved in a local directory'; + } + return 'Saved in this browser'; + }; + function previewBlueprint(blueprintPath: BlueprintsIndexEntry['path']) { dispatch(setSiteManagerOpen(false)); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { name: 'Blueprint preview', 'blueprint-url': `https://raw.githubusercontent.com/WordPress/blueprints/trunk/${blueprintPath.replace( @@ -278,21 +287,22 @@ export function SavedPlaygroundsOverlay({ function createVanillaSite() { dispatch(setSiteManagerOpen(false)); - redirectTo(PlaygroundRoute.newTemporarySite()); + redirectTo(PlaygroundRoute.newSite()); onClose(); } const creationOptions = [ { id: 'vanilla', - title: 'Vanilla WordPress', - iconComponent: , + title: 'New Playground', + icon: plus, + iconSize: 56, onClick: createVanillaSite, disabled: false, }, { id: 'wp-pr', - title: 'WordPress PR', + title: 'Preview a WordPress PR', iconComponent: , onClick: () => { modalDispatch(setActiveModal(modalSlugs.PREVIEW_PR_WP)); @@ -301,7 +311,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'gutenberg-pr', - title: 'Gutenberg PR', + title: 'Preview a Gutenberg PR', iconComponent: , onClick: () => { modalDispatch(setActiveModal(modalSlugs.PREVIEW_PR_GUTENBERG)); @@ -310,19 +320,18 @@ export function SavedPlaygroundsOverlay({ }, { id: 'github', - title: 'From GitHub', + title: 'Import from GitHub', iconComponent: GitHubIcon, onClick: () => { - if (!isTemporarySite) { - switchToTemporarySite(); - } - modalDispatch(setActiveModal(modalSlugs.GITHUB_IMPORT)); + modalDispatch( + setActiveModal(modalSlugs.GITHUB_IMPORT_NEW_SITE) + ); }, disabled: offline, }, { id: 'blueprint-url', - title: 'Blueprint URL', + title: 'Open a Blueprint URL', icon: link, onClick: () => { modalDispatch(setActiveModal(modalSlugs.BLUEPRINT_URL)); @@ -331,7 +340,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'zip', - title: 'Import .zip', + title: 'Import a .zip', icon: upload, onClick: () => { zipFileInputRef.current?.click(); @@ -340,6 +349,160 @@ export function SavedPlaygroundsOverlay({ }, ]; + const visibleSavedSites = showAllSavedSites + ? explicitlySavedSites + : explicitlySavedSites.slice(0, MAX_VISIBLE_SAVED_SITES); + const hiddenSavedSitesCount = + explicitlySavedSites.length - visibleSavedSites.length; + + function formatSiteCreatedDate(site: SiteInfo) { + return site.metadata.whenCreated + ? new Date(site.metadata.whenCreated).toLocaleDateString( + undefined, + { + year: 'numeric', + month: 'short', + day: 'numeric', + } + ) + : undefined; + } + + function renderSiteRow(site: SiteInfo) { + const isSelected = site.slug === activeSite?.slug; + const isAutosave = isAutosavedSite(site); + const createdDate = formatSiteCreatedDate(site); + + return ( +
+ +
+ {isAutosave && ( + + )} + + {({ onClose: closeMenu }) => ( + <> + + {isAutosave && ( + + handleKeepSite(site, closeMenu) + } + > + Store permanently + + )} + + handleRenameSite(site, closeMenu) + } + > + Rename + + + + + handleDeleteSite(site, closeMenu) + } + > + Delete + + + + )} + +
+
+ ); + } + + function renderSavedPlaygroundsSection() { + if (explicitlySavedSites.length === 0) { + return null; + } + + return ( + +
+ {visibleSavedSites.map(renderSiteRow)} +
+ {hiddenSavedSitesCount > 0 && ( + + )} +
+ ); + } + + function renderAutosavesSection() { + if (autosavedSites.length === 0) { + return null; + } + + return ( + +
+ {autosavedSites.map(renderSiteRow)} +
+
+ ); + } + if (viewMode === 'blueprints') { return ( @@ -525,25 +688,45 @@ export function SavedPlaygroundsOverlay({
- {creationOptions.map((option) => ( - - ))} + + {option.title} + + + ); + })}
@@ -612,134 +795,8 @@ export function SavedPlaygroundsOverlay({ )} - -
-
- -
- {storedSites.map((site) => { - const isSelected = site.slug === activeSite?.slug; - return ( -
- - - {({ onClose: closeMenu }) => ( - <> - - - handleRenameSite( - site, - closeMenu - ) - } - > - Rename - - - - - handleDeleteSite( - site, - closeMenu - ) - } - > - Delete - - - - )} - -
- ); - })} -
-
+ {renderAutosavesSection()} + {renderSavedPlaygroundsSection()}
); diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index 0a7d8ca7c81..38dde1756f6 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -116,8 +116,8 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 58px; + height: 58px; } .creationIcon :global(svg) { @@ -126,6 +126,16 @@ height: 28px; } +.newPlaygroundIcon { + width: 58px; + height: 58px; +} + +.newPlaygroundIcon :global(svg) { + width: 56px; + height: 56px; +} + .creationTitle { font-size: clamp(13px, 1.3vw, 14px); font-weight: 500; @@ -289,12 +299,16 @@ display: flex; flex-direction: column; gap: 2px; + min-width: 0; } .siteRowName { font-size: clamp(14px, 1.4vw, 15px); font-weight: 500; color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .siteRowDate { @@ -306,6 +320,14 @@ margin-right: 12px; } +.siteRowActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding-right: 8px; +} + .siteRowMenu button { color: #949494 !important; } @@ -323,6 +345,44 @@ background: rgba(220, 38, 38, 0.1) !important; } +.keepButton { + background: #3858e9; + border: 1px solid #6c84ff; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; + padding: 8px 12px; + white-space: nowrap; +} + +.keepButton:hover { + background: #4f6df0; + border-color: #8fa1ff; +} + +.showMoreButton { + align-items: center; + background: transparent; + border: 1px solid #3c4349; + border-radius: 4px; + color: #dcdcde; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 13px; + margin-top: 12px; + padding: 8px 12px; +} + +.showMoreButton:hover { + background: #2c3338; + color: #fff; +} + /* Loading state */ .loadingContainer { display: flex; @@ -521,6 +581,14 @@ padding: 12px 16px; } + .siteRowActions { + padding-right: 4px; + } + + .keepButton { + padding: 7px 10px; + } + .siteRowLogo { width: 36px; height: 36px; diff --git a/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx b/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx index 380081c6e09..1f2301349e3 100644 --- a/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx @@ -64,7 +64,7 @@ export function BlueprintsPanel({ function previewBlueprint(blueprintPath: BlueprintsIndexEntry['path']) { dispatch(setSiteManagerOpen(false)); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { name: 'Blueprint preview', // Explicitly do not use joinPaths() here as it normalizes the input and 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 689d612a074..1ee94c8e5cb 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,6 +15,10 @@ 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 { + isAutosavedSite, + preserveSite, +} from '../../../lib/state/redux/slice-sites'; import { modalSlugs, setActiveModal, @@ -99,12 +103,16 @@ export function SiteInfoPanel({ }; const isTemporary = site.metadata.storage === 'none'; + const isAutosaved = isAutosavedSite(site); const removeSiteAndCloseMenu = (onClose: () => void) => { dispatch(setSiteSlugToDelete(site.slug)); dispatch(setActiveModal(modalSlugs.DELETE_SITE)); onClose(); }; + const keepSite = () => { + void dispatch(preserveSite(site.slug)); + }; const clientInfo = useAppSelector((state) => selectClientInfoBySiteSlug(state, site.slug) ); @@ -281,6 +289,9 @@ export function SiteInfoPanel({ ` ${createdAgo}` ); case 'opfs': + if (isAutosaved) { + return `Autosaved in this browser ${createdAgo}. Removed after 5 newer autosaves unless saved.`; + } return `Saved in this browser ${createdAgo}`; } })()}{' '} @@ -288,6 +299,13 @@ export function SiteInfoPanel({ )} + {isAutosaved && ( + + + + )} {mobileUi ? (