@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.
@@ -1,18 +1,19 @@
1
1
  /**
2
- * Plugin-update review modal (browser half).
2
+ * Plugin-update review view (browser half).
3
3
  *
4
- * Plain-DOM modal (no React tree) so it can never disturb the DSH shell's
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
- * - header: title + refresh
8
- * - plugin list (name, current → available, state badge, blocked reason)
9
- * - ignored-offer banner + reversible "show again"
10
- * - install action (disabled while installing / nothing actionable / offer
11
- * ignored); after install the UI keeps polling the backend state instead
12
- * of trusting the HTTP response alone, because activating a set restarts
13
- * DSH (spec §7.1: never report success on the 200/202 alone).
14
- * - independent HQ Edge runtime section (read-only report; the actual
15
- * upgrade is owned by the EDA installer).
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: string,
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.blockedBy ?? []
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
- * Open (or focus) the update modal. Re-renders from the store; when the store
72
- * is stale the modal first refreshes the report.
73
- */
74
- export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
75
- const existing = document.querySelector<HTMLElement>('[data-hq-plugin-update-view]')
76
- if (existing !== null) {
77
- existing.style.display = ''
78
- return
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
- const overlay = el('div', 'hqpu-overlay')
82
- overlay.setAttribute('data-hq-plugin-update-view', '')
83
- const panel = el('div', 'hqpu-panel')
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
- // Header
86
- const header = el('div', 'hqpu-header')
87
- header.append(el('div', 'hqpu-title', copy.viewTitle))
88
- const refreshBtn = el('button', 'hqpu-btn', copy.refresh)
89
- header.append(refreshBtn)
90
- const closeBtn = el('button', 'hqpu-btn hqpu-btn-ghost', copy.close)
91
- header.append(closeBtn)
92
-
93
- // Body: plugin list + ignored banner + install action + runtime section.
94
- const body = el('div', 'hqpu-body')
95
- const listSection = el('div', 'hqpu-section')
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
- panel.append(header, body)
100
- body.append(listSection, runtimeSection, actionBar)
101
- overlay.append(panel)
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 disposers: Array<() => void> = []
219
+ const list = el('ul', 'hqpu-cards')
220
+ for (const item of items) list.append(packageCard(item, copy, installing, store))
105
221
 
106
- const render = (): void => {
107
- const report = store.report()
108
- const installing = store.installing()
109
- renderList(listSection, report, copy, installing, store)
110
- renderRuntime(runtimeSection, report, copy)
111
- renderActions(actionBar, report, copy, store, installing)
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
- const unsubscribe = store.subscribe(render)
115
- disposers.push(unsubscribe)
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
- // Refresh on open (bounded; never block the shell).
118
- void store.refresh().then(() => {
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 onClose = (): void => {
123
- for (const dispose of disposers) {
124
- try {
125
- dispose()
126
- } catch {
127
- /* already disposed */
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
- overlay.remove()
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
- render()
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(el('div', 'hqpu-empty', copy.checkFailed))
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 manifestRow = el('div', 'hqpu-manifest')
166
- manifestRow.append(
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
- const list = el('ul', 'hqpu-list')
185
- for (const item of report.items) {
186
- const row = el('li', `hqpu-item hqpu-item-${item.state}`)
187
- const name = el('span', 'hqpu-item-name', item.name)
188
- const version = el(
189
- 'span',
190
- 'hqpu-item-version',
191
- item.state === 'current'
192
- ? item.currentVersion
193
- : `${item.currentVersion} → ${item.availableVersion}`,
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
- const title = el('div', 'hqpu-runtime-title', copy.runtime.title)
213
- section.append(title)
214
- const runtime = report?.runtime
215
- if (runtime === undefined || runtime.configured === false) {
216
- section.append(el('div', 'hqpu-runtime-line', copy.runtime.notConfigured))
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
- if (runtime.updateAvailable && runtime.latest !== undefined) {
220
- section.append(el('div', 'hqpu-runtime-line', copy.runtime.updateAvailable(runtime.latest)))
221
- } else if (runtime.currentVersion !== undefined) {
222
- section.append(el('div', 'hqpu-runtime-line', copy.runtime.current(runtime.currentVersion)))
223
- } else {
224
- section.append(el('div', 'hqpu-runtime-line', copy.runtime.unknown))
225
- }
226
- section.append(el('div', 'hqpu-runtime-note', copy.runtime.note))
227
- if (runtime.lastError !== undefined && runtime.lastError.length > 0) {
228
- section.append(el('div', 'hqpu-runtime-error', `${copy.lastError}: ${runtime.lastError}`))
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
- bar.append(el('span', 'hqpu-installing', copy.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)
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
- const installBtn = el(
251
- 'button',
252
- 'hqpu-btn hqpu-btn-primary',
253
- actionable > 0 ? copy.installAll(actionable) : copy.state.current,
254
- )
255
- installBtn.disabled = actionable === 0 || report.ignoredSet
256
- installBtn.addEventListener('click', () => {
257
- void installSet(report.manifestVersion)
258
- .then((result) => {
259
- if (result.error !== undefined) {
260
- bar.textContent = ''
261
- bar.append(el('div', 'hqpu-error', `${copy.installError}: ${result.error}`))
262
- return
263
- }
264
- // The set is activated atomically; DSH restarts. Keep polling the
265
- // backend state rather than trusting the response alone (spec §7.1).
266
- bar.textContent = ''
267
- bar.append(el('span', 'hqpu-installing', copy.installSuccess))
268
- void pollUntilSettled(store, report.manifestVersion)
269
- })
270
- .catch((err: unknown) => {
271
- bar.textContent = ''
272
- bar.append(
273
- el('div', 'hqpu-error', `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`),
274
- )
275
- })
276
- })
277
- bar.append(installBtn)
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, setVersion: string): void {
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
+ }