@buoy-gg/time-machine 7.0.17 → 7.0.19

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 (69) hide show
  1. package/lib/commonjs/components/HeaderReloadButton.js +61 -0
  2. package/lib/commonjs/components/HeaderReloadButton.js.map +1 -0
  3. package/lib/commonjs/components/TimeMachineBody.js +343 -127
  4. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  5. package/lib/commonjs/components/TimeMachineModal.js +1 -0
  6. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  7. package/lib/commonjs/components/preview/ItemDetailScreen.js +189 -0
  8. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +1 -0
  9. package/lib/commonjs/components/preview/RestorePreviewScreen.js +1056 -0
  10. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -0
  11. package/lib/commonjs/components/preview/verdictMeta.js +58 -0
  12. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -0
  13. package/lib/commonjs/preset.js +11 -2
  14. package/lib/commonjs/preset.js.map +1 -1
  15. package/lib/commonjs/store/snapshotVault.js +19 -0
  16. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  17. package/lib/commonjs/store/timeMachineStore.js +298 -7
  18. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  19. package/lib/commonjs/sync/timeMachineSyncAdapter.js +62 -2
  20. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  21. package/lib/module/components/HeaderReloadButton.js +56 -0
  22. package/lib/module/components/HeaderReloadButton.js.map +1 -0
  23. package/lib/module/components/TimeMachineBody.js +345 -129
  24. package/lib/module/components/TimeMachineBody.js.map +1 -1
  25. package/lib/module/components/TimeMachineModal.js +1 -0
  26. package/lib/module/components/TimeMachineModal.js.map +1 -1
  27. package/lib/module/components/preview/ItemDetailScreen.js +184 -0
  28. package/lib/module/components/preview/ItemDetailScreen.js.map +1 -0
  29. package/lib/module/components/preview/RestorePreviewScreen.js +1051 -0
  30. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -0
  31. package/lib/module/components/preview/verdictMeta.js +53 -0
  32. package/lib/module/components/preview/verdictMeta.js.map +1 -0
  33. package/lib/module/preset.js +11 -2
  34. package/lib/module/preset.js.map +1 -1
  35. package/lib/module/store/snapshotVault.js +18 -0
  36. package/lib/module/store/snapshotVault.js.map +1 -1
  37. package/lib/module/store/timeMachineStore.js +299 -8
  38. package/lib/module/store/timeMachineStore.js.map +1 -1
  39. package/lib/module/sync/timeMachineSyncAdapter.js +62 -2
  40. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  41. package/lib/typescript/components/HeaderReloadButton.d.ts +2 -0
  42. package/lib/typescript/components/HeaderReloadButton.d.ts.map +1 -0
  43. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  44. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  45. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +11 -0
  46. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +1 -0
  47. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +15 -0
  48. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -0
  49. package/lib/typescript/components/preview/verdictMeta.d.ts +10 -0
  50. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -0
  51. package/lib/typescript/preset.d.ts +15 -3
  52. package/lib/typescript/preset.d.ts.map +1 -1
  53. package/lib/typescript/store/snapshotVault.d.ts +7 -0
  54. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  55. package/lib/typescript/store/timeMachineStore.d.ts +49 -2
  56. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  57. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +36 -0
  58. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  59. package/package.json +19 -7
  60. package/src/components/HeaderReloadButton.tsx +45 -0
  61. package/src/components/TimeMachineBody.tsx +416 -137
  62. package/src/components/TimeMachineModal.tsx +1 -0
  63. package/src/components/preview/ItemDetailScreen.tsx +172 -0
  64. package/src/components/preview/RestorePreviewScreen.tsx +1089 -0
  65. package/src/components/preview/verdictMeta.ts +40 -0
  66. package/src/preset.tsx +11 -2
  67. package/src/store/snapshotVault.ts +28 -0
  68. package/src/store/timeMachineStore.ts +330 -7
  69. package/src/sync/timeMachineSyncAdapter.ts +63 -3
@@ -0,0 +1,1089 @@
1
+ /**
2
+ * Restore Preview — "what will restoring this snapshot actually change?"
3
+ *
4
+ * ONE virtualized FlatList is the scrollable area (compare card, filters,
5
+ * source cards and item rows are all list rows) with a sticky Restore CTA
6
+ * footer — no nested scroll regions, thousands of storage keys stay cheap.
7
+ *
8
+ * Layout follows the Time Machine mockup: source sections are card
9
+ * dropdowns with tool-icon tiles, a "N keys · M changed" detection subtitle,
10
+ * ±≈ badges, a search field with an expand/collapse-all button, and a bottom
11
+ * "Restore State" pill. Only rows a restore would touch (or can't touch)
12
+ * render — unchanged items never do. Every applicable item keeps its
13
+ * INCLUDE checkbox (source headers toggle their whole section) — unchecked
14
+ * items are passed to restore as exclusions and left untouched.
15
+ */
16
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
+ import {
18
+ ActivityIndicator,
19
+ FlatList,
20
+ StyleSheet,
21
+ Text,
22
+ TextInput,
23
+ TouchableOpacity,
24
+ View,
25
+ type ListRenderItem,
26
+ } from "react-native";
27
+ import {
28
+ ChevronDown,
29
+ ChevronUp,
30
+ Filter,
31
+ RefreshCw,
32
+ Search,
33
+ buoyColors,
34
+ macOSColors,
35
+ } from "@buoy-gg/shared-ui";
36
+ import {
37
+ JotaiIcon,
38
+ QueryIcon,
39
+ ReduxIcon,
40
+ StorageIcon,
41
+ TimeMachineIcon,
42
+ ZustandIcon,
43
+ } from "@buoy-gg/floating-tools-core";
44
+ import type { StateSnapshotPreviewItem } from "@buoy-gg/shared-ui";
45
+ import { useToolHeader, useToolHost } from "@buoy-gg/core";
46
+ import type {
47
+ PreviewSideRef,
48
+ PreviewSource,
49
+ PreviewState,
50
+ } from "../../store/timeMachineStore";
51
+ import { timeMachineStore } from "../../store/timeMachineStore";
52
+ import type { SnapshotMeta } from "../../store/snapshotVault";
53
+ import { VERDICT_META } from "./verdictMeta";
54
+
55
+ const SOURCE_ICONS: Record<
56
+ string,
57
+ React.ComponentType<{ size?: number; color?: string }>
58
+ > = {
59
+ storage: StorageIcon,
60
+ redux: ReduxIcon,
61
+ zustand: ZustandIcon,
62
+ jotai: JotaiIcon,
63
+ query: QueryIcon,
64
+ };
65
+
66
+ const SOURCE_NOUNS: Record<string, string> = {
67
+ storage: "keys",
68
+ redux: "slices",
69
+ zustand: "stores",
70
+ jotai: "atoms",
71
+ query: "queries",
72
+ };
73
+
74
+ interface RowSourceHeader {
75
+ kind: "source";
76
+ source: PreviewSource;
77
+ collapsed: boolean;
78
+ includeState: "all" | "some" | "none";
79
+ applicableCount: number;
80
+ counts: { added: number; removed: number; changed: number };
81
+ /** False when the card has nothing to expand (a clean "No changes" source). */
82
+ expandable: boolean;
83
+ /** "60 keys · 7 changed" — detection proof without expanding. */
84
+ summary: string;
85
+ }
86
+ interface RowItem {
87
+ kind: "item";
88
+ sourceId: string;
89
+ item: StateSnapshotPreviewItem;
90
+ excluded: boolean;
91
+ }
92
+ interface RowNote {
93
+ kind: "note";
94
+ id: string;
95
+ text: string;
96
+ }
97
+ type Row = RowSourceHeader | RowItem | RowNote;
98
+
99
+ /** Compound exclusion key — what timeMachineStore.restore expects. */
100
+ function cKey(sourceId: string, itemKey: string): string {
101
+ return `${sourceId}::${itemKey}`;
102
+ }
103
+
104
+ function shortPreview(value: unknown): string {
105
+ if (value === undefined) return "∅";
106
+ try {
107
+ const s = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
108
+ if (!s) return String(value);
109
+ return s.length > 46 ? `${s.slice(0, 43)}…` : s;
110
+ } catch {
111
+ return "[complex]";
112
+ }
113
+ }
114
+
115
+ function sideName(ref: PreviewSideRef, snapshots: SnapshotMeta[]): string {
116
+ if (ref === "current") return "Current state";
117
+ return (
118
+ snapshots.find((s) => s.id === ref.snapshotId)?.name ?? ref.snapshotId
119
+ );
120
+ }
121
+
122
+ function timeAgo(ts: number): string {
123
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
124
+ if (s < 60) return `${s}s`;
125
+ const m = Math.round(s / 60);
126
+ if (m < 60) return `${m}m`;
127
+ const h = Math.round(m / 60);
128
+ return h < 24 ? `${h}h` : `${Math.round(h / 24)}d`;
129
+ }
130
+
131
+ function Checkbox({ state }: { state: "all" | "some" | "none" }) {
132
+ return (
133
+ <View
134
+ style={[styles.checkbox, state !== "none" && styles.checkboxChecked]}
135
+ >
136
+ {state === "all" && <Text style={styles.checkboxGlyph}>✓</Text>}
137
+ {state === "some" && <Text style={styles.checkboxGlyph}>−</Text>}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ const ItemRow = memo(function ItemRow({
143
+ row,
144
+ onOpen,
145
+ onToggle,
146
+ }: {
147
+ row: RowItem;
148
+ /** Zero-arg by design — press plumbing may invoke handlers with a
149
+ * synthetic event; the row is bound at the call site. */
150
+ onOpen: () => void;
151
+ onToggle: () => void;
152
+ }) {
153
+ const meta = VERDICT_META[row.item.verdict];
154
+ const dimmed = !meta.applies || row.excluded;
155
+ const openable =
156
+ row.item.verdict === "changed" ||
157
+ row.item.verdict === "added" ||
158
+ row.item.verdict === "removed";
159
+ return (
160
+ <View style={[styles.itemRow, dimmed && styles.dimmed]}>
161
+ {meta.applies && (
162
+ <TouchableOpacity
163
+ onPress={onToggle}
164
+ hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
165
+ testID={`tm-preview-toggle-${row.sourceId}-${row.item.key}`}
166
+ >
167
+ <Checkbox state={row.excluded ? "none" : "all"} />
168
+ </TouchableOpacity>
169
+ )}
170
+ <TouchableOpacity
171
+ style={styles.itemBodyTouch}
172
+ onPress={() => openable && onOpen()}
173
+ disabled={!openable}
174
+ testID={`tm-preview-item-${row.sourceId}-${row.item.key}`}
175
+ >
176
+ <Text style={[styles.itemGlyph, { color: meta.color }]}>
177
+ {meta.glyph}
178
+ </Text>
179
+ <View style={styles.itemBody}>
180
+ <Text style={styles.itemLabel} numberOfLines={1}>
181
+ {row.item.label}
182
+ </Text>
183
+ {row.item.verdict === "changed" && (
184
+ <Text style={styles.itemPreview} numberOfLines={1}>
185
+ {shortPreview(row.item.oldValue)} →{" "}
186
+ {shortPreview(row.item.newValue)}
187
+ </Text>
188
+ )}
189
+ {row.item.verdict === "added" && (
190
+ <Text style={styles.itemPreview} numberOfLines={1}>
191
+ {shortPreview(row.item.newValue)}
192
+ </Text>
193
+ )}
194
+ {row.item.verdict === "removed" && (
195
+ <Text
196
+ style={[styles.itemPreview, styles.itemPreviewRemoved]}
197
+ numberOfLines={1}
198
+ >
199
+ {shortPreview(row.item.oldValue)}
200
+ </Text>
201
+ )}
202
+ {row.excluded ? (
203
+ <Text style={styles.itemExcluded}>
204
+ excluded — restore leaves this untouched
205
+ </Text>
206
+ ) : (
207
+ row.item.reason && (
208
+ <Text style={styles.itemReason} numberOfLines={2}>
209
+ {row.item.reason}
210
+ </Text>
211
+ )
212
+ )}
213
+ </View>
214
+ {openable && <Text style={styles.itemChevron}>›</Text>}
215
+ </TouchableOpacity>
216
+ </View>
217
+ );
218
+ });
219
+
220
+ export interface RestorePreviewScreenProps {
221
+ preview: PreviewState;
222
+ snapshots: SnapshotMeta[];
223
+ busy: boolean;
224
+ onBack: () => void;
225
+ onOpenItem: (sourceId: string, item: StateSnapshotPreviewItem) => void;
226
+ onRestore: (mode: "live" | "reload", excludeKeys: string[]) => void;
227
+ onPickSide: (position: "left" | "right") => void;
228
+ onRefresh: () => void;
229
+ }
230
+
231
+ export function RestorePreviewScreen({
232
+ preview,
233
+ snapshots,
234
+ busy,
235
+ onBack,
236
+ onOpenItem,
237
+ onRestore,
238
+ onPickSide,
239
+ onRefresh,
240
+ }: RestorePreviewScreenProps) {
241
+ const host = useToolHost();
242
+ const hosted = host.instanceId !== undefined;
243
+ const [localSearch, setLocalSearch] = useState("");
244
+ const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
245
+ const [allCollapsed, setAllCollapsed] = useState(false);
246
+ const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
247
+ const search = localSearch;
248
+
249
+ const rightMeta =
250
+ preview.right === "current"
251
+ ? undefined
252
+ : snapshots.find(
253
+ (s) => s.id === (preview.right as { snapshotId: string }).snapshotId
254
+ );
255
+ const isBaseline = rightMeta?.baseline === true;
256
+ const liveLeft = preview.left === "current";
257
+
258
+ // Master selection: "None → tick just what you want" is the fast path for
259
+ // targeted restores ("only my cart"), the inverse of the default all-in.
260
+ const selectAll = useCallback(() => setExcluded(new Set()), []);
261
+ const selectNone = useCallback(() => {
262
+ const next = new Set<string>();
263
+ for (const src of preview.sources) {
264
+ for (const item of src.items) {
265
+ if (VERDICT_META[item.verdict].applies) next.add(cKey(src.id, item.key));
266
+ }
267
+ }
268
+ setExcluded(next);
269
+ }, [preview.sources]);
270
+
271
+ // A saved scope pre-selects the checkboxes: everything applicable that is
272
+ // OUTSIDE the scope starts excluded, so the preview shows exactly what a
273
+ // scoped restore will do (and stays adjustable).
274
+ const seededScopeFor = useRef<string | null>(null);
275
+ useEffect(() => {
276
+ if (!rightMeta?.scope?.length || preview.building) return;
277
+ if (seededScopeFor.current === rightMeta.id) return;
278
+ seededScopeFor.current = rightMeta.id;
279
+ const scopeSet = new Set(rightMeta.scope);
280
+ const next = new Set<string>();
281
+ for (const src of preview.sources) {
282
+ for (const item of src.items) {
283
+ const key = cKey(src.id, item.key);
284
+ if (VERDICT_META[item.verdict].applies && !scopeSet.has(key)) {
285
+ next.add(key);
286
+ }
287
+ }
288
+ }
289
+ setExcluded(next);
290
+ }, [rightMeta, preview.building, preview.sources]);
291
+
292
+ // Persist the current selection as this restore point's scope — from then
293
+ // on, RESTORE (home row, MCP, this screen) touches only these items.
294
+ // Selecting everything clears the scope back to a full restore.
295
+ const saveScope = useCallback(() => {
296
+ if (!rightMeta) return;
297
+ const selected: string[] = [];
298
+ for (const src of preview.sources) {
299
+ for (const item of src.items) {
300
+ const key = cKey(src.id, item.key);
301
+ if (VERDICT_META[item.verdict].applies && !excluded.has(key)) {
302
+ selected.push(key);
303
+ }
304
+ }
305
+ }
306
+ const clearing = excluded.size === 0;
307
+ void timeMachineStore.setScope(rightMeta.id, clearing ? null : selected);
308
+ host.showToast(
309
+ clearing
310
+ ? "Scope cleared — full restore"
311
+ : `Scope saved — this restore point now restores only ${selected.length} item${selected.length === 1 ? "" : "s"}`,
312
+ "success"
313
+ );
314
+ }, [rightMeta, preview.sources, excluded, host]);
315
+
316
+ const toggleItem = useCallback((key: string) => {
317
+ setExcluded((prev) => {
318
+ const next = new Set(prev);
319
+ if (next.has(key)) next.delete(key);
320
+ else next.add(key);
321
+ return next;
322
+ });
323
+ }, []);
324
+
325
+ const toggleSource = useCallback(
326
+ (source: PreviewSource, includeState: "all" | "some" | "none") => {
327
+ setExcluded((prev) => {
328
+ const next = new Set(prev);
329
+ for (const item of source.items) {
330
+ if (!VERDICT_META[item.verdict].applies) continue;
331
+ const key = cKey(source.id, item.key);
332
+ if (includeState === "all") next.add(key);
333
+ else next.delete(key);
334
+ }
335
+ return next;
336
+ });
337
+ },
338
+ []
339
+ );
340
+
341
+ // Takes the EFFECTIVE collapsed state — the map is sparse (defaults come
342
+ // from the ?? fallback), so `!map[id]` would no-op the first tap on a
343
+ // default-collapsed card.
344
+ const toggleCollapse = useCallback((id: string, currentlyCollapsed: boolean) => {
345
+ setCollapsed((c) => ({ ...c, [id]: !currentlyCollapsed }));
346
+ }, []);
347
+
348
+ const toggleAllCollapsed = useCallback(() => {
349
+ setAllCollapsed((prev) => {
350
+ const next = !prev;
351
+ setCollapsed(
352
+ Object.fromEntries(preview.sources.map((s) => [s.id, next]))
353
+ );
354
+ return next;
355
+ });
356
+ }, [preview.sources]);
357
+
358
+ const totals = useMemo(() => {
359
+ let applies = 0;
360
+ let excludedCount = 0;
361
+ let wontApply = 0;
362
+ const perSource: string[] = [];
363
+ for (const src of preview.sources) {
364
+ let n = 0;
365
+ for (const item of src.items) {
366
+ const m = VERDICT_META[item.verdict];
367
+ if (m.applies) {
368
+ if (excluded.has(cKey(src.id, item.key))) excludedCount += 1;
369
+ else {
370
+ applies += 1;
371
+ n += 1;
372
+ }
373
+ } else if (item.verdict === "wont-apply") wontApply += 1;
374
+ }
375
+ if (n > 0) perSource.push(`${n} ${src.label.toLowerCase()}`);
376
+ }
377
+ return { applies, excludedCount, wontApply, perSource };
378
+ }, [preview.sources, excluded]);
379
+
380
+ // Hosted: back + identity in the shared modal header. Restore lives in the
381
+ // bottom CTA; the preset's labeled RELOAD APP action stays available.
382
+ useToolHeader(
383
+ hosted
384
+ ? {
385
+ title: "Restore Preview",
386
+ subtitle: rightMeta?.name ?? "compare",
387
+ onBack,
388
+ searchable: false, // the in-body "Filter keys…" field covers it
389
+ }
390
+ : null,
391
+ // eslint-disable-next-line react-hooks/exhaustive-deps
392
+ [hosted, rightMeta?.name]
393
+ );
394
+
395
+ const rows = useMemo<Row[]>(() => {
396
+ const q = search.trim().toLowerCase();
397
+ const out: Row[] = [];
398
+ for (const src of preview.sources) {
399
+ // Every detected source gets a card whose subtitle carries the
400
+ // detection proof ("60 keys · 7 changed" / "2 slices · no changes"),
401
+ // so clean sources need no expansion. Unchanged ITEMS never render.
402
+ const relevant = src.items.some((i) => i.verdict !== "unchanged");
403
+ const expandable = relevant || src.notes.length > 0 || !!src.error;
404
+
405
+ const applicable = src.items.filter(
406
+ (i) => VERDICT_META[i.verdict].applies
407
+ );
408
+ const wontApplyCount = src.items.filter(
409
+ (i) => i.verdict === "wont-apply"
410
+ ).length;
411
+ const noun =
412
+ src.items.length === 1
413
+ ? (SOURCE_NOUNS[src.id] ?? "items").replace(/ies$/, "y").replace(/s$/, "")
414
+ : SOURCE_NOUNS[src.id] ?? "items";
415
+ const summary =
416
+ `${src.items.length} ${noun} · ` +
417
+ (applicable.length > 0
418
+ ? `${applicable.length} changed`
419
+ : wontApplyCount > 0
420
+ ? `${wontApplyCount} won't apply`
421
+ : "no changes");
422
+ const excludedInSource = applicable.filter((i) =>
423
+ excluded.has(cKey(src.id, i.key))
424
+ ).length;
425
+ const includeState: RowSourceHeader["includeState"] =
426
+ applicable.length === 0 || excludedInSource === 0
427
+ ? "all"
428
+ : excludedInSource === applicable.length
429
+ ? "none"
430
+ : "some";
431
+ const counts = { added: 0, removed: 0, changed: 0 };
432
+ for (const i of src.items) {
433
+ if (excluded.has(cKey(src.id, i.key))) continue;
434
+ if (i.verdict === "added") counts.added += 1;
435
+ else if (i.verdict === "removed") counts.removed += 1;
436
+ else if (i.verdict === "changed" || i.verdict === "too-large")
437
+ counts.changed += 1;
438
+ }
439
+
440
+ const visible = src.items.filter((item) => {
441
+ // Unchanged items never render — the dropdowns show only what a
442
+ // restore would touch (changed) or can't touch (won't-apply).
443
+ if (item.verdict === "unchanged") return false;
444
+ if (q && !item.label.toLowerCase().includes(q)) return false;
445
+ return true;
446
+ });
447
+ const isCollapsed = !expandable
448
+ ? true
449
+ : (collapsed[src.id] ?? (applicable.length === 0 && !src.error));
450
+ out.push({
451
+ kind: "source",
452
+ source: src,
453
+ collapsed: isCollapsed,
454
+ includeState,
455
+ applicableCount: applicable.length,
456
+ counts,
457
+ expandable,
458
+ summary,
459
+ });
460
+ if (!isCollapsed) {
461
+ for (const note of src.notes) {
462
+ out.push({ kind: "note", id: `${src.id}:${note}`, text: note });
463
+ }
464
+ if (src.error) {
465
+ out.push({ kind: "note", id: `${src.id}:err`, text: `⚠ ${src.error}` });
466
+ }
467
+ for (const item of visible) {
468
+ out.push({
469
+ kind: "item",
470
+ sourceId: src.id,
471
+ item,
472
+ excluded: excluded.has(cKey(src.id, item.key)),
473
+ });
474
+ }
475
+ }
476
+ }
477
+ return out;
478
+ }, [preview.sources, search, collapsed, excluded]);
479
+
480
+ const renderRow: ListRenderItem<Row> = useCallback(
481
+ ({ item: row }) => {
482
+ if (row.kind === "source") {
483
+ const src = row.source;
484
+ const { counts } = row;
485
+ const clean =
486
+ counts.added + counts.removed + counts.changed === 0 && !src.error;
487
+ const IconComp = SOURCE_ICONS[src.id] ?? TimeMachineIcon;
488
+ return (
489
+ <View style={styles.sourceCard}>
490
+ {row.applicableCount > 0 && (
491
+ <TouchableOpacity
492
+ onPress={() => toggleSource(src, row.includeState)}
493
+ hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
494
+ testID={`tm-preview-source-toggle-${src.id}`}
495
+ >
496
+ <Checkbox state={row.includeState} />
497
+ </TouchableOpacity>
498
+ )}
499
+ <TouchableOpacity
500
+ style={styles.sourceCardTouch}
501
+ onPress={() => row.expandable && toggleCollapse(src.id, row.collapsed)}
502
+ disabled={!row.expandable}
503
+ testID={`tm-preview-source-${src.id}`}
504
+ >
505
+ <View style={styles.sourceIconTile}>
506
+ <IconComp size={18} />
507
+ </View>
508
+ <View style={styles.sourceNameBlock}>
509
+ <Text style={styles.sourceName} numberOfLines={1}>
510
+ {src.label}
511
+ </Text>
512
+ <Text style={styles.sourceSummary} numberOfLines={1}>
513
+ {row.includeState === "none" && row.applicableCount > 0
514
+ ? `${row.summary} · excluded`
515
+ : row.summary}
516
+ </Text>
517
+ </View>
518
+ {!clean && (
519
+ <View style={styles.sourceBadges}>
520
+ {counts.removed > 0 && (
521
+ <Text style={[styles.sourceBadge, { color: buoyColors.error }]}>
522
+ −{counts.removed}
523
+ </Text>
524
+ )}
525
+ {counts.added > 0 && (
526
+ <Text style={[styles.sourceBadge, { color: buoyColors.success }]}>
527
+ +{counts.added}
528
+ </Text>
529
+ )}
530
+ {counts.changed > 0 && (
531
+ <Text style={[styles.sourceBadge, { color: buoyColors.info }]}>
532
+ ≈{counts.changed}
533
+ </Text>
534
+ )}
535
+ </View>
536
+ )}
537
+ {row.expandable ? (
538
+ row.collapsed ? (
539
+ <ChevronDown size={16} color={buoyColors.textMuted} />
540
+ ) : (
541
+ <ChevronUp size={16} color={buoyColors.textMuted} />
542
+ )
543
+ ) : null}
544
+ </TouchableOpacity>
545
+ </View>
546
+ );
547
+ }
548
+ if (row.kind === "note") {
549
+ return <Text style={styles.noteRow}>{row.text}</Text>;
550
+ }
551
+ return (
552
+ <ItemRow
553
+ row={row}
554
+ onOpen={() => onOpenItem(row.sourceId, row.item)}
555
+ onToggle={() => toggleItem(cKey(row.sourceId, row.item.key))}
556
+ />
557
+ );
558
+ },
559
+ [onOpenItem, toggleCollapse, toggleItem, toggleSource]
560
+ );
561
+
562
+ const keyExtractor = useCallback((row: Row) => {
563
+ if (row.kind === "source") return `s:${row.source.id}`;
564
+ if (row.kind === "note") return `n:${row.id}`;
565
+ return `i:${row.sourceId}:${row.item.key}`;
566
+ }, []);
567
+
568
+ const header = (
569
+ <View>
570
+ {!hosted && (
571
+ <View style={styles.fallbackHeader}>
572
+ <TouchableOpacity onPress={onBack} hitSlop={HIT} testID="tm-preview-back">
573
+ <Text style={styles.backText}>‹ Back</Text>
574
+ </TouchableOpacity>
575
+ <Text style={styles.screenTitle} numberOfLines={1}>
576
+ Restore Preview
577
+ </Text>
578
+ </View>
579
+ )}
580
+
581
+ {/* Compare card */}
582
+ <View style={styles.compareCard}>
583
+ <TouchableOpacity
584
+ style={styles.compareSide}
585
+ onPress={() => onPickSide("left")}
586
+ testID="tm-preview-pick-left"
587
+ >
588
+ <View style={styles.compareTagRow}>
589
+ <View style={[styles.stateDot, { backgroundColor: buoyColors.info }]} />
590
+ <Text style={styles.compareTag}>
591
+ {liveLeft ? "CURRENT STATE" : "SNAPSHOT"}
592
+ </Text>
593
+ </View>
594
+ <Text style={styles.compareName} numberOfLines={1}>
595
+ {sideName(preview.left, snapshots)}
596
+ </Text>
597
+ {liveLeft ? (
598
+ <View style={styles.compareMetaRow}>
599
+ <Text style={styles.compareMeta}>
600
+ built {timeAgo(preview.builtAt)} ago
601
+ </Text>
602
+ <TouchableOpacity
603
+ onPress={onRefresh}
604
+ disabled={preview.building}
605
+ hitSlop={{ top: 8, bottom: 8, left: 6, right: 8 }}
606
+ accessibilityLabel="Rebuild preview"
607
+ testID="tm-preview-refresh"
608
+ >
609
+ <RefreshCw
610
+ size={11}
611
+ color={
612
+ preview.building
613
+ ? macOSColors.text.disabled
614
+ : buoyColors.textSecondary
615
+ }
616
+ />
617
+ </TouchableOpacity>
618
+ </View>
619
+ ) : (
620
+ <Text style={styles.compareMeta}>snapshot</Text>
621
+ )}
622
+ </TouchableOpacity>
623
+
624
+ <View style={styles.swapBadge}>
625
+ <Text style={styles.swapGlyph}>⇄</Text>
626
+ </View>
627
+
628
+ <TouchableOpacity
629
+ style={[styles.compareSide, styles.compareSideRight]}
630
+ onPress={() => onPickSide("right")}
631
+ testID="tm-preview-pick-right"
632
+ >
633
+ <View style={styles.compareTagRow}>
634
+ <Text style={styles.compareTag}>
635
+ {isBaseline ? "BASELINE" : "SNAPSHOT"}
636
+ </Text>
637
+ <View
638
+ style={[styles.stateDot, { backgroundColor: buoyColors.success }]}
639
+ />
640
+ </View>
641
+ <Text style={[styles.compareName, styles.compareNameRight]} numberOfLines={1}>
642
+ {sideName(preview.right, snapshots)}
643
+ </Text>
644
+ <Text style={[styles.compareMeta, styles.compareMetaRight]}>
645
+ {rightMeta ? `${timeAgo(rightMeta.createdAt)} ago` : ""}
646
+ </Text>
647
+ </TouchableOpacity>
648
+ </View>
649
+
650
+ {/* Changes to restore + summary chip */}
651
+ <View style={styles.changesRow}>
652
+ <View style={styles.changesTitleRow}>
653
+ <Text style={styles.changesTitle}>Changes to restore</Text>
654
+ {totals.applies + totals.excludedCount > 1 && (
655
+ <View style={styles.selectLinks}>
656
+ <TouchableOpacity
657
+ onPress={selectAll}
658
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
659
+ testID="tm-preview-select-all"
660
+ >
661
+ <Text
662
+ style={[
663
+ styles.selectLink,
664
+ totals.excludedCount === 0 && styles.selectLinkActive,
665
+ ]}
666
+ >
667
+ all
668
+ </Text>
669
+ </TouchableOpacity>
670
+ <Text style={styles.selectLinkSep}>·</Text>
671
+ <TouchableOpacity
672
+ onPress={selectNone}
673
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
674
+ testID="tm-preview-select-none"
675
+ >
676
+ <Text
677
+ style={[
678
+ styles.selectLink,
679
+ totals.applies === 0 &&
680
+ totals.excludedCount > 0 &&
681
+ styles.selectLinkActive,
682
+ ]}
683
+ >
684
+ none
685
+ </Text>
686
+ </TouchableOpacity>
687
+ {liveLeft && rightMeta && (
688
+ <>
689
+ <Text style={styles.selectLinkSep}>·</Text>
690
+ <TouchableOpacity
691
+ onPress={saveScope}
692
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
693
+ testID="tm-preview-save-scope"
694
+ >
695
+ <Text
696
+ style={[
697
+ styles.selectLink,
698
+ !!rightMeta.scope?.length && styles.selectLinkActive,
699
+ ]}
700
+ >
701
+ save scope
702
+ </Text>
703
+ </TouchableOpacity>
704
+ </>
705
+ )}
706
+ </View>
707
+ )}
708
+ </View>
709
+ {preview.building ? (
710
+ <ActivityIndicator size="small" color={buoyColors.primary} />
711
+ ) : (
712
+ <View style={styles.changesChip}>
713
+ <Text style={styles.changesChipText} numberOfLines={1}>
714
+ {totals.applies === 0
715
+ ? totals.excludedCount > 0
716
+ ? "everything excluded"
717
+ : "no changes"
718
+ : totals.perSource.join(" · ")}
719
+ {totals.excludedCount > 0 && totals.applies > 0
720
+ ? ` · ${totals.excludedCount} excluded`
721
+ : ""}
722
+ </Text>
723
+ </View>
724
+ )}
725
+ </View>
726
+ {totals.wontApply > 0 && (
727
+ <Text style={styles.wontApplyLine}>
728
+ ⊘ {totals.wontApply} item{totals.wontApply === 1 ? "" : "s"} won't
729
+ apply — shown inline with the reason
730
+ </Text>
731
+ )}
732
+
733
+ {/* Search + expand/collapse all */}
734
+ <View style={styles.searchRow}>
735
+ <View style={styles.searchField}>
736
+ <Search size={14} color={buoyColors.textMuted} />
737
+ <TextInput
738
+ style={styles.searchInput}
739
+ value={localSearch}
740
+ onChangeText={setLocalSearch}
741
+ placeholder="Filter keys…"
742
+ placeholderTextColor={buoyColors.textMuted}
743
+ autoCorrect={false}
744
+ autoCapitalize="none"
745
+ testID="tm-preview-search"
746
+ />
747
+ </View>
748
+ <TouchableOpacity
749
+ style={styles.collapseAllButton}
750
+ onPress={toggleAllCollapsed}
751
+ accessibilityLabel={allCollapsed ? "Expand all" : "Collapse all"}
752
+ testID="tm-preview-collapse-all"
753
+ >
754
+ <Filter size={15} color={buoyColors.textSecondary} />
755
+ </TouchableOpacity>
756
+ </View>
757
+ </View>
758
+ );
759
+
760
+ const ctaDisabled = busy || preview.building || totals.applies === 0;
761
+
762
+ return (
763
+ <View style={styles.container}>
764
+ <FlatList
765
+ data={rows}
766
+ renderItem={renderRow}
767
+ keyExtractor={keyExtractor}
768
+ ListHeaderComponent={header}
769
+ ListEmptyComponent={
770
+ preview.building ? null : (
771
+ <Text style={styles.emptyText}>
772
+ {search
773
+ ? "Nothing matches this filter."
774
+ : "No changes — restoring would change nothing."}
775
+ </Text>
776
+ )
777
+ }
778
+ removeClippedSubviews
779
+ initialNumToRender={20}
780
+ windowSize={7}
781
+ contentContainerStyle={styles.listContent}
782
+ />
783
+
784
+ {/* Sticky Restore CTA */}
785
+ {liveLeft && rightMeta && (
786
+ <View style={styles.footer}>
787
+ <TouchableOpacity
788
+ style={[
789
+ styles.cta,
790
+ isBaseline && styles.ctaDanger,
791
+ ctaDisabled && styles.ctaDisabled,
792
+ ]}
793
+ onPress={() =>
794
+ onRestore(isBaseline ? "reload" : "live", Array.from(excluded))
795
+ }
796
+ disabled={isBaseline ? busy || preview.building : ctaDisabled}
797
+ testID="tm-preview-restore"
798
+ >
799
+ <RefreshCw size={16} color="#0A0A0A" />
800
+ <Text style={styles.ctaText}>
801
+ {isBaseline ? "Wipe & Reload" : "Restore State"}
802
+ </Text>
803
+ </TouchableOpacity>
804
+ <Text style={styles.ctaCaption}>
805
+ {isBaseline
806
+ ? "This wipes all app storage and reloads the app."
807
+ : totals.excludedCount > 0
808
+ ? `Restores ${totals.applies} of ${totals.applies + totals.excludedCount} change${totals.applies + totals.excludedCount === 1 ? "" : "s"} — everything else stays as it is.`
809
+ : "This will replace the current state with the snapshot."}
810
+ </Text>
811
+ </View>
812
+ )}
813
+ </View>
814
+ );
815
+ }
816
+
817
+ const HIT = { top: 10, bottom: 10, left: 10, right: 10 };
818
+
819
+ const styles = StyleSheet.create({
820
+ container: { flex: 1 },
821
+ fallbackHeader: {
822
+ flexDirection: "row",
823
+ alignItems: "center",
824
+ paddingVertical: 8,
825
+ gap: 10,
826
+ },
827
+ backText: { color: buoyColors.primary, fontSize: 14 },
828
+ screenTitle: {
829
+ color: buoyColors.text,
830
+ fontSize: 13,
831
+ fontWeight: "700",
832
+ flex: 1,
833
+ },
834
+ toolbarButton: { padding: 4, justifyContent: "center", alignItems: "center" },
835
+ dimmed: { opacity: 0.55 },
836
+ listContent: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 12 },
837
+
838
+ // Compare card
839
+ compareCard: {
840
+ flexDirection: "row",
841
+ alignItems: "center",
842
+ backgroundColor: buoyColors.card,
843
+ borderColor: buoyColors.border,
844
+ borderWidth: 1,
845
+ borderRadius: 14,
846
+ padding: 14,
847
+ gap: 10,
848
+ },
849
+ compareSide: { flex: 1, minWidth: 0, gap: 3 },
850
+ compareSideRight: { alignItems: "flex-end" },
851
+ compareTagRow: { flexDirection: "row", alignItems: "center", gap: 5 },
852
+ stateDot: { width: 6, height: 6, borderRadius: 3 },
853
+ compareTag: {
854
+ color: buoyColors.textSecondary,
855
+ fontSize: 10,
856
+ fontWeight: "700",
857
+ letterSpacing: 0.6,
858
+ },
859
+ compareName: { color: buoyColors.text, fontSize: 15, fontWeight: "700" },
860
+ compareNameRight: { textAlign: "right" },
861
+ compareMeta: { color: buoyColors.textMuted, fontSize: 10 },
862
+ compareMetaRight: { textAlign: "right" },
863
+ compareMetaRow: { flexDirection: "row", alignItems: "center", gap: 6 },
864
+ swapBadge: {
865
+ width: 34,
866
+ height: 34,
867
+ borderRadius: 17,
868
+ backgroundColor: buoyColors.hover,
869
+ borderColor: buoyColors.border,
870
+ borderWidth: 1,
871
+ alignItems: "center",
872
+ justifyContent: "center",
873
+ },
874
+ swapGlyph: { color: buoyColors.textSecondary, fontSize: 14 },
875
+
876
+ // Changes row
877
+ changesRow: {
878
+ flexDirection: "row",
879
+ alignItems: "center",
880
+ justifyContent: "space-between",
881
+ marginTop: 14,
882
+ gap: 10,
883
+ },
884
+ changesTitle: { color: buoyColors.text, fontSize: 14, fontWeight: "700" },
885
+ changesTitleRow: {
886
+ flexDirection: "row",
887
+ alignItems: "baseline",
888
+ gap: 8,
889
+ },
890
+ selectLinks: { flexDirection: "row", alignItems: "baseline", gap: 4 },
891
+ selectLink: { color: buoyColors.textMuted, fontSize: 12 },
892
+ selectLinkActive: { color: buoyColors.primary, fontWeight: "600" },
893
+ selectLinkSep: { color: buoyColors.textMuted, fontSize: 12 },
894
+ changesChip: {
895
+ backgroundColor: buoyColors.card,
896
+ borderColor: buoyColors.border,
897
+ borderWidth: 1,
898
+ borderRadius: 8,
899
+ paddingHorizontal: 10,
900
+ paddingVertical: 5,
901
+ maxWidth: "60%",
902
+ },
903
+ changesChipText: { color: buoyColors.textSecondary, fontSize: 11 },
904
+ wontApplyLine: { color: buoyColors.warning, fontSize: 11, marginTop: 6 },
905
+
906
+ // Search row
907
+ searchRow: {
908
+ flexDirection: "row",
909
+ alignItems: "center",
910
+ gap: 8,
911
+ marginTop: 10,
912
+ marginBottom: 6,
913
+ },
914
+ searchField: {
915
+ flex: 1,
916
+ flexDirection: "row",
917
+ alignItems: "center",
918
+ gap: 8,
919
+ backgroundColor: buoyColors.card,
920
+ borderColor: buoyColors.border,
921
+ borderWidth: 1,
922
+ borderRadius: 10,
923
+ paddingHorizontal: 10,
924
+ },
925
+ searchInput: {
926
+ flex: 1,
927
+ color: buoyColors.text,
928
+ fontSize: 12,
929
+ paddingVertical: 8,
930
+ },
931
+ collapseAllButton: {
932
+ width: 36,
933
+ height: 36,
934
+ borderRadius: 10,
935
+ backgroundColor: buoyColors.card,
936
+ borderColor: buoyColors.border,
937
+ borderWidth: 1,
938
+ alignItems: "center",
939
+ justifyContent: "center",
940
+ },
941
+
942
+ // Checkbox
943
+ checkbox: {
944
+ width: 18,
945
+ height: 18,
946
+ borderRadius: 4,
947
+ borderWidth: 1.5,
948
+ borderColor: buoyColors.textMuted,
949
+ alignItems: "center",
950
+ justifyContent: "center",
951
+ },
952
+ checkboxChecked: {
953
+ borderColor: buoyColors.primary,
954
+ backgroundColor: `${buoyColors.primary}26`,
955
+ },
956
+ checkboxGlyph: {
957
+ color: buoyColors.primary,
958
+ fontSize: 11,
959
+ fontWeight: "700",
960
+ lineHeight: 13,
961
+ },
962
+
963
+ // Source cards
964
+ sourceCard: {
965
+ flexDirection: "row",
966
+ alignItems: "center",
967
+ backgroundColor: buoyColors.card,
968
+ borderColor: buoyColors.border,
969
+ borderWidth: 1,
970
+ borderRadius: 12,
971
+ paddingHorizontal: 12,
972
+ paddingVertical: 10,
973
+ marginTop: 8,
974
+ gap: 10,
975
+ },
976
+ sourceCardTouch: {
977
+ flex: 1,
978
+ flexDirection: "row",
979
+ alignItems: "center",
980
+ gap: 12,
981
+ minWidth: 0,
982
+ },
983
+ sourceIconTile: {
984
+ width: 36,
985
+ height: 36,
986
+ borderRadius: 10,
987
+ backgroundColor: buoyColors.hover,
988
+ alignItems: "center",
989
+ justifyContent: "center",
990
+ },
991
+ sourceNameBlock: { flex: 1, minWidth: 0, gap: 2 },
992
+ sourceName: {
993
+ color: buoyColors.text,
994
+ fontSize: 15,
995
+ fontWeight: "700",
996
+ },
997
+ sourceSummary: {
998
+ color: buoyColors.textMuted,
999
+ fontSize: 11,
1000
+ fontVariant: ["tabular-nums"],
1001
+ },
1002
+ sourceBadges: { flexDirection: "row", gap: 8 },
1003
+ sourceBadge: {
1004
+ fontSize: 13,
1005
+ fontWeight: "700",
1006
+ fontVariant: ["tabular-nums"],
1007
+ },
1008
+
1009
+ noteRow: {
1010
+ color: buoyColors.textMuted,
1011
+ fontSize: 10,
1012
+ paddingLeft: 24,
1013
+ paddingTop: 6,
1014
+ },
1015
+
1016
+ // Item rows (nested under a source card)
1017
+ itemRow: {
1018
+ flexDirection: "row",
1019
+ alignItems: "center",
1020
+ backgroundColor: buoyColors.hover,
1021
+ borderColor: buoyColors.border,
1022
+ borderWidth: 1,
1023
+ borderRadius: 10,
1024
+ paddingHorizontal: 10,
1025
+ paddingVertical: 9,
1026
+ marginTop: 6,
1027
+ marginLeft: 14,
1028
+ gap: 10,
1029
+ },
1030
+ itemBodyTouch: {
1031
+ flex: 1,
1032
+ flexDirection: "row",
1033
+ alignItems: "center",
1034
+ gap: 8,
1035
+ minWidth: 0,
1036
+ },
1037
+ itemGlyph: { fontSize: 13, fontWeight: "700", width: 14, textAlign: "center" },
1038
+ itemBody: { flex: 1, minWidth: 0 },
1039
+ itemLabel: { color: buoyColors.text, fontSize: 12 },
1040
+ itemPreview: {
1041
+ color: buoyColors.textMuted,
1042
+ fontSize: 10,
1043
+ marginTop: 2,
1044
+ fontVariant: ["tabular-nums"],
1045
+ },
1046
+ itemPreviewRemoved: { textDecorationLine: "line-through" },
1047
+ itemReason: { color: buoyColors.warning, fontSize: 10, marginTop: 2 },
1048
+ itemExcluded: {
1049
+ color: buoyColors.textMuted,
1050
+ fontSize: 10,
1051
+ marginTop: 2,
1052
+ fontStyle: "italic",
1053
+ },
1054
+ itemChevron: { color: buoyColors.textMuted, fontSize: 16 },
1055
+ emptyText: {
1056
+ color: buoyColors.textMuted,
1057
+ fontSize: 12,
1058
+ textAlign: "center",
1059
+ marginTop: 24,
1060
+ },
1061
+
1062
+ // Footer CTA
1063
+ footer: {
1064
+ paddingHorizontal: 12,
1065
+ paddingTop: 8,
1066
+ paddingBottom: 12,
1067
+ gap: 6,
1068
+ borderTopColor: buoyColors.border,
1069
+ borderTopWidth: 1,
1070
+ backgroundColor: buoyColors.base,
1071
+ },
1072
+ cta: {
1073
+ flexDirection: "row",
1074
+ alignItems: "center",
1075
+ justifyContent: "center",
1076
+ gap: 8,
1077
+ backgroundColor: buoyColors.primary,
1078
+ borderRadius: 24,
1079
+ paddingVertical: 13,
1080
+ },
1081
+ ctaDanger: { backgroundColor: buoyColors.error },
1082
+ ctaDisabled: { opacity: 0.4 },
1083
+ ctaText: { color: "#0A0A0A", fontSize: 14, fontWeight: "700" },
1084
+ ctaCaption: {
1085
+ color: buoyColors.textMuted,
1086
+ fontSize: 10,
1087
+ textAlign: "center",
1088
+ },
1089
+ });