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/LICENSE +21 -0
- package/README.md +201 -2
- package/client.js +2683 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/bulk.png +0 -0
- package/docs/images/dark.png +0 -0
- package/docs/images/hero.png +0 -0
- package/docs/images/list.png +0 -0
- package/icon.png +0 -0
- package/icon.svg +6 -0
- package/index.js +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
- package/screenshots.json +6 -0
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
|
+
})();
|