@huaqiu/dsh-tool-plugin-update 0.6.12 → 0.6.16
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 +2434 -1781
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +10 -0
- package/package.json +3 -3
- package/src/client/api.ts +19 -0
- package/src/client/badge.ts +59 -0
- package/src/client/copy.ts +171 -101
- package/src/client/icons.ts +45 -0
- package/src/client/index.ts +1159 -988
- package/src/client/update-view.ts +1397 -1175
- package/src/client/version.ts +36 -3
- package/src/routes.ts +23 -0
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin-update review view (browser half).
|
|
2
|
+
* Plugin-update review view (browser half) — the "更新中心" panel.
|
|
3
3
|
*
|
|
4
|
-
* Plain-DOM
|
|
5
|
-
* reconciliation — same rule as the sidebar entry rows.
|
|
4
|
+
* Plain-DOM (no React tree) so it can never disturb the DSH shell's
|
|
5
|
+
* reconciliation — the same rule as the sidebar entry rows. The layout follows
|
|
6
|
+
* `docs/prototype/plugin_update_ui_ux_prototype.html`; the reasoning behind the
|
|
7
|
+
* order is in `docs/report/plugin-update-ui-ux-baseline.md`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
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
|
|
9
|
+
* Information architecture, top to bottom, and why:
|
|
14
10
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. **header** — brand, title, `版本集 vX · N 分钟前`, and the four icon
|
|
12
|
+
* actions (refresh · sources · job log · close). The set version and the
|
|
13
|
+
* relative check time live here exactly once; the body never repeats them.
|
|
14
|
+
* 2. **settings strip** (gear) — the two manifest URLs, editable in place.
|
|
15
|
+
* 3. **check-failed banner** — an error, never the silent empty state.
|
|
16
|
+
* 4. **active job card** — pulsing title + percent + bar + telemetry +
|
|
17
|
+
* 4-step pipeline, resumed on open and polled to its end.
|
|
18
|
+
* 5. **failed-result banner** — error code + Retry + 查看日志. The full log
|
|
19
|
+
* lives in the header's drawer; the *outcome* must not hide behind it.
|
|
20
|
+
* 6. **decision band** — one line: how many plugins, and whether the runtime
|
|
21
|
+
* is separately upgradable. This is the "what do I have to decide" row.
|
|
22
|
+
* 7. **actionable cards** — 单独更新 / 忽略 promoted onto each card; the
|
|
23
|
+
* collapsible drawer keeps only the changelog.
|
|
24
|
+
* 8. **runtime card** — amber, because a swap is not a plugin install.
|
|
25
|
+
* 9. **已忽略 / 已是最新 / 版本说明** — collapsed by default: nothing that
|
|
26
|
+
* needs no action may sit above something that does.
|
|
27
|
+
* 10. **footer** — the primary CTA plus its honest meta line, both reactive to
|
|
28
|
+
* every state (idle / installing / failed check / up to date).
|
|
29
|
+
*
|
|
30
|
+
* The job log is a drawer inside the panel (`.hqpu-log`), not an inline block:
|
|
31
|
+
* a full set lists one row per component, which as inline content pushed every
|
|
32
|
+
* actionable card below the fold.
|
|
17
33
|
*
|
|
18
34
|
* @module
|
|
19
35
|
*/
|
|
@@ -24,6 +40,7 @@ import {
|
|
|
24
40
|
installSet,
|
|
25
41
|
requestRuntimeUpdate,
|
|
26
42
|
runtimeLatestVersion,
|
|
43
|
+
runtimeUpdateOffered,
|
|
27
44
|
setPluginUpdateSource,
|
|
28
45
|
setRuntimeUpdateSource,
|
|
29
46
|
unignoreOffer,
|
|
@@ -34,14 +51,18 @@ import {
|
|
|
34
51
|
type ReleaseInfo,
|
|
35
52
|
type RuntimeUpdateSource,
|
|
36
53
|
type UpdateJob,
|
|
54
|
+
type UpdateStep,
|
|
37
55
|
} from './api.js'
|
|
38
56
|
import { formatMb, type UpdateCopy } from './copy.js'
|
|
57
|
+
import { actionableUpdateCount } from './version.js'
|
|
39
58
|
import {
|
|
59
|
+
BOLT_ICON,
|
|
40
60
|
CHECK_CIRCLE_ICON,
|
|
61
|
+
CHEVRON_RIGHT_ICON,
|
|
41
62
|
CLOSE_ICON,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
COPY_ICON,
|
|
64
|
+
EYE_OFF_ICON,
|
|
65
|
+
LOG_ICON,
|
|
45
66
|
REFRESH_ICON,
|
|
46
67
|
WARNING_ICON,
|
|
47
68
|
} from './icons.js'
|
|
@@ -53,7 +74,7 @@ export interface UpdateStoreLike {
|
|
|
53
74
|
setInstalling(flag: boolean): void
|
|
54
75
|
/** The durable job the server currently reports as active (resumed). */
|
|
55
76
|
activeJob(): UpdateJob | null
|
|
56
|
-
/** Bounded recent jobs (newest first), for the
|
|
77
|
+
/** Bounded recent jobs (newest first), for the log drawer. */
|
|
57
78
|
recentJobs(): UpdateJob[]
|
|
58
79
|
refresh(): Promise<PluginUpdateReport | null>
|
|
59
80
|
/** Force a fresh update check (used by the failed-check Retry action). */
|
|
@@ -66,6 +87,11 @@ export interface UpdateStoreLike {
|
|
|
66
87
|
/** How long the outcome toast stays up, in milliseconds. */
|
|
67
88
|
const TOAST_HOLD_MS = 4000
|
|
68
89
|
|
|
90
|
+
/** Which manifest an editor belongs to — drives data attributes + CSS hook. */
|
|
91
|
+
export type SourceKind = 'runtime' | 'plugin'
|
|
92
|
+
|
|
93
|
+
// ── Small DOM helpers ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
69
95
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
70
96
|
tag: K,
|
|
71
97
|
className = '',
|
|
@@ -93,6 +119,14 @@ function iconButton(
|
|
|
93
119
|
return button
|
|
94
120
|
}
|
|
95
121
|
|
|
122
|
+
/** A text-link button (重新尝试, 展开版本日志, 忽略此版本 …). */
|
|
123
|
+
function linkButton(className: string, label: string, onClick: () => void): HTMLButtonElement {
|
|
124
|
+
const button = el('button', className, label)
|
|
125
|
+
button.type = 'button'
|
|
126
|
+
button.addEventListener('click', onClick)
|
|
127
|
+
return button
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
/**
|
|
97
131
|
* Compact a package name to what a person calls it — the shell plugin
|
|
98
132
|
* manager's own rule (`presentation.ts` `shortName`), so both UIs read the
|
|
@@ -103,200 +137,30 @@ export function shortName(name: string): string {
|
|
|
103
137
|
return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
|
|
104
138
|
}
|
|
105
139
|
|
|
106
|
-
/** The one-line version transition under a card's title. */
|
|
107
|
-
function versionLine(item: PluginUpdateItem): string {
|
|
108
|
-
switch (item.state) {
|
|
109
|
-
case 'current':
|
|
110
|
-
return item.currentVersion ?? item.availableVersion
|
|
111
|
-
case 'install':
|
|
112
|
-
return item.availableVersion
|
|
113
|
-
default:
|
|
114
|
-
// An absent plugin names only the version it would gain.
|
|
115
|
-
return item.currentVersion === null
|
|
116
|
-
? item.availableVersion
|
|
117
|
-
: `${item.currentVersion} → ${item.availableVersion}`
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
140
|
/** Two-letter avatar initials for a card (CJK-safe). */
|
|
122
141
|
function avatarInitials(item: PluginUpdateItem): string {
|
|
123
142
|
const src =
|
|
124
143
|
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
125
144
|
? item.displayName.trim()
|
|
126
145
|
: shortName(item.name)
|
|
127
|
-
if (/[
|
|
146
|
+
if (/[\u2E80-\u9FFF]/.test(src)) return src.slice(0, 2)
|
|
128
147
|
const words = src.split(/[\s_\-./@]+/).filter(Boolean)
|
|
129
148
|
if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
|
|
130
149
|
return src.replace(/^@/, '').slice(0, 2).toUpperCase()
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
/**
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const ul = el('ul')
|
|
139
|
-
for (const change of item.changes ?? []) {
|
|
140
|
-
if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
|
|
141
|
-
}
|
|
142
|
-
drawer.append(ul)
|
|
143
|
-
return drawer
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** One package card per the mockup: avatar initials, display name + package
|
|
147
|
-
* subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
|
|
148
|
-
* inline changelog. */
|
|
149
|
-
function packageCard(
|
|
150
|
-
item: PluginUpdateItem,
|
|
151
|
-
copy: UpdateCopy,
|
|
152
|
-
open: boolean,
|
|
153
|
-
onToggle: () => void,
|
|
154
|
-
onInstall: () => void,
|
|
155
|
-
onIgnore: () => void,
|
|
156
|
-
): HTMLLIElement {
|
|
157
|
-
const card = el('li', 'hqpu-card')
|
|
158
|
-
card.dataset.pluginName = item.name
|
|
159
|
-
card.dataset.state = item.state
|
|
160
|
-
|
|
161
|
-
const head = el('div', 'hqpu-card-head')
|
|
162
|
-
const left = el('div')
|
|
163
|
-
const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
|
|
164
|
-
avatar.setAttribute('aria-hidden', 'true')
|
|
165
|
-
|
|
166
|
-
const main = el('div', 'hqpu-card-main')
|
|
167
|
-
const titleText =
|
|
168
|
-
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
169
|
-
? item.displayName.trim()
|
|
170
|
-
: shortName(item.name)
|
|
171
|
-
main.append(el('span', 'hqpu-card-title', titleText))
|
|
172
|
-
main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
|
|
173
|
-
|
|
174
|
-
// old → new version row; the NEW version renders in emerald.
|
|
175
|
-
const ver = el('div', 'hqpu-version-row')
|
|
176
|
-
if (item.currentVersion !== null && item.currentVersion !== undefined) {
|
|
177
|
-
ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
|
|
178
|
-
ver.append(el('span', 'hqpu-ver-arrow', '→'))
|
|
179
|
-
}
|
|
180
|
-
const size = formatMb(item.sizeBytes ?? 0)
|
|
181
|
-
ver.append(
|
|
182
|
-
el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
|
|
183
|
-
)
|
|
184
|
-
main.append(ver)
|
|
185
|
-
left.append(avatar, main)
|
|
186
|
-
|
|
187
|
-
const right = el('div', 'hqpu-card-end')
|
|
188
|
-
if (item.state === 'update' || item.state === 'install') {
|
|
189
|
-
const install = el('button', 'hqpu-log-toggle', copy.installOne)
|
|
190
|
-
install.type = 'button'
|
|
191
|
-
install.title = copy.installOneTip
|
|
192
|
-
install.addEventListener('click', onInstall)
|
|
193
|
-
const ignore = el('button', 'hqpu-log-toggle', copy.ignore)
|
|
194
|
-
ignore.type = 'button'
|
|
195
|
-
ignore.addEventListener('click', onIgnore)
|
|
196
|
-
right.append(install, ignore)
|
|
197
|
-
} else if (item.state === 'ignored') {
|
|
198
|
-
const show = el('button', 'hqpu-log-toggle', copy.unignore)
|
|
199
|
-
show.type = 'button'
|
|
200
|
-
show.addEventListener('click', onIgnore)
|
|
201
|
-
right.append(show)
|
|
202
|
-
}
|
|
203
|
-
if (Array.isArray(item.changes) && item.changes.length > 0) {
|
|
204
|
-
const toggle = el('button', 'hqpu-log-toggle')
|
|
205
|
-
toggle.type = 'button'
|
|
206
|
-
toggle.dataset.open = open ? 'true' : 'false'
|
|
207
|
-
toggle.innerHTML = `${copy.ui.changelogBtn}<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>`
|
|
208
|
-
toggle.addEventListener('click', onToggle)
|
|
209
|
-
right.append(toggle)
|
|
210
|
-
}
|
|
211
|
-
head.append(left, right)
|
|
212
|
-
card.append(head)
|
|
213
|
-
|
|
214
|
-
// Inline changelog drawer (actionable items only).
|
|
215
|
-
if (Array.isArray(item.changes) && item.changes.length > 0) {
|
|
216
|
-
card.append(changelogDrawer(item, copy, open))
|
|
217
|
-
}
|
|
218
|
-
return card
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** A section: head (title + count pill + expand-all toggle) then its cards. */
|
|
222
|
-
function group(
|
|
223
|
-
heading: string,
|
|
224
|
-
items: PluginUpdateItem[],
|
|
225
|
-
copy: UpdateCopy,
|
|
226
|
-
changelogOpen: Set<string>,
|
|
227
|
-
allOpen: boolean,
|
|
228
|
-
onToggle: (name: string) => void,
|
|
229
|
-
onToggleAll: () => void,
|
|
230
|
-
onInstall: (name: string) => void,
|
|
231
|
-
onIgnore: (item: PluginUpdateItem) => void,
|
|
232
|
-
): HTMLElement | null {
|
|
233
|
-
if (items.length === 0) return null
|
|
234
|
-
const section = el('section', 'hqpu-group')
|
|
235
|
-
section.dataset.group = heading
|
|
236
|
-
|
|
237
|
-
const head = el('div', 'hqpu-section-head')
|
|
238
|
-
const titleWrap = el('div', 'hqpu-section-title')
|
|
239
|
-
titleWrap.append(el('span', '', heading))
|
|
240
|
-
titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
|
|
241
|
-
head.append(titleWrap)
|
|
242
|
-
// Expand-all only makes sense over actionable, changelog-bearing cards.
|
|
243
|
-
const actionable = items.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
|
|
244
|
-
if (actionable) {
|
|
245
|
-
const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
|
|
246
|
-
all.type = 'button'
|
|
247
|
-
all.addEventListener('click', onToggleAll)
|
|
248
|
-
head.append(all)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const list = el('ul', 'hqpu-cards')
|
|
252
|
-
for (const item of items) {
|
|
253
|
-
list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name), () => onInstall(item.name), () => onIgnore(item)))
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
section.append(head, list)
|
|
257
|
-
return section
|
|
152
|
+
/** The name a card shows: the manifest's displayName, else the short name. */
|
|
153
|
+
function displayNameOf(item: PluginUpdateItem): string {
|
|
154
|
+
return typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
155
|
+
? item.displayName.trim()
|
|
156
|
+
: shortName(item.name)
|
|
258
157
|
}
|
|
259
158
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const section = el('section', 'hqpu-group')
|
|
263
|
-
section.setAttribute('role', 'status')
|
|
264
|
-
section.dataset.loading = ''
|
|
265
|
-
const head = el('div', 'hqpu-group-head')
|
|
266
|
-
head.setAttribute('aria-hidden', 'true')
|
|
267
|
-
const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
|
|
268
|
-
const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
|
|
269
|
-
heading.append(headingBar)
|
|
270
|
-
head.append(heading)
|
|
271
|
-
|
|
272
|
-
const list = el('ul', 'hqpu-cards')
|
|
273
|
-
list.setAttribute('aria-hidden', 'true')
|
|
274
|
-
for (let index = 0; index < 3; index += 1) {
|
|
275
|
-
const card = el('li', 'hqpu-card')
|
|
276
|
-
const cardHead = el('div', 'hqpu-card-head')
|
|
277
|
-
const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
|
|
278
|
-
const main = el('div', 'hqpu-card-main')
|
|
279
|
-
const titleRow = el('div', 'hqpu-title-row')
|
|
280
|
-
const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
|
|
281
|
-
title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
282
|
-
titleRow.append(title)
|
|
283
|
-
const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
|
|
284
|
-
desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
285
|
-
main.append(titleRow, desc)
|
|
286
|
-
cardHead.append(glyph, main)
|
|
287
|
-
const end = el('div', 'hqpu-card-end')
|
|
288
|
-
end.setAttribute('aria-hidden', 'true')
|
|
289
|
-
cardHead.append(end)
|
|
290
|
-
card.append(cardHead)
|
|
291
|
-
list.append(card)
|
|
292
|
-
}
|
|
293
|
-
section.append(head, list)
|
|
294
|
-
// The skeleton replaces the whole column, so label it for screen readers.
|
|
295
|
-
section.setAttribute('aria-label', copy.loading)
|
|
296
|
-
return section
|
|
159
|
+
function isActionable(item: PluginUpdateItem): boolean {
|
|
160
|
+
return item.state === 'update' || item.state === 'install'
|
|
297
161
|
}
|
|
298
162
|
|
|
299
|
-
// ──
|
|
163
|
+
// ── Time / size formatting ────────────────────────────────────────────────
|
|
300
164
|
|
|
301
165
|
/** Format a byte count for the progress line, or null when unknown. */
|
|
302
166
|
function formatBytes(n: number | undefined): string | null {
|
|
@@ -307,16 +171,7 @@ function formatBytes(n: number | undefined): string | null {
|
|
|
307
171
|
return `${n} B`
|
|
308
172
|
}
|
|
309
173
|
|
|
310
|
-
/**
|
|
311
|
-
function formatEta(seconds: number | undefined): string | null {
|
|
312
|
-
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
|
|
313
|
-
if (seconds < 60) return `${Math.round(seconds)} s`
|
|
314
|
-
const minutes = Math.round(seconds / 60)
|
|
315
|
-
if (minutes < 60) return `${minutes} min`
|
|
316
|
-
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
|
|
174
|
+
/** "N 分钟前" — relative time from an ISO timestamp. */
|
|
320
175
|
function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
|
|
321
176
|
if (typeof iso !== 'string' || iso.length === 0) return null
|
|
322
177
|
const then = new Date(iso).getTime()
|
|
@@ -329,6 +184,19 @@ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string |
|
|
|
329
184
|
return copy.ui.agoDays(Math.round(hours / 24))
|
|
330
185
|
}
|
|
331
186
|
|
|
187
|
+
/** Short absolute time for log rows: "10/29 14:34". */
|
|
188
|
+
function shortWhen(iso: string | null | undefined): string {
|
|
189
|
+
if (typeof iso !== 'string' || iso.length === 0) return ''
|
|
190
|
+
const at = new Date(iso)
|
|
191
|
+
if (Number.isNaN(at.getTime())) return ''
|
|
192
|
+
return at.toLocaleString(undefined, {
|
|
193
|
+
month: '2-digit',
|
|
194
|
+
day: '2-digit',
|
|
195
|
+
hour: '2-digit',
|
|
196
|
+
minute: '2-digit',
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
332
200
|
function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
333
201
|
return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
|
|
334
202
|
}
|
|
@@ -338,39 +206,13 @@ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
|
338
206
|
return copy.job.phase[job.phase] ?? job.phase
|
|
339
207
|
}
|
|
340
208
|
|
|
341
|
-
/** One per-step line for plugin-set installs (and any job that reports steps). */
|
|
342
|
-
function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
|
|
343
|
-
if (!Array.isArray(job.steps) || job.steps.length === 0) return null
|
|
344
|
-
const wrap = el('div', 'hqpu-job-steps')
|
|
345
|
-
wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
|
|
346
|
-
const ol = el('ol', 'hqpu-steps')
|
|
347
|
-
for (const step of job.steps) {
|
|
348
|
-
const li = el('li', `hqpu-step hqpu-step--${step.state}`)
|
|
349
|
-
const dot = el('span', 'hqpu-dot')
|
|
350
|
-
dot.dataset.state =
|
|
351
|
-
step.state === 'installed'
|
|
352
|
-
? 'done'
|
|
353
|
-
: step.state === 'failed'
|
|
354
|
-
? 'error'
|
|
355
|
-
: step.state === 'running'
|
|
356
|
-
? 'warning'
|
|
357
|
-
: 'idle'
|
|
358
|
-
li.append(dot)
|
|
359
|
-
const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
|
|
360
|
-
li.append(el('span', '', text))
|
|
361
|
-
ol.append(li)
|
|
362
|
-
}
|
|
363
|
-
wrap.append(ol)
|
|
364
|
-
return wrap
|
|
365
|
-
}
|
|
366
|
-
|
|
367
209
|
/** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
|
|
368
210
|
function stepIndex(phase: UpdateJob['phase']): number {
|
|
369
211
|
switch (phase) {
|
|
370
212
|
case 'downloading':
|
|
213
|
+
case 'resolving':
|
|
371
214
|
return 0
|
|
372
215
|
case 'verifying':
|
|
373
|
-
case 'resolving':
|
|
374
216
|
return 1
|
|
375
217
|
case 'staging':
|
|
376
218
|
case 'activating':
|
|
@@ -380,25 +222,228 @@ function stepIndex(phase: UpdateJob['phase']): number {
|
|
|
380
222
|
case 'verifying-after-restart':
|
|
381
223
|
return 3
|
|
382
224
|
case 'queued':
|
|
383
|
-
return -1
|
|
384
225
|
default:
|
|
385
226
|
return -1
|
|
386
227
|
}
|
|
387
228
|
}
|
|
388
229
|
|
|
230
|
+
// ── Section pieces ────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A collapsible section: chevron + title + count, an optional right-hand note,
|
|
234
|
+
* and a body that only exists in the DOM while open.
|
|
235
|
+
*
|
|
236
|
+
* Native `<details>` was the alternative, but it cannot express the
|
|
237
|
+
* "collapsed by default, opened by state" rule the 已是最新 group needs without
|
|
238
|
+
* a JS patch anyway, and its summary cannot host the trailing note in one row.
|
|
239
|
+
*/
|
|
240
|
+
function disclosure(opts: {
|
|
241
|
+
id: string
|
|
242
|
+
title: string
|
|
243
|
+
count?: string
|
|
244
|
+
note?: string
|
|
245
|
+
open: boolean
|
|
246
|
+
onToggle: () => void
|
|
247
|
+
build: () => HTMLElement[]
|
|
248
|
+
}): HTMLElement {
|
|
249
|
+
const section = el('section', 'hqpu-disc')
|
|
250
|
+
// The title doubles as the group hook (`[data-group="已是最新"]`), matching
|
|
251
|
+
// the actionable group's convention so callers need one selector style.
|
|
252
|
+
section.dataset.group = opts.title
|
|
253
|
+
const head = el('button', 'hqpu-disc-head')
|
|
254
|
+
head.type = 'button'
|
|
255
|
+
head.setAttribute('aria-expanded', opts.open ? 'true' : 'false')
|
|
256
|
+
head.setAttribute('aria-controls', opts.id)
|
|
257
|
+
|
|
258
|
+
const left = el('span', 'hqpu-disc-left')
|
|
259
|
+
const chevron = el('span', 'hqpu-disc-chevron')
|
|
260
|
+
chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
|
|
261
|
+
left.append(chevron, el('span', 'hqpu-disc-title', opts.title))
|
|
262
|
+
if (opts.count !== undefined) left.append(el('span', 'hqpu-disc-count', opts.count))
|
|
263
|
+
head.append(left)
|
|
264
|
+
if (opts.note !== undefined && opts.note.length > 0) {
|
|
265
|
+
head.append(el('span', 'hqpu-disc-note', opts.note))
|
|
266
|
+
}
|
|
267
|
+
head.addEventListener('click', opts.onToggle)
|
|
268
|
+
section.append(head)
|
|
269
|
+
|
|
270
|
+
if (opts.open) {
|
|
271
|
+
const body = el('div', 'hqpu-disc-body')
|
|
272
|
+
body.id = opts.id
|
|
273
|
+
for (const node of opts.build()) body.append(node)
|
|
274
|
+
section.append(body)
|
|
275
|
+
}
|
|
276
|
+
return section
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One muted row in a collapsed group (current or ignored plugin). */
|
|
280
|
+
function pluginRow(
|
|
281
|
+
item: PluginUpdateItem,
|
|
282
|
+
copy: UpdateCopy,
|
|
283
|
+
onToggleIgnore: () => void,
|
|
284
|
+
): HTMLLIElement {
|
|
285
|
+
const row = el('li', 'hqpu-row')
|
|
286
|
+
row.dataset.pluginName = item.name
|
|
287
|
+
row.dataset.state = item.state
|
|
288
|
+
row.append(el('span', 'hqpu-row-name', displayNameOf(item)))
|
|
289
|
+
const end = el('span', 'hqpu-row-end')
|
|
290
|
+
const version = item.currentVersion ?? item.availableVersion
|
|
291
|
+
end.append(el('span', 'hqpu-row-meta', copy.ui.upToDateRow(version)))
|
|
292
|
+
if (item.state === 'ignored') {
|
|
293
|
+
end.append(
|
|
294
|
+
linkButton('hqpu-link hqpu-link--tiny', copy.unignore, onToggleIgnore),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
row.append(end)
|
|
298
|
+
return row
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One actionable plugin card.
|
|
303
|
+
*
|
|
304
|
+
* Actions live on the card, not inside the changelog drawer: the drawer is for
|
|
305
|
+
* content (what changed), and hiding 单独更新 / 忽略 behind a disclosure made
|
|
306
|
+
* the set-level footer CTA the only discoverable action.
|
|
307
|
+
*/
|
|
308
|
+
function pluginCard(
|
|
309
|
+
item: PluginUpdateItem,
|
|
310
|
+
copy: UpdateCopy,
|
|
311
|
+
opts: { changesOpen: boolean; onToggleChanges: () => void; onInstall: () => void; onIgnore: () => void; busy: boolean },
|
|
312
|
+
): HTMLLIElement {
|
|
313
|
+
const card = el('li', 'hqpu-card')
|
|
314
|
+
card.dataset.pluginName = item.name
|
|
315
|
+
card.dataset.state = item.state
|
|
316
|
+
|
|
317
|
+
const head = el('div', 'hqpu-card-head')
|
|
318
|
+
const id = el('div', 'hqpu-card-id')
|
|
319
|
+
const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
|
|
320
|
+
avatar.setAttribute('aria-hidden', 'true')
|
|
321
|
+
const main = el('div', 'hqpu-card-main')
|
|
322
|
+
main.append(el('span', 'hqpu-card-title', displayNameOf(item)))
|
|
323
|
+
main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
|
|
324
|
+
id.append(avatar, main)
|
|
325
|
+
|
|
326
|
+
const end = el('div', 'hqpu-card-end')
|
|
327
|
+
const install = el(
|
|
328
|
+
'button',
|
|
329
|
+
'hqpu-card-action hqpu-card-action--install',
|
|
330
|
+
item.state === 'install' ? copy.ui.singleInstall : copy.ui.singleUpdate,
|
|
331
|
+
)
|
|
332
|
+
install.type = 'button'
|
|
333
|
+
install.title = copy.ui.singleUpdateTip
|
|
334
|
+
install.disabled = opts.busy
|
|
335
|
+
install.addEventListener('click', opts.onInstall)
|
|
336
|
+
const ignore = iconButton(
|
|
337
|
+
'hqpu-icon-btn hqpu-icon-btn--sm',
|
|
338
|
+
EYE_OFF_ICON(14),
|
|
339
|
+
copy.ui.ignorePlugin,
|
|
340
|
+
opts.onIgnore,
|
|
341
|
+
)
|
|
342
|
+
ignore.disabled = opts.busy
|
|
343
|
+
end.append(install, ignore)
|
|
344
|
+
head.append(id, end)
|
|
345
|
+
card.append(head)
|
|
346
|
+
|
|
347
|
+
// Version row + the changelog toggle, on one line.
|
|
348
|
+
const foot = el('div', 'hqpu-card-foot')
|
|
349
|
+
const ver = el('div', 'hqpu-version-row')
|
|
350
|
+
if (item.currentVersion !== null && item.currentVersion !== undefined) {
|
|
351
|
+
ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
|
|
352
|
+
ver.append(el('span', 'hqpu-ver-arrow', '→'))
|
|
353
|
+
}
|
|
354
|
+
ver.append(el('span', 'hqpu-ver-new', item.availableVersion))
|
|
355
|
+
const size = formatMb(item.sizeBytes ?? 0)
|
|
356
|
+
if (size.length > 0) ver.append(el('span', 'hqpu-ver-size', `(${size})`))
|
|
357
|
+
foot.append(ver)
|
|
358
|
+
|
|
359
|
+
const changes = Array.isArray(item.changes)
|
|
360
|
+
? item.changes.filter((c) => typeof c === 'string' && c.length > 0)
|
|
361
|
+
: []
|
|
362
|
+
if (changes.length > 0) {
|
|
363
|
+
const toggle = el('button', 'hqpu-toggle')
|
|
364
|
+
toggle.type = 'button'
|
|
365
|
+
toggle.dataset.changesToggle = ''
|
|
366
|
+
toggle.dataset.open = opts.changesOpen ? 'true' : 'false'
|
|
367
|
+
toggle.setAttribute('aria-expanded', opts.changesOpen ? 'true' : 'false')
|
|
368
|
+
toggle.append(el('span', '', copy.ui.changesToggle))
|
|
369
|
+
const chevron = el('span', 'hqpu-toggle-chevron')
|
|
370
|
+
chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
|
|
371
|
+
toggle.append(chevron)
|
|
372
|
+
toggle.addEventListener('click', opts.onToggleChanges)
|
|
373
|
+
foot.append(toggle)
|
|
374
|
+
}
|
|
375
|
+
card.append(foot)
|
|
376
|
+
|
|
377
|
+
if (changes.length > 0 && opts.changesOpen) {
|
|
378
|
+
const drawer = el('div', 'hqpu-changes')
|
|
379
|
+
drawer.append(el('p', 'hqpu-changes-title', copy.ui.changesTitle(item.availableVersion)))
|
|
380
|
+
const ul = el('ul')
|
|
381
|
+
for (const change of changes) ul.append(el('li', '', change))
|
|
382
|
+
drawer.append(ul)
|
|
383
|
+
card.append(drawer)
|
|
384
|
+
}
|
|
385
|
+
return card
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The "what do I actually have to decide" band, above every list. */
|
|
389
|
+
function decisionBand(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement | null {
|
|
390
|
+
const plugins = actionableUpdateCount(report)
|
|
391
|
+
const runtime = runtimeUpdateOffered(report.runtime)
|
|
392
|
+
if (plugins === 0 && !runtime) return null
|
|
393
|
+
const band = el('div', 'hqpu-decision')
|
|
394
|
+
band.dataset.role = 'decision-band'
|
|
395
|
+
if (plugins > 0) band.append(el('span', 'hqpu-decision-main', copy.ui.summaryPlugins(plugins)))
|
|
396
|
+
if (plugins > 0 && runtime) {
|
|
397
|
+
const dot = el('span', 'hqpu-decision-dot')
|
|
398
|
+
dot.setAttribute('aria-hidden', 'true')
|
|
399
|
+
band.append(dot)
|
|
400
|
+
}
|
|
401
|
+
if (runtime) band.append(el('span', 'hqpu-decision-sub', copy.ui.summaryRuntime))
|
|
402
|
+
return band
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Check-status banner. A FAILED check is a red alert with its reason and a
|
|
407
|
+
* retry — never the silent "no updates" empty state, which reads as success.
|
|
408
|
+
*/
|
|
409
|
+
function checkBanner(
|
|
410
|
+
report: PluginUpdateReport,
|
|
411
|
+
copy: UpdateCopy,
|
|
412
|
+
onRetry: () => void,
|
|
413
|
+
): HTMLElement | null {
|
|
414
|
+
const failed = typeof report.lastError === 'string' && report.lastError.length > 0
|
|
415
|
+
if (!failed) return null
|
|
416
|
+
const section = el('section', 'hqpu-group')
|
|
417
|
+
section.dataset.group = 'check-status'
|
|
418
|
+
const card = el('div', 'hqpu-error')
|
|
419
|
+
card.dataset.tone = 'danger'
|
|
420
|
+
const head = el('div', 'hqpu-error-head')
|
|
421
|
+
const icon = el('span', 'hqpu-error-icon')
|
|
422
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
423
|
+
const text = el('div', 'hqpu-error-text')
|
|
424
|
+
text.append(el('p', 'hqpu-error-title', copy.checkFailedTitle))
|
|
425
|
+
text.append(el('p', 'hqpu-error-body', report.lastError as string))
|
|
426
|
+
head.append(icon, text)
|
|
427
|
+
card.append(head)
|
|
428
|
+
|
|
429
|
+
const foot = el('div', 'hqpu-error-foot')
|
|
430
|
+
if (report.items.length > 0) foot.append(el('span', '', copy.staleNote))
|
|
431
|
+
foot.append(linkButton('hqpu-link', copy.retryNow, onRetry))
|
|
432
|
+
card.append(foot)
|
|
433
|
+
section.append(card)
|
|
434
|
+
return section
|
|
435
|
+
}
|
|
436
|
+
|
|
389
437
|
/**
|
|
390
|
-
* The
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* (done=emerald, active=pulsing brand, upcoming=muted).
|
|
438
|
+
* The active job card: title (with the target version) + percent, a
|
|
439
|
+
* determinate bar when the server publishes one, phase + telemetry, the honest
|
|
440
|
+
* "closing does not cancel" note, and the 4-step pipeline.
|
|
394
441
|
*/
|
|
395
442
|
function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
396
443
|
const section = el('section', 'hqpu-group')
|
|
397
444
|
section.dataset.group = 'active-job'
|
|
398
|
-
|
|
399
445
|
const card = el('div', 'hqpu-active')
|
|
400
446
|
|
|
401
|
-
// Top row: pulse dot + title, mono percent.
|
|
402
447
|
const top = el('div', 'hqpu-active-top')
|
|
403
448
|
const titleWrap = el('div', 'hqpu-active-title')
|
|
404
449
|
const pulse = el('span', 'hqpu-pulse-dot')
|
|
@@ -407,23 +452,24 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
407
452
|
job.kind === 'runtime'
|
|
408
453
|
? copy.ui.jobRuntimeTitle(job.targetVersion)
|
|
409
454
|
: copy.ui.jobPluginTitle(job.targetVersion)
|
|
410
|
-
titleWrap.append(pulse, el('span', '', title))
|
|
455
|
+
titleWrap.append(pulse, el('span', 'hqpu-active-label', title))
|
|
456
|
+
top.append(titleWrap)
|
|
411
457
|
const pct = job.progress.percent
|
|
412
|
-
if (pct !== null) {
|
|
413
|
-
top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
|
|
414
|
-
}
|
|
458
|
+
if (pct !== null) top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
|
|
415
459
|
card.append(top)
|
|
416
460
|
|
|
417
|
-
// Gradient bar — only when the server publishes a real percentage.
|
|
418
461
|
if (pct !== null) {
|
|
419
462
|
const bar = el('div', 'hqpu-progress')
|
|
463
|
+
bar.setAttribute('role', 'progressbar')
|
|
464
|
+
bar.setAttribute('aria-valuemin', '0')
|
|
465
|
+
bar.setAttribute('aria-valuemax', '100')
|
|
466
|
+
bar.setAttribute('aria-valuenow', String(Math.round(pct)))
|
|
420
467
|
const fill = el('div', 'hqpu-progress-fill')
|
|
421
468
|
fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
|
|
422
469
|
bar.append(fill)
|
|
423
470
|
card.append(bar)
|
|
424
471
|
}
|
|
425
472
|
|
|
426
|
-
// Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
|
|
427
473
|
const meta = el('div', 'hqpu-active-meta')
|
|
428
474
|
const phaseText =
|
|
429
475
|
typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
|
|
@@ -439,28 +485,29 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
439
485
|
: done !== null && total !== null
|
|
440
486
|
? copy.job.bytes(done, total)
|
|
441
487
|
: ''
|
|
442
|
-
if (telemetry.length > 0) meta.append(el('span', '', telemetry))
|
|
488
|
+
if (telemetry.length > 0) meta.append(el('span', 'hqpu-num', telemetry))
|
|
443
489
|
card.append(meta)
|
|
444
490
|
|
|
445
|
-
// previous → target
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
491
|
+
// previous → target on its own line (mono), then what happens next. A
|
|
492
|
+
// plugin set keeps the "closing does not cancel" note; a runtime swap has a
|
|
493
|
+
// stronger one of its own (HQ Edge exits, the host swaps it out).
|
|
494
|
+
card.append(
|
|
449
495
|
el(
|
|
450
|
-
'
|
|
451
|
-
'',
|
|
496
|
+
'div',
|
|
497
|
+
'hqpu-active-versions',
|
|
452
498
|
job.previousVersion !== null && job.previousVersion.length > 0
|
|
453
499
|
? `${job.previousVersion} → ${job.targetVersion}`
|
|
454
500
|
: job.targetVersion,
|
|
455
501
|
),
|
|
456
502
|
)
|
|
457
|
-
|
|
458
|
-
el(
|
|
503
|
+
card.append(
|
|
504
|
+
el(
|
|
505
|
+
'p',
|
|
506
|
+
'hqpu-active-note',
|
|
507
|
+
job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.ui.activeCloseNote,
|
|
508
|
+
),
|
|
459
509
|
)
|
|
460
|
-
card.append(transition)
|
|
461
|
-
card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
|
|
462
510
|
|
|
463
|
-
// 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
|
|
464
511
|
const current = stepIndex(job.phase)
|
|
465
512
|
const stepsRow = el('div', 'hqpu-job-steps4')
|
|
466
513
|
copy.ui.jobSteps.forEach((label: string, index: number) => {
|
|
@@ -472,7 +519,9 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
472
519
|
: index === current
|
|
473
520
|
? 'hqpu-job-step--active'
|
|
474
521
|
: 'hqpu-job-step--todo'
|
|
475
|
-
|
|
522
|
+
// Numbered like the prototype's pipeline ("1. 下载"): the count is the
|
|
523
|
+
// only cue for how much is left when the active step blinks.
|
|
524
|
+
stepsRow.append(el('div', cls, `${index + 1}. ${label}`))
|
|
476
525
|
})
|
|
477
526
|
card.append(stepsRow)
|
|
478
527
|
|
|
@@ -480,226 +529,410 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
480
529
|
return section
|
|
481
530
|
}
|
|
482
531
|
|
|
532
|
+
/** Phase → the word the log rows use for a component. */
|
|
533
|
+
function stepWord(state: UpdateStep['state'], copy: UpdateCopy): string {
|
|
534
|
+
switch (state) {
|
|
535
|
+
case 'installed':
|
|
536
|
+
return copy.job.phase.done ?? ''
|
|
537
|
+
case 'skipped':
|
|
538
|
+
return copy.ui.stepSkipped
|
|
539
|
+
case 'failed':
|
|
540
|
+
return copy.job.phase.failed ?? ''
|
|
541
|
+
case 'running':
|
|
542
|
+
return copy.job.phase.downloading ?? ''
|
|
543
|
+
default:
|
|
544
|
+
return copy.ui.stepPending
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Dot tone for one step row. */
|
|
549
|
+
function stepDotState(state: UpdateStep['state']): string {
|
|
550
|
+
switch (state) {
|
|
551
|
+
case 'installed':
|
|
552
|
+
return 'done'
|
|
553
|
+
case 'failed':
|
|
554
|
+
return 'error'
|
|
555
|
+
case 'running':
|
|
556
|
+
return 'warning'
|
|
557
|
+
default:
|
|
558
|
+
return 'idle'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
483
562
|
/**
|
|
484
|
-
* The
|
|
485
|
-
*
|
|
486
|
-
*
|
|
563
|
+
* The terminal-result banner for a job that did NOT succeed.
|
|
564
|
+
*
|
|
565
|
+
* A successful install needs no banner — the list and the footer already say
|
|
566
|
+
* "everything is current". A failure does: the error code and the retry must be
|
|
567
|
+
* on screen, not one drawer-click away.
|
|
487
568
|
*/
|
|
488
|
-
function
|
|
569
|
+
function jobResultBanner(
|
|
570
|
+
job: UpdateJob,
|
|
571
|
+
copy: UpdateCopy,
|
|
572
|
+
onRetry: () => void,
|
|
573
|
+
onOpenLog: () => void,
|
|
574
|
+
): HTMLElement {
|
|
489
575
|
const section = el('section', 'hqpu-group')
|
|
490
576
|
section.dataset.group = 'job-result'
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const head = el('div', 'hqpu-group-head')
|
|
504
|
-
const tag = el('span', 'hqpu-tag')
|
|
505
|
-
tag.dataset.tone = tone
|
|
506
|
-
tag.textContent = titleText
|
|
507
|
-
head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
|
|
508
|
-
section.append(head)
|
|
509
|
-
|
|
510
|
-
const card = el('div', 'hqpu-job')
|
|
577
|
+
const tone = job.phase === 'failed' ? 'danger' : 'warning'
|
|
578
|
+
const card = el('div', 'hqpu-error')
|
|
579
|
+
card.dataset.tone = tone
|
|
580
|
+
|
|
581
|
+
const head = el('div', 'hqpu-error-head')
|
|
582
|
+
const icon = el('span', 'hqpu-error-icon')
|
|
583
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
584
|
+
const text = el('div', 'hqpu-error-text')
|
|
585
|
+
const title =
|
|
586
|
+
job.phase === 'rolled-back' ? copy.job.resultRolledBack : copy.job.resultFailed
|
|
587
|
+
text.append(el('p', 'hqpu-error-title', title))
|
|
511
588
|
const res = job.result
|
|
512
589
|
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
513
590
|
const to = res?.installedVersion ?? job.targetVersion
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
|
|
519
|
-
if (res !== undefined && res.message !== undefined && res.message.length > 0) {
|
|
520
|
-
card.append(el('p', 'hqpu-status', res.message))
|
|
521
|
-
}
|
|
522
|
-
if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
|
|
523
|
-
card.append(el('p', 'hqpu-status', res.errorCode))
|
|
591
|
+
const when = res !== undefined && res.finishedAt.length > 0 ? ` · ${shortWhen(res.finishedAt)}` : ''
|
|
592
|
+
text.append(el('p', 'hqpu-error-body', `${from} → ${to}${when}`))
|
|
593
|
+
if (res?.message !== undefined && res.message.length > 0) {
|
|
594
|
+
text.append(el('p', 'hqpu-error-body', res.message))
|
|
524
595
|
}
|
|
525
|
-
if (
|
|
526
|
-
|
|
596
|
+
if (res?.errorCode !== undefined && res.errorCode.length > 0) {
|
|
597
|
+
text.append(el('code', 'hqpu-error-code', res.errorCode))
|
|
527
598
|
}
|
|
599
|
+
head.append(icon, text)
|
|
600
|
+
card.append(head)
|
|
528
601
|
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
retry.addEventListener('click', onRetry)
|
|
537
|
-
card.append(retry)
|
|
538
|
-
}
|
|
602
|
+
const foot = el('div', 'hqpu-error-foot')
|
|
603
|
+
foot.append(el('span', '', jobKindLabel(job, copy)))
|
|
604
|
+
const actions = el('span', 'hqpu-error-actions')
|
|
605
|
+
actions.append(linkButton('hqpu-link', copy.ui.viewLog, onOpenLog))
|
|
606
|
+
actions.append(linkButton('hqpu-link', copy.job.retry, onRetry))
|
|
607
|
+
foot.append(actions)
|
|
608
|
+
card.append(foot)
|
|
539
609
|
section.append(card)
|
|
540
610
|
return section
|
|
541
611
|
}
|
|
542
612
|
|
|
543
|
-
/** A one-line row for older recent jobs (newest already shown as the card). */
|
|
544
|
-
function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
|
|
545
|
-
const older = jobs.slice(1, 5)
|
|
546
|
-
if (older.length === 0) return null
|
|
547
|
-
const wrap = el('div', 'hqpu-group')
|
|
548
|
-
wrap.dataset.group = 'recent-jobs'
|
|
549
|
-
wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
|
|
550
|
-
const ul = el('ul', 'hqpu-cards')
|
|
551
|
-
for (const job of older) {
|
|
552
|
-
const li = el('li', 'hqpu-status')
|
|
553
|
-
const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
|
|
554
|
-
li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
|
|
555
|
-
ul.append(li)
|
|
556
|
-
}
|
|
557
|
-
wrap.append(ul)
|
|
558
|
-
return wrap
|
|
559
|
-
}
|
|
560
|
-
|
|
561
613
|
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
614
|
+
* The host's own report of the last runtime swap. Rolled-back / failed renders
|
|
615
|
+
* as an AMBER alert card; succeeded renders as a quiet green line. The plugin
|
|
616
|
+
* has no log-open affordance for this, so none is invented.
|
|
565
617
|
*/
|
|
566
|
-
function
|
|
618
|
+
function runtimeResultAlert(
|
|
567
619
|
report: PluginUpdateReport,
|
|
568
620
|
copy: UpdateCopy,
|
|
569
|
-
|
|
621
|
+
dismissed: boolean,
|
|
622
|
+
onDismiss: () => void,
|
|
570
623
|
): HTMLElement | null {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
if (!failed && !checked) return null
|
|
624
|
+
const lastResult = report.runtime.lastResult
|
|
625
|
+
if (lastResult === null || lastResult === undefined) return null
|
|
574
626
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
banner.innerHTML = WARNING_ICON(14)
|
|
581
|
-
banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
|
|
582
|
-
wrap.append(banner)
|
|
583
|
-
if (report.items.length > 0) {
|
|
584
|
-
wrap.append(el('p', 'hqpu-status', copy.staleNote))
|
|
585
|
-
}
|
|
586
|
-
const row = el('div', 'hqpu-actions')
|
|
587
|
-
const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
|
|
588
|
-
retry.type = 'button'
|
|
589
|
-
retry.addEventListener('click', onRetry)
|
|
590
|
-
row.append(retry)
|
|
591
|
-
wrap.append(row)
|
|
592
|
-
return wrap
|
|
627
|
+
if (lastResult.state === 'succeeded') {
|
|
628
|
+
const line = el('p', 'hqpu-alert-green')
|
|
629
|
+
line.dataset.role = 'runtime-success'
|
|
630
|
+
line.textContent = copy.ui.successInline
|
|
631
|
+
return line
|
|
593
632
|
}
|
|
594
|
-
|
|
595
|
-
el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
|
|
596
|
-
)
|
|
597
|
-
return wrap
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
601
|
-
function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
|
|
602
|
-
if (release === null || release === undefined) return null
|
|
603
|
-
const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
|
|
604
|
-
const breaking = Array.isArray(release.breaking)
|
|
605
|
-
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
606
|
-
: []
|
|
607
|
-
const hasContent =
|
|
608
|
-
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
609
|
-
notes.length > 0 ||
|
|
610
|
-
breaking.length > 0 ||
|
|
611
|
-
typeof release.notesUrl === 'string' ||
|
|
612
|
-
typeof release.publishedAt === 'string'
|
|
613
|
-
if (!hasContent) return null
|
|
633
|
+
if (dismissed) return null
|
|
614
634
|
|
|
615
635
|
const section = el('section', 'hqpu-group')
|
|
616
|
-
section.dataset.group = '
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
636
|
+
section.dataset.group = 'runtime-alert'
|
|
637
|
+
const card = el('div', 'hqpu-alert hqpu-alert--amber')
|
|
638
|
+
const icon = el('span', 'hqpu-alert-icon')
|
|
639
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
640
|
+
card.append(icon)
|
|
641
|
+
|
|
642
|
+
const body = el('div', 'hqpu-alert-main')
|
|
643
|
+
body.append(
|
|
644
|
+
el(
|
|
645
|
+
'div',
|
|
646
|
+
'hqpu-alert-title',
|
|
647
|
+
lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle,
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
|
|
651
|
+
const bodyP = el('p', 'hqpu-alert-body')
|
|
652
|
+
bodyP.append(
|
|
653
|
+
document.createTextNode(
|
|
654
|
+
lastResult.state === 'failed'
|
|
655
|
+
? copy.ui.failedBody(oldVer, '')
|
|
656
|
+
: copy.ui.rollbackBody(oldVer, ''),
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
660
|
+
bodyP.append(el('code', '', lastResult.errorCode))
|
|
641
661
|
}
|
|
642
|
-
if (typeof
|
|
643
|
-
|
|
644
|
-
link.href = release.notesUrl
|
|
645
|
-
link.target = '_blank'
|
|
646
|
-
link.rel = 'noreferrer'
|
|
647
|
-
section.append(link)
|
|
662
|
+
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
663
|
+
bodyP.append(el('br'), document.createTextNode(lastResult.message))
|
|
648
664
|
}
|
|
665
|
+
body.append(bodyP)
|
|
666
|
+
const actions = el('div', 'hqpu-alert-actions')
|
|
667
|
+
actions.append(linkButton('hqpu-alert-dismiss', copy.ui.dismiss, onDismiss))
|
|
668
|
+
body.append(actions)
|
|
669
|
+
card.append(body)
|
|
670
|
+
section.append(card)
|
|
649
671
|
return section
|
|
650
672
|
}
|
|
651
673
|
|
|
652
|
-
/**
|
|
653
|
-
*
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
674
|
+
/**
|
|
675
|
+
* The HQ Edge runtime card: current chip, the offered version with its size and
|
|
676
|
+
* summary, and the 升级核心 action — arm → confirm, because a runtime swap
|
|
677
|
+
* shuts HQ Edge down and is not undoable from here.
|
|
678
|
+
*/
|
|
679
|
+
function runtimeEngineCard(
|
|
680
|
+
report: PluginUpdateReport,
|
|
681
|
+
copy: UpdateCopy,
|
|
682
|
+
actions: RuntimeActions,
|
|
683
|
+
): HTMLElement | null {
|
|
684
|
+
const runtime = report.runtime
|
|
685
|
+
const latestVersion = runtimeLatestVersion(runtime)
|
|
686
|
+
const card = el('section', 'hqpu-engine')
|
|
687
|
+
card.dataset.group = 'runtime'
|
|
688
|
+
|
|
689
|
+
const head = el('div', 'hqpu-engine-head')
|
|
690
|
+
const titleWrap = el('div', 'hqpu-engine-title')
|
|
691
|
+
const bolt = el('span', 'hqpu-engine-bolt')
|
|
692
|
+
bolt.innerHTML = BOLT_ICON(14)
|
|
693
|
+
titleWrap.append(bolt, el('span', '', copy.ui.runtimeEngine))
|
|
694
|
+
head.append(titleWrap)
|
|
695
|
+
if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
|
|
696
|
+
head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
|
|
697
|
+
}
|
|
698
|
+
card.append(head)
|
|
699
|
+
|
|
700
|
+
const offered = runtime.updateAvailable && latestVersion !== null
|
|
701
|
+
if (!offered && typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
702
|
+
card.append(el('p', 'hqpu-engine-error', `${copy.lastError}: ${runtime.lastError}`))
|
|
703
|
+
return card
|
|
704
|
+
}
|
|
705
|
+
if (!offered) return card
|
|
706
|
+
|
|
707
|
+
const latestEntry = runtime.latest
|
|
708
|
+
const row = el('div', 'hqpu-engine-row')
|
|
709
|
+
const info = el('div', 'hqpu-engine-info')
|
|
710
|
+
const newLine = el('div', 'hqpu-engine-new')
|
|
711
|
+
newLine.append(el('span', 'hqpu-ver-new hqpu-ver-new--lg', copy.ui.newVersion(latestVersion as string)))
|
|
712
|
+
const hasCritical =
|
|
713
|
+
(Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
|
|
714
|
+
(Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
|
|
715
|
+
if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
|
|
716
|
+
info.append(newLine)
|
|
717
|
+
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
718
|
+
const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
|
|
719
|
+
if (size.length > 0 || summary.length > 0) {
|
|
720
|
+
info.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
|
|
721
|
+
}
|
|
722
|
+
row.append(info)
|
|
723
|
+
|
|
724
|
+
if (actions.accepted) {
|
|
725
|
+
row.append(el('span', 'hqpu-engine-state', copy.runtime.updateAccepted))
|
|
726
|
+
} else if (actions.busy) {
|
|
727
|
+
row.append(el('span', 'hqpu-engine-state', copy.runtime.updating))
|
|
728
|
+
} else if (!actions.armed) {
|
|
729
|
+
const start = el('button', 'hqpu-btn--engine', copy.ui.upgradeCore)
|
|
730
|
+
start.type = 'button'
|
|
731
|
+
start.dataset.runtimeUpdate = ''
|
|
732
|
+
start.addEventListener('click', actions.onArm)
|
|
733
|
+
row.append(start)
|
|
734
|
+
}
|
|
735
|
+
card.append(row)
|
|
736
|
+
|
|
737
|
+
// Confirm replaces the action: a full-width block, never a 150 px column.
|
|
738
|
+
if (actions.armed && !actions.busy && !actions.accepted) {
|
|
739
|
+
const confirm = el('div', 'hqpu-engine-confirm')
|
|
740
|
+
confirm.append(
|
|
741
|
+
el('p', 'hqpu-engine-confirm-text', copy.runtime.updateConfirm(latestVersion as string)),
|
|
742
|
+
)
|
|
743
|
+
const buttons = el('div', 'hqpu-engine-confirm-actions')
|
|
744
|
+
const no = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
|
|
745
|
+
no.type = 'button'
|
|
746
|
+
no.addEventListener('click', actions.onCancel)
|
|
747
|
+
const yes = el('button', 'hqpu-btn--engine hqpu-btn--engine-solid', copy.ui.confirmRuntime)
|
|
748
|
+
yes.type = 'button'
|
|
749
|
+
yes.dataset.runtimeConfirm = ''
|
|
750
|
+
yes.addEventListener('click', actions.onConfirm)
|
|
751
|
+
buttons.append(no, yes)
|
|
752
|
+
confirm.append(buttons)
|
|
753
|
+
card.append(confirm)
|
|
659
754
|
}
|
|
660
|
-
|
|
755
|
+
|
|
756
|
+
const note = el('p', 'hqpu-engine-note')
|
|
757
|
+
note.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2L14.4 13.4H1.6L8 2Z"/><path d="M8 6.4V9.2"/><path d="M8 10.6V11.6"/></svg>`
|
|
758
|
+
note.append(el('span', '', copy.ui.runtimeRestartNote))
|
|
759
|
+
card.append(note)
|
|
760
|
+
return card
|
|
661
761
|
}
|
|
662
762
|
|
|
663
|
-
/**
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
763
|
+
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
764
|
+
function releaseBody(release: ReleaseInfo, copy: UpdateCopy): HTMLElement[] {
|
|
765
|
+
const out: HTMLElement[] = []
|
|
766
|
+
const notes = Array.isArray(release.notes)
|
|
767
|
+
? release.notes.filter((n) => typeof n === 'string' && n.length > 0)
|
|
768
|
+
: []
|
|
769
|
+
const breaking = Array.isArray(release.breaking)
|
|
770
|
+
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
771
|
+
: []
|
|
772
|
+
if (typeof release.summary === 'string' && release.summary.length > 0) {
|
|
773
|
+
out.push(el('p', 'hqpu-release-summary', release.summary))
|
|
774
|
+
}
|
|
775
|
+
if (notes.length > 0) {
|
|
776
|
+
const ul = el('ul', 'hqpu-release-notes')
|
|
777
|
+
for (const note of notes) ul.append(el('li', '', note as string))
|
|
778
|
+
out.push(ul)
|
|
779
|
+
}
|
|
780
|
+
if (breaking.length > 0) {
|
|
781
|
+
const banner = el('div', 'hqpu-banner')
|
|
782
|
+
banner.dataset.tone = 'danger'
|
|
783
|
+
const icon = el('span')
|
|
784
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
785
|
+
banner.append(icon)
|
|
786
|
+
const inner = el('span')
|
|
787
|
+
inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
|
|
788
|
+
inner.append(document.createTextNode(breaking.join(' · ')))
|
|
789
|
+
banner.append(inner)
|
|
790
|
+
out.push(banner)
|
|
791
|
+
}
|
|
792
|
+
if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
|
|
793
|
+
out.push(
|
|
794
|
+
el('p', 'hqpu-release-meta', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())),
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
if (release.requiresRestart === true) {
|
|
798
|
+
out.push(el('p', 'hqpu-release-meta', copy.job.willRestartDsh))
|
|
799
|
+
}
|
|
800
|
+
if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
|
|
801
|
+
const link = el('a', 'hqpu-release-link', copy.release.notesLink)
|
|
802
|
+
link.href = release.notesUrl
|
|
803
|
+
link.target = '_blank'
|
|
804
|
+
link.rel = 'noreferrer'
|
|
805
|
+
out.push(link)
|
|
806
|
+
}
|
|
807
|
+
return out
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function hasReleaseContent(release: ReleaseInfo | null | undefined): boolean {
|
|
811
|
+
if (release === null || release === undefined) return false
|
|
812
|
+
return (
|
|
813
|
+
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
814
|
+
(Array.isArray(release.notes) && release.notes.length > 0) ||
|
|
815
|
+
(Array.isArray(release.breaking) && release.breaking.length > 0) ||
|
|
816
|
+
typeof release.notesUrl === 'string' ||
|
|
817
|
+
typeof release.publishedAt === 'string'
|
|
818
|
+
)
|
|
668
819
|
}
|
|
669
820
|
|
|
670
821
|
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* hq-edge serves both manifest sources from one shape (plugin release
|
|
674
|
-
* manifest, runtime-update manifest), and both are editable from this panel:
|
|
675
|
-
* an override is persisted per host, so pointing discovery at a staging
|
|
676
|
-
* channel (or a local test rig) needs no env var and no restart. The editor
|
|
677
|
-
* state lives in the panel closure for the same reason `armed` does — a store
|
|
678
|
-
* refresh must not discard a half-typed URL or silently close the editor.
|
|
822
|
+
* The job log drawer: title + outcome tag, the mono component list with a
|
|
823
|
+
* JOB_ID header, the result detail, older runs, and 复制日志 ID / 关闭.
|
|
679
824
|
*/
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
825
|
+
function buildLogDrawer(
|
|
826
|
+
job: UpdateJob,
|
|
827
|
+
recent: UpdateJob[],
|
|
828
|
+
copy: UpdateCopy,
|
|
829
|
+
onCopy: (id: string) => void,
|
|
830
|
+
onClose: () => void,
|
|
831
|
+
): HTMLElement[] {
|
|
832
|
+
const out: HTMLElement[] = []
|
|
833
|
+
|
|
834
|
+
const head = el('div', 'hqpu-log-head')
|
|
835
|
+
const titleWrap = el('div', 'hqpu-log-title')
|
|
836
|
+
titleWrap.append(el('span', 'hqpu-log-name', copy.ui.logTitle))
|
|
837
|
+
const tag = el('span', 'hqpu-tag')
|
|
838
|
+
tag.dataset.tone =
|
|
839
|
+
job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
|
|
840
|
+
tag.textContent =
|
|
841
|
+
job.phase === 'done'
|
|
842
|
+
? copy.job.resultDone
|
|
843
|
+
: job.phase === 'failed'
|
|
844
|
+
? copy.job.resultFailed
|
|
845
|
+
: job.phase === 'rolled-back'
|
|
846
|
+
? copy.job.resultRolledBack
|
|
847
|
+
: copy.job.resultAbandoned
|
|
848
|
+
titleWrap.append(tag)
|
|
849
|
+
const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--sm', CLOSE_ICON(14), copy.close, onClose)
|
|
850
|
+
head.append(titleWrap, closeBtn)
|
|
851
|
+
out.push(head)
|
|
852
|
+
|
|
853
|
+
const body = el('div', 'hqpu-log-body')
|
|
854
|
+
const block = el('div', 'hqpu-log-block')
|
|
855
|
+
const meta = el('div', 'hqpu-log-meta')
|
|
856
|
+
const logId = job.logHint !== undefined && job.logHint.length > 0 ? job.logHint : job.id
|
|
857
|
+
meta.append(el('span', '', `${copy.ui.jobIdLabel}: ${logId}`))
|
|
858
|
+
meta.append(el('span', '', shortWhen(job.result?.finishedAt ?? job.updatedAt)))
|
|
859
|
+
block.append(meta)
|
|
860
|
+
|
|
861
|
+
const steps = Array.isArray(job.steps) ? job.steps : []
|
|
862
|
+
if (steps.length > 0) {
|
|
863
|
+
for (const step of steps) {
|
|
864
|
+
const line = el('div', 'hqpu-log-line')
|
|
865
|
+
const dot = el('span', 'hqpu-log-dot')
|
|
866
|
+
dot.dataset.state = stepDotState(step.state)
|
|
867
|
+
line.append(dot)
|
|
868
|
+
const word = stepWord(step.state, copy)
|
|
869
|
+
const text =
|
|
870
|
+
step.message !== undefined && step.message.length > 0
|
|
871
|
+
? `${step.name} — ${step.message}`
|
|
872
|
+
: step.name
|
|
873
|
+
line.append(el('span', 'hqpu-log-line-text', word.length > 0 ? `${text} (${word})` : text))
|
|
874
|
+
block.append(line)
|
|
875
|
+
}
|
|
876
|
+
} else {
|
|
877
|
+
block.append(el('div', 'hqpu-log-line hqpu-log-line--empty', copy.job.stepsTitle))
|
|
878
|
+
}
|
|
879
|
+
body.append(block)
|
|
880
|
+
|
|
881
|
+
// Result detail: the transition, the error code, the server's own message.
|
|
882
|
+
const res = job.result
|
|
883
|
+
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
884
|
+
const to = res?.installedVersion ?? job.targetVersion
|
|
885
|
+
const detail = el('div', 'hqpu-log-detail')
|
|
886
|
+
detail.append(el('p', '', `${from} → ${to}`))
|
|
887
|
+
if (res?.errorCode !== undefined && res.errorCode.length > 0) {
|
|
888
|
+
detail.append(el('code', 'hqpu-error-code', res.errorCode))
|
|
889
|
+
}
|
|
890
|
+
if (res?.message !== undefined && res.message.length > 0) {
|
|
891
|
+
detail.append(el('p', '', res.message))
|
|
892
|
+
}
|
|
893
|
+
body.append(detail)
|
|
894
|
+
|
|
895
|
+
const older = recent.filter((entry) => entry.id !== job.id).slice(0, 4)
|
|
896
|
+
if (older.length > 0) {
|
|
897
|
+
const list = el('div', 'hqpu-log-recent')
|
|
898
|
+
list.append(el('p', 'hqpu-log-recent-title', copy.job.recentTitle))
|
|
899
|
+
for (const entry of older) {
|
|
900
|
+
const row = el('div', 'hqpu-log-recent-row')
|
|
901
|
+
row.append(el('span', '', `${jobKindLabel(entry, copy)} ${entry.targetVersion}`))
|
|
902
|
+
row.append(el('span', 'hqpu-log-recent-meta', `${phaseLabel(entry, copy)} · ${shortWhen(entry.updatedAt)}`))
|
|
903
|
+
list.append(row)
|
|
904
|
+
}
|
|
905
|
+
body.append(list)
|
|
906
|
+
}
|
|
907
|
+
out.push(body)
|
|
908
|
+
|
|
909
|
+
const foot = el('div', 'hqpu-log-foot')
|
|
910
|
+
const copyBtn = el('button', 'hqpu-link hqpu-link--icon')
|
|
911
|
+
copyBtn.type = 'button'
|
|
912
|
+
copyBtn.dataset.copyLog = ''
|
|
913
|
+
const copyIcon = el('span')
|
|
914
|
+
copyIcon.innerHTML = COPY_ICON(12)
|
|
915
|
+
copyBtn.append(copyIcon, el('span', '', copy.ui.copyLogId))
|
|
916
|
+
copyBtn.addEventListener('click', () => onCopy(logId))
|
|
917
|
+
const closeFoot = el('button', 'hqpu-btn hqpu-btn--sm', copy.close)
|
|
918
|
+
closeFoot.type = 'button'
|
|
919
|
+
closeFoot.addEventListener('click', onClose)
|
|
920
|
+
foot.append(copyBtn, closeFoot)
|
|
921
|
+
out.push(foot)
|
|
922
|
+
|
|
923
|
+
return out
|
|
693
924
|
}
|
|
694
925
|
|
|
926
|
+
// ── The runtime action contract ────────────────────────────────────────────
|
|
927
|
+
|
|
695
928
|
/**
|
|
696
929
|
* The runtime-update action row's state and callbacks.
|
|
697
930
|
*
|
|
698
|
-
* Installing the runtime is destructive in a way a plugin install is not:
|
|
699
|
-
* shuts itself down so the EDA host can swap the runtime directory. So
|
|
700
|
-
* is a two-step confirm (arm → confirm), never a single
|
|
931
|
+
* Installing the runtime is destructive in a way a plugin install is not:
|
|
932
|
+
* hq-edge shuts itself down so the EDA host can swap the runtime directory. So
|
|
933
|
+
* the row is a two-step confirm (arm → confirm), never a single click.
|
|
701
934
|
*/
|
|
702
|
-
export interface RuntimeActions
|
|
935
|
+
export interface RuntimeActions {
|
|
703
936
|
/** A request is in flight (hq-edge accepted it and is restarting). */
|
|
704
937
|
busy: boolean
|
|
705
938
|
/** The confirm step is armed for the version the row names. */
|
|
@@ -707,8 +940,7 @@ export interface RuntimeActions extends SourceEditorActions {
|
|
|
707
940
|
/**
|
|
708
941
|
* hq-edge answered `202 Accepted`: from here on the outcome belongs to the
|
|
709
942
|
* EDA host, and this process is about to be replaced. Terminal — the row
|
|
710
|
-
* must never re-offer the button once this is true
|
|
711
|
-
* already gone and a second request could only fail.
|
|
943
|
+
* must never re-offer the button once this is true.
|
|
712
944
|
*/
|
|
713
945
|
accepted: boolean
|
|
714
946
|
onArm(): void
|
|
@@ -717,11 +949,11 @@ export interface RuntimeActions extends SourceEditorActions {
|
|
|
717
949
|
}
|
|
718
950
|
|
|
719
951
|
/**
|
|
720
|
-
* Normalize
|
|
952
|
+
* Normalize a report's source field to the full shape.
|
|
721
953
|
*
|
|
722
954
|
* An older hq-edge predates the feature and sends no `source` at all — that
|
|
723
|
-
* must degrade to
|
|
724
|
-
*
|
|
955
|
+
* must degrade to no editor, not crash the render. A future server may add
|
|
956
|
+
* fields; unknown extras are dropped here rather than trusted.
|
|
725
957
|
*/
|
|
726
958
|
function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
|
|
727
959
|
if (raw === null || typeof raw !== 'object') return null
|
|
@@ -735,465 +967,36 @@ function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
|
|
|
735
967
|
}
|
|
736
968
|
}
|
|
737
969
|
|
|
738
|
-
|
|
739
|
-
function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
|
|
740
|
-
switch (origin) {
|
|
741
|
-
case 'override':
|
|
742
|
-
return copy.originOverride
|
|
743
|
-
case 'config':
|
|
744
|
-
return copy.originConfig
|
|
745
|
-
default:
|
|
746
|
-
return copy.originUnset
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/** The 更新源 fact row: the URL in effect plus an origin tag. */
|
|
751
|
-
function sourceFactRow(
|
|
752
|
-
facts: HTMLElement,
|
|
753
|
-
source: RuntimeUpdateSource,
|
|
754
|
-
copy: UpdateCopy,
|
|
755
|
-
): void {
|
|
756
|
-
const wrap = el('div')
|
|
757
|
-
wrap.append(el('dt', '', copy.runtime.source))
|
|
758
|
-
const dd = el('dd')
|
|
759
|
-
const line = el('div', 'hqpu-source-line')
|
|
760
|
-
const code = el(
|
|
761
|
-
'code',
|
|
762
|
-
'hqpu-source-url',
|
|
763
|
-
source.url.length > 0 ? source.url : copy.runtime.notConfigured,
|
|
764
|
-
)
|
|
765
|
-
const tag = el('span', 'hqpu-tag')
|
|
766
|
-
tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
|
|
767
|
-
tag.textContent = originLabel(source.origin, copy)
|
|
768
|
-
line.append(code, tag)
|
|
769
|
-
dd.append(line)
|
|
770
|
-
wrap.append(dd)
|
|
771
|
-
facts.append(wrap)
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/** Which manifest an editor belongs to — drives data attributes + CSS hook. */
|
|
775
|
-
export type SourceKind = 'runtime' | 'plugin'
|
|
776
|
-
|
|
777
|
-
/** The localized strings one source editor needs (both kinds share the flow). */
|
|
778
|
-
interface SourceEditorLabels {
|
|
779
|
-
hint: string
|
|
780
|
-
edit: string
|
|
781
|
-
save: string
|
|
782
|
-
reset: string
|
|
783
|
-
inputLabel: string
|
|
784
|
-
placeholder: string
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/** Pick the labels for one kind out of the copy table. */
|
|
788
|
-
function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
|
|
789
|
-
return kind === 'runtime'
|
|
790
|
-
? {
|
|
791
|
-
hint: copy.runtime.sourceHint,
|
|
792
|
-
edit: copy.runtime.sourceEdit,
|
|
793
|
-
save: copy.runtime.sourceSave,
|
|
794
|
-
reset: copy.runtime.sourceReset,
|
|
795
|
-
inputLabel: copy.runtime.sourceInputLabel,
|
|
796
|
-
placeholder: copy.runtime.sourcePlaceholder,
|
|
797
|
-
}
|
|
798
|
-
: {
|
|
799
|
-
hint: copy.pluginSource.hint,
|
|
800
|
-
edit: copy.pluginSource.edit,
|
|
801
|
-
save: copy.pluginSource.save,
|
|
802
|
-
reset: copy.pluginSource.reset,
|
|
803
|
-
inputLabel: copy.pluginSource.inputLabel,
|
|
804
|
-
placeholder: copy.pluginSource.placeholder,
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* One update-source editor, shared by the plugin manifest and the runtime
|
|
810
|
-
* manifest (the only differences are the labels and the data-attribute
|
|
811
|
-
* prefix, so tests and CSS can target each).
|
|
812
|
-
*
|
|
813
|
-
* Read mode is a hint plus a 修改 button; edit mode is the URL field with
|
|
814
|
-
* 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
|
|
815
|
-
* URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
|
|
816
|
-
* a background store refresh re-renders the typed URL instead of discarding it.
|
|
817
|
-
*/
|
|
818
|
-
function sourceEditor(
|
|
819
|
-
kind: SourceKind,
|
|
820
|
-
source: RuntimeUpdateSource,
|
|
821
|
-
copy: UpdateCopy,
|
|
822
|
-
actions: SourceEditorActions,
|
|
823
|
-
): HTMLElement {
|
|
824
|
-
const labels = sourceLabels(copy, kind)
|
|
825
|
-
const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
|
|
826
|
-
if (!actions.sourceEditing) {
|
|
827
|
-
// Read mode: show the URL currently in effect (mono, muted), then edit.
|
|
828
|
-
const urlLine = el('p', 'hqpu-source-current-url', source.url)
|
|
829
|
-
urlLine.style.cssText =
|
|
830
|
-
'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-tertiary);word-break:break-all;'
|
|
831
|
-
block.append(urlLine)
|
|
832
|
-
block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
|
|
833
|
-
const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
|
|
834
|
-
edit.type = 'button'
|
|
835
|
-
edit.setAttribute(`data-${kind}-source-edit`, '')
|
|
836
|
-
edit.disabled = actions.sourceSaving
|
|
837
|
-
edit.addEventListener('click', actions.onSourceEdit)
|
|
838
|
-
block.append(edit)
|
|
839
|
-
return block
|
|
840
|
-
}
|
|
841
|
-
const input = el('input', 'hqpu-source-input')
|
|
842
|
-
input.type = 'url'
|
|
843
|
-
input.value = actions.sourceDraft
|
|
844
|
-
input.placeholder = labels.placeholder
|
|
845
|
-
input.setAttribute('aria-label', labels.inputLabel)
|
|
846
|
-
input.setAttribute(`data-${kind}-source-input`, '')
|
|
847
|
-
input.disabled = actions.sourceSaving
|
|
848
|
-
input.addEventListener('input', () => {
|
|
849
|
-
// Draft-only: no re-render, so the caret and IME stay untouched.
|
|
850
|
-
actions.onSourceDraft(input.value)
|
|
851
|
-
})
|
|
852
|
-
// Enter saves, Escape cancels — the field is the whole editor, and Escape
|
|
853
|
-
// must not fall through to the panel-wide close handler.
|
|
854
|
-
input.addEventListener('keydown', (ev) => {
|
|
855
|
-
if (ev.key === 'Enter') {
|
|
856
|
-
ev.preventDefault()
|
|
857
|
-
actions.onSourceSave()
|
|
858
|
-
} else if (ev.key === 'Escape') {
|
|
859
|
-
ev.preventDefault()
|
|
860
|
-
ev.stopPropagation()
|
|
861
|
-
actions.onSourceCancel()
|
|
862
|
-
}
|
|
863
|
-
})
|
|
864
|
-
const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
|
|
865
|
-
save.type = 'button'
|
|
866
|
-
save.setAttribute(`data-${kind}-source-save`, '')
|
|
867
|
-
save.disabled = actions.sourceSaving
|
|
868
|
-
save.addEventListener('click', actions.onSourceSave)
|
|
869
|
-
const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
|
|
870
|
-
cancel.type = 'button'
|
|
871
|
-
cancel.setAttribute(`data-${kind}-source-cancel`, '')
|
|
872
|
-
cancel.disabled = actions.sourceSaving
|
|
873
|
-
cancel.addEventListener('click', actions.onSourceCancel)
|
|
874
|
-
const controls = el('div', 'hqpu-actions hqpu-source-controls')
|
|
875
|
-
controls.append(input, save, cancel)
|
|
876
|
-
if (source.override) {
|
|
877
|
-
const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
|
|
878
|
-
reset.type = 'button'
|
|
879
|
-
reset.setAttribute(`data-${kind}-source-reset`, '')
|
|
880
|
-
reset.disabled = actions.sourceSaving
|
|
881
|
-
reset.addEventListener('click', actions.onSourceReset)
|
|
882
|
-
controls.append(reset)
|
|
883
|
-
}
|
|
884
|
-
block.append(controls)
|
|
885
|
-
// Focus on open — and on any re-render while editing, which is exactly what
|
|
886
|
-
// a rejected save wants (the user is dropped back into the field to fix it).
|
|
887
|
-
input.focus()
|
|
888
|
-
return block
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* The plugin release-manifest source, as its own group above the plugin
|
|
893
|
-
* groups: it says where the whole list comes from, so it belongs above the
|
|
894
|
-
* list rather than inside it. Absent on an older hq-edge (no `pluginSource`
|
|
895
|
-
* in the report) — then nothing is rendered at all.
|
|
896
|
-
*/
|
|
897
|
-
function pluginSourceGroup(
|
|
898
|
-
report: PluginUpdateReport,
|
|
899
|
-
copy: UpdateCopy,
|
|
900
|
-
actions: SourceEditorActions,
|
|
901
|
-
): HTMLElement | null {
|
|
902
|
-
const source = normalizeSource(report.pluginSource)
|
|
903
|
-
if (source === null) return null
|
|
904
|
-
const section = el('section', 'hqpu-group')
|
|
905
|
-
section.dataset.group = 'plugin-source'
|
|
906
|
-
const head = el('div', 'hqpu-group-head')
|
|
907
|
-
head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
|
|
908
|
-
section.append(head)
|
|
909
|
-
|
|
910
|
-
const facts = el('dl', 'hqpu-facts')
|
|
911
|
-
sourceFactRow(facts, source, copy)
|
|
912
|
-
section.append(facts)
|
|
913
|
-
section.append(sourceEditor('plugin', source, copy, actions))
|
|
914
|
-
return section
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* The host's own report of the last runtime swap. Rolled-back / failed renders
|
|
919
|
-
* as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
|
|
920
|
-
* succeeded renders as a subtle green inline line — not a loud banner. The
|
|
921
|
-
* plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
|
|
922
|
-
*/
|
|
923
|
-
function runtimeResultAlert(
|
|
924
|
-
report: PluginUpdateReport,
|
|
925
|
-
copy: UpdateCopy,
|
|
926
|
-
dismissed: boolean,
|
|
927
|
-
onDismiss: () => void,
|
|
928
|
-
): HTMLElement | null {
|
|
929
|
-
const lastResult = report.runtime.lastResult
|
|
930
|
-
if (lastResult === null || lastResult === undefined) return null
|
|
931
|
-
|
|
932
|
-
// Green inline confirmation — quiet by design.
|
|
933
|
-
if (lastResult.state === 'succeeded') {
|
|
934
|
-
const line = el('p', 'hqpu-alert-green')
|
|
935
|
-
line.dataset.role = 'runtime-success'
|
|
936
|
-
line.textContent = copy.ui.successInline
|
|
937
|
-
return line
|
|
938
|
-
}
|
|
939
|
-
if (dismissed) return null
|
|
940
|
-
|
|
941
|
-
const section = el('section', 'hqpu-group')
|
|
942
|
-
section.dataset.group = 'runtime-alert'
|
|
943
|
-
const card = el('div', 'hqpu-alert hqpu-alert--amber')
|
|
944
|
-
card.innerHTML = WARNING_ICON(14)
|
|
945
|
-
|
|
946
|
-
const body = el('div')
|
|
947
|
-
const title = el('div', 'hqpu-alert-title')
|
|
948
|
-
title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
|
|
949
|
-
body.append(title)
|
|
950
|
-
|
|
951
|
-
const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
|
|
952
|
-
const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
|
|
953
|
-
? lastResult.errorCode
|
|
954
|
-
: '—'
|
|
955
|
-
const text =
|
|
956
|
-
lastResult.state === 'failed'
|
|
957
|
-
? copy.ui.failedBody(oldVer, code)
|
|
958
|
-
: copy.ui.rollbackBody(oldVer, code)
|
|
959
|
-
// Build the body as text + an inline <code> chip for the error code.
|
|
960
|
-
const bodyP = el('p', 'hqpu-alert-body')
|
|
961
|
-
const textOnly =
|
|
962
|
-
lastResult.state === 'failed'
|
|
963
|
-
? copy.ui.failedBody(oldVer, '')
|
|
964
|
-
: copy.ui.rollbackBody(oldVer, '')
|
|
965
|
-
// Strip the trailing "code" placeholder we appended; re-add as a chip.
|
|
966
|
-
bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
|
|
967
|
-
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
968
|
-
bodyP.append(document.createTextNode(' '))
|
|
969
|
-
const chip = el('code', '', lastResult.errorCode)
|
|
970
|
-
bodyP.append(chip)
|
|
971
|
-
}
|
|
972
|
-
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
973
|
-
bodyP.append(el('br'), document.createTextNode(lastResult.message))
|
|
974
|
-
}
|
|
975
|
-
body.append(bodyP)
|
|
976
|
-
|
|
977
|
-
// Dismiss action (no log button — the plugin has no log-open affordance).
|
|
978
|
-
const actions = el('div', 'hqpu-alert-actions')
|
|
979
|
-
const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
|
|
980
|
-
dismiss.type = 'button'
|
|
981
|
-
dismiss.addEventListener('click', onDismiss)
|
|
982
|
-
actions.append(dismiss)
|
|
983
|
-
body.append(actions)
|
|
984
|
-
|
|
985
|
-
card.append(body)
|
|
986
|
-
section.append(card)
|
|
987
|
-
return section
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
/** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
|
|
991
|
-
* 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
|
|
992
|
-
* 升级核心 button (existing arm→confirm flow), and the amber restart note. */
|
|
993
|
-
function runtimeEngineCard(
|
|
994
|
-
report: PluginUpdateReport,
|
|
995
|
-
copy: UpdateCopy,
|
|
996
|
-
actions: RuntimeActions,
|
|
997
|
-
): HTMLElement | null {
|
|
998
|
-
const runtime = report.runtime
|
|
999
|
-
const latestVersion = runtimeLatestVersion(runtime)
|
|
1000
|
-
const card = el('section', 'hqpu-engine')
|
|
1001
|
-
card.dataset.group = 'runtime'
|
|
1002
|
-
|
|
1003
|
-
const head = el('div', 'hqpu-engine-head')
|
|
1004
|
-
const titleWrap = el('div', 'hqpu-engine-title')
|
|
1005
|
-
titleWrap.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.33333 2L4.66667 8H8L6.66667 14L11.33333 8H8L9.33333 2Z"/></svg>`
|
|
1006
|
-
titleWrap.append(el('span', '', copy.ui.runtimeEngine))
|
|
1007
|
-
head.append(titleWrap)
|
|
1008
|
-
if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
|
|
1009
|
-
head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
|
|
1010
|
-
}
|
|
1011
|
-
card.append(head)
|
|
1012
|
-
|
|
1013
|
-
// Update row (only when a newer distribution is known).
|
|
1014
|
-
if (runtime.updateAvailable && latestVersion !== null) {
|
|
1015
|
-
const row = el('div', 'hqpu-engine-update-row')
|
|
1016
|
-
const left = el('div')
|
|
1017
|
-
const newLine = el('div', 'hqpu-engine-new-line')
|
|
1018
|
-
newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
|
|
1019
|
-
const latestEntry = runtime.latest
|
|
1020
|
-
const hasCritical =
|
|
1021
|
-
(Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
|
|
1022
|
-
(Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
|
|
1023
|
-
if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
|
|
1024
|
-
left.append(newLine)
|
|
1025
|
-
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
1026
|
-
const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
|
|
1027
|
-
if (size.length > 0 || summary.length > 0) {
|
|
1028
|
-
left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
|
|
1029
|
-
}
|
|
1030
|
-
row.append(left)
|
|
1031
|
-
|
|
1032
|
-
// Upgrade action: arm → confirm, accepted/busy states preserved.
|
|
1033
|
-
if (actions.accepted) {
|
|
1034
|
-
row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
|
|
1035
|
-
} else if (actions.busy) {
|
|
1036
|
-
row.append(el('span', 'hqpu-status', copy.runtime.updating))
|
|
1037
|
-
} else if (actions.armed) {
|
|
1038
|
-
const confirmWrap = el('div')
|
|
1039
|
-
confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
|
|
1040
|
-
const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
|
|
1041
|
-
yes.type = 'button'
|
|
1042
|
-
yes.dataset.runtimeConfirm = ''
|
|
1043
|
-
yes.addEventListener('click', actions.onConfirm)
|
|
1044
|
-
const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
|
|
1045
|
-
no.type = 'button'
|
|
1046
|
-
no.addEventListener('click', actions.onCancel)
|
|
1047
|
-
confirmWrap.append(yes, no)
|
|
1048
|
-
row.append(confirmWrap)
|
|
1049
|
-
} else {
|
|
1050
|
-
const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
|
|
1051
|
-
start.type = 'button'
|
|
1052
|
-
start.dataset.runtimeUpdate = ''
|
|
1053
|
-
start.addEventListener('click', actions.onArm)
|
|
1054
|
-
row.append(start)
|
|
1055
|
-
}
|
|
1056
|
-
card.append(row)
|
|
1057
|
-
const note = el('p', 'hqpu-engine-note')
|
|
1058
|
-
note.innerHTML =
|
|
1059
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5L14 13H2L8 2.5Z"/></svg><span>${copy.ui.runtimeRestartNote}</span>`
|
|
1060
|
-
card.append(note)
|
|
1061
|
-
} else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
1062
|
-
card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
|
|
1063
|
-
}
|
|
1064
|
-
return card
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function renderList(
|
|
1068
|
-
section: HTMLElement,
|
|
1069
|
-
report: PluginUpdateReport | null,
|
|
1070
|
-
copy: UpdateCopy,
|
|
1071
|
-
changelogOpen: Set<string>,
|
|
1072
|
-
allOpen: boolean,
|
|
1073
|
-
onToggleCard: (name: string) => void,
|
|
1074
|
-
onToggleAll: () => void,
|
|
1075
|
-
onRetryCheck: () => void,
|
|
1076
|
-
onInstall: (name: string) => void,
|
|
1077
|
-
onIgnore: (item: PluginUpdateItem) => void,
|
|
1078
|
-
): void {
|
|
1079
|
-
section.textContent = ''
|
|
1080
|
-
if (report === null) {
|
|
1081
|
-
section.append(skeleton(copy))
|
|
1082
|
-
return
|
|
1083
|
-
}
|
|
1084
|
-
// Check-status banner: a failed check shows an error row + Retry (never the
|
|
1085
|
-
// silent "no updates" empty state); a successful check shows the time.
|
|
1086
|
-
const banner = checkBanner(report, copy, onRetryCheck)
|
|
1087
|
-
if (banner !== null) section.append(banner)
|
|
1088
|
-
// Set-level release metadata (v2 manifest): summary, notes, breaking.
|
|
1089
|
-
const release = releaseHeader(report.release, copy)
|
|
1090
|
-
if (release !== null) section.append(release)
|
|
1091
|
-
if (report.items.length === 0) {
|
|
1092
|
-
// A successful check with zero updates — distinct from a failed check,
|
|
1093
|
-
// which already rendered its error row above.
|
|
1094
|
-
if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
|
|
1095
|
-
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
1096
|
-
}
|
|
1097
|
-
return
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
1101
|
-
report.items.filter((item) => item.state === state)
|
|
1102
|
-
|
|
1103
|
-
// Most important first: what can be acted on, then what was muted, then what
|
|
1104
|
-
// is already settled. Blocked plugins are intentionally skipped — the server
|
|
1105
|
-
// still reports them, but they carry no user-actionable offer, so surfacing
|
|
1106
|
-
// them only adds noise without a path forward.
|
|
1107
|
-
const order: Array<[string, PluginUpdateItem[]]> = [
|
|
1108
|
-
[copy.group.actionable, [...byState('update'), ...byState('install')]],
|
|
1109
|
-
[copy.group.ignored, byState('ignored')],
|
|
1110
|
-
[copy.group.current, byState('current')],
|
|
1111
|
-
]
|
|
1112
|
-
for (const [heading, items] of order) {
|
|
1113
|
-
const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll, onInstall, onIgnore)
|
|
1114
|
-
if (node !== null) section.append(node)
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
function renderRuntime(
|
|
1119
|
-
section: HTMLElement,
|
|
1120
|
-
report: PluginUpdateReport | null,
|
|
1121
|
-
copy: UpdateCopy,
|
|
1122
|
-
actions: RuntimeActions,
|
|
1123
|
-
dismissedAlert: boolean,
|
|
1124
|
-
onDismissAlert: () => void,
|
|
1125
|
-
): void {
|
|
1126
|
-
section.textContent = ''
|
|
1127
|
-
if (report === null) return
|
|
1128
|
-
const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
|
|
1129
|
-
if (alert !== null) section.append(alert)
|
|
1130
|
-
const engine = runtimeEngineCard(report, copy, actions)
|
|
1131
|
-
if (engine !== null) section.append(engine)
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/** The plugin-source group, above the plugin groups, hidden behind an advanced
|
|
1135
|
-
* disclosure (manifest-URL editing is a developer affordance). No-op without
|
|
1136
|
-
* the field. */
|
|
1137
|
-
function renderPluginSource(
|
|
1138
|
-
section: HTMLElement,
|
|
1139
|
-
report: PluginUpdateReport | null,
|
|
1140
|
-
copy: UpdateCopy,
|
|
1141
|
-
actions: SourceEditorActions,
|
|
1142
|
-
): void {
|
|
1143
|
-
section.textContent = ''
|
|
1144
|
-
if (report === null) return
|
|
1145
|
-
const node = pluginSourceGroup(report, copy, actions)
|
|
1146
|
-
if (node !== null) section.append(advancedBlock(copy, [node]))
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/** Run the install transaction (a new durable job), then let the store poll it. */
|
|
1150
|
-
async function runInstall(store: UpdateStoreLike, copy: UpdateCopy, pluginNames?: string[]): Promise<void> {
|
|
1151
|
-
const report = store.report()
|
|
1152
|
-
if (report === null) return
|
|
1153
|
-
// Without an evaluated set there is nothing to install: hq-edge has not read
|
|
1154
|
-
// a manifest yet, so the report is still its empty shell.
|
|
1155
|
-
if (report.manifestVersion === null) {
|
|
1156
|
-
showToast(copy.checkFailed, 'error')
|
|
1157
|
-
return
|
|
1158
|
-
}
|
|
1159
|
-
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1160
|
-
// action buttons and shows the "DSH will restart" footer state (previously
|
|
1161
|
-
// dead code — runInstall never called it).
|
|
1162
|
-
store.setInstalling(true)
|
|
1163
|
-
try {
|
|
1164
|
-
const result = await installSet(report.manifestVersion, pluginNames)
|
|
1165
|
-
if (result.error !== undefined) {
|
|
1166
|
-
store.setInstalling(false)
|
|
1167
|
-
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1168
|
-
return
|
|
1169
|
-
}
|
|
1170
|
-
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1171
|
-
// poller renders progress and clears the flag when the job settles.
|
|
1172
|
-
showToast(copy.installSuccess, 'success')
|
|
1173
|
-
void store.fetchStatus()
|
|
1174
|
-
} catch (err: unknown) {
|
|
1175
|
-
store.setInstalling(false)
|
|
1176
|
-
showToast(
|
|
1177
|
-
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1178
|
-
'error',
|
|
1179
|
-
)
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
970
|
+
// ── Toast ─────────────────────────────────────────────────────────────────
|
|
1182
971
|
|
|
1183
972
|
/** The single live toast; a new message replaces the one on screen. */
|
|
1184
973
|
let toastHost: HTMLElement | null = null
|
|
1185
974
|
let toastTimer: number | null = null
|
|
975
|
+
/** Copy for the toast's close affordance; set when the panel mounts. */
|
|
976
|
+
let activeCopy: UpdateCopy | null = null
|
|
1186
977
|
|
|
1187
978
|
function showToast(message: string, tone: 'success' | 'error'): void {
|
|
1188
979
|
if (toastHost === null) return
|
|
1189
980
|
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
1190
981
|
toastHost.textContent = ''
|
|
1191
982
|
const toast = el('div', 'hqpu-toast')
|
|
1192
|
-
|
|
983
|
+
// An error must interrupt; a success may wait for a pause.
|
|
984
|
+
toast.setAttribute('role', tone === 'error' ? 'alert' : 'status')
|
|
1193
985
|
const icon = el('span', 'hqpu-toast-icon')
|
|
1194
986
|
icon.dataset.tone = tone
|
|
1195
987
|
icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
|
|
1196
988
|
toast.append(icon, el('span', 'hqpu-toast-text', message))
|
|
989
|
+
const close = iconButton(
|
|
990
|
+
'hqpu-toast-close',
|
|
991
|
+
CLOSE_ICON(12),
|
|
992
|
+
activeCopy?.close ?? 'Close',
|
|
993
|
+
() => {
|
|
994
|
+
toastHost?.replaceChildren()
|
|
995
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
996
|
+
toastTimer = null
|
|
997
|
+
},
|
|
998
|
+
)
|
|
999
|
+
toast.append(close)
|
|
1197
1000
|
toastHost.append(toast)
|
|
1198
1001
|
toastTimer = window.setTimeout(() => {
|
|
1199
1002
|
toastHost?.replaceChildren()
|
|
@@ -1201,100 +1004,7 @@ function showToast(message: string, tone: 'success' | 'error'): void {
|
|
|
1201
1004
|
}, TOAST_HOLD_MS)
|
|
1202
1005
|
}
|
|
1203
1006
|
|
|
1204
|
-
|
|
1205
|
-
* The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
|
|
1206
|
-
* EXISTING plugin-source and runtime-source editors (POST /plugin-source,
|
|
1207
|
-
* POST /runtime-source, save→check→refresh already wired). No new endpoints:
|
|
1208
|
-
* if a manifest source has no data, that field is omitted rather than faked.
|
|
1209
|
-
*/
|
|
1210
|
-
function renderSettingsPanel(
|
|
1211
|
-
panel: HTMLElement,
|
|
1212
|
-
report: PluginUpdateReport | null,
|
|
1213
|
-
copy: UpdateCopy,
|
|
1214
|
-
open: boolean,
|
|
1215
|
-
pluginActions: SourceEditorActions,
|
|
1216
|
-
runtimeActions: SourceEditorActions,
|
|
1217
|
-
): void {
|
|
1218
|
-
panel.textContent = ''
|
|
1219
|
-
panel.style.display = open ? '' : 'none'
|
|
1220
|
-
if (!open || report === null) return
|
|
1221
|
-
|
|
1222
|
-
const row = el('div', 'hqpu-settings-row')
|
|
1223
|
-
row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
|
|
1224
|
-
// Origin badge: custom override (amber) vs the default configured source (green).
|
|
1225
|
-
const pluginSource = normalizeSource(report.pluginSource)
|
|
1226
|
-
const runtimeSource = normalizeSource(report.runtime.source)
|
|
1227
|
-
const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
|
|
1228
|
-
const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
|
|
1229
|
-
badge.dataset.origin = overridden ? 'override' : 'config'
|
|
1230
|
-
row.append(badge)
|
|
1231
|
-
panel.append(row)
|
|
1232
|
-
|
|
1233
|
-
if (pluginSource !== null) {
|
|
1234
|
-
panel.append(el('label', '', copy.ui.pluginUrlLabel))
|
|
1235
|
-
panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
|
|
1236
|
-
}
|
|
1237
|
-
if (runtimeSource !== null) {
|
|
1238
|
-
panel.append(el('label', '', copy.ui.runtimeUrlLabel))
|
|
1239
|
-
panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
|
|
1245
|
-
* full-width primary "一键更新全套插件 (vX)" (install = one durable job),
|
|
1246
|
-
* a secondary 忽略 wired to the existing ignore/unignore affordance, and the
|
|
1247
|
-
* footnote. The count EXCLUDES ignored / already-current entries.
|
|
1248
|
-
*/
|
|
1249
|
-
function renderFooter(
|
|
1250
|
-
footer: HTMLElement,
|
|
1251
|
-
installBtn: HTMLButtonElement,
|
|
1252
|
-
ignoreBtn: HTMLButtonElement,
|
|
1253
|
-
countEl: HTMLElement,
|
|
1254
|
-
setEl: HTMLElement,
|
|
1255
|
-
report: PluginUpdateReport | null,
|
|
1256
|
-
copy: UpdateCopy,
|
|
1257
|
-
installing: boolean,
|
|
1258
|
-
store: UpdateStoreLike,
|
|
1259
|
-
): void {
|
|
1260
|
-
if (report === null) {
|
|
1261
|
-
countEl.textContent = ''
|
|
1262
|
-
setEl.textContent = ''
|
|
1263
|
-
installBtn.disabled = true
|
|
1264
|
-
ignoreBtn.style.display = 'none'
|
|
1265
|
-
return
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
const actionable = report.items.filter(
|
|
1269
|
-
(item) => item.state === 'update' || item.state === 'install',
|
|
1270
|
-
).length
|
|
1271
|
-
const hasSet = report.manifestVersion !== null
|
|
1272
|
-
const setVersion: string = report.manifestVersion ?? ''
|
|
1273
|
-
|
|
1274
|
-
countEl.textContent = copy.ui.footerCount(actionable)
|
|
1275
|
-
setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
|
|
1276
|
-
|
|
1277
|
-
// Install-all: honest set-level label; disabled while busy or nothing to do.
|
|
1278
|
-
installBtn.textContent = hasSet
|
|
1279
|
-
? copy.ui.installAllSet(setVersion)
|
|
1280
|
-
: copy.ui.installAllSet('—')
|
|
1281
|
-
installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
|
|
1282
|
-
installBtn.dataset.busy = installing ? 'true' : 'false'
|
|
1283
|
-
installBtn.onclick = () => {
|
|
1284
|
-
void runInstall(store, copy)
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// Secondary ignore button: only when there is an evaluated set to ignore.
|
|
1288
|
-
ignoreBtn.style.display = hasSet ? '' : 'none'
|
|
1289
|
-
ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
|
|
1290
|
-
ignoreBtn.onclick = () => {
|
|
1291
|
-
const version = report.manifestVersion as string
|
|
1292
|
-
const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
|
|
1293
|
-
void action
|
|
1294
|
-
.then(() => store.refresh())
|
|
1295
|
-
.catch(() => undefined)
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1007
|
+
// ── Panel ─────────────────────────────────────────────────────────────────
|
|
1298
1008
|
|
|
1299
1009
|
/**
|
|
1300
1010
|
* Open (or focus) the update panel. Re-renders from the store; when the store
|
|
@@ -1307,6 +1017,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1307
1017
|
return
|
|
1308
1018
|
}
|
|
1309
1019
|
|
|
1020
|
+
const previouslyFocused = document.activeElement as HTMLElement | null
|
|
1021
|
+
|
|
1310
1022
|
const overlay = el('div', 'hqpu-overlay')
|
|
1311
1023
|
overlay.setAttribute('data-hq-plugin-update-view', '')
|
|
1312
1024
|
overlay.addEventListener('click', (ev) => {
|
|
@@ -1317,15 +1029,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1317
1029
|
const panel = el('div', 'hqpu-panel')
|
|
1318
1030
|
panel.setAttribute('role', 'dialog')
|
|
1319
1031
|
panel.setAttribute('aria-modal', 'true')
|
|
1320
|
-
panel.setAttribute('aria-label', copy.
|
|
1032
|
+
panel.setAttribute('aria-label', copy.ui.title)
|
|
1321
1033
|
panel.tabIndex = -1
|
|
1322
1034
|
|
|
1323
|
-
// ──
|
|
1324
|
-
|
|
1325
|
-
const head = el('div', 'hqpu-head')
|
|
1035
|
+
// ── Header ──
|
|
1036
|
+
const head = el('header', 'hqpu-head')
|
|
1326
1037
|
const headMain = el('div', 'hqpu-head-main')
|
|
1327
1038
|
const brandIcon = el('span', 'hqpu-brand-icon')
|
|
1328
|
-
brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1039
|
+
brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13.8 8.6A5.8 5.8 0 012.2 6.4"/><path d="M13.8 4.4V8.6H9.6"/><path d="M2.2 7.4A5.8 5.8 0 0113.8 9.6"/><path d="M2.2 11.6V7.4H6.4"/></svg>`
|
|
1329
1040
|
const titleBlock = el('div', 'hqpu-title-block')
|
|
1330
1041
|
titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
|
|
1331
1042
|
const sub = el('p', 'hqpu-sub')
|
|
@@ -1333,89 +1044,96 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1333
1044
|
headMain.append(brandIcon, titleBlock)
|
|
1334
1045
|
|
|
1335
1046
|
const toolbar = el('div', 'hqpu-toolbar')
|
|
1336
|
-
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(
|
|
1047
|
+
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(15), copy.refresh, () => {
|
|
1337
1048
|
void store.check()
|
|
1338
1049
|
})
|
|
1339
|
-
toolbar.append(refreshBtn)
|
|
1340
1050
|
const gearBtn = iconButton(
|
|
1341
1051
|
'hqpu-icon-btn',
|
|
1342
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1052
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.2"/><path d="M13.5 9.2v-2.4l-1.8-.6a4.4 4.4 0 00-.5-1.2l.9-1.7-1.7-1.7-1.7.9a4.4 4.4 0 00-1.2-.5L7.2 2.5H4.8l-.6 1.8a4.4 4.4 0 00-1.2.5l-1.7-.9-1.7 1.7.9 1.7a4.4 4.4 0 00-.5 1.2L2.5 7.2v2.4l1.8.6a4.4 4.4 0 00.5 1.2l-.9 1.7 1.7 1.7 1.7-.9a4.4 4.4 0 001.2.5l.6 1.8h2.4l.6-1.8a4.4 4.4 0 001.2-.5l1.7.9 1.7-1.7-.9-1.7a4.4 4.4 0 00.5-1.2l1.8-.6z"/></svg>`,
|
|
1343
1053
|
copy.ui.gearTooltip,
|
|
1344
1054
|
() => {
|
|
1345
|
-
|
|
1346
|
-
render()
|
|
1055
|
+
toggleSettings()
|
|
1347
1056
|
},
|
|
1348
1057
|
)
|
|
1349
|
-
|
|
1350
|
-
|
|
1058
|
+
gearBtn.dataset.settingsToggle = ''
|
|
1059
|
+
gearBtn.setAttribute('aria-pressed', 'false')
|
|
1060
|
+
const logBtn = iconButton('hqpu-icon-btn', LOG_ICON(15), copy.ui.logShow, () => {
|
|
1061
|
+
toggleLog()
|
|
1062
|
+
})
|
|
1063
|
+
logBtn.dataset.logToggle = ''
|
|
1064
|
+
logBtn.setAttribute('aria-pressed', 'false')
|
|
1065
|
+
const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--close', CLOSE_ICON(15), copy.close, () => {
|
|
1351
1066
|
onClose()
|
|
1352
1067
|
})
|
|
1353
|
-
toolbar.append(closeBtn)
|
|
1068
|
+
toolbar.append(refreshBtn, gearBtn, logBtn, closeBtn)
|
|
1354
1069
|
head.append(headMain, toolbar)
|
|
1355
1070
|
panel.append(head)
|
|
1356
1071
|
|
|
1357
|
-
// ──
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1072
|
+
// ── Settings strip (gear-toggled, above the body so it never shrinks it) ──
|
|
1073
|
+
const settingsEl = el('div', 'hqpu-settings')
|
|
1074
|
+
settingsEl.hidden = true
|
|
1075
|
+
panel.append(settingsEl)
|
|
1360
1076
|
|
|
1361
|
-
// ──
|
|
1077
|
+
// ── Body + footer + log drawer share one positioned box, so the drawer can
|
|
1078
|
+
// cover the body without covering the header ──
|
|
1079
|
+
const main = el('div', 'hqpu-main')
|
|
1362
1080
|
const scroll = el('div', 'hqpu-scroll')
|
|
1363
1081
|
const column = el('div', 'hqpu-column')
|
|
1364
|
-
// The durable-job view leads: an active install/runtime update is resumed on
|
|
1365
|
-
// open BEFORE anything else, and a recent terminal result is shown here too.
|
|
1366
1082
|
const jobSection = el('div', 'hqpu-stack')
|
|
1083
|
+
const bannerSection = el('div', 'hqpu-stack')
|
|
1084
|
+
const bandSection = el('div', 'hqpu-stack')
|
|
1367
1085
|
const listSection = el('div', 'hqpu-stack')
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
column.append(jobSection, listSection, runtimeSection, toastSection)
|
|
1086
|
+
const extraSection = el('div', 'hqpu-stack')
|
|
1087
|
+
column.append(jobSection, bannerSection, bandSection, listSection, extraSection)
|
|
1371
1088
|
scroll.append(column)
|
|
1372
|
-
panel.append(scroll)
|
|
1373
1089
|
|
|
1374
|
-
|
|
1375
|
-
const
|
|
1090
|
+
const footer = el('footer', 'hqpu-footer')
|
|
1091
|
+
const cta = el('button', 'hqpu-btn--block')
|
|
1092
|
+
cta.type = 'button'
|
|
1093
|
+
const ctaSpinner = el('span', 'hqpu-cta-spinner')
|
|
1094
|
+
ctaSpinner.hidden = true
|
|
1095
|
+
const ctaLabel = el('span', 'hqpu-cta-label')
|
|
1096
|
+
cta.append(ctaSpinner, ctaLabel)
|
|
1376
1097
|
const footMeta = el('div', 'hqpu-foot-meta')
|
|
1377
1098
|
const footCount = el('span', '')
|
|
1378
|
-
const
|
|
1379
|
-
footMeta.append(footCount, footSet)
|
|
1380
|
-
const footActions = el('div', 'hqpu-foot-actions')
|
|
1381
|
-
const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
|
|
1382
|
-
installAllBtn.type = 'button'
|
|
1383
|
-
const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
|
|
1099
|
+
const ignoreBtn = el('button', 'hqpu-link')
|
|
1384
1100
|
ignoreBtn.type = 'button'
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1101
|
+
footMeta.append(footCount, ignoreBtn)
|
|
1102
|
+
footer.append(cta, footMeta, el('p', 'hqpu-footnote', copy.ui.footnote))
|
|
1103
|
+
|
|
1104
|
+
const logDrawer = el('div', 'hqpu-log')
|
|
1105
|
+
logDrawer.dataset.logDrawer = ''
|
|
1106
|
+
logDrawer.hidden = true
|
|
1107
|
+
|
|
1108
|
+
const toastSection = el('div', 'hqpu-toast-host')
|
|
1109
|
+
main.append(scroll, footer, logDrawer, toastSection)
|
|
1110
|
+
panel.append(main)
|
|
1389
1111
|
|
|
1390
1112
|
overlay.append(scrim, panel)
|
|
1391
1113
|
document.body.appendChild(overlay)
|
|
1392
1114
|
toastHost = toastSection
|
|
1115
|
+
activeCopy = copy
|
|
1393
1116
|
panel.focus()
|
|
1394
1117
|
|
|
1395
1118
|
const disposers: Array<() => void> = []
|
|
1396
1119
|
|
|
1397
|
-
// ──
|
|
1398
|
-
// Live in the panel's closure (not the report) because they are UI state, and
|
|
1399
|
-
// a store refresh must not silently disarm a confirm the user just armed.
|
|
1120
|
+
// ── Panel-closure UI state (survives store refreshes) ──
|
|
1400
1121
|
let runtimeArmed = false
|
|
1401
1122
|
let runtimeBusy = false
|
|
1402
1123
|
let runtimeAccepted = false
|
|
1403
|
-
|
|
1404
|
-
// ── Panel-closure UI state (survive store refreshes) ──
|
|
1405
|
-
const changelogOpen = new Set<string>()
|
|
1406
|
-
let allOpen = false
|
|
1407
1124
|
let settingsOpen = false
|
|
1125
|
+
let settingsBuilt = false
|
|
1126
|
+
let logOpen = false
|
|
1408
1127
|
let dismissedAlert = false
|
|
1128
|
+
let changesAllOpen = false
|
|
1129
|
+
const changesOpen = new Set<string>()
|
|
1130
|
+
let upToDateOpen = false
|
|
1131
|
+
let ignoredOpen = false
|
|
1132
|
+
let releaseOpen = false
|
|
1133
|
+
let saving = false
|
|
1134
|
+
let pluginDraft = ''
|
|
1135
|
+
let runtimeDraft = ''
|
|
1409
1136
|
|
|
1410
|
-
// Source-editor state, same reasoning: a background refresh must not discard
|
|
1411
|
-
// a half-typed URL or silently close either editor.
|
|
1412
|
-
const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
1413
|
-
const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
1414
|
-
|
|
1415
|
-
// Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
|
|
1416
|
-
// getters once and freeze `sourceEditing: false` — the editor would never
|
|
1417
|
-
// open. Read every member through the live object instead.
|
|
1418
|
-
const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
|
|
1419
1137
|
const runtimeActions: RuntimeActions = {
|
|
1420
1138
|
get busy() {
|
|
1421
1139
|
return runtimeBusy
|
|
@@ -1437,87 +1155,51 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1437
1155
|
onConfirm: () => {
|
|
1438
1156
|
void requestRuntime()
|
|
1439
1157
|
},
|
|
1440
|
-
get sourceEditing() {
|
|
1441
|
-
return runtimeSourceActions.sourceEditing
|
|
1442
|
-
},
|
|
1443
|
-
get sourceSaving() {
|
|
1444
|
-
return runtimeSourceActions.sourceSaving
|
|
1445
|
-
},
|
|
1446
|
-
get sourceDraft() {
|
|
1447
|
-
return runtimeSourceActions.sourceDraft
|
|
1448
|
-
},
|
|
1449
|
-
onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
|
|
1450
|
-
onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
|
|
1451
|
-
onSourceSave: () => runtimeSourceActions.onSourceSave(),
|
|
1452
|
-
onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
|
|
1453
|
-
onSourceReset: () => runtimeSourceActions.onSourceReset(),
|
|
1454
1158
|
}
|
|
1455
1159
|
|
|
1456
|
-
|
|
1160
|
+
/** Fill both drafts from the report's URLs in effect. */
|
|
1161
|
+
function syncDrafts(): void {
|
|
1162
|
+
const report = store.report()
|
|
1163
|
+
pluginDraft = normalizeSource(report?.pluginSource)?.url ?? ''
|
|
1164
|
+
runtimeDraft = normalizeSource(report?.runtime.source)?.url ?? ''
|
|
1165
|
+
}
|
|
1457
1166
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
return {
|
|
1464
|
-
get sourceEditing() {
|
|
1465
|
-
return state.editing
|
|
1466
|
-
},
|
|
1467
|
-
get sourceSaving() {
|
|
1468
|
-
return state.saving
|
|
1469
|
-
},
|
|
1470
|
-
get sourceDraft() {
|
|
1471
|
-
return state.draft
|
|
1472
|
-
},
|
|
1473
|
-
onSourceEdit: () => {
|
|
1474
|
-
// Prefill with the URL in effect, so 保存 is a no-op unless edited.
|
|
1475
|
-
const report = store.report()
|
|
1476
|
-
const source =
|
|
1477
|
-
kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
|
|
1478
|
-
state.draft = source?.url ?? ''
|
|
1479
|
-
state.editing = true
|
|
1480
|
-
render()
|
|
1481
|
-
},
|
|
1482
|
-
onSourceDraft: (value: string) => {
|
|
1483
|
-
state.draft = value
|
|
1484
|
-
},
|
|
1485
|
-
onSourceSave: () => {
|
|
1486
|
-
void submitSource(kind, state)
|
|
1487
|
-
},
|
|
1488
|
-
onSourceCancel: () => {
|
|
1489
|
-
state.editing = false
|
|
1490
|
-
render()
|
|
1491
|
-
},
|
|
1492
|
-
onSourceReset: () => {
|
|
1493
|
-
void submitSource(kind, state, '')
|
|
1494
|
-
},
|
|
1167
|
+
function toggleSettings(): void {
|
|
1168
|
+
settingsOpen = !settingsOpen
|
|
1169
|
+
if (settingsOpen) {
|
|
1170
|
+
syncDrafts()
|
|
1171
|
+
settingsBuilt = false
|
|
1495
1172
|
}
|
|
1173
|
+
render()
|
|
1496
1174
|
}
|
|
1497
1175
|
|
|
1498
1176
|
/**
|
|
1499
|
-
* Save
|
|
1500
|
-
* then re-run discovery.
|
|
1177
|
+
* Save both manifest sources, then re-run discovery and close the strip.
|
|
1501
1178
|
*
|
|
1502
1179
|
* The re-check is not optional: the report is cached, so without a fresh
|
|
1503
|
-
* `POST /check` the
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
* the
|
|
1180
|
+
* `POST /check` the list would keep naming the OLD manifest's offer after the
|
|
1181
|
+
* source changed. hq-edge answers 422 with a message meant for a person on
|
|
1182
|
+
* refusal (bad scheme, credentials, …), which the toast shows verbatim —
|
|
1183
|
+
* and the strip stays open with the draft intact so it can be fixed.
|
|
1507
1184
|
*/
|
|
1508
|
-
async function
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1185
|
+
async function saveSources(): Promise<void> {
|
|
1186
|
+
const report = store.report()
|
|
1187
|
+
const pluginSource = normalizeSource(report?.pluginSource)
|
|
1188
|
+
const runtimeSource = normalizeSource(report?.runtime.source)
|
|
1189
|
+
saving = true
|
|
1190
|
+
settingsBuilt = false
|
|
1514
1191
|
render()
|
|
1515
|
-
const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
|
|
1516
1192
|
try {
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1193
|
+
const calls: Array<Promise<unknown>> = []
|
|
1194
|
+
if (pluginSource !== null && pluginDraft !== pluginSource.url) {
|
|
1195
|
+
calls.push(setPluginUpdateSource(pluginDraft))
|
|
1196
|
+
}
|
|
1197
|
+
if (runtimeSource !== null && runtimeDraft !== runtimeSource.url) {
|
|
1198
|
+
calls.push(setRuntimeUpdateSource(runtimeDraft))
|
|
1199
|
+
}
|
|
1200
|
+
await Promise.all(calls)
|
|
1201
|
+
settingsOpen = false
|
|
1202
|
+
showToast(copy.pluginSource.saved, 'success')
|
|
1521
1203
|
try {
|
|
1522
1204
|
await checkUpdates()
|
|
1523
1205
|
await store.refresh()
|
|
@@ -1527,11 +1209,40 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1527
1209
|
}
|
|
1528
1210
|
} catch (err: unknown) {
|
|
1529
1211
|
showToast(
|
|
1530
|
-
`${
|
|
1212
|
+
`${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1531
1213
|
'error',
|
|
1532
1214
|
)
|
|
1533
1215
|
} finally {
|
|
1534
|
-
|
|
1216
|
+
saving = false
|
|
1217
|
+
settingsBuilt = false
|
|
1218
|
+
render()
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Clear one user override so the configured URL applies again. */
|
|
1223
|
+
async function resetSource(kind: SourceKind): Promise<void> {
|
|
1224
|
+
saving = true
|
|
1225
|
+
settingsBuilt = false
|
|
1226
|
+
render()
|
|
1227
|
+
try {
|
|
1228
|
+
if (kind === 'runtime') await setRuntimeUpdateSource('')
|
|
1229
|
+
else await setPluginUpdateSource('')
|
|
1230
|
+
showToast(kind === 'runtime' ? copy.runtime.saved : copy.pluginSource.saved, 'success')
|
|
1231
|
+
try {
|
|
1232
|
+
await checkUpdates()
|
|
1233
|
+
await store.refresh()
|
|
1234
|
+
} catch {
|
|
1235
|
+
/* saved anyway */
|
|
1236
|
+
}
|
|
1237
|
+
syncDrafts()
|
|
1238
|
+
} catch (err: unknown) {
|
|
1239
|
+
showToast(
|
|
1240
|
+
`${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1241
|
+
'error',
|
|
1242
|
+
)
|
|
1243
|
+
} finally {
|
|
1244
|
+
saving = false
|
|
1245
|
+
settingsBuilt = false
|
|
1535
1246
|
render()
|
|
1536
1247
|
}
|
|
1537
1248
|
}
|
|
@@ -1552,13 +1263,10 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1552
1263
|
return
|
|
1553
1264
|
}
|
|
1554
1265
|
// 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
|
|
1555
|
-
// relaunch.
|
|
1556
|
-
//
|
|
1557
|
-
// server that is already gone.
|
|
1266
|
+
// relaunch. Latch it, so a store refresh cannot re-offer the button for
|
|
1267
|
+
// a server that is already gone.
|
|
1558
1268
|
runtimeAccepted = true
|
|
1559
1269
|
showToast(copy.runtime.updateAccepted, 'success')
|
|
1560
|
-
// Adopt the new durable job immediately so the active-job view renders
|
|
1561
|
-
// (it polls from here; this process is about to be swapped away anyway).
|
|
1562
1270
|
void store.fetchStatus()
|
|
1563
1271
|
} catch (err: unknown) {
|
|
1564
1272
|
showToast(
|
|
@@ -1574,7 +1282,7 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1574
1282
|
/** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
|
|
1575
1283
|
* re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
|
|
1576
1284
|
* settled job id. */
|
|
1577
|
-
|
|
1285
|
+
function retryJob(kind: 'plugin-set' | 'runtime'): void {
|
|
1578
1286
|
if (kind === 'runtime') {
|
|
1579
1287
|
runtimeArmed = true
|
|
1580
1288
|
render()
|
|
@@ -1583,100 +1291,543 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1583
1291
|
}
|
|
1584
1292
|
}
|
|
1585
1293
|
|
|
1586
|
-
|
|
1294
|
+
/** The newest settled job — what the log drawer is about. */
|
|
1295
|
+
function settledJob(): UpdateJob | null {
|
|
1296
|
+
const latest = store.recentJobs()[0]
|
|
1297
|
+
return latest !== undefined && TERMINAL_PHASES.has(latest.phase) ? latest : null
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function toggleLog(): void {
|
|
1301
|
+
logOpen = !logOpen
|
|
1302
|
+
render()
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function copyLogId(id: string): Promise<void> {
|
|
1306
|
+
try {
|
|
1307
|
+
const clipboard = navigator.clipboard
|
|
1308
|
+
if (clipboard === undefined) throw new Error('clipboard unavailable')
|
|
1309
|
+
await clipboard.writeText(id)
|
|
1310
|
+
showToast(copy.ui.logCopied, 'success')
|
|
1311
|
+
} catch {
|
|
1312
|
+
showToast(copy.ui.logCopyFailed, 'error')
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── Section renderers ──
|
|
1317
|
+
|
|
1318
|
+
function renderSettings(): void {
|
|
1319
|
+
if (!settingsOpen) {
|
|
1320
|
+
settingsEl.hidden = true
|
|
1321
|
+
settingsEl.textContent = ''
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
settingsEl.hidden = false
|
|
1325
|
+
// Rebuilding on every store emit would blow away the caret of whoever is
|
|
1326
|
+
// typing, so the DOM is built once per open (or per explicit force).
|
|
1327
|
+
if (settingsBuilt) return
|
|
1328
|
+
settingsBuilt = true
|
|
1329
|
+
settingsEl.textContent = ''
|
|
1330
|
+
|
|
1331
|
+
const report = store.report()
|
|
1332
|
+
const pluginSource = normalizeSource(report?.pluginSource)
|
|
1333
|
+
const runtimeSource = normalizeSource(report?.runtime.source)
|
|
1334
|
+
|
|
1335
|
+
const row = el('div', 'hqpu-settings-row')
|
|
1336
|
+
row.append(el('span', 'hqpu-settings-title', copy.ui.settingsTitle))
|
|
1337
|
+
const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
|
|
1338
|
+
const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
|
|
1339
|
+
badge.dataset.origin = overridden ? 'override' : 'config'
|
|
1340
|
+
row.append(badge)
|
|
1341
|
+
settingsEl.append(row)
|
|
1342
|
+
|
|
1343
|
+
const field = (
|
|
1344
|
+
kind: SourceKind,
|
|
1345
|
+
source: RuntimeUpdateSource | null,
|
|
1346
|
+
label: string,
|
|
1347
|
+
ariaLabel: string,
|
|
1348
|
+
placeholder: string,
|
|
1349
|
+
draft: string,
|
|
1350
|
+
onDraft: (value: string) => void,
|
|
1351
|
+
): void => {
|
|
1352
|
+
if (source === null) return
|
|
1353
|
+
const wrap = el('div', 'hqpu-field')
|
|
1354
|
+
const labelRow = el('div', 'hqpu-field-head')
|
|
1355
|
+
const id = `hqpu-source-${kind}`
|
|
1356
|
+
const labelEl = el('label', '', label)
|
|
1357
|
+
labelEl.htmlFor = id
|
|
1358
|
+
labelRow.append(labelEl)
|
|
1359
|
+
if (source.override) {
|
|
1360
|
+
const reset = linkButton('hqpu-link hqpu-link--tiny', copy.ui.resetSource, () => {
|
|
1361
|
+
void resetSource(kind)
|
|
1362
|
+
})
|
|
1363
|
+
reset.setAttribute(`data-${kind}-source-reset`, '')
|
|
1364
|
+
labelRow.append(reset)
|
|
1365
|
+
}
|
|
1366
|
+
wrap.append(labelRow)
|
|
1367
|
+
const input = el('input', 'hqpu-source-input')
|
|
1368
|
+
input.id = id
|
|
1369
|
+
input.type = 'url'
|
|
1370
|
+
input.value = draft
|
|
1371
|
+
input.placeholder = placeholder
|
|
1372
|
+
input.setAttribute('aria-label', ariaLabel)
|
|
1373
|
+
input.setAttribute(`data-${kind}-source-input`, '')
|
|
1374
|
+
input.disabled = saving
|
|
1375
|
+
input.spellcheck = false
|
|
1376
|
+
input.addEventListener('input', () => {
|
|
1377
|
+
// Draft-only: no re-render, so the caret and IME stay untouched.
|
|
1378
|
+
onDraft(input.value)
|
|
1379
|
+
})
|
|
1380
|
+
// Enter saves both; Escape closes the strip without saving.
|
|
1381
|
+
input.addEventListener('keydown', (ev) => {
|
|
1382
|
+
if (ev.key === 'Enter') {
|
|
1383
|
+
ev.preventDefault()
|
|
1384
|
+
void saveSources()
|
|
1385
|
+
} else if (ev.key === 'Escape') {
|
|
1386
|
+
ev.preventDefault()
|
|
1387
|
+
ev.stopPropagation()
|
|
1388
|
+
settingsOpen = false
|
|
1389
|
+
syncDrafts()
|
|
1390
|
+
render()
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1393
|
+
wrap.append(input)
|
|
1394
|
+
settingsEl.append(wrap)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
field(
|
|
1398
|
+
'plugin',
|
|
1399
|
+
pluginSource,
|
|
1400
|
+
copy.ui.pluginUrlLabel,
|
|
1401
|
+
copy.pluginSource.inputLabel,
|
|
1402
|
+
copy.pluginSource.placeholder,
|
|
1403
|
+
pluginDraft,
|
|
1404
|
+
(value) => {
|
|
1405
|
+
pluginDraft = value
|
|
1406
|
+
},
|
|
1407
|
+
)
|
|
1408
|
+
field(
|
|
1409
|
+
'runtime',
|
|
1410
|
+
runtimeSource,
|
|
1411
|
+
copy.ui.runtimeUrlLabel,
|
|
1412
|
+
copy.runtime.sourceInputLabel,
|
|
1413
|
+
copy.runtime.sourcePlaceholder,
|
|
1414
|
+
runtimeDraft,
|
|
1415
|
+
(value) => {
|
|
1416
|
+
runtimeDraft = value
|
|
1417
|
+
},
|
|
1418
|
+
)
|
|
1419
|
+
if (pluginSource === null && runtimeSource === null) {
|
|
1420
|
+
settingsEl.append(el('p', 'hqpu-status', copy.runtime.notConfigured))
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const actions = el('div', 'hqpu-settings-actions')
|
|
1424
|
+
const cancel = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
|
|
1425
|
+
cancel.type = 'button'
|
|
1426
|
+
cancel.addEventListener('click', () => {
|
|
1427
|
+
settingsOpen = false
|
|
1428
|
+
syncDrafts()
|
|
1429
|
+
render()
|
|
1430
|
+
})
|
|
1431
|
+
const save = el('button', 'hqpu-btn hqpu-btn--brand hqpu-btn--sm', copy.ui.saveAndRefresh)
|
|
1432
|
+
save.type = 'button'
|
|
1433
|
+
save.disabled = saving
|
|
1434
|
+
save.setAttribute('data-source-save', '')
|
|
1435
|
+
save.addEventListener('click', () => {
|
|
1436
|
+
void saveSources()
|
|
1437
|
+
})
|
|
1438
|
+
actions.append(cancel, save)
|
|
1439
|
+
settingsEl.append(actions)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function renderJobSection(): void {
|
|
1587
1443
|
jobSection.textContent = ''
|
|
1588
1444
|
const active = store.activeJob()
|
|
1589
1445
|
if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
|
|
1590
|
-
// An active job leads the panel — resumed on open, polled to its end.
|
|
1591
1446
|
jobSection.append(activeJobView(active, copy))
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function renderBanners(report: PluginUpdateReport | null, jobActive: boolean): void {
|
|
1451
|
+
bannerSection.textContent = ''
|
|
1452
|
+
if (report !== null) {
|
|
1453
|
+
const banner = checkBanner(report, copy, () => {
|
|
1454
|
+
void store.check()
|
|
1455
|
+
})
|
|
1456
|
+
if (banner !== null) bannerSection.append(banner)
|
|
1457
|
+
}
|
|
1458
|
+
// A terminal non-success job leads the body; the full log is one click away
|
|
1459
|
+
// in the drawer, but the outcome itself must not hide there.
|
|
1460
|
+
if (jobActive) return
|
|
1461
|
+
const settled = settledJob()
|
|
1462
|
+
if (settled !== null && settled.phase !== 'done') {
|
|
1463
|
+
bannerSection.append(
|
|
1464
|
+
jobResultBanner(
|
|
1465
|
+
settled,
|
|
1466
|
+
copy,
|
|
1467
|
+
() => retryJob(settled.kind),
|
|
1468
|
+
() => {
|
|
1469
|
+
logOpen = true
|
|
1470
|
+
render()
|
|
1471
|
+
},
|
|
1472
|
+
),
|
|
1473
|
+
)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function renderList(report: PluginUpdateReport | null, busy: boolean): void {
|
|
1478
|
+
listSection.textContent = ''
|
|
1479
|
+
bandSection.textContent = ''
|
|
1480
|
+
if (report === null) {
|
|
1481
|
+
listSection.append(skeleton(copy))
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
const band = decisionBand(report, copy)
|
|
1485
|
+
if (band !== null) bandSection.append(band)
|
|
1486
|
+
|
|
1487
|
+
const actionable = report.items.filter(isActionable)
|
|
1488
|
+
if (actionable.length > 0) {
|
|
1489
|
+
const section = el('section', 'hqpu-group')
|
|
1490
|
+
section.dataset.group = copy.group.actionable
|
|
1491
|
+
const head = el('div', 'hqpu-sec-head')
|
|
1492
|
+
const titleWrap = el('div', 'hqpu-sec-title')
|
|
1493
|
+
titleWrap.append(el('span', '', copy.group.actionable))
|
|
1494
|
+
titleWrap.append(el('span', 'hqpu-count-pill', String(actionable.length)))
|
|
1495
|
+
head.append(titleWrap)
|
|
1496
|
+
const anyChanges = actionable.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
|
|
1497
|
+
if (anyChanges) {
|
|
1498
|
+
head.append(
|
|
1499
|
+
linkButton(
|
|
1500
|
+
'hqpu-link',
|
|
1501
|
+
changesAllOpen ? copy.ui.collapseLogs : copy.ui.expandLogs,
|
|
1502
|
+
() => {
|
|
1503
|
+
changesAllOpen = !changesAllOpen
|
|
1504
|
+
if (!changesAllOpen) changesOpen.clear()
|
|
1505
|
+
render()
|
|
1506
|
+
},
|
|
1507
|
+
),
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
section.append(head)
|
|
1511
|
+
const list = el('ul', 'hqpu-cards')
|
|
1512
|
+
for (const item of actionable) {
|
|
1513
|
+
list.append(
|
|
1514
|
+
pluginCard(item, copy, {
|
|
1515
|
+
changesOpen: changesAllOpen || changesOpen.has(item.name),
|
|
1516
|
+
onToggleChanges: () => {
|
|
1517
|
+
if (changesOpen.has(item.name)) changesOpen.delete(item.name)
|
|
1518
|
+
else changesOpen.add(item.name)
|
|
1519
|
+
render()
|
|
1520
|
+
},
|
|
1521
|
+
onInstall: () => {
|
|
1522
|
+
void runInstall(store, copy, [item.name])
|
|
1523
|
+
},
|
|
1524
|
+
onIgnore: () => {
|
|
1525
|
+
void togglePluginIgnore(item)
|
|
1526
|
+
},
|
|
1527
|
+
busy,
|
|
1528
|
+
}),
|
|
1529
|
+
)
|
|
1530
|
+
}
|
|
1531
|
+
section.append(list)
|
|
1532
|
+
listSection.append(section)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function renderExtras(report: PluginUpdateReport | null): void {
|
|
1537
|
+
extraSection.textContent = ''
|
|
1538
|
+
if (report === null) return
|
|
1539
|
+
|
|
1540
|
+
const alert = runtimeResultAlert(report, copy, dismissedAlert, () => {
|
|
1541
|
+
dismissedAlert = true
|
|
1542
|
+
render()
|
|
1543
|
+
})
|
|
1544
|
+
if (alert !== null) extraSection.append(alert)
|
|
1545
|
+
|
|
1546
|
+
const engine = runtimeEngineCard(report, copy, runtimeActions)
|
|
1547
|
+
if (engine !== null) extraSection.append(engine)
|
|
1548
|
+
|
|
1549
|
+
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
1550
|
+
report.items.filter((item) => item.state === state)
|
|
1551
|
+
|
|
1552
|
+
const ignored = byState('ignored')
|
|
1553
|
+
if (ignored.length > 0) {
|
|
1554
|
+
extraSection.append(
|
|
1555
|
+
disclosure({
|
|
1556
|
+
id: 'hqpu-disc-ignored',
|
|
1557
|
+
title: copy.group.ignored,
|
|
1558
|
+
count: `(${ignored.length})`,
|
|
1559
|
+
note: copy.ui.ignoredNote,
|
|
1560
|
+
open: ignoredOpen,
|
|
1561
|
+
onToggle: () => {
|
|
1562
|
+
ignoredOpen = !ignoredOpen
|
|
1563
|
+
render()
|
|
1564
|
+
},
|
|
1565
|
+
build: () => {
|
|
1566
|
+
const ul = el('ul', 'hqpu-rows')
|
|
1567
|
+
for (const item of ignored) {
|
|
1568
|
+
ul.append(
|
|
1569
|
+
pluginRow(item, copy, () => {
|
|
1570
|
+
void togglePluginIgnore(item)
|
|
1571
|
+
}),
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
return [ul]
|
|
1575
|
+
},
|
|
1576
|
+
}),
|
|
1577
|
+
)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const current = byState('current')
|
|
1581
|
+
if (current.length > 0) {
|
|
1582
|
+
extraSection.append(
|
|
1583
|
+
disclosure({
|
|
1584
|
+
id: 'hqpu-disc-current',
|
|
1585
|
+
title: copy.group.current,
|
|
1586
|
+
count: `(${current.length})`,
|
|
1587
|
+
note: copy.ui.upToDateNote,
|
|
1588
|
+
open: upToDateOpen,
|
|
1589
|
+
onToggle: () => {
|
|
1590
|
+
upToDateOpen = !upToDateOpen
|
|
1591
|
+
render()
|
|
1592
|
+
},
|
|
1593
|
+
build: () => {
|
|
1594
|
+
const ul = el('ul', 'hqpu-rows')
|
|
1595
|
+
for (const item of current) ul.append(pluginRow(item, copy, () => undefined))
|
|
1596
|
+
return [ul]
|
|
1597
|
+
},
|
|
1598
|
+
}),
|
|
1599
|
+
)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (hasReleaseContent(report.release)) {
|
|
1603
|
+
extraSection.append(
|
|
1604
|
+
disclosure({
|
|
1605
|
+
id: 'hqpu-disc-release',
|
|
1606
|
+
title: copy.ui.releaseTitle,
|
|
1607
|
+
note: report.manifestVersion !== null ? `v${report.manifestVersion}` : undefined,
|
|
1608
|
+
open: releaseOpen,
|
|
1609
|
+
onToggle: () => {
|
|
1610
|
+
releaseOpen = !releaseOpen
|
|
1611
|
+
render()
|
|
1612
|
+
},
|
|
1613
|
+
build: () => releaseBody(report.release as ReleaseInfo, copy),
|
|
1614
|
+
}),
|
|
1615
|
+
)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (report.items.length === 0 && typeof report.lastError !== 'string') {
|
|
1619
|
+
extraSection.append(el('div', 'hqpu-empty', copy.empty))
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function renderFooter(report: PluginUpdateReport | null, busy: boolean): void {
|
|
1624
|
+
const actionable = report === null ? 0 : actionableUpdateCount(report)
|
|
1625
|
+
const setVersion = report?.manifestVersion ?? null
|
|
1626
|
+
const failed = typeof report?.lastError === 'string' && (report?.lastError ?? '').length > 0
|
|
1627
|
+
const runtimePending = report !== null && runtimeUpdateOffered(report.runtime)
|
|
1628
|
+
const anything = actionable > 0 || runtimePending
|
|
1629
|
+
|
|
1630
|
+
ctaSpinner.hidden = !busy
|
|
1631
|
+
cta.dataset.busy = busy ? 'true' : 'false'
|
|
1632
|
+
cta.setAttribute('aria-busy', busy ? 'true' : 'false')
|
|
1633
|
+
|
|
1634
|
+
if (busy) {
|
|
1635
|
+
ctaLabel.textContent = copy.ui.ctaInstalling
|
|
1636
|
+
cta.disabled = true
|
|
1637
|
+
footCount.textContent = copy.ui.footerBusy
|
|
1638
|
+
} else if (report === null) {
|
|
1639
|
+
ctaLabel.textContent = copy.ui.ctaNoSet
|
|
1640
|
+
cta.disabled = true
|
|
1641
|
+
footCount.textContent = ''
|
|
1642
|
+
} else if (failed) {
|
|
1643
|
+
// Installing against a stale manifest is the one path that can only end
|
|
1644
|
+
// in a 422 — say so instead of offering a button that cannot work.
|
|
1645
|
+
ctaLabel.textContent = copy.ui.ctaFailed
|
|
1646
|
+
cta.disabled = true
|
|
1647
|
+
footCount.textContent = copy.ui.footerFailed
|
|
1648
|
+
} else if (setVersion === null) {
|
|
1649
|
+
ctaLabel.textContent = copy.ui.ctaNoSet
|
|
1650
|
+
cta.disabled = true
|
|
1651
|
+
footCount.textContent = copy.ui.checkedNever
|
|
1652
|
+
} else if (actionable === 0 && runtimePending) {
|
|
1653
|
+
// The plugin set is done; the only remaining offer is the runtime, which
|
|
1654
|
+
// needs its own confirm — the CTA must not imply it would install it.
|
|
1655
|
+
ctaLabel.textContent = copy.ui.ctaNoPlugins
|
|
1656
|
+
cta.disabled = true
|
|
1657
|
+
footCount.textContent = copy.ui.footerRuntimeOnly
|
|
1658
|
+
} else if (actionable === 0 && !runtimePending) {
|
|
1659
|
+
ctaLabel.textContent = copy.ui.ctaUpToDate
|
|
1660
|
+
cta.disabled = true
|
|
1661
|
+
footCount.textContent = copy.ui.footerAllGood
|
|
1662
|
+
} else {
|
|
1663
|
+
ctaLabel.textContent = copy.ui.cta(actionable)
|
|
1664
|
+
cta.disabled = false
|
|
1665
|
+
footCount.textContent = copy.ui.footerCount(actionable)
|
|
1666
|
+
}
|
|
1667
|
+
cta.onclick = () => {
|
|
1668
|
+
void runInstall(store, copy)
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Set-level mute: only with an evaluated set and something to mute.
|
|
1672
|
+
const canMute = setVersion !== null && report !== null && !busy
|
|
1673
|
+
ignoreBtn.hidden = !canMute || (!anything && !report?.ignoredSet)
|
|
1674
|
+
ignoreBtn.textContent = report?.ignoredSet === true ? copy.ui.unignoreBtn : copy.ui.ignoreBtn
|
|
1675
|
+
ignoreBtn.onclick = () => {
|
|
1676
|
+
if (setVersion === null) return
|
|
1677
|
+
const ignored = report?.ignoredSet === true
|
|
1678
|
+
const action = ignored ? unignoreOffer(setVersion) : ignoreOffer(setVersion)
|
|
1679
|
+
// `check()`, never `refresh()`: ignoring only writes the decision to
|
|
1680
|
+
// hq-edge's state file, and its cached report keeps every item at
|
|
1681
|
+
// `update` until discovery runs again.
|
|
1682
|
+
void action
|
|
1683
|
+
.then(() => store.check())
|
|
1684
|
+
.catch((err: unknown) => {
|
|
1685
|
+
showToast(
|
|
1686
|
+
`${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1687
|
+
'error',
|
|
1688
|
+
)
|
|
1689
|
+
})
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function renderLogDrawer(jobActive: boolean): void {
|
|
1694
|
+
const job = settledJob()
|
|
1695
|
+
const available = !jobActive && job !== null
|
|
1696
|
+
logBtn.disabled = !available
|
|
1697
|
+
logBtn.setAttribute('aria-pressed', logOpen && available ? 'true' : 'false')
|
|
1698
|
+
const label =
|
|
1699
|
+
jobActive ? copy.ui.logBusy : job === null ? copy.ui.logNone : logOpen ? copy.ui.logHide : copy.ui.logShow
|
|
1700
|
+
logBtn.title = label
|
|
1701
|
+
logBtn.setAttribute('aria-label', label)
|
|
1702
|
+
|
|
1703
|
+
// A running job (or none at all) cannot have a log; drop the open flag so
|
|
1704
|
+
// the drawer does not snap back open once the job settles.
|
|
1705
|
+
if (!available || !logOpen) {
|
|
1706
|
+
if (!available) logOpen = false
|
|
1707
|
+
logDrawer.hidden = true
|
|
1708
|
+
logDrawer.textContent = ''
|
|
1709
|
+
main.dataset.logOpen = '0'
|
|
1592
1710
|
return
|
|
1593
1711
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1712
|
+
logDrawer.hidden = false
|
|
1713
|
+
logDrawer.textContent = ''
|
|
1714
|
+
// The drawer is absolutely positioned, so it cannot size its own box: on a
|
|
1715
|
+
// short body (nothing left to update) it would be squeezed between the
|
|
1716
|
+
// header and the panel edge. The flag gives `.hqpu-main` a floor while it
|
|
1717
|
+
// is open, and nothing at all once it closes.
|
|
1718
|
+
main.dataset.logOpen = '1'
|
|
1719
|
+
for (const node of buildLogDrawer(
|
|
1720
|
+
job,
|
|
1721
|
+
store.recentJobs(),
|
|
1722
|
+
copy,
|
|
1723
|
+
(id) => {
|
|
1724
|
+
void copyLogId(id)
|
|
1725
|
+
},
|
|
1726
|
+
() => {
|
|
1727
|
+
logOpen = false
|
|
1728
|
+
render()
|
|
1729
|
+
},
|
|
1730
|
+
)) {
|
|
1731
|
+
logDrawer.append(node)
|
|
1600
1732
|
}
|
|
1601
1733
|
}
|
|
1602
1734
|
|
|
1603
|
-
|
|
1735
|
+
function render(): void {
|
|
1604
1736
|
const report = store.report()
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
const
|
|
1608
|
-
store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
|
|
1609
|
-
const installing = store.installing() || jobActive
|
|
1610
|
-
const stale = report === null
|
|
1611
|
-
refreshBtn.disabled = stale
|
|
1612
|
-
refreshBtn.title = copy.refresh
|
|
1613
|
-
|
|
1614
|
-
// Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
|
|
1615
|
-
const ago = relativeAgo(report?.checkedAt, copy)
|
|
1616
|
-
sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
|
|
1737
|
+
const active = store.activeJob()
|
|
1738
|
+
const jobActive = active !== null && !TERMINAL_PHASES.has(active.phase)
|
|
1739
|
+
const busy = store.installing() || jobActive
|
|
1617
1740
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1741
|
+
refreshBtn.disabled = report === null
|
|
1742
|
+
gearBtn.setAttribute('aria-pressed', settingsOpen ? 'true' : 'false')
|
|
1743
|
+
gearBtn.title = copy.ui.gearTooltip
|
|
1621
1744
|
|
|
1745
|
+
// Header sub-line: the set version and the relative check time, once.
|
|
1746
|
+
const ago = relativeAgo(report?.checkedAt, copy)
|
|
1747
|
+
const setLabel =
|
|
1748
|
+
report?.manifestVersion !== null && report?.manifestVersion !== undefined
|
|
1749
|
+
? copy.ui.setVersion(report.manifestVersion)
|
|
1750
|
+
: null
|
|
1751
|
+
if (setLabel !== null && ago !== null) sub.textContent = copy.ui.subJoined(setLabel, ago)
|
|
1752
|
+
else sub.textContent = setLabel ?? ago ?? copy.ui.checkedUnknown
|
|
1753
|
+
|
|
1754
|
+
renderSettings()
|
|
1622
1755
|
renderJobSection()
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
(
|
|
1646
|
-
|
|
1647
|
-
},
|
|
1648
|
-
(item) => {
|
|
1649
|
-
const report = store.report()
|
|
1650
|
-
if (report?.manifestVersion === null || report?.manifestVersion === undefined) return
|
|
1651
|
-
const action = item.state === 'ignored'
|
|
1652
|
-
? unignorePlugin(report.manifestVersion, item.name)
|
|
1653
|
-
: ignorePlugin(report.manifestVersion, item.name)
|
|
1654
|
-
void action.then(() => store.check()).catch(() => undefined)
|
|
1655
|
-
},
|
|
1756
|
+
renderBanners(report, jobActive)
|
|
1757
|
+
renderList(report, busy)
|
|
1758
|
+
renderExtras(report)
|
|
1759
|
+
renderFooter(report, busy)
|
|
1760
|
+
renderLogDrawer(jobActive)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/** Flip one plugin's per-item ignore flag, then re-run discovery. */
|
|
1764
|
+
async function togglePluginIgnore(item: PluginUpdateItem): Promise<void> {
|
|
1765
|
+
const setVersion = store.report()?.manifestVersion ?? null
|
|
1766
|
+
if (setVersion === null) return
|
|
1767
|
+
const action =
|
|
1768
|
+
item.state === 'ignored'
|
|
1769
|
+
? unignorePlugin(setVersion, item.name)
|
|
1770
|
+
: ignorePlugin(setVersion, item.name)
|
|
1771
|
+
try {
|
|
1772
|
+
await action
|
|
1773
|
+
// Discovery must re-run for the item to come back as `ignored`; a
|
|
1774
|
+
// refusal has to be visible, never silently swallowed.
|
|
1775
|
+
await store.check()
|
|
1776
|
+
} catch (err: unknown) {
|
|
1777
|
+
showToast(
|
|
1778
|
+
`${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1779
|
+
'error',
|
|
1656
1780
|
)
|
|
1657
1781
|
}
|
|
1658
|
-
renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
|
|
1659
|
-
dismissedAlert = true
|
|
1660
|
-
render()
|
|
1661
|
-
})
|
|
1662
|
-
renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
|
|
1663
1782
|
}
|
|
1664
1783
|
|
|
1665
1784
|
const unsubscribe = store.subscribe(render)
|
|
1666
1785
|
disposers.push(unsubscribe)
|
|
1667
1786
|
|
|
1668
1787
|
// Refresh on open (bounded; never block the shell).
|
|
1669
|
-
void store.refresh()
|
|
1670
|
-
// Store notification re-renders already; nothing extra to do.
|
|
1671
|
-
})
|
|
1788
|
+
void store.refresh()
|
|
1672
1789
|
|
|
1673
|
-
// Escape
|
|
1790
|
+
// Escape peels one layer at a time; the shell-wide contract still holds.
|
|
1674
1791
|
const onKeyDown = (ev: KeyboardEvent): void => {
|
|
1675
1792
|
if (ev.key === 'Escape') {
|
|
1676
1793
|
ev.stopPropagation()
|
|
1794
|
+
if (logOpen) {
|
|
1795
|
+
logOpen = false
|
|
1796
|
+
render()
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
if (settingsOpen) {
|
|
1800
|
+
settingsOpen = false
|
|
1801
|
+
syncDrafts()
|
|
1802
|
+
render()
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1677
1805
|
onClose()
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
if (ev.key === 'Tab') trapFocus(ev)
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Keep Tab inside the dialog — it declares `aria-modal="true"`. */
|
|
1812
|
+
function trapFocus(ev: KeyboardEvent): void {
|
|
1813
|
+
const focusable = [
|
|
1814
|
+
...panel.querySelectorAll<HTMLElement>(
|
|
1815
|
+
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
|
1816
|
+
),
|
|
1817
|
+
].filter((node) => node.closest('[hidden]') === null)
|
|
1818
|
+
if (focusable.length === 0) return
|
|
1819
|
+
const first = focusable[0]!
|
|
1820
|
+
const last = focusable[focusable.length - 1]!
|
|
1821
|
+
const activeEl = document.activeElement
|
|
1822
|
+
if (ev.shiftKey && (activeEl === first || activeEl === panel)) {
|
|
1823
|
+
ev.preventDefault()
|
|
1824
|
+
last.focus()
|
|
1825
|
+
} else if (!ev.shiftKey && activeEl === last) {
|
|
1826
|
+
ev.preventDefault()
|
|
1827
|
+
first.focus()
|
|
1678
1828
|
}
|
|
1679
1829
|
}
|
|
1830
|
+
|
|
1680
1831
|
document.addEventListener('keydown', onKeyDown)
|
|
1681
1832
|
disposers.push(() => document.removeEventListener('keydown', onKeyDown))
|
|
1682
1833
|
|
|
@@ -1691,8 +1842,79 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1691
1842
|
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
1692
1843
|
toastTimer = null
|
|
1693
1844
|
if (toastHost === toastSection) toastHost = null
|
|
1845
|
+
if (activeCopy === copy) activeCopy = null
|
|
1694
1846
|
overlay.remove()
|
|
1847
|
+
// Focus goes back where it came from — the sidebar row that opened this.
|
|
1848
|
+
if (previouslyFocused !== null && previouslyFocused.isConnected) previouslyFocused.focus()
|
|
1695
1849
|
}
|
|
1696
1850
|
|
|
1697
1851
|
render()
|
|
1698
1852
|
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Run the install transaction (a new durable job), then let the store poll it.
|
|
1856
|
+
*
|
|
1857
|
+
* @param pluginNames - omit for the whole set (the footer CTA), or pass one
|
|
1858
|
+
* name for a card's 单独更新. hq-edge honours the subset.
|
|
1859
|
+
*/
|
|
1860
|
+
async function runInstall(
|
|
1861
|
+
store: UpdateStoreLike,
|
|
1862
|
+
copy: UpdateCopy,
|
|
1863
|
+
pluginNames?: string[],
|
|
1864
|
+
): Promise<void> {
|
|
1865
|
+
const report = store.report()
|
|
1866
|
+
if (report === null) return
|
|
1867
|
+
// Without an evaluated set there is nothing to install: hq-edge has not read
|
|
1868
|
+
// a manifest yet, so the report is still its empty shell.
|
|
1869
|
+
if (report.manifestVersion === null) {
|
|
1870
|
+
showToast(copy.checkFailed, 'error')
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1874
|
+
// action buttons and shows the in-flight footer state.
|
|
1875
|
+
store.setInstalling(true)
|
|
1876
|
+
try {
|
|
1877
|
+
const result = await installSet(report.manifestVersion, pluginNames)
|
|
1878
|
+
if (result.error !== undefined) {
|
|
1879
|
+
store.setInstalling(false)
|
|
1880
|
+
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1884
|
+
// poller renders progress and clears the flag when the job settles.
|
|
1885
|
+
showToast(copy.installSuccess, 'success')
|
|
1886
|
+
void store.fetchStatus()
|
|
1887
|
+
} catch (err: unknown) {
|
|
1888
|
+
store.setInstalling(false)
|
|
1889
|
+
showToast(
|
|
1890
|
+
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1891
|
+
'error',
|
|
1892
|
+
)
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** First-read placeholders, sharing the card layout. */
|
|
1897
|
+
function skeleton(copy: UpdateCopy): HTMLElement {
|
|
1898
|
+
const section = el('section', 'hqpu-group')
|
|
1899
|
+
section.setAttribute('role', 'status')
|
|
1900
|
+
section.dataset.loading = ''
|
|
1901
|
+
section.setAttribute('aria-label', copy.loading)
|
|
1902
|
+
const list = el('ul', 'hqpu-cards')
|
|
1903
|
+
list.setAttribute('aria-hidden', 'true')
|
|
1904
|
+
for (let index = 0; index < 3; index += 1) {
|
|
1905
|
+
const card = el('li', 'hqpu-card')
|
|
1906
|
+
const head = el('div', 'hqpu-card-head')
|
|
1907
|
+
const id = el('div', 'hqpu-card-id')
|
|
1908
|
+
id.append(el('span', 'hqpu-avatar hqpu-skeleton-fill'))
|
|
1909
|
+
const main = el('div', 'hqpu-card-main')
|
|
1910
|
+
const title = el('span', 'hqpu-card-title hqpu-skeleton-fill hqpu-skeleton-title')
|
|
1911
|
+
const sub = el('span', 'hqpu-card-sub hqpu-skeleton-fill hqpu-skeleton-sub')
|
|
1912
|
+
main.append(title, sub)
|
|
1913
|
+
id.append(main)
|
|
1914
|
+
head.append(id)
|
|
1915
|
+
card.append(head)
|
|
1916
|
+
list.append(card)
|
|
1917
|
+
}
|
|
1918
|
+
section.append(list)
|
|
1919
|
+
return section
|
|
1920
|
+
}
|