@narumitw/pi-sync 0.34.0 → 0.38.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.
@@ -1,10 +1,9 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
2
3
  import { loadConfig, readLocalConfigObject } from "./config.js";
3
4
  import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
4
5
  import { syncIncludeSelection } from "./sync-policy.js";
5
6
 
6
- const BACK = "Back";
7
-
8
7
  export async function countValidSyncSetups(
9
8
  setups: Record<string, unknown> | undefined,
10
9
  signal?: AbortSignal,
@@ -39,115 +38,203 @@ type SyncSetupActions = {
39
38
  remove(name: string, signal?: AbortSignal): Promise<void>;
40
39
  };
41
40
 
41
+ interface SetupMenuState {
42
+ setups: Record<string, unknown>;
43
+ active?: string;
44
+ selected?: {
45
+ name: string;
46
+ detail: string[];
47
+ valid: boolean;
48
+ removeUnavailable: boolean;
49
+ };
50
+ }
51
+
42
52
  export async function showSyncSetups(
43
53
  ctx: ExtensionCommandContext,
44
54
  actions: SyncSetupActions,
45
55
  signal?: AbortSignal,
46
56
  ): Promise<"exit" | undefined> {
47
- while (!signal?.aborted) {
48
- const raw = await readLocalConfigObject();
57
+ let selectedName: string | undefined;
58
+ let exit = false;
59
+ const nameById = new Map<string, string>();
60
+ type Screen = "list" | "detail";
61
+ type Action = "add" | "select" | "make-current" | "edit" | "remove" | "back";
62
+ const menu = defineMenu<SetupMenuState, Screen, Action, ExtensionCommandContext>({
63
+ start: "list",
64
+ screens: {
65
+ list: ({ state }) => {
66
+ nameById.clear();
67
+ const names = Object.keys(state.setups).sort((left, right) => left.localeCompare(right));
68
+ return {
69
+ kind: "actions",
70
+ title: "Sync setups",
71
+ items: [
72
+ { id: "add", label: "Add sync setup", action: "add" },
73
+ ...names.map((name, index) => {
74
+ const id = `setup:${index}`;
75
+ nameById.set(id, name);
76
+ return {
77
+ id,
78
+ label: `${safeTerminalText(name)}${name === state.active ? " (current)" : ""}`,
79
+ action: "select" as const,
80
+ };
81
+ }),
82
+ ],
83
+ hint: "back",
84
+ };
85
+ },
86
+ detail: ({ state }) => ({
87
+ kind: "actions",
88
+ title: state.selected
89
+ ? `Sync setup “${safeTerminalText(state.selected.name)}”`
90
+ : "Sync setup",
91
+ lines: state.selected?.detail ?? ["This sync setup no longer exists."],
92
+ items: state.selected
93
+ ? [
94
+ ...(!state.selected.name ||
95
+ state.selected.name === state.active ||
96
+ !state.selected.valid
97
+ ? []
98
+ : [
99
+ {
100
+ id: "make-current",
101
+ label: "Make current…",
102
+ action: "make-current" as const,
103
+ },
104
+ ]),
105
+ { id: "edit", label: "Edit sync setup…", action: "edit" },
106
+ ...(state.selected.removeUnavailable
107
+ ? []
108
+ : [
109
+ {
110
+ id: "remove",
111
+ label: "Remove sync setup…",
112
+ action: "remove" as const,
113
+ },
114
+ ]),
115
+ { id: "back", label: "Back", action: "back" },
116
+ ]
117
+ : [{ id: "back", label: "Back", action: "back" }],
118
+ hint: "back",
119
+ }),
120
+ },
121
+ actions: {
122
+ add: async () => {
123
+ try {
124
+ await actions.add(signal);
125
+ } catch (error) {
126
+ if (!signal?.aborted) {
127
+ ctx.ui.notify(
128
+ `Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`,
129
+ "error",
130
+ );
131
+ }
132
+ }
133
+ return { kind: "stay" };
134
+ },
135
+ select: async ({ itemId }) => {
136
+ selectedName = nameById.get(itemId);
137
+ return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
138
+ },
139
+ "make-current": async () => {
140
+ if (!selectedName) return { kind: "rejected" };
141
+ try {
142
+ exit = (await actions.makeCurrent(selectedName, signal)) === "exit";
143
+ return exit ? { kind: "close" } : { kind: "stay" };
144
+ } catch (error) {
145
+ notifySetupChangeError(ctx, selectedName, error, signal);
146
+ return { kind: "stay" };
147
+ }
148
+ },
149
+ edit: async () => {
150
+ if (!selectedName) return { kind: "rejected" };
151
+ try {
152
+ await actions.edit(selectedName, signal);
153
+ } catch (error) {
154
+ notifySetupChangeError(ctx, selectedName, error, signal);
155
+ }
156
+ return { kind: "stay" };
157
+ },
158
+ remove: async () => {
159
+ if (!selectedName) return { kind: "rejected" };
160
+ const name = selectedName;
161
+ try {
162
+ await actions.remove(name, signal);
163
+ selectedName = undefined;
164
+ return { kind: "back" };
165
+ } catch (error) {
166
+ notifySetupChangeError(ctx, name, error, signal);
167
+ return { kind: "stay" };
168
+ }
169
+ },
170
+ back: async () => {
171
+ selectedName = undefined;
172
+ return { kind: "back" };
173
+ },
174
+ },
175
+ });
176
+ await runMenu(ctx, menu, {
177
+ getState: async () => loadSetupMenuState(selectedName, signal),
178
+ signal,
179
+ isCurrent: () => !signal?.aborted,
180
+ });
181
+ return exit ? "exit" : undefined;
182
+ }
183
+
184
+ async function loadSetupMenuState(
185
+ selectedName: string | undefined,
186
+ signal?: AbortSignal,
187
+ ): Promise<SetupMenuState> {
188
+ const raw = await readLocalConfigObject();
189
+ throwIfAborted(signal);
190
+ const setups = ownRecord(raw?.syncSetups) ?? {};
191
+ const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
192
+ if (!selectedName || !ownRecord(setups[selectedName])) return { setups, active };
193
+ const setupCount = Object.keys(setups).length;
194
+ const isCurrent = selectedName === active;
195
+ let detail: string[];
196
+ let valid = true;
197
+ try {
198
+ const config = await loadConfig(selectedName);
49
199
  throwIfAborted(signal);
50
- const setups = ownRecord(raw?.syncSetups) ?? {};
51
- const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
52
- const labels = new Map<string, string>();
53
- for (const name of Object.keys(setups).sort((left, right) => left.localeCompare(right))) {
54
- const label = `${safeTerminalText(name)}${name === active ? " (current)" : ""}`;
55
- labels.set(label, name);
56
- }
57
- const selected = await ctx.ui.select(
58
- "Sync setups",
59
- ["Add sync setup", ...labels.keys(), BACK],
60
- { signal },
61
- );
62
- if (signal?.aborted || !selected || selected === BACK) return;
63
- if (selected === "Add sync setup") {
64
- try {
65
- await actions.add(signal);
66
- } catch (error) {
67
- if (signal?.aborted) return;
68
- ctx.ui.notify(
69
- `Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`,
70
- "error",
71
- );
72
- }
73
- continue;
74
- }
75
- const name = labels.get(selected);
76
- if (!name) continue;
77
- const result = await showSyncSetupDetail(ctx, name, actions, signal);
78
- if (result === "exit") return "exit";
200
+ const selection = syncIncludeSelection(config.include);
201
+ detail = [
202
+ `Status: ${isCurrent ? "Current" : "Not current"}`,
203
+ `Storage connection: ${safeTerminalText(config.connectionName)}`,
204
+ `Endpoint: ${storageEndpoint(config)}`,
205
+ `Storage location: ${storageLocation(config)}`,
206
+ `Included content: ${selection.builtIns.length} built-in groups · ${selection.custom.length} extra paths`,
207
+ `Sessions: ${selection.sessions ? "On — privacy-sensitive" : "Off"}`,
208
+ `Automatic sync: ${config.automatic ? "On" : "Off"}`,
209
+ ];
210
+ } catch (error) {
211
+ valid = false;
212
+ detail = [
213
+ `Status: Invalid${isCurrent ? " current setup" : ""}`,
214
+ `Reason: ${menuErrorMessage(error)}`,
215
+ "Make current and sync are unavailable until this setup is repaired.",
216
+ ];
79
217
  }
218
+ const removeUnavailable = isCurrent && setupCount > 1;
219
+ if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first.");
220
+ return {
221
+ setups,
222
+ active,
223
+ selected: { name: selectedName, detail, valid, removeUnavailable },
224
+ };
80
225
  }
81
226
 
82
- async function showSyncSetupDetail(
227
+ function notifySetupChangeError(
83
228
  ctx: ExtensionCommandContext,
84
229
  name: string,
85
- actions: SyncSetupActions,
230
+ error: unknown,
86
231
  signal?: AbortSignal,
87
- ): Promise<"exit" | undefined> {
88
- while (!signal?.aborted) {
89
- const raw = await readLocalConfigObject();
90
- throwIfAborted(signal);
91
- const setups = ownRecord(raw?.syncSetups) ?? {};
92
- const setup = ownRecord(setups[name]);
93
- if (!setup) {
94
- ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” no longer exists.`, "warning");
95
- return;
96
- }
97
- const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
98
- const setupCount = Object.keys(setups).length;
99
- const isCurrent = name === active;
100
- let detail: string[];
101
- let valid = true;
102
- try {
103
- const config = await loadConfig(name);
104
- throwIfAborted(signal);
105
- const selection = syncIncludeSelection(config.include);
106
- detail = [
107
- `Status: ${isCurrent ? "Current" : "Not current"}`,
108
- `Storage connection: ${safeTerminalText(config.connectionName)}`,
109
- `Endpoint: ${storageEndpoint(config)}`,
110
- `Storage location: ${storageLocation(config)}`,
111
- `Included content: ${selection.builtIns.length} built-in groups · ${selection.custom.length} extra paths`,
112
- `Sessions: ${selection.sessions ? "On — privacy-sensitive" : "Off"}`,
113
- `Automatic sync: ${config.automatic ? "On" : "Off"}`,
114
- ];
115
- } catch (error) {
116
- valid = false;
117
- detail = [
118
- `Status: Invalid${isCurrent ? " current setup" : ""}`,
119
- `Reason: ${menuErrorMessage(error)}`,
120
- "Make current and sync are unavailable until this setup is repaired.",
121
- ];
122
- }
123
- const removeUnavailable = isCurrent && setupCount > 1;
124
- if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first.");
125
- const selected = await ctx.ui.select(
126
- [`Sync setup “${safeTerminalText(name)}”`, "", ...detail].join("\n"),
127
- [
128
- ...(!isCurrent && valid ? ["Make current…"] : []),
129
- "Edit sync setup…",
130
- ...(removeUnavailable ? [] : ["Remove sync setup…"]),
131
- BACK,
132
- ],
133
- { signal },
134
- );
135
- if (signal?.aborted || !selected || selected === BACK) return;
136
- try {
137
- if (selected === "Make current…") return actions.makeCurrent(name, signal);
138
- if (selected === "Edit sync setup…") await actions.edit(name, signal);
139
- else if (selected === "Remove sync setup…") {
140
- await actions.remove(name, signal);
141
- return;
142
- }
143
- } catch (error) {
144
- if (signal?.aborted) return;
145
- ctx.ui.notify(
146
- `Sync setup “${safeTerminalText(name)}” was not changed: ${menuErrorMessage(error)} Reopen it and retry.`,
147
- "error",
148
- );
149
- }
150
- }
232
+ ) {
233
+ if (signal?.aborted) return;
234
+ ctx.ui.notify(
235
+ `Sync setup “${safeTerminalText(name)}” was not changed: ${menuErrorMessage(error)} Reopen it and retry.`,
236
+ "error",
237
+ );
151
238
  }
152
239
 
153
240
  function menuErrorMessage(error: unknown) {