@buoy-gg/time-machine 7.0.22 → 7.0.24

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 (160) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +147 -677
  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/store/snapshotVault.js +93 -9
  34. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  35. package/lib/commonjs/store/timeMachineStore.js +125 -6
  36. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  37. package/lib/commonjs/sync/timeMachineSyncAdapter.js +41 -0
  38. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  39. package/lib/commonjs/ui/SwipeActionsRow.js +167 -0
  40. package/lib/commonjs/ui/SwipeActionsRow.js.map +1 -0
  41. package/lib/commonjs/ui/primitives.js +164 -0
  42. package/lib/commonjs/ui/primitives.js.map +1 -0
  43. package/lib/commonjs/ui/tmTheme.js +103 -0
  44. package/lib/commonjs/ui/tmTheme.js.map +1 -0
  45. package/lib/module/components/TimeMachineBody.js +150 -680
  46. package/lib/module/components/TimeMachineBody.js.map +1 -1
  47. package/lib/module/components/TimeMachineModal.js +11 -12
  48. package/lib/module/components/TimeMachineModal.js.map +1 -1
  49. package/lib/module/components/confirm.js +34 -0
  50. package/lib/module/components/confirm.js.map +1 -0
  51. package/lib/module/components/format.js +19 -0
  52. package/lib/module/components/format.js.map +1 -0
  53. package/lib/module/components/list/CaptureBar.js +90 -0
  54. package/lib/module/components/list/CaptureBar.js.map +1 -0
  55. package/lib/module/components/list/RestoreOutcomeCard.js +156 -0
  56. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -0
  57. package/lib/module/components/list/SnapshotRow.js +138 -0
  58. package/lib/module/components/list/SnapshotRow.js.map +1 -0
  59. package/lib/module/components/list/SourcesStrip.js +121 -0
  60. package/lib/module/components/list/SourcesStrip.js.map +1 -0
  61. package/lib/module/components/list/UndoDeleteBar.js +78 -0
  62. package/lib/module/components/list/UndoDeleteBar.js.map +1 -0
  63. package/lib/module/components/list/WipeAllRow.js +41 -0
  64. package/lib/module/components/list/WipeAllRow.js.map +1 -0
  65. package/lib/module/components/preview/ItemDiffPanel.js +72 -0
  66. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -0
  67. package/lib/module/components/preview/PreviewFlow.js +46 -0
  68. package/lib/module/components/preview/PreviewFlow.js.map +1 -0
  69. package/lib/module/components/preview/RestorePointNameRow.js +78 -0
  70. package/lib/module/components/preview/RestorePointNameRow.js.map +1 -0
  71. package/lib/module/components/preview/RestorePreviewScreen.js +552 -638
  72. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  73. package/lib/module/components/preview/verdictMeta.js +9 -8
  74. package/lib/module/components/preview/verdictMeta.js.map +1 -1
  75. package/lib/module/components/sources.js +66 -0
  76. package/lib/module/components/sources.js.map +1 -0
  77. package/lib/module/store/snapshotVault.js +87 -9
  78. package/lib/module/store/snapshotVault.js.map +1 -1
  79. package/lib/module/store/timeMachineStore.js +125 -7
  80. package/lib/module/store/timeMachineStore.js.map +1 -1
  81. package/lib/module/sync/timeMachineSyncAdapter.js +41 -0
  82. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  83. package/lib/module/ui/SwipeActionsRow.js +162 -0
  84. package/lib/module/ui/SwipeActionsRow.js.map +1 -0
  85. package/lib/module/ui/primitives.js +156 -0
  86. package/lib/module/ui/primitives.js.map +1 -0
  87. package/lib/module/ui/tmTheme.js +99 -0
  88. package/lib/module/ui/tmTheme.js.map +1 -0
  89. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  90. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  91. package/lib/typescript/components/confirm.d.ts +2 -0
  92. package/lib/typescript/components/confirm.d.ts.map +1 -0
  93. package/lib/typescript/components/format.d.ts +4 -0
  94. package/lib/typescript/components/format.d.ts.map +1 -0
  95. package/lib/typescript/components/list/CaptureBar.d.ts +6 -0
  96. package/lib/typescript/components/list/CaptureBar.d.ts.map +1 -0
  97. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts +11 -0
  98. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -0
  99. package/lib/typescript/components/list/SnapshotRow.d.ts +12 -0
  100. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -0
  101. package/lib/typescript/components/list/SourcesStrip.d.ts +5 -0
  102. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -0
  103. package/lib/typescript/components/list/UndoDeleteBar.d.ts +8 -0
  104. package/lib/typescript/components/list/UndoDeleteBar.d.ts.map +1 -0
  105. package/lib/typescript/components/list/WipeAllRow.d.ts +5 -0
  106. package/lib/typescript/components/list/WipeAllRow.d.ts.map +1 -0
  107. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +5 -0
  108. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -0
  109. package/lib/typescript/components/preview/PreviewFlow.d.ts +9 -0
  110. package/lib/typescript/components/preview/PreviewFlow.d.ts.map +1 -0
  111. package/lib/typescript/components/preview/RestorePointNameRow.d.ts +5 -0
  112. package/lib/typescript/components/preview/RestorePointNameRow.d.ts.map +1 -0
  113. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +1 -5
  114. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  115. package/lib/typescript/components/preview/verdictMeta.d.ts +5 -0
  116. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -1
  117. package/lib/typescript/components/sources.d.ts +36 -0
  118. package/lib/typescript/components/sources.d.ts.map +1 -0
  119. package/lib/typescript/store/snapshotVault.d.ts +48 -0
  120. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  121. package/lib/typescript/store/timeMachineStore.d.ts +35 -1
  122. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  123. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +17 -0
  124. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  125. package/lib/typescript/ui/SwipeActionsRow.d.ts +32 -0
  126. package/lib/typescript/ui/SwipeActionsRow.d.ts.map +1 -0
  127. package/lib/typescript/ui/primitives.d.ts +57 -0
  128. package/lib/typescript/ui/primitives.d.ts.map +1 -0
  129. package/lib/typescript/ui/tmTheme.d.ts +67 -0
  130. package/lib/typescript/ui/tmTheme.d.ts.map +1 -0
  131. package/package.json +7 -7
  132. package/src/components/TimeMachineBody.tsx +183 -817
  133. package/src/components/TimeMachineModal.tsx +10 -11
  134. package/src/components/confirm.ts +35 -0
  135. package/src/components/format.ts +17 -0
  136. package/src/components/list/CaptureBar.tsx +100 -0
  137. package/src/components/list/RestoreOutcomeCard.tsx +159 -0
  138. package/src/components/list/SnapshotRow.tsx +155 -0
  139. package/src/components/list/SourcesStrip.tsx +124 -0
  140. package/src/components/list/UndoDeleteBar.tsx +76 -0
  141. package/src/components/list/WipeAllRow.tsx +42 -0
  142. package/src/components/preview/ItemDiffPanel.tsx +77 -0
  143. package/src/components/preview/PreviewFlow.tsx +70 -0
  144. package/src/components/preview/RestorePointNameRow.tsx +80 -0
  145. package/src/components/preview/RestorePreviewScreen.tsx +535 -535
  146. package/src/components/preview/verdictMeta.ts +8 -8
  147. package/src/components/sources.ts +92 -0
  148. package/src/store/snapshotVault.ts +131 -9
  149. package/src/store/timeMachineStore.ts +136 -5
  150. package/src/sync/timeMachineSyncAdapter.ts +30 -0
  151. package/src/ui/SwipeActionsRow.tsx +199 -0
  152. package/src/ui/primitives.tsx +157 -0
  153. package/src/ui/tmTheme.ts +107 -0
  154. package/lib/commonjs/components/preview/ItemDetailScreen.js +0 -189
  155. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +0 -1
  156. package/lib/module/components/preview/ItemDetailScreen.js +0 -184
  157. package/lib/module/components/preview/ItemDetailScreen.js.map +0 -1
  158. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +0 -11
  159. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +0 -1
  160. 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
+ }
@@ -42,14 +42,37 @@ export interface SnapshotMeta {
42
42
  * User-saved restore scope: compound "sourceId::itemKey" strings. When set,
43
43
  * restoring this snapshot touches ONLY these items — "this restore point
44
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.
45
48
  */
46
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[];
47
62
  /**
48
63
  * The route the app was on when this snapshot was captured. Absent when the
49
64
  * app has no expo-router (nothing to return to). A restore can navigate back
50
65
  * to it — see routeRestore.ts.
51
66
  */
52
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;
53
76
  /** Source ids captured, with their capture warnings. */
54
77
  sources: Record<string, SnapshotSourceMeta>;
55
78
  }
@@ -114,18 +137,17 @@ export async function deleteSnapshot(
114
137
  return next;
115
138
  }
116
139
 
117
- 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(
118
146
  id: string,
119
- scope: string[] | null,
147
+ patch: (meta: SnapshotMeta) => SnapshotMeta,
120
148
  metas: SnapshotMeta[]
121
149
  ): Promise<SnapshotMeta[]> {
122
- const apply = (m: SnapshotMeta): SnapshotMeta => {
123
- if (m.id !== id) return m;
124
- const next = { ...m };
125
- if (scope && scope.length > 0) next.scope = scope;
126
- else delete next.scope;
127
- return next;
128
- };
150
+ const apply = (m: SnapshotMeta): SnapshotMeta => (m.id === id ? patch(m) : m);
129
151
  const next = metas.map(apply);
130
152
  await persistentStorage.setItem(INDEX_KEY, JSON.stringify(next));
131
153
  const stored = await loadSnapshot(id);
@@ -136,6 +158,106 @@ export async function setSnapshotScope(
136
158
  return next;
137
159
  }
138
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
+
139
261
  export async function renameSnapshot(
140
262
  id: string,
141
263
  name: string,
@@ -28,11 +28,17 @@ 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";
@@ -62,6 +68,29 @@ export interface RestoreOutcome {
62
68
  route?: RouteRestoreResult;
63
69
  }
64
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
+
65
94
  /** Extra knobs a restore accepts beyond mode + exclusions. */
66
95
  export interface RestoreOptions {
67
96
  /**
@@ -118,6 +147,11 @@ export interface TimeMachineState {
118
147
  * (Buoy Desktop). Null on-device, where the live router is probed directly.
119
148
  */
120
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;
121
155
  }
122
156
 
123
157
  /** Whether this device can capture and return to routes, and where it is now. */
@@ -153,6 +187,7 @@ class TimeMachineStore {
153
187
  preview: null,
154
188
  remoteProviders: null,
155
189
  remoteRoute: null,
190
+ recentlyDeleted: null,
156
191
  };
157
192
  /** Monotonic token — a preview build checks it after every await and
158
193
  * abandons itself when a newer build (or clearPreview) supersedes it. */
@@ -245,7 +280,7 @@ class TimeMachineStore {
245
280
  const snapshot: StoredSnapshot = {
246
281
  meta: {
247
282
  id: nextId(),
248
- name: name.trim() || "Untitled snapshot",
283
+ name: name.trim() || defaultSnapshotName(route),
249
284
  createdAt: Date.now(),
250
285
  sizeBytes: 0,
251
286
  ...(route ? { route } : {}),
@@ -362,7 +397,11 @@ class TimeMachineStore {
362
397
  return bySource;
363
398
  };
364
399
 
365
- 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);
366
405
  // A saved scope makes this a targeted restore point. An explicit
367
406
  // exclusion list from the preview already reflects the scope (the
368
407
  // preview seeds its selection from it), so it takes precedence.
@@ -371,7 +410,11 @@ class TimeMachineStore {
371
410
  ? splitCompound(stored.meta.scope)
372
411
  : null;
373
412
 
374
- const wantsRoute = options?.restoreRoute === true && !!stored.meta.route;
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);
375
418
 
376
419
  if (mode === "reload") {
377
420
  // Storage only; everything in-memory rebuilds after the reload.
@@ -398,7 +441,11 @@ class TimeMachineStore {
398
441
  }
399
442
  }
400
443
  outcome.willReload = this.scheduleReload("buoy time-machine restore");
401
- 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
+ });
402
449
  return outcome;
403
450
  }
404
451
 
@@ -416,7 +463,10 @@ class TimeMachineStore {
416
463
  if (wantsRoute) {
417
464
  outcome.route = navigateToRoute(stored.meta.route!);
418
465
  }
419
- this.setState({ lastOutcome: outcome });
466
+ this.setState({
467
+ lastOutcome: outcome,
468
+ snapshots: await markSnapshotRestored(id, outcome.at, this.state.snapshots),
469
+ });
420
470
  return outcome;
421
471
  } finally {
422
472
  this.setState({ busy: null });
@@ -461,16 +511,78 @@ class TimeMachineStore {
461
511
  }
462
512
  }
463
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
+ */
464
520
  async delete(id: string): Promise<void> {
465
521
  if (remoteHandler) {
466
522
  await remoteHandler("delete", { id });
467
523
  return;
468
524
  }
469
525
  await this.ensureLoaded();
526
+ const stored = await readSnapshot(id);
470
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
+ );
471
562
  this.setState({ snapshots });
472
563
  }
473
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
+
474
586
  async rename(id: string, name: string): Promise<void> {
475
587
  if (remoteHandler) {
476
588
  await remoteHandler("rename", { id, name });
@@ -496,6 +608,25 @@ class TimeMachineStore {
496
608
  this.setState({ snapshots });
497
609
  }
498
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
+
499
630
  clearLastOutcome(): void {
500
631
  this.setState({ lastOutcome: null });
501
632
  }
@@ -138,6 +138,36 @@ export const timeMachineSyncAdapter = {
138
138
  return { renamed: true, id, name };
139
139
  },
140
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
+
141
171
  /** Save (or clear with null/empty) a snapshot's targeted-restore scope. */
142
172
  setScope: async (params: unknown) => {
143
173
  const { id, scope } =