@cplieger/web-terminal-ui 8.4.0-dev.2 → 8.4.0-dev.3
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 +1 -1
- package/package.json +6 -2
- package/src/features/tabs/address-bar.ts +138 -0
- package/src/features/tabs/index.ts +199 -1
- package/src/features/tabs/model.ts +9 -1
- package/src/features/tabs/route.ts +65 -0
- package/src/kernel/shell.ts +47 -4
- package/src/kernel/types.ts +13 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ web-terminal-ui is built for a shell or a coding agent served by web-terminal-en
|
|
|
14
14
|
|
|
15
15
|
- One `createTerminal()` call builds the UI inside an element you provide. Four presets cover a single, a touch and two tabbed layouts.
|
|
16
16
|
- On a touchscreen it adds a key bar with Tab, Esc, arrows, Enter and a sticky Ctrl, a long-press paste menu and iOS soft-keyboard handling.
|
|
17
|
-
- Tabs show activity dots from the program's `OSC 9;4` progress reports and raise browser notifications. Two tabs can sit side by side.
|
|
17
|
+
- Tabs show activity dots from the program's `OSC 9;4` progress reports and raise browser notifications. Two tabs can sit side by side. A full-page terminal gives each tab and each split its own page address, and names the browser tab after the active tab, followed by the page's own title: `fix build · Web Terminal`.
|
|
18
18
|
- Native text selection, IME input and dictation work, and predictive echo hides latency.
|
|
19
19
|
|
|
20
20
|
Consider [xterm.js](https://github.com/xtermjs/xterm.js) if you want a terminal component for any PTY backend, such as node-pty. It has an optional GPU-accelerated renderer and a set of addons, and VS Code uses it. To build a UI of your own on web-terminal-engine, depend on the engine alone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.4.0-dev.
|
|
3
|
+
"version": "8.4.0-dev.3",
|
|
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",
|
|
@@ -33,8 +33,12 @@
|
|
|
33
33
|
"test": "vitest --run",
|
|
34
34
|
"test:watch": "vitest",
|
|
35
35
|
"test:coverage": "vitest --run --coverage",
|
|
36
|
+
"test:browsers": "playwright install chromium",
|
|
37
|
+
"test:mutation": "stryker run",
|
|
36
38
|
"lint:eslint": "eslint .",
|
|
37
39
|
"lint:prettier": "prettier --check .",
|
|
40
|
+
"lint:css": "stylelint \"css/**/*.css\"",
|
|
41
|
+
"lint:html": "html-validate scaffold/index.html",
|
|
38
42
|
"lint:fix": "eslint . --fix && prettier --write .",
|
|
39
43
|
"lint:knip": "knip",
|
|
40
44
|
"verify": "bash scripts/verify.sh"
|
|
@@ -58,7 +62,7 @@
|
|
|
58
62
|
"eslint": "10.12.0",
|
|
59
63
|
"fast-check": "4.10.2",
|
|
60
64
|
"html-validate": "11.16.2",
|
|
61
|
-
"knip": "6.
|
|
65
|
+
"knip": "6.40.0",
|
|
62
66
|
"playwright": "1.63.0",
|
|
63
67
|
"prettier": "3.9.9",
|
|
64
68
|
"stylelint": "17.16.0",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** How a fragment reached the page. A deep link (a fresh load, a typed fragment)
|
|
2
|
+
* earns a notice when it names a tab that is gone; history and restore, which
|
|
3
|
+
* replay entries the page itself wrote, are corrected silently. */
|
|
4
|
+
export type RouteOrigin = "deeplink" | "history" | "restore";
|
|
5
|
+
|
|
6
|
+
interface AddressBarOptions {
|
|
7
|
+
/** The fragment for what the page shows now (`#...`, or `""` for none), or null
|
|
8
|
+
* while there is nothing to name, which leaves the address bar alone. */
|
|
9
|
+
readonly read: () => string | null;
|
|
10
|
+
/** Show what `fragment` names. Writes the page schedules from inside are
|
|
11
|
+
* dropped; the address bar canonicalises once it returns. */
|
|
12
|
+
readonly apply: (fragment: string, origin: RouteOrigin) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AddressBar {
|
|
16
|
+
/** Write the current fragment after this task: a push adds a history entry, a
|
|
17
|
+
* replace rewrites the current one, and a push wins when both are asked for.
|
|
18
|
+
* Nothing is written before `start` or while applying, and a fragment already
|
|
19
|
+
* the address bar's adds no entry (an unmarked one is only re-marked). */
|
|
20
|
+
schedule(mode: "push" | "replace"): void;
|
|
21
|
+
/** Where this page load came from, for the boot's notice decision. */
|
|
22
|
+
bootOrigin(): RouteOrigin;
|
|
23
|
+
/** Start answering navigation: replace the current entry with what is shown
|
|
24
|
+
* (when `canonicalize`), then follow every fragment change. */
|
|
25
|
+
start(canonicalize: boolean): void;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Marks a history entry this page wrote, so a back or forward onto it is told
|
|
30
|
+
// from a fragment someone typed (whose entry carries no state).
|
|
31
|
+
const ENTRY_MARK = "wtTabs";
|
|
32
|
+
|
|
33
|
+
function isMarked(state: unknown): boolean {
|
|
34
|
+
return typeof state === "object" && state !== null && Object.hasOwn(state, ENTRY_MARK);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The page address as the tabs feature's: it writes the fragment, never the
|
|
38
|
+
* path or the query, and it reacts to the fragment changing under it. */
|
|
39
|
+
export function createAddressBar(win: Window, opts: AddressBarOptions): AddressBar {
|
|
40
|
+
let started = false;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let applying = 0;
|
|
43
|
+
let pending: "push" | "replace" | null = null;
|
|
44
|
+
let timer: number | null = null;
|
|
45
|
+
// The fragment last applied or written. popstate and hashchange both fire for
|
|
46
|
+
// one fragment navigation, and the second must not apply it again.
|
|
47
|
+
let known = win.location.hash;
|
|
48
|
+
|
|
49
|
+
function cancel(): void {
|
|
50
|
+
if (timer !== null) {
|
|
51
|
+
win.clearTimeout(timer);
|
|
52
|
+
timer = null;
|
|
53
|
+
}
|
|
54
|
+
pending = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function write(mode: "push" | "replace"): void {
|
|
58
|
+
const target = opts.read();
|
|
59
|
+
if (target === null) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const current = win.location.hash;
|
|
63
|
+
if (target === current && isMarked(win.history.state)) {
|
|
64
|
+
known = current;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const url = new URL(win.location.href);
|
|
68
|
+
url.hash = target;
|
|
69
|
+
// An entry for a page with no addressable tab is not one worth going back to.
|
|
70
|
+
if (mode === "push" && target !== current && target !== "") {
|
|
71
|
+
win.history.pushState({ [ENTRY_MARK]: 1 }, "", url);
|
|
72
|
+
} else {
|
|
73
|
+
const prior: unknown = win.history.state;
|
|
74
|
+
const state = typeof prior === "object" && prior !== null ? { ...prior } : {};
|
|
75
|
+
win.history.replaceState({ ...state, [ENTRY_MARK]: 1 }, "", url);
|
|
76
|
+
}
|
|
77
|
+
known = win.location.hash;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onNavigate(): void {
|
|
81
|
+
const fragment = win.location.hash;
|
|
82
|
+
if (fragment === known) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
known = fragment;
|
|
86
|
+
const origin: RouteOrigin = isMarked(win.history.state) ? "history" : "deeplink";
|
|
87
|
+
cancel();
|
|
88
|
+
applying++;
|
|
89
|
+
try {
|
|
90
|
+
opts.apply(fragment, origin);
|
|
91
|
+
} finally {
|
|
92
|
+
applying--;
|
|
93
|
+
}
|
|
94
|
+
write("replace");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
schedule(mode) {
|
|
99
|
+
if (!started || disposed || applying > 0) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (pending !== "push") {
|
|
103
|
+
pending = mode;
|
|
104
|
+
}
|
|
105
|
+
timer ??= win.setTimeout(() => {
|
|
106
|
+
timer = null;
|
|
107
|
+
const m = pending;
|
|
108
|
+
pending = null;
|
|
109
|
+
if (m !== null && !disposed) {
|
|
110
|
+
write(m);
|
|
111
|
+
}
|
|
112
|
+
}, 0);
|
|
113
|
+
},
|
|
114
|
+
bootOrigin() {
|
|
115
|
+
const entry: unknown = win.performance.getEntriesByType("navigation")[0];
|
|
116
|
+
const type =
|
|
117
|
+
typeof entry === "object" && entry !== null && "type" in entry ? entry.type : null;
|
|
118
|
+
return type === "reload" || type === "back_forward" ? "restore" : "deeplink";
|
|
119
|
+
},
|
|
120
|
+
start(canonicalize) {
|
|
121
|
+
if (started || disposed) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
started = true;
|
|
125
|
+
if (canonicalize) {
|
|
126
|
+
write("replace");
|
|
127
|
+
}
|
|
128
|
+
win.addEventListener("popstate", onNavigate);
|
|
129
|
+
win.addEventListener("hashchange", onNavigate);
|
|
130
|
+
},
|
|
131
|
+
dispose() {
|
|
132
|
+
disposed = true;
|
|
133
|
+
cancel();
|
|
134
|
+
win.removeEventListener("popstate", onNavigate);
|
|
135
|
+
win.removeEventListener("hashchange", onNavigate);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -19,6 +19,8 @@ import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
|
19
19
|
import { snapToDevicePixels } from "./device-snap.js";
|
|
20
20
|
import { centreChipLabels } from "./ink-centre.js";
|
|
21
21
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
22
|
+
import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
|
|
23
|
+
import { formatTabRoute, parseTabAlias, parseTabRoute, type TabAlias } from "./route.js";
|
|
22
24
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
23
25
|
import {
|
|
24
26
|
CUE_SEEN_KEY,
|
|
@@ -95,6 +97,19 @@ const DEFAULT_POLL_MS = 4000;
|
|
|
95
97
|
// URL and NAVIGATES to it when no handler cancels the drop, so a bare session id
|
|
96
98
|
// loaded /<session-id> on iPadOS. It also keeps the id out of the pasteboard.
|
|
97
99
|
const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
100
|
+
const STALE_TAB_NOTICE = "That tab is no longer open";
|
|
101
|
+
|
|
102
|
+
// Whether two records show the same tabs the same way; the handle is a device's
|
|
103
|
+
// preference and does not count.
|
|
104
|
+
function sameShownLayout(a: PaneLayout, b: PaneLayout | null): boolean {
|
|
105
|
+
return (
|
|
106
|
+
b !== null &&
|
|
107
|
+
a.left === b.left &&
|
|
108
|
+
a.right === b.right &&
|
|
109
|
+
a.open === b.open &&
|
|
110
|
+
a.selected === b.selected
|
|
111
|
+
);
|
|
112
|
+
}
|
|
98
113
|
|
|
99
114
|
/** The value a peer or a host reads through `ctx.use(tabs(...))`. `create` and
|
|
100
115
|
* `close` are server round trips and NEITHER rejects: a refusal is toasted and
|
|
@@ -781,6 +796,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
781
796
|
// The record is read once and never written back by a read.
|
|
782
797
|
let applyingRecord = false;
|
|
783
798
|
let warnedLayout = false;
|
|
799
|
+
// The page address names the shown tabs only when the terminal owns the page.
|
|
800
|
+
const address: AddressBar | null =
|
|
801
|
+
ctx.shell.layoutMode === "viewport"
|
|
802
|
+
? createAddressBar(win, { read: currentFragment, apply: applyFragment })
|
|
803
|
+
: null;
|
|
804
|
+
if (address !== null) {
|
|
805
|
+
ctx.defer(() => {
|
|
806
|
+
address.dispose();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
// The page that owns its address is named after its active tab too.
|
|
810
|
+
const pageTitle = address === null ? null : ctx.shell.claimPageTitle();
|
|
811
|
+
if (pageTitle !== null) {
|
|
812
|
+
ctx.defer(() => {
|
|
813
|
+
pageTitle.release();
|
|
814
|
+
});
|
|
815
|
+
}
|
|
784
816
|
// Twin of marotte static-src/tabs-drag.ts; when changing drag behaviour or its
|
|
785
817
|
// constants, check the other app. Both judge a live drag's viewport against the
|
|
786
818
|
// PRESS-time frame, so a move a hold lifted across still ends the drag.
|
|
@@ -1244,6 +1276,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1244
1276
|
// Every list mutation ends in syncChrome, so no path can forget the
|
|
1245
1277
|
// out-of-page surfaces; the fold is idempotent and the sinks no-op.
|
|
1246
1278
|
paintAttention();
|
|
1279
|
+
const active = selectedId();
|
|
1280
|
+
pageTitle?.set(tabList.find((t) => t.id === active)?.display ?? null);
|
|
1247
1281
|
}
|
|
1248
1282
|
|
|
1249
1283
|
// The percentage is deliberately NOT written into the document title: a page
|
|
@@ -1391,6 +1425,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1391
1425
|
|
|
1392
1426
|
const tab: Tab = {
|
|
1393
1427
|
id: info.id,
|
|
1428
|
+
alias: parseTabAlias(info.alias) ?? undefined,
|
|
1394
1429
|
born: ++tabEpoch,
|
|
1395
1430
|
title: info.title,
|
|
1396
1431
|
pinnedTitle: info.pinnedTitle,
|
|
@@ -1650,6 +1685,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1650
1685
|
ctx.shell.select(side);
|
|
1651
1686
|
}
|
|
1652
1687
|
scheduleLayoutWrite();
|
|
1688
|
+
address?.schedule("push");
|
|
1653
1689
|
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1654
1690
|
// paints from cache in one frame and must not flash a cue at every switch.
|
|
1655
1691
|
if (
|
|
@@ -3066,6 +3102,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3066
3102
|
if (rec.order !== undefined) {
|
|
3067
3103
|
t.order = rec.order;
|
|
3068
3104
|
}
|
|
3105
|
+
// A changed alias renames the tab it already is, so the address it is
|
|
3106
|
+
// shown under is rewritten in place rather than pushed.
|
|
3107
|
+
if (rec.alias !== undefined) {
|
|
3108
|
+
const alias = parseTabAlias(rec.alias) ?? undefined;
|
|
3109
|
+
if (alias !== t.alias) {
|
|
3110
|
+
t.alias = alias;
|
|
3111
|
+
address?.schedule("replace");
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3069
3114
|
paintStatusDot(t.dot, rec.status, reports);
|
|
3070
3115
|
// Only a PRESENT percentage updates the tab: the polling fallback lists
|
|
3071
3116
|
// SessionInfo, which carries none, and reading absence as cleared would
|
|
@@ -3193,6 +3238,98 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3193
3238
|
open: split.open,
|
|
3194
3239
|
};
|
|
3195
3240
|
}
|
|
3241
|
+
// Own-field equality over the list, never a keyed object, so an alias such
|
|
3242
|
+
// as `constructor` finds nothing rather than Object.prototype.
|
|
3243
|
+
function tabByAlias(alias: TabAlias | null): Tab | undefined {
|
|
3244
|
+
return alias === null ? undefined : tabList.find((t) => t.alias === alias);
|
|
3245
|
+
}
|
|
3246
|
+
/** The fragment naming what the panes show: `""` when a shown tab has no
|
|
3247
|
+
* alias, null while no pane shows a tab. */
|
|
3248
|
+
function currentFragment(): string | null {
|
|
3249
|
+
const left = shownIn("left");
|
|
3250
|
+
const right = shownIn("right");
|
|
3251
|
+
if (left === null && right === null) {
|
|
3252
|
+
return null;
|
|
3253
|
+
}
|
|
3254
|
+
const aliasOf = (id: string | null): TabAlias | null | undefined =>
|
|
3255
|
+
id === null ? null : tabList.find((t) => t.id === id)?.alias;
|
|
3256
|
+
const l = aliasOf(left);
|
|
3257
|
+
const r = aliasOf(right);
|
|
3258
|
+
if (l === undefined || r === undefined) {
|
|
3259
|
+
return "";
|
|
3260
|
+
}
|
|
3261
|
+
if (!ctx.shell.split.isOpen()) {
|
|
3262
|
+
const tab = l ?? r;
|
|
3263
|
+
return tab === null ? "" : formatTabRoute({ kind: "single", tab });
|
|
3264
|
+
}
|
|
3265
|
+
return formatTabRoute({ kind: "split", left: l, right: r });
|
|
3266
|
+
}
|
|
3267
|
+
/** Show what a fragment the user navigated to names. Only tabs already open
|
|
3268
|
+
* are shown: a URL never creates or closes a session, because a history
|
|
3269
|
+
* entry would otherwise resurrect a tab closed on another device. */
|
|
3270
|
+
function applyFragment(fragment: string, origin: RouteOrigin): void {
|
|
3271
|
+
const route = parseTabRoute(fragment);
|
|
3272
|
+
if (route === null) {
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
const [wantLeft, wantRight] =
|
|
3276
|
+
route.kind === "single" ? [route.tab, null] : [route.left, route.right];
|
|
3277
|
+
const left = tabByAlias(wantLeft);
|
|
3278
|
+
const right = tabByAlias(wantRight);
|
|
3279
|
+
if (
|
|
3280
|
+
origin === "deeplink" &&
|
|
3281
|
+
((wantLeft !== null && !left) || (wantRight !== null && !right))
|
|
3282
|
+
) {
|
|
3283
|
+
ctx.toast(STALE_TAB_NOTICE);
|
|
3284
|
+
}
|
|
3285
|
+
const first = left ?? right;
|
|
3286
|
+
if (first === undefined) {
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
const split = ctx.shell.split;
|
|
3290
|
+
let wantSplit = route.kind === "split" && split.enabled;
|
|
3291
|
+
if (wantSplit && !split.isOpen() && !split.canOpen()) {
|
|
3292
|
+
wantSplit = false;
|
|
3293
|
+
ctx.toast("Split view needs a wider window");
|
|
3294
|
+
}
|
|
3295
|
+
if (!wantSplit) {
|
|
3296
|
+
if (split.isOpen()) {
|
|
3297
|
+
// Closing keeps the selected pane's tab, so select the one wanted.
|
|
3298
|
+
const at = sideOf(first.id);
|
|
3299
|
+
if (at !== null) {
|
|
3300
|
+
ctx.shell.select(at);
|
|
3301
|
+
}
|
|
3302
|
+
split.close();
|
|
3303
|
+
}
|
|
3304
|
+
if (sideOf(first.id) === null) {
|
|
3305
|
+
showIn(ctx.shell.selected(), first.id, { keepSelection: true });
|
|
3306
|
+
}
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
if (!split.isOpen() && !split.open()) {
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
const want: Record<PaneSide, Tab | undefined> = { left, right };
|
|
3313
|
+
// The selected side goes first: filling the peer first could empty the
|
|
3314
|
+
// selected pane (a tab moving across), and showIn then moves the
|
|
3315
|
+
// selection to the peer. A moving tab is emptied from its old side by showIn.
|
|
3316
|
+
const kept = ctx.shell.selected();
|
|
3317
|
+
for (const side of [kept, otherSide(kept)]) {
|
|
3318
|
+
const tab = want[side];
|
|
3319
|
+
if (tab !== undefined && shownIn(side) !== tab.id) {
|
|
3320
|
+
showIn(side, tab.id, { keepSelection: true });
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
for (const side of ["left", "right"] as const) {
|
|
3324
|
+
if (want[side] === undefined && shownIn(side) !== null) {
|
|
3325
|
+
ctx.shell.pane(side)?.clearActiveSession();
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
const sel = ctx.shell.selected();
|
|
3329
|
+
if (shownIn(sel) === null && shownIn(otherSide(sel)) !== null) {
|
|
3330
|
+
ctx.shell.select(otherSide(sel));
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3196
3333
|
async function writeLayout(layout: PaneLayout, retryOnStale: boolean): Promise<void> {
|
|
3197
3334
|
try {
|
|
3198
3335
|
await api.setLayout(layout);
|
|
@@ -3274,6 +3411,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3274
3411
|
syncCatchups();
|
|
3275
3412
|
syncChrome();
|
|
3276
3413
|
scheduleLayoutWrite();
|
|
3414
|
+
address?.schedule("push");
|
|
3277
3415
|
}),
|
|
3278
3416
|
);
|
|
3279
3417
|
ctx.defer(ctx.shell.onSelectionChange(syncChrome));
|
|
@@ -4173,6 +4311,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4173
4311
|
// its bootShowedNothing gate), or the SSE snapshot would win this race on
|
|
4174
4312
|
// every load.
|
|
4175
4313
|
if (anyShown()) {
|
|
4314
|
+
address?.start(listed.status === "fulfilled");
|
|
4176
4315
|
return true;
|
|
4177
4316
|
}
|
|
4178
4317
|
// The record's sessions where they still exist, else the oldest LIVE tab:
|
|
@@ -4197,7 +4336,64 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4197
4336
|
return true;
|
|
4198
4337
|
};
|
|
4199
4338
|
let shown = false;
|
|
4200
|
-
|
|
4339
|
+
// The address outranks the record when it names an open tab: it is the
|
|
4340
|
+
// explicit instruction for this load. Read only with the server's list in
|
|
4341
|
+
// hand, so a failed list never declares a tab gone.
|
|
4342
|
+
const route =
|
|
4343
|
+
address !== null && listed.status === "fulfilled"
|
|
4344
|
+
? parseTabRoute(win.location.hash)
|
|
4345
|
+
: null;
|
|
4346
|
+
if (route !== null && address !== null) {
|
|
4347
|
+
const [wantLeft, wantRight] =
|
|
4348
|
+
route.kind === "single" ? [route.tab, null] : [route.left, route.right];
|
|
4349
|
+
const fromRoute = (alias: TabAlias | null): Tab | undefined => {
|
|
4350
|
+
const tab = tabByAlias(alias);
|
|
4351
|
+
return tab === undefined ? undefined : saved(tab.id);
|
|
4352
|
+
};
|
|
4353
|
+
const sides: Record<PaneSide, Tab | undefined> = {
|
|
4354
|
+
left: fromRoute(wantLeft),
|
|
4355
|
+
right: fromRoute(wantRight),
|
|
4356
|
+
};
|
|
4357
|
+
// Gone means absent from the list: an ended tab still in the row is
|
|
4358
|
+
// refused here only because a boot never lands on a corpse.
|
|
4359
|
+
const gone = (alias: TabAlias | null): boolean =>
|
|
4360
|
+
alias !== null && tabByAlias(alias) === undefined;
|
|
4361
|
+
if ((gone(wantLeft) || gone(wantRight)) && address.bootOrigin() === "deeplink") {
|
|
4362
|
+
ctx.toast(STALE_TAB_NOTICE);
|
|
4363
|
+
}
|
|
4364
|
+
applyingRecord = true;
|
|
4365
|
+
try {
|
|
4366
|
+
if (route.kind === "split" && (sides.left || sides.right)) {
|
|
4367
|
+
const record =
|
|
4368
|
+
layout?.open === true &&
|
|
4369
|
+
layout.left === (sides.left?.id ?? null) &&
|
|
4370
|
+
layout.right === (sides.right?.id ?? null)
|
|
4371
|
+
? layout.selected
|
|
4372
|
+
: null;
|
|
4373
|
+
const selected: PaneSide =
|
|
4374
|
+
record !== null && sides[record] ? record : sides.left ? "left" : "right";
|
|
4375
|
+
if (ctx.shell.restoreSplit(layout?.handle ?? 0.5, selected)) {
|
|
4376
|
+
for (const side of ["left", "right"] as const) {
|
|
4377
|
+
const tab = sides[side];
|
|
4378
|
+
if (tab) {
|
|
4379
|
+
shown = showAtBoot(side, tab) || shown;
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
const only = sides.left ?? sides.right;
|
|
4385
|
+
if (!shown && only) {
|
|
4386
|
+
shown = showAtBoot(ctx.shell.targetFor(only.id), only);
|
|
4387
|
+
}
|
|
4388
|
+
} finally {
|
|
4389
|
+
applyingRecord = false;
|
|
4390
|
+
}
|
|
4391
|
+
// The shared record follows the address this load was opened at.
|
|
4392
|
+
if (shown && layout !== null && !sameShownLayout(layout, currentLayout())) {
|
|
4393
|
+
scheduleLayoutWrite();
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4396
|
+
if (!shown && layout?.open === true) {
|
|
4201
4397
|
const sides: Record<PaneSide, Tab | undefined> = {
|
|
4202
4398
|
left: saved(layout.left),
|
|
4203
4399
|
right: saved(layout.right),
|
|
@@ -4233,10 +4429,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4233
4429
|
}
|
|
4234
4430
|
if (!shown) {
|
|
4235
4431
|
bootShowedNothing = true;
|
|
4432
|
+
address?.start(false);
|
|
4236
4433
|
return false;
|
|
4237
4434
|
}
|
|
4238
4435
|
syncChrome();
|
|
4239
4436
|
focusInput();
|
|
4437
|
+
address?.start(listed.status === "fulfilled");
|
|
4240
4438
|
return true;
|
|
4241
4439
|
};
|
|
4242
4440
|
live = { resolveInitialLayout, shownIn, showIn };
|
|
@@ -4,14 +4,18 @@ import type { LineStore } from "@cplieger/web-terminal-engine";
|
|
|
4
4
|
import type { SessionInfo } from "@cplieger/web-terminal-engine";
|
|
5
5
|
import type { ViewMemory } from "@cplieger/web-terminal-engine";
|
|
6
6
|
import type { PaneSide, TabHandle } from "../../kernel/types.js";
|
|
7
|
+
import type { TabAlias } from "./route.js";
|
|
7
8
|
|
|
8
9
|
export type { SessionInfo };
|
|
9
10
|
|
|
10
11
|
/** A status record as the tabs feature consumes it: the REST wire shape plus the
|
|
11
12
|
* percentage that exists only on the status STREAM. The polling fallback lists
|
|
12
|
-
* no percentage at all, which means "no information", not "cleared".
|
|
13
|
+
* no percentage at all, which means "no information", not "cleared". `alias` is
|
|
14
|
+
* read as `unknown` because `parseTabAlias` validates it where it enters, and an
|
|
15
|
+
* engine before 6.2 sends none. */
|
|
13
16
|
export type StatusRecord = SessionInfo & {
|
|
14
17
|
readonly progressValue?: number;
|
|
18
|
+
readonly alias?: unknown;
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
// The status vocabulary is module-private so a status's MEANING has one home:
|
|
@@ -392,6 +396,10 @@ export function orderedInsertIndex(current: readonly TabOrderKey[], incoming: Ta
|
|
|
392
396
|
|
|
393
397
|
export interface Tab {
|
|
394
398
|
id: string;
|
|
399
|
+
/** The session's public URL name from the server, or undefined when it sent
|
|
400
|
+
* none (a server before engine 6.2) or one outside the alias grammar; such a
|
|
401
|
+
* tab has no address of its own. */
|
|
402
|
+
alias: TabAlias | undefined;
|
|
395
403
|
/** The local mutation epoch at which this tab was adopted. The list reconcile
|
|
396
404
|
* snapshots the counter BEFORE its GET and drops an unlisted tab only when
|
|
397
405
|
* the tab predates the snapshot: a tab born while the list was in flight is
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** A session alias that has passed `parseTabAlias`: 1 to 64 characters of
|
|
2
|
+
* `[A-Za-z0-9_-]`, the grammar the engine's `SessionInfo.alias` documents. */
|
|
3
|
+
export type TabAlias = string & {
|
|
4
|
+
// deadset:ignore DS1003 -- the brand only makes TabAlias nominal; nothing reads it
|
|
5
|
+
readonly __brand: "TabAlias";
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// A fragment rather than a path: only a fragment edit navigates without a reload,
|
|
9
|
+
// and a library mounted in someone else's page must not claim its path.
|
|
10
|
+
/** What a fragment shows: one tab, or the split with a tab (or nothing) in each
|
|
11
|
+
* pane. A split route names at least one side, and never one alias twice. */
|
|
12
|
+
export type TabRoute =
|
|
13
|
+
| { readonly kind: "single"; readonly tab: TabAlias }
|
|
14
|
+
| { readonly kind: "split"; readonly left: TabAlias | null; readonly right: TabAlias | null };
|
|
15
|
+
|
|
16
|
+
const ALIAS = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
// Two maximal aliases and the separator.
|
|
18
|
+
const MAX_ROUTE_LENGTH = 129;
|
|
19
|
+
|
|
20
|
+
/** The value as an alias, or null for anything outside the grammar, including a
|
|
21
|
+
* non-string. */
|
|
22
|
+
export function parseTabAlias(raw: unknown): TabAlias | null {
|
|
23
|
+
return typeof raw === "string" && ALIAS.test(raw) ? (raw as TabAlias) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parse `location.hash` (with or without its `#`): `#<alias>` is one tab and
|
|
27
|
+
* `#<left>,<right>` the split, either side possibly empty. Null for no fragment
|
|
28
|
+
* and for any malformed one. Nothing is percent-decoded: the alias alphabet
|
|
29
|
+
* needs no escaping, so an escape is malformed. */
|
|
30
|
+
export function parseTabRoute(hash: string): TabRoute | null {
|
|
31
|
+
const body = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
32
|
+
if (body === "" || body.length > MAX_ROUTE_LENGTH) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const parts = body.split(",");
|
|
36
|
+
if (parts.length === 1) {
|
|
37
|
+
const tab = parseTabAlias(body);
|
|
38
|
+
return tab === null ? null : { kind: "single", tab };
|
|
39
|
+
}
|
|
40
|
+
if (parts.length !== 2) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const [rawLeft = "", rawRight = ""] = parts;
|
|
44
|
+
const left = rawLeft === "" ? null : parseTabAlias(rawLeft);
|
|
45
|
+
const right = rawRight === "" ? null : parseTabAlias(rawRight);
|
|
46
|
+
if ((rawLeft !== "" && left === null) || (rawRight !== "" && right === null)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if ((left === null && right === null) || left === right) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { kind: "split", left, right };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The fragment for `route`, `#` included, or `""` (no fragment) for null. The
|
|
56
|
+
* inverse of `parseTabRoute`. */
|
|
57
|
+
export function formatTabRoute(route: TabRoute | null): string {
|
|
58
|
+
if (route === null) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
if (route.kind === "single") {
|
|
62
|
+
return `#${route.tab}`;
|
|
63
|
+
}
|
|
64
|
+
return `#${route.left ?? ""},${route.right ?? ""}`;
|
|
65
|
+
}
|
package/src/kernel/shell.ts
CHANGED
|
@@ -394,18 +394,41 @@ function createShellInto(
|
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
let titleMark = "";
|
|
397
|
+
// The base a feature claimed through claimPageTitle; null text is the served title.
|
|
398
|
+
let claimedTitle: { text: string | null } | null = null;
|
|
399
|
+
// Suffixed from servedTitle, never from doc.title, so a repaint cannot stack it.
|
|
400
|
+
function claimedBase(text: string | null): string {
|
|
401
|
+
if (text === null || text === "") {
|
|
402
|
+
return servedTitle;
|
|
403
|
+
}
|
|
404
|
+
return servedTitle === "" ? text : `${text} · ${servedTitle}`;
|
|
405
|
+
}
|
|
406
|
+
let titleWritten = false;
|
|
397
407
|
function paintTitle(): void {
|
|
398
|
-
|
|
399
|
-
|
|
408
|
+
if (destroyed) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const base =
|
|
412
|
+
claimedTitle !== null
|
|
413
|
+
? claimedBase(claimedTitle.text)
|
|
414
|
+
: (slotAt(selectedSide)?.baseTitle ?? slots[0]?.baseTitle ?? servedTitle);
|
|
415
|
+
const next = titleMark + base;
|
|
400
416
|
// The title doubles as the browser-tab label and the bookmark name, and this
|
|
401
417
|
// runs on every status sweep, so an unchanged assignment must not churn it.
|
|
402
418
|
if (doc.title !== next) {
|
|
403
419
|
doc.title = next;
|
|
420
|
+
titleWritten = true;
|
|
404
421
|
}
|
|
405
422
|
}
|
|
423
|
+
// Hands back the served title the terminal overwrote; paintTitle's destroyed
|
|
424
|
+
// guard stops teardown from repainting over it.
|
|
425
|
+
held.push(() => {
|
|
426
|
+
if (titleWritten) {
|
|
427
|
+
doc.title = servedTitle;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
406
430
|
const attentionSurfaces = new Set<AttentionSurface>();
|
|
407
|
-
// The badge is OS-level and the icon links outlive the terminal
|
|
408
|
-
// title belongs to the document.
|
|
431
|
+
// The badge is OS-level and the icon links outlive the terminal.
|
|
409
432
|
held.push(() => {
|
|
410
433
|
for (const surface of attentionSurfaces) {
|
|
411
434
|
surface.dispose();
|
|
@@ -816,6 +839,7 @@ function createShellInto(
|
|
|
816
839
|
: disabledSplit();
|
|
817
840
|
const shellContext: ShellContext = {
|
|
818
841
|
root: shellRoot,
|
|
842
|
+
layoutMode,
|
|
819
843
|
keyboard: {
|
|
820
844
|
hardwareSeen: () => keyboardPresence.hardwareSeen(),
|
|
821
845
|
likely: () => keyboardPresence.likely(),
|
|
@@ -851,6 +875,25 @@ function createShellInto(
|
|
|
851
875
|
split,
|
|
852
876
|
restoreSplit: (committed, selected) =>
|
|
853
877
|
splitEnabled && isPaneSide(selected) && openSplitAt(committed, selected),
|
|
878
|
+
claimPageTitle() {
|
|
879
|
+
const claim: { text: string | null } = { text: null };
|
|
880
|
+
claimedTitle = claim;
|
|
881
|
+
paintTitle();
|
|
882
|
+
return {
|
|
883
|
+
set(text) {
|
|
884
|
+
claim.text = text;
|
|
885
|
+
if (claimedTitle === claim) {
|
|
886
|
+
paintTitle();
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
release() {
|
|
890
|
+
if (claimedTitle === claim) {
|
|
891
|
+
claimedTitle = null;
|
|
892
|
+
paintTitle();
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
},
|
|
854
897
|
};
|
|
855
898
|
// One row controller over every pane's tabpanel: a chip names the panel of the
|
|
856
899
|
// pane showing it, or the one a click would fill.
|
package/src/kernel/types.ts
CHANGED
|
@@ -293,6 +293,9 @@ export interface KeyboardPresence {
|
|
|
293
293
|
export interface ShellContext {
|
|
294
294
|
/** The shell root; the pane root in the direct-root topology. */
|
|
295
295
|
readonly root: HTMLElement;
|
|
296
|
+
/** The resolved `layout` option. Only a `"viewport"` terminal owns the page, so
|
|
297
|
+
* only it may write the address bar. */
|
|
298
|
+
readonly layoutMode: "viewport" | "container";
|
|
296
299
|
readonly keyboard: KeyboardPresence;
|
|
297
300
|
/** Null while that side has no built kernel. */
|
|
298
301
|
pane(side: PaneSide): PaneHandle | null;
|
|
@@ -322,9 +325,10 @@ export interface ShellContext {
|
|
|
322
325
|
/** Bind the page's attention surfaces (the document-title prefix, the app
|
|
323
326
|
* badge, the icon links) and get the reporter that drives them. The terminal
|
|
324
327
|
* is the only writer of `document.title`: a program's OSC 0/2 title replaces
|
|
325
|
-
* the base
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
+
* the base (unless a feature holds `claimPageTitle`) and keeps the prefix.
|
|
329
|
+
* The surfaces are restored when the page hides and on destroy (which also
|
|
330
|
+
* restores the served title), and repainted on a back-forward cache restore.
|
|
331
|
+
* Keep the report an AGGREGATE, since a page has one title over many sessions. */
|
|
328
332
|
attention(opts: AttentionOptions): AttentionReporter;
|
|
329
333
|
/** Subscribe to the server's session status stream at `path`. The terminal
|
|
330
334
|
* holds ONE stream per path for the page and fans it out, because the server
|
|
@@ -346,6 +350,12 @@ export interface ShellContext {
|
|
|
346
350
|
* shows a tab. False when the split is open already, `committedRatio` is not a
|
|
347
351
|
* share of 0 to 1, or `selected` is not a side. */
|
|
348
352
|
restoreSplit(committedRatio: number, selected: PaneSide): boolean;
|
|
353
|
+
/** Take the document title's base over from the selected pane's program title.
|
|
354
|
+
* `set(text)` names the page `<text> · <served title>` (the text alone when
|
|
355
|
+
* the page was served untitled), `set(null)` or `set("")` shows the served
|
|
356
|
+
* title, and `release()` hands the base back. The attention prefix is kept,
|
|
357
|
+
* and a later claim replaces an earlier one. */
|
|
358
|
+
claimPageTitle(): { set(text: string | null): void; release(): void };
|
|
349
359
|
}
|
|
350
360
|
|
|
351
361
|
/** The inbound-wire + lifecycle events features can subscribe to via ctx.on.
|