@restregular/dsh-powerup 0.2.2 → 0.3.0

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.
Files changed (4) hide show
  1. package/README.md +38 -13
  2. package/client.js +688 -140
  3. package/index.js +161 -14
  4. package/package.json +2 -2
package/client.js CHANGED
@@ -44,7 +44,7 @@
44
44
 
45
45
  const NS = 'dsh-powerup';
46
46
  const SETTINGS_KEY = 'dsh-powerup:settings:v1';
47
- const VERSION = '0.2.2';
47
+ const VERSION = '0.3.0';
48
48
  const MENU_GAP = 8;
49
49
  const MENU_WIDTH = 248;
50
50
 
@@ -154,6 +154,20 @@
154
154
  'update.restartUnsupported': '当前运行方式无法自动重启:{reason}',
155
155
  'update.restarting': 'DSH 正在重启,窗口即将关闭…',
156
156
  'update.restartFailed': '重启请求失败:{message}',
157
+ 'update.selfUpdateWarning': '更新 Power-Up 会在后台卸载并安装新版本;本面板会关闭,完成后刷新页面。',
158
+ 'update.selfUpdating': 'Power-Up 正在后台更新…',
159
+ 'update.selfRefresh': '完成后请刷新页面。',
160
+ 'update.setDefault': '设为默认',
161
+ 'update.defaultTag': '默认',
162
+ 'update.defaultHint': '更新列表默认使用此安装源',
163
+ 'restart.tag': '需重启',
164
+ 'restart.tagHint': '该插件已更新,重启 DSH 后生效',
165
+ 'restart.pending': '{count} 个插件待重启',
166
+ 'restart.action': '重启 DSH',
167
+ 'restart.title': '重启 DSH?',
168
+ 'restart.desc': '重启会关闭并重新打开 DSH,正在运行的任务会中断。',
169
+ 'restart.cancel': '取消',
170
+ 'restart.confirm': '立即重启',
157
171
  'update.doneTitle': '更新完成',
158
172
  'update.doneInstalled': '{name} 已更新到 {version}。',
159
173
  'update.doneDisabled': '{name} 已安装 {version},尚未启用。',
@@ -276,6 +290,20 @@
276
290
  'update.restartUnsupported': 'This runtime cannot restart itself: {reason}',
277
291
  'update.restarting': 'DSH is restarting; this window will close…',
278
292
  'update.restartFailed': 'Restart request failed: {message}',
293
+ 'update.selfUpdateWarning': 'Updating Power-Up removes and reinstalls it in the background; this panel closes, then refresh the page.',
294
+ 'update.selfUpdating': 'Power-Up is updating in the background…',
295
+ 'update.selfRefresh': 'Refresh the page when it finishes.',
296
+ 'update.setDefault': 'Set as default',
297
+ 'update.defaultTag': 'Default',
298
+ 'update.defaultHint': 'The update list starts from this registry',
299
+ 'restart.tag': 'Restart needed',
300
+ 'restart.tagHint': 'Updated; restart DSH to apply',
301
+ 'restart.pending': '{count} plugins need a restart',
302
+ 'restart.action': 'Restart DSH',
303
+ 'restart.title': 'Restart DSH?',
304
+ 'restart.desc': 'Restarting closes and reopens DSH; running tasks are interrupted.',
305
+ 'restart.cancel': 'Cancel',
306
+ 'restart.confirm': 'Restart now',
279
307
  'update.doneTitle': 'Update complete',
280
308
  'update.doneInstalled': '{name} is now on {version}.',
281
309
  'update.doneDisabled': '{name} {version} is installed but not enabled.',
@@ -597,6 +625,15 @@
597
625
  );
598
626
  }
599
627
 
628
+ function StarIcon({ size = 12 }) {
629
+ return h(Icon, { size },
630
+ h('path', {
631
+ d: 'm8 2.6 1.6 3.3 3.6.5-2.6 2.5.6 3.6L8 10.8l-3.2 1.7.6-3.6L2.8 6.4l3.6-.5L8 2.6Z',
632
+ fill: 'currentColor',
633
+ }),
634
+ );
635
+ }
636
+
600
637
  function WarnIcon({ size = 16 }) {
601
638
  return h(Icon, { size },
602
639
  h('path', {
@@ -667,6 +704,15 @@
667
704
  @keyframes dsh-powerup-toast-in{from{opacity:0;transform:translate(-50%,5px)}to{opacity:1;transform:translate(-50%,0)}}
668
705
  .dsh-powerup-update-button{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-caption);cursor:pointer}
669
706
  .dsh-powerup-update-button:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary)}
707
+ .dsh-powerup-restart-tag{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 6px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 32%,transparent);border-radius:999px;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);font-size:10px;line-height:16px;white-space:nowrap}
708
+ .dsh-powerup-restart-bar{display:flex;flex:none;align-items:center;gap:8px;margin-left:8px}
709
+ .dsh-powerup-restart-count{color:var(--dsw-alias-state-warn-label);font-size:11.5px;line-height:18px;white-space:nowrap}
710
+ .dsh-powerup-restart-action{height:26px;padding:0 10px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 36%,transparent);border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);font:inherit;font-size:11.5px;line-height:18px;cursor:pointer}
711
+ .dsh-powerup-restart-action:hover{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 14%,transparent)}
712
+ .dsh-powerup-update-default{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-caption);cursor:pointer}
713
+ .dsh-powerup-update-default:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-warn-label)}
714
+ .dsh-powerup-update-default[aria-pressed=true]{color:var(--dsw-alias-state-warn-label)}
715
+ .dsh-powerup-update-default-tag{flex:none;padding:0 6px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 32%,transparent);border-radius:999px;color:var(--dsw-alias-state-warn-label);font-size:10px;line-height:16px;white-space:nowrap}
670
716
  .dsh-powerup-update-mask{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur,blur(3px));pointer-events:auto;animation:dsh-powerup-update-fade .16s ease-out}
671
717
  @keyframes dsh-powerup-update-fade{from{opacity:0}to{opacity:1}}
672
718
  .dsh-powerup-update-dialog{box-sizing:border-box;position:relative;display:flex;flex-direction:column;width:min(560px,100%);max-height:min(760px,calc(100vh - 48px));max-height:min(760px,calc(100dvh - 48px));border-radius:var(--dsw-radius-panel,14px);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;overflow:hidden;animation:dsh-powerup-update-rise .18s cubic-bezier(.2,.8,.3,1)}
@@ -698,6 +744,9 @@
698
744
  .dsh-powerup-update-toggle .chevron{display:inline-flex;color:var(--dsw-alias-label-tertiary);transition:transform .16s ease}
699
745
  .dsh-powerup-update-toggle[aria-expanded=true] .chevron{transform:rotate(180deg);color:var(--dsw-alias-label-secondary)}
700
746
  .dsh-powerup-update-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-layer-1);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
747
+ .dsh-powerup-update-popover{position:fixed;z-index:1300;box-sizing:border-box;overflow-y:auto;pointer-events:auto;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
748
+ .dsh-powerup-update-popover.dsh-powerup-update-list{border-radius:var(--dsw-radius-lg,10px)}
749
+ .dsh-powerup-update-popover.dsh-powerup-update-select-menu{display:flex;flex-direction:column;padding:4px}
701
750
  .dsh-powerup-update-list-head{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 12px;border-bottom:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
702
751
  .dsh-powerup-update-version{display:flex;align-items:center;gap:8px;width:100%;min-height:34px;padding:0 12px;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;line-height:18px;text-align:left;cursor:pointer;font-variant-numeric:tabular-nums}
703
752
  .dsh-powerup-update-version>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -748,7 +797,7 @@
748
797
  .dsh-powerup-update-ghost{height:36px;padding:0 14px;border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer}
749
798
  .dsh-powerup-update-ghost:hover{background:var(--dsw-alias-interactive-bg-hover)}
750
799
  .dsh-powerup-update-ghost:active{background:var(--dsw-alias-interactive-bg-active)}
751
- .dsh-powerup-update-close:focus-visible,.dsh-powerup-update-toggle:focus-visible,.dsh-powerup-update-version:focus-visible,.dsh-powerup-update-select-trigger:focus-visible,.dsh-powerup-update-option:focus-visible,.dsh-powerup-update-primary:focus-visible,.dsh-powerup-update-ghost:focus-visible,.dsh-powerup-update-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
800
+ .dsh-powerup-update-close:focus-visible,.dsh-powerup-update-toggle:focus-visible,.dsh-powerup-update-version:focus-visible,.dsh-powerup-update-select-trigger:focus-visible,.dsh-powerup-update-option:focus-visible,.dsh-powerup-update-primary:focus-visible,.dsh-powerup-update-ghost:focus-visible,.dsh-powerup-update-button:focus-visible,.dsh-powerup-restart-action:focus-visible,.dsh-powerup-update-default:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
752
801
  @media (max-width:680px){.dsh-powerup-page{padding:20px 16px 42px}.dsh-powerup-actions{grid-template-columns:minmax(0,1fr)}.dsh-powerup-hero-art{width:54px;height:54px}.dsh-powerup-hero h1{font-size:19px}.dsh-powerup-card-head{flex-wrap:wrap}.dsh-powerup-switch{margin-left:auto}.dsh-powerup-update-mask{padding:12px}.dsh-powerup-update-dialog{max-height:calc(100dvh - 24px)}.dsh-powerup-update-head{padding:18px 12px 10px 18px}.dsh-powerup-update-body{padding:0 18px 4px}.dsh-powerup-update-foot{padding:14px 18px 18px}}
753
802
  `;
754
803
 
@@ -1136,7 +1185,10 @@
1136
1185
  // Plugin update: entry button on the built-in Plugins page + dialog
1137
1186
  // ------------------------------------------------------------------
1138
1187
  const PLUGIN_REGISTRY_MEMORY_KEY = 'dsh.plugin-manager.install-registry';
1188
+ const POWERUP_REGISTRY_KEY = 'dsh-powerup:update-registry:v1';
1189
+ const PENDING_RESTART_KEY = 'dsh-powerup:pending-restart:v1';
1139
1190
  const UPDATE_API = '/dsh-powerup/api/restart';
1191
+ const SELF_UPDATE_API = '/dsh-powerup/api/self-update';
1140
1192
  const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
1141
1193
  const REGISTRY_HOSTS = new Map([
1142
1194
  ['registry.npmjs.org', 'update.registryDefault'],
@@ -1185,16 +1237,75 @@
1185
1237
  }
1186
1238
  }
1187
1239
 
1240
+ /**
1241
+ * Read the registry the user starred as Power-Up's list default.
1242
+ * @returns {{ key?: string, value?: string | null, url?: string, label?: string, hint?: string } | null}
1243
+ */
1244
+ function readDefaultRegistry() {
1245
+ try {
1246
+ const raw = localStorage.getItem(POWERUP_REGISTRY_KEY);
1247
+ if (typeof raw !== 'string' || raw === '') return null;
1248
+ const parsed = JSON.parse(raw);
1249
+ if (parsed === null || typeof parsed !== 'object') return null;
1250
+ return parsed;
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Persist the registry the update list should start from.
1258
+ * @param {{ key: string, value: string | null, url: string, label: string, hint: string }} choice
1259
+ */
1260
+ function writeDefaultRegistry(choice) {
1261
+ try {
1262
+ localStorage.setItem(POWERUP_REGISTRY_KEY, JSON.stringify({
1263
+ key: choice.key,
1264
+ value: choice.value,
1265
+ url: choice.url,
1266
+ label: choice.label,
1267
+ hint: choice.hint,
1268
+ }));
1269
+ } catch {
1270
+ /* A full or private store keeps the in-memory selection. */
1271
+ }
1272
+ }
1273
+
1274
+ /** Pending-restart records for this browser session (cleared by a real restart). */
1275
+ function readPendingRestarts() {
1276
+ try {
1277
+ const raw = sessionStorage.getItem(PENDING_RESTART_KEY);
1278
+ if (typeof raw !== 'string' || raw === '') return [];
1279
+ const parsed = JSON.parse(raw);
1280
+ return Array.isArray(parsed) ? parsed.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.name === 'string') : [];
1281
+ } catch {
1282
+ return [];
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * @param {Array<{ name: string, version?: string, reason?: string, at?: number }>} entries
1288
+ */
1289
+ function writePendingRestarts(entries) {
1290
+ try {
1291
+ sessionStorage.setItem(PENDING_RESTART_KEY, JSON.stringify(entries));
1292
+ } catch {
1293
+ /* The badge is a hint; failing to persist it must not break updates. */
1294
+ }
1295
+ }
1296
+
1188
1297
  /**
1189
1298
  * The registry choices the update dialog offers: the Host's configured
1190
1299
  * default (with the URL pnpm actually names), each configured fallback,
1191
- * and a remembered custom address.
1300
+ * the built-in Add dialog's remembered custom address, and Power-Up's
1301
+ * own starred default.
1192
1302
  * @param {{ registry: string | null, fallbackRegistries: string[], resolved: string | null } | null} registries
1193
1303
  * @param {object | null} remembered
1304
+ * @param {object | null} powerupDefault
1194
1305
  * @param {(key: string) => string} t
1195
1306
  * @returns {Array<{ key: string, label: string, hint: string, value: string | null, url: string }>}
1196
1307
  */
1197
- function buildRegistryChoices(registries, remembered, t) {
1308
+ function buildRegistryChoices(registries, remembered, powerupDefault, t) {
1198
1309
  const choices = [];
1199
1310
  const seen = new Set();
1200
1311
  const add = (choice) => {
@@ -1243,6 +1354,18 @@
1243
1354
  url: normalizeRegistry(rememberedOffered),
1244
1355
  });
1245
1356
  }
1357
+ if (powerupDefault !== null && typeof powerupDefault === 'object') {
1358
+ const value = powerupDefault.value === undefined ? null : powerupDefault.value;
1359
+ const url = powerupDefault.url ?? value ?? OFFICIAL_REGISTRY;
1360
+ add({
1361
+ label: typeof powerupDefault.label === 'string'
1362
+ ? powerupDefault.label
1363
+ : (value === null ? t('update.registryDefault') : t('update.registryCustom')),
1364
+ hint: typeof powerupDefault.hint === 'string' ? powerupDefault.hint : registryHost(url),
1365
+ value,
1366
+ url: normalizeRegistry(url),
1367
+ });
1368
+ }
1246
1369
  if (choices.length === 0) {
1247
1370
  add({
1248
1371
  label: t('update.registryDefault'),
@@ -1255,12 +1378,35 @@
1255
1378
  }
1256
1379
 
1257
1380
  /**
1258
- * Choose the offered registry matching what the Add dialog remembered.
1381
+ * Find the offered registry matching a persisted Power-Up default.
1382
+ * @param {Array<{ key: string, value: string | null, url: string }>} choices
1383
+ * @param {{ key?: string, value?: string | null, url?: string } | null} stored
1384
+ * @returns {object | undefined}
1385
+ */
1386
+ function matchStoredChoice(choices, stored) {
1387
+ if (stored === null || typeof stored !== 'object') return undefined;
1388
+ if (typeof stored.key === 'string') {
1389
+ const byKey = choices.find((choice) => choice.key === stored.key);
1390
+ if (byKey !== undefined) return byKey;
1391
+ }
1392
+ const value = stored.value === undefined ? null : stored.value;
1393
+ if (value === null) return choices.find((choice) => choice.value === null);
1394
+ const wanted = normalizeRegistry(value ?? stored.url ?? OFFICIAL_REGISTRY);
1395
+ return choices.find((choice) => choice.value !== null && normalizeRegistry(choice.value) === wanted)
1396
+ ?? choices.find((choice) => normalizeRegistry(choice.url) === wanted);
1397
+ }
1398
+
1399
+ /**
1400
+ * Choose the registry the update list starts from: Power-Up's starred
1401
+ * default, then what the Add dialog remembered, then the configured one.
1259
1402
  * @param {Array<{ key: string, value: string | null, url: string }>} choices
1260
1403
  * @param {object | null} remembered
1404
+ * @param {object | null} powerupDefault
1261
1405
  * @returns {object}
1262
1406
  */
1263
- function pickRegistryChoice(choices, remembered) {
1407
+ function pickRegistryChoice(choices, remembered, powerupDefault) {
1408
+ const starred = matchStoredChoice(choices, powerupDefault);
1409
+ if (starred !== undefined) return starred;
1264
1410
  if (remembered !== null && typeof remembered === 'object') {
1265
1411
  if (remembered.kind === 'offered') {
1266
1412
  const wanted = remembered.registry === null ? null : normalizeRegistry(remembered.registry);
@@ -1459,20 +1605,23 @@
1459
1605
 
1460
1606
  /** Tooltip/accessible label for one injected update button. */
1461
1607
  function updateButtonLabel(name, t) {
1462
- return name === MODULE_ID ? t('update.selfTitle') : `${t('update.button')} · ${name}`;
1608
+ return `${t('update.button')} · ${name}`;
1463
1609
  }
1464
1610
 
1465
1611
  /**
1466
1612
  * Ensure every third-party card in the built-in "Installed plugins"
1467
- * group carries one update button. Idempotent, so React re-renders and
1468
- * the observer can call it repeatedly.
1613
+ * group carries one update button and, when its update waits for a
1614
+ * restart, a small restart tag. Idempotent, so React re-renders and the
1615
+ * observer can call it repeatedly.
1469
1616
  * @param {Document} doc
1470
1617
  * @param {(name: string) => void} open
1618
+ * @param {string[]} pendingNames
1471
1619
  * @param {(key: string, params?: object) => string} t
1472
1620
  * @param {Set<Element>} buttons
1473
1621
  */
1474
- function ensureUpdateButtons(doc, open, t, buttons) {
1622
+ function ensureUpdateButtons(doc, open, pendingNames, t, buttons) {
1475
1623
  if (doc === null || doc === undefined || typeof doc.querySelectorAll !== 'function') return;
1624
+ const pending = new Set(Array.isArray(pendingNames) ? pendingNames : []);
1476
1625
  const cards = doc.querySelectorAll('[data-plugin-group="bundles"] [data-plugin-package]');
1477
1626
  for (const card of cards) {
1478
1627
  const name = card.getAttribute('data-plugin-package');
@@ -1498,10 +1647,105 @@
1498
1647
  const label = updateButtonLabel(name, t);
1499
1648
  if (button.title !== label) button.title = label;
1500
1649
  if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label);
1650
+
1651
+ let tag = card.querySelector('[data-dsh-powerup-restart-tag]');
1652
+ if (pending.has(name)) {
1653
+ if (tag === null || tag === undefined) {
1654
+ const head = card.firstElementChild;
1655
+ const main = head === null || head === undefined ? null : head.children?.[1];
1656
+ const titleRow = main === null || main === undefined ? null : main.firstElementChild;
1657
+ if (titleRow !== null && titleRow !== undefined) {
1658
+ tag = doc.createElement('span');
1659
+ tag.className = 'dsh-powerup-restart-tag';
1660
+ tag.setAttribute('data-dsh-powerup-restart-tag', '');
1661
+ titleRow.appendChild(tag);
1662
+ }
1663
+ }
1664
+ if (tag !== null && tag !== undefined) {
1665
+ const text = t('restart.tag');
1666
+ if (tag.textContent !== text) tag.textContent = text;
1667
+ if (tag.title !== t('restart.tagHint')) tag.title = t('restart.tagHint');
1668
+ }
1669
+ } else if (tag !== null && tag !== undefined) {
1670
+ tag.remove();
1671
+ }
1501
1672
  }
1502
1673
  for (const button of [...buttons]) if (button.isConnected !== true) buttons.delete(button);
1503
1674
  }
1504
1675
 
1676
+ /**
1677
+ * Put "N plugins need a restart" and a restart button beside the
1678
+ * Installed plugins heading. The group head owns the h3; the injected bar
1679
+ * sits at the end of that row and is removed when nothing is pending.
1680
+ * @param {Document} doc
1681
+ * @param {number} count
1682
+ * @param {() => void} onRestart
1683
+ * @param {(key: string, params?: object) => string} t
1684
+ */
1685
+ function ensureRestartBar(doc, count, onRestart, t) {
1686
+ if (doc === null || doc === undefined || typeof doc.querySelector !== 'function') return;
1687
+ const group = doc.querySelector('[data-plugin-group="bundles"]');
1688
+ if (group === null || group === undefined || count <= 0) {
1689
+ const stale = group?.querySelector?.('[data-dsh-powerup-restart-bar]');
1690
+ stale?.remove?.();
1691
+ return;
1692
+ }
1693
+ const head = group.firstElementChild;
1694
+ if (head === null || head === undefined) return;
1695
+ let bar = group.querySelector('[data-dsh-powerup-restart-bar]');
1696
+ if (bar === null || bar === undefined) {
1697
+ bar = doc.createElement('div');
1698
+ bar.className = 'dsh-powerup-restart-bar';
1699
+ bar.setAttribute('data-dsh-powerup-restart-bar', '');
1700
+ const label = doc.createElement('span');
1701
+ label.className = 'dsh-powerup-restart-count';
1702
+ const button = doc.createElement('button');
1703
+ button.type = 'button';
1704
+ button.className = 'dsh-powerup-restart-action';
1705
+ button.addEventListener('click', (event) => {
1706
+ event.preventDefault();
1707
+ event.stopPropagation();
1708
+ onRestart();
1709
+ });
1710
+ bar.appendChild(label);
1711
+ bar.appendChild(button);
1712
+ head.appendChild(bar);
1713
+ }
1714
+ const label = bar.firstElementChild;
1715
+ const button = bar.lastElementChild;
1716
+ const text = t('restart.pending', { count });
1717
+ if (label !== null && label !== undefined && label.textContent !== text) label.textContent = text;
1718
+ const action = t('restart.action');
1719
+ if (button !== null && button !== undefined && button.textContent !== action) button.textContent = action;
1720
+ }
1721
+
1722
+ /**
1723
+ * Place a floating panel under an anchor, flipping above when the space
1724
+ * below is short, and clamp it to the viewport.
1725
+ * @param {{ getBoundingClientRect: () => DOMRect }} anchor
1726
+ * @param {number} minWidth
1727
+ * @param {number} preferredHeight
1728
+ * @returns {{ left: number, width: number, top?: number, bottom?: number, maxHeight: number }}
1729
+ */
1730
+ function popoverPlacement(anchor, minWidth, preferredHeight) {
1731
+ const rect = anchor.getBoundingClientRect();
1732
+ const gap = 6;
1733
+ const margin = 12;
1734
+ const viewportWidth = typeof window === 'undefined' ? 1280 : window.innerWidth;
1735
+ const viewportHeight = typeof window === 'undefined' ? 900 : window.innerHeight;
1736
+ const width = Math.min(Math.max(rect.width, minWidth), Math.max(200, viewportWidth - margin * 2));
1737
+ const left = Math.min(Math.max(margin, rect.left), Math.max(margin, viewportWidth - width - margin));
1738
+ const spaceBelow = viewportHeight - rect.bottom - margin - gap;
1739
+ const spaceAbove = rect.top - margin - gap;
1740
+ const above = spaceBelow < Math.min(preferredHeight, 180) && spaceAbove > spaceBelow;
1741
+ return {
1742
+ left,
1743
+ width,
1744
+ ...(above ? { bottom: viewportHeight - rect.top + gap } : { top: rect.bottom + gap }),
1745
+ maxHeight: Math.max(120, Math.min(preferredHeight, above ? spaceAbove : spaceBelow)),
1746
+ };
1747
+ }
1748
+
1505
1749
  /**
1506
1750
  * The Plugins-page update control. It injects one icon button into each
1507
1751
  * third-party card of the built-in "Installed plugins" group, then owns
@@ -1512,12 +1756,21 @@
1512
1756
  function PluginUpdateOverlay({ manager, remote, ctx }) {
1513
1757
  const [view, setView] = useState(null);
1514
1758
  const [localeTick, forceLocale] = useState(0);
1759
+ const [pending, setPending] = useState(() => readPendingRestarts());
1760
+ const [restartAsk, setRestartAsk] = useState(false);
1761
+ const [restartCapability, setRestartCapability] = useState(null);
1762
+ const [restartBusy, setRestartBusy] = useState(false);
1763
+ const [restartError, setRestartError] = useState(null);
1515
1764
  const tokenRef = useRef(null);
1516
1765
  const abortRef = useRef(null);
1517
1766
  const buttonsRef = useRef(new Set());
1518
1767
  const inputRef = useRef(null);
1768
+ const versionRef = useRef(null);
1769
+ const versionPanelRef = useRef(null);
1519
1770
  const registryRef = useRef(null);
1771
+ const registryPanelRef = useRef(null);
1520
1772
  const versionSeq = useRef(0);
1773
+ const openRestartRef = useRef(() => {});
1521
1774
  const tRef = useRef((key, params) => translateCurrent(key, params));
1522
1775
  tRef.current = (key, params) => translateCurrent(key, params);
1523
1776
 
@@ -1537,7 +1790,7 @@
1537
1790
 
1538
1791
  const close = useCallback(() => {
1539
1792
  const current = view;
1540
- if (current !== null && (current.phase === 'working' || current.phase === 'restarting' || current.enabling === true)) return;
1793
+ if (current !== null && (current.phase === 'working' || current.phase === 'self-updating' || current.phase === 'restarting' || current.enabling === true)) return;
1541
1794
  tokenRef.current = null;
1542
1795
  try {
1543
1796
  abortRef.current?.abort?.();
@@ -1548,6 +1801,36 @@
1548
1801
  setView(null);
1549
1802
  }, [view]);
1550
1803
 
1804
+ /** Dismiss both floating panels without touching the dialog. */
1805
+ const closePopovers = useCallback(() => {
1806
+ setView((current) => current === null ? current : {
1807
+ ...current,
1808
+ listOpen: false,
1809
+ listPopover: null,
1810
+ registryOpen: false,
1811
+ registryPopover: null,
1812
+ });
1813
+ }, []);
1814
+
1815
+ /** Remember that one updated plugin waits for a whole-app restart. */
1816
+ const recordPending = useCallback((entry) => {
1817
+ setPending((current) => {
1818
+ const next = current.filter((item) => item.name !== entry.name);
1819
+ next.push({ ...entry, at: Date.now() });
1820
+ writePendingRestarts(next);
1821
+ return next;
1822
+ });
1823
+ }, []);
1824
+
1825
+ /** Drop a pending marker when its update never started. */
1826
+ const removePending = useCallback((name) => {
1827
+ setPending((current) => {
1828
+ const next = current.filter((item) => item.name !== name);
1829
+ writePendingRestarts(next);
1830
+ return next;
1831
+ });
1832
+ }, []);
1833
+
1551
1834
  const loadVersions = useCallback(async (token, name, choice, choices, resetVersion) => {
1552
1835
  const t = tRef.current;
1553
1836
  const seq = versionSeq.current + 1;
@@ -1596,28 +1879,28 @@
1596
1879
  setView({
1597
1880
  name,
1598
1881
  title: name,
1882
+ self: name === MODULE_ID,
1599
1883
  currentVersion: null,
1600
1884
  phase: 'loading',
1601
1885
  step: null,
1602
1886
  choices: [],
1603
1887
  registryKey: '',
1888
+ defaultRegistryKey: null,
1604
1889
  versions: [],
1605
1890
  versionsState: 'loading',
1606
1891
  versionsError: null,
1607
1892
  versionsRegistry: null,
1608
1893
  version: '',
1609
- listOpen: true,
1894
+ listOpen: false,
1895
+ listPopover: null,
1610
1896
  registryOpen: false,
1897
+ registryPopover: null,
1611
1898
  error: null,
1612
1899
  formError: null,
1613
1900
  log: '',
1614
1901
  enabling: false,
1615
1902
  restarting: false,
1616
1903
  });
1617
- if (name === MODULE_ID) {
1618
- patch(token, { phase: 'self' });
1619
- return;
1620
- }
1621
1904
  try {
1622
1905
  const [bundles, registries] = await Promise.all([manager.listBundles(), manager.registries()]);
1623
1906
  if (tokenRef.current !== token) return;
@@ -1634,8 +1917,10 @@
1634
1917
  return;
1635
1918
  }
1636
1919
  const remembered = readRememberedRegistry();
1637
- const choices = buildRegistryChoices(registries.ok ? registries.value : null, remembered, t);
1638
- const choice = pickRegistryChoice(choices, remembered);
1920
+ const powerupDefault = readDefaultRegistry();
1921
+ const choices = buildRegistryChoices(registries.ok ? registries.value : null, remembered, powerupDefault, t);
1922
+ const choice = pickRegistryChoice(choices, remembered, powerupDefault);
1923
+ const starred = matchStoredChoice(choices, powerupDefault);
1639
1924
  patch(token, {
1640
1925
  title: packageDisplayTitle(pkg),
1641
1926
  currentVersion: pkg.version ?? null,
@@ -1643,6 +1928,7 @@
1643
1928
  phase: 'choose',
1644
1929
  choices,
1645
1930
  registryKey: choice.key,
1931
+ defaultRegistryKey: starred === undefined ? null : starred.key,
1646
1932
  versionsState: 'loading',
1647
1933
  });
1648
1934
  await loadVersions(token, name, choice, choices, false);
@@ -1664,7 +1950,9 @@
1664
1950
  const ensure = () => {
1665
1951
  scheduled = false;
1666
1952
  if (stopped) return;
1667
- ensureUpdateButtons(document, (name) => openRef.current(name), tRef.current, buttons);
1953
+ const names = pending.map((entry) => entry.name);
1954
+ ensureUpdateButtons(document, (name) => openRef.current(name), names, tRef.current, buttons);
1955
+ ensureRestartBar(document, pending.length, () => openRestartRef.current(), tRef.current);
1668
1956
  };
1669
1957
  const schedule = () => {
1670
1958
  if (scheduled || stopped) return;
@@ -1687,7 +1975,7 @@
1687
1975
  }
1688
1976
  buttons.clear();
1689
1977
  };
1690
- }, [localeTick]);
1978
+ }, [localeTick, pending]);
1691
1979
 
1692
1980
  // Keep the selected version input focused when the chooser opens.
1693
1981
  useEffect(() => {
@@ -1700,19 +1988,18 @@
1700
1988
  }
1701
1989
  }, [view === null ? null : view.phase]);
1702
1990
 
1703
- // Escape closes the innermost popover first: registry menu, then the
1704
- // version list, then the dialog itself.
1991
+ // Escape closes the innermost floating panel first, then the dialog.
1705
1992
  useEffect(() => {
1706
1993
  if (view === null) return undefined;
1707
1994
  const onKeyDown = (event) => {
1708
1995
  if (event.key !== 'Escape') return;
1709
1996
  event.stopPropagation();
1710
1997
  if (view.registryOpen === true) {
1711
- setView((current) => current === null ? current : { ...current, registryOpen: false });
1998
+ setView((current) => current === null ? current : { ...current, registryOpen: false, registryPopover: null });
1712
1999
  return;
1713
2000
  }
1714
2001
  if (view.phase === 'choose' && view.listOpen === true) {
1715
- setView((current) => current === null ? current : { ...current, listOpen: false });
2002
+ setView((current) => current === null ? current : { ...current, listOpen: false, listPopover: null });
1716
2003
  return;
1717
2004
  }
1718
2005
  close();
@@ -1721,17 +2008,42 @@
1721
2008
  return () => document.removeEventListener('keydown', onKeyDown, true);
1722
2009
  }, [view, close]);
1723
2010
 
1724
- // A click outside the custom registry select closes only its menu.
2011
+ // Clicking outside the open floating panel and its trigger dismisses it.
1725
2012
  useEffect(() => {
1726
- if (view === null || view.registryOpen !== true) return undefined;
2013
+ if (view === null || (view.registryOpen !== true && view.listOpen !== true)) return undefined;
1727
2014
  const onPointerDown = (event) => {
1728
- const root = registryRef.current;
1729
- if (root !== null && event.target instanceof Node && root.contains(event.target)) return;
1730
- setView((current) => current === null ? current : { ...current, registryOpen: false });
2015
+ const target = event.target;
2016
+ if (!(target instanceof Node)) return;
2017
+ const inside = [versionRef.current, versionPanelRef.current, registryRef.current, registryPanelRef.current]
2018
+ .some((root) => root !== null && root.contains(target));
2019
+ if (inside) return;
2020
+ closePopovers();
1731
2021
  };
1732
2022
  document.addEventListener('pointerdown', onPointerDown, true);
1733
2023
  return () => document.removeEventListener('pointerdown', onPointerDown, true);
1734
- }, [view === null ? null : view.registryOpen]);
2024
+ }, [view === null ? null : `${String(view.registryOpen)}:${String(view.listOpen)}`, closePopovers]);
2025
+
2026
+ // Scrolling the dialog or the page would leave a fixed panel behind;
2027
+ // dismiss it instead. Scrolling inside the panel itself is allowed.
2028
+ useEffect(() => {
2029
+ if (view === null || (view.registryOpen !== true && view.listOpen !== true)) return undefined;
2030
+ const onScroll = (event) => {
2031
+ const target = event.target;
2032
+ if (target instanceof Node) {
2033
+ const insidePanel = [versionPanelRef.current, registryPanelRef.current]
2034
+ .some((panel) => panel !== null && panel.contains(target));
2035
+ if (insidePanel) return;
2036
+ }
2037
+ closePopovers();
2038
+ };
2039
+ const onResize = () => closePopovers();
2040
+ document.addEventListener('scroll', onScroll, true);
2041
+ window.addEventListener('resize', onResize);
2042
+ return () => {
2043
+ document.removeEventListener('scroll', onScroll, true);
2044
+ window.removeEventListener('resize', onResize);
2045
+ };
2046
+ }, [view === null ? null : `${String(view.registryOpen)}:${String(view.listOpen)}`, closePopovers]);
1735
2047
 
1736
2048
  // Show pnpm output while an installation runs.
1737
2049
  useEffect(() => {
@@ -1754,9 +2066,22 @@
1754
2066
  };
1755
2067
  }, [remote]);
1756
2068
 
2069
+ // Escape dismisses the list-header restart confirmation unless a
2070
+ // restart request is already in flight.
2071
+ useEffect(() => {
2072
+ if (!restartAsk) return undefined;
2073
+ const onKeyDown = (event) => {
2074
+ if (event.key !== 'Escape' || restartBusy) return;
2075
+ event.stopPropagation();
2076
+ setRestartAsk(false);
2077
+ };
2078
+ document.addEventListener('keydown', onKeyDown, true);
2079
+ return () => document.removeEventListener('keydown', onKeyDown, true);
2080
+ }, [restartAsk, restartBusy]);
2081
+
1757
2082
  const chooseVersion = (version) => {
1758
2083
  if (tokenRef.current === null) return;
1759
- setView((current) => current === null ? current : { ...current, version, listOpen: false, formError: null });
2084
+ setView((current) => current === null ? current : { ...current, version, listOpen: false, listPopover: null, formError: null });
1760
2085
  };
1761
2086
 
1762
2087
  const chooseRegistry = (key) => {
@@ -1766,10 +2091,61 @@
1766
2091
  if (current === null) return;
1767
2092
  const choice = current.choices.find((item) => item.key === key);
1768
2093
  if (choice === undefined) return;
1769
- setView((state) => state === null ? state : { ...state, registryKey: key, registryOpen: false, formError: null });
2094
+ setView((state) => state === null ? state : { ...state, registryKey: key, registryOpen: false, registryPopover: null, formError: null });
1770
2095
  loadVersions(token, current.name, choice, current.choices, true);
1771
2096
  };
1772
2097
 
2098
+ /** Star one registry as Power-Up's default and select it for this update. */
2099
+ const setDefaultRegistry = (choice) => {
2100
+ if (choice === null || choice === undefined) return;
2101
+ const current = view;
2102
+ writeDefaultRegistry(choice);
2103
+ setView((state) => state === null ? state : {
2104
+ ...state,
2105
+ defaultRegistryKey: choice.key,
2106
+ registryKey: choice.key,
2107
+ });
2108
+ if (current !== null) loadVersions(tokenRef.current, current.name, choice, current.choices, true);
2109
+ };
2110
+
2111
+ /** Open or close the floating version list under the input row. */
2112
+ const toggleVersionList = () => {
2113
+ const current = view;
2114
+ if (current === null || current.phase !== 'choose') return;
2115
+ if (current.listOpen === true) {
2116
+ setView((state) => state === null ? state : { ...state, listOpen: false, listPopover: null });
2117
+ return;
2118
+ }
2119
+ const anchor = versionRef.current;
2120
+ const popover = anchor === null ? null : popoverPlacement(anchor, 280, 260);
2121
+ setView((state) => state === null ? state : {
2122
+ ...state,
2123
+ listOpen: true,
2124
+ listPopover: popover,
2125
+ registryOpen: false,
2126
+ registryPopover: null,
2127
+ });
2128
+ };
2129
+
2130
+ /** Open or close the floating registry menu under its trigger. */
2131
+ const toggleRegistry = () => {
2132
+ const current = view;
2133
+ if (current === null || current.phase !== 'choose') return;
2134
+ if (current.registryOpen === true) {
2135
+ setView((state) => state === null ? state : { ...state, registryOpen: false, registryPopover: null });
2136
+ return;
2137
+ }
2138
+ const anchor = registryRef.current;
2139
+ const popover = anchor === null ? null : popoverPlacement(anchor, 280, 220);
2140
+ setView((state) => state === null ? state : {
2141
+ ...state,
2142
+ registryOpen: true,
2143
+ registryPopover: popover,
2144
+ listOpen: false,
2145
+ listPopover: null,
2146
+ });
2147
+ };
2148
+
1773
2149
  const startUpdate = async () => {
1774
2150
  const token = tokenRef.current;
1775
2151
  const current = view;
@@ -1784,7 +2160,29 @@
1784
2160
  const registry = registryValue(choice);
1785
2161
  const spec = `${current.name}@${version}`;
1786
2162
  const requestId = newRequestId();
1787
- patch(token, { phase: 'working', step: 'removing', error: null, formError: null, log: '', logLabel: spec });
2163
+ patch(token, { phase: 'working', step: current.self === true ? 'installing' : 'removing', error: null, formError: null, log: '', logLabel: spec });
2164
+ if (current.self === true) {
2165
+ // Power-Up cannot reinstall itself from its own client: removing
2166
+ // the bundle unloads this dialog. The Host route receives the
2167
+ // request, answers, then hands remove+inspect+install to the
2168
+ // built-in manager service, which keeps running in the Host
2169
+ // process after this plugin unloads.
2170
+ recordPending({ name: MODULE_ID, version, reason: 'self-update' });
2171
+ try {
2172
+ const response = await fetch(SELF_UPDATE_API, {
2173
+ method: 'POST',
2174
+ headers: { 'content-type': 'application/json' },
2175
+ body: JSON.stringify({ version, registry }),
2176
+ });
2177
+ const data = await response.json().catch(() => null);
2178
+ if (!response.ok || data?.ok !== true) throw new Error(data?.error ?? `HTTP ${response.status}`);
2179
+ patch(token, { phase: 'self-updating', step: null });
2180
+ } catch (error) {
2181
+ removePending(MODULE_ID);
2182
+ patch(token, { phase: 'error', step: null, error: errorText(error) || t('update.unknown') });
2183
+ }
2184
+ return;
2185
+ }
1788
2186
  try {
1789
2187
  // The update flow removes the old version first: the Host's
1790
2188
  // inspection refuses a package that is still installed, so the
@@ -1807,11 +2205,19 @@
1807
2205
  const value = installed.value;
1808
2206
  if (value.error !== undefined || value.application === 'failed') throw new Error(operationReason(value, t));
1809
2207
  if (value.application === 'cancelled') throw new Error(t('update.cancelled'));
2208
+ const restartRequired = value.application === 'restart-required';
2209
+ if (restartRequired) {
2210
+ recordPending({
2211
+ name: current.name,
2212
+ version: value.version ?? version,
2213
+ reason: value.restartReason ?? '',
2214
+ });
2215
+ }
1810
2216
  patch(token, {
1811
2217
  phase: 'enable',
1812
2218
  step: null,
1813
2219
  installedVersion: value.version ?? version,
1814
- restartRequired: value.application === 'restart-required',
2220
+ restartRequired,
1815
2221
  enabling: false,
1816
2222
  });
1817
2223
  } catch (error) {
@@ -1833,6 +2239,35 @@
1833
2239
  }
1834
2240
  };
1835
2241
 
2242
+ /** Open the list-header restart confirmation and read the capability. */
2243
+ const openRestart = () => {
2244
+ setRestartAsk(true);
2245
+ setRestartCapability(null);
2246
+ setRestartError(null);
2247
+ setRestartBusy(false);
2248
+ void readRestartCapability().then((capability) => setRestartCapability(capability));
2249
+ };
2250
+ openRestartRef.current = openRestart;
2251
+
2252
+ /** Confirm the list-header restart. */
2253
+ const confirmRestart = async () => {
2254
+ if (restartBusy) return;
2255
+ setRestartBusy(true);
2256
+ setRestartError(null);
2257
+ try {
2258
+ const response = await fetch(UPDATE_API, {
2259
+ method: 'POST',
2260
+ headers: { 'content-type': 'application/json' },
2261
+ body: JSON.stringify({ action: 'restart' }),
2262
+ });
2263
+ const data = await response.json().catch(() => null);
2264
+ if (!response.ok || data?.ok !== true) throw new Error(data?.error ?? `HTTP ${response.status}`);
2265
+ } catch (error) {
2266
+ setRestartBusy(false);
2267
+ setRestartError(errorText(error) || tRef.current('update.unknown'));
2268
+ }
2269
+ };
2270
+
1836
2271
  const enableNow = async () => {
1837
2272
  const token = tokenRef.current;
1838
2273
  const current = view;
@@ -1844,6 +2279,13 @@
1844
2279
  if (tokenRef.current !== token) return;
1845
2280
  if (!result.ok) throw new Error(result.error?.message ?? t('update.unknown'));
1846
2281
  if (result.value.error !== undefined) throw new Error(operationReason(result.value, t));
2282
+ if (result.value.application === 'restart-required' || current.restartRequired === true) {
2283
+ recordPending({
2284
+ name: current.name,
2285
+ version: current.installedVersion ?? '',
2286
+ reason: result.value.restartReason ?? '',
2287
+ });
2288
+ }
1847
2289
  const restart = await readRestartCapability();
1848
2290
  patch(token, { phase: 'restart', enabling: false, restart });
1849
2291
  } catch (error) {
@@ -1882,9 +2324,64 @@
1882
2324
  }
1883
2325
  };
1884
2326
 
1885
- if (view === null) return null;
1886
2327
  const t = (key, params) => translateCurrent(key, params);
1887
- const busy = view.phase === 'working' || view.phase === 'restarting' || view.enabling === true;
2328
+
2329
+ /** One tinted notice: the success/warn/error states share this chrome. */
2330
+ const notice = (tone, icon, title, message) => h('div', { className: `dsh-powerup-update-notice ${tone}` },
2331
+ icon,
2332
+ h('div', { className: 'dsh-powerup-update-notice-copy' },
2333
+ title === null || title === undefined ? null : h('span', { className: 'dsh-powerup-update-notice-title' }, title),
2334
+ message === null || message === undefined ? null : h('span', null, message),
2335
+ ),
2336
+ );
2337
+
2338
+ const restartPrompt = restartAsk ? h('div', {
2339
+ className: 'dsh-powerup-update-mask',
2340
+ onMouseDown: (event) => {
2341
+ if (event.target === event.currentTarget && !restartBusy) setRestartAsk(false);
2342
+ },
2343
+ },
2344
+ h('div', {
2345
+ className: 'dsh-powerup-update-dialog',
2346
+ style: { width: 'min(420px,100%)' },
2347
+ role: 'dialog',
2348
+ 'aria-modal': 'true',
2349
+ 'aria-labelledby': 'dsh-powerup-restart-title',
2350
+ },
2351
+ h('div', { className: 'dsh-powerup-update-head' },
2352
+ h('div', { className: 'dsh-powerup-update-heading' },
2353
+ h('h2', { id: 'dsh-powerup-restart-title' }, t('restart.title')),
2354
+ h('p', null, t('restart.pending', { count: pending.length })),
2355
+ ),
2356
+ restartBusy ? null : h('button', { type: 'button', className: 'dsh-powerup-update-close', 'aria-label': t('update.close'), onClick: () => setRestartAsk(false) }, '×'),
2357
+ ),
2358
+ h('div', { className: 'dsh-powerup-update-body' },
2359
+ h('p', { className: 'dsh-powerup-update-note' }, t('restart.desc')),
2360
+ restartCapability !== null && restartCapability.supported !== true
2361
+ ? notice('warn', h(WarnIcon, { size: 14 }), null, t('update.restartUnsupported', { reason: restartCapability.reason ?? '' }))
2362
+ : null,
2363
+ restartError === null ? null : notice('error', h(WarnIcon, { size: 14 }), null, restartError),
2364
+ restartBusy
2365
+ ? h('div', { className: 'dsh-powerup-update-status' },
2366
+ h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
2367
+ t('update.restarting'),
2368
+ )
2369
+ : null,
2370
+ ),
2371
+ h('div', { className: 'dsh-powerup-update-foot' },
2372
+ h('button', { type: 'button', className: 'dsh-powerup-update-ghost', disabled: restartBusy, onClick: () => setRestartAsk(false) }, t('restart.cancel')),
2373
+ h('button', {
2374
+ type: 'button',
2375
+ className: 'dsh-powerup-update-primary',
2376
+ disabled: restartBusy || restartCapability?.supported !== true,
2377
+ onClick: confirmRestart,
2378
+ }, restartBusy ? t('update.restarting') : t('restart.confirm')),
2379
+ ),
2380
+ ),
2381
+ ) : null;
2382
+
2383
+ if (view === null) return restartPrompt;
2384
+ const busy = view.phase === 'working' || view.phase === 'self-updating' || view.phase === 'restarting' || view.enabling === true;
1888
2385
  const canClose = !busy;
1889
2386
  const title = view.title === undefined || view.title === '' ? view.name : view.title;
1890
2387
  const showVersions = view.phase === 'choose' && view.listOpen === true;
@@ -1915,23 +2412,69 @@
1915
2412
  supported ? h('button', { key: 'now', type: 'button', className: 'dsh-powerup-update-primary', disabled: view.restarting === true, onClick: restartNow }, view.restarting === true ? t('update.restarting') : t('update.restartNow')) : null,
1916
2413
  ].filter(Boolean);
1917
2414
  }
1918
- if (view.phase === 'restarting' || view.phase === 'working' || view.phase === 'loading') return [];
2415
+ if (view.phase === 'restarting' || view.phase === 'working' || view.phase === 'self-updating' || view.phase === 'loading') return [];
1919
2416
  return [
1920
2417
  h('button', { key: 'done', type: 'button', className: 'dsh-powerup-update-primary', onClick: close }, view.phase === 'done' ? t('update.doneButton') : t('update.close')),
1921
2418
  ];
1922
2419
  })();
1923
2420
 
1924
- /** One tinted notice: the success/warn/error states share this chrome. */
1925
- const notice = (tone, icon, title, message) => h('div', { className: `dsh-powerup-update-notice ${tone}` },
1926
- icon,
1927
- h('div', { className: 'dsh-powerup-update-notice-copy' },
1928
- title === null || title === undefined ? null : h('span', { className: 'dsh-powerup-update-notice-title' }, title),
1929
- message === null || message === undefined ? null : h('span', null, message),
1930
- ),
1931
- );
1932
-
1933
2421
  const currentChoice = view.choices.find((item) => item.key === view.registryKey) ?? view.choices[0];
1934
2422
 
2423
+ /** Rows of the floating version list, including its loading/error states. */
2424
+ const versionRows = (() => {
2425
+ if (view.versionsState === 'loading') {
2426
+ return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
2427
+ h('div', { className: 'dsh-powerup-update-status' },
2428
+ h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
2429
+ t('update.loadingVersions'),
2430
+ ),
2431
+ )];
2432
+ }
2433
+ if (view.versionsState === 'error') {
2434
+ return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
2435
+ notice('error', h(WarnIcon, { size: 14 }), null, t('update.versionsFailed', { message: view.versionsError ?? '' })),
2436
+ h('button', {
2437
+ type: 'button',
2438
+ className: 'dsh-powerup-update-ghost',
2439
+ onClick: () => loadVersions(tokenRef.current, view.name, currentChoice, view.choices, false),
2440
+ }, t('update.retry')),
2441
+ )];
2442
+ }
2443
+ if ((view.versions ?? []).length === 0) {
2444
+ return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
2445
+ h('p', { className: 'dsh-powerup-update-note' }, t('update.versionsEmpty')),
2446
+ )];
2447
+ }
2448
+ return [
2449
+ h('div', { key: 'head', className: 'dsh-powerup-update-list-head' },
2450
+ h('span', null, t('update.versionsCount', { count: view.versions.length })),
2451
+ view.versionsRegistry === null || view.versionsRegistry === undefined
2452
+ ? null
2453
+ : h('span', { title: view.versionsRegistry.hint }, view.versionsRegistry.hint),
2454
+ ),
2455
+ ...view.versions.map((version) => {
2456
+ const tagKind = versionTagKind(version, view.currentVersion, view.latest);
2457
+ return h('button', {
2458
+ key: version,
2459
+ type: 'button',
2460
+ role: 'option',
2461
+ className: 'dsh-powerup-update-version',
2462
+ 'aria-selected': version === view.version ? 'true' : 'false',
2463
+ 'data-current': version === view.currentVersion ? '' : undefined,
2464
+ onClick: () => chooseVersion(version),
2465
+ },
2466
+ h('span', null, version),
2467
+ h('span', { className: 'dsh-powerup-update-trailing' },
2468
+ tagKind === null ? null : h('span', {
2469
+ className: `dsh-powerup-update-tag${tagKind === 'latest' ? '' : ' is-current'}`,
2470
+ }, t(tagKind === 'current-latest' ? 'update.currentAndLatest' : tagKind === 'current' ? 'update.currentTag' : 'update.latest')),
2471
+ version === view.version ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
2472
+ ),
2473
+ );
2474
+ }),
2475
+ ];
2476
+ })();
2477
+
1935
2478
  const body = (() => {
1936
2479
  if (view.phase === 'loading') {
1937
2480
  return h('div', { className: 'dsh-powerup-update-status' },
@@ -1939,66 +2482,20 @@
1939
2482
  t('update.loading'),
1940
2483
  );
1941
2484
  }
1942
- if (view.phase === 'self') {
1943
- return notice('warn', h(WarnIcon, { size: 15 }), t('update.selfTitle'), t('update.selfDesc'));
2485
+ if (view.phase === 'self-updating') {
2486
+ return [
2487
+ h('div', { key: 'status', className: 'dsh-powerup-update-status' },
2488
+ h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
2489
+ t('update.selfUpdating'),
2490
+ ),
2491
+ h('p', { key: 'note', className: 'dsh-powerup-update-note' }, t('update.selfRefresh')),
2492
+ h('span', { key: 'name', className: 'dsh-powerup-update-name' }, view.name),
2493
+ ];
1944
2494
  }
1945
2495
  if (view.phase === 'blocked') {
1946
2496
  return notice('error', h(WarnIcon, { size: 15 }), t('update.unsupportedTitle'), view.error ?? t('update.notRemovable'));
1947
2497
  }
1948
2498
  if (view.phase === 'choose') {
1949
- const rows = (() => {
1950
- if (view.versionsState === 'loading') {
1951
- return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
1952
- h('div', { className: 'dsh-powerup-update-status' },
1953
- h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
1954
- t('update.loadingVersions'),
1955
- ),
1956
- )];
1957
- }
1958
- if (view.versionsState === 'error') {
1959
- return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
1960
- notice('error', h(WarnIcon, { size: 14 }), null, t('update.versionsFailed', { message: view.versionsError ?? '' })),
1961
- h('button', {
1962
- type: 'button',
1963
- className: 'dsh-powerup-update-ghost',
1964
- onClick: () => loadVersions(tokenRef.current, view.name, currentChoice, view.choices, false),
1965
- }, t('update.retry')),
1966
- )];
1967
- }
1968
- if ((view.versions ?? []).length === 0) {
1969
- return [h('div', { key: 'state', className: 'dsh-powerup-update-list-state' },
1970
- h('p', { className: 'dsh-powerup-update-note' }, t('update.versionsEmpty')),
1971
- )];
1972
- }
1973
- return [
1974
- h('div', { key: 'head', className: 'dsh-powerup-update-list-head' },
1975
- h('span', null, t('update.versionsCount', { count: view.versions.length })),
1976
- view.versionsRegistry === null || view.versionsRegistry === undefined
1977
- ? null
1978
- : h('span', { title: view.versionsRegistry.hint }, view.versionsRegistry.hint),
1979
- ),
1980
- ...view.versions.map((version) => {
1981
- const tagKind = versionTagKind(version, view.currentVersion, view.latest);
1982
- return h('button', {
1983
- key: version,
1984
- type: 'button',
1985
- role: 'option',
1986
- className: 'dsh-powerup-update-version',
1987
- 'aria-selected': version === view.version ? 'true' : 'false',
1988
- 'data-current': version === view.currentVersion ? '' : undefined,
1989
- onClick: () => chooseVersion(version),
1990
- },
1991
- h('span', null, version),
1992
- h('span', { className: 'dsh-powerup-update-trailing' },
1993
- tagKind === null ? null : h('span', {
1994
- className: `dsh-powerup-update-tag${tagKind === 'latest' ? '' : ' is-current'}`,
1995
- }, t(tagKind === 'current-latest' ? 'update.currentAndLatest' : tagKind === 'current' ? 'update.currentTag' : 'update.latest')),
1996
- version === view.version ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
1997
- ),
1998
- );
1999
- }),
2000
- ];
2001
- })();
2002
2499
  return [
2003
2500
  h('div', { key: 'summary', className: 'dsh-powerup-update-summary' },
2004
2501
  h('div', { className: 'dsh-powerup-update-now' },
@@ -2008,9 +2505,12 @@
2008
2505
  ),
2009
2506
  h('span', { className: 'dsh-powerup-update-name' }, view.name),
2010
2507
  ),
2508
+ view.self === true
2509
+ ? notice('warn', h(WarnIcon, { size: 14 }), null, t('update.selfUpdateWarning'))
2510
+ : null,
2011
2511
  h('div', { key: 'version', className: 'dsh-powerup-update-field' },
2012
2512
  h('label', { className: 'dsh-powerup-update-label', htmlFor: 'dsh-powerup-update-version' }, t('update.versionLabel')),
2013
- h('div', { className: 'dsh-powerup-update-row' },
2513
+ h('div', { className: 'dsh-powerup-update-row', ref: versionRef },
2014
2514
  h('input', {
2015
2515
  id: 'dsh-powerup-update-version',
2016
2516
  ref: inputRef,
@@ -2030,7 +2530,7 @@
2030
2530
  className: 'dsh-powerup-update-toggle',
2031
2531
  'aria-expanded': showVersions ? 'true' : 'false',
2032
2532
  'aria-controls': 'dsh-powerup-update-versions',
2033
- onClick: () => setView((current) => current === null ? current : { ...current, listOpen: !(current.listOpen === true) }),
2533
+ onClick: toggleVersionList,
2034
2534
  },
2035
2535
  showVersions ? t('update.hideVersions') : t('update.chooseVersion'),
2036
2536
  h('span', { className: 'chevron' }, h(ChevronDownIcon, { size: 12 })),
@@ -2040,7 +2540,6 @@
2040
2540
  ? null
2041
2541
  : notice('error', h(WarnIcon, { size: 14 }), null, view.formError),
2042
2542
  ),
2043
- showVersions ? h('div', { key: 'versions', id: 'dsh-powerup-update-versions', className: 'dsh-powerup-update-list', role: 'listbox', 'aria-label': t('update.versionLabel') }, rows) : null,
2044
2543
  h('div', { key: 'registry', className: 'dsh-powerup-update-field' },
2045
2544
  h('span', { className: 'dsh-powerup-update-label' }, t('update.registry')),
2046
2545
  h('div', { className: 'dsh-powerup-update-select', ref: registryRef },
@@ -2049,27 +2548,12 @@
2049
2548
  className: 'dsh-powerup-update-select-trigger',
2050
2549
  'aria-haspopup': 'listbox',
2051
2550
  'aria-expanded': view.registryOpen === true ? 'true' : 'false',
2052
- onClick: () => setView((current) => current === null ? current : { ...current, registryOpen: !(current.registryOpen === true) }),
2551
+ onClick: toggleRegistry,
2053
2552
  },
2054
2553
  h('span', { className: 'dsh-powerup-update-select-value' }, currentChoice?.label ?? ''),
2055
2554
  h('span', { className: 'dsh-powerup-update-select-host' }, currentChoice?.hint ?? ''),
2056
2555
  h('span', { className: 'chevron' }, h(ChevronDownIcon, { size: 12 })),
2057
2556
  ),
2058
- view.registryOpen === true ? h('div', { className: 'dsh-powerup-update-select-menu', role: 'listbox', 'aria-label': t('update.registry') },
2059
- view.choices.map((choice) => h('button', {
2060
- key: choice.key,
2061
- type: 'button',
2062
- role: 'option',
2063
- className: 'dsh-powerup-update-option',
2064
- 'aria-selected': choice.key === view.registryKey ? 'true' : 'false',
2065
- onClick: () => chooseRegistry(choice.key),
2066
- },
2067
- h('span', { className: 'dsh-powerup-update-option-copy' },
2068
- h('span', { className: 'dsh-powerup-update-option-title' }, choice.label),
2069
- h('span', { className: 'dsh-powerup-update-option-host' }, choice.hint),
2070
- ),
2071
- choice.key === view.registryKey ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
2072
- ))) : null,
2073
2557
  ),
2074
2558
  view.versionsRegistry === null || view.versionsRegistry === undefined
2075
2559
  ? null
@@ -2152,28 +2636,86 @@
2152
2636
  return null;
2153
2637
  })();
2154
2638
 
2155
- return h('div', {
2156
- className: 'dsh-powerup-update-mask',
2157
- onMouseDown: (event) => {
2158
- if (event.target === event.currentTarget) close();
2639
+ const fallbackPopover = { left: 24, top: 120, width: 320, maxHeight: 260 };
2640
+
2641
+ return h(React.Fragment, null,
2642
+ h('div', {
2643
+ className: 'dsh-powerup-update-mask',
2644
+ onMouseDown: (event) => {
2645
+ if (event.target === event.currentTarget) close();
2646
+ },
2159
2647
  },
2160
- },
2161
- h('div', {
2162
- className: 'dsh-powerup-update-dialog',
2163
- role: 'dialog',
2164
- 'aria-modal': 'true',
2165
- 'aria-labelledby': 'dsh-powerup-update-title',
2166
- },
2167
- h('div', { className: 'dsh-powerup-update-head' },
2168
- h('div', { className: 'dsh-powerup-update-heading' },
2169
- h('h2', { id: 'dsh-powerup-update-title' }, t('update.title')),
2170
- h('p', { title }, title),
2648
+ h('div', {
2649
+ className: 'dsh-powerup-update-dialog',
2650
+ role: 'dialog',
2651
+ 'aria-modal': 'true',
2652
+ 'aria-labelledby': 'dsh-powerup-update-title',
2653
+ },
2654
+ h('div', { className: 'dsh-powerup-update-head' },
2655
+ h('div', { className: 'dsh-powerup-update-heading' },
2656
+ h('h2', { id: 'dsh-powerup-update-title' }, t('update.title')),
2657
+ h('p', { title }, title),
2658
+ ),
2659
+ canClose ? h('button', { type: 'button', className: 'dsh-powerup-update-close', 'aria-label': t('update.close'), onClick: close }, '×') : null,
2171
2660
  ),
2172
- canClose ? h('button', { type: 'button', className: 'dsh-powerup-update-close', 'aria-label': t('update.close'), onClick: close }, '×') : null,
2173
- ),
2174
- h('div', { className: 'dsh-powerup-update-body' }, body),
2175
- footer.length === 0 ? null : h('div', { className: 'dsh-powerup-update-foot' }, footer),
2176
- ),
2661
+ h('div', { className: 'dsh-powerup-update-body' }, body),
2662
+ footer.length === 0 ? null : h('div', { className: 'dsh-powerup-update-foot' }, footer),
2663
+ ),
2664
+ ),
2665
+ view.listOpen === true
2666
+ ? h('div', {
2667
+ id: 'dsh-powerup-update-versions',
2668
+ ref: versionPanelRef,
2669
+ className: 'dsh-powerup-update-popover dsh-powerup-update-list',
2670
+ role: 'listbox',
2671
+ 'aria-label': t('update.versionLabel'),
2672
+ style: view.listPopover ?? fallbackPopover,
2673
+ }, versionRows)
2674
+ : null,
2675
+ view.registryOpen === true
2676
+ ? h('div', {
2677
+ ref: registryPanelRef,
2678
+ className: 'dsh-powerup-update-popover dsh-powerup-update-select-menu',
2679
+ role: 'listbox',
2680
+ 'aria-label': t('update.registry'),
2681
+ style: view.registryPopover ?? fallbackPopover,
2682
+ }, view.choices.map((choice) => {
2683
+ const isDefault = choice.key === view.defaultRegistryKey;
2684
+ const isChosen = choice.key === view.registryKey;
2685
+ return h('div', {
2686
+ key: choice.key,
2687
+ role: 'option',
2688
+ tabIndex: 0,
2689
+ className: 'dsh-powerup-update-option',
2690
+ 'aria-selected': isChosen ? 'true' : 'false',
2691
+ onClick: () => chooseRegistry(choice.key),
2692
+ onKeyDown: (event) => {
2693
+ if (event.key !== 'Enter' && event.key !== ' ') return;
2694
+ event.preventDefault();
2695
+ chooseRegistry(choice.key);
2696
+ },
2697
+ },
2698
+ h('span', { className: 'dsh-powerup-update-option-copy' },
2699
+ h('span', { className: 'dsh-powerup-update-option-title' }, choice.label),
2700
+ h('span', { className: 'dsh-powerup-update-option-host' }, choice.hint),
2701
+ ),
2702
+ isDefault
2703
+ ? h('span', { className: 'dsh-powerup-update-default-tag', title: t('update.defaultHint') }, t('update.defaultTag'))
2704
+ : h('button', {
2705
+ type: 'button',
2706
+ className: 'dsh-powerup-update-default',
2707
+ title: t('update.setDefault'),
2708
+ 'aria-label': `${t('update.setDefault')} ${choice.label}`,
2709
+ onClick: (event) => {
2710
+ event.preventDefault();
2711
+ event.stopPropagation();
2712
+ setDefaultRegistry(choice);
2713
+ },
2714
+ }, h(StarIcon, { size: 12 })),
2715
+ isChosen ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
2716
+ );
2717
+ }))
2718
+ : null,
2177
2719
  );
2178
2720
  }
2179
2721
 
@@ -2547,7 +3089,9 @@
2547
3089
  PowerUpOverlay,
2548
3090
  PluginUpdateOverlay,
2549
3091
  ensureUpdateButtons,
3092
+ ensureRestartBar,
2550
3093
  updateButtonLabel,
3094
+ popoverPlacement,
2551
3095
  rowKeyParts,
2552
3096
  resolveForkSeq,
2553
3097
  markdownQuote,
@@ -2559,6 +3103,10 @@
2559
3103
  compareVersionsDesc,
2560
3104
  buildRegistryChoices,
2561
3105
  pickRegistryChoice,
3106
+ matchStoredChoice,
3107
+ readDefaultRegistry,
3108
+ writeDefaultRegistry,
3109
+ readPendingRestarts,
2562
3110
  lookupRegistries,
2563
3111
  fetchRegistryVersions,
2564
3112
  registryHost,