@cplieger/web-terminal-ui 4.8.0 → 4.8.1
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 +1 -1
- package/src/features/tabs/index.ts +96 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.1",
|
|
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": "GPL-3.0-or-later",
|
|
@@ -79,7 +79,8 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
|
79
79
|
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
80
80
|
|
|
81
81
|
export interface TabsApi {
|
|
82
|
-
/** Spawn a fresh session and switch to it.
|
|
82
|
+
/** Spawn a fresh session and switch to it. Calls made while a create is in
|
|
83
|
+
* flight share that create, so one gesture opens exactly one terminal. */
|
|
83
84
|
create(): Promise<void>;
|
|
84
85
|
/** Close a session (kills its process) and drop its tab + cache. */
|
|
85
86
|
close(id: string): Promise<void>;
|
|
@@ -297,7 +298,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
297
298
|
// the FIRST tap to blur the input (so "+" only fired create on the second
|
|
298
299
|
// tap — the reported double-tap). preventDefault on pointerdown fires the
|
|
299
300
|
// action on the first tap; switchTo then re-focuses the input for the new
|
|
300
|
-
// tab
|
|
301
|
+
// tab (see focusAfterSwitch). It does NOT make one press one activation —
|
|
302
|
+
// that is create()'s in-flight coalescing, for the opposite failure.
|
|
301
303
|
btn.addEventListener("pointerdown", (e) => {
|
|
302
304
|
e.preventDefault();
|
|
303
305
|
});
|
|
@@ -657,8 +659,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
657
659
|
}
|
|
658
660
|
}
|
|
659
661
|
|
|
662
|
+
const termInput = (): HTMLElement | null =>
|
|
663
|
+
ctx.surface().querySelector<HTMLElement>(".term-input");
|
|
660
664
|
function focusInput(): void {
|
|
661
|
-
|
|
665
|
+
termInput()?.focus({ preventScroll: true });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Did the terminal input own the keyboard when the pointer press now being
|
|
669
|
+
// handled began? A press on tab chrome focuses the control it landed on
|
|
670
|
+
// (the browser's mousedown default action), so by the time the click
|
|
671
|
+
// handler runs the input is already blurred and only a pointerdown-time
|
|
672
|
+
// snapshot still knows where the keyboard came from. Recorded for the whole
|
|
673
|
+
// chrome by the two pointerdown listeners below (the strip and the
|
|
674
|
+
// switcher), not per control: chips come and go with sessions, and the fact
|
|
675
|
+
// belongs to the gesture rather than to what it hit.
|
|
676
|
+
let inputFocusedAtPress = false;
|
|
677
|
+
function noteChromePress(): void {
|
|
678
|
+
const el = termInput();
|
|
679
|
+
inputFocusedAtPress = el !== null && document.activeElement === el;
|
|
680
|
+
}
|
|
681
|
+
function keyboardParkedOnChrome(): boolean {
|
|
682
|
+
const active = document.activeElement;
|
|
683
|
+
return active !== null && (bar.contains(active) || switcher.contains(active));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// focusAfterSwitch applies the tab-switch focus rule. Two independent
|
|
687
|
+
// reasons to hand the keyboard to the terminal input:
|
|
688
|
+
//
|
|
689
|
+
// - A physical keyboard is (likely) present, so a switch should leave you
|
|
690
|
+
// able to type at once, with no extra tap (the iPad + Magic Keyboard
|
|
691
|
+
// ask). Skipped on a keyboard-less touchscreen, or every switch would
|
|
692
|
+
// pop the virtual keyboard (#7).
|
|
693
|
+
// - The press that drove this switch took the keyboard OFF the terminal —
|
|
694
|
+
// it focused the chip (or the x, or a switcher row) it pressed, and the
|
|
695
|
+
// keyboard is still parked there. Handing it back is not NEW focus, so
|
|
696
|
+
// it pops no soft keyboard: it restores what the press displaced. Both
|
|
697
|
+
// halves are required for exactly that reason — a switch with no press
|
|
698
|
+
// behind it (a remote adopt, ensureActive), or a press on a device where
|
|
699
|
+
// the input was not focused to begin with, must not summon a keyboard.
|
|
700
|
+
// Leaving the keyboard parked on a chip is not neutral either: the
|
|
701
|
+
// strip's own keydown handling reads arrows as "switch tab" and Delete
|
|
702
|
+
// as "close tab", so every keystroke meant for the terminal is eaten.
|
|
703
|
+
//
|
|
704
|
+
// Never while a rename field is open: it owns the keyboard until it closes,
|
|
705
|
+
// and a switch (the leading click of a double-click, a remote-driven
|
|
706
|
+
// ensureActive) would otherwise yank the caret out of the field.
|
|
707
|
+
function focusAfterSwitch(): void {
|
|
708
|
+
if (editingId !== null) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
|
|
712
|
+
focusInput();
|
|
713
|
+
}
|
|
662
714
|
}
|
|
663
715
|
|
|
664
716
|
// paintActive updates the desktop strip's active state. When the ACTIVE
|
|
@@ -1249,6 +1301,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1249
1301
|
|
|
1250
1302
|
function switchTo(id: string, dir?: "next" | "prev"): void {
|
|
1251
1303
|
if (id === activeId) {
|
|
1304
|
+
// Already active, so there is no switch to perform — but the FOCUS rule
|
|
1305
|
+
// still owes an answer, because the press that delivered this click has
|
|
1306
|
+
// already moved the keyboard onto the chip. Two pointer paths land here:
|
|
1307
|
+
// clicking the tab that is already active, and the SECOND activation of
|
|
1308
|
+
// one press on another tab (see create() for the evidence that this
|
|
1309
|
+
// device delivers those). The first activation switches and focuses the
|
|
1310
|
+
// terminal; the second one's mousedown re-focuses the chip and then fell
|
|
1311
|
+
// out here with the keyboard stranded on it — the reported "the
|
|
1312
|
+
// invisible input loses focus when I click another tab", and the same
|
|
1313
|
+
// hole a plain mouse click on the active tab hit on every platform.
|
|
1314
|
+
focusAfterSwitch();
|
|
1252
1315
|
return;
|
|
1253
1316
|
}
|
|
1254
1317
|
const next = tabList.find((t) => t.id === id);
|
|
@@ -1339,16 +1402,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1339
1402
|
}
|
|
1340
1403
|
});
|
|
1341
1404
|
ctx.announce(`Switched to ${next.display}`);
|
|
1342
|
-
|
|
1343
|
-
// A physical keyboard is (likely) present, so focus the input on
|
|
1344
|
-
// switch — switch and type immediately (the iPad + Magic Keyboard
|
|
1345
|
-
// ask). On a keyboard-less touchscreen this is skipped, or every
|
|
1346
|
-
// switch would pop the virtual keyboard (#7). Skipped entirely while a
|
|
1347
|
-
// rename field is open: a switch (e.g. the leading click of a
|
|
1348
|
-
// double-click, or a remote-driven ensureActive) would otherwise yank
|
|
1349
|
-
// the caret out of the field the user is typing in.
|
|
1350
|
-
focusInput();
|
|
1351
|
-
}
|
|
1405
|
+
focusAfterSwitch();
|
|
1352
1406
|
}
|
|
1353
1407
|
|
|
1354
1408
|
// armCatchup shows the "catching up" cue only if the resume delta has not
|
|
@@ -1590,7 +1644,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1590
1644
|
}
|
|
1591
1645
|
}
|
|
1592
1646
|
|
|
1593
|
-
|
|
1647
|
+
// create is the coalesced door: while one create is in flight, every caller
|
|
1648
|
+
// asking for another gets THAT one, so a single gesture can only ever open
|
|
1649
|
+
// one terminal. Spawning a session is expensive and non-idempotent, and one
|
|
1650
|
+
// press does not reliably arrive as one activation — the web-terminal-kiro
|
|
1651
|
+
// server logged two POST /api/sessions 0-3ms apart (three times in one
|
|
1652
|
+
// afternoon, both answered 201) for single "+" taps from an iPad + Magic
|
|
1653
|
+
// Keyboard, so the user got two terminals and had to close one. Sharing the
|
|
1654
|
+
// in-flight promise needs no threshold to make that decision, unlike a
|
|
1655
|
+
// click-level debounce: a duplicate activation lands while the POST is still
|
|
1656
|
+
// open and collapses into it, while a deliberate second tap (the POST
|
|
1657
|
+
// resolves in milliseconds) still opens a second terminal. It also stops "+"
|
|
1658
|
+
// mashing from queueing sessions across the server's 503 install window,
|
|
1659
|
+
// where createSessionHonouringRetry legitimately waits minutes.
|
|
1660
|
+
let creating: Promise<void> | null = null;
|
|
1661
|
+
function create(): Promise<void> {
|
|
1662
|
+
creating ??= openNewSession().finally(() => {
|
|
1663
|
+
creating = null;
|
|
1664
|
+
});
|
|
1665
|
+
return creating;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function openNewSession(): Promise<void> {
|
|
1594
1669
|
let info: SessionInfo;
|
|
1595
1670
|
try {
|
|
1596
1671
|
info = await createSessionHonouringRetry(api, ctx, () => tornDown);
|
|
@@ -2041,15 +2116,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2041
2116
|
const menuSwallow = createClickSwallow();
|
|
2042
2117
|
let menuTouch = false;
|
|
2043
2118
|
// One listener on the bar rather than one per chip: chips are created and
|
|
2044
|
-
// destroyed as sessions come and go, and
|
|
2045
|
-
// gesture,
|
|
2119
|
+
// destroyed as sessions come and go, and both facts recorded here (the
|
|
2120
|
+
// gesture's pointer type, and whether it took the keyboard off the
|
|
2121
|
+
// terminal) belong to the gesture, not to a chip.
|
|
2046
2122
|
bar.addEventListener(
|
|
2047
2123
|
"pointerdown",
|
|
2048
2124
|
(e) => {
|
|
2049
2125
|
menuTouch = e.pointerType === "touch";
|
|
2126
|
+
noteChromePress();
|
|
2050
2127
|
},
|
|
2051
2128
|
{ passive: true },
|
|
2052
2129
|
);
|
|
2130
|
+
// The switcher's half of that snapshot (its rows and the x are buttons, so
|
|
2131
|
+
// pressing one focuses it and blurs the terminal exactly as a chip does).
|
|
2132
|
+
switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
|
|
2053
2133
|
function hideTabMenu(): void {
|
|
2054
2134
|
tabMenu.classList.remove("visible");
|
|
2055
2135
|
tabMenu.replaceChildren();
|