@huaqiu/dsh-tool-plugin-update 0.0.0-stage → 0.5.22
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/cordis.patch.yml +10 -0
- package/lib/client.js +1869 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +32 -0
- package/lib/index.mjs +178 -0
- package/package.json +50 -3
- package/src/client/api.ts +98 -0
- package/src/client/copy.ts +120 -0
- package/src/client/icons.ts +91 -0
- package/src/client/index.ts +1052 -0
- package/src/client/update-view.ts +619 -0
- package/src/index.ts +108 -0
- package/src/routes.ts +149 -0
- package/README.md +0 -3
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-update review view (browser half).
|
|
3
|
+
*
|
|
4
|
+
* Plain-DOM panel (no React tree) so it can never disturb the DSH shell's
|
|
5
|
+
* reconciliation — same rule as the sidebar entry rows. Rendered content:
|
|
6
|
+
*
|
|
7
|
+
* - head: title + intro + refresh toolbar (the shell plugin-manager head row)
|
|
8
|
+
* - state-grouped sections, each a group head + package cards
|
|
9
|
+
* (48px hairline-framed glyph, title row with a state tag, one-line
|
|
10
|
+
* version transition, end actions) — the shell's card geometry
|
|
11
|
+
* - a facts group for the independent HQ Edge runtime report
|
|
12
|
+
* - footer actions (install / ignore / show again), with a toast that
|
|
13
|
+
* reports the outcome of an install the way the shell's Toast does
|
|
14
|
+
*
|
|
15
|
+
* Every surface rides a `--dsw-alias-*` design token (see VIEW_CSS_TEXT), so
|
|
16
|
+
* the panel follows the shell's light/dark theme and its focus-ring contract.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
checkUpdates,
|
|
22
|
+
ignoreOffer,
|
|
23
|
+
installSet,
|
|
24
|
+
unignoreOffer,
|
|
25
|
+
type PluginUpdateItem,
|
|
26
|
+
type PluginUpdateReport,
|
|
27
|
+
} from './api.js'
|
|
28
|
+
import type { UpdateCopy } from './copy.js'
|
|
29
|
+
import {
|
|
30
|
+
CHECK_CIRCLE_ICON,
|
|
31
|
+
CLOSE_ICON,
|
|
32
|
+
DOWNLOAD_ICON,
|
|
33
|
+
INFO_ICON,
|
|
34
|
+
PLUGIN_ICON,
|
|
35
|
+
REFRESH_ICON,
|
|
36
|
+
WARNING_ICON,
|
|
37
|
+
} from './icons.js'
|
|
38
|
+
|
|
39
|
+
export interface UpdateStoreLike {
|
|
40
|
+
report(): PluginUpdateReport | null
|
|
41
|
+
installing(): boolean
|
|
42
|
+
refresh(): Promise<PluginUpdateReport | null>
|
|
43
|
+
subscribe(listener: () => void): () => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How long the outcome toast stays up, in milliseconds. */
|
|
47
|
+
const TOAST_HOLD_MS = 4000
|
|
48
|
+
|
|
49
|
+
function el<K extends keyof HTMLElementTagNameMap>(
|
|
50
|
+
tag: K,
|
|
51
|
+
className = '',
|
|
52
|
+
text?: string,
|
|
53
|
+
): HTMLElementTagNameMap[K] {
|
|
54
|
+
const node = document.createElement(tag)
|
|
55
|
+
if (className.length > 0) node.className = className
|
|
56
|
+
if (text !== undefined) node.textContent = text
|
|
57
|
+
return node
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A button carrying inline SVG artwork (the icon modules emit markup). */
|
|
61
|
+
function iconButton(
|
|
62
|
+
className: string,
|
|
63
|
+
markup: string,
|
|
64
|
+
label: string,
|
|
65
|
+
onClick: () => void,
|
|
66
|
+
): HTMLButtonElement {
|
|
67
|
+
const button = el('button', className)
|
|
68
|
+
button.type = 'button'
|
|
69
|
+
button.setAttribute('aria-label', label)
|
|
70
|
+
button.title = label
|
|
71
|
+
button.innerHTML = markup
|
|
72
|
+
button.addEventListener('click', onClick)
|
|
73
|
+
return button
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compact a package name to what a person calls it — the shell plugin
|
|
78
|
+
* manager's own rule (`presentation.ts` `shortName`), so both UIs read the
|
|
79
|
+
* same plugin by the same name.
|
|
80
|
+
*/
|
|
81
|
+
export function shortName(name: string): string {
|
|
82
|
+
const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
|
|
83
|
+
return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Normalize the server's block reason to a list (it may arrive as a string). */
|
|
87
|
+
function blockedByList(item: PluginUpdateItem): string[] {
|
|
88
|
+
const by = item.blockedBy
|
|
89
|
+
if (Array.isArray(by)) return by.filter((entry): entry is string => typeof entry === 'string')
|
|
90
|
+
return typeof by === 'string' && by.length > 0 ? [by] : []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The tag tone a state reads as, matching the shell's Tag palette. */
|
|
94
|
+
function stateTone(item: PluginUpdateItem): 'success' | 'info' | 'warning' | 'danger' | 'neutral' {
|
|
95
|
+
switch (item.state) {
|
|
96
|
+
case 'update':
|
|
97
|
+
return 'info'
|
|
98
|
+
case 'install':
|
|
99
|
+
return 'warning'
|
|
100
|
+
case 'blocked':
|
|
101
|
+
return 'danger'
|
|
102
|
+
case 'ignored':
|
|
103
|
+
return 'neutral'
|
|
104
|
+
case 'current':
|
|
105
|
+
return 'success'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
|
|
110
|
+
switch (item.state) {
|
|
111
|
+
case 'current':
|
|
112
|
+
return copy.state.current
|
|
113
|
+
case 'update':
|
|
114
|
+
return copy.state.update
|
|
115
|
+
case 'install':
|
|
116
|
+
return copy.state.install
|
|
117
|
+
case 'ignored':
|
|
118
|
+
return copy.state.ignored
|
|
119
|
+
case 'blocked':
|
|
120
|
+
return copy.state.blocked
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The one-line version transition under a card's title. */
|
|
125
|
+
function versionLine(item: PluginUpdateItem): string {
|
|
126
|
+
switch (item.state) {
|
|
127
|
+
case 'current':
|
|
128
|
+
return item.currentVersion ?? item.availableVersion
|
|
129
|
+
case 'install':
|
|
130
|
+
return item.availableVersion
|
|
131
|
+
default:
|
|
132
|
+
// An absent plugin names only the version it would gain.
|
|
133
|
+
return item.currentVersion === null
|
|
134
|
+
? item.availableVersion
|
|
135
|
+
: `${item.currentVersion} → ${item.availableVersion}`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undefined {
|
|
140
|
+
const by = blockedByList(item)
|
|
141
|
+
if (by.includes('host')) return copy.blockedReason.host
|
|
142
|
+
if (by.includes('hq-edge-version')) return copy.blockedReason['hq-edge-version']
|
|
143
|
+
if (by.includes('runtime-unknown')) return copy.blockedReason['runtime-unknown']
|
|
144
|
+
return by.length > 0 ? by.join(', ') : undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One package card: glyph, name with its state tag, version line, end actions. */
|
|
148
|
+
function packageCard(
|
|
149
|
+
item: PluginUpdateItem,
|
|
150
|
+
copy: UpdateCopy,
|
|
151
|
+
installing: boolean,
|
|
152
|
+
store: UpdateStoreLike,
|
|
153
|
+
): HTMLLIElement {
|
|
154
|
+
const card = el('li', 'hqpu-card')
|
|
155
|
+
card.dataset.pluginName = item.name
|
|
156
|
+
card.dataset.state = item.state
|
|
157
|
+
// The full package name is the card's tooltip; the row shows the short name.
|
|
158
|
+
card.title = `${item.name} · ${versionLine(item)}`
|
|
159
|
+
|
|
160
|
+
const head = el('div', 'hqpu-card-head')
|
|
161
|
+
|
|
162
|
+
const glyph = el('span', 'hqpu-card-icon')
|
|
163
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
164
|
+
glyph.innerHTML = PLUGIN_ICON(24)
|
|
165
|
+
|
|
166
|
+
const main = el('div', 'hqpu-card-main')
|
|
167
|
+
const titleRow = el('div', 'hqpu-title-row')
|
|
168
|
+
const title = el('span', 'hqpu-card-title', shortName(item.name))
|
|
169
|
+
const tag = el('span', 'hqpu-tag')
|
|
170
|
+
tag.dataset.tone = stateTone(item)
|
|
171
|
+
tag.textContent = stateLabel(item, copy)
|
|
172
|
+
titleRow.append(title, tag)
|
|
173
|
+
|
|
174
|
+
const desc = el('span', 'hqpu-card-desc', versionLine(item))
|
|
175
|
+
|
|
176
|
+
main.append(titleRow, desc)
|
|
177
|
+
head.append(glyph, main)
|
|
178
|
+
|
|
179
|
+
const end = el('div', 'hqpu-card-end')
|
|
180
|
+
if (item.state === 'update' || item.state === 'install') {
|
|
181
|
+
const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
|
|
182
|
+
button.type = 'button'
|
|
183
|
+
button.disabled = installing
|
|
184
|
+
button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
|
|
185
|
+
button.addEventListener('click', () => {
|
|
186
|
+
void runInstall(store, copy)
|
|
187
|
+
})
|
|
188
|
+
end.append(button)
|
|
189
|
+
}
|
|
190
|
+
head.append(end)
|
|
191
|
+
card.append(head)
|
|
192
|
+
|
|
193
|
+
// A block reason belongs to a blocked offer only.
|
|
194
|
+
if (item.state === 'blocked') {
|
|
195
|
+
const reason = blockedReason(item, copy)
|
|
196
|
+
if (reason !== undefined) {
|
|
197
|
+
card.append(el('p', 'hqpu-card-reason', reason))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return card
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A section: group head with its count, then the cards. */
|
|
204
|
+
function group(
|
|
205
|
+
heading: string,
|
|
206
|
+
items: PluginUpdateItem[],
|
|
207
|
+
copy: UpdateCopy,
|
|
208
|
+
installing: boolean,
|
|
209
|
+
store: UpdateStoreLike,
|
|
210
|
+
): HTMLElement | null {
|
|
211
|
+
if (items.length === 0) return null
|
|
212
|
+
const section = el('section', 'hqpu-group')
|
|
213
|
+
section.dataset.group = heading
|
|
214
|
+
|
|
215
|
+
const head = el('div', 'hqpu-group-head')
|
|
216
|
+
head.append(el('h3', 'hqpu-group-title', heading))
|
|
217
|
+
head.append(el('span', 'hqpu-group-count', copy.groupCount(items.length)))
|
|
218
|
+
|
|
219
|
+
const list = el('ul', 'hqpu-cards')
|
|
220
|
+
for (const item of items) list.append(packageCard(item, copy, installing, store))
|
|
221
|
+
|
|
222
|
+
section.append(head, list)
|
|
223
|
+
return section
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** First-read placeholders, sharing the card and text-line layout. */
|
|
227
|
+
function skeleton(copy: UpdateCopy): HTMLElement {
|
|
228
|
+
const section = el('section', 'hqpu-group')
|
|
229
|
+
section.setAttribute('role', 'status')
|
|
230
|
+
section.dataset.loading = ''
|
|
231
|
+
const head = el('div', 'hqpu-group-head')
|
|
232
|
+
head.setAttribute('aria-hidden', 'true')
|
|
233
|
+
const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
|
|
234
|
+
const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
|
|
235
|
+
heading.append(headingBar)
|
|
236
|
+
head.append(heading)
|
|
237
|
+
|
|
238
|
+
const list = el('ul', 'hqpu-cards')
|
|
239
|
+
list.setAttribute('aria-hidden', 'true')
|
|
240
|
+
for (let index = 0; index < 3; index += 1) {
|
|
241
|
+
const card = el('li', 'hqpu-card')
|
|
242
|
+
const cardHead = el('div', 'hqpu-card-head')
|
|
243
|
+
const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
|
|
244
|
+
const main = el('div', 'hqpu-card-main')
|
|
245
|
+
const titleRow = el('div', 'hqpu-title-row')
|
|
246
|
+
const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
|
|
247
|
+
title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
248
|
+
titleRow.append(title)
|
|
249
|
+
const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
|
|
250
|
+
desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
251
|
+
main.append(titleRow, desc)
|
|
252
|
+
cardHead.append(glyph, main)
|
|
253
|
+
const end = el('div', 'hqpu-card-end')
|
|
254
|
+
end.setAttribute('aria-hidden', 'true')
|
|
255
|
+
cardHead.append(end)
|
|
256
|
+
card.append(cardHead)
|
|
257
|
+
list.append(card)
|
|
258
|
+
}
|
|
259
|
+
section.append(head, list)
|
|
260
|
+
// The skeleton replaces the whole column, so label it for screen readers.
|
|
261
|
+
section.setAttribute('aria-label', copy.loading)
|
|
262
|
+
return section
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The independent HQ Edge runtime report, as ruled fact rows. */
|
|
266
|
+
function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement {
|
|
267
|
+
const section = el('section', 'hqpu-group')
|
|
268
|
+
section.dataset.group = 'runtime'
|
|
269
|
+
const head = el('div', 'hqpu-group-head')
|
|
270
|
+
head.append(el('h3', 'hqpu-group-title', copy.runtime.title))
|
|
271
|
+
section.append(head)
|
|
272
|
+
|
|
273
|
+
const facts = el('dl', 'hqpu-facts')
|
|
274
|
+
const runtime = report.runtime
|
|
275
|
+
|
|
276
|
+
const row = (label: string, value: string, code = false): void => {
|
|
277
|
+
const wrap = el('div')
|
|
278
|
+
wrap.append(el('dt', '', label))
|
|
279
|
+
const dd = el('dd')
|
|
280
|
+
if (code) {
|
|
281
|
+
const node = el('code', '', value)
|
|
282
|
+
dd.append(node)
|
|
283
|
+
} else {
|
|
284
|
+
dd.textContent = value
|
|
285
|
+
}
|
|
286
|
+
wrap.append(dd)
|
|
287
|
+
facts.append(wrap)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (runtime.configured === false) {
|
|
291
|
+
row(copy.runtime.source, copy.runtime.notConfigured)
|
|
292
|
+
} else {
|
|
293
|
+
row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
|
|
294
|
+
if (runtime.updateAvailable && runtime.latest != null) {
|
|
295
|
+
row(copy.runtime.latest, runtime.latest)
|
|
296
|
+
}
|
|
297
|
+
if (report.manifestVersion !== null) {
|
|
298
|
+
row(copy.runtime.set, report.manifestVersion, true)
|
|
299
|
+
}
|
|
300
|
+
if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
|
|
301
|
+
row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
section.append(facts)
|
|
305
|
+
|
|
306
|
+
section.append(el('p', 'hqpu-status', copy.runtime.note))
|
|
307
|
+
if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
308
|
+
const line = el('p', 'hqpu-status hqpu-status--dot')
|
|
309
|
+
line.setAttribute('role', 'status')
|
|
310
|
+
const dot = el('span', 'hqpu-dot')
|
|
311
|
+
dot.dataset.state = 'error'
|
|
312
|
+
line.append(dot, el('span', '', `${copy.lastError}: ${runtime.lastError}`))
|
|
313
|
+
section.append(line)
|
|
314
|
+
}
|
|
315
|
+
return section
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderList(
|
|
319
|
+
section: HTMLElement,
|
|
320
|
+
report: PluginUpdateReport | null,
|
|
321
|
+
copy: UpdateCopy,
|
|
322
|
+
installing: boolean,
|
|
323
|
+
store: UpdateStoreLike,
|
|
324
|
+
): void {
|
|
325
|
+
section.textContent = ''
|
|
326
|
+
if (report === null) {
|
|
327
|
+
section.append(skeleton(copy))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (report.items.length === 0) {
|
|
331
|
+
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
336
|
+
report.items.filter((item) => item.state === state)
|
|
337
|
+
|
|
338
|
+
// Most important first: what can be acted on, then what is refused, then
|
|
339
|
+
// what was muted, then what is already settled.
|
|
340
|
+
const order: Array<[string, PluginUpdateItem[]]> = [
|
|
341
|
+
[copy.group.actionable, [...byState('update'), ...byState('install')]],
|
|
342
|
+
[copy.group.blocked, byState('blocked')],
|
|
343
|
+
[copy.group.ignored, byState('ignored')],
|
|
344
|
+
[copy.group.current, byState('current')],
|
|
345
|
+
]
|
|
346
|
+
for (const [heading, items] of order) {
|
|
347
|
+
const node = group(heading, items, copy, installing, store)
|
|
348
|
+
if (node !== null) section.append(node)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderRuntime(
|
|
353
|
+
section: HTMLElement,
|
|
354
|
+
report: PluginUpdateReport | null,
|
|
355
|
+
copy: UpdateCopy,
|
|
356
|
+
): void {
|
|
357
|
+
section.textContent = ''
|
|
358
|
+
if (report === null) return
|
|
359
|
+
section.append(runtimeGroup(report, copy))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Run the install transaction, then keep polling until the state settles. */
|
|
363
|
+
async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
|
|
364
|
+
const report = store.report()
|
|
365
|
+
if (report === null) return
|
|
366
|
+
// Without an evaluated set there is nothing to install: hq-edge has not read
|
|
367
|
+
// a manifest yet, so the report is still its empty shell.
|
|
368
|
+
if (report.manifestVersion === null) {
|
|
369
|
+
showToast(copy.checkFailed, 'error')
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const result = await installSet(report.manifestVersion)
|
|
374
|
+
if (result.error !== undefined) {
|
|
375
|
+
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
// The set activates atomically and DSH restarts. Keep polling the backend
|
|
379
|
+
// state rather than trusting the response alone (spec §7.1).
|
|
380
|
+
showToast(copy.installSuccess, 'success')
|
|
381
|
+
void pollUntilSettled(store)
|
|
382
|
+
} catch (err: unknown) {
|
|
383
|
+
showToast(
|
|
384
|
+
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
385
|
+
'error',
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The single live toast; a new message replaces the one on screen. */
|
|
391
|
+
let toastHost: HTMLElement | null = null
|
|
392
|
+
let toastTimer: number | null = null
|
|
393
|
+
|
|
394
|
+
function showToast(message: string, tone: 'success' | 'error'): void {
|
|
395
|
+
if (toastHost === null) return
|
|
396
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
397
|
+
toastHost.textContent = ''
|
|
398
|
+
const toast = el('div', 'hqpu-toast')
|
|
399
|
+
toast.setAttribute('role', 'status')
|
|
400
|
+
const icon = el('span', 'hqpu-toast-icon')
|
|
401
|
+
icon.dataset.tone = tone
|
|
402
|
+
icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
|
|
403
|
+
toast.append(icon, el('span', 'hqpu-toast-text', message))
|
|
404
|
+
toastHost.append(toast)
|
|
405
|
+
toastTimer = window.setTimeout(() => {
|
|
406
|
+
toastHost?.replaceChildren()
|
|
407
|
+
toastTimer = null
|
|
408
|
+
}, TOAST_HOLD_MS)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function renderActions(
|
|
412
|
+
bar: HTMLElement,
|
|
413
|
+
report: PluginUpdateReport | null,
|
|
414
|
+
copy: UpdateCopy,
|
|
415
|
+
store: UpdateStoreLike,
|
|
416
|
+
installing: boolean,
|
|
417
|
+
): void {
|
|
418
|
+
bar.textContent = ''
|
|
419
|
+
if (report === null) return
|
|
420
|
+
|
|
421
|
+
if (installing) {
|
|
422
|
+
const line = el('span', 'hqpu-status hqpu-status--dot')
|
|
423
|
+
const spinner = el('span', 'hqpu-spinner')
|
|
424
|
+
line.append(spinner, el('span', '', copy.installing))
|
|
425
|
+
bar.append(line)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const actionable = report.items.filter(
|
|
430
|
+
(item) => item.state === 'update' || item.state === 'install',
|
|
431
|
+
).length
|
|
432
|
+
// Every write needs an evaluated set; hq-edge reports `null` until it has
|
|
433
|
+
// read a manifest, and posting `null` would only earn a 400.
|
|
434
|
+
const hasSet = report.manifestVersion !== null
|
|
435
|
+
|
|
436
|
+
if (actionable === 0) {
|
|
437
|
+
// The official page shows no install control when there is nothing to
|
|
438
|
+
// commit, so mirror that with a muted line rather than a dead button.
|
|
439
|
+
bar.append(el('span', 'hqpu-status', copy.nothingToInstall))
|
|
440
|
+
} else {
|
|
441
|
+
const installBtn = el('button', 'hqpu-btn hqpu-btn--primary')
|
|
442
|
+
installBtn.type = 'button'
|
|
443
|
+
installBtn.disabled = !hasSet || report.ignoredSet
|
|
444
|
+
installBtn.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installAll(actionable)}</span>`
|
|
445
|
+
installBtn.addEventListener('click', () => {
|
|
446
|
+
void runInstall(store, copy)
|
|
447
|
+
})
|
|
448
|
+
bar.append(installBtn)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!hasSet) return
|
|
452
|
+
|
|
453
|
+
if (report.ignoredSet) {
|
|
454
|
+
const unignore = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.unignore)
|
|
455
|
+
unignore.type = 'button'
|
|
456
|
+
unignore.addEventListener('click', () => {
|
|
457
|
+
void unignoreOffer(report.manifestVersion as string)
|
|
458
|
+
.then(() => store.refresh())
|
|
459
|
+
.catch(() => undefined)
|
|
460
|
+
})
|
|
461
|
+
bar.append(unignore)
|
|
462
|
+
} else if (actionable > 0) {
|
|
463
|
+
const ignore = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.ignore)
|
|
464
|
+
ignore.type = 'button'
|
|
465
|
+
ignore.addEventListener('click', () => {
|
|
466
|
+
void ignoreOffer(report.manifestVersion as string)
|
|
467
|
+
.then(() => store.refresh())
|
|
468
|
+
.catch(() => undefined)
|
|
469
|
+
})
|
|
470
|
+
bar.append(ignore)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** After an install, poll the backend until the update report settles
|
|
475
|
+
* (installing flag clears or DSH restarts under us — the next load re-reads
|
|
476
|
+
* the fresh state). */
|
|
477
|
+
function pollUntilSettled(store: UpdateStoreLike): void {
|
|
478
|
+
let attempts = 0
|
|
479
|
+
const tick = (): void => {
|
|
480
|
+
void store
|
|
481
|
+
.refresh()
|
|
482
|
+
.then((report) => {
|
|
483
|
+
if (store.installing()) return
|
|
484
|
+
if (report !== null && report.ignoredSet === false && attempts >= 2) return
|
|
485
|
+
})
|
|
486
|
+
.catch(() => undefined)
|
|
487
|
+
.finally(() => {
|
|
488
|
+
attempts += 1
|
|
489
|
+
if (attempts < 10) {
|
|
490
|
+
window.setTimeout(tick, 2000)
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
tick()
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Open (or focus) the update panel. Re-renders from the store; when the store
|
|
499
|
+
* is stale the panel first refreshes the report.
|
|
500
|
+
*/
|
|
501
|
+
export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
502
|
+
const existing = document.querySelector<HTMLElement>('[data-hq-plugin-update-view]')
|
|
503
|
+
if (existing !== null) {
|
|
504
|
+
existing.style.display = ''
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const overlay = el('div', 'hqpu-overlay')
|
|
509
|
+
overlay.setAttribute('data-hq-plugin-update-view', '')
|
|
510
|
+
overlay.addEventListener('click', (ev) => {
|
|
511
|
+
if (ev.target === overlay) onClose()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
const scrim = el('div', 'hqpu-scrim')
|
|
515
|
+
const panel = el('div', 'hqpu-panel')
|
|
516
|
+
panel.setAttribute('role', 'dialog')
|
|
517
|
+
panel.setAttribute('aria-modal', 'true')
|
|
518
|
+
panel.setAttribute('aria-label', copy.viewTitle)
|
|
519
|
+
panel.tabIndex = -1
|
|
520
|
+
|
|
521
|
+
// ── Head row: title + intro, refresh toolbar ──
|
|
522
|
+
const head = el('div', 'hqpu-head')
|
|
523
|
+
const headMain = el('div', 'hqpu-head-main')
|
|
524
|
+
headMain.append(el('h1', 'hqpu-title', copy.viewTitle))
|
|
525
|
+
const intro = el('p', 'hqpu-intro')
|
|
526
|
+
intro.append(el('span', '', copy.viewIntro))
|
|
527
|
+
const info = el('button', 'hqpu-info')
|
|
528
|
+
info.type = 'button'
|
|
529
|
+
info.setAttribute('aria-label', copy.viewInfo)
|
|
530
|
+
info.title = copy.viewInfo
|
|
531
|
+
info.innerHTML = INFO_ICON(11)
|
|
532
|
+
intro.append(info)
|
|
533
|
+
headMain.append(intro)
|
|
534
|
+
|
|
535
|
+
const toolbar = el('div', 'hqpu-toolbar')
|
|
536
|
+
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
|
|
537
|
+
void store.refresh()
|
|
538
|
+
})
|
|
539
|
+
toolbar.append(refreshBtn)
|
|
540
|
+
const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
|
|
541
|
+
onClose()
|
|
542
|
+
})
|
|
543
|
+
toolbar.append(closeBtn)
|
|
544
|
+
|
|
545
|
+
head.append(headMain, toolbar)
|
|
546
|
+
panel.append(head)
|
|
547
|
+
|
|
548
|
+
// ── Scrolling body: the centred column ──
|
|
549
|
+
const scroll = el('div', 'hqpu-scroll')
|
|
550
|
+
const column = el('div', 'hqpu-column')
|
|
551
|
+
const listSection = el('div', 'hqpu-stack')
|
|
552
|
+
const runtimeSection = el('div', 'hqpu-stack')
|
|
553
|
+
const toastSection = el('div', 'hqpu-toast-host')
|
|
554
|
+
column.append(listSection, runtimeSection, toastSection)
|
|
555
|
+
scroll.append(column)
|
|
556
|
+
panel.append(scroll)
|
|
557
|
+
|
|
558
|
+
// ── Footer actions (ui-primitives Modal .footer) ──
|
|
559
|
+
const footer = el('div', 'hqpu-footer')
|
|
560
|
+
const actionBar = el('div', 'hqpu-actions')
|
|
561
|
+
footer.append(actionBar)
|
|
562
|
+
panel.append(footer)
|
|
563
|
+
|
|
564
|
+
overlay.append(scrim, panel)
|
|
565
|
+
document.body.appendChild(overlay)
|
|
566
|
+
toastHost = toastSection
|
|
567
|
+
panel.focus()
|
|
568
|
+
|
|
569
|
+
const disposers: Array<() => void> = []
|
|
570
|
+
|
|
571
|
+
const render = (): void => {
|
|
572
|
+
const report = store.report()
|
|
573
|
+
const installing = store.installing()
|
|
574
|
+
const stale = report === null
|
|
575
|
+
refreshBtn.disabled = stale
|
|
576
|
+
if (stale) {
|
|
577
|
+
listSection.textContent = ''
|
|
578
|
+
listSection.append(skeleton(copy))
|
|
579
|
+
} else {
|
|
580
|
+
renderList(listSection, report, copy, installing, store)
|
|
581
|
+
}
|
|
582
|
+
renderRuntime(runtimeSection, report, copy)
|
|
583
|
+
renderActions(actionBar, report, copy, store, installing)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const unsubscribe = store.subscribe(render)
|
|
587
|
+
disposers.push(unsubscribe)
|
|
588
|
+
|
|
589
|
+
// Refresh on open (bounded; never block the shell).
|
|
590
|
+
void store.refresh().then(() => {
|
|
591
|
+
// Store notification re-renders already; nothing extra to do.
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// Escape closes, the shell-wide dialog contract.
|
|
595
|
+
const onKeyDown = (ev: KeyboardEvent): void => {
|
|
596
|
+
if (ev.key === 'Escape') {
|
|
597
|
+
ev.stopPropagation()
|
|
598
|
+
onClose()
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
document.addEventListener('keydown', onKeyDown)
|
|
602
|
+
disposers.push(() => document.removeEventListener('keydown', onKeyDown))
|
|
603
|
+
|
|
604
|
+
function onClose(): void {
|
|
605
|
+
for (const dispose of disposers) {
|
|
606
|
+
try {
|
|
607
|
+
dispose()
|
|
608
|
+
} catch {
|
|
609
|
+
/* already disposed */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
613
|
+
toastTimer = null
|
|
614
|
+
if (toastHost === toastSection) toastHost = null
|
|
615
|
+
overlay.remove()
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
render()
|
|
619
|
+
}
|