@narumitw/pi-sync 0.49.15 → 0.50.1

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 (213) hide show
  1. package/README.md +49 -16
  2. package/dist/chunks/attention-widget-Z7G3SYYJ.ts +9 -0
  3. package/dist/chunks/attention-widget-Z7G3SYYJ.ts.map +7 -0
  4. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  5. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  6. package/dist/chunks/{chunk-ARN2672F.ts → chunk-4CHTZMEO.ts} +23 -55
  7. package/dist/chunks/chunk-4CHTZMEO.ts.map +7 -0
  8. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  9. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  10. package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
  11. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  12. package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/chunk-CCSN3F47.ts +51 -0
  15. package/dist/chunks/chunk-CCSN3F47.ts.map +7 -0
  16. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  17. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  18. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  19. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  20. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  21. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  22. package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-ETTRJDNP.ts} +4 -4
  23. package/dist/chunks/chunk-ETTRJDNP.ts.map +7 -0
  24. package/dist/chunks/{chunk-44LPGQTI.ts → chunk-G4RNQQZS.ts} +9 -7
  25. package/dist/chunks/chunk-G4RNQQZS.ts.map +7 -0
  26. package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
  27. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  28. package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-IJ6FJFA5.ts} +9 -9
  29. package/dist/chunks/chunk-IJ6FJFA5.ts.map +7 -0
  30. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  31. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  32. package/dist/chunks/chunk-KW7YQEZN.ts +1255 -0
  33. package/dist/chunks/chunk-KW7YQEZN.ts.map +7 -0
  34. package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
  35. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  36. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  37. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  38. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  39. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  40. package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
  41. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  42. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  43. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  44. package/dist/chunks/chunk-S7YBZR42.ts +146 -0
  45. package/dist/chunks/chunk-S7YBZR42.ts.map +7 -0
  46. package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
  47. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  48. package/dist/chunks/chunk-SZWBGPID.ts +57 -0
  49. package/dist/chunks/chunk-SZWBGPID.ts.map +7 -0
  50. package/dist/chunks/chunk-T2B5IU74.ts +25 -0
  51. package/dist/chunks/chunk-T2B5IU74.ts.map +7 -0
  52. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  53. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  54. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  55. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  56. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  57. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  58. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  59. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  60. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  61. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  62. package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
  63. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  64. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-GMVS4PD4.ts} +39 -14
  65. package/dist/chunks/git-backend-GMVS4PD4.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +22 -0
  67. package/dist/chunks/manager-ui-R3XPUFFS.ts +1680 -0
  68. package/dist/chunks/manager-ui-R3XPUFFS.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +21 -0
  70. package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-66O4FGBH.ts} +94 -16
  71. package/dist/chunks/s3-backend-66O4FGBH.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  73. package/dist/chunks/setup-wizard-IUB6ESEU.ts +89 -0
  74. package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +7 -0
  75. package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
  76. package/dist/chunks/sync-inspection-CST2KL65.ts +20 -0
  77. package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-LBX3GKCM.ts} +369 -289
  78. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +7 -0
  79. package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
  80. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  81. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  82. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  83. package/dist/index.ts +476 -341
  84. package/dist/index.ts.map +4 -4
  85. package/docs/settings.md +30 -6
  86. package/package.json +1 -1
  87. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  88. package/src/backends/backend-identity.ts +40 -0
  89. package/src/backends/backend-types.ts +81 -0
  90. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  91. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  92. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  93. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  94. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  95. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  96. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  97. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  98. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  99. package/src/commands/command-execution.ts +330 -0
  100. package/src/commands/command-handler.ts +201 -0
  101. package/src/commands/command-types.ts +18 -0
  102. package/src/{command.ts → commands/command.ts} +2 -1
  103. package/src/commands/setup-completions.ts +6 -0
  104. package/src/settings/config.ts +238 -0
  105. package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
  106. package/src/settings/settings-store.ts +109 -0
  107. package/src/settings/settings-types.ts +128 -0
  108. package/src/settings/settings-validation.ts +443 -0
  109. package/src/snapshot/session-paths.ts +84 -0
  110. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  111. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  112. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  113. package/src/snapshot/snapshot-types.ts +38 -0
  114. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  115. package/src/state/json-file.ts +20 -0
  116. package/src/{lock.ts → state/lock.ts} +3 -2
  117. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  118. package/src/state/state-types.ts +20 -0
  119. package/src/state/sync-state-store.ts +62 -0
  120. package/src/sync/automatic-sync.ts +103 -0
  121. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  122. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  123. package/src/sync/signals.ts +10 -0
  124. package/src/sync/startup-check.ts +128 -0
  125. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  126. package/src/sync/sync-error-guidance.ts +53 -0
  127. package/src/sync/sync-inspection.ts +51 -0
  128. package/src/sync/sync-loaders.ts +54 -0
  129. package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
  130. package/src/sync/sync-operations.ts +12 -0
  131. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  132. package/src/sync/sync-queries.ts +235 -0
  133. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  134. package/src/sync-extension.ts +64 -708
  135. package/src/sync.ts +8 -8
  136. package/src/types.ts +47 -286
  137. package/src/ui/attention-widget.ts +1 -0
  138. package/src/ui/automatic-sync-summary.ts +6 -0
  139. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  140. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  141. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  142. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  143. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  144. package/src/ui/manager-ui.ts +386 -0
  145. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  146. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  147. package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
  148. package/src/ui/setup/git-ui.ts +293 -0
  149. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  150. package/src/ui/setup/s3-ui.ts +217 -0
  151. package/src/ui/setup/setup-actions.ts +127 -0
  152. package/src/ui/setup/setup-location-ui.ts +167 -0
  153. package/src/ui/setup/setup-name-ui.ts +34 -0
  154. package/src/ui/setup/setup-prompts.ts +92 -0
  155. package/src/ui/setup/setup-review.ts +70 -0
  156. package/src/ui/setup/setup-switcher.ts +181 -0
  157. package/src/ui/setup/setup-wizard.ts +39 -0
  158. package/src/ui/setup/text-input.ts +78 -0
  159. package/src/ui/setup/webdav-ui.ts +330 -0
  160. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  161. package/src/ui/sync-attention.ts +187 -0
  162. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  163. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  164. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  165. package/src/ui/terminal-text.ts +4 -0
  166. package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
  167. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  168. package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
  169. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  170. package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
  171. package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
  172. package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
  173. package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
  174. package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
  175. package/dist/chunks/chunk-E4COSV32.ts +0 -82
  176. package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
  177. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  178. package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
  179. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  180. package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
  181. package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
  182. package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
  183. package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
  184. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  185. package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
  186. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  187. package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
  188. package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
  189. package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
  190. package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
  191. package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
  192. package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
  193. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  194. package/src/config.ts +0 -923
  195. package/src/git-ui.ts +0 -307
  196. package/src/manager-helpers.ts +0 -64
  197. package/src/manager-ui.ts +0 -997
  198. package/src/sync-attention.ts +0 -100
  199. package/src/webdav-ui.ts +0 -421
  200. /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-FKIBM3GO.ts.map} +0 -0
  201. /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-D7RBRJNL.ts.map} +0 -0
  202. /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  203. /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  204. /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-CST2KL65.ts.map} +0 -0
  205. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  206. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  207. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  208. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  209. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  210. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  211. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  212. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  213. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
package/src/manager-ui.ts DELETED
@@ -1,997 +0,0 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
4
- import { setSyncSetupCompletions } from "./command.js";
5
- import {
6
- configuredSyncSetupNames,
7
- isCloudflareR2Endpoint,
8
- loadConfig,
9
- loadOnSwitch,
10
- loadPartialConfig,
11
- localConfigPath,
12
- readLocalConfigObject,
13
- syncConfigReviewIdentity,
14
- } from "./config.js";
15
- import { showAddGitTarget, showEditGitTarget, showGitSetup } from "./git-ui.js";
16
- import {
17
- attentionMainMenuItems,
18
- blockedSyncMenuItem,
19
- type SyncManagerAttentionOptions,
20
- showManagerAttention,
21
- } from "./manager-attention.js";
22
- import {
23
- errorMessage,
24
- ownRecord,
25
- requiredExistingBucket,
26
- requiredInput,
27
- safeTerminalText,
28
- } from "./manager-helpers.js";
29
- import { recoverSyncAccess } from "./manager-recovery.js";
30
- import { dispatchManagerResult } from "./manager-result-dispatcher.js";
31
- import { backendStorageDescription, describeManagerState } from "./manager-state.js";
32
- import { operationCanRecover } from "./operation-availability.js";
33
- import { chooseS3Credentials } from "./s3-credentials-ui.js";
34
- import {
35
- addSyncSetup,
36
- removeSyncSetup,
37
- saveNewV3Settings,
38
- updateSyncSetup,
39
- } from "./settings-management.js";
40
- import { showSyncSettings } from "./settings-ui.js";
41
- import { useSyncSetup } from "./setup-switch.js";
42
- import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
43
- import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
44
- import { showSyncSetups } from "./sync-setups-ui.js";
45
- import type { AnySyncConfig } from "./types.js";
46
- import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
47
-
48
- export async function showSyncManager(
49
- ctx: ExtensionCommandContext,
50
- runRoute: RunRoute,
51
- sessionSignal?: AbortSignal,
52
- options: SyncManagerAttentionOptions = {},
53
- ): Promise<void> {
54
- if (!ctx.hasUI) {
55
- await runRoute("help");
56
- return;
57
- }
58
- type Screen = "main" | "more" | "recovery";
59
- type Action =
60
- | "review-attention"
61
- | "sync"
62
- | "switch"
63
- | "diff"
64
- | "settings"
65
- | "pull"
66
- | "push"
67
- | "setups"
68
- | "connections"
69
- | "history"
70
- | "doctor"
71
- | "unlock"
72
- | "recover"
73
- | "refresh"
74
- | "help"
75
- | "init"
76
- | "back";
77
- interface State {
78
- manager: Awaited<ReturnType<typeof describeManagerState>>;
79
- }
80
- const menu = defineMenu<State, Screen, Action, ExtensionCommandContext>({
81
- start: "main",
82
- screens: {
83
- main: ({ state }) => {
84
- const attentionItems = attentionMainMenuItems(state.manager);
85
- const managerItems = state.manager.actions.map(
86
- (label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label),
87
- );
88
- const operationFirst =
89
- state.manager.operation !== undefined && state.manager.operation.kind !== "free";
90
- return {
91
- kind: "actions",
92
- title: "Manage sync",
93
- lines: state.manager.title.split("\n").slice(1),
94
- items: operationFirst
95
- ? [...managerItems, ...attentionItems]
96
- : [...attentionItems, ...managerItems],
97
- hint: "close",
98
- };
99
- },
100
- more: () => ({
101
- kind: "actions",
102
- title: "More options",
103
- items: [
104
- { id: "pull", label: "Pull from remote…", action: "pull" },
105
- { id: "push", label: "Push to remote…", action: "push" },
106
- { id: "setups", label: "Sync setups…", action: "setups" },
107
- {
108
- id: "connections",
109
- label: "Storage connections…",
110
- action: "connections",
111
- },
112
- { id: "recovery", label: "History & recovery…", to: "recovery" },
113
- { id: "help", label: "Help", action: "help" },
114
- { id: "back", label: "Back", action: "back" },
115
- ],
116
- hint: "back",
117
- }),
118
- recovery: ({ state }) => ({
119
- kind: "actions",
120
- title: "History & recovery",
121
- items: [
122
- { id: "history", label: "Browse history", action: "history" },
123
- { id: "doctor", label: "Check setup", action: "doctor" },
124
- ...(state.manager.operation && operationCanRecover(state.manager.operation)
125
- ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
126
- : []),
127
- { id: "back", label: "Back", action: "back" },
128
- ],
129
- hint: "back",
130
- }),
131
- },
132
- actions: {
133
- "review-attention": async () => {
134
- const attention = options.getAttention?.();
135
- if (!attention) return { kind: "stay" };
136
- const disposition = await showManagerAttention(
137
- ctx,
138
- attention,
139
- runRoute,
140
- sessionSignal,
141
- () => options.onSelectionResolved?.(attention),
142
- );
143
- return { kind: disposition };
144
- },
145
- sync: async () => {
146
- const pendingAttention = options.getAttention?.();
147
- if (pendingAttention) {
148
- const activeConfig = await loadConfig();
149
- if (sessionSignal?.aborted) return { kind: "close" };
150
- if (pendingAttention.decision.setupName === activeConfig.setupName) {
151
- ctx.ui.notify("Review synced content before starting Sync now.", "warning");
152
- return { kind: "stay" };
153
- }
154
- }
155
- const result = await runCancellableOperation(
156
- ctx,
157
- "Checking current sync setup…",
158
- "sync",
159
- runRoute,
160
- {
161
- commitAware: true,
162
- signal: sessionSignal,
163
- },
164
- );
165
- const disposition = await dispatchManagerResult(
166
- ctx,
167
- result,
168
- "sync",
169
- runRoute,
170
- sessionSignal,
171
- );
172
- return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
173
- },
174
- switch: async () => {
175
- const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
176
- return result === "pull-attempted" || result === "closed"
177
- ? { kind: "close" }
178
- : { kind: "stay" };
179
- },
180
- diff: async () => {
181
- const result = await runCancellableOperation(
182
- ctx,
183
- "Checking current sync setup…",
184
- "diff",
185
- runRoute,
186
- { signal: sessionSignal },
187
- );
188
- return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
189
- },
190
- settings: async () => {
191
- await showSyncSettings(ctx, runRoute, sessionSignal);
192
- return { kind: "stay" };
193
- },
194
- pull: async () => {
195
- const result = await runCancellableOperation(
196
- ctx,
197
- "Checking remote changes…",
198
- "pull",
199
- runRoute,
200
- {
201
- commitAware: true,
202
- cancelledMessage: "Pull check cancelled; no local files were changed.",
203
- signal: sessionSignal,
204
- },
205
- );
206
- const disposition = await dispatchManagerResult(
207
- ctx,
208
- result,
209
- "pull",
210
- runRoute,
211
- sessionSignal,
212
- );
213
- return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
214
- },
215
- push: async () => {
216
- const result = await runCancellableOperation(
217
- ctx,
218
- "Preparing push preview…",
219
- "push",
220
- runRoute,
221
- {
222
- commitAware: true,
223
- cancelledMessage: "Push preparation cancelled; no remote files were changed.",
224
- signal: sessionSignal,
225
- },
226
- );
227
- const disposition = await dispatchManagerResult(
228
- ctx,
229
- result,
230
- "push",
231
- runRoute,
232
- sessionSignal,
233
- );
234
- return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
235
- },
236
- setups: async () => {
237
- const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
238
- return result === "exit" ? { kind: "close" } : { kind: "stay" };
239
- },
240
- connections: async () => {
241
- await showStorageConnections(ctx, sessionSignal);
242
- return { kind: "stay" };
243
- },
244
- history: async () => {
245
- await runRoute("history");
246
- return { kind: "stay" };
247
- },
248
- doctor: async () => {
249
- await runRoute("doctor");
250
- return { kind: "stay" };
251
- },
252
- unlock: async ({ state, signal: actionSignal }) => {
253
- const result = await recoverSyncAccess(
254
- ctx,
255
- state.manager,
256
- runRoute,
257
- sessionSignal,
258
- actionSignal,
259
- );
260
- if (result === "close") return { kind: "close" };
261
- return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
262
- },
263
- recover: async ({ state, signal: actionSignal }) => {
264
- const result = await recoverSyncAccess(
265
- ctx,
266
- state.manager,
267
- runRoute,
268
- sessionSignal,
269
- actionSignal,
270
- );
271
- return { kind: result === "close" ? "close" : "stay" };
272
- },
273
- refresh: async () => ({ kind: "stay" }),
274
- help: async () => {
275
- await runRoute("help");
276
- return { kind: "close" };
277
- },
278
- init: async () => {
279
- await runRoute("init");
280
- return { kind: "stay" };
281
- },
282
- back: async () => ({ kind: "back" }),
283
- },
284
- });
285
- await runMenu(ctx, menu, {
286
- getState: async () => {
287
- const pendingAttention = options.getAttention?.();
288
- const manager = await describeManagerState(sessionSignal, pendingAttention);
289
- if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) {
290
- options.onSelectionResolved?.(pendingAttention);
291
- }
292
- return { manager };
293
- },
294
- signal: sessionSignal,
295
- isCurrent: () => !sessionSignal?.aborted,
296
- });
297
- }
298
-
299
- function syncMainMenuItem(
300
- label: string,
301
- ): ActionMenuItem<
302
- "main" | "more" | "recovery",
303
- | "sync"
304
- | "switch"
305
- | "diff"
306
- | "settings"
307
- | "setups"
308
- | "connections"
309
- | "recover"
310
- | "refresh"
311
- | "help"
312
- | "init"
313
- > {
314
- if (label === "More…") return { id: "more", label, to: "more" };
315
- if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
316
- const actions = new Map<
317
- string,
318
- | "sync"
319
- | "switch"
320
- | "diff"
321
- | "settings"
322
- | "setups"
323
- | "connections"
324
- | "recover"
325
- | "refresh"
326
- | "help"
327
- | "init"
328
- >([
329
- ["Sync now (recommended)", "sync"],
330
- ["Switch sync setup", "switch"],
331
- ["Status & changes", "diff"],
332
- ["Settings", "settings"],
333
- ["Restore sync access… (recommended)", "recover"],
334
- ["Refresh operation status", "refresh"],
335
- ["Sync setups…", "setups"],
336
- ["Storage connections…", "connections"],
337
- ["Help", "help"],
338
- ["Set up sync", "init"],
339
- ["Use existing settings", "init"],
340
- ]);
341
- return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
342
- }
343
-
344
- export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
345
- if (ctx.mode !== "tui") {
346
- ctx.ui.notify(
347
- `Guided sync setup requires TUI mode for masked credential input. Create version 3 settings in ${safeTerminalText(localConfigPath())}.`,
348
- "warning",
349
- );
350
- return false;
351
- }
352
- const preset = await ctx.ui.select(
353
- "Set up sync\n\nWhere will Pi settings be stored?",
354
- ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
355
- { signal },
356
- );
357
- if (signal?.aborted || !preset || preset === "Cancel") return false;
358
- const targetName = await chooseInitialTargetName(ctx, signal);
359
- if (!targetName) return false;
360
- if (preset === "WebDAV") {
361
- const saved = await showWebDavSetup(ctx, targetName, signal);
362
- if (signal?.aborted) return false;
363
- if (saved) await refreshTargetCompletions();
364
- return saved;
365
- }
366
- if (preset === "Git") {
367
- const saved = await showGitSetup(ctx, targetName, signal);
368
- if (signal?.aborted) return false;
369
- if (saved) await refreshTargetCompletions();
370
- return saved;
371
- }
372
- const endpoint = await requiredInput(
373
- ctx,
374
- preset === "Cloudflare R2" ? "Cloudflare R2 endpoint" : "S3-compatible endpoint",
375
- preset === "Cloudflare R2"
376
- ? "https://<account-id>.r2.cloudflarestorage.com"
377
- : "https://s3.example.com",
378
- signal,
379
- );
380
- if (!endpoint) return false;
381
- let region = "auto";
382
- if (preset !== "Cloudflare R2") {
383
- const selectedRegion = await requiredInput(ctx, "Storage region", "us-east-1", signal);
384
- if (!selectedRegion) return false;
385
- region = selectedRegion;
386
- }
387
- const location = await chooseInitialRemoteLocation(ctx, preset, targetName, signal);
388
- if (!location) return false;
389
- const { connectionName, bucket, path: storagePath } = location;
390
- const credentials = await chooseS3Credentials(ctx, signal);
391
- if (!credentials) return false;
392
- const contentChoice = await ctx.ui.select(
393
- "Choose an initial sync preset",
394
- ["Recommended Pi settings", "Minimal settings", "Cancel"],
395
- { signal },
396
- );
397
- if (signal?.aborted || !contentChoice || contentChoice === "Cancel") return false;
398
- const syncFiles =
399
- contentChoice === "Minimal settings"
400
- ? ["settings.json", "AGENTS.md"]
401
- : [...DEFAULT_SYNC_INCLUDE];
402
- const automaticChoice = await ctx.ui.select(
403
- "Automatic sync for this setup",
404
- ["Enable automatic sync", "Keep automatic sync off", "Cancel"],
405
- { signal },
406
- );
407
- if (signal?.aborted || !automaticChoice || automaticChoice === "Cancel") return false;
408
- const sessionChoice = await ctx.ui.select(
409
- "Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
410
- ["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
411
- { signal },
412
- );
413
- if (signal?.aborted || !sessionChoice || sessionChoice === "Cancel") return false;
414
- const syncSessions = sessionChoice === "Include session conversations";
415
- if (
416
- syncSessions &&
417
- !(await ctx.ui.confirm(
418
- "Include session conversations?",
419
- "I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
420
- { signal },
421
- ))
422
- ) {
423
- return false;
424
- }
425
- const autoSync = automaticChoice === "Enable automatic sync";
426
- const choice = await ctx.ui.select(
427
- [
428
- "Review sync setup",
429
- "",
430
- `Sync setup: ${safeTerminalText(targetName)}`,
431
- `Storage connection: ${safeTerminalText(connectionName)} (${preset})`,
432
- `Endpoint: ${safeTerminalText(endpoint)}`,
433
- `Bucket: ${safeTerminalText(bucket)}`,
434
- `Storage location: ${safeTerminalText(storagePath)}`,
435
- "Bucket must already exist. pi-sync will not create it.",
436
- `Included content: ${syncFiles.length} built-in groups · Sessions: ${syncSessions ? "On — privacy warning acknowledged" : "Off"}`,
437
- `Automatic sync: ${autoSync ? "On" : "Off"}`,
438
- `Credentials: ${safeTerminalText(credentials.summary)}`,
439
- ].join("\n"),
440
- ["Save sync setup", "Cancel"],
441
- { signal },
442
- );
443
- if (signal?.aborted || choice !== "Save sync setup") return false;
444
- await saveNewV3Settings(
445
- {
446
- setupName: targetName,
447
- connectionName,
448
- connection: {
449
- type: "s3",
450
- endpoint,
451
- region,
452
- credentials: {
453
- accessKeyId: credentials.profileFields.accessKeyId ?? "",
454
- secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
455
- },
456
- },
457
- setup: {
458
- storage: { connection: connectionName, bucket, path: storagePath },
459
- sync: {
460
- include: [...syncFiles, ...(syncSessions ? ["sessions"] : [])],
461
- automatic: autoSync,
462
- },
463
- },
464
- },
465
- signal,
466
- );
467
- if (signal?.aborted) return false;
468
- await refreshTargetCompletions();
469
- if (signal?.aborted) return true;
470
- ctx.ui.notify(
471
- credentials.ready
472
- ? `Sync setup “${safeTerminalText(targetName)}” is ready. Use Sync now when ready.`
473
- : `Saved sync setup “${safeTerminalText(targetName)}”; add credentials before syncing.`,
474
- "info",
475
- );
476
- return true;
477
- }
478
-
479
- async function selectSetupForSwitch(
480
- ctx: ExtensionCommandContext,
481
- raw: Record<string, unknown>,
482
- targets: Record<string, unknown>,
483
- active: string | undefined,
484
- signal?: AbortSignal,
485
- ) {
486
- let selectedName: string | undefined;
487
- const nameById = new Map<string, string>();
488
- const profiles = ownRecord(raw.storageConnections);
489
- const menu = defineMenu<undefined, "setups", "select", ExtensionCommandContext>({
490
- start: "setups",
491
- screens: {
492
- setups: () => ({
493
- kind: "actions",
494
- title: "Switch sync setup",
495
- lines: [`Current sync setup: ${safeTerminalText(active ?? "none")}`],
496
- items: Object.keys(targets)
497
- .sort((left, right) => left.localeCompare(right))
498
- .map((candidate, index) => {
499
- const target = ownRecord(targets[candidate]);
500
- const storage = ownRecord(target?.storage);
501
- const profileName =
502
- typeof storage?.connection === "string" ? storage.connection : undefined;
503
- const profile = profileName && profiles ? ownRecord(profiles[profileName]) : undefined;
504
- const location = profile
505
- ? profile.type === "git"
506
- ? `${String(storage?.branch ?? "missing branch")}:${String(storage?.path ?? "missing path")}`
507
- : profile.type === "s3"
508
- ? `${String(storage?.bucket ?? "missing bucket")}/${String(storage?.path ?? "missing path")}`
509
- : String(storage?.path ?? "missing path")
510
- : `invalid: missing connection ${profileName ?? "reference"}`;
511
- const id = `setup:${index}`;
512
- nameById.set(id, candidate);
513
- return {
514
- id,
515
- label: `${safeTerminalText(candidate)}${candidate === active ? " (current)" : ""}`,
516
- description: `${safeTerminalText(profileName ?? "unknown")} · ${safeTerminalText(location)}`,
517
- action: "select" as const,
518
- };
519
- }),
520
- hint: "close",
521
- }),
522
- },
523
- actions: {
524
- select: async ({ itemId }) => {
525
- selectedName = nameById.get(itemId);
526
- return { kind: "close" };
527
- },
528
- },
529
- });
530
- await runMenu(ctx, menu, {
531
- getState: () => undefined,
532
- signal,
533
- isCurrent: () => !signal?.aborted,
534
- });
535
- return selectedName;
536
- }
537
-
538
- async function showSetupSwitcher(
539
- ctx: ExtensionCommandContext,
540
- runRoute: RunRoute,
541
- selectedName?: string,
542
- signal?: AbortSignal,
543
- ) {
544
- const raw = await readLocalConfigObject();
545
- if (signal?.aborted) return false;
546
- if (raw?.version !== 3) {
547
- ctx.ui.notify("Add a second sync setup before switching setups.", "info");
548
- return false;
549
- }
550
- const targets = ownRecord(raw.syncSetups);
551
- if (!targets) {
552
- ctx.ui.notify("No sync setups are configured.", "warning");
553
- return false;
554
- }
555
- const active = typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
556
- let name = selectedName;
557
- if (!name) {
558
- name = await selectSetupForSwitch(ctx, raw, targets, active, signal);
559
- if (!name) return false;
560
- }
561
- if (!name || !Object.hasOwn(targets, name)) {
562
- ctx.ui.notify(
563
- `Sync setup “${safeTerminalText(name ?? "unknown")}” no longer exists.`,
564
- "warning",
565
- );
566
- return false;
567
- }
568
- if (name === active) {
569
- ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” is already current.`, "info");
570
- return false;
571
- }
572
- let config: AnySyncConfig;
573
- try {
574
- config = await loadConfig(name);
575
- if (signal?.aborted) return false;
576
- } catch (error) {
577
- ctx.ui.notify(
578
- `Cannot use sync setup “${safeTerminalText(name)}”: ${safeTerminalText(errorMessage(error))}`,
579
- "error",
580
- );
581
- return false;
582
- }
583
- const onSwitch = await loadOnSwitch();
584
- if (signal?.aborted) return false;
585
- const switchEffect =
586
- onSwitch === "ask-before-pull"
587
- ? "After switching, pi-sync will ask whether to review a pull for this setup."
588
- : onSwitch === "pull-after-switch"
589
- ? "After switching, pi-sync will check this setup and show exact changes before applying them."
590
- : "After switching, pi-sync will not pull or modify synced files.";
591
- const confirmed = await ctx.ui.confirm(
592
- "Switch sync setup?",
593
- [
594
- `From: ${safeTerminalText(active ?? "none")}`,
595
- `To: ${safeTerminalText(name)}`,
596
- `Storage: ${backendStorageDescription(config)}`,
597
- `Included content: ${config.include.length} paths`,
598
- `Automatic sync: ${config.automatic ? "On" : "Off"} · Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`,
599
- "",
600
- switchEffect,
601
- ].join("\n"),
602
- { signal },
603
- );
604
- if (signal?.aborted || !confirmed) return false;
605
- try {
606
- let pullClosed = false;
607
- const result = await useSyncSetup(
608
- ctx,
609
- name,
610
- async (selectedTarget) => {
611
- const pullResult = await runCancellableOperation(
612
- ctx,
613
- `Pulling sync setup “${safeTerminalText(name)}”…`,
614
- "pull",
615
- runRoute,
616
- {
617
- commitAware: true,
618
- cancelledMessage: null,
619
- target: selectedTarget,
620
- signal,
621
- },
622
- );
623
- const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal);
624
- if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) {
625
- pullClosed = disposition.kind === "close";
626
- }
627
- if (disposition.appliedRoute === "pull") return "applied";
628
- if (pullResult.kind === "completed") return pullResult.outcome;
629
- return pullResult.kind === "cancelled" ? "cancelled" : undefined;
630
- },
631
- onSwitch,
632
- signal,
633
- syncConfigReviewIdentity(config),
634
- );
635
- if (pullClosed) return "closed";
636
- return result.pullApplied ? "pull-attempted" : "switched";
637
- } catch (error) {
638
- if (signal?.aborted) return false;
639
- ctx.ui.notify(
640
- `Sync setup “${safeTerminalText(name)}” was not switched: ${safeTerminalText(errorMessage(error))}`,
641
- "error",
642
- );
643
- return false;
644
- }
645
- }
646
-
647
- async function showSyncSetupManager(
648
- ctx: ExtensionCommandContext,
649
- runRoute: RunRoute,
650
- signal?: AbortSignal,
651
- ) {
652
- return showSyncSetups(
653
- ctx,
654
- {
655
- add: async (setupSignal) => {
656
- await showAddTarget(ctx, setupSignal);
657
- },
658
- edit: async (name, setupSignal) => {
659
- await showEditTarget(ctx, name, setupSignal);
660
- },
661
- makeCurrent: async (name, setupSignal) => {
662
- const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
663
- return result === "pull-attempted" || result === "closed" ? "exit" : undefined;
664
- },
665
- remove: async (name, setupSignal) => {
666
- await showRemoveTarget(ctx, name, setupSignal);
667
- },
668
- },
669
- signal,
670
- );
671
- }
672
-
673
- async function showAddTarget(ctx: ExtensionCommandContext, signal?: AbortSignal) {
674
- let raw = await readLocalConfigObject();
675
- if (signal?.aborted) return;
676
- if (!raw) return void ctx.ui.notify("Set up the first sync setup before adding another.", "info");
677
- if (raw.version !== 3) {
678
- ctx.ui.notify(
679
- "Version 1 and version 2 settings are unsupported and are never migrated.",
680
- "error",
681
- );
682
- return;
683
- }
684
- let profiles = ownRecord(raw.storageConnections) ?? {};
685
- const name = await requiredInput(ctx, "Name the new sync setup", "work", signal);
686
- if (!name) return;
687
- const createConnection = "Add a new storage connection…";
688
- let profile = await ctx.ui.select(
689
- "Choose a storage connection",
690
- [...Object.keys(profiles).sort(), createConnection, "Cancel"],
691
- { signal },
692
- );
693
- if (!profile || profile === "Cancel") return;
694
- if (profile === createConnection) {
695
- const previousNames = new Set(Object.keys(profiles));
696
- if (!(await showAddStorageConnection(ctx, signal))) return;
697
- if (signal?.aborted) return;
698
- raw = (await readLocalConfigObject()) ?? raw;
699
- if (signal?.aborted) return;
700
- profiles = ownRecord(raw.storageConnections) ?? {};
701
- profile = Object.keys(profiles).find((candidate) => !previousNames.has(candidate));
702
- if (!profile) return;
703
- }
704
- const storageKind = ownRecord(profiles[profile])?.type;
705
- if (storageKind === "webdav") {
706
- const saved = await showAddWebDavTarget(ctx, name, profile, signal);
707
- if (signal?.aborted) return;
708
- if (saved) await refreshTargetCompletions();
709
- return;
710
- }
711
- if (storageKind === "git") {
712
- const saved = await showAddGitTarget(ctx, name, profile, signal);
713
- if (signal?.aborted) return;
714
- if (saved) await refreshTargetCompletions();
715
- return;
716
- }
717
- const location = await chooseAdditionalRemoteLocation(ctx, raw, profile, name, signal);
718
- if (!location) return;
719
- const { bucket, path: storagePath } = location;
720
- const preset = await ctx.ui.select(
721
- "Choose included content",
722
- ["Recommended Pi settings", "Minimal settings", "Cancel"],
723
- { signal },
724
- );
725
- if (!preset || preset === "Cancel") return;
726
- const syncFiles =
727
- preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
728
- const overlapsExistingTarget = Object.values(ownRecord(raw.syncSetups) ?? {}).some((value) => {
729
- const existing = ownRecord(value);
730
- const sync = ownRecord(existing?.sync);
731
- const selected = syncIncludeSelection(
732
- Array.isArray(sync?.include) ? sync.include : [],
733
- ).builtIns;
734
- return selected.some((item) => syncFiles.includes(item));
735
- });
736
- const choice = await ctx.ui.select(
737
- [
738
- "Review new sync setup",
739
- "",
740
- `Sync setup: ${safeTerminalText(name)}`,
741
- `Storage connection: ${safeTerminalText(profile)}`,
742
- `Bucket: ${safeTerminalText(bucket)}`,
743
- `Storage location: ${safeTerminalText(storagePath)}`,
744
- "Bucket must already exist. pi-sync will not create it.",
745
- `Included content: ${syncFiles.length} built-in groups · Sessions: Off`,
746
- ...(overlapsExistingTarget
747
- ? [
748
- "Warning: this setup shares local content with another setup; only the current setup syncs automatically.",
749
- ]
750
- : []),
751
- "Adding this setup does not sync or modify remote data.",
752
- ].join("\n"),
753
- ["Add sync setup", "Cancel"],
754
- { signal },
755
- );
756
- if (signal?.aborted || choice !== "Add sync setup") return;
757
- await addSyncSetup(
758
- name,
759
- {
760
- storage: { connection: profile, bucket, path: storagePath },
761
- sync: { include: syncFiles, automatic: true },
762
- },
763
- signal,
764
- );
765
- if (signal?.aborted) return;
766
- await refreshTargetCompletions();
767
- ctx.ui.notify(`Added sync setup “${safeTerminalText(name)}”.`, "info");
768
- }
769
-
770
- async function showEditTarget(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
771
- const partial = await loadPartialConfig(name);
772
- if (signal?.aborted) return;
773
- if (!partial.setupName) {
774
- ctx.ui.notify("Create version 3 settings before editing a named sync setup.", "info");
775
- return;
776
- }
777
- if (partial.storageKind === "webdav") {
778
- await showEditWebDavTarget(ctx, partial, signal);
779
- return;
780
- }
781
- if (partial.storageKind === "git") {
782
- await showEditGitTarget(ctx, partial, signal);
783
- return;
784
- }
785
- const bucket = await requiredInput(ctx, "Bucket", partial.bucket ?? "pi-sync", signal);
786
- if (!bucket) return;
787
- const storagePath = await requiredInput(ctx, "Storage path", partial.storagePath, signal);
788
- if (!storagePath) return;
789
- const normalizedPath = storagePath.replace(/^\/+|\/+$/gu, "");
790
- const choice = await ctx.ui.select(
791
- [
792
- `Review sync setup “${safeTerminalText(partial.setupName)}”`,
793
- "",
794
- `Bucket: ${safeTerminalText(partial.bucket ?? "missing")} → ${safeTerminalText(bucket)}`,
795
- `Storage path: ${safeTerminalText(partial.storagePath ?? "missing")} → ${safeTerminalText(normalizedPath)}`,
796
- "Saving changes the future storage location only; it does not move or delete remote data.",
797
- ].join("\n"),
798
- ["Save sync setup", "Cancel"],
799
- { signal },
800
- );
801
- if (signal?.aborted || choice !== "Save sync setup") return;
802
- await updateSyncSetup(
803
- partial.setupName,
804
- (setup) => {
805
- if (typeof setup.storage.bucket !== "string") {
806
- throw new Error("Sync setup storage type changed; reopen it.");
807
- }
808
- return {
809
- ...setup,
810
- storage: { ...setup.storage, bucket, path: normalizedPath },
811
- };
812
- },
813
- { expectedStorage: partial, signal },
814
- );
815
- if (signal?.aborted) return;
816
- ctx.ui.notify(`Saved sync setup “${safeTerminalText(partial.setupName)}”.`, "info");
817
- }
818
-
819
- async function showRemoveTarget(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
820
- const confirmed = await ctx.ui.confirm(
821
- "Remove sync setup?",
822
- `Remove local sync setup “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
823
- { signal },
824
- );
825
- if (signal?.aborted || !confirmed) return;
826
- await removeSyncSetup(name, signal);
827
- if (signal?.aborted) return;
828
- await refreshTargetCompletions();
829
- ctx.ui.notify(
830
- `Removed sync setup “${safeTerminalText(name)}”; remote data was not deleted.`,
831
- "info",
832
- );
833
- }
834
-
835
- async function refreshTargetCompletions() {
836
- setSyncSetupCompletions(await configuredSyncSetupNames());
837
- }
838
-
839
- interface ChosenRemoteLocation {
840
- connectionName: string;
841
- bucket: string;
842
- path: string;
843
- }
844
-
845
- async function chooseInitialTargetName(ctx: ExtensionCommandContext, signal?: AbortSignal) {
846
- const purpose = await ctx.ui.select(
847
- "What will this sync setup be used for?",
848
- ["Personal / Home", "Work", "Custom", "Cancel"],
849
- { signal },
850
- );
851
- if (!purpose || purpose === "Cancel") return undefined;
852
- if (purpose === "Personal / Home") return "home";
853
- if (purpose === "Work") return "work";
854
- return requiredInput(ctx, "Name this sync setup", "default", signal);
855
- }
856
-
857
- async function chooseInitialRemoteLocation(
858
- ctx: ExtensionCommandContext,
859
- preset: string,
860
- setupName: string,
861
- signal?: AbortSignal,
862
- ): Promise<ChosenRemoteLocation | undefined> {
863
- const connectionName = preset === "Cloudflare R2" ? "r2" : "s3";
864
- const suggested = {
865
- connectionName,
866
- bucket: "pi-sync",
867
- path: `pi-sync/${setupName}`,
868
- };
869
- if (preset === "Cloudflare R2") {
870
- const choice = await ctx.ui.select(
871
- [
872
- "Choose storage location",
873
- "",
874
- `Suggested storage connection: ${connectionName}`,
875
- `Suggested bucket: ${suggested.bucket}`,
876
- `Remote path: ${safeTerminalText(suggested.path)}`,
877
- "Bucket must already exist. pi-sync will not create it.",
878
- ].join("\n"),
879
- ["Use suggested location (recommended)", "Customize remote location", "Cancel"],
880
- { signal },
881
- );
882
- if (!choice || choice === "Cancel") return undefined;
883
- if (choice === "Use suggested location (recommended)") return suggested;
884
- return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
885
- }
886
-
887
- const choice = await ctx.ui.select(
888
- [
889
- "Choose storage location",
890
- "",
891
- `Suggested storage connection: ${connectionName}`,
892
- `Suggested path: ${safeTerminalText(suggested.path)}`,
893
- "S3 bucket names may need to be globally unique and the bucket must already exist.",
894
- ].join("\n"),
895
- [
896
- "Use existing bucket with suggested path (recommended)",
897
- "Customize remote location",
898
- "Cancel",
899
- ],
900
- { signal },
901
- );
902
- if (!choice || choice === "Cancel") return undefined;
903
- if (choice === "Customize remote location") {
904
- return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
905
- }
906
- const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
907
- return bucket ? { ...suggested, bucket } : undefined;
908
- }
909
-
910
- async function chooseAdditionalRemoteLocation(
911
- ctx: ExtensionCommandContext,
912
- settings: Record<string, unknown>,
913
- connectionName: string,
914
- setupName: string,
915
- signal?: AbortSignal,
916
- ): Promise<Omit<ChosenRemoteLocation, "connectionName"> | undefined> {
917
- const setups = ownRecord(settings.syncSetups) ?? {};
918
- const currentSetup =
919
- typeof settings.activeSyncSetup === "string" ? settings.activeSyncSetup : undefined;
920
- const candidates = Object.entries(setups)
921
- .map(([name, value]) => ({ name, storage: ownRecord(ownRecord(value)?.storage) }))
922
- .filter(
923
- (item): item is { name: string; storage: Record<string, unknown> } =>
924
- item.storage?.connection === connectionName && typeof item.storage.bucket === "string",
925
- );
926
- const source =
927
- candidates.find((item) => item.name === currentSetup) ??
928
- candidates.sort((left, right) => left.name.localeCompare(right.name))[0];
929
- if (source) {
930
- const sourcePath =
931
- typeof source.storage.path === "string" ? source.storage.path : "pi-sync/home";
932
- const sourceParent = sourcePath.includes("/")
933
- ? sourcePath.slice(0, sourcePath.lastIndexOf("/"))
934
- : "pi-sync";
935
- const suggestedPath = `${sourceParent}/${setupName}`;
936
- const sameBucketLabel = `Same bucket as “${safeTerminalText(source.name)}” (recommended)`;
937
- const choice = await ctx.ui.select(
938
- [
939
- `Storage location for “${safeTerminalText(setupName)}”`,
940
- "",
941
- `Recommended bucket: ${safeTerminalText(String(source.storage.bucket))}`,
942
- `Remote path: ${safeTerminalText(suggestedPath)}`,
943
- "The complete path and local sync state remain separate.",
944
- ].join("\n"),
945
- [sameBucketLabel, "Use a different bucket", "Customize remote location", "Cancel"],
946
- { signal },
947
- );
948
- if (!choice || choice === "Cancel") return undefined;
949
- if (choice === sameBucketLabel) {
950
- return { bucket: String(source.storage.bucket), path: suggestedPath };
951
- }
952
- if (choice === "Use a different bucket") {
953
- const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
954
- return bucket ? { bucket, path: `pi-sync/${setupName}` } : undefined;
955
- }
956
- const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
957
- return custom ? { bucket: custom.bucket, path: custom.path } : undefined;
958
- }
959
-
960
- const connectionSettings = ownRecord(ownRecord(settings.storageConnections)?.[connectionName]);
961
- const isR2 = isCloudflareR2Endpoint(String(connectionSettings?.endpoint ?? ""));
962
- const suggestedPath = `pi-sync/${setupName}`;
963
- const suggestedLabel = isR2
964
- ? "Use suggested location (recommended)"
965
- : "Use existing bucket with suggested path (recommended)";
966
- const choice = await ctx.ui.select(
967
- `Storage location for “${safeTerminalText(setupName)}”\n\nSuggested path: ${safeTerminalText(suggestedPath)}`,
968
- [suggestedLabel, "Customize remote location", "Cancel"],
969
- { signal },
970
- );
971
- if (!choice || choice === "Cancel") return undefined;
972
- if (choice === "Customize remote location") {
973
- const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
974
- return custom ? { bucket: custom.bucket, path: custom.path } : undefined;
975
- }
976
- if (isR2) return { bucket: "pi-sync", path: suggestedPath };
977
- const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
978
- return bucket ? { bucket, path: suggestedPath } : undefined;
979
- }
980
-
981
- async function chooseCustomRemoteLocation(
982
- ctx: ExtensionCommandContext,
983
- setupName: string,
984
- initialConnectionName: string,
985
- customizeConnectionName: boolean,
986
- signal?: AbortSignal,
987
- ): Promise<ChosenRemoteLocation | undefined> {
988
- const connectionName = customizeConnectionName
989
- ? await requiredInput(ctx, "Storage connection name", initialConnectionName, signal)
990
- : initialConnectionName;
991
- if (!connectionName) return undefined;
992
- const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
993
- if (!bucket) return undefined;
994
- const storagePath = await requiredInput(ctx, "Storage path", `pi-sync/${setupName}`, signal);
995
- if (!storagePath) return undefined;
996
- return { connectionName, bucket, path: storagePath.replace(/^\/+|\/+$/gu, "") };
997
- }