@narumitw/pi-sync 0.53.0 → 0.54.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.
Files changed (53) hide show
  1. package/README.md +6 -4
  2. package/dist/chunks/{chunk-I5IG4WV4.ts → chunk-5ZWQ33XL.ts} +39 -19
  3. package/dist/chunks/{chunk-I5IG4WV4.ts.map → chunk-5ZWQ33XL.ts.map} +2 -2
  4. package/dist/chunks/{chunk-QTCGWQP3.ts → chunk-QDPED3XJ.ts} +67 -8
  5. package/dist/chunks/{chunk-QTCGWQP3.ts.map → chunk-QDPED3XJ.ts.map} +3 -3
  6. package/dist/chunks/{chunk-FHNISD7A.ts → chunk-SDZCTCYQ.ts} +4 -4
  7. package/dist/chunks/{chunk-NGIJQSRE.ts → chunk-VHXBCEVF.ts} +2 -2
  8. package/dist/chunks/{chunk-BX3K7YW7.ts → chunk-WFKUKVCP.ts} +2 -2
  9. package/dist/chunks/{chunk-UETBKTDI.ts → chunk-YDVQYUHE.ts} +1 -1
  10. package/dist/chunks/{chunk-UETBKTDI.ts.map → chunk-YDVQYUHE.ts.map} +2 -2
  11. package/dist/chunks/{conflict-review-NK5CM6UA.ts → conflict-review-JANNPKKS.ts} +4 -4
  12. package/dist/chunks/{git-backend-HLGKUQJT.ts → git-backend-FD7AVSK6.ts} +2 -2
  13. package/dist/chunks/{manager-result-dispatcher-35TM3ZR3.ts → manager-result-dispatcher-6MQKFCWB.ts} +4 -4
  14. package/dist/chunks/{manager-ui-6AYMNXUP.ts → manager-ui-IURS4WXY.ts} +675 -610
  15. package/dist/chunks/manager-ui-IURS4WXY.ts.map +7 -0
  16. package/dist/chunks/{remote-selection-ui-BB5SX6P5.ts → remote-selection-ui-6LJJ4IMQ.ts} +3 -3
  17. package/dist/chunks/{s3-backend-O2AS3TH6.ts → s3-backend-AQGV6LFT.ts} +18 -4
  18. package/dist/chunks/s3-backend-AQGV6LFT.ts.map +7 -0
  19. package/dist/chunks/{sync-inspection-RLIHUYDV.ts → sync-inspection-K45QVLHB.ts} +3 -3
  20. package/dist/chunks/{sync-operations-RBUOUJWL.ts → sync-operations-OWTE3ZL2.ts} +18 -12
  21. package/dist/chunks/{sync-operations-RBUOUJWL.ts.map → sync-operations-OWTE3ZL2.ts.map} +2 -2
  22. package/dist/chunks/{webdav-backend-ETQGSI6I.ts → webdav-backend-TBILVEQK.ts} +2 -2
  23. package/dist/index.ts +33 -8
  24. package/dist/index.ts.map +3 -3
  25. package/docs/settings.md +11 -3
  26. package/package.json +4 -4
  27. package/src/backends/s3/s3-backend.ts +22 -1
  28. package/src/backends/s3/s3-client.ts +3 -1
  29. package/src/backends/sync-backend.ts +2 -0
  30. package/src/commands/command-execution.ts +3 -0
  31. package/src/commands/command-handler.ts +8 -1
  32. package/src/sync/merge-transfer-error.ts +72 -0
  33. package/src/sync/merged-sync.ts +8 -5
  34. package/src/sync/sync-mutations.ts +9 -7
  35. package/src/sync/sync-queries.ts +1 -0
  36. package/src/ui/cancellable-operation.ts +47 -17
  37. package/src/ui/manager-attention.ts +3 -5
  38. package/src/ui/manager-state.ts +32 -15
  39. package/src/ui/manager-ui.ts +59 -87
  40. package/src/ui/operation-dialogs.ts +29 -0
  41. package/src/ui/settings-ui.ts +140 -46
  42. package/src/ui/setup/setup-actions.ts +2 -2
  43. package/dist/chunks/manager-ui-6AYMNXUP.ts.map +0 -7
  44. package/dist/chunks/s3-backend-O2AS3TH6.ts.map +0 -7
  45. /package/dist/chunks/{chunk-FHNISD7A.ts.map → chunk-SDZCTCYQ.ts.map} +0 -0
  46. /package/dist/chunks/{chunk-NGIJQSRE.ts.map → chunk-VHXBCEVF.ts.map} +0 -0
  47. /package/dist/chunks/{chunk-BX3K7YW7.ts.map → chunk-WFKUKVCP.ts.map} +0 -0
  48. /package/dist/chunks/{conflict-review-NK5CM6UA.ts.map → conflict-review-JANNPKKS.ts.map} +0 -0
  49. /package/dist/chunks/{git-backend-HLGKUQJT.ts.map → git-backend-FD7AVSK6.ts.map} +0 -0
  50. /package/dist/chunks/{manager-result-dispatcher-35TM3ZR3.ts.map → manager-result-dispatcher-6MQKFCWB.ts.map} +0 -0
  51. /package/dist/chunks/{remote-selection-ui-BB5SX6P5.ts.map → remote-selection-ui-6LJJ4IMQ.ts.map} +0 -0
  52. /package/dist/chunks/{sync-inspection-RLIHUYDV.ts.map → sync-inspection-K45QVLHB.ts.map} +0 -0
  53. /package/dist/chunks/{webdav-backend-ETQGSI6I.ts.map → webdav-backend-TBILVEQK.ts.map} +0 -0
@@ -574,15 +574,17 @@ export async function rollback(
574
574
  validateMutation();
575
575
  const physical = overlayLocalFields(remote, local, config.localFields);
576
576
 
577
- if (
578
- !options.yes &&
579
- !(await ctx.ui.confirm(
577
+ if (!options.yes) {
578
+ const confirmed = await ctx.ui.confirm(
580
579
  snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
581
580
  formatRollbackSummary(config, backend.destination, local, physical, target, protectedSessionPaths(ctx).size),
582
- ))
583
- ) {
584
- ctx.ui.notify("Rollback cancelled.", "info");
585
- return;
581
+ { signal: options.signal },
582
+ );
583
+ validateMutation();
584
+ if (!confirmed) {
585
+ ctx.ui.notify("Rollback cancelled.", "info");
586
+ return;
587
+ }
586
588
  }
587
589
 
588
590
  validateMutation();
@@ -199,6 +199,7 @@ export async function history(
199
199
  const selected = await ctx.ui.select(
200
200
  `History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
201
201
  [...labels, "Back"],
202
+ { signal: options.signal },
202
203
  );
203
204
  if (!selected || selected === "Back") return;
204
205
  throwIfAborted(options.signal);
@@ -1,5 +1,5 @@
1
1
  import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { formatInteractionHints, runCustomInteraction } from "@narumitw/pi-tui-kit";
4
4
  import type { SetupPullOutcome } from "../sync/setup-switch.js";
5
5
  import type { SyncDecision } from "../sync/sync-decision.js";
@@ -16,12 +16,14 @@ export type RunRoute = (
16
16
  signal?: AbortSignal,
17
17
  onCommit?: () => void,
18
18
  target?: string,
19
+ onDialog?: (active: boolean) => void,
19
20
  ) => Promise<RunRouteResult | undefined>;
20
21
 
21
22
  export type CancellableOperationResult = RunRouteResult | { kind: "closed" } | { kind: "cancelled" };
22
23
 
23
24
  interface CancellableOperationOptions {
24
25
  commitAware?: boolean;
26
+ cancelAcrossDialogs?: boolean;
25
27
  cancelledMessage?: string | null;
26
28
  target?: string;
27
29
  signal?: AbortSignal;
@@ -36,6 +38,7 @@ export async function runCancellableOperation(
36
38
  ): Promise<CancellableOperationResult> {
37
39
  const {
38
40
  commitAware = false,
41
+ cancelAcrossDialogs = false,
39
42
  cancelledMessage = "Check cancelled; no settings or files were changed.",
40
43
  target,
41
44
  signal,
@@ -56,12 +59,45 @@ export async function runCancellableOperation(
56
59
  label: "cancel",
57
60
  },
58
61
  ]);
59
- const operation = runRoute(
60
- route,
61
- interactionSignal,
62
- commitAware ? () => (commitStarted = true) : undefined,
63
- target,
64
- ).then(
62
+ const cancel = (data: string) => {
63
+ // Raw terminal listeners run before Pi filters Kitty key releases.
64
+ if (isKeyRelease(data)) return false;
65
+ if (!matchesKey(data, Key.ctrl("c")) && !keybindings.matches(data, "tui.select.cancel")) return false;
66
+ if (commitStarted) {
67
+ ctx.ui.notify("Applying or publishing has started and cannot be cancelled safely.", "warning");
68
+ } else complete({ cancelled: true });
69
+ return true;
70
+ };
71
+ // Pi dialogs replace this loader and restore the normal editor. Defer
72
+ // ordinary keys while they are open; retain raw cancellation afterward.
73
+ // Ctrl+C remains the root flow's hard-cancel path regardless of focus.
74
+ let activeDialogs = 0;
75
+ const unsubscribe = cancelAcrossDialogs
76
+ ? ctx.ui.onTerminalInput((data) => {
77
+ if (isKeyRelease(data)) return;
78
+ if (activeDialogs > 0 && !matchesKey(data, Key.ctrl("c"))) return;
79
+ return cancel(data) ? { consume: true } : undefined;
80
+ })
81
+ : undefined;
82
+ let pendingRoute: ReturnType<RunRoute>;
83
+ try {
84
+ pendingRoute = runRoute(
85
+ route,
86
+ interactionSignal,
87
+ commitAware ? () => (commitStarted = true) : undefined,
88
+ target,
89
+ cancelAcrossDialogs
90
+ ? (active) => {
91
+ activeDialogs += active ? 1 : -1;
92
+ }
93
+ : undefined,
94
+ );
95
+ } catch (error) {
96
+ unsubscribe?.();
97
+ loader.dispose();
98
+ throw error;
99
+ }
100
+ const operation = pendingRoute.then(
65
101
  (result) => {
66
102
  routeResult = result;
67
103
  complete({});
@@ -80,17 +116,11 @@ export async function runCancellableOperation(
80
116
  ];
81
117
  },
82
118
  invalidate: () => loader.invalidate(),
83
- handleInput(data: string) {
84
- if (!matchesKey(data, Key.ctrl("c")) && !keybindings.matches(data, "tui.select.cancel")) {
85
- return;
86
- }
87
- if (commitStarted) {
88
- ctx.ui.notify("Applying or publishing has started and cannot be cancelled safely.", "warning");
89
- return;
90
- }
91
- complete({ cancelled: true });
119
+ handleInput: cancel,
120
+ dispose() {
121
+ unsubscribe?.();
122
+ loader.dispose();
92
123
  },
93
- dispose: () => loader.dispose(),
94
124
  waitForPending: () => operation,
95
125
  };
96
126
  },
@@ -13,9 +13,7 @@ export interface SyncManagerAttentionOptions {
13
13
  onSelectionResolved?: (expected: SyncAttentionState) => void;
14
14
  }
15
15
 
16
- export function attentionMainMenuItems(
17
- manager: ManagerDescription,
18
- ): ActionMenuItem<"main" | "more" | "recovery", "review-attention">[] {
16
+ export function attentionMainMenuItems(manager: ManagerDescription): ActionMenuItem<"main", "review-attention">[] {
19
17
  if (!manager.attention && manager.observation?.inspection.selectionState?.kind !== "different") return [];
20
18
  const disabled = manager.attentionReviewDisabled === true;
21
19
  return [
@@ -36,8 +34,8 @@ export function attentionMainMenuItems(
36
34
  export function blockedSyncMenuItem(
37
35
  label: string,
38
36
  manager: ManagerDescription,
39
- ): ActionMenuItem<"main" | "more" | "recovery", "sync"> | undefined {
40
- if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return undefined;
37
+ ): ActionMenuItem<"main", "sync"> | undefined {
38
+ if (label !== "Sync now" || !manager.attentionBlocksSync) return undefined;
41
39
  return {
42
40
  id: "sync",
43
41
  label,
@@ -25,11 +25,12 @@ import { countValidSyncSetups } from "./sync-setups-ui.js";
25
25
  import { safeTerminalText } from "./terminal-text.js";
26
26
 
27
27
  export const MAIN_MENU_ACTIONS = [
28
- "Sync now (recommended)",
29
- "Switch sync setup",
30
- "Status & changes",
28
+ "Sync now",
29
+ "Pull from remote…",
30
+ "Push to remote…",
31
31
  "Settings",
32
- "More…",
32
+ "History",
33
+ "Diagnostics",
33
34
  ] as const;
34
35
 
35
36
  export interface ManagerDescription {
@@ -125,7 +126,9 @@ export async function describeManagerState(
125
126
  ? "Never synced"
126
127
  : "Unavailable";
127
128
  const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
128
- const mainActions = MAIN_MENU_ACTIONS.filter((action) => action !== "Switch sync setup" || canSwitch);
129
+ const mainActions: string[] = [...MAIN_MENU_ACTIONS];
130
+ if (canSwitch) mainActions.push("Switch sync setup");
131
+ if (syncState?.unresolved?.length) mainActions.unshift("Review unresolved conflicts");
129
132
  const ordinaryTitle = [
130
133
  "Manage sync",
131
134
  "",
@@ -137,7 +140,7 @@ export async function describeManagerState(
137
140
  `Last applied: ${lastAppliedSnapshot}`,
138
141
  ...(syncState?.unresolved?.length
139
142
  ? [
140
- `Unresolved: ${syncState.unresolved.length} dependency groups · review History & recovery`,
143
+ `Unresolved: ${syncState.unresolved.length} dependency groups · choose Review unresolved conflicts`,
141
144
  `Last observed: ${safeTerminalText(syncState.lastObservedSnapshot ?? "unknown")}`,
142
145
  ]
143
146
  : []),
@@ -189,7 +192,7 @@ export async function describeManagerState(
189
192
  "",
190
193
  "What do you want to do?",
191
194
  ].join("\n"),
192
- actions: ["Sync setups…", "Storage connections…", "History & recovery…", "Help"],
195
+ actions: ["Sync setups…", "Storage connections…", "History", "Help"],
193
196
  };
194
197
  }
195
198
  }
@@ -201,13 +204,19 @@ function operationActions(
201
204
  mainActions: string[],
202
205
  ) {
203
206
  if (operationCanRecover(operation)) {
204
- return ["Restore sync access… (recommended)", "Status & changes", "History & recovery…", "Help"];
207
+ return ["Restore sync access… (recommended)", "History", "Help"];
205
208
  }
206
209
  if (operation.kind !== "free") {
207
- return ["Refresh operation status", "Status & changes", "History & recovery…", "Help"];
210
+ return ["Refresh operation status", "History", "Help"];
208
211
  }
209
212
  return noSyncedContent
210
- ? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
213
+ ? [
214
+ ...(mainActions.includes("Review unresolved conflicts") ? ["Review unresolved conflicts"] : []),
215
+ "Settings",
216
+ ...(canSwitch ? ["Switch sync setup"] : []),
217
+ "History",
218
+ "Diagnostics",
219
+ ]
211
220
  : mainActions;
212
221
  }
213
222
 
@@ -217,16 +226,24 @@ function operationStatusLines(operation: OperationAvailability): string[] {
217
226
  return [];
218
227
  case "live": {
219
228
  const command = truncateToWidth(safeTerminalText(operation.lock.command), 16, "…");
220
- return [`Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.`];
229
+ return [
230
+ `Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Transfer actions and Settings return.`,
231
+ ];
221
232
  }
222
233
  case "busy":
223
- return ["Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable."];
234
+ return [
235
+ "Pi-sync may be starting or finishing. Wait, then refresh; Transfer actions and Settings remain unavailable.",
236
+ ];
224
237
  case "recoverable-stale":
225
- return ["Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return."];
238
+ return [
239
+ "Sync paused: old lock remains. Close other Pi sessions then restore; Transfer actions and Settings return.",
240
+ ];
226
241
  case "recoverable-unreadable":
227
- return ["Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return."];
242
+ return [
243
+ "Sync paused: owner unknown. Close other Pi sessions then restore; Transfer actions and Settings return.",
244
+ ];
228
245
  case "inspection-error":
229
- return ["Lock check failed. Fix path access, then refresh; Settings and More remain unavailable."];
246
+ return ["Lock check failed. Fix path access, then refresh; Transfer actions and Settings remain unavailable."];
230
247
  }
231
248
  }
232
249
 
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
  import { loadConfig } from "../settings/config.js";
4
- import { operationBlocksChanges, operationCanRecover } from "../state/operation-availability.js";
4
+ import { operationBlocksChanges } from "../state/operation-availability.js";
5
5
  import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
6
6
  import {
7
7
  attentionMainMenuItems,
@@ -28,12 +28,11 @@ export async function showSyncManager(
28
28
  await runRoute("help");
29
29
  return;
30
30
  }
31
- type Screen = "main" | "more" | "recovery";
31
+ type Screen = "main";
32
32
  type Action =
33
33
  | "review-attention"
34
34
  | "sync"
35
35
  | "switch"
36
- | "diff"
37
36
  | "settings"
38
37
  | "pull"
39
38
  | "push"
@@ -42,12 +41,10 @@ export async function showSyncManager(
42
41
  | "history"
43
42
  | "conflicts"
44
43
  | "doctor"
45
- | "unlock"
46
44
  | "recover"
47
45
  | "refresh"
48
46
  | "help"
49
- | "init"
50
- | "back";
47
+ | "init";
51
48
  interface State {
52
49
  manager: Awaited<ReturnType<typeof describeManagerState>>;
53
50
  }
@@ -63,54 +60,15 @@ export async function showSyncManager(
63
60
  return {
64
61
  kind: "actions",
65
62
  title: "Manage sync",
66
- lines: state.manager.title.split("\n").slice(1),
63
+ // Frequent transfers stay visible; status is advisory and Help remains a direct route.
64
+ lines: [
65
+ ...state.manager.title.split("\n").slice(1),
66
+ "Help: /sync help · Fresh status: /sync status · Differences: /sync diff",
67
+ ],
67
68
  items: operationFirst ? [...managerItems, ...attentionItems] : [...attentionItems, ...managerItems],
68
69
  hint: "close",
69
70
  };
70
71
  },
71
- more: () => ({
72
- kind: "actions",
73
- title: "More options",
74
- items: [
75
- { id: "pull", label: "Pull from remote…", action: "pull" },
76
- { id: "push", label: "Push to remote…", action: "push" },
77
- { id: "setups", label: "Sync setups…", action: "setups" },
78
- {
79
- id: "connections",
80
- label: "Storage connections…",
81
- action: "connections",
82
- },
83
- {
84
- id: "doctor",
85
- label: "Check setup",
86
- description: "Check current setup access. WebDAV also probes writes and repairs history.",
87
- action: "doctor",
88
- },
89
- { id: "recovery", label: "History & recovery…", to: "recovery" },
90
- { id: "help", label: "Help", action: "help" },
91
- { id: "back", label: "Back", action: "back" },
92
- ],
93
- hint: "back",
94
- }),
95
- recovery: ({ state }) => ({
96
- kind: "actions",
97
- title: "History & recovery",
98
- items: [
99
- { id: "history", label: "Browse history", action: "history" },
100
- { id: "conflicts", label: "Review unresolved conflicts", action: "conflicts" },
101
- {
102
- id: "doctor",
103
- label: "Check setup",
104
- description: "WebDAV also probes writes and repairs history.",
105
- action: "doctor",
106
- },
107
- ...(state.manager.operation && operationCanRecover(state.manager.operation)
108
- ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
109
- : []),
110
- { id: "back", label: "Back", action: "back" },
111
- ],
112
- hint: "back",
113
- }),
114
72
  },
115
73
  actions: {
116
74
  "review-attention": async () => {
@@ -170,15 +128,9 @@ export async function showSyncManager(
170
128
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
171
129
  return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" };
172
130
  },
173
- diff: async () => {
174
- const result = await runCancellableOperation(ctx, "Checking current sync setup…", "diff", runRoute, {
175
- signal: sessionSignal,
176
- });
177
- return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
178
- },
179
131
  settings: async () => {
180
- await showSyncSettings(ctx, runRoute, sessionSignal);
181
- return { kind: "stay" };
132
+ const result = await showSyncSettings(ctx, runRoute, sessionSignal);
133
+ return { kind: result === "exit" ? "close" : "stay" };
182
134
  },
183
135
  pull: async () => {
184
136
  const result = await runCancellableOperation(ctx, "Checking remote changes…", "pull", runRoute, {
@@ -207,8 +159,13 @@ export async function showSyncManager(
207
159
  return { kind: "stay" };
208
160
  },
209
161
  history: async () => {
210
- await runRoute("history");
211
- return { kind: "stay" };
162
+ const result = await runCancellableOperation(ctx, "Loading sync history…", "history", runRoute, {
163
+ signal: sessionSignal,
164
+ commitAware: true,
165
+ cancelAcrossDialogs: true,
166
+ cancelledMessage: "History review cancelled; no files were changed.",
167
+ });
168
+ return { kind: result.kind === "closed" ? "close" : "stay" };
212
169
  },
213
170
  doctor: async () => {
214
171
  const result = await runCancellableOperation(ctx, "Checking setup access…", "doctor", runRoute, {
@@ -216,11 +173,6 @@ export async function showSyncManager(
216
173
  });
217
174
  return { kind: result.kind === "closed" ? "close" : "stay" };
218
175
  },
219
- unlock: async ({ state, signal: actionSignal }) => {
220
- const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
221
- if (result === "close") return { kind: "close" };
222
- return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
223
- },
224
176
  recover: async ({ state, signal: actionSignal }) => {
225
177
  const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
226
178
  return { kind: result === "close" ? "close" : "stay" };
@@ -242,7 +194,6 @@ export async function showSyncManager(
242
194
  const disposition = await dispatchManagerResult(ctx, result, "sync", runRoute, sessionSignal);
243
195
  return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
244
196
  },
245
- back: async () => ({ kind: "back" }),
246
197
  },
247
198
  });
248
199
  await runMenu(ctx, menu, {
@@ -273,26 +224,47 @@ export async function showSyncManager(
273
224
  function syncMainMenuItem(
274
225
  label: string,
275
226
  ): ActionMenuItem<
276
- "main" | "more" | "recovery",
277
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "recover" | "refresh" | "help" | "init"
227
+ "main",
228
+ | "sync"
229
+ | "pull"
230
+ | "push"
231
+ | "switch"
232
+ | "settings"
233
+ | "setups"
234
+ | "connections"
235
+ | "history"
236
+ | "conflicts"
237
+ | "doctor"
238
+ | "recover"
239
+ | "refresh"
240
+ | "help"
241
+ | "init"
278
242
  > {
279
- if (label === "More…") return { id: "more", label, to: "more" };
280
- if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
281
- const actions = new Map<
282
- string,
283
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "recover" | "refresh" | "help" | "init"
284
- >([
285
- ["Sync now (recommended)", "sync"],
286
- ["Switch sync setup", "switch"],
287
- ["Status & changes", "diff"],
288
- ["Settings", "settings"],
289
- ["Restore sync access… (recommended)", "recover"],
290
- ["Refresh operation status", "refresh"],
291
- ["Sync setups…", "setups"],
292
- ["Storage connections…", "connections"],
293
- ["Help", "help"],
294
- ["Set up sync", "init"],
295
- ["Use existing settings", "init"],
296
- ]);
297
- return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
243
+ const actions = {
244
+ "Sync now": "sync",
245
+ "Pull from remote…": "pull",
246
+ "Push to remote…": "push",
247
+ "Switch sync setup": "switch",
248
+ Settings: "settings",
249
+ History: "history",
250
+ Diagnostics: "doctor",
251
+ "Review unresolved conflicts": "conflicts",
252
+ "Restore sync access… (recommended)": "recover",
253
+ "Refresh operation status": "refresh",
254
+ "Sync setups…": "setups",
255
+ "Storage connections…": "connections",
256
+ Help: "help",
257
+ "Set up sync": "init",
258
+ "Use existing settings": "init",
259
+ } as const;
260
+ const action = actions[label as keyof typeof actions];
261
+ if (!action) throw new Error(`Unknown sync manager action: ${label}`);
262
+ return {
263
+ id: action,
264
+ label,
265
+ action,
266
+ ...(action === "doctor"
267
+ ? { description: "Check setup access; WebDAV also probes writes and repairs history." }
268
+ : {}),
269
+ };
298
270
  }
@@ -0,0 +1,29 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Track History's Pi-owned selector/confirmation lifetime without inspecting renderer internals. */
4
+ export function trackOperationDialogs<Context extends ExtensionContext>(
5
+ ctx: Context,
6
+ onDialog?: (active: boolean) => void,
7
+ ): Context {
8
+ if (!onDialog) return ctx;
9
+ const runDialog = async <Value>(task: () => Promise<Value>): Promise<Value> => {
10
+ onDialog(true);
11
+ try {
12
+ return await task();
13
+ } finally {
14
+ onDialog(false);
15
+ }
16
+ };
17
+ const ui: ExtensionContext["ui"] = {
18
+ ...ctx.ui,
19
+ select: (...args) => runDialog(() => ctx.ui.select(...args)),
20
+ confirm: (...args) => runDialog(() => ctx.ui.confirm(...args)),
21
+ };
22
+ // Forward live context properties: copying the context would hide session
23
+ // replacement from mutation-owner revalidation after a dialog settles.
24
+ return new Proxy(ctx, {
25
+ get(target, key, receiver) {
26
+ return key === "ui" ? ui : Reflect.get(target, key, receiver);
27
+ },
28
+ });
29
+ }