@narumitw/pi-sync 0.49.6 → 0.49.9
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/README.md +77 -26
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/lock.ts +26 -2
- package/src/manager-attention.ts +81 -0
- package/src/manager-recovery.ts +65 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +145 -52
- package/src/manager-ui.ts +130 -75
- package/src/operation-availability.ts +64 -0
- package/src/remote-selection-ui.ts +541 -142
- package/src/remote-snapshot.ts +3 -2
- package/src/settings-management.ts +9 -2
- package/src/settings-ui.ts +21 -5
- package/src/sync-attention.ts +100 -0
- package/src/sync-extension.ts +203 -5
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
|
@@ -1,212 +1,495 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
3
|
import { createSyncBackend, type SyncBackendFactory } from "./backend-factory.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type RunRoute,
|
|
6
|
+
type RunRouteResult,
|
|
7
|
+
runCancellableOperation,
|
|
8
|
+
} from "./cancellable-operation.js";
|
|
9
|
+
import {
|
|
10
|
+
loadConfig,
|
|
11
|
+
loadPartialConfig,
|
|
12
|
+
type SyncSetupStorageReview,
|
|
13
|
+
syncConfigReviewFingerprint,
|
|
14
|
+
} from "./config.js";
|
|
5
15
|
import { readSnapshotForHead } from "./remote-snapshot.js";
|
|
6
|
-
import { updateSyncSetup } from "./settings-management.js";
|
|
16
|
+
import { SyncSetupReviewChangedError, updateSyncSetup } from "./settings-management.js";
|
|
7
17
|
import type { RemoteHead, SyncBackend } from "./sync-backend.js";
|
|
8
18
|
import { errorMessage, safeTerminalText } from "./sync-format.js";
|
|
9
19
|
import {
|
|
20
|
+
compareSyncInclude,
|
|
10
21
|
inspectRemoteSelection,
|
|
22
|
+
type RemoteSelectionDecision,
|
|
11
23
|
type RemoteSelectionState,
|
|
12
24
|
sameSyncInclude,
|
|
13
25
|
} from "./sync-policy.js";
|
|
14
|
-
import type { AnySyncConfig } from "./types.js";
|
|
26
|
+
import type { AnySyncConfig, PartialConfig } from "./types.js";
|
|
15
27
|
|
|
16
28
|
const STATUS_KEY = "sync";
|
|
17
29
|
|
|
30
|
+
export type RemoteSelectionOrigin = "settings" | "sync" | "pull" | "push";
|
|
31
|
+
|
|
32
|
+
export interface RemoteSelectionReviewOptions {
|
|
33
|
+
decision?: RemoteSelectionDecision;
|
|
34
|
+
origin?: RemoteSelectionOrigin;
|
|
35
|
+
runRoute?: RunRoute;
|
|
36
|
+
cancelLabel?: string;
|
|
37
|
+
onSelectionResolved?: () => void;
|
|
38
|
+
withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RemoteSelectionReviewResult =
|
|
42
|
+
| { kind: "back" }
|
|
43
|
+
| { kind: "closed" }
|
|
44
|
+
| { kind: "done" }
|
|
45
|
+
| { kind: "stale" }
|
|
46
|
+
| { kind: "route-result"; result: RunRouteResult; route: "sync" | "pull" | "push" };
|
|
47
|
+
|
|
18
48
|
export async function showRemoteSelectionReview(
|
|
19
|
-
ctx:
|
|
49
|
+
ctx: ExtensionContext,
|
|
20
50
|
setupName?: string,
|
|
21
51
|
signal?: AbortSignal,
|
|
22
52
|
factory: SyncBackendFactory = createSyncBackend,
|
|
23
|
-
|
|
53
|
+
options: RemoteSelectionReviewOptions = {},
|
|
54
|
+
): Promise<RemoteSelectionReviewResult> {
|
|
24
55
|
try {
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
let decision = options.decision;
|
|
57
|
+
if (!decision) {
|
|
58
|
+
const inspected = await inspectConfiguredRemoteSelection(ctx, setupName, signal, factory);
|
|
59
|
+
if (!inspected || signal?.aborted) return { kind: "stale" };
|
|
60
|
+
if (inspected.kind === "empty") {
|
|
61
|
+
ctx.ui.notify("Remote storage has no snapshot or synced-content list yet.", "info");
|
|
62
|
+
return { kind: "back" };
|
|
63
|
+
}
|
|
64
|
+
if (inspected.state.kind === "same") {
|
|
65
|
+
ctx.ui.notify("Remote synced content already matches this sync setup.", "info");
|
|
66
|
+
return { kind: "back" };
|
|
67
|
+
}
|
|
68
|
+
if (inspected.state.kind === "legacy") {
|
|
69
|
+
if (ctx.mode !== "tui") {
|
|
70
|
+
ctx.ui.notify(formatLegacySummary(inspected.config, inspected.state.discovered), "info");
|
|
71
|
+
return { kind: "back" };
|
|
72
|
+
}
|
|
73
|
+
await showLegacyDiscovery(ctx, inspected.config, inspected.state.discovered, signal);
|
|
74
|
+
return signal?.aborted ? { kind: "stale" } : { kind: "back" };
|
|
75
|
+
}
|
|
76
|
+
decision = decisionFromState(inspected.config, inspected.state);
|
|
38
77
|
}
|
|
39
|
-
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
40
|
-
if (signal?.aborted) return;
|
|
41
|
-
const state = inspectRemoteSelection(config.include, snapshot);
|
|
42
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
43
78
|
|
|
44
79
|
if (ctx.mode !== "tui") {
|
|
45
|
-
ctx.ui.notify(
|
|
46
|
-
|
|
47
|
-
state.kind === "different" ? "warning" : "info",
|
|
48
|
-
);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (state.kind === "same") {
|
|
52
|
-
ctx.ui.notify("Remote included content already matches this sync setup.", "info");
|
|
53
|
-
return;
|
|
80
|
+
ctx.ui.notify(formatRemoteSelectionSummary(decision), "warning");
|
|
81
|
+
return { kind: "back" };
|
|
54
82
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
|
|
84
|
+
let currentDecision = decision;
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (signal?.aborted) return { kind: "stale" };
|
|
87
|
+
const result = await showSelectionDifference(
|
|
88
|
+
ctx,
|
|
89
|
+
currentDecision,
|
|
90
|
+
options.origin ?? "settings",
|
|
91
|
+
options.runRoute,
|
|
92
|
+
signal,
|
|
93
|
+
factory,
|
|
94
|
+
options,
|
|
95
|
+
);
|
|
96
|
+
if (result.kind !== "refresh") return result;
|
|
97
|
+
const refreshed = await runWithOptionalStateAccess(options, () =>
|
|
98
|
+
inspectConfiguredRemoteSelection(ctx, currentDecision.setupName, signal, factory),
|
|
99
|
+
);
|
|
100
|
+
if (!refreshed || signal?.aborted) return { kind: "stale" };
|
|
101
|
+
if (refreshed.kind === "empty") {
|
|
102
|
+
ctx.ui.notify("Remote storage no longer has a snapshot or synced-content list.", "warning");
|
|
103
|
+
return { kind: "back" };
|
|
104
|
+
}
|
|
105
|
+
if (refreshed.state.kind !== "different") {
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
refreshed.state.kind === "same"
|
|
108
|
+
? "Remote synced content now matches this sync setup."
|
|
109
|
+
: "The refreshed legacy snapshot has no authoritative synced-content list.",
|
|
110
|
+
"info",
|
|
111
|
+
);
|
|
112
|
+
options.onSelectionResolved?.();
|
|
113
|
+
return { kind: "done" };
|
|
114
|
+
}
|
|
115
|
+
currentDecision = decisionFromState(refreshed.config, refreshed.state);
|
|
58
116
|
}
|
|
59
|
-
await showSelectionDifference(ctx, config, storageReview, backend, head, state, signal);
|
|
60
117
|
} catch (error) {
|
|
61
|
-
if (signal?.aborted) return;
|
|
62
|
-
ctx.ui.notify(`Could not review
|
|
118
|
+
if (signal?.aborted) return { kind: "stale" };
|
|
119
|
+
ctx.ui.notify(`Could not review synced content: ${errorMessage(error)}`, "error");
|
|
120
|
+
return { kind: "back" };
|
|
63
121
|
} finally {
|
|
64
122
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
65
123
|
}
|
|
66
124
|
}
|
|
67
125
|
|
|
126
|
+
type DifferenceFlowResult = RemoteSelectionReviewResult | { kind: "refresh" };
|
|
127
|
+
|
|
68
128
|
async function showSelectionDifference(
|
|
69
|
-
ctx:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
ctx: ExtensionContext,
|
|
130
|
+
initialDecision: RemoteSelectionDecision,
|
|
131
|
+
origin: RemoteSelectionOrigin,
|
|
132
|
+
runRoute: RunRoute | undefined,
|
|
133
|
+
sessionSignal: AbortSignal | undefined,
|
|
134
|
+
factory: SyncBackendFactory,
|
|
135
|
+
options: Pick<
|
|
136
|
+
RemoteSelectionReviewOptions,
|
|
137
|
+
"cancelLabel" | "onSelectionResolved" | "withStateAccess"
|
|
138
|
+
>,
|
|
139
|
+
): Promise<DifferenceFlowResult> {
|
|
140
|
+
type Screen = "choice" | "review" | "saved";
|
|
141
|
+
type Action = "adopt" | "keep" | "cancel" | "continue" | "done";
|
|
142
|
+
interface FlowState {
|
|
143
|
+
decision: RemoteSelectionDecision;
|
|
144
|
+
saved: boolean;
|
|
145
|
+
}
|
|
146
|
+
let flowState: FlowState = { decision: initialDecision, saved: false };
|
|
147
|
+
let continuationReview: PartialConfig | undefined;
|
|
148
|
+
let nextResult: RemoteSelectionReviewResult | undefined;
|
|
149
|
+
let refreshRequested = false;
|
|
150
|
+
const route = origin === "settings" ? "sync" : origin;
|
|
151
|
+
const menu = defineMenu<FlowState, Screen, Action, ExtensionContext>({
|
|
80
152
|
start: "choice",
|
|
81
153
|
screens: {
|
|
82
|
-
choice: () => ({
|
|
154
|
+
choice: ({ state }) => ({
|
|
83
155
|
kind: "actions",
|
|
84
|
-
title:
|
|
85
|
-
lines:
|
|
86
|
-
"The remote selection differs from this local sync setup.",
|
|
87
|
-
"No settings or files have been changed.",
|
|
88
|
-
],
|
|
156
|
+
title: "Synced content differs",
|
|
157
|
+
lines: selectionSummaryLines(state.decision),
|
|
89
158
|
items: [
|
|
159
|
+
{
|
|
160
|
+
id: "review",
|
|
161
|
+
label: "Review all paths (recommended)",
|
|
162
|
+
description: "Compare exact remote-only, device-only, and ordered lists.",
|
|
163
|
+
to: "review",
|
|
164
|
+
},
|
|
90
165
|
{
|
|
91
166
|
id: "adopt",
|
|
92
|
-
label: "
|
|
93
|
-
description: "Save the reviewed
|
|
167
|
+
label: "Use remote content list",
|
|
168
|
+
description: "Save the reviewed list on this device without pulling files.",
|
|
94
169
|
action: "adopt",
|
|
95
170
|
},
|
|
96
171
|
{
|
|
97
172
|
id: "keep",
|
|
98
|
-
label: "Keep
|
|
99
|
-
description:
|
|
173
|
+
label: "Keep this device's content list and update remote…",
|
|
174
|
+
description:
|
|
175
|
+
"Open the existing exact force-push preview without skipping confirmation.",
|
|
100
176
|
action: "keep",
|
|
101
177
|
},
|
|
102
|
-
{
|
|
103
|
-
id: "review",
|
|
104
|
-
label: "Review paths",
|
|
105
|
-
description: "Compare exact local-only and remote-only selection paths.",
|
|
106
|
-
to: "review",
|
|
107
|
-
},
|
|
108
|
-
{ id: "cancel", label: "Cancel", action: "cancel" },
|
|
178
|
+
{ id: "cancel", label: options.cancelLabel ?? "Cancel", action: "cancel" },
|
|
109
179
|
],
|
|
110
|
-
hint: "
|
|
180
|
+
hint: "back",
|
|
111
181
|
}),
|
|
112
|
-
review: () => ({
|
|
182
|
+
review: ({ state }) => ({
|
|
113
183
|
kind: "review",
|
|
114
|
-
title: `Review
|
|
115
|
-
content: formatSelectionDifference(
|
|
184
|
+
title: `Review synced content · ${safeTerminalText(state.decision.setupName)}`,
|
|
185
|
+
content: formatSelectionDifference(state.decision),
|
|
116
186
|
format: { kind: "text" },
|
|
117
187
|
viewportSize: "adaptive",
|
|
118
188
|
hint: "back",
|
|
119
189
|
}),
|
|
190
|
+
saved: ({ state }) => ({
|
|
191
|
+
kind: "actions",
|
|
192
|
+
title: "Remote content list saved",
|
|
193
|
+
lines: [
|
|
194
|
+
`Sync setup: ${safeTerminalText(state.decision.setupName)}`,
|
|
195
|
+
"Only the included-content setting was saved.",
|
|
196
|
+
"No files were pulled and sync state was not changed.",
|
|
197
|
+
],
|
|
198
|
+
items: [
|
|
199
|
+
...(runRoute
|
|
200
|
+
? [
|
|
201
|
+
{
|
|
202
|
+
id: "continue",
|
|
203
|
+
label: continueLabel(origin),
|
|
204
|
+
description: "Start a fresh check and exact preview for this sync setup.",
|
|
205
|
+
action: "continue" as const,
|
|
206
|
+
},
|
|
207
|
+
]
|
|
208
|
+
: []),
|
|
209
|
+
{ id: "done", label: "Done", action: "done" },
|
|
210
|
+
],
|
|
211
|
+
hint: "close",
|
|
212
|
+
}),
|
|
120
213
|
},
|
|
121
214
|
actions: {
|
|
122
|
-
adopt: async ({ signal: actionSignal }) => {
|
|
123
|
-
const signal = sessionSignal
|
|
124
|
-
? AbortSignal.any([sessionSignal, actionSignal])
|
|
125
|
-
: actionSignal;
|
|
215
|
+
adopt: async ({ state, signal: actionSignal }) => {
|
|
216
|
+
const signal = combineSignals(sessionSignal, actionSignal);
|
|
126
217
|
try {
|
|
127
|
-
|
|
218
|
+
const currentConfig = await loadConfig(state.decision.setupName);
|
|
219
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
220
|
+
assertLocalSelectionCurrent(currentConfig, state.decision);
|
|
221
|
+
if (
|
|
222
|
+
!currentConfig.include.includes("sessions") &&
|
|
223
|
+
state.decision.remoteInclude.includes("sessions")
|
|
224
|
+
) {
|
|
128
225
|
const acknowledged = await ctx.ui.confirm(
|
|
129
|
-
"
|
|
130
|
-
"Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the
|
|
226
|
+
"Use a content list that includes session conversations?",
|
|
227
|
+
"Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the list only; it does not pull files.",
|
|
131
228
|
{ signal },
|
|
132
229
|
);
|
|
133
230
|
if (signal.aborted) return { kind: "close" as const };
|
|
134
231
|
if (!acknowledged) return { kind: "stay" as const };
|
|
135
232
|
}
|
|
233
|
+
const review = await runWithOptionalStateAccess(options, async () => {
|
|
234
|
+
const loaded = await loadAdoptionReview(state.decision, signal, factory);
|
|
235
|
+
if (signal.aborted) throw signal.reason;
|
|
236
|
+
await revalidateAndAdopt(loaded, state.decision, signal);
|
|
237
|
+
return loaded;
|
|
238
|
+
});
|
|
136
239
|
if (signal.aborted) return { kind: "close" as const };
|
|
137
|
-
|
|
240
|
+
options.onSelectionResolved?.();
|
|
241
|
+
continuationReview = {
|
|
242
|
+
...review.storageReview,
|
|
243
|
+
setupName: state.decision.setupName,
|
|
244
|
+
include: [...state.decision.remoteInclude],
|
|
245
|
+
automatic: review.config.automatic,
|
|
246
|
+
onSwitch: review.config.onSwitch,
|
|
247
|
+
};
|
|
248
|
+
flowState = { decision: state.decision, saved: true };
|
|
249
|
+
return { kind: "to" as const, screen: "saved" as const };
|
|
250
|
+
} catch (error) {
|
|
138
251
|
if (signal.aborted) return { kind: "close" as const };
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
252
|
+
if (isStaleReviewError(error)) {
|
|
253
|
+
ctx.ui.notify(`${errorMessage(error)} Refreshing the comparison.`, "warning");
|
|
254
|
+
refreshRequested = true;
|
|
255
|
+
return { kind: "close" as const };
|
|
256
|
+
}
|
|
257
|
+
ctx.ui.notify(`Could not save the remote content list: ${errorMessage(error)}`, "error");
|
|
258
|
+
return { kind: "stay" as const };
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
keep: async ({ state, signal: actionSignal }) => {
|
|
262
|
+
if (!runRoute) {
|
|
263
|
+
ctx.ui.notify("The reviewed update-remote route is unavailable.", "error");
|
|
264
|
+
return { kind: "stay" as const };
|
|
265
|
+
}
|
|
266
|
+
const signal = combineSignals(sessionSignal, actionSignal);
|
|
267
|
+
try {
|
|
268
|
+
const latest = await loadConfig(state.decision.setupName);
|
|
269
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
270
|
+
assertLocalSelectionCurrent(latest, state.decision);
|
|
271
|
+
const result = await runCancellableOperation(
|
|
272
|
+
ctx,
|
|
273
|
+
"Preparing this device's push preview…",
|
|
274
|
+
"push --force",
|
|
275
|
+
runRoute,
|
|
276
|
+
{
|
|
277
|
+
commitAware: true,
|
|
278
|
+
cancelledMessage: "Push preparation cancelled; no remote files were changed.",
|
|
279
|
+
target: state.decision.setupName,
|
|
280
|
+
signal,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
return handleNestedRouteResult(result, "push");
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
286
|
+
if (isStaleReviewError(error)) {
|
|
287
|
+
ctx.ui.notify(`${errorMessage(error)} Refreshing the comparison.`, "warning");
|
|
288
|
+
refreshRequested = true;
|
|
289
|
+
return { kind: "close" as const };
|
|
143
290
|
}
|
|
144
|
-
|
|
291
|
+
ctx.ui.notify(`Could not prepare the remote update: ${errorMessage(error)}`, "error");
|
|
292
|
+
return { kind: "stay" as const };
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
continue: async ({ state, signal: actionSignal }) => {
|
|
296
|
+
if (!runRoute || !continuationReview) return { kind: "stay" as const };
|
|
297
|
+
const signal = combineSignals(sessionSignal, actionSignal);
|
|
298
|
+
try {
|
|
299
|
+
const latest = await loadPartialConfig(state.decision.setupName);
|
|
145
300
|
if (signal.aborted) return { kind: "close" as const };
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
!sameSyncInclude(currentState.include, state.include)
|
|
150
|
-
) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
"Remote included content changed while the review was open; reopen it.",
|
|
301
|
+
if (!sameContinuationReview(latest, continuationReview)) {
|
|
302
|
+
throw new StaleRemoteSelectionReviewError(
|
|
303
|
+
`Sync setup “${safeTerminalText(state.decision.setupName)}” changed after the remote content list was saved.`,
|
|
153
304
|
);
|
|
154
305
|
}
|
|
155
|
-
await
|
|
156
|
-
|
|
157
|
-
(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}),
|
|
306
|
+
const result = await runCancellableOperation(
|
|
307
|
+
ctx,
|
|
308
|
+
continueBusyLabel(origin),
|
|
309
|
+
route,
|
|
310
|
+
runRoute,
|
|
161
311
|
{
|
|
162
|
-
|
|
163
|
-
|
|
312
|
+
commitAware: true,
|
|
313
|
+
cancelledMessage: continuationCancelledMessage(route),
|
|
314
|
+
target: state.decision.setupName,
|
|
164
315
|
signal,
|
|
165
316
|
},
|
|
166
317
|
);
|
|
167
|
-
|
|
168
|
-
ctx.ui.notify(
|
|
169
|
-
`Saved remote included content for sync setup “${safeTerminalText(config.setupName)}”. No files were pulled; review Sync now separately.`,
|
|
170
|
-
"info",
|
|
171
|
-
);
|
|
172
|
-
return { kind: "close" as const };
|
|
318
|
+
return handleNestedRouteResult(result, route);
|
|
173
319
|
} catch (error) {
|
|
174
320
|
if (signal.aborted) return { kind: "close" as const };
|
|
175
|
-
ctx.ui.notify(`Could not
|
|
176
|
-
return { kind: "
|
|
321
|
+
ctx.ui.notify(`Could not continue: ${errorMessage(error)}`, "error");
|
|
322
|
+
return { kind: "stay" as const };
|
|
177
323
|
}
|
|
178
324
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"info",
|
|
183
|
-
);
|
|
325
|
+
cancel: async () => ({ kind: "back" }),
|
|
326
|
+
done: async () => {
|
|
327
|
+
nextResult = { kind: "done" };
|
|
184
328
|
return { kind: "close" };
|
|
185
329
|
},
|
|
186
|
-
cancel: async () => ({ kind: "close" }),
|
|
187
330
|
},
|
|
188
331
|
});
|
|
189
|
-
|
|
190
|
-
|
|
332
|
+
|
|
333
|
+
function handleNestedRouteResult(
|
|
334
|
+
result: Awaited<ReturnType<typeof runCancellableOperation>>,
|
|
335
|
+
nestedRoute: "sync" | "pull" | "push",
|
|
336
|
+
) {
|
|
337
|
+
if (result.kind === "completed" && result.outcome === "applied") {
|
|
338
|
+
options.onSelectionResolved?.();
|
|
339
|
+
}
|
|
340
|
+
if (result.kind === "closed") {
|
|
341
|
+
nextResult = { kind: "closed" };
|
|
342
|
+
return { kind: "close" as const };
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
result.kind === "cancelled" ||
|
|
346
|
+
(result.kind === "completed" && result.outcome === "cancelled") ||
|
|
347
|
+
result.kind === "failed"
|
|
348
|
+
) {
|
|
349
|
+
return { kind: "stay" as const };
|
|
350
|
+
}
|
|
351
|
+
nextResult = { kind: "route-result", result, route: nestedRoute };
|
|
352
|
+
return { kind: "close" as const };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const menuResult = await runMenu(ctx, menu, {
|
|
356
|
+
getState: () => flowState,
|
|
191
357
|
signal: sessionSignal,
|
|
192
358
|
isCurrent: () => !sessionSignal?.aborted,
|
|
359
|
+
onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
|
|
193
360
|
});
|
|
361
|
+
if (sessionSignal?.aborted || menuResult.kind === "stale") return { kind: "stale" };
|
|
362
|
+
if (refreshRequested) return { kind: "refresh" };
|
|
363
|
+
if (nextResult) return nextResult;
|
|
364
|
+
if (menuResult.kind === "closed") {
|
|
365
|
+
return menuResult.reason === "back" ? { kind: "back" } : { kind: "closed" };
|
|
366
|
+
}
|
|
367
|
+
return { kind: "closed" };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface AdoptionReview {
|
|
371
|
+
config: AnySyncConfig;
|
|
372
|
+
storageReview: SyncSetupStorageReview;
|
|
373
|
+
backend: SyncBackend;
|
|
374
|
+
reviewedHead: RemoteHead;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function loadAdoptionReview(
|
|
378
|
+
decision: RemoteSelectionDecision,
|
|
379
|
+
signal: AbortSignal,
|
|
380
|
+
factory: SyncBackendFactory,
|
|
381
|
+
): Promise<AdoptionReview> {
|
|
382
|
+
const config = await loadConfig(decision.setupName);
|
|
383
|
+
throwIfAborted(signal);
|
|
384
|
+
assertLocalSelectionCurrent(config, decision);
|
|
385
|
+
const partial = await loadPartialConfig(decision.setupName);
|
|
386
|
+
throwIfAborted(signal);
|
|
387
|
+
if (!sameSyncInclude(partial.include, decision.localInclude)) {
|
|
388
|
+
throw new StaleRemoteSelectionReviewError(
|
|
389
|
+
`Sync setup “${safeTerminalText(decision.setupName)}” changed while the comparison was open.`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const backend = await factory(config);
|
|
393
|
+
throwIfAborted(signal);
|
|
394
|
+
const reviewedHead = await backend.readHead(signal);
|
|
395
|
+
throwIfAborted(signal);
|
|
396
|
+
if (!reviewedHead) {
|
|
397
|
+
throw new StaleRemoteSelectionReviewError(
|
|
398
|
+
"Remote storage changed while the comparison was open.",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const snapshot = await readSnapshotForHead(backend, reviewedHead, signal);
|
|
402
|
+
throwIfAborted(signal);
|
|
403
|
+
const state = inspectRemoteSelection(config.include, snapshot);
|
|
404
|
+
if (state.kind !== "different" || !sameSyncInclude(state.include, decision.remoteInclude)) {
|
|
405
|
+
throw new StaleRemoteSelectionReviewError(
|
|
406
|
+
"Remote synced content changed while the comparison was open.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return { config, storageReview: partial, backend, reviewedHead };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function revalidateAndAdopt(
|
|
413
|
+
review: AdoptionReview,
|
|
414
|
+
decision: RemoteSelectionDecision,
|
|
415
|
+
signal: AbortSignal,
|
|
416
|
+
) {
|
|
417
|
+
const currentHead = await review.backend.readHead(signal);
|
|
418
|
+
throwIfAborted(signal);
|
|
419
|
+
if (
|
|
420
|
+
!currentHead ||
|
|
421
|
+
!review.backend.sameRevision(review.reviewedHead.revision, currentHead.revision)
|
|
422
|
+
) {
|
|
423
|
+
throw new StaleRemoteSelectionReviewError(
|
|
424
|
+
"Remote storage changed while the comparison was open.",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const currentSnapshot = await readSnapshotForHead(review.backend, currentHead, signal);
|
|
428
|
+
throwIfAborted(signal);
|
|
429
|
+
const currentState = inspectRemoteSelection(review.config.include, currentSnapshot);
|
|
430
|
+
if (
|
|
431
|
+
currentState.kind !== "different" ||
|
|
432
|
+
!sameSyncInclude(currentState.include, decision.remoteInclude)
|
|
433
|
+
) {
|
|
434
|
+
throw new StaleRemoteSelectionReviewError(
|
|
435
|
+
"Remote synced content changed while the comparison was open.",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
await updateSyncSetup(
|
|
439
|
+
decision.setupName,
|
|
440
|
+
(setup) => ({
|
|
441
|
+
...setup,
|
|
442
|
+
sync: { ...setup.sync, include: [...decision.remoteInclude] },
|
|
443
|
+
}),
|
|
444
|
+
{
|
|
445
|
+
expectedStorage: review.storageReview,
|
|
446
|
+
expectedInclude: decision.localInclude,
|
|
447
|
+
signal,
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function inspectConfiguredRemoteSelection(
|
|
453
|
+
ctx: ExtensionContext,
|
|
454
|
+
setupName: string | undefined,
|
|
455
|
+
signal: AbortSignal | undefined,
|
|
456
|
+
factory: SyncBackendFactory,
|
|
457
|
+
): Promise<
|
|
458
|
+
| { kind: "empty"; config: AnySyncConfig }
|
|
459
|
+
| { kind: "selection"; config: AnySyncConfig; state: RemoteSelectionState }
|
|
460
|
+
| undefined
|
|
461
|
+
> {
|
|
462
|
+
const config = await loadConfig(setupName);
|
|
463
|
+
if (signal?.aborted) return undefined;
|
|
464
|
+
ctx.ui.setStatus(STATUS_KEY, `checking synced content for ${safeTerminalText(config.setupName)}`);
|
|
465
|
+
const backend = await factory(config);
|
|
466
|
+
if (signal?.aborted) return undefined;
|
|
467
|
+
const head = await backend.readHead(signal);
|
|
468
|
+
if (signal?.aborted) return undefined;
|
|
469
|
+
if (!head) return { kind: "empty", config };
|
|
470
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
471
|
+
if (signal?.aborted) return undefined;
|
|
472
|
+
return {
|
|
473
|
+
kind: "selection",
|
|
474
|
+
config,
|
|
475
|
+
state: inspectRemoteSelection(config.include, snapshot),
|
|
476
|
+
};
|
|
194
477
|
}
|
|
195
478
|
|
|
196
479
|
async function showLegacyDiscovery(
|
|
197
|
-
ctx:
|
|
480
|
+
ctx: ExtensionContext,
|
|
198
481
|
config: AnySyncConfig,
|
|
199
482
|
discovered: string[],
|
|
200
483
|
signal?: AbortSignal,
|
|
201
484
|
) {
|
|
202
|
-
const menu = defineMenu<undefined, "choice" | "review", "back",
|
|
485
|
+
const menu = defineMenu<undefined, "choice" | "review", "back", ExtensionContext>({
|
|
203
486
|
start: "choice",
|
|
204
487
|
screens: {
|
|
205
488
|
choice: () => ({
|
|
206
489
|
kind: "actions",
|
|
207
|
-
title: `
|
|
490
|
+
title: `Compare synced content · ${safeTerminalText(config.setupName)}`,
|
|
208
491
|
lines: [
|
|
209
|
-
"This legacy snapshot has no portable
|
|
492
|
+
"This legacy snapshot has no portable synced-content list.",
|
|
210
493
|
"Discovered paths are partial and read-only; preserved files may not have been selected.",
|
|
211
494
|
],
|
|
212
495
|
items: [
|
|
@@ -241,39 +524,155 @@ async function showLegacyDiscovery(
|
|
|
241
524
|
});
|
|
242
525
|
}
|
|
243
526
|
|
|
244
|
-
function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
527
|
+
function selectionSummaryLines(decision: RemoteSelectionDecision) {
|
|
528
|
+
const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
|
|
529
|
+
return [
|
|
530
|
+
`Sync setup: ${safeTerminalText(decision.setupName)}`,
|
|
531
|
+
"Nothing changed. Review both lists before choosing what happens next.",
|
|
532
|
+
...(comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
|
|
533
|
+
? ["Only the ordering differs; membership is the same."]
|
|
534
|
+
: [
|
|
535
|
+
`Remote-only paths: ${comparison.remoteOnly.length} · Device-only paths: ${comparison.localOnly.length}`,
|
|
536
|
+
]),
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function formatSelectionDifference(decision: RemoteSelectionDecision) {
|
|
541
|
+
const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
|
|
248
542
|
return [
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
543
|
+
...(comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
|
|
544
|
+
? ["Only ordering differs; both lists contain the same paths.", ""]
|
|
545
|
+
: []),
|
|
546
|
+
"Remote-only paths:",
|
|
547
|
+
...(comparison.remoteOnly.length > 0
|
|
548
|
+
? comparison.remoteOnly.map((item) => `+ ${safeTerminalText(item)}`)
|
|
549
|
+
: ["(none)"]),
|
|
550
|
+
"",
|
|
551
|
+
"Device-only paths:",
|
|
552
|
+
...(comparison.localOnly.length > 0
|
|
553
|
+
? comparison.localOnly.map((item) => `- ${safeTerminalText(item)}`)
|
|
252
554
|
: ["(none)"]),
|
|
253
555
|
"",
|
|
254
|
-
"
|
|
255
|
-
...(
|
|
256
|
-
?
|
|
556
|
+
"Remote ordered list:",
|
|
557
|
+
...(decision.remoteInclude.length > 0
|
|
558
|
+
? decision.remoteInclude.map((item, index) => `${index + 1}. ${safeTerminalText(item)}`)
|
|
257
559
|
: ["(none)"]),
|
|
258
560
|
"",
|
|
259
|
-
|
|
260
|
-
|
|
561
|
+
"This device's ordered list:",
|
|
562
|
+
...(decision.localInclude.length > 0
|
|
563
|
+
? decision.localInclude.map((item, index) => `${index + 1}. ${safeTerminalText(item)}`)
|
|
564
|
+
: ["(none)"]),
|
|
261
565
|
"",
|
|
262
|
-
"
|
|
566
|
+
"Using the remote list saves settings only and does not pull files.",
|
|
263
567
|
].join("\n");
|
|
264
568
|
}
|
|
265
569
|
|
|
266
|
-
function formatRemoteSelectionSummary(
|
|
267
|
-
|
|
268
|
-
return `Remote included content for “${safeTerminalText(config.setupName)}” matches local settings.`;
|
|
269
|
-
}
|
|
270
|
-
if (state.kind === "legacy") {
|
|
271
|
-
return `Remote snapshot for “${safeTerminalText(config.setupName)}” has no portable included-content policy; ${state.discovered.length} safe path${state.discovered.length === 1 ? " was" : "s were"} discovered, but the result is partial and read-only.`;
|
|
272
|
-
}
|
|
570
|
+
function formatRemoteSelectionSummary(decision: RemoteSelectionDecision) {
|
|
571
|
+
const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
|
|
273
572
|
return [
|
|
274
|
-
`
|
|
275
|
-
`Remote-only: ${
|
|
276
|
-
`
|
|
277
|
-
|
|
573
|
+
`Synced content for “${safeTerminalText(decision.setupName)}” differs from this device.`,
|
|
574
|
+
`Remote-only: ${safeList(comparison.remoteOnly)}`,
|
|
575
|
+
`Device-only: ${safeList(comparison.localOnly)}`,
|
|
576
|
+
...(comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
|
|
577
|
+
? [
|
|
578
|
+
"Only ordering differs.",
|
|
579
|
+
`Remote order: ${safeList(decision.remoteInclude)}`,
|
|
580
|
+
`Device order: ${safeList(decision.localInclude)}`,
|
|
581
|
+
]
|
|
582
|
+
: []),
|
|
583
|
+
"Run /sync in TUI to choose a content list; RPC review is read-only.",
|
|
278
584
|
].join("\n");
|
|
279
585
|
}
|
|
586
|
+
|
|
587
|
+
function formatLegacySummary(config: AnySyncConfig, discovered: readonly string[]) {
|
|
588
|
+
return `Remote snapshot for “${safeTerminalText(config.setupName)}” has no portable synced-content list; ${discovered.length} safe path${discovered.length === 1 ? " was" : "s were"} discovered, but the result is partial and read-only.`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function decisionFromState(
|
|
592
|
+
config: AnySyncConfig,
|
|
593
|
+
state: Extract<RemoteSelectionState, { kind: "different" }>,
|
|
594
|
+
): RemoteSelectionDecision {
|
|
595
|
+
return {
|
|
596
|
+
setupName: config.setupName,
|
|
597
|
+
configIdentity: syncConfigReviewFingerprint(config),
|
|
598
|
+
localInclude: [...config.include],
|
|
599
|
+
remoteInclude: [...state.include],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function assertLocalSelectionCurrent(config: AnySyncConfig, decision: RemoteSelectionDecision) {
|
|
604
|
+
if (
|
|
605
|
+
syncConfigReviewFingerprint(config) === decision.configIdentity &&
|
|
606
|
+
sameSyncInclude(config.include, decision.localInclude)
|
|
607
|
+
) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
throw new StaleRemoteSelectionReviewError(
|
|
611
|
+
`Sync setup “${safeTerminalText(config.setupName)}” changed while the comparison was open.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function sameContinuationReview(left: PartialConfig, right: PartialConfig) {
|
|
616
|
+
return (
|
|
617
|
+
left.setupName === right.setupName &&
|
|
618
|
+
left.connectionName === right.connectionName &&
|
|
619
|
+
left.storageKind === right.storageKind &&
|
|
620
|
+
left.storagePath === right.storagePath &&
|
|
621
|
+
left.bucket === right.bucket &&
|
|
622
|
+
left.branch === right.branch &&
|
|
623
|
+
sameSyncInclude(left.include, right.include)
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function continueLabel(origin: RemoteSelectionOrigin) {
|
|
628
|
+
if (origin === "pull") return "Continue Pull now…";
|
|
629
|
+
if (origin === "push") return "Continue Push now…";
|
|
630
|
+
return "Continue Sync now…";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function continueBusyLabel(origin: RemoteSelectionOrigin) {
|
|
634
|
+
if (origin === "pull") return "Checking remote changes…";
|
|
635
|
+
if (origin === "push") return "Preparing push preview…";
|
|
636
|
+
return "Checking current sync setup…";
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function continuationCancelledMessage(route: "sync" | "pull" | "push") {
|
|
640
|
+
if (route === "pull") return "Pull check cancelled; no local files were changed.";
|
|
641
|
+
if (route === "push") return "Push preparation cancelled; no remote files were changed.";
|
|
642
|
+
return "Sync check cancelled; no settings or files were changed.";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function safeList(values: readonly string[]) {
|
|
646
|
+
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
class StaleRemoteSelectionReviewError extends Error {
|
|
650
|
+
constructor(message: string) {
|
|
651
|
+
super(message);
|
|
652
|
+
this.name = "StaleRemoteSelectionReviewError";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function isStaleReviewError(error: unknown) {
|
|
657
|
+
return (
|
|
658
|
+
error instanceof StaleRemoteSelectionReviewError || error instanceof SyncSetupReviewChangedError
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function runWithOptionalStateAccess<T>(
|
|
663
|
+
options: Pick<RemoteSelectionReviewOptions, "withStateAccess">,
|
|
664
|
+
task: () => Promise<T>,
|
|
665
|
+
) {
|
|
666
|
+
return options.withStateAccess ? options.withStateAccess(task) : task();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function combineSignals(sessionSignal: AbortSignal | undefined, actionSignal: AbortSignal) {
|
|
670
|
+
return sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function throwIfAborted(signal: AbortSignal) {
|
|
674
|
+
if (!signal.aborted) return;
|
|
675
|
+
throw signal.reason instanceof Error
|
|
676
|
+
? signal.reason
|
|
677
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
678
|
+
}
|