@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.
@@ -1,212 +1,495 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
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 { loadConfig, loadPartialConfig } from "./config.js";
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: ExtensionCommandContext,
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
- const config = await loadConfig(setupName);
26
- if (signal?.aborted) return;
27
- const storageReview = await loadPartialConfig(config.setupName);
28
- if (signal?.aborted) return;
29
- ctx.ui.setStatus(STATUS_KEY, `checking remote selection for ${config.setupName}`);
30
- const backend = await factory(config);
31
- if (signal?.aborted) return;
32
- const head = await backend.readHead(signal);
33
- if (signal?.aborted) return;
34
- if (!head) {
35
- ctx.ui.setStatus(STATUS_KEY, undefined);
36
- ctx.ui.notify("Remote storage has no snapshot or included-content policy yet.", "info");
37
- return;
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
- formatRemoteSelectionSummary(config, state),
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
- if (state.kind === "legacy") {
56
- await showLegacyDiscovery(ctx, config, state.discovered, signal);
57
- return;
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 remote included content: ${errorMessage(error)}`, "error");
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: ExtensionCommandContext,
70
- config: AnySyncConfig,
71
- storageReview: Awaited<ReturnType<typeof loadPartialConfig>>,
72
- backend: SyncBackend,
73
- reviewedHead: RemoteHead,
74
- state: Extract<RemoteSelectionState, { kind: "different" }>,
75
- sessionSignal?: AbortSignal,
76
- ) {
77
- type Screen = "choice" | "review";
78
- type Action = "adopt" | "keep" | "cancel";
79
- const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
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: `Remote included content · ${safeTerminalText(config.setupName)}`,
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: "Adopt remote included content",
93
- description: "Save the reviewed remote paths locally without pulling files.",
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 local included content",
99
- description: "Make no settings change; a reviewed force push can publish it later.",
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: "close",
180
+ hint: "back",
111
181
  }),
112
- review: () => ({
182
+ review: ({ state }) => ({
113
183
  kind: "review",
114
- title: `Review remote included content · ${safeTerminalText(config.setupName)}`,
115
- content: formatSelectionDifference(config.include, state),
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
- if (!config.include.includes("sessions") && state.include.includes("sessions")) {
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
- "Adopt session conversations?",
130
- "Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the selection only; it does not pull files.",
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
- const currentHead = await backend.readHead(signal);
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 (!currentHead || !backend.sameRevision(reviewedHead.revision, currentHead.revision)) {
140
- throw new Error(
141
- "Remote changed while the included-content review was open; reopen it.",
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
- const currentSnapshot = await readSnapshotForHead(backend, currentHead, signal);
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
- const currentState = inspectRemoteSelection(config.include, currentSnapshot);
147
- if (
148
- currentState.kind === "legacy" ||
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 updateSyncSetup(
156
- config.setupName,
157
- (setup) => ({
158
- ...setup,
159
- sync: { ...setup.sync, include: [...state.include] },
160
- }),
306
+ const result = await runCancellableOperation(
307
+ ctx,
308
+ continueBusyLabel(origin),
309
+ route,
310
+ runRoute,
161
311
  {
162
- expectedStorage: storageReview,
163
- expectedInclude: config.include,
312
+ commitAware: true,
313
+ cancelledMessage: continuationCancelledMessage(route),
314
+ target: state.decision.setupName,
164
315
  signal,
165
316
  },
166
317
  );
167
- if (signal.aborted) return { kind: "close" as const };
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 adopt remote included content: ${errorMessage(error)}`, "error");
176
- return { kind: "close" as const };
321
+ ctx.ui.notify(`Could not continue: ${errorMessage(error)}`, "error");
322
+ return { kind: "stay" as const };
177
323
  }
178
324
  },
179
- keep: async () => {
180
- ctx.ui.notify(
181
- "Kept local included content. No settings or files changed; use a reviewed force push to publish the local selection.",
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
- await runMenu(ctx, menu, {
190
- getState: () => undefined,
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: ExtensionCommandContext,
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", ExtensionCommandContext>({
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: `Remote included content · ${safeTerminalText(config.setupName)}`,
490
+ title: `Compare synced content · ${safeTerminalText(config.setupName)}`,
208
491
  lines: [
209
- "This legacy snapshot has no portable included-content policy.",
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 formatSelectionDifference(
245
- localInclude: readonly string[],
246
- state: Extract<RemoteSelectionState, { kind: "different" }>,
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
- "Remote-only selection:",
250
- ...(state.remoteOnly.length > 0
251
- ? state.remoteOnly.map((item) => `+ ${safeTerminalText(item)}`)
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
- "Local-only selection:",
255
- ...(state.localOnly.length > 0
256
- ? state.localOnly.map((item) => `- ${safeTerminalText(item)}`)
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
- `Remote order: ${state.include.map(safeTerminalText).join(", ") || "none"}`,
260
- `Local order: ${localInclude.map(safeTerminalText).join(", ") || "none"}`,
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
- "Adopting saves settings only and does not pull files.",
566
+ "Using the remote list saves settings only and does not pull files.",
263
567
  ].join("\n");
264
568
  }
265
569
 
266
- function formatRemoteSelectionSummary(config: AnySyncConfig, state: RemoteSelectionState) {
267
- if (state.kind === "same") {
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
- `Remote included content for “${safeTerminalText(config.setupName)}” differs from local settings.`,
275
- `Remote-only: ${state.remoteOnly.map(safeTerminalText).join(", ") || "none"}`,
276
- `Local-only: ${state.localOnly.map(safeTerminalText).join(", ") || "none"}`,
277
- "Use TUI Settings to review or adopt it; RPC is read-only.",
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
+ }