Browse Source
Standalone undo manager with push/pop/clear/max-depth (50) logic, independent of Zustand. Includes 11 unit tests covering all operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 199 additions and 0 deletions
@ -0,0 +1,148 @@ |
|||||
|
import { describe, it, expect, beforeEach } from "vitest"; |
||||
|
import { UndoManager, UndoSnapshot } from "../undoHistory"; |
||||
|
|
||||
|
function makeSnapshot(label: string): UndoSnapshot { |
||||
|
return { |
||||
|
nodes: [{ id: label }] as UndoSnapshot["nodes"], |
||||
|
edges: [], |
||||
|
groups: {}, |
||||
|
edgeStyle: "curved", |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("UndoManager", () => { |
||||
|
let manager: UndoManager; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
manager = new UndoManager(); |
||||
|
}); |
||||
|
|
||||
|
it("starts with empty stacks", () => { |
||||
|
expect(manager.canUndo).toBe(false); |
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("push adds to undo stack", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
expect(manager.canUndo).toBe(true); |
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("undo pops from undo stack and pushes current to redo", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
const current = makeSnapshot("B"); |
||||
|
const result = manager.undo(current); |
||||
|
expect(result?.nodes[0].id).toBe("A"); |
||||
|
expect(manager.canUndo).toBe(false); |
||||
|
expect(manager.canRedo).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("undo returns null when stack is empty", () => { |
||||
|
const result = manager.undo(makeSnapshot("current")); |
||||
|
expect(result).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("redo pops from redo stack and pushes current to undo", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
const current = makeSnapshot("B"); |
||||
|
manager.undo(current); |
||||
|
// now redo stack has B, undo stack is empty
|
||||
|
const redone = manager.redo(makeSnapshot("A-restored")); |
||||
|
expect(redone?.nodes[0].id).toBe("B"); |
||||
|
expect(manager.canUndo).toBe(true); |
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("redo returns null when stack is empty", () => { |
||||
|
const result = manager.redo(makeSnapshot("current")); |
||||
|
expect(result).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("push clears redo stack", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
manager.push(makeSnapshot("B")); |
||||
|
const current = makeSnapshot("C"); |
||||
|
manager.undo(current); // redo has C
|
||||
|
expect(manager.canRedo).toBe(true); |
||||
|
manager.push(makeSnapshot("D")); // new action clears redo
|
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("respects max depth of 50", () => { |
||||
|
for (let i = 0; i < 60; i++) { |
||||
|
manager.push(makeSnapshot(`snap-${i}`)); |
||||
|
} |
||||
|
// Should have exactly 50 entries
|
||||
|
let count = 0; |
||||
|
while (manager.canUndo) { |
||||
|
manager.undo(makeSnapshot("current")); |
||||
|
count++; |
||||
|
} |
||||
|
expect(count).toBe(50); |
||||
|
}); |
||||
|
|
||||
|
it("oldest entries are pruned when exceeding max depth", () => { |
||||
|
for (let i = 0; i < 55; i++) { |
||||
|
manager.push(makeSnapshot(`snap-${i}`)); |
||||
|
} |
||||
|
// The first undo should give us snap-54 (most recent), and the oldest available should be snap-5
|
||||
|
const result = manager.undo(makeSnapshot("current")); |
||||
|
expect(result?.nodes[0].id).toBe("snap-54"); |
||||
|
|
||||
|
// Undo 48 more times to get to the oldest
|
||||
|
let last: UndoSnapshot | null = null; |
||||
|
for (let i = 0; i < 48; i++) { |
||||
|
last = manager.undo(makeSnapshot("temp")); |
||||
|
} |
||||
|
// last should be snap-6
|
||||
|
expect(last?.nodes[0].id).toBe("snap-6"); |
||||
|
|
||||
|
// One more undo to get the oldest
|
||||
|
const oldest = manager.undo(makeSnapshot("temp")); |
||||
|
expect(oldest?.nodes[0].id).toBe("snap-5"); |
||||
|
|
||||
|
// No more undo available
|
||||
|
expect(manager.canUndo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("clear empties both stacks", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
manager.push(makeSnapshot("B")); |
||||
|
manager.undo(makeSnapshot("C")); // creates redo entry
|
||||
|
expect(manager.canUndo).toBe(true); |
||||
|
expect(manager.canRedo).toBe(true); |
||||
|
manager.clear(); |
||||
|
expect(manager.canUndo).toBe(false); |
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("multiple undo/redo cycles work correctly", () => { |
||||
|
manager.push(makeSnapshot("A")); |
||||
|
manager.push(makeSnapshot("B")); |
||||
|
manager.push(makeSnapshot("C")); |
||||
|
|
||||
|
// Undo 3 times
|
||||
|
const r1 = manager.undo(makeSnapshot("D")); |
||||
|
expect(r1?.nodes[0].id).toBe("C"); |
||||
|
|
||||
|
const r2 = manager.undo(makeSnapshot("C")); |
||||
|
expect(r2?.nodes[0].id).toBe("B"); |
||||
|
|
||||
|
const r3 = manager.undo(makeSnapshot("B")); |
||||
|
expect(r3?.nodes[0].id).toBe("A"); |
||||
|
|
||||
|
expect(manager.canUndo).toBe(false); |
||||
|
|
||||
|
// Redo 3 times
|
||||
|
const f1 = manager.redo(makeSnapshot("A")); |
||||
|
expect(f1?.nodes[0].id).toBe("B"); |
||||
|
|
||||
|
const f2 = manager.redo(makeSnapshot("B")); |
||||
|
expect(f2?.nodes[0].id).toBe("C"); |
||||
|
|
||||
|
const f3 = manager.redo(makeSnapshot("C")); |
||||
|
expect(f3?.nodes[0].id).toBe("D"); |
||||
|
|
||||
|
expect(manager.canRedo).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,51 @@ |
|||||
|
import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; |
||||
|
import type { EdgeStyle } from "./workflowStore"; |
||||
|
|
||||
|
export interface UndoSnapshot { |
||||
|
nodes: WorkflowNode[]; |
||||
|
edges: WorkflowEdge[]; |
||||
|
groups: Record<string, NodeGroup>; |
||||
|
edgeStyle: EdgeStyle; |
||||
|
} |
||||
|
|
||||
|
const MAX_HISTORY = 50; |
||||
|
|
||||
|
export class UndoManager { |
||||
|
private undoStack: UndoSnapshot[] = []; |
||||
|
private redoStack: UndoSnapshot[] = []; |
||||
|
|
||||
|
get canUndo(): boolean { |
||||
|
return this.undoStack.length > 0; |
||||
|
} |
||||
|
|
||||
|
get canRedo(): boolean { |
||||
|
return this.redoStack.length > 0; |
||||
|
} |
||||
|
|
||||
|
push(snapshot: UndoSnapshot): void { |
||||
|
this.undoStack.push(snapshot); |
||||
|
if (this.undoStack.length > MAX_HISTORY) { |
||||
|
this.undoStack.shift(); |
||||
|
} |
||||
|
this.redoStack = []; |
||||
|
} |
||||
|
|
||||
|
undo(currentState: UndoSnapshot): UndoSnapshot | null { |
||||
|
const previous = this.undoStack.pop(); |
||||
|
if (!previous) return null; |
||||
|
this.redoStack.push(currentState); |
||||
|
return previous; |
||||
|
} |
||||
|
|
||||
|
redo(currentState: UndoSnapshot): UndoSnapshot | null { |
||||
|
const next = this.redoStack.pop(); |
||||
|
if (!next) return null; |
||||
|
this.undoStack.push(currentState); |
||||
|
return next; |
||||
|
} |
||||
|
|
||||
|
clear(): void { |
||||
|
this.undoStack = []; |
||||
|
this.redoStack = []; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue