@metricinsights/pp-dev 1.5.0-beta.3 → 1.5.0-beta.31

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 (72) hide show
  1. package/README.md +200 -145
  2. package/dist/cjs/auth.provider-fI0tFsf5.js +2 -0
  3. package/dist/cjs/auth.provider-fI0tFsf5.js.map +1 -0
  4. package/dist/cjs/cli.js +1 -1
  5. package/dist/cjs/cli.js.map +1 -1
  6. package/dist/cjs/index-Ty1d2YCg.js +2 -0
  7. package/dist/cjs/index-Ty1d2YCg.js.map +1 -0
  8. package/dist/cjs/index.js +1 -1
  9. package/dist/cjs/migrate-Gi9k9cLw.js +2 -0
  10. package/dist/cjs/migrate-Gi9k9cLw.js.map +1 -0
  11. package/dist/cjs/package.json +2 -135
  12. package/dist/cjs/plugin.js +1 -1
  13. package/dist/cjs/plugin.js.map +1 -1
  14. package/dist/cjs/request-capture.middleware-6DhFLyIz.js +2 -0
  15. package/dist/cjs/request-capture.middleware-6DhFLyIz.js.map +1 -0
  16. package/dist/cjs/version-manifest-CVBuIKBS.js +2 -0
  17. package/dist/cjs/version-manifest-CVBuIKBS.js.map +1 -0
  18. package/dist/cjs/version-plugin-DBdqn2Rn.js +2 -0
  19. package/dist/cjs/version-plugin-DBdqn2Rn.js.map +1 -0
  20. package/dist/client/client.js +278 -126
  21. package/dist/client/client.js.map +1 -1
  22. package/dist/client/request-inspector.css +3 -0
  23. package/dist/client/request-inspector.css.map +1 -0
  24. package/dist/client/request-inspector.html +87 -0
  25. package/dist/client/request-inspector.js +549 -0
  26. package/dist/client/request-inspector.js.map +1 -0
  27. package/dist/client/variables-editor.css +3 -0
  28. package/dist/client/variables-editor.css.map +1 -0
  29. package/dist/client/variables-editor.html +44 -0
  30. package/dist/client/variables-editor.js +3474 -0
  31. package/dist/client/variables-editor.js.map +1 -0
  32. package/dist/esm/auth.provider-DDUsu1H1.js +2 -0
  33. package/dist/esm/auth.provider-DDUsu1H1.js.map +1 -0
  34. package/dist/esm/cli.js +1 -1
  35. package/dist/esm/cli.js.map +1 -1
  36. package/dist/esm/index-tD-QICGk.js +2 -0
  37. package/dist/esm/index-tD-QICGk.js.map +1 -0
  38. package/dist/esm/index.js +1 -1
  39. package/dist/esm/migrate-CCBRuVfj.js +2 -0
  40. package/dist/esm/{migrate-B2gJ62fm.js.map → migrate-CCBRuVfj.js.map} +1 -1
  41. package/dist/esm/package.json +2 -135
  42. package/dist/esm/plugin.js +1 -1
  43. package/dist/esm/plugin.js.map +1 -1
  44. package/dist/esm/request-capture.middleware-VElwjU1W.js +2 -0
  45. package/dist/esm/request-capture.middleware-VElwjU1W.js.map +1 -0
  46. package/dist/esm/version-manifest-CyzmnFOe.js +2 -0
  47. package/dist/esm/version-manifest-CyzmnFOe.js.map +1 -0
  48. package/dist/esm/version-plugin-6rxIgQ-I.js +2 -0
  49. package/dist/esm/version-plugin-6rxIgQ-I.js.map +1 -0
  50. package/dist/types/helpers.d.ts +29 -0
  51. package/dist/types/index.d.ts +15 -156
  52. package/dist/types/plugin.d.ts +8 -0
  53. package/dist/types/pp-dev-config-D889A-PO.d.ts +189 -0
  54. package/docs/features.md +2 -1
  55. package/package.json +26 -35
  56. package/dist/CHANGELOG.md +0 -442
  57. package/dist/README.md +0 -724
  58. package/dist/cjs/index-CCUrjlJE.js +0 -2
  59. package/dist/cjs/index-CCUrjlJE.js.map +0 -1
  60. package/dist/cjs/migrate-BYno__sx.js +0 -2
  61. package/dist/cjs/migrate-BYno__sx.js.map +0 -1
  62. package/dist/cjs/plugin-D_RXI7ru.js +0 -2
  63. package/dist/cjs/plugin-D_RXI7ru.js.map +0 -1
  64. package/dist/cjs/version-plugin-Bf4jLh17.js +0 -2
  65. package/dist/cjs/version-plugin-Bf4jLh17.js.map +0 -1
  66. package/dist/esm/index-BazQY12s.js +0 -2
  67. package/dist/esm/index-BazQY12s.js.map +0 -1
  68. package/dist/esm/migrate-B2gJ62fm.js +0 -2
  69. package/dist/esm/plugin-NIWxLreC.js +0 -2
  70. package/dist/esm/plugin-NIWxLreC.js.map +0 -1
  71. package/dist/esm/version-plugin-DwuZCHob.js +0 -2
  72. package/dist/esm/version-plugin-DwuZCHob.js.map +0 -1
@@ -1,17 +1,13 @@
1
1
  /**
2
- * Minimal `import.meta.hot`-compatible context for environments without Vite HMR
3
- * (e.g. the `pp-dev next` server). The dev-panel client only uses `hot.on()` and
4
- * `hot.send()`; this shim implements those over a raw WebSocket using the same
5
- * custom-event wire shape Vite uses: `{ type: 'custom', event, data }`.
6
- *
7
- * Used only as a fallback: when `import.meta.hot` exists (Vite), that is used
8
- * instead and this code never runs.
9
- */
10
- /**
11
- * WebSocket path the pp-dev Next.js server listens on for dev-panel messages.
12
- * Kept in sync with `PP_DEV_HMR_WS_PATH` in `src/constants.ts` (server side).
2
+ * The WebSocket messages between the dev server and the dev panel, for both sides: the server
3
+ * (`src/lib/client.service.ts`, `src/lib/pp-ws-server.ts`) and the panel (`src/client/**`) import
4
+ * them from here, so they can't drift apart. No Node or DOM imports: the panel bundle includes it.
13
5
  */
6
+ /** WebSocket path the `pp-dev next` server listens on for dev-panel messages. */
14
7
  const PP_DEV_HMR_WS_PATH = '/@pp-dev-hmr';
8
+ /** `template:sync:response` code for a sync requested while another one runs. */
9
+ const SYNC_IN_PROGRESS_CODE = 'SYNC_IN_PROGRESS';
10
+
15
11
  const MAX_OUTBOX_SIZE = 50;
16
12
  function resolveWebSocketUrl() {
17
13
  const { protocol, host } = window.location;
@@ -23,6 +19,8 @@ function createPPDevHotContext() {
23
19
  /** Outgoing messages queued while the socket is not OPEN; flushed on connect. */
24
20
  const outbox = [];
25
21
  let socket = null;
22
+ /** The connection state last reported to the panel, so each change is reported once. */
23
+ let reported = null;
26
24
  let reconnectTimer = null;
27
25
  let reconnectDelay = 1_000;
28
26
  const MAX_RECONNECT_DELAY = 10_000;
@@ -60,6 +58,8 @@ function createPPDevHotContext() {
60
58
  }
61
59
  socket.addEventListener('open', () => {
62
60
  reconnectDelay = 1_000;
61
+ reported = 'connected';
62
+ dispatch('vite:ws:connect', {});
63
63
  flushOutbox();
64
64
  });
65
65
  socket.addEventListener('message', (ev) => {
@@ -76,6 +76,14 @@ function createPPDevHotContext() {
76
76
  });
77
77
  socket.addEventListener('close', () => {
78
78
  socket = null;
79
+ // Same events as Vite's own HMR client, so the panel can tell a lost connection apart
80
+ // (e.g. a sync whose response will never arrive, or a click that can't be sent). Once per
81
+ // loss: each failed reconnect attempt is not a new one, but a first connection that never
82
+ // opened is.
83
+ if (reported !== 'disconnected') {
84
+ reported = 'disconnected';
85
+ dispatch('vite:ws:disconnect', {});
86
+ }
79
87
  scheduleReconnect();
80
88
  });
81
89
  socket.addEventListener('error', () => {
@@ -119,6 +127,9 @@ function createPPDevHotContext() {
119
127
  outbox.push(payload);
120
128
  }
121
129
  },
130
+ // Not part of Vite's API: lets the panel tell an open connection from one still connecting, whose
131
+ // queued messages would go out whenever it opens (see `src/client/sync-button.ts`).
132
+ isConnected: () => socket?.readyState === WebSocket.OPEN,
122
133
  // Unused by the dev-panel client; provided as no-ops to satisfy the shape.
123
134
  accept: noop,
124
135
  acceptExports: noop,
@@ -644,6 +655,37 @@ function initPanelSettings($panel, controller, hooks) {
644
655
  });
645
656
  }
646
657
 
658
+ /**
659
+ * The rules for a saved user's name, which is also its host (`<name>.localhost`), for both sides:
660
+ * the server checks them (`src/lib/user-registry.ts`) and the dev panel's Users popover
661
+ * (`src/client/users-panel.ts`) checks them as you type. No Node or DOM imports: the panel bundle
662
+ * includes it.
663
+ */
664
+ /** Longest user name; well under the 63 characters a DNS label allows. */
665
+ const MAX_USER_NAME_LENGTH = 32;
666
+ /**
667
+ * A DNS label: lowercase latin letters, digits and `-`, starting with a letter and not ending with
668
+ * `-`. Also used as an input's `pattern`, which browsers compile with the `v` flag: there an
669
+ * unescaped `-` in a class is a syntax error, and the whole pattern was silently ignored.
670
+ */
671
+ const USER_NAME_PATTERN = String.raw `[a-z](?:[a-z0-9\-]*[a-z0-9])?`;
672
+ const USER_NAME_REGEXP = new RegExp(`^(?:${USER_NAME_PATTERN})$`);
673
+ /** The suffix of a user's host: `viewer` works on `viewer.localhost`. */
674
+ const USER_HOST_SUFFIX = '.localhost';
675
+ /** Why `name` can't be a user name, or `undefined` if it can. */
676
+ function getUserNameError(name) {
677
+ if (!name) {
678
+ return 'The name is required';
679
+ }
680
+ if (name.length > MAX_USER_NAME_LENGTH) {
681
+ return `The name must be at most ${MAX_USER_NAME_LENGTH} characters long`;
682
+ }
683
+ if (!USER_NAME_REGEXP.test(name)) {
684
+ return 'The name may contain only lowercase latin letters, digits and "-", must start with a letter and must not end with "-"';
685
+ }
686
+ return undefined;
687
+ }
688
+
647
689
  /**
648
690
  * The dev panel's "Users" popover (PP-4597): the user of this tab, the saved users (open as, log
649
691
  * in, make default, remove) and adding a user by API token or by username + password. Each user
@@ -651,7 +693,8 @@ function initPanelSettings($panel, controller, hooks) {
651
693
  */
652
694
  const USERS_API = '/@api/users';
653
695
  const REFRESH_INTERVAL = 3000;
654
- const USER_HOST_REGEXP = /^([a-z](?:[a-z0-9-]*[a-z0-9])?)\.localhost$/;
696
+ // The server's name rules (src/shared/user-names.ts), not a copy of them.
697
+ const USER_HOST_REGEXP = new RegExp(`^(${USER_NAME_PATTERN})${USER_HOST_SUFFIX.replace('.', '\\.')}$`);
655
698
  /** Marks the user this tab works as, apart from the radio, which picks localhost's user. */
656
699
  const THIS_TAB_BADGE = '<span class="pp-dev-info__users-badge">this tab</span>';
657
700
  const STATE_LABELS = {
@@ -716,6 +759,9 @@ function initUsersPanel($panel, hooks) {
716
759
  let data = null;
717
760
  // What the list shows now, so a refresh with the same data leaves it (and the focus) alone.
718
761
  let renderedData = '';
762
+ // Only the latest refresh paints: two quick default switches sent two, and the first one's
763
+ // answer, arriving last, drew the state before the second.
764
+ let refreshSeq = 0;
719
765
  // The token user being edited, whose row shows the edit form instead.
720
766
  let editing = null;
721
767
  const onDocClick = (ev) => {
@@ -823,7 +869,7 @@ function initUsersPanel($panel, hooks) {
823
869
  const name = escapeHtml(user.name);
824
870
  if (user.kind === 'token' && user.name === editing) {
825
871
  return `<form class="pp-dev-info__users-row pp-dev-info__users-edit" data-user="${name}">
826
- <input class="pp-dev-info__users-input pp-dev-info__users-edit-name" value="${name}" maxlength="32"
872
+ <input class="pp-dev-info__users-input pp-dev-info__users-edit-name" value="${name}" maxlength="${MAX_USER_NAME_LENGTH}" pattern="${USER_NAME_PATTERN}"
827
873
  autocomplete="off" spellcheck="false" aria-label="Name" data-focus-key="${name}:edit-name"
828
874
  title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
829
875
  <input class="pp-dev-info__users-input pp-dev-info__users-edit-token" type="password" autocomplete="off"
@@ -868,6 +914,9 @@ function initUsersPanel($panel, hooks) {
868
914
  $open.addEventListener('click', (ev) => {
869
915
  ev.preventDefault();
870
916
  const target = $open.dataset.open;
917
+ if (!target) {
918
+ return;
919
+ }
871
920
  window.open($open.dataset.raw ? target : samePageOn(target), '_blank');
872
921
  });
873
922
  });
@@ -880,7 +929,7 @@ function initUsersPanel($panel, hooks) {
880
929
  $list.querySelectorAll('[data-edit]').forEach(($edit) => {
881
930
  $edit.addEventListener('click', (ev) => {
882
931
  ev.preventDefault();
883
- startEditing($edit.dataset.edit);
932
+ startEditing($edit.dataset.edit ?? null);
884
933
  });
885
934
  });
886
935
  $list.querySelector('[data-cancel-edit]')?.addEventListener('click', (ev) => {
@@ -892,8 +941,11 @@ function initUsersPanel($panel, hooks) {
892
941
  ev.preventDefault();
893
942
  const name = editing;
894
943
  const $form = ev.currentTarget;
895
- const newName = $form.querySelector('.pp-dev-info__users-edit-name').value.trim();
896
- const token = $form.querySelector('.pp-dev-info__users-edit-token').value.trim();
944
+ if (!name) {
945
+ return;
946
+ }
947
+ const newName = $form.querySelector('.pp-dev-info__users-edit-name')?.value.trim() ?? '';
948
+ const token = $form.querySelector('.pp-dev-info__users-edit-token')?.value.trim() ?? '';
897
949
  const response = await request('PATCH', `${USERS_API}/${encodeURIComponent(name)}`, {
898
950
  name: newName,
899
951
  ...(token ? { token } : {}),
@@ -914,7 +966,8 @@ function initUsersPanel($panel, hooks) {
914
966
  $list.querySelectorAll('[data-remove]').forEach(($remove) => {
915
967
  $remove.addEventListener('click', async (ev) => {
916
968
  ev.preventDefault();
917
- if (await request('DELETE', `${USERS_API}/${encodeURIComponent($remove.dataset.remove)}`)) {
969
+ const name = $remove.dataset.remove;
970
+ if (name && (await request('DELETE', `${USERS_API}/${encodeURIComponent(name)}`))) {
918
971
  await refresh();
919
972
  }
920
973
  });
@@ -950,12 +1003,17 @@ function initUsersPanel($panel, hooks) {
950
1003
  }
951
1004
  };
952
1005
  const refresh = async () => {
1006
+ const seq = ++refreshSeq;
953
1007
  try {
954
1008
  const response = await fetch(USERS_API);
955
1009
  if (!response.ok) {
956
1010
  return;
957
1011
  }
958
- data = (await response.json());
1012
+ const loaded = (await response.json());
1013
+ if (seq !== refreshSeq) {
1014
+ return;
1015
+ }
1016
+ data = loaded;
959
1017
  renderList();
960
1018
  renderAvailability();
961
1019
  }
@@ -995,20 +1053,34 @@ function initUsersPanel($panel, hooks) {
995
1053
  const $name = $popover.querySelector('.pp-dev-info__users-name-input');
996
1054
  const $token = $popover.querySelector('.pp-dev-info__users-token');
997
1055
  const $submit = $popover.querySelector('.pp-dev-info__users-submit');
998
- const body = addKind === 'token'
999
- ? { name: $name.value.trim(), token: $token.value }
1000
- : { name: $name.value.trim(), kind: 'session' };
1056
+ if (!$name || !$token || !$submit) {
1057
+ return;
1058
+ }
1059
+ const name = $name.value.trim();
1060
+ const nameError = getUserNameError(name);
1061
+ if (nameError) {
1062
+ setError(nameError);
1063
+ return;
1064
+ }
1065
+ const body = addKind === 'token' ? { name, token: $token.value } : { name, kind: 'session' };
1066
+ // A session user logs in on a tab of its own. Opened here, while the click still counts as the
1067
+ // user's: opened after the request, Safari and Chrome blocked it as a popup.
1068
+ const loginTab = addKind === 'session' ? window.open('', '_blank') : null;
1001
1069
  $submit.disabled = true;
1002
1070
  const response = await request('POST', USERS_API, body);
1003
1071
  $submit.disabled = false;
1004
1072
  if (!response) {
1073
+ loginTab?.close();
1005
1074
  return;
1006
1075
  }
1007
1076
  const { user } = (await response.json());
1008
1077
  $name.value = '';
1009
1078
  $token.value = '';
1010
- if (user.kind === 'session' && user.loginUrl) {
1011
- window.open(user.loginUrl, '_blank');
1079
+ if (user.kind === 'session' && user.loginUrl && loginTab) {
1080
+ loginTab.location.href = user.loginUrl;
1081
+ }
1082
+ else {
1083
+ loginTab?.close();
1012
1084
  }
1013
1085
  await refresh();
1014
1086
  };
@@ -1033,8 +1105,8 @@ function initUsersPanel($panel, hooks) {
1033
1105
  <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="token">API token</button>
1034
1106
  <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="session">Username + password</button>
1035
1107
  </div>
1036
- <input class="pp-dev-info__users-input pp-dev-info__users-name-input" placeholder="Name, e.g. viewer" maxlength="32"
1037
- autocomplete="off" spellcheck="false" pattern="[a-z]([a-z0-9-]*[a-z0-9])?"
1108
+ <input class="pp-dev-info__users-input pp-dev-info__users-name-input" placeholder="Name, e.g. viewer" maxlength="${MAX_USER_NAME_LENGTH}"
1109
+ autocomplete="off" spellcheck="false" pattern="${USER_NAME_PATTERN}"
1038
1110
  title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
1039
1111
  <input class="pp-dev-info__users-input pp-dev-info__users-token" type="password" placeholder="API token" autocomplete="off" />
1040
1112
  <button type="submit" class="pp-dev-info__vars-btn pp-dev-info__users-submit">Add user</button>
@@ -1050,7 +1122,7 @@ function initUsersPanel($panel, hooks) {
1050
1122
  renderKind();
1051
1123
  });
1052
1124
  });
1053
- $popover.querySelector('.pp-dev-info__users-add').addEventListener('submit', (ev) => {
1125
+ $popover.querySelector('.pp-dev-info__users-add')?.addEventListener('submit', (ev) => {
1054
1126
  ev.preventDefault();
1055
1127
  submit();
1056
1128
  });
@@ -1139,6 +1211,175 @@ function createPopupElement(opts) {
1139
1211
  $popup.appendChild($popupContent);
1140
1212
  return $popup;
1141
1213
  }
1214
+ /**
1215
+ * Calls `onElapsed` once the page has been visible for `duration` ms in all (time in a hidden tab
1216
+ * doesn't count). Returns a function that stops waiting, which also removes the page's
1217
+ * `visibilitychange` listener: one used to stay behind for every popup ever shown.
1218
+ */
1219
+ function startAutoDismiss(duration, onElapsed) {
1220
+ let remaining = duration;
1221
+ let lastUpdate = Date.now();
1222
+ let visible = true;
1223
+ let frame;
1224
+ const tick = () => {
1225
+ frame = undefined;
1226
+ if (!visible) {
1227
+ return;
1228
+ }
1229
+ const now = Date.now();
1230
+ remaining -= now - lastUpdate;
1231
+ lastUpdate = now;
1232
+ if (remaining <= 0) {
1233
+ stop();
1234
+ onElapsed();
1235
+ return;
1236
+ }
1237
+ frame = requestAnimationFrame(tick);
1238
+ };
1239
+ const onVisibilityChange = () => {
1240
+ visible = !document.hidden;
1241
+ if (visible) {
1242
+ lastUpdate = Date.now();
1243
+ frame ??= requestAnimationFrame(tick);
1244
+ }
1245
+ };
1246
+ const stop = () => {
1247
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1248
+ if (frame !== undefined) {
1249
+ cancelAnimationFrame(frame);
1250
+ frame = undefined;
1251
+ }
1252
+ };
1253
+ document.addEventListener('visibilitychange', onVisibilityChange);
1254
+ frame = requestAnimationFrame(tick);
1255
+ return stop;
1256
+ }
1257
+
1258
+ const TITLE_AVAILABLE = 'Sync template';
1259
+ const TITLE_UNAVAILABLE = 'Sync is unavailable on this instance';
1260
+ /**
1261
+ * Wires the dev panel's "Sync template" button to the dev server.
1262
+ *
1263
+ * The button is disabled while a sync runs, so a double click can't start a second build, and is
1264
+ * enabled again by whatever ends the sync: its response, or a lost connection. It stays disabled
1265
+ * only when the server says sync is unavailable on this instance (`config.canSync === false`);
1266
+ * a failed sync (MI unreachable, a build error) can simply be retried.
1267
+ */
1268
+ function initSyncButton(hot, $button, deps) {
1269
+ const reload = deps.reload ?? (() => window.location.reload());
1270
+ let available = !$button.disabled && !$button.classList.contains('disabled');
1271
+ let syncing = false;
1272
+ // Vite's connection is taken as open until reported lost: it may have opened before these handlers
1273
+ // were registered, and its client reloads the page once a lost one is back. The `pp-dev next`
1274
+ // transport says itself whether its socket is open, also while the first one is still connecting.
1275
+ let connected = true;
1276
+ const isConnected = () => connected && (hot.isConnected?.() ?? true);
1277
+ const applyState = () => {
1278
+ $button.disabled = syncing || !available;
1279
+ $button.classList.toggle('syncing', syncing);
1280
+ $button.classList.toggle('disabled', !available);
1281
+ $button.title = available ? TITLE_AVAILABLE : TITLE_UNAVAILABLE;
1282
+ if (syncing) {
1283
+ $button.setAttribute('aria-busy', 'true');
1284
+ }
1285
+ else {
1286
+ $button.removeAttribute('aria-busy');
1287
+ }
1288
+ };
1289
+ const endSync = () => {
1290
+ syncing = false;
1291
+ applyState();
1292
+ };
1293
+ hot.on('client:config:update', (data) => {
1294
+ if (typeof data?.config?.canSync !== 'boolean') {
1295
+ return;
1296
+ }
1297
+ // The server sends `canSync: true` at the start of every sync: it must not enable the
1298
+ // button while that sync runs, which `applyState` takes care of.
1299
+ available = data.config.canSync;
1300
+ applyState();
1301
+ });
1302
+ hot.on('template:sync:action-required', async (payload) => {
1303
+ // The spinner keeps running while a confirmation modal is shown: the sync is still in
1304
+ // progress and only ends on `template:sync:response`.
1305
+ const approved = await deps.confirmModal({
1306
+ title: payload.title,
1307
+ content: payload.content,
1308
+ confirmText: payload.confirmText,
1309
+ cancelText: payload.cancelText,
1310
+ });
1311
+ hot.send('template:sync:action-response', {
1312
+ requestId: payload.requestId,
1313
+ approved,
1314
+ });
1315
+ });
1316
+ hot.on('template:sync:response', (payload) => {
1317
+ deps.closeAllConfirmModals();
1318
+ if ('error' in payload && payload.config?.canSync === false) {
1319
+ available = false;
1320
+ }
1321
+ endSync();
1322
+ if ('cancelled' in payload && payload.cancelled) {
1323
+ deps.infoPopup({ title: 'Sync cancelled', content: payload.message, type: 'warning' });
1324
+ return;
1325
+ }
1326
+ if ('error' in payload && typeof payload.error !== 'undefined') {
1327
+ if (payload.code === SYNC_IN_PROGRESS_CODE) {
1328
+ deps.infoPopup({ title: 'Sync already running', content: payload.error, type: 'warning' });
1329
+ return;
1330
+ }
1331
+ deps.infoPopup({ title: 'Sync error', content: payload.error, type: 'danger' });
1332
+ if (payload.refresh) {
1333
+ setTimeout(reload);
1334
+ }
1335
+ return;
1336
+ }
1337
+ if ('syncedAt' in payload && typeof payload.syncedAt !== 'undefined') {
1338
+ deps.infoPopup({
1339
+ title: 'Sync success',
1340
+ content: `Synced at ${new Date(payload.syncedAt).toLocaleString()}.\nBackup filename: ${payload.backupFilename}`,
1341
+ type: 'success',
1342
+ });
1343
+ }
1344
+ });
1345
+ // A response sent to a socket that has closed never arrives. Without this, the button would
1346
+ // spin, disabled, until the page is reloaded.
1347
+ hot.on('vite:ws:connect', () => {
1348
+ connected = true;
1349
+ });
1350
+ hot.on('vite:ws:disconnect', () => {
1351
+ connected = false;
1352
+ if (!syncing) {
1353
+ return;
1354
+ }
1355
+ deps.closeAllConfirmModals();
1356
+ endSync();
1357
+ deps.infoPopup({
1358
+ title: 'Sync result unknown',
1359
+ content: 'The connection to the dev server was lost during the sync. Check its terminal for the result before syncing again.',
1360
+ type: 'warning',
1361
+ });
1362
+ });
1363
+ $button.addEventListener('click', (ev) => {
1364
+ ev.preventDefault();
1365
+ if (syncing || !available) {
1366
+ return;
1367
+ }
1368
+ // Sent now, the request would wait for a reconnect with the button spinning, and start a sync
1369
+ // whenever that happens. Say so instead, and let the click be repeated once connected.
1370
+ if (!isConnected()) {
1371
+ deps.infoPopup({
1372
+ title: 'No connection',
1373
+ content: 'The dev server is not connected. Check that it is running, then try again.',
1374
+ type: 'warning',
1375
+ });
1376
+ return;
1377
+ }
1378
+ syncing = true;
1379
+ applyState();
1380
+ hot.send('template:sync', {});
1381
+ });
1382
+ }
1142
1383
 
1143
1384
  /// <reference types="vite/client" />
1144
1385
  // Applied unconditionally (even if the panel itself is hidden) — this is what makes the
@@ -1211,7 +1452,16 @@ function animatePopup($popup, type) {
1211
1452
  function infoPopup(opts) {
1212
1453
  const $popup = createPopupElement(opts);
1213
1454
  const $closeButton = $popup.querySelector('.pp-dev-info__popup-title-close');
1455
+ let removed = false;
1456
+ let stopAutoDismiss;
1457
+ // Once only: closing it by hand used to leave its timer running, which removed it again and
1458
+ // counted it out of `activePopups` twice.
1214
1459
  const removePopup = async () => {
1460
+ if (removed) {
1461
+ return;
1462
+ }
1463
+ removed = true;
1464
+ stopAutoDismiss?.();
1215
1465
  await animatePopup($popup, 'exit');
1216
1466
  $popup.remove();
1217
1467
  updatePopupPositions();
@@ -1224,32 +1474,7 @@ function infoPopup(opts) {
1224
1474
  animatePopup($popup, 'enter');
1225
1475
  const duration = opts.duration ?? 10000;
1226
1476
  if (duration > 0) {
1227
- let remainingTime = duration;
1228
- let lastUpdate = Date.now();
1229
- let isVisible = true;
1230
- const scheduleDismiss = () => {
1231
- if (!isVisible) {
1232
- return;
1233
- }
1234
- const now = Date.now();
1235
- const elapsed = now - lastUpdate;
1236
- remainingTime -= elapsed;
1237
- lastUpdate = now;
1238
- if (remainingTime <= 0) {
1239
- removePopup();
1240
- return;
1241
- }
1242
- requestAnimationFrame(scheduleDismiss);
1243
- };
1244
- // Handle visibility change
1245
- document.addEventListener('visibilitychange', () => {
1246
- isVisible = !document.hidden;
1247
- if (isVisible) {
1248
- lastUpdate = Date.now();
1249
- requestAnimationFrame(scheduleDismiss);
1250
- }
1251
- });
1252
- requestAnimationFrame(scheduleDismiss);
1477
+ stopAutoDismiss = startAutoDismiss(duration, removePopup);
1253
1478
  }
1254
1479
  return { close: removePopup };
1255
1480
  }
@@ -1372,82 +1597,9 @@ if (hot) {
1372
1597
  hot.on('redirect', (data) => {
1373
1598
  window.location.href = data.url;
1374
1599
  });
1375
- hot.on('client:config:update', (data) => {
1376
- if (typeof data.config?.canSync === 'boolean') {
1377
- if (data.config.canSync) {
1378
- const $syncButton = document.getElementById('sync-template');
1379
- if ($syncButton) {
1380
- $syncButton.disabled = false;
1381
- $syncButton.classList.remove('disabled');
1382
- $syncButton.title = 'Sync template';
1383
- }
1384
- }
1385
- else {
1386
- const $syncButton = document.getElementById('sync-template');
1387
- if ($syncButton) {
1388
- $syncButton.disabled = true;
1389
- $syncButton.classList.add('disabled');
1390
- $syncButton.title = 'Sync is unavailable on this instance';
1391
- }
1392
- }
1393
- }
1394
- });
1395
1600
  const $syncButton = document.getElementById('sync-template');
1396
1601
  if ($syncButton) {
1397
- hot.on('template:sync:action-required', async (payload) => {
1398
- // Keep the sync spinner running while a confirmation modal is shown — the sync
1399
- // process is still in progress and only ends on `template:sync:response`.
1400
- const approved = await confirmModal({
1401
- title: payload.title,
1402
- content: payload.content,
1403
- confirmText: payload.confirmText,
1404
- cancelText: payload.cancelText,
1405
- });
1406
- hot.send('template:sync:action-response', {
1407
- requestId: payload.requestId,
1408
- approved,
1409
- });
1410
- });
1411
- hot.on('template:sync:response', (payload) => {
1412
- closeAllConfirmModals();
1413
- $syncButton.classList.remove('syncing');
1414
- if ('cancelled' in payload && payload.cancelled) {
1415
- infoPopup({
1416
- title: 'Sync cancelled',
1417
- content: payload.message,
1418
- type: 'warning',
1419
- });
1420
- }
1421
- else if ('error' in payload && typeof payload.error !== 'undefined') {
1422
- infoPopup({
1423
- title: 'Sync error',
1424
- content: payload.error,
1425
- type: 'danger',
1426
- });
1427
- if (payload.refresh) {
1428
- setTimeout(() => {
1429
- window.location.reload();
1430
- });
1431
- }
1432
- else {
1433
- $syncButton.disabled = true;
1434
- $syncButton.classList.add('disabled');
1435
- $syncButton.title = 'Sync is unavailable on this instance';
1436
- }
1437
- }
1438
- else if ('syncedAt' in payload && typeof payload.syncedAt !== 'undefined') {
1439
- infoPopup({
1440
- title: 'Sync success',
1441
- content: `Synced at ${new Date(payload.syncedAt).toLocaleString()}.\nBackup filename: ${payload.backupFilename}`,
1442
- type: 'success',
1443
- });
1444
- }
1445
- });
1446
- $syncButton.addEventListener('click', (ev) => {
1447
- ev.preventDefault();
1448
- $syncButton.classList.add('syncing');
1449
- hot.send('template:sync', {});
1450
- });
1602
+ initSyncButton(hot, $syncButton, { infoPopup, confirmModal, closeAllConfirmModals });
1451
1603
  }
1452
1604
  hot.on('page-variables:reload:response', (payload) => {
1453
1605
  pendingReloadVariablesPopup?.close();