@huaqiu/dsh-tool-plugin-update 0.0.0-stage → 0.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Plugin-update review view (browser half).
3
+ *
4
+ * Plain-DOM panel (no React tree) so it can never disturb the DSH shell's
5
+ * reconciliation — same rule as the sidebar entry rows. Rendered content:
6
+ *
7
+ * - head: title + intro + refresh toolbar (the shell plugin-manager head row)
8
+ * - state-grouped sections, each a group head + package cards
9
+ * (48px hairline-framed glyph, title row with a state tag, one-line
10
+ * version transition, end actions) — the shell's card geometry
11
+ * - a facts group for the independent HQ Edge runtime report
12
+ * - footer actions (install / ignore / show again), with a toast that
13
+ * reports the outcome of an install the way the shell's Toast does
14
+ *
15
+ * Every surface rides a `--dsw-alias-*` design token (see VIEW_CSS_TEXT), so
16
+ * the panel follows the shell's light/dark theme and its focus-ring contract.
17
+ *
18
+ * @module
19
+ */
20
+ import {
21
+ checkUpdates,
22
+ ignoreOffer,
23
+ installSet,
24
+ unignoreOffer,
25
+ type PluginUpdateItem,
26
+ type PluginUpdateReport,
27
+ } from './api.js'
28
+ import type { UpdateCopy } from './copy.js'
29
+ import {
30
+ CHECK_CIRCLE_ICON,
31
+ CLOSE_ICON,
32
+ DOWNLOAD_ICON,
33
+ INFO_ICON,
34
+ PLUGIN_ICON,
35
+ REFRESH_ICON,
36
+ WARNING_ICON,
37
+ } from './icons.js'
38
+
39
+ export interface UpdateStoreLike {
40
+ report(): PluginUpdateReport | null
41
+ installing(): boolean
42
+ refresh(): Promise<PluginUpdateReport | null>
43
+ subscribe(listener: () => void): () => void
44
+ }
45
+
46
+ /** How long the outcome toast stays up, in milliseconds. */
47
+ const TOAST_HOLD_MS = 4000
48
+
49
+ function el<K extends keyof HTMLElementTagNameMap>(
50
+ tag: K,
51
+ className = '',
52
+ text?: string,
53
+ ): HTMLElementTagNameMap[K] {
54
+ const node = document.createElement(tag)
55
+ if (className.length > 0) node.className = className
56
+ if (text !== undefined) node.textContent = text
57
+ return node
58
+ }
59
+
60
+ /** A button carrying inline SVG artwork (the icon modules emit markup). */
61
+ function iconButton(
62
+ className: string,
63
+ markup: string,
64
+ label: string,
65
+ onClick: () => void,
66
+ ): HTMLButtonElement {
67
+ const button = el('button', className)
68
+ button.type = 'button'
69
+ button.setAttribute('aria-label', label)
70
+ button.title = label
71
+ button.innerHTML = markup
72
+ button.addEventListener('click', onClick)
73
+ return button
74
+ }
75
+
76
+ /**
77
+ * Compact a package name to what a person calls it — the shell plugin
78
+ * manager's own rule (`presentation.ts` `shortName`), so both UIs read the
79
+ * same plugin by the same name.
80
+ */
81
+ export function shortName(name: string): string {
82
+ const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
83
+ return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
84
+ }
85
+
86
+ /** Normalize the server's block reason to a list (it may arrive as a string). */
87
+ function blockedByList(item: PluginUpdateItem): string[] {
88
+ const by = item.blockedBy
89
+ if (Array.isArray(by)) return by.filter((entry): entry is string => typeof entry === 'string')
90
+ return typeof by === 'string' && by.length > 0 ? [by] : []
91
+ }
92
+
93
+ /** The tag tone a state reads as, matching the shell's Tag palette. */
94
+ function stateTone(item: PluginUpdateItem): 'success' | 'info' | 'warning' | 'danger' | 'neutral' {
95
+ switch (item.state) {
96
+ case 'update':
97
+ return 'info'
98
+ case 'install':
99
+ return 'warning'
100
+ case 'blocked':
101
+ return 'danger'
102
+ case 'ignored':
103
+ return 'neutral'
104
+ case 'current':
105
+ return 'success'
106
+ }
107
+ }
108
+
109
+ function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
110
+ switch (item.state) {
111
+ case 'current':
112
+ return copy.state.current
113
+ case 'update':
114
+ return copy.state.update
115
+ case 'install':
116
+ return copy.state.install
117
+ case 'ignored':
118
+ return copy.state.ignored
119
+ case 'blocked':
120
+ return copy.state.blocked
121
+ }
122
+ }
123
+
124
+ /** The one-line version transition under a card's title. */
125
+ function versionLine(item: PluginUpdateItem): string {
126
+ switch (item.state) {
127
+ case 'current':
128
+ return item.currentVersion ?? item.availableVersion
129
+ case 'install':
130
+ return item.availableVersion
131
+ default:
132
+ // An absent plugin names only the version it would gain.
133
+ return item.currentVersion === null
134
+ ? item.availableVersion
135
+ : `${item.currentVersion} → ${item.availableVersion}`
136
+ }
137
+ }
138
+
139
+ function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undefined {
140
+ const by = blockedByList(item)
141
+ if (by.includes('host')) return copy.blockedReason.host
142
+ if (by.includes('hq-edge-version')) return copy.blockedReason['hq-edge-version']
143
+ if (by.includes('runtime-unknown')) return copy.blockedReason['runtime-unknown']
144
+ return by.length > 0 ? by.join(', ') : undefined
145
+ }
146
+
147
+ /** One package card: glyph, name with its state tag, version line, end actions. */
148
+ function packageCard(
149
+ item: PluginUpdateItem,
150
+ copy: UpdateCopy,
151
+ installing: boolean,
152
+ store: UpdateStoreLike,
153
+ ): HTMLLIElement {
154
+ const card = el('li', 'hqpu-card')
155
+ card.dataset.pluginName = item.name
156
+ card.dataset.state = item.state
157
+ // The full package name is the card's tooltip; the row shows the short name.
158
+ card.title = `${item.name} · ${versionLine(item)}`
159
+
160
+ const head = el('div', 'hqpu-card-head')
161
+
162
+ const glyph = el('span', 'hqpu-card-icon')
163
+ glyph.setAttribute('aria-hidden', 'true')
164
+ glyph.innerHTML = PLUGIN_ICON(24)
165
+
166
+ const main = el('div', 'hqpu-card-main')
167
+ const titleRow = el('div', 'hqpu-title-row')
168
+ const title = el('span', 'hqpu-card-title', shortName(item.name))
169
+ const tag = el('span', 'hqpu-tag')
170
+ tag.dataset.tone = stateTone(item)
171
+ tag.textContent = stateLabel(item, copy)
172
+ titleRow.append(title, tag)
173
+
174
+ const desc = el('span', 'hqpu-card-desc', versionLine(item))
175
+
176
+ main.append(titleRow, desc)
177
+ head.append(glyph, main)
178
+
179
+ const end = el('div', 'hqpu-card-end')
180
+ if (item.state === 'update' || item.state === 'install') {
181
+ const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
182
+ button.type = 'button'
183
+ button.disabled = installing
184
+ button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
185
+ button.addEventListener('click', () => {
186
+ void runInstall(store, copy)
187
+ })
188
+ end.append(button)
189
+ }
190
+ head.append(end)
191
+ card.append(head)
192
+
193
+ // A block reason belongs to a blocked offer only.
194
+ if (item.state === 'blocked') {
195
+ const reason = blockedReason(item, copy)
196
+ if (reason !== undefined) {
197
+ card.append(el('p', 'hqpu-card-reason', reason))
198
+ }
199
+ }
200
+ return card
201
+ }
202
+
203
+ /** A section: group head with its count, then the cards. */
204
+ function group(
205
+ heading: string,
206
+ items: PluginUpdateItem[],
207
+ copy: UpdateCopy,
208
+ installing: boolean,
209
+ store: UpdateStoreLike,
210
+ ): HTMLElement | null {
211
+ if (items.length === 0) return null
212
+ const section = el('section', 'hqpu-group')
213
+ section.dataset.group = heading
214
+
215
+ const head = el('div', 'hqpu-group-head')
216
+ head.append(el('h3', 'hqpu-group-title', heading))
217
+ head.append(el('span', 'hqpu-group-count', copy.groupCount(items.length)))
218
+
219
+ const list = el('ul', 'hqpu-cards')
220
+ for (const item of items) list.append(packageCard(item, copy, installing, store))
221
+
222
+ section.append(head, list)
223
+ return section
224
+ }
225
+
226
+ /** First-read placeholders, sharing the card and text-line layout. */
227
+ function skeleton(copy: UpdateCopy): HTMLElement {
228
+ const section = el('section', 'hqpu-group')
229
+ section.setAttribute('role', 'status')
230
+ section.dataset.loading = ''
231
+ const head = el('div', 'hqpu-group-head')
232
+ head.setAttribute('aria-hidden', 'true')
233
+ const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
234
+ const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
235
+ heading.append(headingBar)
236
+ head.append(heading)
237
+
238
+ const list = el('ul', 'hqpu-cards')
239
+ list.setAttribute('aria-hidden', 'true')
240
+ for (let index = 0; index < 3; index += 1) {
241
+ const card = el('li', 'hqpu-card')
242
+ const cardHead = el('div', 'hqpu-card-head')
243
+ const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
244
+ const main = el('div', 'hqpu-card-main')
245
+ const titleRow = el('div', 'hqpu-title-row')
246
+ const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
247
+ title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
248
+ titleRow.append(title)
249
+ const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
250
+ desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
251
+ main.append(titleRow, desc)
252
+ cardHead.append(glyph, main)
253
+ const end = el('div', 'hqpu-card-end')
254
+ end.setAttribute('aria-hidden', 'true')
255
+ cardHead.append(end)
256
+ card.append(cardHead)
257
+ list.append(card)
258
+ }
259
+ section.append(head, list)
260
+ // The skeleton replaces the whole column, so label it for screen readers.
261
+ section.setAttribute('aria-label', copy.loading)
262
+ return section
263
+ }
264
+
265
+ /** The independent HQ Edge runtime report, as ruled fact rows. */
266
+ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement {
267
+ const section = el('section', 'hqpu-group')
268
+ section.dataset.group = 'runtime'
269
+ const head = el('div', 'hqpu-group-head')
270
+ head.append(el('h3', 'hqpu-group-title', copy.runtime.title))
271
+ section.append(head)
272
+
273
+ const facts = el('dl', 'hqpu-facts')
274
+ const runtime = report.runtime
275
+
276
+ const row = (label: string, value: string, code = false): void => {
277
+ const wrap = el('div')
278
+ wrap.append(el('dt', '', label))
279
+ const dd = el('dd')
280
+ if (code) {
281
+ const node = el('code', '', value)
282
+ dd.append(node)
283
+ } else {
284
+ dd.textContent = value
285
+ }
286
+ wrap.append(dd)
287
+ facts.append(wrap)
288
+ }
289
+
290
+ if (runtime.configured === false) {
291
+ row(copy.runtime.source, copy.runtime.notConfigured)
292
+ } else {
293
+ row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
294
+ if (runtime.updateAvailable && runtime.latest != null) {
295
+ row(copy.runtime.latest, runtime.latest)
296
+ }
297
+ if (report.manifestVersion !== null) {
298
+ row(copy.runtime.set, report.manifestVersion, true)
299
+ }
300
+ if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
301
+ row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
302
+ }
303
+ }
304
+ section.append(facts)
305
+
306
+ section.append(el('p', 'hqpu-status', copy.runtime.note))
307
+ if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
308
+ const line = el('p', 'hqpu-status hqpu-status--dot')
309
+ line.setAttribute('role', 'status')
310
+ const dot = el('span', 'hqpu-dot')
311
+ dot.dataset.state = 'error'
312
+ line.append(dot, el('span', '', `${copy.lastError}: ${runtime.lastError}`))
313
+ section.append(line)
314
+ }
315
+ return section
316
+ }
317
+
318
+ function renderList(
319
+ section: HTMLElement,
320
+ report: PluginUpdateReport | null,
321
+ copy: UpdateCopy,
322
+ installing: boolean,
323
+ store: UpdateStoreLike,
324
+ ): void {
325
+ section.textContent = ''
326
+ if (report === null) {
327
+ section.append(skeleton(copy))
328
+ return
329
+ }
330
+ if (report.items.length === 0) {
331
+ section.append(el('div', 'hqpu-empty', copy.empty))
332
+ return
333
+ }
334
+
335
+ const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
336
+ report.items.filter((item) => item.state === state)
337
+
338
+ // Most important first: what can be acted on, then what is refused, then
339
+ // what was muted, then what is already settled.
340
+ const order: Array<[string, PluginUpdateItem[]]> = [
341
+ [copy.group.actionable, [...byState('update'), ...byState('install')]],
342
+ [copy.group.blocked, byState('blocked')],
343
+ [copy.group.ignored, byState('ignored')],
344
+ [copy.group.current, byState('current')],
345
+ ]
346
+ for (const [heading, items] of order) {
347
+ const node = group(heading, items, copy, installing, store)
348
+ if (node !== null) section.append(node)
349
+ }
350
+ }
351
+
352
+ function renderRuntime(
353
+ section: HTMLElement,
354
+ report: PluginUpdateReport | null,
355
+ copy: UpdateCopy,
356
+ ): void {
357
+ section.textContent = ''
358
+ if (report === null) return
359
+ section.append(runtimeGroup(report, copy))
360
+ }
361
+
362
+ /** Run the install transaction, then keep polling until the state settles. */
363
+ async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
364
+ const report = store.report()
365
+ if (report === null) return
366
+ // Without an evaluated set there is nothing to install: hq-edge has not read
367
+ // a manifest yet, so the report is still its empty shell.
368
+ if (report.manifestVersion === null) {
369
+ showToast(copy.checkFailed, 'error')
370
+ return
371
+ }
372
+ try {
373
+ const result = await installSet(report.manifestVersion)
374
+ if (result.error !== undefined) {
375
+ showToast(`${copy.installError}: ${result.error}`, 'error')
376
+ return
377
+ }
378
+ // The set activates atomically and DSH restarts. Keep polling the backend
379
+ // state rather than trusting the response alone (spec §7.1).
380
+ showToast(copy.installSuccess, 'success')
381
+ void pollUntilSettled(store)
382
+ } catch (err: unknown) {
383
+ showToast(
384
+ `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
385
+ 'error',
386
+ )
387
+ }
388
+ }
389
+
390
+ /** The single live toast; a new message replaces the one on screen. */
391
+ let toastHost: HTMLElement | null = null
392
+ let toastTimer: number | null = null
393
+
394
+ function showToast(message: string, tone: 'success' | 'error'): void {
395
+ if (toastHost === null) return
396
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
397
+ toastHost.textContent = ''
398
+ const toast = el('div', 'hqpu-toast')
399
+ toast.setAttribute('role', 'status')
400
+ const icon = el('span', 'hqpu-toast-icon')
401
+ icon.dataset.tone = tone
402
+ icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
403
+ toast.append(icon, el('span', 'hqpu-toast-text', message))
404
+ toastHost.append(toast)
405
+ toastTimer = window.setTimeout(() => {
406
+ toastHost?.replaceChildren()
407
+ toastTimer = null
408
+ }, TOAST_HOLD_MS)
409
+ }
410
+
411
+ function renderActions(
412
+ bar: HTMLElement,
413
+ report: PluginUpdateReport | null,
414
+ copy: UpdateCopy,
415
+ store: UpdateStoreLike,
416
+ installing: boolean,
417
+ ): void {
418
+ bar.textContent = ''
419
+ if (report === null) return
420
+
421
+ if (installing) {
422
+ const line = el('span', 'hqpu-status hqpu-status--dot')
423
+ const spinner = el('span', 'hqpu-spinner')
424
+ line.append(spinner, el('span', '', copy.installing))
425
+ bar.append(line)
426
+ return
427
+ }
428
+
429
+ const actionable = report.items.filter(
430
+ (item) => item.state === 'update' || item.state === 'install',
431
+ ).length
432
+ // Every write needs an evaluated set; hq-edge reports `null` until it has
433
+ // read a manifest, and posting `null` would only earn a 400.
434
+ const hasSet = report.manifestVersion !== null
435
+
436
+ if (actionable === 0) {
437
+ // The official page shows no install control when there is nothing to
438
+ // commit, so mirror that with a muted line rather than a dead button.
439
+ bar.append(el('span', 'hqpu-status', copy.nothingToInstall))
440
+ } else {
441
+ const installBtn = el('button', 'hqpu-btn hqpu-btn--primary')
442
+ installBtn.type = 'button'
443
+ installBtn.disabled = !hasSet || report.ignoredSet
444
+ installBtn.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installAll(actionable)}</span>`
445
+ installBtn.addEventListener('click', () => {
446
+ void runInstall(store, copy)
447
+ })
448
+ bar.append(installBtn)
449
+ }
450
+
451
+ if (!hasSet) return
452
+
453
+ if (report.ignoredSet) {
454
+ const unignore = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.unignore)
455
+ unignore.type = 'button'
456
+ unignore.addEventListener('click', () => {
457
+ void unignoreOffer(report.manifestVersion as string)
458
+ .then(() => store.refresh())
459
+ .catch(() => undefined)
460
+ })
461
+ bar.append(unignore)
462
+ } else if (actionable > 0) {
463
+ const ignore = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.ignore)
464
+ ignore.type = 'button'
465
+ ignore.addEventListener('click', () => {
466
+ void ignoreOffer(report.manifestVersion as string)
467
+ .then(() => store.refresh())
468
+ .catch(() => undefined)
469
+ })
470
+ bar.append(ignore)
471
+ }
472
+ }
473
+
474
+ /** After an install, poll the backend until the update report settles
475
+ * (installing flag clears or DSH restarts under us — the next load re-reads
476
+ * the fresh state). */
477
+ function pollUntilSettled(store: UpdateStoreLike): void {
478
+ let attempts = 0
479
+ const tick = (): void => {
480
+ void store
481
+ .refresh()
482
+ .then((report) => {
483
+ if (store.installing()) return
484
+ if (report !== null && report.ignoredSet === false && attempts >= 2) return
485
+ })
486
+ .catch(() => undefined)
487
+ .finally(() => {
488
+ attempts += 1
489
+ if (attempts < 10) {
490
+ window.setTimeout(tick, 2000)
491
+ }
492
+ })
493
+ }
494
+ tick()
495
+ }
496
+
497
+ /**
498
+ * Open (or focus) the update panel. Re-renders from the store; when the store
499
+ * is stale the panel first refreshes the report.
500
+ */
501
+ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
502
+ const existing = document.querySelector<HTMLElement>('[data-hq-plugin-update-view]')
503
+ if (existing !== null) {
504
+ existing.style.display = ''
505
+ return
506
+ }
507
+
508
+ const overlay = el('div', 'hqpu-overlay')
509
+ overlay.setAttribute('data-hq-plugin-update-view', '')
510
+ overlay.addEventListener('click', (ev) => {
511
+ if (ev.target === overlay) onClose()
512
+ })
513
+
514
+ const scrim = el('div', 'hqpu-scrim')
515
+ const panel = el('div', 'hqpu-panel')
516
+ panel.setAttribute('role', 'dialog')
517
+ panel.setAttribute('aria-modal', 'true')
518
+ panel.setAttribute('aria-label', copy.viewTitle)
519
+ panel.tabIndex = -1
520
+
521
+ // ── Head row: title + intro, refresh toolbar ──
522
+ const head = el('div', 'hqpu-head')
523
+ const headMain = el('div', 'hqpu-head-main')
524
+ headMain.append(el('h1', 'hqpu-title', copy.viewTitle))
525
+ const intro = el('p', 'hqpu-intro')
526
+ intro.append(el('span', '', copy.viewIntro))
527
+ const info = el('button', 'hqpu-info')
528
+ info.type = 'button'
529
+ info.setAttribute('aria-label', copy.viewInfo)
530
+ info.title = copy.viewInfo
531
+ info.innerHTML = INFO_ICON(11)
532
+ intro.append(info)
533
+ headMain.append(intro)
534
+
535
+ const toolbar = el('div', 'hqpu-toolbar')
536
+ const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
537
+ void store.refresh()
538
+ })
539
+ toolbar.append(refreshBtn)
540
+ const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
541
+ onClose()
542
+ })
543
+ toolbar.append(closeBtn)
544
+
545
+ head.append(headMain, toolbar)
546
+ panel.append(head)
547
+
548
+ // ── Scrolling body: the centred column ──
549
+ const scroll = el('div', 'hqpu-scroll')
550
+ const column = el('div', 'hqpu-column')
551
+ const listSection = el('div', 'hqpu-stack')
552
+ const runtimeSection = el('div', 'hqpu-stack')
553
+ const toastSection = el('div', 'hqpu-toast-host')
554
+ column.append(listSection, runtimeSection, toastSection)
555
+ scroll.append(column)
556
+ panel.append(scroll)
557
+
558
+ // ── Footer actions (ui-primitives Modal .footer) ──
559
+ const footer = el('div', 'hqpu-footer')
560
+ const actionBar = el('div', 'hqpu-actions')
561
+ footer.append(actionBar)
562
+ panel.append(footer)
563
+
564
+ overlay.append(scrim, panel)
565
+ document.body.appendChild(overlay)
566
+ toastHost = toastSection
567
+ panel.focus()
568
+
569
+ const disposers: Array<() => void> = []
570
+
571
+ const render = (): void => {
572
+ const report = store.report()
573
+ const installing = store.installing()
574
+ const stale = report === null
575
+ refreshBtn.disabled = stale
576
+ if (stale) {
577
+ listSection.textContent = ''
578
+ listSection.append(skeleton(copy))
579
+ } else {
580
+ renderList(listSection, report, copy, installing, store)
581
+ }
582
+ renderRuntime(runtimeSection, report, copy)
583
+ renderActions(actionBar, report, copy, store, installing)
584
+ }
585
+
586
+ const unsubscribe = store.subscribe(render)
587
+ disposers.push(unsubscribe)
588
+
589
+ // Refresh on open (bounded; never block the shell).
590
+ void store.refresh().then(() => {
591
+ // Store notification re-renders already; nothing extra to do.
592
+ })
593
+
594
+ // Escape closes, the shell-wide dialog contract.
595
+ const onKeyDown = (ev: KeyboardEvent): void => {
596
+ if (ev.key === 'Escape') {
597
+ ev.stopPropagation()
598
+ onClose()
599
+ }
600
+ }
601
+ document.addEventListener('keydown', onKeyDown)
602
+ disposers.push(() => document.removeEventListener('keydown', onKeyDown))
603
+
604
+ function onClose(): void {
605
+ for (const dispose of disposers) {
606
+ try {
607
+ dispose()
608
+ } catch {
609
+ /* already disposed */
610
+ }
611
+ }
612
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
613
+ toastTimer = null
614
+ if (toastHost === toastSection) toastHost = null
615
+ overlay.remove()
616
+ }
617
+
618
+ render()
619
+ }