@buoy-gg/time-machine 7.0.28 → 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.
Files changed (68) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +13 -1
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/components/list/RestoreOutcomeCard.js +15 -4
  4. package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -1
  5. package/lib/commonjs/components/list/SnapshotRow.js +5 -0
  6. package/lib/commonjs/components/list/SnapshotRow.js.map +1 -1
  7. package/lib/commonjs/components/list/SourcesStrip.js +2 -0
  8. package/lib/commonjs/components/list/SourcesStrip.js.map +1 -1
  9. package/lib/commonjs/components/list/UndoDeleteBar.js +2 -0
  10. package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -1
  11. package/lib/commonjs/components/preview/ItemDiffPanel.js +3 -1
  12. package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -1
  13. package/lib/commonjs/components/preview/RestorePreviewScreen.js +41 -7
  14. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
  15. package/lib/commonjs/ui/primitives.js +9 -3
  16. package/lib/commonjs/ui/primitives.js.map +1 -1
  17. package/lib/module/components/TimeMachineBody.js +13 -1
  18. package/lib/module/components/TimeMachineBody.js.map +1 -1
  19. package/lib/module/components/list/RestoreOutcomeCard.js +15 -4
  20. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -1
  21. package/lib/module/components/list/SnapshotRow.js +5 -0
  22. package/lib/module/components/list/SnapshotRow.js.map +1 -1
  23. package/lib/module/components/list/SourcesStrip.js +2 -0
  24. package/lib/module/components/list/SourcesStrip.js.map +1 -1
  25. package/lib/module/components/list/UndoDeleteBar.js +2 -0
  26. package/lib/module/components/list/UndoDeleteBar.js.map +1 -1
  27. package/lib/module/components/preview/ItemDiffPanel.js +3 -1
  28. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -1
  29. package/lib/module/components/preview/RestorePreviewScreen.js +41 -7
  30. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  31. package/lib/module/ui/primitives.js +9 -3
  32. package/lib/module/ui/primitives.js.map +1 -1
  33. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -1
  34. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -1
  35. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -1
  36. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +2 -1
  37. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -1
  38. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  39. package/lib/typescript/ui/primitives.d.ts +6 -3
  40. package/lib/typescript/ui/primitives.d.ts.map +1 -1
  41. package/package.json +10 -11
  42. package/src/components/HeaderReloadButton.tsx +0 -45
  43. package/src/components/TimeMachineBody.tsx +0 -237
  44. package/src/components/TimeMachineModal.tsx +0 -75
  45. package/src/components/confirm.ts +0 -35
  46. package/src/components/format.ts +0 -17
  47. package/src/components/list/CaptureBar.tsx +0 -100
  48. package/src/components/list/RestoreOutcomeCard.tsx +0 -159
  49. package/src/components/list/SnapshotRow.tsx +0 -155
  50. package/src/components/list/SourcesStrip.tsx +0 -124
  51. package/src/components/list/UndoDeleteBar.tsx +0 -76
  52. package/src/components/list/WipeAllRow.tsx +0 -42
  53. package/src/components/preview/ItemDiffPanel.tsx +0 -77
  54. package/src/components/preview/PreviewFlow.tsx +0 -70
  55. package/src/components/preview/RestorePointNameRow.tsx +0 -80
  56. package/src/components/preview/RestorePreviewScreen.tsx +0 -1090
  57. package/src/components/preview/verdictMeta.ts +0 -40
  58. package/src/components/sources.ts +0 -92
  59. package/src/hooks/useTimeMachine.ts +0 -26
  60. package/src/index.tsx +0 -68
  61. package/src/preset.tsx +0 -47
  62. package/src/store/routeRestore.ts +0 -194
  63. package/src/store/snapshotVault.ts +0 -274
  64. package/src/store/timeMachineStore.ts +0 -881
  65. package/src/sync/timeMachineSyncAdapter.ts +0 -241
  66. package/src/ui/SwipeActionsRow.tsx +0 -199
  67. package/src/ui/primitives.tsx +0 -157
  68. package/src/ui/tmTheme.ts +0 -109
@@ -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,237 +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
- >
130
- {/* Only when the restore did something the app itself won't show you. */}
131
- {lastOutcome && restoreNeedsAttention(lastOutcome) ? (
132
- <RestoreOutcomeCard outcome={lastOutcome} />
133
- ) : null}
134
-
135
- {recentlyDeleted ? (
136
- <UndoDeleteBar
137
- name={recentlyDeleted.stored.meta.name}
138
- at={recentlyDeleted.at}
139
- onUndo={() => void timeMachineStore.undoDelete()}
140
- onExpire={() => timeMachineStore.clearRecentlyDeleted()}
141
- />
142
- ) : null}
143
-
144
- <View>
145
- <CaptureBar
146
- disabled={captureDisabled}
147
- busy={busy === "capture"}
148
- onCapture={(name) => void timeMachineStore.capture(name)}
149
- />
150
- </View>
151
- {atCaptureLimit ? (
152
- <Footnote tone="warning">
153
- Free plan keeps {snapshotLimit} restore points. Upgrade to Pro
154
- for unlimited snapshots.
155
- </Footnote>
156
- ) : null}
157
-
158
- <SectionLabel>Sources</SectionLabel>
159
- <SourcesStrip sources={sources} />
160
- {/* A source that can't fully participate says why, right where its
161
- tile is — the strip only has room for the dot. */}
162
- {sources
163
- .filter((s) => s.detail && !(s.canCapture && s.canRestore))
164
- .map((s) => (
165
- <Footnote key={s.id}>
166
- {s.label}: {s.detail}
167
- </Footnote>
168
- ))}
169
- <SectionLabel>Restore Points</SectionLabel>
170
- <Group>
171
- {visible.map((meta) => (
172
- <SnapshotRow
173
- key={meta.id}
174
- meta={meta}
175
- busy={busy !== null}
176
- onOpen={() => onOpenPreview(meta)}
177
- onRestore={() => restore(meta)}
178
- onDelete={() => void timeMachineStore.delete(meta.id)}
179
- onDuplicate={() => void timeMachineStore.duplicate(meta.id)}
180
- />
181
- ))}
182
- {visible.length === 0 ? (
183
- <Text style={styles.empty}>
184
- {!hasLoaded
185
- ? "Loading restore points…"
186
- : snapshots.length > 0
187
- ? "No restore point matches that name."
188
- : "Set up the state you want to come back to, then capture it."}
189
- </Text>
190
- ) : null}
191
- </Group>
192
- {visible.length > 0 ? (
193
- <Footnote>Swipe a restore point to rename, copy or delete it.</Footnote>
194
- ) : null}
195
-
196
- <View style={styles.spacer} />
197
-
198
- <Group>
199
- <WipeAllRow
200
- disabled={busy !== null}
201
- onWipe={() =>
202
- confirmDestructive(
203
- "Wipe all app state?",
204
- "This deletes all app storage and reloads the app — like a fresh install.",
205
- "Wipe & reload",
206
- () => void timeMachineStore.wipeAll()
207
- )
208
- }
209
- />
210
- </Group>
211
- <Footnote centered>
212
- Deletes all app storage and reloads — like a fresh install.
213
- </Footnote>
214
- </ScrollView>
215
- );
216
- }
217
-
218
- const styles = StyleSheet.create({
219
- fill: {
220
- flex: 1,
221
- // Transparent so JsModal's ToolBackground shows through (the modal paints opaque base beneath it).
222
- backgroundColor: "transparent",
223
- },
224
- content: {
225
- paddingTop: 14,
226
- },
227
- empty: {
228
- color: tm.color.labelSecondary,
229
- fontSize: tm.font.footnote,
230
- lineHeight: tm.font.footnote * 1.4,
231
- paddingHorizontal: tm.textInset,
232
- paddingVertical: 16,
233
- },
234
- spacer: {
235
- height: 24,
236
- },
237
- });
@@ -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
- });
@@ -1,159 +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
- }: {
36
- glyph: string;
37
- text: string;
38
- ok: boolean;
39
- }) {
40
- return (
41
- <Text
42
- style={[styles.line, { color: ok ? tm.color.accent : tm.color.warning }]}
43
- numberOfLines={2}
44
- >
45
- <Text style={styles.glyph}>{glyph} </Text>
46
- {text}
47
- </Text>
48
- );
49
- }
50
-
51
- function RouteResult({ route }: { route: NonNullable<RestoreOutcome["route"]> }) {
52
- if (route.status === "navigated")
53
- return <ResultLine glyph="✓" ok text={`route — ${route.href}`} />;
54
- if (route.status === "deferred")
55
- return (
56
- <ResultLine glyph="↻" ok text={`route — ${route.href} after reload`} />
57
- );
58
- return (
59
- <ResultLine
60
- glyph="✕"
61
- ok={false}
62
- text={`route — ${route.error ?? route.status}`}
63
- />
64
- );
65
- }
66
-
67
- export function RestoreOutcomeCard({ outcome }: { outcome: RestoreOutcome }) {
68
- const entries = Object.entries(outcome.results);
69
- const reloading = outcome.mode === "reload" || outcome.willReload;
70
-
71
- return (
72
- <View style={styles.card}>
73
- <View style={styles.headerRow}>
74
- <Text style={styles.title} numberOfLines={1}>
75
- Restored “{outcome.snapshotName}”{reloading ? " · reloading" : ""}
76
- </Text>
77
- <TouchableOpacity
78
- onPress={() => timeMachineStore.clearLastOutcome()}
79
- hitSlop={HIT}
80
- accessibilityRole="button"
81
- accessibilityLabel="Dismiss restore result"
82
- >
83
- <X size={13} color={tm.color.labelSecondary} />
84
- </TouchableOpacity>
85
- </View>
86
-
87
- {entries.length === 0 && !outcome.route ? (
88
- <Text style={styles.empty}>No sources restored.</Text>
89
- ) : null}
90
-
91
- {outcome.route ? <RouteResult route={outcome.route} /> : null}
92
-
93
- {entries.map(([sourceId, result]) => (
94
- <View key={sourceId}>
95
- <ResultLine
96
- glyph={result.ok ? "✓" : "✕"}
97
- ok={result.ok}
98
- text={`${sourceId} — applied ${result.applied}${
99
- result.skipped.length > 0
100
- ? `, skipped ${result.skipped.length}`
101
- : ""
102
- }`}
103
- />
104
- {result.skipped.slice(0, 3).map((skip) => (
105
- <Text key={skip.id} style={styles.detail} numberOfLines={2}>
106
- {skip.id}: {skip.reason}
107
- </Text>
108
- ))}
109
- {result.warnings.slice(0, 3).map((warning) => (
110
- <Text key={warning} style={styles.detail} numberOfLines={2}>
111
- ⚠ {warning}
112
- </Text>
113
- ))}
114
- </View>
115
- ))}
116
- </View>
117
- );
118
- }
119
-
120
- const styles = StyleSheet.create({
121
- card: {
122
- marginHorizontal: tm.gutter,
123
- marginTop: 12,
124
- backgroundColor: tm.color.group,
125
- borderRadius: tm.radius.group,
126
- padding: 14,
127
- gap: 4,
128
- },
129
- headerRow: {
130
- flexDirection: "row",
131
- alignItems: "center",
132
- gap: 10,
133
- marginBottom: 2,
134
- },
135
- title: {
136
- flex: 1,
137
- color: tm.color.label,
138
- fontSize: 15,
139
- fontWeight: "600",
140
- letterSpacing: tm.tracking.control,
141
- },
142
- line: {
143
- fontSize: tm.font.footnote,
144
- lineHeight: tm.font.footnote * 1.4,
145
- },
146
- glyph: {
147
- fontWeight: "700",
148
- },
149
- detail: {
150
- color: tm.color.labelTertiary,
151
- fontSize: tm.font.mono,
152
- lineHeight: tm.font.mono * 1.4,
153
- paddingLeft: 14,
154
- },
155
- empty: {
156
- color: tm.color.labelSecondary,
157
- fontSize: tm.font.footnote,
158
- },
159
- });