@buoy-gg/time-machine 7.0.29 → 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.
- package/package.json +10 -11
- package/src/components/HeaderReloadButton.tsx +0 -45
- package/src/components/TimeMachineBody.tsx +0 -246
- package/src/components/TimeMachineModal.tsx +0 -75
- package/src/components/confirm.ts +0 -35
- package/src/components/format.ts +0 -17
- package/src/components/list/CaptureBar.tsx +0 -100
- package/src/components/list/RestoreOutcomeCard.tsx +0 -181
- package/src/components/list/SnapshotRow.tsx +0 -164
- package/src/components/list/SourcesStrip.tsx +0 -127
- package/src/components/list/UndoDeleteBar.tsx +0 -76
- package/src/components/list/WipeAllRow.tsx +0 -42
- package/src/components/preview/ItemDiffPanel.tsx +0 -83
- package/src/components/preview/PreviewFlow.tsx +0 -70
- package/src/components/preview/RestorePointNameRow.tsx +0 -80
- package/src/components/preview/RestorePreviewScreen.tsx +0 -1149
- package/src/components/preview/verdictMeta.ts +0 -40
- package/src/components/sources.ts +0 -92
- package/src/hooks/useTimeMachine.ts +0 -26
- package/src/index.tsx +0 -68
- package/src/preset.tsx +0 -47
- package/src/store/routeRestore.ts +0 -194
- package/src/store/snapshotVault.ts +0 -274
- package/src/store/timeMachineStore.ts +0 -881
- package/src/sync/timeMachineSyncAdapter.ts +0 -241
- package/src/ui/SwipeActionsRow.tsx +0 -199
- package/src/ui/primitives.tsx +0 -174
- package/src/ui/tmTheme.ts +0 -109
|
@@ -1,1149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Restore Preview — "what will restoring this snapshot actually change?"
|
|
3
|
-
*
|
|
4
|
-
* ONE virtualized FlatList is the scrollable area (the section header, the
|
|
5
|
-
* source rows, their item rows and the inline diffs are all list rows) with
|
|
6
|
-
* a sticky Restore CTA footer — no nested scroll regions, so thousands of
|
|
7
|
-
* storage keys stay cheap.
|
|
8
|
-
*
|
|
9
|
-
* Layout follows the Time Machine mockup: source sections are card
|
|
10
|
-
* dropdowns with tool-icon tiles, a "N keys · M changed" detection subtitle,
|
|
11
|
-
* ±≈ badges, a search field with an expand/collapse-all button, and a bottom
|
|
12
|
-
* "Restore State" pill. Only rows a restore would touch (or can't touch)
|
|
13
|
-
* render — unchanged items never do. Every applicable item keeps its
|
|
14
|
-
* INCLUDE checkbox (source headers toggle their whole section) — unchecked
|
|
15
|
-
* items are passed to restore as exclusions and left untouched.
|
|
16
|
-
*/
|
|
17
|
-
import {
|
|
18
|
-
memo,
|
|
19
|
-
useCallback,
|
|
20
|
-
useEffect,
|
|
21
|
-
useMemo,
|
|
22
|
-
useRef,
|
|
23
|
-
useState,
|
|
24
|
-
type ReactNode,
|
|
25
|
-
} from "react";
|
|
26
|
-
import {
|
|
27
|
-
ActivityIndicator,
|
|
28
|
-
FlatList,
|
|
29
|
-
StyleSheet,
|
|
30
|
-
Text,
|
|
31
|
-
TouchableOpacity,
|
|
32
|
-
View,
|
|
33
|
-
type ListRenderItem,
|
|
34
|
-
} from "react-native";
|
|
35
|
-
import {
|
|
36
|
-
ChevronUp,
|
|
37
|
-
AlertTriangle,
|
|
38
|
-
RefreshCw,
|
|
39
|
-
useSafeAreaInsets,
|
|
40
|
-
} from "@buoy-gg/shared-ui";
|
|
41
|
-
import type { StateSnapshotPreviewItem } from "@buoy-gg/shared-ui";
|
|
42
|
-
import { useToolHeader, useToolHost } from "@buoy-gg/core";
|
|
43
|
-
import { DisclosureChevron, Group, SectionLabel } from "../../ui/primitives";
|
|
44
|
-
import { TM_MONO, tm } from "../../ui/tmTheme";
|
|
45
|
-
import type {
|
|
46
|
-
PreviewSideRef,
|
|
47
|
-
PreviewSource,
|
|
48
|
-
PreviewState,
|
|
49
|
-
} from "../../store/timeMachineStore";
|
|
50
|
-
import { timeMachineStore, willRestoreRoute } from "../../store/timeMachineStore";
|
|
51
|
-
import { ItemDiffPanel } from "./ItemDiffPanel";
|
|
52
|
-
import { RestorePointNameRow } from "./RestorePointNameRow";
|
|
53
|
-
import type { SnapshotMeta } from "../../store/snapshotVault";
|
|
54
|
-
import { VERDICT_META } from "./verdictMeta";
|
|
55
|
-
|
|
56
|
-
const SOURCE_NOUNS: Record<string, string> = {
|
|
57
|
-
storage: "keys",
|
|
58
|
-
redux: "slices",
|
|
59
|
-
zustand: "stores",
|
|
60
|
-
jotai: "atoms",
|
|
61
|
-
query: "queries",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Where a row sits in the one card that holds the whole list. The list has
|
|
66
|
-
* to stay flat to stay virtualized, so each row paints its own share of the
|
|
67
|
-
* card: the first row rounds its top, the last rounds its bottom, and every
|
|
68
|
-
* other row draws the hairline above it.
|
|
69
|
-
*/
|
|
70
|
-
interface CardPosition {
|
|
71
|
-
first: boolean;
|
|
72
|
-
last: boolean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface RowSourceHeader extends CardPosition {
|
|
76
|
-
kind: "source";
|
|
77
|
-
source: PreviewSource;
|
|
78
|
-
collapsed: boolean;
|
|
79
|
-
includeState: "all" | "some" | "none";
|
|
80
|
-
applicableCount: number;
|
|
81
|
-
counts: { added: number; removed: number; changed: number };
|
|
82
|
-
/** False when the card has nothing to expand (a clean "No changes" source). */
|
|
83
|
-
expandable: boolean;
|
|
84
|
-
/** "60 keys · 7 changed" — detection proof without expanding. */
|
|
85
|
-
summary: string;
|
|
86
|
-
}
|
|
87
|
-
interface RowItem extends CardPosition {
|
|
88
|
-
kind: "item";
|
|
89
|
-
sourceId: string;
|
|
90
|
-
item: StateSnapshotPreviewItem;
|
|
91
|
-
excluded: boolean;
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* The route leg, as a row in the same card as the sources. It is not a
|
|
95
|
-
* source — nothing diffs it — but "will this also take me back to
|
|
96
|
-
* /checkout?" is a decision about THIS restore, so it belongs beside the
|
|
97
|
-
* other things the restore is about to do rather than on the list screen.
|
|
98
|
-
*/
|
|
99
|
-
interface RowRoute extends CardPosition {
|
|
100
|
-
kind: "route";
|
|
101
|
-
href: string;
|
|
102
|
-
on: boolean;
|
|
103
|
-
}
|
|
104
|
-
interface RowDiff extends CardPosition {
|
|
105
|
-
kind: "diff";
|
|
106
|
-
sourceId: string;
|
|
107
|
-
item: StateSnapshotPreviewItem;
|
|
108
|
-
}
|
|
109
|
-
interface RowNote extends CardPosition {
|
|
110
|
-
kind: "note";
|
|
111
|
-
id: string;
|
|
112
|
-
text: string;
|
|
113
|
-
}
|
|
114
|
-
type Row = RowSourceHeader | RowItem | RowNote | RowDiff | RowRoute;
|
|
115
|
-
|
|
116
|
-
/** Shared empty selection — a stable identity for "nothing is excluded". */
|
|
117
|
-
const EMPTY_SELECTION: ReadonlySet<string> = new Set<string>();
|
|
118
|
-
|
|
119
|
-
function sameSelection(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
120
|
-
if (a.size !== b.size) return false;
|
|
121
|
-
for (const key of a) if (!b.has(key)) return false;
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Compound exclusion key — what timeMachineStore.restore expects. */
|
|
126
|
-
function cKey(sourceId: string, itemKey: string): string {
|
|
127
|
-
return `${sourceId}::${itemKey}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function shortPreview(value: unknown): string {
|
|
131
|
-
if (value === undefined) return "∅";
|
|
132
|
-
try {
|
|
133
|
-
const s = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
|
|
134
|
-
if (!s) return String(value);
|
|
135
|
-
return s.length > 46 ? `${s.slice(0, 43)}…` : s;
|
|
136
|
-
} catch {
|
|
137
|
-
return "[complex]";
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* The grouped-card shell one row paints. Rounds the ends, insets the card
|
|
145
|
-
* from the sheet, and draws the hairline that separates it from the row
|
|
146
|
-
* above — the same shape the home list's <Group> makes, rebuilt per row so
|
|
147
|
-
* the list stays virtualized.
|
|
148
|
-
*/
|
|
149
|
-
function CardRow({
|
|
150
|
-
position,
|
|
151
|
-
children,
|
|
152
|
-
}: {
|
|
153
|
-
position: CardPosition;
|
|
154
|
-
children: ReactNode;
|
|
155
|
-
}) {
|
|
156
|
-
return (
|
|
157
|
-
<View
|
|
158
|
-
style={[
|
|
159
|
-
styles.cardRow,
|
|
160
|
-
position.first ? styles.cardRowFirst : null,
|
|
161
|
-
position.last ? styles.cardRowLast : null,
|
|
162
|
-
]}
|
|
163
|
-
>
|
|
164
|
-
{position.first ? null : <View style={styles.cardSeparator} />}
|
|
165
|
-
{children}
|
|
166
|
-
</View>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function CountPill({
|
|
171
|
-
glyph,
|
|
172
|
-
count,
|
|
173
|
-
color,
|
|
174
|
-
testID,
|
|
175
|
-
}: {
|
|
176
|
-
glyph: string;
|
|
177
|
-
count: number;
|
|
178
|
-
color: string;
|
|
179
|
-
testID?: string;
|
|
180
|
-
}) {
|
|
181
|
-
return (
|
|
182
|
-
<View style={[styles.countPill, { backgroundColor: `${color}26` }]}>
|
|
183
|
-
<Text style={[styles.countPillText, { color }]} testID={testID}>
|
|
184
|
-
{glyph}
|
|
185
|
-
{count}
|
|
186
|
-
</Text>
|
|
187
|
-
</View>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function Checkbox({ state }: { state: "all" | "some" | "none" }) {
|
|
192
|
-
return (
|
|
193
|
-
<View style={[styles.checkbox, state !== "none" && styles.checkboxChecked]}>
|
|
194
|
-
{state === "none" ? null : (
|
|
195
|
-
<Text style={styles.checkboxGlyph}>{state === "all" ? "✓" : "−"}</Text>
|
|
196
|
-
)}
|
|
197
|
-
</View>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const ItemRow = memo(function ItemRow({
|
|
202
|
-
row,
|
|
203
|
-
expanded,
|
|
204
|
-
onOpen,
|
|
205
|
-
onToggle,
|
|
206
|
-
}: {
|
|
207
|
-
row: RowItem;
|
|
208
|
-
expanded: boolean;
|
|
209
|
-
/** Zero-arg by design — press plumbing may invoke handlers with a
|
|
210
|
-
* synthetic event; the row is bound at the call site. */
|
|
211
|
-
onOpen: () => void;
|
|
212
|
-
onToggle: () => void;
|
|
213
|
-
}) {
|
|
214
|
-
const meta = VERDICT_META[row.item.verdict];
|
|
215
|
-
const dimmed = !meta.applies || row.excluded;
|
|
216
|
-
const openable =
|
|
217
|
-
row.item.verdict === "changed" ||
|
|
218
|
-
row.item.verdict === "added" ||
|
|
219
|
-
row.item.verdict === "removed";
|
|
220
|
-
// One stem for the row and every part of it, so a test that found the row
|
|
221
|
-
// can read its label, its before/after and its exclusion note without
|
|
222
|
-
// rebuilding the compound key three times.
|
|
223
|
-
const id = `tm-preview-item-${row.sourceId}-${row.item.key}`;
|
|
224
|
-
return (
|
|
225
|
-
<CardRow position={row}>
|
|
226
|
-
<View style={[styles.itemRow, dimmed && styles.dimmed]}>
|
|
227
|
-
<TouchableOpacity
|
|
228
|
-
onPress={onToggle}
|
|
229
|
-
disabled={!meta.applies}
|
|
230
|
-
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
|
|
231
|
-
testID={`tm-preview-toggle-${row.sourceId}-${row.item.key}`}
|
|
232
|
-
accessibilityRole="checkbox"
|
|
233
|
-
accessibilityState={{ checked: !(!meta.applies || row.excluded) }}
|
|
234
|
-
accessibilityLabel={`Include ${row.item.label}`}
|
|
235
|
-
>
|
|
236
|
-
<Checkbox state={!meta.applies || row.excluded ? "none" : "all"} />
|
|
237
|
-
</TouchableOpacity>
|
|
238
|
-
<TouchableOpacity
|
|
239
|
-
style={styles.itemBodyTouch}
|
|
240
|
-
onPress={() => openable && onOpen()}
|
|
241
|
-
disabled={!openable}
|
|
242
|
-
testID={id}
|
|
243
|
-
>
|
|
244
|
-
<Text style={[styles.itemGlyph, { color: meta.color }]}>
|
|
245
|
-
{meta.glyph}
|
|
246
|
-
</Text>
|
|
247
|
-
<View style={styles.itemBody}>
|
|
248
|
-
<Text style={styles.itemLabel} numberOfLines={1} testID={`${id}-label`}>
|
|
249
|
-
{row.item.label}
|
|
250
|
-
</Text>
|
|
251
|
-
{row.item.verdict === "changed" && (
|
|
252
|
-
<Text
|
|
253
|
-
style={styles.itemPreview}
|
|
254
|
-
numberOfLines={1}
|
|
255
|
-
testID={`${id}-values`}
|
|
256
|
-
>
|
|
257
|
-
{shortPreview(row.item.oldValue)} →{" "}
|
|
258
|
-
{shortPreview(row.item.newValue)}
|
|
259
|
-
</Text>
|
|
260
|
-
)}
|
|
261
|
-
{row.item.verdict === "added" && (
|
|
262
|
-
<Text
|
|
263
|
-
style={styles.itemPreview}
|
|
264
|
-
numberOfLines={1}
|
|
265
|
-
testID={`${id}-values`}
|
|
266
|
-
>
|
|
267
|
-
{shortPreview(row.item.newValue)}
|
|
268
|
-
</Text>
|
|
269
|
-
)}
|
|
270
|
-
{row.item.verdict === "removed" && (
|
|
271
|
-
<Text
|
|
272
|
-
style={[styles.itemPreview, styles.itemPreviewRemoved]}
|
|
273
|
-
numberOfLines={1}
|
|
274
|
-
testID={`${id}-values`}
|
|
275
|
-
>
|
|
276
|
-
{shortPreview(row.item.oldValue)}
|
|
277
|
-
</Text>
|
|
278
|
-
)}
|
|
279
|
-
{row.excluded ? (
|
|
280
|
-
<Text style={styles.itemExcluded} testID={`${id}-excluded`}>
|
|
281
|
-
excluded — restore leaves this untouched
|
|
282
|
-
</Text>
|
|
283
|
-
) : (
|
|
284
|
-
row.item.reason && (
|
|
285
|
-
<Text
|
|
286
|
-
style={styles.itemReason}
|
|
287
|
-
numberOfLines={2}
|
|
288
|
-
testID={`${id}-reason`}
|
|
289
|
-
>
|
|
290
|
-
{row.item.reason}
|
|
291
|
-
</Text>
|
|
292
|
-
)
|
|
293
|
-
)}
|
|
294
|
-
</View>
|
|
295
|
-
{openable ? (
|
|
296
|
-
expanded ? (
|
|
297
|
-
<ChevronUp size={16} color={tm.color.labelTertiary} />
|
|
298
|
-
) : (
|
|
299
|
-
<DisclosureChevron />
|
|
300
|
-
)
|
|
301
|
-
) : null}
|
|
302
|
-
</TouchableOpacity>
|
|
303
|
-
</View>
|
|
304
|
-
</CardRow>
|
|
305
|
-
);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
export interface RestorePreviewScreenProps {
|
|
309
|
-
preview: PreviewState;
|
|
310
|
-
snapshots: SnapshotMeta[];
|
|
311
|
-
busy: boolean;
|
|
312
|
-
onBack: () => void;
|
|
313
|
-
onRestore: (mode: "live" | "reload", excludeKeys: string[]) => void;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export function RestorePreviewScreen({
|
|
317
|
-
preview,
|
|
318
|
-
snapshots,
|
|
319
|
-
busy,
|
|
320
|
-
onBack,
|
|
321
|
-
onRestore,
|
|
322
|
-
}: RestorePreviewScreenProps) {
|
|
323
|
-
const host = useToolHost();
|
|
324
|
-
const hosted = host.instanceId !== undefined;
|
|
325
|
-
// `minBottom` is a floor, not a preference: the dimension map can't know
|
|
326
|
-
// about a device that ships next year, and an unmapped one falls through to
|
|
327
|
-
// `bottom: 0`. This keeps the CTA off the home indicator either way.
|
|
328
|
-
const insets = useSafeAreaInsets({ minBottom: 16 });
|
|
329
|
-
|
|
330
|
-
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
331
|
-
const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
|
|
332
|
-
/** Set by every checkbox interaction — see the auto-save effect below. */
|
|
333
|
-
const userEditedRef = useRef(false);
|
|
334
|
-
/** Item rows whose diff is expanded inline, by compound key. */
|
|
335
|
-
const [openDiffs, setOpenDiffs] = useState<ReadonlySet<string>>(EMPTY_SELECTION);
|
|
336
|
-
const toggleDiff = useCallback((key: string) => {
|
|
337
|
-
setOpenDiffs((prev) => {
|
|
338
|
-
const next = new Set(prev);
|
|
339
|
-
if (next.has(key)) next.delete(key);
|
|
340
|
-
else next.add(key);
|
|
341
|
-
return next;
|
|
342
|
-
});
|
|
343
|
-
}, []);
|
|
344
|
-
// Filtering lives in the shared modal header (the magnifier), same as the
|
|
345
|
-
// restore-point list and every other Buoy tool — one place to look for it.
|
|
346
|
-
const search = host.search;
|
|
347
|
-
|
|
348
|
-
const rightMeta =
|
|
349
|
-
preview.right === "current"
|
|
350
|
-
? undefined
|
|
351
|
-
: snapshots.find(
|
|
352
|
-
(s) => s.id === (preview.right as { snapshotId: string }).snapshotId
|
|
353
|
-
);
|
|
354
|
-
// Latest-ref: `renderRow` is memoized on its handlers, so this one has to
|
|
355
|
-
// keep a stable identity while still reading the current snapshot.
|
|
356
|
-
const rightMetaRef = useRef(rightMeta);
|
|
357
|
-
rightMetaRef.current = rightMeta;
|
|
358
|
-
|
|
359
|
-
/** Flip whether this restore also navigates back to the captured route. */
|
|
360
|
-
const toggleRoute = useCallback(() => {
|
|
361
|
-
if (!rightMetaRef.current) return;
|
|
362
|
-
const meta = rightMetaRef.current;
|
|
363
|
-
void timeMachineStore.setRestoreRoute(meta.id, !willRestoreRoute(meta));
|
|
364
|
-
}, []);
|
|
365
|
-
|
|
366
|
-
const isBaseline = rightMeta?.baseline === true;
|
|
367
|
-
const liveLeft = preview.left === "current";
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* The selection this restore point already remembers, as exclusions. Null
|
|
371
|
-
* until there is a snapshot on the right-hand side to have one.
|
|
372
|
-
*/
|
|
373
|
-
const savedExcluded = useMemo<ReadonlySet<string> | null>(() => {
|
|
374
|
-
if (!rightMeta) return null;
|
|
375
|
-
return rightMeta.excludedKeys?.length
|
|
376
|
-
? new Set(rightMeta.excludedKeys)
|
|
377
|
-
: EMPTY_SELECTION;
|
|
378
|
-
}, [rightMeta]);
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* A snapshot still carrying the older `scope` include-list ("only ever
|
|
382
|
-
* touch these", set via MCP). It seeds the checkboxes and is converted to
|
|
383
|
-
* exclusions on read — a restore of it would otherwise still apply include
|
|
384
|
-
* semantics, and the two forms would disagree about what one tap does.
|
|
385
|
-
*/
|
|
386
|
-
const legacyScoped =
|
|
387
|
-
!!rightMeta?.scope?.length && !rightMeta?.excludedKeys?.length;
|
|
388
|
-
const legacySeed = useMemo<ReadonlySet<string>>(() => {
|
|
389
|
-
if (!legacyScoped || !rightMeta?.scope) return EMPTY_SELECTION;
|
|
390
|
-
const scopeSet = new Set(rightMeta.scope);
|
|
391
|
-
const next = new Set<string>();
|
|
392
|
-
for (const src of preview.sources) {
|
|
393
|
-
for (const item of src.items) {
|
|
394
|
-
const key = cKey(src.id, item.key);
|
|
395
|
-
if (VERDICT_META[item.verdict].applies && !scopeSet.has(key)) {
|
|
396
|
-
next.add(key);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
return next;
|
|
401
|
-
}, [legacyScoped, rightMeta, preview.sources]);
|
|
402
|
-
|
|
403
|
-
// Open with the saved selection already applied — once per snapshot, and
|
|
404
|
-
// only once there is a diff to apply it to.
|
|
405
|
-
const seededFor = useRef<string | null>(null);
|
|
406
|
-
useEffect(() => {
|
|
407
|
-
if (!rightMeta || preview.building || !savedExcluded) return;
|
|
408
|
-
if (seededFor.current === rightMeta.id) return;
|
|
409
|
-
seededFor.current = rightMeta.id;
|
|
410
|
-
userEditedRef.current = false;
|
|
411
|
-
const seed = savedExcluded.size > 0 ? savedExcluded : legacySeed;
|
|
412
|
-
if (seed.size > 0) setExcluded(new Set(seed));
|
|
413
|
-
// Convert an older include-list scope the moment it's read, so the two
|
|
414
|
-
// forms never disagree about what a restore will do.
|
|
415
|
-
if (legacyScoped && seed.size > 0) {
|
|
416
|
-
void timeMachineStore.setExclusions(rightMeta.id, Array.from(seed));
|
|
417
|
-
}
|
|
418
|
-
}, [rightMeta, preview.building, savedExcluded, legacySeed, legacyScoped]);
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Auto-save. The checkboxes ARE the restore point's setting, so there is
|
|
422
|
-
* nothing to confirm — every edit persists as EXCLUSIONS (the items to
|
|
423
|
-
* leave alone), and every later restore of this point honors them, one-tap
|
|
424
|
-
* RESTORE and MCP included.
|
|
425
|
-
*
|
|
426
|
-
* Never an include list: unchecking one of ten keys must keep restoring the
|
|
427
|
-
* other nine AND anything that only starts differing tomorrow, which a list
|
|
428
|
-
* built from today's diff cannot do.
|
|
429
|
-
*
|
|
430
|
-
* Gated on a ref rather than on comparing to what's stored, because on the
|
|
431
|
-
* frames before the seed lands `excluded` is legitimately empty — writing
|
|
432
|
-
* that would erase the saved selection we are about to load.
|
|
433
|
-
*/
|
|
434
|
-
useEffect(() => {
|
|
435
|
-
if (!rightMeta || !savedExcluded || !userEditedRef.current) return;
|
|
436
|
-
if (sameSelection(excluded, savedExcluded)) return;
|
|
437
|
-
const keys = Array.from(excluded);
|
|
438
|
-
void timeMachineStore.setExclusions(rightMeta.id, keys.length ? keys : null);
|
|
439
|
-
}, [excluded, rightMeta, savedExcluded]);
|
|
440
|
-
|
|
441
|
-
const toggleItem = useCallback((key: string) => {
|
|
442
|
-
userEditedRef.current = true;
|
|
443
|
-
setExcluded((prev) => {
|
|
444
|
-
const next = new Set(prev);
|
|
445
|
-
if (next.has(key)) next.delete(key);
|
|
446
|
-
else next.add(key);
|
|
447
|
-
return next;
|
|
448
|
-
});
|
|
449
|
-
}, []);
|
|
450
|
-
|
|
451
|
-
const toggleSource = useCallback(
|
|
452
|
-
(source: PreviewSource, includeState: "all" | "some" | "none") => {
|
|
453
|
-
userEditedRef.current = true;
|
|
454
|
-
setExcluded((prev) => {
|
|
455
|
-
const next = new Set(prev);
|
|
456
|
-
for (const item of source.items) {
|
|
457
|
-
if (!VERDICT_META[item.verdict].applies) continue;
|
|
458
|
-
const key = cKey(source.id, item.key);
|
|
459
|
-
if (includeState === "all") next.add(key);
|
|
460
|
-
else next.delete(key);
|
|
461
|
-
}
|
|
462
|
-
return next;
|
|
463
|
-
});
|
|
464
|
-
},
|
|
465
|
-
[]
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// Takes the EFFECTIVE collapsed state — the map is sparse (defaults come
|
|
469
|
-
// from the ?? fallback), so `!map[id]` would no-op the first tap on a
|
|
470
|
-
// default-collapsed card.
|
|
471
|
-
const toggleCollapse = useCallback((id: string, currentlyCollapsed: boolean) => {
|
|
472
|
-
setCollapsed((c) => ({ ...c, [id]: !currentlyCollapsed }));
|
|
473
|
-
}, []);
|
|
474
|
-
|
|
475
|
-
const totals = useMemo(() => {
|
|
476
|
-
let applies = 0;
|
|
477
|
-
let excludedCount = 0;
|
|
478
|
-
let wontApply = 0;
|
|
479
|
-
const perSource: string[] = [];
|
|
480
|
-
for (const src of preview.sources) {
|
|
481
|
-
let n = 0;
|
|
482
|
-
for (const item of src.items) {
|
|
483
|
-
const m = VERDICT_META[item.verdict];
|
|
484
|
-
if (m.applies) {
|
|
485
|
-
if (excluded.has(cKey(src.id, item.key))) excludedCount += 1;
|
|
486
|
-
else {
|
|
487
|
-
applies += 1;
|
|
488
|
-
n += 1;
|
|
489
|
-
}
|
|
490
|
-
} else if (item.verdict === "wont-apply") wontApply += 1;
|
|
491
|
-
}
|
|
492
|
-
if (n > 0) perSource.push(`${n} ${src.label.toLowerCase()}`);
|
|
493
|
-
}
|
|
494
|
-
return { applies, excludedCount, wontApply, perSource };
|
|
495
|
-
}, [preview.sources, excluded]);
|
|
496
|
-
|
|
497
|
-
// Hosted: back + identity in the shared modal header. Restore lives in the
|
|
498
|
-
// bottom CTA; the preset's labeled RELOAD APP action stays available.
|
|
499
|
-
useToolHeader(
|
|
500
|
-
hosted
|
|
501
|
-
? {
|
|
502
|
-
title: "Restore Preview",
|
|
503
|
-
subtitle: rightMeta?.name ?? "preview",
|
|
504
|
-
onBack,
|
|
505
|
-
}
|
|
506
|
-
: null,
|
|
507
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
508
|
-
[hosted, rightMeta?.name]
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
const { rows, blockedSources } = useMemo(() => {
|
|
512
|
-
const q = search.trim().toLowerCase();
|
|
513
|
-
const out: Row[] = [];
|
|
514
|
-
// Sources the restore would not touch at all get no card: "no changes"
|
|
515
|
-
// rows are noise on a screen about what WILL change.
|
|
516
|
-
/** Sources holding a won't-apply item, so the note can name where to look. */
|
|
517
|
-
const blocked: string[] = [];
|
|
518
|
-
for (const src of preview.sources) {
|
|
519
|
-
// Every detected source gets a card whose subtitle carries the
|
|
520
|
-
// detection proof ("60 keys · 7 changed" / "2 slices · no changes"),
|
|
521
|
-
// so clean sources need no expansion. Unchanged ITEMS never render.
|
|
522
|
-
const relevant = src.items.some((i) => i.verdict !== "unchanged");
|
|
523
|
-
if (!relevant && !src.error) continue;
|
|
524
|
-
const expandable = relevant || src.notes.length > 0 || !!src.error;
|
|
525
|
-
|
|
526
|
-
const applicable = src.items.filter(
|
|
527
|
-
(i) => VERDICT_META[i.verdict].applies
|
|
528
|
-
);
|
|
529
|
-
const wontApplyCount = src.items.filter(
|
|
530
|
-
(i) => i.verdict === "wont-apply"
|
|
531
|
-
).length;
|
|
532
|
-
if (wontApplyCount > 0) blocked.push(src.label);
|
|
533
|
-
const noun =
|
|
534
|
-
src.items.length === 1
|
|
535
|
-
? (SOURCE_NOUNS[src.id] ?? "items").replace(/ies$/, "y").replace(/s$/, "")
|
|
536
|
-
: SOURCE_NOUNS[src.id] ?? "items";
|
|
537
|
-
const summary =
|
|
538
|
-
`${src.items.length} ${noun} · ` +
|
|
539
|
-
(applicable.length > 0
|
|
540
|
-
? `${applicable.length} changed`
|
|
541
|
-
: wontApplyCount > 0
|
|
542
|
-
? `${wontApplyCount} won't apply`
|
|
543
|
-
: "no changes");
|
|
544
|
-
const excludedInSource = applicable.filter((i) =>
|
|
545
|
-
excluded.has(cKey(src.id, i.key))
|
|
546
|
-
).length;
|
|
547
|
-
const includeState: RowSourceHeader["includeState"] =
|
|
548
|
-
applicable.length === 0 || excludedInSource === 0
|
|
549
|
-
? "all"
|
|
550
|
-
: excludedInSource === applicable.length
|
|
551
|
-
? "none"
|
|
552
|
-
: "some";
|
|
553
|
-
const counts = { added: 0, removed: 0, changed: 0 };
|
|
554
|
-
for (const i of src.items) {
|
|
555
|
-
if (excluded.has(cKey(src.id, i.key))) continue;
|
|
556
|
-
if (i.verdict === "added") counts.added += 1;
|
|
557
|
-
else if (i.verdict === "removed") counts.removed += 1;
|
|
558
|
-
else if (i.verdict === "changed" || i.verdict === "too-large")
|
|
559
|
-
counts.changed += 1;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const visible = src.items.filter((item) => {
|
|
563
|
-
// Unchanged items never render — the dropdowns show only what a
|
|
564
|
-
// restore would touch (changed) or can't touch (won't-apply).
|
|
565
|
-
if (item.verdict === "unchanged") return false;
|
|
566
|
-
if (q && !item.label.toLowerCase().includes(q)) return false;
|
|
567
|
-
return true;
|
|
568
|
-
});
|
|
569
|
-
// Everything starts collapsed. The screen's job on open is "which
|
|
570
|
-
// sources would this touch, and how much" — expanding a source with
|
|
571
|
-
// 58 keys before you asked buries that under its own contents.
|
|
572
|
-
const isCollapsed = expandable ? (collapsed[src.id] ?? true) : true;
|
|
573
|
-
out.push({
|
|
574
|
-
kind: "source",
|
|
575
|
-
source: src,
|
|
576
|
-
collapsed: isCollapsed,
|
|
577
|
-
includeState,
|
|
578
|
-
applicableCount: applicable.length,
|
|
579
|
-
counts,
|
|
580
|
-
expandable,
|
|
581
|
-
summary,
|
|
582
|
-
first: false,
|
|
583
|
-
last: false,
|
|
584
|
-
});
|
|
585
|
-
if (!isCollapsed) {
|
|
586
|
-
for (const note of src.notes) {
|
|
587
|
-
out.push({
|
|
588
|
-
kind: "note",
|
|
589
|
-
id: `${src.id}:${note}`,
|
|
590
|
-
text: note,
|
|
591
|
-
first: false,
|
|
592
|
-
last: false,
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
if (src.error) {
|
|
596
|
-
out.push({
|
|
597
|
-
kind: "note",
|
|
598
|
-
id: `${src.id}:err`,
|
|
599
|
-
text: `⚠ ${src.error}`,
|
|
600
|
-
first: false,
|
|
601
|
-
last: false,
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
for (const item of visible) {
|
|
605
|
-
const key = cKey(src.id, item.key);
|
|
606
|
-
out.push({
|
|
607
|
-
kind: "item",
|
|
608
|
-
sourceId: src.id,
|
|
609
|
-
item,
|
|
610
|
-
excluded: excluded.has(key),
|
|
611
|
-
first: false,
|
|
612
|
-
last: false,
|
|
613
|
-
});
|
|
614
|
-
if (openDiffs.has(key)) {
|
|
615
|
-
out.push({
|
|
616
|
-
kind: "diff",
|
|
617
|
-
sourceId: src.id,
|
|
618
|
-
item,
|
|
619
|
-
first: false,
|
|
620
|
-
last: false,
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
if (rightMeta?.route) {
|
|
627
|
-
out.push({
|
|
628
|
-
kind: "route",
|
|
629
|
-
href: rightMeta.route.href,
|
|
630
|
-
on: willRestoreRoute(rightMeta),
|
|
631
|
-
first: false,
|
|
632
|
-
last: false,
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
// ONE card holds every source — the ends of the LIST round, and every
|
|
636
|
-
// other boundary is just a hairline. Per-source cards would read as
|
|
637
|
-
// separate groups, which they aren't: this is one list of what a restore
|
|
638
|
-
// would touch.
|
|
639
|
-
if (out.length > 0) {
|
|
640
|
-
out[0].first = true;
|
|
641
|
-
out[out.length - 1].last = true;
|
|
642
|
-
}
|
|
643
|
-
return { rows: out, blockedSources: blocked };
|
|
644
|
-
}, [preview.sources, search, collapsed, excluded, openDiffs, rightMeta]);
|
|
645
|
-
|
|
646
|
-
const renderRow: ListRenderItem<Row> = useCallback(
|
|
647
|
-
({ item: row }) => {
|
|
648
|
-
if (row.kind === "source") {
|
|
649
|
-
const src = row.source;
|
|
650
|
-
const { counts } = row;
|
|
651
|
-
return (
|
|
652
|
-
<CardRow position={row}>
|
|
653
|
-
<View style={styles.sourceCard}>
|
|
654
|
-
{/* Always rendered, even when there is nothing to select: it is
|
|
655
|
-
what every row lines up against now that the icons are gone,
|
|
656
|
-
and an empty ring reads as "nothing selected here". */}
|
|
657
|
-
<TouchableOpacity
|
|
658
|
-
onPress={() => toggleSource(src, row.includeState)}
|
|
659
|
-
disabled={row.applicableCount === 0}
|
|
660
|
-
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
|
|
661
|
-
testID={`tm-preview-source-toggle-${src.id}`}
|
|
662
|
-
>
|
|
663
|
-
<Checkbox
|
|
664
|
-
state={row.applicableCount === 0 ? "none" : row.includeState}
|
|
665
|
-
/>
|
|
666
|
-
</TouchableOpacity>
|
|
667
|
-
<TouchableOpacity
|
|
668
|
-
style={styles.sourceCardTouch}
|
|
669
|
-
onPress={() => row.expandable && toggleCollapse(src.id, row.collapsed)}
|
|
670
|
-
disabled={!row.expandable}
|
|
671
|
-
testID={`tm-preview-source-${src.id}`}
|
|
672
|
-
>
|
|
673
|
-
<View style={styles.sourceNameBlock}>
|
|
674
|
-
<Text
|
|
675
|
-
style={styles.sourceName}
|
|
676
|
-
numberOfLines={1}
|
|
677
|
-
testID={`tm-preview-source-${src.id}-name`}
|
|
678
|
-
>
|
|
679
|
-
{src.label}
|
|
680
|
-
</Text>
|
|
681
|
-
<Text
|
|
682
|
-
style={styles.sourceSummary}
|
|
683
|
-
numberOfLines={1}
|
|
684
|
-
testID={`tm-preview-source-${src.id}-summary`}
|
|
685
|
-
>
|
|
686
|
-
{row.includeState === "none" && row.applicableCount > 0
|
|
687
|
-
? `${row.summary} · excluded`
|
|
688
|
-
: row.summary}
|
|
689
|
-
</Text>
|
|
690
|
-
</View>
|
|
691
|
-
<View style={styles.sourceBadges}>
|
|
692
|
-
{counts.removed > 0 ? (
|
|
693
|
-
<CountPill
|
|
694
|
-
glyph="−"
|
|
695
|
-
count={counts.removed}
|
|
696
|
-
color={tm.color.danger}
|
|
697
|
-
testID={`tm-preview-source-${src.id}-removed`}
|
|
698
|
-
/>
|
|
699
|
-
) : null}
|
|
700
|
-
{counts.added > 0 ? (
|
|
701
|
-
<CountPill
|
|
702
|
-
glyph="+"
|
|
703
|
-
count={counts.added}
|
|
704
|
-
color={tm.color.accent}
|
|
705
|
-
testID={`tm-preview-source-${src.id}-added`}
|
|
706
|
-
/>
|
|
707
|
-
) : null}
|
|
708
|
-
{counts.changed > 0 ? (
|
|
709
|
-
<CountPill
|
|
710
|
-
glyph="≈"
|
|
711
|
-
count={counts.changed}
|
|
712
|
-
color={tm.color.info}
|
|
713
|
-
testID={`tm-preview-source-${src.id}-changed`}
|
|
714
|
-
/>
|
|
715
|
-
) : null}
|
|
716
|
-
</View>
|
|
717
|
-
{row.expandable ? (
|
|
718
|
-
row.collapsed ? (
|
|
719
|
-
<DisclosureChevron />
|
|
720
|
-
) : (
|
|
721
|
-
<ChevronUp size={16} color={tm.color.labelTertiary} />
|
|
722
|
-
)
|
|
723
|
-
) : null}
|
|
724
|
-
</TouchableOpacity>
|
|
725
|
-
</View>
|
|
726
|
-
</CardRow>
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
if (row.kind === "note") {
|
|
730
|
-
return (
|
|
731
|
-
<CardRow position={row}>
|
|
732
|
-
<Text style={styles.noteRow} testID={`tm-preview-note-${row.id}`}>
|
|
733
|
-
{row.text}
|
|
734
|
-
</Text>
|
|
735
|
-
</CardRow>
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
if (row.kind === "route") {
|
|
739
|
-
return (
|
|
740
|
-
<CardRow position={row}>
|
|
741
|
-
<TouchableOpacity
|
|
742
|
-
style={styles.routeRow}
|
|
743
|
-
onPress={toggleRoute}
|
|
744
|
-
accessibilityRole="switch"
|
|
745
|
-
accessibilityState={{ checked: row.on }}
|
|
746
|
-
accessibilityLabel={`Also return to ${row.href}`}
|
|
747
|
-
testID="tm-preview-route"
|
|
748
|
-
>
|
|
749
|
-
<Checkbox state={row.on ? "all" : "none"} />
|
|
750
|
-
<View style={styles.routeText}>
|
|
751
|
-
<Text style={styles.routeTitle}>Return to this screen</Text>
|
|
752
|
-
<Text
|
|
753
|
-
style={styles.routeHref}
|
|
754
|
-
numberOfLines={1}
|
|
755
|
-
testID="tm-preview-route-href"
|
|
756
|
-
>
|
|
757
|
-
{row.href}
|
|
758
|
-
</Text>
|
|
759
|
-
</View>
|
|
760
|
-
</TouchableOpacity>
|
|
761
|
-
</CardRow>
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
if (row.kind === "diff") {
|
|
765
|
-
return (
|
|
766
|
-
<CardRow position={row}>
|
|
767
|
-
<ItemDiffPanel
|
|
768
|
-
item={row.item}
|
|
769
|
-
testID={`tm-preview-diff-${row.sourceId}-${row.item.key}`}
|
|
770
|
-
/>
|
|
771
|
-
</CardRow>
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
return (
|
|
775
|
-
<ItemRow
|
|
776
|
-
row={row}
|
|
777
|
-
expanded={openDiffs.has(cKey(row.sourceId, row.item.key))}
|
|
778
|
-
onOpen={() => toggleDiff(cKey(row.sourceId, row.item.key))}
|
|
779
|
-
onToggle={() => toggleItem(cKey(row.sourceId, row.item.key))}
|
|
780
|
-
/>
|
|
781
|
-
);
|
|
782
|
-
},
|
|
783
|
-
[openDiffs, toggleDiff, toggleCollapse, toggleItem, toggleSource, toggleRoute]
|
|
784
|
-
);
|
|
785
|
-
|
|
786
|
-
const keyExtractor = useCallback((row: Row) => {
|
|
787
|
-
if (row.kind === "source") return `s:${row.source.id}`;
|
|
788
|
-
if (row.kind === "note") return `n:${row.id}`;
|
|
789
|
-
if (row.kind === "route") return "route";
|
|
790
|
-
if (row.kind === "diff") return `d:${row.sourceId}:${row.item.key}`;
|
|
791
|
-
return `i:${row.sourceId}:${row.item.key}`;
|
|
792
|
-
}, []);
|
|
793
|
-
|
|
794
|
-
const header = (
|
|
795
|
-
<View>
|
|
796
|
-
{!hosted && (
|
|
797
|
-
<View style={styles.fallbackHeader}>
|
|
798
|
-
<TouchableOpacity onPress={onBack} hitSlop={HIT} testID="tm-preview-back">
|
|
799
|
-
<Text style={styles.backText}>‹ Back</Text>
|
|
800
|
-
</TouchableOpacity>
|
|
801
|
-
<Text style={styles.screenTitle} numberOfLines={1}>
|
|
802
|
-
Restore Preview
|
|
803
|
-
</Text>
|
|
804
|
-
</View>
|
|
805
|
-
)}
|
|
806
|
-
|
|
807
|
-
{rightMeta ? (
|
|
808
|
-
<>
|
|
809
|
-
<SectionLabel>Restore Point</SectionLabel>
|
|
810
|
-
<Group>
|
|
811
|
-
<RestorePointNameRow
|
|
812
|
-
name={rightMeta.name}
|
|
813
|
-
onRename={(next) =>
|
|
814
|
-
void timeMachineStore.rename(rightMeta.id, next)
|
|
815
|
-
}
|
|
816
|
-
/>
|
|
817
|
-
</Group>
|
|
818
|
-
</>
|
|
819
|
-
) : null}
|
|
820
|
-
|
|
821
|
-
<SectionLabel>Changes to Restore</SectionLabel>
|
|
822
|
-
</View>
|
|
823
|
-
);
|
|
824
|
-
|
|
825
|
-
const ctaDisabled = busy || preview.building || totals.applies === 0;
|
|
826
|
-
|
|
827
|
-
return (
|
|
828
|
-
<View style={styles.container}>
|
|
829
|
-
<FlatList
|
|
830
|
-
testID="tm-preview-list"
|
|
831
|
-
data={rows}
|
|
832
|
-
renderItem={renderRow}
|
|
833
|
-
keyExtractor={keyExtractor}
|
|
834
|
-
ListHeaderComponent={header}
|
|
835
|
-
ListEmptyComponent={
|
|
836
|
-
preview.building ? (
|
|
837
|
-
<ActivityIndicator
|
|
838
|
-
style={styles.buildingSpinner}
|
|
839
|
-
size="small"
|
|
840
|
-
color={tm.color.accent}
|
|
841
|
-
/>
|
|
842
|
-
) : (
|
|
843
|
-
<Text style={styles.emptyText} testID="tm-preview-empty">
|
|
844
|
-
{search
|
|
845
|
-
? "Nothing matches this filter."
|
|
846
|
-
: "No changes — restoring would change nothing."}
|
|
847
|
-
</Text>
|
|
848
|
-
)
|
|
849
|
-
}
|
|
850
|
-
ListFooterComponent={
|
|
851
|
-
<View>
|
|
852
|
-
{totals.wontApply > 0 ? (
|
|
853
|
-
<View style={styles.warnNote}>
|
|
854
|
-
<AlertTriangle size={12} color={tm.color.warning} />
|
|
855
|
-
<Text style={styles.warnNoteText} testID="tm-preview-wont-apply">
|
|
856
|
-
{totals.wontApply} item{totals.wontApply === 1 ? "" : "s"}{" "}
|
|
857
|
-
won't apply — expand{" "}
|
|
858
|
-
{blockedSources.length > 0
|
|
859
|
-
? blockedSources.join(" and ")
|
|
860
|
-
: "the source"}{" "}
|
|
861
|
-
to see the reason.
|
|
862
|
-
</Text>
|
|
863
|
-
</View>
|
|
864
|
-
) : null}
|
|
865
|
-
</View>
|
|
866
|
-
}
|
|
867
|
-
// The name row is a TextInput inside this list: without "handled",
|
|
868
|
-
// the first tap after renaming only dismisses the keyboard, so the
|
|
869
|
-
// Restore CTA and every checkbox silently swallow one press.
|
|
870
|
-
keyboardShouldPersistTaps="handled"
|
|
871
|
-
removeClippedSubviews
|
|
872
|
-
initialNumToRender={20}
|
|
873
|
-
windowSize={7}
|
|
874
|
-
contentContainerStyle={styles.listContent}
|
|
875
|
-
/>
|
|
876
|
-
|
|
877
|
-
{/* Sticky Restore CTA */}
|
|
878
|
-
{liveLeft && rightMeta && (
|
|
879
|
-
<View style={[styles.footer, { paddingBottom: insets.bottom }]}>
|
|
880
|
-
<TouchableOpacity
|
|
881
|
-
style={[
|
|
882
|
-
styles.cta,
|
|
883
|
-
isBaseline && styles.ctaDanger,
|
|
884
|
-
ctaDisabled && styles.ctaDisabled,
|
|
885
|
-
]}
|
|
886
|
-
onPress={() =>
|
|
887
|
-
onRestore(isBaseline ? "reload" : "live", Array.from(excluded))
|
|
888
|
-
}
|
|
889
|
-
disabled={isBaseline ? busy || preview.building : ctaDisabled}
|
|
890
|
-
testID="tm-preview-restore"
|
|
891
|
-
>
|
|
892
|
-
<RefreshCw size={16} color={isBaseline ? tm.color.onDanger : tm.color.onAccent} />
|
|
893
|
-
<Text
|
|
894
|
-
style={[styles.ctaText, isBaseline ? styles.ctaTextDanger : null]}
|
|
895
|
-
>
|
|
896
|
-
{isBaseline ? "Wipe & Reload" : "Restore State"}
|
|
897
|
-
</Text>
|
|
898
|
-
</TouchableOpacity>
|
|
899
|
-
<Text style={styles.ctaCaption} testID="tm-preview-cta-caption">
|
|
900
|
-
{isBaseline
|
|
901
|
-
? "This wipes all app storage and reloads the app."
|
|
902
|
-
: totals.excludedCount > 0
|
|
903
|
-
? `Restores ${totals.applies} of ${totals.applies + totals.excludedCount} change${totals.applies + totals.excludedCount === 1 ? "" : "s"} — everything else stays as it is.`
|
|
904
|
-
: "This will replace the current state with the snapshot."}
|
|
905
|
-
</Text>
|
|
906
|
-
</View>
|
|
907
|
-
)}
|
|
908
|
-
</View>
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const HIT = { top: 10, bottom: 10, left: 10, right: 10 };
|
|
913
|
-
|
|
914
|
-
const styles = StyleSheet.create({
|
|
915
|
-
// Transparent so JsModal's ToolBackground shows through (the modal paints opaque base beneath it).
|
|
916
|
-
container: { flex: 1, backgroundColor: "transparent" },
|
|
917
|
-
fallbackHeader: {
|
|
918
|
-
flexDirection: "row",
|
|
919
|
-
alignItems: "center",
|
|
920
|
-
paddingHorizontal: tm.gutter,
|
|
921
|
-
paddingVertical: 10,
|
|
922
|
-
gap: 10,
|
|
923
|
-
},
|
|
924
|
-
backText: { color: tm.color.accent, fontSize: tm.font.control },
|
|
925
|
-
screenTitle: {
|
|
926
|
-
color: tm.color.label,
|
|
927
|
-
fontSize: 20,
|
|
928
|
-
fontWeight: "700",
|
|
929
|
-
letterSpacing: tm.tracking.row,
|
|
930
|
-
flex: 1,
|
|
931
|
-
},
|
|
932
|
-
dimmed: { opacity: 0.5 },
|
|
933
|
-
listContent: { paddingTop: 14, paddingBottom: 12 },
|
|
934
|
-
|
|
935
|
-
// ── The grouped card each list row paints its own share of ──────────────
|
|
936
|
-
cardRow: {
|
|
937
|
-
marginHorizontal: tm.gutter,
|
|
938
|
-
backgroundColor: tm.color.group,
|
|
939
|
-
},
|
|
940
|
-
cardRowFirst: {
|
|
941
|
-
borderTopLeftRadius: tm.radius.group,
|
|
942
|
-
borderTopRightRadius: tm.radius.group,
|
|
943
|
-
},
|
|
944
|
-
cardRowLast: {
|
|
945
|
-
borderBottomLeftRadius: tm.radius.group,
|
|
946
|
-
borderBottomRightRadius: tm.radius.group,
|
|
947
|
-
marginBottom: 10,
|
|
948
|
-
},
|
|
949
|
-
cardSeparator: {
|
|
950
|
-
height: StyleSheet.hairlineWidth,
|
|
951
|
-
marginLeft: 51,
|
|
952
|
-
backgroundColor: tm.color.separator,
|
|
953
|
-
},
|
|
954
|
-
|
|
955
|
-
// ── Selection card ──────────────────────────────────────────────────────
|
|
956
|
-
|
|
957
|
-
// ── The "won't apply" heads-up under the card ───────────────────────────
|
|
958
|
-
warnNote: {
|
|
959
|
-
flexDirection: "row",
|
|
960
|
-
alignItems: "flex-start",
|
|
961
|
-
gap: 6,
|
|
962
|
-
paddingHorizontal: tm.gutter + tm.textInset,
|
|
963
|
-
paddingTop: 8,
|
|
964
|
-
},
|
|
965
|
-
warnNoteText: {
|
|
966
|
-
flex: 1,
|
|
967
|
-
color: tm.color.labelSecondary,
|
|
968
|
-
fontSize: tm.font.footnote,
|
|
969
|
-
lineHeight: tm.font.footnote * 1.35,
|
|
970
|
-
},
|
|
971
|
-
|
|
972
|
-
// ── Count pills on a source row ─────────────────────────────────────────
|
|
973
|
-
countPill: {
|
|
974
|
-
borderRadius: tm.radius.pill,
|
|
975
|
-
paddingHorizontal: 9,
|
|
976
|
-
paddingVertical: 3,
|
|
977
|
-
},
|
|
978
|
-
countPillText: {
|
|
979
|
-
fontFamily: TM_MONO,
|
|
980
|
-
fontSize: tm.font.footnote,
|
|
981
|
-
fontWeight: "600",
|
|
982
|
-
},
|
|
983
|
-
|
|
984
|
-
// ── Include checkbox ────────────────────────────────────────────────────
|
|
985
|
-
checkbox: {
|
|
986
|
-
width: 23,
|
|
987
|
-
height: 23,
|
|
988
|
-
borderRadius: tm.radius.pill,
|
|
989
|
-
borderWidth: 1.7,
|
|
990
|
-
borderColor: tm.color.labelTertiary,
|
|
991
|
-
alignItems: "center",
|
|
992
|
-
justifyContent: "center",
|
|
993
|
-
},
|
|
994
|
-
checkboxChecked: {
|
|
995
|
-
borderColor: tm.color.accent,
|
|
996
|
-
backgroundColor: tm.color.accent,
|
|
997
|
-
},
|
|
998
|
-
checkboxGlyph: {
|
|
999
|
-
color: tm.color.onAccent,
|
|
1000
|
-
fontSize: 13,
|
|
1001
|
-
fontWeight: "700",
|
|
1002
|
-
lineHeight: 15,
|
|
1003
|
-
},
|
|
1004
|
-
|
|
1005
|
-
// ── Route row ───────────────────────────────────────────────────────────
|
|
1006
|
-
routeRow: {
|
|
1007
|
-
flexDirection: "row",
|
|
1008
|
-
alignItems: "center",
|
|
1009
|
-
minHeight: 58,
|
|
1010
|
-
paddingHorizontal: tm.textInset,
|
|
1011
|
-
paddingVertical: 9,
|
|
1012
|
-
gap: 12,
|
|
1013
|
-
},
|
|
1014
|
-
routeText: { flex: 1, minWidth: 0, gap: 2 },
|
|
1015
|
-
routeTitle: {
|
|
1016
|
-
color: tm.color.label,
|
|
1017
|
-
fontSize: tm.font.row,
|
|
1018
|
-
fontWeight: "600",
|
|
1019
|
-
letterSpacing: tm.tracking.row,
|
|
1020
|
-
},
|
|
1021
|
-
routeHref: {
|
|
1022
|
-
color: tm.color.labelSecondary,
|
|
1023
|
-
fontFamily: TM_MONO,
|
|
1024
|
-
fontSize: tm.font.mono,
|
|
1025
|
-
},
|
|
1026
|
-
|
|
1027
|
-
// ── Source header row (first row of its card) ───────────────────────────
|
|
1028
|
-
sourceCard: {
|
|
1029
|
-
flexDirection: "row",
|
|
1030
|
-
alignItems: "center",
|
|
1031
|
-
minHeight: 58,
|
|
1032
|
-
paddingHorizontal: tm.textInset,
|
|
1033
|
-
paddingVertical: 9,
|
|
1034
|
-
gap: 12,
|
|
1035
|
-
},
|
|
1036
|
-
sourceCardTouch: {
|
|
1037
|
-
flex: 1,
|
|
1038
|
-
flexDirection: "row",
|
|
1039
|
-
alignItems: "center",
|
|
1040
|
-
gap: 12,
|
|
1041
|
-
minWidth: 0,
|
|
1042
|
-
},
|
|
1043
|
-
sourceNameBlock: { flex: 1, minWidth: 0, gap: 2 },
|
|
1044
|
-
sourceName: {
|
|
1045
|
-
color: tm.color.label,
|
|
1046
|
-
fontSize: tm.font.row,
|
|
1047
|
-
fontWeight: "600",
|
|
1048
|
-
letterSpacing: tm.tracking.row,
|
|
1049
|
-
},
|
|
1050
|
-
sourceSummary: {
|
|
1051
|
-
color: tm.color.labelSecondary,
|
|
1052
|
-
fontSize: tm.font.footnote,
|
|
1053
|
-
fontVariant: ["tabular-nums"],
|
|
1054
|
-
},
|
|
1055
|
-
sourceBadges: { flexDirection: "row", gap: 6 },
|
|
1056
|
-
|
|
1057
|
-
noteRow: {
|
|
1058
|
-
color: tm.color.labelTertiary,
|
|
1059
|
-
fontSize: tm.font.footnote,
|
|
1060
|
-
lineHeight: tm.font.footnote * 1.35,
|
|
1061
|
-
paddingHorizontal: tm.textInset,
|
|
1062
|
-
paddingVertical: 9,
|
|
1063
|
-
},
|
|
1064
|
-
|
|
1065
|
-
// ── Item rows (further rows of the same card) ───────────────────────────
|
|
1066
|
-
itemRow: {
|
|
1067
|
-
flexDirection: "row",
|
|
1068
|
-
alignItems: "center",
|
|
1069
|
-
paddingLeft: tm.textInset,
|
|
1070
|
-
paddingRight: tm.textInset,
|
|
1071
|
-
paddingVertical: 10,
|
|
1072
|
-
gap: 12,
|
|
1073
|
-
},
|
|
1074
|
-
itemBodyTouch: {
|
|
1075
|
-
flex: 1,
|
|
1076
|
-
flexDirection: "row",
|
|
1077
|
-
alignItems: "center",
|
|
1078
|
-
gap: 10,
|
|
1079
|
-
minWidth: 0,
|
|
1080
|
-
},
|
|
1081
|
-
itemGlyph: { fontSize: 14, fontWeight: "700", width: 14, textAlign: "center" },
|
|
1082
|
-
itemBody: { flex: 1, minWidth: 0 },
|
|
1083
|
-
itemLabel: {
|
|
1084
|
-
color: tm.color.label,
|
|
1085
|
-
fontSize: tm.font.control,
|
|
1086
|
-
letterSpacing: tm.tracking.control,
|
|
1087
|
-
},
|
|
1088
|
-
itemPreview: {
|
|
1089
|
-
color: tm.color.labelSecondary,
|
|
1090
|
-
fontFamily: TM_MONO,
|
|
1091
|
-
fontSize: tm.font.mono,
|
|
1092
|
-
marginTop: 3,
|
|
1093
|
-
},
|
|
1094
|
-
itemPreviewRemoved: { textDecorationLine: "line-through" },
|
|
1095
|
-
itemReason: {
|
|
1096
|
-
color: tm.color.warning,
|
|
1097
|
-
fontSize: tm.font.mono,
|
|
1098
|
-
marginTop: 3,
|
|
1099
|
-
},
|
|
1100
|
-
itemExcluded: {
|
|
1101
|
-
color: tm.color.labelTertiary,
|
|
1102
|
-
fontSize: tm.font.mono,
|
|
1103
|
-
marginTop: 3,
|
|
1104
|
-
fontStyle: "italic",
|
|
1105
|
-
},
|
|
1106
|
-
buildingSpinner: {
|
|
1107
|
-
marginTop: 28,
|
|
1108
|
-
},
|
|
1109
|
-
emptyText: {
|
|
1110
|
-
color: tm.color.labelSecondary,
|
|
1111
|
-
fontSize: tm.font.footnote,
|
|
1112
|
-
textAlign: "center",
|
|
1113
|
-
marginTop: 28,
|
|
1114
|
-
},
|
|
1115
|
-
|
|
1116
|
-
// ── Sticky restore CTA ──────────────────────────────────────────────────
|
|
1117
|
-
footer: {
|
|
1118
|
-
paddingHorizontal: tm.gutter,
|
|
1119
|
-
paddingTop: 10,
|
|
1120
|
-
gap: 8,
|
|
1121
|
-
borderTopColor: tm.color.separator,
|
|
1122
|
-
borderTopWidth: StyleSheet.hairlineWidth,
|
|
1123
|
-
backgroundColor: tm.color.sheet,
|
|
1124
|
-
},
|
|
1125
|
-
cta: {
|
|
1126
|
-
flexDirection: "row",
|
|
1127
|
-
alignItems: "center",
|
|
1128
|
-
justifyContent: "center",
|
|
1129
|
-
gap: 8,
|
|
1130
|
-
height: 50,
|
|
1131
|
-
backgroundColor: tm.color.accent,
|
|
1132
|
-
borderRadius: tm.radius.group,
|
|
1133
|
-
},
|
|
1134
|
-
ctaDanger: { backgroundColor: tm.color.danger },
|
|
1135
|
-
ctaTextDanger: { color: tm.color.onDanger },
|
|
1136
|
-
ctaDisabled: { opacity: 0.4 },
|
|
1137
|
-
ctaText: {
|
|
1138
|
-
color: tm.color.onAccent,
|
|
1139
|
-
fontSize: tm.font.row,
|
|
1140
|
-
fontWeight: "600",
|
|
1141
|
-
letterSpacing: tm.tracking.row,
|
|
1142
|
-
},
|
|
1143
|
-
ctaCaption: {
|
|
1144
|
-
color: tm.color.labelSecondary,
|
|
1145
|
-
fontSize: tm.font.footnote,
|
|
1146
|
-
lineHeight: tm.font.footnote * 1.35,
|
|
1147
|
-
textAlign: "center",
|
|
1148
|
-
},
|
|
1149
|
-
});
|