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/<project>/ (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 <name>". 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
17a6fd0339
commit
94749b4f3e
18 files changed
+849
-7
No files matched your search
@@ -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<ProjectSettingsDialog>();
|
||||
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
||||
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
|
||||
|
||||
/**
|
||||
* `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<void> },
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user