jotai-state-tree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/undo.ts ADDED
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Undo/Redo Manager for jotai-state-tree
3
+ * Provides time-travel debugging capabilities
4
+ */
5
+
6
+ import type { IDisposer, IJsonPatch, IReversibleJsonPatch } from './types';
7
+ import { getStateTreeNode, applyPatch, onPatch, getSnapshot, applySnapshot } from './tree';
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface IUndoManagerOptions {
14
+ /** Maximum number of history entries to keep */
15
+ maxHistoryLength?: number;
16
+ /** Whether to group rapid changes together */
17
+ groupByTime?: boolean;
18
+ /** Time window for grouping changes (ms) */
19
+ groupingWindow?: number;
20
+ }
21
+
22
+ export interface IHistoryEntry {
23
+ /** Patches to apply to undo this entry */
24
+ patches: IReversibleJsonPatch[];
25
+ /** Patches to apply to redo this entry */
26
+ inversePatches: IReversibleJsonPatch[];
27
+ /** Timestamp when this entry was created */
28
+ timestamp: number;
29
+ }
30
+
31
+ export interface IUndoManager {
32
+ /** Whether there are entries that can be undone */
33
+ readonly canUndo: boolean;
34
+ /** Whether there are entries that can be redone */
35
+ readonly canRedo: boolean;
36
+ /** Number of undo entries available */
37
+ readonly undoLevels: number;
38
+ /** Number of redo entries available */
39
+ readonly redoLevels: number;
40
+ /** The full history */
41
+ readonly history: IHistoryEntry[];
42
+ /** Current position in history */
43
+ readonly historyIndex: number;
44
+ /** Undo the last change */
45
+ undo(): void;
46
+ /** Redo the last undone change */
47
+ redo(): void;
48
+ /** Clear all history */
49
+ clear(): void;
50
+ /** Start grouping changes */
51
+ startGroup(): void;
52
+ /** End grouping changes */
53
+ endGroup(): void;
54
+ /** Execute a function without recording history */
55
+ withoutUndo<T>(fn: () => T): T;
56
+ /** Stop tracking changes */
57
+ dispose(): void;
58
+ }
59
+
60
+ // ============================================================================
61
+ // UndoManager Implementation
62
+ // ============================================================================
63
+
64
+ class UndoManager implements IUndoManager {
65
+ private target: unknown;
66
+ private options: Required<IUndoManagerOptions>;
67
+ private historyEntries: IHistoryEntry[] = [];
68
+ private currentIndex: number = -1;
69
+ private isUndoing: boolean = false;
70
+ private isRedoing: boolean = false;
71
+ private skipRecording: boolean = false;
72
+ private grouping: boolean = false;
73
+ private currentGroup: IReversibleJsonPatch[] = [];
74
+ private currentGroupInverse: IReversibleJsonPatch[] = [];
75
+ private disposer: IDisposer | null = null;
76
+ private lastChangeTime: number = 0;
77
+
78
+ constructor(target: unknown, options: IUndoManagerOptions = {}) {
79
+ this.target = target;
80
+ this.options = {
81
+ maxHistoryLength: options.maxHistoryLength ?? 100,
82
+ groupByTime: options.groupByTime ?? false,
83
+ groupingWindow: options.groupingWindow ?? 200,
84
+ };
85
+
86
+ // Subscribe to patches
87
+ this.disposer = onPatch(target, (patch, reversePatch) => {
88
+ this.recordPatch(patch, reversePatch);
89
+ });
90
+ }
91
+
92
+ get canUndo(): boolean {
93
+ return this.currentIndex >= 0;
94
+ }
95
+
96
+ get canRedo(): boolean {
97
+ return this.currentIndex < this.historyEntries.length - 1;
98
+ }
99
+
100
+ get undoLevels(): number {
101
+ return this.currentIndex + 1;
102
+ }
103
+
104
+ get redoLevels(): number {
105
+ return this.historyEntries.length - this.currentIndex - 1;
106
+ }
107
+
108
+ get history(): IHistoryEntry[] {
109
+ return [...this.historyEntries];
110
+ }
111
+
112
+ get historyIndex(): number {
113
+ return this.currentIndex;
114
+ }
115
+
116
+ private recordPatch(patch: IJsonPatch, reversePatch: IReversibleJsonPatch): void {
117
+ if (this.isUndoing || this.isRedoing || this.skipRecording) {
118
+ return;
119
+ }
120
+
121
+ const now = Date.now();
122
+
123
+ if (this.grouping) {
124
+ // Add to current group
125
+ this.currentGroup.push(reversePatch);
126
+ this.currentGroupInverse.unshift({ ...patch } as IReversibleJsonPatch);
127
+ return;
128
+ }
129
+
130
+ // Check if we should group with previous entry
131
+ if (
132
+ this.options.groupByTime &&
133
+ this.historyEntries.length > 0 &&
134
+ now - this.lastChangeTime < this.options.groupingWindow &&
135
+ this.currentIndex === this.historyEntries.length - 1
136
+ ) {
137
+ // Add to the last entry
138
+ const lastEntry = this.historyEntries[this.currentIndex];
139
+ lastEntry.patches.push(reversePatch);
140
+ lastEntry.inversePatches.unshift({ ...patch } as IReversibleJsonPatch);
141
+ lastEntry.timestamp = now;
142
+ } else {
143
+ // Remove any redo entries
144
+ if (this.currentIndex < this.historyEntries.length - 1) {
145
+ this.historyEntries.splice(this.currentIndex + 1);
146
+ }
147
+
148
+ // Add new entry
149
+ this.historyEntries.push({
150
+ patches: [reversePatch],
151
+ inversePatches: [{ ...patch } as IReversibleJsonPatch],
152
+ timestamp: now,
153
+ });
154
+ this.currentIndex++;
155
+
156
+ // Trim history if needed
157
+ if (this.historyEntries.length > this.options.maxHistoryLength) {
158
+ const excess = this.historyEntries.length - this.options.maxHistoryLength;
159
+ this.historyEntries.splice(0, excess);
160
+ this.currentIndex -= excess;
161
+ }
162
+ }
163
+
164
+ this.lastChangeTime = now;
165
+ }
166
+
167
+ undo(): void {
168
+ if (!this.canUndo) {
169
+ return;
170
+ }
171
+
172
+ this.isUndoing = true;
173
+ try {
174
+ const entry = this.historyEntries[this.currentIndex];
175
+ // Apply patches in reverse order
176
+ for (let i = entry.patches.length - 1; i >= 0; i--) {
177
+ applyPatch(this.target, entry.patches[i]);
178
+ }
179
+ this.currentIndex--;
180
+ } finally {
181
+ this.isUndoing = false;
182
+ }
183
+ }
184
+
185
+ redo(): void {
186
+ if (!this.canRedo) {
187
+ return;
188
+ }
189
+
190
+ this.isRedoing = true;
191
+ try {
192
+ this.currentIndex++;
193
+ const entry = this.historyEntries[this.currentIndex];
194
+ // Apply inverse patches in order
195
+ for (const patch of entry.inversePatches) {
196
+ applyPatch(this.target, patch);
197
+ }
198
+ } finally {
199
+ this.isRedoing = false;
200
+ }
201
+ }
202
+
203
+ clear(): void {
204
+ this.historyEntries = [];
205
+ this.currentIndex = -1;
206
+ this.currentGroup = [];
207
+ this.currentGroupInverse = [];
208
+ this.grouping = false;
209
+ }
210
+
211
+ startGroup(): void {
212
+ this.grouping = true;
213
+ this.currentGroup = [];
214
+ this.currentGroupInverse = [];
215
+ }
216
+
217
+ endGroup(): void {
218
+ if (!this.grouping) {
219
+ return;
220
+ }
221
+
222
+ this.grouping = false;
223
+
224
+ if (this.currentGroup.length > 0) {
225
+ // Remove any redo entries
226
+ if (this.currentIndex < this.historyEntries.length - 1) {
227
+ this.historyEntries.splice(this.currentIndex + 1);
228
+ }
229
+
230
+ // Add grouped entry
231
+ this.historyEntries.push({
232
+ patches: this.currentGroup,
233
+ inversePatches: this.currentGroupInverse,
234
+ timestamp: Date.now(),
235
+ });
236
+ this.currentIndex++;
237
+
238
+ // Trim history if needed
239
+ if (this.historyEntries.length > this.options.maxHistoryLength) {
240
+ const excess = this.historyEntries.length - this.options.maxHistoryLength;
241
+ this.historyEntries.splice(0, excess);
242
+ this.currentIndex -= excess;
243
+ }
244
+ }
245
+
246
+ this.currentGroup = [];
247
+ this.currentGroupInverse = [];
248
+ }
249
+
250
+ withoutUndo<T>(fn: () => T): T {
251
+ this.skipRecording = true;
252
+ try {
253
+ return fn();
254
+ } finally {
255
+ this.skipRecording = false;
256
+ }
257
+ }
258
+
259
+ dispose(): void {
260
+ if (this.disposer) {
261
+ this.disposer();
262
+ this.disposer = null;
263
+ }
264
+ this.clear();
265
+ }
266
+ }
267
+
268
+ // ============================================================================
269
+ // Factory Function
270
+ // ============================================================================
271
+
272
+ /**
273
+ * Create an undo manager for a state tree
274
+ */
275
+ export function createUndoManager(
276
+ target: unknown,
277
+ options?: IUndoManagerOptions
278
+ ): IUndoManager {
279
+ return new UndoManager(target, options);
280
+ }
281
+
282
+ // ============================================================================
283
+ // Snapshot-based Time Travel
284
+ // ============================================================================
285
+
286
+ export interface ITimeTravelManager {
287
+ /** Current snapshot index */
288
+ readonly currentIndex: number;
289
+ /** Total number of snapshots */
290
+ readonly snapshotCount: number;
291
+ /** Whether we can go back */
292
+ readonly canGoBack: boolean;
293
+ /** Whether we can go forward */
294
+ readonly canGoForward: boolean;
295
+ /** Record the current snapshot */
296
+ record(): void;
297
+ /** Go back to previous snapshot */
298
+ goBack(): void;
299
+ /** Go forward to next snapshot */
300
+ goForward(): void;
301
+ /** Go to a specific snapshot index */
302
+ goTo(index: number): void;
303
+ /** Get snapshot at index */
304
+ getSnapshot(index: number): unknown;
305
+ /** Clear all snapshots */
306
+ clear(): void;
307
+ /** Dispose and clean up */
308
+ dispose(): void;
309
+ }
310
+
311
+ class TimeTravelManager implements ITimeTravelManager {
312
+ private target: unknown;
313
+ private snapshots: unknown[] = [];
314
+ private index: number = -1;
315
+ private maxSnapshots: number;
316
+ private isApplying: boolean = false;
317
+ private disposer: IDisposer | null = null;
318
+ private autoRecord: boolean;
319
+
320
+ constructor(
321
+ target: unknown,
322
+ options: {
323
+ maxSnapshots?: number;
324
+ autoRecord?: boolean;
325
+ } = {}
326
+ ) {
327
+ this.target = target;
328
+ this.maxSnapshots = options.maxSnapshots ?? 50;
329
+ this.autoRecord = options.autoRecord ?? false;
330
+
331
+ // Record initial snapshot
332
+ this.record();
333
+
334
+ // Auto-record on changes if enabled
335
+ if (this.autoRecord) {
336
+ this.disposer = onPatch(target, () => {
337
+ if (!this.isApplying) {
338
+ this.record();
339
+ }
340
+ });
341
+ }
342
+ }
343
+
344
+ get currentIndex(): number {
345
+ return this.index;
346
+ }
347
+
348
+ get snapshotCount(): number {
349
+ return this.snapshots.length;
350
+ }
351
+
352
+ get canGoBack(): boolean {
353
+ return this.index > 0;
354
+ }
355
+
356
+ get canGoForward(): boolean {
357
+ return this.index < this.snapshots.length - 1;
358
+ }
359
+
360
+ record(): void {
361
+ // Remove any future snapshots
362
+ if (this.index < this.snapshots.length - 1) {
363
+ this.snapshots.splice(this.index + 1);
364
+ }
365
+
366
+ // Add new snapshot
367
+ this.snapshots.push(getSnapshot(this.target));
368
+ this.index++;
369
+
370
+ // Trim if needed
371
+ if (this.snapshots.length > this.maxSnapshots) {
372
+ const excess = this.snapshots.length - this.maxSnapshots;
373
+ this.snapshots.splice(0, excess);
374
+ this.index -= excess;
375
+ }
376
+ }
377
+
378
+ goBack(): void {
379
+ if (!this.canGoBack) return;
380
+ this.goTo(this.index - 1);
381
+ }
382
+
383
+ goForward(): void {
384
+ if (!this.canGoForward) return;
385
+ this.goTo(this.index + 1);
386
+ }
387
+
388
+ goTo(index: number): void {
389
+ if (index < 0 || index >= this.snapshots.length) return;
390
+
391
+ this.isApplying = true;
392
+ try {
393
+ this.index = index;
394
+ applySnapshot(this.target, this.snapshots[index]);
395
+ } finally {
396
+ this.isApplying = false;
397
+ }
398
+ }
399
+
400
+ getSnapshot(index: number): unknown {
401
+ if (index < 0 || index >= this.snapshots.length) {
402
+ throw new Error(`[jotai-state-tree] Invalid snapshot index: ${index}`);
403
+ }
404
+ return this.snapshots[index];
405
+ }
406
+
407
+ clear(): void {
408
+ this.snapshots = [];
409
+ this.index = -1;
410
+ // Record current state
411
+ this.record();
412
+ }
413
+
414
+ dispose(): void {
415
+ if (this.disposer) {
416
+ this.disposer();
417
+ this.disposer = null;
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Create a time travel manager for snapshot-based history
424
+ */
425
+ export function createTimeTravelManager(
426
+ target: unknown,
427
+ options?: {
428
+ maxSnapshots?: number;
429
+ autoRecord?: boolean;
430
+ }
431
+ ): ITimeTravelManager {
432
+ return new TimeTravelManager(target, options);
433
+ }
434
+
435
+ // ============================================================================
436
+ // Action-based Recording
437
+ // ============================================================================
438
+
439
+ export interface IActionRecording {
440
+ /** Name of the action */
441
+ name: string;
442
+ /** Path to the node where action was called */
443
+ path: string;
444
+ /** Arguments passed to the action */
445
+ args: unknown[];
446
+ /** Timestamp */
447
+ timestamp: number;
448
+ }
449
+
450
+ export interface IActionRecorder {
451
+ /** Whether currently recording */
452
+ readonly isRecording: boolean;
453
+ /** All recorded actions */
454
+ readonly actions: IActionRecording[];
455
+ /** Start recording */
456
+ start(): void;
457
+ /** Stop recording */
458
+ stop(): void;
459
+ /** Clear recorded actions */
460
+ clear(): void;
461
+ /** Replay actions on a target */
462
+ replay(target: unknown): void;
463
+ /** Export actions as JSON */
464
+ export(): string;
465
+ /** Import actions from JSON */
466
+ import(json: string): void;
467
+ /** Dispose and clean up */
468
+ dispose(): void;
469
+ }
470
+
471
+ class ActionRecorder implements IActionRecorder {
472
+ private target: unknown;
473
+ private recording: boolean = false;
474
+ private recordedActions: IActionRecording[] = [];
475
+ private disposer: IDisposer | null = null;
476
+
477
+ constructor(target: unknown) {
478
+ this.target = target;
479
+ }
480
+
481
+ get isRecording(): boolean {
482
+ return this.recording;
483
+ }
484
+
485
+ get actions(): IActionRecording[] {
486
+ return [...this.recordedActions];
487
+ }
488
+
489
+ start(): void {
490
+ if (this.recording) return;
491
+ this.recording = true;
492
+
493
+ // Import onAction dynamically to avoid circular dependency
494
+ const { onAction } = require('./tree');
495
+ this.disposer = onAction(this.target, (action: { name: string; path: string; args: unknown[] }) => {
496
+ this.recordedActions.push({
497
+ ...action,
498
+ timestamp: Date.now(),
499
+ });
500
+ });
501
+ }
502
+
503
+ stop(): void {
504
+ this.recording = false;
505
+ if (this.disposer) {
506
+ this.disposer();
507
+ this.disposer = null;
508
+ }
509
+ }
510
+
511
+ clear(): void {
512
+ this.recordedActions = [];
513
+ }
514
+
515
+ replay(target: unknown): void {
516
+ const node = getStateTreeNode(target);
517
+
518
+ for (const action of this.recordedActions) {
519
+ // Navigate to the correct node
520
+ let currentNode = node;
521
+ if (action.path) {
522
+ const parts = action.path.split('/').filter(Boolean);
523
+ for (const part of parts) {
524
+ const child = currentNode.getChild(part);
525
+ if (!child) {
526
+ console.warn(`[jotai-state-tree] Could not find path: ${action.path}`);
527
+ continue;
528
+ }
529
+ currentNode = child;
530
+ }
531
+ }
532
+
533
+ const instance = currentNode.getInstance() as Record<string, Function>;
534
+ if (typeof instance[action.name] === 'function') {
535
+ instance[action.name](...action.args);
536
+ }
537
+ }
538
+ }
539
+
540
+ export(): string {
541
+ return JSON.stringify(this.recordedActions, null, 2);
542
+ }
543
+
544
+ import(json: string): void {
545
+ try {
546
+ const actions = JSON.parse(json);
547
+ if (Array.isArray(actions)) {
548
+ this.recordedActions = actions;
549
+ }
550
+ } catch (e) {
551
+ throw new Error(`[jotai-state-tree] Failed to import actions: ${e}`);
552
+ }
553
+ }
554
+
555
+ dispose(): void {
556
+ this.stop();
557
+ this.clear();
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Create an action recorder for debugging and testing
563
+ */
564
+ export function createActionRecorder(target: unknown): IActionRecorder {
565
+ return new ActionRecorder(target);
566
+ }