@cplieger/web-terminal-ui 8.0.1 → 8.1.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/README.md +53 -27
- package/css/00-tokens.css +1 -1
- package/css/01-scope.css +1 -1
- package/css/10-primitives.css +1 -1
- package/css/21-scroll-to-bottom.css +3 -3
- package/css/30-tabs.css +82 -29
- package/css/31-switcher.css +5 -0
- package/css/32-split.css +67 -15
- package/css/MANIFEST.touch +1 -1
- package/css/page.css +1 -1
- package/package.json +12 -12
- package/src/features/tabs/device-snap.ts +56 -0
- package/src/features/tabs/index.ts +212 -99
- package/src/features/tabs/strip.ts +8 -3
- package/src/kernel/pane.ts +34 -14
- package/src/kernel/shell.ts +91 -16
- package/src/kernel/split-handle.ts +37 -13
- package/src/kernel/types.ts +8 -1
- package/src/viewport.ts +166 -73
|
@@ -10,12 +10,13 @@ import type {
|
|
|
10
10
|
TerminalFeature,
|
|
11
11
|
Unsubscribe,
|
|
12
12
|
} from "../../kernel/types.js";
|
|
13
|
-
import { isPaneSide, MIN_SPLIT_AREA_PX } from "../../kernel/layout-policy.js";
|
|
13
|
+
import { isPaneSide, MIN_SPLIT_AREA_PX, SPLIT_GUTTER_PX } from "../../kernel/layout-policy.js";
|
|
14
14
|
import { windowOf } from "../../kernel/realm.js";
|
|
15
15
|
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
16
16
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
17
17
|
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
18
18
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
19
|
+
import { snapToDevicePixels } from "./device-snap.js";
|
|
19
20
|
import { centreChipLabels } from "./ink-centre.js";
|
|
20
21
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
21
22
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
@@ -94,19 +95,24 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
|
94
95
|
* an in-flight create, so a duplicate activation opens one terminal. `list` is a
|
|
95
96
|
* snapshot of display data, not a live view. */
|
|
96
97
|
export interface TabsApi {
|
|
97
|
-
/** Spawn a fresh session and show it
|
|
98
|
-
*
|
|
99
|
-
*
|
|
98
|
+
/** Spawn a fresh session and show it in the first empty pane, else (both
|
|
99
|
+
* panes showing a tab) in the unselected one, and select its pane. Calls made
|
|
100
|
+
* while a create is in flight share that create, so one gesture opens exactly
|
|
101
|
+
* one terminal. */
|
|
100
102
|
create(): Promise<void>;
|
|
101
103
|
/** Close a session (kills its process) and drop its tab + cache. */
|
|
102
104
|
close(id: string): Promise<void>;
|
|
103
105
|
/** Show a tab: in the first empty pane, else in the selected pane; a tab a
|
|
104
106
|
* pane already shows has that pane selected instead. */
|
|
105
107
|
switchTo(id: string): void;
|
|
106
|
-
/** Show a tab on one side of the split, opening it when closed
|
|
107
|
-
*
|
|
108
|
-
* tab
|
|
109
|
-
*
|
|
108
|
+
/** Show a tab on one side of the split, opening it when closed, and select its
|
|
109
|
+
* pane. Opening the split keeps the single view's tab on the other side — or,
|
|
110
|
+
* when the moved tab IS it, its split partner — so neither pane opens empty. A
|
|
111
|
+
* tab the other pane shows trades places with the tab on that side (or leaves
|
|
112
|
+
* its pane empty when the side showed none); any other tab there becomes
|
|
113
|
+
* ordinary. A tab already shown on that side changes nothing. False when
|
|
114
|
+
* refused: an unknown id, an invalid side, or a split the pane row is too
|
|
115
|
+
* narrow to open. */
|
|
110
116
|
snap(id: string, side: PaneSide): boolean;
|
|
111
117
|
/** The current tabs, first-to-last by creation; `active` marks a tab a pane
|
|
112
118
|
* shows. */
|
|
@@ -257,6 +263,12 @@ async function createSessionHonouringRetry(
|
|
|
257
263
|
}
|
|
258
264
|
}
|
|
259
265
|
|
|
266
|
+
interface ShowOptions {
|
|
267
|
+
readonly dir?: "next" | "prev" | undefined;
|
|
268
|
+
/** Leave the selection where it is, unless the selected pane shows nothing. */
|
|
269
|
+
readonly keepSelection?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
260
272
|
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
261
273
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
262
274
|
return true;
|
|
@@ -362,12 +374,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
362
374
|
const btn = fromHTML(doc, splitButtonHTML(cls));
|
|
363
375
|
ctx.defer(holdFocusOnPress(btn));
|
|
364
376
|
btn.addEventListener("click", () => {
|
|
365
|
-
|
|
366
|
-
if (split.isOpen()) {
|
|
367
|
-
split.close();
|
|
368
|
-
} else {
|
|
369
|
-
split.open();
|
|
370
|
-
}
|
|
377
|
+
toggleSplit();
|
|
371
378
|
});
|
|
372
379
|
splitButtons.push(btn);
|
|
373
380
|
return btn;
|
|
@@ -464,6 +471,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
464
471
|
const varRoot = ctx.shell.root;
|
|
465
472
|
const barResize = new win.ResizeObserver(() => {
|
|
466
473
|
varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
474
|
+
ctx.shell.chromeResized();
|
|
467
475
|
});
|
|
468
476
|
barResize.observe(bar);
|
|
469
477
|
ctx.defer(() => {
|
|
@@ -650,6 +658,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
650
658
|
// engine produced for THIS font at THIS size, rather than the em constant
|
|
651
659
|
// in 00-tokens.css that can only be right at one size (see ink-centre.ts).
|
|
652
660
|
ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
|
|
661
|
+
for (const b of splitButtons) {
|
|
662
|
+
const icon = b.querySelector("svg");
|
|
663
|
+
if (icon) {
|
|
664
|
+
ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
653
667
|
|
|
654
668
|
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
655
669
|
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
@@ -661,13 +675,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
661
675
|
// the root). Measure the bar row (not the expandable list, which just
|
|
662
676
|
// overlays content). innerHeight - rect.top captures the row plus the
|
|
663
677
|
// safe-area beneath it; the RO fires with the keyboard closed, so the value
|
|
664
|
-
// excludes the keyboard lift (viewport.ts adds that separately).
|
|
665
|
-
// synthetic visualViewport resize makes viewport.ts recompute immediately.
|
|
678
|
+
// excludes the keyboard lift (viewport.ts adds that separately).
|
|
666
679
|
const swReserve = new win.ResizeObserver(() => {
|
|
667
680
|
const rect = swBar.getBoundingClientRect();
|
|
668
681
|
const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
|
|
669
682
|
varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
|
|
670
|
-
|
|
683
|
+
ctx.shell.chromeResized();
|
|
671
684
|
});
|
|
672
685
|
swReserve.observe(swBar);
|
|
673
686
|
ctx.defer(() => {
|
|
@@ -745,6 +758,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
745
758
|
/** The tab that receives typing: the selected pane's. */
|
|
746
759
|
const selectedId = (): string | null => shownIn(ctx.shell.selected());
|
|
747
760
|
const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
|
|
761
|
+
/** Tabs the selected pane has shown, most recent first; fed from
|
|
762
|
+
* `syncChrome`, which every change of the selected tab ends in. */
|
|
763
|
+
const recent: string[] = [];
|
|
764
|
+
function noteSelected(): void {
|
|
765
|
+
const id = selectedId();
|
|
766
|
+
if (id === null || recent[0] === id) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const older = recent.filter((r) => r !== id && tabList.some((t) => t.id === r));
|
|
770
|
+
recent.splice(0, recent.length, id, ...older);
|
|
771
|
+
}
|
|
748
772
|
// Set by a bootstrap that showed nothing, cleared by the first showIn: the
|
|
749
773
|
// one case a status event may pick a tab to show.
|
|
750
774
|
let bootShowedNothing = false;
|
|
@@ -905,32 +929,49 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
905
929
|
const active = doc.activeElement;
|
|
906
930
|
return active !== null && (bar.contains(active) || switcher.contains(active));
|
|
907
931
|
}
|
|
932
|
+
function keyboardInPaneInput(): boolean {
|
|
933
|
+
const active = doc.activeElement;
|
|
934
|
+
return (
|
|
935
|
+
active !== null &&
|
|
936
|
+
active.classList.contains("term-input") &&
|
|
937
|
+
ctx.shell.panes().some((p) => p.root.contains(active))
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
function keyboardInOtherPane(): boolean {
|
|
941
|
+
return keyboardInPaneInput() && doc.activeElement !== termInput();
|
|
942
|
+
}
|
|
908
943
|
|
|
909
944
|
// The keyboard goes to the terminal input when a physical keyboard is likely
|
|
910
|
-
// (on a keyboard-less touchscreen every switch would pop the soft keyboard)
|
|
911
|
-
//
|
|
912
|
-
// strip's own keydown handling would eat every keystroke
|
|
913
|
-
//
|
|
945
|
+
// (on a keyboard-less touchscreen every switch would pop the soft keyboard),
|
|
946
|
+
// when the press behind this switch parked it on the chrome, where the
|
|
947
|
+
// strip's own keydown handling would eat every keystroke, or when it sits in
|
|
948
|
+
// the other pane's input, which would keep the typing; moving it from one
|
|
949
|
+
// input to another pops no keyboard. Never while a rename field is open.
|
|
914
950
|
function focusAfterSwitch(): void {
|
|
915
951
|
if (editingId !== null) {
|
|
916
952
|
return;
|
|
917
953
|
}
|
|
918
|
-
if (
|
|
954
|
+
if (
|
|
955
|
+
physicalKeyboardLikely() ||
|
|
956
|
+
(inputFocusedAtPress && keyboardParkedOnChrome()) ||
|
|
957
|
+
keyboardInOtherPane()
|
|
958
|
+
) {
|
|
919
959
|
focusInput();
|
|
920
960
|
}
|
|
921
961
|
}
|
|
922
962
|
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// browsing a scrolled strip is never yanked back by an unrelated repaint.
|
|
963
|
+
// The selected pane's chip is revealed in the scroller only when THAT TAB
|
|
964
|
+
// CHANGES, so a user browsing a scrolled strip is never yanked back by an
|
|
965
|
+
// unrelated repaint.
|
|
927
966
|
let lastRevealedActive = "";
|
|
928
967
|
function paintActive(): void {
|
|
929
968
|
const open = ctx.shell.split.isOpen();
|
|
969
|
+
const selectedSide = open ? ctx.shell.selected() : null;
|
|
930
970
|
for (const t of tabList) {
|
|
931
971
|
const side = sideOf(t.id);
|
|
932
972
|
const on = side !== null;
|
|
933
973
|
t.el.classList.toggle("wt-tab-active", on);
|
|
974
|
+
t.el.classList.toggle("wt-tab-selected", on && side === selectedSide);
|
|
934
975
|
t.aria.setPanel(side);
|
|
935
976
|
t.aria.setExpanded(open ? on : null);
|
|
936
977
|
// setSelected would undo setEditing(true) on the chip hosting the rename
|
|
@@ -1158,6 +1199,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1158
1199
|
|
|
1159
1200
|
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
1160
1201
|
function syncChrome(): void {
|
|
1202
|
+
noteSelected();
|
|
1161
1203
|
relabelAll();
|
|
1162
1204
|
paintActive();
|
|
1163
1205
|
syncMobile();
|
|
@@ -1518,9 +1560,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1518
1560
|
|
|
1519
1561
|
/** Show `id` in the pane on `side`: the pane's current tab becomes an
|
|
1520
1562
|
* ordinary tab, a pane elsewhere showing `id` is emptied first (a session is
|
|
1521
|
-
* never shown twice), and the pane is selected
|
|
1522
|
-
* pane that
|
|
1523
|
-
|
|
1563
|
+
* never shown twice), and the pane is selected unless `keepSelection` holds
|
|
1564
|
+
* it on a pane that shows a tab. False when `side` names no pane that can
|
|
1565
|
+
* show a tab (absent, failed or hidden) or `id` no tab. */
|
|
1566
|
+
function showIn(side: PaneSide, id: string, how: ShowOptions = {}): boolean {
|
|
1524
1567
|
const pane = ctx.shell.pane(side);
|
|
1525
1568
|
const next = tabList.find((t) => t.id === id);
|
|
1526
1569
|
if (pane === null || !next) {
|
|
@@ -1550,7 +1593,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1550
1593
|
}
|
|
1551
1594
|
// A later tab slides in from the right, an earlier one from the left, so a
|
|
1552
1595
|
// desktop switch feels like the mobile swipe.
|
|
1553
|
-
let slide = dir;
|
|
1596
|
+
let slide = how.dir;
|
|
1554
1597
|
const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
|
|
1555
1598
|
const toIdx = tabList.findIndex((t) => t.id === id);
|
|
1556
1599
|
if (slide === undefined && fromIdx >= 0) {
|
|
@@ -1581,6 +1624,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1581
1624
|
// the user just looked at.
|
|
1582
1625
|
acknowledgeSwitchNotify(next.id);
|
|
1583
1626
|
markCueSeen(next.id, next.dot.dataset["status"] ?? "");
|
|
1627
|
+
const moveSelection = how.keepSelection !== true || selectedId() === null;
|
|
1584
1628
|
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1585
1629
|
// follow flag is one per renderer, so a bind without it gated the first
|
|
1586
1630
|
// flush on the state of the tab we LEFT and the cached screen rendered
|
|
@@ -1591,7 +1635,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1591
1635
|
// add/remove row animation (the reel owns row motion here); set before the
|
|
1592
1636
|
// selection change, whose listener reconciles the list too.
|
|
1593
1637
|
reelReconcile = playReel !== undefined;
|
|
1594
|
-
|
|
1638
|
+
if (moveSelection) {
|
|
1639
|
+
ctx.shell.select(side);
|
|
1640
|
+
}
|
|
1595
1641
|
scheduleLayoutWrite();
|
|
1596
1642
|
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1597
1643
|
// paints from cache in one frame and must not flash a cue at every switch.
|
|
@@ -1605,8 +1651,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1605
1651
|
syncChrome(); // reconciles the expanded list into the new order
|
|
1606
1652
|
reelReconcile = false;
|
|
1607
1653
|
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
1608
|
-
|
|
1609
|
-
|
|
1654
|
+
if (moveSelection) {
|
|
1655
|
+
ctx.announce(`Switched to ${next.display}`);
|
|
1656
|
+
focusAfterSwitch();
|
|
1657
|
+
}
|
|
1610
1658
|
return true;
|
|
1611
1659
|
}
|
|
1612
1660
|
|
|
@@ -1619,21 +1667,67 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1619
1667
|
focusAfterSwitch();
|
|
1620
1668
|
return;
|
|
1621
1669
|
}
|
|
1622
|
-
showIn(ctx.shell.targetFor(id), id, dir);
|
|
1670
|
+
showIn(ctx.shell.targetFor(id), id, { dir });
|
|
1623
1671
|
}
|
|
1624
1672
|
|
|
1625
|
-
// The one rule every split entry point reduces to
|
|
1626
|
-
// tab, whatever was there becomes an ordinary tab, the tab's old side becomes
|
|
1627
|
-
// empty, and the tab's pane is selected.
|
|
1673
|
+
// The one rule every split entry point reduces to (`TabsApi.snap`).
|
|
1628
1674
|
function snap(id: string, side: PaneSide): boolean {
|
|
1629
1675
|
if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
|
|
1630
1676
|
return false;
|
|
1631
1677
|
}
|
|
1678
|
+
const from = sideOf(id);
|
|
1679
|
+
if (from === side) {
|
|
1680
|
+
return true;
|
|
1681
|
+
}
|
|
1632
1682
|
const split = ctx.shell.split;
|
|
1633
|
-
|
|
1683
|
+
const opening = !split.isOpen();
|
|
1684
|
+
if (opening && (!split.canOpen() || !split.open())) {
|
|
1634
1685
|
return false;
|
|
1635
1686
|
}
|
|
1636
|
-
|
|
1687
|
+
// Emptying the tab's old pane drops a keyboard its input held to the body.
|
|
1688
|
+
const typing = keyboardInPaneInput();
|
|
1689
|
+
const displaced = shownIn(side);
|
|
1690
|
+
if (!showIn(side, id)) {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
// Opening pairs the moved tab with the single view's tab, or its partner
|
|
1694
|
+
// when they are one, so no pane opens empty; an open split only re-homes
|
|
1695
|
+
// the displaced tab, into a pane the move emptied.
|
|
1696
|
+
const back = displaced ?? (opening ? splitPartner(id) : null);
|
|
1697
|
+
if (
|
|
1698
|
+
(from !== null || opening) &&
|
|
1699
|
+
back !== null &&
|
|
1700
|
+
back !== id &&
|
|
1701
|
+
shownIn(otherSide(side)) === null
|
|
1702
|
+
) {
|
|
1703
|
+
showIn(otherSide(side), back, { keepSelection: true });
|
|
1704
|
+
}
|
|
1705
|
+
if (typing) {
|
|
1706
|
+
focusInput();
|
|
1707
|
+
}
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function toggleSplit(): void {
|
|
1712
|
+
const split = ctx.shell.split;
|
|
1713
|
+
if (split.isOpen()) {
|
|
1714
|
+
split.close();
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const partner = splitPartner(selectedId());
|
|
1718
|
+
if (partner === null) {
|
|
1719
|
+
void create(true);
|
|
1720
|
+
} else if (split.open()) {
|
|
1721
|
+
showIn("right", partner, { keepSelection: true });
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
function splitPartner(of: string | null): string | null {
|
|
1725
|
+
const used = recent.find((id) => id !== of && tabList.some((t) => t.id === id));
|
|
1726
|
+
if (used !== undefined) {
|
|
1727
|
+
return used;
|
|
1728
|
+
}
|
|
1729
|
+
const at = tabList.findIndex((t) => t.id === of);
|
|
1730
|
+
return (tabList[at + 1] ?? tabList[at - 1])?.id ?? null;
|
|
1637
1731
|
}
|
|
1638
1732
|
|
|
1639
1733
|
// A switched-into tab's cached screen is stale until its resume delta lands,
|
|
@@ -2233,6 +2327,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2233
2327
|
if (after) {
|
|
2234
2328
|
scroller.insertBefore(tab.el, after.el);
|
|
2235
2329
|
}
|
|
2330
|
+
if (started) {
|
|
2331
|
+
showCreatedElsewhere(tab.id);
|
|
2332
|
+
}
|
|
2236
2333
|
}
|
|
2237
2334
|
|
|
2238
2335
|
// The runtime repair for a bootstrap whose list AND create both failed: the
|
|
@@ -2255,21 +2352,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2255
2352
|
// promise needs no threshold: a duplicate lands while the POST is open and
|
|
2256
2353
|
// collapses into it, while a deliberate second tap still opens a second
|
|
2257
2354
|
// terminal. It also stops "+" mashing from queueing sessions across the
|
|
2258
|
-
// server's retry window.
|
|
2355
|
+
// server's retry window. Any coalesced caller asking for the split gets it.
|
|
2259
2356
|
let creating: Promise<void> | null = null;
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2357
|
+
let createIntoSplit = false;
|
|
2358
|
+
function create(intoSplit = false): Promise<void> {
|
|
2359
|
+
if (creating === null) {
|
|
2360
|
+
createIntoSplit = intoSplit;
|
|
2361
|
+
creating = openNewSession().finally(() => {
|
|
2362
|
+
creating = null;
|
|
2363
|
+
});
|
|
2364
|
+
} else {
|
|
2365
|
+
createIntoSplit ||= intoSplit;
|
|
2366
|
+
}
|
|
2264
2367
|
return creating;
|
|
2265
2368
|
}
|
|
2266
2369
|
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2370
|
+
function showNewTab(id: string): void {
|
|
2371
|
+
const side = ctx.shell.targetFor(id);
|
|
2372
|
+
const other = otherSide(side);
|
|
2373
|
+
// Suppress the swipe reel for a new tab: the list should grow and fade the
|
|
2374
|
+
// new row in (animateRowIn) rather than rotate.
|
|
2375
|
+
creatingTab = true;
|
|
2376
|
+
showIn(shownIn(side) !== null && shownIn(other) !== null ? other : side, id);
|
|
2377
|
+
creatingTab = false;
|
|
2378
|
+
}
|
|
2379
|
+
// This client's own new session can reach the stream before its POST
|
|
2380
|
+
// answers, so the placement waits until a create in flight has shown its tab.
|
|
2381
|
+
function showCreatedElsewhere(id: string): void {
|
|
2382
|
+
if (creating !== null) {
|
|
2383
|
+
void creating.then(() => {
|
|
2384
|
+
showCreatedElsewhere(id);
|
|
2385
|
+
});
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (!tornDown() && ctx.shell.split.isOpen() && sideOf(id) === null) {
|
|
2389
|
+
showNewTab(id);
|
|
2272
2390
|
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
async function openNewSession(): Promise<void> {
|
|
2273
2394
|
let info: SessionInfo;
|
|
2274
2395
|
try {
|
|
2275
2396
|
info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
|
|
@@ -2295,18 +2416,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2295
2416
|
tab = addTabChrome(info);
|
|
2296
2417
|
tabList.push(tab);
|
|
2297
2418
|
}
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
const side =
|
|
2305
|
-
target !== undefined && ctx.shell.pane(target)?.state() === "empty"
|
|
2306
|
-
? target
|
|
2307
|
-
: ctx.shell.targetFor(tab.id);
|
|
2308
|
-
showIn(side, tab.id);
|
|
2309
|
-
creatingTab = false;
|
|
2419
|
+
// Opened once the tab exists, so a failed create leaves the single view
|
|
2420
|
+
// as it was rather than an empty pane.
|
|
2421
|
+
if (createIntoSplit && !ctx.shell.split.isOpen()) {
|
|
2422
|
+
ctx.shell.split.open();
|
|
2423
|
+
}
|
|
2424
|
+
showNewTab(tab.id);
|
|
2310
2425
|
}
|
|
2311
2426
|
|
|
2312
2427
|
// dropTab removes a tab's chrome + cache and, for a shown tab, decides what
|
|
@@ -2321,10 +2436,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2321
2436
|
}
|
|
2322
2437
|
// Closing the only remaining tab: spawn its replacement BEFORE removing
|
|
2323
2438
|
// this one, so the strip never empties and the "+" never teleports to the
|
|
2324
|
-
// far left while the create POST is in flight. The replacement lands
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
// not re-fire. A user close only.
|
|
2439
|
+
// far left while the create POST is in flight. The replacement lands as any
|
|
2440
|
+
// new tab does, and dropping the old tab is then an ordinary non-last
|
|
2441
|
+
// close, so this intercept does not re-fire. A user close only.
|
|
2328
2442
|
if (remote && tabList.length === 1 && tabList[0]?.id === id) {
|
|
2329
2443
|
await create();
|
|
2330
2444
|
// A failed create adds nothing (and toasts): keep the existing tab
|
|
@@ -2338,6 +2452,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2338
2452
|
// anything moves.
|
|
2339
2453
|
const side = sideOf(id);
|
|
2340
2454
|
const splitOpen = ctx.shell.split.isOpen();
|
|
2455
|
+
// Emptying a pane makes it inert, which drops a keyboard it held to the
|
|
2456
|
+
// body before the view moves; the keyboard follows the view instead.
|
|
2457
|
+
const typing = splitOpen && side !== null && keyboardInPaneInput();
|
|
2341
2458
|
const [tab] = tabList.splice(idx, 1);
|
|
2342
2459
|
if (!tab) {
|
|
2343
2460
|
return;
|
|
@@ -2387,26 +2504,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2387
2504
|
// What the panes show next is decided here, before the server's answer:
|
|
2388
2505
|
// selection must not sit on an emptied pane for a DELETE round trip.
|
|
2389
2506
|
let replacement: Promise<void> | undefined;
|
|
2390
|
-
if (side !== null &&
|
|
2391
|
-
|
|
2392
|
-
|
|
2507
|
+
if (side !== null && splitOpen && shownIn(otherSide(side)) !== null) {
|
|
2508
|
+
ctx.shell.split.closeSide(side);
|
|
2509
|
+
focusAfterSwitch();
|
|
2510
|
+
} else if (side !== null) {
|
|
2511
|
+
if (splitOpen) {
|
|
2512
|
+
ctx.shell.split.close();
|
|
2513
|
+
}
|
|
2393
2514
|
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2394
2515
|
if (neighbor) {
|
|
2395
2516
|
switchTo(neighbor.id);
|
|
2396
2517
|
} else {
|
|
2397
2518
|
replacement = create();
|
|
2398
2519
|
}
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
const other = otherSide(side);
|
|
2403
|
-
if (shownIn(other) !== null) {
|
|
2404
|
-
ctx.shell.select(other);
|
|
2405
|
-
focusAfterSwitch();
|
|
2406
|
-
} else if (!anyShown()) {
|
|
2407
|
-
replacement = create(other);
|
|
2408
|
-
}
|
|
2409
|
-
scheduleLayoutWrite();
|
|
2520
|
+
}
|
|
2521
|
+
if (typing) {
|
|
2522
|
+
focusInput();
|
|
2410
2523
|
}
|
|
2411
2524
|
if (remote) {
|
|
2412
2525
|
try {
|
|
@@ -2467,25 +2580,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2467
2580
|
}
|
|
2468
2581
|
}
|
|
2469
2582
|
// Re-home before the DELETEs: selection must not sit on an emptied pane for
|
|
2470
|
-
// a round trip.
|
|
2471
|
-
|
|
2583
|
+
// a round trip.
|
|
2584
|
+
let needsTab = !splitOpen && closingSelected;
|
|
2585
|
+
if (emptiedPane) {
|
|
2586
|
+
const keep = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
|
|
2587
|
+
if (keep === undefined) {
|
|
2588
|
+
ctx.shell.split.close();
|
|
2589
|
+
needsTab = true;
|
|
2590
|
+
} else {
|
|
2591
|
+
ctx.shell.split.closeSide(otherSide(keep));
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
if (needsTab && tabList[0]) {
|
|
2472
2595
|
switchTo(tabList[0].id);
|
|
2473
2596
|
} else {
|
|
2474
2597
|
syncChrome();
|
|
2475
|
-
if (splitOpen && closingSelected) {
|
|
2476
|
-
const shownSide = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
|
|
2477
|
-
if (shownSide !== undefined) {
|
|
2478
|
-
ctx.shell.select(shownSide);
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
if (emptiedPane) {
|
|
2482
|
-
scheduleLayoutWrite();
|
|
2483
|
-
}
|
|
2484
2598
|
}
|
|
2485
|
-
// One terminal always stays open
|
|
2599
|
+
// One terminal always stays open.
|
|
2486
2600
|
let replacement: Promise<void> | undefined;
|
|
2487
|
-
if (tabList.length === 0
|
|
2488
|
-
replacement = create(
|
|
2601
|
+
if (tabList.length === 0) {
|
|
2602
|
+
replacement = create();
|
|
2489
2603
|
}
|
|
2490
2604
|
// The person asked for these closes, so every DELETE still goes out past a
|
|
2491
2605
|
// teardown; only what the page shows for them stops.
|
|
@@ -3233,10 +3347,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3233
3347
|
endRestNet();
|
|
3234
3348
|
}
|
|
3235
3349
|
});
|
|
3236
|
-
// The
|
|
3237
|
-
//
|
|
3238
|
-
// caller's early return). An empty pane is `inert`, so a tab dragged over it
|
|
3239
|
-
// hits the shell root underneath.
|
|
3350
|
+
// The left width rounds as `--wt-split-left` does in 32-split.css, so the
|
|
3351
|
+
// boundary is the one the highlights draw.
|
|
3240
3352
|
function dropHalf(e: DragEvent): PaneSide | null {
|
|
3241
3353
|
const root = ctx.shell.root;
|
|
3242
3354
|
const target = e.target as Node;
|
|
@@ -3248,8 +3360,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3248
3360
|
) {
|
|
3249
3361
|
return null;
|
|
3250
3362
|
}
|
|
3251
|
-
const
|
|
3252
|
-
|
|
3363
|
+
const { left, width } = root.getBoundingClientRect();
|
|
3364
|
+
const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
|
|
3365
|
+
return e.clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
|
|
3253
3366
|
}
|
|
3254
3367
|
function paintDropHalf(half: PaneSide | null): void {
|
|
3255
3368
|
ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
|
|
@@ -74,9 +74,14 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
|
|
|
74
74
|
// background-tab notification dot rides on it (see switchButtonHTML). Same
|
|
75
75
|
// viewBox + stroke=currentcolor treatment as the others.
|
|
76
76
|
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
77
|
+
// The state is the button's aria-expanded, not its icon. The viewBox equals the
|
|
78
|
+
// 20px box 30-tabs.css draws it in, one unit per CSS px: that stylesheet's
|
|
79
|
+
// per-DPR geometry is written in those units.
|
|
80
|
+
const SPLIT_SVG =
|
|
81
|
+
`<svg class="wt-split-icon" viewBox="0 0 20 20" aria-hidden="true">` +
|
|
82
|
+
`<rect class="wt-split-icon-left" x="1" y="4" width="6" height="12" rx="1.5"/>` +
|
|
83
|
+
`<rect class="wt-split-icon-divider" x="9" y="2" width="2" height="16" rx="1"/>` +
|
|
84
|
+
`<rect class="wt-split-icon-right" x="13" y="4" width="6" height="12" rx="1.5"/></svg>`;
|
|
80
85
|
|
|
81
86
|
/** The ONE builder for a tab chip's content (status dot, label, progress bar,
|
|
82
87
|
* close), shared by the strip chip, the mobile active row and each mobile list
|