@huaqiu/dsh-tool-plugin-update 0.5.21 → 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/lib/client.js +1112 -249
- package/lib/client.js.map +1 -1
- package/package.json +3 -3
- package/src/client/api.ts +12 -8
- package/src/client/copy.ts +46 -18
- package/src/client/icons.ts +91 -0
- package/src/client/index.ts +657 -113
- package/src/client/update-view.ts +478 -160
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin-update review
|
|
2
|
+
* Plugin-update review view (browser half).
|
|
3
3
|
*
|
|
4
|
-
* Plain-DOM
|
|
4
|
+
* Plain-DOM panel (no React tree) so it can never disturb the DSH shell's
|
|
5
5
|
* reconciliation — same rule as the sidebar entry rows. Rendered content:
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
16
17
|
*
|
|
17
18
|
* @module
|
|
18
19
|
*/
|
|
@@ -25,6 +26,15 @@ import {
|
|
|
25
26
|
type PluginUpdateReport,
|
|
26
27
|
} from './api.js'
|
|
27
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'
|
|
28
38
|
|
|
29
39
|
export interface UpdateStoreLike {
|
|
30
40
|
report(): PluginUpdateReport | null
|
|
@@ -33,17 +43,69 @@ export interface UpdateStoreLike {
|
|
|
33
43
|
subscribe(listener: () => void): () => void
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
/** How long the outcome toast stays up, in milliseconds. */
|
|
47
|
+
const TOAST_HOLD_MS = 4000
|
|
48
|
+
|
|
36
49
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
37
50
|
tag: K,
|
|
38
|
-
className
|
|
51
|
+
className = '',
|
|
39
52
|
text?: string,
|
|
40
53
|
): HTMLElementTagNameMap[K] {
|
|
41
54
|
const node = document.createElement(tag)
|
|
42
|
-
node.className = className
|
|
55
|
+
if (className.length > 0) node.className = className
|
|
43
56
|
if (text !== undefined) node.textContent = text
|
|
44
57
|
return node
|
|
45
58
|
}
|
|
46
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
|
+
|
|
47
109
|
function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
|
|
48
110
|
switch (item.state) {
|
|
49
111
|
case 'current':
|
|
@@ -59,90 +121,198 @@ function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
|
|
|
59
121
|
}
|
|
60
122
|
}
|
|
61
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
|
+
|
|
62
139
|
function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undefined {
|
|
63
|
-
const by = item
|
|
140
|
+
const by = blockedByList(item)
|
|
64
141
|
if (by.includes('host')) return copy.blockedReason.host
|
|
65
142
|
if (by.includes('hq-edge-version')) return copy.blockedReason['hq-edge-version']
|
|
66
143
|
if (by.includes('runtime-unknown')) return copy.blockedReason['runtime-unknown']
|
|
67
144
|
return by.length > 0 ? by.join(', ') : undefined
|
|
68
145
|
}
|
|
69
146
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
79
189
|
}
|
|
190
|
+
head.append(end)
|
|
191
|
+
card.append(head)
|
|
80
192
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
202
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const runtimeSection = el('div', 'hqpu-section hqpu-runtime')
|
|
97
|
-
const actionBar = el('div', 'hqpu-actions')
|
|
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
|
|
98
214
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
document.body.appendChild(overlay)
|
|
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)))
|
|
103
218
|
|
|
104
|
-
const
|
|
219
|
+
const list = el('ul', 'hqpu-cards')
|
|
220
|
+
for (const item of items) list.append(packageCard(item, copy, installing, store))
|
|
105
221
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
112
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
|
+
}
|
|
113
264
|
|
|
114
|
-
|
|
115
|
-
|
|
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)
|
|
116
272
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// Store notification re-renders already; nothing extra to do.
|
|
120
|
-
})
|
|
273
|
+
const facts = el('dl', 'hqpu-facts')
|
|
274
|
+
const runtime = report.runtime
|
|
121
275
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
129
285
|
}
|
|
130
|
-
|
|
286
|
+
wrap.append(dd)
|
|
287
|
+
facts.append(wrap)
|
|
131
288
|
}
|
|
132
|
-
closeBtn.addEventListener('click', onClose)
|
|
133
|
-
overlay.addEventListener('click', (ev) => {
|
|
134
|
-
if (ev.target === overlay) onClose()
|
|
135
|
-
})
|
|
136
|
-
refreshBtn.addEventListener('click', () => {
|
|
137
|
-
void store.refresh()
|
|
138
|
-
})
|
|
139
|
-
disposers.push(() => {
|
|
140
|
-
closeBtn.removeEventListener('click', onClose)
|
|
141
|
-
overlay.removeEventListener('click', onClose)
|
|
142
|
-
refreshBtn.removeEventListener('click', () => {})
|
|
143
|
-
})
|
|
144
289
|
|
|
145
|
-
|
|
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
|
|
146
316
|
}
|
|
147
317
|
|
|
148
318
|
function renderList(
|
|
@@ -154,7 +324,7 @@ function renderList(
|
|
|
154
324
|
): void {
|
|
155
325
|
section.textContent = ''
|
|
156
326
|
if (report === null) {
|
|
157
|
-
section.append(
|
|
327
|
+
section.append(skeleton(copy))
|
|
158
328
|
return
|
|
159
329
|
}
|
|
160
330
|
if (report.items.length === 0) {
|
|
@@ -162,45 +332,21 @@ function renderList(
|
|
|
162
332
|
return
|
|
163
333
|
}
|
|
164
334
|
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
el('span', 'hqpu-manifest-label', `${copy.manifestVersion}`),
|
|
168
|
-
el('span', 'hqpu-manifest-value', report.manifestVersion),
|
|
169
|
-
)
|
|
170
|
-
section.append(manifestRow)
|
|
171
|
-
|
|
172
|
-
if (report.ignoredSet) {
|
|
173
|
-
const banner = el('div', 'hqpu-ignored-banner', copy.ignoredOffer)
|
|
174
|
-
const unignore = el('button', 'hqpu-btn hqpu-btn-small', copy.unignore)
|
|
175
|
-
unignore.addEventListener('click', () => {
|
|
176
|
-
void unignoreOffer(report.manifestVersion)
|
|
177
|
-
.then(() => store.refresh())
|
|
178
|
-
.catch(() => undefined)
|
|
179
|
-
})
|
|
180
|
-
banner.append(unignore)
|
|
181
|
-
section.append(banner)
|
|
182
|
-
}
|
|
335
|
+
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
336
|
+
report.items.filter((item) => item.state === state)
|
|
183
337
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
const state = el('span', `hqpu-item-state hqpu-state-${item.state}`, stateLabel(item, copy))
|
|
196
|
-
row.append(name, version, state)
|
|
197
|
-
const reason = blockedReason(item, copy)
|
|
198
|
-
if (reason !== undefined) {
|
|
199
|
-
row.append(el('span', 'hqpu-item-reason', reason))
|
|
200
|
-
}
|
|
201
|
-
list.append(row)
|
|
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)
|
|
202
349
|
}
|
|
203
|
-
section.append(list)
|
|
204
350
|
}
|
|
205
351
|
|
|
206
352
|
function renderRuntime(
|
|
@@ -209,26 +355,59 @@ function renderRuntime(
|
|
|
209
355
|
copy: UpdateCopy,
|
|
210
356
|
): void {
|
|
211
357
|
section.textContent = ''
|
|
212
|
-
|
|
213
|
-
section.append(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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')
|
|
217
370
|
return
|
|
218
371
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
)
|
|
229
387
|
}
|
|
230
388
|
}
|
|
231
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
|
+
|
|
232
411
|
function renderActions(
|
|
233
412
|
bar: HTMLElement,
|
|
234
413
|
report: PluginUpdateReport | null,
|
|
@@ -240,47 +419,62 @@ function renderActions(
|
|
|
240
419
|
if (report === null) return
|
|
241
420
|
|
|
242
421
|
if (installing) {
|
|
243
|
-
|
|
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)
|
|
244
426
|
return
|
|
245
427
|
}
|
|
246
428
|
|
|
247
429
|
const actionable = report.items.filter(
|
|
248
430
|
(item) => item.state === 'update' || item.state === 'install',
|
|
249
431
|
).length
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
|
278
472
|
}
|
|
279
473
|
|
|
280
474
|
/** After an install, poll the backend until the update report settles
|
|
281
475
|
* (installing flag clears or DSH restarts under us — the next load re-reads
|
|
282
476
|
* the fresh state). */
|
|
283
|
-
function pollUntilSettled(store: UpdateStoreLike
|
|
477
|
+
function pollUntilSettled(store: UpdateStoreLike): void {
|
|
284
478
|
let attempts = 0
|
|
285
479
|
const tick = (): void => {
|
|
286
480
|
void store
|
|
@@ -299,3 +493,127 @@ function pollUntilSettled(store: UpdateStoreLike, setVersion: string): void {
|
|
|
299
493
|
}
|
|
300
494
|
tick()
|
|
301
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
|
+
}
|