Skip to content

Commit 6af05aa

Browse files
CopilotCopilot
andcommitted
feat(core): add batch undo/redo and persistent undo stack to UndoManager
- Add pushBatch() for atomic multi-operation push - Add popUndoBatch()/popRedoBatch() for batch undo/redo - Add saveToStorage()/loadFromStorage() for localStorage persistence - Add getRedoHistory() for completeness - Add comprehensive test suite (28 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cdeed72 commit 6af05aa

2 files changed

Lines changed: 418 additions & 0 deletions

File tree

packages/core/src/actions/UndoManager.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface UndoManagerOptions {
3333
maxHistory?: number;
3434
}
3535

36+
/** Type guard validating the required shape of a persisted UndoableOperation. */
37+
function isValidOperation(op: unknown): op is UndoableOperation {
38+
if (typeof op !== 'object' || op === null) return false;
39+
const o = op as Record<string, unknown>;
40+
return (
41+
typeof o.id === 'string' &&
42+
typeof o.type === 'string' &&
43+
typeof o.objectName === 'string' &&
44+
typeof o.recordId === 'string' &&
45+
typeof o.timestamp === 'number'
46+
);
47+
}
48+
3649
/**
3750
* Manages undo/redo stacks for CRUD operations.
3851
*
@@ -110,6 +123,91 @@ export class UndoManager {
110123
/** Get a shallow copy of the undo history (for developer tools). */
111124
getHistory(): UndoableOperation[] { return [...this.undoStack]; }
112125

126+
/** Get a shallow copy of the redo history (for developer tools). */
127+
getRedoHistory(): UndoableOperation[] { return [...this.redoStack]; }
128+
129+
// ---------------------------------------------------------------------------
130+
// Batch operations
131+
// ---------------------------------------------------------------------------
132+
133+
/** Push multiple operations as one atomic unit. Clears the redo stack. */
134+
pushBatch(operations: UndoableOperation[]): void {
135+
if (operations.length === 0) return;
136+
this.undoStack.push(...operations);
137+
// Trim from the front if we exceed maxHistory
138+
if (this.undoStack.length > this.maxHistory) {
139+
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
140+
}
141+
this.redoStack = [];
142+
this.notify();
143+
}
144+
145+
/** Pop `count` operations from the undo stack and move them to redo (LIFO order). */
146+
popUndoBatch(count: number): UndoableOperation[] {
147+
const actual = Math.min(count, this.undoStack.length);
148+
if (actual === 0) return [];
149+
const ops = this.undoStack.splice(this.undoStack.length - actual, actual);
150+
// Preserve LIFO order on the redo stack (last undone goes on top)
151+
this.redoStack.push(...ops);
152+
this.notify();
153+
return ops;
154+
}
155+
156+
/** Pop `count` operations from the redo stack and move them to undo (LIFO order). */
157+
popRedoBatch(count: number): UndoableOperation[] {
158+
const actual = Math.min(count, this.redoStack.length);
159+
if (actual === 0) return [];
160+
const ops = this.redoStack.splice(this.redoStack.length - actual, actual);
161+
this.undoStack.push(...ops);
162+
this.notify();
163+
return ops;
164+
}
165+
166+
// ---------------------------------------------------------------------------
167+
// Persistence (localStorage)
168+
// ---------------------------------------------------------------------------
169+
170+
private static readonly STORAGE_KEY = 'objectui:undo-history';
171+
172+
/** Persist the current undo/redo stacks to localStorage. */
173+
saveToStorage(): void {
174+
try {
175+
const payload = JSON.stringify({
176+
undoStack: this.undoStack,
177+
redoStack: this.redoStack,
178+
});
179+
localStorage.setItem(UndoManager.STORAGE_KEY, payload);
180+
} catch {
181+
// localStorage may be unavailable (SSR, quota exceeded, etc.)
182+
}
183+
}
184+
185+
/** Restore undo/redo stacks from localStorage (no-op when unavailable). */
186+
loadFromStorage(): void {
187+
try {
188+
if (typeof localStorage === 'undefined') return;
189+
const raw = localStorage.getItem(UndoManager.STORAGE_KEY);
190+
if (!raw) return;
191+
const parsed = JSON.parse(raw) as {
192+
undoStack?: UndoableOperation[];
193+
redoStack?: UndoableOperation[];
194+
};
195+
if (Array.isArray(parsed.undoStack)) {
196+
this.undoStack = parsed.undoStack.filter(isValidOperation);
197+
}
198+
if (Array.isArray(parsed.redoStack)) {
199+
this.redoStack = parsed.redoStack.filter(isValidOperation);
200+
}
201+
// Enforce maxHistory in case persisted state used a different limit
202+
if (this.undoStack.length > this.maxHistory) {
203+
this.undoStack.splice(0, this.undoStack.length - this.maxHistory);
204+
}
205+
this.notify();
206+
} catch {
207+
// Silently ignore parse errors or missing storage
208+
}
209+
}
210+
113211
private notify(): void { this.listeners.forEach((fn) => fn()); }
114212
}
115213

0 commit comments

Comments
 (0)