@cplieger/web-terminal-ui 8.0.1 → 8.1.0
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 +51 -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 +55 -13
- 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 +200 -98
- 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,22 @@ 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
|
-
*
|
|
109
|
-
*
|
|
108
|
+
/** Show a tab on one side of the split, opening it when closed, and select its
|
|
109
|
+
* pane. A tab the other pane shows trades places with the tab on that side
|
|
110
|
+
* (or leaves its pane empty when the side showed none); any other tab there
|
|
111
|
+
* becomes ordinary. A tab already shown on that side changes nothing. False
|
|
112
|
+
* when refused: an unknown id, an invalid side, or a split the pane row is too
|
|
113
|
+
* narrow to open. */
|
|
110
114
|
snap(id: string, side: PaneSide): boolean;
|
|
111
115
|
/** The current tabs, first-to-last by creation; `active` marks a tab a pane
|
|
112
116
|
* shows. */
|
|
@@ -257,6 +261,12 @@ async function createSessionHonouringRetry(
|
|
|
257
261
|
}
|
|
258
262
|
}
|
|
259
263
|
|
|
264
|
+
interface ShowOptions {
|
|
265
|
+
readonly dir?: "next" | "prev" | undefined;
|
|
266
|
+
/** Leave the selection where it is, unless the selected pane shows nothing. */
|
|
267
|
+
readonly keepSelection?: boolean;
|
|
268
|
+
}
|
|
269
|
+
|
|
260
270
|
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
261
271
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
262
272
|
return true;
|
|
@@ -362,12 +372,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
362
372
|
const btn = fromHTML(doc, splitButtonHTML(cls));
|
|
363
373
|
ctx.defer(holdFocusOnPress(btn));
|
|
364
374
|
btn.addEventListener("click", () => {
|
|
365
|
-
|
|
366
|
-
if (split.isOpen()) {
|
|
367
|
-
split.close();
|
|
368
|
-
} else {
|
|
369
|
-
split.open();
|
|
370
|
-
}
|
|
375
|
+
toggleSplit();
|
|
371
376
|
});
|
|
372
377
|
splitButtons.push(btn);
|
|
373
378
|
return btn;
|
|
@@ -464,6 +469,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
464
469
|
const varRoot = ctx.shell.root;
|
|
465
470
|
const barResize = new win.ResizeObserver(() => {
|
|
466
471
|
varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
472
|
+
ctx.shell.chromeResized();
|
|
467
473
|
});
|
|
468
474
|
barResize.observe(bar);
|
|
469
475
|
ctx.defer(() => {
|
|
@@ -650,6 +656,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
650
656
|
// engine produced for THIS font at THIS size, rather than the em constant
|
|
651
657
|
// in 00-tokens.css that can only be right at one size (see ink-centre.ts).
|
|
652
658
|
ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
|
|
659
|
+
for (const b of splitButtons) {
|
|
660
|
+
const icon = b.querySelector("svg");
|
|
661
|
+
if (icon) {
|
|
662
|
+
ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
653
665
|
|
|
654
666
|
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
655
667
|
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
@@ -661,13 +673,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
661
673
|
// the root). Measure the bar row (not the expandable list, which just
|
|
662
674
|
// overlays content). innerHeight - rect.top captures the row plus the
|
|
663
675
|
// 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.
|
|
676
|
+
// excludes the keyboard lift (viewport.ts adds that separately).
|
|
666
677
|
const swReserve = new win.ResizeObserver(() => {
|
|
667
678
|
const rect = swBar.getBoundingClientRect();
|
|
668
679
|
const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
|
|
669
680
|
varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
|
|
670
|
-
|
|
681
|
+
ctx.shell.chromeResized();
|
|
671
682
|
});
|
|
672
683
|
swReserve.observe(swBar);
|
|
673
684
|
ctx.defer(() => {
|
|
@@ -745,6 +756,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
745
756
|
/** The tab that receives typing: the selected pane's. */
|
|
746
757
|
const selectedId = (): string | null => shownIn(ctx.shell.selected());
|
|
747
758
|
const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
|
|
759
|
+
/** Tabs the selected pane has shown, most recent first; fed from
|
|
760
|
+
* `syncChrome`, which every change of the selected tab ends in. */
|
|
761
|
+
const recent: string[] = [];
|
|
762
|
+
function noteSelected(): void {
|
|
763
|
+
const id = selectedId();
|
|
764
|
+
if (id === null || recent[0] === id) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const older = recent.filter((r) => r !== id && tabList.some((t) => t.id === r));
|
|
768
|
+
recent.splice(0, recent.length, id, ...older);
|
|
769
|
+
}
|
|
748
770
|
// Set by a bootstrap that showed nothing, cleared by the first showIn: the
|
|
749
771
|
// one case a status event may pick a tab to show.
|
|
750
772
|
let bootShowedNothing = false;
|
|
@@ -905,32 +927,49 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
905
927
|
const active = doc.activeElement;
|
|
906
928
|
return active !== null && (bar.contains(active) || switcher.contains(active));
|
|
907
929
|
}
|
|
930
|
+
function keyboardInPaneInput(): boolean {
|
|
931
|
+
const active = doc.activeElement;
|
|
932
|
+
return (
|
|
933
|
+
active !== null &&
|
|
934
|
+
active.classList.contains("term-input") &&
|
|
935
|
+
ctx.shell.panes().some((p) => p.root.contains(active))
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
function keyboardInOtherPane(): boolean {
|
|
939
|
+
return keyboardInPaneInput() && doc.activeElement !== termInput();
|
|
940
|
+
}
|
|
908
941
|
|
|
909
942
|
// 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
|
-
//
|
|
943
|
+
// (on a keyboard-less touchscreen every switch would pop the soft keyboard),
|
|
944
|
+
// when the press behind this switch parked it on the chrome, where the
|
|
945
|
+
// strip's own keydown handling would eat every keystroke, or when it sits in
|
|
946
|
+
// the other pane's input, which would keep the typing; moving it from one
|
|
947
|
+
// input to another pops no keyboard. Never while a rename field is open.
|
|
914
948
|
function focusAfterSwitch(): void {
|
|
915
949
|
if (editingId !== null) {
|
|
916
950
|
return;
|
|
917
951
|
}
|
|
918
|
-
if (
|
|
952
|
+
if (
|
|
953
|
+
physicalKeyboardLikely() ||
|
|
954
|
+
(inputFocusedAtPress && keyboardParkedOnChrome()) ||
|
|
955
|
+
keyboardInOtherPane()
|
|
956
|
+
) {
|
|
919
957
|
focusInput();
|
|
920
958
|
}
|
|
921
959
|
}
|
|
922
960
|
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// browsing a scrolled strip is never yanked back by an unrelated repaint.
|
|
961
|
+
// The selected pane's chip is revealed in the scroller only when THAT TAB
|
|
962
|
+
// CHANGES, so a user browsing a scrolled strip is never yanked back by an
|
|
963
|
+
// unrelated repaint.
|
|
927
964
|
let lastRevealedActive = "";
|
|
928
965
|
function paintActive(): void {
|
|
929
966
|
const open = ctx.shell.split.isOpen();
|
|
967
|
+
const selectedSide = open ? ctx.shell.selected() : null;
|
|
930
968
|
for (const t of tabList) {
|
|
931
969
|
const side = sideOf(t.id);
|
|
932
970
|
const on = side !== null;
|
|
933
971
|
t.el.classList.toggle("wt-tab-active", on);
|
|
972
|
+
t.el.classList.toggle("wt-tab-selected", on && side === selectedSide);
|
|
934
973
|
t.aria.setPanel(side);
|
|
935
974
|
t.aria.setExpanded(open ? on : null);
|
|
936
975
|
// setSelected would undo setEditing(true) on the chip hosting the rename
|
|
@@ -1158,6 +1197,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1158
1197
|
|
|
1159
1198
|
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
1160
1199
|
function syncChrome(): void {
|
|
1200
|
+
noteSelected();
|
|
1161
1201
|
relabelAll();
|
|
1162
1202
|
paintActive();
|
|
1163
1203
|
syncMobile();
|
|
@@ -1518,9 +1558,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1518
1558
|
|
|
1519
1559
|
/** Show `id` in the pane on `side`: the pane's current tab becomes an
|
|
1520
1560
|
* 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
|
-
|
|
1561
|
+
* never shown twice), and the pane is selected unless `keepSelection` holds
|
|
1562
|
+
* it on a pane that shows a tab. False when `side` names no pane that can
|
|
1563
|
+
* show a tab (absent, failed or hidden) or `id` no tab. */
|
|
1564
|
+
function showIn(side: PaneSide, id: string, how: ShowOptions = {}): boolean {
|
|
1524
1565
|
const pane = ctx.shell.pane(side);
|
|
1525
1566
|
const next = tabList.find((t) => t.id === id);
|
|
1526
1567
|
if (pane === null || !next) {
|
|
@@ -1550,7 +1591,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1550
1591
|
}
|
|
1551
1592
|
// A later tab slides in from the right, an earlier one from the left, so a
|
|
1552
1593
|
// desktop switch feels like the mobile swipe.
|
|
1553
|
-
let slide = dir;
|
|
1594
|
+
let slide = how.dir;
|
|
1554
1595
|
const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
|
|
1555
1596
|
const toIdx = tabList.findIndex((t) => t.id === id);
|
|
1556
1597
|
if (slide === undefined && fromIdx >= 0) {
|
|
@@ -1581,6 +1622,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1581
1622
|
// the user just looked at.
|
|
1582
1623
|
acknowledgeSwitchNotify(next.id);
|
|
1583
1624
|
markCueSeen(next.id, next.dot.dataset["status"] ?? "");
|
|
1625
|
+
const moveSelection = how.keepSelection !== true || selectedId() === null;
|
|
1584
1626
|
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1585
1627
|
// follow flag is one per renderer, so a bind without it gated the first
|
|
1586
1628
|
// flush on the state of the tab we LEFT and the cached screen rendered
|
|
@@ -1591,7 +1633,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1591
1633
|
// add/remove row animation (the reel owns row motion here); set before the
|
|
1592
1634
|
// selection change, whose listener reconciles the list too.
|
|
1593
1635
|
reelReconcile = playReel !== undefined;
|
|
1594
|
-
|
|
1636
|
+
if (moveSelection) {
|
|
1637
|
+
ctx.shell.select(side);
|
|
1638
|
+
}
|
|
1595
1639
|
scheduleLayoutWrite();
|
|
1596
1640
|
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1597
1641
|
// paints from cache in one frame and must not flash a cue at every switch.
|
|
@@ -1605,8 +1649,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1605
1649
|
syncChrome(); // reconciles the expanded list into the new order
|
|
1606
1650
|
reelReconcile = false;
|
|
1607
1651
|
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
1608
|
-
|
|
1609
|
-
|
|
1652
|
+
if (moveSelection) {
|
|
1653
|
+
ctx.announce(`Switched to ${next.display}`);
|
|
1654
|
+
focusAfterSwitch();
|
|
1655
|
+
}
|
|
1610
1656
|
return true;
|
|
1611
1657
|
}
|
|
1612
1658
|
|
|
@@ -1619,21 +1665,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1619
1665
|
focusAfterSwitch();
|
|
1620
1666
|
return;
|
|
1621
1667
|
}
|
|
1622
|
-
showIn(ctx.shell.targetFor(id), id, dir);
|
|
1668
|
+
showIn(ctx.shell.targetFor(id), id, { dir });
|
|
1623
1669
|
}
|
|
1624
1670
|
|
|
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.
|
|
1671
|
+
// The one rule every split entry point reduces to (`TabsApi.snap`).
|
|
1628
1672
|
function snap(id: string, side: PaneSide): boolean {
|
|
1629
1673
|
if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
|
|
1630
1674
|
return false;
|
|
1631
1675
|
}
|
|
1676
|
+
const from = sideOf(id);
|
|
1677
|
+
if (from === side) {
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1632
1680
|
const split = ctx.shell.split;
|
|
1633
1681
|
if (!split.isOpen() && (!split.canOpen() || !split.open())) {
|
|
1634
1682
|
return false;
|
|
1635
1683
|
}
|
|
1636
|
-
|
|
1684
|
+
// Emptying the tab's old pane drops a keyboard its input held to the body.
|
|
1685
|
+
const typing = keyboardInPaneInput();
|
|
1686
|
+
const displaced = from === null ? null : shownIn(side);
|
|
1687
|
+
if (!showIn(side, id)) {
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
if (from !== null && displaced !== null) {
|
|
1691
|
+
showIn(from, displaced, { keepSelection: true });
|
|
1692
|
+
}
|
|
1693
|
+
if (typing) {
|
|
1694
|
+
focusInput();
|
|
1695
|
+
}
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function toggleSplit(): void {
|
|
1700
|
+
const split = ctx.shell.split;
|
|
1701
|
+
if (split.isOpen()) {
|
|
1702
|
+
split.close();
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const partner = splitPartner();
|
|
1706
|
+
if (partner === null) {
|
|
1707
|
+
void create(true);
|
|
1708
|
+
} else if (split.open()) {
|
|
1709
|
+
showIn("right", partner, { keepSelection: true });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function splitPartner(): string | null {
|
|
1713
|
+
const current = selectedId();
|
|
1714
|
+
const used = recent.find((id) => id !== current && tabList.some((t) => t.id === id));
|
|
1715
|
+
if (used !== undefined) {
|
|
1716
|
+
return used;
|
|
1717
|
+
}
|
|
1718
|
+
const at = tabList.findIndex((t) => t.id === current);
|
|
1719
|
+
return (tabList[at + 1] ?? tabList[at - 1])?.id ?? null;
|
|
1637
1720
|
}
|
|
1638
1721
|
|
|
1639
1722
|
// A switched-into tab's cached screen is stale until its resume delta lands,
|
|
@@ -2233,6 +2316,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2233
2316
|
if (after) {
|
|
2234
2317
|
scroller.insertBefore(tab.el, after.el);
|
|
2235
2318
|
}
|
|
2319
|
+
if (started) {
|
|
2320
|
+
showCreatedElsewhere(tab.id);
|
|
2321
|
+
}
|
|
2236
2322
|
}
|
|
2237
2323
|
|
|
2238
2324
|
// The runtime repair for a bootstrap whose list AND create both failed: the
|
|
@@ -2255,21 +2341,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2255
2341
|
// promise needs no threshold: a duplicate lands while the POST is open and
|
|
2256
2342
|
// collapses into it, while a deliberate second tap still opens a second
|
|
2257
2343
|
// terminal. It also stops "+" mashing from queueing sessions across the
|
|
2258
|
-
// server's retry window.
|
|
2344
|
+
// server's retry window. Any coalesced caller asking for the split gets it.
|
|
2259
2345
|
let creating: Promise<void> | null = null;
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2346
|
+
let createIntoSplit = false;
|
|
2347
|
+
function create(intoSplit = false): Promise<void> {
|
|
2348
|
+
if (creating === null) {
|
|
2349
|
+
createIntoSplit = intoSplit;
|
|
2350
|
+
creating = openNewSession().finally(() => {
|
|
2351
|
+
creating = null;
|
|
2352
|
+
});
|
|
2353
|
+
} else {
|
|
2354
|
+
createIntoSplit ||= intoSplit;
|
|
2355
|
+
}
|
|
2264
2356
|
return creating;
|
|
2265
2357
|
}
|
|
2266
2358
|
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2359
|
+
function showNewTab(id: string): void {
|
|
2360
|
+
const side = ctx.shell.targetFor(id);
|
|
2361
|
+
const other = otherSide(side);
|
|
2362
|
+
// Suppress the swipe reel for a new tab: the list should grow and fade the
|
|
2363
|
+
// new row in (animateRowIn) rather than rotate.
|
|
2364
|
+
creatingTab = true;
|
|
2365
|
+
showIn(shownIn(side) !== null && shownIn(other) !== null ? other : side, id);
|
|
2366
|
+
creatingTab = false;
|
|
2367
|
+
}
|
|
2368
|
+
// This client's own new session can reach the stream before its POST
|
|
2369
|
+
// answers, so the placement waits until a create in flight has shown its tab.
|
|
2370
|
+
function showCreatedElsewhere(id: string): void {
|
|
2371
|
+
if (creating !== null) {
|
|
2372
|
+
void creating.then(() => {
|
|
2373
|
+
showCreatedElsewhere(id);
|
|
2374
|
+
});
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (!tornDown() && ctx.shell.split.isOpen() && sideOf(id) === null) {
|
|
2378
|
+
showNewTab(id);
|
|
2272
2379
|
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
async function openNewSession(): Promise<void> {
|
|
2273
2383
|
let info: SessionInfo;
|
|
2274
2384
|
try {
|
|
2275
2385
|
info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
|
|
@@ -2295,18 +2405,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2295
2405
|
tab = addTabChrome(info);
|
|
2296
2406
|
tabList.push(tab);
|
|
2297
2407
|
}
|
|
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;
|
|
2408
|
+
// Opened once the tab exists, so a failed create leaves the single view
|
|
2409
|
+
// as it was rather than an empty pane.
|
|
2410
|
+
if (createIntoSplit && !ctx.shell.split.isOpen()) {
|
|
2411
|
+
ctx.shell.split.open();
|
|
2412
|
+
}
|
|
2413
|
+
showNewTab(tab.id);
|
|
2310
2414
|
}
|
|
2311
2415
|
|
|
2312
2416
|
// dropTab removes a tab's chrome + cache and, for a shown tab, decides what
|
|
@@ -2321,10 +2425,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2321
2425
|
}
|
|
2322
2426
|
// Closing the only remaining tab: spawn its replacement BEFORE removing
|
|
2323
2427
|
// 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.
|
|
2428
|
+
// far left while the create POST is in flight. The replacement lands as any
|
|
2429
|
+
// new tab does, and dropping the old tab is then an ordinary non-last
|
|
2430
|
+
// close, so this intercept does not re-fire. A user close only.
|
|
2328
2431
|
if (remote && tabList.length === 1 && tabList[0]?.id === id) {
|
|
2329
2432
|
await create();
|
|
2330
2433
|
// A failed create adds nothing (and toasts): keep the existing tab
|
|
@@ -2338,6 +2441,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2338
2441
|
// anything moves.
|
|
2339
2442
|
const side = sideOf(id);
|
|
2340
2443
|
const splitOpen = ctx.shell.split.isOpen();
|
|
2444
|
+
// Emptying a pane makes it inert, which drops a keyboard it held to the
|
|
2445
|
+
// body before the view moves; the keyboard follows the view instead.
|
|
2446
|
+
const typing = splitOpen && side !== null && keyboardInPaneInput();
|
|
2341
2447
|
const [tab] = tabList.splice(idx, 1);
|
|
2342
2448
|
if (!tab) {
|
|
2343
2449
|
return;
|
|
@@ -2387,26 +2493,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2387
2493
|
// What the panes show next is decided here, before the server's answer:
|
|
2388
2494
|
// selection must not sit on an emptied pane for a DELETE round trip.
|
|
2389
2495
|
let replacement: Promise<void> | undefined;
|
|
2390
|
-
if (side !== null &&
|
|
2391
|
-
|
|
2392
|
-
|
|
2496
|
+
if (side !== null && splitOpen && shownIn(otherSide(side)) !== null) {
|
|
2497
|
+
ctx.shell.split.closeSide(side);
|
|
2498
|
+
focusAfterSwitch();
|
|
2499
|
+
} else if (side !== null) {
|
|
2500
|
+
if (splitOpen) {
|
|
2501
|
+
ctx.shell.split.close();
|
|
2502
|
+
}
|
|
2393
2503
|
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2394
2504
|
if (neighbor) {
|
|
2395
2505
|
switchTo(neighbor.id);
|
|
2396
2506
|
} else {
|
|
2397
2507
|
replacement = create();
|
|
2398
2508
|
}
|
|
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();
|
|
2509
|
+
}
|
|
2510
|
+
if (typing) {
|
|
2511
|
+
focusInput();
|
|
2410
2512
|
}
|
|
2411
2513
|
if (remote) {
|
|
2412
2514
|
try {
|
|
@@ -2467,25 +2569,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2467
2569
|
}
|
|
2468
2570
|
}
|
|
2469
2571
|
// Re-home before the DELETEs: selection must not sit on an emptied pane for
|
|
2470
|
-
// a round trip.
|
|
2471
|
-
|
|
2572
|
+
// a round trip.
|
|
2573
|
+
let needsTab = !splitOpen && closingSelected;
|
|
2574
|
+
if (emptiedPane) {
|
|
2575
|
+
const keep = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
|
|
2576
|
+
if (keep === undefined) {
|
|
2577
|
+
ctx.shell.split.close();
|
|
2578
|
+
needsTab = true;
|
|
2579
|
+
} else {
|
|
2580
|
+
ctx.shell.split.closeSide(otherSide(keep));
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (needsTab && tabList[0]) {
|
|
2472
2584
|
switchTo(tabList[0].id);
|
|
2473
2585
|
} else {
|
|
2474
2586
|
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
2587
|
}
|
|
2485
|
-
// One terminal always stays open
|
|
2588
|
+
// One terminal always stays open.
|
|
2486
2589
|
let replacement: Promise<void> | undefined;
|
|
2487
|
-
if (tabList.length === 0
|
|
2488
|
-
replacement = create(
|
|
2590
|
+
if (tabList.length === 0) {
|
|
2591
|
+
replacement = create();
|
|
2489
2592
|
}
|
|
2490
2593
|
// The person asked for these closes, so every DELETE still goes out past a
|
|
2491
2594
|
// teardown; only what the page shows for them stops.
|
|
@@ -3233,10 +3336,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3233
3336
|
endRestNet();
|
|
3234
3337
|
}
|
|
3235
3338
|
});
|
|
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.
|
|
3339
|
+
// The left width rounds as `--wt-split-left` does in 32-split.css, so the
|
|
3340
|
+
// boundary is the one the highlights draw.
|
|
3240
3341
|
function dropHalf(e: DragEvent): PaneSide | null {
|
|
3241
3342
|
const root = ctx.shell.root;
|
|
3242
3343
|
const target = e.target as Node;
|
|
@@ -3248,8 +3349,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3248
3349
|
) {
|
|
3249
3350
|
return null;
|
|
3250
3351
|
}
|
|
3251
|
-
const
|
|
3252
|
-
|
|
3352
|
+
const { left, width } = root.getBoundingClientRect();
|
|
3353
|
+
const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
|
|
3354
|
+
return e.clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
|
|
3253
3355
|
}
|
|
3254
3356
|
function paintDropHalf(half: PaneSide | null): void {
|
|
3255
3357
|
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
|