@narumitw/pi-sync 0.43.0 → 0.48.0
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 +23 -0
- package/package.json +2 -2
- package/src/cancellable-operation.ts +125 -0
- package/src/manager-ui.ts +101 -83
- package/src/secret-input.ts +73 -56
- package/src/settings-ui.ts +2 -7
- package/src/sync-decision.ts +113 -0
- package/src/sync-operations.ts +71 -19
- package/src/sync-resolution-ui.ts +166 -0
- package/src/sync.ts +34 -21
package/README.md
CHANGED
|
@@ -58,6 +58,29 @@ recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes
|
|
|
58
58
|
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
59
59
|
and confirmations.
|
|
60
60
|
|
|
61
|
+
### Resolve conflicts in the manager
|
|
62
|
+
|
|
63
|
+
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
|
|
64
|
+
direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
|
|
65
|
+
error. The flow names the current sync setup and explains whether local content, remote content, or
|
|
66
|
+
the included-content policy changed.
|
|
67
|
+
|
|
68
|
+
Choose **Review differences (recommended)** to inspect the exact affected paths without changing
|
|
69
|
+
local files, remote data, or sync state. Then choose one reviewed direction:
|
|
70
|
+
|
|
71
|
+
- **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
|
|
72
|
+
local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
|
|
73
|
+
and asks again if the reviewed plan changed.
|
|
74
|
+
- **Use remote content and replace local…** uses the existing forced-pull path. It shows exact local
|
|
75
|
+
writes and deletions, protects the live session, and creates a local backup before applying.
|
|
76
|
+
- First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
|
|
77
|
+
empty remote offers **Push local content…** only.
|
|
78
|
+
|
|
79
|
+
Cancelling a preparation or confirmation returns to conflict resolution with no side effects. Back
|
|
80
|
+
returns to the sync manager; Ctrl+C closes the complete flow. Automatic startup/shutdown sync never
|
|
81
|
+
opens this interactive flow and continues to report an actionable warning. Deterministic direct
|
|
82
|
+
routes remain available and keep their command-oriented error guidance.
|
|
83
|
+
|
|
61
84
|
## ⚙️ Settings
|
|
62
85
|
|
|
63
86
|
The canonical private user file is:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
39
|
+
"@narumitw/pi-tui-kit": "^0.45.0",
|
|
40
40
|
"fast-xml-parser": "^5.10.1",
|
|
41
41
|
"proper-lockfile": "^4.1.2"
|
|
42
42
|
},
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BorderedLoader,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type KeybindingsManager,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
8
|
+
import { safeTerminalText } from "./manager-helpers.js";
|
|
9
|
+
import type { SetupPullOutcome } from "./setup-switch.js";
|
|
10
|
+
import type { SyncDecision } from "./sync-decision.js";
|
|
11
|
+
|
|
12
|
+
export type RunRouteResult =
|
|
13
|
+
| { kind: "completed"; outcome?: SetupPullOutcome }
|
|
14
|
+
| { kind: "decision-required"; decision: SyncDecision }
|
|
15
|
+
| { kind: "failed" };
|
|
16
|
+
|
|
17
|
+
export type RunRoute = (
|
|
18
|
+
route: string,
|
|
19
|
+
signal?: AbortSignal,
|
|
20
|
+
onCommit?: () => void,
|
|
21
|
+
target?: string,
|
|
22
|
+
) => Promise<RunRouteResult | undefined>;
|
|
23
|
+
|
|
24
|
+
export type CancellableOperationResult =
|
|
25
|
+
| RunRouteResult
|
|
26
|
+
| { kind: "closed" }
|
|
27
|
+
| { kind: "cancelled" };
|
|
28
|
+
|
|
29
|
+
interface CancellableOperationOptions {
|
|
30
|
+
commitAware?: boolean;
|
|
31
|
+
cancelledMessage?: string | null;
|
|
32
|
+
target?: string;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runCancellableOperation(
|
|
37
|
+
ctx: ExtensionCommandContext,
|
|
38
|
+
message: string,
|
|
39
|
+
route: string,
|
|
40
|
+
runRoute: RunRoute,
|
|
41
|
+
options: CancellableOperationOptions = {},
|
|
42
|
+
): Promise<CancellableOperationResult> {
|
|
43
|
+
const {
|
|
44
|
+
commitAware = false,
|
|
45
|
+
cancelledMessage = "Check cancelled; no settings or files were changed.",
|
|
46
|
+
target,
|
|
47
|
+
signal,
|
|
48
|
+
} = options;
|
|
49
|
+
if (ctx.mode !== "tui") {
|
|
50
|
+
return (await runRoute(route, signal, undefined, target)) ?? { kind: "failed" };
|
|
51
|
+
}
|
|
52
|
+
let commitStarted = false;
|
|
53
|
+
let routeResult: RunRouteResult | undefined;
|
|
54
|
+
const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
|
|
55
|
+
signal,
|
|
56
|
+
isCurrent: () => !signal?.aborted,
|
|
57
|
+
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
|
58
|
+
const loader = new BorderedLoader(tui, theme, message, { cancellable: false });
|
|
59
|
+
const cancelHint = `${keybindingText(keybindings, "tui.select.cancel", "esc")} cancel`;
|
|
60
|
+
const operation = runRoute(
|
|
61
|
+
route,
|
|
62
|
+
interactionSignal,
|
|
63
|
+
commitAware ? () => (commitStarted = true) : undefined,
|
|
64
|
+
target,
|
|
65
|
+
).then(
|
|
66
|
+
(result) => {
|
|
67
|
+
routeResult = result;
|
|
68
|
+
complete({});
|
|
69
|
+
},
|
|
70
|
+
(error: unknown) => complete({ error }),
|
|
71
|
+
);
|
|
72
|
+
return {
|
|
73
|
+
render(width: number) {
|
|
74
|
+
const safeWidth = Math.max(1, width);
|
|
75
|
+
const lines = loader.render(safeWidth);
|
|
76
|
+
const bottomBorder = lines.at(-1);
|
|
77
|
+
return [
|
|
78
|
+
...lines.slice(0, -1),
|
|
79
|
+
truncateToWidth(theme.fg("dim", cancelHint), safeWidth, ""),
|
|
80
|
+
...(bottomBorder === undefined ? [] : [bottomBorder]),
|
|
81
|
+
];
|
|
82
|
+
},
|
|
83
|
+
invalidate: () => loader.invalidate(),
|
|
84
|
+
handleInput(data: string) {
|
|
85
|
+
if (!keybindings.matches(data, "tui.select.cancel")) return;
|
|
86
|
+
if (commitStarted) {
|
|
87
|
+
ctx.ui.notify(
|
|
88
|
+
"Applying or publishing has started and cannot be cancelled safely.",
|
|
89
|
+
"warning",
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
complete({ cancelled: true });
|
|
94
|
+
},
|
|
95
|
+
dispose: () => loader.dispose(),
|
|
96
|
+
waitForPending: () => operation,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
if (interaction.kind === "error") throw interaction.error;
|
|
101
|
+
if (interaction.kind !== "completed") return { kind: "closed" };
|
|
102
|
+
if (interaction.value.cancelled) {
|
|
103
|
+
if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
|
|
104
|
+
return { kind: "cancelled" };
|
|
105
|
+
}
|
|
106
|
+
if (interaction.value.error) throw interaction.value.error;
|
|
107
|
+
return routeResult ?? { kind: "failed" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function keybindingText(
|
|
111
|
+
keybindings: Pick<KeybindingsManager, "getKeys">,
|
|
112
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
113
|
+
fallback: string,
|
|
114
|
+
) {
|
|
115
|
+
const keys = keybindings
|
|
116
|
+
.getKeys(binding)
|
|
117
|
+
.map(String)
|
|
118
|
+
.map((key) => {
|
|
119
|
+
if (key === "return") return "enter";
|
|
120
|
+
if (key === "escape") return "esc";
|
|
121
|
+
return safeTerminalText(key);
|
|
122
|
+
})
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
return keys.join("/") || fallback;
|
|
125
|
+
}
|
package/src/manager-ui.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import {
|
|
4
|
+
type CancellableOperationResult,
|
|
5
|
+
type RunRoute,
|
|
6
|
+
runCancellableOperation,
|
|
7
|
+
} from "./cancellable-operation.js";
|
|
3
8
|
import { setSyncSetupCompletions } from "./command.js";
|
|
4
9
|
import {
|
|
5
10
|
configuredSyncSetupNames,
|
|
@@ -29,20 +34,14 @@ import {
|
|
|
29
34
|
updateSyncSetup,
|
|
30
35
|
} from "./settings-management.js";
|
|
31
36
|
import { showSyncSettings } from "./settings-ui.js";
|
|
32
|
-
import {
|
|
37
|
+
import { useSyncSetup } from "./setup-switch.js";
|
|
33
38
|
import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
|
|
34
39
|
import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
|
|
40
|
+
import { showSyncResolution } from "./sync-resolution-ui.js";
|
|
35
41
|
import { showSyncSetups } from "./sync-setups-ui.js";
|
|
36
42
|
import type { AnySyncConfig } from "./types.js";
|
|
37
43
|
import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
|
|
38
44
|
|
|
39
|
-
type RunRoute = (
|
|
40
|
-
route: string,
|
|
41
|
-
signal?: AbortSignal,
|
|
42
|
-
onCommit?: () => void,
|
|
43
|
-
target?: string,
|
|
44
|
-
) => Promise<SetupPullOutcome | undefined>;
|
|
45
|
-
|
|
46
45
|
export async function showSyncManager(
|
|
47
46
|
ctx: ExtensionCommandContext,
|
|
48
47
|
runRoute: RunRoute,
|
|
@@ -116,16 +115,36 @@ export async function showSyncManager(
|
|
|
116
115
|
},
|
|
117
116
|
actions: {
|
|
118
117
|
sync: async () => {
|
|
119
|
-
await runCancellableOperation(
|
|
120
|
-
|
|
118
|
+
const result = await runCancellableOperation(
|
|
119
|
+
ctx,
|
|
120
|
+
"Checking current sync setup…",
|
|
121
|
+
"sync",
|
|
122
|
+
runRoute,
|
|
123
|
+
{
|
|
124
|
+
commitAware: true,
|
|
125
|
+
signal: sessionSignal,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
129
|
+
return result.kind === "closed" || resolution === "close"
|
|
130
|
+
? { kind: "close" }
|
|
131
|
+
: { kind: "stay" };
|
|
121
132
|
},
|
|
122
133
|
switch: async () => {
|
|
123
134
|
const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
|
|
124
|
-
return result === "pull-attempted"
|
|
135
|
+
return result === "pull-attempted" || result === "closed"
|
|
136
|
+
? { kind: "close" }
|
|
137
|
+
: { kind: "stay" };
|
|
125
138
|
},
|
|
126
139
|
diff: async () => {
|
|
127
|
-
await runCancellableOperation(
|
|
128
|
-
|
|
140
|
+
const result = await runCancellableOperation(
|
|
141
|
+
ctx,
|
|
142
|
+
"Checking current sync setup…",
|
|
143
|
+
"diff",
|
|
144
|
+
runRoute,
|
|
145
|
+
{ signal: sessionSignal },
|
|
146
|
+
);
|
|
147
|
+
return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
129
148
|
},
|
|
130
149
|
settings: async () => {
|
|
131
150
|
await showSyncSettings(ctx, runRoute, sessionSignal);
|
|
@@ -137,21 +156,35 @@ export async function showSyncManager(
|
|
|
137
156
|
"Checking remote changes…",
|
|
138
157
|
"pull",
|
|
139
158
|
runRoute,
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
{
|
|
160
|
+
commitAware: true,
|
|
161
|
+
cancelledMessage: "Pull check cancelled; no local files were changed.",
|
|
162
|
+
signal: sessionSignal,
|
|
163
|
+
},
|
|
142
164
|
);
|
|
143
|
-
|
|
165
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
166
|
+
return result.kind === "closed" ||
|
|
167
|
+
(result.kind === "completed" && result.outcome === "applied") ||
|
|
168
|
+
resolution === "close"
|
|
169
|
+
? { kind: "close" }
|
|
170
|
+
: { kind: "stay" };
|
|
144
171
|
},
|
|
145
172
|
push: async () => {
|
|
146
|
-
await runCancellableOperation(
|
|
173
|
+
const result = await runCancellableOperation(
|
|
147
174
|
ctx,
|
|
148
175
|
"Preparing push preview…",
|
|
149
176
|
"push",
|
|
150
177
|
runRoute,
|
|
151
|
-
|
|
152
|
-
|
|
178
|
+
{
|
|
179
|
+
commitAware: true,
|
|
180
|
+
cancelledMessage: "Push preparation cancelled; no remote files were changed.",
|
|
181
|
+
signal: sessionSignal,
|
|
182
|
+
},
|
|
153
183
|
);
|
|
154
|
-
|
|
184
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
185
|
+
return result.kind === "closed" || resolution === "close"
|
|
186
|
+
? { kind: "close" }
|
|
187
|
+
: { kind: "stay" };
|
|
155
188
|
},
|
|
156
189
|
setups: async () => {
|
|
157
190
|
const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
|
|
@@ -201,6 +234,21 @@ export async function showSyncManager(
|
|
|
201
234
|
});
|
|
202
235
|
}
|
|
203
236
|
|
|
237
|
+
async function resolveManagerDecision(
|
|
238
|
+
ctx: ExtensionCommandContext,
|
|
239
|
+
result: CancellableOperationResult,
|
|
240
|
+
runRoute: RunRoute,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
) {
|
|
243
|
+
if (result.kind !== "decision-required") return undefined;
|
|
244
|
+
const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
|
|
245
|
+
return resolution.kind === "resolved" ||
|
|
246
|
+
resolution.kind === "closed" ||
|
|
247
|
+
resolution.kind === "stale"
|
|
248
|
+
? "close"
|
|
249
|
+
: "stay";
|
|
250
|
+
}
|
|
251
|
+
|
|
204
252
|
function syncMainMenuItem(
|
|
205
253
|
label: string,
|
|
206
254
|
): ActionMenuItem<
|
|
@@ -226,61 +274,6 @@ function syncMainMenuItem(
|
|
|
226
274
|
return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
|
|
227
275
|
}
|
|
228
276
|
|
|
229
|
-
async function runCancellableOperation(
|
|
230
|
-
ctx: ExtensionCommandContext,
|
|
231
|
-
message: string,
|
|
232
|
-
route: string,
|
|
233
|
-
runRoute: RunRoute,
|
|
234
|
-
commitAware = false,
|
|
235
|
-
cancelledMessage: string | null = "Check cancelled; no settings or files were changed.",
|
|
236
|
-
target?: string,
|
|
237
|
-
) {
|
|
238
|
-
if (ctx.mode !== "tui") {
|
|
239
|
-
return await runRoute(route, undefined, undefined, target);
|
|
240
|
-
}
|
|
241
|
-
let commitStarted = false;
|
|
242
|
-
let operation: Promise<void> | undefined;
|
|
243
|
-
let routeResult: SetupPullOutcome | undefined;
|
|
244
|
-
const result = await ctx.ui.custom<{ cancelled?: boolean; error?: unknown }>(
|
|
245
|
-
(tui, theme, _kb, done) => {
|
|
246
|
-
const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
|
|
247
|
-
let closed = false;
|
|
248
|
-
loader.onAbort = () => {
|
|
249
|
-
if (commitStarted) {
|
|
250
|
-
ctx.ui.notify(
|
|
251
|
-
"Applying or publishing has started and cannot be cancelled safely.",
|
|
252
|
-
"warning",
|
|
253
|
-
);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
closed = true;
|
|
257
|
-
done({ cancelled: true });
|
|
258
|
-
};
|
|
259
|
-
operation = runRoute(
|
|
260
|
-
route,
|
|
261
|
-
loader.signal,
|
|
262
|
-
commitAware ? () => (commitStarted = true) : undefined,
|
|
263
|
-
target,
|
|
264
|
-
)
|
|
265
|
-
.then((result) => {
|
|
266
|
-
routeResult = result;
|
|
267
|
-
if (!closed) done({});
|
|
268
|
-
})
|
|
269
|
-
.catch((error) => {
|
|
270
|
-
if (!closed) done({ error });
|
|
271
|
-
});
|
|
272
|
-
return loader;
|
|
273
|
-
},
|
|
274
|
-
);
|
|
275
|
-
if (result?.cancelled) {
|
|
276
|
-
await operation;
|
|
277
|
-
if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
|
|
278
|
-
return "cancelled";
|
|
279
|
-
}
|
|
280
|
-
if (result?.error) throw result.error;
|
|
281
|
-
return routeResult;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
277
|
export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
285
278
|
if (ctx.mode !== "tui") {
|
|
286
279
|
ctx.ui.notify(
|
|
@@ -543,23 +536,48 @@ async function showSetupSwitcher(
|
|
|
543
536
|
);
|
|
544
537
|
if (signal?.aborted || !confirmed) return false;
|
|
545
538
|
try {
|
|
539
|
+
let pullClosed = false;
|
|
546
540
|
const result = await useSyncSetup(
|
|
547
541
|
ctx,
|
|
548
542
|
name,
|
|
549
|
-
(selectedTarget) =>
|
|
550
|
-
runCancellableOperation(
|
|
543
|
+
async (selectedTarget) => {
|
|
544
|
+
const pullResult = await runCancellableOperation(
|
|
551
545
|
ctx,
|
|
552
546
|
`Pulling sync setup “${safeTerminalText(name)}”…`,
|
|
553
547
|
"pull",
|
|
554
548
|
runRoute,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
549
|
+
{
|
|
550
|
+
commitAware: true,
|
|
551
|
+
cancelledMessage: null,
|
|
552
|
+
target: selectedTarget,
|
|
553
|
+
signal,
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
if (pullResult.kind === "closed") {
|
|
557
|
+
pullClosed = true;
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
if (pullResult.kind === "decision-required") {
|
|
561
|
+
const resolution = await showSyncResolution(ctx, pullResult.decision, runRoute, signal);
|
|
562
|
+
if (
|
|
563
|
+
resolution.kind === "resolved" ||
|
|
564
|
+
resolution.kind === "closed" ||
|
|
565
|
+
resolution.kind === "stale"
|
|
566
|
+
) {
|
|
567
|
+
pullClosed = true;
|
|
568
|
+
}
|
|
569
|
+
return resolution.kind === "resolved" && resolution.direction === "pull"
|
|
570
|
+
? "applied"
|
|
571
|
+
: undefined;
|
|
572
|
+
}
|
|
573
|
+
if (pullResult.kind === "completed") return pullResult.outcome;
|
|
574
|
+
return pullResult.kind === "cancelled" ? "cancelled" : undefined;
|
|
575
|
+
},
|
|
559
576
|
onSwitch,
|
|
560
577
|
signal,
|
|
561
578
|
syncConfigReviewIdentity(config),
|
|
562
579
|
);
|
|
580
|
+
if (pullClosed) return "closed";
|
|
563
581
|
return result.pullApplied ? "pull-attempted" : "switched";
|
|
564
582
|
} catch (error) {
|
|
565
583
|
if (signal?.aborted) return false;
|
|
@@ -587,7 +605,7 @@ async function showSyncSetupManager(
|
|
|
587
605
|
},
|
|
588
606
|
makeCurrent: async (name, setupSignal) => {
|
|
589
607
|
const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
|
|
590
|
-
return result === "pull-attempted" ? "exit" : undefined;
|
|
608
|
+
return result === "pull-attempted" || result === "closed" ? "exit" : undefined;
|
|
591
609
|
},
|
|
592
610
|
remove: async (name, setupSignal) => {
|
|
593
611
|
await showRemoveTarget(ctx, name, setupSignal);
|
package/src/secret-input.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Text,
|
|
8
8
|
truncateToWidth,
|
|
9
9
|
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
10
11
|
|
|
11
12
|
const MASK = "•";
|
|
12
13
|
|
|
@@ -16,63 +17,62 @@ export async function promptSecret(
|
|
|
16
17
|
options: { required?: boolean; signal?: AbortSignal } = { required: true },
|
|
17
18
|
) {
|
|
18
19
|
if (ctx.mode !== "tui" || options.signal?.aborted) return undefined;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
heading
|
|
24
|
-
hint
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
return component;
|
|
20
|
+
const result = await runCustomInteraction<string | undefined>(ctx, {
|
|
21
|
+
signal: options.signal,
|
|
22
|
+
isCurrent: () => !options.signal?.aborted,
|
|
23
|
+
create: ({ tui, theme, keybindings, complete }) => {
|
|
24
|
+
const heading = new Text("", 0, 0);
|
|
25
|
+
const hint = new Text("", 0, 0);
|
|
26
|
+
const submitKey = keybindingText(keybindings, "tui.input.submit", "enter");
|
|
27
|
+
const cancelKey = keybindingText(keybindings, "tui.select.cancel", "esc");
|
|
28
|
+
const applyTheme = () => {
|
|
29
|
+
heading.setText(theme.fg("accent", theme.bold(title)));
|
|
30
|
+
hint.setText(theme.fg("dim", `${submitKey} save • ${cancelKey} cancel • Input is hidden`));
|
|
31
|
+
};
|
|
32
|
+
applyTheme();
|
|
33
|
+
const input = new MaskedInput(keybindings);
|
|
34
|
+
const component: Focusable & {
|
|
35
|
+
render(width: number): string[];
|
|
36
|
+
invalidate(): void;
|
|
37
|
+
handleInput(data: string): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
} = {
|
|
40
|
+
get focused() {
|
|
41
|
+
return input.focused;
|
|
42
|
+
},
|
|
43
|
+
set focused(focused: boolean) {
|
|
44
|
+
input.focused = focused;
|
|
45
|
+
},
|
|
46
|
+
render(width: number) {
|
|
47
|
+
const safeWidth = Math.max(1, width);
|
|
48
|
+
return [
|
|
49
|
+
...heading.render(safeWidth),
|
|
50
|
+
...input.render(safeWidth),
|
|
51
|
+
...hint.render(safeWidth),
|
|
52
|
+
].map((line) => truncateToWidth(line, safeWidth));
|
|
53
|
+
},
|
|
54
|
+
invalidate() {
|
|
55
|
+
applyTheme();
|
|
56
|
+
heading.invalidate();
|
|
57
|
+
input.invalidate();
|
|
58
|
+
hint.invalidate();
|
|
59
|
+
},
|
|
60
|
+
handleInput(data: string) {
|
|
61
|
+
if (keybindings.matches(data, "tui.select.cancel")) complete(undefined);
|
|
62
|
+
else if (keybindings.matches(data, "tui.input.submit")) complete(input.getValue());
|
|
63
|
+
else input.handleInput(data);
|
|
64
|
+
tui.requestRender();
|
|
65
|
+
},
|
|
66
|
+
dispose() {
|
|
67
|
+
input.clear();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return component;
|
|
71
|
+
},
|
|
74
72
|
});
|
|
75
|
-
if (
|
|
73
|
+
if (result.kind === "error") throw result.error;
|
|
74
|
+
if (result.kind !== "completed" || result.value === undefined) return undefined;
|
|
75
|
+
const value = result.value;
|
|
76
76
|
if (options.required !== false && value.length === 0) {
|
|
77
77
|
ctx.ui.notify(`${title} is required.`, "warning");
|
|
78
78
|
return undefined;
|
|
@@ -191,6 +191,23 @@ class MaskedInput implements Focusable {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
function keybindingText(
|
|
195
|
+
keybindings: Pick<KeybindingsManager, "getKeys">,
|
|
196
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
197
|
+
fallback: string,
|
|
198
|
+
) {
|
|
199
|
+
const keys = keybindings
|
|
200
|
+
.getKeys(binding)
|
|
201
|
+
.map(String)
|
|
202
|
+
.map((key) => {
|
|
203
|
+
if (key === "return") return "enter";
|
|
204
|
+
if (key === "escape") return "esc";
|
|
205
|
+
return hasControlCharacter(key) ? "" : key;
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
return keys.join("/") || fallback;
|
|
209
|
+
}
|
|
210
|
+
|
|
194
211
|
function hasControlCharacter(value: string) {
|
|
195
212
|
return [...value].some((character) => {
|
|
196
213
|
const code = character.codePointAt(0) ?? 0;
|
package/src/settings-ui.ts
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { RunRoute } from "./cancellable-operation.js";
|
|
3
4
|
import { loadConfig, localConfigPath } from "./config.js";
|
|
4
5
|
import { updateSyncSetup } from "./settings-management.js";
|
|
5
6
|
import {
|
|
6
7
|
SETUP_SWITCH_ACTION_OPTIONS,
|
|
7
|
-
type SetupPullOutcome,
|
|
8
8
|
saveOnSwitch,
|
|
9
9
|
setupSwitchActionFromLabel,
|
|
10
10
|
setupSwitchActionLabel,
|
|
11
11
|
} from "./setup-switch.js";
|
|
12
12
|
import { safeTerminalText } from "./sync-format.js";
|
|
13
13
|
|
|
14
|
-
export type SyncSettingsRoute =
|
|
15
|
-
route: string,
|
|
16
|
-
signal?: AbortSignal,
|
|
17
|
-
onCommit?: () => void,
|
|
18
|
-
setup?: string,
|
|
19
|
-
) => Promise<SetupPullOutcome | undefined>;
|
|
14
|
+
export type SyncSettingsRoute = RunRoute;
|
|
20
15
|
|
|
21
16
|
export async function showSyncSettings(
|
|
22
17
|
ctx: ExtensionCommandContext,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { syncConfigReviewIdentity } from "./config.js";
|
|
2
|
+
import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
|
|
3
|
+
import { includeFromSelectionConfig } from "./sync-policy.js";
|
|
4
|
+
import { syncPolicyChanged } from "./sync-state.js";
|
|
5
|
+
import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type SyncDecisionKind =
|
|
8
|
+
| "remote-or-policy-changed"
|
|
9
|
+
| "both-changed"
|
|
10
|
+
| "first-sync-settings-diverged"
|
|
11
|
+
| "first-sync-sessions-diverged"
|
|
12
|
+
| "remote-empty";
|
|
13
|
+
|
|
14
|
+
export type SyncResolutionDirection = "push" | "pull";
|
|
15
|
+
|
|
16
|
+
export interface SyncDecision {
|
|
17
|
+
kind: SyncDecisionKind;
|
|
18
|
+
setupName: string;
|
|
19
|
+
configIdentity: string;
|
|
20
|
+
causes: {
|
|
21
|
+
localChanged: boolean;
|
|
22
|
+
remoteChanged: boolean;
|
|
23
|
+
policyChanged: boolean;
|
|
24
|
+
};
|
|
25
|
+
previousInclude?: readonly string[];
|
|
26
|
+
currentInclude: readonly string[];
|
|
27
|
+
review: string;
|
|
28
|
+
directions: readonly SyncResolutionDirection[];
|
|
29
|
+
directMessage: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SyncDecisionRequiredError extends Error {
|
|
33
|
+
readonly decision: SyncDecision;
|
|
34
|
+
|
|
35
|
+
constructor(decision: SyncDecision) {
|
|
36
|
+
super(decision.directMessage);
|
|
37
|
+
this.name = "SyncDecisionRequiredError";
|
|
38
|
+
this.decision = decision;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CreateSyncDecisionOptions {
|
|
43
|
+
kind: SyncDecisionKind;
|
|
44
|
+
config: AnySyncConfig;
|
|
45
|
+
state: SyncState;
|
|
46
|
+
local: Snapshot;
|
|
47
|
+
remote?: Snapshot;
|
|
48
|
+
localChanged: boolean;
|
|
49
|
+
remoteChanged: boolean;
|
|
50
|
+
directMessage: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSyncDecision(options: CreateSyncDecisionOptions) {
|
|
54
|
+
const { config, state, local, remote, kind } = options;
|
|
55
|
+
const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
|
|
56
|
+
const previousInclude = includeFromSelectionConfig(state);
|
|
57
|
+
const currentInclude = [...config.include];
|
|
58
|
+
const causes = {
|
|
59
|
+
localChanged: options.localChanged,
|
|
60
|
+
remoteChanged: options.remoteChanged,
|
|
61
|
+
policyChanged,
|
|
62
|
+
};
|
|
63
|
+
const causeLines =
|
|
64
|
+
kind === "first-sync-settings-diverged"
|
|
65
|
+
? ["This machine and the remote have different Pi settings on first sync."]
|
|
66
|
+
: kind === "first-sync-sessions-diverged"
|
|
67
|
+
? ["Pi settings match, but local and remote sessions differ on first sync."]
|
|
68
|
+
: kind === "remote-empty"
|
|
69
|
+
? ["The remote storage location is empty."]
|
|
70
|
+
: [
|
|
71
|
+
...(causes.localChanged ? ["Local content changed since the last sync."] : []),
|
|
72
|
+
...(causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
|
|
73
|
+
...(causes.policyChanged ? ["Included content changed since the last sync."] : []),
|
|
74
|
+
];
|
|
75
|
+
const comparison = remote
|
|
76
|
+
? formatDiff(local, remote)
|
|
77
|
+
: formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
|
|
78
|
+
const review = [
|
|
79
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
80
|
+
"",
|
|
81
|
+
"Why a decision is required:",
|
|
82
|
+
...causeLines,
|
|
83
|
+
...(policyChanged
|
|
84
|
+
? [
|
|
85
|
+
"",
|
|
86
|
+
`Previously included: ${safeList(previousInclude)}`,
|
|
87
|
+
`Currently included: ${safeList(currentInclude)}`,
|
|
88
|
+
]
|
|
89
|
+
: []),
|
|
90
|
+
"",
|
|
91
|
+
"Observed differences:",
|
|
92
|
+
...comparison.split("\n").map(safeTerminalText),
|
|
93
|
+
].join("\n");
|
|
94
|
+
return new SyncDecisionRequiredError({
|
|
95
|
+
kind,
|
|
96
|
+
setupName: config.setupName,
|
|
97
|
+
configIdentity: syncConfigReviewIdentity(config),
|
|
98
|
+
causes,
|
|
99
|
+
...(policyChanged ? { previousInclude: [...previousInclude] } : {}),
|
|
100
|
+
currentInclude,
|
|
101
|
+
review,
|
|
102
|
+
directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
|
|
103
|
+
directMessage: options.directMessage,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
|
|
108
|
+
return error instanceof SyncDecisionRequiredError;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeList(values: readonly string[]) {
|
|
112
|
+
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
113
|
+
}
|
package/src/sync-operations.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type RemoteHead,
|
|
30
30
|
type SyncBackend,
|
|
31
31
|
} from "./sync-backend.js";
|
|
32
|
+
import { createSyncDecision } from "./sync-decision.js";
|
|
32
33
|
import {
|
|
33
34
|
countPreservedRemoteFiles,
|
|
34
35
|
errorMessage,
|
|
@@ -308,9 +309,17 @@ export async function push(
|
|
|
308
309
|
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
309
310
|
: undefined;
|
|
310
311
|
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
311
|
-
throw
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
throw createSyncDecision({
|
|
313
|
+
kind: head ? "remote-or-policy-changed" : "remote-empty",
|
|
314
|
+
config,
|
|
315
|
+
state,
|
|
316
|
+
local,
|
|
317
|
+
remote: remoteForConflict,
|
|
318
|
+
localChanged: hasLocalChanges(local, state, config),
|
|
319
|
+
remoteChanged: true,
|
|
320
|
+
directMessage:
|
|
321
|
+
"Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
|
|
322
|
+
});
|
|
314
323
|
}
|
|
315
324
|
}
|
|
316
325
|
|
|
@@ -330,7 +339,7 @@ export async function push(
|
|
|
330
339
|
}
|
|
331
340
|
|
|
332
341
|
if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
|
|
333
|
-
return;
|
|
342
|
+
return "cancelled" as const;
|
|
334
343
|
}
|
|
335
344
|
|
|
336
345
|
if (options.force) {
|
|
@@ -361,7 +370,7 @@ export async function push(
|
|
|
361
370
|
"Remote changed during review. Push the refreshed plan?",
|
|
362
371
|
))
|
|
363
372
|
) {
|
|
364
|
-
return;
|
|
373
|
+
return "cancelled" as const;
|
|
365
374
|
}
|
|
366
375
|
}
|
|
367
376
|
}
|
|
@@ -395,6 +404,7 @@ export async function push(
|
|
|
395
404
|
result.warnings.length > 0 ? "warning" : "info",
|
|
396
405
|
);
|
|
397
406
|
}
|
|
407
|
+
return "applied" as const;
|
|
398
408
|
}
|
|
399
409
|
|
|
400
410
|
export async function pull(
|
|
@@ -415,14 +425,32 @@ export async function pull(
|
|
|
415
425
|
throwIfAborted(options.signal);
|
|
416
426
|
const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
|
|
417
427
|
throwIfAborted(options.signal);
|
|
418
|
-
if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
|
|
419
|
-
|
|
420
428
|
const localChanged = hasLocalChanges(local, state, config);
|
|
429
|
+
if (!remote) {
|
|
430
|
+
throw createSyncDecision({
|
|
431
|
+
kind: "remote-empty",
|
|
432
|
+
config,
|
|
433
|
+
state,
|
|
434
|
+
local,
|
|
435
|
+
localChanged,
|
|
436
|
+
remoteChanged: false,
|
|
437
|
+
directMessage: "Remote is empty. Run /sync push from a configured machine first.",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
421
441
|
const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
|
|
422
442
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
423
|
-
throw
|
|
424
|
-
"
|
|
425
|
-
|
|
443
|
+
throw createSyncDecision({
|
|
444
|
+
kind: "both-changed",
|
|
445
|
+
config,
|
|
446
|
+
state,
|
|
447
|
+
local,
|
|
448
|
+
remote,
|
|
449
|
+
localChanged,
|
|
450
|
+
remoteChanged,
|
|
451
|
+
directMessage:
|
|
452
|
+
"Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
|
|
453
|
+
});
|
|
426
454
|
}
|
|
427
455
|
|
|
428
456
|
if (
|
|
@@ -519,15 +547,31 @@ export async function syncBoth(
|
|
|
519
547
|
|
|
520
548
|
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
521
549
|
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
522
|
-
throw
|
|
523
|
-
"
|
|
524
|
-
|
|
550
|
+
throw createSyncDecision({
|
|
551
|
+
kind: "first-sync-settings-diverged",
|
|
552
|
+
config,
|
|
553
|
+
state,
|
|
554
|
+
local,
|
|
555
|
+
remote,
|
|
556
|
+
localChanged: true,
|
|
557
|
+
remoteChanged: true,
|
|
558
|
+
directMessage:
|
|
559
|
+
"Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
560
|
+
});
|
|
525
561
|
}
|
|
526
562
|
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
527
563
|
if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
|
|
528
|
-
throw
|
|
529
|
-
"
|
|
530
|
-
|
|
564
|
+
throw createSyncDecision({
|
|
565
|
+
kind: "first-sync-sessions-diverged",
|
|
566
|
+
config,
|
|
567
|
+
state,
|
|
568
|
+
local,
|
|
569
|
+
remote,
|
|
570
|
+
localChanged: true,
|
|
571
|
+
remoteChanged: true,
|
|
572
|
+
directMessage:
|
|
573
|
+
"Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
574
|
+
});
|
|
531
575
|
}
|
|
532
576
|
await pull(ctx, options, factory);
|
|
533
577
|
return;
|
|
@@ -557,9 +601,17 @@ export async function syncBoth(
|
|
|
557
601
|
return;
|
|
558
602
|
}
|
|
559
603
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
560
|
-
throw
|
|
561
|
-
"
|
|
562
|
-
|
|
604
|
+
throw createSyncDecision({
|
|
605
|
+
kind: "both-changed",
|
|
606
|
+
config,
|
|
607
|
+
state,
|
|
608
|
+
local,
|
|
609
|
+
remote,
|
|
610
|
+
localChanged,
|
|
611
|
+
remoteChanged,
|
|
612
|
+
directMessage:
|
|
613
|
+
"Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
|
|
614
|
+
});
|
|
563
615
|
}
|
|
564
616
|
if (remoteChanged) {
|
|
565
617
|
await pull(ctx, options, factory);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
|
|
4
|
+
import { loadConfig, syncConfigReviewIdentity } from "./config.js";
|
|
5
|
+
import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
|
|
6
|
+
import { errorMessage, safeTerminalText } from "./sync-format.js";
|
|
7
|
+
|
|
8
|
+
export type SyncResolutionResult =
|
|
9
|
+
| { kind: "resolved"; direction: SyncResolutionDirection }
|
|
10
|
+
| { kind: "back" }
|
|
11
|
+
| { kind: "closed" }
|
|
12
|
+
| { kind: "stale" };
|
|
13
|
+
|
|
14
|
+
export async function showSyncResolution(
|
|
15
|
+
ctx: ExtensionCommandContext,
|
|
16
|
+
initialDecision: SyncDecision,
|
|
17
|
+
runRoute: RunRoute,
|
|
18
|
+
sessionSignal?: AbortSignal,
|
|
19
|
+
): Promise<SyncResolutionResult> {
|
|
20
|
+
type Screen = "resolve" | "review";
|
|
21
|
+
type Action = "push" | "pull" | "back";
|
|
22
|
+
let currentDecision = initialDecision;
|
|
23
|
+
let resolvedDirection: SyncResolutionDirection | undefined;
|
|
24
|
+
const menu = defineMenu<SyncDecision, Screen, Action, ExtensionCommandContext>({
|
|
25
|
+
start: "resolve",
|
|
26
|
+
screens: {
|
|
27
|
+
resolve: ({ state }) => ({
|
|
28
|
+
kind: "actions",
|
|
29
|
+
title: resolutionTitle(state),
|
|
30
|
+
lines: resolutionLines(state),
|
|
31
|
+
items: [
|
|
32
|
+
{ id: "review", label: "Review differences (recommended)", to: "review" },
|
|
33
|
+
...(state.directions.includes("push")
|
|
34
|
+
? [
|
|
35
|
+
{
|
|
36
|
+
id: "push",
|
|
37
|
+
label: pushLabel(state),
|
|
38
|
+
description: "Review an exact push before replacing the remote version.",
|
|
39
|
+
action: "push" as const,
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
: []),
|
|
43
|
+
...(state.directions.includes("pull")
|
|
44
|
+
? [
|
|
45
|
+
{
|
|
46
|
+
id: "pull",
|
|
47
|
+
label: pullLabel(state),
|
|
48
|
+
description: "Review exact local changes and create a backup before applying.",
|
|
49
|
+
action: "pull" as const,
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
53
|
+
{ id: "back", label: "Back", action: "back" },
|
|
54
|
+
],
|
|
55
|
+
hint: "back",
|
|
56
|
+
}),
|
|
57
|
+
review: ({ state }) => ({
|
|
58
|
+
kind: "review",
|
|
59
|
+
title: `Review differences · ${safeTerminalText(state.setupName)}`,
|
|
60
|
+
content: state.review,
|
|
61
|
+
format: { kind: "text" },
|
|
62
|
+
viewportSize: "adaptive",
|
|
63
|
+
hint: "back",
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
actions: {
|
|
67
|
+
push: ({ state, signal }) => resolveDirection("push", state, signal),
|
|
68
|
+
pull: ({ state, signal }) => resolveDirection("pull", state, signal),
|
|
69
|
+
back: async () => ({ kind: "back" }),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function resolveDirection(
|
|
74
|
+
direction: SyncResolutionDirection,
|
|
75
|
+
decision: SyncDecision,
|
|
76
|
+
actionSignal: AbortSignal,
|
|
77
|
+
) {
|
|
78
|
+
const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
79
|
+
const config = await loadConfig(decision.setupName);
|
|
80
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
81
|
+
if (syncConfigReviewIdentity(config) !== decision.configIdentity) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Sync setup “${safeTerminalText(decision.setupName)}” changed while conflict resolution was open; return to the sync manager and retry.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const result = await runCancellableOperation(
|
|
87
|
+
ctx,
|
|
88
|
+
direction === "push"
|
|
89
|
+
? "Preparing local-wins push preview…"
|
|
90
|
+
: "Preparing remote-wins pull preview…",
|
|
91
|
+
`${direction} --force`,
|
|
92
|
+
runRoute,
|
|
93
|
+
{
|
|
94
|
+
commitAware: true,
|
|
95
|
+
cancelledMessage:
|
|
96
|
+
direction === "push"
|
|
97
|
+
? "Push preparation cancelled; no remote files were changed."
|
|
98
|
+
: "Pull check cancelled; no local files were changed.",
|
|
99
|
+
target: decision.setupName,
|
|
100
|
+
signal,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
if (result.kind === "decision-required") {
|
|
104
|
+
currentDecision = result.decision;
|
|
105
|
+
return { kind: "stay" as const };
|
|
106
|
+
}
|
|
107
|
+
if (result.kind === "completed") {
|
|
108
|
+
if (result.outcome === "cancelled") return { kind: "stay" as const };
|
|
109
|
+
resolvedDirection = direction;
|
|
110
|
+
return { kind: "close" as const };
|
|
111
|
+
}
|
|
112
|
+
return result.kind === "closed" ? { kind: "close" as const } : { kind: "stay" as const };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await runMenu(ctx, menu, {
|
|
116
|
+
getState: () => currentDecision,
|
|
117
|
+
signal: sessionSignal,
|
|
118
|
+
isCurrent: () => !sessionSignal?.aborted,
|
|
119
|
+
onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
|
|
120
|
+
});
|
|
121
|
+
if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
|
|
122
|
+
if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
|
|
123
|
+
if (result.kind === "closed") {
|
|
124
|
+
return result.reason === "back" ? { kind: "back" } : { kind: "closed" };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "closed" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolutionTitle(decision: SyncDecision) {
|
|
130
|
+
return decision.kind === "remote-empty" ? "Remote is empty" : "Resolve sync conflict";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolutionLines(decision: SyncDecision) {
|
|
134
|
+
return [
|
|
135
|
+
`Sync setup: ${safeTerminalText(decision.setupName)}`,
|
|
136
|
+
...causeSummary(decision),
|
|
137
|
+
"No files have been changed by this failed operation.",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function causeSummary(decision: SyncDecision) {
|
|
142
|
+
if (decision.kind === "first-sync-settings-diverged") {
|
|
143
|
+
return ["This machine and the remote have different Pi settings on first sync."];
|
|
144
|
+
}
|
|
145
|
+
if (decision.kind === "first-sync-sessions-diverged") {
|
|
146
|
+
return ["Pi settings match, but local and remote sessions differ on first sync."];
|
|
147
|
+
}
|
|
148
|
+
if (decision.kind === "remote-empty") return ["The remote storage location has no snapshot."];
|
|
149
|
+
return [
|
|
150
|
+
...(decision.causes.localChanged ? ["Local content changed since the last sync."] : []),
|
|
151
|
+
...(decision.causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
|
|
152
|
+
...(decision.causes.policyChanged ? ["Included content changed since the last sync."] : []),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pushLabel(decision: SyncDecision) {
|
|
157
|
+
if (decision.kind === "remote-empty") return "Push local content…";
|
|
158
|
+
if (decision.kind.startsWith("first-sync-")) return "Use local as initial source…";
|
|
159
|
+
return "Keep local content and replace remote…";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pullLabel(decision: SyncDecision) {
|
|
163
|
+
return decision.kind.startsWith("first-sync-")
|
|
164
|
+
? "Use remote as initial source…"
|
|
165
|
+
: "Use remote content and replace local…";
|
|
166
|
+
}
|
package/src/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { RunRouteResult } from "./cancellable-operation.js";
|
|
6
7
|
import {
|
|
7
8
|
completeSyncArguments,
|
|
8
9
|
parseOptions,
|
|
@@ -33,6 +34,7 @@ import { showSetupWizard, showSyncManager } from "./manager-ui.js";
|
|
|
33
34
|
import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
|
|
34
35
|
import { createSnapshot } from "./snapshot.js";
|
|
35
36
|
import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
|
|
37
|
+
import { isSyncDecisionRequiredError } from "./sync-decision.js";
|
|
36
38
|
import { errorMessage } from "./sync-format.js";
|
|
37
39
|
import {
|
|
38
40
|
diff,
|
|
@@ -141,7 +143,10 @@ async function handleCommand(
|
|
|
141
143
|
}
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
|
-
await executeCommand(rawArgs, ctx, sessionSignal);
|
|
146
|
+
const result = await executeCommand(rawArgs, ctx, sessionSignal);
|
|
147
|
+
if (result.kind === "decision-required") {
|
|
148
|
+
ctx.ui.notify(result.decision.directMessage, "error");
|
|
149
|
+
}
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
async function executeCommand(
|
|
@@ -150,10 +155,10 @@ async function executeCommand(
|
|
|
150
155
|
signal?: AbortSignal,
|
|
151
156
|
onCommit?: () => void,
|
|
152
157
|
setup?: string,
|
|
153
|
-
) {
|
|
158
|
+
): Promise<RunRouteResult> {
|
|
154
159
|
try {
|
|
155
160
|
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
156
|
-
if (signal?.aborted || !command) return;
|
|
161
|
+
if (signal?.aborted || !command) return { kind: "completed" };
|
|
157
162
|
const { subcommand, rest } = command;
|
|
158
163
|
const options = parseOptions(rest);
|
|
159
164
|
if (setup !== undefined) options.setup = setup;
|
|
@@ -164,7 +169,7 @@ async function executeCommand(
|
|
|
164
169
|
switch (subcommand) {
|
|
165
170
|
case "help":
|
|
166
171
|
ctx.ui.notify(usage(), "info");
|
|
167
|
-
return;
|
|
172
|
+
return { kind: "completed" };
|
|
168
173
|
case "use":
|
|
169
174
|
await useSyncSetup(
|
|
170
175
|
ctx,
|
|
@@ -174,50 +179,58 @@ async function executeCommand(
|
|
|
174
179
|
undefined,
|
|
175
180
|
options.signal,
|
|
176
181
|
);
|
|
177
|
-
return;
|
|
182
|
+
return { kind: "completed" };
|
|
178
183
|
case "init":
|
|
179
184
|
await initConfig(ctx, signal);
|
|
180
|
-
return;
|
|
185
|
+
return { kind: "completed" };
|
|
181
186
|
case "config":
|
|
182
187
|
await showConfig(ctx, options);
|
|
183
|
-
return;
|
|
188
|
+
return { kind: "completed" };
|
|
184
189
|
case "files":
|
|
185
190
|
await showFileSelection(ctx, options.setup, options.signal);
|
|
186
|
-
return;
|
|
191
|
+
return { kind: "completed" };
|
|
187
192
|
case "status":
|
|
188
193
|
await status(ctx, options);
|
|
189
|
-
return;
|
|
194
|
+
return { kind: "completed" };
|
|
190
195
|
case "diff":
|
|
191
196
|
await diff(ctx, options);
|
|
192
|
-
return;
|
|
197
|
+
return { kind: "completed" };
|
|
193
198
|
case "doctor":
|
|
194
199
|
await doctor(ctx, options);
|
|
195
|
-
return;
|
|
196
|
-
case "push":
|
|
197
|
-
await withLock("push", () => push(ctx, options));
|
|
198
|
-
return;
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
return { kind: "completed" };
|
|
201
|
+
case "push": {
|
|
202
|
+
const outcome = await withLock("push", () => push(ctx, options));
|
|
203
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
204
|
+
}
|
|
205
|
+
case "pull": {
|
|
206
|
+
const outcome = await withLock("pull", () => pull(ctx, options));
|
|
207
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
208
|
+
}
|
|
201
209
|
case "sync":
|
|
202
210
|
await withLock("sync", () => syncBoth(ctx, options));
|
|
203
|
-
return;
|
|
211
|
+
return { kind: "completed" };
|
|
204
212
|
case "history":
|
|
205
213
|
await history(ctx, options);
|
|
206
|
-
return;
|
|
214
|
+
return { kind: "completed" };
|
|
207
215
|
case "rollback":
|
|
208
216
|
await withLock("rollback", () => rollback(ctx, options));
|
|
209
|
-
return;
|
|
217
|
+
return { kind: "completed" };
|
|
210
218
|
case "unlock":
|
|
211
219
|
await unlock(ctx, options);
|
|
212
|
-
return;
|
|
220
|
+
return { kind: "completed" };
|
|
213
221
|
default:
|
|
214
222
|
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
223
|
+
return { kind: "failed" };
|
|
215
224
|
}
|
|
216
225
|
} catch (error) {
|
|
217
|
-
if (signal?.aborted) return;
|
|
226
|
+
if (signal?.aborted) return { kind: "failed" };
|
|
218
227
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
219
228
|
if (error instanceof SetupPullRequiresUiError) throw error;
|
|
229
|
+
if (isSyncDecisionRequiredError(error)) {
|
|
230
|
+
return { kind: "decision-required", decision: error.decision };
|
|
231
|
+
}
|
|
220
232
|
ctx.ui.notify(errorMessage(error), "error");
|
|
233
|
+
return { kind: "failed" };
|
|
221
234
|
}
|
|
222
235
|
}
|
|
223
236
|
|