@cplieger/web-terminal-ui 7.0.0 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -55,7 +55,7 @@
55
55
  "@vitest/coverage-v8": "4.1.11",
56
56
  "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.2",
58
+ "happy-dom": "20.11.6",
59
59
  "html-validate": "11.8.0",
60
60
  "knip": "6.32.2",
61
61
  "prettier": "3.9.6",
@@ -188,9 +188,6 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
188
188
  clipboard.paste();
189
189
  });
190
190
  }
191
- if (menu.childElementCount === 0) {
192
- return; // nothing to offer
193
- }
194
191
  // Make visible (so it has measurable size), then place it within the
195
192
  // visible viewport via the shared point-anchored positioner (clamp to the
196
193
  // visual viewport; flip above the finger near the bottom edge).
@@ -244,7 +244,12 @@ async function createSessionHonouringRetry(
244
244
  isTornDown: () => boolean,
245
245
  ): Promise<SessionInfo> {
246
246
  const startedAt = Date.now();
247
- let lastAnnouncedAt = 0;
247
+ // The elapsed reading of the last announcement, or null when nothing has been
248
+ // announced yet. NOT a 0 sentinel: elapsed is legitimately 0 when the server
249
+ // refuses inside the first millisecond (a host on the same machine), and a 0
250
+ // sentinel read that as "never spoken" and re-announced on the next refusal —
251
+ // the one thing the throttle below exists to prevent.
252
+ let lastAnnouncedAt: number | null = null;
248
253
  for (;;) {
249
254
  try {
250
255
  return await api.create();
@@ -266,7 +271,7 @@ async function createSessionHonouringRetry(
266
271
  // only said "not yet" -- and the first announcement fires before any retry
267
272
  // has happened at all, so a user two seconds into a boot was told
268
273
  // "retrying" about a request that had been made exactly once.
269
- if (lastAnnouncedAt === 0 || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
274
+ if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
270
275
  lastAnnouncedAt = elapsed;
271
276
  const reason = err.serverMessage ?? "Server is not ready yet";
272
277
  ctx.toast(`${reason}; waiting…`, 8000);
@@ -2868,13 +2873,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2868
2873
  // still be), to the terminal otherwise. Recorded at entry because the exit
2869
2874
  // paths cannot tell them apart.
2870
2875
  let editFrom: "keyboard" | "pointer" = "pointer";
2871
- // Tab ids with a pinned-name request in flight. While an id is here, a status
2872
- // record carrying a DIFFERENT value is applied for display but does not
2873
- // supersede the request: SSE delivery order and REST mutation order are not
2874
- // one total order, so a record sampled BEFORE our PUT can arrive after it,
2875
- // and treating it as newer authority would suppress the request's own
2876
- // rollback and its failure toast.
2877
- const namesInFlight = new Set<string>();
2876
+ // Tab ids with pinned-name requests in flight, COUNTED per id. While a count
2877
+ // is held, a status record carrying a DIFFERENT value is applied for display
2878
+ // but does not supersede the request: SSE delivery order and REST mutation
2879
+ // order are not one total order, so a record sampled BEFORE our PUT can
2880
+ // arrive after it, and treating it as newer authority would suppress the
2881
+ // request's own rollback and its failure toast.
2882
+ // Counted rather than a Set of ids, because two renames of one tab can be out
2883
+ // at once and the invariant is "no request of OURS is in flight", not "no id
2884
+ // is marked": a bare Set let the first request's completion clear the marker
2885
+ // while the second's PUT was still open, and the second rename then failed
2886
+ // silently. A per-request token would carry more information, but the only
2887
+ // reader asks whether ANY request is out for this id, and the failure handler
2888
+ // already distinguishes its own supersession by (born, nameSeq).
2889
+ const namesInFlight = new Map<string, number>();
2878
2890
 
2879
2891
  /** restoreFocusAfterEdit sends focus where the entry path implies. The
2880
2892
  * terminal branch is gated exactly like switchTo's focus-on-switch: on a
@@ -2944,8 +2956,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2944
2956
  const { text, fallback } = baseLabel(t);
2945
2957
  ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
2946
2958
  }
2947
- namesInFlight.add(id);
2948
2959
  const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
2960
+ // Counted only once the request exists: an api implementation that throws
2961
+ // instead of rejecting would otherwise leave a count behind that no
2962
+ // .finally ever releases, and the id would read as in flight forever.
2963
+ namesInFlight.set(id, (namesInFlight.get(id) ?? 0) + 1);
2949
2964
  void request
2950
2965
  .catch(() => {
2951
2966
  const cur = tabList.find((x) => x.id === id);
@@ -2957,7 +2972,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2957
2972
  ctx.toast("Couldn't save the terminal name");
2958
2973
  })
2959
2974
  .finally(() => {
2960
- namesInFlight.delete(id);
2975
+ const left = (namesInFlight.get(id) ?? 0) - 1;
2976
+ if (left > 0) {
2977
+ namesInFlight.set(id, left);
2978
+ } else {
2979
+ namesInFlight.delete(id);
2980
+ }
2961
2981
  });
2962
2982
  return true;
2963
2983
  }
@@ -274,17 +274,21 @@ function buildTerminal(
274
274
  // Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
275
275
  // narrow embedded panel) or short (a phone in landscape). The one
276
276
  // ResizeObserver below fires on both width and height changes.
277
+ //
278
+ // Constructed unconditionally: ResizeObserver is a hard requirement of this
279
+ // library, not an enhancement. viewport.init() and the tabs strip both
280
+ // construct one with no guard, and the same buildTerminal builds a WeakRef
281
+ // (whose browser floor is strictly higher), so a `typeof ResizeObserver ===
282
+ // "function"` guard here could only ever protect a browser that crashes
283
+ // moments later. It used to be here, and it was measured dead.
277
284
  const isNarrow = (): boolean =>
278
285
  root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
279
286
  function updateNarrow(): void {
280
287
  root.classList.toggle("wt-narrow", isNarrow());
281
288
  }
282
289
  updateNarrow();
283
- let narrowObserver: ResizeObserver | null = null;
284
- if (typeof ResizeObserver === "function") {
285
- narrowObserver = new ResizeObserver(updateNarrow);
286
- narrowObserver.observe(root);
287
- }
290
+ let narrowObserver: ResizeObserver | null = new ResizeObserver(updateNarrow);
291
+ narrowObserver.observe(root);
288
292
 
289
293
  // --- Document title (kernel-owned, composed from two inputs) ---
290
294
  //
@@ -790,29 +794,29 @@ function buildTerminal(
790
794
  );
791
795
 
792
796
  /**
793
- * Applies the part of a `mapKeyboardEvent` result that BOTH keydown paths (the
794
- * focused textarea below, and the document-level type-to-focus fallback in the
795
- * focus-strategy section) handle identically. Returns false only for "ignore",
796
- * the one arm the two callers resolve differently: the textarea lets its own
797
- * `input` event deliver the character, while the fallback has no `input` event
798
- * coming and must send the character itself.
797
+ * Applies a `mapKeyboardEvent` result. BOTH keydown paths — the focused textarea
798
+ * below, and the document-level type-to-focus fallback in the focus-strategy
799
+ * section — handle every arm identically, including "ignore": the textarea lets
800
+ * its own `input` event deliver the character, and the fallback has already
801
+ * resolved that case before it gets here, computing the typed character itself
802
+ * because no `input` event is coming for a key that targeted the body.
799
803
  *
800
- * Switching over the whole `KeyboardResult` union in one place is what keeps
801
- * the two paths from drifting — handling only "send" in the fallback silently
802
- * dropped Shift+PageUp, which is exactly what a reader who just selected some
803
- * scrollback presses next.
804
+ * Switching over the whole `KeyboardResult` union in one place is what keeps the
805
+ * two paths from drifting — handling only "send" in the fallback silently dropped
806
+ * Shift+PageUp, which is exactly what a reader who just selected some scrollback
807
+ * presses next.
804
808
  */
805
- function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): boolean {
809
+ function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): void {
806
810
  switch (result.kind) {
807
811
  case "send":
808
812
  ev.preventDefault();
809
813
  sendText(result.bytes);
810
- return true;
814
+ return;
811
815
  case "scroll-up": {
812
816
  ev.preventDefault();
813
817
  const h = termWrap.clientHeight;
814
818
  termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
815
- return true;
819
+ return;
816
820
  }
817
821
  case "scroll-down": {
818
822
  ev.preventDefault();
@@ -823,10 +827,10 @@ function buildTerminal(
823
827
  // reasoning as the engine's bottom writes.
824
828
  const max = Math.max(0, termWrap.scrollHeight - h);
825
829
  termWrap.scrollTop = Math.min(max, termWrap.scrollTop + h);
826
- return true;
830
+ return;
827
831
  }
828
832
  case "ignore":
829
- return false;
833
+ return;
830
834
  }
831
835
  }
832
836
 
@@ -842,9 +846,9 @@ function buildTerminal(
842
846
  return;
843
847
  }
844
848
  }
845
- // A false return ("ignore") needs nothing here: the `input` listener above
846
- // delivers the character, which is also how IME and dead-key composition
847
- // output reaches the terminal.
849
+ // Nothing more is needed for an "ignore": the `input` listener above delivers
850
+ // the character, which is also how IME and dead-key composition output
851
+ // reaches the terminal.
848
852
  applyMappedKey(ev, mapKeyboardEvent(ev, modes));
849
853
  },
850
854
  { signal },