@buoy-gg/time-machine 7.0.21 → 7.0.23

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 (174) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +148 -583
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/components/TimeMachineModal.js +10 -11
  4. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  5. package/lib/commonjs/components/confirm.js +39 -0
  6. package/lib/commonjs/components/confirm.js.map +1 -0
  7. package/lib/commonjs/components/format.js +24 -0
  8. package/lib/commonjs/components/format.js.map +1 -0
  9. package/lib/commonjs/components/list/CaptureBar.js +95 -0
  10. package/lib/commonjs/components/list/CaptureBar.js.map +1 -0
  11. package/lib/commonjs/components/list/RestoreOutcomeCard.js +162 -0
  12. package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -0
  13. package/lib/commonjs/components/list/SnapshotRow.js +141 -0
  14. package/lib/commonjs/components/list/SnapshotRow.js.map +1 -0
  15. package/lib/commonjs/components/list/SourcesStrip.js +126 -0
  16. package/lib/commonjs/components/list/SourcesStrip.js.map +1 -0
  17. package/lib/commonjs/components/list/UndoDeleteBar.js +81 -0
  18. package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -0
  19. package/lib/commonjs/components/list/WipeAllRow.js +46 -0
  20. package/lib/commonjs/components/list/WipeAllRow.js.map +1 -0
  21. package/lib/commonjs/components/preview/ItemDiffPanel.js +77 -0
  22. package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -0
  23. package/lib/commonjs/components/preview/PreviewFlow.js +51 -0
  24. package/lib/commonjs/components/preview/PreviewFlow.js.map +1 -0
  25. package/lib/commonjs/components/preview/RestorePointNameRow.js +83 -0
  26. package/lib/commonjs/components/preview/RestorePointNameRow.js.map +1 -0
  27. package/lib/commonjs/components/preview/RestorePreviewScreen.js +549 -635
  28. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
  29. package/lib/commonjs/components/preview/verdictMeta.js +8 -8
  30. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -1
  31. package/lib/commonjs/components/sources.js +72 -0
  32. package/lib/commonjs/components/sources.js.map +1 -0
  33. package/lib/commonjs/index.js +55 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/store/routeRestore.js +188 -0
  36. package/lib/commonjs/store/routeRestore.js.map +1 -0
  37. package/lib/commonjs/store/snapshotVault.js +93 -9
  38. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  39. package/lib/commonjs/store/timeMachineStore.js +164 -7
  40. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  41. package/lib/commonjs/sync/timeMachineSyncAdapter.js +59 -4
  42. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  43. package/lib/commonjs/ui/SwipeActionsRow.js +167 -0
  44. package/lib/commonjs/ui/SwipeActionsRow.js.map +1 -0
  45. package/lib/commonjs/ui/primitives.js +164 -0
  46. package/lib/commonjs/ui/primitives.js.map +1 -0
  47. package/lib/commonjs/ui/tmTheme.js +103 -0
  48. package/lib/commonjs/ui/tmTheme.js.map +1 -0
  49. package/lib/module/components/TimeMachineBody.js +151 -586
  50. package/lib/module/components/TimeMachineBody.js.map +1 -1
  51. package/lib/module/components/TimeMachineModal.js +11 -12
  52. package/lib/module/components/TimeMachineModal.js.map +1 -1
  53. package/lib/module/components/confirm.js +34 -0
  54. package/lib/module/components/confirm.js.map +1 -0
  55. package/lib/module/components/format.js +19 -0
  56. package/lib/module/components/format.js.map +1 -0
  57. package/lib/module/components/list/CaptureBar.js +90 -0
  58. package/lib/module/components/list/CaptureBar.js.map +1 -0
  59. package/lib/module/components/list/RestoreOutcomeCard.js +156 -0
  60. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -0
  61. package/lib/module/components/list/SnapshotRow.js +138 -0
  62. package/lib/module/components/list/SnapshotRow.js.map +1 -0
  63. package/lib/module/components/list/SourcesStrip.js +121 -0
  64. package/lib/module/components/list/SourcesStrip.js.map +1 -0
  65. package/lib/module/components/list/UndoDeleteBar.js +78 -0
  66. package/lib/module/components/list/UndoDeleteBar.js.map +1 -0
  67. package/lib/module/components/list/WipeAllRow.js +41 -0
  68. package/lib/module/components/list/WipeAllRow.js.map +1 -0
  69. package/lib/module/components/preview/ItemDiffPanel.js +72 -0
  70. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -0
  71. package/lib/module/components/preview/PreviewFlow.js +46 -0
  72. package/lib/module/components/preview/PreviewFlow.js.map +1 -0
  73. package/lib/module/components/preview/RestorePointNameRow.js +78 -0
  74. package/lib/module/components/preview/RestorePointNameRow.js.map +1 -0
  75. package/lib/module/components/preview/RestorePreviewScreen.js +552 -638
  76. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  77. package/lib/module/components/preview/verdictMeta.js +9 -8
  78. package/lib/module/components/preview/verdictMeta.js.map +1 -1
  79. package/lib/module/components/sources.js +66 -0
  80. package/lib/module/components/sources.js.map +1 -0
  81. package/lib/module/index.js +11 -0
  82. package/lib/module/index.js.map +1 -1
  83. package/lib/module/store/routeRestore.js +176 -0
  84. package/lib/module/store/routeRestore.js.map +1 -0
  85. package/lib/module/store/snapshotVault.js +87 -9
  86. package/lib/module/store/snapshotVault.js.map +1 -1
  87. package/lib/module/store/timeMachineStore.js +164 -8
  88. package/lib/module/store/timeMachineStore.js.map +1 -1
  89. package/lib/module/sync/timeMachineSyncAdapter.js +59 -4
  90. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  91. package/lib/module/ui/SwipeActionsRow.js +162 -0
  92. package/lib/module/ui/SwipeActionsRow.js.map +1 -0
  93. package/lib/module/ui/primitives.js +156 -0
  94. package/lib/module/ui/primitives.js.map +1 -0
  95. package/lib/module/ui/tmTheme.js +99 -0
  96. package/lib/module/ui/tmTheme.js.map +1 -0
  97. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  98. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  99. package/lib/typescript/components/confirm.d.ts +2 -0
  100. package/lib/typescript/components/confirm.d.ts.map +1 -0
  101. package/lib/typescript/components/format.d.ts +4 -0
  102. package/lib/typescript/components/format.d.ts.map +1 -0
  103. package/lib/typescript/components/list/CaptureBar.d.ts +6 -0
  104. package/lib/typescript/components/list/CaptureBar.d.ts.map +1 -0
  105. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts +11 -0
  106. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -0
  107. package/lib/typescript/components/list/SnapshotRow.d.ts +12 -0
  108. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -0
  109. package/lib/typescript/components/list/SourcesStrip.d.ts +5 -0
  110. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -0
  111. package/lib/typescript/components/list/UndoDeleteBar.d.ts +8 -0
  112. package/lib/typescript/components/list/UndoDeleteBar.d.ts.map +1 -0
  113. package/lib/typescript/components/list/WipeAllRow.d.ts +5 -0
  114. package/lib/typescript/components/list/WipeAllRow.d.ts.map +1 -0
  115. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +5 -0
  116. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -0
  117. package/lib/typescript/components/preview/PreviewFlow.d.ts +9 -0
  118. package/lib/typescript/components/preview/PreviewFlow.d.ts.map +1 -0
  119. package/lib/typescript/components/preview/RestorePointNameRow.d.ts +5 -0
  120. package/lib/typescript/components/preview/RestorePointNameRow.d.ts.map +1 -0
  121. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +1 -5
  122. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  123. package/lib/typescript/components/preview/verdictMeta.d.ts +5 -0
  124. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -1
  125. package/lib/typescript/components/sources.d.ts +36 -0
  126. package/lib/typescript/components/sources.d.ts.map +1 -0
  127. package/lib/typescript/index.d.ts +2 -1
  128. package/lib/typescript/index.d.ts.map +1 -1
  129. package/lib/typescript/store/routeRestore.d.ts +51 -0
  130. package/lib/typescript/store/routeRestore.d.ts.map +1 -0
  131. package/lib/typescript/store/snapshotVault.d.ts +55 -0
  132. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  133. package/lib/typescript/store/timeMachineStore.d.ts +64 -2
  134. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  135. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +25 -0
  136. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  137. package/lib/typescript/ui/SwipeActionsRow.d.ts +32 -0
  138. package/lib/typescript/ui/SwipeActionsRow.d.ts.map +1 -0
  139. package/lib/typescript/ui/primitives.d.ts +57 -0
  140. package/lib/typescript/ui/primitives.d.ts.map +1 -0
  141. package/lib/typescript/ui/tmTheme.d.ts +67 -0
  142. package/lib/typescript/ui/tmTheme.d.ts.map +1 -0
  143. package/package.json +12 -8
  144. package/src/components/TimeMachineBody.tsx +184 -682
  145. package/src/components/TimeMachineModal.tsx +10 -11
  146. package/src/components/confirm.ts +35 -0
  147. package/src/components/format.ts +17 -0
  148. package/src/components/list/CaptureBar.tsx +100 -0
  149. package/src/components/list/RestoreOutcomeCard.tsx +159 -0
  150. package/src/components/list/SnapshotRow.tsx +155 -0
  151. package/src/components/list/SourcesStrip.tsx +124 -0
  152. package/src/components/list/UndoDeleteBar.tsx +76 -0
  153. package/src/components/list/WipeAllRow.tsx +42 -0
  154. package/src/components/preview/ItemDiffPanel.tsx +77 -0
  155. package/src/components/preview/PreviewFlow.tsx +70 -0
  156. package/src/components/preview/RestorePointNameRow.tsx +80 -0
  157. package/src/components/preview/RestorePreviewScreen.tsx +535 -535
  158. package/src/components/preview/verdictMeta.ts +8 -8
  159. package/src/components/sources.ts +92 -0
  160. package/src/index.tsx +22 -0
  161. package/src/store/routeRestore.ts +194 -0
  162. package/src/store/snapshotVault.ts +138 -9
  163. package/src/store/timeMachineStore.ts +195 -5
  164. package/src/sync/timeMachineSyncAdapter.ts +57 -4
  165. package/src/ui/SwipeActionsRow.tsx +199 -0
  166. package/src/ui/primitives.tsx +157 -0
  167. package/src/ui/tmTheme.ts +107 -0
  168. package/lib/commonjs/components/preview/ItemDetailScreen.js +0 -189
  169. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +0 -1
  170. package/lib/module/components/preview/ItemDetailScreen.js +0 -184
  171. package/lib/module/components/preview/ItemDetailScreen.js.map +0 -1
  172. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +0 -11
  173. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +0 -1
  174. package/src/components/preview/ItemDetailScreen.tsx +0 -172
@@ -28,14 +28,26 @@ import {
28
28
  import { objectDiff } from "@buoy-gg/shared-ui/dataViewer";
29
29
  import {
30
30
  deleteSnapshot,
31
+ duplicateSnapshot,
31
32
  loadIndex,
32
33
  loadSnapshot,
34
+ markSnapshotRestored,
35
+ readSnapshot,
33
36
  renameSnapshot,
37
+ restoreDeletedSnapshot,
34
38
  saveSnapshot,
39
+ setSnapshotRestoreRoute,
35
40
  setSnapshotScope,
41
+ setSnapshotExclusions,
36
42
  type SnapshotMeta,
37
43
  type StoredSnapshot,
38
44
  } from "./snapshotVault";
45
+ import {
46
+ captureCurrentRoute,
47
+ navigateToRoute,
48
+ savePendingRoute,
49
+ type RouteRestoreResult,
50
+ } from "./routeRestore";
39
51
 
40
52
  export const RESTORE_ORDER = ["storage", "redux", "zustand", "jotai", "query"];
41
53
 
@@ -49,6 +61,44 @@ export interface RestoreOutcome {
49
61
  /** True when a JS reload was scheduled as part of this restore. */
50
62
  willReload: boolean;
51
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;
52
102
  }
53
103
 
54
104
  /** Provider capability row as mirrored to a remote dashboard. */
@@ -92,6 +142,23 @@ export interface TimeMachineState {
92
142
  * registry, and every operation forwards over the socket.
93
143
  */
94
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;
95
162
  }
96
163
 
97
164
  type RemoteHandler = (
@@ -119,6 +186,8 @@ class TimeMachineStore {
119
186
  lastOutcome: null,
120
187
  preview: null,
121
188
  remoteProviders: null,
189
+ remoteRoute: null,
190
+ recentlyDeleted: null,
122
191
  };
123
192
  /** Monotonic token — a preview build checks it after every await and
124
193
  * abandons itself when a newer build (or clearPreview) supersedes it. */
@@ -166,12 +235,14 @@ class TimeMachineStore {
166
235
  busy?: TimeMachineState["busy"];
167
236
  lastOutcome?: RestoreOutcome | null;
168
237
  providers?: RemoteProviderStatus[];
238
+ route?: RouteToolStatus;
169
239
  }): void {
170
240
  this.setState({
171
241
  snapshots: payload.snapshots ?? [],
172
242
  busy: payload.busy ?? null,
173
243
  lastOutcome: payload.lastOutcome ?? null,
174
244
  remoteProviders: payload.providers ?? [],
245
+ remoteRoute: payload.route ?? { available: false },
175
246
  hasLoaded: true,
176
247
  });
177
248
  }
@@ -204,12 +275,15 @@ class TimeMachineStore {
204
275
  };
205
276
  }
206
277
  }
278
+ // Where the app was when this state was true — restoring can return here.
279
+ const route = captureCurrentRoute();
207
280
  const snapshot: StoredSnapshot = {
208
281
  meta: {
209
282
  id: nextId(),
210
- name: name.trim() || "Untitled snapshot",
283
+ name: name.trim() || defaultSnapshotName(route),
211
284
  createdAt: Date.now(),
212
285
  sizeBytes: 0,
286
+ ...(route ? { route } : {}),
213
287
  sources,
214
288
  },
215
289
  data,
@@ -259,13 +333,15 @@ class TimeMachineStore {
259
333
  async restore(
260
334
  id: string,
261
335
  mode: RestoreMode,
262
- excludeKeys?: readonly string[]
336
+ excludeKeys?: readonly string[],
337
+ options?: RestoreOptions
263
338
  ): Promise<RestoreOutcome> {
264
339
  if (remoteHandler) {
265
340
  const outcome = (await remoteHandler("restore", {
266
341
  id,
267
342
  mode,
268
343
  excludeKeys,
344
+ restoreRoute: options?.restoreRoute,
269
345
  })) as RestoreOutcome;
270
346
  this.setState({ lastOutcome: outcome });
271
347
  return outcome;
@@ -321,7 +397,11 @@ class TimeMachineStore {
321
397
  return bySource;
322
398
  };
323
399
 
324
- const excludeBySource = splitCompound(excludeKeys ?? []);
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);
325
405
  // A saved scope makes this a targeted restore point. An explicit
326
406
  // exclusion list from the preview already reflects the scope (the
327
407
  // preview seeds its selection from it), so it takes precedence.
@@ -330,6 +410,12 @@ class TimeMachineStore {
330
410
  ? splitCompound(stored.meta.scope)
331
411
  : null;
332
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
+
333
419
  if (mode === "reload") {
334
420
  // Storage only; everything in-memory rebuilds after the reload.
335
421
  if (
@@ -343,8 +429,23 @@ class TimeMachineStore {
343
429
  includeBySource?.get("storage")
344
430
  );
345
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
+ }
346
443
  outcome.willReload = this.scheduleReload("buoy time-machine restore");
347
- this.setState({ lastOutcome: outcome });
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
+ });
348
449
  return outcome;
349
450
  }
350
451
 
@@ -358,7 +459,14 @@ class TimeMachineStore {
358
459
  includeBySource?.get(sid)
359
460
  );
360
461
  }
361
- this.setState({ lastOutcome: outcome });
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
+ });
362
470
  return outcome;
363
471
  } finally {
364
472
  this.setState({ busy: null });
@@ -403,16 +511,78 @@ class TimeMachineStore {
403
511
  }
404
512
  }
405
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
+ */
406
520
  async delete(id: string): Promise<void> {
407
521
  if (remoteHandler) {
408
522
  await remoteHandler("delete", { id });
409
523
  return;
410
524
  }
411
525
  await this.ensureLoaded();
526
+ const stored = await readSnapshot(id);
412
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
+ );
413
562
  this.setState({ snapshots });
414
563
  }
415
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
+
416
586
  async rename(id: string, name: string): Promise<void> {
417
587
  if (remoteHandler) {
418
588
  await remoteHandler("rename", { id, name });
@@ -438,6 +608,25 @@ class TimeMachineStore {
438
608
  this.setState({ snapshots });
439
609
  }
440
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
+
441
630
  clearLastOutcome(): void {
442
631
  this.setState({ lastOutcome: null });
443
632
  }
@@ -686,6 +875,7 @@ export function replaceTimeMachineFromSnapshot(payload: {
686
875
  busy?: TimeMachineState["busy"];
687
876
  lastOutcome?: RestoreOutcome | null;
688
877
  providers?: RemoteProviderStatus[];
878
+ route?: RouteToolStatus;
689
879
  }): void {
690
880
  timeMachineStore.replaceFromSnapshot(payload);
691
881
  }
@@ -23,6 +23,7 @@ import {
23
23
  type RestoreMode,
24
24
  } from "../store/timeMachineStore";
25
25
  import { loadSnapshot } from "../store/snapshotVault";
26
+ import { canNavigate, currentRoute } from "../store/routeRestore";
26
27
 
27
28
  interface CaptureParams {
28
29
  name?: string;
@@ -33,6 +34,8 @@ interface RestoreParams {
33
34
  mode?: RestoreMode;
34
35
  /** Compound "sourceId::itemKey" strings — items the caller opted out of. */
35
36
  excludeKeys?: string[];
37
+ /** Also navigate back to the route the snapshot was captured on. */
38
+ restoreRoute?: boolean;
36
39
  }
37
40
  interface IdParams {
38
41
  id: string;
@@ -61,11 +64,24 @@ function providerStatuses() {
61
64
 
62
65
  function metadata() {
63
66
  const { snapshots, busy, lastOutcome } = timeMachineStore.getState();
64
- return { snapshots, busy, lastOutcome, providers: providerStatuses() };
67
+ const current = currentRoute();
68
+ return {
69
+ snapshots,
70
+ busy,
71
+ lastOutcome,
72
+ providers: providerStatuses(),
73
+ // Route capture/restore is a device capability, not a snapshot provider —
74
+ // a dashboard has no router of its own to probe, so it's mirrored here.
75
+ route: {
76
+ available: canNavigate(),
77
+ ...(current ? { current: current.href } : {}),
78
+ },
79
+ };
65
80
  }
66
81
 
67
82
  export const timeMachineSyncAdapter = {
68
- version: 1,
83
+ // v2 adds `route` (device route capability) and `snapshots[].route`.
84
+ version: 2,
69
85
  getSnapshot: () => metadata(),
70
86
  subscribe: (onChange: () => void) => {
71
87
  const unsubStore = timeMachineStore.subscribe(onChange);
@@ -96,9 +112,16 @@ export const timeMachineSyncAdapter = {
96
112
  * persisted storage then reloads the JS bundle (always the path for
97
113
  * baseline snapshots). */
98
114
  restore: async (params: unknown) => {
99
- const { id, mode = "live", excludeKeys } = (params as RestoreParams) ?? {};
115
+ const {
116
+ id,
117
+ mode = "live",
118
+ excludeKeys,
119
+ restoreRoute,
120
+ } = (params as RestoreParams) ?? {};
100
121
  if (!id) throw new Error("restore requires a snapshot `id`");
101
- return await timeMachineStore.restore(id, mode, excludeKeys);
122
+ return await timeMachineStore.restore(id, mode, excludeKeys, {
123
+ restoreRoute,
124
+ });
102
125
  },
103
126
 
104
127
  delete: async (params: unknown) => {
@@ -115,6 +138,36 @@ export const timeMachineSyncAdapter = {
115
138
  return { renamed: true, id, name };
116
139
  },
117
140
 
141
+ /**
142
+ * Save (or clear with null/empty) the items a restore of this snapshot
143
+ * must leave alone — the persisted form of the preview's checkboxes.
144
+ */
145
+ setExclusions: async (params: unknown) => {
146
+ const { id, excludedKeys } =
147
+ (params as { id?: string; excludedKeys?: string[] | null }) ?? {};
148
+ if (!id) throw new Error("setExclusions requires `id`");
149
+ await timeMachineStore.setExclusions(id, excludedKeys ?? null);
150
+ return { id, excluded: excludedKeys?.length ?? 0 };
151
+ },
152
+
153
+ /** Turn a restore point's route restore on or off. */
154
+ setRestoreRoute: async (params: unknown) => {
155
+ const { id, restoreRoute } =
156
+ (params as { id?: string; restoreRoute?: boolean }) ?? {};
157
+ if (!id) throw new Error("setRestoreRoute requires `id`");
158
+ await timeMachineStore.setRestoreRoute(id, restoreRoute !== false);
159
+ return { id, restoreRoute: restoreRoute !== false };
160
+ },
161
+
162
+ /** Copy a restore point so a variation can start from a known base. */
163
+ duplicate: async (params: unknown) => {
164
+ const { id } = (params as { id?: string }) ?? {};
165
+ if (!id) throw new Error("duplicate requires `id`");
166
+ const snapshot = await timeMachineStore.duplicate(id);
167
+ if (!snapshot) throw new Error(`No snapshot with id "${id}"`);
168
+ return { snapshot };
169
+ },
170
+
118
171
  /** Save (or clear with null/empty) a snapshot's targeted-restore scope. */
119
172
  setScope: async (params: unknown) => {
120
173
  const { id, scope } =
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Swipe-left-to-reveal-actions wrapper for a grouped-list row.
3
+ *
4
+ * Deliberately dependency-free (Animated + PanResponder): the package has no
5
+ * gesture-handler peer and a devtool must not add one to the host app.
6
+ *
7
+ * Gesture contract, in the order the checks matter:
8
+ * - The pan only claims the gesture once the finger has moved LEFT past a
9
+ * threshold and is travelling more horizontally than vertically, so taps
10
+ * still reach the row and vertical drags still scroll the list.
11
+ * - Releasing past half the action width latches open; anything less snaps
12
+ * shut, so a hesitant swipe never leaves a half-open row.
13
+ * - Only one row stays open at a time (module-level latch below) — matching
14
+ * iOS, and it means "swipe, then tap an action" can't hit the wrong row.
15
+ */
16
+ import { useEffect, useRef, type ReactNode } from "react";
17
+ import {
18
+ Animated,
19
+ PanResponder,
20
+ StyleSheet,
21
+ Text,
22
+ TouchableOpacity,
23
+ View,
24
+ type PanResponderInstance,
25
+ } from "react-native";
26
+ import { tm } from "./tmTheme";
27
+
28
+ const ACTION_WIDTH = 84;
29
+ /** Horizontal travel before the pan takes the gesture from the scroll view. */
30
+ const CLAIM_THRESHOLD = 8;
31
+
32
+ /** The one open row, so opening another closes it. */
33
+ let closeOpenRow: (() => void) | null = null;
34
+
35
+ interface Gesture {
36
+ pan: PanResponderInstance;
37
+ /** Closes this row — also the row's identity in the open-row latch. */
38
+ close: () => void;
39
+ }
40
+
41
+ /**
42
+ * Everything gesture-side for one row, built once. Kept out of the component
43
+ * so it reads as the state machine it is: `open` is the only state, and the
44
+ * pan handlers are the only writers.
45
+ */
46
+ function createGesture(
47
+ translateX: Animated.Value,
48
+ openWidth: number
49
+ ): Gesture {
50
+ let open = false;
51
+
52
+ const settle = (next: boolean) => {
53
+ open = next;
54
+ if (next) {
55
+ if (closeOpenRow && closeOpenRow !== close) closeOpenRow();
56
+ closeOpenRow = close;
57
+ } else if (closeOpenRow === close) {
58
+ closeOpenRow = null;
59
+ }
60
+ Animated.spring(translateX, {
61
+ toValue: next ? -openWidth : 0,
62
+ useNativeDriver: true,
63
+ bounciness: 0,
64
+ speed: 18,
65
+ }).start();
66
+ };
67
+
68
+ const close = () => settle(false);
69
+
70
+ const pan = PanResponder.create({
71
+ onMoveShouldSetPanResponder: (_e, g) => {
72
+ const horizontal = Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
73
+ if (!horizontal) return false;
74
+ // Left to open; right only matters when there is something to close.
75
+ return g.dx < -CLAIM_THRESHOLD || (open && g.dx > CLAIM_THRESHOLD);
76
+ },
77
+ onPanResponderMove: (_e, g) => {
78
+ const base = open ? -openWidth : 0;
79
+ // Clamp: no rubber-banding past the actions, none past closed.
80
+ translateX.setValue(Math.min(0, Math.max(-openWidth, base + g.dx)));
81
+ },
82
+ onPanResponderRelease: (_e, g) => {
83
+ const resting = (open ? -openWidth : 0) + g.dx;
84
+ // A fast flick decides on velocity; a slow drag on distance.
85
+ settle(
86
+ g.vx < -0.5 ? true : g.vx > 0.5 ? false : resting < -openWidth / 2
87
+ );
88
+ },
89
+ onPanResponderTerminate: () => settle(open),
90
+ });
91
+
92
+ return { pan, close };
93
+ }
94
+
95
+ export interface SwipeAction {
96
+ label: string;
97
+ /** Called after the row closes itself. */
98
+ onPress: () => void;
99
+ /** Fill behind the row. Defaults to the neutral action fill. */
100
+ background?: string;
101
+ color?: string;
102
+ testID?: string;
103
+ }
104
+
105
+ export function SwipeActionsRow({
106
+ children,
107
+ actions,
108
+ testID,
109
+ }: {
110
+ children: ReactNode;
111
+ /** Rightmost is the one furthest from the content, iOS-style. */
112
+ actions: SwipeAction[];
113
+ testID?: string;
114
+ }) {
115
+ const openWidth = ACTION_WIDTH * actions.length;
116
+ const translateX = useRef(new Animated.Value(0)).current;
117
+ const gestureRef = useRef<Gesture | null>(null);
118
+ if (gestureRef.current === null)
119
+ gestureRef.current = createGesture(translateX, openWidth);
120
+ const { pan, close } = gestureRef.current;
121
+
122
+ // A row that unmounts while open (deleted, filtered out, screen changed)
123
+ // must not leave the latch pointing at a dead closer.
124
+ useEffect(
125
+ () => () => {
126
+ if (closeOpenRow === close) closeOpenRow = null;
127
+ },
128
+ [close]
129
+ );
130
+
131
+ return (
132
+ <View style={styles.clip}>
133
+ <View style={[styles.actionLayer, { width: openWidth }]}>
134
+ {actions.map((action) => (
135
+ <TouchableOpacity
136
+ key={action.label}
137
+ style={[
138
+ styles.action,
139
+ action.background ? { backgroundColor: action.background } : null,
140
+ ]}
141
+ onPress={() => {
142
+ close();
143
+ action.onPress();
144
+ }}
145
+ accessibilityLabel={action.label}
146
+ testID={action.testID ?? (testID ? `${testID}-${action.label.toLowerCase()}` : undefined)}
147
+ >
148
+ <Text
149
+ style={[styles.actionText, action.color ? { color: action.color } : null]}
150
+ numberOfLines={1}
151
+ >
152
+ {action.label}
153
+ </Text>
154
+ </TouchableOpacity>
155
+ ))}
156
+ </View>
157
+ <Animated.View
158
+ style={[styles.content, { transform: [{ translateX }] }]}
159
+ {...pan.panHandlers}
160
+ >
161
+ {children}
162
+ </Animated.View>
163
+ </View>
164
+ );
165
+ }
166
+
167
+ const styles = StyleSheet.create({
168
+ clip: {
169
+ overflow: "hidden",
170
+ },
171
+ // Own geometry, no percentages: an absoluteFill layer with a `height:
172
+ // "100%"` child measured against this content-sized parent inflated every
173
+ // row to several hundred points on iOS/Fabric.
174
+ actionLayer: {
175
+ position: "absolute",
176
+ top: 0,
177
+ bottom: 0,
178
+ right: 0,
179
+ flexDirection: "row",
180
+ },
181
+ action: {
182
+ width: ACTION_WIDTH,
183
+ height: "100%",
184
+ backgroundColor: tm.color.fillStrong,
185
+ alignItems: "center",
186
+ justifyContent: "center",
187
+ paddingHorizontal: 6,
188
+ },
189
+ actionText: {
190
+ color: tm.color.label,
191
+ fontSize: tm.font.footnote,
192
+ fontWeight: "600",
193
+ letterSpacing: tm.tracking.control,
194
+ },
195
+ // Opaque so the red action never bleeds through the row it sits under.
196
+ content: {
197
+ backgroundColor: tm.color.group,
198
+ },
199
+ });