@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.
- package/package.json +10 -11
- package/src/components/HeaderReloadButton.tsx +0 -45
- package/src/components/TimeMachineBody.tsx +0 -246
- package/src/components/TimeMachineModal.tsx +0 -75
- package/src/components/confirm.ts +0 -35
- package/src/components/format.ts +0 -17
- package/src/components/list/CaptureBar.tsx +0 -100
- package/src/components/list/RestoreOutcomeCard.tsx +0 -181
- package/src/components/list/SnapshotRow.tsx +0 -164
- package/src/components/list/SourcesStrip.tsx +0 -127
- package/src/components/list/UndoDeleteBar.tsx +0 -76
- package/src/components/list/WipeAllRow.tsx +0 -42
- package/src/components/preview/ItemDiffPanel.tsx +0 -83
- package/src/components/preview/PreviewFlow.tsx +0 -70
- package/src/components/preview/RestorePointNameRow.tsx +0 -80
- package/src/components/preview/RestorePreviewScreen.tsx +0 -1149
- package/src/components/preview/verdictMeta.ts +0 -40
- package/src/components/sources.ts +0 -92
- package/src/hooks/useTimeMachine.ts +0 -26
- package/src/index.tsx +0 -68
- package/src/preset.tsx +0 -47
- package/src/store/routeRestore.ts +0 -194
- package/src/store/snapshotVault.ts +0 -274
- package/src/store/timeMachineStore.ts +0 -881
- package/src/sync/timeMachineSyncAdapter.ts +0 -241
- package/src/ui/SwipeActionsRow.tsx +0 -199
- package/src/ui/primitives.tsx +0 -174
- package/src/ui/tmTheme.ts +0 -109
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the last restore did, shown ONLY when something went wrong.
|
|
3
|
-
*
|
|
4
|
-
* A clean restore says so with a toast and then gets out of the way — the
|
|
5
|
-
* restored app is the proof, and a wall of green ticks on top of the list
|
|
6
|
-
* every single time was noise. A restore that skipped or failed a source is
|
|
7
|
-
* the opposite: nothing else on screen would ever tell you, so it stays until
|
|
8
|
-
* dismissed.
|
|
9
|
-
*/
|
|
10
|
-
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
11
|
-
import { X } from "@buoy-gg/shared-ui";
|
|
12
|
-
import { tm } from "../../ui/tmTheme";
|
|
13
|
-
import { timeMachineStore } from "../../store/timeMachineStore";
|
|
14
|
-
import type { RestoreOutcome } from "../../store/timeMachineStore";
|
|
15
|
-
|
|
16
|
-
const HIT = { top: 10, bottom: 10, left: 10, right: 10 };
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Did this restore do anything the app itself won't show you? Warnings alone
|
|
20
|
-
* don't count — "1 derived atom recomputes" is true of every restore and
|
|
21
|
-
* would put the card back on screen permanently.
|
|
22
|
-
*/
|
|
23
|
-
export function restoreNeedsAttention(outcome: RestoreOutcome): boolean {
|
|
24
|
-
if (Object.values(outcome.results).some((r) => !r.ok || r.skipped.length > 0))
|
|
25
|
-
return true;
|
|
26
|
-
const route = outcome.route;
|
|
27
|
-
return route?.status === "failed" || route?.status === "unavailable";
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** ✓ / ✕ / ↻ line — the glyph carries the meaning, the color reinforces it. */
|
|
31
|
-
function ResultLine({
|
|
32
|
-
glyph,
|
|
33
|
-
text,
|
|
34
|
-
ok,
|
|
35
|
-
testID,
|
|
36
|
-
}: {
|
|
37
|
-
glyph: string;
|
|
38
|
-
text: string;
|
|
39
|
-
ok: boolean;
|
|
40
|
-
testID?: string;
|
|
41
|
-
}) {
|
|
42
|
-
return (
|
|
43
|
-
<Text
|
|
44
|
-
style={[styles.line, { color: ok ? tm.color.accent : tm.color.warning }]}
|
|
45
|
-
numberOfLines={2}
|
|
46
|
-
testID={testID}
|
|
47
|
-
>
|
|
48
|
-
<Text style={styles.glyph}>{glyph} </Text>
|
|
49
|
-
{text}
|
|
50
|
-
</Text>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const ROUTE_TEST_ID = "time-machine-outcome-route";
|
|
55
|
-
|
|
56
|
-
function RouteResult({ route }: { route: NonNullable<RestoreOutcome["route"]> }) {
|
|
57
|
-
if (route.status === "navigated")
|
|
58
|
-
return (
|
|
59
|
-
<ResultLine
|
|
60
|
-
glyph="✓"
|
|
61
|
-
ok
|
|
62
|
-
text={`route — ${route.href}`}
|
|
63
|
-
testID={ROUTE_TEST_ID}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
66
|
-
if (route.status === "deferred")
|
|
67
|
-
return (
|
|
68
|
-
<ResultLine
|
|
69
|
-
glyph="↻"
|
|
70
|
-
ok
|
|
71
|
-
text={`route — ${route.href} after reload`}
|
|
72
|
-
testID={ROUTE_TEST_ID}
|
|
73
|
-
/>
|
|
74
|
-
);
|
|
75
|
-
return (
|
|
76
|
-
<ResultLine
|
|
77
|
-
glyph="✕"
|
|
78
|
-
ok={false}
|
|
79
|
-
text={`route — ${route.error ?? route.status}`}
|
|
80
|
-
testID={ROUTE_TEST_ID}
|
|
81
|
-
/>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function RestoreOutcomeCard({ outcome }: { outcome: RestoreOutcome }) {
|
|
86
|
-
const entries = Object.entries(outcome.results);
|
|
87
|
-
const reloading = outcome.mode === "reload" || outcome.willReload;
|
|
88
|
-
|
|
89
|
-
return (
|
|
90
|
-
<View style={styles.card} testID="time-machine-outcome">
|
|
91
|
-
<View style={styles.headerRow}>
|
|
92
|
-
<Text style={styles.title} numberOfLines={1} testID="time-machine-outcome-title">
|
|
93
|
-
Restored “{outcome.snapshotName}”{reloading ? " · reloading" : ""}
|
|
94
|
-
</Text>
|
|
95
|
-
<TouchableOpacity
|
|
96
|
-
onPress={() => timeMachineStore.clearLastOutcome()}
|
|
97
|
-
hitSlop={HIT}
|
|
98
|
-
accessibilityRole="button"
|
|
99
|
-
accessibilityLabel="Dismiss restore result"
|
|
100
|
-
testID="time-machine-outcome-dismiss"
|
|
101
|
-
>
|
|
102
|
-
<X size={13} color={tm.color.labelSecondary} />
|
|
103
|
-
</TouchableOpacity>
|
|
104
|
-
</View>
|
|
105
|
-
|
|
106
|
-
{entries.length === 0 && !outcome.route ? (
|
|
107
|
-
<Text style={styles.empty} testID="time-machine-outcome-empty">
|
|
108
|
-
No sources restored.
|
|
109
|
-
</Text>
|
|
110
|
-
) : null}
|
|
111
|
-
|
|
112
|
-
{outcome.route ? <RouteResult route={outcome.route} /> : null}
|
|
113
|
-
|
|
114
|
-
{entries.map(([sourceId, result]) => (
|
|
115
|
-
<View key={sourceId}>
|
|
116
|
-
<ResultLine
|
|
117
|
-
testID={`time-machine-outcome-${sourceId}`}
|
|
118
|
-
glyph={result.ok ? "✓" : "✕"}
|
|
119
|
-
ok={result.ok}
|
|
120
|
-
text={`${sourceId} — applied ${result.applied}${
|
|
121
|
-
result.skipped.length > 0
|
|
122
|
-
? `, skipped ${result.skipped.length}`
|
|
123
|
-
: ""
|
|
124
|
-
}`}
|
|
125
|
-
/>
|
|
126
|
-
{result.skipped.slice(0, 3).map((skip) => (
|
|
127
|
-
<Text key={skip.id} style={styles.detail} numberOfLines={2}>
|
|
128
|
-
{skip.id}: {skip.reason}
|
|
129
|
-
</Text>
|
|
130
|
-
))}
|
|
131
|
-
{result.warnings.slice(0, 3).map((warning) => (
|
|
132
|
-
<Text key={warning} style={styles.detail} numberOfLines={2}>
|
|
133
|
-
⚠ {warning}
|
|
134
|
-
</Text>
|
|
135
|
-
))}
|
|
136
|
-
</View>
|
|
137
|
-
))}
|
|
138
|
-
</View>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const styles = StyleSheet.create({
|
|
143
|
-
card: {
|
|
144
|
-
marginHorizontal: tm.gutter,
|
|
145
|
-
marginTop: 12,
|
|
146
|
-
backgroundColor: tm.color.group,
|
|
147
|
-
borderRadius: tm.radius.group,
|
|
148
|
-
padding: 14,
|
|
149
|
-
gap: 4,
|
|
150
|
-
},
|
|
151
|
-
headerRow: {
|
|
152
|
-
flexDirection: "row",
|
|
153
|
-
alignItems: "center",
|
|
154
|
-
gap: 10,
|
|
155
|
-
marginBottom: 2,
|
|
156
|
-
},
|
|
157
|
-
title: {
|
|
158
|
-
flex: 1,
|
|
159
|
-
color: tm.color.label,
|
|
160
|
-
fontSize: 15,
|
|
161
|
-
fontWeight: "600",
|
|
162
|
-
letterSpacing: tm.tracking.control,
|
|
163
|
-
},
|
|
164
|
-
line: {
|
|
165
|
-
fontSize: tm.font.footnote,
|
|
166
|
-
lineHeight: tm.font.footnote * 1.4,
|
|
167
|
-
},
|
|
168
|
-
glyph: {
|
|
169
|
-
fontWeight: "700",
|
|
170
|
-
},
|
|
171
|
-
detail: {
|
|
172
|
-
color: tm.color.labelTertiary,
|
|
173
|
-
fontSize: tm.font.mono,
|
|
174
|
-
lineHeight: tm.font.mono * 1.4,
|
|
175
|
-
paddingLeft: 14,
|
|
176
|
-
},
|
|
177
|
-
empty: {
|
|
178
|
-
color: tm.color.labelSecondary,
|
|
179
|
-
fontSize: tm.font.footnote,
|
|
180
|
-
},
|
|
181
|
-
});
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One restore point in the list: what it is, when it was taken, how big it
|
|
3
|
-
* is, where it was captured — and the single action worth having at arm's
|
|
4
|
-
* reach. Tapping the row opens the restore preview; the pill restores now.
|
|
5
|
-
*
|
|
6
|
-
* The route is shown as context — where this state was true. Whether a
|
|
7
|
-
* restore also navigates there is decided in the restore preview, beside the
|
|
8
|
-
* other changes it is about to make.
|
|
9
|
-
*
|
|
10
|
-
* Duplicate and delete live behind a swipe. A destructive target beside
|
|
11
|
-
* "Restore" is a mis-tap that costs a snapshot, and the two-step gesture is
|
|
12
|
-
* deliberate enough — recovery is the undo the list offers after, not a
|
|
13
|
-
* dialog before. Renaming is NOT here: it belongs with the restore point's
|
|
14
|
-
* other settings, on the preview screen.
|
|
15
|
-
*/
|
|
16
|
-
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
17
|
-
import { PillLabel } from "../../ui/primitives";
|
|
18
|
-
import { SwipeActionsRow } from "../../ui/SwipeActionsRow";
|
|
19
|
-
import { TM_MONO, tm } from "../../ui/tmTheme";
|
|
20
|
-
import type { SnapshotMeta } from "../../store/snapshotVault";
|
|
21
|
-
import { formatBytes, timeAgo } from "../format";
|
|
22
|
-
|
|
23
|
-
/** "15h ago · 9.5 KB · 5 sources · skips 2" */
|
|
24
|
-
function describe(meta: SnapshotMeta): string {
|
|
25
|
-
if (meta.baseline) return "Wipes to fresh install";
|
|
26
|
-
const parts = [
|
|
27
|
-
timeAgo(meta.createdAt),
|
|
28
|
-
formatBytes(meta.sizeBytes),
|
|
29
|
-
`${Object.keys(meta.sources).length} sources`,
|
|
30
|
-
];
|
|
31
|
-
// A saved selection is worth stating on the row — this restore point no
|
|
32
|
-
// longer does the obvious thing, and one tap on RESTORE honors it.
|
|
33
|
-
if (meta.excludedKeys?.length) parts.push(`skips ${meta.excludedKeys.length}`);
|
|
34
|
-
else if (meta.scope?.length) parts.push(`scoped (${meta.scope.length})`);
|
|
35
|
-
return parts.join(" · ");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function SnapshotRow({
|
|
39
|
-
meta,
|
|
40
|
-
busy,
|
|
41
|
-
onOpen,
|
|
42
|
-
onRestore,
|
|
43
|
-
onDelete,
|
|
44
|
-
onDuplicate,
|
|
45
|
-
}: {
|
|
46
|
-
meta: SnapshotMeta;
|
|
47
|
-
busy: boolean;
|
|
48
|
-
/** Zero-arg: a data-taking `onPress` prop is invoked with the event by
|
|
49
|
-
* Buoy MCP's tap_element, which crashes the row. */
|
|
50
|
-
onOpen: () => void;
|
|
51
|
-
onRestore: () => void;
|
|
52
|
-
onDelete: () => void;
|
|
53
|
-
onDuplicate: () => void;
|
|
54
|
-
}) {
|
|
55
|
-
const testID = `time-machine-snap-${meta.id}`;
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<SwipeActionsRow
|
|
59
|
-
testID={testID}
|
|
60
|
-
actions={[
|
|
61
|
-
// "Dup" over "Copy": on a row full of text, "Copy" reads as
|
|
62
|
-
// copy-to-clipboard.
|
|
63
|
-
{ label: "Dup", onPress: onDuplicate },
|
|
64
|
-
{
|
|
65
|
-
label: "Delete",
|
|
66
|
-
onPress: onDelete,
|
|
67
|
-
background: tm.color.danger,
|
|
68
|
-
color: tm.color.onDanger,
|
|
69
|
-
},
|
|
70
|
-
]}
|
|
71
|
-
>
|
|
72
|
-
<TouchableOpacity
|
|
73
|
-
style={styles.row}
|
|
74
|
-
onPress={onOpen}
|
|
75
|
-
activeOpacity={0.7}
|
|
76
|
-
accessibilityRole="button"
|
|
77
|
-
accessibilityLabel={`${meta.name}. ${describe(meta)}. Open restore preview`}
|
|
78
|
-
testID={testID}
|
|
79
|
-
>
|
|
80
|
-
<View style={styles.text}>
|
|
81
|
-
<Text style={styles.name} numberOfLines={1} testID={`${testID}-name`}>
|
|
82
|
-
{meta.name}
|
|
83
|
-
</Text>
|
|
84
|
-
<Text style={styles.meta} numberOfLines={1} testID={`${testID}-meta`}>
|
|
85
|
-
{describe(meta)}
|
|
86
|
-
</Text>
|
|
87
|
-
{meta.lastRestoredAt ? (
|
|
88
|
-
<Text
|
|
89
|
-
style={styles.restored}
|
|
90
|
-
numberOfLines={1}
|
|
91
|
-
testID={`${testID}-restored`}
|
|
92
|
-
>
|
|
93
|
-
Restored {timeAgo(meta.lastRestoredAt)}
|
|
94
|
-
</Text>
|
|
95
|
-
) : null}
|
|
96
|
-
{meta.route ? (
|
|
97
|
-
<Text
|
|
98
|
-
style={styles.route}
|
|
99
|
-
numberOfLines={1}
|
|
100
|
-
testID={`${testID}-route`}
|
|
101
|
-
>
|
|
102
|
-
{meta.route.href}
|
|
103
|
-
</Text>
|
|
104
|
-
) : null}
|
|
105
|
-
</View>
|
|
106
|
-
<TouchableOpacity
|
|
107
|
-
onPress={onRestore}
|
|
108
|
-
disabled={busy}
|
|
109
|
-
style={busy ? styles.actionDisabled : null}
|
|
110
|
-
accessibilityRole="button"
|
|
111
|
-
accessibilityLabel={meta.baseline ? "Wipe and reload" : "Restore"}
|
|
112
|
-
testID={`${testID}-restore`}
|
|
113
|
-
>
|
|
114
|
-
<PillLabel
|
|
115
|
-
testID={`${testID}-restore-label`}
|
|
116
|
-
label={meta.baseline ? "Wipe" : "Restore"}
|
|
117
|
-
color={meta.baseline ? tm.color.danger : tm.color.accent}
|
|
118
|
-
background={meta.baseline ? tm.color.dangerSoft : tm.color.accentSoft}
|
|
119
|
-
/>
|
|
120
|
-
</TouchableOpacity>
|
|
121
|
-
</TouchableOpacity>
|
|
122
|
-
</SwipeActionsRow>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const styles = StyleSheet.create({
|
|
127
|
-
row: {
|
|
128
|
-
flexDirection: "row",
|
|
129
|
-
alignItems: "center",
|
|
130
|
-
gap: 10,
|
|
131
|
-
paddingHorizontal: tm.textInset,
|
|
132
|
-
paddingVertical: 11,
|
|
133
|
-
},
|
|
134
|
-
text: {
|
|
135
|
-
flex: 1,
|
|
136
|
-
minWidth: 0,
|
|
137
|
-
},
|
|
138
|
-
name: {
|
|
139
|
-
color: tm.color.label,
|
|
140
|
-
fontSize: tm.font.row,
|
|
141
|
-
fontWeight: "600",
|
|
142
|
-
letterSpacing: tm.tracking.row,
|
|
143
|
-
},
|
|
144
|
-
meta: {
|
|
145
|
-
color: tm.color.labelSecondary,
|
|
146
|
-
fontSize: tm.font.footnote,
|
|
147
|
-
marginTop: 2,
|
|
148
|
-
fontVariant: ["tabular-nums"],
|
|
149
|
-
},
|
|
150
|
-
restored: {
|
|
151
|
-
color: tm.color.accent,
|
|
152
|
-
fontSize: tm.font.footnote,
|
|
153
|
-
marginTop: 2,
|
|
154
|
-
},
|
|
155
|
-
route: {
|
|
156
|
-
color: tm.color.labelTertiary,
|
|
157
|
-
fontFamily: TM_MONO,
|
|
158
|
-
fontSize: tm.font.mono,
|
|
159
|
-
marginTop: 4,
|
|
160
|
-
},
|
|
161
|
-
actionDisabled: {
|
|
162
|
-
opacity: 0.4,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sources — one horizontally scrollable line of what a capture will pick up.
|
|
3
|
-
*
|
|
4
|
-
* Each source is its own tile (icon over name) rather than a count behind a
|
|
5
|
-
* disclosure: "is Redux wired?" is a glance question, and the answer should
|
|
6
|
-
* not cost a screen. A source that can't fully participate dims and carries a
|
|
7
|
-
* status dot, with the reason spelled out underneath the strip.
|
|
8
|
-
*/
|
|
9
|
-
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
|
10
|
-
import { tm } from "../../ui/tmTheme";
|
|
11
|
-
import { sourceIcon, type SourceStatus } from "../sources";
|
|
12
|
-
|
|
13
|
-
function statusDotColor(source: SourceStatus): string | null {
|
|
14
|
-
if (!source.canCapture) return tm.color.labelTertiary; // not detected
|
|
15
|
-
if (!source.canRestore) return tm.color.warning; // capture only
|
|
16
|
-
return null; // ready — nothing to flag
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function SourceTile({ source }: { source: SourceStatus }) {
|
|
20
|
-
const Icon = sourceIcon(source.id);
|
|
21
|
-
const dot = statusDotColor(source);
|
|
22
|
-
return (
|
|
23
|
-
<View
|
|
24
|
-
style={styles.tile}
|
|
25
|
-
testID={`time-machine-source-${source.id}`}
|
|
26
|
-
accessibilityLabel={`${source.label}: ${
|
|
27
|
-
source.canCapture
|
|
28
|
-
? source.canRestore
|
|
29
|
-
? "capture and restore"
|
|
30
|
-
: "capture only"
|
|
31
|
-
: "not detected"
|
|
32
|
-
}`}
|
|
33
|
-
>
|
|
34
|
-
<View style={[styles.disc, !source.canCapture ? styles.discOff : null]}>
|
|
35
|
-
{/* Disc color in, so the icon's cut-outs blend with it. */}
|
|
36
|
-
<Icon size={26} bgColor={tm.color.fill} />
|
|
37
|
-
{dot ? <View style={[styles.dot, { backgroundColor: dot }]} /> : null}
|
|
38
|
-
</View>
|
|
39
|
-
<Text
|
|
40
|
-
style={[styles.label, !source.canCapture ? styles.labelOff : null]}
|
|
41
|
-
numberOfLines={1}
|
|
42
|
-
testID={`time-machine-source-${source.id}-label`}
|
|
43
|
-
>
|
|
44
|
-
{source.label}
|
|
45
|
-
</Text>
|
|
46
|
-
</View>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function SourcesStrip({ sources }: { sources: SourceStatus[] }) {
|
|
51
|
-
return (
|
|
52
|
-
<View style={styles.card}>
|
|
53
|
-
<ScrollView
|
|
54
|
-
horizontal
|
|
55
|
-
showsHorizontalScrollIndicator={false}
|
|
56
|
-
contentContainerStyle={styles.strip}
|
|
57
|
-
testID="time-machine-sources"
|
|
58
|
-
>
|
|
59
|
-
{sources.map((source) => (
|
|
60
|
-
<SourceTile key={source.id} source={source} />
|
|
61
|
-
))}
|
|
62
|
-
{sources.length === 0 ? (
|
|
63
|
-
<Text style={styles.empty} testID="time-machine-sources-empty">
|
|
64
|
-
No state tools installed.
|
|
65
|
-
</Text>
|
|
66
|
-
) : null}
|
|
67
|
-
</ScrollView>
|
|
68
|
-
</View>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const styles = StyleSheet.create({
|
|
73
|
-
card: {
|
|
74
|
-
marginHorizontal: tm.gutter,
|
|
75
|
-
backgroundColor: tm.color.group,
|
|
76
|
-
borderRadius: tm.radius.group,
|
|
77
|
-
paddingVertical: 12,
|
|
78
|
-
},
|
|
79
|
-
strip: {
|
|
80
|
-
flexDirection: "row",
|
|
81
|
-
alignItems: "flex-start",
|
|
82
|
-
paddingHorizontal: tm.textInset,
|
|
83
|
-
gap: 18,
|
|
84
|
-
},
|
|
85
|
-
tile: {
|
|
86
|
-
alignItems: "center",
|
|
87
|
-
gap: 7,
|
|
88
|
-
// Fixed width so the row reads as a grid, and wide enough for the
|
|
89
|
-
// longest source name on ONE line: a second line would be blank for
|
|
90
|
-
// every other tile while still setting the card's height.
|
|
91
|
-
width: 78,
|
|
92
|
-
},
|
|
93
|
-
disc: {
|
|
94
|
-
width: 50,
|
|
95
|
-
height: 50,
|
|
96
|
-
borderRadius: tm.radius.pill,
|
|
97
|
-
backgroundColor: tm.color.fill,
|
|
98
|
-
alignItems: "center",
|
|
99
|
-
justifyContent: "center",
|
|
100
|
-
},
|
|
101
|
-
discOff: {
|
|
102
|
-
opacity: 0.35,
|
|
103
|
-
},
|
|
104
|
-
dot: {
|
|
105
|
-
position: "absolute",
|
|
106
|
-
top: 2,
|
|
107
|
-
right: 2,
|
|
108
|
-
width: 10,
|
|
109
|
-
height: 10,
|
|
110
|
-
borderRadius: 5,
|
|
111
|
-
// Ring it in the card color so it reads as a badge, not a smudge.
|
|
112
|
-
borderWidth: 2,
|
|
113
|
-
borderColor: tm.color.group,
|
|
114
|
-
},
|
|
115
|
-
label: {
|
|
116
|
-
color: tm.color.labelSecondary,
|
|
117
|
-
fontSize: tm.font.mono,
|
|
118
|
-
textAlign: "center",
|
|
119
|
-
},
|
|
120
|
-
labelOff: {
|
|
121
|
-
color: tm.color.labelTertiary,
|
|
122
|
-
},
|
|
123
|
-
empty: {
|
|
124
|
-
color: tm.color.labelSecondary,
|
|
125
|
-
fontSize: tm.font.footnote,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The way back from a swipe-delete.
|
|
3
|
-
*
|
|
4
|
-
* Swipe-then-tap is deliberate, which is why there's no confirmation dialog —
|
|
5
|
-
* but deliberate and recoverable are different properties, and a restore
|
|
6
|
-
* point can stand for state that took ten minutes and a cooperative backend
|
|
7
|
-
* to build. The record is held in memory until this bar expires.
|
|
8
|
-
*/
|
|
9
|
-
import { useEffect } from "react";
|
|
10
|
-
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
11
|
-
import { tm } from "../../ui/tmTheme";
|
|
12
|
-
|
|
13
|
-
/** Long enough to notice and react, short enough not to linger. */
|
|
14
|
-
const WINDOW_MS = 8000;
|
|
15
|
-
|
|
16
|
-
export function UndoDeleteBar({
|
|
17
|
-
name,
|
|
18
|
-
at,
|
|
19
|
-
onUndo,
|
|
20
|
-
onExpire,
|
|
21
|
-
}: {
|
|
22
|
-
name: string;
|
|
23
|
-
/** Restarts the countdown when a second delete replaces the first. */
|
|
24
|
-
at: number;
|
|
25
|
-
onUndo: () => void;
|
|
26
|
-
onExpire: () => void;
|
|
27
|
-
}) {
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
const timer = setTimeout(onExpire, WINDOW_MS);
|
|
30
|
-
return () => clearTimeout(timer);
|
|
31
|
-
// `at` keyed rather than `onExpire`: a new delete must restart the clock.
|
|
32
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
-
}, [at]);
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<View style={styles.bar} testID="time-machine-undo-bar">
|
|
37
|
-
<Text style={styles.text} numberOfLines={1} testID="time-machine-undo-text">
|
|
38
|
-
Deleted “{name}”
|
|
39
|
-
</Text>
|
|
40
|
-
<TouchableOpacity
|
|
41
|
-
onPress={onUndo}
|
|
42
|
-
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
43
|
-
accessibilityRole="button"
|
|
44
|
-
testID="time-machine-undo-delete"
|
|
45
|
-
>
|
|
46
|
-
<Text style={styles.undo}>Undo</Text>
|
|
47
|
-
</TouchableOpacity>
|
|
48
|
-
</View>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const styles = StyleSheet.create({
|
|
53
|
-
bar: {
|
|
54
|
-
flexDirection: "row",
|
|
55
|
-
alignItems: "center",
|
|
56
|
-
gap: 12,
|
|
57
|
-
marginHorizontal: tm.gutter,
|
|
58
|
-
marginTop: 12,
|
|
59
|
-
minHeight: 46,
|
|
60
|
-
paddingHorizontal: tm.textInset,
|
|
61
|
-
backgroundColor: tm.color.group,
|
|
62
|
-
borderRadius: tm.radius.group,
|
|
63
|
-
},
|
|
64
|
-
text: {
|
|
65
|
-
flex: 1,
|
|
66
|
-
color: tm.color.labelSecondary,
|
|
67
|
-
fontSize: tm.font.control,
|
|
68
|
-
letterSpacing: tm.tracking.control,
|
|
69
|
-
},
|
|
70
|
-
undo: {
|
|
71
|
-
color: tm.color.accent,
|
|
72
|
-
fontSize: tm.font.control,
|
|
73
|
-
fontWeight: "600",
|
|
74
|
-
letterSpacing: tm.tracking.control,
|
|
75
|
-
},
|
|
76
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Wipe All App State…" — the destructive utility, deliberately last, alone
|
|
3
|
-
* in its own card and with no icon competing for attention.
|
|
4
|
-
*/
|
|
5
|
-
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
6
|
-
import { tm } from "../../ui/tmTheme";
|
|
7
|
-
|
|
8
|
-
export function WipeAllRow({
|
|
9
|
-
disabled,
|
|
10
|
-
onWipe,
|
|
11
|
-
}: {
|
|
12
|
-
disabled: boolean;
|
|
13
|
-
onWipe: () => void;
|
|
14
|
-
}) {
|
|
15
|
-
return (
|
|
16
|
-
<TouchableOpacity
|
|
17
|
-
style={[styles.row, disabled ? styles.disabled : null]}
|
|
18
|
-
onPress={onWipe}
|
|
19
|
-
disabled={disabled}
|
|
20
|
-
accessibilityRole="button"
|
|
21
|
-
testID="time-machine-wipe-all"
|
|
22
|
-
>
|
|
23
|
-
<Text style={styles.label}>Wipe All App State…</Text>
|
|
24
|
-
</TouchableOpacity>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const styles = StyleSheet.create({
|
|
29
|
-
row: {
|
|
30
|
-
minHeight: tm.rowMinHeight,
|
|
31
|
-
alignItems: "center",
|
|
32
|
-
justifyContent: "center",
|
|
33
|
-
},
|
|
34
|
-
disabled: {
|
|
35
|
-
opacity: 0.4,
|
|
36
|
-
},
|
|
37
|
-
label: {
|
|
38
|
-
color: tm.color.danger,
|
|
39
|
-
fontSize: tm.font.row,
|
|
40
|
-
letterSpacing: tm.tracking.row,
|
|
41
|
-
},
|
|
42
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One preview item's before/after, rendered INLINE under its row rather than
|
|
3
|
-
* on a screen of its own — "what changed in this key" is a glance question,
|
|
4
|
-
* and losing the list to answer it costs you the place you were reading.
|
|
5
|
-
*
|
|
6
|
-
* Split view only, with no view switcher. Side-by-side is the shape that
|
|
7
|
-
* answers the question here ("what is it now, what will it become"), and
|
|
8
|
-
* inside a list row the segmented control cost more vertical space than the
|
|
9
|
-
* diff it was switching.
|
|
10
|
-
*
|
|
11
|
-
* The render-layer viewer only handles plain JSON faithfully, so both sides
|
|
12
|
-
* pass through toDiffable (Dates→ISO, Maps→objects, BigInt→"1n", cycles→
|
|
13
|
-
* "[Circular]") and the pair is memoized ONCE per item — the storage tool's
|
|
14
|
-
* fix for the fresh-identity-every-render trap that redux/zustand still have.
|
|
15
|
-
*
|
|
16
|
-
* No scroller of its own: this sits inside the preview's FlatList, so the
|
|
17
|
-
* viewer renders at natural height with `scrollEnabled={false}` (the storage
|
|
18
|
-
* matched-pair contract).
|
|
19
|
-
*/
|
|
20
|
-
import { useMemo } from "react";
|
|
21
|
-
import { StyleSheet, Text, View } from "react-native";
|
|
22
|
-
import type { StateSnapshotPreviewItem } from "@buoy-gg/shared-ui";
|
|
23
|
-
import {
|
|
24
|
-
ABSENT_VALUE,
|
|
25
|
-
ThemedSplitView,
|
|
26
|
-
devToolsDefaultTheme,
|
|
27
|
-
toDiffable,
|
|
28
|
-
} from "@buoy-gg/shared-ui/dataViewer";
|
|
29
|
-
import { tm } from "../../ui/tmTheme";
|
|
30
|
-
|
|
31
|
-
const EMPTY: unknown[] = [];
|
|
32
|
-
|
|
33
|
-
export function ItemDiffPanel({
|
|
34
|
-
item,
|
|
35
|
-
testID,
|
|
36
|
-
}: {
|
|
37
|
-
item: StateSnapshotPreviewItem;
|
|
38
|
-
testID?: string;
|
|
39
|
-
}) {
|
|
40
|
-
// ABSENT_VALUE keeps added/removed reading as pure additions/removals
|
|
41
|
-
// instead of diffs against the literal "null".
|
|
42
|
-
const { oldValue, newValue } = useMemo(
|
|
43
|
-
() => ({
|
|
44
|
-
oldValue:
|
|
45
|
-
item.verdict === "added" ? ABSENT_VALUE : toDiffable(item.oldValue),
|
|
46
|
-
newValue:
|
|
47
|
-
item.verdict === "removed" ? ABSENT_VALUE : toDiffable(item.newValue),
|
|
48
|
-
}),
|
|
49
|
-
[item]
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<View style={styles.panel} testID={testID}>
|
|
54
|
-
{item.reason ? <Text style={styles.reason}>⚠ {item.reason}</Text> : null}
|
|
55
|
-
<ThemedSplitView
|
|
56
|
-
oldValue={oldValue}
|
|
57
|
-
newValue={newValue}
|
|
58
|
-
differences={EMPTY}
|
|
59
|
-
theme={devToolsDefaultTheme}
|
|
60
|
-
scrollEnabled={false}
|
|
61
|
-
/>
|
|
62
|
-
</View>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const styles = StyleSheet.create({
|
|
67
|
-
// Same fill as the rows above it: this is part of the card, not a thing
|
|
68
|
-
// floating under it.
|
|
69
|
-
panel: {
|
|
70
|
-
// Only a token indent: the viewer lays values out at their natural width
|
|
71
|
-
// and a 51pt inset (matching the checkbox column) clipped long numbers.
|
|
72
|
-
paddingLeft: 16,
|
|
73
|
-
paddingRight: 12,
|
|
74
|
-
paddingTop: 10,
|
|
75
|
-
paddingBottom: 14,
|
|
76
|
-
gap: 10,
|
|
77
|
-
backgroundColor: tm.color.group,
|
|
78
|
-
},
|
|
79
|
-
reason: {
|
|
80
|
-
color: tm.color.warning,
|
|
81
|
-
fontSize: tm.font.footnote,
|
|
82
|
-
},
|
|
83
|
-
});
|