From 94749b4f3e1406884ac1e50b68409b436aa28322 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 18:21:52 +0200 Subject: [PATCH] Let a project keep named snapshots and restore them (#231) Autosave only keeps the latest revision. Before a risky change there was no way to put a known good state aside and go back to it later. The main menu now has "Snapshots..." to create, restore and delete named copies of the open project. - Server: SnapshotStore.php keeps each snapshot as a copy of the project JSON under data/snapshots// (git-ignored). Four routes under projects/{id}/snapshots use the same owner check as the project, so an admin also sees foreign snapshots. - Create writes the autosave first. With unsaved edits (play with open edits, failed save, conflict) no snapshot is made and a message says why. The rule is the pure function in src/editor/snapshots.ts. - An empty name falls back to the local date and time. - Restore asks first, then keeps the current state as "Before restoring ". It writes the snapshot back as a new revision and reloads the project, so the undo list is empty. A restore can itself be undone by restoring that snapshot. - Deleting a project deletes its snapshots too. Images, sounds, fonts and pixel art are content-addressed and never deleted, so a snapshot can always reach them. - Client: SnapshotStore and HttpSnapshotStore in src/core/store, the SnapshotsDialog, and the flows in ProjectSession. A stale revision (409) becomes StaleRevisionError. - CI runs the new PHP test in the gate job next to accounts. Not covered by e2e: the lock during play and the conflict case. Both are unit tests only. Tests: - check.sh: 164 files, 2401 tests, CHECK EXIT 0. - tests/php/snapshots.test.php: 14 checks, all passed. - tests/editor/snapshots.test.ts: 5 tests for the time format, the blocker rule, the routes and 409. - New e2e module 50 on a throwaway project: create, rename, restore, undo the restore, delete, then delete the project and get 404 on its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks passed. The check sum goes from 1385 to 1393 (computed). - Counter-checks, originals restored and compared with cmp: blocker always allowed fails 1 unit test. No sorting fails 2 PHP checks. Restore without keeping the old state fails 50c and aborts 50d. - The first sort test with 2 snapshots passed by chance half the time because of random ids. It now uses 8. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 8 +- .gitignore | 1 + README.md | 2 +- api/index.php | 101 ++++++++++++++++- api/lib/SnapshotStore.php | 131 +++++++++++++++++++++ src/core/store/HttpSnapshotStore.ts | 48 ++++++++ src/core/store/SnapshotStore.ts | 26 +++++ src/editor/App.ts | 2 + src/editor/Editor.ts | 9 ++ src/editor/ProjectSession.ts | 95 +++++++++++++++- src/editor/SnapshotsDialog.ts | 85 ++++++++++++++ src/editor/debug/uiHooks.ts | 5 + src/editor/snapshots.ts | 25 ++++ tests/editor/snapshots.test.ts | 67 +++++++++++ tests/php/snapshots.test.php | 78 +++++++++++++ tools/e2e/46-topbar.mjs | 1 + tools/e2e/50-snapshots.mjs | 170 ++++++++++++++++++++++++++++ tools/e2e/index.mjs | 2 + 18 files changed, 849 insertions(+), 7 deletions(-) create mode 100644 api/lib/SnapshotStore.php create mode 100644 src/core/store/HttpSnapshotStore.ts create mode 100644 src/core/store/SnapshotStore.ts create mode 100644 src/editor/SnapshotsDialog.ts create mode 100644 src/editor/snapshots.ts create mode 100644 tests/editor/snapshots.test.ts create mode 100644 tests/php/snapshots.test.php create mode 100644 tools/e2e/50-snapshots.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index db50e934..715888d8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -33,10 +33,12 @@ jobs: env: GM_TEST_WORKERS: 2 - # The API's own rules (#180, #179). Not part of check.sh: the host the editor is built on - # has no PHP, the image does. + # The API's own rules (#180, #179, #231). Not part of check.sh: the host the editor is built + # on has no PHP, the image does. - name: PHP tests - run: php tests/php/accounts.test.php + run: | + php tests/php/accounts.test.php + php tests/php/snapshots.test.php suite: runs-on: game-maker-ci diff --git a/.gitignore b/.gitignore index ad40f7d7..5ae51aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /data/e2e-password.txt /data/e2e-admin-password.txt /data/sessions/ +/data/snapshots/ /data/users.json /data/textures/ /data/fonts/ diff --git a/README.md b/README.md index 44fb2b60..6031656c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1385 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1393 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index 73009357..de0546a8 100644 --- a/api/index.php +++ b/api/index.php @@ -31,7 +31,14 @@ declare(strict_types=1); * POST ?r=projects {name} → {id,name,revision,updatedAt,project} * GET ?r=projects/{id} → {id,name,revision,updatedAt,project} * PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision} - * DELETE ?r=projects/{id} → 204 + * DELETE ?r=projects/{id} → 204 (its snapshots go with it) + * + * GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first + * POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt} + * DELETE ?r=projects/{id}/snapshots/{sid} → 204 + * POST ?r=projects/{id}/snapshots/{sid}/restore {revision} → {revision,updatedAt,before} | 409 + * (a snapshot copies the project as stored; restoring keeps the + * stand it replaces as the snapshot `before`, #231) * * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} @@ -65,6 +72,7 @@ require __DIR__ . '/lib/PixelStore.php'; require __DIR__ . '/lib/PluginStore.php'; require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/SessionStore.php'; +require __DIR__ . '/lib/SnapshotStore.php'; require __DIR__ . '/lib/SoundStore.php'; require __DIR__ . '/lib/Store.php'; require __DIR__ . '/lib/TextureStore.php'; @@ -168,6 +176,14 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl Response::error('Invalid project id', 400); } + $snapshots = new SnapshotStore(dirname(__DIR__) . '/data/snapshots'); + if (($parts[2] ?? null) === 'snapshots') { + routeSnapshots($parts, $method, $store, $snapshots, $id); + } + if (count($parts) > 2) { + Response::error('Not found', 404); + } + if ($method === 'GET') { $doc = $store->load($id); if ($doc === null) { @@ -196,12 +212,92 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl if (!$store->delete($id)) { Response::error('Project not found', 404); } + $snapshots->deleteAll($id); Response::noContent(); } Response::error('Method not allowed', 405); } +/** + * The snapshots of one project (#231). Whoever may load the project may see, make, restore and + * delete its snapshots; for everybody else the project, and with it this route, is not there. + * + * @param list $parts + */ +function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id): never +{ + $doc = $store->load($id); + if ($doc === null) { + Response::error('Project not found', 404); + } + + if (count($parts) === 3) { + if ($method === 'GET') { + Response::json($snapshots->list($id)); + } + if ($method === 'POST') { + $name = snapshotName(Response::body()); + Response::json($snapshots->create($id, $doc, $name === '' ? gmdate('Y-m-d H:i') . ' UTC' : $name), 201); + } + Response::error('Method not allowed', 405); + } + + $snapshotId = $parts[3]; + if (!Store::isValidId($snapshotId) || count($parts) > 5) { + Response::error('Not found', 404); + } + $snapshot = $snapshots->load($id, $snapshotId); + if ($snapshot === null) { + Response::error('Snapshot not found', 404); + } + + if (count($parts) === 4) { + if ($method !== 'DELETE') { + Response::error('Method not allowed', 405); + } + $snapshots->delete($id, $snapshotId); + Response::noContent(); + } + + if ($parts[4] !== 'restore') { + Response::error('Not found', 404); + } + if ($method !== 'POST') { + Response::error('Method not allowed', 405); + } + $body = Response::body(); + if (!isset($body['revision']) || !is_int($body['revision'])) { + Response::error('Body must contain integer "revision"', 422); + } + // The same guard a save has: a tab that is behind must not throw away what another one saved. + if ($body['revision'] !== $doc['revision']) { + Response::error('Project was modified elsewhere', 409, ['revision' => $doc['revision']]); + } + // The stand being replaced is kept first, so a restore can itself be taken back. + $before = $snapshots->create($id, $doc, mb_substr('Before restoring ' . $snapshot['name'], 0, 120)); + try { + $result = $store->save($id, $doc['revision'], $snapshot['project']); + } catch (StaleRevisionException $e) { + $snapshots->delete($id, $before['id']); + Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]); + } + if ($result === null) { + $snapshots->delete($id, $before['id']); + Response::error('Project not found', 404); + } + Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt'], 'before' => $before]); +} + +function snapshotName(array $body): string +{ + $name = trim((string) ($body['name'] ?? '')); + if (mb_strlen($name) > 120) { + Response::error('Snapshot name must be at most 120 characters', 422); + } + return $name; +} + /** @param list $parts */ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never { @@ -472,7 +568,8 @@ try { $route = trim((string) ($_GET['r'] ?? ''), '/'); $parts = $route === '' ? [] : explode('/', $route); - if (count($parts) < 1 || count($parts) > 2) { + // Only a project reaches deeper than two parts: `projects/{id}/snapshots/{snapshotId}/restore`. + if (count($parts) < 1 || count($parts) > ($parts[0] === 'projects' ? 5 : 2)) { Response::error('Not found', 404); } diff --git a/api/lib/SnapshotStore.php b/api/lib/SnapshotStore.php new file mode 100644 index 00000000..3e1906c1 --- /dev/null +++ b/api/lib/SnapshotStore.php @@ -0,0 +1,131 @@ +, newest first */ + public function list(string $projectId): array + { + $items = []; + foreach (glob($this->folder($projectId) . '/*.json') ?: [] as $file) { + $snapshot = $this->readFile($file); + if ($snapshot !== null) { + $items[] = self::summary($snapshot); + } + } + usort($items, static fn(array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt'])); + return $items; + } + + /** + * Copies a stored project document into a new snapshot. + * + * @param array{revision:int,project:array} $doc + * @return array{id:string,name:string,revision:int,createdAt:string} + */ + public function create(string $projectId, array $doc, string $name): array + { + $folder = $this->folder($projectId); + if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) { + throw new RuntimeException("Cannot create snapshot directory {$folder}"); + } + do { + $id = Store::newId(); + } while (file_exists($this->path($projectId, $id))); + + $snapshot = [ + 'id' => $id, + 'name' => $name, + 'revision' => $doc['revision'], + // Microseconds, so two snapshots made within one second still list in the order made. + 'createdAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.uP'), + 'project' => $doc['project'], + ]; + $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('Cannot encode snapshot'); + } + $path = $this->path($projectId, $id); + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException('Cannot write snapshot file'); + } + return self::summary($snapshot); + } + + /** @return array{id:string,name:string,revision:int,createdAt:string,project:array}|null */ + public function load(string $projectId, string $id): ?array + { + return $this->readFile($this->path($projectId, $id)); + } + + public function delete(string $projectId, string $id): bool + { + $path = $this->path($projectId, $id); + return is_file($path) && unlink($path); + } + + /** What deleting the project does to its snapshots. */ + public function deleteAll(string $projectId): void + { + $folder = $this->folder($projectId); + foreach (glob($folder . '/*.json') ?: [] as $file) { + unlink($file); + } + if (is_dir($folder)) { + @rmdir($folder); + } + } + + /** @return array{id:string,name:string,revision:int,createdAt:string} */ + private static function summary(array $snapshot): array + { + return [ + 'id' => $snapshot['id'], + 'name' => $snapshot['name'], + 'revision' => $snapshot['revision'], + 'createdAt' => $snapshot['createdAt'], + ]; + } + + private function folder(string $projectId): string + { + return $this->dir . '/' . $projectId; + } + + private function path(string $projectId, string $id): string + { + return $this->folder($projectId) . '/' . $id . '.json'; + } + + private function readFile(string $path): ?array + { + if (!is_file($path)) { + return null; + } + $raw = file_get_contents($path); + $snapshot = $raw === false ? null : json_decode($raw, true); + if (!is_array($snapshot) || !isset($snapshot['id'], $snapshot['project']) || !is_array($snapshot['project'])) { + return null; + } + $snapshot['name'] = is_string($snapshot['name'] ?? null) ? $snapshot['name'] : ''; + $snapshot['revision'] = (int) ($snapshot['revision'] ?? 0); + $snapshot['createdAt'] = is_string($snapshot['createdAt'] ?? null) ? $snapshot['createdAt'] : ''; + return $snapshot; + } +} diff --git a/src/core/store/HttpSnapshotStore.ts b/src/core/store/HttpSnapshotStore.ts new file mode 100644 index 00000000..5b2d71e1 --- /dev/null +++ b/src/core/store/HttpSnapshotStore.ts @@ -0,0 +1,48 @@ +import { apiRequest } from './http'; +import { StaleRevisionError } from './ProjectStore'; +import type { SnapshotStore, SnapshotSummary } from './SnapshotStore'; + +export class HttpSnapshotStore implements SnapshotStore { + constructor(private readonly endpoint: string) {} + + list(projectId: string): Promise { + return this.request('GET', route(projectId)); + } + + create(projectId: string, name: string): Promise { + return this.request('POST', route(projectId), { name }); + } + + restore( + projectId: string, + snapshotId: string, + revision: number, + ): Promise<{ revision: number; before: SnapshotSummary }> { + return this.request('POST', `${route(projectId, snapshotId)}/restore`, { revision }); + } + + delete(projectId: string, snapshotId: string): Promise { + return this.request('DELETE', route(projectId, snapshotId)); + } + + private request(method: string, path: string, payload?: unknown): Promise { + return apiRequest( + this.endpoint, + method, + path, + { + headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: payload !== undefined ? JSON.stringify(payload) : undefined, + }, + (status, data) => + status === 409 + ? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1) + : undefined, + ); + } +} + +function route(projectId: string, snapshotId?: string): string { + const base = `projects/${encodeURIComponent(projectId)}/snapshots`; + return snapshotId === undefined ? base : `${base}/${encodeURIComponent(snapshotId)}`; +} diff --git a/src/core/store/SnapshotStore.ts b/src/core/store/SnapshotStore.ts new file mode 100644 index 00000000..316a6ed4 --- /dev/null +++ b/src/core/store/SnapshotStore.ts @@ -0,0 +1,26 @@ +/** One snapshot of a project as the list shows it (#231). */ +export interface SnapshotSummary { + id: string; + name: string; + /** The project revision the snapshot copied. */ + revision: number; + /** ISO time, UTC. */ + createdAt: string; +} + +/** + * The snapshots of a project: copies of the project as the server stored it. A restore writes the + * copy back as a new revision and keeps the stand it replaced as a snapshot of its own, `before`. + */ +export interface SnapshotStore { + list(projectId: string): Promise; + /** An empty name lets the server name it by the time. */ + create(projectId: string, name: string): Promise; + /** Rejects with `StaleRevisionError` when `revision` is not the stored one. */ + restore( + projectId: string, + snapshotId: string, + revision: number, + ): Promise<{ revision: number; before: SnapshotSummary }>; + delete(projectId: string, snapshotId: string): Promise; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 364e14fc..38a8ac5c 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -3,6 +3,7 @@ import type { AuthStore, SignedIn } from '../core/store/AuthStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore'; import { HttpFileStore } from '../core/store/HttpFileStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; +import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore'; import { HttpUsersStore } from '../core/store/HttpUsersStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import { loadPhysics } from '../runtime/physics/engine'; @@ -108,6 +109,7 @@ export async function startEditor(app: Application, root: VStack): Promise new HttpFileStore(API_ENDPOINT, 'textures'), new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'sounds'), + new HttpSnapshotStore(API_ENDPOINT), { user, role, diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 26aba5f9..fdf268ae 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -5,6 +5,7 @@ import type { Role } from '../core/store/AuthStore'; import type { ProjectStore } from '../core/store/ProjectStore'; import { pluginsOf } from '../core/store/projectPlugins'; import type { FontStore } from '../core/store/FontStore'; +import type { SnapshotStore } from '../core/store/SnapshotStore'; import type { SoundStore } from '../core/store/SoundStore'; import type { TextureStore } from '../core/store/TextureStore'; import type { UsersStore } from '../core/store/UsersStore'; @@ -35,6 +36,7 @@ import { TAB_OF_ASSET } from './assetTabs'; import { attachAutosave } from './autosave'; import { PasswordDialog } from './auth/PasswordDialog'; import type { OpenProjectDialog } from './OpenProjectDialog'; +import type { SnapshotsDialog } from './SnapshotsDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; import { ContainerEditor } from './container/ContainerEditor'; @@ -256,6 +258,7 @@ export class Editor implements PlayHost { textures: TextureStore, fonts: FontStore, sounds: SoundStore, + snapshots: SnapshotStore, account: EditorAccount, ) { this.viewport = new SceneViewport(state); @@ -291,6 +294,7 @@ export class Editor implements PlayHost { () => { if (this.play.isPlaying) this.play.stop(); }, + { store: snapshots, flush: () => this.autosave.flush() }, // Every project brings its own plugins (#119); until `startPlugins` has run there is no // manager, and a project with plugins simply has none of them. async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))), @@ -524,6 +528,11 @@ export class Editor implements PlayHost { return this.projects.projectPicker(); } + /** The open "Snapshots" dialog (#231), for the check scripts. */ + snapshotsDialog(): SnapshotsDialog | null { + return this.projects.snapshotList(); + } + /** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */ hasTab(id: string): boolean { return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`); diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 3511c705..227f09f7 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -7,17 +7,21 @@ import { SetPlayFromCommand, SetSymbolScaleCommand, } from '../core/commands/projectCommands'; -import type { ProjectStore } from '../core/store/ProjectStore'; +import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore'; +import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore'; import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; import { OpenProjectDialog } from './OpenProjectDialog'; import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'; import { clearSaved, hasSaved } from './saves'; +import { snapshotBlocker, snapshotTime } from './snapshots'; +import { SnapshotsDialog } from './SnapshotsDialog'; import type { EditorState } from './state'; export class ProjectSession { private readonly settingsDialog = dialogSlot(); private readonly pickerDialog = dialogSlot(); + private readonly snapshotsDialog = dialogSlot(); /** * `beforeSwitch` runs before a different project is loaded or the current one is deleted: a @@ -27,6 +31,8 @@ export class ProjectSession { private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void, + /** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */ + private readonly snapshots: { store: SnapshotStore; flush: () => Promise }, /** * Runs on the project as it came off the server, before it is read into the model: the plugins * this project switches on are loaded there (#119), so that what they register is in place @@ -94,6 +100,7 @@ export class ProjectSession { { label: 'New project', onSelect: () => void this.run(this.createNext()) }, { label: 'Open project…', onSelect: () => this.openProjectPicker() }, { separator: true }, + { label: 'Snapshots…', onSelect: () => this.openSnapshots() }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, { label: 'Clear saved data', onSelect: () => this.clearSavedData() }, ...more, @@ -141,6 +148,92 @@ export class ProjectSession { ]).openCentered(); } + /** The snapshots of the open project (#231). */ + openSnapshots(): void { + const projectId = this.state.project.id; + void this.run( + (async () => { + const list = await this.snapshots.store.list(projectId); + this.snapshotsDialog.open( + new SnapshotsDialog(list, { + onCreate: (name) => void this.run(this.createSnapshot(projectId, name)), + onRestore: (snapshot) => this.confirmRestore(projectId, snapshot), + onDelete: (snapshot) => this.confirmDeleteSnapshot(projectId, snapshot), + }), + ); + })(), + ); + } + + /** The open snapshots dialog, for the check scripts. */ + snapshotList(): SnapshotsDialog | null { + return this.snapshotsDialog.current(); + } + + private async createSnapshot(projectId: string, name: string): Promise { + await this.snapshots.flush(); + const blocker = snapshotBlocker(this.state.saveStatus); + if (blocker) { + this.state.setNotice(blocker, 'error'); + return; + } + const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date())); + this.state.setNotice(`Snapshot "${made.name}" created`); + this.openSnapshots(); + } + + private confirmRestore(projectId: string, snapshot: SnapshotSummary): void { + new Dialog( + `Restore snapshot "${snapshot.name}"?`, + 'The project as it is now is kept as a snapshot of its own first, so this can be taken back.', + [ + { label: 'Cancel' }, + { + label: 'Restore', + primary: true, + onClick: () => void this.run(this.restoreSnapshot(projectId, snapshot)), + }, + ], + ).openCentered(); + } + + /** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */ + private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise { + this.beforeSwitch(); + await this.snapshots.flush(); + const blocker = snapshotBlocker(this.state.saveStatus); + if (blocker) { + this.state.setNotice(blocker, 'error'); + return; + } + try { + const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision); + await this.open(projectId); + this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`); + } catch (error) { + if (!(error instanceof StaleRevisionError)) throw error; + this.state.setSaveStatus('conflict'); + this.state.setNotice('This project was changed elsewhere. Reload to continue.', 'error'); + } + } + + private confirmDeleteSnapshot(projectId: string, snapshot: SnapshotSummary): void { + new Dialog(`Delete snapshot "${snapshot.name}"?`, 'The snapshot is removed permanently.', [ + { label: 'Cancel', onClick: () => this.openSnapshots() }, + { + label: 'Delete', + danger: true, + onClick: () => + void this.run( + (async () => { + await this.snapshots.store.delete(projectId, snapshot.id); + this.openSnapshots(); + })(), + ), + }, + ]).openCentered(); + } + /** * Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog * (#183). Each is an undo step of its own – one each, and only for what actually changed. diff --git a/src/editor/SnapshotsDialog.ts b/src/editor/SnapshotsDialog.ts new file mode 100644 index 00000000..50ab3b7a --- /dev/null +++ b/src/editor/SnapshotsDialog.ts @@ -0,0 +1,85 @@ +import type { SnapshotSummary } from '../core/store/SnapshotStore'; +import { Button, HStack, Label, ListView, Popup, ScrollContainer, TextField, Theme } from '../ui'; +import { dialogButtons } from './FormDialog'; +import { snapshotTime } from './snapshots'; + +const LIST_WIDTH = 360; +const LIST_HEIGHT = 220; + +/** + * "Snapshots…" (#231): the snapshots of the open project, newest first, a field to name the next + * one, and Restore and Delete on the one picked. + * + * Like the project picker, Restore and Delete close the dialog and hand the choice on: the caller + * asks for confirmation, and a question over a list it is no longer about would only confuse. + * Create keeps it open – the caller opens it again with the new snapshot in the list. + */ +export class SnapshotsDialog extends Popup { + private readonly list = new ListView(); + private readonly scroll = new ScrollContainer(); + private readonly nameField: TextField; + private readonly restoreButton: Button; + private readonly deleteButton: Button; + private picked: string | null; + private readonly rows: { name: string; time: string }[]; + + constructor( + snapshots: readonly SnapshotSummary[], + actions: { + onCreate(name: string): void; + onRestore(snapshot: SnapshotSummary): void; + onDelete(snapshot: SnapshotSummary): void; + }, + ) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); + this.picked = snapshots[0]?.id ?? null; + this.rows = snapshots.map((s) => ({ name: s.name, time: snapshotTime(new Date(s.createdAt)) })); + + this.content.add(new Label('Snapshots', { style: 'bodyBold' })); + + const create = (): void => actions.onCreate(this.nameField.value.trim()); + this.nameField = new TextField('', { placeholder: 'Name (optional)', width: 220, onSubmit: create }); + const createButton = new Button('Create snapshot', { variant: 'primary', onClick: create }); + const createRow = new HStack({ gap: Theme.gap, align: 'center' }); + createRow.add(this.nameField, 1); + createRow.add(createButton); + this.content.add(createRow); + + this.list.minWidth = LIST_WIDTH; + this.list.setItems(snapshots.map((s, i) => ({ id: s.id, label: s.name, hint: this.rows[i]?.time }))); + this.list.select(this.picked, true); + this.list.onSelect = (id) => { + this.picked = id; + this.refresh(); + }; + this.scroll.minWidth = LIST_WIDTH; + this.scroll.minHeight = LIST_HEIGHT; + this.scroll.setContent(this.list); + this.content.add(this.scroll); + if (snapshots.length === 0) this.content.add(new Label('No snapshots yet', { style: 'muted' })); + + const withPicked = (action: (snapshot: SnapshotSummary) => void) => () => { + const snapshot = snapshots.find((s) => s.id === this.picked); + if (snapshot) this.closeThen(() => action(snapshot))(); + }; + this.deleteButton = new Button('Delete', { variant: 'danger', onClick: withPicked(actions.onDelete) }); + this.restoreButton = new Button('Restore', { onClick: withPicked(actions.onRestore) }); + this.content.add(dialogButtons(this.closeThen(), this.restoreButton, this.deleteButton)); + this.refresh(); + } + + /** What the list shows and which row is picked (test hook). */ + info(): { rows: { name: string; time: string }[]; picked: string | null } { + return { rows: this.rows.map((r) => ({ ...r })), picked: this.picked }; + } + + /** The name field, for the check scripts to type into. */ + get nameControl(): TextField { + return this.nameField; + } + + private refresh(): void { + this.restoreButton.setEnabled(this.picked !== null); + this.deleteButton.setEnabled(this.picked !== null); + } +} diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 07a87b58..5990a162 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -234,6 +234,11 @@ export function uiHooks({ app, state, editor }: HookContext) { }, /** The "Open project" dialog (#183): the projects it lists and the row picked. */ projectPicker: () => editor.projectPickerDialog()?.info() ?? null, + /** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */ + snapshots: () => { + const dialog = editor.snapshotsDialog(); + return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null; + }, /** The dividers left and right of the centre column – dragging them narrows the tab row. */ panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), /** The left column: asset panel on top, scene objects below, and the divider between them. */ diff --git a/src/editor/snapshots.ts b/src/editor/snapshots.ts new file mode 100644 index 00000000..d0279f98 --- /dev/null +++ b/src/editor/snapshots.ts @@ -0,0 +1,25 @@ +import type { SaveStatus } from './state'; + +/** When a snapshot was made, in the local time of the browser: `2026-09-25 17:39`. */ +export function snapshotTime(at: Date): string { + const two = (n: number) => String(n).padStart(2, '0'); + return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; +} + +/** + * Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies + * what it has stored, so the editor has to have saved everything first: a snapshot without the + * last edits would be a stand nobody ever saw. + */ +export function snapshotBlocker(status: SaveStatus): string | null { + switch (status) { + case 'saved': + return null; + case 'conflict': + return 'This project was changed elsewhere. Reload to continue.'; + case 'error': + return 'The project could not be saved, so there is nothing current to take a snapshot of'; + default: + return 'The latest changes are not saved yet – stop the game or wait a moment'; + } +} diff --git a/tests/editor/snapshots.test.ts b/tests/editor/snapshots.test.ts new file mode 100644 index 00000000..57e45396 --- /dev/null +++ b/tests/editor/snapshots.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HttpSnapshotStore } from '../../src/core/store/HttpSnapshotStore'; +import { StaleRevisionError } from '../../src/core/store/ProjectStore'; +import { snapshotBlocker, snapshotTime } from '../../src/editor/snapshots'; + +function stubFetch(body: BodyInit | null, init: ResponseInit) { + const mock = vi.fn(() => Promise.resolve(new Response(body, init))); + vi.stubGlobal('fetch', mock); + return mock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('snapshot rules (#231)', () => { + it('shows the time in local time, minutes padded', () => { + expect(snapshotTime(new Date(2026, 0, 5, 7, 3))).toBe('2026-01-05 07:03'); + }); + + it('allows a snapshot only when everything is saved', () => { + expect(snapshotBlocker('saved')).toBeNull(); + for (const status of ['dirty', 'saving', 'error', 'conflict'] as const) { + expect(snapshotBlocker(status)).toEqual(expect.any(String)); + } + expect(snapshotBlocker('conflict')).toContain('changed elsewhere'); + }); +}); + +describe('HttpSnapshotStore (#231)', () => { + it('lists, creates and deletes under the project', async () => { + const mock = stubFetch(JSON.stringify([]), { status: 200 }); + const store = new HttpSnapshotStore('/api'); + await store.list('p1'); + await store.create('p1', 'before the boss'); + stubFetch(null, { status: 204 }); + await store.delete('p1', 's1'); + const calls = [...mock.mock.calls, ...vi.mocked(fetch).mock.calls].map(([url, init]) => [ + decodeURIComponent((url as string).split('r=')[1] ?? ''), + init?.method, + init?.body, + ]); + expect(calls).toEqual([ + ['projects/p1/snapshots', 'GET', undefined], + ['projects/p1/snapshots', 'POST', JSON.stringify({ name: 'before the boss' })], + ['projects/p1/snapshots/s1', 'DELETE', undefined], + ]); + }); + + it('restores with the revision the editor holds and hands back the kept stand', async () => { + const before = { id: 's2', name: 'Before restoring a', revision: 7, createdAt: '' }; + const mock = stubFetch(JSON.stringify({ revision: 8, before }), { status: 200 }); + const result = await new HttpSnapshotStore('/api').restore('p1', 's1', 7); + expect(result).toEqual({ revision: 8, before }); + expect(decodeURIComponent(mock.mock.calls[0]?.[0] as string)).toContain( + 'r=projects/p1/snapshots/s1/restore', + ); + expect(mock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ revision: 7 })); + }); + + it('turns a 409 into a stale revision', async () => { + stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 9 }), { status: 409 }); + const failure = await new HttpSnapshotStore('/api').restore('p1', 's1', 7).catch((e: unknown) => e); + expect(failure).toBeInstanceOf(StaleRevisionError); + expect((failure as StaleRevisionError).serverRevision).toBe(9); + }); +}); diff --git a/tests/php/snapshots.test.php b/tests/php/snapshots.test.php new file mode 100644 index 00000000..72d0e627 --- /dev/null +++ b/tests/php/snapshots.test.php @@ -0,0 +1,78 @@ +list('p1') === []); + +$doc = ['revision' => 4, 'project' => ['id' => 'p1', 'name' => 'Game', 'assets' => [['id' => 'a']]]]; +$first = $snapshots->create('p1', $doc, 'first'); +$second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second'); +check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first); +check('two snapshots get two ids', $first['id'] !== $second['id']); +check('the summary carries no project', !isset($first['project'])); + +$listed = $snapshots->list('p1'); +check( + 'the list is newest first, also within one second', + array_column($listed, 'name') === ['second', 'first'], + array_column($listed, 'name'), +); +// The ids are random, so two files come back from the folder in the right order half the time +// anyway. With eight, the folder's own order matching by chance is one in 40320. +$more = new SnapshotStore("{$dir}/order"); +foreach (range(1, 8) as $n) { + $more->create('p', $doc, "n{$n}"); +} +check( + 'eight made in a row list in reverse order of making', + array_column($more->list('p'), 'name') === ['n8', 'n7', 'n6', 'n5', 'n4', 'n3', 'n2', 'n1'], + array_column($more->list('p'), 'name'), +); + +$loaded = $snapshots->load('p1', $first['id']); +check('a snapshot loads the project as it was copied', $loaded !== null && $loaded['project'] === $doc['project'], $loaded); +check('a snapshot of another project is not found', $snapshots->load('p2', $first['id']) === null); + +file_put_contents("{$dir}/p1/broken.json", '{"id":"broken"'); +check('a broken file is left out of the list', count($snapshots->list('p1')) === 2, $snapshots->list('p1')); +unlink("{$dir}/p1/broken.json"); + +check('deleting a snapshot says so', $snapshots->delete('p1', $first['id'])); +check('it is gone from the list', array_column($snapshots->list('p1'), 'name') === ['second']); +check('deleting it again says it was not there', !$snapshots->delete('p1', $first['id'])); + +$snapshots->create('p2', $doc, 'other'); +$snapshots->deleteAll('p1'); +check('deleting all removes the folder of that project', !is_dir("{$dir}/p1")); +check('and leaves the snapshots of other projects', count($snapshots->list('p2')) === 1); + +exec('rm -rf ' . escapeshellarg($dir)); + +echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index d837d31e..c7cd5b24 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -55,6 +55,7 @@ export async function topbar(t) { 'New project', 'Open project…', null, + 'Snapshots…', 'Project settings…', 'Clear saved data', null, diff --git a/tools/e2e/50-snapshots.mjs b/tools/e2e/50-snapshots.mjs new file mode 100644 index 00000000..c2a379e4 --- /dev/null +++ b/tools/e2e/50-snapshots.mjs @@ -0,0 +1,170 @@ +import { authHeaders, forgetSession } from '../lib/auth.mjs'; + +/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */ +const TEMP_NAME = 'Snapshots 50'; +const RENAMED = 'Renamed by 50'; + +/** + * Snapshots of a project (#231): made from the menu, restored over a later stand, the replaced + * stand kept as a snapshot of its own, and deleted with the project. + * + * Works on a throwaway project of its own, because a restore reloads the project it is done on. + * The last thing it does is delete that project and open the fixture again. + */ +export async function snapshots(t) { + const { check, cdp, json, click, press, typeText, locate, sleep, waitUntil, settle } = t; + const { clickToolbar, clickText, shot, saved, baseUrl, navigate } = t; + + const send = async (method, route, body) => + fetch(`${baseUrl}api/index.php?r=${route}`, { + method, + headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + // Step 45 changes the password of `e2e`, which ends the session the tools had cached. + const api = async (method, route, body) => { + const first = await send(method, route, body); + if (first.status !== 401) return first; + forgetSession(baseUrl); + return send(method, route, body); + }; + const sweep = async () => { + for (const project of await (await api('GET', 'projects')).json()) { + if (project.name === TEMP_NAME || project.name === RENAMED) + await api('DELETE', `projects/${project.id}`); + } + }; + /** The last-drawn `text` – the dialog's, over a row or a menu that says the same. */ + const clickLast = async (text) => { + await settle(); + const at = await locate(text, true); + await click(at.x, at.y); + }; + const openSnapshots = async () => { + await clickToolbar('menu'); + await clickText('Snapshots…'); + await waitUntil('__gm.snapshots() !== null', { label: 'the snapshots dialog opened' }); + }; + const notice = () => json('__gm.state.notice'); + + await sweep(); + const made = await (await api('POST', 'projects', { name: TEMP_NAME })).json(); + const stored = async () => (await (await api('GET', `projects/${made.id}`)).json()).project; + try { + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` }); + await sleep(4000); + await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, { + timeout: 15000, + label: 'the throwaway project is open', + }); + + // 50a. A new project has no snapshots; a named one is made from what is saved and listed. + await openSnapshots(); + const empty = await json('__gm.snapshots()'); + const field = empty?.nameAt; + await click(field.x, field.y); + await typeText('Clean start'); + await press('Enter'); + await waitUntil('__gm.snapshots()?.rows.length === 1', { label: 'the dialog shows the new snapshot' }); + const one = await json('__gm.snapshots()'); + const listed = await (await api('GET', `projects/${made.id}/snapshots`)).json(); + check( + 'Create snapshot copies the saved project under the name typed, and the list shows it', + empty.rows.length === 0 && + one.rows[0]?.name === 'Clean start' && + /^\d{4}-\d\d-\d\d \d\d:\d\d$/.test(one.rows[0]?.time ?? '') && + listed.length === 1 && + listed[0].revision === (await json('__gm.state.revision')) && + (await notice()) === 'Snapshot "Clean start" created', + JSON.stringify({ empty, one, listed, notice: await notice() }), + ); + await shot('50-snapshots'); + await clickLast('Cancel'); + + // 50b. A later stand: the project renamed through the settings, and saved. + await clickToolbar('menu'); + await clickText('Project settings…'); + const nameField = await locate(TEMP_NAME, true); + await t.doubleClick(nameField.x, nameField.y); + await press('a', { ctrl: true }); + await typeText(RENAMED); + await press('Enter'); + await clickText('Apply'); + await saved(); + const later = { + name: await json('__gm.state.project.name'), + revision: await json('__gm.state.revision'), + }; + + // 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads + // the project with an empty undo history, and tells so. + await openSnapshots(); + await clickLast('Clean start'); + await clickLast('Restore'); + await clickLast('Restore'); + await waitUntil('__gm.state.notice.startsWith("Restored")', { label: 'the restore finished' }); + const restored = { + name: await json('__gm.state.project.name'), + revision: await json('__gm.state.revision'), + canUndo: await json('__gm.state.commands.canUndo'), + notice: await notice(), + stored: (await stored()).name, + list: (await (await api('GET', `projects/${made.id}/snapshots`)).json()).map((s) => s.name), + }; + check( + 'Restore brings the snapshot back as a new revision and keeps the replaced stand as a snapshot', + later.name === RENAMED && + restored.name === TEMP_NAME && + restored.stored === TEMP_NAME && + restored.revision === later.revision + 1 && + restored.canUndo === false && + JSON.stringify(restored.list) === JSON.stringify(['Before restoring Clean start', 'Clean start']) && + restored.notice === + 'Restored "Clean start" – the previous stand is kept as "Before restoring Clean start"', + JSON.stringify({ later, restored }), + ); + + // 50d. That kept stand restores like any other: the restore is taken back. + await openSnapshots(); + await clickLast('Before restoring Clean start'); + await clickLast('Restore'); + await clickLast('Restore'); + await waitUntil('__gm.state.notice.startsWith("Restored \\"Before")', { + label: 'the second restore finished', + }); + check( + 'restoring the kept stand takes the restore back', + (await json('__gm.state.project.name')) === RENAMED && (await stored()).name === RENAMED, + JSON.stringify({ name: await json('__gm.state.project.name'), stored: (await stored()).name }), + ); + + // 50e. Delete asks, then takes the one picked out of the list. + await openSnapshots(); + const before = (await json('__gm.snapshots()')).rows.map((r) => r.name); + await clickLast('Clean start'); + await clickLast('Delete'); + await clickLast('Delete'); + await waitUntil(`__gm.snapshots()?.rows.length === ${before.length - 1}`, { + label: 'the dialog shows one snapshot fewer', + }); + const after = (await json('__gm.snapshots()')).rows.map((r) => r.name); + check( + 'Delete removes the snapshot picked and nothing else', + before.includes('Clean start') && + !after.includes('Clean start') && + after.length === before.length - 1 && + after.every((name) => before.includes(name)), + JSON.stringify({ before, after }), + ); + await clickLast('Cancel'); + + // 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards. + await api('DELETE', `projects/${made.id}`); + const gone = await api('GET', `projects/${made.id}/snapshots`); + check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status)); + } finally { + await sweep(); + await navigate(); + } +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 3cc78760..c88cc085 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -52,6 +52,7 @@ import { topbar } from './46-topbar.mjs'; import { vectorArt } from './47-vectorart.mjs'; import { pixelArt } from './48-pixelart.mjs'; import { users } from './49-users.mjs'; +import { snapshots } from './50-snapshots.mjs'; export const STEPS = [ hierarchy, @@ -103,4 +104,5 @@ export const STEPS = [ vectorArt, pixelArt, users, + snapshots, ];