@buoy-gg/time-machine 7.0.29 → 7.0.31

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.
@@ -1,241 +0,0 @@
1
- /**
2
- * Sync adapter for the Time Machine tool, consumed by @buoy-gg/external-sync's
3
- * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
4
- * this package doesn't need a dependency on it).
5
- *
6
- * Snapshots stay ON-DEVICE — getSnapshot ships METADATA only (list, provider
7
- * statuses, last restore outcome), far under the 2MB per-tool budget. Payload
8
- * inspection goes through the `inspect` action (8MB action-result budget).
9
- *
10
- * The `restore` action uses the store's scheduleReloadApp path for reload
11
- * mode, so the action RESULT is flushed to the broker before the JS realm
12
- * dies — a remote caller always gets its answer.
13
- */
14
- import {
15
- getStateSnapshotProviders,
16
- subscribeToStateSnapshotProviders,
17
- superJsonDeserialize,
18
- } from "@buoy-gg/shared-ui";
19
- import { jsonLikelyLargerThan } from "@buoy-gg/shared-ui/dataViewer";
20
- import {
21
- timeMachineStore,
22
- type PreviewSideRef,
23
- type RestoreMode,
24
- } from "../store/timeMachineStore";
25
- import { loadSnapshot } from "../store/snapshotVault";
26
- import { canNavigate, currentRoute } from "../store/routeRestore";
27
-
28
- interface CaptureParams {
29
- name?: string;
30
- sourceIds?: string[];
31
- }
32
- interface RestoreParams {
33
- id: string;
34
- mode?: RestoreMode;
35
- /** Compound "sourceId::itemKey" strings — items the caller opted out of. */
36
- excludeKeys?: string[];
37
- /** Also navigate back to the route the snapshot was captured on. */
38
- restoreRoute?: boolean;
39
- }
40
- interface IdParams {
41
- id: string;
42
- }
43
- interface RenameParams {
44
- id: string;
45
- name: string;
46
- }
47
- interface InspectParams {
48
- id: string;
49
- sourceId: string;
50
- }
51
-
52
- function providerStatuses() {
53
- return getStateSnapshotProviders().map((p) => {
54
- const s = p.status();
55
- return {
56
- id: p.id,
57
- label: p.label,
58
- canCapture: s.canCapture,
59
- canRestore: s.canRestore,
60
- detail: s.detail,
61
- };
62
- });
63
- }
64
-
65
- function metadata() {
66
- const { snapshots, busy, lastOutcome } = timeMachineStore.getState();
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
- };
80
- }
81
-
82
- export const timeMachineSyncAdapter = {
83
- // v2 adds `route` (device route capability) and `snapshots[].route`.
84
- version: 2,
85
- getSnapshot: () => metadata(),
86
- subscribe: (onChange: () => void) => {
87
- const unsubStore = timeMachineStore.subscribe(onChange);
88
- const unsubProviders = subscribeToStateSnapshotProviders(onChange);
89
- return () => {
90
- unsubStore();
91
- unsubProviders();
92
- };
93
- },
94
- actions: {
95
- list: () => metadata(),
96
-
97
- capture: async (params: unknown) => {
98
- const { name = "", sourceIds } = (params as CaptureParams) ?? {};
99
- const meta = await timeMachineStore.capture(name, sourceIds);
100
- return { captured: true, snapshot: meta };
101
- },
102
-
103
- captureBaseline: async () => {
104
- const meta = await timeMachineStore.captureBaseline();
105
- return { captured: true, snapshot: meta };
106
- },
107
-
108
- /** Wipe every clearable source to fresh-install state, then reload. */
109
- wipeAll: async () => await timeMachineStore.wipeAll(),
110
-
111
- /** mode "live" (default) replaces state in place; "reload" restores
112
- * persisted storage then reloads the JS bundle (always the path for
113
- * baseline snapshots). */
114
- restore: async (params: unknown) => {
115
- const {
116
- id,
117
- mode = "live",
118
- excludeKeys,
119
- restoreRoute,
120
- } = (params as RestoreParams) ?? {};
121
- if (!id) throw new Error("restore requires a snapshot `id`");
122
- return await timeMachineStore.restore(id, mode, excludeKeys, {
123
- restoreRoute,
124
- });
125
- },
126
-
127
- delete: async (params: unknown) => {
128
- const { id } = (params as IdParams) ?? {};
129
- if (!id) throw new Error("delete requires a snapshot `id`");
130
- await timeMachineStore.delete(id);
131
- return { deleted: true, id };
132
- },
133
-
134
- rename: async (params: unknown) => {
135
- const { id, name } = (params as RenameParams) ?? {};
136
- if (!id || !name) throw new Error("rename requires `id` and `name`");
137
- await timeMachineStore.rename(id, name);
138
- return { renamed: true, id, name };
139
- },
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
-
171
- /** Save (or clear with null/empty) a snapshot's targeted-restore scope. */
172
- setScope: async (params: unknown) => {
173
- const { id, scope } =
174
- (params as { id?: string; scope?: string[] | null }) ?? {};
175
- if (!id) throw new Error("setScope requires a snapshot `id`");
176
- await timeMachineStore.setScope(id, scope ?? null);
177
- return { scoped: true, id, count: scope?.length ?? 0 };
178
- },
179
-
180
- /**
181
- * Item-level restore preview, computed ON-DEVICE. Values above ~4KB per
182
- * item are omitted (valueOmitted) so the result stays far under the 8MB
183
- * action budget on any app size.
184
- */
185
- preview: async (params: unknown) => {
186
- const { id, compareTo } =
187
- (params as { id?: string; compareTo?: string }) ?? {};
188
- if (!id) throw new Error("preview requires a snapshot `id`");
189
- const right: PreviewSideRef = { snapshotId: id };
190
- const left: PreviewSideRef = compareTo
191
- ? { snapshotId: compareTo }
192
- : "current";
193
- await timeMachineStore.buildPreview(right, left);
194
- const preview = timeMachineStore.getState().preview;
195
- if (!preview) throw new Error("preview build produced no result");
196
- const VALUE_BUDGET = 4 * 1024;
197
- return {
198
- left,
199
- right,
200
- builtAt: preview.builtAt,
201
- sources: preview.sources.map((src) => ({
202
- ...src,
203
- items: src.items.map((item) => {
204
- const oldBig =
205
- item.oldValue !== undefined &&
206
- jsonLikelyLargerThan(item.oldValue, VALUE_BUDGET);
207
- const newBig =
208
- item.newValue !== undefined &&
209
- jsonLikelyLargerThan(item.newValue, VALUE_BUDGET);
210
- return {
211
- ...item,
212
- oldValue: oldBig ? undefined : item.oldValue,
213
- newValue: newBig ? undefined : item.newValue,
214
- ...(oldBig || newBig ? { valueOmitted: true } : {}),
215
- };
216
- }),
217
- })),
218
- };
219
- },
220
-
221
- /** One source's captured payload, deserialized — for remote inspection. */
222
- inspect: async (params: unknown) => {
223
- const { id, sourceId } = (params as InspectParams) ?? {};
224
- if (!id || !sourceId) {
225
- throw new Error("inspect requires `id` and `sourceId`");
226
- }
227
- const stored = await loadSnapshot(id);
228
- if (!stored) throw new Error(`No snapshot with id "${id}"`);
229
- const payload = stored.data[sourceId];
230
- if (!payload) {
231
- throw new Error(`Snapshot "${id}" has no "${sourceId}" source`);
232
- }
233
- return {
234
- id,
235
- sourceId,
236
- warnings: stored.meta.sources[sourceId]?.warnings ?? [],
237
- data: superJsonDeserialize(payload),
238
- };
239
- },
240
- },
241
- };
@@ -1,199 +0,0 @@
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
- });
@@ -1,174 +0,0 @@
1
- /**
2
- * Grouped-inset list primitives — the building blocks of the Time Machine
3
- * surface (see ./tmTheme for the tokens and the promotion plan).
4
- *
5
- * The shape they compose to:
6
- *
7
- * SectionLabel SOURCES ← outside the card, uppercase
8
- * Group ┌──────────────────┐
9
- * <row> │ All sources … 6 ›│ ← rows are plain children;
10
- * <row> ├──────────────────┤ Group draws the hairlines
11
- * └──────────────────┘
12
- * Footnote Route is captured with… ← explains the group above
13
- *
14
- * Rows are whatever a screen needs — Group only owns the card, the clipping
15
- * and the separators, so a row never has to know its position in the list.
16
- */
17
- import { Children, type ReactNode } from "react";
18
- import { StyleSheet, Text, View, type ViewStyle } from "react-native";
19
- import { ChevronRight } from "@buoy-gg/shared-ui";
20
- import { tm } from "./tmTheme";
21
-
22
- /** Uppercase section label that sits above a Group. */
23
- export function SectionLabel({
24
- children,
25
- testID,
26
- }: {
27
- children: string;
28
- testID?: string;
29
- }) {
30
- return (
31
- <Text style={styles.sectionLabel} testID={testID}>
32
- {children}
33
- </Text>
34
- );
35
- }
36
-
37
- /**
38
- * Explanatory line under a Group ("Swipe a restore point to delete it").
39
- * `centered` matches the iOS convention for a footnote under a lone button.
40
- */
41
- export function Footnote({
42
- children,
43
- centered,
44
- tone = "secondary",
45
- testID,
46
- }: {
47
- children: ReactNode;
48
- centered?: boolean;
49
- tone?: "secondary" | "warning";
50
- testID?: string;
51
- }) {
52
- return (
53
- <Text
54
- style={[
55
- styles.footnote,
56
- centered ? styles.footnoteCentered : null,
57
- tone === "warning" ? styles.footnoteWarning : null,
58
- ]}
59
- testID={testID}
60
- >
61
- {children}
62
- </Text>
63
- );
64
- }
65
-
66
- /**
67
- * A rounded grouped card. Draws a hairline between consecutive children and
68
- * clips them, so a row can paint edge-to-edge (swipe actions, press washes)
69
- * without punching through the corners.
70
- */
71
- export function Group({
72
- children,
73
- style,
74
- }: {
75
- children: ReactNode;
76
- style?: ViewStyle;
77
- }) {
78
- const rows = Children.toArray(children);
79
- return (
80
- <View style={[styles.group, style]}>
81
- {rows.map((row, index) => (
82
- // Children.toArray assigns stable synthetic keys ('.$id' for keyed
83
- // children), so a row keeps its identity as the list changes.
84
- <View key={(row as { key?: string }).key ?? index}>
85
- {index > 0 ? <View style={styles.separator} /> : null}
86
- {row}
87
- </View>
88
- ))}
89
- </View>
90
- );
91
- }
92
-
93
- /**
94
- * Trailing disclosure chevron for a row that opens or expands.
95
- *
96
- * A real glyph, not two rotated hairlines: at this size the hand-built
97
- * version's strokes never met at the vertex, so it read as a broken arrow.
98
- */
99
- export function DisclosureChevron() {
100
- return <ChevronRight size={16} color={tm.color.labelTertiary} />;
101
- }
102
-
103
- /**
104
- * Pill button. Parameterized by color rather than by variant flags so a
105
- * screen can name its own (`CapturePill`, `RestorePill`) in one line.
106
- */
107
- export function PillLabel({
108
- label,
109
- color,
110
- background,
111
- testID,
112
- }: {
113
- label: string;
114
- color: string;
115
- background: string;
116
- testID?: string;
117
- }) {
118
- return (
119
- <View style={[styles.pill, { backgroundColor: background }]}>
120
- <Text style={[styles.pillText, { color }]} testID={testID}>
121
- {label}
122
- </Text>
123
- </View>
124
- );
125
- }
126
-
127
- const styles = StyleSheet.create({
128
- sectionLabel: {
129
- color: tm.color.labelSecondary,
130
- fontSize: tm.font.footnote,
131
- fontWeight: "500",
132
- textTransform: "uppercase",
133
- letterSpacing: 0.4,
134
- paddingHorizontal: tm.gutter + tm.textInset,
135
- paddingTop: 22,
136
- paddingBottom: 7,
137
- },
138
- footnote: {
139
- color: tm.color.labelSecondary,
140
- fontSize: tm.font.footnote,
141
- lineHeight: tm.font.footnote * 1.35,
142
- paddingHorizontal: tm.gutter + tm.textInset,
143
- paddingTop: 7,
144
- },
145
- footnoteCentered: {
146
- textAlign: "center",
147
- },
148
- footnoteWarning: {
149
- color: tm.color.warning,
150
- },
151
- group: {
152
- marginHorizontal: tm.gutter,
153
- backgroundColor: tm.color.group,
154
- borderRadius: tm.radius.group,
155
- overflow: "hidden",
156
- },
157
- separator: {
158
- height: StyleSheet.hairlineWidth,
159
- marginLeft: tm.separatorInset,
160
- backgroundColor: tm.color.separator,
161
- },
162
- pill: {
163
- height: 32,
164
- paddingHorizontal: 15,
165
- borderRadius: tm.radius.pill,
166
- alignItems: "center",
167
- justifyContent: "center",
168
- },
169
- pillText: {
170
- fontSize: tm.font.control,
171
- fontWeight: "600",
172
- letterSpacing: tm.tracking.control,
173
- },
174
- });