@cplieger/web-terminal-ui 5.4.4 → 5.4.6

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": "5.4.4",
3
+ "version": "5.4.6",
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",
@@ -678,13 +678,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
678
678
  // back to the stylesheet — including when a second drag interrupts the first.
679
679
  const shifted = new Set<HTMLElement>();
680
680
  let shiftTimer: ReturnType<typeof setTimeout> | null = null;
681
- // The slot waiting to open and the rest window that will open it.
682
- // `restTimer !== null` is the only "a slot is pending" flag — `restBefore` cannot
683
- // be one, because null is a legitimate value there meaning "past the last chip".
684
- // restX is the last pointer position seen, which is how movement is told from
685
- // stillness (see trackRest).
681
+ // The no-events fallback timer. It carries its own target, so there is
682
+ // deliberately NO pending-slot field here: one was the cause of the reorder
683
+ // dropping commits (see trackRest). restX is the last pointer position seen, which
684
+ // is how movement is told from stillness.
686
685
  let restTimer: ReturnType<typeof setTimeout> | null = null;
687
- let restBefore: HTMLElement | null = null;
688
686
  let restX: number | null = null;
689
687
  // When the pointer last actually MOVED. A stationary dragover this long after it
690
688
  // is believed as a stop (see trackRest).
@@ -744,26 +742,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
744
742
  // name, `list()` reports the real name, and clearing the progress needs no
745
743
  // cleanup — the next paint simply stops adding it.
746
744
  function relabelAll(): void {
747
- // Count only real (non-fallback) labels, so multiple untitled tabs all
748
- // read "New tab" without a numeric suffix.
749
- const counts = new Map<string, number>();
745
+ // De-duplicate identical labels with a "(k)" suffix, numbered by the session's
746
+ // AGE — never by its position in the strip.
747
+ //
748
+ // The suffix used to be assigned by encounter order while walking tabList, which
749
+ // made it a property of the SLOT: two tabs both called "workspace" always read
750
+ // "workspace" then "workspace (2)" whichever way round they sat, so the label
751
+ // text stayed put while the sessions moved underneath it. Dragging one of them
752
+ // changed nothing on screen, and the reorder — which had worked correctly all
753
+ // along, server included — was invisible. It also meant dragging tab A could
754
+ // renumber tab B, which is the clearest sign the number belonged to the wrong
755
+ // thing.
756
+ //
757
+ // A label answers "which session is this", so it has to be a property of the
758
+ // session: stable while other tabs move, and travelling with its own tab. Age is
759
+ // that key. createdAt (the server's, tie-broken by id) rather than the local
760
+ // `born` counter, so every device and every reload agrees on which "workspace"
761
+ // is (2) — `born` is adoption order, which races.
762
+ //
763
+ // Numbering is recomputed over the LIVE group each time, so it stays contiguous:
764
+ // closing "(2)" of three renumbers the survivor rather than leaving a hole. That
765
+ // is a renumber with a visible cause the user just performed, which is the one
766
+ // kind that reads as sensible.
767
+ const groups = new Map<string, Tab[]>();
750
768
  for (const t of tabList) {
751
769
  const { text, fallback } = baseLabel(t);
752
- if (!fallback) {
753
- counts.set(text, (counts.get(text) ?? 0) + 1);
770
+ // Fallback labels are not real names: several untitled tabs all read
771
+ // "New tab" with no numbering, which is the pre-existing behaviour.
772
+ if (fallback) {
773
+ continue;
774
+ }
775
+ const group = groups.get(text);
776
+ if (group) {
777
+ group.push(t);
778
+ } else {
779
+ groups.set(text, [t]);
754
780
  }
755
781
  }
756
- const seen = new Map<string, number>();
757
- for (const t of tabList) {
758
- const { text, fallback } = baseLabel(t);
759
- let display = text;
760
- if (!fallback && (counts.get(text) ?? 0) > 1) {
761
- const k = (seen.get(text) ?? 0) + 1;
762
- seen.set(text, k);
763
- if (k > 1) {
764
- display = `${text} (${String(k)})`;
765
- }
782
+ const ordinals = new Map<Tab, number>();
783
+ for (const group of groups.values()) {
784
+ if (group.length < 2) {
785
+ continue; // nothing to disambiguate from, so no suffix at all
766
786
  }
787
+ [...group]
788
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
789
+ .forEach((t, i) => {
790
+ ordinals.set(t, i + 1);
791
+ });
792
+ }
793
+ for (const t of tabList) {
794
+ const { text } = baseLabel(t);
795
+ const k = ordinals.get(t);
796
+ const display = k !== undefined && k > 1 ? `${text} (${String(k)})` : text;
767
797
  t.display = display;
768
798
  t.label.textContent = display;
769
799
  // The accessible name carries the state and the percentage as well as
@@ -1864,7 +1894,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1864
1894
  // was too small to tell them apart).
1865
1895
  //
1866
1896
  // A release never waits either: `drop` commits whatever slot is pending
1867
- // (flushRest), so dropping mid-sweep still lands the tab where it was headed.
1897
+ // (the drop handler commits the slot under the pointer), so dropping mid-sweep
1898
+ // still lands the tab exactly where it was headed.
1868
1899
  //
1869
1900
  // dropTargetBefore returns the first tab whose horizontal midpoint is past
1870
1901
  // x (the element the dragged tab should sit before), or null to drop at the
@@ -1971,21 +2002,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1971
2002
  if (!dragged) {
1972
2003
  return;
1973
2004
  }
2005
+ const now = Date.now();
1974
2006
  const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
1975
2007
  restX = clientX;
1976
- const before = dropTargetBefore(clientX);
1977
- // Already the slot the tab holds, so there is nothing to preview or commit.
1978
- // Withdraw anything pending: the pointer came back.
1979
- if (before === dragged || before === dragged.nextElementSibling) {
1980
- cancelRest();
1981
- return;
1982
- }
1983
- restBefore = before;
1984
- const now = Date.now();
1985
2008
  if (moved) {
1986
- // Still travelling. Push the net out and wait.
1987
2009
  restMovedAt = now;
1988
- armRestNet();
2010
+ }
2011
+ // The candidate is recomputed from THIS event, every time, and the commit below
2012
+ // uses that value rather than anything stored. That is the fix for a real
2013
+ // unreliability, so it must not be refactored back into a pending-target field:
2014
+ //
2015
+ // There used to be a `restBefore` holding the pending slot, and both the
2016
+ // already-there branch and the leave-the-strip paths nulled it. So a drag that
2017
+ // passed through "already there" on its way somewhere else lost the pending
2018
+ // state, and the stillness branch below — which then early-returned because no
2019
+ // timer was armed — committed NOTHING when the pointer stopped. The tab stayed on
2020
+ // its previous slot until the user jiggled the mouse to re-arm, which is exactly
2021
+ // the reported "it does not let go of the old drop spot". It hit leftward drags
2022
+ // hardest, because after each commit the dragged chip sits immediately left of
2023
+ // where the pointer is heading, so `nextElementSibling` matches constantly.
2024
+ const before = dropTargetBefore(clientX);
2025
+ // Already the slot it would land in, so there is nothing to commit. Only the net
2026
+ // is dropped; there is no pending target left to lose.
2027
+ if (before === dragged.nextElementSibling) {
2028
+ endRestNet();
1989
2029
  return;
1990
2030
  }
1991
2031
  // A dragover at an UNCHANGED position is positive evidence that the pointer has
@@ -1994,48 +2034,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1994
2034
  // cadence. The elapsed check filters the coincidence where one event of a sweep
1995
2035
  // lands within REORDER_MOVE_EPS_PX of the previous (a reversal, or a frame whose
1996
2036
  // motion was almost all vertical); a real stop clears it on the next event.
1997
- if (now - restMovedAt >= REORDER_STILL_MS) {
1998
- flushRest();
2037
+ if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
2038
+ endRestNet();
2039
+ commitSlot(before);
1999
2040
  return;
2000
2041
  }
2001
- armRestNet(); // too soon to believe; keep the net alive
2042
+ armRestNet(before);
2002
2043
  }
2003
2044
  // armRestNet is the no-events fallback: if dragover stops arriving altogether, no
2004
- // stationary event will ever confirm the stop, so this commits anyway. Sized to
2005
- // out-wait the cadence (see REORDER_REST_MS) because it is the one timer that a
2006
- // fast sweep could otherwise expire between two of its own events.
2007
- function armRestNet(): void {
2008
- if (restTimer !== null) {
2009
- clearTimeout(restTimer);
2010
- }
2045
+ // stationary event will ever confirm the stop, so this commits anyway. It carries
2046
+ // its own target for the same reason trackRest recomputes one — a net whose target
2047
+ // lived in shared mutable state was how the old pending slot got lost. Sized to
2048
+ // out-wait the cadence (see REORDER_REST_MS) because it is the one timer a fast
2049
+ // sweep could otherwise expire between two of its own events.
2050
+ function armRestNet(before: HTMLElement | null): void {
2051
+ endRestNet();
2011
2052
  restTimer = setTimeout(() => {
2012
2053
  restTimer = null;
2013
- const target = restBefore;
2014
- restBefore = null;
2015
- commitSlot(target);
2054
+ commitSlot(before);
2016
2055
  }, REORDER_REST_MS);
2017
2056
  }
2018
- function cancelRest(): void {
2019
- if (restTimer === null) {
2020
- return;
2021
- }
2022
- clearTimeout(restTimer);
2023
- restTimer = null;
2024
- restBefore = null;
2025
- }
2026
- // flushRest commits a pending slot at once, because a release is a decision. It is
2027
- // what stops the rest window from ever being felt as a floor on the gesture: drop
2028
- // mid-sweep and the tab still lands where it was headed. Only a `drop` calls it;
2029
- // an abandoned drag reaches dragend without one and reverts.
2030
- function flushRest(): void {
2031
- if (restTimer === null) {
2032
- return;
2057
+ function endRestNet(): void {
2058
+ if (restTimer !== null) {
2059
+ clearTimeout(restTimer);
2060
+ restTimer = null;
2033
2061
  }
2034
- const target = restBefore;
2035
- clearTimeout(restTimer);
2036
- restTimer = null;
2037
- restBefore = null;
2038
- commitSlot(target);
2039
2062
  }
2040
2063
 
2041
2064
  // flipTo runs a rearranging mutation and animates its result: every chip that
@@ -2231,7 +2254,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2231
2254
  // start from rest, and a torn-down feature may leave no inline style on an
2232
2255
  // element it no longer owns.
2233
2256
  function endReorderPreview(): void {
2234
- cancelRest();
2257
+ endRestNet();
2235
2258
  restX = null;
2236
2259
  endSlotFade();
2237
2260
  }
@@ -2251,9 +2274,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2251
2274
  // in progress" and so would preventDefault every unrelated file or text drop on
2252
2275
  // the page from then on.
2253
2276
  function abortReorderFor(removed: HTMLElement): void {
2254
- if (removed === restBefore) {
2255
- cancelRest();
2256
- }
2257
2277
  if (removed !== draggingEl) {
2258
2278
  return;
2259
2279
  }
@@ -3327,7 +3347,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3327
3347
  }
3328
3348
  e.preventDefault();
3329
3349
  dropped = true;
3330
- flushRest(); // a release decides: never make a drop wait on the rest window
3350
+ // A release decides, and it decides by POSITION: commit the slot the pointer is
3351
+ // actually over rather than whatever a timer had pending. commitSlot no-ops when
3352
+ // the tab already sits there, so a drop with nothing to move costs nothing.
3353
+ endRestNet();
3354
+ commitSlot(dropTargetBefore(e.clientX));
3331
3355
  syncOrderFromDom();
3332
3356
  announceMoved(moved); // the ONE completed-move announcement per gesture
3333
3357
  });
@@ -3350,7 +3374,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3350
3374
  }
3351
3375
  const next = e.relatedTarget;
3352
3376
  if (next === null || !bar.contains(next as Node)) {
3353
- cancelRest();
3377
+ endRestNet();
3354
3378
  }
3355
3379
  });
3356
3380
  // A tab released anywhere OTHER than the strip — over the terminal, over
@@ -3380,14 +3404,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3380
3404
  // So: leave `dropped` false, withdraw anything pending, and let dragend run the
3381
3405
  // revert. Nothing is lost silently — the revert animates and announces, so a
3382
3406
  // mis-release reads as "that did not take" rather than as a mystery.
3383
- cancelRest();
3407
+ endRestNet();
3384
3408
  return;
3385
3409
  }
3386
3410
  // dragover, off the strip: no candidate slot exists out here, so withdraw a
3387
3411
  // pending one rather than let the rest window open a gap for a target the pointer
3388
3412
  // has already left. A slot already COMMITTED stays put; the pointer may yet come
3389
3413
  // back onto the strip and release there.
3390
- cancelRest();
3414
+ endRestNet();
3391
3415
  };
3392
3416
  document.addEventListener("dragover", onDocTabDrop);
3393
3417
  document.addEventListener("drop", onDocTabDrop);
@@ -31,7 +31,7 @@ import { statusPhrase, statusRevealsDot } from "./model.js";
31
31
  * pointer announces itself; this only filters the coincidence where one event of a
32
32
  * sweep happens to land within REORDER_MOVE_EPS_PX of the previous one (a direction
33
33
  * reversal, or a frame where the motion was almost entirely vertical). */
34
- export const REORDER_STILL_MS = 90;
34
+ export const REORDER_STILL_MS = 50;
35
35
 
36
36
  /** Fallback guard (ms): commit the pending slot this long after the last MOVEMENT, for
37
37
  * the case where `dragover` stops arriving altogether and no stationary event ever