@buoy-gg/time-machine 7.0.21 → 7.0.23

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 (174) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +148 -583
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/components/TimeMachineModal.js +10 -11
  4. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  5. package/lib/commonjs/components/confirm.js +39 -0
  6. package/lib/commonjs/components/confirm.js.map +1 -0
  7. package/lib/commonjs/components/format.js +24 -0
  8. package/lib/commonjs/components/format.js.map +1 -0
  9. package/lib/commonjs/components/list/CaptureBar.js +95 -0
  10. package/lib/commonjs/components/list/CaptureBar.js.map +1 -0
  11. package/lib/commonjs/components/list/RestoreOutcomeCard.js +162 -0
  12. package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -0
  13. package/lib/commonjs/components/list/SnapshotRow.js +141 -0
  14. package/lib/commonjs/components/list/SnapshotRow.js.map +1 -0
  15. package/lib/commonjs/components/list/SourcesStrip.js +126 -0
  16. package/lib/commonjs/components/list/SourcesStrip.js.map +1 -0
  17. package/lib/commonjs/components/list/UndoDeleteBar.js +81 -0
  18. package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -0
  19. package/lib/commonjs/components/list/WipeAllRow.js +46 -0
  20. package/lib/commonjs/components/list/WipeAllRow.js.map +1 -0
  21. package/lib/commonjs/components/preview/ItemDiffPanel.js +77 -0
  22. package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -0
  23. package/lib/commonjs/components/preview/PreviewFlow.js +51 -0
  24. package/lib/commonjs/components/preview/PreviewFlow.js.map +1 -0
  25. package/lib/commonjs/components/preview/RestorePointNameRow.js +83 -0
  26. package/lib/commonjs/components/preview/RestorePointNameRow.js.map +1 -0
  27. package/lib/commonjs/components/preview/RestorePreviewScreen.js +549 -635
  28. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
  29. package/lib/commonjs/components/preview/verdictMeta.js +8 -8
  30. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -1
  31. package/lib/commonjs/components/sources.js +72 -0
  32. package/lib/commonjs/components/sources.js.map +1 -0
  33. package/lib/commonjs/index.js +55 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/store/routeRestore.js +188 -0
  36. package/lib/commonjs/store/routeRestore.js.map +1 -0
  37. package/lib/commonjs/store/snapshotVault.js +93 -9
  38. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  39. package/lib/commonjs/store/timeMachineStore.js +164 -7
  40. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  41. package/lib/commonjs/sync/timeMachineSyncAdapter.js +59 -4
  42. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  43. package/lib/commonjs/ui/SwipeActionsRow.js +167 -0
  44. package/lib/commonjs/ui/SwipeActionsRow.js.map +1 -0
  45. package/lib/commonjs/ui/primitives.js +164 -0
  46. package/lib/commonjs/ui/primitives.js.map +1 -0
  47. package/lib/commonjs/ui/tmTheme.js +103 -0
  48. package/lib/commonjs/ui/tmTheme.js.map +1 -0
  49. package/lib/module/components/TimeMachineBody.js +151 -586
  50. package/lib/module/components/TimeMachineBody.js.map +1 -1
  51. package/lib/module/components/TimeMachineModal.js +11 -12
  52. package/lib/module/components/TimeMachineModal.js.map +1 -1
  53. package/lib/module/components/confirm.js +34 -0
  54. package/lib/module/components/confirm.js.map +1 -0
  55. package/lib/module/components/format.js +19 -0
  56. package/lib/module/components/format.js.map +1 -0
  57. package/lib/module/components/list/CaptureBar.js +90 -0
  58. package/lib/module/components/list/CaptureBar.js.map +1 -0
  59. package/lib/module/components/list/RestoreOutcomeCard.js +156 -0
  60. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -0
  61. package/lib/module/components/list/SnapshotRow.js +138 -0
  62. package/lib/module/components/list/SnapshotRow.js.map +1 -0
  63. package/lib/module/components/list/SourcesStrip.js +121 -0
  64. package/lib/module/components/list/SourcesStrip.js.map +1 -0
  65. package/lib/module/components/list/UndoDeleteBar.js +78 -0
  66. package/lib/module/components/list/UndoDeleteBar.js.map +1 -0
  67. package/lib/module/components/list/WipeAllRow.js +41 -0
  68. package/lib/module/components/list/WipeAllRow.js.map +1 -0
  69. package/lib/module/components/preview/ItemDiffPanel.js +72 -0
  70. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -0
  71. package/lib/module/components/preview/PreviewFlow.js +46 -0
  72. package/lib/module/components/preview/PreviewFlow.js.map +1 -0
  73. package/lib/module/components/preview/RestorePointNameRow.js +78 -0
  74. package/lib/module/components/preview/RestorePointNameRow.js.map +1 -0
  75. package/lib/module/components/preview/RestorePreviewScreen.js +552 -638
  76. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  77. package/lib/module/components/preview/verdictMeta.js +9 -8
  78. package/lib/module/components/preview/verdictMeta.js.map +1 -1
  79. package/lib/module/components/sources.js +66 -0
  80. package/lib/module/components/sources.js.map +1 -0
  81. package/lib/module/index.js +11 -0
  82. package/lib/module/index.js.map +1 -1
  83. package/lib/module/store/routeRestore.js +176 -0
  84. package/lib/module/store/routeRestore.js.map +1 -0
  85. package/lib/module/store/snapshotVault.js +87 -9
  86. package/lib/module/store/snapshotVault.js.map +1 -1
  87. package/lib/module/store/timeMachineStore.js +164 -8
  88. package/lib/module/store/timeMachineStore.js.map +1 -1
  89. package/lib/module/sync/timeMachineSyncAdapter.js +59 -4
  90. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  91. package/lib/module/ui/SwipeActionsRow.js +162 -0
  92. package/lib/module/ui/SwipeActionsRow.js.map +1 -0
  93. package/lib/module/ui/primitives.js +156 -0
  94. package/lib/module/ui/primitives.js.map +1 -0
  95. package/lib/module/ui/tmTheme.js +99 -0
  96. package/lib/module/ui/tmTheme.js.map +1 -0
  97. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  98. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  99. package/lib/typescript/components/confirm.d.ts +2 -0
  100. package/lib/typescript/components/confirm.d.ts.map +1 -0
  101. package/lib/typescript/components/format.d.ts +4 -0
  102. package/lib/typescript/components/format.d.ts.map +1 -0
  103. package/lib/typescript/components/list/CaptureBar.d.ts +6 -0
  104. package/lib/typescript/components/list/CaptureBar.d.ts.map +1 -0
  105. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts +11 -0
  106. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -0
  107. package/lib/typescript/components/list/SnapshotRow.d.ts +12 -0
  108. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -0
  109. package/lib/typescript/components/list/SourcesStrip.d.ts +5 -0
  110. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -0
  111. package/lib/typescript/components/list/UndoDeleteBar.d.ts +8 -0
  112. package/lib/typescript/components/list/UndoDeleteBar.d.ts.map +1 -0
  113. package/lib/typescript/components/list/WipeAllRow.d.ts +5 -0
  114. package/lib/typescript/components/list/WipeAllRow.d.ts.map +1 -0
  115. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +5 -0
  116. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -0
  117. package/lib/typescript/components/preview/PreviewFlow.d.ts +9 -0
  118. package/lib/typescript/components/preview/PreviewFlow.d.ts.map +1 -0
  119. package/lib/typescript/components/preview/RestorePointNameRow.d.ts +5 -0
  120. package/lib/typescript/components/preview/RestorePointNameRow.d.ts.map +1 -0
  121. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +1 -5
  122. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  123. package/lib/typescript/components/preview/verdictMeta.d.ts +5 -0
  124. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -1
  125. package/lib/typescript/components/sources.d.ts +36 -0
  126. package/lib/typescript/components/sources.d.ts.map +1 -0
  127. package/lib/typescript/index.d.ts +2 -1
  128. package/lib/typescript/index.d.ts.map +1 -1
  129. package/lib/typescript/store/routeRestore.d.ts +51 -0
  130. package/lib/typescript/store/routeRestore.d.ts.map +1 -0
  131. package/lib/typescript/store/snapshotVault.d.ts +55 -0
  132. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  133. package/lib/typescript/store/timeMachineStore.d.ts +64 -2
  134. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  135. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +25 -0
  136. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  137. package/lib/typescript/ui/SwipeActionsRow.d.ts +32 -0
  138. package/lib/typescript/ui/SwipeActionsRow.d.ts.map +1 -0
  139. package/lib/typescript/ui/primitives.d.ts +57 -0
  140. package/lib/typescript/ui/primitives.d.ts.map +1 -0
  141. package/lib/typescript/ui/tmTheme.d.ts +67 -0
  142. package/lib/typescript/ui/tmTheme.d.ts.map +1 -0
  143. package/package.json +12 -8
  144. package/src/components/TimeMachineBody.tsx +184 -682
  145. package/src/components/TimeMachineModal.tsx +10 -11
  146. package/src/components/confirm.ts +35 -0
  147. package/src/components/format.ts +17 -0
  148. package/src/components/list/CaptureBar.tsx +100 -0
  149. package/src/components/list/RestoreOutcomeCard.tsx +159 -0
  150. package/src/components/list/SnapshotRow.tsx +155 -0
  151. package/src/components/list/SourcesStrip.tsx +124 -0
  152. package/src/components/list/UndoDeleteBar.tsx +76 -0
  153. package/src/components/list/WipeAllRow.tsx +42 -0
  154. package/src/components/preview/ItemDiffPanel.tsx +77 -0
  155. package/src/components/preview/PreviewFlow.tsx +70 -0
  156. package/src/components/preview/RestorePointNameRow.tsx +80 -0
  157. package/src/components/preview/RestorePreviewScreen.tsx +535 -535
  158. package/src/components/preview/verdictMeta.ts +8 -8
  159. package/src/components/sources.ts +92 -0
  160. package/src/index.tsx +22 -0
  161. package/src/store/routeRestore.ts +194 -0
  162. package/src/store/snapshotVault.ts +138 -9
  163. package/src/store/timeMachineStore.ts +195 -5
  164. package/src/sync/timeMachineSyncAdapter.ts +57 -4
  165. package/src/ui/SwipeActionsRow.tsx +199 -0
  166. package/src/ui/primitives.tsx +157 -0
  167. package/src/ui/tmTheme.ts +107 -0
  168. package/lib/commonjs/components/preview/ItemDetailScreen.js +0 -189
  169. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +0 -1
  170. package/lib/module/components/preview/ItemDetailScreen.js +0 -184
  171. package/lib/module/components/preview/ItemDetailScreen.js.map +0 -1
  172. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +0 -11
  173. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +0 -1
  174. package/src/components/preview/ItemDetailScreen.tsx +0 -172
@@ -1,192 +1,110 @@
1
1
  /**
2
- * Time Machine body — snapshot list, capture bar, provider status strip and
3
- * the Restore Preview flow (tap a snapshot row → item-level diff of what a
4
- * restore would change; tap an item → tree/split detail).
2
+ * Time Machine body — the screen router plus the restore-point list.
5
3
  *
6
- * Shared by the RN modal and the inline (desktop) tool view. The host must
7
- * NOT wrap this in a scroll view — each screen owns its scrolling (the
8
- * preview is a virtualized FlatList; wrapping it would defeat that).
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.
9
13
  */
10
14
  import { useState } from "react";
11
- import {
12
- Alert,
13
- Platform,
14
- ScrollView,
15
- StyleSheet,
16
- Text,
17
- TextInput,
18
- TouchableOpacity,
19
- View,
20
- } from "react-native";
21
- import {
22
- Plus,
23
- EventPickerModal,
24
- Home,
25
- Trash2,
26
- buoyColors,
27
- } from "@buoy-gg/shared-ui";
28
- import {
29
- JotaiIcon,
30
- QueryIcon,
31
- ReduxIcon,
32
- StorageIcon,
33
- TimeMachineIcon,
34
- ZustandIcon,
35
- } from "@buoy-gg/floating-tools-core";
36
- import type {
37
- EventPickerItem,
38
- StateSnapshotPreviewItem,
39
- } from "@buoy-gg/shared-ui";
15
+ import { ScrollView, StyleSheet, Text, View } from "react-native";
16
+ import { useSafeAreaInsets, useLimit } from "@buoy-gg/shared-ui";
40
17
  import { useToolHost } from "@buoy-gg/core";
41
18
  import { useIsPro } from "@buoy-gg/license";
42
- import { useSnapshotProviders, useTimeMachineState } from "../hooks/useTimeMachine";
19
+ import { useTimeMachineState } from "../hooks/useTimeMachine";
43
20
  import { timeMachineStore } from "../store/timeMachineStore";
44
- import type { PreviewSideRef, RestoreOutcome } from "../store/timeMachineStore";
45
21
  import type { SnapshotMeta } from "../store/snapshotVault";
46
- import { RestorePreviewScreen } from "./preview/RestorePreviewScreen";
47
- import { ItemDetailScreen } from "./preview/ItemDetailScreen";
48
-
49
- const FREE_SNAPSHOT_LIMIT = 2;
50
-
51
- const SOURCE_ICONS: Record<
52
- string,
53
- React.ComponentType<{ size?: number; color?: string }>
54
- > = {
55
- storage: StorageIcon,
56
- redux: ReduxIcon,
57
- zustand: ZustandIcon,
58
- jotai: JotaiIcon,
59
- query: QueryIcon,
60
- };
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";
61
36
 
62
- function timeAgo(ts: number): string {
63
- const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
64
- if (s < 60) return `${s}s ago`;
65
- const m = Math.round(s / 60);
66
- if (m < 60) return `${m}m ago`;
67
- const h = Math.round(m / 60);
68
- if (h < 24) return `${h}h ago`;
69
- return `${Math.round(h / 24)}d ago`;
70
- }
37
+ type Screen = { kind: "list" } | { kind: "preview" };
71
38
 
72
- function formatBytes(bytes: number): string {
73
- if (bytes < 1024) return `${bytes} B`;
74
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
75
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
76
- }
39
+ export function TimeMachineBody() {
40
+ const [screen, setScreen] = useState<Screen>({ kind: "list" });
41
+ const { snapshots, busy, preview } = useTimeMachineState();
42
+ const sources = useSourceStatuses();
77
43
 
78
- function warningCount(meta: SnapshotMeta): number {
79
- return Object.values(meta.sources).reduce(
80
- (sum, s) => sum + s.warnings.length,
81
- 0
82
- );
83
- }
44
+ const backToList = () => {
45
+ timeMachineStore.clearPreview();
46
+ setScreen({ kind: "list" });
47
+ };
84
48
 
85
- /** RN-web's Alert renders no buttons — fall back to the browser dialog there. */
86
- function confirmDestructive(
87
- title: string,
88
- message: string,
89
- confirmLabel: string,
90
- onConfirm: () => void
91
- ) {
92
- if (Platform.OS === "web") {
93
- const confirmFn = (globalThis as { confirm?: (msg: string) => boolean }).confirm;
94
- if (!confirmFn || confirmFn(`${title}\n\n${message}`)) onConfirm();
95
- return;
96
- }
97
- Alert.alert(title, message, [
98
- { text: "Cancel", style: "cancel" },
99
- { text: confirmLabel, style: "destructive", onPress: onConfirm },
100
- ]);
101
- }
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
+ );
102
60
 
103
- function OutcomePanel({ outcome }: { outcome: RestoreOutcome }) {
104
- const entries = Object.entries(outcome.results);
105
61
  return (
106
- <View style={styles.outcomePanel}>
107
- <View style={styles.outcomeHeaderRow}>
108
- <Text style={styles.outcomeTitle}>
109
- Restored “{outcome.snapshotName}”
110
- {outcome.mode === "reload" || outcome.willReload ? " · reloading" : ""}
111
- </Text>
112
- <TouchableOpacity
113
- onPress={() => timeMachineStore.clearLastOutcome()}
114
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
115
- >
116
- <Text style={styles.outcomeDismiss}>✕</Text>
117
- </TouchableOpacity>
118
- </View>
119
- {entries.length === 0 && (
120
- <Text style={styles.outcomeLine}>no sources restored</Text>
121
- )}
122
- {entries.map(([sourceId, r]) => (
123
- <View key={sourceId} style={styles.outcomeSource}>
124
- <Text style={[styles.outcomeLine, !r.ok && styles.outcomeLineBad]}>
125
- {r.ok ? "✓" : "✕"} {sourceId} — applied {r.applied}
126
- {r.skipped.length > 0 ? `, skipped ${r.skipped.length}` : ""}
127
- </Text>
128
- {r.skipped.slice(0, 3).map((skip) => (
129
- <Text key={skip.id} style={styles.outcomeDetail}>
130
- · {skip.id}: {skip.reason}
131
- </Text>
132
- ))}
133
- {r.warnings.slice(0, 3).map((warning) => (
134
- <Text key={warning} style={styles.outcomeDetail}>
135
- ⚠ {warning}
136
- </Text>
137
- ))}
138
- </View>
139
- ))}
140
- </View>
62
+ <RestorePointList
63
+ sources={sources}
64
+ onOpenPreview={(meta) => {
65
+ void timeMachineStore.buildPreview({ snapshotId: meta.id });
66
+ setScreen({ kind: "preview" });
67
+ }}
68
+ />
141
69
  );
142
70
  }
143
71
 
144
- type Screen =
145
- | { kind: "list" }
146
- | { kind: "preview" }
147
- | {
148
- kind: "item";
149
- sourceId: string;
150
- sourceLabel: string;
151
- item: StateSnapshotPreviewItem;
152
- };
153
-
154
- export function TimeMachineBody() {
155
- const { snapshots, hasLoaded, busy, lastOutcome, preview, remoteProviders } =
72
+ function RestorePointList({
73
+ sources,
74
+ onOpenPreview,
75
+ }: {
76
+ sources: SourceStatus[];
77
+ onOpenPreview: (meta: SnapshotMeta) => void;
78
+ }) {
79
+ const { snapshots, hasLoaded, busy, lastOutcome, recentlyDeleted } =
156
80
  useTimeMachineState();
157
- const localProviders = useSnapshotProviders();
158
81
  const isPro = useIsPro();
82
+ const snapshotLimit = useLimit("timeMachine.snapshots");
159
83
  const host = useToolHost();
160
- const [name, setName] = useState("");
161
- const [screen, setScreen] = useState<Screen>({ kind: "list" });
162
- const [picker, setPicker] = useState<"left" | "right" | null>(null);
84
+ const insets = useSafeAreaInsets({ minBottom: 16 });
163
85
 
164
- // Header search (when hosted by the core shell) filters the snapshot list.
165
- const listQuery = host.search.trim().toLowerCase();
166
- const visibleSnapshots = listQuery
167
- ? snapshots.filter((s) => s.name.toLowerCase().includes(listQuery))
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))
168
90
  : snapshots;
169
91
 
170
- // On-device: live registry probes. Buoy Desktop: statuses mirrored from
171
- // the device's sync payload.
172
- const providers =
173
- remoteProviders ??
174
- localProviders.map((p) => ({ id: p.id, label: p.label, ...p.status() }));
175
-
176
- const atFreeLimit = !isPro && snapshots.length >= FREE_SNAPSHOT_LIMIT;
177
- const captureDisabled = busy !== null || atFreeLimit;
178
-
179
- const capture = () => {
180
- if (captureDisabled) return;
181
- void timeMachineStore.capture(name);
182
- setName("");
183
- };
184
-
185
- const restore = (
186
- meta: SnapshotMeta,
187
- mode: "live" | "reload",
188
- excludeKeys?: string[]
189
- ) => {
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) => {
190
108
  if (busy) return;
191
109
  if (meta.baseline) {
192
110
  confirmDestructive(
@@ -197,538 +115,122 @@ export function TimeMachineBody() {
197
115
  );
198
116
  return;
199
117
  }
200
- void timeMachineStore.restore(meta.id, mode, excludeKeys);
201
- };
202
-
203
- const remove = (meta: SnapshotMeta) => {
204
- confirmDestructive(
205
- "Delete snapshot?",
206
- `“${meta.name}” cannot be recovered.`,
207
- "Delete",
208
- () => void timeMachineStore.delete(meta.id)
209
- );
210
- };
211
-
212
- const wipeAll = () => {
213
- if (busy) return;
214
- confirmDestructive(
215
- "Wipe all app state?",
216
- "This deletes all app storage and reloads the app — like a fresh install.",
217
- "Wipe & reload",
218
- () => void timeMachineStore.wipeAll()
219
- );
220
- };
221
-
222
- const openPreview = (meta: SnapshotMeta) => {
223
- void timeMachineStore.buildPreview({ snapshotId: meta.id });
224
- setScreen({ kind: "preview" });
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
+ });
225
123
  };
226
124
 
227
- const closePreview = () => {
228
- timeMachineStore.clearPreview();
229
- setScreen({ kind: "list" });
230
- };
231
-
232
- // ── Preview / item screens ──
233
- if (screen.kind === "item" && preview) {
234
- return (
235
- <ItemDetailScreen
236
- sourceLabel={screen.sourceLabel}
237
- item={screen.item}
238
- leftLabel={
239
- preview.left === "current"
240
- ? "CURRENT"
241
- : snapshots.find(
242
- (s) => s.id === (preview.left as { snapshotId: string }).snapshotId
243
- )?.name ?? "snapshot"
244
- }
245
- rightLabel={
246
- preview.right === "current"
247
- ? "CURRENT"
248
- : snapshots.find(
249
- (s) =>
250
- s.id === (preview.right as { snapshotId: string }).snapshotId
251
- )?.name ?? "snapshot"
252
- }
253
- onBack={() => setScreen({ kind: "preview" })}
254
- />
255
- );
256
- }
257
-
258
- if (screen.kind === "preview" && preview) {
259
- const rightMeta =
260
- preview.right === "current"
261
- ? undefined
262
- : snapshots.find(
263
- (s) => s.id === (preview.right as { snapshotId: string }).snapshotId
264
- );
265
-
266
- const pickerItems: EventPickerItem[] = [];
267
- if (picker) {
268
- const otherRef = picker === "left" ? preview.right : preview.left;
269
- const otherId =
270
- otherRef === "current" ? null : (otherRef as { snapshotId: string }).snapshotId;
271
- if (picker === "left") {
272
- pickerItems.push({
273
- index: 0,
274
- label: "Current state",
275
- timestamp: "live",
276
- relativeTime: "now",
277
- selected: preview.left === "current",
278
- });
279
- }
280
- snapshots.forEach((s, i) => {
281
- const ref = { snapshotId: s.id };
282
- const current = picker === "left" ? preview.left : preview.right;
283
- pickerItems.push({
284
- index: picker === "left" ? i + 1 : i,
285
- label: s.baseline ? `⌂ ${s.name}` : s.name,
286
- timestamp: new Date(s.createdAt).toLocaleTimeString(),
287
- relativeTime: timeAgo(s.createdAt),
288
- disabled: s.id === otherId || (picker === "right" && s.baseline),
289
- selected:
290
- current !== "current" &&
291
- (current as { snapshotId: string }).snapshotId === s.id,
292
- });
293
- });
294
- }
295
-
296
- const onPickerSelect = (index: number) => {
297
- const side = picker;
298
- setPicker(null);
299
- if (!side) return;
300
- let ref: PreviewSideRef;
301
- if (side === "left") {
302
- ref = index === 0 ? "current" : { snapshotId: snapshots[index - 1].id };
303
- void timeMachineStore.buildPreview(preview.right, ref);
304
- } else {
305
- ref = { snapshotId: snapshots[index].id };
306
- void timeMachineStore.buildPreview(ref, preview.left);
307
- }
308
- };
309
-
310
- return (
311
- <View style={styles.screenFill}>
312
- <RestorePreviewScreen
313
- preview={preview}
314
- snapshots={snapshots}
315
- busy={busy !== null}
316
- onBack={closePreview}
317
- onOpenItem={(sourceId, item) => {
318
- const source = preview.sources.find((s) => s.id === sourceId);
319
- setScreen({
320
- kind: "item",
321
- sourceId,
322
- sourceLabel: source?.label ?? sourceId,
323
- item,
324
- });
325
- }}
326
- onRestore={(mode, excludeKeys) => {
327
- if (!rightMeta || busy) return;
328
- if (rightMeta.baseline) {
329
- confirmDestructive(
330
- "Restore fresh install?",
331
- "This wipes all app storage and reloads the app.",
332
- "Wipe & reload",
333
- () => void timeMachineStore.restore(rightMeta.id, "reload")
334
- );
335
- return;
336
- }
337
- // Land back on the list, where the outcome panel reports the
338
- // results — the preview's diff is stale the moment restore runs.
339
- void timeMachineStore
340
- .restore(rightMeta.id, mode, excludeKeys)
341
- .then(() => closePreview());
342
- }}
343
- onPickSide={setPicker}
344
- onRefresh={() =>
345
- void timeMachineStore.buildPreview(preview.right, preview.left)
346
- }
347
- />
348
- {picker && (
349
- <EventPickerModal
350
- isOpen
351
- onClose={() => setPicker(null)}
352
- title={picker === "left" ? "Compare from" : "Compare to"}
353
- position={picker}
354
- items={pickerItems}
355
- onSelect={onPickerSelect}
356
- />
357
- )}
358
- </View>
359
- );
360
- }
361
-
362
- // ── List screen ──
363
- const unavailable = providers.filter(
364
- (p) => p.detail && (!p.canCapture || !p.canRestore)
365
- );
366
-
367
125
  return (
368
- <ScrollView style={styles.screenFill} contentContainerStyle={styles.container}>
369
- {lastOutcome && <OutcomePanel outcome={lastOutcome} />}
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}
370
143
 
371
- {/* Capture card */}
372
- <View style={styles.captureCard}>
373
- <View style={styles.captureField}>
374
- <Plus size={14} color={buoyColors.textMuted} />
375
- <TextInput
376
- style={styles.captureInput}
377
- value={name}
378
- onChangeText={setName}
379
- placeholder="Name this restore point…"
380
- placeholderTextColor={buoyColors.textMuted}
381
- editable={!captureDisabled}
382
- onSubmitEditing={capture}
383
- returnKeyType="done"
384
- testID="time-machine-name-input"
385
- />
386
- </View>
387
- <TouchableOpacity
388
- style={[styles.captureButton, captureDisabled && styles.buttonDisabled]}
389
- onPress={capture}
144
+ <View>
145
+ <CaptureBar
390
146
  disabled={captureDisabled}
391
- testID="time-machine-capture"
392
- >
393
- <Text style={styles.captureButtonText}>
394
- {busy === "capture" ? "…" : "CAPTURE"}
395
- </Text>
396
- </TouchableOpacity>
397
- </View>
398
- {atFreeLimit && (
399
- <Text style={styles.freeLimitNote}>
400
- Free plan: {FREE_SNAPSHOT_LIMIT} restore points. Upgrade to Pro for
401
- unlimited snapshots.
402
- </Text>
403
- )}
404
-
405
- {/* Sources — detection at a glance */}
406
- <View style={styles.sectionRow}>
407
- <Text style={styles.sectionTitle}>Sources</Text>
408
- <View style={styles.sourceTiles}>
409
- {providers.map((p) => {
410
- const IconComp = SOURCE_ICONS[p.id] ?? TimeMachineIcon;
411
- const dotColor = !p.canCapture
412
- ? buoyColors.textMuted
413
- : p.canRestore
414
- ? buoyColors.success
415
- : buoyColors.warning;
416
- return (
417
- <View key={p.id} style={styles.sourceTile}>
418
- <IconComp size={16} />
419
- <View
420
- style={[styles.sourceTileDot, { backgroundColor: dotColor }]}
421
- />
422
- </View>
423
- );
424
- })}
425
- {providers.length === 0 && (
426
- <Text style={styles.emptyText}>none registered</Text>
427
- )}
428
- </View>
147
+ busy={busy === "capture"}
148
+ onCapture={(name) => void timeMachineStore.capture(name)}
149
+ />
429
150
  </View>
430
- {unavailable.map((p) => (
431
- <Text key={p.id} style={styles.providerDetail}>
432
- {p.label}: {p.detail}
433
- </Text>
434
- ))}
435
-
436
- {/* Restore points */}
437
- <Text style={[styles.sectionTitle, styles.sectionSpacing]}>
438
- Restore points
439
- </Text>
440
- {hasLoaded && snapshots.length === 0 && (
441
- <Text style={styles.emptyText}>
442
- No restore points yet. Set up the state you want to return to, then
443
- capture it.
444
- </Text>
445
- )}
446
- {visibleSnapshots.map((meta) => {
447
- const warnings = warningCount(meta);
448
- return (
449
- <TouchableOpacity
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
450
173
  key={meta.id}
451
- style={styles.snapshotCard}
452
- onPress={() => openPreview(meta)}
453
- testID={`time-machine-snap-${meta.id}`}
454
- >
455
- <View style={styles.snapshotIconTile}>
456
- {meta.baseline ? (
457
- <Home size={17} color={buoyColors.textSecondary} />
458
- ) : (
459
- <TimeMachineIcon size={18} />
460
- )}
461
- </View>
462
- <View style={styles.snapshotInfo}>
463
- <Text style={styles.snapshotName} numberOfLines={1}>
464
- {meta.name}
465
- </Text>
466
- <Text style={styles.snapshotMeta} numberOfLines={1}>
467
- {meta.baseline
468
- ? "wipes to fresh install"
469
- : `${timeAgo(meta.createdAt)} · ${formatBytes(meta.sizeBytes)} · ${Object.keys(meta.sources).length} sources`}
470
- {meta.scope?.length
471
- ? ` · scoped (${meta.scope.length})`
472
- : ""}
473
- {warnings > 0 ? ` · ⚠ ${warnings}` : ""}
474
- </Text>
475
- </View>
476
- {!meta.baseline ? (
477
- <TouchableOpacity
478
- style={[styles.rowAction, busy && styles.buttonDisabled]}
479
- onPress={() => restore(meta, "live")}
480
- disabled={busy !== null}
481
- accessibilityLabel="Restore"
482
- >
483
- <Text style={styles.rowActionLabel}>RESTORE</Text>
484
- </TouchableOpacity>
485
- ) : (
486
- <TouchableOpacity
487
- style={[styles.rowAction, busy && styles.buttonDisabled]}
488
- onPress={() => restore(meta, "reload")}
489
- disabled={busy !== null}
490
- accessibilityLabel="Wipe and reload"
491
- >
492
- <Text style={styles.rowActionLabelDanger}>WIPE</Text>
493
- </TouchableOpacity>
494
- )}
495
- <TouchableOpacity
496
- style={styles.rowAction}
497
- onPress={() => remove(meta)}
498
- hitSlop={{ top: 8, bottom: 8, left: 4, right: 8 }}
499
- accessibilityLabel="Delete"
500
- >
501
- <Trash2 size={14} color={buoyColors.error} />
502
- </TouchableOpacity>
503
- </TouchableOpacity>
504
- );
505
- })}
506
-
507
- {/* Destructive utility — deliberately last and understated. */}
508
- <TouchableOpacity
509
- style={[styles.wipeAllButton, busy && styles.buttonDisabled]}
510
- onPress={wipeAll}
511
- disabled={busy !== null}
512
- testID="time-machine-wipe-all"
513
- >
514
- <Trash2 size={13} color={buoyColors.error} />
515
- <Text style={styles.wipeAllText}>Wipe all app state…</Text>
516
- </TouchableOpacity>
517
- <Text style={styles.wipeAllCaption}>
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>
518
212
  Deletes all app storage and reloads — like a fresh install.
519
- </Text>
213
+ </Footnote>
520
214
  </ScrollView>
521
215
  );
522
216
  }
523
217
 
524
218
  const styles = StyleSheet.create({
525
- screenFill: {
526
- flex: 1,
527
- },
528
- container: {
529
- padding: 12,
530
- gap: 8,
531
- },
532
- captureCard: {
533
- flexDirection: "row",
534
- alignItems: "center",
535
- gap: 8,
536
- },
537
- captureField: {
538
- flex: 1,
539
- flexDirection: "row",
540
- alignItems: "center",
541
- gap: 8,
542
- backgroundColor: buoyColors.card,
543
- borderColor: buoyColors.border,
544
- borderWidth: 1,
545
- borderRadius: 10,
546
- paddingHorizontal: 10,
547
- },
548
- captureInput: {
549
- flex: 1,
550
- color: buoyColors.text,
551
- fontSize: 13,
552
- paddingVertical: 9,
553
- },
554
- captureButton: {
555
- backgroundColor: buoyColors.primary,
556
- borderRadius: 10,
557
- paddingHorizontal: 16,
558
- paddingVertical: 10,
559
- justifyContent: "center",
560
- },
561
- captureButtonText: {
562
- color: "#0A0A0A",
563
- fontSize: 12,
564
- fontWeight: "700",
565
- letterSpacing: 0.5,
566
- },
567
- buttonDisabled: {
568
- opacity: 0.4,
569
- },
570
- freeLimitNote: {
571
- color: buoyColors.warning,
572
- fontSize: 11,
573
- },
574
- sectionRow: {
575
- flexDirection: "row",
576
- alignItems: "center",
577
- justifyContent: "space-between",
578
- marginTop: 12,
579
- gap: 10,
580
- },
581
- sectionTitle: {
582
- color: buoyColors.text,
583
- fontSize: 14,
584
- fontWeight: "700",
585
- },
586
- sectionSpacing: {
587
- marginTop: 14,
588
- },
589
- sourceTiles: {
590
- flexDirection: "row",
591
- gap: 6,
592
- flexWrap: "wrap",
593
- justifyContent: "flex-end",
594
- flex: 1,
595
- },
596
- sourceTile: {
597
- width: 32,
598
- height: 32,
599
- borderRadius: 9,
600
- backgroundColor: buoyColors.card,
601
- borderColor: buoyColors.border,
602
- borderWidth: 1,
603
- alignItems: "center",
604
- justifyContent: "center",
605
- },
606
- sourceTileDot: {
607
- position: "absolute",
608
- top: 3,
609
- right: 3,
610
- width: 6,
611
- height: 6,
612
- borderRadius: 3,
613
- },
614
- providerDetail: {
615
- color: buoyColors.textMuted,
616
- fontSize: 10,
617
- marginTop: 4,
618
- },
619
- emptyText: {
620
- color: buoyColors.textMuted,
621
- fontSize: 12,
622
- marginTop: 8,
623
- },
624
- snapshotCard: {
625
- flexDirection: "row",
626
- alignItems: "center",
627
- backgroundColor: buoyColors.card,
628
- borderColor: buoyColors.border,
629
- borderWidth: 1,
630
- borderRadius: 12,
631
- paddingHorizontal: 12,
632
- paddingVertical: 10,
633
- marginTop: 8,
634
- gap: 10,
635
- },
636
- snapshotIconTile: {
637
- width: 36,
638
- height: 36,
639
- borderRadius: 10,
640
- backgroundColor: buoyColors.hover,
641
- alignItems: "center",
642
- justifyContent: "center",
643
- },
644
- snapshotInfo: {
219
+ fill: {
645
220
  flex: 1,
646
- minWidth: 0,
647
- gap: 2,
648
- },
649
- snapshotName: {
650
- color: buoyColors.text,
651
- fontSize: 14,
652
- fontWeight: "700",
653
- },
654
- snapshotMeta: {
655
- color: buoyColors.textMuted,
656
- fontSize: 10,
657
- fontVariant: ["tabular-nums"],
658
- },
659
- rowAction: {
660
- padding: 4,
661
- justifyContent: "center",
662
- alignItems: "center",
663
- },
664
- rowActionLabel: {
665
- color: buoyColors.primary,
666
- fontSize: 12,
667
- fontWeight: "600",
668
- },
669
- rowActionLabelDanger: {
670
- color: buoyColors.error,
671
- fontSize: 12,
672
- fontWeight: "600",
673
- },
674
- wipeAllButton: {
675
- flexDirection: "row",
676
- alignItems: "center",
677
- justifyContent: "center",
678
- gap: 7,
679
- borderColor: `${buoyColors.error}55`,
680
- borderWidth: 1,
681
- borderRadius: 10,
682
- paddingVertical: 10,
683
- marginTop: 18,
684
- },
685
- wipeAllText: {
686
- color: buoyColors.error,
687
- fontSize: 12.5,
688
- fontWeight: "600",
689
- },
690
- wipeAllCaption: {
691
- color: buoyColors.textMuted,
692
- fontSize: 10,
693
- textAlign: "center",
694
- marginTop: 6,
695
- },
696
- outcomePanel: {
697
- backgroundColor: buoyColors.card,
698
- borderColor: buoyColors.borderStrong,
699
- borderWidth: 1,
700
- borderRadius: 8,
701
- padding: 10,
702
- gap: 4,
703
- },
704
- outcomeHeaderRow: {
705
- flexDirection: "row",
706
- justifyContent: "space-between",
707
- alignItems: "center",
708
- },
709
- outcomeTitle: {
710
- color: buoyColors.text,
711
- fontSize: 12,
712
- fontWeight: "700",
713
- flex: 1,
714
- },
715
- outcomeDismiss: {
716
- color: buoyColors.textMuted,
717
- fontSize: 13,
718
- },
719
- outcomeSource: {
720
- gap: 2,
221
+ backgroundColor: tm.color.sheet,
721
222
  },
722
- outcomeLine: {
723
- color: buoyColors.success,
724
- fontSize: 11,
223
+ content: {
224
+ paddingTop: 14,
725
225
  },
726
- outcomeLineBad: {
727
- color: buoyColors.warning,
226
+ empty: {
227
+ color: tm.color.labelSecondary,
228
+ fontSize: tm.font.footnote,
229
+ lineHeight: tm.font.footnote * 1.4,
230
+ paddingHorizontal: tm.textInset,
231
+ paddingVertical: 16,
728
232
  },
729
- outcomeDetail: {
730
- color: buoyColors.textMuted,
731
- fontSize: 10,
732
- paddingLeft: 12,
233
+ spacer: {
234
+ height: 24,
733
235
  },
734
236
  });