@danypops/pi-packed 0.13.2 → 0.15.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.
- package/README.md +1 -1
- package/extension/src/reload.ts +25 -0
- package/extension/src/resource-config.ts +2 -2
- package/extension/src/spinner.ts +44 -0
- package/extension/src/tui.ts +130 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ The extension connects to Packed's authenticated user daemon and starts the pack
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
13
|
- `/packed` -- a floating overlay panel: every installed Pi package, with update availability. Mnemonics follow lazy.nvim's own convention (uppercase acts on every row, lowercase on the one under the cursor):
|
|
14
|
-
- `u` / `U` -- update the selected package / update every outdated package, one combined confirmation and one reload. `U`
|
|
14
|
+
- `u` / `U` -- update the selected package / update every outdated package, one combined confirmation and one reload. `U` shows a spinner inline next to the package currently updating, settling into a real ✓ or ✗ plus a short tail of that update's own captured output once it finishes -- the list stays visible throughout, nothing swaps to a separate screen. A reload is confirmed separately from the update itself -- decline it to defer: the update already happened, only picking it up in this Pi session is deferred until `/reload`.
|
|
15
15
|
- `x` -- remove the selected package.
|
|
16
16
|
- `d` -- disable (or re-enable) the selected package's own extensions.
|
|
17
17
|
- `c` -- jump to resource config for the selected package (skills, prompts, themes).
|
package/extension/src/reload.ts
CHANGED
|
@@ -14,3 +14,28 @@ export function reloadWarning(operation: PackageOperation): string {
|
|
|
14
14
|
? "This will require a Pi reload (/reload) to deactivate it."
|
|
15
15
|
: "This will likely require a Pi reload (/reload) to activate its resources.";
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
export interface ReloadConfirmContext {
|
|
19
|
+
hasUI: boolean;
|
|
20
|
+
ui: { confirm(title: string, message: string): Promise<boolean> };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The second, separate decision point: reloadWarning above is a still-just-
|
|
25
|
+
* likely warning shown before a mutation runs; this is asked only once the
|
|
26
|
+
* mutation has actually succeeded and a reload is now definitely needed,
|
|
27
|
+
* not merely predicted. Declining defers it -- the mutation itself already
|
|
28
|
+
* happened (the package really did install/update/toggle); only Pi's own
|
|
29
|
+
* currently-loaded resources are stale until /reload runs later. No-UI
|
|
30
|
+
* contexts reload immediately: there's no one to ask, and staying silently
|
|
31
|
+
* stale forever is worse than reloading.
|
|
32
|
+
*
|
|
33
|
+
* Extracted from resource-config.ts's own pre-existing pendingReload gate
|
|
34
|
+
* (the first surface to implement this pattern) so every mutation surface
|
|
35
|
+
* shares identical wording and behavior instead of drifting apart --
|
|
36
|
+
* reload.ts's whole reason for existing.
|
|
37
|
+
*/
|
|
38
|
+
export async function confirmReload(ctx: ReloadConfirmContext): Promise<boolean> {
|
|
39
|
+
if (!ctx.hasUI) return true;
|
|
40
|
+
return ctx.ui.confirm("Reload Pi now?", "This change only takes effect after a reload.");
|
|
41
|
+
}
|
|
@@ -14,6 +14,7 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
|
|
|
14
14
|
import type { PackageResources, ResourceField } from "./packed.js";
|
|
15
15
|
import type { Natives } from "./packed.js";
|
|
16
16
|
import { packagePermissionDecision } from "./permission.js";
|
|
17
|
+
import { confirmReload } from "./reload.js";
|
|
17
18
|
|
|
18
19
|
type Scope = "global" | "project";
|
|
19
20
|
const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceField[];
|
|
@@ -132,8 +133,7 @@ export async function showResourceConfig(ctx: ExtensionCommandContext, natives:
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
if (!pendingReload) return;
|
|
135
|
-
|
|
136
|
-
if (confirmed) await ctx.reload();
|
|
136
|
+
if (await confirmReload(ctx)) await ctx.reload();
|
|
137
137
|
else ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spinner.ts — a tiny, testable indeterminate-progress ticker for a
|
|
3
|
+
* surface with no discrete step count (a single in-flight subprocess
|
|
4
|
+
* call, unlike ProgressBar's own known N-of-M batch position). tick() is
|
|
5
|
+
* the pure, synchronously-testable core; start()/stop() wire it to a real
|
|
6
|
+
* interval for genuine on-screen animation. pi-tui's own Loader component
|
|
7
|
+
* does the identical thing internally but keeps its current frame private
|
|
8
|
+
* (it owns a whole Text line), so it can't be embedded inline in a
|
|
9
|
+
* caller's own row the way this one is -- hence this small local one.
|
|
10
|
+
*/
|
|
11
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
12
|
+
const INTERVAL_MS = 80;
|
|
13
|
+
|
|
14
|
+
export class Spinner {
|
|
15
|
+
private index = 0;
|
|
16
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
17
|
+
|
|
18
|
+
/** Current animation frame -- a single braille glyph. */
|
|
19
|
+
glyph(): string {
|
|
20
|
+
return FRAMES[this.index]!;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Advances one frame. Pure and synchronous -- the deterministic unit under test; start() is just this wired to a real timer. */
|
|
24
|
+
tick(): void {
|
|
25
|
+
this.index = (this.index + 1) % FRAMES.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Wires tick() to a real interval, calling onTick after each advance so a host can requestRender(). Idempotent -- calling start() again restarts cleanly rather than stacking a second interval. */
|
|
29
|
+
start(onTick: () => void): void {
|
|
30
|
+
this.stop();
|
|
31
|
+
this.timer = setInterval(() => {
|
|
32
|
+
this.tick();
|
|
33
|
+
onTick();
|
|
34
|
+
}, INTERVAL_MS);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Safe to call even if never started, or more than once. */
|
|
38
|
+
stop(): void {
|
|
39
|
+
if (this.timer !== undefined) {
|
|
40
|
+
clearInterval(this.timer);
|
|
41
|
+
this.timer = undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/extension/src/tui.ts
CHANGED
|
@@ -12,20 +12,28 @@
|
|
|
12
12
|
* own row percentage, not a fixed row count -- scales with the real
|
|
13
13
|
* terminal, unlike the anchor:"top-center"+offsetY this replaced, which
|
|
14
14
|
* was pinned 1 row below the absolute top on any terminal size). Enter
|
|
15
|
-
* opens a second, smaller overlay action menu on top of it. U's batch
|
|
16
|
-
* this same package list --
|
|
17
|
-
*
|
|
15
|
+
* opens a second, smaller overlay action menu on top of it. U's batch
|
|
16
|
+
* update stays on this same package list -- an indeterminate spinner
|
|
17
|
+
* (Spinner, this package's own -- neither Malevich nor pi-tui exposes an
|
|
18
|
+
* embeddable one) renders inline next to the row currently updating,
|
|
19
|
+
* settling into a real ✓/✗ plus a bounded tail of that row's own actual
|
|
20
|
+
* captured stdout/stderr once it finishes, never a determinate bar (a
|
|
21
|
+
* single subprocess call has no knowable percentage). Rows render through
|
|
18
22
|
* Malevich's Table (real column-aligned Package/Version/status cells,
|
|
19
23
|
* per-row selection styling baked into each cell since Table's own
|
|
20
24
|
* cellStyle is column-wide, not row-wide) inside this panel's own
|
|
21
25
|
* scroll-window slice -- Table deliberately owns no pagination of its
|
|
22
|
-
* own, so the visible-window-around-selectedIndex math stays here.
|
|
26
|
+
* own, so the visible-window-around-selectedIndex math stays here. Every
|
|
27
|
+
* mutation that actually changes something asks confirmReload separately
|
|
28
|
+
* from the earlier mutation-approval confirm -- declining defers the
|
|
29
|
+
* reload (the mutation itself already happened) and keeps the panel open
|
|
30
|
+
* with refreshed rows instead of ending the session. All
|
|
23
31
|
* data flows through the packed CLI (thin seam).
|
|
24
32
|
*/
|
|
25
33
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
34
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
27
35
|
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
28
|
-
import { Envelope, Menu,
|
|
36
|
+
import { Envelope, Menu, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
|
|
29
37
|
import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
|
|
30
38
|
import type { Row, ViewMode } from "./model.js";
|
|
31
39
|
import type { Natives, PackageResources } from "./packed.js";
|
|
@@ -34,6 +42,8 @@ import { showPackedSettings } from "./security-tui.js";
|
|
|
34
42
|
import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
|
|
35
43
|
import { showDiscoverPanel } from "./discover.js";
|
|
36
44
|
import { menuTheme } from "./menu-theme.js";
|
|
45
|
+
import { confirmReload } from "./reload.js";
|
|
46
|
+
import { Spinner } from "./spinner.js";
|
|
37
47
|
|
|
38
48
|
interface PanelAction {
|
|
39
49
|
type: "update" | "remove" | "disable" | "config" | "find" | "refresh" | "settings";
|
|
@@ -43,8 +53,11 @@ interface PanelAction {
|
|
|
43
53
|
/** Outcome of a confirmed row action, resolved after any real mutation and
|
|
44
54
|
* reload decision -- "changed" means the daemon state changed and Pi has
|
|
45
55
|
* already been reloaded (the caller should stop showing the stale panel);
|
|
56
|
+
* "deferred" means the mutation itself genuinely happened but the user
|
|
57
|
+
* declined confirmReload's separate reload gate -- Pi's session is still
|
|
58
|
+
* alive and the panel should refresh its rows and keep running, not close;
|
|
46
59
|
* "unchanged"/"cancelled" mean the panel keeps running as-is. */
|
|
47
|
-
export type PackageChoiceOutcome = "changed" | "unchanged" | "cancelled";
|
|
60
|
+
export type PackageChoiceOutcome = "changed" | "unchanged" | "cancelled" | "deferred";
|
|
48
61
|
|
|
49
62
|
export async function applyPackageChoice(
|
|
50
63
|
choice: string | undefined,
|
|
@@ -70,6 +83,10 @@ export async function applyPackageChoice(
|
|
|
70
83
|
return "unchanged";
|
|
71
84
|
}
|
|
72
85
|
const transition = outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
86
|
+
if (!(await confirmReload(ctx))) {
|
|
87
|
+
ctx.ui.notify(`Updated ${row.name}${transition}; reload pending -- run /reload when ready.`, "warning");
|
|
88
|
+
return "deferred";
|
|
89
|
+
}
|
|
73
90
|
ctx.ui.notify(`Updated ${row.name}${transition}; reloading Pi resources.`, "info");
|
|
74
91
|
await ctx.reload();
|
|
75
92
|
return "changed";
|
|
@@ -86,6 +103,10 @@ export async function applyPackageChoice(
|
|
|
86
103
|
return "cancelled";
|
|
87
104
|
}
|
|
88
105
|
await natives.remove(row.name, approval.approved);
|
|
106
|
+
if (!(await confirmReload(ctx))) {
|
|
107
|
+
ctx.ui.notify(`Removed ${row.name}; reload pending -- run /reload when ready.`, "warning");
|
|
108
|
+
return "deferred";
|
|
109
|
+
}
|
|
89
110
|
ctx.ui.notify(`Removed ${row.name}; reloading Pi resources.`, "info");
|
|
90
111
|
await ctx.reload();
|
|
91
112
|
return "changed";
|
|
@@ -99,7 +120,13 @@ export async function applyPackageChoice(
|
|
|
99
120
|
|
|
100
121
|
interface UpdateAllResult { changed: number; failedNames: string[]; }
|
|
101
122
|
|
|
102
|
-
|
|
123
|
+
/** Present only on phase "done" -- the real captured stdout+stderr from
|
|
124
|
+
* ExecInstaller (ok: true) or the thrown error's message (ok: false). This
|
|
125
|
+
* is the actual execution output, not a synthetic status string, so a host
|
|
126
|
+
* can show a genuine success/failure sign instead of guessing from
|
|
127
|
+
* reloadRequired alone. */
|
|
128
|
+
interface UpdateProgressResult { ok: boolean; output: string; }
|
|
129
|
+
interface UpdateProgressEvent { row: Row; index: number; total: number; phase: "start" | "done"; result?: UpdateProgressResult; }
|
|
103
130
|
|
|
104
131
|
/** The core sequential-update loop, with no UI of its own -- reports each
|
|
105
132
|
* step via onProgress so any host surface (a floating overlay, or the
|
|
@@ -118,14 +145,28 @@ async function performUpdateAll(
|
|
|
118
145
|
try {
|
|
119
146
|
const outcome = await natives.update(`npm:${row.name}`, approved);
|
|
120
147
|
if (outcome.reloadRequired) changed += 1;
|
|
148
|
+
onProgress?.({ row, index: i, total: outdated.length, phase: "done", result: { ok: true, output: outcome.output } });
|
|
121
149
|
} catch (e) {
|
|
122
|
-
|
|
150
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
151
|
+
failedNames.push(`${row.name}: ${message}`);
|
|
152
|
+
onProgress?.({ row, index: i, total: outdated.length, phase: "done", result: { ok: false, output: message } });
|
|
123
153
|
}
|
|
124
|
-
onProgress?.({ row, index: i, total: outdated.length, phase: "done" });
|
|
125
154
|
}
|
|
126
155
|
return { changed, failedNames };
|
|
127
156
|
}
|
|
128
157
|
|
|
158
|
+
const MAX_LOG_TAIL_CHARS = 60;
|
|
159
|
+
|
|
160
|
+
/** The last non-empty line of real captured output, bounded -- never the
|
|
161
|
+
* full stdout/stderr dump inline (a noisy npm install can produce hundreds
|
|
162
|
+
* of lines). undefined when there's nothing worth showing (the common
|
|
163
|
+
* case: `pi update` often produces no output on success at all). */
|
|
164
|
+
function logTail(output: string): string | undefined {
|
|
165
|
+
const line = output.trim().split("\n").filter(Boolean).at(-1);
|
|
166
|
+
if (!line) return undefined;
|
|
167
|
+
return line.length > MAX_LOG_TAIL_CHARS ? `${line.slice(0, MAX_LOG_TAIL_CHARS - 1)}…` : line;
|
|
168
|
+
}
|
|
169
|
+
|
|
129
170
|
/** Approves once for the whole batch, runs it via whatever runBatch does
|
|
130
171
|
* (a floating overlay for applyUpdateAll's own public API, or renderPanel's
|
|
131
172
|
* embedded progress bar), then reports the combined result -- shared so
|
|
@@ -156,16 +197,27 @@ async function approveAndRunUpdateAll(
|
|
|
156
197
|
ctx.ui.notify(failedNames.length > 0 ? `No packages updated; ${failedNames.length} failed.` : "All packages already up to date.", failedNames.length > 0 ? "warning" : "info");
|
|
157
198
|
return failedNames.length > 0 ? "cancelled" : "unchanged";
|
|
158
199
|
}
|
|
159
|
-
|
|
200
|
+
const failedSuffix = failedNames.length > 0 ? `, ${failedNames.length} failed` : "";
|
|
201
|
+
if (!(await confirmReload(ctx))) {
|
|
202
|
+
ctx.ui.notify(`Updated ${changed} package(s)${failedSuffix}; reload pending -- run /reload when ready.`, "warning");
|
|
203
|
+
return "deferred";
|
|
204
|
+
}
|
|
205
|
+
ctx.ui.notify(`Updated ${changed} package(s)${failedSuffix}; reloading Pi resources.`, "info");
|
|
160
206
|
await ctx.reload();
|
|
161
207
|
return "changed";
|
|
162
208
|
}
|
|
163
209
|
|
|
164
|
-
|
|
210
|
+
const MAX_SETTLED_LOG_LINES = 5;
|
|
211
|
+
|
|
212
|
+
/** Floats its own spinner+log overlay over the still-open panel -- kept
|
|
165
213
|
* for applyUpdateAll's own public API (and anything calling it directly,
|
|
166
214
|
* outside the packages panel). renderPanel's own U key does not use this;
|
|
167
|
-
* it renders the same
|
|
168
|
-
* instead of stacking a second one.
|
|
215
|
+
* it renders the same spinner+log inline on its own already-open overlay
|
|
216
|
+
* instead of stacking a second one. An indeterminate spinner (not a
|
|
217
|
+
* determinate bar) because a single subprocess call has no knowable
|
|
218
|
+
* percentage -- only "still running" or "settled". Each settled row
|
|
219
|
+
* appends one bounded log line (real captured output, not a synthetic
|
|
220
|
+
* status) with a genuine success/failure glyph, up to a small scrollback. */
|
|
169
221
|
async function runUpdatesWithProgress(
|
|
170
222
|
outdated: Row[],
|
|
171
223
|
natives: Natives,
|
|
@@ -174,22 +226,35 @@ async function runUpdatesWithProgress(
|
|
|
174
226
|
): Promise<UpdateAllResult> {
|
|
175
227
|
return ctx.ui.custom<UpdateAllResult>(
|
|
176
228
|
(tui, theme, _kb, done) => {
|
|
177
|
-
const
|
|
229
|
+
const spinner = new Spinner();
|
|
230
|
+
const settledLines: string[] = [];
|
|
231
|
+
let currentLabel = `${outdated[0]?.name ?? ""} (1/${outdated.length})`;
|
|
178
232
|
const border = () => new DynamicBorder((s) => theme.fg("border", s));
|
|
179
233
|
const container = new Container();
|
|
180
234
|
container.addChild(new Spacer(1));
|
|
181
235
|
container.addChild(border());
|
|
182
236
|
container.addChild({ invalidate() {}, render: (_width: number) => [theme.bold("Updating packages")] });
|
|
183
237
|
container.addChild(new Spacer(1));
|
|
184
|
-
container.addChild(
|
|
238
|
+
container.addChild({ invalidate() {}, render: (width: number) => [truncateToWidth(`${theme.fg("accent", spinner.glyph())} ${currentLabel}`, width, "")] });
|
|
239
|
+
container.addChild(new Spacer(1));
|
|
240
|
+
container.addChild({
|
|
241
|
+
invalidate() {},
|
|
242
|
+
render: (width: number) => settledLines.slice(-MAX_SETTLED_LOG_LINES).map((line) => truncateToWidth(line, width, "")),
|
|
243
|
+
});
|
|
185
244
|
container.addChild(new Spacer(1));
|
|
186
245
|
container.addChild(border());
|
|
187
246
|
|
|
247
|
+
spinner.start(() => tui.requestRender());
|
|
188
248
|
performUpdateAll(outdated, natives, approved, (event) => {
|
|
189
|
-
|
|
190
|
-
|
|
249
|
+
if (event.phase === "start") {
|
|
250
|
+
currentLabel = `${event.row.name} (${event.index + 1}/${event.total})`;
|
|
251
|
+
} else {
|
|
252
|
+
const glyph = event.result?.ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
253
|
+
const tail = event.result ? logTail(event.result.output) : undefined;
|
|
254
|
+
settledLines.push(`${glyph} ${event.row.name}${tail ? theme.fg("dim", ` -- ${tail}`) : ""}`);
|
|
255
|
+
}
|
|
191
256
|
tui.requestRender();
|
|
192
|
-
}).then(done);
|
|
257
|
+
}).finally(() => spinner.stop()).then(done);
|
|
193
258
|
|
|
194
259
|
return { render: (width: number) => container.render(width), invalidate: () => container.invalidate(), handleInput() {} };
|
|
195
260
|
},
|
|
@@ -239,7 +304,12 @@ export async function applyDisableExtensions(row: Row, natives: Natives, ctx: Ex
|
|
|
239
304
|
else if (outcome === "cancelled") return toggled > 0 ? "changed" : "cancelled";
|
|
240
305
|
}
|
|
241
306
|
if (toggled === 0) return "unchanged";
|
|
242
|
-
|
|
307
|
+
const verb = disabling ? "Disabled" : "Enabled";
|
|
308
|
+
if (!(await confirmReload(ctx))) {
|
|
309
|
+
ctx.ui.notify(`${verb} ${toggled} extension(s) for ${row.name}; reload pending -- run /reload when ready.`, "warning");
|
|
310
|
+
return "deferred";
|
|
311
|
+
}
|
|
312
|
+
ctx.ui.notify(`${verb} ${toggled} extension(s) for ${row.name}; reloading Pi resources.`, "info");
|
|
243
313
|
await ctx.reload();
|
|
244
314
|
return "changed";
|
|
245
315
|
}
|
|
@@ -287,6 +357,14 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
|
|
|
287
357
|
return;
|
|
288
358
|
}
|
|
289
359
|
|
|
360
|
+
// A deferred reload means the mutation itself genuinely happened but
|
|
361
|
+
// ctx.reload() was declined -- the session is still alive, so refresh
|
|
362
|
+
// rows (real on-disk versions) and keep the panel open, same as "refresh".
|
|
363
|
+
async function refreshRows(): Promise<void> {
|
|
364
|
+
({ rows, error } = await loadRows(natives));
|
|
365
|
+
if (error) ctx.ui.notify(`refresh failed: ${error}`, "error");
|
|
366
|
+
}
|
|
367
|
+
|
|
290
368
|
// Panel loop: actions resolve the component, run outside it, then reopen.
|
|
291
369
|
// U/updateAll is handled entirely inside renderPanel itself (progress
|
|
292
370
|
// rendered on the same already-open overlay) and never reaches here.
|
|
@@ -295,8 +373,7 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
|
|
|
295
373
|
if (!action) return; // closed
|
|
296
374
|
|
|
297
375
|
if (action.type === "refresh") {
|
|
298
|
-
|
|
299
|
-
if (error) ctx.ui.notify(`refresh failed: ${error}`, "error");
|
|
376
|
+
await refreshRows();
|
|
300
377
|
continue;
|
|
301
378
|
}
|
|
302
379
|
|
|
@@ -321,11 +398,13 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
|
|
|
321
398
|
if (action.type === "disable") {
|
|
322
399
|
const outcome = await applyDisableExtensions(row, natives, ctx);
|
|
323
400
|
if (outcome === "changed") return;
|
|
401
|
+
if (outcome === "deferred") await refreshRows();
|
|
324
402
|
continue;
|
|
325
403
|
}
|
|
326
404
|
|
|
327
405
|
const outcome = await applyPackageChoice(action.type === "update" ? `Update to ${row.latest}` : "Remove", row, natives, ctx);
|
|
328
406
|
if (outcome === "changed") return; // ctx.reload() already replaced the session
|
|
407
|
+
if (outcome === "deferred") await refreshRows();
|
|
329
408
|
}
|
|
330
409
|
}
|
|
331
410
|
|
|
@@ -337,11 +416,16 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
337
416
|
let searchActive = false;
|
|
338
417
|
let filtered = visibleRows(rows, mode);
|
|
339
418
|
let selectedIndex = 0;
|
|
340
|
-
// Set only while U's batch update is running
|
|
341
|
-
//
|
|
342
|
-
//
|
|
419
|
+
// Set only while U's batch update is running -- blocks input for the
|
|
420
|
+
// whole batch (the installer owns input until it finishes), same as
|
|
421
|
+
// before. The list stays fully visible throughout. Which specific row
|
|
422
|
+
// currently shows a spinner vs. a settled ✓/✗ is settled's own job
|
|
423
|
+
// (below), not this -- a just-finished row must show its glyph
|
|
424
|
+
// immediately, even for the instant before the next row's "start"
|
|
425
|
+
// event reassigns this to the next name.
|
|
343
426
|
let updatingRowName: string | undefined;
|
|
344
|
-
const
|
|
427
|
+
const spinner = new Spinner();
|
|
428
|
+
const settled = new Map<string, { ok: boolean; tail: string | undefined }>();
|
|
345
429
|
|
|
346
430
|
const maxVisible = 20;
|
|
347
431
|
|
|
@@ -351,21 +435,23 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
351
435
|
}
|
|
352
436
|
|
|
353
437
|
/** U -- runs the whole approve+update+notify+reload flow without ever
|
|
354
|
-
* closing this panel or replacing the list: each
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* session. */
|
|
438
|
+
* closing this panel or replacing the list: each row's own settled
|
|
439
|
+
* outcome (spinner while in flight, then a real ✓/✗ plus a bounded tail
|
|
440
|
+
* of its actual captured output) appears inline next to that row, via
|
|
441
|
+
* updatingRowName/spinner/settled, which list's own render checks. Rows
|
|
442
|
+
* refresh in place afterward unless a reload already ended the session. */
|
|
359
443
|
async function runUpdateAllInline(): Promise<void> {
|
|
360
444
|
const outdated = rows.filter((row) => row.hasUpdate);
|
|
445
|
+
settled.clear();
|
|
361
446
|
const outcome = await approveAndRunUpdateAll(outdated, natives, ctx, (batch, approved) => {
|
|
362
|
-
|
|
363
|
-
updatingBar.setMax(batch.length);
|
|
447
|
+
spinner.start(() => tui.requestRender());
|
|
364
448
|
return performUpdateAll(batch, natives, approved, (event) => {
|
|
365
449
|
updatingRowName = event.row.name;
|
|
366
|
-
if (event.phase === "done"
|
|
450
|
+
if (event.phase === "done" && event.result) {
|
|
451
|
+
settled.set(event.row.name, { ok: event.result.ok, tail: logTail(event.result.output) });
|
|
452
|
+
}
|
|
367
453
|
tui.requestRender();
|
|
368
|
-
});
|
|
454
|
+
}).finally(() => spinner.stop());
|
|
369
455
|
});
|
|
370
456
|
updatingRowName = undefined;
|
|
371
457
|
if (outcome === "changed") {
|
|
@@ -375,6 +461,7 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
375
461
|
const reloaded = await loadRows(natives);
|
|
376
462
|
if (reloaded.error) ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
|
|
377
463
|
else rows = reloaded.rows;
|
|
464
|
+
settled.clear(); // fresh state for a subsequent batch
|
|
378
465
|
applyFilter();
|
|
379
466
|
tui.requestRender();
|
|
380
467
|
}
|
|
@@ -450,10 +537,16 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
450
537
|
const selected = i === selectedIndex;
|
|
451
538
|
const cursor = selected ? theme.fg("accent", "❯ ") : " ";
|
|
452
539
|
const name = selected ? theme.bold(row.name) : row.name;
|
|
453
|
-
|
|
540
|
+
// A settled row shows its real outcome even for the instant
|
|
541
|
+
// before the next row's "start" event moves updatingRowName
|
|
542
|
+
// off it -- settled always wins over "still spinning".
|
|
543
|
+
const rowSettled = settled.get(row.name);
|
|
544
|
+
const isUpdating = !rowSettled && updatingRowName === row.name;
|
|
454
545
|
const status = isUpdating
|
|
455
|
-
? theme.fg("accent", `${
|
|
456
|
-
:
|
|
546
|
+
? theme.fg("accent", `${spinner.glyph()} updating…`)
|
|
547
|
+
: rowSettled
|
|
548
|
+
? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
|
|
549
|
+
: row.hasUpdate ? theme.fg("warning", `↑${row.latest}`) : "";
|
|
457
550
|
return { name: `${cursor}${name}`, version: theme.fg("dim", row.version), status };
|
|
458
551
|
}),
|
|
459
552
|
);
|