@narumitw/pi-sync 0.50.3 → 0.51.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 (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -2,23 +2,15 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
4
4
  import {
5
- addStorageConnection,
6
- removeStorageConnection,
7
- updateStorageConnection,
5
+ addStorageConnection,
6
+ removeStorageConnection,
7
+ updateStorageConnection,
8
8
  } from "../settings/settings-management.js";
9
9
  import { readLocalConfigObject } from "../settings/settings-store.js";
10
- import {
11
- isCloudflareR2Endpoint,
12
- normalizeS3Endpoint,
13
- ownRecord,
14
- } from "../settings/settings-validation.js";
10
+ import { isCloudflareR2Endpoint, normalizeS3Endpoint, ownRecord } from "../settings/settings-validation.js";
15
11
  import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
16
12
  import { showAddGitStorageProfile, showEditGitStorageProfile } from "./setup/git-ui.js";
17
- import {
18
- applyS3CredentialUpdate,
19
- chooseS3Credentials,
20
- chooseS3CredentialUpdate,
21
- } from "./setup/s3-credentials-ui.js";
13
+ import { applyS3CredentialUpdate, chooseS3Credentials, chooseS3CredentialUpdate } from "./setup/s3-credentials-ui.js";
22
14
  import { promptResourceName } from "./setup/setup-prompts.js";
23
15
  import { saveReviewedDraft } from "./setup/setup-review.js";
24
16
  import { requiredInput, requiredValueInput } from "./setup/text-input.js";
@@ -26,387 +18,343 @@ import { showAddWebDavStorageProfile, showEditWebDavStorageProfile } from "./set
26
18
  import { safeTerminalText } from "./terminal-text.js";
27
19
 
28
20
  export async function showStorageConnections(ctx: ExtensionCommandContext, signal?: AbortSignal) {
29
- let selectedName: string | undefined;
30
- const nameById = new Map<string, string>();
31
- type Screen = "list" | "detail";
32
- type Action = "add" | "select" | "edit" | "remove" | "back";
33
- const menu = defineMenu<
34
- Awaited<ReturnType<typeof loadStorageMenuState>>,
35
- Screen,
36
- Action,
37
- ExtensionCommandContext
38
- >({
39
- start: "list",
40
- screens: {
41
- list: ({ state }) => {
42
- nameById.clear();
43
- const names = Object.keys(state.profiles).sort((left, right) => left.localeCompare(right));
44
- return {
45
- kind: "actions",
46
- title: "Storage connections",
47
- lines: state.version3
48
- ? ["Server addresses and sign-in details, reusable across sync setups."]
49
- : ["Create version 3 settings before managing storage connections."],
50
- items: state.version3
51
- ? [
52
- { id: "add", label: "Add storage connection", action: "add" as const },
53
- ...names.map((name, index) => {
54
- const id = `connection:${index}`;
55
- nameById.set(id, name);
56
- return {
57
- id,
58
- label: safeTerminalText(name),
59
- description: connectionSummary(ownRecord(state.profiles[name]) ?? {}),
60
- action: "select" as const,
61
- };
62
- }),
63
- ]
64
- : [],
65
- hint: "back",
66
- };
67
- },
68
- detail: ({ state }) => ({
69
- kind: "actions",
70
- title: state.selected
71
- ? `Storage connection “${safeTerminalText(state.selected.name)}”`
72
- : "Storage connection",
73
- lines: state.selected?.lines ?? ["This storage connection no longer exists."],
74
- items: state.selected
75
- ? [
76
- { id: "edit", label: "Edit storage connection…", action: "edit" },
77
- ...(state.selected.usedBy.length === 0
78
- ? [
79
- {
80
- id: "remove",
81
- label: "Remove storage connection…",
82
- action: "remove" as const,
83
- },
84
- ]
85
- : []),
86
- { id: "back", label: "Back", action: "back" },
87
- ]
88
- : [{ id: "back", label: "Back", action: "back" }],
89
- hint: "back",
90
- }),
91
- },
92
- actions: {
93
- add: async () => {
94
- try {
95
- await showAddStorageConnection(ctx, signal);
96
- } catch (error) {
97
- if (!signal?.aborted) {
98
- ctx.ui.notify(`Storage connection was not added: ${syncErrorGuidance(error)}`, "error");
99
- }
100
- }
101
- return { kind: "stay" };
102
- },
103
- select: async ({ itemId }) => {
104
- selectedName = nameById.get(itemId);
105
- return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
106
- },
107
- edit: async ({ state }) => {
108
- if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
109
- try {
110
- await editStorageConnection(
111
- ctx,
112
- state.selected.name,
113
- state.selected.profile,
114
- state.selected.usedBy,
115
- signal,
116
- );
117
- } catch (error) {
118
- notifyConnectionError(ctx, state.selected.name, error, signal);
119
- }
120
- return { kind: "stay" };
121
- },
122
- remove: async ({ state }) => {
123
- if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
124
- const name = state.selected.name;
125
- const confirmed = await ctx.ui.confirm(
126
- "Remove storage connection?",
127
- `Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
128
- { signal },
129
- );
130
- if (!confirmed || signal?.aborted) return { kind: "rejected" };
131
- try {
132
- await removeStorageConnection(name, signal);
133
- ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
134
- selectedName = undefined;
135
- return { kind: "back" };
136
- } catch (error) {
137
- notifyConnectionError(ctx, name, error, signal);
138
- return { kind: "stay" };
139
- }
140
- },
141
- back: async () => {
142
- selectedName = undefined;
143
- return { kind: "back" };
144
- },
145
- },
146
- });
147
- await runMenu(ctx, menu, {
148
- getState: () => loadStorageMenuState(selectedName, signal),
149
- signal,
150
- isCurrent: () => !signal?.aborted,
151
- });
21
+ let selectedName: string | undefined;
22
+ const nameById = new Map<string, string>();
23
+ type Screen = "list" | "detail";
24
+ type Action = "add" | "select" | "edit" | "remove" | "back";
25
+ const menu = defineMenu<Awaited<ReturnType<typeof loadStorageMenuState>>, Screen, Action, ExtensionCommandContext>({
26
+ start: "list",
27
+ screens: {
28
+ list: ({ state }) => {
29
+ nameById.clear();
30
+ const names = Object.keys(state.profiles).sort((left, right) => left.localeCompare(right));
31
+ return {
32
+ kind: "actions",
33
+ title: "Storage connections",
34
+ lines: state.version3
35
+ ? ["Server addresses and sign-in details, reusable across sync setups."]
36
+ : ["Create version 3 settings before managing storage connections."],
37
+ items: state.version3
38
+ ? [
39
+ { id: "add", label: "Add storage connection", action: "add" as const },
40
+ ...names.map((name, index) => {
41
+ const id = `connection:${index}`;
42
+ nameById.set(id, name);
43
+ return {
44
+ id,
45
+ label: safeTerminalText(name),
46
+ description: connectionSummary(ownRecord(state.profiles[name]) ?? {}),
47
+ action: "select" as const,
48
+ };
49
+ }),
50
+ ]
51
+ : [],
52
+ hint: "back",
53
+ };
54
+ },
55
+ detail: ({ state }) => ({
56
+ kind: "actions",
57
+ title: state.selected ? `Storage connection “${safeTerminalText(state.selected.name)}”` : "Storage connection",
58
+ lines: state.selected?.lines ?? ["This storage connection no longer exists."],
59
+ items: state.selected
60
+ ? [
61
+ { id: "edit", label: "Edit storage connection…", action: "edit" },
62
+ ...(state.selected.usedBy.length === 0
63
+ ? [
64
+ {
65
+ id: "remove",
66
+ label: "Remove storage connection…",
67
+ action: "remove" as const,
68
+ },
69
+ ]
70
+ : []),
71
+ { id: "back", label: "Back", action: "back" },
72
+ ]
73
+ : [{ id: "back", label: "Back", action: "back" }],
74
+ hint: "back",
75
+ }),
76
+ },
77
+ actions: {
78
+ add: async () => {
79
+ try {
80
+ await showAddStorageConnection(ctx, signal);
81
+ } catch (error) {
82
+ if (!signal?.aborted) {
83
+ ctx.ui.notify(`Storage connection was not added: ${syncErrorGuidance(error)}`, "error");
84
+ }
85
+ }
86
+ return { kind: "stay" };
87
+ },
88
+ select: async ({ itemId }) => {
89
+ selectedName = nameById.get(itemId);
90
+ return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
91
+ },
92
+ edit: async ({ state }) => {
93
+ if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
94
+ try {
95
+ await editStorageConnection(ctx, state.selected.name, state.selected.profile, state.selected.usedBy, signal);
96
+ } catch (error) {
97
+ notifyConnectionError(ctx, state.selected.name, error, signal);
98
+ }
99
+ return { kind: "stay" };
100
+ },
101
+ remove: async ({ state }) => {
102
+ if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
103
+ const name = state.selected.name;
104
+ const confirmed = await ctx.ui.confirm(
105
+ "Remove storage connection?",
106
+ `Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
107
+ { signal },
108
+ );
109
+ if (!confirmed || signal?.aborted) return { kind: "rejected" };
110
+ try {
111
+ await removeStorageConnection(name, signal);
112
+ ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
113
+ selectedName = undefined;
114
+ return { kind: "back" };
115
+ } catch (error) {
116
+ notifyConnectionError(ctx, name, error, signal);
117
+ return { kind: "stay" };
118
+ }
119
+ },
120
+ back: async () => {
121
+ selectedName = undefined;
122
+ return { kind: "back" };
123
+ },
124
+ },
125
+ });
126
+ await runMenu(ctx, menu, {
127
+ getState: () => loadStorageMenuState(selectedName, signal),
128
+ signal,
129
+ isCurrent: () => !signal?.aborted,
130
+ });
152
131
  }
153
132
 
154
133
  async function loadStorageMenuState(selectedName: string | undefined, signal?: AbortSignal) {
155
- const raw = await readLocalConfigObject();
156
- if (signal?.aborted) throw signal.reason;
157
- const profiles = ownRecord(raw?.storageConnections) ?? {};
158
- const profile = selectedName ? ownRecord(profiles[selectedName]) : undefined;
159
- if (!selectedName || !profile) {
160
- return { version3: raw?.version === 3, profiles, selected: undefined };
161
- }
162
- const usedBy = referencingSetups(raw, selectedName);
163
- return {
164
- version3: raw?.version === 3,
165
- profiles,
166
- selected: {
167
- name: selectedName,
168
- profile,
169
- usedBy,
170
- lines: [
171
- `Type: ${connectionType(profile)}`,
172
- `Endpoint: ${connectionEndpoint(profile)}`,
173
- `Credentials: ${credentialSource(profile)}`,
174
- `Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "No sync setups"}`,
175
- ...(usedBy.length > 0
176
- ? [
177
- "Remove unavailable: remove the listed sync setups first. Switch away from a current setup before removing it if other setups remain.",
178
- ]
179
- : []),
180
- ],
181
- },
182
- };
134
+ const raw = await readLocalConfigObject();
135
+ if (signal?.aborted) throw signal.reason;
136
+ const profiles = ownRecord(raw?.storageConnections) ?? {};
137
+ const profile = selectedName ? ownRecord(profiles[selectedName]) : undefined;
138
+ if (!selectedName || !profile) {
139
+ return { version3: raw?.version === 3, profiles, selected: undefined };
140
+ }
141
+ const usedBy = referencingSetups(raw, selectedName);
142
+ return {
143
+ version3: raw?.version === 3,
144
+ profiles,
145
+ selected: {
146
+ name: selectedName,
147
+ profile,
148
+ usedBy,
149
+ lines: [
150
+ `Type: ${connectionType(profile)}`,
151
+ `Endpoint: ${connectionEndpoint(profile)}`,
152
+ `Credentials: ${credentialSource(profile)}`,
153
+ `Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "No sync setups"}`,
154
+ ...(usedBy.length > 0
155
+ ? [
156
+ "Remove unavailable: remove the listed sync setups first. Switch away from a current setup before removing it if other setups remain.",
157
+ ]
158
+ : []),
159
+ ],
160
+ },
161
+ };
183
162
  }
184
163
 
185
- function notifyConnectionError(
186
- ctx: ExtensionCommandContext,
187
- name: string,
188
- error: unknown,
189
- signal?: AbortSignal,
190
- ) {
191
- if (signal?.aborted) return;
192
- ctx.ui.notify(
193
- `Storage connection “${safeTerminalText(name)}” was not changed: ${syncErrorGuidance(error)}`,
194
- "error",
195
- );
164
+ function notifyConnectionError(ctx: ExtensionCommandContext, name: string, error: unknown, signal?: AbortSignal) {
165
+ if (signal?.aborted) return;
166
+ ctx.ui.notify(`Storage connection “${safeTerminalText(name)}” was not changed: ${syncErrorGuidance(error)}`, "error");
196
167
  }
197
168
 
198
169
  async function editStorageConnection(
199
- ctx: ExtensionCommandContext,
200
- name: string,
201
- profile: Record<string, unknown>,
202
- usedBy: string[],
203
- signal?: AbortSignal,
170
+ ctx: ExtensionCommandContext,
171
+ name: string,
172
+ profile: Record<string, unknown>,
173
+ usedBy: string[],
174
+ signal?: AbortSignal,
204
175
  ) {
205
- if (ctx.mode !== "tui") {
206
- ctx.ui.notify(
207
- "Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
208
- "warning",
209
- );
210
- return;
211
- }
212
- if (profile.type === "webdav") {
213
- await showEditWebDavStorageProfile(
214
- ctx,
215
- name,
216
- { ...profile, kind: "webdav", ...(ownRecord(profile.credentials) ?? {}) },
217
- signal,
218
- usedBy,
219
- );
220
- return;
221
- }
222
- if (profile.type === "git") {
223
- await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy);
224
- return;
225
- }
226
- const endpoint = await requiredInput(
227
- ctx,
228
- "Endpoint",
229
- String(profile.endpoint ?? "https://s3.example.com"),
230
- signal,
231
- normalizeS3Endpoint,
232
- );
233
- if (!endpoint || signal?.aborted) return;
234
- const region = await requiredInput(
235
- ctx,
236
- "Region\n\nUse your bucket's region; Cloudflare R2 uses auto.",
237
- String(profile.region ?? "auto"),
238
- signal,
239
- );
240
- if (!region || signal?.aborted) return;
241
- const storedCredentials = ownRecord(profile.credentials) ?? {};
242
- const credentials = await chooseS3CredentialUpdate(
243
- ctx,
244
- { ...profile, ...storedCredentials },
245
- signal,
246
- );
247
- if (!credentials || signal?.aborted) return;
248
- const saved = await saveReviewedDraft(
249
- ctx,
250
- "Review storage connection",
251
- [
252
- `Storage connection: ${safeTerminalText(name)}`,
253
- `Endpoint: ${safeTerminalText(String(profile.endpoint ?? "missing"))} → ${safeTerminalText(endpoint)}`,
254
- `Region: ${safeTerminalText(String(profile.region ?? "auto"))} → ${safeTerminalText(region)}`,
255
- `Credentials: ${safeTerminalText(credentials.summary)}`,
256
- `Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "None"}`,
257
- "Saving changes future storage access for every affected setup; it does not move remote data.",
258
- ],
259
- "Save storage connection",
260
- (saveSignal) =>
261
- updateStorageConnection(
262
- name,
263
- (current) => {
264
- if (current.type !== "s3" || !isDeepStrictEqual(current, profile)) {
265
- throw new Error("Storage connection changed while it was open; reopen it.");
266
- }
267
- return {
268
- ...current,
269
- endpoint,
270
- region,
271
- credentials: applyS3CredentialUpdate(
272
- current.credentials,
273
- credentials,
274
- ) as typeof current.credentials,
275
- };
276
- },
277
- usedBy,
278
- saveSignal,
279
- ),
280
- signal,
281
- );
282
- if (!saved || signal?.aborted) return;
283
- ctx.ui.notify(`Saved storage connection “${safeTerminalText(name)}”.`, "info");
176
+ if (ctx.mode !== "tui") {
177
+ ctx.ui.notify(
178
+ "Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
179
+ "warning",
180
+ );
181
+ return;
182
+ }
183
+ if (profile.type === "webdav") {
184
+ await showEditWebDavStorageProfile(
185
+ ctx,
186
+ name,
187
+ { ...profile, kind: "webdav", ...(ownRecord(profile.credentials) ?? {}) },
188
+ signal,
189
+ usedBy,
190
+ );
191
+ return;
192
+ }
193
+ if (profile.type === "git") {
194
+ await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy);
195
+ return;
196
+ }
197
+ const endpoint = await requiredInput(
198
+ ctx,
199
+ "Endpoint",
200
+ String(profile.endpoint ?? "https://s3.example.com"),
201
+ signal,
202
+ normalizeS3Endpoint,
203
+ );
204
+ if (!endpoint || signal?.aborted) return;
205
+ const region = await requiredInput(
206
+ ctx,
207
+ "Region\n\nUse your bucket's region; Cloudflare R2 uses auto.",
208
+ String(profile.region ?? "auto"),
209
+ signal,
210
+ );
211
+ if (!region || signal?.aborted) return;
212
+ const storedCredentials = ownRecord(profile.credentials) ?? {};
213
+ const credentials = await chooseS3CredentialUpdate(ctx, { ...profile, ...storedCredentials }, signal);
214
+ if (!credentials || signal?.aborted) return;
215
+ const saved = await saveReviewedDraft(
216
+ ctx,
217
+ "Review storage connection",
218
+ [
219
+ `Storage connection: ${safeTerminalText(name)}`,
220
+ `Endpoint: ${safeTerminalText(String(profile.endpoint ?? "missing"))} → ${safeTerminalText(endpoint)}`,
221
+ `Region: ${safeTerminalText(String(profile.region ?? "auto"))} → ${safeTerminalText(region)}`,
222
+ `Credentials: ${safeTerminalText(credentials.summary)}`,
223
+ `Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "None"}`,
224
+ "Saving changes future storage access for every affected setup; it does not move remote data.",
225
+ ],
226
+ "Save storage connection",
227
+ (saveSignal) =>
228
+ updateStorageConnection(
229
+ name,
230
+ (current) => {
231
+ if (current.type !== "s3" || !isDeepStrictEqual(current, profile)) {
232
+ throw new Error("Storage connection changed while it was open; reopen it.");
233
+ }
234
+ return {
235
+ ...current,
236
+ endpoint,
237
+ region,
238
+ credentials: applyS3CredentialUpdate(current.credentials, credentials) as typeof current.credentials,
239
+ };
240
+ },
241
+ usedBy,
242
+ saveSignal,
243
+ ),
244
+ signal,
245
+ );
246
+ if (!saved || signal?.aborted) return;
247
+ ctx.ui.notify(`Saved storage connection “${safeTerminalText(name)}”.`, "info");
284
248
  }
285
249
 
286
250
  export async function showAddStorageConnection(ctx: ExtensionCommandContext, signal?: AbortSignal) {
287
- if (ctx.mode !== "tui") {
288
- ctx.ui.notify(
289
- "Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
290
- "warning",
291
- );
292
- return false;
293
- }
294
- const preset = await ctx.ui.select(
295
- "Storage type",
296
- ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
297
- { signal },
298
- );
299
- if (signal?.aborted || !preset || preset === "Cancel") return false;
300
- if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal);
301
- if (preset === "Git") return showAddGitStorageProfile(ctx, signal);
302
- const name = await promptResourceName(
303
- ctx,
304
- "storage connection",
305
- preset === "Cloudflare R2" ? "r2" : "s3",
306
- signal,
307
- );
308
- if (!name || signal?.aborted) return false;
309
- const endpoint = await requiredValueInput(
310
- ctx,
311
- "S3 API endpoint\n\nUse the API URL from your storage provider, not its web console.",
312
- preset === "Cloudflare R2"
313
- ? "https://<account-id>.r2.cloudflarestorage.com"
314
- : "https://s3.example.com",
315
- signal,
316
- normalizeS3Endpoint,
317
- );
318
- if (!endpoint || signal?.aborted) return false;
319
- const region =
320
- preset === "Cloudflare R2"
321
- ? "auto"
322
- : await requiredInput(
323
- ctx,
324
- "Region\n\nUse the region assigned to your bucket by the provider.",
325
- "us-east-1",
326
- signal,
327
- );
328
- if (!region || signal?.aborted) return false;
329
- const credentials = await chooseS3Credentials(ctx, signal);
330
- if (!credentials || signal?.aborted) return false;
331
- const saved = await saveReviewedDraft(
332
- ctx,
333
- "Review storage connection",
334
- [
335
- `Name: ${safeTerminalText(name)}`,
336
- `Type: ${preset}`,
337
- `Endpoint: ${safeTerminalText(endpoint)}`,
338
- `Region: ${safeTerminalText(region)}`,
339
- `Credentials: ${safeTerminalText(credentials.summary)}`,
340
- "Adding a connection does not contact remote storage or start syncing.",
341
- ],
342
- "Add storage connection",
343
- (saveSignal) =>
344
- addStorageConnection(
345
- name,
346
- {
347
- type: "s3",
348
- endpoint,
349
- region,
350
- credentials: {
351
- accessKeyId: credentials.profileFields.accessKeyId ?? "",
352
- secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
353
- },
354
- },
355
- saveSignal,
356
- ),
357
- signal,
358
- );
359
- if (!saved) return false;
360
- if (signal?.aborted) return true;
361
- ctx.ui.notify(`Added storage connection “${safeTerminalText(name)}”.`, "info");
362
- return true;
251
+ if (ctx.mode !== "tui") {
252
+ ctx.ui.notify(
253
+ "Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
254
+ "warning",
255
+ );
256
+ return false;
257
+ }
258
+ const preset = await ctx.ui.select(
259
+ "Storage type",
260
+ ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
261
+ { signal },
262
+ );
263
+ if (signal?.aborted || !preset || preset === "Cancel") return false;
264
+ if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal);
265
+ if (preset === "Git") return showAddGitStorageProfile(ctx, signal);
266
+ const name = await promptResourceName(ctx, "storage connection", preset === "Cloudflare R2" ? "r2" : "s3", signal);
267
+ if (!name || signal?.aborted) return false;
268
+ const endpoint = await requiredValueInput(
269
+ ctx,
270
+ "S3 API endpoint\n\nUse the API URL from your storage provider, not its web console.",
271
+ preset === "Cloudflare R2" ? "https://<account-id>.r2.cloudflarestorage.com" : "https://s3.example.com",
272
+ signal,
273
+ normalizeS3Endpoint,
274
+ );
275
+ if (!endpoint || signal?.aborted) return false;
276
+ const region =
277
+ preset === "Cloudflare R2"
278
+ ? "auto"
279
+ : await requiredInput(
280
+ ctx,
281
+ "Region\n\nUse the region assigned to your bucket by the provider.",
282
+ "us-east-1",
283
+ signal,
284
+ );
285
+ if (!region || signal?.aborted) return false;
286
+ const credentials = await chooseS3Credentials(ctx, signal);
287
+ if (!credentials || signal?.aborted) return false;
288
+ const saved = await saveReviewedDraft(
289
+ ctx,
290
+ "Review storage connection",
291
+ [
292
+ `Name: ${safeTerminalText(name)}`,
293
+ `Type: ${preset}`,
294
+ `Endpoint: ${safeTerminalText(endpoint)}`,
295
+ `Region: ${safeTerminalText(region)}`,
296
+ `Credentials: ${safeTerminalText(credentials.summary)}`,
297
+ "Adding a connection does not contact remote storage or start syncing.",
298
+ ],
299
+ "Add storage connection",
300
+ (saveSignal) =>
301
+ addStorageConnection(
302
+ name,
303
+ {
304
+ type: "s3",
305
+ endpoint,
306
+ region,
307
+ credentials: {
308
+ accessKeyId: credentials.profileFields.accessKeyId ?? "",
309
+ secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
310
+ },
311
+ },
312
+ saveSignal,
313
+ ),
314
+ signal,
315
+ );
316
+ if (!saved) return false;
317
+ if (signal?.aborted) return true;
318
+ ctx.ui.notify(`Added storage connection “${safeTerminalText(name)}”.`, "info");
319
+ return true;
363
320
  }
364
321
 
365
322
  function referencingSetups(raw: Record<string, unknown> | undefined, connection: string) {
366
- return Object.entries(ownRecord(raw?.syncSetups) ?? {})
367
- .filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection)
368
- .map(([name]) => name)
369
- .sort((left, right) => left.localeCompare(right));
323
+ return Object.entries(ownRecord(raw?.syncSetups) ?? {})
324
+ .filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection)
325
+ .map(([name]) => name)
326
+ .sort((left, right) => left.localeCompare(right));
370
327
  }
371
328
 
372
329
  export function connectionSummary(profile: Record<string, unknown>) {
373
- return `${connectionType(profile)} · ${connectionEndpoint(profile)}`;
330
+ return `${connectionType(profile)} · ${connectionEndpoint(profile)}`;
374
331
  }
375
332
 
376
333
  function connectionType(profile: Record<string, unknown>) {
377
- if (profile.type === "git") return "Git";
378
- if (profile.type === "webdav") return "WebDAV";
379
- if (
380
- profile.type === "s3" &&
381
- typeof profile.endpoint === "string" &&
382
- isCloudflareR2Endpoint(profile.endpoint)
383
- ) {
384
- return "Cloudflare R2";
385
- }
386
- return "S3-compatible";
334
+ if (profile.type === "git") return "Git";
335
+ if (profile.type === "webdav") return "WebDAV";
336
+ if (profile.type === "s3" && typeof profile.endpoint === "string" && isCloudflareR2Endpoint(profile.endpoint)) {
337
+ return "Cloudflare R2";
338
+ }
339
+ return "S3-compatible";
387
340
  }
388
341
 
389
342
  function connectionEndpoint(profile: Record<string, unknown>) {
390
- const value =
391
- profile.type === "git"
392
- ? profile.remote
393
- : profile.type === "webdav"
394
- ? profile.url
395
- : profile.endpoint;
396
- if (typeof value !== "string" || value.length === 0) return "Missing";
397
- if (profile.type === "git") return safeTerminalText(value);
398
- try {
399
- const url = new URL(value);
400
- return safeTerminalText(`${url.origin}${url.pathname}`);
401
- } catch {
402
- return "Invalid";
403
- }
343
+ const value = profile.type === "git" ? profile.remote : profile.type === "webdav" ? profile.url : profile.endpoint;
344
+ if (typeof value !== "string" || value.length === 0) return "Missing";
345
+ if (profile.type === "git") return safeTerminalText(value);
346
+ try {
347
+ const url = new URL(value);
348
+ return safeTerminalText(`${url.origin}${url.pathname}`);
349
+ } catch {
350
+ return "Invalid";
351
+ }
404
352
  }
405
353
 
406
354
  function credentialSource(profile: Record<string, unknown>) {
407
- if (profile.type === "git") return "Git credential helper or SSH configuration";
408
- const credentials = ownRecord(profile.credentials);
409
- if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing";
410
- if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file";
411
- return "Missing";
355
+ if (profile.type === "git") return "Git credential helper or SSH configuration";
356
+ const credentials = ownRecord(profile.credentials);
357
+ if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing";
358
+ if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file";
359
+ return "Missing";
412
360
  }