@buoy-gg/time-machine 7.0.17
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/LICENSE +58 -0
- package/README.md +55 -0
- package/lib/commonjs/components/TimeMachineBody.js +420 -0
- package/lib/commonjs/components/TimeMachineBody.js.map +1 -0
- package/lib/commonjs/components/TimeMachineModal.js +74 -0
- package/lib/commonjs/components/TimeMachineModal.js.map +1 -0
- package/lib/commonjs/hooks/useTimeMachine.js +24 -0
- package/lib/commonjs/hooks/useTimeMachine.js.map +1 -0
- package/lib/commonjs/index.js +64 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/preset.js +45 -0
- package/lib/commonjs/preset.js.map +1 -0
- package/lib/commonjs/store/snapshotVault.js +83 -0
- package/lib/commonjs/store/snapshotVault.js.map +1 -0
- package/lib/commonjs/store/timeMachineStore.js +335 -0
- package/lib/commonjs/store/timeMachineStore.js.map +1 -0
- package/lib/commonjs/sync/timeMachineSyncAdapter.js +139 -0
- package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -0
- package/lib/module/components/TimeMachineBody.js +415 -0
- package/lib/module/components/TimeMachineBody.js.map +1 -0
- package/lib/module/components/TimeMachineModal.js +68 -0
- package/lib/module/components/TimeMachineModal.js.map +1 -0
- package/lib/module/hooks/useTimeMachine.js +19 -0
- package/lib/module/hooks/useTimeMachine.js.map +1 -0
- package/lib/module/index.js +29 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/preset.js +40 -0
- package/lib/module/preset.js.map +1 -0
- package/lib/module/store/snapshotVault.js +74 -0
- package/lib/module/store/snapshotVault.js.map +1 -0
- package/lib/module/store/timeMachineStore.js +327 -0
- package/lib/module/store/timeMachineStore.js.map +1 -0
- package/lib/module/sync/timeMachineSyncAdapter.js +134 -0
- package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -0
- package/lib/typescript/components/TimeMachineBody.d.ts +2 -0
- package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -0
- package/lib/typescript/components/TimeMachineModal.d.ts +12 -0
- package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -0
- package/lib/typescript/hooks/useTimeMachine.d.ts +6 -0
- package/lib/typescript/hooks/useTimeMachine.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +18 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/preset.d.ts +38 -0
- package/lib/typescript/preset.d.ts.map +1 -0
- package/lib/typescript/store/snapshotVault.d.ts +46 -0
- package/lib/typescript/store/snapshotVault.d.ts.map +1 -0
- package/lib/typescript/store/timeMachineStore.d.ts +97 -0
- package/lib/typescript/store/timeMachineStore.d.ts.map +1 -0
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +59 -0
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/components/TimeMachineBody.tsx +455 -0
- package/src/components/TimeMachineModal.tsx +75 -0
- package/src/hooks/useTimeMachine.ts +26 -0
- package/src/index.tsx +45 -0
- package/src/preset.tsx +38 -0
- package/src/store/snapshotVault.ts +117 -0
- package/src/store/timeMachineStore.ts +368 -0
- package/src/sync/timeMachineSyncAdapter.ts +128 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Machine body — capture bar, provider status strip, snapshot list and
|
|
3
|
+
* the last-restore results panel. Shared by the RN modal and the inline
|
|
4
|
+
* (desktop) tool view.
|
|
5
|
+
*/
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Alert,
|
|
9
|
+
Platform,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
Text,
|
|
12
|
+
TextInput,
|
|
13
|
+
TouchableOpacity,
|
|
14
|
+
View,
|
|
15
|
+
} from "react-native";
|
|
16
|
+
import { buoyColors } from "@buoy-gg/shared-ui";
|
|
17
|
+
import { useIsPro } from "@buoy-gg/license";
|
|
18
|
+
import { useSnapshotProviders, useTimeMachineState } from "../hooks/useTimeMachine";
|
|
19
|
+
import { timeMachineStore } from "../store/timeMachineStore";
|
|
20
|
+
import type { RestoreOutcome } from "../store/timeMachineStore";
|
|
21
|
+
import type { SnapshotMeta } from "../store/snapshotVault";
|
|
22
|
+
|
|
23
|
+
const FREE_SNAPSHOT_LIMIT = 2;
|
|
24
|
+
|
|
25
|
+
function timeAgo(ts: number): string {
|
|
26
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
27
|
+
if (s < 60) return `${s}s ago`;
|
|
28
|
+
const m = Math.round(s / 60);
|
|
29
|
+
if (m < 60) return `${m}m ago`;
|
|
30
|
+
const h = Math.round(m / 60);
|
|
31
|
+
if (h < 24) return `${h}h ago`;
|
|
32
|
+
return `${Math.round(h / 24)}d ago`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatBytes(bytes: number): string {
|
|
36
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
37
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
38
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function warningCount(meta: SnapshotMeta): number {
|
|
42
|
+
return Object.values(meta.sources).reduce(
|
|
43
|
+
(sum, s) => sum + s.warnings.length,
|
|
44
|
+
0
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** RN-web's Alert renders no buttons — fall back to the browser dialog there. */
|
|
49
|
+
function confirmDestructive(
|
|
50
|
+
title: string,
|
|
51
|
+
message: string,
|
|
52
|
+
confirmLabel: string,
|
|
53
|
+
onConfirm: () => void
|
|
54
|
+
) {
|
|
55
|
+
if (Platform.OS === "web") {
|
|
56
|
+
const confirmFn = (globalThis as { confirm?: (msg: string) => boolean }).confirm;
|
|
57
|
+
if (!confirmFn || confirmFn(`${title}\n\n${message}`)) onConfirm();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
Alert.alert(title, message, [
|
|
61
|
+
{ text: "Cancel", style: "cancel" },
|
|
62
|
+
{ text: confirmLabel, style: "destructive", onPress: onConfirm },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function OutcomePanel({ outcome }: { outcome: RestoreOutcome }) {
|
|
67
|
+
const entries = Object.entries(outcome.results);
|
|
68
|
+
return (
|
|
69
|
+
<View style={styles.outcomePanel}>
|
|
70
|
+
<View style={styles.outcomeHeaderRow}>
|
|
71
|
+
<Text style={styles.outcomeTitle}>
|
|
72
|
+
Restored “{outcome.snapshotName}”
|
|
73
|
+
{outcome.mode === "reload" || outcome.willReload ? " · reloading" : ""}
|
|
74
|
+
</Text>
|
|
75
|
+
<TouchableOpacity
|
|
76
|
+
onPress={() => timeMachineStore.clearLastOutcome()}
|
|
77
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
78
|
+
>
|
|
79
|
+
<Text style={styles.outcomeDismiss}>✕</Text>
|
|
80
|
+
</TouchableOpacity>
|
|
81
|
+
</View>
|
|
82
|
+
{entries.length === 0 && (
|
|
83
|
+
<Text style={styles.outcomeLine}>no sources restored</Text>
|
|
84
|
+
)}
|
|
85
|
+
{entries.map(([sourceId, r]) => (
|
|
86
|
+
<View key={sourceId} style={styles.outcomeSource}>
|
|
87
|
+
<Text style={[styles.outcomeLine, !r.ok && styles.outcomeLineBad]}>
|
|
88
|
+
{r.ok ? "✓" : "✕"} {sourceId} — applied {r.applied}
|
|
89
|
+
{r.skipped.length > 0 ? `, skipped ${r.skipped.length}` : ""}
|
|
90
|
+
</Text>
|
|
91
|
+
{r.skipped.slice(0, 3).map((skip) => (
|
|
92
|
+
<Text key={skip.id} style={styles.outcomeDetail}>
|
|
93
|
+
· {skip.id}: {skip.reason}
|
|
94
|
+
</Text>
|
|
95
|
+
))}
|
|
96
|
+
{r.warnings.slice(0, 3).map((warning) => (
|
|
97
|
+
<Text key={warning} style={styles.outcomeDetail}>
|
|
98
|
+
⚠ {warning}
|
|
99
|
+
</Text>
|
|
100
|
+
))}
|
|
101
|
+
</View>
|
|
102
|
+
))}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function TimeMachineBody() {
|
|
108
|
+
const { snapshots, hasLoaded, busy, lastOutcome, remoteProviders } =
|
|
109
|
+
useTimeMachineState();
|
|
110
|
+
const localProviders = useSnapshotProviders();
|
|
111
|
+
const isPro = useIsPro();
|
|
112
|
+
const [name, setName] = useState("");
|
|
113
|
+
|
|
114
|
+
// On-device: live registry probes. Buoy Desktop: statuses mirrored from
|
|
115
|
+
// the device's sync payload.
|
|
116
|
+
const providers =
|
|
117
|
+
remoteProviders ??
|
|
118
|
+
localProviders.map((p) => ({ id: p.id, label: p.label, ...p.status() }));
|
|
119
|
+
|
|
120
|
+
const atFreeLimit = !isPro && snapshots.length >= FREE_SNAPSHOT_LIMIT;
|
|
121
|
+
const captureDisabled = busy !== null || atFreeLimit;
|
|
122
|
+
|
|
123
|
+
const capture = () => {
|
|
124
|
+
if (captureDisabled) return;
|
|
125
|
+
void timeMachineStore.capture(name);
|
|
126
|
+
setName("");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const captureBaseline = () => {
|
|
130
|
+
if (captureDisabled) return;
|
|
131
|
+
void timeMachineStore.captureBaseline();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const restore = (meta: SnapshotMeta, mode: "live" | "reload") => {
|
|
135
|
+
if (busy) return;
|
|
136
|
+
if (meta.baseline) {
|
|
137
|
+
confirmDestructive(
|
|
138
|
+
"Restore fresh install?",
|
|
139
|
+
"This wipes all app storage and reloads the app.",
|
|
140
|
+
"Wipe & reload",
|
|
141
|
+
() => void timeMachineStore.restore(meta.id, "reload")
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
void timeMachineStore.restore(meta.id, mode);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const remove = (meta: SnapshotMeta) => {
|
|
149
|
+
confirmDestructive(
|
|
150
|
+
"Delete snapshot?",
|
|
151
|
+
`“${meta.name}” cannot be recovered.`,
|
|
152
|
+
"Delete",
|
|
153
|
+
() => void timeMachineStore.delete(meta.id)
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const unavailable = providers.filter(
|
|
158
|
+
(p) => p.detail && (!p.canCapture || !p.canRestore)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<View style={styles.container}>
|
|
163
|
+
{lastOutcome && <OutcomePanel outcome={lastOutcome} />}
|
|
164
|
+
|
|
165
|
+
{/* Capture bar */}
|
|
166
|
+
<View style={styles.captureRow}>
|
|
167
|
+
<TextInput
|
|
168
|
+
style={styles.nameInput}
|
|
169
|
+
value={name}
|
|
170
|
+
onChangeText={setName}
|
|
171
|
+
placeholder="Snapshot name…"
|
|
172
|
+
placeholderTextColor={buoyColors.textMuted}
|
|
173
|
+
editable={!captureDisabled}
|
|
174
|
+
onSubmitEditing={capture}
|
|
175
|
+
returnKeyType="done"
|
|
176
|
+
testID="time-machine-name-input"
|
|
177
|
+
/>
|
|
178
|
+
<TouchableOpacity
|
|
179
|
+
style={[styles.captureButton, captureDisabled && styles.buttonDisabled]}
|
|
180
|
+
onPress={capture}
|
|
181
|
+
disabled={captureDisabled}
|
|
182
|
+
testID="time-machine-capture"
|
|
183
|
+
>
|
|
184
|
+
<Text style={styles.captureButtonText}>
|
|
185
|
+
{busy === "capture" ? "…" : "CAPTURE"}
|
|
186
|
+
</Text>
|
|
187
|
+
</TouchableOpacity>
|
|
188
|
+
</View>
|
|
189
|
+
<TouchableOpacity
|
|
190
|
+
style={[styles.baselineButton, captureDisabled && styles.buttonDisabled]}
|
|
191
|
+
onPress={captureBaseline}
|
|
192
|
+
disabled={captureDisabled}
|
|
193
|
+
testID="time-machine-capture-baseline"
|
|
194
|
+
>
|
|
195
|
+
<Text style={styles.baselineButtonText}>
|
|
196
|
+
⌂ CAPTURE FRESH-INSTALL BASELINE
|
|
197
|
+
</Text>
|
|
198
|
+
</TouchableOpacity>
|
|
199
|
+
{atFreeLimit && (
|
|
200
|
+
<Text style={styles.freeLimitNote}>
|
|
201
|
+
Free plan: {FREE_SNAPSHOT_LIMIT} restore points. Upgrade to Pro for
|
|
202
|
+
unlimited snapshots.
|
|
203
|
+
</Text>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Provider status */}
|
|
207
|
+
<View style={styles.providerStrip}>
|
|
208
|
+
{providers.map((p) => {
|
|
209
|
+
const dotColor = !p.canCapture
|
|
210
|
+
? buoyColors.textMuted
|
|
211
|
+
: p.canRestore
|
|
212
|
+
? buoyColors.success
|
|
213
|
+
: buoyColors.warning;
|
|
214
|
+
return (
|
|
215
|
+
<View key={p.id} style={styles.providerChip}>
|
|
216
|
+
<View style={[styles.providerDot, { backgroundColor: dotColor }]} />
|
|
217
|
+
<Text style={styles.providerLabel}>{p.label}</Text>
|
|
218
|
+
</View>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
{providers.length === 0 && (
|
|
222
|
+
<Text style={styles.emptyText}>No snapshot providers registered</Text>
|
|
223
|
+
)}
|
|
224
|
+
</View>
|
|
225
|
+
{unavailable.map((p) => (
|
|
226
|
+
<Text key={p.id} style={styles.providerDetail}>
|
|
227
|
+
{p.label}: {p.detail}
|
|
228
|
+
</Text>
|
|
229
|
+
))}
|
|
230
|
+
|
|
231
|
+
{/* Snapshot list */}
|
|
232
|
+
{hasLoaded && snapshots.length === 0 && (
|
|
233
|
+
<Text style={styles.emptyText}>
|
|
234
|
+
No snapshots yet. Capture one, test your flow, then restore to jump
|
|
235
|
+
right back.
|
|
236
|
+
</Text>
|
|
237
|
+
)}
|
|
238
|
+
{snapshots.map((meta) => {
|
|
239
|
+
const warnings = warningCount(meta);
|
|
240
|
+
return (
|
|
241
|
+
<View key={meta.id} style={styles.snapshotRow} testID={`time-machine-snap-${meta.id}`}>
|
|
242
|
+
<View style={styles.snapshotInfo}>
|
|
243
|
+
<Text style={styles.snapshotName} numberOfLines={1}>
|
|
244
|
+
{meta.baseline ? "⌂ " : ""}
|
|
245
|
+
{meta.name}
|
|
246
|
+
</Text>
|
|
247
|
+
<Text style={styles.snapshotMeta}>
|
|
248
|
+
{timeAgo(meta.createdAt)}
|
|
249
|
+
{meta.baseline
|
|
250
|
+
? " · wipes to fresh install"
|
|
251
|
+
: ` · ${formatBytes(meta.sizeBytes)} · ${Object.keys(meta.sources).join(", ") || "empty"}`}
|
|
252
|
+
{warnings > 0 ? ` · ⚠ ${warnings}` : ""}
|
|
253
|
+
</Text>
|
|
254
|
+
</View>
|
|
255
|
+
{!meta.baseline && (
|
|
256
|
+
<TouchableOpacity
|
|
257
|
+
style={[styles.rowButton, busy && styles.buttonDisabled]}
|
|
258
|
+
onPress={() => restore(meta, "live")}
|
|
259
|
+
disabled={busy !== null}
|
|
260
|
+
>
|
|
261
|
+
<Text style={styles.rowButtonText}>RESTORE</Text>
|
|
262
|
+
</TouchableOpacity>
|
|
263
|
+
)}
|
|
264
|
+
<TouchableOpacity
|
|
265
|
+
style={[styles.rowButton, busy && styles.buttonDisabled]}
|
|
266
|
+
onPress={() => restore(meta, "reload")}
|
|
267
|
+
disabled={busy !== null}
|
|
268
|
+
>
|
|
269
|
+
<Text style={styles.rowButtonText}>
|
|
270
|
+
{meta.baseline ? "RESTORE" : "↻"}
|
|
271
|
+
</Text>
|
|
272
|
+
</TouchableOpacity>
|
|
273
|
+
<TouchableOpacity
|
|
274
|
+
style={styles.deleteButton}
|
|
275
|
+
onPress={() => remove(meta)}
|
|
276
|
+
hitSlop={{ top: 8, bottom: 8, left: 4, right: 8 }}
|
|
277
|
+
>
|
|
278
|
+
<Text style={styles.deleteButtonText}>✕</Text>
|
|
279
|
+
</TouchableOpacity>
|
|
280
|
+
</View>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
283
|
+
</View>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const styles = StyleSheet.create({
|
|
288
|
+
container: {
|
|
289
|
+
padding: 12,
|
|
290
|
+
gap: 8,
|
|
291
|
+
},
|
|
292
|
+
captureRow: {
|
|
293
|
+
flexDirection: "row",
|
|
294
|
+
gap: 8,
|
|
295
|
+
},
|
|
296
|
+
nameInput: {
|
|
297
|
+
flex: 1,
|
|
298
|
+
backgroundColor: buoyColors.input,
|
|
299
|
+
borderColor: buoyColors.border,
|
|
300
|
+
borderWidth: 1,
|
|
301
|
+
borderRadius: 6,
|
|
302
|
+
color: buoyColors.text,
|
|
303
|
+
paddingHorizontal: 10,
|
|
304
|
+
paddingVertical: 8,
|
|
305
|
+
fontSize: 13,
|
|
306
|
+
},
|
|
307
|
+
captureButton: {
|
|
308
|
+
backgroundColor: buoyColors.primary,
|
|
309
|
+
borderRadius: 6,
|
|
310
|
+
paddingHorizontal: 14,
|
|
311
|
+
justifyContent: "center",
|
|
312
|
+
},
|
|
313
|
+
captureButtonText: {
|
|
314
|
+
color: "#0A0A0A",
|
|
315
|
+
fontSize: 12,
|
|
316
|
+
fontWeight: "700",
|
|
317
|
+
letterSpacing: 0.5,
|
|
318
|
+
},
|
|
319
|
+
baselineButton: {
|
|
320
|
+
borderColor: buoyColors.border,
|
|
321
|
+
borderWidth: 1,
|
|
322
|
+
borderRadius: 6,
|
|
323
|
+
paddingVertical: 8,
|
|
324
|
+
alignItems: "center",
|
|
325
|
+
},
|
|
326
|
+
baselineButtonText: {
|
|
327
|
+
color: buoyColors.textSecondary,
|
|
328
|
+
fontSize: 11,
|
|
329
|
+
fontWeight: "600",
|
|
330
|
+
letterSpacing: 0.5,
|
|
331
|
+
},
|
|
332
|
+
buttonDisabled: {
|
|
333
|
+
opacity: 0.4,
|
|
334
|
+
},
|
|
335
|
+
freeLimitNote: {
|
|
336
|
+
color: buoyColors.warning,
|
|
337
|
+
fontSize: 11,
|
|
338
|
+
},
|
|
339
|
+
providerStrip: {
|
|
340
|
+
flexDirection: "row",
|
|
341
|
+
flexWrap: "wrap",
|
|
342
|
+
gap: 6,
|
|
343
|
+
marginTop: 4,
|
|
344
|
+
},
|
|
345
|
+
providerChip: {
|
|
346
|
+
flexDirection: "row",
|
|
347
|
+
alignItems: "center",
|
|
348
|
+
gap: 5,
|
|
349
|
+
backgroundColor: buoyColors.card,
|
|
350
|
+
borderColor: buoyColors.border,
|
|
351
|
+
borderWidth: 1,
|
|
352
|
+
borderRadius: 12,
|
|
353
|
+
paddingHorizontal: 8,
|
|
354
|
+
paddingVertical: 3,
|
|
355
|
+
},
|
|
356
|
+
providerDot: {
|
|
357
|
+
width: 6,
|
|
358
|
+
height: 6,
|
|
359
|
+
borderRadius: 3,
|
|
360
|
+
},
|
|
361
|
+
providerLabel: {
|
|
362
|
+
color: buoyColors.textSecondary,
|
|
363
|
+
fontSize: 11,
|
|
364
|
+
},
|
|
365
|
+
providerDetail: {
|
|
366
|
+
color: buoyColors.textMuted,
|
|
367
|
+
fontSize: 10,
|
|
368
|
+
},
|
|
369
|
+
emptyText: {
|
|
370
|
+
color: buoyColors.textMuted,
|
|
371
|
+
fontSize: 12,
|
|
372
|
+
marginTop: 8,
|
|
373
|
+
},
|
|
374
|
+
snapshotRow: {
|
|
375
|
+
flexDirection: "row",
|
|
376
|
+
alignItems: "center",
|
|
377
|
+
backgroundColor: buoyColors.card,
|
|
378
|
+
borderColor: buoyColors.border,
|
|
379
|
+
borderWidth: 1,
|
|
380
|
+
borderRadius: 8,
|
|
381
|
+
padding: 10,
|
|
382
|
+
gap: 8,
|
|
383
|
+
},
|
|
384
|
+
snapshotInfo: {
|
|
385
|
+
flex: 1,
|
|
386
|
+
minWidth: 0,
|
|
387
|
+
},
|
|
388
|
+
snapshotName: {
|
|
389
|
+
color: buoyColors.text,
|
|
390
|
+
fontSize: 13,
|
|
391
|
+
fontWeight: "600",
|
|
392
|
+
},
|
|
393
|
+
snapshotMeta: {
|
|
394
|
+
color: buoyColors.textMuted,
|
|
395
|
+
fontSize: 10,
|
|
396
|
+
marginTop: 2,
|
|
397
|
+
},
|
|
398
|
+
rowButton: {
|
|
399
|
+
borderColor: buoyColors.primary,
|
|
400
|
+
borderWidth: 1,
|
|
401
|
+
borderRadius: 6,
|
|
402
|
+
paddingHorizontal: 8,
|
|
403
|
+
paddingVertical: 6,
|
|
404
|
+
},
|
|
405
|
+
rowButtonText: {
|
|
406
|
+
color: buoyColors.primary,
|
|
407
|
+
fontSize: 10,
|
|
408
|
+
fontWeight: "700",
|
|
409
|
+
},
|
|
410
|
+
deleteButton: {
|
|
411
|
+
paddingHorizontal: 2,
|
|
412
|
+
},
|
|
413
|
+
deleteButtonText: {
|
|
414
|
+
color: buoyColors.textMuted,
|
|
415
|
+
fontSize: 13,
|
|
416
|
+
},
|
|
417
|
+
outcomePanel: {
|
|
418
|
+
backgroundColor: buoyColors.card,
|
|
419
|
+
borderColor: buoyColors.borderStrong,
|
|
420
|
+
borderWidth: 1,
|
|
421
|
+
borderRadius: 8,
|
|
422
|
+
padding: 10,
|
|
423
|
+
gap: 4,
|
|
424
|
+
},
|
|
425
|
+
outcomeHeaderRow: {
|
|
426
|
+
flexDirection: "row",
|
|
427
|
+
justifyContent: "space-between",
|
|
428
|
+
alignItems: "center",
|
|
429
|
+
},
|
|
430
|
+
outcomeTitle: {
|
|
431
|
+
color: buoyColors.text,
|
|
432
|
+
fontSize: 12,
|
|
433
|
+
fontWeight: "700",
|
|
434
|
+
flex: 1,
|
|
435
|
+
},
|
|
436
|
+
outcomeDismiss: {
|
|
437
|
+
color: buoyColors.textMuted,
|
|
438
|
+
fontSize: 13,
|
|
439
|
+
},
|
|
440
|
+
outcomeSource: {
|
|
441
|
+
gap: 2,
|
|
442
|
+
},
|
|
443
|
+
outcomeLine: {
|
|
444
|
+
color: buoyColors.success,
|
|
445
|
+
fontSize: 11,
|
|
446
|
+
},
|
|
447
|
+
outcomeLineBad: {
|
|
448
|
+
color: buoyColors.warning,
|
|
449
|
+
},
|
|
450
|
+
outcomeDetail: {
|
|
451
|
+
color: buoyColors.textMuted,
|
|
452
|
+
fontSize: 10,
|
|
453
|
+
paddingLeft: 12,
|
|
454
|
+
},
|
|
455
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimeMachineModal — bottom-sheet wrapper rendered by FloatingDevTools when
|
|
3
|
+
* the Time Machine tool is opened. TimeMachineToolView is the inline variant
|
|
4
|
+
* for Buoy Desktop.
|
|
5
|
+
*/
|
|
6
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
7
|
+
import { JsModal, buoyColors } from "@buoy-gg/shared-ui";
|
|
8
|
+
import type { ModalState } from "@buoy-gg/shared-ui";
|
|
9
|
+
import { TimeMachineBody } from "./TimeMachineBody";
|
|
10
|
+
|
|
11
|
+
export interface TimeMachineModalProps {
|
|
12
|
+
visible: boolean;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
onBack?: () => void;
|
|
15
|
+
onMinimize?: (modalState: ModalState) => void;
|
|
16
|
+
persistenceKey?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Header() {
|
|
20
|
+
return (
|
|
21
|
+
<View style={styles.header}>
|
|
22
|
+
<Text style={styles.headerTitle}>TIME MACHINE</Text>
|
|
23
|
+
<Text style={styles.headerSubtitle}>snapshot & restore app state</Text>
|
|
24
|
+
</View>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TimeMachineModal({
|
|
29
|
+
visible,
|
|
30
|
+
onClose,
|
|
31
|
+
onBack: _onBack,
|
|
32
|
+
onMinimize,
|
|
33
|
+
persistenceKey,
|
|
34
|
+
}: TimeMachineModalProps) {
|
|
35
|
+
return (
|
|
36
|
+
<JsModal
|
|
37
|
+
visible={visible}
|
|
38
|
+
onClose={onClose}
|
|
39
|
+
onMinimize={onMinimize}
|
|
40
|
+
persistenceKey={persistenceKey}
|
|
41
|
+
header={{
|
|
42
|
+
showToggleButton: true,
|
|
43
|
+
customContent: <Header />,
|
|
44
|
+
}}
|
|
45
|
+
enablePersistence
|
|
46
|
+
initialMode="bottomSheet"
|
|
47
|
+
>
|
|
48
|
+
<TimeMachineBody />
|
|
49
|
+
</JsModal>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inline variant (no modal chrome) — used by Buoy Desktop. */
|
|
54
|
+
export function TimeMachineToolView() {
|
|
55
|
+
return <TimeMachineBody />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const styles = StyleSheet.create({
|
|
59
|
+
header: {
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
alignItems: "baseline",
|
|
62
|
+
gap: 8,
|
|
63
|
+
paddingHorizontal: 4,
|
|
64
|
+
},
|
|
65
|
+
headerTitle: {
|
|
66
|
+
color: buoyColors.text,
|
|
67
|
+
fontSize: 13,
|
|
68
|
+
fontWeight: "700",
|
|
69
|
+
letterSpacing: 1,
|
|
70
|
+
},
|
|
71
|
+
headerSubtitle: {
|
|
72
|
+
color: buoyColors.textMuted,
|
|
73
|
+
fontSize: 10,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getStateSnapshotProviders,
|
|
4
|
+
subscribeToStateSnapshotProviders,
|
|
5
|
+
type StateSnapshotProvider,
|
|
6
|
+
} from "@buoy-gg/shared-ui";
|
|
7
|
+
import { timeMachineStore } from "../store/timeMachineStore";
|
|
8
|
+
import type { TimeMachineState } from "../store/timeMachineStore";
|
|
9
|
+
|
|
10
|
+
export function useTimeMachineState(): TimeMachineState {
|
|
11
|
+
return useSyncExternalStore(timeMachineStore.subscribe, () =>
|
|
12
|
+
timeMachineStore.getState()
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Registered snapshot providers (query registers late, hence the subscribe). */
|
|
17
|
+
export function useSnapshotProviders(): StateSnapshotProvider[] {
|
|
18
|
+
const [providers, setProviders] = useState(getStateSnapshotProviders);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setProviders(getStateSnapshotProviders());
|
|
21
|
+
return subscribeToStateSnapshotProviders(() =>
|
|
22
|
+
setProviders(getStateSnapshotProviders())
|
|
23
|
+
);
|
|
24
|
+
}, []);
|
|
25
|
+
return providers;
|
|
26
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @buoy-gg/time-machine
|
|
3
|
+
*
|
|
4
|
+
* Time Machine — snapshot & restore app state for React Native DevTools.
|
|
5
|
+
*
|
|
6
|
+
* Capture the complete client-side state of the app (storage, Redux,
|
|
7
|
+
* Zustand, Jotai, React Query) as a named snapshot; restore any snapshot in
|
|
8
|
+
* one tap to iterate on multi-step test flows without rebuilding state.
|
|
9
|
+
*
|
|
10
|
+
* PUBLIC API - Only these exports are supported for external use.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Tool preset for FloatingDevTools
|
|
14
|
+
export { timeMachineToolPreset, createTimeMachineTool } from "./preset";
|
|
15
|
+
|
|
16
|
+
// Components
|
|
17
|
+
export {
|
|
18
|
+
TimeMachineModal,
|
|
19
|
+
TimeMachineToolView,
|
|
20
|
+
type TimeMachineModalProps,
|
|
21
|
+
} from "./components/TimeMachineModal";
|
|
22
|
+
|
|
23
|
+
// External-sync adapter (auto-registered by @buoy-gg/core)
|
|
24
|
+
export { timeMachineSyncAdapter } from "./sync/timeMachineSyncAdapter";
|
|
25
|
+
|
|
26
|
+
// Programmatic snapshot control (also used by tests / power users)
|
|
27
|
+
export {
|
|
28
|
+
timeMachineStore,
|
|
29
|
+
RESTORE_ORDER,
|
|
30
|
+
type RestoreMode,
|
|
31
|
+
type RestoreOutcome,
|
|
32
|
+
type TimeMachineState,
|
|
33
|
+
type RemoteProviderStatus,
|
|
34
|
+
} from "./store/timeMachineStore";
|
|
35
|
+
|
|
36
|
+
// Buoy Desktop remote-mirror seam
|
|
37
|
+
export {
|
|
38
|
+
setRemoteTimeMachineHandler,
|
|
39
|
+
replaceTimeMachineFromSnapshot,
|
|
40
|
+
} from "./store/timeMachineStore";
|
|
41
|
+
export type {
|
|
42
|
+
SnapshotMeta,
|
|
43
|
+
SnapshotSourceMeta,
|
|
44
|
+
StoredSnapshot,
|
|
45
|
+
} from "./store/snapshotVault";
|
package/src/preset.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-configured Time Machine tool for FloatingDevTools.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { timeMachineToolPreset } from '@buoy-gg/time-machine';
|
|
7
|
+
*
|
|
8
|
+
* <FloatingDevTools apps={[timeMachineToolPreset]} />
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
TimeMachineIcon,
|
|
14
|
+
TIME_MACHINE_ICON_COLOR,
|
|
15
|
+
} from "@buoy-gg/shared-ui";
|
|
16
|
+
import { TimeMachineModal } from "./components/TimeMachineModal";
|
|
17
|
+
|
|
18
|
+
export const timeMachineToolPreset = {
|
|
19
|
+
id: "time-machine",
|
|
20
|
+
name: "TIME MACHINE",
|
|
21
|
+
description: "Snapshot & restore app state (storage, stores, query cache)",
|
|
22
|
+
slot: "both" as const,
|
|
23
|
+
icon: ({ size, color }: { size: number; color?: string }) => (
|
|
24
|
+
<TimeMachineIcon size={size} color={color || TIME_MACHINE_ICON_COLOR} />
|
|
25
|
+
),
|
|
26
|
+
component: TimeMachineModal,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Create a custom Time Machine tool configuration. */
|
|
30
|
+
export function createTimeMachineTool(
|
|
31
|
+
options: { name?: string; description?: string } = {}
|
|
32
|
+
) {
|
|
33
|
+
return {
|
|
34
|
+
...timeMachineToolPreset,
|
|
35
|
+
name: options.name ?? timeMachineToolPreset.name,
|
|
36
|
+
description: options.description ?? timeMachineToolPreset.description,
|
|
37
|
+
};
|
|
38
|
+
}
|