@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.
- package/lib/commonjs/components/TimeMachineBody.js +148 -583
- package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
- package/lib/commonjs/components/TimeMachineModal.js +10 -11
- package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
- package/lib/commonjs/components/confirm.js +39 -0
- package/lib/commonjs/components/confirm.js.map +1 -0
- package/lib/commonjs/components/format.js +24 -0
- package/lib/commonjs/components/format.js.map +1 -0
- package/lib/commonjs/components/list/CaptureBar.js +95 -0
- package/lib/commonjs/components/list/CaptureBar.js.map +1 -0
- package/lib/commonjs/components/list/RestoreOutcomeCard.js +162 -0
- package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -0
- package/lib/commonjs/components/list/SnapshotRow.js +141 -0
- package/lib/commonjs/components/list/SnapshotRow.js.map +1 -0
- package/lib/commonjs/components/list/SourcesStrip.js +126 -0
- package/lib/commonjs/components/list/SourcesStrip.js.map +1 -0
- package/lib/commonjs/components/list/UndoDeleteBar.js +81 -0
- package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -0
- package/lib/commonjs/components/list/WipeAllRow.js +46 -0
- package/lib/commonjs/components/list/WipeAllRow.js.map +1 -0
- package/lib/commonjs/components/preview/ItemDiffPanel.js +77 -0
- package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -0
- package/lib/commonjs/components/preview/PreviewFlow.js +51 -0
- package/lib/commonjs/components/preview/PreviewFlow.js.map +1 -0
- package/lib/commonjs/components/preview/RestorePointNameRow.js +83 -0
- package/lib/commonjs/components/preview/RestorePointNameRow.js.map +1 -0
- package/lib/commonjs/components/preview/RestorePreviewScreen.js +549 -635
- package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
- package/lib/commonjs/components/preview/verdictMeta.js +8 -8
- package/lib/commonjs/components/preview/verdictMeta.js.map +1 -1
- package/lib/commonjs/components/sources.js +72 -0
- package/lib/commonjs/components/sources.js.map +1 -0
- package/lib/commonjs/index.js +55 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/store/routeRestore.js +188 -0
- package/lib/commonjs/store/routeRestore.js.map +1 -0
- package/lib/commonjs/store/snapshotVault.js +93 -9
- package/lib/commonjs/store/snapshotVault.js.map +1 -1
- package/lib/commonjs/store/timeMachineStore.js +164 -7
- package/lib/commonjs/store/timeMachineStore.js.map +1 -1
- package/lib/commonjs/sync/timeMachineSyncAdapter.js +59 -4
- package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/commonjs/ui/SwipeActionsRow.js +167 -0
- package/lib/commonjs/ui/SwipeActionsRow.js.map +1 -0
- package/lib/commonjs/ui/primitives.js +164 -0
- package/lib/commonjs/ui/primitives.js.map +1 -0
- package/lib/commonjs/ui/tmTheme.js +103 -0
- package/lib/commonjs/ui/tmTheme.js.map +1 -0
- package/lib/module/components/TimeMachineBody.js +151 -586
- package/lib/module/components/TimeMachineBody.js.map +1 -1
- package/lib/module/components/TimeMachineModal.js +11 -12
- package/lib/module/components/TimeMachineModal.js.map +1 -1
- package/lib/module/components/confirm.js +34 -0
- package/lib/module/components/confirm.js.map +1 -0
- package/lib/module/components/format.js +19 -0
- package/lib/module/components/format.js.map +1 -0
- package/lib/module/components/list/CaptureBar.js +90 -0
- package/lib/module/components/list/CaptureBar.js.map +1 -0
- package/lib/module/components/list/RestoreOutcomeCard.js +156 -0
- package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -0
- package/lib/module/components/list/SnapshotRow.js +138 -0
- package/lib/module/components/list/SnapshotRow.js.map +1 -0
- package/lib/module/components/list/SourcesStrip.js +121 -0
- package/lib/module/components/list/SourcesStrip.js.map +1 -0
- package/lib/module/components/list/UndoDeleteBar.js +78 -0
- package/lib/module/components/list/UndoDeleteBar.js.map +1 -0
- package/lib/module/components/list/WipeAllRow.js +41 -0
- package/lib/module/components/list/WipeAllRow.js.map +1 -0
- package/lib/module/components/preview/ItemDiffPanel.js +72 -0
- package/lib/module/components/preview/ItemDiffPanel.js.map +1 -0
- package/lib/module/components/preview/PreviewFlow.js +46 -0
- package/lib/module/components/preview/PreviewFlow.js.map +1 -0
- package/lib/module/components/preview/RestorePointNameRow.js +78 -0
- package/lib/module/components/preview/RestorePointNameRow.js.map +1 -0
- package/lib/module/components/preview/RestorePreviewScreen.js +552 -638
- package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
- package/lib/module/components/preview/verdictMeta.js +9 -8
- package/lib/module/components/preview/verdictMeta.js.map +1 -1
- package/lib/module/components/sources.js +66 -0
- package/lib/module/components/sources.js.map +1 -0
- package/lib/module/index.js +11 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/store/routeRestore.js +176 -0
- package/lib/module/store/routeRestore.js.map +1 -0
- package/lib/module/store/snapshotVault.js +87 -9
- package/lib/module/store/snapshotVault.js.map +1 -1
- package/lib/module/store/timeMachineStore.js +164 -8
- package/lib/module/store/timeMachineStore.js.map +1 -1
- package/lib/module/sync/timeMachineSyncAdapter.js +59 -4
- package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/module/ui/SwipeActionsRow.js +162 -0
- package/lib/module/ui/SwipeActionsRow.js.map +1 -0
- package/lib/module/ui/primitives.js +156 -0
- package/lib/module/ui/primitives.js.map +1 -0
- package/lib/module/ui/tmTheme.js +99 -0
- package/lib/module/ui/tmTheme.js.map +1 -0
- package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
- package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
- package/lib/typescript/components/confirm.d.ts +2 -0
- package/lib/typescript/components/confirm.d.ts.map +1 -0
- package/lib/typescript/components/format.d.ts +4 -0
- package/lib/typescript/components/format.d.ts.map +1 -0
- package/lib/typescript/components/list/CaptureBar.d.ts +6 -0
- package/lib/typescript/components/list/CaptureBar.d.ts.map +1 -0
- package/lib/typescript/components/list/RestoreOutcomeCard.d.ts +11 -0
- package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -0
- package/lib/typescript/components/list/SnapshotRow.d.ts +12 -0
- package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -0
- package/lib/typescript/components/list/SourcesStrip.d.ts +5 -0
- package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -0
- package/lib/typescript/components/list/UndoDeleteBar.d.ts +8 -0
- package/lib/typescript/components/list/UndoDeleteBar.d.ts.map +1 -0
- package/lib/typescript/components/list/WipeAllRow.d.ts +5 -0
- package/lib/typescript/components/list/WipeAllRow.d.ts.map +1 -0
- package/lib/typescript/components/preview/ItemDiffPanel.d.ts +5 -0
- package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -0
- package/lib/typescript/components/preview/PreviewFlow.d.ts +9 -0
- package/lib/typescript/components/preview/PreviewFlow.d.ts.map +1 -0
- package/lib/typescript/components/preview/RestorePointNameRow.d.ts +5 -0
- package/lib/typescript/components/preview/RestorePointNameRow.d.ts.map +1 -0
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +1 -5
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
- package/lib/typescript/components/preview/verdictMeta.d.ts +5 -0
- package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -1
- package/lib/typescript/components/sources.d.ts +36 -0
- package/lib/typescript/components/sources.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/store/routeRestore.d.ts +51 -0
- package/lib/typescript/store/routeRestore.d.ts.map +1 -0
- package/lib/typescript/store/snapshotVault.d.ts +55 -0
- package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
- package/lib/typescript/store/timeMachineStore.d.ts +64 -2
- package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +25 -0
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/ui/SwipeActionsRow.d.ts +32 -0
- package/lib/typescript/ui/SwipeActionsRow.d.ts.map +1 -0
- package/lib/typescript/ui/primitives.d.ts +57 -0
- package/lib/typescript/ui/primitives.d.ts.map +1 -0
- package/lib/typescript/ui/tmTheme.d.ts +67 -0
- package/lib/typescript/ui/tmTheme.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/components/TimeMachineBody.tsx +184 -682
- package/src/components/TimeMachineModal.tsx +10 -11
- package/src/components/confirm.ts +35 -0
- package/src/components/format.ts +17 -0
- package/src/components/list/CaptureBar.tsx +100 -0
- package/src/components/list/RestoreOutcomeCard.tsx +159 -0
- package/src/components/list/SnapshotRow.tsx +155 -0
- package/src/components/list/SourcesStrip.tsx +124 -0
- package/src/components/list/UndoDeleteBar.tsx +76 -0
- package/src/components/list/WipeAllRow.tsx +42 -0
- package/src/components/preview/ItemDiffPanel.tsx +77 -0
- package/src/components/preview/PreviewFlow.tsx +70 -0
- package/src/components/preview/RestorePointNameRow.tsx +80 -0
- package/src/components/preview/RestorePreviewScreen.tsx +535 -535
- package/src/components/preview/verdictMeta.ts +8 -8
- package/src/components/sources.ts +92 -0
- package/src/index.tsx +22 -0
- package/src/store/routeRestore.ts +194 -0
- package/src/store/snapshotVault.ts +138 -9
- package/src/store/timeMachineStore.ts +195 -5
- package/src/sync/timeMachineSyncAdapter.ts +57 -4
- package/src/ui/SwipeActionsRow.tsx +199 -0
- package/src/ui/primitives.tsx +157 -0
- package/src/ui/tmTheme.ts +107 -0
- package/lib/commonjs/components/preview/ItemDetailScreen.js +0 -189
- package/lib/commonjs/components/preview/ItemDetailScreen.js.map +0 -1
- package/lib/module/components/preview/ItemDetailScreen.js +0 -184
- package/lib/module/components/preview/ItemDetailScreen.js.map +0 -1
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts +0 -11
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +0 -1
- 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() ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|