dsh-plugin-update-entry 0.0.0-stage → 1.0.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/client.js ADDED
@@ -0,0 +1,2683 @@
1
+ /**
2
+ * plugin-update-entry — Client module.
3
+ *
4
+ * The official plugin page puts the update action on the detail page only and
5
+ * leaves the list card with nothing but a `可更新` tag. This module adds an
6
+ * `更新` control to that card, immediately before the switch, and opens a
7
+ * popover that lists the versions the Host is willing to install, with the
8
+ * GitHub release notes of each version on hover.
9
+ *
10
+ * Placement is a deliberate, contained exception to "stay inside your slot":
11
+ * the plugin page exposes no slot for a list-card action. We therefore add one
12
+ * node of our own inside `.cardEnd` and never touch official attributes,
13
+ * classes or React state. Every anchor is a semantic `data-*` attribute or a
14
+ * stable class substring; if any anchor disappears the module does nothing at
15
+ * all (fail-closed).
16
+ */
17
+
18
+ /* A classic script may be concatenated with itself.
19
+ *
20
+ * The Host composes the boot graph into ONE classic script per batch by
21
+ * concatenating each package's bundle. If two graph rows ever point at this
22
+ * same file (observed after a package rename: the old row lives on in the
23
+ * Host's in-memory table until a restart, so both `@local/old-name` and
24
+ * `dsh-plugin-update-entry` resolve to these bytes), those bytes appear twice
25
+ * in one script — and two `const` declarations of the same name is a PARSE
26
+ * error, which kills the entire batch before a single line runs. Nothing this
27
+ * file can catch afterwards would help.
28
+ *
29
+ * The function wrapper gives each copy its own declarations, so the script
30
+ * parses no matter how many times the Host embeds it. Registration is still
31
+ * attempted on every execution (HMR invalidation drops the factory and needs
32
+ * the next execution to re-register), and only the loader's own
33
+ * `duplicate factory registration` error for THIS package is swallowed below —
34
+ * that is the second copy of one batch, which carries no new information.
35
+ */
36
+ ;(function () {
37
+ const PACKAGE_ID = 'dsh-plugin-update-entry';
38
+ const NS = 'plugin-update-entry';
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * Pure helpers (also exported for `node --test`, see `__pure` below)
42
+ * ------------------------------------------------------------------ */
43
+
44
+ const BUILD = '1.0.0';
45
+
46
+ /* Read a cordis service by its registration name, without the ctx proxy guard.
47
+ *
48
+ * `ctx.<name>` on the context proxy throws `cannot get property "<name>"
49
+ * without inject` for anything this fiber did not declare. `remote.pluginManager`
50
+ * is a Service registered under a DOTTED name, so even `ctx.remote` being
51
+ * declared does not make `.pluginManager` reachable — the proxy only resolves
52
+ * dotted names through an `associate` tracker, which re-enters the same guard.
53
+ *
54
+ * `ctx.get(name)` is the documented unguarded read (cordis ReflectService
55
+ * mixes it onto every context) and returns `undefined` instead of throwing when
56
+ * the service is not mounted yet. Declaring `remote.pluginManager` in `inject`
57
+ * would park this whole module until the namespace mounts, which is a heavier
58
+ * coupling than a lazy read on click.
59
+ */
60
+ function readService(ctx, name) {
61
+ if (!ctx) return undefined;
62
+ try {
63
+ const found = ctx.get(name);
64
+ if (found) return found;
65
+ } catch {}
66
+ try {
67
+ const found = ctx.reflect && ctx.reflect.get(name);
68
+ if (found) return found;
69
+ } catch {}
70
+ return undefined;
71
+ }
72
+
73
+ const VERSION_IN_TAG = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/;
74
+
75
+ function interp(text, params) {
76
+ if (!params) return text;
77
+ return String(text).replace(/\{(\w+)\}/g, (match, key) =>
78
+ params[key] === undefined || params[key] === null ? match : String(params[key]),
79
+ );
80
+ }
81
+
82
+ function versionOfTag(tag) {
83
+ const match = String(tag || '').match(VERSION_IN_TAG);
84
+ return match ? match[1] : null;
85
+ }
86
+
87
+ /** Resolve an npm `repository` field (string or object) to `owner/repo`. */
88
+ function repoFromUrl(repository) {
89
+ let url = '';
90
+ if (typeof repository === 'string') url = repository;
91
+ else if (repository && typeof repository === 'object' && typeof repository.url === 'string') {
92
+ url = repository.url;
93
+ }
94
+ url = url.trim();
95
+ if (!url) return null;
96
+ url = url.replace(/^git\+/, '');
97
+ const shorthand = url.match(/^github:([\w.-]+)\/([\w.-]+)$/i);
98
+ if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
99
+ const scp = url.match(/^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i);
100
+ if (scp) return `${scp[1]}/${scp[2]}`;
101
+ const http = url.match(/^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i);
102
+ if (http) return `${http[1]}/${http[2]}`;
103
+ return null;
104
+ }
105
+
106
+ /** Look one exact version up in the `{ version: release }` map built above. */
107
+ function matchRelease(releases, version) {
108
+ if (!releases || !version) return null;
109
+ return releases[version] || null;
110
+ }
111
+
112
+ function filterPrerelease(versions, show) {
113
+ const list = Array.isArray(versions) ? versions : [];
114
+ return show ? list.slice() : list.filter((item) => !item || !item.prerelease);
115
+ }
116
+
117
+ /** Keep a version installable-or-not decision in one place. */
118
+ function installBlocker(item) {
119
+ if (!item) return 'notBundle';
120
+ if (item.bundle !== true) return 'notBundle';
121
+ if (item.incompatible) return 'blocked';
122
+ return null;
123
+ }
124
+
125
+ /** GitHub release bodies are markdown; the bubble shows plain text. */
126
+ function stripMarkdown(text) {
127
+ return String(text || '')
128
+ .replace(/\r\n?/g, '\n')
129
+ .replace(/```[^\n]*\n([\s\S]*?)```/g, (match, code) => code)
130
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
131
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
132
+ .replace(/<[^>]+>/g, '')
133
+ .replace(/^#{1,6}\s+/gm, '')
134
+ .replace(/^\s*[-*+]\s+/gm, '· ')
135
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
136
+ .replace(/[*_`]{1,2}/g, '')
137
+ .replace(/\n{3,}/g, '\n\n')
138
+ .trim()
139
+ .slice(0, 6000);
140
+ }
141
+
142
+ /**
143
+ * Map an `updateBundle` answer to the outcome the user is told about.
144
+ * Conditions mirror the official page's own `outcomeOf` exactly.
145
+ */
146
+ function outcomeOf(answer) {
147
+ const value = answer && answer.ok === false
148
+ ? null
149
+ : answer && Object.prototype.hasOwnProperty.call(answer, 'value') ? answer.value : answer;
150
+ if (!value) {
151
+ const reason = answer && answer.error
152
+ ? answer.error.message || String(answer.error)
153
+ : '';
154
+ return { kind: 'lost', reason };
155
+ }
156
+ const result = value.result;
157
+ if (!result) return { kind: 'lost', reason: '' };
158
+ if (value.step === 'check') return { kind: 'refused', to: value.to };
159
+ if (value.step === 'remove') return { kind: 'removeFailed', from: value.from };
160
+ if (result.application !== 'failed') {
161
+ return result.application === 'restart-required'
162
+ ? { kind: 'restart', to: value.to }
163
+ : { kind: 'done', to: value.to };
164
+ }
165
+ if (value.restore === undefined) return { kind: 'broken', from: value.from, to: value.to };
166
+ return { kind: 'restored', from: value.from, to: value.to };
167
+ }
168
+
169
+ const STEP_ORDER = ['checking', 'removing', 'installing', 'restoring'];
170
+
171
+ function planSteps(from, to, registry) {
172
+ return [
173
+ { key: 'checking', labelKey: 'updateStepChecking', params: { version: to } },
174
+ { key: 'removing', labelKey: 'updateStepRemoving', params: { version: from } },
175
+ { key: 'installing', labelKey: 'updateStepInstalling', params: { version: to, registry: registry || '…' } },
176
+ { key: 'restoring', labelKey: 'updateStepRestoring', params: { version: from } },
177
+ ];
178
+ }
179
+
180
+ function stepState(index, current) {
181
+ if (!current) return 'pending';
182
+ const at = STEP_ORDER.indexOf(current);
183
+ if (at < 0) return 'pending';
184
+ if (index < at) {
185
+ // A restore only happens because the install failed.
186
+ return current === 'restoring' && index === 2 ? 'failed' : 'done';
187
+ }
188
+ return index === at ? 'active' : 'pending';
189
+ }
190
+
191
+ function outputOf(answer) {
192
+ const value = answer && answer.value ? answer.value : null;
193
+ if (!value) return '';
194
+ const direct = value.result && value.result.packageResult && value.result.packageResult.output;
195
+ if (direct) return String(direct);
196
+ const restored = value.restore && value.restore.packageResult && value.restore.packageResult.output;
197
+ return restored ? String(restored) : '';
198
+ }
199
+
200
+ /**
201
+ * The bundles worth listing: installed, updatable, and actually a bundle.
202
+ * `listBundles` reports both profile and installation packages; only the
203
+ * installed ones can be updated from here, and `official` packages are the
204
+ * Host's own and never appear as updatable.
205
+ */
206
+ function updatableBundles(bundles) {
207
+ const list = Array.isArray(bundles) ? bundles : [];
208
+ const out = [];
209
+ for (const bundle of list) {
210
+ if (!bundle || typeof bundle.name !== 'string') continue;
211
+ if (!bundle.installed || !bundle.updatable) continue;
212
+ out.push({ name: bundle.name, from: String(bundle.version || '') });
213
+ }
214
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
215
+ return out;
216
+ }
217
+
218
+ /** Tally of a bulk run, for the summary line. */
219
+ function bulkSummary(items) {
220
+ let ok = 0;
221
+ let failed = 0;
222
+ let skipped = 0;
223
+ let pending = 0;
224
+ for (const item of Array.isArray(items) ? items : []) {
225
+ if (!item) continue;
226
+ if (item.state === 'done') ok += 1;
227
+ else if (item.state === 'failed') failed += 1;
228
+ else if (item.state === 'skipped') skipped += 1;
229
+ else if (item.state === 'idle') pending += 1;
230
+ }
231
+ return { ok, failed, skipped, pending };
232
+ }
233
+
234
+ /** The rows a run will actually try: they have a target, or their version read
235
+ * never landed and the run re-reads them first. A row that was read but offers
236
+ * nothing installable here stays visible yet is not attempted. */
237
+ function bulkTargets(items) {
238
+ const out = [];
239
+ for (const item of Array.isArray(items) ? items : []) {
240
+ if (!item || item.state !== 'idle') continue;
241
+ if (!item.to && item.phase === 'listed') continue;
242
+ out.push(item);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /** The rows the panel shows. `listBundles` calls a package updatable when it
248
+ * *could* be updated, not when a newer version exists, so a listed answer
249
+ * with no versions at all means "nothing newer" — that row is hidden instead
250
+ * of being reported as up to date. A row whose versions exist but none can be
251
+ * installed here stays visible: it carries information the user needs. */
252
+ function bulkRows(items) {
253
+ const out = [];
254
+ for (const item of Array.isArray(items) ? items : []) {
255
+ if (!item) continue;
256
+ const versions = (item.list && item.list.versions) || [];
257
+ if (item.phase === 'listed' && versions.length === 0) continue;
258
+ out.push(item);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ function messageOf(error) {
264
+ if (!error) return '';
265
+ return String(error.message || error.error || error || '');
266
+ }
267
+
268
+ /* Module scope: this string is needed before `apply()` (and therefore DICT)
269
+ * exists, so the Chinese default lives here and DICT reuses it. */
270
+ const FALLBACK_UNAVAILABLE = '与 DSH 的连接尚未就绪,请稍后重试';
271
+
272
+ /* A raw `cannot get property "…" without inject` is a cordis proxy guard, never
273
+ * something a user can act on. Map it (and the other transport-level strings)
274
+ * to the localized explanation; the raw text still reaches the console. */
275
+ function userMessage(error) {
276
+ const raw = messageOf(error);
277
+ if (!raw) return '';
278
+ if (/without inject|cannot get property|client api:|has no active Connection/i.test(raw)) {
279
+ return FALLBACK_UNAVAILABLE;
280
+ }
281
+ return raw;
282
+ }
283
+
284
+ /* ------------------------------------------------------------------ *
285
+ * Client module
286
+ * ------------------------------------------------------------------ */
287
+
288
+ const REGISTRATION = {
289
+ id: PACKAGE_ID,
290
+ __pure: {
291
+ readService,
292
+ interp,
293
+ versionOfTag,
294
+ repoFromUrl,
295
+ matchRelease,
296
+ filterPrerelease,
297
+ userMessage,
298
+ diagnostics: null,
299
+ installBlocker,
300
+ stripMarkdown,
301
+ outcomeOf,
302
+ planSteps,
303
+ stepState,
304
+ outputOf,
305
+ updatableBundles,
306
+ bulkSummary,
307
+ bulkRows,
308
+ bulkTargets,
309
+ },
310
+ factory() {
311
+ const SEL = {
312
+ scope: '[data-plugin-body]',
313
+ scopeAlt: '[data-plugin-panel]',
314
+ card: 'li[data-plugin-package]',
315
+ end: '[class*="cardEnd"]',
316
+ badge: '[class*="statusTag"]',
317
+ toggle: '[class*="bundleToggle"]',
318
+ switch: '[role="switch"]',
319
+ toolbar: '[data-plugin-toolbar]',
320
+ note: '[class*="updatesNote"]',
321
+ };
322
+
323
+ /** Localised text of the official `statusUpdateAvailable` tag, all 8 shipped languages. */
324
+ const BADGE_TEXTS = [
325
+ '可更新',
326
+ 'Update available',
327
+ 'アップデートあり',
328
+ 'Actualización disponible',
329
+ 'Mise à jour disponible',
330
+ 'Update verfügbar',
331
+ 'Доступно обновление',
332
+ 'Atualização disponível',
333
+ ];
334
+
335
+ const DEFAULT_REGISTRY = 'https://registry.npmmirror.com';
336
+ const VERSION_TTL = 10 * 60 * 1000;
337
+ const RELEASES_TTL = 20 * 60 * 1000;
338
+ const HOVER_OPEN_MS = 140;
339
+ const HOVER_CLOSE_MS = 180;
340
+ const FETCH_TIMEOUT = 12000;
341
+
342
+ const CSS = `
343
+ .pue-slot{display:inline-flex;align-items:center;flex:none}
344
+ .pue-btn{appearance:none;border:0;background:transparent;font-family:inherit;font-size:12px;line-height:20px;height:22px;padding:0 8px;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;transition:background-color .12s ease,color .12s ease}
345
+ .pue-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
346
+ .pue-btn:active{background:var(--dsw-alias-interactive-bg-active)}
347
+ .pue-btn:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}
348
+ .pue-btn:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;background:transparent}
349
+ .pue-open{position:relative;z-index:3}
350
+ .pue-pop{position:absolute;right:0;top:calc(100% + 6px);z-index:4;box-sizing:border-box;display:flex;flex-direction:column;width:min(320px,calc(100vw - 32px));max-height:min(360px,60vh);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);animation:pue-in .12s ease-out}
351
+ .pue-pop[data-flip="up"]{top:auto;bottom:calc(100% + 6px)}
352
+ @keyframes pue-in{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}
353
+ .pue-head{display:flex;align-items:center;gap:8px;padding:10px 10px 6px 12px;min-width:0}
354
+ .pue-hint{margin-top:6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
355
+ .pue-build{flex:none;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
356
+ .pue-title{font-size:13px;line-height:20px;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
357
+ .pue-chip{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 7px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums}
358
+ .pue-x{flex:none;margin-left:auto;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:var(--dsw-radius-xs);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1;cursor:pointer;font-family:inherit}
359
+ .pue-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
360
+ .pue-x:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}
361
+ .pue-body{min-height:0;display:flex;flex-direction:column}
362
+ .pue-list{list-style:none;margin:0;padding:2px 6px 6px;overflow:auto;max-height:min(240px,42vh);display:flex;flex-direction:column;gap:1px}
363
+ .pue-li{display:block;margin:0}
364
+ .pue-row{display:flex;flex-direction:column;gap:1px;width:100%;padding:5px 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;font-family:inherit;text-align:left;color:var(--dsw-alias-label-primary);cursor:pointer}
365
+ .pue-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
366
+ .pue-row:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:-2px}
367
+ .pue-row[aria-selected="true"]{background:var(--dsw-alias-interactive-bg-active)}
368
+ .pue-row[aria-disabled="true"]{opacity:.45;cursor:default}
369
+ .pue-row[aria-disabled="true"]:hover{background:transparent}
370
+ .pue-row-main{display:flex;align-items:baseline;gap:8px;min-width:0}
371
+ .pue-v{font-size:13px;line-height:18px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}
372
+ .pue-tag{flex:none;display:inline-flex;align-items:center;height:16px;padding:0 5px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;white-space:nowrap}
373
+ .pue-tag[data-tone="warn"]{color:var(--dsw-alias-state-warn-primary)}
374
+ .pue-tag[data-tone="error"]{color:var(--dsw-alias-state-error-primary)}
375
+ .pue-date{margin-left:auto;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap}
376
+ .pue-why{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
377
+ .pue-tick{flex:none;width:12px;font-size:11px;line-height:16px;color:var(--dsw-alias-brand-primary);opacity:0}
378
+ .pue-row[aria-selected="true"] .pue-tick{opacity:1}
379
+ .pue-loading{display:flex;align-items:center;gap:8px;padding:10px 12px 12px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
380
+ .pue-spin{width:12px;height:12px;flex:none;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary);animation:pue-spin .8s linear infinite}
381
+ @keyframes pue-spin{to{transform:rotate(360deg)}}
382
+ .pue-foot{display:flex;flex-direction:column;gap:8px;padding:8px 10px 10px}
383
+ .pue-foot[data-bordered="true"]{border-top:1px solid var(--dsw-alias-border-l1)}
384
+ .pue-info{display:flex;flex-wrap:wrap;gap:2px 10px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
385
+ .pue-actions{display:flex;align-items:center;gap:8px}
386
+ .pue-spacer{margin-left:auto}
387
+ .pue-primary{height:24px;padding:0 12px;border:0;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-family:inherit;font-size:12px;line-height:1;cursor:pointer;white-space:nowrap}
388
+ .pue-primary:hover{background:var(--dsw-alias-button-primary-hover)}
389
+ .pue-primary:disabled{opacity:.5;cursor:default}
390
+ .pue-primary:focus-visible,.pue-ghost:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}
391
+ .pue-ghost{height:24px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;line-height:1;cursor:pointer;white-space:nowrap}
392
+ .pue-ghost:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
393
+ .pue-ghost:disabled{opacity:.5;cursor:default;background:transparent}
394
+ .pue-confirm{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
395
+ .pue-steps{display:flex;flex-direction:column;gap:2px;padding:6px 12px 8px}
396
+ .pue-step{display:flex;align-items:center;gap:8px;font-size:13px;line-height:22px;color:var(--dsw-alias-label-tertiary)}
397
+ .pue-step[data-state="active"],.pue-step[data-state="done"]{color:var(--dsw-alias-label-primary)}
398
+ .pue-step[data-state="failed"]{color:var(--dsw-alias-state-error-primary)}
399
+ .pue-glyph{flex:none;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:11px;line-height:1}
400
+ .pue-out{display:flex;flex-direction:column;gap:4px;padding:8px 12px 6px}
401
+ .pue-out-title{font-size:13px;line-height:20px;font-weight:500;color:var(--dsw-alias-label-primary)}
402
+ .pue-out[data-tone="success"] .pue-out-title{color:var(--dsw-alias-state-success-primary)}
403
+ .pue-out[data-tone="warn"] .pue-out-title{color:var(--dsw-alias-state-warn-primary)}
404
+ .pue-out[data-tone="error"] .pue-out-title{color:var(--dsw-alias-state-error-primary)}
405
+ .pue-out-detail{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
406
+ .pue-out details{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);margin-top:2px}
407
+ .pue-out summary{cursor:pointer;padding:2px 0}
408
+ .pue-out pre{margin:4px 0 0;max-height:160px;overflow:auto;padding:6px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-family:var(--ds-font-family-code);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-word}
409
+ .pue-notes{position:absolute;right:calc(100% + 8px);box-sizing:border-box;width:min(360px,calc(100vw - 64px));max-height:300px;overflow:auto;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);box-shadow:var(--dsw-shadow-lv3);z-index:5;animation:pue-in .1s ease-out}
410
+ .pue-notes-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px;flex-wrap:wrap}
411
+ .pue-notes-v{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
412
+ .pue-notes-date{font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
413
+ .pue-notes-link{margin-left:auto;border:0;background:transparent;padding:0;font-family:inherit;font-size:11px;line-height:18px;color:var(--dsw-alias-link);text-decoration:none;cursor:pointer}
414
+ .pue-notes-body{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word}
415
+ .pue-notes[data-scrollable="true"]{-webkit-mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - 20px),transparent 100%);mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - 20px),transparent 100%)}
416
+ .pue-notes-empty{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
417
+ .pue-facts{display:flex;flex-direction:column;gap:2px;margin-bottom:6px}
418
+ .pue-fact{display:flex;gap:8px;font-size:12px;line-height:18px}
419
+ .pue-fact-k{flex:none;width:60px;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
420
+ .pue-fact-v{min-width:0;color:var(--dsw-alias-label-secondary);word-break:break-word}
421
+ .pue-all{appearance:none;flex:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);font-family:inherit;font-size:12px;line-height:20px;height:24px;padding:0 10px;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;transition:background-color .12s ease,color .12s ease}
422
+ .pue-all:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
423
+ .pue-all:active{background:var(--dsw-alias-interactive-bg-active)}
424
+ .pue-all:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}
425
+ .pue-all:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;background:var(--dsw-alias-bg-layer-2)}
426
+ .pue-all[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}
427
+ .pue-panel{position:fixed;z-index:60;box-sizing:border-box;display:flex;flex-direction:column;width:min(440px,calc(100vw - 24px));max-height:min(540px,calc(100vh - 24px));background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);animation:pue-in .12s ease-out}
428
+ .pue-panel-body{min-height:0;display:flex;flex-direction:column;overflow:auto}
429
+ .pue-bulk{list-style:none;margin:0;padding:2px 6px 8px;display:flex;flex-direction:column;gap:2px}
430
+ .pue-bulk-item{display:flex;flex-direction:column;border-radius:var(--dsw-radius-sm)}
431
+ .pue-bulk-item[data-open="true"]{background:var(--dsw-alias-bg-layer-2)}
432
+ .pue-bulk-head{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--dsw-radius-sm);min-width:0}
433
+ .pue-bulk-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
434
+ .pue-bulk-name{min-width:0;flex:1;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
435
+ .pue-bulk-from{flex:none;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
436
+ .pue-bulk-arrow{flex:none;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
437
+ .pue-bulk-pick{flex:none;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:11px;line-height:1;cursor:pointer;font-variant-numeric:tabular-nums;white-space:nowrap}
438
+ .pue-bulk-pick:hover{background:var(--dsw-alias-interactive-bg-hover)}
439
+ .pue-bulk-pick:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px}
440
+ .pue-bulk-pick:disabled{opacity:.5;cursor:default}
441
+ .pue-bulk-pick[data-state="loading"]{color:var(--dsw-alias-label-tertiary)}
442
+ .pue-bulk-state{flex:none;width:14px;font-size:11px;line-height:18px;text-align:center;color:var(--dsw-alias-label-tertiary)}
443
+ .pue-bulk-state[data-tone="done"]{color:var(--dsw-alias-state-success-primary)}
444
+ .pue-bulk-state[data-tone="failed"]{color:var(--dsw-alias-state-error-primary)}
445
+ .pue-bulk-versions{padding:0 6px 6px 22px}
446
+ .pue-bulk-versions .pue-list{max-height:min(180px,32vh)}
447
+ .pue-bulk-foot{display:flex;flex-direction:column;gap:8px;padding:8px 10px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
448
+ .pue-bulk-note{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);padding:0 8px 6px}
449
+ .pue-bulk-note[data-tone="error"]{color:var(--dsw-alias-state-error-primary)}
450
+ .pue-bulk-done{display:flex;flex-direction:column;gap:2px;max-height:min(240px,40vh);overflow:auto;padding:2px 12px 8px}
451
+ .pue-bulk-line{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:18px}
452
+ .pue-bulk-line .pue-bulk-name{flex:1}
453
+ .pue-bulk-line[data-tone="failed"] .pue-bulk-name{color:var(--dsw-alias-state-error-primary)}
454
+ .pue-bulk-line span:last-child{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}
455
+ .pue-panel-head{display:flex;align-items:center;gap:8px;padding:10px 10px 6px 12px;min-width:0}
456
+ .pue-panel-title{flex:1;min-width:0;font-size:13px;line-height:20px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
457
+ .pue-panel-body{position:relative}
458
+ .pue-panel .pue-list{max-height:none}
459
+ .pue-bulk-item[data-state="running"] .pue-bulk-name{color:var(--dsw-alias-label-primary)}
460
+ .pue-bulk-item[data-state="done"] .pue-bulk-name{color:var(--dsw-alias-label-secondary)}
461
+ .pue-bulk-item[data-state="failed"] .pue-bulk-name{color:var(--dsw-alias-state-error-primary)}
462
+ .pue-bulk-item[data-state="skipped"] .pue-bulk-name{color:var(--dsw-alias-label-tertiary)}
463
+ .pue-notes[data-fixed="true"]{position:fixed;right:auto;z-index:61}
464
+ .pue-panel-empty{display:flex;flex-direction:column;gap:8px;padding:14px 14px 16px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
465
+ .pue-panel-empty .pue-loading{padding:0}
466
+ @media (prefers-reduced-motion:reduce){.pue-pop,.pue-panel,.pue-notes{animation:none}.pue-spin{animation-duration:2.4s}}
467
+ `;
468
+
469
+ const DICT = {
470
+ zh: {
471
+ entry: '更新',
472
+ rpcUnavailable: FALLBACK_UNAVAILABLE,
473
+ entryLabel: '更新 {name}',
474
+ close: '关闭',
475
+ versions: '版本',
476
+ installedAt: '已安装 {version}',
477
+ checking: '正在检查更新',
478
+ latest: '已是最新版本',
479
+ checkedAt: '上次检查于 {time}',
480
+ blocked: '新版本需要更新 DSH',
481
+ needsPeers: '需要 {peers}',
482
+ notBundle: '这个版本没有声明组合包,无法安装',
483
+ noneInstallable: '没有可直接安装的新版本',
484
+ prerelease: '预发布',
485
+ deprecated: '已弃用',
486
+ publishedAt: '发布于 {date}',
487
+ omitted: '另有 {count} 个版本未列出',
488
+ showPrerelease: '显示预发布版本',
489
+ hidePrerelease: '隐藏预发布版本',
490
+ registry: '安装源',
491
+ updateTo: '更新到 {version}',
492
+ cancel: '取消',
493
+ confirm: '更新',
494
+ confirmText: '将从 {from} 更新到 {to},期间插件可能短暂停止。安装失败时会保留或重新安装 {from}。',
495
+ running: '正在更新',
496
+ updateStepChecking: '检查 {version}',
497
+ updateStepRemoving: '停止并移除 {version}',
498
+ updateStepInstalling: '从 {registry} 安装 {version}',
499
+ updateStepRestoring: '重新安装 {version}',
500
+ done: '已更新到 {version}',
501
+ doneDetail: '新版本已经开始运行。',
502
+ restart: '已安装 {version},重启后生效',
503
+ restartDetail: '重启 DSH 后生效,当前仍在运行旧版本。',
504
+ refused: '无法更新到 {version}',
505
+ removeFailed: '无法移除 {version}',
506
+ restored: '更新失败,已恢复到 {version}',
507
+ restoredDetail: '{to} 安装失败,插件已重新安装 {from}。',
508
+ broken: '更新失败,插件未安装',
509
+ brokenDetail: '{to} 安装失败,重新安装 {from} 也未成功。请从“添加插件”重新安装。',
510
+ lost: '无法确认更新结果',
511
+ lostDetail: '与 DSH 的连接中断:{reason}。刷新插件列表可查看当前版本。',
512
+ keptDetail: '{from} 未被移除,仍在使用。{reason}',
513
+ finish: '完成',
514
+ output: '查看安装输出',
515
+ network: '无法连接安装源',
516
+ unknown: '失败',
517
+ retry: '重试',
518
+ buildHint: '插件更新入口的构建版本',
519
+ serviceHint: '如果一直如此,请在插件页把「插件更新入口」关闭再打开,让入口重新加载;仍失败请重启 DSH。',
520
+ noNotes: '没有找到这一版本的更新说明',
521
+ notesLoading: '正在读取更新说明',
522
+ viewOnGitHub: '在 GitHub 查看',
523
+ factVersion: '版本',
524
+ factDate: '发布',
525
+ factTags: '标签',
526
+ factNeeds: '需要',
527
+ factBundle: '组合包',
528
+ yes: '是',
529
+ no: '否',
530
+ all: '一键更新',
531
+ allLabel: '一键更新全部可更新插件',
532
+ allTitle: '可更新的插件',
533
+ allChecking: '正在读取可更新的插件',
534
+ allNone: '当前没有可更新的插件',
535
+ allCount: '{count} 个插件可更新',
536
+ allRun: '更新 {count} 个',
537
+ allRunning: '正在更新',
538
+ allProgress: '第 {index}/{total} 个 · {name}',
539
+ allDone: '更新完成',
540
+ allDoneDetail: '{ok} 个已更新,{failed} 个失败',
541
+ allPartial: '部分插件未能更新',
542
+ allFailed: '失败',
543
+ allSkipped: '已跳过',
544
+ allStopped: '已停止,剩余 {count} 个未处理',
545
+ allSkippedDetail: '{count} 个已跳过',
546
+ allStop: '停止',
547
+ allPick: '选择版本',
548
+ allHint: '按列表顺序逐个更新;展开插件可换版本或查看更新日志。',
549
+ allRetry: '重新读取',
550
+ allStuck: '{count} 个版本尚未取到',
551
+ allEmpty: '全部插件已是最新版本',
552
+ },
553
+ en: {
554
+ entry: 'Update',
555
+ rpcUnavailable: 'The DSH connection is not ready yet. Try again in a moment.',
556
+ entryLabel: 'Update {name}',
557
+ close: 'Close',
558
+ versions: 'Versions',
559
+ installedAt: 'Installed {version}',
560
+ checking: 'Checking for updates',
561
+ latest: 'Already up to date',
562
+ checkedAt: 'Checked {time}',
563
+ blocked: 'A newer version needs a newer DSH',
564
+ needsPeers: 'Needs {peers}',
565
+ notBundle: 'This version declares no bundle and cannot be installed',
566
+ noneInstallable: 'No newer version can be installed directly',
567
+ prerelease: 'Pre-release',
568
+ deprecated: 'Deprecated',
569
+ publishedAt: 'Published {date}',
570
+ omitted: '{count} more versions are not listed',
571
+ showPrerelease: 'Show pre-release versions',
572
+ hidePrerelease: 'Hide pre-release versions',
573
+ registry: 'Registry',
574
+ updateTo: 'Update to {version}',
575
+ cancel: 'Cancel',
576
+ confirm: 'Update',
577
+ confirmText: 'Updates from {from} to {to}. The plugin may stop briefly; a failed install keeps or reinstalls {from}.',
578
+ running: 'Updating',
579
+ updateStepChecking: 'Check {version}',
580
+ updateStepRemoving: 'Stop and remove {version}',
581
+ updateStepInstalling: 'Install {version} from {registry}',
582
+ updateStepRestoring: 'Reinstall {version}',
583
+ done: 'Updated to {version}',
584
+ doneDetail: 'The new version is already running.',
585
+ restart: 'Installed {version}, takes effect after restart',
586
+ restartDetail: 'Restart DSH to run it; the old version is still running.',
587
+ refused: 'Cannot update to {version}',
588
+ removeFailed: 'Cannot remove {version}',
589
+ restored: 'Update failed, restored {version}',
590
+ restoredDetail: 'Installing {to} failed; {from} was installed again.',
591
+ broken: 'Update failed, the plugin is not installed',
592
+ brokenDetail: 'Installing {to} failed and reinstalling {from} did not succeed either. Reinstall it from “Add plugin”.',
593
+ lost: 'The update result is unknown',
594
+ lostDetail: 'The connection to DSH was interrupted: {reason}. Refresh the plugin list to see the current version.',
595
+ keptDetail: '{from} was not removed and is still in use. {reason}',
596
+ finish: 'Done',
597
+ output: 'Show install output',
598
+ network: 'Cannot reach the registry',
599
+ unknown: 'Failed',
600
+ retry: 'Retry',
601
+ buildHint: 'Build of Plugin update entry',
602
+ serviceHint: 'If this persists, switch Plugin update entry off and on in the plugin page so the entry reloads; restart DSH if it still fails.',
603
+ noNotes: 'No release notes were found for this version',
604
+ notesLoading: 'Reading release notes',
605
+ viewOnGitHub: 'View on GitHub',
606
+ factVersion: 'Version',
607
+ factDate: 'Published',
608
+ factTags: 'Tags',
609
+ factNeeds: 'Needs',
610
+ factBundle: 'Bundle',
611
+ yes: 'Yes',
612
+ no: 'No',
613
+ all: 'Update all',
614
+ allLabel: 'Update every updatable plugin',
615
+ allTitle: 'Plugins with updates',
616
+ allChecking: 'Reading updatable plugins',
617
+ allNone: 'No plugin can be updated right now',
618
+ allCount: 'Updates available: {count}',
619
+ allRun: 'Update {count}',
620
+ allRunning: 'Updating',
621
+ allProgress: '{index}/{total} · {name}',
622
+ allDone: 'Updates finished',
623
+ allDoneDetail: '{ok} updated, {failed} failed',
624
+ allPartial: 'Some plugins could not be updated',
625
+ allFailed: 'Failed',
626
+ allSkipped: 'Skipped',
627
+ allStopped: 'Stopped with {count} left to process',
628
+ allSkippedDetail: '{count} skipped',
629
+ allStop: 'Stop',
630
+ allPick: 'Choose version',
631
+ allHint: 'Plugins update one after another. Expand one to change its version or read release notes.',
632
+ allRetry: 'Read again',
633
+ allStuck: '{count} not read yet',
634
+ allEmpty: 'Everything is up to date',
635
+ },
636
+ };
637
+
638
+ /* ---------------- small DOM helpers ---------------- */
639
+
640
+ function el(tag, props, ...children) {
641
+ const node = document.createElement(tag);
642
+ if (props) {
643
+ for (const [key, value] of Object.entries(props)) {
644
+ if (value === undefined || value === null || value === false) continue;
645
+ if (key === 'class') node.className = value;
646
+ else if (key === 'text') node.textContent = value;
647
+ else if (key === 'style') Object.assign(node.style, value);
648
+ else if (key.startsWith('on')) node.addEventListener(key.slice(2).toLowerCase(), value);
649
+ else node.setAttribute(key, value === true ? '' : String(value));
650
+ }
651
+ }
652
+ for (const child of children.flat(4)) {
653
+ if (child === undefined || child === null || child === false || child === '') continue;
654
+ node.append(typeof child === 'string' || typeof child === 'number'
655
+ ? document.createTextNode(String(child))
656
+ : child);
657
+ }
658
+ return node;
659
+ }
660
+
661
+ function formatDate(iso) {
662
+ const text = String(iso || '');
663
+ const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/);
664
+ return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
665
+ }
666
+
667
+ function shortRegistry(registry) {
668
+ return String(registry || '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
669
+ }
670
+
671
+ function makeRequestId() {
672
+ try {
673
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
674
+ return globalThis.crypto.randomUUID();
675
+ }
676
+ } catch {}
677
+ return `pue-${Date.now()}-${Math.random().toString(16).slice(2)}`;
678
+ }
679
+
680
+ /* ---------------- module ---------------- */
681
+
682
+ function apply(ctx) {
683
+ const t = makeTranslate(ctx);
684
+ const entries = new Map();
685
+ const versionCache = new Map();
686
+ const versionPending = new Map();
687
+ const notesCache = new Map();
688
+ const repoCache = new Map();
689
+ const blockedRepos = new Set();
690
+ const hover = { open: 0, close: 0 };
691
+ let active = null;
692
+ let running = null;
693
+ let observer = null;
694
+ let raf = 0;
695
+ let scanFailures = 0;
696
+ let disposed = false;
697
+ let uid = 0;
698
+
699
+ /* Bulk update targets the official toolbar's “N plugins can be updated”
700
+ * note. That note is rendered by React only while every check is done,
701
+ * so both the button and this state have to survive being torn down and
702
+ * rebuilt at any moment. */
703
+ /* The bulk surface reuses the card popover's note renderer, which reads
704
+ * its subject from an `entry`-shaped object. `bulk.host` is that stand-in:
705
+ * it is never mounted, never scanned, and only carries the fields
706
+ * `notesNodes`/`loadNotes` actually touch. */
707
+ const bulk = {
708
+ open: false,
709
+ phase: 'idle',
710
+ items: [],
711
+ error: '',
712
+ cancelled: false,
713
+ expanded: null,
714
+ showPre: false,
715
+ currentName: '',
716
+ index: 0,
717
+ total: 0,
718
+ button: null,
719
+ anchor: null,
720
+ panel: null,
721
+ body: null,
722
+ foot: null,
723
+ hover: { open: 0, close: 0 },
724
+ host: {
725
+ uid: 'pue-bulk',
726
+ name: '',
727
+ popover: null,
728
+ bubble: null,
729
+ bubbleRow: null,
730
+ bubbleKey: null,
731
+ view: { list: null },
732
+ },
733
+ };
734
+
735
+ const debug = readFlag('pueDebug');
736
+
737
+ /* ---- locale ---- */
738
+
739
+ function makeTranslate(service) {
740
+ let bound = null;
741
+ try {
742
+ const translate = service.locale.bind(NS);
743
+ if (typeof translate === 'function') bound = translate;
744
+ } catch {}
745
+ return (key, params) => {
746
+ let text = null;
747
+ if (bound) {
748
+ try {
749
+ const answer = bound(key, params);
750
+ if (typeof answer === 'string' && answer !== key) text = answer;
751
+ } catch {}
752
+ }
753
+ if (text === null) text = DICT.zh[key];
754
+ if (text === undefined) text = key;
755
+ return interp(text, params);
756
+ };
757
+ }
758
+
759
+ /* ---- lifecycle ---- */
760
+
761
+ try {
762
+ window.__PUE_BUILD__ = BUILD;
763
+ } catch {}
764
+
765
+ ctx.effect(() => {
766
+ try {
767
+ return ctx.locale.register(NS, DICT);
768
+ } catch {
769
+ return () => {};
770
+ }
771
+ }, `${NS}: dictionaries`);
772
+
773
+ ctx.effect(() => {
774
+ const style = document.createElement('style');
775
+ style.dataset.pluginCss = `${NS}/client.css`;
776
+ style.textContent = CSS;
777
+ document.head.append(style);
778
+ return () => style.remove();
779
+ }, `${NS}: styles`);
780
+
781
+ ctx.effect(() => {
782
+ const stopEvents = subscribeUpdates();
783
+ attach();
784
+ return () => {
785
+ disposed = true;
786
+ stopEvents();
787
+ detach();
788
+ };
789
+ }, `${NS}: card entries`);
790
+
791
+ /* ---- host events ---- */
792
+
793
+ function subscribeUpdates() {
794
+ const bus = readService(ctx, 'remote');
795
+ if (!bus || typeof bus.$on !== 'function') return () => {};
796
+ try {
797
+ const dispose = bus.$on('plugin-manager/update-state', (payload) => advance(payload));
798
+ return typeof dispose === 'function' ? dispose : () => {};
799
+ } catch {
800
+ return () => {};
801
+ }
802
+ }
803
+
804
+ /* `remote.pluginManager` is a cordis Service registered under a DOTTED
805
+ * name (see the gateway: `super(ctx, remoteServiceKey(namespace))`).
806
+ * Reading it through the context proxy needs BOTH `remote` and
807
+ * `remote.pluginManager` in this fiber's `inject` — with only the dotted
808
+ * name the proxy throws on the `remote` hop, with only the bare name it
809
+ * throws on the dotted read (reproduced against the vendored cordis).
810
+ * `readService()` above avoids that coupling entirely: `ctx.get(name)`
811
+ * reads the same store with no proxy guard and no activation condition. */
812
+ const MANAGER_SERVICE = 'remote.pluginManager';
813
+
814
+ /* Third tier: the gateway's own transport. `connection.rpc.call` is what
815
+ * every namespace method ultimately does (`invoke()` → `rpc.call('/api',
816
+ * endpoint, {args})`), and `connection` is a plain-named service that is
817
+ * always mounted, so this works even when the namespace fiber is missing
818
+ * or the ctx proxy refuses the dotted read. */
819
+ function managerFromConnection() {
820
+ const connection = readService(ctx, 'connection');
821
+ if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') return undefined;
822
+ /* The Host refuses anything but ONE plain object keyed by parameter
823
+ * name: `Remote payload must contain exactly one plain-object args
824
+ * field`. The gateway builds it exactly this way (`prepared.args` is
825
+ * keyed by the descriptor's parameter names, undefined values dropped).
826
+ * An array is rejected, so the call shape below mirrors the wire. */
827
+ const call = (method, args) => connection.rpc.call('/api', `pluginManager/${method}`, { args });
828
+ return {
829
+ listBundles: () => call('listBundles', {}),
830
+ listVersions: (name, options) => call('listVersions', { name, options }),
831
+ updateBundle: (name, version, options) => call('updateBundle', { name, version, options }),
832
+ };
833
+ }
834
+
835
+ /* Last tier: the wire itself. The page is same-origin with the Host, so a
836
+ * plain POST carries the session cookie, and `/api/<namespace>/<method>`
837
+ * accepts the same envelope the Connection caller sends:
838
+ * { type: 'client-request', rpcId, method, payload: { args } }
839
+ * The answer is `{ type: 'server-response', rpcId, result: { ok, value } }`.
840
+ * This tier touches no cordis service at all, which is what makes the
841
+ * control survive a module that materialized before the gateway did. */
842
+ function managerFromFetch() {
843
+ const send = typeof globalThis.fetch === 'function' ? globalThis.fetch : undefined;
844
+ if (!send) return undefined;
845
+ const call = async (method, args) => {
846
+ const rpcId = makeRequestId();
847
+ const response = await send(`/api/pluginManager/${method}`, {
848
+ method: 'POST',
849
+ headers: { 'content-type': 'application/json' },
850
+ credentials: 'same-origin',
851
+ body: JSON.stringify({
852
+ type: 'client-request',
853
+ rpcId,
854
+ method: `pluginManager/${method}`,
855
+ payload: { args },
856
+ }),
857
+ });
858
+ if (!response || !response.ok) throw new Error(`plugin-manager: HTTP ${response ? response.status : 'no response'}`);
859
+ const envelope = await response.json();
860
+ if (!envelope || typeof envelope !== 'object' || !envelope.result) {
861
+ throw new Error('plugin-manager: malformed response envelope');
862
+ }
863
+ return envelope.result;
864
+ };
865
+ return {
866
+ listBundles: () => call('listBundles', {}),
867
+ listVersions: (name, options) => call('listVersions', { name, options }),
868
+ updateBundle: (name, version, options) => call('updateBundle', { name, version, options }),
869
+ };
870
+ }
871
+
872
+ /* Why the page needs this: `remote.pluginManager` is mounted by the api
873
+ * gateway only after it has mounted the contribution that declares the
874
+ * methods, and the whole module can be materialized before that happens.
875
+ * A silent fallback message cannot tell "not mounted yet" apart from "this
876
+ * bundle is stale", so the failure carries what was actually found. */
877
+ function diagnostics() {
878
+ const found = [];
879
+ try {
880
+ if (ctx.get) found.push(`get=${typeof ctx.get(MANAGER_SERVICE)}`);
881
+ } catch (error) {
882
+ found.push(`get=throw:${messageOf(error)}`);
883
+ }
884
+ try {
885
+ if (ctx.reflect && ctx.reflect.get) found.push(`reflect=${typeof ctx.reflect.get(MANAGER_SERVICE)}`);
886
+ } catch (error) {
887
+ found.push(`reflect=throw:${messageOf(error)}`);
888
+ }
889
+ try {
890
+ const connection = readService(ctx, 'connection');
891
+ found.push(`connection=${connection ? (connection.rpc && typeof connection.rpc.call === 'function' ? 'rpc' : 'no-rpc') : 'none'}`);
892
+ found.push(`fetch=${typeof globalThis.fetch === 'function' ? 'yes' : 'none'}`);
893
+ } catch (error) {
894
+ found.push(`connection=throw:${messageOf(error)}`);
895
+ }
896
+ try {
897
+ if (ctx.reflect && ctx.reflect.props) {
898
+ const names = Object.keys(ctx.reflect.props).filter((key) => key.startsWith('remote'));
899
+ found.push(`props=${names.length ? names.join(',') : 'none'}`);
900
+ }
901
+ } catch {}
902
+ return found.join(' · ') || 'no ctx';
903
+ }
904
+
905
+ function advance(payload) {
906
+ if (!running || !payload || payload.requestId !== running.requestId) return;
907
+ running.current = payload.step;
908
+ const entry = running.entry;
909
+ entry.view.current = payload.step;
910
+ if (entry.popover) render(entry);
911
+ }
912
+
913
+ /* ---- scanning the official page ---- */
914
+
915
+ function scope() {
916
+ return document.querySelector(SEL.scope) || document.querySelector(SEL.scopeAlt);
917
+ }
918
+
919
+ function attach() {
920
+ observer = new MutationObserver(() => schedule());
921
+ observer.observe(document.body, { childList: true, subtree: true });
922
+ document.addEventListener('pointerdown', onDocumentPointerDown, true);
923
+ document.addEventListener('keydown', onDocumentKeydown, true);
924
+ window.addEventListener('scroll', onViewportChange, true);
925
+ window.addEventListener('resize', onViewportChange);
926
+ schedule();
927
+ }
928
+
929
+ function detach() {
930
+ if (observer) observer.disconnect();
931
+ observer = null;
932
+ document.removeEventListener('pointerdown', onDocumentPointerDown, true);
933
+ document.removeEventListener('keydown', onDocumentKeydown, true);
934
+ window.removeEventListener('scroll', onViewportChange, true);
935
+ window.removeEventListener('resize', onViewportChange);
936
+ if (raf) cancelAnimationFrame(raf);
937
+ raf = 0;
938
+ for (const li of [...entries.keys()]) removeEntry(li);
939
+ closeBulk();
940
+ bulk.button = null;
941
+ bulk.anchor = null;
942
+ clearTimeout(hover.open);
943
+ clearTimeout(hover.close);
944
+ clearTimeout(bulk.hover.open);
945
+ clearTimeout(bulk.hover.close);
946
+ active = null;
947
+ running = null;
948
+ }
949
+
950
+ function schedule() {
951
+ if (disposed || raf) return;
952
+ raf = requestAnimationFrame(() => {
953
+ raf = 0;
954
+ try {
955
+ scan();
956
+ scanFailures = 0;
957
+ } catch (error) {
958
+ scanFailures += 1;
959
+ warn('scan failed', error);
960
+ if (scanFailures >= 3) {
961
+ warn('giving up on this page build');
962
+ if (observer) observer.disconnect();
963
+ observer = null;
964
+ }
965
+ }
966
+ });
967
+ }
968
+
969
+ function scan() {
970
+ if (disposed) return;
971
+ const host = scope();
972
+ if (!host) {
973
+ for (const li of [...entries.keys()]) removeEntry(li);
974
+ ensureBulkButton();
975
+ return;
976
+ }
977
+ const live = new Set();
978
+ for (const li of host.querySelectorAll(SEL.card)) {
979
+ const name = li.getAttribute('data-plugin-package');
980
+ if (!name) continue;
981
+ live.add(li);
982
+ const end = li.querySelector(SEL.end);
983
+ const badged = Boolean(end && hasBadge(li));
984
+ const entry = entries.get(li);
985
+ if (!badged) {
986
+ if (entry) removeEntry(li);
987
+ continue;
988
+ }
989
+ if (entry && entry.slot.parentElement === end) {
990
+ syncBusy(entry);
991
+ continue;
992
+ }
993
+ if (entry) removeEntry(li);
994
+ mount(li, end, name);
995
+ }
996
+ for (const li of [...entries.keys()]) {
997
+ if (!live.has(li) || !li.isConnected) removeEntry(li);
998
+ }
999
+ ensureBulkButton();
1000
+ }
1001
+
1002
+ function hasBadge(li) {
1003
+ for (const tag of li.querySelectorAll(SEL.badge)) {
1004
+ const text = (tag.textContent || '').replace(/\s+/g, ' ').trim();
1005
+ if (!text) continue;
1006
+ if (BADGE_TEXTS.some((label) => text === label || text.includes(label))) return tag;
1007
+ }
1008
+ return null;
1009
+ }
1010
+
1011
+ function mount(li, end, name) {
1012
+ const slot = el('span', { class: 'pue-slot' });
1013
+ const button = el('button', {
1014
+ class: 'pue-btn',
1015
+ type: 'button',
1016
+ 'aria-haspopup': 'dialog',
1017
+ 'aria-expanded': 'false',
1018
+ title: t('entryLabel', { name }),
1019
+ }, t('entry'));
1020
+ slot.append(button);
1021
+ end.insertBefore(slot, end.firstElementChild);
1022
+ uid += 1;
1023
+ const entry = {
1024
+ li,
1025
+ slot,
1026
+ button,
1027
+ name,
1028
+ uid: `pue-${uid}`,
1029
+ popover: null,
1030
+ body: null,
1031
+ foot: null,
1032
+ chip: null,
1033
+ bubble: null,
1034
+ bubbleRow: null,
1035
+ view: {
1036
+ phase: 'idle',
1037
+ list: null,
1038
+ selected: null,
1039
+ showPre: false,
1040
+ confirm: false,
1041
+ outcome: null,
1042
+ output: '',
1043
+ current: null,
1044
+ run: null,
1045
+ error: '',
1046
+ installed: '',
1047
+ },
1048
+ };
1049
+ entries.set(li, entry);
1050
+ button.addEventListener('pointerdown', (event) => event.stopPropagation());
1051
+ button.addEventListener('click', (event) => {
1052
+ event.preventDefault();
1053
+ event.stopPropagation();
1054
+ if (active === entry) closePopover(entry, { restoreFocus: true });
1055
+ else openPopover(entry);
1056
+ });
1057
+ syncBusy(entry);
1058
+ if (debug) console.info(`[${NS}] attached ${name}`);
1059
+ return entry;
1060
+ }
1061
+
1062
+ function removeEntry(li) {
1063
+ const entry = entries.get(li);
1064
+ if (!entry) return;
1065
+ closePopover(entry);
1066
+ entry.slot.remove();
1067
+ entries.delete(li);
1068
+ li.classList.remove('pue-open');
1069
+ li.style.zIndex = '';
1070
+ }
1071
+
1072
+ function isCardBusy(li) {
1073
+ const toggle = li.querySelector(SEL.switch);
1074
+ if (!toggle) return false;
1075
+ return toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true';
1076
+ }
1077
+
1078
+ function syncBusy(entry) {
1079
+ /* A bulk run and a card run write the same profile, so one disables
1080
+ * the entry point of the other. */
1081
+ entry.button.disabled = Boolean(running) || bulk.phase === 'running' || isCardBusy(entry.li);
1082
+ }
1083
+
1084
+ function syncBusyAll() {
1085
+ for (const entry of entries.values()) syncBusy(entry);
1086
+ }
1087
+
1088
+ /* ---- popover shell ---- */
1089
+
1090
+ function openPopover(entry) {
1091
+ if (bulk.open && bulk.phase !== 'running') closeBulk();
1092
+ if (active && active !== entry) closePopover(active);
1093
+ ensurePopover(entry);
1094
+ active = entry;
1095
+ entry.li.classList.add('pue-open');
1096
+ entry.li.style.zIndex = '3';
1097
+ entry.button.setAttribute('aria-expanded', 'true');
1098
+ entry.view.confirm = false;
1099
+ const cached = fresh(versionCache.get(entry.name));
1100
+ if (cached) {
1101
+ entry.view.list = cached;
1102
+ entry.view.phase = 'listed';
1103
+ entry.view.installed = cached.installed || '';
1104
+ pickDefault(entry);
1105
+ render(entry);
1106
+ } else {
1107
+ loadVersions(entry);
1108
+ }
1109
+ position(entry);
1110
+ }
1111
+
1112
+ function closePopover(entry, options) {
1113
+ if (!entry) return;
1114
+ hideNotes(entry);
1115
+ if (entry.popover) {
1116
+ entry.popover.remove();
1117
+ entry.popover = null;
1118
+ entry.body = null;
1119
+ entry.foot = null;
1120
+ entry.chip = null;
1121
+ entry.bubble = null;
1122
+ entry.bubbleRow = null;
1123
+ }
1124
+ entry.li.classList.remove('pue-open');
1125
+ entry.li.style.zIndex = '';
1126
+ entry.button.setAttribute('aria-expanded', 'false');
1127
+ if (active === entry) active = null;
1128
+ if (options && options.restoreFocus) {
1129
+ try {
1130
+ entry.button.focus();
1131
+ } catch {}
1132
+ }
1133
+ }
1134
+
1135
+ function ensurePopover(entry) {
1136
+ if (entry.popover) return entry.popover;
1137
+ const popover = el('div', {
1138
+ class: 'pue-pop',
1139
+ role: 'dialog',
1140
+ 'aria-modal': 'false',
1141
+ 'aria-label': t('entryLabel', { name: entry.name }),
1142
+ });
1143
+ const chip = el('span', { class: 'pue-chip' });
1144
+ chip.hidden = true;
1145
+ const title = el('span', { class: 'pue-title', title: entry.name }, entry.name);
1146
+ const close = el('button', {
1147
+ class: 'pue-x',
1148
+ type: 'button',
1149
+ 'aria-label': t('close'),
1150
+ onclick: (event) => {
1151
+ event.stopPropagation();
1152
+ closePopover(entry, { restoreFocus: true });
1153
+ },
1154
+ }, '×');
1155
+ const stamp = readFlag('pueDebug')
1156
+ ? el('span', { class: 'pue-build', title: t('buildHint') }, BUILD)
1157
+ : null;
1158
+ const head = el('div', { class: 'pue-head' }, title, chip, stamp, close);
1159
+ const body = el('div', { class: 'pue-body' });
1160
+ const foot = el('div', { class: 'pue-foot' });
1161
+ popover.append(head, body, foot);
1162
+ popover.addEventListener('pointerdown', (event) => event.stopPropagation());
1163
+ popover.addEventListener('click', (event) => event.stopPropagation());
1164
+ popover.addEventListener('keydown', (event) => onPopoverKeydown(entry, event));
1165
+ entry.slot.append(popover);
1166
+ entry.popover = popover;
1167
+ entry.body = body;
1168
+ entry.foot = foot;
1169
+ entry.chip = chip;
1170
+ return popover;
1171
+ }
1172
+
1173
+ /* ---- version data ---- */
1174
+
1175
+ function fresh(recorded) {
1176
+ if (!recorded) return null;
1177
+ return Date.now() - recorded.at < VERSION_TTL ? recorded.list : null;
1178
+ }
1179
+
1180
+ async function loadVersions(entry, force) {
1181
+ const name = entry.name;
1182
+ const cached = force ? null : fresh(versionCache.get(name));
1183
+ if (cached) {
1184
+ entry.view.list = cached;
1185
+ entry.view.phase = 'listed';
1186
+ entry.view.installed = cached.installed || '';
1187
+ pickDefault(entry);
1188
+ render(entry);
1189
+ return;
1190
+ }
1191
+ entry.view.phase = 'loading';
1192
+ entry.view.confirm = false;
1193
+ render(entry);
1194
+ try {
1195
+ const list = await listVersions(name, versionPending);
1196
+ entry.view.list = list;
1197
+ if (list.status === 'listed') {
1198
+ versionCache.set(name, { list, at: Date.now() });
1199
+ entry.view.phase = 'listed';
1200
+ entry.view.installed = list.installed || '';
1201
+ pickDefault(entry);
1202
+ } else {
1203
+ entry.view.phase = 'refused';
1204
+ entry.view.installed = '';
1205
+ }
1206
+ } catch (error) {
1207
+ entry.view.phase = 'error';
1208
+ entry.view.error = userMessage(error);
1209
+ warn(`listVersions(${name})`, error);
1210
+ }
1211
+ render(entry);
1212
+ }
1213
+
1214
+ /* The gateway mounts a namespace as its own fiber when a contribution is
1215
+ * mounted; a module that materialized earlier can lose that race. Poll
1216
+ * briefly instead of failing the click, and never poll while a popover is
1217
+ * closed. */
1218
+ /* The tiers are tried in order, and a tier that THROWS is skipped for the
1219
+ * next one instead of ending the click. That matters because the first
1220
+ * two tiers exist only to keep the call in-process; the last tier is the
1221
+ * one that always works on a page same-origin with its Host. */
1222
+ function managerTiers() {
1223
+ const tiers = [];
1224
+ const service = readService(ctx, MANAGER_SERVICE);
1225
+ if (service) tiers.push({ name: 'service', manager: service });
1226
+ const rpc = managerFromConnection();
1227
+ if (rpc) tiers.push({ name: 'rpc', manager: rpc });
1228
+ const wire = managerFromFetch();
1229
+ if (wire) tiers.push({ name: 'fetch', manager: wire });
1230
+ return tiers;
1231
+ }
1232
+
1233
+ /* A read may be retried on the next tier after ANY failure: the only cost
1234
+ * is a second lookup. A mutation may not. If the Host accepted
1235
+ * `updateBundle` and the answer was lost on the way back, retrying would
1236
+ * dispatch a second update, so a mutation only moves to the next tier
1237
+ * while the failure proves the request never left the page. */
1238
+ const NEVER_SENT = /without inject|cannot get property|has no active Connection|is not a function|not a function/i;
1239
+
1240
+ async function viaTiers(method, args, options = {}) {
1241
+ const { mutating = false, timeoutMs = 6000 } = options;
1242
+ const deadline = Date.now() + timeoutMs;
1243
+ const failures = [];
1244
+ for (;;) {
1245
+ const tiers = managerTiers();
1246
+ if (tiers.length === 0) {
1247
+ if (Date.now() >= deadline) break;
1248
+ await new Promise((resolve) => setTimeout(resolve, 200));
1249
+ continue;
1250
+ }
1251
+ for (const tier of tiers) {
1252
+ try {
1253
+ return await tier.manager[method](...args);
1254
+ } catch (error) {
1255
+ const raw = messageOf(error);
1256
+ failures.push(`${tier.name}: ${raw}`);
1257
+ if (mutating && !NEVER_SENT.test(raw)) {
1258
+ failures.push('mutation not retried');
1259
+ break;
1260
+ }
1261
+ }
1262
+ }
1263
+ break;
1264
+ }
1265
+ const detail = failures.length ? ` · ${failures.join(' | ')}` : '';
1266
+ throw new Error(`${t('rpcUnavailable')}(pue ${BUILD} · ${diagnostics()}${detail})`);
1267
+ }
1268
+
1269
+ function listVersions(name, pending, force) {
1270
+ const shared = force ? null : pending.get(name);
1271
+ if (shared) return shared;
1272
+ const task = (async () => {
1273
+ const answer = await viaTiers('listVersions', [name, {}]);
1274
+ if (answer && answer.ok === false) {
1275
+ throw new Error((answer.error && (answer.error.message || answer.error.code)) || t('unknown'));
1276
+ }
1277
+ return answer && answer.value ? answer.value : answer;
1278
+ })().finally(() => pending.delete(name));
1279
+ pending.set(name, task);
1280
+ return task;
1281
+ }
1282
+
1283
+ function pickDefault(entry) {
1284
+ const list = entry.view.list;
1285
+ if (!list || list.status !== 'listed') {
1286
+ entry.view.selected = null;
1287
+ return;
1288
+ }
1289
+ const shown = filterPrerelease(list.versions, entry.view.showPre);
1290
+ const first = shown.find((item) => !installBlocker(item));
1291
+ if (entry.view.selected && !shown.some((item) => item.version === entry.view.selected)) {
1292
+ entry.view.selected = null;
1293
+ }
1294
+ if (!entry.view.selected && first) entry.view.selected = first.version;
1295
+ }
1296
+
1297
+ /* ---- rendering ---- */
1298
+
1299
+ function render(entry) {
1300
+ if (!entry.popover || !entry.body || !entry.foot) return;
1301
+ const view = entry.view;
1302
+ entry.chip.hidden = !view.installed;
1303
+ entry.chip.textContent = view.installed ? t('installedAt', { version: view.installed }) : '';
1304
+ entry.body.replaceChildren(...bodyNodes(entry));
1305
+ entry.foot.replaceChildren(...footNodes(entry));
1306
+ if (entry.bubbleRow && entry.bubbleRow.isConnected) positionNotes(entry, entry.bubbleRow);
1307
+ else hideNotes(entry);
1308
+ position(entry);
1309
+ }
1310
+
1311
+ function bodyNodes(entry) {
1312
+ const view = entry.view;
1313
+ if (view.phase === 'loading') {
1314
+ return [el('div', { class: 'pue-loading' }, el('span', { class: 'pue-spin' }), t('checking'))];
1315
+ }
1316
+ if (view.phase === 'refused') {
1317
+ const reason = (view.list && view.list.reason) || t('unknown');
1318
+ return [el('div', { class: 'pue-loading' }, reason)];
1319
+ }
1320
+ if (view.phase === 'error') {
1321
+ return [
1322
+ el('div', { class: 'pue-loading' }, view.error || t('network')),
1323
+ el('div', { class: 'pue-hint' }, t('serviceHint')),
1324
+ ];
1325
+ }
1326
+ if (view.phase === 'running') return [stepsNode(entry)];
1327
+ if (view.phase === 'done') return [outcomeNode(entry)];
1328
+ return [listNode(entry)];
1329
+ }
1330
+
1331
+ function listNode(entry) {
1332
+ const view = entry.view;
1333
+ const list = view.list || { versions: [] };
1334
+ const shown = filterPrerelease(list.versions, view.showPre);
1335
+ if (!shown.length) {
1336
+ return el('div', { class: 'pue-loading' }, list.versions && list.versions.length ? t('noneInstallable') : t('latest'));
1337
+ }
1338
+ const selectedId = view.selected ? `${entry.uid}-opt-${view.selected}` : undefined;
1339
+ const listbox = el('ul', {
1340
+ class: 'pue-list',
1341
+ role: 'listbox',
1342
+ 'aria-label': t('versions'),
1343
+ 'aria-activedescendant': selectedId,
1344
+ onkeydown: (event) => onListKeydown(entry, event),
1345
+ });
1346
+ for (const item of shown) {
1347
+ const row = rowNode(entry, item);
1348
+ listbox.append(el('li', { class: 'pue-li', role: 'none' }, row));
1349
+ }
1350
+ return listbox;
1351
+ }
1352
+
1353
+ function rowNode(entry, item) {
1354
+ const view = entry.view;
1355
+ const blocker = installBlocker(item);
1356
+ const selected = view.selected === item.version;
1357
+ const tags = el('span', { class: 'pue-row-main' },
1358
+ el('span', { class: 'pue-v' }, item.version),
1359
+ item.prerelease ? el('span', { class: 'pue-tag' }, t('prerelease')) : null,
1360
+ item.deprecated ? el('span', { class: 'pue-tag', 'data-tone': 'warn' }, t('deprecated')) : null,
1361
+ item.incompatible ? el('span', { class: 'pue-tag', 'data-tone': 'error' }, t('blocked')) : null,
1362
+ item.publishedAt ? el('span', { class: 'pue-date' }, formatDate(item.publishedAt)) : null,
1363
+ el('span', { class: 'pue-tick', 'aria-hidden': 'true' }, '✓'),
1364
+ );
1365
+ const children = [tags];
1366
+ if (blocker) children.push(el('span', { class: 'pue-why' }, blocker === 'notBundle' ? t('notBundle') : t('blocked')));
1367
+ const row = el('button', {
1368
+ class: 'pue-row',
1369
+ type: 'button',
1370
+ role: 'option',
1371
+ id: `${entry.uid}-opt-${item.version}`,
1372
+ 'aria-selected': selected ? 'true' : 'false',
1373
+ 'aria-disabled': blocker ? 'true' : 'false',
1374
+ 'data-version': item.version,
1375
+ onclick: (event) => {
1376
+ event.stopPropagation();
1377
+ if (blocker) return;
1378
+ view.selected = item.version;
1379
+ view.confirm = false;
1380
+ render(entry);
1381
+ },
1382
+ }, children);
1383
+ wireNotes(entry, item, row);
1384
+ return row;
1385
+ }
1386
+
1387
+ function stepsNode(entry) {
1388
+ const view = entry.view;
1389
+ const run = view.run || { from: '', to: '', registry: '' };
1390
+ const steps = planSteps(run.from, run.to, run.registry);
1391
+ const rows = steps.map((step, index) => {
1392
+ const state = stepState(index, view.current);
1393
+ const glyph = state === 'done' ? '✓' : state === 'failed' ? '×' : state === 'active' ? '·' : '·';
1394
+ return el('div', { class: 'pue-step', 'data-state': state },
1395
+ el('span', { class: 'pue-glyph' },
1396
+ state === 'active' ? el('span', { class: 'pue-spin' }) : glyph),
1397
+ t(step.labelKey, step.params));
1398
+ });
1399
+ return el('div', { class: 'pue-steps' }, rows);
1400
+ }
1401
+
1402
+ function outcomeNode(entry) {
1403
+ const view = entry.view;
1404
+ const outcome = view.outcome || { kind: 'lost', reason: '' };
1405
+ const tone = outcome.kind === 'done'
1406
+ ? 'success'
1407
+ : ['restart', 'restored', 'removeFailed', 'refused'].includes(outcome.kind) ? 'warn' : 'error';
1408
+ const params = {
1409
+ from: outcome.from || (view.run && view.run.from) || '',
1410
+ to: outcome.to || (view.run && view.run.to) || '',
1411
+ version: outcome.to || (view.run && view.run.to) || '',
1412
+ reason: outcome.reason || '',
1413
+ };
1414
+ const detailKey = `${outcome.kind}Detail`;
1415
+ let detail = t(detailKey, params);
1416
+ if (detail === detailKey) detail = '';
1417
+ if (outcome.kind === 'refused' || outcome.kind === 'lost') {
1418
+ if (outcome.reason) detail = outcome.reason;
1419
+ }
1420
+ const children = [
1421
+ el('div', { class: 'pue-out-title' }, t(outcome.kind, params)),
1422
+ detail ? el('div', { class: 'pue-out-detail' }, detail) : null,
1423
+ ];
1424
+ if (view.output) {
1425
+ children.push(el('details', {},
1426
+ el('summary', {}, t('output')),
1427
+ el('pre', {}, view.output)));
1428
+ }
1429
+ return el('div', { class: 'pue-out', 'data-tone': tone }, children);
1430
+ }
1431
+
1432
+ function footNodes(entry) {
1433
+ const view = entry.view;
1434
+ if (view.phase === 'loading') return [];
1435
+ if (view.phase === 'running') {
1436
+ return [el('div', { class: 'pue-actions' },
1437
+ el('div', { class: 'pue-info' }, t('running')),
1438
+ el('span', { class: 'pue-spacer' }),
1439
+ el('span', { class: 'pue-spin' }))];
1440
+ }
1441
+ if (view.phase === 'done') {
1442
+ return [el('div', { class: 'pue-actions' },
1443
+ el('span', { class: 'pue-spacer' }),
1444
+ el('button', {
1445
+ class: 'pue-primary',
1446
+ type: 'button',
1447
+ onclick: (event) => {
1448
+ event.stopPropagation();
1449
+ closePopover(entry, { restoreFocus: true });
1450
+ },
1451
+ }, t('finish')))];
1452
+ }
1453
+ if (view.phase === 'refused' || view.phase === 'error') {
1454
+ return [el('div', { class: 'pue-actions' },
1455
+ el('span', { class: 'pue-spacer' }),
1456
+ el('button', {
1457
+ class: 'pue-ghost',
1458
+ type: 'button',
1459
+ onclick: (event) => {
1460
+ event.stopPropagation();
1461
+ versionCache.delete(entry.name);
1462
+ loadVersions(entry, true);
1463
+ },
1464
+ }, t('retry')))];
1465
+ }
1466
+
1467
+ const list = view.list || { versions: [] };
1468
+ const info = [];
1469
+ if (list.registry) info.push(el('span', {}, `${t('registry')} ${shortRegistry(list.registry)}`));
1470
+ if (list.omitted > 0) info.push(el('span', {}, t('omitted', { count: list.omitted })));
1471
+ const hidden = filterPrerelease(list.versions, false).length !== (list.versions || []).length;
1472
+
1473
+ if (view.confirm && view.selected) {
1474
+ return [
1475
+ el('div', { class: 'pue-confirm' }, t('confirmText', { from: view.installed, to: view.selected })),
1476
+ el('div', { class: 'pue-actions' },
1477
+ el('span', { class: 'pue-spacer' }),
1478
+ el('button', {
1479
+ class: 'pue-ghost',
1480
+ type: 'button',
1481
+ onclick: (event) => {
1482
+ event.stopPropagation();
1483
+ view.confirm = false;
1484
+ render(entry);
1485
+ },
1486
+ }, t('cancel')),
1487
+ el('button', {
1488
+ class: 'pue-primary',
1489
+ type: 'button',
1490
+ onclick: (event) => {
1491
+ event.stopPropagation();
1492
+ runUpdate(entry);
1493
+ },
1494
+ }, t('confirm'))),
1495
+ ];
1496
+ }
1497
+
1498
+ const primary = el('button', {
1499
+ class: 'pue-primary',
1500
+ type: 'button',
1501
+ disabled: !view.selected,
1502
+ onclick: (event) => {
1503
+ event.stopPropagation();
1504
+ if (!view.selected) return;
1505
+ view.confirm = true;
1506
+ render(entry);
1507
+ },
1508
+ }, view.selected ? t('updateTo', { version: view.selected }) : t('noneInstallable'));
1509
+
1510
+ return [
1511
+ info.length ? el('div', { class: 'pue-info' }, info) : null,
1512
+ el('div', { class: 'pue-actions' },
1513
+ hidden
1514
+ ? el('button', {
1515
+ class: 'pue-ghost',
1516
+ type: 'button',
1517
+ 'aria-pressed': view.showPre ? 'true' : 'false',
1518
+ onclick: (event) => {
1519
+ event.stopPropagation();
1520
+ view.showPre = !view.showPre;
1521
+ pickDefault(entry);
1522
+ render(entry);
1523
+ },
1524
+ }, view.showPre ? t('hidePrerelease') : t('showPrerelease'))
1525
+ : null,
1526
+ el('span', { class: 'pue-spacer' }),
1527
+ primary),
1528
+ ].filter(Boolean);
1529
+ }
1530
+
1531
+ /* ---- notes bubble ---- */
1532
+
1533
+ function wireNotes(entry, item, row) {
1534
+ row.addEventListener('pointerenter', () => {
1535
+ clearTimeout(hover.close);
1536
+ clearTimeout(hover.open);
1537
+ hover.open = setTimeout(() => drawNotes(entry, item, row), HOVER_OPEN_MS);
1538
+ });
1539
+ row.addEventListener('pointerleave', () => {
1540
+ clearTimeout(hover.open);
1541
+ scheduleHideNotes(entry);
1542
+ });
1543
+ row.addEventListener('focus', () => {
1544
+ clearTimeout(hover.close);
1545
+ clearTimeout(hover.open);
1546
+ drawNotes(entry, item, row);
1547
+ });
1548
+ row.addEventListener('blur', () => scheduleHideNotes(entry));
1549
+ }
1550
+
1551
+ function scheduleHideNotes(entry) {
1552
+ clearTimeout(hover.close);
1553
+ hover.close = setTimeout(() => hideNotes(entry), HOVER_CLOSE_MS);
1554
+ }
1555
+
1556
+ function hideNotes(entry) {
1557
+ clearTimeout(hover.close);
1558
+ clearTimeout(hover.open);
1559
+ if (entry && entry.bubble) {
1560
+ entry.bubble.remove();
1561
+ entry.bubble = null;
1562
+ entry.bubbleRow = null;
1563
+ }
1564
+ }
1565
+
1566
+ function drawNotes(entry, item, row) {
1567
+ if (!entry.popover || !row.isConnected) return;
1568
+ let bubble = entry.bubble;
1569
+ if (!bubble) {
1570
+ bubble = el('div', { class: 'pue-notes', role: 'tooltip', id: `${entry.uid}-notes` });
1571
+ bubble.addEventListener('pointerenter', () => clearTimeout(hover.close));
1572
+ bubble.addEventListener('pointerleave', () => scheduleHideNotes(entry));
1573
+ entry.popover.append(bubble);
1574
+ entry.bubble = bubble;
1575
+ }
1576
+ entry.bubbleRow = row;
1577
+ row.setAttribute('aria-describedby', bubble.id);
1578
+ bubble.replaceChildren(...notesNodes(entry, item));
1579
+ markScrollable(bubble);
1580
+ positionNotes(entry, row);
1581
+ if (!item.__notes) {
1582
+ loadNotes(entry, item).then(() => {
1583
+ if (entry.bubble === bubble && entry.bubbleRow === row) {
1584
+ bubble.replaceChildren(...notesNodes(entry, item));
1585
+ markScrollable(bubble);
1586
+ positionNotes(entry, row);
1587
+ }
1588
+ });
1589
+ }
1590
+ }
1591
+
1592
+ /* A masked fade says "there is more"; a hard cut says "this is broken". */
1593
+ function markScrollable(bubble) {
1594
+ if (!bubble) return;
1595
+ if (bubble.scrollHeight > bubble.clientHeight + 1) bubble.dataset.scrollable = 'true';
1596
+ else delete bubble.dataset.scrollable;
1597
+ }
1598
+
1599
+ function positionNotes(entry, row) {
1600
+ const bubble = entry.bubble;
1601
+ if (!bubble) return;
1602
+ const popover = entry.popover;
1603
+ bubble.style.top = '0px';
1604
+ const limit = Math.max(0, (popover ? popover.clientHeight : 360) - bubble.offsetHeight);
1605
+ bubble.style.top = `${Math.min(Math.max(0, row.offsetTop), limit)}px`;
1606
+ }
1607
+
1608
+ function notesNodes(entry, item) {
1609
+ const notes = item.__notes;
1610
+ const date = notes && notes.kind === 'release'
1611
+ ? formatDate(notes.release.publishedAt)
1612
+ : formatDate(item.publishedAt);
1613
+ const head = el('div', { class: 'pue-notes-head' },
1614
+ el('span', { class: 'pue-notes-v' }, item.version),
1615
+ date ? el('span', { class: 'pue-notes-date' }, t('publishedAt', { date })) : null,
1616
+ notes && notes.kind === 'release' && notes.release.url
1617
+ ? el('a', {
1618
+ class: 'pue-notes-link',
1619
+ href: notes.release.url,
1620
+ target: '_blank',
1621
+ rel: 'noreferrer noopener',
1622
+ }, t('viewOnGitHub'))
1623
+ : null);
1624
+
1625
+ if (!notes) {
1626
+ return [head, el('div', { class: 'pue-notes-body' },
1627
+ el('span', { class: 'pue-spin' }), ' ', t('notesLoading'))];
1628
+ }
1629
+ if (notes.kind === 'release') {
1630
+ const body = stripMarkdown(notes.release.body);
1631
+ return [head, el('div', { class: 'pue-notes-body' }, body || t('noNotes'))];
1632
+ }
1633
+ const facts = [
1634
+ [t('factVersion'), item.version],
1635
+ [t('factDate'), formatDate(item.publishedAt)],
1636
+ [t('factTags'), (item.tags || []).join(', ') || '—'],
1637
+ [t('factBundle'), item.bundle ? t('yes') : t('no')],
1638
+ ];
1639
+ if (item.incompatible) {
1640
+ const peers = item.incompatible.requirements && Array.isArray(item.incompatible.requirements)
1641
+ ? item.incompatible.requirements.join(', ')
1642
+ : String(item.incompatible.runtimeVersion || '');
1643
+ facts.push([t('factNeeds'), peers || `${item.incompatible.name} ${item.incompatible.version}`]);
1644
+ }
1645
+ return [
1646
+ head,
1647
+ el('div', { class: 'pue-facts' }, facts.map(([key, value]) => el('div', { class: 'pue-fact' },
1648
+ el('span', { class: 'pue-fact-k' }, key),
1649
+ el('span', { class: 'pue-fact-v' }, String(value || '—'))))),
1650
+ el('div', { class: 'pue-notes-empty' }, t('noNotes')),
1651
+ ];
1652
+ }
1653
+
1654
+ /* ---- release notes data ---- */
1655
+
1656
+ async function loadNotes(entry, item) {
1657
+ if (item.__notes) return item.__notes;
1658
+ const registry = (entry.view.list && entry.view.list.registry) || DEFAULT_REGISTRY;
1659
+ let repo = null;
1660
+ try {
1661
+ repo = await repoFor(entry.name, registry);
1662
+ } catch (error) {
1663
+ warn(`registry(${entry.name})`, error);
1664
+ }
1665
+ if (repo && !blockedRepos.has(repo)) {
1666
+ try {
1667
+ const releases = await releasesFor(repo);
1668
+ const release = matchRelease(releases, item.version);
1669
+ if (release) {
1670
+ item.__notes = { kind: 'release', release };
1671
+ return item.__notes;
1672
+ }
1673
+ } catch (error) {
1674
+ if (error && (error.status === 403 || error.status === 429)) blockedRepos.add(repo);
1675
+ warn(`releases(${repo})`, error);
1676
+ }
1677
+ }
1678
+ item.__notes = { kind: 'facts' };
1679
+ return item.__notes;
1680
+ }
1681
+
1682
+ async function repoFor(name, registry) {
1683
+ const cached = repoCache.get(name);
1684
+ if (cached && Date.now() - cached.at < RELEASES_TTL) return cached.repo;
1685
+ const url = `${String(registry).replace(/\/+$/, '')}/${name.replace('/', '%2F')}/latest`;
1686
+ const meta = await fetchJson(url);
1687
+ const repo = repoFromUrl(meta && meta.repository);
1688
+ repoCache.set(name, { repo, at: Date.now() });
1689
+ return repo;
1690
+ }
1691
+
1692
+ async function releasesFor(repo) {
1693
+ const cached = notesCache.get(repo);
1694
+ if (cached && Date.now() - cached.at < RELEASES_TTL) return cached.map;
1695
+ const stored = readSession(`pue:releases:${repo}`);
1696
+ if (stored && Date.now() - stored.at < RELEASES_TTL) {
1697
+ notesCache.set(repo, { map: stored.map, at: stored.at });
1698
+ return stored.map;
1699
+ }
1700
+ /* Some repositories publish enormous release bodies (the whole page
1701
+ * can pass 800 KB). A full page is asked first; when that answer is
1702
+ * too slow or arrives truncated, the smaller page is asked instead of
1703
+ * giving up on the notes entirely. */
1704
+ let payload;
1705
+ try {
1706
+ payload = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=50`);
1707
+ } catch (error) {
1708
+ payload = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=10`);
1709
+ }
1710
+ const map = {};
1711
+ for (const release of Array.isArray(payload) ? payload : []) {
1712
+ const version = versionOfTag(release.tag_name) || versionOfTag(release.name);
1713
+ if (!version || map[version]) continue;
1714
+ map[version] = {
1715
+ version,
1716
+ tag: release.tag_name || '',
1717
+ name: release.name || '',
1718
+ body: release.body || '',
1719
+ publishedAt: release.published_at || release.created_at || '',
1720
+ url: release.html_url || '',
1721
+ };
1722
+ }
1723
+ notesCache.set(repo, { map, at: Date.now() });
1724
+ writeSession(`pue:releases:${repo}`, { at: Date.now(), map });
1725
+ return map;
1726
+ }
1727
+
1728
+ async function fetchJson(url) {
1729
+ const controller = new AbortController();
1730
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
1731
+ try {
1732
+ const response = await fetch(url, {
1733
+ signal: controller.signal,
1734
+ headers: { accept: 'application/json' },
1735
+ credentials: 'omit',
1736
+ mode: 'cors',
1737
+ cache: 'no-cache',
1738
+ });
1739
+ if (!response.ok) {
1740
+ const error = new Error(`HTTP ${response.status}`);
1741
+ error.status = response.status;
1742
+ throw error;
1743
+ }
1744
+ return await response.json();
1745
+ } finally {
1746
+ clearTimeout(timer);
1747
+ }
1748
+ }
1749
+
1750
+ function readSession(key) {
1751
+ try {
1752
+ const raw = sessionStorage.getItem(key);
1753
+ return raw ? JSON.parse(raw) : null;
1754
+ } catch {
1755
+ return null;
1756
+ }
1757
+ }
1758
+
1759
+ function writeSession(key, value) {
1760
+ try {
1761
+ const text = JSON.stringify(value);
1762
+ if (text.length > 120000) return;
1763
+ sessionStorage.setItem(key, text);
1764
+ } catch {}
1765
+ }
1766
+
1767
+ /* ---- update ---- */
1768
+
1769
+ async function runUpdate(entry) {
1770
+ const view = entry.view;
1771
+ if (!view.selected || !view.list || running || bulk.phase === 'running') return;
1772
+ const from = view.list.installed || '';
1773
+ const to = view.selected;
1774
+ const registry = view.list.registry || undefined;
1775
+ const requestId = makeRequestId();
1776
+ running = { requestId, entry, from, to, registry, current: 'checking' };
1777
+ view.confirm = false;
1778
+ view.phase = 'running';
1779
+ view.current = 'checking';
1780
+ view.run = { from, to, registry };
1781
+ view.outcome = null;
1782
+ view.output = '';
1783
+ render(entry);
1784
+ syncBusyAll();
1785
+ let answer;
1786
+ try {
1787
+ answer = await viaTiers('updateBundle', [entry.name, to, { registry, requestId }], { mutating: true });
1788
+ } catch (error) {
1789
+ answer = { ok: false, error: { message: userMessage(error) } };
1790
+ }
1791
+ const outcome = outcomeOf(answer);
1792
+ view.outcome = outcome;
1793
+ view.output = outputOf(answer);
1794
+ view.phase = 'done';
1795
+ view.current = null;
1796
+ running = null;
1797
+ versionCache.delete(entry.name);
1798
+ render(entry);
1799
+ syncBusyAll();
1800
+ if (outcome.kind === 'done' || outcome.kind === 'restart') {
1801
+ setTimeout(() => {
1802
+ if (!disposed) schedule();
1803
+ }, 600);
1804
+ }
1805
+ }
1806
+
1807
+ /* ---- bulk update: the toolbar's "N plugins can be updated" button ---- */
1808
+
1809
+ /* The official note is React state: it exists only while every check has
1810
+ * finished, so pressing Refresh makes it vanish. The button therefore has
1811
+ * to come and go with the note instead of being mounted once, exactly
1812
+ * like the per-card buttons. Losing the note must never abandon a run
1813
+ * that is already in flight. */
1814
+ function ensureBulkButton() {
1815
+ const toolbar = document.querySelector(SEL.toolbar);
1816
+ const note = toolbar ? toolbar.querySelector(SEL.note) : null;
1817
+ if (!note) {
1818
+ if (bulk.button && bulk.button.isConnected) bulk.button.remove();
1819
+ bulk.button = null;
1820
+ bulk.anchor = toolbar || null;
1821
+ if (!toolbar && bulk.phase !== 'running') closeBulk();
1822
+ else if (bulk.open) positionBulk();
1823
+ return;
1824
+ }
1825
+ if (bulk.button && bulk.button.isConnected) {
1826
+ if (bulk.button.previousElementSibling !== note) note.after(bulk.button);
1827
+ bulk.anchor = note;
1828
+ syncBulkButton();
1829
+ return;
1830
+ }
1831
+ const button = el('button', {
1832
+ class: 'pue-all',
1833
+ type: 'button',
1834
+ 'aria-haspopup': 'dialog',
1835
+ 'aria-expanded': 'false',
1836
+ title: t('allLabel'),
1837
+ onpointerdown: (event) => event.stopPropagation(),
1838
+ onclick: (event) => {
1839
+ event.preventDefault();
1840
+ event.stopPropagation();
1841
+ if (bulk.open) closeBulk({ restoreFocus: true });
1842
+ else openBulk();
1843
+ },
1844
+ }, t('all'));
1845
+ note.after(button);
1846
+ bulk.button = button;
1847
+ bulk.anchor = note;
1848
+ syncBulkButton();
1849
+ }
1850
+
1851
+ function syncBulkButton() {
1852
+ if (!bulk.button) return;
1853
+ bulk.button.disabled = bulk.phase === 'running';
1854
+ bulk.button.setAttribute('aria-expanded', bulk.open ? 'true' : 'false');
1855
+ }
1856
+
1857
+ function openBulk() {
1858
+ if (bulk.open) return;
1859
+ if (active) closePopover(active);
1860
+ bulk.open = true;
1861
+ ensureBulkPanel();
1862
+ syncBulkButton();
1863
+ if (!bulk.items.length && bulk.phase !== 'running' && bulk.phase !== 'done') loadBulk();
1864
+ else renderBulk();
1865
+ }
1866
+
1867
+ function closeBulk(options) {
1868
+ const wasOpen = bulk.open;
1869
+ bulk.open = false;
1870
+ hideBulkNotes();
1871
+ if (bulk.panel) {
1872
+ bulk.panel.remove();
1873
+ bulk.panel = null;
1874
+ bulk.body = null;
1875
+ bulk.foot = null;
1876
+ bulk.host.popover = null;
1877
+ }
1878
+ if (bulk.phase === 'done') {
1879
+ bulk.phase = 'idle';
1880
+ bulk.items = [];
1881
+ bulk.index = 0;
1882
+ bulk.total = 0;
1883
+ bulk.currentName = '';
1884
+ }
1885
+ syncBulkButton();
1886
+ if (wasOpen && options && options.restoreFocus && bulk.button) {
1887
+ try {
1888
+ bulk.button.focus();
1889
+ } catch {}
1890
+ }
1891
+ }
1892
+
1893
+ function ensureBulkPanel() {
1894
+ if (bulk.panel) return bulk.panel;
1895
+ const head = el('div', { class: 'pue-panel-head' },
1896
+ el('span', { class: 'pue-panel-title' }, t('allTitle')),
1897
+ el('button', {
1898
+ class: 'pue-x',
1899
+ type: 'button',
1900
+ 'aria-label': t('close'),
1901
+ onclick: (event) => {
1902
+ event.stopPropagation();
1903
+ closeBulk({ restoreFocus: true });
1904
+ },
1905
+ }, '×'));
1906
+ const body = el('div', { class: 'pue-panel-body' });
1907
+ const foot = el('div', { class: 'pue-bulk-foot' });
1908
+ const panel = el('div', {
1909
+ class: 'pue-panel',
1910
+ role: 'dialog',
1911
+ 'aria-modal': 'false',
1912
+ 'aria-label': t('allTitle'),
1913
+ }, head, body, foot);
1914
+ panel.addEventListener('pointerdown', (event) => event.stopPropagation());
1915
+ panel.addEventListener('click', (event) => event.stopPropagation());
1916
+ panel.addEventListener('keydown', onPanelKeydown);
1917
+ document.body.append(panel);
1918
+ bulk.panel = panel;
1919
+ bulk.body = body;
1920
+ bulk.foot = foot;
1921
+ bulk.host.popover = panel;
1922
+ return panel;
1923
+ }
1924
+
1925
+ /* Same source as the official page (`pluginManager/listBundles`); reading
1926
+ * the DOM would only see the cards that happen to be rendered, and the
1927
+ * note counts packages that may be far below the fold. */
1928
+ function unwrapAnswer(answer) {
1929
+ if (answer && answer.ok === false) {
1930
+ const error = answer.error || {};
1931
+ throw new Error(error.message || error.code || t('unknown'));
1932
+ }
1933
+ if (answer && Object.prototype.hasOwnProperty.call(answer, 'value')) return answer.value;
1934
+ return answer;
1935
+ }
1936
+
1937
+ async function loadBulk() {
1938
+ bulk.phase = 'loading';
1939
+ bulk.error = '';
1940
+ bulk.items = [];
1941
+ bulk.expanded = null;
1942
+ renderBulk();
1943
+ try {
1944
+ const bundles = unwrapAnswer(await viaTiers('listBundles', []));
1945
+ bulk.items = updatableBundles(bundles).map((bundle) => ({
1946
+ name: bundle.name,
1947
+ from: bundle.from,
1948
+ to: '',
1949
+ list: null,
1950
+ phase: 'idle',
1951
+ state: 'idle',
1952
+ error: '',
1953
+ outcome: null,
1954
+ }));
1955
+ bulk.phase = bulk.items.length ? 'listed' : 'empty';
1956
+ for (const item of bulk.items) primeBulkVersions(item);
1957
+ } catch (error) {
1958
+ bulk.phase = 'error';
1959
+ bulk.error = userMessage(error);
1960
+ warn('listBundles', error);
1961
+ }
1962
+ renderBulk();
1963
+ syncBulkButton();
1964
+ }
1965
+
1966
+ /* Versions load in the background so the list is usable immediately and
1967
+ * every row already knows its default target by the time a run starts. */
1968
+ function primeBulkVersions(item, force) {
1969
+ const cached = force ? null : fresh(versionCache.get(item.name));
1970
+ /* An empty list may just mean the registry call failed on the Host.
1971
+ * Trusting it would hide a real update for the whole TTL. */
1972
+ if (cached && ((cached.versions && cached.versions.length) || 0) > 0) {
1973
+ applyBulkList(item, cached);
1974
+ if (bulk.open) renderBulk();
1975
+ return Promise.resolve(item);
1976
+ }
1977
+ item.phase = 'loading';
1978
+ item.error = '';
1979
+ return listVersions(item.name, versionPending, force).then((list) => {
1980
+ if (disposed) return item;
1981
+ if (list && list.status === 'listed' && (list.versions || []).length > 0) {
1982
+ versionCache.set(item.name, { list, at: Date.now() });
1983
+ }
1984
+ applyBulkList(item, list);
1985
+ if (bulk.open) renderBulk();
1986
+ return item;
1987
+ }).catch((error) => {
1988
+ if (disposed) return item;
1989
+ item.phase = 'error';
1990
+ item.error = userMessage(error);
1991
+ warn(`listVersions(${item.name})`, error);
1992
+ if (bulk.open) renderBulk();
1993
+ return item;
1994
+ });
1995
+ }
1996
+
1997
+ function applyBulkList(item, list) {
1998
+ item.list = list || null;
1999
+ if (!list || list.status !== 'listed') {
2000
+ item.phase = list && list.status === 'refused' ? 'refused' : 'error';
2001
+ item.to = '';
2002
+ return;
2003
+ }
2004
+ item.phase = 'listed';
2005
+ const shown = filterPrerelease(list.versions, bulk.showPre);
2006
+ /* Same rule the official page applies: the default target is the
2007
+ * newest version that is a bundle, installable here, and not
2008
+ * deprecated. Everything else stays listed but unselected. */
2009
+ const pick = shown.find((candidate) => !installBlocker(candidate) && !candidate.deprecated);
2010
+ item.to = pick ? pick.version : '';
2011
+ }
2012
+
2013
+ function renderBulk() {
2014
+ if (!bulk.panel || !bulk.body || !bulk.foot) return;
2015
+ /* A version check finishing in the background rebuilds this body and
2016
+ * therefore replaces the row the notes bubble is pinned to. Remember
2017
+ * which version it described, then re-attach it to the equivalent row
2018
+ * so hovering does not blink out mid-read. */
2019
+ const pinned = bulk.host.bubbleKey;
2020
+ bulk.body.replaceChildren(...bulkBodyNodes());
2021
+ bulk.foot.replaceChildren(...bulkFootNodes());
2022
+ const row = pinned ? rowFor(pinned) : null;
2023
+ if (row && bulk.host.bubble) {
2024
+ bulk.host.bubbleRow = row;
2025
+ row.setAttribute('aria-describedby', bulk.host.bubble.id);
2026
+ positionFixedNotes(bulk.host.bubble, row);
2027
+ } else {
2028
+ hideBulkNotes();
2029
+ }
2030
+ positionBulk();
2031
+ }
2032
+
2033
+ function rowFor(key) {
2034
+ if (!bulk.body || !key) return null;
2035
+ const item = [...bulk.body.querySelectorAll('.pue-bulk-item')]
2036
+ .find((node) => node.getAttribute('data-name') === key.name);
2037
+ if (!item) return null;
2038
+ return [...item.querySelectorAll('.pue-row')]
2039
+ .find((node) => node.getAttribute('data-version') === key.version) || null;
2040
+ }
2041
+
2042
+ function bulkBodyNodes() {
2043
+ if (bulk.phase === 'loading') {
2044
+ return [el('div', { class: 'pue-loading' }, el('span', { class: 'pue-spin' }), t('allChecking'))];
2045
+ }
2046
+ if (bulk.phase === 'error') {
2047
+ return [el('div', { class: 'pue-panel-empty' },
2048
+ el('div', {}, bulk.error || t('network')),
2049
+ el('div', {}, el('button', {
2050
+ class: 'pue-ghost',
2051
+ type: 'button',
2052
+ onclick: () => loadBulk(),
2053
+ }, t('allRetry'))))];
2054
+ }
2055
+ if (bulk.phase === 'empty') return [el('div', { class: 'pue-panel-empty' }, t('allEmpty'))];
2056
+ if (bulk.phase === 'done') return [bulkDoneNodes()];
2057
+ const rows = bulkRows(bulk.items);
2058
+ if (rows.length === 0) return [el('div', { class: 'pue-panel-empty' }, t('allEmpty'))];
2059
+ return [el('ul', { class: 'pue-bulk', role: 'list' }, rows.map((item) => bulkItemNode(item)))];
2060
+ }
2061
+
2062
+ function bulkItemNode(item) {
2063
+ const open = bulk.expanded === item.name;
2064
+ const busy = bulk.phase === 'running';
2065
+ const moved = item.to && item.to !== item.from;
2066
+ const pick = el('button', {
2067
+ class: 'pue-bulk-pick',
2068
+ type: 'button',
2069
+ 'aria-expanded': open ? 'true' : 'false',
2070
+ 'aria-label': t('allPick'),
2071
+ 'data-state': item.phase === 'listed' ? 'ready'
2072
+ : (item.phase === 'error' || item.phase === 'refused') ? 'error' : 'loading',
2073
+ disabled: busy || item.state === 'running',
2074
+ onclick: (event) => {
2075
+ event.stopPropagation();
2076
+ toggleBulkItem(item);
2077
+ },
2078
+ }, item.phase === 'listed' ? (item.to || t('allPick'))
2079
+ : (item.phase === 'error' || item.phase === 'refused') ? t('retry') : t('checking'));
2080
+ const head = el('div', {
2081
+ class: 'pue-bulk-head',
2082
+ role: 'button',
2083
+ tabindex: busy ? undefined : '0',
2084
+ onclick: () => {
2085
+ if (!busy) toggleBulkItem(item);
2086
+ },
2087
+ onkeydown: (event) => {
2088
+ if (event.target !== head) return;
2089
+ if (event.key !== 'Enter' && event.key !== ' ') return;
2090
+ event.preventDefault();
2091
+ if (!busy) toggleBulkItem(item);
2092
+ },
2093
+ },
2094
+ bulkStateNode(item),
2095
+ el('span', { class: 'pue-bulk-name', title: item.name }, item.name),
2096
+ el('span', { class: 'pue-bulk-from' }, item.from || '—'),
2097
+ moved ? el('span', { class: 'pue-bulk-arrow' }, '→') : null,
2098
+ pick);
2099
+ const node = el('div', {
2100
+ class: 'pue-bulk-item',
2101
+ 'data-name': item.name,
2102
+ 'data-open': open ? 'true' : 'false',
2103
+ 'data-state': item.state,
2104
+ }, head);
2105
+ if (item.error) node.append(el('div', { class: 'pue-bulk-note', 'data-tone': 'error' }, item.error));
2106
+ else if (item.phase === 'listed' && !item.to && item.state === 'idle') {
2107
+ /* Listed but nothing installable here: say it once, up front, instead
2108
+ * of letting the run silently skip the package later. */
2109
+ const versions = (item.list && item.list.versions) || [];
2110
+ node.append(el('div', { class: 'pue-bulk-note' },
2111
+ versions.length ? t('noneInstallable') : t('latest')));
2112
+ }
2113
+ if (open) node.append(bulkVersionsNode(item));
2114
+ return el('li', { role: 'none' }, node);
2115
+ }
2116
+
2117
+ /* Mirrors the card: opening a surface re-reads its versions when the
2118
+ * cache is stale. Without this an item whose very first check failed
2119
+ * stays dead — the panel re-renders the same message on every click and
2120
+ * no request is ever made again. */
2121
+ function toggleBulkItem(item) {
2122
+ const opening = bulk.expanded !== item.name;
2123
+ bulk.expanded = opening ? item.name : null;
2124
+ hideBulkNotes();
2125
+ if (opening && bulk.phase !== 'running' && item.state !== 'running') primeBulkVersions(item);
2126
+ renderBulk();
2127
+ }
2128
+
2129
+ function bulkStateNode(item) {
2130
+ if (item.state === 'done') return el('span', { class: 'pue-bulk-state', 'data-tone': 'done' }, '✓');
2131
+ if (item.state === 'failed') return el('span', { class: 'pue-bulk-state', 'data-tone': 'failed' }, '×');
2132
+ if (item.state === 'running') return el('span', { class: 'pue-bulk-state' }, el('span', { class: 'pue-spin' }));
2133
+ if (item.state === 'skipped') return el('span', { class: 'pue-bulk-state' }, '–');
2134
+ return el('span', { class: 'pue-bulk-state' });
2135
+ }
2136
+
2137
+ /* Reuses the card's version row so both surfaces show the same tags,
2138
+ * dates, blockers and release-note tooltips. */
2139
+ function bulkVersionsNode(item) {
2140
+ const box = el('div', { class: 'pue-bulk-versions' });
2141
+ if (item.phase === 'idle' || item.phase === 'loading') {
2142
+ box.append(el('div', { class: 'pue-loading' }, el('span', { class: 'pue-spin' }), t('checking')));
2143
+ return box;
2144
+ }
2145
+ if (item.phase === 'refused' || item.phase === 'error') {
2146
+ box.append(el('div', { class: 'pue-loading' },
2147
+ item.error || (item.list && item.list.reason) || t('unknown')));
2148
+ /* The card offers the same escape hatch. A failed version check is
2149
+ * recoverable; leaving it with no button strands the row forever. */
2150
+ box.append(el('div', { class: 'pue-actions' },
2151
+ el('span', { class: 'pue-spacer' }),
2152
+ el('button', {
2153
+ class: 'pue-ghost',
2154
+ type: 'button',
2155
+ onclick: (event) => {
2156
+ event.stopPropagation();
2157
+ versionCache.delete(item.name);
2158
+ primeBulkVersions(item, true);
2159
+ },
2160
+ }, t('retry'))));
2161
+ return box;
2162
+ }
2163
+ const list = item.list || { versions: [] };
2164
+ const shown = filterPrerelease(list.versions, bulk.showPre);
2165
+ if ((list.versions || []).some((candidate) => candidate && candidate.prerelease)) {
2166
+ box.append(el('button', {
2167
+ class: 'pue-ghost',
2168
+ type: 'button',
2169
+ onclick: (event) => {
2170
+ event.stopPropagation();
2171
+ bulk.showPre = !bulk.showPre;
2172
+ for (const other of bulk.items) {
2173
+ if (other.phase === 'listed') applyBulkList(other, other.list);
2174
+ }
2175
+ hideBulkNotes();
2176
+ renderBulk();
2177
+ },
2178
+ }, bulk.showPre ? t('hidePrerelease') : t('showPrerelease')));
2179
+ }
2180
+ if (!shown.length) {
2181
+ box.append(el('div', { class: 'pue-loading' },
2182
+ list.versions && list.versions.length ? t('noneInstallable') : t('latest')));
2183
+ return box;
2184
+ }
2185
+ const listbox = el('ul', { class: 'pue-list', role: 'listbox', 'aria-label': t('versions') });
2186
+ for (const candidate of shown) {
2187
+ listbox.append(el('li', { class: 'pue-li', role: 'none' }, versionRow({
2188
+ item,
2189
+ selected: item.to,
2190
+ onPick: (version) => {
2191
+ item.to = version;
2192
+ hideBulkNotes();
2193
+ renderBulk();
2194
+ },
2195
+ }, candidate)));
2196
+ }
2197
+ box.append(listbox);
2198
+ return box;
2199
+ }
2200
+
2201
+ function bulkDoneNodes() {
2202
+ return el('div', { class: 'pue-bulk-done' }, bulkRows(bulk.items).filter((item) => item.state !== 'idle').map((item) => {
2203
+ const tone = item.state === 'failed' ? 'failed' : item.state === 'done' ? 'done' : '';
2204
+ const detail = item.state === 'done'
2205
+ ? (item.to || '')
2206
+ : item.state === 'skipped'
2207
+ ? t('allSkipped')
2208
+ : (item.error || t('allFailed'));
2209
+ return el('div', { class: 'pue-bulk-line', 'data-tone': tone },
2210
+ el('span', { class: 'pue-bulk-name', title: item.name }, item.name),
2211
+ el('span', {}, detail));
2212
+ }));
2213
+ }
2214
+
2215
+ function bulkFootNodes() {
2216
+ if (bulk.phase === 'running') {
2217
+ return [
2218
+ el('div', { class: 'pue-bulk-note' },
2219
+ t('allProgress', { index: bulk.index, total: bulk.total, name: bulk.currentName || '—' })),
2220
+ el('div', { class: 'pue-actions' },
2221
+ el('span', { class: 'pue-spacer' }),
2222
+ el('button', {
2223
+ class: 'pue-ghost',
2224
+ type: 'button',
2225
+ onclick: () => {
2226
+ /* A request already sent cannot be recalled; the flag only
2227
+ * stops the next package, which is the safe thing to promise. */
2228
+ bulk.cancelled = true;
2229
+ renderBulk();
2230
+ },
2231
+ }, t('allStop'))),
2232
+ ];
2233
+ }
2234
+ if (bulk.phase === 'done') {
2235
+ const summary = bulkSummary(bulkRows(bulk.items));
2236
+ const detail = t('allDoneDetail', summary)
2237
+ + (summary.skipped ? ` · ${t('allSkippedDetail', { count: summary.skipped })}` : '');
2238
+ return [
2239
+ el('div', { class: 'pue-bulk-note' },
2240
+ summary.failed ? `${t('allPartial')} · ${detail}` : detail),
2241
+ el('div', { class: 'pue-actions' },
2242
+ el('span', { class: 'pue-spacer' }),
2243
+ el('button', {
2244
+ class: 'pue-primary',
2245
+ type: 'button',
2246
+ onclick: () => closeBulk({ restoreFocus: true }),
2247
+ }, t('finish'))),
2248
+ ];
2249
+ }
2250
+ if (bulk.phase !== 'listed') return [];
2251
+ const targets = bulkTargets(bulk.items);
2252
+ const ready = targets.filter((item) => item.to).length;
2253
+ /* Rows whose version read never landed are re-read by the run itself,
2254
+ * so a batch with no target yet is still runnable. A disabled button
2255
+ * reading "update 0" is exactly how a real update looks undetected. */
2256
+ const stuck = targets.length - ready;
2257
+ const label = t('allRun', { count: targets.length });
2258
+ return [
2259
+ el('div', { class: 'pue-bulk-note' },
2260
+ stuck && ready ? `${t('allHint')} · ${t('allStuck', { count: stuck })}` : t('allHint')),
2261
+ el('div', { class: 'pue-actions' },
2262
+ el('span', { class: 'pue-spacer' }),
2263
+ el('button', {
2264
+ class: 'pue-primary',
2265
+ type: 'button',
2266
+ disabled: targets.length === 0,
2267
+ onclick: () => runBulk(),
2268
+ }, label)),
2269
+ ];
2270
+ }
2271
+
2272
+ /* One request at a time, strictly sequential: `updateBundle` rewrites the
2273
+ * same profile, so concurrent installs would fight over it. The card-level
2274
+ * `running` slot stays untouched — a bulk run drives this panel's own
2275
+ * progress line instead of opening a card popover. */
2276
+ async function runBulk() {
2277
+ if (bulk.phase === 'running') return;
2278
+ const rows = bulkTargets(bulk.items);
2279
+ if (rows.length === 0) return;
2280
+ bulk.phase = 'running';
2281
+ bulk.cancelled = false;
2282
+ bulk.currentName = '';
2283
+ bulk.index = 0;
2284
+ bulk.total = 0;
2285
+ bulk.expanded = null;
2286
+ hideBulkNotes();
2287
+ renderBulk();
2288
+ syncBulkButton();
2289
+ /* A row whose version read failed or was still in flight is not the
2290
+ * same thing as "nothing to install". Skipping it silently is what
2291
+ * makes a real update look undetected, so re-read it first. */
2292
+ for (const item of rows) {
2293
+ if (bulk.cancelled || item.to || item.phase === 'listed') continue;
2294
+ bulk.currentName = item.name;
2295
+ renderBulk();
2296
+ await primeBulkVersions(item, true);
2297
+ if (disposed) return;
2298
+ }
2299
+ bulk.total = rows.filter((item) => item.to).length;
2300
+ bulk.currentName = '';
2301
+ renderBulk();
2302
+ for (const item of rows) {
2303
+ if (item.state !== 'idle') continue;
2304
+ if (bulk.cancelled) {
2305
+ item.state = 'skipped';
2306
+ renderBulk();
2307
+ continue;
2308
+ }
2309
+ if (!item.to) {
2310
+ item.state = 'skipped';
2311
+ item.error = item.phase === 'listed' ? t('noneInstallable') : t('unknown');
2312
+ renderBulk();
2313
+ continue;
2314
+ }
2315
+ bulk.index += 1;
2316
+ bulk.currentName = item.name;
2317
+ item.state = 'running';
2318
+ item.error = '';
2319
+ renderBulk();
2320
+ const registry = (item.list && item.list.registry) || undefined;
2321
+ const requestId = makeRequestId();
2322
+ let answer;
2323
+ try {
2324
+ answer = await viaTiers('updateBundle', [item.name, item.to, { registry, requestId }], { mutating: true });
2325
+ } catch (error) {
2326
+ answer = { ok: false, error: { message: userMessage(error) } };
2327
+ }
2328
+ if (disposed) return;
2329
+ const outcome = outcomeOf(answer);
2330
+ item.outcome = outcome;
2331
+ const ok = outcome.kind === 'done' || outcome.kind === 'restart';
2332
+ item.state = ok ? 'done' : 'failed';
2333
+ item.error = ok ? '' : bulkOutcomeText(outcome);
2334
+ versionCache.delete(item.name);
2335
+ renderBulk();
2336
+ if (ok) schedule();
2337
+ }
2338
+ for (const item of bulkRows(bulk.items)) {
2339
+ if (item.state !== 'idle') continue;
2340
+ item.state = 'skipped';
2341
+ item.error = item.phase === 'listed' ? t('noneInstallable') : t('unknown');
2342
+ }
2343
+ bulk.phase = 'done';
2344
+ bulk.currentName = '';
2345
+ renderBulk();
2346
+ syncBulkButton();
2347
+ schedule();
2348
+ }
2349
+
2350
+ /* Every outcome the official page can report, in one place. */
2351
+ function bulkOutcomeText(outcome) {
2352
+ if (!outcome) return t('unknown');
2353
+ if (outcome.kind === 'done') return t('done', { version: outcome.to });
2354
+ if (outcome.kind === 'restart') return t('restart', { version: outcome.to });
2355
+ if (outcome.kind === 'refused') return t('refused', { version: outcome.to });
2356
+ if (outcome.kind === 'removeFailed') return t('removeFailed', { version: outcome.from });
2357
+ if (outcome.kind === 'restored') return t('restored', { version: outcome.from });
2358
+ if (outcome.kind === 'broken') return t('broken');
2359
+ return outcome.reason ? `${t('lost')} · ${outcome.reason}` : t('lost');
2360
+ }
2361
+
2362
+ function onPanelKeydown(event) {
2363
+ if (event.key === 'Escape') {
2364
+ event.stopPropagation();
2365
+ if (bulk.host.bubble) hideBulkNotes();
2366
+ else if (bulk.phase === 'running') {
2367
+ bulk.cancelled = true;
2368
+ renderBulk();
2369
+ } else closeBulk({ restoreFocus: true });
2370
+ return;
2371
+ }
2372
+ if (event.key !== 'Tab') return;
2373
+ const focusable = [...bulk.panel.querySelectorAll('button:not([disabled]),[tabindex]:not([tabindex="-1"])')];
2374
+ if (!focusable.length) return;
2375
+ const first = focusable[0];
2376
+ const last = focusable[focusable.length - 1];
2377
+ if (!event.shiftKey && document.activeElement === last) {
2378
+ event.preventDefault();
2379
+ first.focus();
2380
+ } else if (event.shiftKey && document.activeElement === first) {
2381
+ event.preventDefault();
2382
+ last.focus();
2383
+ }
2384
+ }
2385
+
2386
+ function positionBulk() {
2387
+ const panel = bulk.panel;
2388
+ if (!panel) return;
2389
+ const anchor = bulk.button && bulk.button.isConnected ? bulk.button : bulk.anchor;
2390
+ const rect = anchor && anchor.isConnected
2391
+ ? anchor.getBoundingClientRect()
2392
+ : { left: window.innerWidth - 24, right: window.innerWidth - 24, top: 48, bottom: 48 };
2393
+ panel.removeAttribute('data-flip');
2394
+ const width = panel.offsetWidth;
2395
+ const height = panel.offsetHeight;
2396
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
2397
+ let top = rect.bottom + 6;
2398
+ if (top + height > window.innerHeight - 8 && rect.top - 6 - height >= 8) {
2399
+ top = rect.top - 6 - height;
2400
+ panel.setAttribute('data-flip', 'up');
2401
+ }
2402
+ panel.style.left = `${left}px`;
2403
+ panel.style.top = `${Math.max(8, top)}px`;
2404
+ }
2405
+
2406
+ /* The panel scrolls, so its tooltip is placed in viewport space
2407
+ * (`data-fixed`) instead of at `row.offsetTop` inside a clipped box. */
2408
+ function positionFixedNotes(bubble, row) {
2409
+ if (!bubble || !row) return;
2410
+ const rect = row.getBoundingClientRect();
2411
+ const width = bubble.offsetWidth || 320;
2412
+ const height = bubble.offsetHeight || 200;
2413
+ const left = rect.left - width - 8;
2414
+ const right = rect.right + 8;
2415
+ const x = left >= 8 ? left : Math.min(right, window.innerWidth - width - 8);
2416
+ const y = Math.max(8, Math.min(rect.top, window.innerHeight - 8 - height));
2417
+ bubble.style.left = `${Math.max(8, x)}px`;
2418
+ bubble.style.top = `${y}px`;
2419
+ }
2420
+
2421
+ function hideBulkNotes() {
2422
+ clearTimeout(bulk.hover.open);
2423
+ clearTimeout(bulk.hover.close);
2424
+ bulk.host.bubbleKey = null;
2425
+ if (bulk.host.bubble) {
2426
+ bulk.host.bubble.remove();
2427
+ bulk.host.bubble = null;
2428
+ bulk.host.bubbleRow = null;
2429
+ }
2430
+ }
2431
+
2432
+ function slugOf(name) {
2433
+ return String(name).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'pkg';
2434
+ }
2435
+
2436
+ /* The card's row renderer, with the panel supplying the target and the
2437
+ * tooltip host: rows here must not rerender the card's own popover. */
2438
+ function versionRow(spec, candidate) {
2439
+ const blocker = installBlocker(candidate);
2440
+ const selected = spec.selected === candidate.version;
2441
+ const tags = el('span', { class: 'pue-row-main' },
2442
+ el('span', { class: 'pue-v' }, candidate.version),
2443
+ candidate.prerelease ? el('span', { class: 'pue-tag' }, t('prerelease')) : null,
2444
+ candidate.deprecated ? el('span', { class: 'pue-tag', 'data-tone': 'warn' }, t('deprecated')) : null,
2445
+ candidate.incompatible ? el('span', { class: 'pue-tag', 'data-tone': 'error' }, t('blocked')) : null,
2446
+ candidate.publishedAt ? el('span', { class: 'pue-date' }, formatDate(candidate.publishedAt)) : null,
2447
+ el('span', { class: 'pue-tick', 'aria-hidden': 'true' }, '✓'));
2448
+ const children = [tags];
2449
+ if (blocker) {
2450
+ children.push(el('span', { class: 'pue-why' },
2451
+ blocker === 'notBundle' ? t('notBundle') : t('blocked')));
2452
+ }
2453
+ const row = el('button', {
2454
+ class: 'pue-row',
2455
+ type: 'button',
2456
+ role: 'option',
2457
+ 'aria-selected': selected ? 'true' : 'false',
2458
+ 'aria-disabled': blocker ? 'true' : 'false',
2459
+ 'data-version': candidate.version,
2460
+ onclick: (event) => {
2461
+ event.stopPropagation();
2462
+ if (blocker) return;
2463
+ spec.onPick(candidate.version);
2464
+ },
2465
+ }, children);
2466
+ wireBulkNotes(spec.item, candidate, row);
2467
+ return row;
2468
+ }
2469
+
2470
+ function wireBulkNotes(item, candidate, row) {
2471
+ row.addEventListener('pointerenter', () => {
2472
+ clearTimeout(bulk.hover.close);
2473
+ clearTimeout(bulk.hover.open);
2474
+ bulk.hover.open = setTimeout(() => drawBulkNotes(item, candidate, row), HOVER_OPEN_MS);
2475
+ });
2476
+ row.addEventListener('pointerleave', () => {
2477
+ clearTimeout(bulk.hover.open);
2478
+ scheduleHideBulkNotes();
2479
+ });
2480
+ row.addEventListener('focus', () => {
2481
+ clearTimeout(bulk.hover.close);
2482
+ clearTimeout(bulk.hover.open);
2483
+ drawBulkNotes(item, candidate, row);
2484
+ });
2485
+ row.addEventListener('blur', () => scheduleHideBulkNotes());
2486
+ }
2487
+
2488
+ function scheduleHideBulkNotes() {
2489
+ clearTimeout(bulk.hover.close);
2490
+ bulk.hover.close = setTimeout(() => hideBulkNotes(), HOVER_CLOSE_MS);
2491
+ }
2492
+
2493
+ function drawBulkNotes(item, candidate, row) {
2494
+ if (!bulk.panel || !row.isConnected) return;
2495
+ let bubble = bulk.host.bubble;
2496
+ if (!bubble) {
2497
+ bubble = el('div', { class: 'pue-notes', role: 'tooltip', 'data-fixed': 'true' });
2498
+ bubble.addEventListener('pointerenter', () => clearTimeout(bulk.hover.close));
2499
+ bubble.addEventListener('pointerleave', () => scheduleHideBulkNotes());
2500
+ bulk.panel.append(bubble);
2501
+ bulk.host.bubble = bubble;
2502
+ }
2503
+ bulk.host.bubbleRow = row;
2504
+ bulk.host.bubbleKey = { name: item.name, version: candidate.version };
2505
+ bulk.host.name = item.name;
2506
+ bulk.host.view.list = item.list;
2507
+ if (!bubble.id) bubble.id = `pue-notes-${slugOf(item.name)}`;
2508
+ row.setAttribute('aria-describedby', bubble.id);
2509
+ bubble.replaceChildren(...notesNodes(bulk.host, candidate));
2510
+ markScrollable(bubble);
2511
+ positionFixedNotes(bubble, row);
2512
+ if (!candidate.__notes) {
2513
+ loadNotes(bulk.host, candidate).then(() => {
2514
+ /* The pointer may have moved on (or the row been rebuilt) while
2515
+ * the release notes were fetched; only repaint if this bubble is
2516
+ * still describing the very same version. */
2517
+ const key = bulk.host.bubbleKey;
2518
+ if (bulk.host.bubble !== bubble || !key || key.version !== candidate.version) return;
2519
+ const live = bulk.host.bubbleRow;
2520
+ if (!live || !live.isConnected) return;
2521
+ bubble.replaceChildren(...notesNodes(bulk.host, candidate));
2522
+ markScrollable(bubble);
2523
+ positionFixedNotes(bubble, live);
2524
+ });
2525
+ }
2526
+ }
2527
+
2528
+ /* ---- keyboard & pointer plumbing ---- */
2529
+
2530
+ function onListKeydown(entry, event) {
2531
+ const rows = [...entry.body.querySelectorAll('.pue-row:not([aria-disabled="true"])')];
2532
+ if (!rows.length) return;
2533
+ const index = rows.indexOf(document.activeElement);
2534
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2535
+ event.preventDefault();
2536
+ const step = event.key === 'ArrowDown' ? 1 : -1;
2537
+ const next = rows[(index + step + rows.length) % rows.length];
2538
+ next.focus();
2539
+ } else if (event.key === 'Home' || event.key === 'End') {
2540
+ event.preventDefault();
2541
+ rows[event.key === 'Home' ? 0 : rows.length - 1].focus();
2542
+ }
2543
+ }
2544
+
2545
+ function onPopoverKeydown(entry, event) {
2546
+ if (event.key === 'Escape') {
2547
+ event.stopPropagation();
2548
+ if (entry.bubble) hideNotes(entry);
2549
+ else closePopover(entry, { restoreFocus: true });
2550
+ return;
2551
+ }
2552
+ if (event.key !== 'Tab') return;
2553
+ const focusable = [...entry.popover.querySelectorAll('button:not([disabled]),a[href],[tabindex]:not([tabindex="-1"])')];
2554
+ if (!focusable.length) return;
2555
+ const first = focusable[0];
2556
+ const last = focusable[focusable.length - 1];
2557
+ if (!event.shiftKey && document.activeElement === last) {
2558
+ event.preventDefault();
2559
+ first.focus();
2560
+ } else if (event.shiftKey && document.activeElement === first) {
2561
+ event.preventDefault();
2562
+ last.focus();
2563
+ }
2564
+ }
2565
+
2566
+ function onDocumentPointerDown(event) {
2567
+ const target = event.target;
2568
+ if (!(target instanceof Node)) return;
2569
+ if (bulk.open) {
2570
+ if (bulk.panel && bulk.panel.contains(target)) return;
2571
+ if (bulk.button && bulk.button.contains(target)) return;
2572
+ if (bulk.host.bubble && bulk.host.bubble.contains(target)) return;
2573
+ /* A run keeps its panel: closing it would hide the only progress
2574
+ * line the user has. */
2575
+ if (bulk.phase !== 'running') {
2576
+ closeBulk();
2577
+ if (!active) return;
2578
+ }
2579
+ }
2580
+ if (!active) return;
2581
+ if (active.slot.contains(target)) return;
2582
+ if (active.popover && active.popover.contains(target)) return;
2583
+ closePopover(active);
2584
+ }
2585
+
2586
+ function onDocumentKeydown(event) {
2587
+ if (event.key !== 'Escape') return;
2588
+ if (bulk.open) {
2589
+ if (bulk.host.bubble) hideBulkNotes();
2590
+ else if (bulk.phase === 'running') {
2591
+ bulk.cancelled = true;
2592
+ renderBulk();
2593
+ } else closeBulk({ restoreFocus: true });
2594
+ return;
2595
+ }
2596
+ if (!active) return;
2597
+ if (active.bubble) {
2598
+ hideNotes(active);
2599
+ return;
2600
+ }
2601
+ closePopover(active, { restoreFocus: true });
2602
+ }
2603
+
2604
+ function onViewportChange() {
2605
+ if (bulk.open) {
2606
+ positionBulk();
2607
+ if (bulk.host.bubbleRow) positionFixedNotes(bulk.host.bubble, bulk.host.bubbleRow);
2608
+ }
2609
+ if (!active) return;
2610
+ position(active);
2611
+ if (active.bubbleRow) positionNotes(active, active.bubbleRow);
2612
+ }
2613
+
2614
+ function position(entry) {
2615
+ const popover = entry.popover;
2616
+ if (!popover) return;
2617
+ popover.removeAttribute('data-flip');
2618
+ const host = scope();
2619
+ const bound = host ? host.getBoundingClientRect().bottom : window.innerHeight;
2620
+ const anchor = entry.slot.getBoundingClientRect();
2621
+ const height = popover.offsetHeight;
2622
+ const fitsBelow = anchor.bottom + 6 + height <= bound - 8;
2623
+ if (!fitsBelow && anchor.top - 6 - height >= 8) popover.setAttribute('data-flip', 'up');
2624
+ }
2625
+
2626
+ function warn(...args) {
2627
+ try {
2628
+ console.warn(`[${NS}]`, ...args);
2629
+ } catch {}
2630
+ }
2631
+ }
2632
+
2633
+ return {
2634
+ /* Only `locale` is required. Declaring `remote` + `remote.pluginManager`
2635
+ * would also work (the gateway proxy needs both names), but a required
2636
+ * service that never mounts parks this whole module — and then the card
2637
+ * gets no button at all, which is the worse failure. `ctx.get()` reads the
2638
+ * same services without that coupling. */
2639
+ name: NS,
2640
+ inject: ['locale'],
2641
+ apply,
2642
+ };
2643
+ },
2644
+ };
2645
+
2646
+ /* Never let a duplicate registration abort the whole batch.
2647
+ *
2648
+ * The Host composes the boot graph into ONE classic script per batch. If two
2649
+ * graph rows ever name this same bundle (observed after a package rename: the
2650
+ * old row lives on in the Host's in-memory table until a restart, so both
2651
+ * `@local/old-name` and `dsh-plugin-update-entry` point at these bytes), the
2652
+ * batch embeds the script twice. The second execution calls
2653
+ * `__ModuleLoader__.load` for an id the first execution already registered, and
2654
+ * the loader throws `duplicate factory registration for "<id>"`. That throw
2655
+ * escapes a classic script and stops the batch, so every package scheduled
2656
+ * after us silently fails to register as well — one stale row takes down other
2657
+ * people's plugins.
2658
+ *
2659
+ * A duplicate registration carries no new information (same id, same factory,
2660
+ * same bytes), so swallowing exactly that error keeps the rest of the batch
2661
+ * loadable. Every other failure still propagates: a real load error must stay
2662
+ * loud. The match is narrow on purpose — the loader's own message plus our own
2663
+ * package id — so an unrelated duplicate from another bundle is never hidden.
2664
+ *
2665
+ * A page reload after HMR invalidation is NOT affected: `invalidate()` drops the
2666
+ * factory first, so the next `load` for this id succeeds and never reaches the
2667
+ * catch.
2668
+ */
2669
+ try {
2670
+ window.__ModuleLoader__.load(REGISTRATION);
2671
+ } catch (error) {
2672
+ const message = String((error && error.message) || error || '');
2673
+ if (!(message.includes('duplicate factory registration') && message.includes(PACKAGE_ID))) throw error;
2674
+ }
2675
+
2676
+ function readFlag(key) {
2677
+ try {
2678
+ return localStorage.getItem(key) === '1';
2679
+ } catch {
2680
+ return false;
2681
+ }
2682
+ }
2683
+ })();