feat(canvas): undo/redo command pattern + history slice

Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.

useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
  clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
  recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
  recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
  cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
  endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
  clearHistory inline — leaving stale commands pointing at IDs that no
  longer exist would crash on undo.

Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
  growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
  on drag). Snapshot/diff middleware would either burn memory tracking
  them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
  free with this approach; zundo/travels would need to infer them.

Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.

Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-08 12:03:17 -03:00
parent 5114c40af5
commit 99ed22ba5d
2 changed files with 532 additions and 1 deletions

View File

@ -0,0 +1,227 @@
/**
* Unit tests for the undo/redo history slice in useSimulatorStore.
*
* The store is a singleton, so each test resets it to a known baseline
* via the existing `setComponents([])` / `setWires([])` mutators (which
* also clear history see beforeEach).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import type { Wire } from '../types/wire';
// Stub out anything that touches a custom-element or a real simulator —
// these tests only exercise the store's data layer.
vi.mock('../utils/pinPositionCalculator', () => ({
// updateWirePositions calls this; returning null = "fall back to raw x/y".
calculatePinPosition: () => null,
}));
const led = (id: string, x = 0, y = 0) => ({
id,
metadataId: 'led',
x,
y,
properties: { color: 'red' },
});
const wire = (id: string, fromId: string, toId: string): Wire => ({
id,
start: { componentId: fromId, pinName: 'A', x: 0, y: 0 },
end: { componentId: toId, pinName: 'C', x: 0, y: 0 },
waypoints: [],
color: '#22c55e',
});
beforeEach(() => {
// Wipe the canvas + history before every test.
const s = useSimulatorStore.getState();
s.setComponents([]);
s.setWires([]);
});
describe('history primitives', () => {
it('starts empty', () => {
const s = useSimulatorStore.getState();
expect(s.history).toEqual([]);
expect(s.historyIndex).toBe(-1);
expect(s.canUndo()).toBe(false);
expect(s.canRedo()).toBe(false);
});
it('caps at HISTORY_MAX (50)', () => {
const s = useSimulatorStore.getState();
for (let i = 0; i < 60; i++) {
s.recordAddComponent(led(`led-${i}`));
}
const after = useSimulatorStore.getState();
expect(after.history.length).toBe(50);
expect(after.historyIndex).toBe(49);
// The 10 oldest commands were dropped — undoing 50 times should NOT
// restore the canvas to fully empty.
for (let i = 0; i < 50; i++) after.undo();
const afterUndo = useSimulatorStore.getState();
// The first 10 leds were never undoable — they stay on the canvas.
expect(afterUndo.components.length).toBe(10);
});
it('truncates the redo branch when a new command pushes mid-history', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('a'));
s.recordAddComponent(led('b'));
s.recordAddComponent(led('c'));
s.undo(); // c removed, history still has 3 entries, index=1
s.undo(); // b removed, index=0
expect(useSimulatorStore.getState().historyIndex).toBe(0);
expect(useSimulatorStore.getState().history.length).toBe(3);
s.recordAddComponent(led('d')); // truncates b/c, pushes d
const after = useSimulatorStore.getState();
expect(after.history.length).toBe(2); // a + d
expect(after.historyIndex).toBe(1);
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'd']);
});
});
describe('recordAddComponent', () => {
it('adds the component and pushes a command', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
expect(useSimulatorStore.getState().components).toHaveLength(1);
expect(useSimulatorStore.getState().history).toHaveLength(1);
expect(useSimulatorStore.getState().canUndo()).toBe(true);
});
it('undo removes it; redo restores it', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.undo();
expect(useSimulatorStore.getState().components).toHaveLength(0);
expect(useSimulatorStore.getState().canUndo()).toBe(false);
expect(useSimulatorStore.getState().canRedo()).toBe(true);
s.redo();
expect(useSimulatorStore.getState().components).toHaveLength(1);
});
});
describe('recordRemoveComponent (cascade)', () => {
it('undo restores BOTH the component and its connected wires', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
s.addWire(wire('w1', 'a', 'b'));
s.addWire(wire('w2', 'a', 'b'));
// Remove 'a' — cascade should kill both wires.
s.recordRemoveComponent('a');
expect(useSimulatorStore.getState().components.map((c) => c.id)).toEqual(['b']);
expect(useSimulatorStore.getState().wires).toEqual([]);
s.undo();
const after = useSimulatorStore.getState();
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'b']);
expect(after.wires.map((w) => w.id).sort()).toEqual(['w1', 'w2']);
});
it('no-ops cleanly on a missing id', () => {
const s = useSimulatorStore.getState();
s.recordRemoveComponent('does-not-exist');
expect(useSimulatorStore.getState().history).toHaveLength(0);
});
});
describe('recordMove', () => {
it('captures from/to, undo restores from, redo restores to', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('m', 100, 100));
// Simulate a drag — UI mutated to (200,200) directly via raw mutator.
s.updateComponent('m', { x: 200, y: 200 });
// Drag-end records the diff.
s.recordMove('m', { x: 100, y: 100 }, { x: 200, y: 200 });
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
s.undo();
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 100, y: 100 });
s.redo();
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
});
});
describe('recordRotate', () => {
it('flips rotation property both directions', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('r'));
s.recordRotate('r', 0, 90);
s.updateComponent('r', { properties: { color: 'red', rotation: 90 } });
s.undo();
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(0);
s.redo();
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(90);
});
});
describe('recordSetProperty', () => {
it('undo/redo flips a property value', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('p'));
s.updateComponent('p', { properties: { color: 'green' } });
s.recordSetProperty('p', 'color', 'red', 'green');
s.undo();
expect(useSimulatorStore.getState().components[0].properties.color).toBe('red');
s.redo();
expect(useSimulatorStore.getState().components[0].properties.color).toBe('green');
});
});
describe('recordAddWire / recordRemoveWire', () => {
it('add: undo removes, redo restores', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
s.recordAddWire(wire('w', 'a', 'b'));
expect(useSimulatorStore.getState().wires).toHaveLength(1);
s.undo();
expect(useSimulatorStore.getState().wires).toHaveLength(0);
s.redo();
expect(useSimulatorStore.getState().wires).toHaveLength(1);
});
it('remove: undo brings back the wire intact', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
const w = wire('w', 'a', 'b');
s.addWire(w);
s.recordRemoveWire('w');
expect(useSimulatorStore.getState().wires).toHaveLength(0);
s.undo();
expect(useSimulatorStore.getState().wires[0]).toEqual(w);
});
});
describe('bulk setters clear history', () => {
it('setComponents wipes the stack', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.recordAddComponent(led('y'));
expect(useSimulatorStore.getState().history).toHaveLength(2);
s.setComponents([]);
expect(useSimulatorStore.getState().history).toHaveLength(0);
expect(useSimulatorStore.getState().historyIndex).toBe(-1);
});
it('setWires wipes the stack', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.setWires([]);
expect(useSimulatorStore.getState().history).toHaveLength(0);
});
});
describe('clearHistory', () => {
it('resets index and entries without touching components/wires', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('keep'));
s.clearHistory();
const after = useSimulatorStore.getState();
expect(after.components).toHaveLength(1); // component still there
expect(after.history).toHaveLength(0);
expect(after.canUndo()).toBe(false);
});
});

View File

@ -335,6 +335,32 @@ interface Component {
properties: Record<string, unknown>;
}
// ── Undo/redo history ────────────────────────────────────────────────────
/**
* One entry on the canvas undo/redo stack.
*
* description human-readable label shown as the undo/redo button
* tooltip ("Undo: Move LED").
* execute() applied on redo. Should be idempotent against the
* current state at redo time (the user may have undone
* several steps then started a new branch).
* undo() reverts the change. Same idempotency contract.
*
* Commands that capture the inverse on construction (e.g. `recordMove`
* captures fromX/fromY) are pushed with `applyNow:false` because the
* mutation already happened the command only needs to remember how to
* undo/redo it later. Commands that ARE the canonical mutation (e.g.
* `recordAddComponent`) are pushed with `applyNow:true` so a single call
* both performs the action and stores the undo path.
*/
export interface CanvasCommand {
description: string;
execute(): void;
undo(): void;
}
const HISTORY_MAX = 50;
// ── Store interface ───────────────────────────────────────────────────────
interface SimulatorState {
// ── Multi-board state ───────────────────────────────────────────────────
@ -436,6 +462,44 @@ interface SimulatorState {
updateWirePositions: (componentId: string) => void;
recalculateAllWirePositions: () => void;
// ── Undo/redo ────────────────────────────────────────────────────────────
/** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */
history: CanvasCommand[];
/** Index of the last APPLIED command. -1 = empty / fully undone. */
historyIndex: number;
/** Push a command and (by default) execute it. Truncates the redo stack. */
pushCommand: (cmd: CanvasCommand, opts?: { applyNow?: boolean }) => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
/** Wipe the stack (called on project load / clear). */
clearHistory: () => void;
/**
* Recorded canvas actions these are the public API the UI and agent
* tools should use to mutate the canvas. Each one wraps a raw mutator
* with a CanvasCommand so the change is undoable. Drag-preview frames
* still use the raw mutators (addComponent / updateComponent / addWire
* / removeWire / updateWire) which DO NOT touch history.
*/
recordAddComponent: (component: Component) => void;
recordRemoveComponent: (id: string) => void;
recordMove: (
id: string,
from: { x: number; y: number },
to: { x: number; y: number },
) => void;
recordRotate: (id: string, prevRotation: number, nextRotation: number) => void;
recordSetProperty: (id: string, key: string, prevValue: unknown, nextValue: unknown) => void;
recordAddWire: (wire: Wire) => void;
recordRemoveWire: (wireId: string) => void;
recordUpdateWire: (
wireId: string,
prev: Partial<Wire>,
next: Partial<Wire>,
description?: string,
) => void;
// ── Serial monitor ──────────────────────────────────────────────────────
toggleSerialMonitor: () => void;
serialWrite: (text: string) => void;
@ -1503,7 +1567,11 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
handleComponentEvent: (_componentId, _eventName, _data) => {},
setComponents: (components) => set({ components }),
setComponents: (components) => {
// Bulk replacement (project load / clear) — any pending undo/redo
// would point at component IDs that no longer exist after this.
set({ components, history: [], historyIndex: -1 });
},
addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })),
@ -1524,6 +1592,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
set({
// Ensure every wire has waypoints (backwards-compatible with saved projects)
wires: wires.map((w) => ({ waypoints: [], ...w })),
// Bulk replacement clears history for the same reason as setComponents.
history: [],
historyIndex: -1,
}),
startWireCreation: (endpoint, color) =>
@ -1645,6 +1716,239 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
set({ wires: updatedWires });
},
// ── Undo/redo ──────────────────────────────────────────────────────────
history: [],
historyIndex: -1,
pushCommand: (cmd, opts) => {
const applyNow = opts?.applyNow ?? true;
if (applyNow) cmd.execute();
set((state) => {
// Truncate the redo branch — once you push a new command, the
// entries you'd previously redone are abandoned.
const truncated = state.history.slice(0, state.historyIndex + 1);
let next = [...truncated, cmd];
let nextIdx = next.length - 1;
// Cap at HISTORY_MAX. When over, drop the oldest entry and shift
// the index down so it still points at the just-pushed command.
if (next.length > HISTORY_MAX) {
const overflow = next.length - HISTORY_MAX;
next = next.slice(overflow);
nextIdx = next.length - 1;
}
return { history: next, historyIndex: nextIdx };
});
},
undo: () => {
const state = get();
if (state.historyIndex < 0) return;
const cmd = state.history[state.historyIndex];
try {
cmd.undo();
} catch (err) {
// A failing undo would otherwise leave the index pointing at a
// half-applied command. Bail out cleanly.
// eslint-disable-next-line no-console
console.error('[history] undo failed:', cmd.description, err);
return;
}
set({ historyIndex: state.historyIndex - 1 });
},
redo: () => {
const state = get();
if (state.historyIndex >= state.history.length - 1) return;
const cmd = state.history[state.historyIndex + 1];
try {
cmd.execute();
} catch (err) {
// eslint-disable-next-line no-console
console.error('[history] redo failed:', cmd.description, err);
return;
}
set({ historyIndex: state.historyIndex + 1 });
},
canUndo: () => get().historyIndex >= 0,
canRedo: () => {
const s = get();
return s.historyIndex < s.history.length - 1;
},
clearHistory: () => set({ history: [], historyIndex: -1 }),
// ── Recorded canvas actions ────────────────────────────────────────────
// Each `record*` builds a CanvasCommand that captures both directions
// and pushes it. Naming intent: the user has *committed* a change
// (drag-end, click finalised, agent tool execute) — distinct from the
// raw mutators above which can be called per-frame during a drag.
recordAddComponent: (component) => {
get().pushCommand({
description: `Add ${component.metadataId}`,
execute: () =>
set((s) => ({ components: [...s.components, component] })),
undo: () =>
set((s) => ({
components: s.components.filter((c) => c.id !== component.id),
// Mirror the cascade in removeComponent so a redo→undo round
// trip of an add-then-wired pair stays consistent.
wires: s.wires.filter(
(w) =>
w.start.componentId !== component.id && w.end.componentId !== component.id,
),
})),
});
},
recordRemoveComponent: (id) => {
const state = get();
const removed = state.components.find((c) => c.id === id);
if (!removed) return;
// Capture wires that will be cascaded too — undo must restore both
// the component AND its wires together.
const removedWires = state.wires.filter(
(w) => w.start.componentId === id || w.end.componentId === id,
);
get().pushCommand({
description: `Remove ${removed.metadataId}`,
execute: () =>
set((s) => ({
components: s.components.filter((c) => c.id !== id),
wires: s.wires.filter(
(w) => w.start.componentId !== id && w.end.componentId !== id,
),
})),
undo: () =>
set((s) => ({
components: [...s.components, removed],
wires: [...s.wires, ...removedWires],
})),
});
},
recordMove: (id, from, to) => {
// The state is already at `to` (caller mutated during drag). We push
// applyNow:false so we don't redundantly re-apply on first push;
// execute()/undo() are only invoked on future redo/undo.
get().pushCommand(
{
description: 'Move component',
execute: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id ? { ...c, x: to.x, y: to.y } : c,
),
}));
get().updateWirePositions(id);
},
undo: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id ? { ...c, x: from.x, y: from.y } : c,
),
}));
get().updateWirePositions(id);
},
},
{ applyNow: false },
);
},
recordRotate: (id, prevRotation, nextRotation) => {
get().pushCommand(
{
description: 'Rotate component',
execute: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
: c,
),
})),
undo: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
: c,
),
})),
},
{ applyNow: false },
);
},
recordSetProperty: (id, key, prevValue, nextValue) => {
get().pushCommand(
{
description: `Change ${key}`,
execute: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, [key]: nextValue } }
: c,
),
})),
undo: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, [key]: prevValue } }
: c,
),
})),
},
{ applyNow: false },
);
},
recordAddWire: (wire) => {
get().pushCommand({
description: 'Add wire',
execute: () => set((s) => ({ wires: [...s.wires, wire] })),
undo: () =>
set((s) => ({
wires: s.wires.filter((w) => w.id !== wire.id),
selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId,
})),
});
},
recordRemoveWire: (wireId) => {
const removed = get().wires.find((w) => w.id === wireId);
if (!removed) return;
get().pushCommand({
description: 'Remove wire',
execute: () =>
set((s) => ({
wires: s.wires.filter((w) => w.id !== wireId),
selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId,
})),
undo: () => set((s) => ({ wires: [...s.wires, removed] })),
});
},
recordUpdateWire: (wireId, prev, next, description = 'Update wire') => {
get().pushCommand(
{
description,
execute: () =>
set((s) => ({
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)),
})),
undo: () =>
set((s) => ({
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)),
})),
},
{ applyNow: false },
);
},
toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })),
serialWrite: (text: string) => {