@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
@@ -3,8 +3,8 @@
3
3
  * paired with a distinct glyph (true minus U+2212, approx U+2248) — the
4
4
  * repo-wide accessibility convention for diff surfaces.
5
5
  */
6
- import { buoyColors } from "@buoy-gg/shared-ui";
7
6
  import type { StateSnapshotPreviewVerdict } from "@buoy-gg/shared-ui";
7
+ import { tm } from "../../ui/tmTheme";
8
8
 
9
9
  export interface VerdictMeta {
10
10
  glyph: string;
@@ -15,25 +15,25 @@ export interface VerdictMeta {
15
15
  }
16
16
 
17
17
  export const VERDICT_META: Record<StateSnapshotPreviewVerdict, VerdictMeta> = {
18
- added: { glyph: "+", color: buoyColors.success, label: "added", applies: true },
19
- removed: { glyph: "−", color: buoyColors.error, label: "removed", applies: true },
20
- changed: { glyph: "≈", color: buoyColors.info, label: "changed", applies: true },
18
+ added: { glyph: "+", color: tm.color.accent, label: "added", applies: true },
19
+ removed: { glyph: "−", color: tm.color.danger, label: "removed", applies: true },
20
+ changed: { glyph: "≈", color: tm.color.info, label: "changed", applies: true },
21
21
  "too-large": {
22
22
  glyph: "≈",
23
- color: buoyColors.warning,
23
+ color: tm.color.warning,
24
24
  label: "changed (large)",
25
25
  applies: true,
26
26
  },
27
- unchanged: { glyph: "=", color: buoyColors.textMuted, label: "unchanged", applies: false },
27
+ unchanged: { glyph: "=", color: tm.color.labelTertiary, label: "unchanged", applies: false },
28
28
  "wont-apply": {
29
29
  glyph: "⊘",
30
- color: buoyColors.warning,
30
+ color: tm.color.warning,
31
31
  label: "won't apply",
32
32
  applies: false,
33
33
  },
34
34
  recomputes: {
35
35
  glyph: "↻",
36
- color: buoyColors.textMuted,
36
+ color: tm.color.labelTertiary,
37
37
  label: "recomputes",
38
38
  applies: false,
39
39
  },
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What Time Machine can capture from, and how healthy each source is.
3
+ *
4
+ * On device these are live registry probes; on Buoy Desktop the same shapes
5
+ * arrive mirrored in the sync payload. Route is folded in as a source even
6
+ * though it is a device capability rather than a registered provider — from
7
+ * the user's side "can this snapshot take me back to that screen?" is the
8
+ * same question as "will my Redux store come back".
9
+ */
10
+ import type { ComponentType } from "react";
11
+ import {
12
+ JotaiIcon,
13
+ QueryIcon,
14
+ ReduxIcon,
15
+ RoutesIcon,
16
+ StorageIcon,
17
+ TimeMachineIcon,
18
+ ZustandIcon,
19
+ } from "@buoy-gg/floating-tools-core";
20
+ import { useSnapshotProviders, useTimeMachineState } from "../hooks/useTimeMachine";
21
+ import { canNavigate } from "../store/routeRestore";
22
+
23
+ export interface SourceStatus {
24
+ id: string;
25
+ label: string;
26
+ canCapture: boolean;
27
+ canRestore: boolean;
28
+ /** Why it is in this state — shown on the Sources screen. */
29
+ detail?: string;
30
+ }
31
+
32
+ /**
33
+ * Buoy icons paint their knock-out parts with `fill: "bg"`, defaulting to a
34
+ * near-black — so whatever surface an icon sits on has to be handed in as
35
+ * `bgColor` or those cut-outs read as black holes (storage's floppy slot and
36
+ * the route glyph are the two that use it).
37
+ */
38
+ export type SourceIcon = ComponentType<{
39
+ size?: number;
40
+ color?: string;
41
+ bgColor?: string;
42
+ }>;
43
+
44
+ const ICONS: Record<string, SourceIcon> = {
45
+ storage: StorageIcon,
46
+ redux: ReduxIcon,
47
+ zustand: ZustandIcon,
48
+ jotai: JotaiIcon,
49
+ query: QueryIcon,
50
+ route: RoutesIcon,
51
+ };
52
+
53
+ export function sourceIcon(id: string): SourceIcon {
54
+ return ICONS[id] ?? TimeMachineIcon;
55
+ }
56
+
57
+ /** Id of the route tile — a device capability, not a snapshot provider. */
58
+ export const ROUTE_SOURCE_ID = "route";
59
+
60
+ export function useSourceStatuses(): SourceStatus[] {
61
+ const { remoteProviders, remoteRoute } = useTimeMachineState();
62
+ const localProviders = useSnapshotProviders();
63
+
64
+ const providers: SourceStatus[] =
65
+ remoteProviders ??
66
+ localProviders.map((p) => ({ id: p.id, label: p.label, ...p.status() }));
67
+
68
+ const routeAvailable = remoteProviders
69
+ ? remoteRoute?.available === true
70
+ : canNavigate();
71
+
72
+ return [
73
+ ...providers,
74
+ {
75
+ id: ROUTE_SOURCE_ID,
76
+ label: "Route",
77
+ canCapture: routeAvailable,
78
+ canRestore: routeAvailable,
79
+ // No live pathname here: these screens only re-render on store changes,
80
+ // so a route printed as status goes stale the moment you navigate with
81
+ // the tool still open. Each restore point shows the route it holds.
82
+ detail: routeAvailable
83
+ ? "Recorded with every snapshot — a restore can take you back"
84
+ : "No Expo Router detected — snapshots record no route",
85
+ },
86
+ ];
87
+ }
88
+
89
+ /** A source is "attached" when a snapshot would actually pick it up. */
90
+ export function isAttached(source: SourceStatus): boolean {
91
+ return source.canCapture;
92
+ }
package/src/index.tsx CHANGED
@@ -10,6 +10,8 @@
10
10
  * PUBLIC API - Only these exports are supported for external use.
11
11
  */
12
12
 
13
+ import { armPendingRouteRestore as armPendingRouteRestoreOnLoad } from "./store/routeRestore";
14
+
13
15
  // Tool preset for FloatingDevTools
14
16
  export { timeMachineToolPreset, createTimeMachineTool } from "./preset";
15
17
 
@@ -28,11 +30,23 @@ export {
28
30
  timeMachineStore,
29
31
  RESTORE_ORDER,
30
32
  type RestoreMode,
33
+ type RestoreOptions,
31
34
  type RestoreOutcome,
32
35
  type TimeMachineState,
33
36
  type RemoteProviderStatus,
34
37
  } from "./store/timeMachineStore";
35
38
 
39
+ // Route capture / restore ("also take me back to the screen I was on")
40
+ export {
41
+ armPendingRouteRestore,
42
+ captureCurrentRoute,
43
+ navigateToRoute,
44
+ canNavigate as canRestoreRoute,
45
+ type SnapshotRoute,
46
+ type RouteRestoreResult,
47
+ type RouteRestoreStatus,
48
+ } from "./store/routeRestore";
49
+
36
50
  // Buoy Desktop remote-mirror seam
37
51
  export {
38
52
  setRemoteTimeMachineHandler,
@@ -43,3 +57,11 @@ export type {
43
57
  SnapshotSourceMeta,
44
58
  StoredSnapshot,
45
59
  } from "./store/snapshotVault";
60
+
61
+ // A reload-mode restore that also restores the route can't navigate itself —
62
+ // the JS realm dies with the reload. It parks the route in storage; this picks
63
+ // it up on the next boot. Core requires this module while mounting the
64
+ // floating menu, which is the earliest point we're guaranteed to run.
65
+ // Idempotent: repeated imports (or a host calling it explicitly) arm once.
66
+ armPendingRouteRestoreOnLoad();
67
+
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Route capture & restore for Time Machine.
3
+ *
4
+ * A restore point is only half the story: state without the screen you were
5
+ * on drops you into the right data on the wrong page. Every snapshot records
6
+ * the route it was captured on, and a restore can navigate back to it.
7
+ *
8
+ * Two navigation paths, because a "reload" restore kills the JS realm:
9
+ * - LIVE restore: navigate immediately once the sources are applied.
10
+ * - RELOAD restore: persist the route BEFORE the reload is scheduled, then
11
+ * {@link armPendingRouteRestore} picks it up on the next boot, waits for the
12
+ * router to come online, and navigates.
13
+ *
14
+ * Everything here is best-effort: apps without expo-router capture no route,
15
+ * so the UI simply never offers the option.
16
+ */
17
+ import {
18
+ getSafeCurrentRoute,
19
+ getSafeRouter,
20
+ persistentStorage,
21
+ type SafeRouteLocation,
22
+ } from "@buoy-gg/shared-ui";
23
+
24
+ /**
25
+ * The route a snapshot was captured on. Only the URL is stored — imperative
26
+ * params passed as objects (`navigate(path, { user })`) are not part of a
27
+ * route and are not restored.
28
+ */
29
+ export interface SnapshotRoute {
30
+ /** Pathname with dynamic segments resolved, e.g. `/pokemon/25`. */
31
+ pathname: string;
32
+ /** What we navigate to — pathname plus search params. */
33
+ href: string;
34
+ }
35
+
36
+ /** How a restore's route leg ended, reported back in the RestoreOutcome. */
37
+ export type RouteRestoreStatus =
38
+ | "navigated"
39
+ | "deferred"
40
+ | "failed"
41
+ | "unavailable";
42
+
43
+ export interface RouteRestoreResult {
44
+ pathname: string;
45
+ href: string;
46
+ status: RouteRestoreStatus;
47
+ /** Present when status is "failed" / "unavailable". */
48
+ error?: string;
49
+ }
50
+
51
+ /**
52
+ * Same `@react_buoy` family as the snapshot vault, so every tool's
53
+ * isDevToolsStorageKey filter (and therefore every wipe/restore) leaves it
54
+ * alone — a pending route must survive the storage restore that precedes the
55
+ * reload it's waiting on.
56
+ */
57
+ const PENDING_KEY = "@react_buoy_time_machine:pending-route";
58
+
59
+ /**
60
+ * A pending route older than this belongs to a reload that never happened
61
+ * (the app was killed, the user moved on). Boot-time navigation into a screen
62
+ * someone left minutes ago is worse than doing nothing.
63
+ */
64
+ export const PENDING_ROUTE_TTL_MS = 60_000;
65
+
66
+ /** How long to wait at boot for the router to come online. */
67
+ const ROUTER_READY_TIMEOUT_MS = 8_000;
68
+
69
+ /** Settle window after the router appears, so the initial route mounts first. */
70
+ const ROUTER_SETTLE_MS = 250;
71
+
72
+ interface PendingRoute extends SnapshotRoute {
73
+ savedAt: number;
74
+ snapshotName?: string;
75
+ }
76
+
77
+ function toSnapshotRoute(location: SafeRouteLocation): SnapshotRoute {
78
+ return { pathname: location.pathname, href: location.href };
79
+ }
80
+
81
+ /** The route to stamp on a snapshot being captured, or null when unavailable. */
82
+ export function captureCurrentRoute(): SnapshotRoute | null {
83
+ const current = getSafeCurrentRoute();
84
+ return current ? toSnapshotRoute(current) : null;
85
+ }
86
+
87
+ /** The live route, for "you're already here" checks in the UI. */
88
+ export function currentRoute(): SnapshotRoute | null {
89
+ return captureCurrentRoute();
90
+ }
91
+
92
+ /** True when imperative navigation is possible on this device. */
93
+ export function canNavigate(): boolean {
94
+ return getSafeRouter() != null;
95
+ }
96
+
97
+ /**
98
+ * Navigate to a captured route now. Uses `navigate` (not `push`) so restoring
99
+ * the same route twice doesn't stack duplicate screens.
100
+ */
101
+ export function navigateToRoute(route: SnapshotRoute): RouteRestoreResult {
102
+ const base = { pathname: route.pathname, href: route.href };
103
+ const router = getSafeRouter();
104
+ if (!router?.navigate) {
105
+ return {
106
+ ...base,
107
+ status: "unavailable",
108
+ error: "expo-router is not available on this device",
109
+ };
110
+ }
111
+ try {
112
+ router.navigate(route.href);
113
+ return { ...base, status: "navigated" };
114
+ } catch (e) {
115
+ return { ...base, status: "failed", error: String(e) };
116
+ }
117
+ }
118
+
119
+ // ── Pending route (survives the reload) ──────────────────────────────────────
120
+
121
+ export async function savePendingRoute(
122
+ route: SnapshotRoute,
123
+ snapshotName?: string
124
+ ): Promise<void> {
125
+ const pending: PendingRoute = {
126
+ ...route,
127
+ savedAt: Date.now(),
128
+ ...(snapshotName ? { snapshotName } : {}),
129
+ };
130
+ await persistentStorage.setItem(PENDING_KEY, JSON.stringify(pending));
131
+ }
132
+
133
+ export async function clearPendingRoute(): Promise<void> {
134
+ try {
135
+ await persistentStorage.removeItem(PENDING_KEY);
136
+ } catch {
137
+ // best-effort — a stale key is dropped by the TTL check anyway
138
+ }
139
+ }
140
+
141
+ async function loadPendingRoute(): Promise<PendingRoute | null> {
142
+ try {
143
+ const raw = await persistentStorage.getItem(PENDING_KEY);
144
+ if (!raw) return null;
145
+ const parsed = JSON.parse(raw) as Partial<PendingRoute>;
146
+ if (typeof parsed?.href !== "string" || !parsed.href) return null;
147
+ if (typeof parsed.savedAt !== "number") return null;
148
+ return {
149
+ pathname:
150
+ typeof parsed.pathname === "string" ? parsed.pathname : parsed.href,
151
+ href: parsed.href,
152
+ savedAt: parsed.savedAt,
153
+ snapshotName:
154
+ typeof parsed.snapshotName === "string" ? parsed.snapshotName : undefined,
155
+ };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ let armed = false;
162
+
163
+ /**
164
+ * Boot-time consumer of a pending route: called once when the package loads
165
+ * (core requires it while mounting the floating menu), it navigates to the
166
+ * route a reload-mode restore asked for and clears the slot.
167
+ *
168
+ * Idempotent and self-limiting — the slot is cleared before navigating, so a
169
+ * route that crashes the app on arrival can't put it in a boot loop.
170
+ */
171
+ export function armPendingRouteRestore(): void {
172
+ if (armed) return;
173
+ armed = true;
174
+ void (async () => {
175
+ const pending = await loadPendingRoute();
176
+ if (!pending) return;
177
+ // Clear FIRST: whatever happens next, this route is spent.
178
+ await clearPendingRoute();
179
+ if (Date.now() - pending.savedAt > PENDING_ROUTE_TTL_MS) return;
180
+
181
+ const deadline = Date.now() + ROUTER_READY_TIMEOUT_MS;
182
+ while (!canNavigate() && Date.now() < deadline) {
183
+ await new Promise((r) => setTimeout(r, 100));
184
+ }
185
+ if (!canNavigate()) return;
186
+ await new Promise((r) => setTimeout(r, ROUTER_SETTLE_MS));
187
+ navigateToRoute({ pathname: pending.pathname, href: pending.href });
188
+ })();
189
+ }
190
+
191
+ /** @internal test seam — re-arms the boot-time consumer. */
192
+ export function resetPendingRouteArm(): void {
193
+ armed = false;
194
+ }
@@ -18,6 +18,7 @@ import {
18
18
  persistentStorage,
19
19
  type SuperJSONResult,
20
20
  } from "@buoy-gg/shared-ui";
21
+ import type { SnapshotRoute } from "./routeRestore";
21
22
 
22
23
  const INDEX_KEY = "@react_buoy_time_machine:index";
23
24
  const SNAP_PREFIX = "@react_buoy_time_machine:snap:";
@@ -41,8 +42,37 @@ export interface SnapshotMeta {
41
42
  * User-saved restore scope: compound "sourceId::itemKey" strings. When set,
42
43
  * restoring this snapshot touches ONLY these items — "this restore point
43
44
  * means just my cart". Absent = full restore.
45
+ *
46
+ * Set through the MCP/adapter `setScope` action. The tool's own UI writes
47
+ * `excludedKeys` instead — see there for why the two exist.
44
48
  */
45
49
  scope?: string[];
50
+ /**
51
+ * Items the user switched OFF in the restore preview and saved: compound
52
+ * "sourceId::itemKey" strings that every later restore of this snapshot
53
+ * leaves untouched, including the one-tap RESTORE on the list.
54
+ *
55
+ * The inverse of `scope`, and the right default for "I unchecked one of
56
+ * these ten keys": naming what to SKIP lets everything else through, so a
57
+ * key that only starts differing tomorrow is still restored. An include
58
+ * list built from today's diff would silently freeze the restore point to
59
+ * whatever happened to differ when it was saved.
60
+ */
61
+ excludedKeys?: string[];
62
+ /**
63
+ * The route the app was on when this snapshot was captured. Absent when the
64
+ * app has no expo-router (nothing to return to). A restore can navigate back
65
+ * to it — see routeRestore.ts.
66
+ */
67
+ route?: SnapshotRoute;
68
+ /**
69
+ * Whether restoring this point should also navigate back to `route`.
70
+ * Undefined means "yes, if there is a route" — the useful default, and the
71
+ * reason there is no longer a prompt on every restore.
72
+ */
73
+ restoreRoute?: boolean;
74
+ /** When this point was last restored, for the "restored 4m ago" marker. */
75
+ lastRestoredAt?: number;
46
76
  /** Source ids captured, with their capture warnings. */
47
77
  sources: Record<string, SnapshotSourceMeta>;
48
78
  }
@@ -107,18 +137,17 @@ export async function deleteSnapshot(
107
137
  return next;
108
138
  }
109
139
 
110
- export async function setSnapshotScope(
140
+ /**
141
+ * Rewrite one snapshot's metadata in BOTH places it lives — the index and the
142
+ * snapshot's own record — so a reload can't resurrect the old value from
143
+ * whichever one was missed.
144
+ */
145
+ async function patchMeta(
111
146
  id: string,
112
- scope: string[] | null,
147
+ patch: (meta: SnapshotMeta) => SnapshotMeta,
113
148
  metas: SnapshotMeta[]
114
149
  ): Promise<SnapshotMeta[]> {
115
- const apply = (m: SnapshotMeta): SnapshotMeta => {
116
- if (m.id !== id) return m;
117
- const next = { ...m };
118
- if (scope && scope.length > 0) next.scope = scope;
119
- else delete next.scope;
120
- return next;
121
- };
150
+ const apply = (m: SnapshotMeta): SnapshotMeta => (m.id === id ? patch(m) : m);
122
151
  const next = metas.map(apply);
123
152
  await persistentStorage.setItem(INDEX_KEY, JSON.stringify(next));
124
153
  const stored = await loadSnapshot(id);
@@ -129,6 +158,106 @@ export async function setSnapshotScope(
129
158
  return next;
130
159
  }
131
160
 
161
+ /** Set (or clear with null/empty) the "only these items" restore scope. */
162
+ export function setSnapshotScope(
163
+ id: string,
164
+ scope: string[] | null,
165
+ metas: SnapshotMeta[]
166
+ ): Promise<SnapshotMeta[]> {
167
+ return patchMeta(
168
+ id,
169
+ (m) => {
170
+ const next = { ...m };
171
+ if (scope && scope.length > 0) next.scope = scope;
172
+ else delete next.scope;
173
+ return next;
174
+ },
175
+ metas
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Set (or clear with null/empty) the items a restore of this snapshot must
181
+ * leave alone. Saving exclusions also clears any legacy `scope`: the two
182
+ * answer the same question in opposite directions, and the one the user just
183
+ * chose in the preview wins.
184
+ */
185
+ export function setSnapshotExclusions(
186
+ id: string,
187
+ excludedKeys: string[] | null,
188
+ metas: SnapshotMeta[]
189
+ ): Promise<SnapshotMeta[]> {
190
+ return patchMeta(
191
+ id,
192
+ (m) => {
193
+ const next = { ...m };
194
+ delete next.scope;
195
+ if (excludedKeys && excludedKeys.length > 0)
196
+ next.excludedKeys = excludedKeys;
197
+ else delete next.excludedKeys;
198
+ return next;
199
+ },
200
+ metas
201
+ );
202
+ }
203
+
204
+ /** Turn this point's route restore on or off — the row's route toggle. */
205
+ export function setSnapshotRestoreRoute(
206
+ id: string,
207
+ restoreRoute: boolean,
208
+ metas: SnapshotMeta[]
209
+ ): Promise<SnapshotMeta[]> {
210
+ return patchMeta(id, (m) => ({ ...m, restoreRoute }), metas);
211
+ }
212
+
213
+ /** Stamp a restore so the list can say which point you are currently in. */
214
+ export function markSnapshotRestored(
215
+ id: string,
216
+ at: number,
217
+ metas: SnapshotMeta[]
218
+ ): Promise<SnapshotMeta[]> {
219
+ return patchMeta(id, (m) => ({ ...m, lastRestoredAt: at }), metas);
220
+ }
221
+
222
+ /**
223
+ * Copy a stored snapshot under a new id and name. The payload is copied by
224
+ * reference into a fresh record — the vault serializes it on write, so the
225
+ * two never share mutable state.
226
+ */
227
+ export async function duplicateSnapshot(
228
+ id: string,
229
+ name: string,
230
+ metas: SnapshotMeta[]
231
+ ): Promise<{ metas: SnapshotMeta[]; meta: SnapshotMeta } | null> {
232
+ const stored = await loadSnapshot(id);
233
+ if (!stored) return null;
234
+ const copy: StoredSnapshot = {
235
+ meta: {
236
+ ...stored.meta,
237
+ id: `${stored.meta.id}_copy_${Date.now().toString(36)}`,
238
+ name,
239
+ createdAt: Date.now(),
240
+ lastRestoredAt: undefined,
241
+ },
242
+ data: stored.data,
243
+ };
244
+ delete copy.meta.lastRestoredAt;
245
+ return { metas: await saveSnapshot(copy, metas), meta: copy.meta };
246
+ }
247
+
248
+ /** Read a whole record so a delete can be held in memory and undone. */
249
+ export async function readSnapshot(id: string): Promise<StoredSnapshot | null> {
250
+ return loadSnapshot(id);
251
+ }
252
+
253
+ /** Put a held record back exactly as it was. */
254
+ export async function restoreDeletedSnapshot(
255
+ stored: StoredSnapshot,
256
+ metas: SnapshotMeta[]
257
+ ): Promise<SnapshotMeta[]> {
258
+ return saveSnapshot(stored, metas);
259
+ }
260
+
132
261
  export async function renameSnapshot(
133
262
  id: string,
134
263
  name: string,