@cplieger/web-terminal-ui 8.3.0 → 8.3.2
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 +65 -612
- package/css/01-scope.css +3 -9
- package/package.json +4 -4
- package/src/composition.ts +1 -1
- package/src/features/connection-banner.ts +2 -2
- package/src/features/context-menu.ts +3 -8
- package/src/features/predictive-echo.ts +0 -1
- package/src/features/tabs/index.ts +53 -111
- package/src/features/tabs/model.ts +3 -10
- package/src/kernel/a11y.ts +10 -5
- package/src/kernel/conn-state.ts +3 -9
- package/src/kernel/loading-status.ts +6 -14
- package/src/kernel/pane.ts +4 -10
- package/src/kernel/scrollback-storage.ts +0 -5
- package/src/kernel/selection.ts +0 -3
- package/src/kernel/shell.ts +2 -27
- package/src/kernel/split-handle.ts +0 -3
- package/src/predict.ts +2 -11
- package/src/viewport.ts +9 -1
package/css/01-scope.css
CHANGED
|
@@ -15,15 +15,9 @@
|
|
|
15
15
|
.wt-root.wt-viewport {
|
|
16
16
|
position: fixed;
|
|
17
17
|
inset: 0;
|
|
18
|
-
/* Clip like the container mode below, so the two
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
position:fixed descendants escape a clip regardless. It exists so the
|
|
22
|
-
asymmetry cannot be mistaken for a deliberate difference, and so a future
|
|
23
|
-
transform on .term (see docs/tab-switch-repaint.md) is bounded by a
|
|
24
|
-
declaration rather than by the viewport happening to coincide with the box.
|
|
25
|
-
overflow:clip specifically, not hidden: no scroll container, no block
|
|
26
|
-
formatting context, nothing programmatically scrollable. */
|
|
18
|
+
/* Clip like the container mode below, so the two modes agree. Inert while
|
|
19
|
+
this box is the viewport, but it bounds a future transform on .term by a
|
|
20
|
+
declaration. clip, not hidden: no scroll container, no BFC. */
|
|
27
21
|
overflow: clip;
|
|
28
22
|
}
|
|
29
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.3.
|
|
3
|
+
"version": "8.3.2",
|
|
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",
|
|
@@ -50,18 +50,18 @@
|
|
|
50
50
|
"@stryker-mutator/core": "10.0.0",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
|
-
"@types/node": "26.6.
|
|
53
|
+
"@types/node": "26.6.4",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
55
|
"@vitest/browser": "5.0.3",
|
|
56
56
|
"@vitest/browser-playwright": "5.0.3",
|
|
57
57
|
"@vitest/coverage-v8": "5.0.3",
|
|
58
|
-
"eslint": "10.
|
|
58
|
+
"eslint": "10.12.0",
|
|
59
59
|
"fast-check": "4.10.2",
|
|
60
60
|
"html-validate": "11.16.1",
|
|
61
61
|
"knip": "6.39.0",
|
|
62
62
|
"playwright": "1.63.0",
|
|
63
63
|
"prettier": "3.9.9",
|
|
64
|
-
"stylelint": "17.
|
|
64
|
+
"stylelint": "17.16.0",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
67
|
"typescript-eslint": "8.71.0",
|
package/src/composition.ts
CHANGED
|
@@ -190,7 +190,7 @@ export function createComposition(opts: CompositionOptions): Composition {
|
|
|
190
190
|
|
|
191
191
|
return {
|
|
192
192
|
isComposing,
|
|
193
|
-
isCompositionOpen: () => composing
|
|
193
|
+
isCompositionOpen: () => composing,
|
|
194
194
|
cancelComposition,
|
|
195
195
|
positionCompositionView,
|
|
196
196
|
teardown,
|
|
@@ -8,11 +8,11 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
8
8
|
connecting: "Reconnecting\u2026",
|
|
9
9
|
reconnecting: "Reconnecting\u2026",
|
|
10
10
|
offline: "Offline",
|
|
11
|
-
restarted: "Server restarted
|
|
11
|
+
restarted: "Server restarted. Recent input may be lost",
|
|
12
12
|
// The session's process exited (definitive; nothing is retrying). The final
|
|
13
13
|
// screen stays rendered behind the banner; open a new tab to keep working.
|
|
14
14
|
ended: "Session ended",
|
|
15
|
-
incompatible: "Terminal protocol mismatch
|
|
15
|
+
incompatible: "Terminal protocol mismatch. Update the server or reload this page",
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
/** Build the connectionBanner feature. Exposes no API: it owns one element in
|
|
@@ -226,9 +226,6 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
226
226
|
pressSelection = selectionTextWithin(surface);
|
|
227
227
|
};
|
|
228
228
|
const onTouchMove = (e: TouchEvent): void => {
|
|
229
|
-
if (!pressLive) {
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
229
|
const t = e.touches.length === 1 ? e.touches[0] : undefined;
|
|
233
230
|
if (!t) {
|
|
234
231
|
pressLive = false;
|
|
@@ -258,11 +255,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
258
255
|
return; // this press selected text; the OS callout owns it
|
|
259
256
|
}
|
|
260
257
|
show(pressX, pressY);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
swallow.arm();
|
|
265
|
-
}
|
|
258
|
+
// Armed at the release edge, so the trailing click this same gesture
|
|
259
|
+
// emits is always inside the window.
|
|
260
|
+
swallow.arm();
|
|
266
261
|
};
|
|
267
262
|
const onTouchCancel = (): void => {
|
|
268
263
|
pressLive = false;
|
|
@@ -247,12 +247,12 @@ async function createSessionHonouringRetry(
|
|
|
247
247
|
if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
248
248
|
lastAnnouncedAt = elapsed;
|
|
249
249
|
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
250
|
-
ctx.toast(`${reason}
|
|
251
|
-
ctx.announce(`${reason}
|
|
250
|
+
ctx.toast(`${reason}, waiting…`, 8000);
|
|
251
|
+
ctx.announce(`${reason}, waiting…`);
|
|
252
252
|
}
|
|
253
253
|
// Unthrottled: the overlay is the only surface visible before the first
|
|
254
254
|
// frame, and it replaces text in place.
|
|
255
|
-
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}
|
|
255
|
+
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
256
256
|
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
257
257
|
continue;
|
|
258
258
|
}
|
|
@@ -545,11 +545,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
545
545
|
}
|
|
546
546
|
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
547
547
|
// is a fresh cue: called when its status moves off the acknowledged value
|
|
548
|
-
// (a new working phase, an exit) and when the session goes away.
|
|
548
|
+
// (a new working phase, an exit) and when the session goes away. Every
|
|
549
|
+
// caller ends in syncChrome, which repaints the out-of-page surfaces.
|
|
549
550
|
function forgetCueSeen(id: string): void {
|
|
550
551
|
if (cueSeen.delete(id)) {
|
|
551
552
|
writeCueSeen(win, cueSeen);
|
|
552
|
-
paintAttention();
|
|
553
553
|
}
|
|
554
554
|
}
|
|
555
555
|
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
@@ -600,9 +600,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
600
600
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
601
601
|
swSwitchDot.title = statusPhrase(switchNotify);
|
|
602
602
|
}
|
|
603
|
-
// The out-of-page surfaces answer to the same set, so they repaint
|
|
604
|
-
// wherever this does: a raise, and every clear through clearSwitchNotify.
|
|
605
|
-
paintAttention();
|
|
606
603
|
}
|
|
607
604
|
function clearSwitchNotify(): void {
|
|
608
605
|
switchNotify = "";
|
|
@@ -931,7 +928,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
931
928
|
}
|
|
932
929
|
function keyboardParkedOnChrome(): boolean {
|
|
933
930
|
const active = doc.activeElement;
|
|
934
|
-
return
|
|
931
|
+
return bar.contains(active) || switcher.contains(active);
|
|
935
932
|
}
|
|
936
933
|
function keyboardInPaneInput(): boolean {
|
|
937
934
|
const active = doc.activeElement;
|
|
@@ -1254,8 +1251,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1254
1251
|
for (const tab of tabList) {
|
|
1255
1252
|
scroller.appendChild(tab.el);
|
|
1256
1253
|
}
|
|
1257
|
-
paintActive();
|
|
1258
|
-
syncMobile();
|
|
1259
1254
|
}
|
|
1260
1255
|
|
|
1261
1256
|
// closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
|
|
@@ -1298,9 +1293,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1298
1293
|
}
|
|
1299
1294
|
collapseClearTimer = win.setTimeout(() => {
|
|
1300
1295
|
collapseClearTimer = null;
|
|
1301
|
-
|
|
1302
|
-
clearRows();
|
|
1303
|
-
}
|
|
1296
|
+
clearRows();
|
|
1304
1297
|
}, 260);
|
|
1305
1298
|
}
|
|
1306
1299
|
}
|
|
@@ -1414,10 +1407,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1414
1407
|
activityCount: tab.activityCount,
|
|
1415
1408
|
}),
|
|
1416
1409
|
);
|
|
1417
|
-
el.addEventListener("click", (
|
|
1418
|
-
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1419
|
-
return; // handled by the close button
|
|
1420
|
-
}
|
|
1410
|
+
el.addEventListener("click", () => {
|
|
1421
1411
|
if (editingId === tab.id) {
|
|
1422
1412
|
return; // the rename field owns this chip
|
|
1423
1413
|
}
|
|
@@ -1983,10 +1973,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1983
1973
|
// would ignore an inline transform and refuse to move. `translate` composes
|
|
1984
1974
|
// with it instead, so the two can own one chip at the same time.
|
|
1985
1975
|
function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
|
|
1986
|
-
if (shiftTimer !== null) {
|
|
1987
|
-
win.clearTimeout(shiftTimer);
|
|
1988
|
-
shiftTimer = null;
|
|
1989
|
-
}
|
|
1990
1976
|
for (const [el, dx] of px) {
|
|
1991
1977
|
el.style.transition = trans;
|
|
1992
1978
|
el.style.translate = `${String(Math.round(dx))}px`;
|
|
@@ -2105,7 +2091,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2105
2091
|
if (before !== null && before.parentNode !== scroller) {
|
|
2106
2092
|
return;
|
|
2107
2093
|
}
|
|
2108
|
-
if (before === dragged
|
|
2094
|
+
if (before === dragged.nextElementSibling) {
|
|
2109
2095
|
return;
|
|
2110
2096
|
}
|
|
2111
2097
|
flipTo(() => {
|
|
@@ -2162,16 +2148,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2162
2148
|
return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
|
|
2163
2149
|
}
|
|
2164
2150
|
function announceTarget(el: HTMLElement): void {
|
|
2165
|
-
|
|
2166
|
-
if (at > 0) {
|
|
2167
|
-
ctx.announce(`Drop position ${String(at)}`);
|
|
2168
|
-
}
|
|
2151
|
+
ctx.announce(`Drop position ${String(slotPosition(el))}`);
|
|
2169
2152
|
}
|
|
2170
2153
|
function announceMoved(el: HTMLElement): void {
|
|
2171
2154
|
const tab = tabList.find((t) => t.el === el);
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
ctx.announce(`Moved ${tab.display} to position ${String(at)}`);
|
|
2155
|
+
if (tab) {
|
|
2156
|
+
ctx.announce(`Moved ${tab.display} to position ${String(slotPosition(el))}`);
|
|
2175
2157
|
}
|
|
2176
2158
|
}
|
|
2177
2159
|
|
|
@@ -2222,10 +2204,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2222
2204
|
if (removed !== draggingEl) {
|
|
2223
2205
|
return;
|
|
2224
2206
|
}
|
|
2225
|
-
removed.classList.remove("wt-tab-dragging");
|
|
2226
2207
|
draggingEl = null;
|
|
2227
|
-
dropped = false;
|
|
2228
|
-
endReorderPreview();
|
|
2229
2208
|
endShift();
|
|
2230
2209
|
clearDragGhost();
|
|
2231
2210
|
}
|
|
@@ -2238,14 +2217,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2238
2217
|
order.push(t);
|
|
2239
2218
|
}
|
|
2240
2219
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
syncChrome();
|
|
2248
|
-
}
|
|
2220
|
+
tabList.length = 0;
|
|
2221
|
+
tabList.push(...order);
|
|
2222
|
+
// Before syncChrome: it ends in applyServerOrder, which would undo this
|
|
2223
|
+
// move while the tabs still carry their old positions.
|
|
2224
|
+
publishOrder();
|
|
2225
|
+
syncChrome();
|
|
2249
2226
|
}
|
|
2250
2227
|
|
|
2251
2228
|
// The write half of tab-order sync, called only from the paths that COMMIT
|
|
@@ -2254,9 +2231,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2254
2231
|
// the strip straight back. A 409 means this client's list is stale, so the
|
|
2255
2232
|
// server's word is taken; any other failure leaves the arrangement local.
|
|
2256
2233
|
function publishOrder(): void {
|
|
2257
|
-
if (tabList.length === 0) {
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
2234
|
tabList.forEach((tab, i) => {
|
|
2261
2235
|
tab.order = i;
|
|
2262
2236
|
});
|
|
@@ -2432,8 +2406,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2432
2406
|
// became ready: say so with the server's own words when it gave any.
|
|
2433
2407
|
ctx.toast(
|
|
2434
2408
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2435
|
-
? `
|
|
2436
|
-
: "
|
|
2409
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
2410
|
+
: "Could not open a terminal",
|
|
2437
2411
|
);
|
|
2438
2412
|
return;
|
|
2439
2413
|
}
|
|
@@ -2486,7 +2460,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2486
2460
|
// far left while the create POST is in flight. The replacement lands as any
|
|
2487
2461
|
// new tab does, and dropping the old tab is then an ordinary non-last
|
|
2488
2462
|
// close, so this intercept does not re-fire. A user close only.
|
|
2489
|
-
if (remote && tabList.length === 1
|
|
2463
|
+
if (remote && tabList.length === 1) {
|
|
2490
2464
|
await create();
|
|
2491
2465
|
// A failed create adds nothing (and toasts): keep the existing tab
|
|
2492
2466
|
// rather than stranding the user on an empty strip.
|
|
@@ -2573,7 +2547,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2573
2547
|
await api.close(id);
|
|
2574
2548
|
} catch {
|
|
2575
2549
|
if (!tornDown()) {
|
|
2576
|
-
ctx.toast("
|
|
2550
|
+
ctx.toast("Could not close the terminal on the server");
|
|
2577
2551
|
}
|
|
2578
2552
|
}
|
|
2579
2553
|
}
|
|
@@ -2655,7 +2629,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2655
2629
|
await api.close(t.id);
|
|
2656
2630
|
} catch {
|
|
2657
2631
|
if (!tornDown()) {
|
|
2658
|
-
ctx.toast("
|
|
2632
|
+
ctx.toast("Could not close a terminal on the server");
|
|
2659
2633
|
}
|
|
2660
2634
|
}
|
|
2661
2635
|
}
|
|
@@ -2687,10 +2661,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2687
2661
|
// Recorded at entry because the exit paths cannot tell a keyboard-started
|
|
2688
2662
|
// edit (focus returns to the chip) from a pointer one (to the terminal).
|
|
2689
2663
|
let editFrom: "keyboard" | "pointer" = "pointer";
|
|
2690
|
-
// Pinned-name requests in flight, COUNTED per id: two renames of one tab can
|
|
2691
|
-
// be out at once, and a bare Set let the first completion clear the marker
|
|
2692
|
-
// while the second's PUT was still open, so that rename failed silently.
|
|
2693
|
-
const namesInFlight = new Map<string, number>();
|
|
2694
2664
|
|
|
2695
2665
|
/** restoreFocusAfterEdit sends focus where the entry path implies. The
|
|
2696
2666
|
* terminal branch is gated exactly like switchTo's focus-on-switch: on a
|
|
@@ -2745,8 +2715,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2745
2715
|
}
|
|
2746
2716
|
// Optimistic: paint it now, guarded by a monotonic counter AND the tab's
|
|
2747
2717
|
// birth epoch so a slow response cannot roll back a newer rename, a later
|
|
2748
|
-
// clear,
|
|
2749
|
-
//
|
|
2718
|
+
// clear, or — if this id were ever reused by a fresh session — a
|
|
2719
|
+
// different tab entirely.
|
|
2750
2720
|
const seq = ++t.nameSeq;
|
|
2751
2721
|
const born = t.born;
|
|
2752
2722
|
t.pinnedTitle = name;
|
|
@@ -2761,28 +2731,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2761
2731
|
ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
|
|
2762
2732
|
}
|
|
2763
2733
|
const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
.
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
}
|
|
2774
|
-
cur.pinnedTitle = before;
|
|
2775
|
-
syncChrome();
|
|
2776
|
-
ctx.toast("Couldn't save the terminal name");
|
|
2777
|
-
})
|
|
2778
|
-
.finally(() => {
|
|
2779
|
-
const left = (namesInFlight.get(id) ?? 0) - 1;
|
|
2780
|
-
if (left > 0) {
|
|
2781
|
-
namesInFlight.set(id, left);
|
|
2782
|
-
} else {
|
|
2783
|
-
namesInFlight.delete(id);
|
|
2784
|
-
}
|
|
2785
|
-
});
|
|
2734
|
+
void request.catch(() => {
|
|
2735
|
+
const cur = tabList.find((x) => x.id === id);
|
|
2736
|
+
if (cur?.born !== born || cur.nameSeq !== seq) {
|
|
2737
|
+
return; // gone, reused, or superseded: the newer state stands
|
|
2738
|
+
}
|
|
2739
|
+
cur.pinnedTitle = before;
|
|
2740
|
+
syncChrome();
|
|
2741
|
+
ctx.toast("Could not save the terminal name");
|
|
2742
|
+
});
|
|
2786
2743
|
return true;
|
|
2787
2744
|
}
|
|
2788
2745
|
|
|
@@ -2973,7 +2930,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2973
2930
|
hideTabMenu();
|
|
2974
2931
|
const idx = tabList.findIndex((t) => t.id === id);
|
|
2975
2932
|
const target = tabList[idx];
|
|
2976
|
-
if (
|
|
2933
|
+
if (!target) {
|
|
2977
2934
|
return;
|
|
2978
2935
|
}
|
|
2979
2936
|
const n = tabList.length;
|
|
@@ -3134,17 +3091,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3134
3091
|
t.title = rec.title;
|
|
3135
3092
|
}
|
|
3136
3093
|
// "" is meaningful here: it is how a clear made in ANOTHER browser reaches
|
|
3137
|
-
// this one.
|
|
3138
|
-
//
|
|
3139
|
-
//
|
|
3140
|
-
if (rec.pinnedTitle !== undefined
|
|
3094
|
+
// this one. It never bumps nameSeq: SSE delivery and REST mutation are not
|
|
3095
|
+
// one total order, so a record arriving during a pending request may
|
|
3096
|
+
// predate our own PUT, and a bump would suppress that request's rollback.
|
|
3097
|
+
if (rec.pinnedTitle !== undefined) {
|
|
3141
3098
|
t.pinnedTitle = rec.pinnedTitle;
|
|
3142
|
-
// SSE delivery and REST mutation are not one total order: during a
|
|
3143
|
-
// pending request the record may predate our own PUT, and bumping would
|
|
3144
|
-
// suppress that request's rollback and its failure toast.
|
|
3145
|
-
if (!namesInFlight.has(rec.id)) {
|
|
3146
|
-
t.nameSeq++;
|
|
3147
|
-
}
|
|
3148
3099
|
}
|
|
3149
3100
|
}
|
|
3150
3101
|
|
|
@@ -3350,20 +3301,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3350
3301
|
if (!draggingEl) {
|
|
3351
3302
|
return;
|
|
3352
3303
|
}
|
|
3353
|
-
e.preventDefault();
|
|
3354
3304
|
if (e.dataTransfer) {
|
|
3355
3305
|
e.dataTransfer.dropEffect = "move";
|
|
3356
3306
|
}
|
|
3357
3307
|
trackRest(e.clientX);
|
|
3358
3308
|
});
|
|
3359
|
-
//
|
|
3360
|
-
// drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
|
|
3361
|
-
// navigated the page to /<session-id>.
|
|
3309
|
+
// onDocTabDrop cancels both events here too, as they bubble.
|
|
3362
3310
|
bar.addEventListener("drop", (e) => {
|
|
3363
|
-
if (!draggingEl) {
|
|
3364
|
-
return;
|
|
3365
|
-
}
|
|
3366
|
-
e.preventDefault();
|
|
3367
3311
|
dropOnStrip(e.clientX);
|
|
3368
3312
|
});
|
|
3369
3313
|
function dropOnStrip(clientX: number): void {
|
|
@@ -3388,7 +3332,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3388
3332
|
return;
|
|
3389
3333
|
}
|
|
3390
3334
|
const next = e.relatedTarget;
|
|
3391
|
-
if (
|
|
3335
|
+
if (!bar.contains(next as Node | null)) {
|
|
3392
3336
|
endRestNet();
|
|
3393
3337
|
}
|
|
3394
3338
|
});
|
|
@@ -3412,10 +3356,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3412
3356
|
ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
|
|
3413
3357
|
ctx.shell.root.classList.toggle("wt-drop-right", half === "right");
|
|
3414
3358
|
}
|
|
3415
|
-
// A tab released anywhere
|
|
3416
|
-
//
|
|
3417
|
-
//
|
|
3418
|
-
//
|
|
3359
|
+
// A tab released anywhere must be inert, not a navigation: WebKit's default
|
|
3360
|
+
// for an uncancelled drop is to LOAD the payload as a URL, which sent iPadOS
|
|
3361
|
+
// to /<session-id>. So the whole document, the strip included, is a drop
|
|
3362
|
+
// target for the life of a tab drag; over a half of the pane row it is a
|
|
3363
|
+
// snap. Gated on draggingEl so a file dropped on the page is the browser's.
|
|
3419
3364
|
const onDocTabDrop = (e: DragEvent): void => {
|
|
3420
3365
|
if (!draggingEl) {
|
|
3421
3366
|
return;
|
|
@@ -3499,7 +3444,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3499
3444
|
const rule = pointerDragActivation(e.pointerType);
|
|
3500
3445
|
const el =
|
|
3501
3446
|
e.target instanceof win.Element ? e.target.closest<HTMLElement>(".wt-tab") : null;
|
|
3502
|
-
if (rule === null || !e.isPrimary ||
|
|
3447
|
+
if (rule === null || !e.isPrimary || el?.parentNode !== scroller) {
|
|
3503
3448
|
return;
|
|
3504
3449
|
}
|
|
3505
3450
|
const tab = tabList.find((t) => t.el === el);
|
|
@@ -3529,7 +3474,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3529
3474
|
win.addEventListener("pointermove", onPressMove, opts);
|
|
3530
3475
|
win.addEventListener("pointerup", onPressUp, opts);
|
|
3531
3476
|
win.addEventListener("pointercancel", onPressCancel, opts);
|
|
3532
|
-
|
|
3477
|
+
// Registered after this press's own pointerdown has passed the window, so
|
|
3478
|
+
// any pointerdown it sees is another pointer.
|
|
3479
|
+
win.addEventListener("pointerdown", cancelPointerPress, opts);
|
|
3533
3480
|
win.addEventListener("keydown", onPressKey, opts);
|
|
3534
3481
|
win.addEventListener("blur", cancelPointerPress, { signal: off.signal });
|
|
3535
3482
|
doc.addEventListener("visibilitychange", onPressVisibility, { signal: off.signal });
|
|
@@ -3608,11 +3555,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3608
3555
|
cancelPointerPress();
|
|
3609
3556
|
}
|
|
3610
3557
|
}
|
|
3611
|
-
function onPressSecond(e: PointerEvent): void {
|
|
3612
|
-
if (press !== null && press.id !== e.pointerId) {
|
|
3613
|
-
cancelPointerPress();
|
|
3614
|
-
}
|
|
3615
|
-
}
|
|
3616
3558
|
// lostpointercapture bubbles, and the chip's implicit touch capture ending
|
|
3617
3559
|
// when the bar takes it over is not a loss.
|
|
3618
3560
|
function onPressLostCapture(e: PointerEvent): void {
|
|
@@ -3657,7 +3599,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3657
3599
|
return;
|
|
3658
3600
|
}
|
|
3659
3601
|
const target = doc.elementFromPoint(x, y);
|
|
3660
|
-
if (
|
|
3602
|
+
if (bar.contains(target)) {
|
|
3661
3603
|
paintDropHalf(null);
|
|
3662
3604
|
if (release) {
|
|
3663
3605
|
dropOnStrip(x);
|
|
@@ -4184,8 +4126,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4184
4126
|
// can retry, as the runtime create() does.
|
|
4185
4127
|
ctx.toast(
|
|
4186
4128
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4187
|
-
? `
|
|
4188
|
-
: "
|
|
4129
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
4130
|
+
: "Could not open a terminal",
|
|
4189
4131
|
);
|
|
4190
4132
|
}
|
|
4191
4133
|
}
|
|
@@ -167,10 +167,9 @@ export function tabAccessibleName(v: {
|
|
|
167
167
|
}): string {
|
|
168
168
|
const state = statusPhrase(v.status);
|
|
169
169
|
const progress = v.progress ?? PROGRESS_ABSENT;
|
|
170
|
-
const head =
|
|
171
|
-
progress < 0 ? `${v.label} — ${state}` : `${v.label} — ${state}, ${String(progress)}%`;
|
|
170
|
+
const head = progress < 0 ? `${v.label}, ${state}` : `${v.label}, ${state}, ${String(progress)}%`;
|
|
172
171
|
const activity = activityPhrase(v.activity ?? "", v.activityCount ?? 0);
|
|
173
|
-
return activity === "" ? head : `${head}
|
|
172
|
+
return activity === "" ? head : `${head}, ${activity}`;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
@@ -294,7 +293,7 @@ export const MAX_PERSISTED_CUE_SEEN = 200;
|
|
|
294
293
|
* lost acknowledgement only re-lights a dot the user can dismiss again. */
|
|
295
294
|
export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
|
|
296
295
|
const out = new Map<string, CueStatus>();
|
|
297
|
-
if (raw === null
|
|
296
|
+
if (raw === null) {
|
|
298
297
|
return out;
|
|
299
298
|
}
|
|
300
299
|
let data: unknown;
|
|
@@ -609,9 +608,6 @@ function parseRetryAfter(header: string | null, now: number): number | undefined
|
|
|
609
608
|
return undefined;
|
|
610
609
|
}
|
|
611
610
|
const raw = header.trim();
|
|
612
|
-
if (raw === "") {
|
|
613
|
-
return undefined;
|
|
614
|
-
}
|
|
615
611
|
if (/^\d+$/.test(raw)) {
|
|
616
612
|
return Math.min(Number(raw) * 1000, RETRY_AFTER_MAX_MS);
|
|
617
613
|
}
|
|
@@ -628,9 +624,6 @@ function parseRetryAfter(header: string | null, now: number): number | undefined
|
|
|
628
624
|
async function readServerMessage(r: Response): Promise<string | undefined> {
|
|
629
625
|
try {
|
|
630
626
|
const body: unknown = await r.json();
|
|
631
|
-
if (typeof body !== "object" || body === null) {
|
|
632
|
-
return undefined;
|
|
633
|
-
}
|
|
634
627
|
const fields = body as { error?: unknown; message?: unknown };
|
|
635
628
|
for (const candidate of [fields.error, fields.message]) {
|
|
636
629
|
if (typeof candidate === "string" && candidate.trim() !== "") {
|
package/src/kernel/a11y.ts
CHANGED
|
@@ -29,7 +29,15 @@ export interface Announcer {
|
|
|
29
29
|
/** Delay before a cleared live region is re-set with the new message (ms).
|
|
30
30
|
* Long enough for assistive tech to register the clear and the set as two
|
|
31
31
|
* distinct mutations (a sub-frame gap is not). */
|
|
32
|
-
|
|
32
|
+
let REANNOUNCE_DELAY_MS = 100;
|
|
33
|
+
|
|
34
|
+
/** @internal Test seam: re-set every later announcement after `ms`; returns the
|
|
35
|
+
* previous value so the caller can restore it. */
|
|
36
|
+
export function setReannounceDelayMs(ms: number): number {
|
|
37
|
+
const previous = REANNOUNCE_DELAY_MS;
|
|
38
|
+
REANNOUNCE_DELAY_MS = ms;
|
|
39
|
+
return previous;
|
|
40
|
+
}
|
|
33
41
|
|
|
34
42
|
/** Build the single pair of live regions inside root. */
|
|
35
43
|
export function createAnnouncer(root: HTMLElement): Announcer {
|
|
@@ -58,10 +66,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
|
|
|
58
66
|
// distinct mutations — the same rationale ui-primitives' announce()
|
|
59
67
|
// documents for its shared region.
|
|
60
68
|
el.textContent = "";
|
|
61
|
-
|
|
62
|
-
if (pending !== undefined) {
|
|
63
|
-
win.clearTimeout(pending);
|
|
64
|
-
}
|
|
69
|
+
win.clearTimeout(timers.get(el));
|
|
65
70
|
timers.set(
|
|
66
71
|
el,
|
|
67
72
|
win.setTimeout(() => {
|
package/src/kernel/conn-state.ts
CHANGED
|
@@ -62,12 +62,10 @@ export function createConnState(opts: {
|
|
|
62
62
|
// Suppress transient states until the initial load is over (the loading
|
|
63
63
|
// overlay owns the screen). Terminal states ("ended" and "incompatible")
|
|
64
64
|
// must pass through because no later frame or reconnect will explain the
|
|
65
|
-
// failure;
|
|
65
|
+
// failure; offline, which before load is reached only at the initial-failure
|
|
66
|
+
// limit (closed() returns before it until then), passes for the same reason.
|
|
66
67
|
// connectionBanner renders "open" as hidden.
|
|
67
|
-
const passesLoadingGate =
|
|
68
|
-
state === "ended" ||
|
|
69
|
-
state === "incompatible" ||
|
|
70
|
-
(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
|
|
68
|
+
const passesLoadingGate = state === "ended" || state === "incompatible" || state === "offline";
|
|
71
69
|
if (!loaded && !passesLoadingGate) {
|
|
72
70
|
opts.onState("open"); // nothing to show yet
|
|
73
71
|
return;
|
|
@@ -140,10 +138,6 @@ export function createConnState(opts: {
|
|
|
140
138
|
},
|
|
141
139
|
idle(): void {
|
|
142
140
|
consecutiveFailures = 0;
|
|
143
|
-
if (restartedTimer !== null) {
|
|
144
|
-
timers.clearTimeout(restartedTimer);
|
|
145
|
-
restartedTimer = null;
|
|
146
|
-
}
|
|
147
141
|
setState("idle", 0);
|
|
148
142
|
},
|
|
149
143
|
setLoaded(): void {
|
|
@@ -19,10 +19,10 @@ export interface LoadingMessages {
|
|
|
19
19
|
export const DEFAULT_LOADING_MESSAGES: LoadingMessages = {
|
|
20
20
|
initial: "Loading terminal…",
|
|
21
21
|
waiting: [
|
|
22
|
-
"Still working
|
|
23
|
-
"Still working
|
|
24
|
-
"Still working
|
|
25
|
-
"Still working
|
|
22
|
+
"Still working. This can take a while on first start.",
|
|
23
|
+
"Still working, preparing the environment.",
|
|
24
|
+
"Still working, almost there.",
|
|
25
|
+
"Still working, thanks for your patience.",
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|
|
@@ -165,10 +165,7 @@ export function attachLoadingStatus(
|
|
|
165
165
|
}
|
|
166
166
|
current = text;
|
|
167
167
|
pinned = true;
|
|
168
|
-
|
|
169
|
-
win.clearInterval(rotation);
|
|
170
|
-
rotation = undefined;
|
|
171
|
-
}
|
|
168
|
+
win.clearInterval(rotation);
|
|
172
169
|
show(text);
|
|
173
170
|
announce(text);
|
|
174
171
|
},
|
|
@@ -179,16 +176,11 @@ export function attachLoadingStatus(
|
|
|
179
176
|
// cancellable callback, can run, and it is the one reader of `stopped`.
|
|
180
177
|
// show() and announce() do not re-test it; a timer armed outside later()
|
|
181
178
|
// is what the "stop() leaves no timer armed" test catches.
|
|
182
|
-
if (stopped) {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
179
|
stopped = true;
|
|
186
180
|
for (const t of timers) {
|
|
187
181
|
win.clearTimeout(t);
|
|
188
182
|
}
|
|
189
|
-
|
|
190
|
-
win.clearInterval(rotation);
|
|
191
|
-
}
|
|
183
|
+
win.clearInterval(rotation);
|
|
192
184
|
visible.remove();
|
|
193
185
|
live.remove();
|
|
194
186
|
},
|