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>
86 lines
3.5 KiB
TypeScript
86 lines
3.5 KiB
TypeScript
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);
|
||
}
|
||
}
|