* Add bumpEventSequenceUp, to re-send an older copy as a new revision Google answers 409 to a VEVENT whose SEQUENCE is below the one it holds. Undo and redo re-send a snapshot of the event, so each VEVENT that differs from the copy last sent moves one past the higher of the two and gets a fresh DTSTAMP. A VEVENT that matches apart from those takes the last copy's, so guests see no revision of an occurrence nobody changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Send a calendar undo or redo past the revision it replaces Undo re-sent the pre-edit snapshot verbatim, SEQUENCE included, so after a move Google held a higher SEQUENCE and answered 409. Redo had the same problem after an undo, and so did undoing two edits to one event in turn. Each window now records the ICS it last queued per event, and undo and redo send their snapshot through bumpEventSequenceUp against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Match occurrences by instant when bumping an older copy's SEQUENCE Google stores a RECURRENCE-ID sent in UTC back in the event's zone, so the same occurrence reads as 20261022T103000Z in the copy we sent and TZID=America/Chicago:20261022T053000 in the row it returns. Compared as text, an unchanged override found no counterpart and was bumped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Send an undo or redo at the SEQUENCE last sent rather than past it Google accepts a tie and rejects only a lower SEQUENCE. A bump lifted an override past the master + 1 that createRecurrenceException restarts it from, so the next edit of that occurrence came back 409, which a live re-edit after undo and redo confirmed. bumpEventSequenceUp becomes matchEventSequence. Whether non-Google guests apply an update at a tied SEQUENCE is untested; plans/calendar-open-gaps.md records it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put back an override an undo leaves out, at its slot Google keeps an override VEVENT that a PUT omits, so undoing the first edit to one occurrence was accepted and changed nothing: a moved occurrence stayed moved. The undo now sends that override back with the master's content at its own slot, the occurrence as the series has it. A slot the restored copy's EXDATEs cancel stays cancelled, so redoing the cancellation of an overridden occurrence doesn't revive it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record that an Outlook.com guest applies a tied update Live: Google's update emails carried our SEQUENCE through with its own fresh DTSTAMP, and an Outlook.com guest applied an undo and a re-edit sent at the same SEQUENCE as the change before them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep only the untested part of the guest SEQUENCE note in the gaps doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revert only the overrides the undone edit added, and only when the series is unchanged Putting back every override the last copy had and the restored copy lacked, matched by RECURRENCE-ID, misfired twice. A series move relabels its overrides, so undoing one added an override at a slot the restored series never generates. An occurrence response made after the undone edit came back with the series' content and the old PARTSTAT. revertAddedOverrides now runs on undo only, against the edit's own before and after copies, and skips edits that changed the master. A reverted all-day override takes the series' duration in days, so it stays one day long on a DST change. Adds specs that fail without redo's matchEventSequence call and without undo recording what it queued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revert an override when the restored series still has its slot Requiring the undone edit to have added the override, with the master unchanged, missed two cases. Undoing a series edit after undoing an occurrence edit left that occurrence with the newer title, because the override the first undo sent was never rebuilt. And adding the first guest to one occurrence writes ORGANIZER onto the master, so nothing was reverted. revertAddedOverrides now takes the copy last sent, runs on undo and redo, and rebuilds every override that copy has and the restored one lacks, as long as the restored series still generates that slot. A slot a series move relabelled, or an EXDATE cancelled, is skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
7.5 KiB
Undo/Redo Task Pattern in Mailspring
This document explains how to implement undoable tasks in Mailspring following the established patterns.
Overview
Mailspring uses a task-based architecture where operations are represented as Task objects that get queued and executed by the sync engine. The UndoRedoStore automatically tracks tasks that can be undone and provides undo/redo functionality.
How It Works
Automatic Registration
When tasks are queued via Actions.queueTask() or Actions.queueTasks(), UndoRedoStore._onQueue() registers one undo block for them if every task has canBeUndone set. Undo queues each task's createUndoTasks(), which defaults to [createUndoTask()]; redo queues createIdenticalTask(). Tasks marked engineWritesUndoData are reversed from the version the engine streams back (Pattern 3). Actions.queueUndoOnlyTask() registers a task for undo without sending it to the engine (Undo Send).
Task Requirements
For a task to be undoable, it must:
- Set
canBeUndonetotrue. It is a field onTask, so assign it in the constructor; a subclass getter is a type error (TS2611). - Implement
createUndoTask()that returns a task to reverse the operation - Implement
description()for the undo toast message
Patterns
Pattern 1: Simple State Toggle (ChangeStarredTask)
For operations that toggle a boolean state:
export class ChangeStarredTask extends ChangeMailTask {
starred: boolean;
// Inherited from ChangeMailTask: canBeUndone defaults to true
createUndoTask() {
const task = super.createUndoTask();
task.starred = !this.starred; // Simply flip the flag
return task;
}
}
Pattern 2: State Snapshot (SyncbackMetadataTask, SyncbackEventTask)
For operations where the inverse isn't a simple toggle, snapshot the state on the task. See SyncbackEventTask (app/src/flux/tasks/syncback-event-task.ts):
forUpdating({ event, undoData, description })takesundoData, the state from before the edit, and snapshots the state being written asnewData.- The constructor sets
this.canBeUndone = !!this.undoData, so a task built withoutundoData(creation, for example) is not undoable. createUndoTask()restoresundoDataand swaps the two snapshots, so undoing the undo reapplies the edit.createIdenticalTask()(redo) rebuilds the event fromnewDatarather than readingthis.event, which may have been mutated since the task was queued.- Both re-send their snapshot through
matchEventSequence, measured against the ICS this window last queued for the event. A snapshot carries the SEQUENCE it had when captured, and Google answers 409 to one below the SEQUENCE it holds, so a snapshot cannot be sent verbatim. A tie is accepted, and changed VEVENTs go out at one. Both first runrevertAddedOverrides, because Google keeps an override a PUT leaves out: an occurrence overridden since the snapshot goes back as the snapshot's series has it.
SyncbackMetadataTask follows the same shape with undoValue.
Pattern 3: Engine-Written Snapshot (ChangeFolderTask)
Sometimes the client cannot know the original state when it queues the task. A folder move
is the canonical case: a message may have copies in several folders, and only the sync
engine knows which copies it moved and from where. The engine writes that snapshot onto the
task's data during its local phase (the same mechanism DestroyDraftTask uses to receive
stubIds), and the task streams back to the client as a Task persist delta.
export class ChangeFolderTask extends ChangeMailTask {
folder: Folder;
sourceFolderIds: string[];
undoPlacements?: SourceFoldersByMessageId; // written by the engine: where each moved copy came from
restorePlacements?: SourceFoldersByMessageId; // read by the engine on the undo task
engineWritesUndoData = true; // tells UndoRedoStore to wait for the engine's version
createUndoTasks() {
const task = super.createUndoTask(); // isUndo = true
task.folder = this._firstRestoreFolder(); // for the description only
task.sourceFolderIds = [this.folder.id]; // the copies to send back are in the destination
task.restorePlacements = this.undoPlacements;
return [task];
}
}
Three consequences for anyone using this pattern:
- Set
engineWritesUndoData = trueon the task class.UndoRedoStore.undo()then resolves each such task throughTaskQueue.waitForPerformLocal()before building the undo, so it runs against the engine-updated version. The wait is bounded (and abandoned early when the engine never echoes the task back), so an offline engine degrades to an approximate undo rather than none. Tasks without the flag, and tasks registered throughActions.queueUndoOnlyTask, are reversed immediately from the client's copy. - Implement
createUndoTasks()when the approximate undo can need several tasks (one per original folder), and return[]when nothing can be reversed;createUndoTask()stays for callers that can only queue one. createIdenticalTask()(used for redo) must strip the engine-written field so a re-run starts with a clean snapshot.
Implementation Steps
Step 1: Add Undo Data Attributes
Add attributes to store the original state:
static attributes = {
...Task.attributes,
undoData: Attributes.Obj({
modelKey: 'undoData',
}),
taskDescription: Attributes.String({
modelKey: 'taskDescription',
}),
};
Step 2: Capture State Before Modification
Before modifying the model, capture its current state:
function modifyEvent(event: Event, newData: EventData) {
// Capture BEFORE modifying
const undoData = {
ics: event.ics,
recurrenceStart: event.recurrenceStart,
recurrenceEnd: event.recurrenceEnd,
};
// Now modify
event.ics = newData.ics;
event.recurrenceStart = newData.start;
event.recurrenceEnd = newData.end;
// Queue with undo support
Actions.queueTask(SyncbackEventTask.forUpdating({
event,
undoData,
description: localized('Edit event'),
}));
}
Step 3: Set canBeUndone and implement createUndoTask
constructor(data: AttributeValues<typeof YourTask.attributes> = {}) {
super(data);
this.canBeUndone = !!this.undoData;
}
createUndoTask(): YourTask {
if (!this.undoData) {
throw new Error('Cannot create undo task without undoData');
}
// Create task that restores original state
// Store current state in the undo task's undoData for redo
}
Anti-Patterns to Avoid
Don't Forget to Capture State First
❌ Wrong: Capturing after modification
event.ics = newIcs; // Modified first!
const undoData = { ics: event.ics }; // Too late - this is the new state
✅ Correct: Capture before modification
const undoData = { ics: event.ics }; // Capture original
event.ics = newIcs; // Now modify
Testing Undo
- Perform the action
- Press Cmd/Ctrl+Z to undo
- Verify the model is restored to original state
- Press Cmd/Ctrl+Shift+Z to redo
- Verify the model has the modified state again
Related Files
app/src/flux/stores/undo-redo-store.ts- UndoRedoStore implementationapp/src/flux/tasks/task.ts- Base Task classapp/src/flux/tasks/change-mail-task.ts- Base class for mail changesapp/src/flux/tasks/syncback-metadata-task.ts- Example of snapshot patternapp/src/flux/tasks/syncback-event-task.ts- Calendar event undo implementationapp/src/flux/tasks/change-folder-task.ts- Engine-written snapshot (undoPlacements)