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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
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
|
+
}
|