@buoy-gg/time-machine 7.0.28 → 7.0.30

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.
Files changed (68) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +13 -1
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/components/list/RestoreOutcomeCard.js +15 -4
  4. package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -1
  5. package/lib/commonjs/components/list/SnapshotRow.js +5 -0
  6. package/lib/commonjs/components/list/SnapshotRow.js.map +1 -1
  7. package/lib/commonjs/components/list/SourcesStrip.js +2 -0
  8. package/lib/commonjs/components/list/SourcesStrip.js.map +1 -1
  9. package/lib/commonjs/components/list/UndoDeleteBar.js +2 -0
  10. package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -1
  11. package/lib/commonjs/components/preview/ItemDiffPanel.js +3 -1
  12. package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -1
  13. package/lib/commonjs/components/preview/RestorePreviewScreen.js +41 -7
  14. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
  15. package/lib/commonjs/ui/primitives.js +9 -3
  16. package/lib/commonjs/ui/primitives.js.map +1 -1
  17. package/lib/module/components/TimeMachineBody.js +13 -1
  18. package/lib/module/components/TimeMachineBody.js.map +1 -1
  19. package/lib/module/components/list/RestoreOutcomeCard.js +15 -4
  20. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -1
  21. package/lib/module/components/list/SnapshotRow.js +5 -0
  22. package/lib/module/components/list/SnapshotRow.js.map +1 -1
  23. package/lib/module/components/list/SourcesStrip.js +2 -0
  24. package/lib/module/components/list/SourcesStrip.js.map +1 -1
  25. package/lib/module/components/list/UndoDeleteBar.js +2 -0
  26. package/lib/module/components/list/UndoDeleteBar.js.map +1 -1
  27. package/lib/module/components/preview/ItemDiffPanel.js +3 -1
  28. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -1
  29. package/lib/module/components/preview/RestorePreviewScreen.js +41 -7
  30. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  31. package/lib/module/ui/primitives.js +9 -3
  32. package/lib/module/ui/primitives.js.map +1 -1
  33. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -1
  34. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -1
  35. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -1
  36. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +2 -1
  37. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -1
  38. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  39. package/lib/typescript/ui/primitives.d.ts +6 -3
  40. package/lib/typescript/ui/primitives.d.ts.map +1 -1
  41. package/package.json +10 -11
  42. package/src/components/HeaderReloadButton.tsx +0 -45
  43. package/src/components/TimeMachineBody.tsx +0 -237
  44. package/src/components/TimeMachineModal.tsx +0 -75
  45. package/src/components/confirm.ts +0 -35
  46. package/src/components/format.ts +0 -17
  47. package/src/components/list/CaptureBar.tsx +0 -100
  48. package/src/components/list/RestoreOutcomeCard.tsx +0 -159
  49. package/src/components/list/SnapshotRow.tsx +0 -155
  50. package/src/components/list/SourcesStrip.tsx +0 -124
  51. package/src/components/list/UndoDeleteBar.tsx +0 -76
  52. package/src/components/list/WipeAllRow.tsx +0 -42
  53. package/src/components/preview/ItemDiffPanel.tsx +0 -77
  54. package/src/components/preview/PreviewFlow.tsx +0 -70
  55. package/src/components/preview/RestorePointNameRow.tsx +0 -80
  56. package/src/components/preview/RestorePreviewScreen.tsx +0 -1090
  57. package/src/components/preview/verdictMeta.ts +0 -40
  58. package/src/components/sources.ts +0 -92
  59. package/src/hooks/useTimeMachine.ts +0 -26
  60. package/src/index.tsx +0 -68
  61. package/src/preset.tsx +0 -47
  62. package/src/store/routeRestore.ts +0 -194
  63. package/src/store/snapshotVault.ts +0 -274
  64. package/src/store/timeMachineStore.ts +0 -881
  65. package/src/sync/timeMachineSyncAdapter.ts +0 -241
  66. package/src/ui/SwipeActionsRow.tsx +0 -199
  67. package/src/ui/primitives.tsx +0 -157
  68. package/src/ui/tmTheme.ts +0 -109
@@ -1,881 +0,0 @@
1
- /**
2
- * Time Machine orchestrator — snapshot CRUD + capture/restore across every
3
- * registered StateSnapshotProvider (storage, redux, zustand, jotai, query,
4
- * plus any custom providers the app registers).
5
- *
6
- * Restore ordering is fixed: persisted storage FIRST (so store persist
7
- * middleware writing through after an in-memory restore converges with what
8
- * we just wrote), then redux → zustand → jotai, query LAST (restored queries
9
- * may derive from store state). Custom providers run after, in data order.
10
- *
11
- * Two restore modes:
12
- * - "live": in-place state replacement, capability-gated per source.
13
- * - "reload": restore ONLY persisted storage, then reload the JS bundle —
14
- * in-memory stores rebuild themselves from the restored storage.
15
- * Baseline snapshots always use this path (clear + reload).
16
- */
17
- import {
18
- getStateSnapshotProvider,
19
- getStateSnapshotProviders,
20
- scheduleReloadApp,
21
- superJsonDeserialize,
22
- superJsonSerialize,
23
- type StateSnapshotRestoreResult,
24
- type StateSnapshotPreview,
25
- type StateSnapshotPreviewItem,
26
- type SuperJSONResult,
27
- } from "@buoy-gg/shared-ui";
28
- import { objectDiff } from "@buoy-gg/shared-ui/dataViewer";
29
- import {
30
- deleteSnapshot,
31
- duplicateSnapshot,
32
- loadIndex,
33
- loadSnapshot,
34
- markSnapshotRestored,
35
- readSnapshot,
36
- renameSnapshot,
37
- restoreDeletedSnapshot,
38
- saveSnapshot,
39
- setSnapshotRestoreRoute,
40
- setSnapshotScope,
41
- setSnapshotExclusions,
42
- type SnapshotMeta,
43
- type StoredSnapshot,
44
- } from "./snapshotVault";
45
- import {
46
- captureCurrentRoute,
47
- navigateToRoute,
48
- savePendingRoute,
49
- type RouteRestoreResult,
50
- } from "./routeRestore";
51
-
52
- export const RESTORE_ORDER = ["storage", "redux", "zustand", "jotai", "query"];
53
-
54
- export type RestoreMode = "live" | "reload";
55
-
56
- export interface RestoreOutcome {
57
- snapshotId: string;
58
- snapshotName: string;
59
- mode: RestoreMode;
60
- at: number;
61
- /** True when a JS reload was scheduled as part of this restore. */
62
- willReload: boolean;
63
- results: Record<string, StateSnapshotRestoreResult>;
64
- /**
65
- * Present when the caller asked to also return to the snapshot's route.
66
- * "deferred" means the navigation is queued for the post-reload boot.
67
- */
68
- route?: RouteRestoreResult;
69
- }
70
-
71
- /**
72
- * What to call a capture nobody named. The screen you were on plus the time
73
- * is the pair that makes a restore point recognizable an hour later —
74
- * "Untitled snapshot" never was, and typing mid-flow is the friction that
75
- * produced it.
76
- */
77
- function defaultSnapshotName(route: { pathname: string } | null | undefined): string {
78
- const now = new Date();
79
- const clock = `${String(now.getHours()).padStart(2, "0")}:${String(
80
- now.getMinutes()
81
- ).padStart(2, "0")}`;
82
- return route ? `${route.pathname} · ${clock}` : `Snapshot · ${clock}`;
83
- }
84
-
85
- /**
86
- * Will restoring this point also navigate back to its route? Having a route
87
- * means wanting it back unless the point says otherwise — the same rule
88
- * `restore` applies, exported so the UI can't drift from it.
89
- */
90
- export function willRestoreRoute(meta: SnapshotMeta): boolean {
91
- return !!meta.route && (meta.restoreRoute ?? true);
92
- }
93
-
94
- /** Extra knobs a restore accepts beyond mode + exclusions. */
95
- export interface RestoreOptions {
96
- /**
97
- * Also navigate back to the route the snapshot was captured on. Ignored for
98
- * snapshots with no captured route (and for baselines, which wipe to the
99
- * app's own start route).
100
- */
101
- restoreRoute?: boolean;
102
- }
103
-
104
- /** Provider capability row as mirrored to a remote dashboard. */
105
- export interface RemoteProviderStatus {
106
- id: string;
107
- label: string;
108
- canCapture: boolean;
109
- canRestore: boolean;
110
- detail?: string;
111
- }
112
-
113
- /** One side of a preview comparison. */
114
- export type PreviewSideRef = "current" | { snapshotId: string };
115
-
116
- export interface PreviewSource {
117
- id: string;
118
- label: string;
119
- items: StateSnapshotPreviewItem[];
120
- notes: string[];
121
- error?: string;
122
- }
123
-
124
- export interface PreviewState {
125
- left: PreviewSideRef;
126
- right: PreviewSideRef;
127
- /** True while the pipeline is still filling `sources` in. */
128
- building: boolean;
129
- builtAt: number;
130
- sources: PreviewSource[];
131
- }
132
-
133
- export interface TimeMachineState {
134
- snapshots: SnapshotMeta[];
135
- hasLoaded: boolean;
136
- busy: "capture" | "restore" | null;
137
- lastOutcome: RestoreOutcome | null;
138
- preview: PreviewState | null;
139
- /**
140
- * Non-null when this store is a REMOTE MIRROR (Buoy Desktop): provider
141
- * statuses come from the device's sync payload instead of the local
142
- * registry, and every operation forwards over the socket.
143
- */
144
- remoteProviders: RemoteProviderStatus[] | null;
145
- /**
146
- * Route capture/restore availability, mirrored from the device's payload
147
- * (Buoy Desktop). Null on-device, where the live router is probed directly.
148
- */
149
- remoteRoute: RouteToolStatus | null;
150
- /**
151
- * The last deleted record, held in memory so the list can offer an undo.
152
- * The vault row is already gone — this is the only copy until it expires.
153
- */
154
- recentlyDeleted: { stored: StoredSnapshot; at: number } | null;
155
- }
156
-
157
- /** Whether this device can capture and return to routes, and where it is now. */
158
- export interface RouteToolStatus {
159
- available: boolean;
160
- /** The route the app is on right now, when one can be read. */
161
- current?: string;
162
- }
163
-
164
- type RemoteHandler = (
165
- action: string,
166
- params?: Record<string, unknown>
167
- ) => Promise<unknown>;
168
-
169
- let remoteHandler: RemoteHandler | null = null;
170
-
171
- function skippedResult(reason: string): StateSnapshotRestoreResult {
172
- return { ok: false, applied: 0, skipped: [{ id: "*", reason }], warnings: [] };
173
- }
174
-
175
- let idCounter = 0;
176
- function nextId(): string {
177
- idCounter += 1;
178
- return `snap_${Date.now().toString(36)}_${idCounter.toString(36)}`;
179
- }
180
-
181
- class TimeMachineStore {
182
- private state: TimeMachineState = {
183
- snapshots: [],
184
- hasLoaded: false,
185
- busy: null,
186
- lastOutcome: null,
187
- preview: null,
188
- remoteProviders: null,
189
- remoteRoute: null,
190
- recentlyDeleted: null,
191
- };
192
- /** Monotonic token — a preview build checks it after every await and
193
- * abandons itself when a newer build (or clearPreview) supersedes it. */
194
- private previewRunId = 0;
195
- private listeners = new Set<() => void>();
196
- private loadPromise: Promise<void> | null = null;
197
-
198
- getState(): TimeMachineState {
199
- return this.state;
200
- }
201
-
202
- subscribe = (listener: () => void): (() => void) => {
203
- this.listeners.add(listener);
204
- void this.ensureLoaded();
205
- return () => {
206
- this.listeners.delete(listener);
207
- };
208
- };
209
-
210
- private setState(patch: Partial<TimeMachineState>) {
211
- this.state = { ...this.state, ...patch };
212
- this.listeners.forEach((l) => {
213
- try {
214
- l();
215
- } catch {
216
- // listener errors must not break the store
217
- }
218
- });
219
- }
220
-
221
- ensureLoaded(): Promise<void> {
222
- if (remoteHandler) return Promise.resolve(); // mirror mode — device owns the vault
223
- if (!this.loadPromise) {
224
- this.loadPromise = loadIndex().then((snapshots) => {
225
- if (remoteHandler) return;
226
- this.setState({ snapshots, hasLoaded: true });
227
- });
228
- }
229
- return this.loadPromise;
230
- }
231
-
232
- /** @internal Buoy Desktop: mirror the device's synced metadata payload. */
233
- replaceFromSnapshot(payload: {
234
- snapshots?: SnapshotMeta[];
235
- busy?: TimeMachineState["busy"];
236
- lastOutcome?: RestoreOutcome | null;
237
- providers?: RemoteProviderStatus[];
238
- route?: RouteToolStatus;
239
- }): void {
240
- this.setState({
241
- snapshots: payload.snapshots ?? [],
242
- busy: payload.busy ?? null,
243
- lastOutcome: payload.lastOutcome ?? null,
244
- remoteProviders: payload.providers ?? [],
245
- remoteRoute: payload.route ?? { available: false },
246
- hasLoaded: true,
247
- });
248
- }
249
-
250
- /** Capture all capturable sources (or a subset) into a named snapshot. */
251
- async capture(name: string, sourceIds?: string[]): Promise<SnapshotMeta> {
252
- if (remoteHandler) {
253
- const result = (await remoteHandler("capture", { name, sourceIds })) as {
254
- snapshot: SnapshotMeta;
255
- };
256
- return result.snapshot;
257
- }
258
- await this.ensureLoaded();
259
- this.setState({ busy: "capture" });
260
- try {
261
- const providers = getStateSnapshotProviders().filter(
262
- (p) =>
263
- p.status().canCapture && (!sourceIds || sourceIds.includes(p.id))
264
- );
265
- const data: Record<string, SuperJSONResult> = {};
266
- const sources: SnapshotMeta["sources"] = {};
267
- for (const provider of providers) {
268
- try {
269
- const captured = await provider.capture();
270
- data[provider.id] = superJsonSerialize(captured.data);
271
- sources[provider.id] = { warnings: captured.warnings };
272
- } catch (e) {
273
- sources[provider.id] = {
274
- warnings: [`capture failed: ${String(e)}`],
275
- };
276
- }
277
- }
278
- // Where the app was when this state was true — restoring can return here.
279
- const route = captureCurrentRoute();
280
- const snapshot: StoredSnapshot = {
281
- meta: {
282
- id: nextId(),
283
- name: name.trim() || defaultSnapshotName(route),
284
- createdAt: Date.now(),
285
- sizeBytes: 0,
286
- ...(route ? { route } : {}),
287
- sources,
288
- },
289
- data,
290
- };
291
- const snapshots = await saveSnapshot(snapshot, this.state.snapshots);
292
- this.setState({ snapshots });
293
- return snapshot.meta;
294
- } finally {
295
- this.setState({ busy: null });
296
- }
297
- }
298
-
299
- /**
300
- * Capture the fresh-install baseline: an EMPTY snapshot whose restore
301
- * wipes app state (clear + reload) — regardless of what state the app is
302
- * in when the button is pressed.
303
- */
304
- async captureBaseline(): Promise<SnapshotMeta> {
305
- if (remoteHandler) {
306
- const result = (await remoteHandler("captureBaseline", {})) as {
307
- snapshot: SnapshotMeta;
308
- };
309
- return result.snapshot;
310
- }
311
- await this.ensureLoaded();
312
- const snapshot: StoredSnapshot = {
313
- meta: {
314
- id: nextId(),
315
- name: "Fresh install",
316
- createdAt: Date.now(),
317
- sizeBytes: 0,
318
- baseline: true,
319
- sources: {},
320
- },
321
- data: {},
322
- };
323
- const snapshots = await saveSnapshot(snapshot, this.state.snapshots);
324
- this.setState({ snapshots });
325
- return snapshot.meta;
326
- }
327
-
328
- /**
329
- * `excludeKeys` entries are compound `"sourceId::itemKey"` strings (the
330
- * itemKey being what previewItems emitted) — split per source before each
331
- * provider sees only its own keys.
332
- */
333
- async restore(
334
- id: string,
335
- mode: RestoreMode,
336
- excludeKeys?: readonly string[],
337
- options?: RestoreOptions
338
- ): Promise<RestoreOutcome> {
339
- if (remoteHandler) {
340
- const outcome = (await remoteHandler("restore", {
341
- id,
342
- mode,
343
- excludeKeys,
344
- restoreRoute: options?.restoreRoute,
345
- })) as RestoreOutcome;
346
- this.setState({ lastOutcome: outcome });
347
- return outcome;
348
- }
349
- await this.ensureLoaded();
350
- this.setState({ busy: "restore" });
351
- try {
352
- const stored = await loadSnapshot(id);
353
- if (!stored) throw new Error(`No snapshot with id "${id}"`);
354
-
355
- const outcome: RestoreOutcome = {
356
- snapshotId: id,
357
- snapshotName: stored.meta.name,
358
- mode,
359
- at: Date.now(),
360
- willReload: false,
361
- results: {},
362
- };
363
-
364
- if (stored.meta.baseline) {
365
- // Fresh install: wipe every clearable source, then reload so all
366
- // in-memory state rebuilds from nothing.
367
- for (const provider of getStateSnapshotProviders()) {
368
- if (provider.clear) {
369
- try {
370
- outcome.results[provider.id] = await provider.clear();
371
- } catch (e) {
372
- outcome.results[provider.id] = skippedResult(String(e));
373
- }
374
- }
375
- }
376
- outcome.willReload = this.scheduleReload("buoy time-machine baseline");
377
- this.setState({ lastOutcome: outcome });
378
- return outcome;
379
- }
380
-
381
- const sourceIds = [
382
- ...RESTORE_ORDER.filter((sid) => sid in stored.data),
383
- ...Object.keys(stored.data).filter((sid) => !RESTORE_ORDER.includes(sid)),
384
- ];
385
-
386
- const splitCompound = (keys: readonly string[]) => {
387
- const bySource = new Map<string, string[]>();
388
- for (const compound of keys) {
389
- const sep = compound.indexOf("::");
390
- if (sep <= 0) continue;
391
- const sid = compound.slice(0, sep);
392
- const itemKey = compound.slice(sep + 2);
393
- const list = bySource.get(sid) ?? [];
394
- list.push(itemKey);
395
- bySource.set(sid, list);
396
- }
397
- return bySource;
398
- };
399
-
400
- // No explicit list means "use what the user saved in the preview" —
401
- // that is what makes an unchecked item stay unchecked forever, for the
402
- // one-tap RESTORE on the list and for MCP alike.
403
- const effectiveExcludes = excludeKeys ?? stored.meta.excludedKeys ?? [];
404
- const excludeBySource = splitCompound(effectiveExcludes);
405
- // A saved scope makes this a targeted restore point. An explicit
406
- // exclusion list from the preview already reflects the scope (the
407
- // preview seeds its selection from it), so it takes precedence.
408
- const includeBySource =
409
- !excludeKeys && stored.meta.scope?.length
410
- ? splitCompound(stored.meta.scope)
411
- : null;
412
-
413
- // No prompt any more: the point carries its own answer, and having a
414
- // route means wanting it back unless you said otherwise. An explicit
415
- // option still wins, so MCP and the desktop can force either way.
416
- const wantsRoute =
417
- options?.restoreRoute ?? willRestoreRoute(stored.meta);
418
-
419
- if (mode === "reload") {
420
- // Storage only; everything in-memory rebuilds after the reload.
421
- if (
422
- stored.data.storage &&
423
- (!includeBySource || includeBySource.has("storage"))
424
- ) {
425
- outcome.results.storage = await this.restoreSource(
426
- "storage",
427
- stored.data.storage,
428
- excludeBySource.get("storage"),
429
- includeBySource?.get("storage")
430
- );
431
- }
432
- // Hand the route to the NEXT boot — this realm is about to die, so the
433
- // slot must be written before the reload is armed.
434
- if (wantsRoute) {
435
- const route = stored.meta.route!;
436
- try {
437
- await savePendingRoute(route, stored.meta.name);
438
- outcome.route = { ...route, status: "deferred" };
439
- } catch (e) {
440
- outcome.route = { ...route, status: "failed", error: String(e) };
441
- }
442
- }
443
- outcome.willReload = this.scheduleReload("buoy time-machine restore");
444
- // Stamped BEFORE the reload is armed — the JS realm is about to die.
445
- this.setState({
446
- lastOutcome: outcome,
447
- snapshots: await markSnapshotRestored(id, outcome.at, this.state.snapshots),
448
- });
449
- return outcome;
450
- }
451
-
452
- for (const sid of sourceIds) {
453
- // Sources entirely outside the scope are untouched and unreported.
454
- if (includeBySource && !includeBySource.has(sid)) continue;
455
- outcome.results[sid] = await this.restoreSource(
456
- sid,
457
- stored.data[sid],
458
- excludeBySource.get(sid),
459
- includeBySource?.get(sid)
460
- );
461
- }
462
- // Navigate LAST — the screen we land on should read the restored state.
463
- if (wantsRoute) {
464
- outcome.route = navigateToRoute(stored.meta.route!);
465
- }
466
- this.setState({
467
- lastOutcome: outcome,
468
- snapshots: await markSnapshotRestored(id, outcome.at, this.state.snapshots),
469
- });
470
- return outcome;
471
- } finally {
472
- this.setState({ busy: null });
473
- }
474
- }
475
-
476
- private async restoreSource(
477
- sourceId: string,
478
- payload: SuperJSONResult,
479
- excludeKeys?: readonly string[],
480
- includeKeys?: readonly string[]
481
- ): Promise<StateSnapshotRestoreResult> {
482
- const provider = getStateSnapshotProvider(sourceId);
483
- if (!provider) {
484
- return skippedResult("provider not available (package missing or not initialized)");
485
- }
486
- const status = provider.status();
487
- if (!status.canRestore) {
488
- return skippedResult(status.detail ?? "restore unavailable");
489
- }
490
- try {
491
- const options =
492
- excludeKeys?.length || includeKeys?.length
493
- ? {
494
- ...(excludeKeys?.length ? { excludeKeys } : {}),
495
- ...(includeKeys?.length ? { includeKeys } : {}),
496
- }
497
- : undefined;
498
- return await provider.restore(superJsonDeserialize(payload), options);
499
- } catch (e) {
500
- return skippedResult(String(e));
501
- }
502
- }
503
-
504
- private scheduleReload(reason: string): boolean {
505
- try {
506
- scheduleReloadApp({ reason });
507
- return true;
508
- } catch {
509
- // No reload mechanism (bare production build?) — restore still stands.
510
- return false;
511
- }
512
- }
513
-
514
- /**
515
- * Delete, holding the whole record in memory so `undoDelete` can put it
516
- * back. Swipe-and-tap is deliberate but not recoverable, and those are
517
- * different properties — a restore point can represent state that takes
518
- * ten minutes and a cooperative backend to rebuild.
519
- */
520
- async delete(id: string): Promise<void> {
521
- if (remoteHandler) {
522
- await remoteHandler("delete", { id });
523
- return;
524
- }
525
- await this.ensureLoaded();
526
- const stored = await readSnapshot(id);
527
- const snapshots = await deleteSnapshot(id, this.state.snapshots);
528
- this.setState({
529
- snapshots,
530
- recentlyDeleted: stored ? { stored, at: Date.now() } : null,
531
- });
532
- }
533
-
534
- /** Put the last deleted point back. No-op once the window has passed. */
535
- async undoDelete(): Promise<void> {
536
- const held = this.state.recentlyDeleted;
537
- if (!held) return;
538
- const snapshots = await restoreDeletedSnapshot(
539
- held.stored,
540
- this.state.snapshots
541
- );
542
- this.setState({ snapshots, recentlyDeleted: null });
543
- }
544
-
545
- /** Drop the undo offer — on timeout, or when the user moves on. */
546
- clearRecentlyDeleted(): void {
547
- if (this.state.recentlyDeleted) this.setState({ recentlyDeleted: null });
548
- }
549
-
550
- /** Turn this point's route restore on or off. */
551
- async setRestoreRoute(id: string, restoreRoute: boolean): Promise<void> {
552
- if (remoteHandler) {
553
- await remoteHandler("setRestoreRoute", { id, restoreRoute });
554
- return;
555
- }
556
- await this.ensureLoaded();
557
- const snapshots = await setSnapshotRestoreRoute(
558
- id,
559
- restoreRoute,
560
- this.state.snapshots
561
- );
562
- this.setState({ snapshots });
563
- }
564
-
565
- /** Copy a point so a variation can start from a known-good base. */
566
- async duplicate(id: string): Promise<SnapshotMeta | null> {
567
- if (remoteHandler) {
568
- const result = (await remoteHandler("duplicate", { id })) as {
569
- snapshot: SnapshotMeta;
570
- } | null;
571
- return result?.snapshot ?? null;
572
- }
573
- await this.ensureLoaded();
574
- const source = this.state.snapshots.find((s) => s.id === id);
575
- if (!source) return null;
576
- const result = await duplicateSnapshot(
577
- id,
578
- `${source.name} copy`,
579
- this.state.snapshots
580
- );
581
- if (!result) return null;
582
- this.setState({ snapshots: result.metas });
583
- return result.meta;
584
- }
585
-
586
- async rename(id: string, name: string): Promise<void> {
587
- if (remoteHandler) {
588
- await remoteHandler("rename", { id, name });
589
- return;
590
- }
591
- await this.ensureLoaded();
592
- const snapshots = await renameSnapshot(id, name, this.state.snapshots);
593
- this.setState({ snapshots });
594
- }
595
-
596
- /**
597
- * Save (or clear with null/empty) a snapshot's restore scope — compound
598
- * "sourceId::itemKey" strings. A scoped restore point only ever touches
599
- * its scoped items.
600
- */
601
- async setScope(id: string, scope: string[] | null): Promise<void> {
602
- if (remoteHandler) {
603
- await remoteHandler("setScope", { id, scope });
604
- return;
605
- }
606
- await this.ensureLoaded();
607
- const snapshots = await setSnapshotScope(id, scope, this.state.snapshots);
608
- this.setState({ snapshots });
609
- }
610
-
611
- /**
612
- * Save (or clear with null/empty) the items every later restore of this
613
- * snapshot must leave untouched — what the restore preview's checkboxes
614
- * write when you save a selection.
615
- */
616
- async setExclusions(id: string, excludedKeys: string[] | null): Promise<void> {
617
- if (remoteHandler) {
618
- await remoteHandler("setExclusions", { id, excludedKeys });
619
- return;
620
- }
621
- await this.ensureLoaded();
622
- const snapshots = await setSnapshotExclusions(
623
- id,
624
- excludedKeys,
625
- this.state.snapshots
626
- );
627
- this.setState({ snapshots });
628
- }
629
-
630
- clearLastOutcome(): void {
631
- this.setState({ lastOutcome: null });
632
- }
633
-
634
- /**
635
- * Wipe every clearable source to fresh-install state and reload — the
636
- * standalone utility behind the home screen's "Wipe all app state" button
637
- * (no baseline snapshot involved).
638
- */
639
- async wipeAll(): Promise<RestoreOutcome> {
640
- if (remoteHandler) {
641
- const outcome = (await remoteHandler("wipeAll", {})) as RestoreOutcome;
642
- this.setState({ lastOutcome: outcome });
643
- return outcome;
644
- }
645
- this.setState({ busy: "restore" });
646
- try {
647
- const outcome: RestoreOutcome = {
648
- snapshotId: "wipe-all",
649
- snapshotName: "Wipe all",
650
- mode: "reload",
651
- at: Date.now(),
652
- willReload: false,
653
- results: {},
654
- };
655
- for (const provider of getStateSnapshotProviders()) {
656
- if (provider.clear) {
657
- try {
658
- outcome.results[provider.id] = await provider.clear();
659
- } catch (e) {
660
- outcome.results[provider.id] = skippedResult(String(e));
661
- }
662
- }
663
- }
664
- outcome.willReload = this.scheduleReload("buoy time-machine wipe all");
665
- this.setState({ lastOutcome: outcome });
666
- return outcome;
667
- } finally {
668
- this.setState({ busy: null });
669
- }
670
- }
671
-
672
- clearPreview(): void {
673
- this.previewRunId += 1;
674
- this.setState({ preview: null });
675
- }
676
-
677
- /**
678
- * Build (or rebuild) the restore preview: item-level verdicts per source,
679
- * filled in incrementally with a frame yield between sources so a big app
680
- * never freezes the JS thread in one go.
681
- */
682
- async buildPreview(
683
- right: PreviewSideRef,
684
- left: PreviewSideRef = "current"
685
- ): Promise<void> {
686
- if (remoteHandler) {
687
- // Buoy Desktop: the device builds it; result mirrors into state.
688
- const runId = ++this.previewRunId;
689
- this.setState({
690
- preview: { left, right, building: true, builtAt: Date.now(), sources: [] },
691
- });
692
- try {
693
- const result = (await remoteHandler("preview", {
694
- id: right === "current" ? undefined : right.snapshotId,
695
- compareTo: left === "current" ? undefined : left.snapshotId,
696
- })) as { sources: PreviewSource[] };
697
- if (runId !== this.previewRunId) return;
698
- this.setState({
699
- preview: {
700
- left,
701
- right,
702
- building: false,
703
- builtAt: Date.now(),
704
- sources: result.sources ?? [],
705
- },
706
- });
707
- } catch (e) {
708
- if (runId !== this.previewRunId) return;
709
- this.setState({
710
- preview: {
711
- left,
712
- right,
713
- building: false,
714
- builtAt: Date.now(),
715
- sources: [
716
- { id: "error", label: "Preview", items: [], notes: [], error: String(e) },
717
- ],
718
- },
719
- });
720
- }
721
- return;
722
- }
723
-
724
- await this.ensureLoaded();
725
- const runId = ++this.previewRunId;
726
- this.setState({
727
- preview: { left, right, building: true, builtAt: Date.now(), sources: [] },
728
- });
729
-
730
- const loadSide = async (
731
- ref: PreviewSideRef
732
- ): Promise<Record<string, SuperJSONResult> | null> => {
733
- if (ref === "current") return null; // providers capture live themselves
734
- const stored = await loadSnapshot(ref.snapshotId);
735
- if (!stored) throw new Error(`No snapshot with id "${ref.snapshotId}"`);
736
- return stored.data;
737
- };
738
-
739
- try {
740
- if (right === "current") throw new Error("preview target must be a snapshot");
741
- const [leftData, rightStored] = await Promise.all([
742
- loadSide(left),
743
- loadSnapshot(right.snapshotId),
744
- ]);
745
- if (runId !== this.previewRunId) return;
746
- if (!rightStored) {
747
- throw new Error(`No snapshot with id "${right.snapshotId}"`);
748
- }
749
- const rightData = rightStored.data;
750
-
751
- // Baseline = empty target: preview every clearable source against live,
752
- // so each live key naturally reads as "removed" — the informed wipe.
753
- const isBaseline = rightStored.meta.baseline === true;
754
- const sourceIds = isBaseline
755
- ? getStateSnapshotProviders()
756
- .filter((p) => p.clear)
757
- .map((p) => p.id)
758
- : [
759
- ...RESTORE_ORDER.filter((sid) => sid in rightData),
760
- ...Object.keys(rightData).filter(
761
- (sid) => !RESTORE_ORDER.includes(sid)
762
- ),
763
- ];
764
-
765
- const sources: PreviewSource[] = [];
766
- for (const sid of sourceIds) {
767
- // Yield a frame between sources — verdict passes are bounded but a
768
- // five-source build shouldn't run as one synchronous block.
769
- await new Promise((r) => setTimeout(r, 0));
770
- if (runId !== this.previewRunId) return;
771
-
772
- const provider = getStateSnapshotProvider(sid);
773
- const label = provider?.label ?? sid;
774
- try {
775
- const targetData = isBaseline
776
- ? undefined // provider treats an absent target as "empty"
777
- : superJsonDeserialize(rightData[sid]);
778
- const baseData =
779
- leftData === null
780
- ? undefined
781
- : sid in leftData
782
- ? superJsonDeserialize(leftData[sid])
783
- : undefined;
784
- let result: StateSnapshotPreview;
785
- if (!provider) {
786
- result = {
787
- items: [
788
- {
789
- key: sid,
790
- label,
791
- verdict: "wont-apply",
792
- reason: "tool not installed — restore skips this source",
793
- },
794
- ],
795
- notes: [],
796
- };
797
- } else if (provider.previewItems) {
798
- result = await provider.previewItems(targetData, baseData);
799
- } else {
800
- // Whole-source fallback for custom providers without previewItems.
801
- const diff = objectDiff(baseData, targetData);
802
- result = {
803
- items: [
804
- {
805
- key: sid,
806
- label,
807
- verdict: diff.equal ? "unchanged" : "changed",
808
- oldValue: baseData,
809
- newValue: targetData,
810
- },
811
- ],
812
- notes: diff.truncated ? ["diff truncated (large value)"] : [],
813
- };
814
- }
815
- if (runId !== this.previewRunId) return;
816
- sources.push({ id: sid, label, ...result });
817
- } catch (e) {
818
- sources.push({ id: sid, label, items: [], notes: [], error: String(e) });
819
- }
820
- this.setState({
821
- preview: {
822
- left,
823
- right,
824
- building: true,
825
- builtAt: Date.now(),
826
- sources: [...sources],
827
- },
828
- });
829
- }
830
- if (runId !== this.previewRunId) return;
831
- this.setState({
832
- preview: {
833
- left,
834
- right,
835
- building: false,
836
- builtAt: Date.now(),
837
- sources,
838
- },
839
- });
840
- } catch (e) {
841
- if (runId !== this.previewRunId) return;
842
- this.setState({
843
- preview: {
844
- left,
845
- right,
846
- building: false,
847
- builtAt: Date.now(),
848
- sources: [
849
- { id: "error", label: "Preview", items: [], notes: [], error: String(e) },
850
- ],
851
- },
852
- });
853
- }
854
- }
855
- }
856
-
857
- export const timeMachineStore = new TimeMachineStore();
858
-
859
- /**
860
- * Buoy Desktop: route every Time Machine operation through a remote handler
861
- * (the device's sync adapter actions) instead of running locally. Pass null
862
- * to detach. While attached, feed device payloads to
863
- * {@link replaceTimeMachineFromSnapshot}.
864
- */
865
- export function setRemoteTimeMachineHandler(handler: RemoteHandler | null): void {
866
- remoteHandler = handler;
867
- if (!handler) {
868
- timeMachineStore.replaceFromSnapshot({});
869
- }
870
- }
871
-
872
- /** Buoy Desktop: mirror a device's synced Time Machine metadata payload. */
873
- export function replaceTimeMachineFromSnapshot(payload: {
874
- snapshots?: SnapshotMeta[];
875
- busy?: TimeMachineState["busy"];
876
- lastOutcome?: RestoreOutcome | null;
877
- providers?: RemoteProviderStatus[];
878
- route?: RouteToolStatus;
879
- }): void {
880
- timeMachineStore.replaceFromSnapshot(payload);
881
- }