@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.8

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.
@@ -26,11 +26,14 @@ import {
26
26
  setPluginUpdateSource,
27
27
  setRuntimeUpdateSource,
28
28
  unignoreOffer,
29
+ TERMINAL_PHASES,
29
30
  type PluginUpdateItem,
30
31
  type PluginUpdateReport,
32
+ type ReleaseInfo,
31
33
  type RuntimeUpdateSource,
34
+ type UpdateJob,
32
35
  } from './api.js'
33
- import type { UpdateCopy } from './copy.js'
36
+ import { formatMb, type UpdateCopy } from './copy.js'
34
37
  import {
35
38
  CHECK_CIRCLE_ICON,
36
39
  CLOSE_ICON,
@@ -44,7 +47,17 @@ import {
44
47
  export interface UpdateStoreLike {
45
48
  report(): PluginUpdateReport | null
46
49
  installing(): boolean
50
+ /** Mark the installing footer state (set by the panel before the restart). */
51
+ setInstalling(flag: boolean): void
52
+ /** The durable job the server currently reports as active (resumed). */
53
+ activeJob(): UpdateJob | null
54
+ /** Bounded recent jobs (newest first), for the result/history view. */
55
+ recentJobs(): UpdateJob[]
47
56
  refresh(): Promise<PluginUpdateReport | null>
57
+ /** Force a fresh update check (used by the failed-check Retry action). */
58
+ check(): Promise<PluginUpdateReport | null>
59
+ /** Re-read `/status` (resume an active job immediately after submitting). */
60
+ fetchStatus(): Promise<void>
48
61
  subscribe(listener: () => void): () => void
49
62
  }
50
63
 
@@ -149,80 +162,142 @@ function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undef
149
162
  return by.length > 0 ? by.join(', ') : undefined
150
163
  }
151
164
 
152
- /** One package card: glyph, name with its state tag, version line, end actions. */
165
+ /** Two-letter avatar initials for a card (CJK-safe). */
166
+ function avatarInitials(item: PluginUpdateItem): string {
167
+ const src =
168
+ typeof item.displayName === 'string' && item.displayName.trim().length > 0
169
+ ? item.displayName.trim()
170
+ : shortName(item.name)
171
+ if (/[⺀-鿿]/.test(src)) return src.slice(0, 2)
172
+ const words = src.split(/[\s_\-./@]+/).filter(Boolean)
173
+ if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
174
+ return src.replace(/^@/, '').slice(0, 2).toUpperCase()
175
+ }
176
+
177
+ /** Shared per-card changelog toggle drawer (the per-plugin changes[] list). */
178
+ function changelogDrawer(item: PluginUpdateItem, copy: UpdateCopy, open: boolean): HTMLElement {
179
+ const drawer = el('div', 'hqpu-changelog-drawer')
180
+ drawer.style.display = open ? '' : 'none'
181
+ drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
182
+ const ul = el('ul')
183
+ for (const change of item.changes ?? []) {
184
+ if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
185
+ }
186
+ drawer.append(ul)
187
+ return drawer
188
+ }
189
+
190
+ /** One package card per the mockup: avatar initials, display name + package
191
+ * subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
192
+ * inline changelog. Blocked/incompatible cards are dimmed with a rose
193
+ * 无法安装 badge and a reason line, and carry NO install button. */
153
194
  function packageCard(
154
195
  item: PluginUpdateItem,
155
196
  copy: UpdateCopy,
156
- installing: boolean,
157
- store: UpdateStoreLike,
197
+ open: boolean,
198
+ onToggle: () => void,
158
199
  ): HTMLLIElement {
159
- const card = el('li', 'hqpu-card')
200
+ const blocked = item.state === 'blocked'
201
+ const card = el('li', blocked ? 'hqpu-card hqpu-card--blocked' : 'hqpu-card')
160
202
  card.dataset.pluginName = item.name
161
203
  card.dataset.state = item.state
162
- // The full package name is the card's tooltip; the row shows the short name.
163
- card.title = `${item.name} · ${versionLine(item)}`
164
204
 
165
205
  const head = el('div', 'hqpu-card-head')
166
-
167
- const glyph = el('span', 'hqpu-card-icon')
168
- glyph.setAttribute('aria-hidden', 'true')
169
- glyph.innerHTML = PLUGIN_ICON(24)
206
+ const left = el('div')
207
+ const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
208
+ avatar.setAttribute('aria-hidden', 'true')
170
209
 
171
210
  const main = el('div', 'hqpu-card-main')
172
- const titleRow = el('div', 'hqpu-title-row')
173
- const title = el('span', 'hqpu-card-title', shortName(item.name))
174
- const tag = el('span', 'hqpu-tag')
175
- tag.dataset.tone = stateTone(item)
176
- tag.textContent = stateLabel(item, copy)
177
- titleRow.append(title, tag)
178
-
179
- const desc = el('span', 'hqpu-card-desc', versionLine(item))
180
-
181
- main.append(titleRow, desc)
182
- head.append(glyph, main)
183
-
184
- const end = el('div', 'hqpu-card-end')
185
- if (item.state === 'update' || item.state === 'install') {
186
- const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
187
- button.type = 'button'
188
- button.disabled = installing
189
- button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
190
- button.addEventListener('click', () => {
191
- void runInstall(store, copy)
192
- })
193
- end.append(button)
211
+ const titleText =
212
+ typeof item.displayName === 'string' && item.displayName.trim().length > 0
213
+ ? item.displayName.trim()
214
+ : shortName(item.name)
215
+ main.append(el('span', 'hqpu-card-title', titleText))
216
+ main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
217
+
218
+ // old → new version row; the NEW version renders in emerald.
219
+ const ver = el('div', 'hqpu-version-row')
220
+ if (item.currentVersion !== null && item.currentVersion !== undefined) {
221
+ ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
222
+ ver.append(el('span', 'hqpu-ver-arrow', '→'))
223
+ }
224
+ const size = formatMb(item.sizeBytes ?? 0)
225
+ ver.append(
226
+ el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
227
+ )
228
+ main.append(ver)
229
+ left.append(avatar, main)
230
+
231
+ const right = el('div', 'hqpu-card-end')
232
+ if (blocked) {
233
+ right.append(el('span', 'hqpu-blocked-badge', copy.ui.blockedBadge))
234
+ } else if (Array.isArray(item.changes) && item.changes.length > 0) {
235
+ const toggle = el('button', 'hqpu-log-toggle')
236
+ toggle.type = 'button'
237
+ toggle.dataset.open = open ? 'true' : 'false'
238
+ 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>`
239
+ toggle.addEventListener('click', onToggle)
240
+ right.append(toggle)
194
241
  }
195
- head.append(end)
242
+ head.append(left, right)
196
243
  card.append(head)
197
244
 
198
- // A block reason belongs to a blocked offer only.
199
- if (item.state === 'blocked') {
245
+ // Inline changelog drawer (actionable items only).
246
+ if (!blocked && Array.isArray(item.changes) && item.changes.length > 0) {
247
+ card.append(changelogDrawer(item, copy, open))
248
+ }
249
+
250
+ // Blocked reason line(s): mapped copy plus the server's full reasons/notes.
251
+ if (blocked) {
200
252
  const reason = blockedReason(item, copy)
201
- if (reason !== undefined) {
202
- card.append(el('p', 'hqpu-card-reason', reason))
253
+ const entries = [
254
+ ...(reason !== undefined ? [reason] : []),
255
+ ...[...(item.reasons ?? []), ...(item.notes ?? [])].filter(
256
+ (e): e is string => typeof e === 'string' && e.length > 0,
257
+ ),
258
+ ]
259
+ for (const entry of entries) {
260
+ const line = el('p', 'hqpu-reason-line')
261
+ line.innerHTML = `<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="M8 5V8.5M8 11.25h.01M14.5 8a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0Z"/></svg>`
262
+ line.append(document.createTextNode(entry))
263
+ card.append(line)
203
264
  }
204
265
  }
205
266
  return card
206
267
  }
207
268
 
208
- /** A section: group head with its count, then the cards. */
269
+ /** A section: head (title + count pill + expand-all toggle) then its cards. */
209
270
  function group(
210
271
  heading: string,
211
272
  items: PluginUpdateItem[],
212
273
  copy: UpdateCopy,
213
- installing: boolean,
214
- store: UpdateStoreLike,
274
+ changelogOpen: Set<string>,
275
+ allOpen: boolean,
276
+ onToggle: (name: string) => void,
277
+ onToggleAll: () => void,
215
278
  ): HTMLElement | null {
216
279
  if (items.length === 0) return null
217
280
  const section = el('section', 'hqpu-group')
218
281
  section.dataset.group = heading
219
282
 
220
- const head = el('div', 'hqpu-group-head')
221
- head.append(el('h3', 'hqpu-group-title', heading))
222
- head.append(el('span', 'hqpu-group-count', copy.groupCount(items.length)))
283
+ const head = el('div', 'hqpu-section-head')
284
+ const titleWrap = el('div', 'hqpu-section-title')
285
+ titleWrap.append(el('span', '', heading))
286
+ titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
287
+ head.append(titleWrap)
288
+ // Expand-all only makes sense over actionable, changelog-bearing cards.
289
+ const actionable = items.some((i) => i.state !== 'blocked' && Array.isArray(i.changes) && i.changes.length > 0)
290
+ if (actionable) {
291
+ const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
292
+ all.type = 'button'
293
+ all.addEventListener('click', onToggleAll)
294
+ head.append(all)
295
+ }
223
296
 
224
297
  const list = el('ul', 'hqpu-cards')
225
- for (const item of items) list.append(packageCard(item, copy, installing, store))
298
+ for (const item of items) {
299
+ list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name)))
300
+ }
226
301
 
227
302
  section.append(head, list)
228
303
  return section
@@ -267,6 +342,370 @@ function skeleton(copy: UpdateCopy): HTMLElement {
267
342
  return section
268
343
  }
269
344
 
345
+ // ── Durable job views (active / result / recent) ───────────────────────────
346
+
347
+ /** Format a byte count for the progress line, or null when unknown. */
348
+ function formatBytes(n: number | undefined): string | null {
349
+ if (n === undefined || !Number.isFinite(n) || n < 0) return null
350
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`
351
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`
352
+ if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
353
+ return `${n} B`
354
+ }
355
+
356
+ /** Format an ETA in seconds to a short human string, or null when unknown. */
357
+ function formatEta(seconds: number | undefined): string | null {
358
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
359
+ if (seconds < 60) return `${Math.round(seconds)} s`
360
+ const minutes = Math.round(seconds / 60)
361
+ if (minutes < 60) return `${minutes} min`
362
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
363
+ }
364
+
365
+ /** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
366
+ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
367
+ if (typeof iso !== 'string' || iso.length === 0) return null
368
+ const then = new Date(iso).getTime()
369
+ if (!Number.isFinite(then)) return null
370
+ const diffMin = Math.round((Date.now() - then) / 60_000)
371
+ if (diffMin < 1) return copy.ui.agoJust
372
+ if (diffMin < 60) return copy.ui.agoMinutes(diffMin)
373
+ const hours = Math.round(diffMin / 60)
374
+ if (hours < 24) return copy.ui.agoHours(hours)
375
+ return copy.ui.agoDays(Math.round(hours / 24))
376
+ }
377
+
378
+ function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
379
+ return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
380
+ }
381
+
382
+ /** The phase in human words, falling back to the raw code. */
383
+ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
384
+ return copy.job.phase[job.phase] ?? job.phase
385
+ }
386
+
387
+ /** One per-step line for plugin-set installs (and any job that reports steps). */
388
+ function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
389
+ if (!Array.isArray(job.steps) || job.steps.length === 0) return null
390
+ const wrap = el('div', 'hqpu-job-steps')
391
+ wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
392
+ const ol = el('ol', 'hqpu-steps')
393
+ for (const step of job.steps) {
394
+ const li = el('li', `hqpu-step hqpu-step--${step.state}`)
395
+ const dot = el('span', 'hqpu-dot')
396
+ dot.dataset.state =
397
+ step.state === 'installed'
398
+ ? 'done'
399
+ : step.state === 'failed'
400
+ ? 'error'
401
+ : step.state === 'running'
402
+ ? 'warning'
403
+ : 'idle'
404
+ li.append(dot)
405
+ const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
406
+ li.append(el('span', '', text))
407
+ ol.append(li)
408
+ }
409
+ wrap.append(ol)
410
+ return wrap
411
+ }
412
+
413
+ /** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
414
+ function stepIndex(phase: UpdateJob['phase']): number {
415
+ switch (phase) {
416
+ case 'downloading':
417
+ return 0
418
+ case 'verifying':
419
+ case 'resolving':
420
+ return 1
421
+ case 'staging':
422
+ case 'activating':
423
+ case 'awaiting-host':
424
+ return 2
425
+ case 'restarting':
426
+ case 'verifying-after-restart':
427
+ return 3
428
+ case 'queued':
429
+ return -1
430
+ default:
431
+ return -1
432
+ }
433
+ }
434
+
435
+ /**
436
+ * The ACTIVE-JOB banner per the mockup: pulsing brand dot + job title with the
437
+ * target version, mono percentage, a gradient bar only when `percent != null`,
438
+ * phase + speed line ("3.1 MB/s (286MB / 450MB)"), and a 4-step indicator row
439
+ * (done=emerald, active=pulsing brand, upcoming=muted).
440
+ */
441
+ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
442
+ const section = el('section', 'hqpu-group')
443
+ section.dataset.group = 'active-job'
444
+
445
+ const card = el('div', 'hqpu-active')
446
+
447
+ // Top row: pulse dot + title, mono percent.
448
+ const top = el('div', 'hqpu-active-top')
449
+ const titleWrap = el('div', 'hqpu-active-title')
450
+ const pulse = el('span', 'hqpu-pulse-dot')
451
+ pulse.append(el('span', 'hqpu-pulse-ring'))
452
+ const title =
453
+ job.kind === 'runtime'
454
+ ? copy.ui.jobRuntimeTitle(job.targetVersion)
455
+ : copy.ui.jobPluginTitle(job.targetVersion)
456
+ titleWrap.append(pulse, el('span', '', title))
457
+ const pct = job.progress.percent
458
+ if (pct !== null) {
459
+ top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
460
+ }
461
+ card.append(top)
462
+
463
+ // Gradient bar — only when the server publishes a real percentage.
464
+ if (pct !== null) {
465
+ const bar = el('div', 'hqpu-progress')
466
+ const fill = el('div', 'hqpu-progress-fill')
467
+ fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
468
+ bar.append(fill)
469
+ card.append(bar)
470
+ }
471
+
472
+ // Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
473
+ const meta = el('div', 'hqpu-active-meta')
474
+ const phaseText =
475
+ typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
476
+ meta.append(el('span', '', phaseText))
477
+ const speed = formatBytes(job.progress.bytesPerSecond)
478
+ const done = formatBytes(job.progress.bytesDone)
479
+ const total = formatBytes(job.progress.bytesTotal)
480
+ const telemetry =
481
+ speed !== null && done !== null && total !== null
482
+ ? `${speed}/s (${done} / ${total})`
483
+ : speed !== null
484
+ ? `${speed}/s`
485
+ : done !== null && total !== null
486
+ ? copy.job.bytes(done, total)
487
+ : ''
488
+ if (telemetry.length > 0) meta.append(el('span', '', telemetry))
489
+ card.append(meta)
490
+
491
+ // previous → target transition + what happens next (restart / elevation),
492
+ // and the honest "closing does not cancel" note.
493
+ const transition = el('div', 'hqpu-active-meta')
494
+ transition.append(
495
+ el(
496
+ 'span',
497
+ '',
498
+ job.previousVersion !== null && job.previousVersion.length > 0
499
+ ? `${job.previousVersion} → ${job.targetVersion}`
500
+ : job.targetVersion,
501
+ ),
502
+ )
503
+ transition.append(
504
+ el('span', '', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh),
505
+ )
506
+ card.append(transition)
507
+ card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
508
+
509
+ // 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
510
+ const current = stepIndex(job.phase)
511
+ const stepsRow = el('div', 'hqpu-job-steps4')
512
+ copy.ui.jobSteps.forEach((label: string, index: number) => {
513
+ const cls =
514
+ current === -1
515
+ ? 'hqpu-job-step--todo'
516
+ : index < current
517
+ ? 'hqpu-job-step--done'
518
+ : index === current
519
+ ? 'hqpu-job-step--active'
520
+ : 'hqpu-job-step--todo'
521
+ stepsRow.append(el('div', cls, label))
522
+ })
523
+ card.append(stepsRow)
524
+
525
+ section.append(card)
526
+ return section
527
+ }
528
+
529
+ /**
530
+ * The RESULT view for a terminal job: success / failed / rolled-back /
531
+ * abandoned, previous→installed, per-package steps, errorCode + message, a log
532
+ * correlation hint, and retry guidance (a retry starts a NEW job).
533
+ */
534
+ function resultView(job: UpdateJob, copy: UpdateCopy, onRetry: () => void): HTMLElement {
535
+ const section = el('section', 'hqpu-group')
536
+ section.dataset.group = 'job-result'
537
+
538
+ const tone =
539
+ job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
540
+ const titleText =
541
+ job.phase === 'done'
542
+ ? copy.job.resultDone
543
+ : job.phase === 'failed'
544
+ ? copy.job.resultFailed
545
+ : job.phase === 'rolled-back'
546
+ ? copy.job.resultRolledBack
547
+ : copy.job.resultAbandoned
548
+
549
+ const head = el('div', 'hqpu-group-head')
550
+ const tag = el('span', 'hqpu-tag')
551
+ tag.dataset.tone = tone
552
+ tag.textContent = titleText
553
+ head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
554
+ section.append(head)
555
+
556
+ const card = el('div', 'hqpu-job')
557
+ const res = job.result
558
+ const from = job.previousVersion ?? res?.installedVersion ?? '—'
559
+ const to = res?.installedVersion ?? job.targetVersion
560
+ const finished =
561
+ res !== undefined && res.finishedAt.length > 0
562
+ ? ` · ${new Date(res.finishedAt).toLocaleString()}`
563
+ : ''
564
+ card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
565
+ if (res !== undefined && res.message !== undefined && res.message.length > 0) {
566
+ card.append(el('p', 'hqpu-status', res.message))
567
+ }
568
+ if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
569
+ card.append(el('p', 'hqpu-status', res.errorCode))
570
+ }
571
+ if (typeof job.logHint === 'string' && job.logHint.length > 0) {
572
+ card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
573
+ }
574
+
575
+ const steps = stepsList(job, copy)
576
+ if (steps !== null) card.append(steps)
577
+
578
+ // Retry starts a NEW update job — never re-click the settled job id.
579
+ if (job.phase === 'failed' || job.phase === 'abandoned') {
580
+ const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
581
+ retry.type = 'button'
582
+ retry.addEventListener('click', onRetry)
583
+ card.append(retry)
584
+ }
585
+ section.append(card)
586
+ return section
587
+ }
588
+
589
+ /** A one-line row for older recent jobs (newest already shown as the card). */
590
+ function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
591
+ const older = jobs.slice(1, 5)
592
+ if (older.length === 0) return null
593
+ const wrap = el('div', 'hqpu-group')
594
+ wrap.dataset.group = 'recent-jobs'
595
+ wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
596
+ const ul = el('ul', 'hqpu-cards')
597
+ for (const job of older) {
598
+ const li = el('li', 'hqpu-status')
599
+ const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
600
+ li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
601
+ ul.append(li)
602
+ }
603
+ wrap.append(ul)
604
+ return wrap
605
+ }
606
+
607
+ /**
608
+ * Check-status banner: a FAILED check shows a clear error row + Retry (never
609
+ * the silent "no updates" empty state); a successful check shows the time;
610
+ * when a check failed but a prior report exists it is labelled stale.
611
+ */
612
+ function checkBanner(
613
+ report: PluginUpdateReport,
614
+ copy: UpdateCopy,
615
+ onRetry: () => void,
616
+ ): HTMLElement | null {
617
+ const failed = typeof report.lastError === 'string' && report.lastError.length > 0
618
+ const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
619
+ if (!failed && !checked) return null
620
+
621
+ const wrap = el('section', 'hqpu-group')
622
+ wrap.dataset.group = 'check-status'
623
+ if (failed) {
624
+ const banner = el('div', 'hqpu-banner')
625
+ banner.dataset.tone = 'danger'
626
+ banner.innerHTML = WARNING_ICON(14)
627
+ banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
628
+ wrap.append(banner)
629
+ if (report.items.length > 0) {
630
+ wrap.append(el('p', 'hqpu-status', copy.staleNote))
631
+ }
632
+ const row = el('div', 'hqpu-actions')
633
+ const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
634
+ retry.type = 'button'
635
+ retry.addEventListener('click', onRetry)
636
+ row.append(retry)
637
+ wrap.append(row)
638
+ return wrap
639
+ }
640
+ wrap.append(
641
+ el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
642
+ )
643
+ return wrap
644
+ }
645
+
646
+ /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
647
+ function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
648
+ if (release === null || release === undefined) return null
649
+ const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
650
+ const breaking = Array.isArray(release.breaking)
651
+ ? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
652
+ : []
653
+ const hasContent =
654
+ (typeof release.summary === 'string' && release.summary.length > 0) ||
655
+ notes.length > 0 ||
656
+ breaking.length > 0 ||
657
+ typeof release.notesUrl === 'string' ||
658
+ typeof release.publishedAt === 'string'
659
+ if (!hasContent) return null
660
+
661
+ const section = el('section', 'hqpu-group')
662
+ section.dataset.group = 'release'
663
+ section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
664
+ if (typeof release.summary === 'string' && release.summary.length > 0) {
665
+ section.append(el('p', 'hqpu-status', release.summary))
666
+ }
667
+ if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
668
+ section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
669
+ }
670
+ if (release.requiresRestart === true) {
671
+ section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
672
+ }
673
+ if (notes.length > 0) {
674
+ const ul = el('ul', 'hqpu-release-notes')
675
+ for (const note of notes) ul.append(el('li', '', note as string))
676
+ section.append(ul)
677
+ }
678
+ if (breaking.length > 0) {
679
+ const banner = el('div', 'hqpu-banner')
680
+ banner.dataset.tone = 'danger'
681
+ banner.innerHTML = WARNING_ICON(14)
682
+ const inner = el('span')
683
+ inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
684
+ inner.append(document.createTextNode(breaking.join(' · ')))
685
+ banner.append(inner)
686
+ section.append(banner)
687
+ }
688
+ if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
689
+ const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
690
+ link.href = release.notesUrl
691
+ link.target = '_blank'
692
+ link.rel = 'noreferrer'
693
+ section.append(link)
694
+ }
695
+ return section
696
+ }
697
+
698
+ /** Native disclosure that hides the developer-facing source editors by
699
+ * default (end users should never need to type a manifest URL). */
700
+ function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
701
+ const details = el('details', 'hqpu-advanced')
702
+ details.append(el('summary', '', copy.release.advanced))
703
+ for (const node of nodes) {
704
+ if (node !== null) details.append(node)
705
+ }
706
+ return details
707
+ }
708
+
270
709
  /** Mutable editor state for one manifest source (lives in the panel closure). */
271
710
  interface SourceState {
272
711
  editing: boolean
@@ -431,6 +870,11 @@ function sourceEditor(
431
870
  const labels = sourceLabels(copy, kind)
432
871
  const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
433
872
  if (!actions.sourceEditing) {
873
+ // Read mode: show the URL currently in effect (mono, muted), then edit.
874
+ const urlLine = el('p', 'hqpu-source-current-url', source.url)
875
+ urlLine.style.cssText =
876
+ 'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#a1a1aa;word-break:break-all;'
877
+ block.append(urlLine)
434
878
  block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
435
879
  const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
436
880
  edit.type = 'button'
@@ -516,140 +960,184 @@ function pluginSourceGroup(
516
960
  return section
517
961
  }
518
962
 
519
- /** The independent HQ Edge runtime report, as ruled fact rows. */
520
- function runtimeGroup(
963
+ /**
964
+ * The host's own report of the last runtime swap. Rolled-back / failed renders
965
+ * as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
966
+ * succeeded renders as a subtle green inline line — not a loud banner. The
967
+ * plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
968
+ */
969
+ function runtimeResultAlert(
521
970
  report: PluginUpdateReport,
522
971
  copy: UpdateCopy,
523
- actions: RuntimeActions,
524
- ): HTMLElement {
972
+ dismissed: boolean,
973
+ onDismiss: () => void,
974
+ ): HTMLElement | null {
975
+ const lastResult = report.runtime.lastResult
976
+ if (lastResult === null || lastResult === undefined) return null
977
+
978
+ // Green inline confirmation — quiet by design.
979
+ if (lastResult.state === 'succeeded') {
980
+ const line = el('p', 'hqpu-alert-green')
981
+ line.dataset.role = 'runtime-success'
982
+ line.textContent = copy.ui.successInline
983
+ return line
984
+ }
985
+ if (dismissed) return null
986
+
525
987
  const section = el('section', 'hqpu-group')
526
- section.dataset.group = 'runtime'
527
- const head = el('div', 'hqpu-group-head')
528
- head.append(el('h3', 'hqpu-group-title', copy.runtime.title))
529
- section.append(head)
988
+ section.dataset.group = 'runtime-alert'
989
+ const card = el('div', 'hqpu-alert hqpu-alert--amber')
990
+ card.innerHTML = WARNING_ICON(14)
991
+
992
+ const body = el('div')
993
+ const title = el('div', 'hqpu-alert-title')
994
+ title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
995
+ body.append(title)
996
+
997
+ const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
998
+ const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
999
+ ? lastResult.errorCode
1000
+ : '—'
1001
+ const text =
1002
+ lastResult.state === 'failed'
1003
+ ? copy.ui.failedBody(oldVer, code)
1004
+ : copy.ui.rollbackBody(oldVer, code)
1005
+ // Build the body as text + an inline <code> chip for the error code.
1006
+ const bodyP = el('p', 'hqpu-alert-body')
1007
+ const textOnly =
1008
+ lastResult.state === 'failed'
1009
+ ? copy.ui.failedBody(oldVer, '')
1010
+ : copy.ui.rollbackBody(oldVer, '')
1011
+ // Strip the trailing "code" placeholder we appended; re-add as a chip.
1012
+ bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
1013
+ if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
1014
+ bodyP.append(document.createTextNode(' '))
1015
+ const chip = el('code', '', lastResult.errorCode)
1016
+ bodyP.append(chip)
1017
+ }
1018
+ if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
1019
+ bodyP.append(el('br'), document.createTextNode(lastResult.message))
1020
+ }
1021
+ body.append(bodyP)
1022
+
1023
+ // Dismiss action (no log button — the plugin has no log-open affordance).
1024
+ const actions = el('div', 'hqpu-alert-actions')
1025
+ const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
1026
+ dismiss.type = 'button'
1027
+ dismiss.addEventListener('click', onDismiss)
1028
+ actions.append(dismiss)
1029
+ body.append(actions)
1030
+
1031
+ card.append(body)
1032
+ section.append(card)
1033
+ return section
1034
+ }
530
1035
 
531
- const facts = el('dl', 'hqpu-facts')
1036
+ /** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
1037
+ * 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
1038
+ * 升级核心 button (existing arm→confirm flow), and the amber restart note. */
1039
+ function runtimeEngineCard(
1040
+ report: PluginUpdateReport,
1041
+ copy: UpdateCopy,
1042
+ actions: RuntimeActions,
1043
+ ): HTMLElement | null {
532
1044
  const runtime = report.runtime
533
- // `runtime.latest` is a release ENTRY; only its version is ever displayed or
534
- // sent back as `targetVersion`.
535
1045
  const latestVersion = runtimeLatestVersion(runtime)
536
-
537
- const row = (label: string, value: string, code = false): void => {
538
- const wrap = el('div')
539
- wrap.append(el('dt', '', label))
540
- const dd = el('dd')
541
- if (code) {
542
- const node = el('code', '', value)
543
- dd.append(node)
544
- } else {
545
- dd.textContent = value
546
- }
547
- wrap.append(dd)
548
- facts.append(wrap)
1046
+ const card = el('section', 'hqpu-engine')
1047
+ card.dataset.group = 'runtime'
1048
+
1049
+ const head = el('div', 'hqpu-engine-head')
1050
+ const titleWrap = el('div', 'hqpu-engine-title')
1051
+ 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>`
1052
+ titleWrap.append(el('span', '', copy.ui.runtimeEngine))
1053
+ head.append(titleWrap)
1054
+ if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
1055
+ head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
549
1056
  }
1057
+ card.append(head)
550
1058
 
551
- // The source row: which manifest URL is in effect and where it came from.
552
- // Absent on an older hq-edge — then only the legacy notConfigured row shows.
553
- const source = normalizeSource(runtime.source)
554
-
555
- if (runtime.configured === false) {
556
- if (source !== null) {
557
- sourceFactRow(facts, source, copy)
558
- } else {
559
- row(copy.runtime.source, copy.runtime.notConfigured)
560
- }
561
- } else {
562
- row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
563
- if (runtime.updateAvailable && latestVersion !== null) {
564
- row(copy.runtime.latest, latestVersion)
565
- }
566
- if (report.manifestVersion !== null) {
567
- row(copy.runtime.set, report.manifestVersion, true)
568
- }
569
- if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
570
- row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
571
- }
572
- if (source !== null) {
573
- sourceFactRow(facts, source, copy)
1059
+ // Update row (only when a newer distribution is known).
1060
+ if (runtime.updateAvailable && latestVersion !== null) {
1061
+ const row = el('div', 'hqpu-engine-update-row')
1062
+ const left = el('div')
1063
+ const newLine = el('div', 'hqpu-engine-new-line')
1064
+ newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
1065
+ const latestEntry = runtime.latest
1066
+ const hasCritical =
1067
+ (Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
1068
+ (Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
1069
+ if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
1070
+ left.append(newLine)
1071
+ const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
1072
+ const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
1073
+ if (size.length > 0 || summary.length > 0) {
1074
+ left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
574
1075
  }
575
- }
576
- section.append(facts)
1076
+ row.append(left)
577
1077
 
578
- // The editor: point the runtime at another release channel (a staging
579
- // manifest, a local rig) without an env var and a restart. hq-edge persists
580
- // the override per host, so it survives restarts of both processes.
581
- if (source !== null) {
582
- section.append(sourceEditor('runtime', source, copy, actions))
583
- }
584
-
585
- // ── Runtime update action row ──
586
- // Only offered when hq-edge reports a newer distribution AND named its
587
- // version: the request needs `targetVersion`, so an entry without one has
588
- // nothing to send.
589
- if (runtime.updateAvailable && latestVersion !== null) {
590
- const row = el('div', 'hqpu-actions hqpu-runtime-actions')
1078
+ // Upgrade action: arm → confirm, accepted/busy states preserved.
591
1079
  if (actions.accepted) {
592
- // Terminal state: no button. hq-edge has already exited, so re-offering
593
- // "update" here could only produce a transport error.
594
- const line = el('span', 'hqpu-status hqpu-status--dot')
595
- line.setAttribute('role', 'status')
596
- line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updateAccepted))
597
- row.append(line)
1080
+ row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
598
1081
  } else if (actions.busy) {
599
- // Downloading + verifying + staging the bundle: tens of seconds to a few
600
- // minutes. Say what is happening instead of an open-ended spinner.
601
- const line = el('span', 'hqpu-status hqpu-status--dot')
602
- line.setAttribute('role', 'status')
603
- line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updating))
604
- row.append(line)
1082
+ row.append(el('span', 'hqpu-status', copy.runtime.updating))
605
1083
  } else if (actions.armed) {
606
- row.append(el('p', 'hqpu-status hqpu-runtime-confirm', copy.runtime.updateConfirm(latestVersion)))
607
- const yes = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.confirmInstall)
1084
+ const confirmWrap = el('div')
1085
+ confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
1086
+ const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
608
1087
  yes.type = 'button'
609
1088
  yes.dataset.runtimeConfirm = ''
610
1089
  yes.addEventListener('click', actions.onConfirm)
611
- const no = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
1090
+ const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
612
1091
  no.type = 'button'
613
1092
  no.addEventListener('click', actions.onCancel)
614
- row.append(yes, no)
1093
+ confirmWrap.append(yes, no)
1094
+ row.append(confirmWrap)
615
1095
  } else {
616
- const start = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm')
1096
+ const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
617
1097
  start.type = 'button'
618
1098
  start.dataset.runtimeUpdate = ''
619
- start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`
620
1099
  start.addEventListener('click', actions.onArm)
621
1100
  row.append(start)
622
1101
  }
623
- section.append(row)
624
- } else {
625
- section.append(el('p', 'hqpu-status', copy.runtime.note))
1102
+ card.append(row)
1103
+ const note = el('p', 'hqpu-engine-note')
1104
+ note.innerHTML =
1105
+ `<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>`
1106
+ card.append(note)
1107
+ } else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
1108
+ card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
626
1109
  }
627
-
628
- if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
629
- const line = el('p', 'hqpu-status hqpu-status--dot')
630
- line.setAttribute('role', 'status')
631
- const dot = el('span', 'hqpu-dot')
632
- dot.dataset.state = 'error'
633
- line.append(dot, el('span', '', `${copy.lastError}: ${runtime.lastError}`))
634
- section.append(line)
635
- }
636
- return section
1110
+ return card
637
1111
  }
638
1112
 
639
1113
  function renderList(
640
1114
  section: HTMLElement,
641
1115
  report: PluginUpdateReport | null,
642
1116
  copy: UpdateCopy,
643
- installing: boolean,
644
- store: UpdateStoreLike,
1117
+ changelogOpen: Set<string>,
1118
+ allOpen: boolean,
1119
+ onToggleCard: (name: string) => void,
1120
+ onToggleAll: () => void,
1121
+ onRetryCheck: () => void,
645
1122
  ): void {
646
1123
  section.textContent = ''
647
1124
  if (report === null) {
648
1125
  section.append(skeleton(copy))
649
1126
  return
650
1127
  }
1128
+ // Check-status banner: a failed check shows an error row + Retry (never the
1129
+ // silent "no updates" empty state); a successful check shows the time.
1130
+ const banner = checkBanner(report, copy, onRetryCheck)
1131
+ if (banner !== null) section.append(banner)
1132
+ // Set-level release metadata (v2 manifest): summary, notes, breaking.
1133
+ const release = releaseHeader(report.release, copy)
1134
+ if (release !== null) section.append(release)
651
1135
  if (report.items.length === 0) {
652
- section.append(el('div', 'hqpu-empty', copy.empty))
1136
+ // A successful check with zero updates — distinct from a failed check,
1137
+ // which already rendered its error row above.
1138
+ if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
1139
+ section.append(el('div', 'hqpu-empty', copy.empty))
1140
+ }
653
1141
  return
654
1142
  }
655
1143
 
@@ -665,7 +1153,7 @@ function renderList(
665
1153
  [copy.group.current, byState('current')],
666
1154
  ]
667
1155
  for (const [heading, items] of order) {
668
- const node = group(heading, items, copy, installing, store)
1156
+ const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll)
669
1157
  if (node !== null) section.append(node)
670
1158
  }
671
1159
  }
@@ -675,13 +1163,20 @@ function renderRuntime(
675
1163
  report: PluginUpdateReport | null,
676
1164
  copy: UpdateCopy,
677
1165
  actions: RuntimeActions,
1166
+ dismissedAlert: boolean,
1167
+ onDismissAlert: () => void,
678
1168
  ): void {
679
1169
  section.textContent = ''
680
1170
  if (report === null) return
681
- section.append(runtimeGroup(report, copy, actions))
1171
+ const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
1172
+ if (alert !== null) section.append(alert)
1173
+ const engine = runtimeEngineCard(report, copy, actions)
1174
+ if (engine !== null) section.append(engine)
682
1175
  }
683
1176
 
684
- /** The plugin-source group, above the plugin groups. No-op without the field. */
1177
+ /** The plugin-source group, above the plugin groups, hidden behind an advanced
1178
+ * disclosure (manifest-URL editing is a developer affordance). No-op without
1179
+ * the field. */
685
1180
  function renderPluginSource(
686
1181
  section: HTMLElement,
687
1182
  report: PluginUpdateReport | null,
@@ -691,10 +1186,10 @@ function renderPluginSource(
691
1186
  section.textContent = ''
692
1187
  if (report === null) return
693
1188
  const node = pluginSourceGroup(report, copy, actions)
694
- if (node !== null) section.append(node)
1189
+ if (node !== null) section.append(advancedBlock(copy, [node]))
695
1190
  }
696
1191
 
697
- /** Run the install transaction, then keep polling until the state settles. */
1192
+ /** Run the install transaction (a new durable job), then let the store poll it. */
698
1193
  async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
699
1194
  const report = store.report()
700
1195
  if (report === null) return
@@ -704,17 +1199,23 @@ async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<voi
704
1199
  showToast(copy.checkFailed, 'error')
705
1200
  return
706
1201
  }
1202
+ // Flip the installing flag BEFORE the request: this is what disables the
1203
+ // action buttons and shows the "DSH will restart" footer state (previously
1204
+ // dead code — runInstall never called it).
1205
+ store.setInstalling(true)
707
1206
  try {
708
1207
  const result = await installSet(report.manifestVersion)
709
1208
  if (result.error !== undefined) {
1209
+ store.setInstalling(false)
710
1210
  showToast(`${copy.installError}: ${result.error}`, 'error')
711
1211
  return
712
1212
  }
713
- // The set activates atomically and DSH restarts. Keep polling the backend
714
- // state rather than trusting the response alone (spec §7.1).
1213
+ // 202 accepted: a durable job now owns the lifecycle. The store's /status
1214
+ // poller renders progress and clears the flag when the job settles.
715
1215
  showToast(copy.installSuccess, 'success')
716
- void pollUntilSettled(store)
1216
+ void store.fetchStatus()
717
1217
  } catch (err: unknown) {
1218
+ store.setInstalling(false)
718
1219
  showToast(
719
1220
  `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
720
1221
  'error',
@@ -743,90 +1244,99 @@ function showToast(message: string, tone: 'success' | 'error'): void {
743
1244
  }, TOAST_HOLD_MS)
744
1245
  }
745
1246
 
746
- function renderActions(
747
- bar: HTMLElement,
1247
+ /**
1248
+ * The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
1249
+ * EXISTING plugin-source and runtime-source editors (POST /plugin-source,
1250
+ * POST /runtime-source, save→check→refresh already wired). No new endpoints:
1251
+ * if a manifest source has no data, that field is omitted rather than faked.
1252
+ */
1253
+ function renderSettingsPanel(
1254
+ panel: HTMLElement,
748
1255
  report: PluginUpdateReport | null,
749
1256
  copy: UpdateCopy,
750
- store: UpdateStoreLike,
751
- installing: boolean,
1257
+ open: boolean,
1258
+ pluginActions: SourceEditorActions,
1259
+ runtimeActions: SourceEditorActions,
752
1260
  ): void {
753
- bar.textContent = ''
754
- if (report === null) return
1261
+ panel.textContent = ''
1262
+ panel.style.display = open ? '' : 'none'
1263
+ if (!open || report === null) return
1264
+
1265
+ const row = el('div', 'hqpu-settings-row')
1266
+ row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
1267
+ // Origin badge: custom override (amber) vs the default configured source (green).
1268
+ const pluginSource = normalizeSource(report.pluginSource)
1269
+ const runtimeSource = normalizeSource(report.runtime.source)
1270
+ const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
1271
+ const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
1272
+ badge.dataset.origin = overridden ? 'override' : 'config'
1273
+ row.append(badge)
1274
+ panel.append(row)
1275
+
1276
+ if (pluginSource !== null) {
1277
+ panel.append(el('label', '', copy.ui.pluginUrlLabel))
1278
+ panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
1279
+ }
1280
+ if (runtimeSource !== null) {
1281
+ panel.append(el('label', '', copy.ui.runtimeUrlLabel))
1282
+ panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
1283
+ }
1284
+ }
755
1285
 
756
- if (installing) {
757
- const line = el('span', 'hqpu-status hqpu-status--dot')
758
- const spinner = el('span', 'hqpu-spinner')
759
- line.append(spinner, el('span', '', copy.installing))
760
- bar.append(line)
1286
+ /**
1287
+ * The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
1288
+ * full-width primary "一键更新全套插件 (vX)" (install = one durable job),
1289
+ * a secondary 忽略 wired to the existing ignore/unignore affordance, and the
1290
+ * footnote. The count EXCLUDES blocked / ignored / already-current entries.
1291
+ */
1292
+ function renderFooter(
1293
+ footer: HTMLElement,
1294
+ installBtn: HTMLButtonElement,
1295
+ ignoreBtn: HTMLButtonElement,
1296
+ countEl: HTMLElement,
1297
+ setEl: HTMLElement,
1298
+ report: PluginUpdateReport | null,
1299
+ copy: UpdateCopy,
1300
+ installing: boolean,
1301
+ store: UpdateStoreLike,
1302
+ ): void {
1303
+ if (report === null) {
1304
+ countEl.textContent = ''
1305
+ setEl.textContent = ''
1306
+ installBtn.disabled = true
1307
+ ignoreBtn.style.display = 'none'
761
1308
  return
762
1309
  }
763
1310
 
764
1311
  const actionable = report.items.filter(
765
1312
  (item) => item.state === 'update' || item.state === 'install',
766
1313
  ).length
767
- // Every write needs an evaluated set; hq-edge reports `null` until it has
768
- // read a manifest, and posting `null` would only earn a 400.
769
1314
  const hasSet = report.manifestVersion !== null
770
-
771
- if (actionable === 0) {
772
- // The official page shows no install control when there is nothing to
773
- // commit, so mirror that with a muted line rather than a dead button.
774
- bar.append(el('span', 'hqpu-status', copy.nothingToInstall))
775
- } else {
776
- const installBtn = el('button', 'hqpu-btn hqpu-btn--primary')
777
- installBtn.type = 'button'
778
- installBtn.disabled = !hasSet || report.ignoredSet
779
- installBtn.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installAll(actionable)}</span>`
780
- installBtn.addEventListener('click', () => {
781
- void runInstall(store, copy)
782
- })
783
- bar.append(installBtn)
1315
+ const setVersion: string = report.manifestVersion ?? ''
1316
+
1317
+ countEl.textContent = copy.ui.footerCount(actionable)
1318
+ setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
1319
+
1320
+ // Install-all: honest set-level label; disabled while busy or nothing to do.
1321
+ installBtn.textContent = hasSet
1322
+ ? copy.ui.installAllSet(setVersion)
1323
+ : copy.ui.installAllSet('—')
1324
+ installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
1325
+ installBtn.dataset.busy = installing ? 'true' : 'false'
1326
+ installBtn.onclick = () => {
1327
+ void runInstall(store, copy)
784
1328
  }
785
1329
 
786
- if (!hasSet) return
787
-
788
- if (report.ignoredSet) {
789
- const unignore = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.unignore)
790
- unignore.type = 'button'
791
- unignore.addEventListener('click', () => {
792
- void unignoreOffer(report.manifestVersion as string)
793
- .then(() => store.refresh())
794
- .catch(() => undefined)
795
- })
796
- bar.append(unignore)
797
- } else if (actionable > 0) {
798
- const ignore = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.ignore)
799
- ignore.type = 'button'
800
- ignore.addEventListener('click', () => {
801
- void ignoreOffer(report.manifestVersion as string)
802
- .then(() => store.refresh())
803
- .catch(() => undefined)
804
- })
805
- bar.append(ignore)
806
- }
807
- }
808
-
809
- /** After an install, poll the backend until the update report settles
810
- * (installing flag clears or DSH restarts under us — the next load re-reads
811
- * the fresh state). */
812
- function pollUntilSettled(store: UpdateStoreLike): void {
813
- let attempts = 0
814
- const tick = (): void => {
815
- void store
816
- .refresh()
817
- .then((report) => {
818
- if (store.installing()) return
819
- if (report !== null && report.ignoredSet === false && attempts >= 2) return
820
- })
1330
+ // Secondary ignore button: only when there is an evaluated set to ignore.
1331
+ ignoreBtn.style.display = hasSet ? '' : 'none'
1332
+ ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
1333
+ ignoreBtn.onclick = () => {
1334
+ const version = report.manifestVersion as string
1335
+ const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
1336
+ void action
1337
+ .then(() => store.refresh())
821
1338
  .catch(() => undefined)
822
- .finally(() => {
823
- attempts += 1
824
- if (attempts < 10) {
825
- window.setTimeout(tick, 2000)
826
- }
827
- })
828
1339
  }
829
- tick()
830
1340
  }
831
1341
 
832
1342
  /**
@@ -853,48 +1363,71 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
853
1363
  panel.setAttribute('aria-label', copy.viewTitle)
854
1364
  panel.tabIndex = -1
855
1365
 
856
- // ── Head row: title + intro, refresh toolbar ──
1366
+ // ── Sticky header: brand icon + title + relative checked time, refresh +
1367
+ // gear (advanced source settings) + close ──
857
1368
  const head = el('div', 'hqpu-head')
858
1369
  const headMain = el('div', 'hqpu-head-main')
859
- headMain.append(el('h1', 'hqpu-title', copy.viewTitle))
860
- const intro = el('p', 'hqpu-intro')
861
- intro.append(el('span', '', copy.viewIntro))
862
- const info = el('button', 'hqpu-info')
863
- info.type = 'button'
864
- info.setAttribute('aria-label', copy.viewInfo)
865
- info.title = copy.viewInfo
866
- info.innerHTML = INFO_ICON(11)
867
- intro.append(info)
868
- headMain.append(intro)
1370
+ const brandIcon = el('span', 'hqpu-brand-icon')
1371
+ brandIcon.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.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5.6 2.6a6 6 0 015.6 1.6M12.4 13.4a6 6 0 01-5.6-1.6M10.6 2V5.4H7.2M5.4 14V10.6H8.8"/></svg>`
1372
+ const titleBlock = el('div', 'hqpu-title-block')
1373
+ titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
1374
+ const sub = el('p', 'hqpu-sub')
1375
+ titleBlock.append(sub)
1376
+ headMain.append(brandIcon, titleBlock)
869
1377
 
870
1378
  const toolbar = el('div', 'hqpu-toolbar')
871
1379
  const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
872
- void store.refresh()
1380
+ void store.check()
873
1381
  })
874
1382
  toolbar.append(refreshBtn)
1383
+ const gearBtn = iconButton(
1384
+ 'hqpu-icon-btn',
1385
+ `<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"><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>`,
1386
+ copy.ui.gearTooltip,
1387
+ () => {
1388
+ settingsOpen = !settingsOpen
1389
+ render()
1390
+ },
1391
+ )
1392
+ toolbar.append(gearBtn)
875
1393
  const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
876
1394
  onClose()
877
1395
  })
878
1396
  toolbar.append(closeBtn)
879
-
880
1397
  head.append(headMain, toolbar)
881
1398
  panel.append(head)
882
1399
 
883
- // ── Scrolling body: the centred column ──
1400
+ // ── Collapsible advanced settings panel (gear-toggled) ──
1401
+ const settingsPanel = el('div', 'hqpu-settings')
1402
+ panel.append(settingsPanel)
1403
+
1404
+ // ── Scrolling body: the narrow column ──
884
1405
  const scroll = el('div', 'hqpu-scroll')
885
1406
  const column = el('div', 'hqpu-column')
886
- const pluginSourceSection = el('div', 'hqpu-stack')
1407
+ // The durable-job view leads: an active install/runtime update is resumed on
1408
+ // open BEFORE anything else, and a recent terminal result is shown here too.
1409
+ const jobSection = el('div', 'hqpu-stack')
887
1410
  const listSection = el('div', 'hqpu-stack')
888
1411
  const runtimeSection = el('div', 'hqpu-stack')
889
1412
  const toastSection = el('div', 'hqpu-toast-host')
890
- column.append(pluginSourceSection, listSection, runtimeSection, toastSection)
1413
+ column.append(jobSection, listSection, runtimeSection, toastSection)
891
1414
  scroll.append(column)
892
1415
  panel.append(scroll)
893
1416
 
894
- // ── Footer actions (ui-primitives Modal .footer) ──
1417
+ // ── Sticky footer ──
895
1418
  const footer = el('div', 'hqpu-footer')
896
- const actionBar = el('div', 'hqpu-actions')
897
- footer.append(actionBar)
1419
+ const footMeta = el('div', 'hqpu-foot-meta')
1420
+ const footCount = el('span', '')
1421
+ const footSet = el('span', 'hqpu-foot-set')
1422
+ footMeta.append(footCount, footSet)
1423
+ const footActions = el('div', 'hqpu-foot-actions')
1424
+ const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
1425
+ installAllBtn.type = 'button'
1426
+ const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
1427
+ ignoreBtn.type = 'button'
1428
+ footActions.append(installAllBtn, ignoreBtn)
1429
+ const footnote = el('p', 'hqpu-footnote', copy.ui.footnote)
1430
+ footer.append(footMeta, footActions, footnote)
898
1431
  panel.append(footer)
899
1432
 
900
1433
  overlay.append(scrim, panel)
@@ -911,6 +1444,12 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
911
1444
  let runtimeBusy = false
912
1445
  let runtimeAccepted = false
913
1446
 
1447
+ // ── Panel-closure UI state (survive store refreshes) ──
1448
+ const changelogOpen = new Set<string>()
1449
+ let allOpen = false
1450
+ let settingsOpen = false
1451
+ let dismissedAlert = false
1452
+
914
1453
  // Source-editor state, same reasoning: a background refresh must not discard
915
1454
  // a half-typed URL or silently close either editor.
916
1455
  const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
@@ -1061,6 +1600,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1061
1600
  // server that is already gone.
1062
1601
  runtimeAccepted = true
1063
1602
  showToast(copy.runtime.updateAccepted, 'success')
1603
+ // Adopt the new durable job immediately so the active-job view renders
1604
+ // (it polls from here; this process is about to be swapped away anyway).
1605
+ void store.fetchStatus()
1064
1606
  } catch (err: unknown) {
1065
1607
  showToast(
1066
1608
  `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
@@ -1072,20 +1614,84 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1072
1614
  }
1073
1615
  }
1074
1616
 
1617
+ /** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
1618
+ * re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
1619
+ * settled job id. */
1620
+ const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
1621
+ if (kind === 'runtime') {
1622
+ runtimeArmed = true
1623
+ render()
1624
+ } else {
1625
+ void runInstall(store, copy)
1626
+ }
1627
+ }
1628
+
1629
+ const renderJobSection = (): void => {
1630
+ jobSection.textContent = ''
1631
+ const active = store.activeJob()
1632
+ if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1633
+ // An active job leads the panel — resumed on open, polled to its end.
1634
+ jobSection.append(activeJobView(active, copy))
1635
+ return
1636
+ }
1637
+ const recent = store.recentJobs()
1638
+ const latest = recent[0]
1639
+ if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
1640
+ jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
1641
+ const older = recentJobsList(recent, copy)
1642
+ if (older !== null) jobSection.append(older)
1643
+ }
1644
+ }
1645
+
1075
1646
  const render = (): void => {
1076
1647
  const report = store.report()
1077
- const installing = store.installing()
1648
+ // The footer "installing" state is owned by the store flag OR any active
1649
+ // durable job — buttons stay disabled while either is true.
1650
+ const jobActive =
1651
+ store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
1652
+ const installing = store.installing() || jobActive
1078
1653
  const stale = report === null
1079
1654
  refreshBtn.disabled = stale
1655
+ refreshBtn.title = copy.refresh
1656
+
1657
+ // Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
1658
+ const ago = relativeAgo(report?.checkedAt, copy)
1659
+ sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
1660
+
1661
+ // Collapsible advanced settings panel (gear-toggled): existing source
1662
+ // editors — plugin + runtime manifest URL override, save→check→refresh.
1663
+ renderSettingsPanel(settingsPanel, report, copy, settingsOpen, pluginActions, runtimeSourceActions)
1664
+
1665
+ renderJobSection()
1080
1666
  if (stale) {
1081
1667
  listSection.textContent = ''
1082
1668
  listSection.append(skeleton(copy))
1083
1669
  } else {
1084
- renderList(listSection, report, copy, installing, store)
1670
+ renderList(
1671
+ listSection,
1672
+ report,
1673
+ copy,
1674
+ changelogOpen,
1675
+ allOpen,
1676
+ (name) => {
1677
+ if (changelogOpen.has(name)) changelogOpen.delete(name)
1678
+ else changelogOpen.add(name)
1679
+ render()
1680
+ },
1681
+ () => {
1682
+ allOpen = !allOpen
1683
+ render()
1684
+ },
1685
+ () => {
1686
+ void store.check()
1687
+ },
1688
+ )
1085
1689
  }
1086
- renderPluginSource(pluginSourceSection, report, copy, pluginActions)
1087
- renderRuntime(runtimeSection, report, copy, runtimeActions)
1088
- renderActions(actionBar, report, copy, store, installing)
1690
+ renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
1691
+ dismissedAlert = true
1692
+ render()
1693
+ })
1694
+ renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
1089
1695
  }
1090
1696
 
1091
1697
  const unsubscribe = store.subscribe(render)