@metricinsights/pp-dev 1.5.0-beta.2 → 1.5.0-beta.21

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 (68) hide show
  1. package/README.md +54 -11
  2. package/dist/README.md +54 -11
  3. package/dist/cjs/auth.provider-CyXyBAm3.js +2 -0
  4. package/dist/cjs/auth.provider-CyXyBAm3.js.map +1 -0
  5. package/dist/cjs/cli.js +1 -1
  6. package/dist/cjs/cli.js.map +1 -1
  7. package/dist/cjs/index-CeIQtamz.js +2 -0
  8. package/dist/cjs/index-CeIQtamz.js.map +1 -0
  9. package/dist/cjs/index.js +1 -1
  10. package/dist/cjs/migrate-C1svbbPB.js +2 -0
  11. package/dist/cjs/migrate-C1svbbPB.js.map +1 -0
  12. package/dist/cjs/package.json +8 -12
  13. package/dist/cjs/plugin.js +1 -1
  14. package/dist/cjs/plugin.js.map +1 -1
  15. package/dist/cjs/request-capture.middleware-Chm27VXb.js +2 -0
  16. package/dist/cjs/request-capture.middleware-Chm27VXb.js.map +1 -0
  17. package/dist/cjs/version-manifest-C15hpGGV.js +2 -0
  18. package/dist/cjs/version-manifest-C15hpGGV.js.map +1 -0
  19. package/dist/cjs/version-plugin-DtPQBhEK.js +2 -0
  20. package/dist/cjs/version-plugin-DtPQBhEK.js.map +1 -0
  21. package/dist/client/client.js +269 -125
  22. package/dist/client/client.js.map +1 -1
  23. package/dist/client/request-inspector.css +3 -0
  24. package/dist/client/request-inspector.css.map +1 -0
  25. package/dist/client/request-inspector.html +87 -0
  26. package/dist/client/request-inspector.js +527 -0
  27. package/dist/client/request-inspector.js.map +1 -0
  28. package/dist/client/variables-editor.css +3 -0
  29. package/dist/client/variables-editor.css.map +1 -0
  30. package/dist/client/variables-editor.html +44 -0
  31. package/dist/client/variables-editor.js +3419 -0
  32. package/dist/client/variables-editor.js.map +1 -0
  33. package/dist/esm/auth.provider-Cq0EuReh.js +2 -0
  34. package/dist/esm/auth.provider-Cq0EuReh.js.map +1 -0
  35. package/dist/esm/cli.js +1 -1
  36. package/dist/esm/cli.js.map +1 -1
  37. package/dist/esm/index-DioJ4R68.js +2 -0
  38. package/dist/esm/index-DioJ4R68.js.map +1 -0
  39. package/dist/esm/index.js +1 -1
  40. package/dist/esm/migrate-FpJKfYgz.js +2 -0
  41. package/dist/esm/{migrate-CCFtF5bt.js.map → migrate-FpJKfYgz.js.map} +1 -1
  42. package/dist/esm/package.json +8 -12
  43. package/dist/esm/plugin.js +1 -1
  44. package/dist/esm/plugin.js.map +1 -1
  45. package/dist/esm/request-capture.middleware-BNCdPdkF.js +2 -0
  46. package/dist/esm/request-capture.middleware-BNCdPdkF.js.map +1 -0
  47. package/dist/esm/version-manifest-qxz5FXuG.js +2 -0
  48. package/dist/esm/version-manifest-qxz5FXuG.js.map +1 -0
  49. package/dist/esm/version-plugin-b2xMXK9M.js +2 -0
  50. package/dist/esm/version-plugin-b2xMXK9M.js.map +1 -0
  51. package/dist/types/index.d.ts +46 -9
  52. package/docs/features.md +4 -1
  53. package/package.json +21 -28
  54. package/dist/cjs/index-CNJgcIGX.js +0 -2
  55. package/dist/cjs/index-CNJgcIGX.js.map +0 -1
  56. package/dist/cjs/migrate-DkUysGJq.js +0 -2
  57. package/dist/cjs/migrate-DkUysGJq.js.map +0 -1
  58. package/dist/cjs/plugin-BZ6G8J6V.js +0 -2
  59. package/dist/cjs/plugin-BZ6G8J6V.js.map +0 -1
  60. package/dist/cjs/version-plugin-433yyuTq.js +0 -2
  61. package/dist/cjs/version-plugin-433yyuTq.js.map +0 -1
  62. package/dist/esm/index-BeF76QPw.js +0 -2
  63. package/dist/esm/index-BeF76QPw.js.map +0 -1
  64. package/dist/esm/migrate-CCFtF5bt.js +0 -2
  65. package/dist/esm/plugin-l4fgvJz0.js +0 -2
  66. package/dist/esm/plugin-l4fgvJz0.js.map +0 -1
  67. package/dist/esm/version-plugin-Bqy1V1SQ.js +0 -2
  68. package/dist/esm/version-plugin-Bqy1V1SQ.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 = {
@@ -823,7 +866,7 @@ function initUsersPanel($panel, hooks) {
823
866
  const name = escapeHtml(user.name);
824
867
  if (user.kind === 'token' && user.name === editing) {
825
868
  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"
869
+ <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
870
  autocomplete="off" spellcheck="false" aria-label="Name" data-focus-key="${name}:edit-name"
828
871
  title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
829
872
  <input class="pp-dev-info__users-input pp-dev-info__users-edit-token" type="password" autocomplete="off"
@@ -868,6 +911,9 @@ function initUsersPanel($panel, hooks) {
868
911
  $open.addEventListener('click', (ev) => {
869
912
  ev.preventDefault();
870
913
  const target = $open.dataset.open;
914
+ if (!target) {
915
+ return;
916
+ }
871
917
  window.open($open.dataset.raw ? target : samePageOn(target), '_blank');
872
918
  });
873
919
  });
@@ -880,7 +926,7 @@ function initUsersPanel($panel, hooks) {
880
926
  $list.querySelectorAll('[data-edit]').forEach(($edit) => {
881
927
  $edit.addEventListener('click', (ev) => {
882
928
  ev.preventDefault();
883
- startEditing($edit.dataset.edit);
929
+ startEditing($edit.dataset.edit ?? null);
884
930
  });
885
931
  });
886
932
  $list.querySelector('[data-cancel-edit]')?.addEventListener('click', (ev) => {
@@ -892,8 +938,11 @@ function initUsersPanel($panel, hooks) {
892
938
  ev.preventDefault();
893
939
  const name = editing;
894
940
  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();
941
+ if (!name) {
942
+ return;
943
+ }
944
+ const newName = $form.querySelector('.pp-dev-info__users-edit-name')?.value.trim() ?? '';
945
+ const token = $form.querySelector('.pp-dev-info__users-edit-token')?.value.trim() ?? '';
897
946
  const response = await request('PATCH', `${USERS_API}/${encodeURIComponent(name)}`, {
898
947
  name: newName,
899
948
  ...(token ? { token } : {}),
@@ -914,7 +963,8 @@ function initUsersPanel($panel, hooks) {
914
963
  $list.querySelectorAll('[data-remove]').forEach(($remove) => {
915
964
  $remove.addEventListener('click', async (ev) => {
916
965
  ev.preventDefault();
917
- if (await request('DELETE', `${USERS_API}/${encodeURIComponent($remove.dataset.remove)}`)) {
966
+ const name = $remove.dataset.remove;
967
+ if (name && (await request('DELETE', `${USERS_API}/${encodeURIComponent(name)}`))) {
918
968
  await refresh();
919
969
  }
920
970
  });
@@ -995,20 +1045,34 @@ function initUsersPanel($panel, hooks) {
995
1045
  const $name = $popover.querySelector('.pp-dev-info__users-name-input');
996
1046
  const $token = $popover.querySelector('.pp-dev-info__users-token');
997
1047
  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' };
1048
+ if (!$name || !$token || !$submit) {
1049
+ return;
1050
+ }
1051
+ const name = $name.value.trim();
1052
+ const nameError = getUserNameError(name);
1053
+ if (nameError) {
1054
+ setError(nameError);
1055
+ return;
1056
+ }
1057
+ const body = addKind === 'token' ? { name, token: $token.value } : { name, kind: 'session' };
1058
+ // A session user logs in on a tab of its own. Opened here, while the click still counts as the
1059
+ // user's: opened after the request, Safari and Chrome blocked it as a popup.
1060
+ const loginTab = addKind === 'session' ? window.open('', '_blank') : null;
1001
1061
  $submit.disabled = true;
1002
1062
  const response = await request('POST', USERS_API, body);
1003
1063
  $submit.disabled = false;
1004
1064
  if (!response) {
1065
+ loginTab?.close();
1005
1066
  return;
1006
1067
  }
1007
1068
  const { user } = (await response.json());
1008
1069
  $name.value = '';
1009
1070
  $token.value = '';
1010
- if (user.kind === 'session' && user.loginUrl) {
1011
- window.open(user.loginUrl, '_blank');
1071
+ if (user.kind === 'session' && user.loginUrl && loginTab) {
1072
+ loginTab.location.href = user.loginUrl;
1073
+ }
1074
+ else {
1075
+ loginTab?.close();
1012
1076
  }
1013
1077
  await refresh();
1014
1078
  };
@@ -1033,8 +1097,8 @@ function initUsersPanel($panel, hooks) {
1033
1097
  <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="token">API token</button>
1034
1098
  <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="session">Username + password</button>
1035
1099
  </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])?"
1100
+ <input class="pp-dev-info__users-input pp-dev-info__users-name-input" placeholder="Name, e.g. viewer" maxlength="${MAX_USER_NAME_LENGTH}"
1101
+ autocomplete="off" spellcheck="false" pattern="${USER_NAME_PATTERN}"
1038
1102
  title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
1039
1103
  <input class="pp-dev-info__users-input pp-dev-info__users-token" type="password" placeholder="API token" autocomplete="off" />
1040
1104
  <button type="submit" class="pp-dev-info__vars-btn pp-dev-info__users-submit">Add user</button>
@@ -1050,7 +1114,7 @@ function initUsersPanel($panel, hooks) {
1050
1114
  renderKind();
1051
1115
  });
1052
1116
  });
1053
- $popover.querySelector('.pp-dev-info__users-add').addEventListener('submit', (ev) => {
1117
+ $popover.querySelector('.pp-dev-info__users-add')?.addEventListener('submit', (ev) => {
1054
1118
  ev.preventDefault();
1055
1119
  submit();
1056
1120
  });
@@ -1139,6 +1203,175 @@ function createPopupElement(opts) {
1139
1203
  $popup.appendChild($popupContent);
1140
1204
  return $popup;
1141
1205
  }
1206
+ /**
1207
+ * Calls `onElapsed` once the page has been visible for `duration` ms in all (time in a hidden tab
1208
+ * doesn't count). Returns a function that stops waiting, which also removes the page's
1209
+ * `visibilitychange` listener: one used to stay behind for every popup ever shown.
1210
+ */
1211
+ function startAutoDismiss(duration, onElapsed) {
1212
+ let remaining = duration;
1213
+ let lastUpdate = Date.now();
1214
+ let visible = true;
1215
+ let frame;
1216
+ const tick = () => {
1217
+ frame = undefined;
1218
+ if (!visible) {
1219
+ return;
1220
+ }
1221
+ const now = Date.now();
1222
+ remaining -= now - lastUpdate;
1223
+ lastUpdate = now;
1224
+ if (remaining <= 0) {
1225
+ stop();
1226
+ onElapsed();
1227
+ return;
1228
+ }
1229
+ frame = requestAnimationFrame(tick);
1230
+ };
1231
+ const onVisibilityChange = () => {
1232
+ visible = !document.hidden;
1233
+ if (visible) {
1234
+ lastUpdate = Date.now();
1235
+ frame ??= requestAnimationFrame(tick);
1236
+ }
1237
+ };
1238
+ const stop = () => {
1239
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1240
+ if (frame !== undefined) {
1241
+ cancelAnimationFrame(frame);
1242
+ frame = undefined;
1243
+ }
1244
+ };
1245
+ document.addEventListener('visibilitychange', onVisibilityChange);
1246
+ frame = requestAnimationFrame(tick);
1247
+ return stop;
1248
+ }
1249
+
1250
+ const TITLE_AVAILABLE = 'Sync template';
1251
+ const TITLE_UNAVAILABLE = 'Sync is unavailable on this instance';
1252
+ /**
1253
+ * Wires the dev panel's "Sync template" button to the dev server.
1254
+ *
1255
+ * The button is disabled while a sync runs, so a double click can't start a second build, and is
1256
+ * enabled again by whatever ends the sync: its response, or a lost connection. It stays disabled
1257
+ * only when the server says sync is unavailable on this instance (`config.canSync === false`);
1258
+ * a failed sync (MI unreachable, a build error) can simply be retried.
1259
+ */
1260
+ function initSyncButton(hot, $button, deps) {
1261
+ const reload = deps.reload ?? (() => window.location.reload());
1262
+ let available = !$button.disabled && !$button.classList.contains('disabled');
1263
+ let syncing = false;
1264
+ // Vite's connection is taken as open until reported lost: it may have opened before these handlers
1265
+ // were registered, and its client reloads the page once a lost one is back. The `pp-dev next`
1266
+ // transport says itself whether its socket is open, also while the first one is still connecting.
1267
+ let connected = true;
1268
+ const isConnected = () => connected && (hot.isConnected?.() ?? true);
1269
+ const applyState = () => {
1270
+ $button.disabled = syncing || !available;
1271
+ $button.classList.toggle('syncing', syncing);
1272
+ $button.classList.toggle('disabled', !available);
1273
+ $button.title = available ? TITLE_AVAILABLE : TITLE_UNAVAILABLE;
1274
+ if (syncing) {
1275
+ $button.setAttribute('aria-busy', 'true');
1276
+ }
1277
+ else {
1278
+ $button.removeAttribute('aria-busy');
1279
+ }
1280
+ };
1281
+ const endSync = () => {
1282
+ syncing = false;
1283
+ applyState();
1284
+ };
1285
+ hot.on('client:config:update', (data) => {
1286
+ if (typeof data?.config?.canSync !== 'boolean') {
1287
+ return;
1288
+ }
1289
+ // The server sends `canSync: true` at the start of every sync: it must not enable the
1290
+ // button while that sync runs, which `applyState` takes care of.
1291
+ available = data.config.canSync;
1292
+ applyState();
1293
+ });
1294
+ hot.on('template:sync:action-required', async (payload) => {
1295
+ // The spinner keeps running while a confirmation modal is shown: the sync is still in
1296
+ // progress and only ends on `template:sync:response`.
1297
+ const approved = await deps.confirmModal({
1298
+ title: payload.title,
1299
+ content: payload.content,
1300
+ confirmText: payload.confirmText,
1301
+ cancelText: payload.cancelText,
1302
+ });
1303
+ hot.send('template:sync:action-response', {
1304
+ requestId: payload.requestId,
1305
+ approved,
1306
+ });
1307
+ });
1308
+ hot.on('template:sync:response', (payload) => {
1309
+ deps.closeAllConfirmModals();
1310
+ if ('error' in payload && payload.config?.canSync === false) {
1311
+ available = false;
1312
+ }
1313
+ endSync();
1314
+ if ('cancelled' in payload && payload.cancelled) {
1315
+ deps.infoPopup({ title: 'Sync cancelled', content: payload.message, type: 'warning' });
1316
+ return;
1317
+ }
1318
+ if ('error' in payload && typeof payload.error !== 'undefined') {
1319
+ if (payload.code === SYNC_IN_PROGRESS_CODE) {
1320
+ deps.infoPopup({ title: 'Sync already running', content: payload.error, type: 'warning' });
1321
+ return;
1322
+ }
1323
+ deps.infoPopup({ title: 'Sync error', content: payload.error, type: 'danger' });
1324
+ if (payload.refresh) {
1325
+ setTimeout(reload);
1326
+ }
1327
+ return;
1328
+ }
1329
+ if ('syncedAt' in payload && typeof payload.syncedAt !== 'undefined') {
1330
+ deps.infoPopup({
1331
+ title: 'Sync success',
1332
+ content: `Synced at ${new Date(payload.syncedAt).toLocaleString()}.\nBackup filename: ${payload.backupFilename}`,
1333
+ type: 'success',
1334
+ });
1335
+ }
1336
+ });
1337
+ // A response sent to a socket that has closed never arrives. Without this, the button would
1338
+ // spin, disabled, until the page is reloaded.
1339
+ hot.on('vite:ws:connect', () => {
1340
+ connected = true;
1341
+ });
1342
+ hot.on('vite:ws:disconnect', () => {
1343
+ connected = false;
1344
+ if (!syncing) {
1345
+ return;
1346
+ }
1347
+ deps.closeAllConfirmModals();
1348
+ endSync();
1349
+ deps.infoPopup({
1350
+ title: 'Sync result unknown',
1351
+ content: 'The connection to the dev server was lost during the sync. Check its terminal for the result before syncing again.',
1352
+ type: 'warning',
1353
+ });
1354
+ });
1355
+ $button.addEventListener('click', (ev) => {
1356
+ ev.preventDefault();
1357
+ if (syncing || !available) {
1358
+ return;
1359
+ }
1360
+ // Sent now, the request would wait for a reconnect with the button spinning, and start a sync
1361
+ // whenever that happens. Say so instead, and let the click be repeated once connected.
1362
+ if (!isConnected()) {
1363
+ deps.infoPopup({
1364
+ title: 'No connection',
1365
+ content: 'The dev server is not connected. Check that it is running, then try again.',
1366
+ type: 'warning',
1367
+ });
1368
+ return;
1369
+ }
1370
+ syncing = true;
1371
+ applyState();
1372
+ hot.send('template:sync', {});
1373
+ });
1374
+ }
1142
1375
 
1143
1376
  /// <reference types="vite/client" />
1144
1377
  // Applied unconditionally (even if the panel itself is hidden) — this is what makes the
@@ -1211,7 +1444,16 @@ function animatePopup($popup, type) {
1211
1444
  function infoPopup(opts) {
1212
1445
  const $popup = createPopupElement(opts);
1213
1446
  const $closeButton = $popup.querySelector('.pp-dev-info__popup-title-close');
1447
+ let removed = false;
1448
+ let stopAutoDismiss;
1449
+ // Once only: closing it by hand used to leave its timer running, which removed it again and
1450
+ // counted it out of `activePopups` twice.
1214
1451
  const removePopup = async () => {
1452
+ if (removed) {
1453
+ return;
1454
+ }
1455
+ removed = true;
1456
+ stopAutoDismiss?.();
1215
1457
  await animatePopup($popup, 'exit');
1216
1458
  $popup.remove();
1217
1459
  updatePopupPositions();
@@ -1224,32 +1466,7 @@ function infoPopup(opts) {
1224
1466
  animatePopup($popup, 'enter');
1225
1467
  const duration = opts.duration ?? 10000;
1226
1468
  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);
1469
+ stopAutoDismiss = startAutoDismiss(duration, removePopup);
1253
1470
  }
1254
1471
  return { close: removePopup };
1255
1472
  }
@@ -1372,82 +1589,9 @@ if (hot) {
1372
1589
  hot.on('redirect', (data) => {
1373
1590
  window.location.href = data.url;
1374
1591
  });
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
1592
  const $syncButton = document.getElementById('sync-template');
1396
1593
  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
- });
1594
+ initSyncButton(hot, $syncButton, { infoPopup, confirmModal, closeAllConfirmModals });
1451
1595
  }
1452
1596
  hot.on('page-variables:reload:response', (payload) => {
1453
1597
  pendingReloadVariablesPopup?.close();