pi-extmgr 0.1.22 → 0.1.24

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.
package/src/ui/unified.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  updatePackageWithOutcome,
26
26
  removePackageWithOutcome,
27
27
  updatePackagesWithOutcome,
28
+ showInstalledPackagesList,
28
29
  } from "../packages/management.js";
29
30
  import { showRemote } from "./remote.js";
30
31
  import { showHelp } from "./help.js";
@@ -37,38 +38,39 @@ import {
37
38
  formatSize,
38
39
  } from "./theme.js";
39
40
  import { buildFooterState, buildFooterShortcuts, getPendingToggleChangeCount } from "./footer.js";
40
- import { logExtensionToggle } from "../utils/history.js";
41
+ import { logExtensionDelete, logExtensionToggle } from "../utils/history.js";
41
42
  import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js";
42
43
  import { updateExtmgrStatus } from "../utils/status.js";
43
44
  import { parseChoiceByLabel } from "../utils/command.js";
44
- import { getPackageSourceKind } from "../utils/package-source.js";
45
+ import { notify } from "../utils/notify.js";
46
+ import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js";
47
+ import { hasCustomUI, runCustomUI } from "../utils/mode.js";
48
+ import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
45
49
  import { UI } from "../constants.js";
46
50
  import { configurePackageExtensions } from "./package-config.js";
47
51
 
48
- // Type guard for SettingsList with selectedIndex
49
- interface SelectableList {
50
- selectedIndex?: number;
51
- handleInput?(data: string): void;
52
- }
53
-
54
- /**
55
- * Safely gets the selected index from a SettingsList component
56
- * Returns undefined if the component doesn't have the expected interface
57
- */
58
- function getSelectedIndex(settingsList: unknown): number | undefined {
59
- if (settingsList && typeof settingsList === "object") {
60
- const selectable = settingsList as SelectableList;
61
- if (typeof selectable.selectedIndex === "number") {
62
- return selectable.selectedIndex;
63
- }
64
- }
65
- return undefined;
52
+ async function showInteractiveFallback(
53
+ ctx: ExtensionCommandContext,
54
+ pi: ExtensionAPI
55
+ ): Promise<void> {
56
+ await showListOnly(ctx);
57
+ await showInstalledPackagesList(ctx, pi);
66
58
  }
67
59
 
68
60
  export async function showInteractive(
69
61
  ctx: ExtensionCommandContext,
70
62
  pi: ExtensionAPI
71
63
  ): Promise<void> {
64
+ if (!hasCustomUI(ctx)) {
65
+ notify(
66
+ ctx,
67
+ "The unified extensions manager requires the full interactive TUI. Showing read-only local and installed package lists instead.",
68
+ "warning"
69
+ );
70
+ await showInteractiveFallback(ctx, pi);
71
+ return;
72
+ }
73
+
72
74
  // Main loop - keeps showing the menu until user explicitly exits
73
75
  while (true) {
74
76
  const shouldExit = await showInteractiveOnce(ctx, pi);
@@ -108,162 +110,202 @@ async function showInteractiveOnce(
108
110
  const staged = new Map<string, State>();
109
111
  const byId = new Map(items.map((item) => [item.id, item]));
110
112
 
111
- const result = await ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
112
- const container = new Container();
113
-
114
- // Header
115
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
116
- container.addChild(new Text(theme.fg("accent", theme.bold("Extensions Manager")), 2, 0));
117
- container.addChild(
118
- new Text(
119
- theme.fg(
120
- "muted",
121
- `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
122
- ),
123
- 2,
124
- 0
125
- )
126
- );
127
- container.addChild(
128
- new Text(
129
- theme.fg("dim", "Quick: i Install | f Search | U Update all | t Auto-update | p Palette"),
130
- 2,
131
- 0
132
- )
133
- );
134
- container.addChild(new Spacer(1));
135
-
136
- // Build settings items
137
- const settingsItems = buildSettingsItems(items, staged, theme);
138
-
139
- const settingsList = new SettingsList(
140
- settingsItems,
141
- Math.min(items.length + 2, UI.maxListHeight),
142
- getSettingsListTheme(),
143
- (id: string, newValue: string) => {
144
- const item = byId.get(id);
145
- if (!item || item.type !== "local") return;
146
-
147
- const state = newValue as State;
148
- staged.set(id, state);
149
-
150
- const settingsItem = settingsItems.find((x) => x.id === id);
151
- if (settingsItem) {
152
- const changed = state !== item.originalState;
153
- settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed);
154
- }
155
- tui.requestRender();
156
- },
157
- () => done({ type: "cancel" }),
158
- { enableSearch: items.length > UI.searchThreshold }
159
- );
160
-
161
- container.addChild(settingsList);
162
- container.addChild(new Spacer(1));
163
-
164
- // Footer with keyboard shortcuts
165
- const footerState = buildFooterState(items);
166
- container.addChild(new Text(theme.fg("dim", buildFooterShortcuts(footerState)), 2, 0));
167
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
168
-
169
- return {
170
- render(width: number) {
171
- return container.render(width);
172
- },
173
- invalidate() {
174
- container.invalidate();
175
- },
176
- handleInput(data: string) {
177
- const selIdx = getSelectedIndex(settingsList) ?? 0;
178
- const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id;
179
- const selectedItem = selectedId ? byId.get(selectedId) : undefined;
180
-
181
- if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
182
- done({ type: "apply" });
183
- return;
184
- }
185
-
186
- // Enter on a package opens its action menu (fewer clicks)
187
- if ((data === "\r" || data === "\n") && selectedId && selectedItem?.type === "package") {
188
- done({ type: "action", itemId: selectedId, action: "menu" });
189
- return;
190
- }
191
-
192
- if (data === "a" || data === "A") {
193
- if (selectedId) {
194
- done({ type: "action", itemId: selectedId, action: "menu" });
195
- }
196
- return;
197
- }
198
-
199
- // Quick actions (global)
200
- if (data === "i") {
201
- done({ type: "quick", action: "install" });
202
- return;
203
- }
204
- if (data === "f") {
205
- done({ type: "quick", action: "search" });
206
- return;
207
- }
208
- if (data === "U") {
209
- done({ type: "quick", action: "update-all" });
210
- return;
211
- }
212
- if (data === "t" || data === "T") {
213
- done({ type: "quick", action: "auto-update" });
214
- return;
215
- }
216
-
217
- // Fast actions on selected row
218
- if (selectedId && selectedItem?.type === "package") {
219
- if (data === "u") {
220
- done({ type: "action", itemId: selectedId, action: "update" });
221
- return;
222
- }
223
- if (data === "x" || data === "X") {
224
- done({ type: "action", itemId: selectedId, action: "remove" });
225
- return;
226
- }
227
- if (data === "v" || data === "V") {
228
- done({ type: "action", itemId: selectedId, action: "details" });
229
- return;
230
- }
231
- if (data === "c" || data === "C") {
232
- done({ type: "action", itemId: selectedId, action: "configure" });
233
- return;
113
+ const result = await runCustomUI(
114
+ ctx,
115
+ "The unified extensions manager",
116
+ () =>
117
+ ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
118
+ const container = new Container();
119
+
120
+ const titleText = new Text("", 2, 0);
121
+ const subtitleText = new Text("", 2, 0);
122
+ const quickText = new Text("", 2, 0);
123
+ const footerState = buildFooterState(items);
124
+ const footerText = new Text("", 2, 0);
125
+
126
+ // Header
127
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
128
+ container.addChild(titleText);
129
+ container.addChild(subtitleText);
130
+ container.addChild(quickText);
131
+ container.addChild(new Spacer(1));
132
+
133
+ // Build settings items
134
+ const settingsItems = buildSettingsItems(items, staged, theme);
135
+ const syncThemedContent = (): void => {
136
+ titleText.setText(theme.fg("accent", theme.bold("Extensions Manager")));
137
+ subtitleText.setText(
138
+ theme.fg(
139
+ "muted",
140
+ `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
141
+ )
142
+ );
143
+ quickText.setText(
144
+ theme.fg(
145
+ "dim",
146
+ "Quick: i Install | f Search | U Update all | t Auto-update | p Palette"
147
+ )
148
+ );
149
+ footerText.setText(theme.fg("dim", buildFooterShortcuts(footerState)));
150
+
151
+ for (const settingsItem of settingsItems) {
152
+ const item = byId.get(settingsItem.id);
153
+ if (!item) continue;
154
+
155
+ if (item.type === "local") {
156
+ const currentState = staged.get(item.id) ?? item.state!;
157
+ const changed = staged.has(item.id) && currentState !== item.originalState;
158
+ settingsItem.label = formatUnifiedItemLabel(item, currentState, theme, changed);
159
+ } else {
160
+ settingsItem.label = formatUnifiedItemLabel(item, "enabled", theme, false);
161
+ }
234
162
  }
235
- }
163
+ };
164
+ syncThemedContent();
165
+
166
+ const settingsList = new SettingsList(
167
+ settingsItems,
168
+ Math.min(items.length + 2, UI.maxListHeight),
169
+ getSettingsListTheme(),
170
+ (id: string, newValue: string) => {
171
+ const item = byId.get(id);
172
+ if (!item || item.type !== "local") return;
173
+
174
+ const state = newValue as State;
175
+ staged.set(id, state);
176
+
177
+ const settingsItem = settingsItems.find((x) => x.id === id);
178
+ if (settingsItem) {
179
+ const changed = state !== item.originalState;
180
+ settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed);
181
+ }
182
+ tui.requestRender();
183
+ },
184
+ () => done({ type: "cancel" }),
185
+ { enableSearch: items.length > UI.searchThreshold }
186
+ );
236
187
 
237
- if (selectedId && selectedItem?.type === "local") {
238
- if (data === "x" || data === "X") {
239
- done({ type: "action", itemId: selectedId, action: "remove" });
240
- return;
241
- }
242
- }
243
-
244
- if (data === "r" || data === "R") {
245
- done({ type: "remote" });
246
- return;
247
- }
248
- if (data === "?" || data === "h" || data === "H") {
249
- done({ type: "help" });
250
- return;
251
- }
252
- if (data === "m" || data === "M" || data === "p" || data === "P") {
253
- done({ type: "menu" });
254
- return;
255
- }
256
- settingsList.handleInput?.(data);
257
- tui.requestRender();
258
- },
259
- };
260
- });
188
+ container.addChild(settingsList);
189
+ container.addChild(new Spacer(1));
190
+
191
+ // Footer with keyboard shortcuts
192
+ container.addChild(footerText);
193
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
194
+
195
+ return {
196
+ render(width: number) {
197
+ return container.render(width);
198
+ },
199
+ invalidate() {
200
+ container.invalidate();
201
+ syncThemedContent();
202
+ },
203
+ handleInput(data: string) {
204
+ const selIdx = getSettingsListSelectedIndex(settingsList) ?? 0;
205
+ const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id;
206
+ const selectedItem = selectedId ? byId.get(selectedId) : undefined;
207
+
208
+ if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
209
+ done({ type: "apply" });
210
+ return;
211
+ }
212
+
213
+ // Enter on a package opens its action menu (fewer clicks)
214
+ if (
215
+ (data === "\r" || data === "\n") &&
216
+ selectedId &&
217
+ selectedItem?.type === "package"
218
+ ) {
219
+ done({ type: "action", itemId: selectedId, action: "menu" });
220
+ return;
221
+ }
222
+
223
+ if (data === "a" || data === "A") {
224
+ if (selectedId) {
225
+ done({ type: "action", itemId: selectedId, action: "menu" });
226
+ }
227
+ return;
228
+ }
229
+
230
+ // Quick actions (global)
231
+ if (data === "i") {
232
+ done({ type: "quick", action: "install" });
233
+ return;
234
+ }
235
+ if (data === "f") {
236
+ done({ type: "quick", action: "search" });
237
+ return;
238
+ }
239
+ if (data === "U") {
240
+ done({ type: "quick", action: "update-all" });
241
+ return;
242
+ }
243
+ if (data === "t" || data === "T") {
244
+ done({ type: "quick", action: "auto-update" });
245
+ return;
246
+ }
247
+
248
+ // Fast actions on selected row
249
+ if (selectedId && selectedItem?.type === "package") {
250
+ if (data === "u") {
251
+ done({ type: "action", itemId: selectedId, action: "update" });
252
+ return;
253
+ }
254
+ if (data === "x" || data === "X") {
255
+ done({ type: "action", itemId: selectedId, action: "remove" });
256
+ return;
257
+ }
258
+ if (data === "v" || data === "V") {
259
+ done({ type: "action", itemId: selectedId, action: "details" });
260
+ return;
261
+ }
262
+ if (data === "c" || data === "C") {
263
+ done({ type: "action", itemId: selectedId, action: "configure" });
264
+ return;
265
+ }
266
+ }
267
+
268
+ if (selectedId && selectedItem?.type === "local") {
269
+ if (data === "x" || data === "X") {
270
+ done({ type: "action", itemId: selectedId, action: "remove" });
271
+ return;
272
+ }
273
+ }
274
+
275
+ if (data === "r" || data === "R") {
276
+ done({ type: "remote" });
277
+ return;
278
+ }
279
+ if (data === "?" || data === "h" || data === "H") {
280
+ done({ type: "help" });
281
+ return;
282
+ }
283
+ if (data === "m" || data === "M" || data === "p" || data === "P") {
284
+ done({ type: "menu" });
285
+ return;
286
+ }
287
+ settingsList.handleInput?.(data);
288
+ tui.requestRender();
289
+ },
290
+ };
291
+ }),
292
+ "Showing read-only local and installed package lists instead."
293
+ );
294
+
295
+ if (!result) {
296
+ await showInteractiveFallback(ctx, pi);
297
+ return true;
298
+ }
261
299
 
262
300
  return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
263
301
  }
264
302
 
265
303
  function normalizePathForDuplicateCheck(value: string): string {
266
- return value.replace(/\\/g, "/").toLowerCase();
304
+ const normalized = value.replace(/\\/g, "/");
305
+ const looksWindowsPath =
306
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");
307
+
308
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
267
309
  }
268
310
 
269
311
  export function buildUnifiedItems(
@@ -328,7 +370,7 @@ export function buildUnifiedItems(
328
370
  version: pkg.version,
329
371
  description: pkg.description,
330
372
  size: pkg.size,
331
- updateAvailable: knownUpdates.has(pkg.name),
373
+ updateAvailable: knownUpdates.has(normalizePackageIdentity(pkg.source)),
332
374
  });
333
375
  }
334
376
 
@@ -716,10 +758,12 @@ async function handleUnifiedAction(
716
758
  ctx.cwd
717
759
  );
718
760
  if (!removal.ok) {
761
+ logExtensionDelete(pi, item.id, false, removal.error);
719
762
  ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error");
720
763
  return false;
721
764
  }
722
765
 
766
+ logExtensionDelete(pi, item.id, true);
723
767
  ctx.ui.notify(
724
768
  `Removed ${item.displayName}${removal.removedDirectory ? " (directory)" : ""}.`,
725
769
  "info"
@@ -821,12 +865,15 @@ export async function showInstalledPackagesLegacy(
821
865
  ctx: ExtensionCommandContext,
822
866
  pi: ExtensionAPI
823
867
  ): Promise<void> {
868
+ if (!hasCustomUI(ctx)) {
869
+ await showInstalledPackagesList(ctx, pi);
870
+ return;
871
+ }
872
+
824
873
  ctx.ui.notify(
825
874
  "📦 Use /extensions for the unified view.\nInstalled packages are now shown alongside local extensions.",
826
875
  "info"
827
876
  );
828
- // Small delay then open the main manager
829
- await new Promise((r) => setTimeout(r, 1500));
830
877
  await showInteractive(ctx, pi);
831
878
  }
832
879
 
@@ -845,10 +892,12 @@ export async function showListOnly(ctx: ExtensionCommandContext): Promise<void>
845
892
 
846
893
  const lines = entries.map(formatExtEntry);
847
894
  const output = lines.join("\n");
895
+ const titledOutput = `Local extensions:\n${output}`;
848
896
 
849
897
  if (ctx.hasUI) {
850
- ctx.ui.notify(output, "info");
898
+ ctx.ui.notify(titledOutput, "info");
851
899
  } else {
900
+ console.log("Local extensions:");
852
901
  console.log(output);
853
902
  }
854
903
  }
@@ -18,6 +18,9 @@ import {
18
18
  type AutoUpdateConfig,
19
19
  } from "./settings.js";
20
20
  import { parseNpmSource } from "./format.js";
21
+ import { execNpm } from "./npm-exec.js";
22
+ import { normalizePackageIdentity } from "./package-source.js";
23
+ import { logAutoUpdateConfig } from "./history.js";
21
24
  import { TIMEOUTS } from "../constants.js";
22
25
 
23
26
  import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
@@ -25,6 +28,10 @@ import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
25
28
  // Context provider for safe session handling
26
29
  export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
27
30
 
31
+ function getUpdateIdentity(pkg: InstalledPackage): string {
32
+ return normalizePackageIdentity(pkg.source);
33
+ }
34
+
28
35
  /**
29
36
  * Start auto-update background checker
30
37
  * Uses a context provider to avoid stale context issues when sessions switch
@@ -47,27 +54,23 @@ export function startAutoUpdateTimer(
47
54
  const interval = getScheduleInterval(config);
48
55
  if (!interval) return;
49
56
 
50
- // Run an initial check immediately.
51
- const initialCtx = getCtx();
52
- if (initialCtx) {
53
- void checkForUpdates(pi, initialCtx, onUpdateAvailable);
54
- }
55
-
56
- // Set up recurring checks
57
- startTimer(interval, () => {
58
- const checkCtx = getCtx();
59
- if (!checkCtx) {
60
- stopAutoUpdateTimer();
61
- return;
62
- }
63
- void checkForUpdates(pi, checkCtx, onUpdateAvailable);
64
- });
65
-
66
- // Persist that timer is running
67
- saveAutoUpdateConfig(pi, {
68
- ...config,
69
- nextCheck: calculateNextCheck(config.intervalMs),
70
- });
57
+ const now = Date.now();
58
+ const nextCheck = config.nextCheck;
59
+ const initialDelayMs =
60
+ typeof nextCheck === "number" && nextCheck > now ? Math.max(0, nextCheck - now) : 0;
61
+
62
+ startTimer(
63
+ interval,
64
+ () => {
65
+ const checkCtx = getCtx();
66
+ if (!checkCtx) {
67
+ stopAutoUpdateTimer();
68
+ return;
69
+ }
70
+ void checkForUpdates(pi, checkCtx, onUpdateAvailable);
71
+ },
72
+ { initialDelayMs }
73
+ );
71
74
  }
72
75
 
73
76
  /**
@@ -97,28 +100,30 @@ export async function checkForUpdates(
97
100
  const npmPackages = packages.filter((p) => p.source.startsWith("npm:"));
98
101
 
99
102
  const updatesAvailable: string[] = [];
103
+ const updatedPackageNames: string[] = [];
100
104
 
101
105
  for (const pkg of npmPackages) {
102
106
  const hasUpdate = await checkPackageUpdate(pkg, ctx, pi);
103
107
  if (hasUpdate) {
104
- updatesAvailable.push(pkg.name);
108
+ updatesAvailable.push(getUpdateIdentity(pkg));
109
+ updatedPackageNames.push(pkg.name);
105
110
  }
106
111
  }
107
112
 
108
- // Update last check time
113
+ const checkedAt = Date.now();
109
114
  const config = getAutoUpdateConfig(ctx);
110
115
  saveAutoUpdateConfig(pi, {
111
116
  ...config,
112
- lastCheck: Date.now(),
117
+ lastCheck: checkedAt,
113
118
  nextCheck: calculateNextCheck(config.intervalMs),
114
119
  updatesAvailable,
115
120
  });
116
121
 
117
- if (updatesAvailable.length > 0 && onUpdateAvailable) {
118
- onUpdateAvailable(updatesAvailable);
122
+ if (updatedPackageNames.length > 0 && onUpdateAvailable) {
123
+ onUpdateAvailable(updatedPackageNames);
119
124
  }
120
125
 
121
- return updatesAvailable;
126
+ return updatedPackageNames;
122
127
  }
123
128
 
124
129
  /**
@@ -134,9 +139,8 @@ async function checkPackageUpdate(
134
139
  if (!pkgName) return false;
135
140
 
136
141
  try {
137
- const res = await pi.exec("npm", ["view", pkgName, "version", "--json"], {
142
+ const res = await execNpm(pi, ["view", pkgName, "version", "--json"], ctx, {
138
143
  timeout: TIMEOUTS.npmView,
139
- cwd: ctx.cwd,
140
144
  });
141
145
 
142
146
  if (res.code !== 0) return false;
@@ -168,7 +172,7 @@ export function getAutoUpdateStatus(ctx: ExtensionCommandContext | ExtensionCont
168
172
  }
169
173
 
170
174
  /**
171
- * Return package names currently known to have updates available
175
+ * Return normalized package identities currently known to have updates available
172
176
  * (from the latest background check).
173
177
  */
174
178
  export function getKnownUpdates(ctx: ExtensionCommandContext | ExtensionContext): Set<string> {
@@ -251,12 +255,12 @@ export function enableAutoUpdate(
251
255
  intervalMs,
252
256
  enabled: true,
253
257
  displayText,
254
- lastCheck: Date.now(),
255
258
  nextCheck: calculateNextCheck(intervalMs),
256
259
  updatesAvailable: [],
257
260
  };
258
261
 
259
262
  saveAutoUpdateConfig(pi, config);
263
+ logAutoUpdateConfig(pi, `set to ${displayText}`, true);
260
264
 
261
265
  const getCtx: ContextProvider = () => ctx;
262
266
 
@@ -280,6 +284,7 @@ export function disableAutoUpdate(
280
284
  displayText: "off",
281
285
  updatesAvailable: [],
282
286
  });
287
+ logAutoUpdateConfig(pi, "disabled", true);
283
288
 
284
289
  notify(ctx, "Auto-update disabled", "info");
285
290
  }