@narumitw/pi-sync 0.43.0 → 0.46.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.43.0",
3
+ "version": "0.46.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.40.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,116 @@
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
+
11
+ export type RunRoute = (
12
+ route: string,
13
+ signal?: AbortSignal,
14
+ onCommit?: () => void,
15
+ target?: string,
16
+ ) => Promise<SetupPullOutcome | undefined>;
17
+
18
+ export type CancellableOperationResult = SetupPullOutcome | "closed" | undefined;
19
+
20
+ interface CancellableOperationOptions {
21
+ commitAware?: boolean;
22
+ cancelledMessage?: string | null;
23
+ target?: string;
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ export async function runCancellableOperation(
28
+ ctx: ExtensionCommandContext,
29
+ message: string,
30
+ route: string,
31
+ runRoute: RunRoute,
32
+ options: CancellableOperationOptions = {},
33
+ ): Promise<CancellableOperationResult> {
34
+ const {
35
+ commitAware = false,
36
+ cancelledMessage = "Check cancelled; no settings or files were changed.",
37
+ target,
38
+ signal,
39
+ } = options;
40
+ if (ctx.mode !== "tui") {
41
+ return await runRoute(route, undefined, undefined, target);
42
+ }
43
+ let commitStarted = false;
44
+ let routeResult: SetupPullOutcome | undefined;
45
+ const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
46
+ signal,
47
+ isCurrent: () => !signal?.aborted,
48
+ create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
49
+ const loader = new BorderedLoader(tui, theme, message, { cancellable: false });
50
+ const cancelHint = `${keybindingText(keybindings, "tui.select.cancel", "esc")} cancel`;
51
+ const operation = runRoute(
52
+ route,
53
+ interactionSignal,
54
+ commitAware ? () => (commitStarted = true) : undefined,
55
+ target,
56
+ ).then(
57
+ (result) => {
58
+ routeResult = result;
59
+ complete({});
60
+ },
61
+ (error: unknown) => complete({ error }),
62
+ );
63
+ return {
64
+ render(width: number) {
65
+ const safeWidth = Math.max(1, width);
66
+ const lines = loader.render(safeWidth);
67
+ const bottomBorder = lines.at(-1);
68
+ return [
69
+ ...lines.slice(0, -1),
70
+ truncateToWidth(theme.fg("dim", cancelHint), safeWidth, ""),
71
+ ...(bottomBorder === undefined ? [] : [bottomBorder]),
72
+ ];
73
+ },
74
+ invalidate: () => loader.invalidate(),
75
+ handleInput(data: string) {
76
+ if (!keybindings.matches(data, "tui.select.cancel")) return;
77
+ if (commitStarted) {
78
+ ctx.ui.notify(
79
+ "Applying or publishing has started and cannot be cancelled safely.",
80
+ "warning",
81
+ );
82
+ return;
83
+ }
84
+ complete({ cancelled: true });
85
+ },
86
+ dispose: () => loader.dispose(),
87
+ waitForPending: () => operation,
88
+ };
89
+ },
90
+ });
91
+ if (interaction.kind === "error") throw interaction.error;
92
+ if (interaction.kind !== "completed") return "closed";
93
+ if (interaction.value.cancelled) {
94
+ if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
95
+ return "cancelled";
96
+ }
97
+ if (interaction.value.error) throw interaction.value.error;
98
+ return routeResult;
99
+ }
100
+
101
+ function keybindingText(
102
+ keybindings: Pick<KeybindingsManager, "getKeys">,
103
+ binding: Parameters<KeybindingsManager["getKeys"]>[0],
104
+ fallback: string,
105
+ ) {
106
+ const keys = keybindings
107
+ .getKeys(binding)
108
+ .map(String)
109
+ .map((key) => {
110
+ if (key === "return") return "enter";
111
+ if (key === "escape") return "esc";
112
+ return safeTerminalText(key);
113
+ })
114
+ .filter(Boolean);
115
+ return keys.join("/") || fallback;
116
+ }
package/src/manager-ui.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
3
4
  import { setSyncSetupCompletions } from "./command.js";
4
5
  import {
5
6
  configuredSyncSetupNames,
@@ -29,20 +30,13 @@ import {
29
30
  updateSyncSetup,
30
31
  } from "./settings-management.js";
31
32
  import { showSyncSettings } from "./settings-ui.js";
32
- import { type SetupPullOutcome, useSyncSetup } from "./setup-switch.js";
33
+ import { useSyncSetup } from "./setup-switch.js";
33
34
  import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
34
35
  import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
35
36
  import { showSyncSetups } from "./sync-setups-ui.js";
36
37
  import type { AnySyncConfig } from "./types.js";
37
38
  import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
38
39
 
39
- type RunRoute = (
40
- route: string,
41
- signal?: AbortSignal,
42
- onCommit?: () => void,
43
- target?: string,
44
- ) => Promise<SetupPullOutcome | undefined>;
45
-
46
40
  export async function showSyncManager(
47
41
  ctx: ExtensionCommandContext,
48
42
  runRoute: RunRoute,
@@ -116,16 +110,33 @@ export async function showSyncManager(
116
110
  },
117
111
  actions: {
118
112
  sync: async () => {
119
- await runCancellableOperation(ctx, "Checking current sync setup…", "sync", runRoute, true);
120
- return { kind: "stay" };
113
+ const result = await runCancellableOperation(
114
+ ctx,
115
+ "Checking current sync setup…",
116
+ "sync",
117
+ runRoute,
118
+ {
119
+ commitAware: true,
120
+ signal: sessionSignal,
121
+ },
122
+ );
123
+ return result === "closed" ? { kind: "close" } : { kind: "stay" };
121
124
  },
122
125
  switch: async () => {
123
126
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
124
- return result === "pull-attempted" ? { kind: "close" } : { kind: "stay" };
127
+ return result === "pull-attempted" || result === "closed"
128
+ ? { kind: "close" }
129
+ : { kind: "stay" };
125
130
  },
126
131
  diff: async () => {
127
- await runCancellableOperation(ctx, "Checking current sync setup…", "diff", runRoute);
128
- return { kind: "stay" };
132
+ const result = await runCancellableOperation(
133
+ ctx,
134
+ "Checking current sync setup…",
135
+ "diff",
136
+ runRoute,
137
+ { signal: sessionSignal },
138
+ );
139
+ return result === "closed" ? { kind: "close" } : { kind: "stay" };
129
140
  },
130
141
  settings: async () => {
131
142
  await showSyncSettings(ctx, runRoute, sessionSignal);
@@ -137,21 +148,27 @@ export async function showSyncManager(
137
148
  "Checking remote changes…",
138
149
  "pull",
139
150
  runRoute,
140
- true,
141
- "Pull check cancelled; no local files were changed.",
151
+ {
152
+ commitAware: true,
153
+ cancelledMessage: "Pull check cancelled; no local files were changed.",
154
+ signal: sessionSignal,
155
+ },
142
156
  );
143
- return result === "applied" ? { kind: "close" } : { kind: "stay" };
157
+ return result === "applied" || result === "closed" ? { kind: "close" } : { kind: "stay" };
144
158
  },
145
159
  push: async () => {
146
- await runCancellableOperation(
160
+ const result = await runCancellableOperation(
147
161
  ctx,
148
162
  "Preparing push preview…",
149
163
  "push",
150
164
  runRoute,
151
- true,
152
- "Push preparation cancelled; no remote files were changed.",
165
+ {
166
+ commitAware: true,
167
+ cancelledMessage: "Push preparation cancelled; no remote files were changed.",
168
+ signal: sessionSignal,
169
+ },
153
170
  );
154
- return { kind: "stay" };
171
+ return result === "closed" ? { kind: "close" } : { kind: "stay" };
155
172
  },
156
173
  setups: async () => {
157
174
  const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
@@ -226,61 +243,6 @@ function syncMainMenuItem(
226
243
  return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
227
244
  }
228
245
 
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
246
  export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
285
247
  if (ctx.mode !== "tui") {
286
248
  ctx.ui.notify(
@@ -543,23 +505,34 @@ async function showSetupSwitcher(
543
505
  );
544
506
  if (signal?.aborted || !confirmed) return false;
545
507
  try {
508
+ let pullClosed = false;
546
509
  const result = await useSyncSetup(
547
510
  ctx,
548
511
  name,
549
- (selectedTarget) =>
550
- runCancellableOperation(
512
+ async (selectedTarget) => {
513
+ const pullResult = await runCancellableOperation(
551
514
  ctx,
552
515
  `Pulling sync setup “${safeTerminalText(name)}”…`,
553
516
  "pull",
554
517
  runRoute,
555
- true,
556
- null,
557
- selectedTarget,
558
- ),
518
+ {
519
+ commitAware: true,
520
+ cancelledMessage: null,
521
+ target: selectedTarget,
522
+ signal,
523
+ },
524
+ );
525
+ if (pullResult === "closed") {
526
+ pullClosed = true;
527
+ return undefined;
528
+ }
529
+ return pullResult;
530
+ },
559
531
  onSwitch,
560
532
  signal,
561
533
  syncConfigReviewIdentity(config),
562
534
  );
535
+ if (pullClosed) return "closed";
563
536
  return result.pullApplied ? "pull-attempted" : "switched";
564
537
  } catch (error) {
565
538
  if (signal?.aborted) return false;
@@ -587,7 +560,7 @@ async function showSyncSetupManager(
587
560
  },
588
561
  makeCurrent: async (name, setupSignal) => {
589
562
  const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
590
- return result === "pull-attempted" ? "exit" : undefined;
563
+ return result === "pull-attempted" || result === "closed" ? "exit" : undefined;
591
564
  },
592
565
  remove: async (name, setupSignal) => {
593
566
  await showRemoveTarget(ctx, name, setupSignal);
@@ -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 value = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
20
- const heading = new Text("", 0, 0);
21
- const hint = new Text("", 0, 0);
22
- const applyTheme = () => {
23
- heading.setText(theme.fg("accent", theme.bold(title)));
24
- hint.setText(theme.fg("dim", "Enter save • Escape cancel • Input is hidden"));
25
- };
26
- applyTheme();
27
- const input = new MaskedInput(keybindings);
28
- let settled = false;
29
- const finish = (result: string | undefined) => {
30
- if (settled) return;
31
- settled = true;
32
- done(result);
33
- };
34
- const onAbort = () => finish(undefined);
35
- options.signal?.addEventListener("abort", onAbort, { once: true });
36
- const component: Focusable & {
37
- render(width: number): string[];
38
- invalidate(): void;
39
- handleInput(data: string): void;
40
- dispose(): void;
41
- } = {
42
- get focused() {
43
- return input.focused;
44
- },
45
- set focused(focused: boolean) {
46
- input.focused = focused;
47
- },
48
- render(width: number) {
49
- const safeWidth = Math.max(1, width);
50
- return [
51
- ...heading.render(safeWidth),
52
- ...input.render(safeWidth),
53
- ...hint.render(safeWidth),
54
- ].map((line) => truncateToWidth(line, safeWidth));
55
- },
56
- invalidate() {
57
- applyTheme();
58
- heading.invalidate();
59
- input.invalidate();
60
- hint.invalidate();
61
- },
62
- handleInput(data: string) {
63
- if (keybindings.matches(data, "tui.select.cancel")) finish(undefined);
64
- else if (keybindings.matches(data, "tui.input.submit")) finish(input.getValue());
65
- else input.handleInput(data);
66
- tui.requestRender();
67
- },
68
- dispose() {
69
- options.signal?.removeEventListener("abort", onAbort);
70
- input.clear();
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 (value === undefined) return undefined;
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;