@buoy-gg/time-machine 7.0.29 → 7.0.30

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/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@buoy-gg/time-machine",
3
- "version": "7.0.29",
3
+ "version": "7.0.30",
4
4
  "description": "Time Machine - snapshot & restore app state (storage, redux, zustand, jotai, react-query) for React Native DevTools",
5
- "main": "lib/commonjs/index",
6
- "module": "lib/module/index",
5
+ "main": "lib/commonjs/index.js",
6
+ "module": "lib/module/index.js",
7
7
  "types": "lib/typescript/index.d.ts",
8
- "react-native": "src/index.tsx",
8
+ "react-native": "lib/module/index.js",
9
9
  "source": "src/index.tsx",
10
10
  "files": [
11
- "src",
12
11
  "lib"
13
12
  ],
14
13
  "sideEffects": [
@@ -17,21 +16,21 @@
17
16
  "./lib/module/index.js"
18
17
  ],
19
18
  "dependencies": {
20
- "@buoy-gg/floating-tools-core": "^7.0.29",
21
- "@buoy-gg/shared-ui": "^7.0.29"
19
+ "@buoy-gg/floating-tools-core": "^7.0.30",
20
+ "@buoy-gg/shared-ui": "^7.0.30"
22
21
  },
23
22
  "peerDependencies": {
24
- "@buoy-gg/license": "7.0.29",
23
+ "@buoy-gg/license": "7.0.30",
25
24
  "react": "*",
26
25
  "react-native": "*",
27
- "@buoy-gg/core": "^7.0.29"
26
+ "@buoy-gg/core": "^7.0.30"
28
27
  },
29
28
  "devDependencies": {
30
29
  "@types/react": "^19.1.0",
31
30
  "@types/react-native": "^0.73.0",
32
31
  "typescript": "~5.8.3",
33
- "@buoy-gg/license": "^7.0.29",
34
- "@buoy-gg/core": "^7.0.29"
32
+ "@buoy-gg/license": "^7.0.30",
33
+ "@buoy-gg/core": "^7.0.30"
35
34
  },
36
35
  "react-native-builder-bob": {
37
36
  "source": "src",
@@ -1,45 +0,0 @@
1
- /**
2
- * Labeled "RELOAD APP" header action — the one reload affordance in the tool.
3
- * Plain JS-bundle reload (no restore): in-memory state rebuilds from whatever
4
- * is persisted. Registered as the preset's default headerActions, so it shows
5
- * on every screen unless a screen overrides the header actions.
6
- */
7
- import { StyleSheet, Text, TouchableOpacity } from "react-native";
8
- import { RefreshCw, night, scheduleReloadApp } from "@buoy-gg/shared-ui";
9
-
10
- export function HeaderReloadButton() {
11
- const onPress = () => {
12
- try {
13
- scheduleReloadApp({ reason: "buoy time-machine reload app" });
14
- } catch {
15
- // No reload mechanism available (bare production build) — nothing to do.
16
- }
17
- };
18
- return (
19
- <TouchableOpacity
20
- style={styles.button}
21
- onPress={onPress}
22
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
23
- accessibilityLabel="Reload app"
24
- accessibilityRole="button"
25
- testID="time-machine-reload-app"
26
- >
27
- <RefreshCw size={12} color={night.color.textSecondary} />
28
- <Text style={styles.label}>RELOAD APP</Text>
29
- </TouchableOpacity>
30
- );
31
- }
32
-
33
- const styles = StyleSheet.create({
34
- button: {
35
- flexDirection: "row",
36
- alignItems: "center",
37
- gap: 6,
38
- padding: 4,
39
- },
40
- label: {
41
- color: night.color.textSecondary,
42
- fontSize: 12,
43
- fontWeight: "600",
44
- },
45
- });
@@ -1,246 +0,0 @@
1
- /**
2
- * Time Machine body — the screen router plus the restore-point list.
3
- *
4
- * Screens: list → preview. Each is its own component that mounts and
5
- * unmounts whole (no branching inside a single tree), and each owns its own
6
- * scrolling — the host must NOT wrap this in a scroll view, or the preview's
7
- * virtualized FlatList is defeated. Item diffs expand inline in that list
8
- * rather than being a third screen.
9
- *
10
- * The list follows the grouped-inset surface in ../ui: capture bar, the
11
- * Sources strip, one card of restore points with swipe-to-delete, and the
12
- * app-wide wipe kept last and understated.
13
- */
14
- import { useState } from "react";
15
- import { ScrollView, StyleSheet, Text, View } from "react-native";
16
- import { useSafeAreaInsets, useLimit } from "@buoy-gg/shared-ui";
17
- import { useToolHost } from "@buoy-gg/core";
18
- import { useIsPro } from "@buoy-gg/license";
19
- import { useTimeMachineState } from "../hooks/useTimeMachine";
20
- import { timeMachineStore } from "../store/timeMachineStore";
21
- import type { SnapshotMeta } from "../store/snapshotVault";
22
- import { Footnote, Group, SectionLabel } from "../ui/primitives";
23
- import { tm } from "../ui/tmTheme";
24
- import { useSourceStatuses, type SourceStatus } from "./sources";
25
- import { confirmDestructive } from "./confirm";
26
- import { CaptureBar } from "./list/CaptureBar";
27
- import {
28
- RestoreOutcomeCard,
29
- restoreNeedsAttention,
30
- } from "./list/RestoreOutcomeCard";
31
- import { SnapshotRow } from "./list/SnapshotRow";
32
- import { UndoDeleteBar } from "./list/UndoDeleteBar";
33
- import { SourcesStrip } from "./list/SourcesStrip";
34
- import { WipeAllRow } from "./list/WipeAllRow";
35
- import { PreviewScreen } from "./preview/PreviewFlow";
36
-
37
- type Screen = { kind: "list" } | { kind: "preview" };
38
-
39
- export function TimeMachineBody() {
40
- const [screen, setScreen] = useState<Screen>({ kind: "list" });
41
- const { snapshots, busy, preview } = useTimeMachineState();
42
- const sources = useSourceStatuses();
43
-
44
- const backToList = () => {
45
- timeMachineStore.clearPreview();
46
- setScreen({ kind: "list" });
47
- };
48
-
49
- // The preview can't render without a built diff (clearPreview races a
50
- // back-navigation) — fall through to the list rather than an empty shell.
51
- if (screen.kind === "preview" && preview)
52
- return (
53
- <PreviewScreen
54
- preview={preview}
55
- snapshots={snapshots}
56
- busy={busy !== null}
57
- onClose={backToList}
58
- />
59
- );
60
-
61
- return (
62
- <RestorePointList
63
- sources={sources}
64
- onOpenPreview={(meta) => {
65
- void timeMachineStore.buildPreview({ snapshotId: meta.id });
66
- setScreen({ kind: "preview" });
67
- }}
68
- />
69
- );
70
- }
71
-
72
- function RestorePointList({
73
- sources,
74
- onOpenPreview,
75
- }: {
76
- sources: SourceStatus[];
77
- onOpenPreview: (meta: SnapshotMeta) => void;
78
- }) {
79
- const { snapshots, hasLoaded, busy, lastOutcome, recentlyDeleted } =
80
- useTimeMachineState();
81
- const isPro = useIsPro();
82
- const snapshotLimit = useLimit("timeMachine.snapshots");
83
- const host = useToolHost();
84
- const insets = useSafeAreaInsets({ minBottom: 16 });
85
-
86
- // Header search (when hosted by the core shell) filters the list.
87
- const query = host.search.trim().toLowerCase();
88
- const visible = query
89
- ? snapshots.filter((s) => s.name.toLowerCase().includes(query))
90
- : snapshots;
91
-
92
- // Infinity for Pro, so this is simply false for them.
93
- const atCaptureLimit = snapshots.length >= snapshotLimit;
94
- const captureDisabled = busy !== null || atCaptureLimit;
95
-
96
- /**
97
- * Restoring is the last thing anyone wants from this screen, so the tool
98
- * gets out of the way the moment it worked — with no toast and no banner.
99
- * The restored app IS the confirmation, and anything announcing it is one
100
- * more thing to dismiss on a flow people run dozens of times an hour.
101
- *
102
- * It MINIMIZES rather than closes: the list, the search and the screen you
103
- * were on all survive, and coming back is one tap on the dock. A partial
104
- * restore stays open instead, because the outcome card is the only place
105
- * the per-source failures are reported.
106
- */
107
- const restore = (meta: SnapshotMeta) => {
108
- if (busy) return;
109
- if (meta.baseline) {
110
- confirmDestructive(
111
- "Restore fresh install?",
112
- "This wipes all app storage and reloads the app.",
113
- "Wipe & reload",
114
- () => void timeMachineStore.restore(meta.id, "reload")
115
- );
116
- return;
117
- }
118
- void timeMachineStore.restore(meta.id, "live").then((outcome) => {
119
- const clean = Object.values(outcome.results).every((r) => r.ok);
120
- if (!clean || outcome.willReload) return;
121
- host.minimize();
122
- });
123
- };
124
-
125
- return (
126
- <ScrollView
127
- style={styles.fill}
128
- contentContainerStyle={[styles.content, { paddingBottom: insets.bottom }]}
129
- testID="time-machine-scroll"
130
- // Without this, the first tap after typing a name only DISMISSES the
131
- // keyboard — Capture never fires and the restore point is silently not
132
- // taken. Every other Buoy screen with a field inside a scroller sets it;
133
- // this one didn't, and only the XCTest lane could see it (Detox's
134
- // replaceText never opens a keyboard, so the tap always landed).
135
- keyboardShouldPersistTaps="handled"
136
- >
137
- {/* Only when the restore did something the app itself won't show you. */}
138
- {lastOutcome && restoreNeedsAttention(lastOutcome) ? (
139
- <RestoreOutcomeCard outcome={lastOutcome} />
140
- ) : null}
141
-
142
- {recentlyDeleted ? (
143
- <UndoDeleteBar
144
- name={recentlyDeleted.stored.meta.name}
145
- at={recentlyDeleted.at}
146
- onUndo={() => void timeMachineStore.undoDelete()}
147
- onExpire={() => timeMachineStore.clearRecentlyDeleted()}
148
- />
149
- ) : null}
150
-
151
- <View>
152
- <CaptureBar
153
- disabled={captureDisabled}
154
- busy={busy === "capture"}
155
- onCapture={(name) => void timeMachineStore.capture(name)}
156
- />
157
- </View>
158
- {atCaptureLimit ? (
159
- <Footnote tone="warning" testID="time-machine-limit">
160
- Free plan keeps {snapshotLimit} restore points. Upgrade to Pro
161
- for unlimited snapshots.
162
- </Footnote>
163
- ) : null}
164
-
165
- <SectionLabel>Sources</SectionLabel>
166
- <SourcesStrip sources={sources} />
167
- {/* A source that can't fully participate says why, right where its
168
- tile is — the strip only has room for the dot. */}
169
- {sources
170
- .filter((s) => s.detail && !(s.canCapture && s.canRestore))
171
- .map((s) => (
172
- <Footnote key={s.id} testID={`time-machine-source-${s.id}-detail`}>
173
- {s.label}: {s.detail}
174
- </Footnote>
175
- ))}
176
- <SectionLabel>Restore Points</SectionLabel>
177
- <Group>
178
- {visible.map((meta) => (
179
- <SnapshotRow
180
- key={meta.id}
181
- meta={meta}
182
- busy={busy !== null}
183
- onOpen={() => onOpenPreview(meta)}
184
- onRestore={() => restore(meta)}
185
- onDelete={() => void timeMachineStore.delete(meta.id)}
186
- onDuplicate={() => void timeMachineStore.duplicate(meta.id)}
187
- />
188
- ))}
189
- {visible.length === 0 ? (
190
- <Text style={styles.empty} testID="time-machine-empty">
191
- {!hasLoaded
192
- ? "Loading restore points…"
193
- : snapshots.length > 0
194
- ? "No restore point matches that name."
195
- : "Set up the state you want to come back to, then capture it."}
196
- </Text>
197
- ) : null}
198
- </Group>
199
- {visible.length > 0 ? (
200
- <Footnote testID="time-machine-swipe-hint">
201
- Swipe a restore point to copy or delete it.
202
- </Footnote>
203
- ) : null}
204
-
205
- <View style={styles.spacer} />
206
-
207
- <Group>
208
- <WipeAllRow
209
- disabled={busy !== null}
210
- onWipe={() =>
211
- confirmDestructive(
212
- "Wipe all app state?",
213
- "This deletes all app storage and reloads the app — like a fresh install.",
214
- "Wipe & reload",
215
- () => void timeMachineStore.wipeAll()
216
- )
217
- }
218
- />
219
- </Group>
220
- <Footnote centered>
221
- Deletes all app storage and reloads — like a fresh install.
222
- </Footnote>
223
- </ScrollView>
224
- );
225
- }
226
-
227
- const styles = StyleSheet.create({
228
- fill: {
229
- flex: 1,
230
- // Transparent so JsModal's ToolBackground shows through (the modal paints opaque base beneath it).
231
- backgroundColor: "transparent",
232
- },
233
- content: {
234
- paddingTop: 14,
235
- },
236
- empty: {
237
- color: tm.color.labelSecondary,
238
- fontSize: tm.font.footnote,
239
- lineHeight: tm.font.footnote * 1.4,
240
- paddingHorizontal: tm.textInset,
241
- paddingVertical: 16,
242
- },
243
- spacer: {
244
- height: 24,
245
- },
246
- });
@@ -1,75 +0,0 @@
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 } from "@buoy-gg/shared-ui";
8
- import type { ModalState } from "@buoy-gg/shared-ui";
9
- import { tm } from "../ui/tmTheme";
10
- import { TimeMachineBody } from "./TimeMachineBody";
11
-
12
- export interface TimeMachineModalProps {
13
- visible: boolean;
14
- onClose: () => void;
15
- onBack?: () => void;
16
- onMinimize?: (modalState: ModalState) => void;
17
- persistenceKey?: string;
18
- }
19
-
20
- function Header() {
21
- return (
22
- <View style={styles.header}>
23
- <Text style={styles.headerTitle}>Time Machine</Text>
24
- <Text style={styles.headerSubtitle}>Snapshot & restore app state</Text>
25
- </View>
26
- );
27
- }
28
-
29
- export function TimeMachineModal({
30
- visible,
31
- onClose,
32
- onBack: _onBack,
33
- onMinimize,
34
- persistenceKey,
35
- }: TimeMachineModalProps) {
36
- return (
37
- <JsModal
38
- visible={visible}
39
- onClose={onClose}
40
- onMinimize={onMinimize}
41
- persistenceKey={persistenceKey}
42
- header={{
43
- showToggleButton: true,
44
- customContent: <Header />,
45
- }}
46
- enablePersistence
47
- initialMode="bottomSheet"
48
- disableScrollWrapper
49
- >
50
- <TimeMachineBody />
51
- </JsModal>
52
- );
53
- }
54
-
55
- /** Inline variant (no modal chrome) — used by Buoy Desktop. */
56
- export function TimeMachineToolView() {
57
- return <TimeMachineBody />;
58
- }
59
-
60
- const styles = StyleSheet.create({
61
- header: {
62
- paddingHorizontal: 4,
63
- gap: 2,
64
- },
65
- headerTitle: {
66
- color: tm.color.label,
67
- fontSize: 22,
68
- fontWeight: "700",
69
- letterSpacing: 0.3,
70
- },
71
- headerSubtitle: {
72
- color: tm.color.labelSecondary,
73
- fontSize: tm.font.footnote,
74
- },
75
- });
@@ -1,35 +0,0 @@
1
- /**
2
- * Confirmation dialogs for the destructive / branching Time Machine actions.
3
- *
4
- * RN-web's Alert renders no buttons at all, so the web path (Buoy Desktop,
5
- * the web example) uses `window.confirm`.
6
- *
7
- * There is deliberately NO "restore the route too?" prompt: that question is
8
- * answered once per restore point by the route toggle on its row, because
9
- * asking it on every restore meant answering it thirty times a day.
10
- */
11
- import { Alert, Platform } from "react-native";
12
-
13
- function webConfirm(message: string): boolean {
14
- const confirmFn = (globalThis as { confirm?: (msg: string) => boolean })
15
- .confirm;
16
- // No dialog available at all (headless web): don't silently swallow the
17
- // user's intent — treat it as confirmed, same as the previous behavior.
18
- return confirmFn ? confirmFn(message) : true;
19
- }
20
-
21
- export function confirmDestructive(
22
- title: string,
23
- message: string,
24
- confirmLabel: string,
25
- onConfirm: () => void
26
- ) {
27
- if (Platform.OS === "web") {
28
- if (webConfirm(`${title}\n\n${message}`)) onConfirm();
29
- return;
30
- }
31
- Alert.alert(title, message, [
32
- { text: "Cancel", style: "cancel" },
33
- { text: confirmLabel, style: "destructive", onPress: onConfirm },
34
- ]);
35
- }
@@ -1,17 +0,0 @@
1
- /** Display formatting shared by the Time Machine screens. */
2
-
3
- export function timeAgo(ts: number): string {
4
- const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
5
- if (s < 60) return `${s}s ago`;
6
- const m = Math.round(s / 60);
7
- if (m < 60) return `${m}m ago`;
8
- const h = Math.round(m / 60);
9
- if (h < 24) return `${h}h ago`;
10
- return `${Math.round(h / 24)}d ago`;
11
- }
12
-
13
- export function formatBytes(bytes: number): string {
14
- if (bytes < 1024) return `${bytes} B`;
15
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
16
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
17
- }
@@ -1,100 +0,0 @@
1
- /**
2
- * Capture bar — the single field-plus-pill control at the top of the list.
3
- *
4
- * Owns the draft name itself so keystrokes re-render this component and not
5
- * the snapshot list underneath it. The parent only hears about a finished
6
- * capture.
7
- */
8
- import { useState } from "react";
9
- import {
10
- StyleSheet,
11
- Text,
12
- TextInput,
13
- TouchableOpacity,
14
- View,
15
- } from "react-native";
16
- import { tm } from "../../ui/tmTheme";
17
-
18
- export function CaptureBar({
19
- disabled,
20
- busy,
21
- onCapture,
22
- }: {
23
- disabled: boolean;
24
- busy: boolean;
25
- onCapture: (name: string) => void;
26
- }) {
27
- const [name, setName] = useState("");
28
-
29
- const capture = () => {
30
- if (disabled) return;
31
- onCapture(name);
32
- setName("");
33
- };
34
-
35
- return (
36
- <View style={styles.bar}>
37
- <TextInput
38
- style={styles.input}
39
- value={name}
40
- onChangeText={setName}
41
- placeholder="Name this restore point…"
42
- placeholderTextColor={tm.color.labelTertiary}
43
- editable={!disabled}
44
- onSubmitEditing={capture}
45
- returnKeyType="done"
46
- testID="time-machine-name-input"
47
- />
48
- <TouchableOpacity
49
- style={[styles.button, disabled ? styles.buttonDisabled : null]}
50
- onPress={capture}
51
- disabled={disabled}
52
- accessibilityRole="button"
53
- accessibilityLabel="Capture restore point"
54
- testID="time-machine-capture"
55
- >
56
- <Text style={styles.buttonText}>{busy ? "Capturing…" : "Capture"}</Text>
57
- </TouchableOpacity>
58
- </View>
59
- );
60
- }
61
-
62
- const styles = StyleSheet.create({
63
- bar: {
64
- flexDirection: "row",
65
- alignItems: "center",
66
- gap: 8,
67
- marginHorizontal: tm.gutter,
68
- backgroundColor: tm.color.group,
69
- borderRadius: tm.radius.group,
70
- paddingLeft: tm.textInset,
71
- padding: 6,
72
- },
73
- input: {
74
- flex: 1,
75
- minWidth: 0,
76
- color: tm.color.label,
77
- fontSize: tm.font.row,
78
- letterSpacing: tm.tracking.row,
79
- // Zero out the platform chrome so the field reads as part of the card.
80
- padding: 0,
81
- backgroundColor: "transparent",
82
- },
83
- button: {
84
- height: 34,
85
- paddingHorizontal: 16,
86
- borderRadius: tm.radius.pill,
87
- backgroundColor: tm.color.accent,
88
- alignItems: "center",
89
- justifyContent: "center",
90
- },
91
- buttonDisabled: {
92
- opacity: 0.4,
93
- },
94
- buttonText: {
95
- color: tm.color.onAccent,
96
- fontSize: tm.font.control,
97
- fontWeight: "600",
98
- letterSpacing: tm.tracking.control,
99
- },
100
- });