@huaqiu/dsh-tool-plugin-update 0.6.1 → 0.6.2
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 +516 -13
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +86 -18
- package/package.json +3 -3
- package/src/client/api.ts +156 -1
- package/src/client/copy.ts +65 -0
- package/src/client/index.ts +63 -0
- package/src/client/update-view.ts +513 -8
- package/src/routes.ts +110 -12
|
@@ -21,9 +21,14 @@ import {
|
|
|
21
21
|
checkUpdates,
|
|
22
22
|
ignoreOffer,
|
|
23
23
|
installSet,
|
|
24
|
+
requestRuntimeUpdate,
|
|
25
|
+
runtimeLatestVersion,
|
|
26
|
+
setPluginUpdateSource,
|
|
27
|
+
setRuntimeUpdateSource,
|
|
24
28
|
unignoreOffer,
|
|
25
29
|
type PluginUpdateItem,
|
|
26
30
|
type PluginUpdateReport,
|
|
31
|
+
type RuntimeUpdateSource,
|
|
27
32
|
} from './api.js'
|
|
28
33
|
import type { UpdateCopy } from './copy.js'
|
|
29
34
|
import {
|
|
@@ -262,8 +267,261 @@ function skeleton(copy: UpdateCopy): HTMLElement {
|
|
|
262
267
|
return section
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
/** Mutable editor state for one manifest source (lives in the panel closure). */
|
|
271
|
+
interface SourceState {
|
|
272
|
+
editing: boolean
|
|
273
|
+
saving: boolean
|
|
274
|
+
draft: string
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Shared state + callbacks for one update-source editor.
|
|
279
|
+
*
|
|
280
|
+
* hq-edge serves both manifest sources from one shape (plugin release
|
|
281
|
+
* manifest, runtime-update manifest), and both are editable from this panel:
|
|
282
|
+
* an override is persisted per host, so pointing discovery at a staging
|
|
283
|
+
* channel (or a local test rig) needs no env var and no restart. The editor
|
|
284
|
+
* state lives in the panel closure for the same reason `armed` does — a store
|
|
285
|
+
* refresh must not discard a half-typed URL or silently close the editor.
|
|
286
|
+
*/
|
|
287
|
+
export interface SourceEditorActions {
|
|
288
|
+
/** The source editor is open (input shown instead of the read-only row). */
|
|
289
|
+
sourceEditing: boolean
|
|
290
|
+
/** A save is in flight. */
|
|
291
|
+
sourceSaving: boolean
|
|
292
|
+
/** The URL being edited (what the input shows). */
|
|
293
|
+
sourceDraft: string
|
|
294
|
+
onSourceEdit(): void
|
|
295
|
+
onSourceDraft(value: string): void
|
|
296
|
+
onSourceSave(): void
|
|
297
|
+
onSourceCancel(): void
|
|
298
|
+
/** Clear the user override, falling back to the configured URL. */
|
|
299
|
+
onSourceReset(): void
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The runtime-update action row's state and callbacks.
|
|
304
|
+
*
|
|
305
|
+
* Installing the runtime is destructive in a way a plugin install is not: hq-edge
|
|
306
|
+
* shuts itself down so the EDA host can swap the runtime directory. So the row
|
|
307
|
+
* is a two-step confirm (arm → confirm), never a single fire-and-forget click.
|
|
308
|
+
*/
|
|
309
|
+
export interface RuntimeActions extends SourceEditorActions {
|
|
310
|
+
/** A request is in flight (hq-edge accepted it and is restarting). */
|
|
311
|
+
busy: boolean
|
|
312
|
+
/** The confirm step is armed for the version the row names. */
|
|
313
|
+
armed: boolean
|
|
314
|
+
/**
|
|
315
|
+
* hq-edge answered `202 Accepted`: from here on the outcome belongs to the
|
|
316
|
+
* EDA host, and this process is about to be replaced. Terminal — the row
|
|
317
|
+
* must never re-offer the button once this is true, because hq-edge is
|
|
318
|
+
* already gone and a second request could only fail.
|
|
319
|
+
*/
|
|
320
|
+
accepted: boolean
|
|
321
|
+
onArm(): void
|
|
322
|
+
onConfirm(): void
|
|
323
|
+
onCancel(): void
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Normalize the report's `runtime.source` to the full shape.
|
|
328
|
+
*
|
|
329
|
+
* An older hq-edge predates the feature and sends no `source` at all — that
|
|
330
|
+
* must degrade to a read-only panel, not crash the render. A future server
|
|
331
|
+
* may add fields; unknown extras are dropped here rather than trusted.
|
|
332
|
+
*/
|
|
333
|
+
function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
|
|
334
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
335
|
+
const candidate = raw as Partial<RuntimeUpdateSource>
|
|
336
|
+
if (typeof candidate.origin !== 'string') return null
|
|
337
|
+
return {
|
|
338
|
+
url: typeof candidate.url === 'string' ? candidate.url : '',
|
|
339
|
+
origin: candidate.origin as RuntimeUpdateSource['origin'],
|
|
340
|
+
configuredUrl: typeof candidate.configuredUrl === 'string' ? candidate.configuredUrl : '',
|
|
341
|
+
override: candidate.override === true,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The tag naming where the URL in effect came from. */
|
|
346
|
+
function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
|
|
347
|
+
switch (origin) {
|
|
348
|
+
case 'override':
|
|
349
|
+
return copy.originOverride
|
|
350
|
+
case 'config':
|
|
351
|
+
return copy.originConfig
|
|
352
|
+
default:
|
|
353
|
+
return copy.originUnset
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The 更新源 fact row: the URL in effect plus an origin tag. */
|
|
358
|
+
function sourceFactRow(
|
|
359
|
+
facts: HTMLElement,
|
|
360
|
+
source: RuntimeUpdateSource,
|
|
361
|
+
copy: UpdateCopy,
|
|
362
|
+
): void {
|
|
363
|
+
const wrap = el('div')
|
|
364
|
+
wrap.append(el('dt', '', copy.runtime.source))
|
|
365
|
+
const dd = el('dd')
|
|
366
|
+
const line = el('div', 'hqpu-source-line')
|
|
367
|
+
const code = el(
|
|
368
|
+
'code',
|
|
369
|
+
'hqpu-source-url',
|
|
370
|
+
source.url.length > 0 ? source.url : copy.runtime.notConfigured,
|
|
371
|
+
)
|
|
372
|
+
const tag = el('span', 'hqpu-tag')
|
|
373
|
+
tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
|
|
374
|
+
tag.textContent = originLabel(source.origin, copy)
|
|
375
|
+
line.append(code, tag)
|
|
376
|
+
dd.append(line)
|
|
377
|
+
wrap.append(dd)
|
|
378
|
+
facts.append(wrap)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Which manifest an editor belongs to — drives data attributes + CSS hook. */
|
|
382
|
+
export type SourceKind = 'runtime' | 'plugin'
|
|
383
|
+
|
|
384
|
+
/** The localized strings one source editor needs (both kinds share the flow). */
|
|
385
|
+
interface SourceEditorLabels {
|
|
386
|
+
hint: string
|
|
387
|
+
edit: string
|
|
388
|
+
save: string
|
|
389
|
+
reset: string
|
|
390
|
+
inputLabel: string
|
|
391
|
+
placeholder: string
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Pick the labels for one kind out of the copy table. */
|
|
395
|
+
function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
|
|
396
|
+
return kind === 'runtime'
|
|
397
|
+
? {
|
|
398
|
+
hint: copy.runtime.sourceHint,
|
|
399
|
+
edit: copy.runtime.sourceEdit,
|
|
400
|
+
save: copy.runtime.sourceSave,
|
|
401
|
+
reset: copy.runtime.sourceReset,
|
|
402
|
+
inputLabel: copy.runtime.sourceInputLabel,
|
|
403
|
+
placeholder: copy.runtime.sourcePlaceholder,
|
|
404
|
+
}
|
|
405
|
+
: {
|
|
406
|
+
hint: copy.pluginSource.hint,
|
|
407
|
+
edit: copy.pluginSource.edit,
|
|
408
|
+
save: copy.pluginSource.save,
|
|
409
|
+
reset: copy.pluginSource.reset,
|
|
410
|
+
inputLabel: copy.pluginSource.inputLabel,
|
|
411
|
+
placeholder: copy.pluginSource.placeholder,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One update-source editor, shared by the plugin manifest and the runtime
|
|
417
|
+
* manifest (the only differences are the labels and the data-attribute
|
|
418
|
+
* prefix, so tests and CSS can target each).
|
|
419
|
+
*
|
|
420
|
+
* Read mode is a hint plus a 修改 button; edit mode is the URL field with
|
|
421
|
+
* 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
|
|
422
|
+
* URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
|
|
423
|
+
* a background store refresh re-renders the typed URL instead of discarding it.
|
|
424
|
+
*/
|
|
425
|
+
function sourceEditor(
|
|
426
|
+
kind: SourceKind,
|
|
427
|
+
source: RuntimeUpdateSource,
|
|
428
|
+
copy: UpdateCopy,
|
|
429
|
+
actions: SourceEditorActions,
|
|
430
|
+
): HTMLElement {
|
|
431
|
+
const labels = sourceLabels(copy, kind)
|
|
432
|
+
const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
|
|
433
|
+
if (!actions.sourceEditing) {
|
|
434
|
+
block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
|
|
435
|
+
const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
|
|
436
|
+
edit.type = 'button'
|
|
437
|
+
edit.setAttribute(`data-${kind}-source-edit`, '')
|
|
438
|
+
edit.disabled = actions.sourceSaving
|
|
439
|
+
edit.addEventListener('click', actions.onSourceEdit)
|
|
440
|
+
block.append(edit)
|
|
441
|
+
return block
|
|
442
|
+
}
|
|
443
|
+
const input = el('input', 'hqpu-source-input')
|
|
444
|
+
input.type = 'url'
|
|
445
|
+
input.value = actions.sourceDraft
|
|
446
|
+
input.placeholder = labels.placeholder
|
|
447
|
+
input.setAttribute('aria-label', labels.inputLabel)
|
|
448
|
+
input.setAttribute(`data-${kind}-source-input`, '')
|
|
449
|
+
input.disabled = actions.sourceSaving
|
|
450
|
+
input.addEventListener('input', () => {
|
|
451
|
+
// Draft-only: no re-render, so the caret and IME stay untouched.
|
|
452
|
+
actions.onSourceDraft(input.value)
|
|
453
|
+
})
|
|
454
|
+
// Enter saves, Escape cancels — the field is the whole editor, and Escape
|
|
455
|
+
// must not fall through to the panel-wide close handler.
|
|
456
|
+
input.addEventListener('keydown', (ev) => {
|
|
457
|
+
if (ev.key === 'Enter') {
|
|
458
|
+
ev.preventDefault()
|
|
459
|
+
actions.onSourceSave()
|
|
460
|
+
} else if (ev.key === 'Escape') {
|
|
461
|
+
ev.preventDefault()
|
|
462
|
+
ev.stopPropagation()
|
|
463
|
+
actions.onSourceCancel()
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
|
|
467
|
+
save.type = 'button'
|
|
468
|
+
save.setAttribute(`data-${kind}-source-save`, '')
|
|
469
|
+
save.disabled = actions.sourceSaving
|
|
470
|
+
save.addEventListener('click', actions.onSourceSave)
|
|
471
|
+
const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
|
|
472
|
+
cancel.type = 'button'
|
|
473
|
+
cancel.setAttribute(`data-${kind}-source-cancel`, '')
|
|
474
|
+
cancel.disabled = actions.sourceSaving
|
|
475
|
+
cancel.addEventListener('click', actions.onSourceCancel)
|
|
476
|
+
const controls = el('div', 'hqpu-actions hqpu-source-controls')
|
|
477
|
+
controls.append(input, save, cancel)
|
|
478
|
+
if (source.override) {
|
|
479
|
+
const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
|
|
480
|
+
reset.type = 'button'
|
|
481
|
+
reset.setAttribute(`data-${kind}-source-reset`, '')
|
|
482
|
+
reset.disabled = actions.sourceSaving
|
|
483
|
+
reset.addEventListener('click', actions.onSourceReset)
|
|
484
|
+
controls.append(reset)
|
|
485
|
+
}
|
|
486
|
+
block.append(controls)
|
|
487
|
+
// Focus on open — and on any re-render while editing, which is exactly what
|
|
488
|
+
// a rejected save wants (the user is dropped back into the field to fix it).
|
|
489
|
+
input.focus()
|
|
490
|
+
return block
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The plugin release-manifest source, as its own group above the plugin
|
|
495
|
+
* groups: it says where the whole list comes from, so it belongs above the
|
|
496
|
+
* list rather than inside it. Absent on an older hq-edge (no `pluginSource`
|
|
497
|
+
* in the report) — then nothing is rendered at all.
|
|
498
|
+
*/
|
|
499
|
+
function pluginSourceGroup(
|
|
500
|
+
report: PluginUpdateReport,
|
|
501
|
+
copy: UpdateCopy,
|
|
502
|
+
actions: SourceEditorActions,
|
|
503
|
+
): HTMLElement | null {
|
|
504
|
+
const source = normalizeSource(report.pluginSource)
|
|
505
|
+
if (source === null) return null
|
|
506
|
+
const section = el('section', 'hqpu-group')
|
|
507
|
+
section.dataset.group = 'plugin-source'
|
|
508
|
+
const head = el('div', 'hqpu-group-head')
|
|
509
|
+
head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
|
|
510
|
+
section.append(head)
|
|
511
|
+
|
|
512
|
+
const facts = el('dl', 'hqpu-facts')
|
|
513
|
+
sourceFactRow(facts, source, copy)
|
|
514
|
+
section.append(facts)
|
|
515
|
+
section.append(sourceEditor('plugin', source, copy, actions))
|
|
516
|
+
return section
|
|
517
|
+
}
|
|
518
|
+
|
|
265
519
|
/** The independent HQ Edge runtime report, as ruled fact rows. */
|
|
266
|
-
function runtimeGroup(
|
|
520
|
+
function runtimeGroup(
|
|
521
|
+
report: PluginUpdateReport,
|
|
522
|
+
copy: UpdateCopy,
|
|
523
|
+
actions: RuntimeActions,
|
|
524
|
+
): HTMLElement {
|
|
267
525
|
const section = el('section', 'hqpu-group')
|
|
268
526
|
section.dataset.group = 'runtime'
|
|
269
527
|
const head = el('div', 'hqpu-group-head')
|
|
@@ -272,6 +530,9 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
|
|
|
272
530
|
|
|
273
531
|
const facts = el('dl', 'hqpu-facts')
|
|
274
532
|
const runtime = report.runtime
|
|
533
|
+
// `runtime.latest` is a release ENTRY; only its version is ever displayed or
|
|
534
|
+
// sent back as `targetVersion`.
|
|
535
|
+
const latestVersion = runtimeLatestVersion(runtime)
|
|
275
536
|
|
|
276
537
|
const row = (label: string, value: string, code = false): void => {
|
|
277
538
|
const wrap = el('div')
|
|
@@ -287,12 +548,20 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
|
|
|
287
548
|
facts.append(wrap)
|
|
288
549
|
}
|
|
289
550
|
|
|
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
|
+
|
|
290
555
|
if (runtime.configured === false) {
|
|
291
|
-
|
|
556
|
+
if (source !== null) {
|
|
557
|
+
sourceFactRow(facts, source, copy)
|
|
558
|
+
} else {
|
|
559
|
+
row(copy.runtime.source, copy.runtime.notConfigured)
|
|
560
|
+
}
|
|
292
561
|
} else {
|
|
293
562
|
row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
|
|
294
|
-
if (runtime.updateAvailable &&
|
|
295
|
-
row(copy.runtime.latest,
|
|
563
|
+
if (runtime.updateAvailable && latestVersion !== null) {
|
|
564
|
+
row(copy.runtime.latest, latestVersion)
|
|
296
565
|
}
|
|
297
566
|
if (report.manifestVersion !== null) {
|
|
298
567
|
row(copy.runtime.set, report.manifestVersion, true)
|
|
@@ -300,10 +569,62 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
|
|
|
300
569
|
if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
|
|
301
570
|
row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
|
|
302
571
|
}
|
|
572
|
+
if (source !== null) {
|
|
573
|
+
sourceFactRow(facts, source, copy)
|
|
574
|
+
}
|
|
303
575
|
}
|
|
304
576
|
section.append(facts)
|
|
305
577
|
|
|
306
|
-
|
|
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')
|
|
591
|
+
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)
|
|
598
|
+
} 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)
|
|
605
|
+
} 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)
|
|
608
|
+
yes.type = 'button'
|
|
609
|
+
yes.dataset.runtimeConfirm = ''
|
|
610
|
+
yes.addEventListener('click', actions.onConfirm)
|
|
611
|
+
const no = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
|
|
612
|
+
no.type = 'button'
|
|
613
|
+
no.addEventListener('click', actions.onCancel)
|
|
614
|
+
row.append(yes, no)
|
|
615
|
+
} else {
|
|
616
|
+
const start = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm')
|
|
617
|
+
start.type = 'button'
|
|
618
|
+
start.dataset.runtimeUpdate = ''
|
|
619
|
+
start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`
|
|
620
|
+
start.addEventListener('click', actions.onArm)
|
|
621
|
+
row.append(start)
|
|
622
|
+
}
|
|
623
|
+
section.append(row)
|
|
624
|
+
} else {
|
|
625
|
+
section.append(el('p', 'hqpu-status', copy.runtime.note))
|
|
626
|
+
}
|
|
627
|
+
|
|
307
628
|
if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
308
629
|
const line = el('p', 'hqpu-status hqpu-status--dot')
|
|
309
630
|
line.setAttribute('role', 'status')
|
|
@@ -353,10 +674,24 @@ function renderRuntime(
|
|
|
353
674
|
section: HTMLElement,
|
|
354
675
|
report: PluginUpdateReport | null,
|
|
355
676
|
copy: UpdateCopy,
|
|
677
|
+
actions: RuntimeActions,
|
|
356
678
|
): void {
|
|
357
679
|
section.textContent = ''
|
|
358
680
|
if (report === null) return
|
|
359
|
-
section.append(runtimeGroup(report, copy))
|
|
681
|
+
section.append(runtimeGroup(report, copy, actions))
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The plugin-source group, above the plugin groups. No-op without the field. */
|
|
685
|
+
function renderPluginSource(
|
|
686
|
+
section: HTMLElement,
|
|
687
|
+
report: PluginUpdateReport | null,
|
|
688
|
+
copy: UpdateCopy,
|
|
689
|
+
actions: SourceEditorActions,
|
|
690
|
+
): void {
|
|
691
|
+
section.textContent = ''
|
|
692
|
+
if (report === null) return
|
|
693
|
+
const node = pluginSourceGroup(report, copy, actions)
|
|
694
|
+
if (node !== null) section.append(node)
|
|
360
695
|
}
|
|
361
696
|
|
|
362
697
|
/** Run the install transaction, then keep polling until the state settles. */
|
|
@@ -548,10 +883,11 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
548
883
|
// ── Scrolling body: the centred column ──
|
|
549
884
|
const scroll = el('div', 'hqpu-scroll')
|
|
550
885
|
const column = el('div', 'hqpu-column')
|
|
886
|
+
const pluginSourceSection = el('div', 'hqpu-stack')
|
|
551
887
|
const listSection = el('div', 'hqpu-stack')
|
|
552
888
|
const runtimeSection = el('div', 'hqpu-stack')
|
|
553
889
|
const toastSection = el('div', 'hqpu-toast-host')
|
|
554
|
-
column.append(listSection, runtimeSection, toastSection)
|
|
890
|
+
column.append(pluginSourceSection, listSection, runtimeSection, toastSection)
|
|
555
891
|
scroll.append(column)
|
|
556
892
|
panel.append(scroll)
|
|
557
893
|
|
|
@@ -568,6 +904,174 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
568
904
|
|
|
569
905
|
const disposers: Array<() => void> = []
|
|
570
906
|
|
|
907
|
+
// ── Runtime update: arm → confirm, then hand off to hq-edge ──
|
|
908
|
+
// Live in the panel's closure (not the report) because they are UI state, and
|
|
909
|
+
// a store refresh must not silently disarm a confirm the user just armed.
|
|
910
|
+
let runtimeArmed = false
|
|
911
|
+
let runtimeBusy = false
|
|
912
|
+
let runtimeAccepted = false
|
|
913
|
+
|
|
914
|
+
// Source-editor state, same reasoning: a background refresh must not discard
|
|
915
|
+
// a half-typed URL or silently close either editor.
|
|
916
|
+
const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
917
|
+
const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
918
|
+
|
|
919
|
+
// Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
|
|
920
|
+
// getters once and freeze `sourceEditing: false` — the editor would never
|
|
921
|
+
// open. Read every member through the live object instead.
|
|
922
|
+
const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
|
|
923
|
+
const runtimeActions: RuntimeActions = {
|
|
924
|
+
get busy() {
|
|
925
|
+
return runtimeBusy
|
|
926
|
+
},
|
|
927
|
+
get armed() {
|
|
928
|
+
return runtimeArmed
|
|
929
|
+
},
|
|
930
|
+
get accepted() {
|
|
931
|
+
return runtimeAccepted
|
|
932
|
+
},
|
|
933
|
+
onArm: () => {
|
|
934
|
+
runtimeArmed = true
|
|
935
|
+
render()
|
|
936
|
+
},
|
|
937
|
+
onCancel: () => {
|
|
938
|
+
runtimeArmed = false
|
|
939
|
+
render()
|
|
940
|
+
},
|
|
941
|
+
onConfirm: () => {
|
|
942
|
+
void requestRuntime()
|
|
943
|
+
},
|
|
944
|
+
get sourceEditing() {
|
|
945
|
+
return runtimeSourceActions.sourceEditing
|
|
946
|
+
},
|
|
947
|
+
get sourceSaving() {
|
|
948
|
+
return runtimeSourceActions.sourceSaving
|
|
949
|
+
},
|
|
950
|
+
get sourceDraft() {
|
|
951
|
+
return runtimeSourceActions.sourceDraft
|
|
952
|
+
},
|
|
953
|
+
onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
|
|
954
|
+
onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
|
|
955
|
+
onSourceSave: () => runtimeSourceActions.onSourceSave(),
|
|
956
|
+
onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
|
|
957
|
+
onSourceReset: () => runtimeSourceActions.onSourceReset(),
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const pluginActions: SourceEditorActions = sourceActionsFor('plugin', pluginSourceState)
|
|
961
|
+
|
|
962
|
+
/** Build the {@link SourceEditorActions} half for one manifest source. */
|
|
963
|
+
function sourceActionsFor(
|
|
964
|
+
kind: SourceKind,
|
|
965
|
+
state: SourceState,
|
|
966
|
+
): SourceEditorActions {
|
|
967
|
+
return {
|
|
968
|
+
get sourceEditing() {
|
|
969
|
+
return state.editing
|
|
970
|
+
},
|
|
971
|
+
get sourceSaving() {
|
|
972
|
+
return state.saving
|
|
973
|
+
},
|
|
974
|
+
get sourceDraft() {
|
|
975
|
+
return state.draft
|
|
976
|
+
},
|
|
977
|
+
onSourceEdit: () => {
|
|
978
|
+
// Prefill with the URL in effect, so 保存 is a no-op unless edited.
|
|
979
|
+
const report = store.report()
|
|
980
|
+
const source =
|
|
981
|
+
kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
|
|
982
|
+
state.draft = source?.url ?? ''
|
|
983
|
+
state.editing = true
|
|
984
|
+
render()
|
|
985
|
+
},
|
|
986
|
+
onSourceDraft: (value: string) => {
|
|
987
|
+
state.draft = value
|
|
988
|
+
},
|
|
989
|
+
onSourceSave: () => {
|
|
990
|
+
void submitSource(kind, state)
|
|
991
|
+
},
|
|
992
|
+
onSourceCancel: () => {
|
|
993
|
+
state.editing = false
|
|
994
|
+
render()
|
|
995
|
+
},
|
|
996
|
+
onSourceReset: () => {
|
|
997
|
+
void submitSource(kind, state, '')
|
|
998
|
+
},
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Save one manifest source — or clear the override with an empty URL —
|
|
1004
|
+
* then re-run discovery.
|
|
1005
|
+
*
|
|
1006
|
+
* The re-check is not optional: the report is cached, so without a fresh
|
|
1007
|
+
* `POST /check` the facts and the update button would keep naming the OLD
|
|
1008
|
+
* manifest's offer after the source changed. hq-edge answers 422 with a
|
|
1009
|
+
* message meant for a person on refusal (bad scheme, credentials, …), which
|
|
1010
|
+
* the toast shows verbatim.
|
|
1011
|
+
*/
|
|
1012
|
+
async function submitSource(
|
|
1013
|
+
kind: SourceKind,
|
|
1014
|
+
state: SourceState,
|
|
1015
|
+
url: string = state.draft,
|
|
1016
|
+
): Promise<void> {
|
|
1017
|
+
state.saving = true
|
|
1018
|
+
render()
|
|
1019
|
+
const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
|
|
1020
|
+
try {
|
|
1021
|
+
if (kind === 'runtime') await setRuntimeUpdateSource(url)
|
|
1022
|
+
else await setPluginUpdateSource(url)
|
|
1023
|
+
state.editing = false
|
|
1024
|
+
showToast(labels.saved, 'success')
|
|
1025
|
+
try {
|
|
1026
|
+
await checkUpdates()
|
|
1027
|
+
await store.refresh()
|
|
1028
|
+
} catch {
|
|
1029
|
+
// The source IS saved; a failed re-check only leaves the previous
|
|
1030
|
+
// offer on screen until the next refresh — the toolbar re-runs it.
|
|
1031
|
+
}
|
|
1032
|
+
} catch (err: unknown) {
|
|
1033
|
+
showToast(
|
|
1034
|
+
`${labels.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1035
|
+
'error',
|
|
1036
|
+
)
|
|
1037
|
+
} finally {
|
|
1038
|
+
state.saving = false
|
|
1039
|
+
render()
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function requestRuntime(): Promise<void> {
|
|
1044
|
+
const version = runtimeLatestVersion(store.report()?.runtime)
|
|
1045
|
+
if (version === null) {
|
|
1046
|
+
showToast(copy.checkFailed, 'error')
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
runtimeArmed = false
|
|
1050
|
+
runtimeBusy = true
|
|
1051
|
+
render()
|
|
1052
|
+
try {
|
|
1053
|
+
const result = await requestRuntimeUpdate(version)
|
|
1054
|
+
if (result?.error !== undefined) {
|
|
1055
|
+
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
// 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
|
|
1059
|
+
// relaunch. This panel is about to go away with the restart, so say so —
|
|
1060
|
+
// and latch it, so a store refresh cannot re-offer the button for a
|
|
1061
|
+
// server that is already gone.
|
|
1062
|
+
runtimeAccepted = true
|
|
1063
|
+
showToast(copy.runtime.updateAccepted, 'success')
|
|
1064
|
+
} catch (err: unknown) {
|
|
1065
|
+
showToast(
|
|
1066
|
+
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1067
|
+
'error',
|
|
1068
|
+
)
|
|
1069
|
+
} finally {
|
|
1070
|
+
runtimeBusy = false
|
|
1071
|
+
render()
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
571
1075
|
const render = (): void => {
|
|
572
1076
|
const report = store.report()
|
|
573
1077
|
const installing = store.installing()
|
|
@@ -579,7 +1083,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
579
1083
|
} else {
|
|
580
1084
|
renderList(listSection, report, copy, installing, store)
|
|
581
1085
|
}
|
|
582
|
-
|
|
1086
|
+
renderPluginSource(pluginSourceSection, report, copy, pluginActions)
|
|
1087
|
+
renderRuntime(runtimeSection, report, copy, runtimeActions)
|
|
583
1088
|
renderActions(actionBar, report, copy, store, installing)
|
|
584
1089
|
}
|
|
585
1090
|
|