@narumitw/pi-sync 0.35.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,8 +1,5 @@
1
- import {
2
- type ExtensionCommandContext,
3
- getSettingsListTheme,
4
- } from "@earendil-works/pi-coding-agent";
5
- import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
6
3
  import { loadConfig, localConfigPath } from "./config.js";
7
4
  import { updateSyncSetup } from "./settings-management.js";
8
5
  import {
@@ -30,149 +27,110 @@ export async function showSyncSettings(
30
27
  ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
31
28
  return;
32
29
  }
33
- while (!signal?.aborted) {
34
- const config = await loadConfig();
35
- if (signal?.aborted) return;
36
- const action = await showSettingsList(ctx, config, signal);
37
- if (signal?.aborted || action !== "files") return;
38
- await runRoute("files", signal, undefined, config.setupName);
39
- }
40
- }
41
-
42
- async function showSettingsList(
43
- ctx: ExtensionCommandContext,
44
- config: Awaited<ReturnType<typeof loadConfig>>,
45
- signal?: AbortSignal,
46
- ) {
47
- const automaticValue = config.automatic ? "On" : "Off";
48
- const switchValue = setupSwitchActionLabel(config.onSwitch);
49
- let saveQueue = Promise.resolve();
50
- const latestRequested = new Map<string, string>();
51
- const mutationController = new AbortController();
52
- const mutationSignal = signal
53
- ? AbortSignal.any([signal, mutationController.signal])
54
- : mutationController.signal;
55
-
56
- return ctx.ui.custom<"files" | undefined>((tui, theme, _keybindings, done) => {
57
- const items: SettingItem[] = [
58
- {
59
- id: "automatic",
60
- label: "Automatic sync",
61
- description: "Run conservative synchronization at session startup and shutdown.",
62
- currentValue: automaticValue,
63
- values: ["On", "Off"],
64
- },
65
- {
66
- id: "onSwitch",
67
- label: "After switching setup",
68
- description:
69
- "Ask before a reviewed pull, start a reviewed pull, or switch without checking remote files.",
70
- currentValue: switchValue,
71
- values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
72
- },
73
- {
74
- id: "include",
75
- label: "Included content",
76
- description: `${config.include.length} selected path${config.include.length === 1 ? "" : "s"}. Opens the reviewed content-selection draft.`,
77
- currentValue: "Open editor",
78
- values: ["Open editor"],
79
- },
80
- ];
81
- const container = new Container();
82
- container.addChild(new Text(theme.fg("accent", theme.bold("Pi Sync Settings")), 1, 0));
83
- container.addChild(
84
- new Text(
85
- theme.fg(
86
- "muted",
87
- `Sync setup: ${safeTerminalText(config.setupName)} · Storage connection: ${safeTerminalText(config.connectionName)}`,
88
- ),
89
- 1,
90
- 0,
91
- ),
92
- );
93
- let settingsList: SettingsList;
94
- let closing = false;
95
- let disposed = false;
96
- const closeAfterSaves = (result: "files" | undefined) => {
97
- if (closing || disposed) return;
98
- closing = true;
99
- void saveQueue.then(() => {
100
- if (!disposed) done(signal?.aborted ? undefined : result);
101
- });
102
- };
103
- const onAbort = () => {
104
- if (!closing && !disposed) done(undefined);
105
- };
106
- signal?.addEventListener("abort", onAbort, { once: true });
107
- settingsList = new SettingsList(
108
- items,
109
- Math.min(items.length + 2, 15),
110
- getSettingsListTheme(),
111
- (id, newValue) => {
112
- if (id === "include") {
113
- closeAfterSaves("files");
114
- return;
30
+ const initial = await loadConfig();
31
+ if (signal?.aborted) return;
32
+ const setupName = initial.setupName;
33
+ type Action = "automatic" | "on-switch" | "include";
34
+ const menu = defineMenu<
35
+ Awaited<ReturnType<typeof loadConfig>>,
36
+ "settings",
37
+ Action,
38
+ ExtensionCommandContext
39
+ >({
40
+ start: "settings",
41
+ screens: {
42
+ settings: ({ state }) => ({
43
+ kind: "settings",
44
+ title: "Pi Sync Settings",
45
+ lines: [
46
+ `Sync setup: ${safeTerminalText(state.setupName)} · Storage connection: ${safeTerminalText(state.connectionName)}`,
47
+ ],
48
+ items: [
49
+ {
50
+ id: "automatic",
51
+ label: "Automatic sync",
52
+ description: "Run conservative synchronization at session startup and shutdown.",
53
+ currentValue: state.automatic ? "On" : "Off",
54
+ values: ["On", "Off"],
55
+ action: "automatic",
56
+ },
57
+ {
58
+ id: "onSwitch",
59
+ label: "After switching setup",
60
+ description:
61
+ "Ask before a reviewed pull, start a reviewed pull, or switch without checking remote files.",
62
+ currentValue: setupSwitchActionLabel(state.onSwitch),
63
+ values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
64
+ action: "on-switch",
65
+ },
66
+ {
67
+ id: "include",
68
+ label: "Included content",
69
+ description: `${state.include.length} selected path${state.include.length === 1 ? "" : "s"}. Opens the reviewed content-selection draft.`,
70
+ currentValue: "Open editor",
71
+ action: "include",
72
+ },
73
+ ],
74
+ }),
75
+ },
76
+ actions: {
77
+ automatic: async ({ value, signal: actionSignal }) => {
78
+ const automatic = value === "On";
79
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
80
+ try {
81
+ const latest = await loadConfig(setupName);
82
+ if (mutationSignal.aborted) return { kind: "rejected" };
83
+ if (latest.automatic === automatic) return { kind: "stay" };
84
+ await updateSyncSetup(
85
+ setupName,
86
+ (setup) => ({ ...setup, sync: { ...setup.sync, automatic } }),
87
+ { signal: mutationSignal },
88
+ );
89
+ if (mutationSignal.aborted) return { kind: "rejected" };
90
+ ctx.ui.notify(
91
+ `Automatic sync ${automatic ? "enabled" : "disabled"} for “${safeTerminalText(setupName)}”.`,
92
+ "info",
93
+ );
94
+ return { kind: "stay" };
95
+ } catch (error) {
96
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
97
+ return { kind: "rejected" };
115
98
  }
116
- latestRequested.set(id, newValue);
117
- const fallback = id === "automatic" ? automaticValue : switchValue;
118
- const operation = saveQueue.then(async () => {
119
- if (disposed || signal?.aborted) return;
120
- let previousValue = fallback;
121
- try {
122
- const latest = await loadConfig(config.setupName);
123
- if (disposed || signal?.aborted) return;
124
- if (id === "automatic") {
125
- previousValue = latest.automatic ? "On" : "Off";
126
- const automatic = newValue === "On";
127
- await updateSyncSetup(
128
- config.setupName,
129
- (setup) => ({
130
- ...setup,
131
- sync: { ...setup.sync, automatic },
132
- }),
133
- { signal: mutationSignal },
134
- );
135
- if (disposed || signal?.aborted) return;
136
- ctx.ui.notify(
137
- `Automatic sync ${automatic ? "enabled" : "disabled"} for “${safeTerminalText(config.setupName)}”.`,
138
- "info",
139
- );
140
- } else {
141
- previousValue = setupSwitchActionLabel(latest.onSwitch);
142
- const action = setupSwitchActionFromLabel(newValue);
143
- if (!action) throw new Error(`Invalid setup-switch action: ${newValue}`);
144
- await saveOnSwitch(action, mutationSignal);
145
- if (disposed || signal?.aborted) return;
146
- ctx.ui.notify(`After switching setup: ${newValue}.`, "info");
147
- }
148
- } catch (error) {
149
- if (disposed || signal?.aborted) return;
150
- if (latestRequested.get(id) === newValue) settingsList.updateValue(id, previousValue);
151
- ctx.ui.notify(
152
- `Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`,
153
- "error",
154
- );
155
- tui.requestRender();
156
- }
157
- });
158
- saveQueue = operation.catch(() => undefined);
159
99
  },
160
- () => closeAfterSaves(undefined),
161
- );
162
- container.addChild(settingsList);
163
- return {
164
- render: (width: number) => container.render(width),
165
- invalidate: () => container.invalidate(),
166
- handleInput(data: string) {
167
- if (disposed) return;
168
- settingsList.handleInput?.(data);
169
- tui.requestRender();
100
+ "on-switch": async ({ value, signal: actionSignal }) => {
101
+ const action = value ? setupSwitchActionFromLabel(value) : undefined;
102
+ if (!action) return { kind: "rejected" };
103
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
104
+ try {
105
+ const latest = await loadConfig(setupName);
106
+ if (mutationSignal.aborted) return { kind: "rejected" };
107
+ if (latest.onSwitch === action) return { kind: "stay" };
108
+ await saveOnSwitch(action, mutationSignal);
109
+ if (mutationSignal.aborted) return { kind: "rejected" };
110
+ ctx.ui.notify(`After switching setup: ${value}.`, "info");
111
+ return { kind: "stay" };
112
+ } catch (error) {
113
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
114
+ return { kind: "rejected" };
115
+ }
170
116
  },
171
- dispose() {
172
- disposed = true;
173
- mutationController.abort(new DOMException("Settings UI disposed", "AbortError"));
174
- signal?.removeEventListener("abort", onAbort);
117
+ include: async ({ signal: actionSignal }) => {
118
+ const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
119
+ await runRoute("files", editorSignal, undefined, setupName);
120
+ return editorSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
175
121
  },
176
- };
122
+ },
177
123
  });
124
+ await runMenu(ctx, menu, {
125
+ getState: () => loadConfig(setupName),
126
+ signal,
127
+ isCurrent: () => !signal?.aborted,
128
+ });
129
+ }
130
+
131
+ function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown) {
132
+ ctx.ui.notify(
133
+ `Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`,
134
+ "error",
135
+ );
178
136
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
2
3
  import { isCloudflareR2Endpoint, readLocalConfigObject } from "./config.js";
3
4
  import { showAddGitStorageProfile, showEditGitStorageProfile } from "./git-ui.js";
4
5
  import { errorMessage, ownRecord, requiredInput, safeTerminalText } from "./manager-helpers.js";
@@ -14,64 +15,151 @@ import {
14
15
  } from "./settings-management.js";
15
16
  import { showAddWebDavStorageProfile, showEditWebDavStorageProfile } from "./webdav-ui.js";
16
17
 
17
- const BACK = "Back";
18
-
19
18
  export async function showStorageConnections(ctx: ExtensionCommandContext, signal?: AbortSignal) {
20
- while (!signal?.aborted) {
21
- const raw = await readLocalConfigObject();
22
- if (signal?.aborted) return;
23
- if (raw?.version !== 3) {
24
- ctx.ui.notify("Create version 3 settings before managing storage connections.", "info");
25
- return;
26
- }
27
- const profiles = ownRecord(raw.storageConnections) ?? {};
28
- const labels = new Map(
29
- Object.keys(profiles)
30
- .sort((left, right) => left.localeCompare(right))
31
- .map((name) => [safeTerminalText(name), name]),
32
- );
33
- const selected = await ctx.ui.select(
34
- "Storage connections",
35
- ["Add storage connection", ...labels.keys(), BACK],
36
- { signal },
37
- );
38
- if (signal?.aborted || !selected || selected === BACK) return;
39
- if (selected === "Add storage connection") {
40
- try {
41
- await showAddStorageConnection(ctx, signal);
42
- } catch (error) {
43
- if (signal?.aborted) return;
44
- ctx.ui.notify(
45
- `Storage connection was not added: ${safeTerminalText(errorMessage(error))} Retry from Add storage connection.`,
46
- "error",
19
+ let selectedName: string | undefined;
20
+ const nameById = new Map<string, string>();
21
+ type Screen = "list" | "detail";
22
+ type Action = "add" | "select" | "edit" | "remove" | "back";
23
+ const menu = defineMenu<
24
+ Awaited<ReturnType<typeof loadStorageMenuState>>,
25
+ Screen,
26
+ Action,
27
+ ExtensionCommandContext
28
+ >({
29
+ start: "list",
30
+ screens: {
31
+ list: ({ state }) => {
32
+ nameById.clear();
33
+ const names = Object.keys(state.profiles).sort((left, right) => left.localeCompare(right));
34
+ return {
35
+ kind: "actions",
36
+ title: "Storage connections",
37
+ lines: state.version3
38
+ ? []
39
+ : ["Create version 3 settings before managing storage connections."],
40
+ items: state.version3
41
+ ? [
42
+ { id: "add", label: "Add storage connection", action: "add" as const },
43
+ ...names.map((name, index) => {
44
+ const id = `connection:${index}`;
45
+ nameById.set(id, name);
46
+ return {
47
+ id,
48
+ label: safeTerminalText(name),
49
+ action: "select" as const,
50
+ };
51
+ }),
52
+ ]
53
+ : [],
54
+ hint: "back",
55
+ };
56
+ },
57
+ detail: ({ state }) => ({
58
+ kind: "actions",
59
+ title: state.selected
60
+ ? `Storage connection “${safeTerminalText(state.selected.name)}”`
61
+ : "Storage connection",
62
+ lines: state.selected?.lines ?? ["This storage connection no longer exists."],
63
+ items: state.selected
64
+ ? [
65
+ { id: "edit", label: "Edit storage connection…", action: "edit" },
66
+ ...(state.selected.usedBy.length === 0
67
+ ? [
68
+ {
69
+ id: "remove",
70
+ label: "Remove storage connection…",
71
+ action: "remove" as const,
72
+ },
73
+ ]
74
+ : []),
75
+ { id: "back", label: "Back", action: "back" },
76
+ ]
77
+ : [{ id: "back", label: "Back", action: "back" }],
78
+ hint: "back",
79
+ }),
80
+ },
81
+ actions: {
82
+ add: async () => {
83
+ try {
84
+ await showAddStorageConnection(ctx, signal);
85
+ } catch (error) {
86
+ if (!signal?.aborted) {
87
+ ctx.ui.notify(
88
+ `Storage connection was not added: ${safeTerminalText(errorMessage(error))} Retry from Add storage connection.`,
89
+ "error",
90
+ );
91
+ }
92
+ }
93
+ return { kind: "stay" };
94
+ },
95
+ select: async ({ itemId }) => {
96
+ selectedName = nameById.get(itemId);
97
+ return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
98
+ },
99
+ edit: async ({ state }) => {
100
+ if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
101
+ try {
102
+ await editStorageConnection(
103
+ ctx,
104
+ state.selected.name,
105
+ state.selected.profile,
106
+ state.selected.usedBy,
107
+ signal,
108
+ );
109
+ } catch (error) {
110
+ notifyConnectionError(ctx, state.selected.name, error, signal);
111
+ }
112
+ return { kind: "stay" };
113
+ },
114
+ remove: async ({ state }) => {
115
+ if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
116
+ const name = state.selected.name;
117
+ const confirmed = await ctx.ui.confirm(
118
+ "Remove storage connection?",
119
+ `Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
120
+ { signal },
47
121
  );
48
- }
49
- continue;
50
- }
51
- const name = labels.get(selected);
52
- if (name) await showStorageConnectionDetail(ctx, name, signal);
53
- }
122
+ if (!confirmed || signal?.aborted) return { kind: "rejected" };
123
+ try {
124
+ await removeStorageConnection(name, signal);
125
+ ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
126
+ selectedName = undefined;
127
+ return { kind: "back" };
128
+ } catch (error) {
129
+ notifyConnectionError(ctx, name, error, signal);
130
+ return { kind: "stay" };
131
+ }
132
+ },
133
+ back: async () => {
134
+ selectedName = undefined;
135
+ return { kind: "back" };
136
+ },
137
+ },
138
+ });
139
+ await runMenu(ctx, menu, {
140
+ getState: () => loadStorageMenuState(selectedName, signal),
141
+ signal,
142
+ isCurrent: () => !signal?.aborted,
143
+ });
54
144
  }
55
145
 
56
- async function showStorageConnectionDetail(
57
- ctx: ExtensionCommandContext,
58
- name: string,
59
- signal?: AbortSignal,
60
- ) {
61
- while (!signal?.aborted) {
62
- const raw = await readLocalConfigObject();
63
- if (signal?.aborted) return;
64
- const profiles = ownRecord(raw?.storageConnections) ?? {};
65
- const profile = ownRecord(profiles[name]);
66
- if (!profile) {
67
- ctx.ui.notify(`Storage connection “${safeTerminalText(name)}” no longer exists.`, "warning");
68
- return;
69
- }
70
- const usedBy = referencingSetups(raw, name);
71
- const selected = await ctx.ui.select(
72
- [
73
- `Storage connection “${safeTerminalText(name)}”`,
74
- "",
146
+ async function loadStorageMenuState(selectedName: string | undefined, signal?: AbortSignal) {
147
+ const raw = await readLocalConfigObject();
148
+ if (signal?.aborted) throw signal.reason;
149
+ const profiles = ownRecord(raw?.storageConnections) ?? {};
150
+ const profile = selectedName ? ownRecord(profiles[selectedName]) : undefined;
151
+ if (!selectedName || !profile) {
152
+ return { version3: raw?.version === 3, profiles, selected: undefined };
153
+ }
154
+ const usedBy = referencingSetups(raw, selectedName);
155
+ return {
156
+ version3: raw?.version === 3,
157
+ profiles,
158
+ selected: {
159
+ name: selectedName,
160
+ profile,
161
+ usedBy,
162
+ lines: [
75
163
  `Type: ${connectionType(profile)}`,
76
164
  `Endpoint: ${connectionEndpoint(profile)}`,
77
165
  `Credentials: ${credentialSource(profile)}`,
@@ -79,36 +167,22 @@ async function showStorageConnectionDetail(
79
167
  ...(usedBy.length > 0
80
168
  ? ["Remove unavailable: edit or remove the listed sync setups first."]
81
169
  : []),
82
- ].join("\n"),
83
- [
84
- "Edit storage connection…",
85
- ...(usedBy.length === 0 ? ["Remove storage connection…"] : []),
86
- BACK,
87
170
  ],
88
- { signal },
89
- );
90
- if (signal?.aborted || !selected || selected === BACK) return;
91
- try {
92
- if (selected === "Remove storage connection…") {
93
- const confirmed = await ctx.ui.confirm(
94
- "Remove storage connection?",
95
- `Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
96
- { signal },
97
- );
98
- if (!confirmed || signal?.aborted) continue;
99
- await removeStorageConnection(name, signal);
100
- ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
101
- return;
102
- }
103
- await editStorageConnection(ctx, name, profile, usedBy, signal);
104
- } catch (error) {
105
- if (signal?.aborted) return;
106
- ctx.ui.notify(
107
- `Storage connection “${safeTerminalText(name)}” was not changed: ${safeTerminalText(errorMessage(error))} Reopen it and retry.`,
108
- "error",
109
- );
110
- }
111
- }
171
+ },
172
+ };
173
+ }
174
+
175
+ function notifyConnectionError(
176
+ ctx: ExtensionCommandContext,
177
+ name: string,
178
+ error: unknown,
179
+ signal?: AbortSignal,
180
+ ) {
181
+ if (signal?.aborted) return;
182
+ ctx.ui.notify(
183
+ `Storage connection “${safeTerminalText(name)}” was not changed: ${safeTerminalText(errorMessage(error))} Reopen it and retry.`,
184
+ "error",
185
+ );
112
186
  }
113
187
 
114
188
  async function editStorageConnection(