@cplieger/web-terminal-ui 7.3.2 → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +114 -18
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +15 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
package/src/kernel/kernel.ts
CHANGED
|
@@ -1,2079 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// display-only output surface and the hidden textarea that owns the keyboard,
|
|
5
|
-
// IME/composition, the engine wiring (render/connection/scroll), viewport and
|
|
6
|
-
// keyboard-inset handling, the single sanitizing input funnel, the
|
|
7
|
-
// connection-state machine + loading lifecycle, the shared primitives (toast,
|
|
8
|
-
// announce, tablist), and the named layout regions. Everything visible above
|
|
9
|
-
// the raw terminal (toolbar, menu, banner text, tabs) is an opt-in feature.
|
|
10
|
-
//
|
|
11
|
-
// State is closure-scoped per createTerminal call (not module singletons), so
|
|
12
|
-
// destroy() can tear it down; the engine's render/connection/scroll modules are
|
|
13
|
-
// still single-instance, so createTerminal is called once per page (tabs
|
|
14
|
-
// multiplex sessions over the one kernel, section 22.5).
|
|
15
|
-
//
|
|
16
|
-
// The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
|
|
17
|
-
// REPORTS focus and blur to the transport (`connection.setClientFocus`) and
|
|
18
|
-
// never writes DEC 1004 bytes itself. The server derives the answer for the
|
|
19
|
-
// session from every attached client's report plus its own hold, which is the
|
|
20
|
-
// only place the question can be answered once a second device attaches.
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
render,
|
|
24
|
-
scroll,
|
|
25
|
-
connection,
|
|
26
|
-
keyboard,
|
|
27
|
-
modes,
|
|
28
|
-
mouse,
|
|
29
|
-
LineStore,
|
|
30
|
-
} from "@cplieger/web-terminal-engine";
|
|
31
|
-
import * as composition from "../composition.js";
|
|
32
|
-
import * as viewport from "../viewport.js";
|
|
33
|
-
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
|
|
34
|
-
import { createBus } from "./bus.js";
|
|
35
|
-
import { optionalPositiveIntOption } from "./options.js";
|
|
36
|
-
import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
|
|
37
|
-
import { createRegions } from "./regions.js";
|
|
38
|
-
import { createAnnouncer, createTablist } from "./a11y.js";
|
|
39
|
-
import { createConnState } from "./conn-state.js";
|
|
40
|
-
import { createScrollbackKeeper } from "./scrollback.js";
|
|
41
|
-
import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
|
|
42
|
-
import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
|
|
43
|
-
import { reloadPage } from "./navigation.js";
|
|
44
|
-
import type {
|
|
45
|
-
CreateTerminalOptions,
|
|
46
|
-
FeatureInstance,
|
|
47
|
-
SessionRef,
|
|
48
|
-
TerminalContext,
|
|
49
|
-
TerminalFeature,
|
|
50
|
-
TerminalHandle,
|
|
51
|
-
Unsubscribe,
|
|
52
|
-
} from "./types.js";
|
|
53
|
-
|
|
54
|
-
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
55
|
-
|
|
56
|
-
const DEFAULT_WS_PATH = "/ws";
|
|
57
|
-
// The family that carries the CELL METRICS, alone: deliberately not --font-mono's
|
|
58
|
-
// list, which leads with the tiling overlay. WebKit resolves load() over a list as
|
|
59
|
-
// soon as the FIRST family whose descriptor covers the sample has loaded
|
|
60
|
-
// (CSSFontFaceSet::matchingFacesExcludingPreinstalledFonts, `if (found) break;`),
|
|
61
|
-
// so naming the overlay would settle the gate on its 11 KB while the companion is
|
|
62
|
-
// still loading — and the overlay copies the companion's metrics and carries no
|
|
63
|
-
// "M", the glyph render.ts measures, so waiting on it defends no number anyway.
|
|
64
|
-
const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
|
|
65
|
-
// The deadline on the WHOLE gate below, because neither document.fonts.load nor
|
|
66
|
-
// document.fonts.ready carries one: a response held open leaves both pending for
|
|
67
|
-
// as long as that request lives, and a gate that never settles reports no size at
|
|
68
|
-
// all. The budget is the block period the faces themselves buy with font-display:
|
|
69
|
-
// block (css/page.css declares it on every one; 3s is what CSS Fonts 4 recommends
|
|
70
|
-
// for `block`) — how long an engine withholds glyphs before it paints the
|
|
71
|
-
// fallback. Once it has, the cell on screen IS the fallback cell, so a longer wait
|
|
72
|
-
// defends metrics nobody is looking at.
|
|
73
|
-
const FONT_READY_TIMEOUT_MS = 3000;
|
|
74
|
-
|
|
75
|
-
const TOAST_MS = 3000;
|
|
76
|
-
// A touch that focuses the input (opens the soft keyboard) must be a genuine
|
|
77
|
-
// tap: short and low-movement. A longer hold is a long-press, which belongs to
|
|
78
|
-
// native text selection / the context menu — so tap-to-focus bows out above
|
|
79
|
-
// this duration and never steals a long-press or a selection. The thresholds
|
|
80
|
-
// live in gesture.ts because the contextMenu feature classifies the other side
|
|
81
|
-
// of the same boundary from them.
|
|
82
|
-
// The narrow-layout breakpoints, in ROOT pixels. The single source of the
|
|
83
|
-
// numbers: the kernel mirrors the resulting fact into the .wt-narrow root
|
|
84
|
-
// class for CSS (paired there with pointer-coarseness media queries where
|
|
85
|
-
// touch matters) and features read the same fact via ctx.layout(). Root
|
|
86
|
-
// dimensions — not viewport dimensions — so an embedded terminal in a narrow
|
|
87
|
-
// panel counts as narrow. For every full-page consumer root size equals
|
|
88
|
-
// viewport size, so the width half matches the old (max-width: 600px) media
|
|
89
|
-
// queries exactly.
|
|
90
|
-
const NARROW_MAX_PX = 600;
|
|
91
|
-
// A root can be compact by HEIGHT as well as width: a large phone rotated to
|
|
92
|
-
// landscape (e.g. an iPhone 14 Pro Max at 932x430) is wider than
|
|
93
|
-
// NARROW_MAX_PX, but its short viewport is still a phone in the hand — the
|
|
94
|
-
// thumb-reach switcher bar is the right UX and the desktop strip wastes
|
|
95
|
-
// scarce rows. Tablets are never this short (the smallest iPad is 744 CSS px
|
|
96
|
-
// tall in landscape), so height <= 500 cleanly separates "landscape phone"
|
|
97
|
-
// from "tablet/desktop" with margin on both sides (the tallest current
|
|
98
|
-
// phones are ~440 in landscape, less with browser chrome).
|
|
99
|
-
const SHORT_MAX_PX = 500;
|
|
100
|
-
|
|
101
|
-
// Kernel-owned core subtree: the display-only output, the hidden textarea (the
|
|
102
|
-
// single keyboard target), and the IME composition view. No chrome; features
|
|
103
|
-
// build that into regions. Static, trusted markup parsed once via a template.
|
|
104
|
-
const CORE_TEMPLATE = `
|
|
105
|
-
<div class="term">
|
|
106
|
-
<div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
|
|
107
|
-
<textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
|
|
108
|
-
<div class="composition-view" aria-hidden="true"></div>
|
|
109
|
-
</div>`;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The single character this key types, or null when it types none. Used to tell
|
|
113
|
-
* a character the engine's encoder deferred to the `input` event apart from a
|
|
114
|
-
* key whose `key` is a NAME rather than text.
|
|
115
|
-
*
|
|
116
|
-
* `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
|
|
117
|
-
* the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
|
|
118
|
-
* are not. Code points rather than grapheme clusters on purpose: a character key
|
|
119
|
-
* carries one code point, and a segmenter would also accept a multi-scalar
|
|
120
|
-
* cluster. UI Events does allow a key "associated with multiple characters"
|
|
121
|
-
* (macros, some dead-key sequences); those return null here and are simply not
|
|
122
|
-
* recovered, which is a stated limitation rather than a claim about key names.
|
|
123
|
-
*/
|
|
124
|
-
function typedChar(ev: KeyboardEvent): string | null {
|
|
125
|
-
const first = ev.key.codePointAt(0);
|
|
126
|
-
return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
|
|
131
|
-
* typed. Shared by every send path so one physical key cannot produce two byte
|
|
132
|
-
* sequences depending on which listener caught it (the `input` event on the
|
|
133
|
-
* focused textarea, or the document-level type-to-focus fallback).
|
|
134
|
-
*/
|
|
135
|
-
function normalizeTypedText(text: string): string {
|
|
136
|
-
return text.replace(/\u00A0/g, " ");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** `KeyboardEvent.keyCode` for a key an IME has claimed. Windows' VK_PROCESS,
|
|
140
|
-
* reported by Blink and WebKit for the key that commits a composition, and by
|
|
141
|
-
* Android for essentially every soft-keyboard key — which is why every read of
|
|
142
|
-
* it is gated on a composition being open.
|
|
143
|
-
*
|
|
144
|
-
* Deprecated and nonetheless the only carrier of this fact: for that commit key
|
|
145
|
-
* both engines report `key` as the ordinary `"Enter"`, and Safari reports
|
|
146
|
-
* `isComposing` false, so neither the modern flag nor `key` distinguishes it. */
|
|
147
|
-
const IME_COMMIT_KEYCODE = 229;
|
|
148
|
-
|
|
149
|
-
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
150
|
-
const el = root.querySelector<HTMLElement>(selector);
|
|
151
|
-
if (!el) {
|
|
152
|
-
throw new Error(`web-terminal-ui: createTerminal failed to build ${selector}`);
|
|
153
|
-
}
|
|
154
|
-
return el;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Fade out and remove a consumer-supplied loading overlay.
|
|
158
|
-
*
|
|
159
|
-
* Module scope because BOTH startup-failure phases need it and they live on
|
|
160
|
-
* opposite sides of createTerminal's closure: the async `feature-setup` path
|
|
161
|
-
* reaches it through dismissLoadingOverlay (which adds the once-only guard),
|
|
162
|
-
* the synchronous `kernel-init` path calls it directly from the catch, where
|
|
163
|
-
* no kernel state exists yet. */
|
|
164
|
-
function fadeOutOverlay(ld: HTMLElement | undefined): void {
|
|
165
|
-
if (!ld) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
ld.classList.add("fade");
|
|
169
|
-
const removeOverlay = (): void => {
|
|
170
|
-
ld.remove();
|
|
171
|
-
};
|
|
172
|
-
ld.addEventListener("transitionend", removeOverlay, { once: true });
|
|
173
|
-
window.setTimeout(removeOverlay, 1500);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Render the kernel-owned "Terminal failed to start" recovery surface into root.
|
|
177
|
-
*
|
|
178
|
-
* Module scope for the same reason as fadeOutOverlay: it is the ONE
|
|
179
|
-
* implementation of this surface, shared by the async `feature-setup` phase and
|
|
180
|
-
* the synchronous `kernel-init` phase, so the two can never drift apart. It
|
|
181
|
-
* depends on nothing but its two arguments. */
|
|
182
|
-
function renderFatalStartupInto(
|
|
183
|
-
root: HTMLElement,
|
|
184
|
-
layoutMode: "viewport" | "container",
|
|
185
|
-
message: string = STARTUP_FAILURE_COPY.message,
|
|
186
|
-
): void {
|
|
187
|
-
const surface = document.createElement("dialog");
|
|
188
|
-
surface.className = "wt-fatal";
|
|
189
|
-
// alertdialog over the implicit `dialog` role: an urgent message to act on. No
|
|
190
|
-
// `aria-modal` — showModal() conveys modality; an authored value can contradict the UA.
|
|
191
|
-
surface.setAttribute("role", "alertdialog");
|
|
192
|
-
surface.setAttribute("aria-labelledby", "wt-fatal-title");
|
|
193
|
-
surface.setAttribute("aria-describedby", "wt-fatal-message");
|
|
194
|
-
// showModal() makes Escape a close request, and the kernel has already cleared
|
|
195
|
-
// the root: nothing is behind this panel, so reload is the only recovery.
|
|
196
|
-
surface.addEventListener("cancel", (ev) => {
|
|
197
|
-
ev.preventDefault();
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const card = document.createElement("div");
|
|
201
|
-
card.className = "wt-fatal-card";
|
|
202
|
-
const title = document.createElement("h2");
|
|
203
|
-
title.id = "wt-fatal-title";
|
|
204
|
-
title.className = "wt-fatal-title";
|
|
205
|
-
title.textContent = STARTUP_FAILURE_COPY.title;
|
|
206
|
-
const messageEl = document.createElement("p");
|
|
207
|
-
messageEl.id = "wt-fatal-message";
|
|
208
|
-
messageEl.className = "wt-fatal-message";
|
|
209
|
-
messageEl.textContent = message;
|
|
210
|
-
const reloadButton = document.createElement("button");
|
|
211
|
-
reloadButton.className = "wt-btn wt-fatal-reload";
|
|
212
|
-
reloadButton.type = "button";
|
|
213
|
-
reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
|
|
214
|
-
reloadButton.addEventListener("click", () => {
|
|
215
|
-
reloadPage();
|
|
216
|
-
});
|
|
217
|
-
card.append(title, messageEl, reloadButton);
|
|
218
|
-
surface.appendChild(card);
|
|
219
|
-
root.replaceChildren(surface);
|
|
220
|
-
|
|
221
|
-
// Viewport: the terminal IS the page, so the top layer and an inert document
|
|
222
|
-
// are both accurate; container gets the bare `open` attribute, which is neither
|
|
223
|
-
// and is what an embedded panel wants. `isConnected` is showModal()'s own
|
|
224
|
-
// precondition and a detached root arrives here from a typed call, where a
|
|
225
|
-
// throw would bury the real startup cause under a second error out of the catch.
|
|
226
|
-
if (layoutMode === "viewport" && surface.isConnected) {
|
|
227
|
-
surface.showModal();
|
|
228
|
-
} else {
|
|
229
|
-
surface.open = true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// The terminal input held focus before setup failed; move it to the only
|
|
233
|
-
// recovery action. showModal() lands there itself, the non-modal half does not.
|
|
234
|
-
reloadButton.focus();
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Build the terminal. See createTerminal, which wraps this to give a
|
|
238
|
-
* SYNCHRONOUS failure the same recovery surface an async one already gets. */
|
|
239
|
-
function buildTerminal(
|
|
240
|
-
root: HTMLElement,
|
|
241
|
-
opts: CreateTerminalOptions,
|
|
242
|
-
featureList: readonly TerminalFeature<unknown>[],
|
|
243
|
-
): TerminalHandle {
|
|
244
|
-
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
245
|
-
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
246
|
-
// The one resolved retained-line cap: the engine renderer's implicit store
|
|
247
|
-
// and every ctx.newLineStore() (the tabs switching cache) both honor it, so
|
|
248
|
-
// a consumer sets its memory budget in exactly one place.
|
|
249
|
-
// Invalid reads as OMITTED, so the engine's own default applies — and the engine
|
|
250
|
-
// additionally floors the cap at the live screen, so even a tiny valid value
|
|
251
|
-
// cannot truncate the visible window.
|
|
252
|
-
const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
|
|
253
|
-
// Scrollback persistence (off unless the consumer supplied storage). One
|
|
254
|
-
// keeper serves the whole terminal: the kernel's implicit store below and
|
|
255
|
-
// every per-session store a session-owning feature creates through
|
|
256
|
-
// ctx.newLineStore(sessionId).
|
|
257
|
-
const scrollbackKeeper =
|
|
258
|
-
opts.persistScrollback !== undefined
|
|
259
|
-
? createScrollbackKeeper(opts.persistScrollback, scrollbackLines)
|
|
260
|
-
: null;
|
|
261
|
-
// At most one feature owns session selection (fail fast, before any DOM
|
|
262
|
-
// work): the kernel drives the first connect through its registration.
|
|
263
|
-
const owners = featureList.filter((f) => f.sessionOwner !== undefined);
|
|
264
|
-
if (owners.length > 1) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
const sessionOwner = owners[0]?.sessionOwner;
|
|
270
|
-
const sessionOwnerName = owners[0]?.name ?? "";
|
|
271
|
-
const encoder = new TextEncoder();
|
|
272
|
-
const kernelAbort = new AbortController();
|
|
273
|
-
const { signal } = kernelAbort;
|
|
274
|
-
|
|
275
|
-
// Apply consumer theme overrides as CSS custom properties on the root, so the
|
|
276
|
-
// whole terminal subtree inherits them. The library ships the token defaults
|
|
277
|
-
// (css/00-tokens.css); these override them for this instance. Only custom
|
|
278
|
-
// properties (leading "--") are set.
|
|
279
|
-
if (opts.theme) {
|
|
280
|
-
for (const [key, value] of Object.entries(opts.theme)) {
|
|
281
|
-
if (key.startsWith("--")) {
|
|
282
|
-
root.style.setProperty(key, value);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Stamp the boundary classes: .wt-root scopes every library token and style
|
|
288
|
-
// rule to this subtree, and the layout-mode class decides how the root claims
|
|
289
|
-
// space — wt-viewport pins the root itself to the full viewport (the
|
|
290
|
-
// full-page product), wt-container fills the parent element (the embedded
|
|
291
|
-
// case). All chrome positions against the root, never the page. destroy()
|
|
292
|
-
// removes them.
|
|
293
|
-
const layoutMode = opts.layout ?? "viewport";
|
|
294
|
-
root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
|
|
295
|
-
|
|
296
|
-
// --- Build the core subtree ---
|
|
297
|
-
const tpl = document.createElement("template");
|
|
298
|
-
tpl.innerHTML = CORE_TEMPLATE;
|
|
299
|
-
root.replaceChildren(tpl.content);
|
|
300
|
-
const termWrap = pick(root, ".term");
|
|
301
|
-
const outputEl = pick(root, ".term-output");
|
|
302
|
-
const input = pick(root, ".term-input") as HTMLTextAreaElement;
|
|
303
|
-
const compositionViewEl = pick(root, ".composition-view");
|
|
304
|
-
|
|
305
|
-
// --- Regions + shared primitives ---
|
|
306
|
-
const regions = createRegions(root);
|
|
307
|
-
const bus = createBus();
|
|
308
|
-
const announcer = createAnnouncer(root);
|
|
309
|
-
const tablistController = createTablist(outputEl);
|
|
310
|
-
|
|
311
|
-
// --- Narrow-layout driver (.wt-narrow mirrors root size for CSS) ---
|
|
312
|
-
// Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
|
|
313
|
-
// narrow embedded panel) or short (a phone in landscape). The one
|
|
314
|
-
// ResizeObserver below fires on both width and height changes.
|
|
315
|
-
//
|
|
316
|
-
// Constructed unconditionally: ResizeObserver is a hard requirement of this
|
|
317
|
-
// library, not an enhancement. viewport.init() and the tabs strip both
|
|
318
|
-
// construct one with no guard, and the same buildTerminal builds a WeakRef
|
|
319
|
-
// (whose browser floor is strictly higher), so a `typeof ResizeObserver ===
|
|
320
|
-
// "function"` guard here could only ever protect a browser that crashes
|
|
321
|
-
// moments later. It used to be here, and it was measured dead.
|
|
322
|
-
const isNarrow = (): boolean =>
|
|
323
|
-
root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
|
|
324
|
-
function updateNarrow(): void {
|
|
325
|
-
root.classList.toggle("wt-narrow", isNarrow());
|
|
326
|
-
}
|
|
327
|
-
updateNarrow();
|
|
328
|
-
let narrowObserver: ResizeObserver | null = new ResizeObserver(updateNarrow);
|
|
329
|
-
narrowObserver.observe(root);
|
|
330
|
-
|
|
331
|
-
// --- Document title (kernel-owned, composed from two inputs) ---
|
|
332
|
-
//
|
|
333
|
-
// The page has ONE title and two things want to write it: a program's OSC 0/2
|
|
334
|
-
// window title, and a feature's attention prefix (tabs' unseen-cue count). So
|
|
335
|
-
// the kernel holds both and is the only assigner. Two direct writers was the
|
|
336
|
-
// alternative and it cannot work: whichever ran last won, so a shell or an
|
|
337
|
-
// editor emitting OSC 2 silently erased the prefix, and there is no ordering
|
|
338
|
-
// that fixes it because both inputs change on their own schedule.
|
|
339
|
-
//
|
|
340
|
-
// The base starts as whatever the served document declared, so a consumer that
|
|
341
|
-
// never receives an OSC title (web-terminal-kiro sets kiro-cli's
|
|
342
|
-
// chat.terminalTitle=false) keeps its own <title> as the base rather than
|
|
343
|
-
// needing to declare it twice.
|
|
344
|
-
let baseTitle = document.title;
|
|
345
|
-
let titlePrefixText = "";
|
|
346
|
-
function paintTitle(): void {
|
|
347
|
-
const next = titlePrefixText + baseTitle;
|
|
348
|
-
// Idempotent: the title doubles as the browser-tab label and the bookmark
|
|
349
|
-
// name, and this runs on every status sweep, so an unchanged assignment must
|
|
350
|
-
// not churn it.
|
|
351
|
-
if (document.title !== next) {
|
|
352
|
-
document.title = next;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
357
|
-
const toastEl = document.createElement("div");
|
|
358
|
-
toastEl.className = "wt-toast";
|
|
359
|
-
toastEl.setAttribute("role", "status");
|
|
360
|
-
regions.region("banner", "toast").appendChild(toastEl);
|
|
361
|
-
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
362
|
-
function toast(message: string, ms = TOAST_MS): void {
|
|
363
|
-
toastEl.textContent = message;
|
|
364
|
-
toastEl.classList.add("visible");
|
|
365
|
-
if (toastTimer !== null) {
|
|
366
|
-
clearTimeout(toastTimer);
|
|
367
|
-
}
|
|
368
|
-
toastTimer = setTimeout(() => {
|
|
369
|
-
toastTimer = null;
|
|
370
|
-
toastEl.classList.remove("visible");
|
|
371
|
-
toastEl.textContent = "";
|
|
372
|
-
}, ms);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// --- Feature error routing ---
|
|
376
|
-
const errorHandlers = new Set<(feature: string, err: unknown) => void>();
|
|
377
|
-
function reportError(feature: string, err: unknown): void {
|
|
378
|
-
if (errorHandlers.size === 0) {
|
|
379
|
-
console.error(`web-terminal-ui: feature "${feature}" error`, err);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
for (const fn of [...errorHandlers]) {
|
|
383
|
-
try {
|
|
384
|
-
fn(feature, err);
|
|
385
|
-
} catch (handlerErr) {
|
|
386
|
-
// Error reporting must never turn the original feature failure into an
|
|
387
|
-
// unhandled rejection or prevent fatal-startup cleanup from running.
|
|
388
|
-
console.error("web-terminal-ui: feature error handler failed", handlerErr);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// --- Input funnel (the single sanitizing, session-routed send path) ---
|
|
394
|
-
const inputTransforms: ((b: Uint8Array) => Uint8Array)[] = [];
|
|
395
|
-
const inputObservers: ((b: Uint8Array) => void)[] = [];
|
|
396
|
-
|
|
397
|
-
function sendBytes(bytes: Uint8Array): void {
|
|
398
|
-
let out = bytes;
|
|
399
|
-
for (const t of inputTransforms) {
|
|
400
|
-
out = t(out);
|
|
401
|
-
if (out.length === 0) {
|
|
402
|
-
return; // a transform dropped it (e.g. the col-0 backspace brake)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
// sendBinary buffers while disconnected (resume layer) and returns false
|
|
406
|
-
// only when the outbox is full (already surfaced via onOutboxFull). Only
|
|
407
|
-
// notify observers on accepted input, so predictive echo never paints a
|
|
408
|
-
// phantom char that never reached the server.
|
|
409
|
-
if (!connection.sendBinary(out)) {
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
// Classic-terminal behavior (GNOME/xterm): user input re-engages follow and
|
|
413
|
-
// snaps to the bottom, so typing while scrolled up jumps to the input line.
|
|
414
|
-
// Instant (not the jump-button's smooth scroll), so it isn't janky per
|
|
415
|
-
// keystroke. This is the ONLY thing that scrolls a held view down — program
|
|
416
|
-
// output never does (the scroll controller's follow/hold job is unchanged),
|
|
417
|
-
// so scrolling up to read while output streams is preserved. A no-op on the
|
|
418
|
-
// alt screen (no scrollback) and when already following. NOTE: every caller
|
|
419
|
-
// of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
|
|
420
|
-
// mouse reports deliberately bypass this funnel and go out as ephemeral
|
|
421
|
-
// input, or the view would snap on every motion report.
|
|
422
|
-
scroll.scrollToBottom();
|
|
423
|
-
for (const obs of inputObservers) {
|
|
424
|
-
obs(out);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
function sendText(text: string): void {
|
|
428
|
-
sendBytes(encoder.encode(text));
|
|
429
|
-
}
|
|
430
|
-
function paste(text: string): void {
|
|
431
|
-
// The one bracketed-paste + newline-normalize funnel; every feature and the
|
|
432
|
-
// kernel's own paste paths route through here (paste-jacking defense).
|
|
433
|
-
sendText(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// --- Keydown intercept chain (clipboard shortcuts, contextMenu Escape) ---
|
|
437
|
-
const keydownHandlers: ((ev: KeyboardEvent) => boolean)[] = [];
|
|
438
|
-
|
|
439
|
-
// --- Active session (SessionView + onSwitch fan-out) ---
|
|
440
|
-
let activeSession: SessionRef | null = null;
|
|
441
|
-
// Whether the first connection has been kicked off. The wake-reconnect
|
|
442
|
-
// handlers (visibilitychange/pageshow/online) must not open a socket before
|
|
443
|
-
// it has: under a session owner the kernel drives the first connect only
|
|
444
|
-
// once resolveInitialSession() returns a session id, and connecting to the
|
|
445
|
-
// bare wsPath before then hits a session-gated endpoint (the SessionManager
|
|
446
|
-
// 404s a /ws with no ?session=). pageshow fires on the initial load, so
|
|
447
|
-
// without this gate a slow session list lets pageshow's reconnectNow open a
|
|
448
|
-
// bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
|
|
449
|
-
// true on the startup connect (no owner) or the first setSession (owned).
|
|
450
|
-
let connectionInitiated = false;
|
|
451
|
-
|
|
452
|
-
// --- Loading lifecycle ---
|
|
453
|
-
let ready = false;
|
|
454
|
-
/**
|
|
455
|
-
* Every LineStore this kernel hands out, held WEAKLY and keyed by session. A
|
|
456
|
-
* closed tab's store must stay collectable, and a weak registry cannot leak or
|
|
457
|
-
* go stale the way a strong map paired with an unregister call can — the store
|
|
458
|
-
* outlives the tab record in more than one teardown order.
|
|
459
|
-
*
|
|
460
|
-
* Keyed rather than a bare set because two features need to reach a SPECIFIC
|
|
461
|
-
* session's store, not just "all of them": the restore guard below, and the
|
|
462
|
-
* browse-cache sweep. The implicit store (a bare kernel with no session owner)
|
|
463
|
-
* has no id, so it takes a reserved key no session can collide with.
|
|
464
|
-
*/
|
|
465
|
-
const knownStores = new Map<string, WeakRef<LineStore>>();
|
|
466
|
-
const IMPLICIT_STORE_KEY = "\u0000implicit";
|
|
467
|
-
/** The live stores, pruning entries whose store has been collected. */
|
|
468
|
-
function liveStores(): LineStore[] {
|
|
469
|
-
const out: LineStore[] = [];
|
|
470
|
-
for (const [key, ref] of knownStores) {
|
|
471
|
-
const store = ref.deref();
|
|
472
|
-
if (store === undefined) {
|
|
473
|
-
knownStores.delete(key); // the tab closed and its store was collected
|
|
474
|
-
continue;
|
|
475
|
-
}
|
|
476
|
-
out.push(store);
|
|
477
|
-
}
|
|
478
|
-
return out;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Which restored scrollbacks have not yet been confirmed against the live
|
|
482
|
-
// server. A session enters when its snapshot is hydrated and leaves on the
|
|
483
|
-
// first resumeAck for THAT session; in between, the content is last session's —
|
|
484
|
-
// plausible but unconfirmed.
|
|
485
|
-
//
|
|
486
|
-
// Per SESSION, and that is the whole point. Two booleans stood here, and under
|
|
487
|
-
// the tabs preset (which every reference app uses) they could not do the job:
|
|
488
|
-
// N sessions are hydrated at boot and all set the same flag, the active
|
|
489
|
-
// session's first ack cleared it for everyone, and the reset reached only the
|
|
490
|
-
// renderer-BOUND store. So tabs 2..N were neither verified nor discardable for
|
|
491
|
-
// the life of the page, and the exact failure this guard exists to prevent —
|
|
492
|
-
// the previous run's output under a "Session ended" banner — stayed reachable
|
|
493
|
-
// by switching tabs. The claimed invariant ("only ever discards a restore,
|
|
494
|
-
// never live content") also did not hold: a background tab's restore
|
|
495
|
-
// authorised a reset of the bound tab's store.
|
|
496
|
-
const unverifiedRestores = new Set<string>();
|
|
497
|
-
/** Drop restored scrollback that will never be confirmed.
|
|
498
|
-
*
|
|
499
|
-
* The epoch check makes a stale restore self-correcting ONLY when a resumeAck
|
|
500
|
-
* arrives: that is what compares the seeded epoch and fires the restart reset.
|
|
501
|
-
* Two closes reach `markReady` without one — a process-exited 4001 (the session
|
|
502
|
-
* is gone, which is what a container restart leaves behind) and a wire-revision
|
|
503
|
-
* refusal (a cached client one revision behind, which is what an image update
|
|
504
|
-
* leaves behind). Both are exactly the restart case, and without this the
|
|
505
|
-
* overlay lifts over the PREVIOUS run's output under a "Session ended" banner —
|
|
506
|
-
* the one path where the design's stated worst case was reachable.
|
|
507
|
-
*
|
|
508
|
-
* SCOPE matters because the two triggers differ: a 4001 says one session's
|
|
509
|
-
* process is gone, so only that session's restore is condemned; a wire refusal
|
|
510
|
-
* says this client cannot talk to this server at all, so every unverified
|
|
511
|
-
* restore is. Discarding page-wide on a 4001 would wipe restores for sessions
|
|
512
|
-
* that are perfectly alive.
|
|
513
|
-
*
|
|
514
|
-
* Only ever discards a restore, never live content: a session leaves the set
|
|
515
|
-
* the moment its resume is served, and a session that was never hydrated never
|
|
516
|
-
* enters it. */
|
|
517
|
-
function discardUnverifiedRestore(scope: { session: string } | "page"): void {
|
|
518
|
-
const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
|
|
519
|
-
const bound = render.boundStore();
|
|
520
|
-
for (const id of ids) {
|
|
521
|
-
if (!unverifiedRestores.delete(id)) {
|
|
522
|
-
continue; // never hydrated, or already verified
|
|
523
|
-
}
|
|
524
|
-
const store = knownStores.get(id)?.deref();
|
|
525
|
-
if (store === undefined || store === bound) {
|
|
526
|
-
// The BOUND store goes through the renderer so the DOM is reconciled;
|
|
527
|
-
// an unknown id can only be the implicit store, which is also bound.
|
|
528
|
-
render.resetScrollback();
|
|
529
|
-
render.resetScreen();
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
// A background store has no DOM: resetting it is enough, and the next
|
|
533
|
-
// bind rebuilds from whatever it then holds.
|
|
534
|
-
store.reset();
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
let firstFrameRendered = false;
|
|
538
|
-
let fontsLoaded = false;
|
|
539
|
-
let wsOpen = false;
|
|
540
|
-
let overlayDismissed = false;
|
|
541
|
-
// Progressive status text on the consumer's overlay. Attached unconditionally
|
|
542
|
-
// (inert when no overlay was supplied) and torn down by the same function that
|
|
543
|
-
// lowers the overlay, so there is exactly one place where the loading screen's
|
|
544
|
-
// life ends and no timer can outlive the thing it was writing into.
|
|
545
|
-
const loadingStatus = attachLoadingStatus(opts.loading, {
|
|
546
|
-
...DEFAULT_LOADING_MESSAGES,
|
|
547
|
-
...opts.loadingMessages,
|
|
548
|
-
});
|
|
549
|
-
function dismissLoadingOverlay(): void {
|
|
550
|
-
loadingStatus.stop();
|
|
551
|
-
const ld = opts.loading;
|
|
552
|
-
if (!ld || overlayDismissed) {
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
overlayDismissed = true;
|
|
556
|
-
fadeOutOverlay(ld);
|
|
557
|
-
}
|
|
558
|
-
function markReady(): void {
|
|
559
|
-
if (ready) {
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
ready = true;
|
|
563
|
-
connState.setLoaded();
|
|
564
|
-
dismissLoadingOverlay();
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// --- Connection-state machine ---
|
|
568
|
-
const connState = createConnState({
|
|
569
|
-
onState: (s) => {
|
|
570
|
-
bus.emit("connection:state", s);
|
|
571
|
-
},
|
|
572
|
-
onGiveUp: dismissLoadingOverlay,
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
// --- Engine wiring ---
|
|
576
|
-
render.init({
|
|
577
|
-
output: outputEl,
|
|
578
|
-
termWrap,
|
|
579
|
-
onCursorMove: () => {
|
|
580
|
-
composition.positionCompositionView();
|
|
581
|
-
bus.emit("render:cursor", undefined);
|
|
582
|
-
},
|
|
583
|
-
// Demand-paged scrollback (engine docs/paged-scrollback.md §5.4). The
|
|
584
|
-
// renderer owns the DECISION (which gap the reader is approaching, and how
|
|
585
|
-
// much to ask for); the transport owns the request. Wrapped rather than
|
|
586
|
-
// passed by reference so neither module's identity leaks into the other's
|
|
587
|
-
// options object.
|
|
588
|
-
requestHistory: (fromAbs, maxLines) => connection.requestHistory(fromAbs, maxLines),
|
|
589
|
-
historyBudget: () => connection.historyBudget(),
|
|
590
|
-
...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
|
|
591
|
-
});
|
|
592
|
-
render.updateFontMetrics();
|
|
593
|
-
|
|
594
|
-
// A mouse report is BEST-EFFORT and never enters the reliable outbox: it
|
|
595
|
-
// carries no sequence number, so one retransmitted after a resume describes a
|
|
596
|
-
// screen since repainted, and `sendEphemeral` refuses rather than queues. Not
|
|
597
|
-
// sendBytes either: that funnel snaps the view to the bottom on every accepted
|
|
598
|
-
// byte, so a motion report would jump the viewport. The listeners belong on the
|
|
599
|
-
// scroll container (a click in its padding or over the reserved scrollbar
|
|
600
|
-
// gutter never reaches a row, and the wheel has to be cancellable there); the
|
|
601
|
-
// coordinate frame is the row container, whose box IS the grid.
|
|
602
|
-
const disposeMouse = mouse.init({
|
|
603
|
-
sendReport: (data) => connection.sendEphemeral(data),
|
|
604
|
-
cellSize: render.cellSize,
|
|
605
|
-
termElement: () => termWrap,
|
|
606
|
-
gridElement: () => outputEl,
|
|
607
|
-
// The RENDERED grid, not computeSize(): the row hit test anchors on its
|
|
608
|
-
// height, and this client's own measurement disagrees with the negotiated
|
|
609
|
-
// screen whenever a second device is attached or a local resize has not been
|
|
610
|
-
// answered yet, which would shift every reported row.
|
|
611
|
-
gridSize: render.gridSize,
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
// A press over the display-only output takes focus off the hidden textarea and
|
|
615
|
-
// the click handler below puts it back, so that blur/focus pair is an artifact
|
|
616
|
-
// of this input model, not a focus change the application should be told about.
|
|
617
|
-
// xterm.js never emits one because its textarea keeps focus through a click on
|
|
618
|
-
// the terminal. So a blur raised inside a press is withheld and the gesture's
|
|
619
|
-
// REAL end state is reported once it resolves: nothing when focus came back,
|
|
620
|
-
// one report when it did not (a live selection, a link and a bare touch tap all
|
|
621
|
-
// decline to restore it). Measured before this: 3600 focus reports in 90s of
|
|
622
|
-
// clicking, which reaches an application as FocusLost/FocusGained per click.
|
|
623
|
-
let pressHoldsFocus = false;
|
|
624
|
-
let focusResolve: ReturnType<typeof setTimeout> | null = null;
|
|
625
|
-
|
|
626
|
-
function reportFocusNow(): void {
|
|
627
|
-
connection.setClientFocus(
|
|
628
|
-
document.activeElement !== null && termWrap.contains(document.activeElement),
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Scheduled rather than read at pointerup: the focus restore happens in the
|
|
633
|
-
// `click` handler, which the browser dispatches after pointerup within the same
|
|
634
|
-
// task, so a task boundary is the first point where the end state is settled.
|
|
635
|
-
function endPressFocusGesture(): void {
|
|
636
|
-
if (focusResolve !== null) {
|
|
637
|
-
clearTimeout(focusResolve);
|
|
638
|
-
}
|
|
639
|
-
focusResolve = setTimeout(() => {
|
|
640
|
-
focusResolve = null;
|
|
641
|
-
pressHoldsFocus = false;
|
|
642
|
-
reportFocusNow();
|
|
643
|
-
}, 0);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
|
|
647
|
-
// own focus events bubble to here. What goes out is the widget's STATE, not
|
|
648
|
-
// DEC 1004 bytes: the server derives the answer from this report plus every
|
|
649
|
-
// other attached client's plus its own hold.
|
|
650
|
-
termWrap.addEventListener(
|
|
651
|
-
"focusin",
|
|
652
|
-
() => {
|
|
653
|
-
connection.setClientFocus(true);
|
|
654
|
-
},
|
|
655
|
-
{ signal },
|
|
656
|
-
);
|
|
657
|
-
termWrap.addEventListener(
|
|
658
|
-
"focusout",
|
|
659
|
-
(ev) => {
|
|
660
|
-
if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
|
|
661
|
-
return; // focus moving WITHIN the terminal is not a blur
|
|
662
|
-
}
|
|
663
|
-
if (pressHoldsFocus) {
|
|
664
|
-
return; // this gesture's own doing; endPressFocusGesture reports the truth
|
|
665
|
-
}
|
|
666
|
-
connection.setClientFocus(false);
|
|
667
|
-
},
|
|
668
|
-
{ signal },
|
|
669
|
-
);
|
|
670
|
-
// Seeded once, so a terminal mounted with its textarea already focused does not
|
|
671
|
-
// report blurred until the first real focusin.
|
|
672
|
-
reportFocusNow();
|
|
673
|
-
|
|
674
|
-
// The kernel is the toggler, and the class goes on the scroll container rather
|
|
675
|
-
// than on the rows: `.term-output` is replaced wholesale by the renderer, and
|
|
676
|
-
// `.term` is the element that survives every rebuild. What the shape MEANS is
|
|
677
|
-
// at the CSS rule it drives (css/02-terminal.css).
|
|
678
|
-
function updateMousePointer(): void {
|
|
679
|
-
termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Derived once here as well as from the two triggers below: the modes module is
|
|
683
|
-
// a singleton that outlives a terminal, so a re-created terminal on a page
|
|
684
|
-
// whose mirror already holds a tracking mode would otherwise show the resting
|
|
685
|
-
// pointer until the next frame.
|
|
686
|
-
updateMousePointer();
|
|
687
|
-
|
|
688
|
-
composition.init({
|
|
689
|
-
textarea: input,
|
|
690
|
-
compositionView: compositionViewEl,
|
|
691
|
-
getCursorPx: render.getCursorPx,
|
|
692
|
-
send: sendText,
|
|
693
|
-
paste,
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
scroll.init({
|
|
697
|
-
scrollEl: termWrap,
|
|
698
|
-
onUserScrollChange(scrolledUp) {
|
|
699
|
-
bus.emit("scroll:state", { scrolledUp });
|
|
700
|
-
},
|
|
701
|
-
// Every position change, not only a follow/hold toggle: a reader scrolling
|
|
702
|
-
// WITHIN history never toggles follow, and that is exactly when paging has
|
|
703
|
-
// to work. The trigger re-evaluates its own guards, so firing for the
|
|
704
|
-
// browser's own clamps as well as gestures costs nothing.
|
|
705
|
-
//
|
|
706
|
-
// ONE hook, deliberately. The renderer's scroll-position contract is two jobs
|
|
707
|
-
// (re-evaluate the paging trigger, and finish a drain that stopped with rows
|
|
708
|
-
// queued) and it owns the ordering and the guards for both. A consumer wiring
|
|
709
|
-
// them as two calls is a consumer that can wire one.
|
|
710
|
-
onScrollPosition: render.handleScrollPosition,
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
// The size this client would announce right now, or null when it cannot
|
|
714
|
-
// measure trustworthily yet. Both the resume-time announce (the engine's
|
|
715
|
-
// Callbacks.initialSize) and the open-time send below read it, so there is one
|
|
716
|
-
// definition of "measurable" rather than two that can disagree.
|
|
717
|
-
//
|
|
718
|
-
// Unmeasurable means either web fonts are still loading (cell metrics would be
|
|
719
|
-
// wrong, so cols/rows would be) or a viewport transition is in flight (an iOS
|
|
720
|
-
// keyboard slide or rotation, whose intermediate geometry is provisional). In
|
|
721
|
-
// both cases announcing costs a second resize once the real size is known, and
|
|
722
|
-
// therefore a second redraw from any program that repaints on SIGWINCH.
|
|
723
|
-
function measurableSize(): { cols: number; rows: number } | null {
|
|
724
|
-
if (!fontsLoaded || viewport.isInTransition()) {
|
|
725
|
-
return null;
|
|
726
|
-
}
|
|
727
|
-
render.updateFontMetrics();
|
|
728
|
-
return render.computeSize();
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function maybeSendFirstResize(): void {
|
|
732
|
-
if (!wsOpen || measurableSize() === null) {
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
connection.sendResize();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
connection.init({
|
|
739
|
-
computeSize: render.computeSize,
|
|
740
|
-
// getHaveThrough is deliberately NOT supplied. The engine defaults it to its
|
|
741
|
-
// own renderer (engine >= 5.0.3), which is the only party that knows which
|
|
742
|
-
// store is currently bound and which of its rows the server has confirmed.
|
|
743
|
-
// Supplying it from here is what claimed rows the application had merely
|
|
744
|
-
// DRAWN — `render.getHighestIndex` is the window's bottom row — and left a
|
|
745
|
-
// frozen copy of the composer parked in scrollback on almost every reattach.
|
|
746
|
-
// An explicit member wins over the default, so re-adding one reopens that.
|
|
747
|
-
// --- Demand-paged scrollback (engine docs/paged-scrollback.md §4-5) ---
|
|
748
|
-
// The transport is store-blind and viewport-blind by design, so every one of
|
|
749
|
-
// these forwards a decision only the renderer can make. Without them the
|
|
750
|
-
// engine's paging machinery is inert: the server still declares the
|
|
751
|
-
// capability and still bounds its replay, but nothing ever asks for a page.
|
|
752
|
-
//
|
|
753
|
-
// How much replay this client wants on attach: no more than it intends to
|
|
754
|
-
// keep resident. The server bounds the replay regardless, and the client
|
|
755
|
-
// sends the same number, so its replay-jump prediction and the server's
|
|
756
|
-
// actual start agree.
|
|
757
|
-
getReplayMax: render.replayMaxForResume,
|
|
758
|
-
onHistoryReply: render.handleHistoryReply,
|
|
759
|
-
// ONE transition per ack, carrying the values this socket SENT. It must not
|
|
760
|
-
// be split into separate callbacks: the store's five steps (epoch reset,
|
|
761
|
-
// bounds, cap flip, replay-jump prediction, budget pass) are ordered against
|
|
762
|
-
// each other, and an implementer with five hooks can interleave them.
|
|
763
|
-
onResumeTransition: render.applyResumeTransition,
|
|
764
|
-
// The solicited window: the store's permission to admit lines below its
|
|
765
|
-
// stale-re-send watermark, opened for exactly the range in flight and closed
|
|
766
|
-
// when the reply lands.
|
|
767
|
-
noteSolicited: render.noteSolicited,
|
|
768
|
-
clearSolicited: render.clearSolicited,
|
|
769
|
-
// A denied request (paced by the token bucket, or a data timeout) re-runs the
|
|
770
|
-
// FULL trigger rather than replaying the denied range: by the time the bucket
|
|
771
|
-
// refills the gap may have healed, or the session may have entered alt.
|
|
772
|
-
onHistoryRetry: () => {
|
|
773
|
-
render.maybeFetchHistory();
|
|
774
|
-
},
|
|
775
|
-
onResumeBounds(committed, oldest) {
|
|
776
|
-
// A resume VERIFIES the restored store of the session it belongs to: the
|
|
777
|
-
// server has answered under a known epoch, so that content is confirmed
|
|
778
|
-
// rather than merely plausible. Recorded here because onResumeBounds rides
|
|
779
|
-
// every resumeAck, which is also where the epoch comparison happens. Scoped
|
|
780
|
-
// to the socket's OWN session — one ack cannot vouch for a tab it never
|
|
781
|
-
// talked to.
|
|
782
|
-
unverifiedRestores.delete(connection.currentSessionId());
|
|
783
|
-
render.noteResumeBounds(committed, oldest);
|
|
784
|
-
},
|
|
785
|
-
// Announced BEFORE the resume so the server's snapshot and history replay
|
|
786
|
-
// come back at this client's geometry (engine Callbacks.initialSize).
|
|
787
|
-
initialSize: measurableSize,
|
|
788
|
-
wsPath,
|
|
789
|
-
onMessage(msg) {
|
|
790
|
-
if (msg.type === "screen") {
|
|
791
|
-
render.handleScreen(msg);
|
|
792
|
-
firstFrameRendered = true;
|
|
793
|
-
if (fontsLoaded) {
|
|
794
|
-
markReady();
|
|
795
|
-
}
|
|
796
|
-
bus.emit("wire:screen", msg);
|
|
797
|
-
} else if (msg.type === "scroll") {
|
|
798
|
-
render.handleScroll(msg);
|
|
799
|
-
} else if (msg.type === "title") {
|
|
800
|
-
// Match the app's own title policy (tabs.ts wire:title / applyStatus,
|
|
801
|
-
// which ignore a blank OSC 0/2 title): a shell clears its window title
|
|
802
|
-
// when it redraws its prompt after idling, so hold the last-good browser
|
|
803
|
-
// title instead of flickering blank.
|
|
804
|
-
if (msg.title.trim() !== "") {
|
|
805
|
-
baseTitle = msg.title;
|
|
806
|
-
paintTitle();
|
|
807
|
-
}
|
|
808
|
-
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
809
|
-
} else if (msg.type === "modes") {
|
|
810
|
-
render.updateReverseVideo();
|
|
811
|
-
updateMousePointer();
|
|
812
|
-
bus.emit("wire:modes", msg);
|
|
813
|
-
} else if (msg.type === "clipboard") {
|
|
814
|
-
// Inbound OSC 52. With no clipboard feature subscribed this is a no-op
|
|
815
|
-
// (section 22.4).
|
|
816
|
-
bus.emit("wire:clipboard", msg.text);
|
|
817
|
-
}
|
|
818
|
-
},
|
|
819
|
-
onOpen() {
|
|
820
|
-
connState.open();
|
|
821
|
-
wsOpen = true;
|
|
822
|
-
maybeSendFirstResize();
|
|
823
|
-
},
|
|
824
|
-
onConnecting() {
|
|
825
|
-
connState.reconnecting();
|
|
826
|
-
},
|
|
827
|
-
onClose() {
|
|
828
|
-
connState.closed();
|
|
829
|
-
},
|
|
830
|
-
onOutboxFull() {
|
|
831
|
-
connState.closed();
|
|
832
|
-
},
|
|
833
|
-
onProcessExit() {
|
|
834
|
-
// The engine's definitive 4001 close: the session's process has exited
|
|
835
|
-
// and the engine will not reconnect it. Three jobs here. markReady()
|
|
836
|
-
// guarantees the page is usable even when the exit lands before any
|
|
837
|
-
// screen frame (attaching to an already-dead session on a server that
|
|
838
|
-
// races the replay) — without it the loading overlay would sit on top of
|
|
839
|
-
// the tabs chrome forever, which is exactly the reported stuck-loading
|
|
840
|
-
// wedge. Then surface the end state: "Session ended", not a flapping
|
|
841
|
-
// "Reconnecting…", since no reconnect is coming. The final screen (when
|
|
842
|
-
// the server delivered it) stays rendered behind the banner.
|
|
843
|
-
discardUnverifiedRestore({ session: connection.currentSessionId() });
|
|
844
|
-
markReady();
|
|
845
|
-
connState.ended();
|
|
846
|
-
// The host's turn, and LAST: everything above is unconditional, so a
|
|
847
|
-
// handler that throws cannot leave the page without its overlay lowered
|
|
848
|
-
// or its banner shown. The host is told because the kernel's own answer
|
|
849
|
-
// stops here — only the host knows whether its endpoint yields a new
|
|
850
|
-
// session on the next connect, which is what reattach() is for.
|
|
851
|
-
if (opts.onSessionEnded !== undefined) {
|
|
852
|
-
try {
|
|
853
|
-
opts.onSessionEnded();
|
|
854
|
-
} catch (err) {
|
|
855
|
-
console.error("web-terminal-ui: onSessionEnded handler failed", err);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
},
|
|
859
|
-
onWireIncompatible() {
|
|
860
|
-
// Compatibility refusal is terminal for this page instance. Lower the
|
|
861
|
-
// pre-JS overlay even if no screen frame arrived, then leave the
|
|
862
|
-
// actionable banner visible while the engine suppresses reconnects.
|
|
863
|
-
discardUnverifiedRestore("page");
|
|
864
|
-
markReady();
|
|
865
|
-
connState.incompatible();
|
|
866
|
-
},
|
|
867
|
-
onServerRestart() {
|
|
868
|
-
render.resetScrollback();
|
|
869
|
-
render.resetScreen();
|
|
870
|
-
connState.restarted();
|
|
871
|
-
},
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
// --- Input handling ---
|
|
875
|
-
resetToPlaceholder(input);
|
|
876
|
-
|
|
877
|
-
input.addEventListener(
|
|
878
|
-
"input",
|
|
879
|
-
(e: Event) => {
|
|
880
|
-
if (composition.isComposing()) {
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
const ev = e as InputEvent;
|
|
884
|
-
const inputType = ev.inputType;
|
|
885
|
-
if (
|
|
886
|
-
inputType === "deleteContentBackward" ||
|
|
887
|
-
inputType === "deleteContentForward" ||
|
|
888
|
-
inputType === "deleteWordBackward" ||
|
|
889
|
-
inputType === "deleteWordForward"
|
|
890
|
-
) {
|
|
891
|
-
resetToPlaceholder(input);
|
|
892
|
-
return;
|
|
893
|
-
} else if (inputType === "insertReplacementText") {
|
|
894
|
-
// A spellcheck / autocorrect / autofill REWRITE of text already typed.
|
|
895
|
-
// The bytes it is correcting have already gone to the pty and a terminal
|
|
896
|
-
// cannot retract them: 0x7f means whatever the remote application decides
|
|
897
|
-
// it means, so a pager, vim, a y/n prompt and a no-echo password prompt
|
|
898
|
-
// each read it as their own input. Applying the rewrite forward instead
|
|
899
|
-
// duplicates — measured on xterm.js's value-diff, where `helo` corrected
|
|
900
|
-
// to `hello` puts `helohello` on the wire (its issue #3600). So drop it
|
|
901
|
-
// and leave the typed text standing, which is also what a shell wants:
|
|
902
|
-
// `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
|
|
903
|
-
//
|
|
904
|
-
// The spec's own signal for this, so no length heuristic is needed: a
|
|
905
|
-
// max-one-character rule would break glide typing (a whole word in one
|
|
906
|
-
// event, and SwiftKey's flagship input method) while still admitting
|
|
907
|
-
// CJK's one-for-one in-place replacement. Cancelling is not an option
|
|
908
|
-
// either — preventDefault on beforeinput is a no-op in Chrome, per
|
|
909
|
-
// CodeMirror's own note — but declining to SEND is entirely ours.
|
|
910
|
-
//
|
|
911
|
-
// Reachable on more than Android: WebKit fires this type for a spelling
|
|
912
|
-
// suggestion and for a macOS system text substitution whatever
|
|
913
|
-
// autocorrect="off" says. Dropping is right wherever it fires, because
|
|
914
|
-
// what it carries is always a replacement for bytes the pty already has.
|
|
915
|
-
resetToPlaceholder(input);
|
|
916
|
-
return;
|
|
917
|
-
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
918
|
-
if (inputType === "insertFromPaste") {
|
|
919
|
-
paste(ev.data);
|
|
920
|
-
} else {
|
|
921
|
-
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
922
|
-
sendText(normalizeTypedText(ev.data));
|
|
923
|
-
}
|
|
924
|
-
} else {
|
|
925
|
-
const v = input.value;
|
|
926
|
-
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
927
|
-
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
928
|
-
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
929
|
-
sendText(normalizeTypedText(v));
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
resetToPlaceholder(input);
|
|
933
|
-
},
|
|
934
|
-
{ signal },
|
|
935
|
-
);
|
|
936
|
-
|
|
937
|
-
input.addEventListener(
|
|
938
|
-
"focus",
|
|
939
|
-
() => {
|
|
940
|
-
termWrap.classList.add("focus");
|
|
941
|
-
},
|
|
942
|
-
{ signal },
|
|
943
|
-
);
|
|
944
|
-
input.addEventListener(
|
|
945
|
-
"blur",
|
|
946
|
-
() => {
|
|
947
|
-
// cancelComposition, not just resetToPlaceholder: `composing` gates both
|
|
948
|
-
// input listeners, so a composition abandoned by losing focus would
|
|
949
|
-
// otherwise keep the gate shut and the terminal would take no input when
|
|
950
|
-
// focus came back. The staleness read covers a keyboard that strands us
|
|
951
|
-
// while still focused; this covers the case with a definite signal.
|
|
952
|
-
composition.cancelComposition();
|
|
953
|
-
termWrap.classList.remove("focus");
|
|
954
|
-
},
|
|
955
|
-
{ signal },
|
|
956
|
-
);
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Applies a `mapKeyboardEvent` result. BOTH keydown paths — the focused textarea
|
|
960
|
-
* below, and the document-level type-to-focus fallback in the focus-strategy
|
|
961
|
-
* section — handle every arm identically, including "ignore": the textarea lets
|
|
962
|
-
* its own `input` event deliver the character, and the fallback has already
|
|
963
|
-
* resolved that case before it gets here, computing the typed character itself
|
|
964
|
-
* because no `input` event is coming for a key that targeted the body.
|
|
965
|
-
*
|
|
966
|
-
* Switching over the whole `KeyboardResult` union in one place is what keeps the
|
|
967
|
-
* two paths from drifting — handling only "send" in the fallback silently dropped
|
|
968
|
-
* Shift+PageUp, which is exactly what a reader who just selected some scrollback
|
|
969
|
-
* presses next.
|
|
970
|
-
*/
|
|
971
|
-
function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): void {
|
|
972
|
-
switch (result.kind) {
|
|
973
|
-
case "send":
|
|
974
|
-
ev.preventDefault();
|
|
975
|
-
sendText(result.bytes);
|
|
976
|
-
return;
|
|
977
|
-
case "scroll-up": {
|
|
978
|
-
ev.preventDefault();
|
|
979
|
-
const h = termWrap.clientHeight;
|
|
980
|
-
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
case "scroll-down": {
|
|
984
|
-
ev.preventDefault();
|
|
985
|
-
const h = termWrap.clientHeight;
|
|
986
|
-
// The ceiling is the maximum offset, not scrollHeight: the latter is one
|
|
987
|
-
// clientHeight past the end, so a page-down near the bottom handed the
|
|
988
|
-
// container an out-of-range offset and relied on it to clamp. Same
|
|
989
|
-
// reasoning as the engine's bottom writes.
|
|
990
|
-
const max = Math.max(0, termWrap.scrollHeight - h);
|
|
991
|
-
termWrap.scrollTop = Math.min(max, termWrap.scrollTop + h);
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
case "ignore":
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
input.addEventListener(
|
|
1000
|
-
"keydown",
|
|
1001
|
-
(ev: KeyboardEvent) => {
|
|
1002
|
-
// Three gates, and the order is the design. The spec signal first: UI
|
|
1003
|
-
// Events requires a key event inside a composition session to carry
|
|
1004
|
-
// isComposing, which covers every engine except one case.
|
|
1005
|
-
//
|
|
1006
|
-
// That case is the key which COMMITS a composition: Chrome and Safari both
|
|
1007
|
-
// report keyCode 229 for it and Safari reports isComposing false (measured
|
|
1008
|
-
// in quill PR #3445), so a second signal is needed. It is read against the
|
|
1009
|
-
// RAW latch, before the reconciling isComposing() below, and both halves of
|
|
1010
|
-
// that matter. Gated, because on Android 229 is the value for nearly every
|
|
1011
|
-
// soft-keyboard key, so an unconditional check swallows ordinary Backspace
|
|
1012
|
-
// and Enter — found in review after exactly that shipped here. Before the
|
|
1013
|
-
// reconcile, because reconciling clears the latch, and a CJK user who left
|
|
1014
|
-
// a candidate window open past the idle bound is still committing a real
|
|
1015
|
-
// composition: taking this key as an ordinary Enter would send a carriage
|
|
1016
|
-
// return before the composed phrase and execute a half-typed line.
|
|
1017
|
-
//
|
|
1018
|
-
// The consequence to know: in a stranded composition on Android, Enter and
|
|
1019
|
-
// Backspace stay blocked until a character arrives (which reconciles the
|
|
1020
|
-
// latch and releases them) or focus is lost. That is the deliberate side
|
|
1021
|
-
// of the trade, because the alternative costs desktop and iOS correctness.
|
|
1022
|
-
if (ev.isComposing) {
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
|
|
1026
|
-
if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
if (composition.isComposing()) {
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
// Features intercept first (clipboard Ctrl+Shift+C/V, contextMenu Escape).
|
|
1033
|
-
for (const h of keydownHandlers) {
|
|
1034
|
-
if (h(ev)) {
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
// Nothing more is needed for an "ignore": the `input` listener above delivers
|
|
1039
|
-
// the character, which is also how IME and dead-key composition output
|
|
1040
|
-
// reaches the terminal.
|
|
1041
|
-
applyMappedKey(ev, mapKeyboardEvent(ev, modes));
|
|
1042
|
-
},
|
|
1043
|
-
{ signal },
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
// --- Focus strategy (the touch focus dance; see the input-model contract) ---
|
|
1047
|
-
// On touch, the terminal output is the native text-selection surface, so this
|
|
1048
|
-
// handler deliberately does the MINIMUM: it opens the keyboard on a clean tap
|
|
1049
|
-
// and otherwise gets out of the browser's way. It never preventDefaults a
|
|
1050
|
-
// touch and never clears a selection — a long-press to select a word, the OS
|
|
1051
|
-
// copy/paste callout, and drag-to-scroll are all left to the platform (the
|
|
1052
|
-
// research consensus: over text, allow the default and emit nothing).
|
|
1053
|
-
// A fine pointer (mouse / trackpad) is available: a desktop, or a tablet with
|
|
1054
|
-
// a trackpad / Magic Keyboard. Such a device has a hardware keyboard, so there
|
|
1055
|
-
// is no soft keyboard to protect — focus should be eager and land in one tap.
|
|
1056
|
-
// The bare-touch guards below bow out of focusing (to avoid popping the soft
|
|
1057
|
-
// keyboard or stealing a selection); they are relaxed when a fine pointer
|
|
1058
|
-
// exists. This is keyed off `any-pointer: fine` rather than the event's
|
|
1059
|
-
// pointerType because iPadOS reports a COARSE primary pointer even with a
|
|
1060
|
-
// trackpad attached, and its trackpad taps arrive inconsistently as "mouse" or
|
|
1061
|
-
// "touch" — so pointerType alone made the terminal take several taps to focus.
|
|
1062
|
-
const hasFinePointer = (): boolean =>
|
|
1063
|
-
typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
|
|
1064
|
-
let lastPointerType = "mouse";
|
|
1065
|
-
let pointerDownX = 0;
|
|
1066
|
-
let pointerDownY = 0;
|
|
1067
|
-
let pointerDownTime = 0;
|
|
1068
|
-
function focusTerminal(): void {
|
|
1069
|
-
// The public handle can outlive a fatal startup or explicit destroy. Never
|
|
1070
|
-
// focus the detached textarea after the runtime has been released.
|
|
1071
|
-
if (destroyed) {
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
input.focus({ preventScroll: true });
|
|
1075
|
-
}
|
|
1076
|
-
termWrap.addEventListener(
|
|
1077
|
-
"pointerdown",
|
|
1078
|
-
(e) => {
|
|
1079
|
-
lastPointerType = e.pointerType;
|
|
1080
|
-
pointerDownX = e.clientX;
|
|
1081
|
-
pointerDownY = e.clientY;
|
|
1082
|
-
pointerDownTime = e.timeStamp;
|
|
1083
|
-
pressHoldsFocus = true;
|
|
1084
|
-
},
|
|
1085
|
-
{ passive: true, signal },
|
|
1086
|
-
);
|
|
1087
|
-
for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
|
|
1088
|
-
termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
|
|
1089
|
-
}
|
|
1090
|
-
termWrap.addEventListener(
|
|
1091
|
-
"pointerup",
|
|
1092
|
-
(e) => {
|
|
1093
|
-
if (e.pointerType !== "touch") {
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
if (isLinkTarget(e.target)) {
|
|
1097
|
-
return;
|
|
1098
|
-
}
|
|
1099
|
-
const dx = Math.abs(e.clientX - pointerDownX);
|
|
1100
|
-
const dy = Math.abs(e.clientY - pointerDownY);
|
|
1101
|
-
// A drag (scroll / selection-extend) or a long-press (native word-select /
|
|
1102
|
-
// context menu) is not a tap-to-focus: bow out and let the browser own it.
|
|
1103
|
-
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
1104
|
-
return;
|
|
1105
|
-
}
|
|
1106
|
-
if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
// A clean tap while text is selected means "done selecting": clear the
|
|
1110
|
-
// selection (and dismiss the OS callout with it). This is our deselect —
|
|
1111
|
-
// iOS otherwise leaves the selection stuck, because the synthetic mousedown
|
|
1112
|
-
// we preventDefault to preserve the keyboard also suppresses the platform's
|
|
1113
|
-
// own tap-to-deselect. Do NOT also focus: a deselect tap should not pop the
|
|
1114
|
-
// keyboard; the next clean tap (nothing selected) opens it. The long-press
|
|
1115
|
-
// that MADE the selection is filtered out above by the duration/movement
|
|
1116
|
-
// guards, so only a deliberate later tap reaches here.
|
|
1117
|
-
const sel = window.getSelection();
|
|
1118
|
-
if (sel && !sel.isCollapsed) {
|
|
1119
|
-
sel.removeAllRanges();
|
|
1120
|
-
// On a bare touchscreen a deselect tap must NOT also focus: that would
|
|
1121
|
-
// pop the soft keyboard right after a copy. But with a fine pointer
|
|
1122
|
-
// (a hardware keyboard is present) there is no soft keyboard to pop, so
|
|
1123
|
-
// focus in the same tap rather than forcing the user to tap again — this
|
|
1124
|
-
// is a large part of the "2-3 taps to focus" on an iPad + Magic Keyboard.
|
|
1125
|
-
if (hasFinePointer()) {
|
|
1126
|
-
focusTerminal();
|
|
1127
|
-
}
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
// A clean tap with nothing selected focuses the input, synchronously (an
|
|
1131
|
-
// async focus would not raise the iOS soft keyboard).
|
|
1132
|
-
focusTerminal();
|
|
1133
|
-
},
|
|
1134
|
-
{ passive: true, signal },
|
|
1135
|
-
);
|
|
1136
|
-
termWrap.addEventListener(
|
|
1137
|
-
"mousedown",
|
|
1138
|
-
(e) => {
|
|
1139
|
-
// Touch is the platform's press, start to finish: its own selection UI
|
|
1140
|
-
// (long-press word select, the drag handles, the OS callout) owns a press
|
|
1141
|
-
// that lands on a selection, and it never uses HTML5 drag-and-drop. So a
|
|
1142
|
-
// touch press gets nothing here but the keyboard preservation below, and
|
|
1143
|
-
// the selection policy that follows is deliberately mouse-and-pen only.
|
|
1144
|
-
if (lastPointerType === "touch") {
|
|
1145
|
-
// Cancel the synthetic mousedown after a touch tap so iOS keeps the
|
|
1146
|
-
// keyboard up (xterm.js focus-preservation pattern, scoped to touch).
|
|
1147
|
-
// Skip it when a fine pointer is present (iPad + trackpad / Magic
|
|
1148
|
-
// Keyboard): there is no soft keyboard to protect, and suppressing the
|
|
1149
|
-
// mousedown was defeating the native focus, so the terminal needed
|
|
1150
|
-
// several taps to focus.
|
|
1151
|
-
if (!hasFinePointer()) {
|
|
1152
|
-
e.preventDefault();
|
|
1153
|
-
}
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
// Terminal text is display-only, never draggable content — but a browser
|
|
1157
|
-
// does not know that, and a bare left press that lands INSIDE the current
|
|
1158
|
-
// selection is the one gesture where it matters: Blink and Gecko both read
|
|
1159
|
-
// it as the start of a native drag-and-drop of the selected text, so the
|
|
1160
|
-
// press neither collapses the selection nor begins a new one, and the
|
|
1161
|
-
// release drops nothing (there is no drop target). Drag over text you just
|
|
1162
|
-
// selected and the selection is stuck: no new selection, and not even a
|
|
1163
|
-
// click clears it, because a real mouse always moves a pixel or two and
|
|
1164
|
-
// that is enough to re-enter the drag path. Collapsing the selection here,
|
|
1165
|
-
// before the browser resolves the gesture, is what keeps it out: with
|
|
1166
|
-
// nothing selected under the press both engines take their ordinary select
|
|
1167
|
-
// path and the drag is never considered. A press OUTSIDE the selection
|
|
1168
|
-
// collapses it natively anyway, so this only ever pre-empts the browser,
|
|
1169
|
-
// never contradicts it — no hit test against the selection needed.
|
|
1170
|
-
// Non-left and modified presses are left alone: a right-click has to keep
|
|
1171
|
-
// the selection for the context menu's Copy, a middle-click for the
|
|
1172
|
-
// primary selection it pastes, Shift-click extends a selection, and
|
|
1173
|
-
// Ctrl-drag adds a second range in Firefox.
|
|
1174
|
-
if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
const pressSel = window.getSelection();
|
|
1178
|
-
if (pressSel && !pressSel.isCollapsed) {
|
|
1179
|
-
pressSel.removeAllRanges();
|
|
1180
|
-
}
|
|
1181
|
-
},
|
|
1182
|
-
{ signal },
|
|
1183
|
-
);
|
|
1184
|
-
termWrap.addEventListener(
|
|
1185
|
-
"click",
|
|
1186
|
-
(e) => {
|
|
1187
|
-
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
1188
|
-
if (link) {
|
|
1189
|
-
e.preventDefault();
|
|
1190
|
-
window.open(link.href, "_blank", "noopener,noreferrer");
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
if (lastPointerType === "touch" && !hasFinePointer()) {
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
const sel = window.getSelection();
|
|
1197
|
-
if (sel && sel.toString().length > 0) {
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
focusTerminal();
|
|
1201
|
-
},
|
|
1202
|
-
{ signal },
|
|
1203
|
-
);
|
|
1204
|
-
|
|
1205
|
-
// --- Type-to-focus: recovering a keystroke after a mouse selection ---
|
|
1206
|
-
// A mouse gesture over `.term-output` leaves the browser no focusable target,
|
|
1207
|
-
// so the key target becomes the document body (UI Events: "If no suitable
|
|
1208
|
-
// element is in focus, the event target will be the HTML body element") and
|
|
1209
|
-
// the hidden textarea stops receiving keys. The click handler above cannot fix
|
|
1210
|
-
// that, because focusing the textarea COLLAPSES the selection the user just
|
|
1211
|
-
// made, so it deliberately declines while one exists. Net effect without this
|
|
1212
|
-
// listener: select a single cell with the mouse and typing silently does
|
|
1213
|
-
// nothing, and the two shortcuts that ride the keydown chain go with it
|
|
1214
|
-
// (clipboard's Ctrl+Shift+C, contextMenu's Escape).
|
|
1215
|
-
//
|
|
1216
|
-
// Native terminals have no such competition: a selection is app state and the
|
|
1217
|
-
// keyboard belongs to the window, so VTE grabs widget focus on the very press
|
|
1218
|
-
// that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
|
|
1219
|
-
// and painting its own selection, which is not open to us — native touch
|
|
1220
|
-
// selection is this UI's mobile selection UX (css/02-terminal.css). So the
|
|
1221
|
-
// browser's model stands and typing takes the keyboard back instead, which is
|
|
1222
|
-
// also what Windows Terminal does ("most key input clears the selection and
|
|
1223
|
-
// passes the key event directly to the underlying shell").
|
|
1224
|
-
const doc = root.ownerDocument;
|
|
1225
|
-
function ownsSelection(): boolean {
|
|
1226
|
-
// EVERY range, and both ends of each: a range dragged from the terminal out
|
|
1227
|
-
// into the host page's own content never arms this in either drag direction,
|
|
1228
|
-
// and neither does a Firefox Ctrl+drag multi-range selection with one range
|
|
1229
|
-
// outside (the mousedown policy above deliberately preserves those, and
|
|
1230
|
-
// removeAllRanges below would take the host's range with the terminal's).
|
|
1231
|
-
// Range ends are in document order whichever way the user dragged, so
|
|
1232
|
-
// direction costs nothing here. Selected TEXT rather than isCollapsed, so a
|
|
1233
|
-
// bare caret does not arm, matching the click handler's own test. Scoped to
|
|
1234
|
-
// `.term-output`, so a selection in the chrome does not arm. Known
|
|
1235
|
-
// limitation: a terminal inside a host's shadow root never arms, because
|
|
1236
|
-
// getSelection() does not reach into shadow trees.
|
|
1237
|
-
const sel = doc.getSelection();
|
|
1238
|
-
if (sel === null || sel.toString().length === 0) {
|
|
1239
|
-
return false;
|
|
1240
|
-
}
|
|
1241
|
-
for (let i = 0; i < sel.rangeCount; i++) {
|
|
1242
|
-
const range = sel.getRangeAt(i);
|
|
1243
|
-
if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
|
|
1244
|
-
return false;
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
return true;
|
|
1248
|
-
}
|
|
1249
|
-
doc.addEventListener(
|
|
1250
|
-
"keydown",
|
|
1251
|
-
(ev: KeyboardEvent) => {
|
|
1252
|
-
// Two groups, in this order. The ARMING gates immediately below decide
|
|
1253
|
-
// whether this listener is responsible for the keystroke at all; the
|
|
1254
|
-
// TERMINAL-KEY gates after the feature chain decide whether the terminal
|
|
1255
|
-
// wants this particular key. Both are preconditions rather than a
|
|
1256
|
-
// classification: nothing runs unless every gate passes, so no keystroke
|
|
1257
|
-
// falls between them.
|
|
1258
|
-
if (destroyed) {
|
|
1259
|
-
// Reachable in one window only: cleanupRuntime() tears features down
|
|
1260
|
-
// BEFORE it aborts the signal that removes this listener, so a feature
|
|
1261
|
-
// teardown that synchronously dispatched a key would otherwise be served
|
|
1262
|
-
// by a terminal that is already gone. (The textarea listener needs no
|
|
1263
|
-
// such gate: it only fires while the textarea holds focus, and a teardown
|
|
1264
|
-
// does not focus it.)
|
|
1265
|
-
return;
|
|
1266
|
-
}
|
|
1267
|
-
// A host that already handled this key marked it, so leave it alone. This
|
|
1268
|
-
// is the platform's own convention for composing keyboard handlers, and it
|
|
1269
|
-
// is the reason this listener needs no opt-out option: an embedder with a
|
|
1270
|
-
// document-level keymap cancels the events it owns.
|
|
1271
|
-
if (ev.defaultPrevented) {
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
// Nobody owns the keyboard. This one test replaces a blocklist of
|
|
1275
|
-
// interactive tag names and is strictly more complete: focus inside an open
|
|
1276
|
-
// shadow root reports the HOST, an open <dialog> reports the dialog (or its
|
|
1277
|
-
// focused child), and a tabindex="0" ARIA widget reports itself. All
|
|
1278
|
-
// non-body, so all bail. It also subsumes the double-send guard: a focused
|
|
1279
|
-
// textarea is not the body, so a key the handler above already took can
|
|
1280
|
-
// never reach here. Residual: HTML's focus fixup rule can park focus back
|
|
1281
|
-
// on the body while a modal dialog is still open (its focused child was
|
|
1282
|
-
// removed), which the focus-landed check below is what actually covers.
|
|
1283
|
-
if (doc.activeElement !== null && doc.activeElement !== doc.body) {
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
// Composition first, matching the textarea path, and on the composition
|
|
1287
|
-
// module's clock rather than ev.isComposing: that clock stays true through
|
|
1288
|
-
// the deferred compositionend read (the documented Chromium workaround).
|
|
1289
|
-
if (composition.isComposing()) {
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
if (!ownsSelection()) {
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
// Features next, while the selection is STILL INTACT, and BEFORE the
|
|
1296
|
-
// modifier gate below. Both orderings are load-bearing: clipboard's
|
|
1297
|
-
// Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
|
|
1298
|
-
// gate placed first would deny the chain the one shortcut this listener
|
|
1299
|
-
// exists to restore (caught by its own test). A claimed key never focuses,
|
|
1300
|
-
// so the selection survives it and a second copy is still possible.
|
|
1301
|
-
for (const h of keydownHandlers) {
|
|
1302
|
-
if (h(ev)) {
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
// What this key DOES is decided before taking focus, because a keystroke
|
|
1307
|
-
// that delivers nothing must leave the selection and the focus untouched.
|
|
1308
|
-
// A bare modifier press is the case that proves it: Shift on its way to
|
|
1309
|
-
// Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
|
|
1310
|
-
// the chord is about to act on.
|
|
1311
|
-
//
|
|
1312
|
-
// The character is computed ONLY for an "ignore" result, so the encoder
|
|
1313
|
-
// always wins a key it maps. Under the kitty protocol a MODIFIED character
|
|
1314
|
-
// key encodes to a CSI-u sequence rather than to its text, and the focused
|
|
1315
|
-
// path sends that sequence; this path has to agree with it.
|
|
1316
|
-
const result = mapKeyboardEvent(ev, modes);
|
|
1317
|
-
const char = result.kind === "ignore" ? typedChar(ev) : null;
|
|
1318
|
-
if (result.kind === "ignore" && char === null) {
|
|
1319
|
-
// Nothing to deliver: a bare modifier or lock key, a dead key, an IME
|
|
1320
|
-
// start, a multi-character or unknown key name.
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
1323
|
-
// Modified keys stay with the browser, with one exception: an AltGr
|
|
1324
|
-
// CHARACTER, because on the layouts that need AltGr that is how the
|
|
1325
|
-
// character is typed. The exception is deliberately limited to a character
|
|
1326
|
-
// rather than granted to any AltGraph event, because AltGraph is not a
|
|
1327
|
-
// reliable signal on its own: Firefox reports it for plain Option on macOS
|
|
1328
|
-
// and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
|
|
1329
|
-
// would hand the terminal Option+ArrowLeft, which is browser back.
|
|
1330
|
-
// (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
|
|
1331
|
-
// scopes it to Windows.)
|
|
1332
|
-
//
|
|
1333
|
-
// Nothing else modified is recovered, and the asymmetry decides that: not
|
|
1334
|
-
// recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
|
|
1335
|
-
// next character typed, while stealing Ctrl+C costs the user the selection
|
|
1336
|
-
// they just made, and Alt+ArrowLeft is browser back.
|
|
1337
|
-
const altGraphChar = char !== null && ev.getModifierState("AltGraph");
|
|
1338
|
-
if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
// Tab stays with the browser: this only ever runs on a body-focused page,
|
|
1342
|
-
// where Tab is how a keyboard user moves on (web-terminal-kiro's
|
|
1343
|
-
// focusable-inventory test pins that expectation).
|
|
1344
|
-
if (ev.key === "Tab") {
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
// Take the keyboard. Collapsing explicitly rather than leaning on focus()
|
|
1348
|
-
// to do it keeps the behaviour identical wherever focus() would not.
|
|
1349
|
-
doc.getSelection()?.removeAllRanges();
|
|
1350
|
-
focusTerminal();
|
|
1351
|
-
// Fail closed. Focus can fail to land (an inert subtree under an open modal
|
|
1352
|
-
// dialog, a released root), and bytes whose effect the user cannot see do
|
|
1353
|
-
// not belong in a shell.
|
|
1354
|
-
if (doc.activeElement !== input) {
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
if (char !== null) {
|
|
1358
|
-
// The encoder defers a plain character to the `input` event, which the
|
|
1359
|
-
// focused path delivers. No input event is coming for a key that targeted
|
|
1360
|
-
// the body, so send it here. preventDefault makes that exactly once BY
|
|
1361
|
-
// SPEC ("If this event is canceled, the associated event types MUST NOT be
|
|
1362
|
-
// dispatched"), rather than by depending on whether an engine re-resolves
|
|
1363
|
-
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1364
|
-
// mid-dispatch. Chromium does; no specification requires it.
|
|
1365
|
-
ev.preventDefault();
|
|
1366
|
-
sendText(normalizeTypedText(char));
|
|
1367
|
-
return;
|
|
1368
|
-
}
|
|
1369
|
-
applyMappedKey(ev, result);
|
|
1370
|
-
},
|
|
1371
|
-
{ signal },
|
|
1372
|
-
);
|
|
1373
|
-
|
|
1374
|
-
// --- Viewport ---
|
|
1375
|
-
viewport.init({
|
|
1376
|
-
termWrap,
|
|
1377
|
-
root,
|
|
1378
|
-
suppressKeyboardInset: hasFinePointer,
|
|
1379
|
-
onSettled() {
|
|
1380
|
-
// The settle is the authoritative size for this geometry: measurableSize
|
|
1381
|
-
// re-measures the font metrics and now reports a viewport that has stopped
|
|
1382
|
-
// moving, so this is the one resize a keyboard slide or rotation should
|
|
1383
|
-
// cost. sendResize deduplicates, so a settle that changed nothing is free.
|
|
1384
|
-
if (measurableSize() !== null) {
|
|
1385
|
-
connection.sendResize();
|
|
1386
|
-
}
|
|
1387
|
-
composition.positionCompositionView();
|
|
1388
|
-
},
|
|
1389
|
-
});
|
|
1390
|
-
|
|
1391
|
-
// --- Fonts ---
|
|
1392
|
-
const onFontSettled = (): void => {
|
|
1393
|
-
fontsLoaded = true;
|
|
1394
|
-
if (firstFrameRendered) {
|
|
1395
|
-
markReady();
|
|
1396
|
-
}
|
|
1397
|
-
requestAnimationFrame(() => {
|
|
1398
|
-
maybeSendFirstResize();
|
|
1399
|
-
});
|
|
1400
|
-
};
|
|
1401
|
-
try {
|
|
1402
|
-
// false once the fonts have landed, true when the deadline opened the gate
|
|
1403
|
-
// without them. The race covers the whole wait, not just the rejection arm: a
|
|
1404
|
-
// request that stalls with nothing rejecting leaves load() ITSELF pending, so a
|
|
1405
|
-
// deadline armed on the rejection would bound one of the ways a font can hang.
|
|
1406
|
-
const landed: Promise<boolean> = document.fonts.load(fontReady).then(
|
|
1407
|
-
() => false,
|
|
1408
|
-
(err: unknown) => {
|
|
1409
|
-
console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
|
|
1410
|
-
// One load() over a comma-separated stack rejects as a UNIT, so settling
|
|
1411
|
-
// here would let an unavailable OPTIONAL family abandon the gate for the
|
|
1412
|
-
// REQUIRED one and announce a size measured on fallback metrics. `ready`
|
|
1413
|
-
// settles the initial load INCLUDING a failed one (the same property
|
|
1414
|
-
// features/tabs/ink-centre.ts relies on), so the families that ARE served
|
|
1415
|
-
// still land their bytes before the first resize is measured.
|
|
1416
|
-
return document.fonts.ready.then(() => false);
|
|
1417
|
-
},
|
|
1418
|
-
);
|
|
1419
|
-
const deadline = new Promise<boolean>((resolve) => {
|
|
1420
|
-
window.setTimeout(() => {
|
|
1421
|
-
resolve(true);
|
|
1422
|
-
}, FONT_READY_TIMEOUT_MS);
|
|
1423
|
-
});
|
|
1424
|
-
// Whichever settles first opens the gate exactly once; the loser is ignored.
|
|
1425
|
-
void Promise.race([landed, deadline]).then((timedOut) => {
|
|
1426
|
-
onFontSettled();
|
|
1427
|
-
if (!timedOut) {
|
|
1428
|
-
return;
|
|
1429
|
-
}
|
|
1430
|
-
// The gate opened on fallback metrics, and the swap period is infinite: bytes
|
|
1431
|
-
// that land after this change the cell width with fontsLoaded already true,
|
|
1432
|
-
// and nothing re-measures until the next viewport transition. So one
|
|
1433
|
-
// corrective announce, through the path the viewport settle uses.
|
|
1434
|
-
// sendResize deduplicates, so a font that never lands costs nothing.
|
|
1435
|
-
void document.fonts.ready.then(() => {
|
|
1436
|
-
// The bytes can arrive arbitrarily late, so this is the one arm of the gate
|
|
1437
|
-
// that outlives its terminal; destroy() wins, as it does for a feature's
|
|
1438
|
-
// un-cancelled async.
|
|
1439
|
-
if (isDestroyed() || measurableSize() === null) {
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
connection.sendResize();
|
|
1443
|
-
});
|
|
1444
|
-
}, onFontSettled);
|
|
1445
|
-
} catch (err) {
|
|
1446
|
-
console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
|
|
1447
|
-
onFontSettled();
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// --- Browse-cache TTL (engine docs/paged-scrollback.md §5.6) ---
|
|
1451
|
-
//
|
|
1452
|
-
// Paged-in history is disposable by construction: recovery is one fetch. The
|
|
1453
|
-
// ENGINE provides the mechanism and this layer owns the clock, because the
|
|
1454
|
-
// engine has no notion of a page or a tab. Eviction is by INACTIVITY, never
|
|
1455
|
-
// eagerly, so that rapid scrolling in any direction stays instant.
|
|
1456
|
-
//
|
|
1457
|
-
// The visible-page drop is CONDITIONAL inside the store: a reader parked on
|
|
1458
|
-
// cached rows is inactive while looking straight at them, so the store skips
|
|
1459
|
-
// and this timer retries. A hidden page has no reader, so its drop is
|
|
1460
|
-
// unconditional.
|
|
1461
|
-
const BROWSE_CACHE_TTL_MS = 5 * 60_000;
|
|
1462
|
-
/**
|
|
1463
|
-
* Every store EXCEPT the renderer-bound one, pruning refs whose store has been
|
|
1464
|
-
* collected. A background tab has no reader, which is what lets both callers
|
|
1465
|
-
* below drop its cache without a position to protect.
|
|
1466
|
-
*/
|
|
1467
|
-
const backgroundStores = (bound: LineStore): LineStore[] => {
|
|
1468
|
-
const out: LineStore[] = [];
|
|
1469
|
-
for (const store of liveStores()) {
|
|
1470
|
-
if (store !== bound) {
|
|
1471
|
-
out.push(store);
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
return out;
|
|
1475
|
-
};
|
|
1476
|
-
const browseSweep = window.setInterval(() => {
|
|
1477
|
-
// The BOUND store is the one with a reader, so its drop is conditional and
|
|
1478
|
-
// goes through the renderer — the only layer that knows where that reader is.
|
|
1479
|
-
const bound = render.boundStore();
|
|
1480
|
-
if (
|
|
1481
|
-
render.browseCacheSize() > 0 &&
|
|
1482
|
-
Date.now() - render.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
1483
|
-
) {
|
|
1484
|
-
render.dropBrowseCache(document.visibilityState === "visible");
|
|
1485
|
-
}
|
|
1486
|
-
// Every OTHER store belongs to a background tab. Without this pass the sweep
|
|
1487
|
-
// only ever reached the visible tab and every background tab's cache was
|
|
1488
|
-
// immortal for the life of the page — up to the engine's whole cache budget
|
|
1489
|
-
// per tab, on the phone this feature exists for.
|
|
1490
|
-
for (const store of backgroundStores(bound)) {
|
|
1491
|
-
if (
|
|
1492
|
-
store.browseCacheSize() > 0 &&
|
|
1493
|
-
Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
1494
|
-
) {
|
|
1495
|
-
store.dropBrowseCache(-1, false); // no reader: no position to exempt
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
}, 60_000);
|
|
1499
|
-
|
|
1500
|
-
/**
|
|
1501
|
-
* Drop every cache unconditionally, TTL ignored. The LAST CHANCE handler: it
|
|
1502
|
-
* runs when the page is about to stop executing entirely, which is the one
|
|
1503
|
-
* state the sweep above cannot cover — a frozen page runs no code at all, so
|
|
1504
|
-
* without this its caches stay resident for as long as the freeze lasts, and a
|
|
1505
|
-
* discard then throws them away unread. Ten to twenty megabytes (estimate), for
|
|
1506
|
-
* an unbounded time, for nobody.
|
|
1507
|
-
*
|
|
1508
|
-
* Unconditional is right HERE and wrong on the return transition, and the
|
|
1509
|
-
* difference is which way the reader is walking. `visibilitychange` to visible
|
|
1510
|
-
* fires as a reader ARRIVES, so dropping there deletes the rows they are about
|
|
1511
|
-
* to look at (that drop existed and was removed). `freeze` fires as the page
|
|
1512
|
-
* stops being a running program, with no reader and none imminent — and the
|
|
1513
|
-
* cache is disposable by construction, so the cost of being wrong is one fetch
|
|
1514
|
-
* on a return that may never come.
|
|
1515
|
-
*/
|
|
1516
|
-
const dropEveryBrowseCache = (): void => {
|
|
1517
|
-
const bound = render.boundStore();
|
|
1518
|
-
render.dropBrowseCache(false); // through the renderer: it schedules the reconcile
|
|
1519
|
-
for (const store of backgroundStores(bound)) {
|
|
1520
|
-
store.dropBrowseCache(-1, false);
|
|
1521
|
-
}
|
|
1522
|
-
};
|
|
1523
|
-
// Two hooks for one state, because no single one covers both engines: `freeze`
|
|
1524
|
-
// is Chrome's Page Lifecycle signal, and `pagehide` with `persisted` is entry
|
|
1525
|
-
// into the back/forward cache — the Safari path, on the platform this feature
|
|
1526
|
-
// is for. Either can fire without the other.
|
|
1527
|
-
document.addEventListener(
|
|
1528
|
-
"freeze",
|
|
1529
|
-
() => {
|
|
1530
|
-
// Persist BEFORE dropping. The keeper already wrote on `visibilitychange`
|
|
1531
|
-
// to hidden, but a hidden page's socket keeps delivering, so the tail can
|
|
1532
|
-
// have advanced since — and `freeze` is the last code that runs, so a write
|
|
1533
|
-
// skipped here is a write that never happens. The order matters only in that
|
|
1534
|
-
// direction: the snapshot excludes browse cache by classification, so
|
|
1535
|
-
// flushing first costs nothing and dropping first could lose a page of live
|
|
1536
|
-
// tail on a page that never wakes.
|
|
1537
|
-
scrollbackKeeper?.flush();
|
|
1538
|
-
dropEveryBrowseCache();
|
|
1539
|
-
},
|
|
1540
|
-
{ signal },
|
|
1541
|
-
);
|
|
1542
|
-
signal.addEventListener("abort", () => {
|
|
1543
|
-
clearInterval(browseSweep);
|
|
1544
|
-
});
|
|
1545
|
-
|
|
1546
|
-
// --- Reconnect-on-wake ---
|
|
1547
|
-
document.addEventListener(
|
|
1548
|
-
"visibilitychange",
|
|
1549
|
-
() => {
|
|
1550
|
-
if (document.visibilityState === "visible") {
|
|
1551
|
-
// No cache drop on the RETURN transition. An earlier version enforced the
|
|
1552
|
-
// TTL the throttled hidden period owed, with hidden-page semantics
|
|
1553
|
-
// (unconditional) — which deleted the rows the returning reader was
|
|
1554
|
-
// parked on, in the one moment they are certain to look at them. The page
|
|
1555
|
-
// is visible again the instant this fires, so the visible-page rule
|
|
1556
|
-
// applies, and that is exactly what the periodic sweep does within its
|
|
1557
|
-
// next tick. There is nothing left for this branch to add.
|
|
1558
|
-
if (connectionInitiated) {
|
|
1559
|
-
connection.reconnectNow();
|
|
1560
|
-
}
|
|
1561
|
-
focusTerminal();
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
// Hidden. This and pagehide are the last callbacks that reliably run
|
|
1565
|
-
// before a discard, and a discard is the case scrollback persistence
|
|
1566
|
-
// exists for, so write now rather than waiting for the background pass.
|
|
1567
|
-
scrollbackKeeper?.flush();
|
|
1568
|
-
},
|
|
1569
|
-
{ signal },
|
|
1570
|
-
);
|
|
1571
|
-
window.addEventListener(
|
|
1572
|
-
"pageshow",
|
|
1573
|
-
() => {
|
|
1574
|
-
if (connectionInitiated) {
|
|
1575
|
-
connection.reconnectNow();
|
|
1576
|
-
}
|
|
1577
|
-
focusTerminal();
|
|
1578
|
-
},
|
|
1579
|
-
{ signal },
|
|
1580
|
-
);
|
|
1581
|
-
window.addEventListener(
|
|
1582
|
-
"pagehide",
|
|
1583
|
-
(event) => {
|
|
1584
|
-
// Both halves of the last-chance write: visibilitychange fires when the
|
|
1585
|
-
// page is backgrounded, pagehide when it is unloaded or frozen into
|
|
1586
|
-
// bfcache. Chrome's page-lifecycle guidance is explicit that pagehide is
|
|
1587
|
-
// not guaranteed to run, which is why the keeper also saves on a timer.
|
|
1588
|
-
scrollbackKeeper?.flush();
|
|
1589
|
-
if (event.persisted) {
|
|
1590
|
-
// Into the back/forward cache: the page is frozen, so this is the last
|
|
1591
|
-
// code that runs until it is restored — if it ever is.
|
|
1592
|
-
dropEveryBrowseCache();
|
|
1593
|
-
}
|
|
1594
|
-
},
|
|
1595
|
-
{ signal },
|
|
1596
|
-
);
|
|
1597
|
-
window.addEventListener(
|
|
1598
|
-
"online",
|
|
1599
|
-
() => {
|
|
1600
|
-
if (connectionInitiated) {
|
|
1601
|
-
connection.reconnectNow();
|
|
1602
|
-
}
|
|
1603
|
-
},
|
|
1604
|
-
{ signal },
|
|
1605
|
-
);
|
|
1606
|
-
|
|
1607
|
-
// --- Feature context ---
|
|
1608
|
-
const subscriptions: Unsubscribe[] = [];
|
|
1609
|
-
const apiMap = new Map<TerminalFeature<unknown>, unknown>();
|
|
1610
|
-
const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
|
|
1611
|
-
|
|
1612
|
-
// The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
|
|
1613
|
-
// owner-resolved first connect both land here.
|
|
1614
|
-
function performSwitch(session: SessionRef): void {
|
|
1615
|
-
// A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
|
|
1616
|
-
// after destroy() and request a switch; ignore it so a torn-down terminal
|
|
1617
|
-
// never re-points or reopens the socket (connection.setSession below).
|
|
1618
|
-
if (isDestroyed()) {
|
|
1619
|
-
return;
|
|
1620
|
-
}
|
|
1621
|
-
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
1622
|
-
// End any in-flight IME composition and clear the textarea so
|
|
1623
|
-
// half-composed text is not delivered to either session, and let every
|
|
1624
|
-
// feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
|
|
1625
|
-
// cannot fire against the incoming session. This all runs before
|
|
1626
|
-
// setSession, and the switch is synchronous, so input is inert between
|
|
1627
|
-
// detach here and the onSwitch attach below.
|
|
1628
|
-
composition.cancelComposition();
|
|
1629
|
-
resetToPlaceholder(input);
|
|
1630
|
-
// A held mouse gesture is latched input too, and the mouse module is per
|
|
1631
|
-
// TERMINAL while sessions multiplex over it: a record kept across the switch
|
|
1632
|
-
// would let the incoming session's first open synthesize a release for a
|
|
1633
|
-
// press its application never saw.
|
|
1634
|
-
mouse.disarmGesture();
|
|
1635
|
-
for (const { instance } of instances) {
|
|
1636
|
-
instance.onDetach?.();
|
|
1637
|
-
}
|
|
1638
|
-
activeSession = session;
|
|
1639
|
-
// Reconnect the terminal WS to this session using its per-tab resume
|
|
1640
|
-
// state; the renderer was already pointed at its store by the owner.
|
|
1641
|
-
connection.setSession(session.id);
|
|
1642
|
-
// setSession restores the incoming session's mode mirror synchronously and
|
|
1643
|
-
// delivers no modes frame, so nothing else re-derives the pointer here.
|
|
1644
|
-
updateMousePointer();
|
|
1645
|
-
// The owned first connect has happened (session id is now on the WS URL);
|
|
1646
|
-
// wake-reconnect handlers may fire from here on.
|
|
1647
|
-
connectionInitiated = true;
|
|
1648
|
-
for (const { instance } of instances) {
|
|
1649
|
-
if (instance.onSwitch) {
|
|
1650
|
-
instance.onSwitch(session);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
bus.emit("session:switch", session);
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
function makeContext(featureName: string): TerminalContext {
|
|
1657
|
-
return {
|
|
1658
|
-
region: (name, slot) => regions.region(name, slot),
|
|
1659
|
-
surface: () => termWrap,
|
|
1660
|
-
send: sendBytes,
|
|
1661
|
-
paste,
|
|
1662
|
-
registerInputTransform(fn) {
|
|
1663
|
-
inputTransforms.push(fn);
|
|
1664
|
-
return () => {
|
|
1665
|
-
const i = inputTransforms.indexOf(fn);
|
|
1666
|
-
if (i >= 0) {
|
|
1667
|
-
inputTransforms.splice(i, 1);
|
|
1668
|
-
}
|
|
1669
|
-
};
|
|
1670
|
-
},
|
|
1671
|
-
registerInputObserver(fn) {
|
|
1672
|
-
inputObservers.push(fn);
|
|
1673
|
-
return () => {
|
|
1674
|
-
const i = inputObservers.indexOf(fn);
|
|
1675
|
-
if (i >= 0) {
|
|
1676
|
-
inputObservers.splice(i, 1);
|
|
1677
|
-
}
|
|
1678
|
-
};
|
|
1679
|
-
},
|
|
1680
|
-
registerKeydown(fn) {
|
|
1681
|
-
keydownHandlers.push(fn);
|
|
1682
|
-
return () => {
|
|
1683
|
-
const i = keydownHandlers.indexOf(fn);
|
|
1684
|
-
if (i >= 0) {
|
|
1685
|
-
keydownHandlers.splice(i, 1);
|
|
1686
|
-
}
|
|
1687
|
-
};
|
|
1688
|
-
},
|
|
1689
|
-
render,
|
|
1690
|
-
scroll,
|
|
1691
|
-
session: {
|
|
1692
|
-
get id() {
|
|
1693
|
-
return activeSession?.id ?? null;
|
|
1694
|
-
},
|
|
1695
|
-
size: () => render.computeSize(),
|
|
1696
|
-
highestIndex: () => render.getHighestIndex(),
|
|
1697
|
-
},
|
|
1698
|
-
on(e, fn) {
|
|
1699
|
-
// Wrap so a throwing feature handler is isolated and attributed.
|
|
1700
|
-
const wrapped = (p: Parameters<typeof fn>[0]): void => {
|
|
1701
|
-
try {
|
|
1702
|
-
fn(p);
|
|
1703
|
-
} catch (err) {
|
|
1704
|
-
reportError(featureName, err);
|
|
1705
|
-
}
|
|
1706
|
-
};
|
|
1707
|
-
const off = bus.on(e, wrapped);
|
|
1708
|
-
subscriptions.push(off);
|
|
1709
|
-
return off;
|
|
1710
|
-
},
|
|
1711
|
-
use<A>(feature: TerminalFeature<A>): A | undefined {
|
|
1712
|
-
return apiMap.get(feature) as A | undefined;
|
|
1713
|
-
},
|
|
1714
|
-
toast,
|
|
1715
|
-
announce: (message, politeness) => {
|
|
1716
|
-
announcer.announce(message, politeness);
|
|
1717
|
-
},
|
|
1718
|
-
loadingReason: (message) => {
|
|
1719
|
-
// No overlayDismissed guard needed: loadingStatus.stop() runs in
|
|
1720
|
-
// dismissLoadingOverlay and every method is inert afterwards, so the
|
|
1721
|
-
// "a late retry cannot resurrect the overlay" contract holds in ONE
|
|
1722
|
-
// place rather than at each caller.
|
|
1723
|
-
loadingStatus.reason(message);
|
|
1724
|
-
},
|
|
1725
|
-
titlePrefix: (text) => {
|
|
1726
|
-
titlePrefixText = text;
|
|
1727
|
-
paintTitle();
|
|
1728
|
-
},
|
|
1729
|
-
tablist: () => tablistController,
|
|
1730
|
-
newLineStore: (sessionId) => {
|
|
1731
|
-
// Registered on EVERY path, because this factory is the only way a store
|
|
1732
|
-
// enters the app and the browse-cache sweep has to be able to reach a
|
|
1733
|
-
// background tab's store (the renderer only ever sees the bound one).
|
|
1734
|
-
const track = (store: LineStore, key: string): LineStore => {
|
|
1735
|
-
knownStores.set(key, new WeakRef(store));
|
|
1736
|
-
return store;
|
|
1737
|
-
};
|
|
1738
|
-
if (scrollbackKeeper !== null) {
|
|
1739
|
-
if (sessionId !== undefined) {
|
|
1740
|
-
// Hydrated from storage when a usable entry exists, and tracked for
|
|
1741
|
-
// saving either way. Necessarily before this session connects: the
|
|
1742
|
-
// caller is building the tab, and the kernel's switch (which opens
|
|
1743
|
-
// the socket) happens afterwards.
|
|
1744
|
-
const store = scrollbackKeeper.storeFor(sessionId);
|
|
1745
|
-
if (store.highestIndex() >= 0) {
|
|
1746
|
-
unverifiedRestores.add(sessionId);
|
|
1747
|
-
}
|
|
1748
|
-
return track(store, sessionId);
|
|
1749
|
-
}
|
|
1750
|
-
scrollbackKeeper.noteMissingSessionId();
|
|
1751
|
-
}
|
|
1752
|
-
return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
|
|
1753
|
-
},
|
|
1754
|
-
layout: () => ({
|
|
1755
|
-
narrow: isNarrow(),
|
|
1756
|
-
coarse:
|
|
1757
|
-
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
|
|
1758
|
-
}),
|
|
1759
|
-
notifySwitch(session) {
|
|
1760
|
-
performSwitch(session);
|
|
1761
|
-
},
|
|
1762
|
-
dropSession(id) {
|
|
1763
|
-
connection.forgetSession(id);
|
|
1764
|
-
// A closed tab's scrollback should not outlive it. This is also the only
|
|
1765
|
-
// collection path that runs promptly; the keeper's age bound covers the
|
|
1766
|
-
// case this one cannot see, which is a tab the browser discarded without
|
|
1767
|
-
// ever running a close.
|
|
1768
|
-
scrollbackKeeper?.forget(id);
|
|
1769
|
-
},
|
|
1770
|
-
onError(fn) {
|
|
1771
|
-
errorHandlers.add(fn);
|
|
1772
|
-
return () => errorHandlers.delete(fn);
|
|
1773
|
-
},
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
// --- Feature lifecycle ---
|
|
1778
|
-
// `destroyed` means the terminal runtime is no longer usable, whether that
|
|
1779
|
-
// happened through the public destroy() handle or a fatal startup rollback.
|
|
1780
|
-
// `rootReleased` is narrower: destroy() has also removed the recovery surface
|
|
1781
|
-
// and boundary classes. Keeping the two facts separate lets a fatal terminal
|
|
1782
|
-
// remain visible and still be explicitly destroyed later.
|
|
1783
|
-
let destroyed = false;
|
|
1784
|
-
let rootReleased = false;
|
|
1785
|
-
let runtimeCleaned = false;
|
|
1786
|
-
// Live read of `destroyed` for post-await re-checks: a plain variable read is
|
|
1787
|
-
// narrowed to always-false by TS CFA because it cannot model destroy() firing
|
|
1788
|
-
// while feature setup or session resolution is awaiting.
|
|
1789
|
-
const isDestroyed = (): boolean => destroyed;
|
|
1790
|
-
|
|
1791
|
-
function teardownFeatures(): void {
|
|
1792
|
-
for (let i = instances.length - 1; i >= 0; i--) {
|
|
1793
|
-
const entry = instances[i];
|
|
1794
|
-
if (!entry) {
|
|
1795
|
-
continue;
|
|
1796
|
-
}
|
|
1797
|
-
try {
|
|
1798
|
-
entry.instance.teardown();
|
|
1799
|
-
} catch (err) {
|
|
1800
|
-
reportError(entry.feature.name, err);
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
instances.length = 0;
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
// Release the complete live runtime while leaving the root boundary classes
|
|
1807
|
-
// intact. Fatal startup uses the root for its recovery surface; destroy()
|
|
1808
|
-
// follows this cleanup by removing the classes and any recovery UI.
|
|
1809
|
-
function cleanupRuntime(): void {
|
|
1810
|
-
if (runtimeCleaned) {
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
runtimeCleaned = true;
|
|
1814
|
-
// Before anything is torn down: a deliberate teardown (a host closing an
|
|
1815
|
-
// embedded panel, a reload path calling destroy()) is still a page the user
|
|
1816
|
-
// may come back to, and the tracked stores are about to become unreachable.
|
|
1817
|
-
scrollbackKeeper?.flush();
|
|
1818
|
-
scrollbackKeeper?.stop();
|
|
1819
|
-
teardownFeatures();
|
|
1820
|
-
// A destroyed terminal must not leave its attention prefix on the page: the
|
|
1821
|
-
// tab it counted is gone, and the title outlives this runtime (an embedded
|
|
1822
|
-
// panel closing, a reload path). The base is left alone -- it belongs to the
|
|
1823
|
-
// document, not to us.
|
|
1824
|
-
titlePrefixText = "";
|
|
1825
|
-
paintTitle();
|
|
1826
|
-
kernelAbort.abort();
|
|
1827
|
-
narrowObserver?.disconnect();
|
|
1828
|
-
narrowObserver = null;
|
|
1829
|
-
viewport.teardown();
|
|
1830
|
-
// Reset the composition singleton: a failure during an IME cycle must not
|
|
1831
|
-
// leave module state or a deferred send alive for a later terminal mount.
|
|
1832
|
-
composition.teardown();
|
|
1833
|
-
disposeMouse();
|
|
1834
|
-
connection.disconnect();
|
|
1835
|
-
connState.destroy();
|
|
1836
|
-
if (toastTimer !== null) {
|
|
1837
|
-
clearTimeout(toastTimer);
|
|
1838
|
-
toastTimer = null;
|
|
1839
|
-
}
|
|
1840
|
-
if (focusResolve !== null) {
|
|
1841
|
-
clearTimeout(focusResolve);
|
|
1842
|
-
focusResolve = null;
|
|
1843
|
-
}
|
|
1844
|
-
for (const off of subscriptions) {
|
|
1845
|
-
off();
|
|
1846
|
-
}
|
|
1847
|
-
subscriptions.length = 0;
|
|
1848
|
-
inputTransforms.length = 0;
|
|
1849
|
-
inputObservers.length = 0;
|
|
1850
|
-
keydownHandlers.length = 0;
|
|
1851
|
-
errorHandlers.clear();
|
|
1852
|
-
apiMap.clear();
|
|
1853
|
-
activeSession = null;
|
|
1854
|
-
bus.clear();
|
|
1855
|
-
announcer.destroy();
|
|
1856
|
-
regions.destroy();
|
|
1857
|
-
root.classList.remove("wt-narrow");
|
|
1858
|
-
root.replaceChildren();
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
function renderFatalStartup(): void {
|
|
1862
|
-
renderFatalStartupInto(root, layoutMode);
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
function enterFatalStartup(feature: string, cause: unknown): void {
|
|
1866
|
-
if (destroyed) {
|
|
1867
|
-
return;
|
|
1868
|
-
}
|
|
1869
|
-
destroyed = true;
|
|
1870
|
-
cleanupRuntime();
|
|
1871
|
-
dismissLoadingOverlay();
|
|
1872
|
-
|
|
1873
|
-
let handled = false;
|
|
1874
|
-
try {
|
|
1875
|
-
handled =
|
|
1876
|
-
opts.onFatalError?.({ phase: "feature-setup", feature, cause, surface: root }) === true;
|
|
1877
|
-
} catch (handlerErr) {
|
|
1878
|
-
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
1879
|
-
}
|
|
1880
|
-
// A handler may call destroy() while taking over, so do not recreate the
|
|
1881
|
-
// default surface after the root has explicitly been released.
|
|
1882
|
-
if (!handled && !rootReleased) {
|
|
1883
|
-
renderFatalStartup();
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
type FeatureSetupOutcome =
|
|
1888
|
-
| { readonly status: "ready" }
|
|
1889
|
-
| { readonly status: "aborted" }
|
|
1890
|
-
| {
|
|
1891
|
-
readonly status: "failed";
|
|
1892
|
-
readonly feature: string;
|
|
1893
|
-
readonly cause: unknown;
|
|
1894
|
-
};
|
|
1895
|
-
|
|
1896
|
-
// Distinguish intentional cancellation from a fatal feature failure. A
|
|
1897
|
-
// boolean collapsed both into the same "not ready" outcome, which is why a
|
|
1898
|
-
// failed setup previously left a half-live terminal and only wrote a log.
|
|
1899
|
-
async function setupFeatures(): Promise<FeatureSetupOutcome> {
|
|
1900
|
-
for (const feature of featureList) {
|
|
1901
|
-
if (destroyed) {
|
|
1902
|
-
return { status: "aborted" };
|
|
1903
|
-
}
|
|
1904
|
-
try {
|
|
1905
|
-
const instance = await feature.setup(makeContext(feature.name));
|
|
1906
|
-
// destroy() may have run during the await above; cleanupRuntime() has
|
|
1907
|
-
// already swept `instances`, and destroy() is one-shot, so a straight
|
|
1908
|
-
// push here would leave this instance's listeners/timers/observers alive
|
|
1909
|
-
// forever. Tear it down instead of registering it.
|
|
1910
|
-
if (isDestroyed()) {
|
|
1911
|
-
try {
|
|
1912
|
-
instance.teardown();
|
|
1913
|
-
} catch (err) {
|
|
1914
|
-
reportError(feature.name, err);
|
|
1915
|
-
}
|
|
1916
|
-
return { status: "aborted" };
|
|
1917
|
-
}
|
|
1918
|
-
instances.push({ feature, instance });
|
|
1919
|
-
// Populate the feature value's readonly api (consumer pattern:
|
|
1920
|
-
// tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
|
|
1921
|
-
(feature as { api?: unknown }).api = instance.api;
|
|
1922
|
-
apiMap.set(feature, instance.api);
|
|
1923
|
-
} catch (cause) {
|
|
1924
|
-
if (isDestroyed()) {
|
|
1925
|
-
return { status: "aborted" };
|
|
1926
|
-
}
|
|
1927
|
-
reportError(feature.name, cause);
|
|
1928
|
-
console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
|
|
1929
|
-
if (isDestroyed()) {
|
|
1930
|
-
return { status: "aborted" };
|
|
1931
|
-
}
|
|
1932
|
-
return { status: "failed", feature: feature.name, cause };
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
return { status: "ready" };
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
// Features set up in the background; first paint is never gated on them.
|
|
1939
|
-
// Under a session owner the kernel then drives the first connect itself:
|
|
1940
|
-
// await the owner's resolveInitialSession() and switch to the session it
|
|
1941
|
-
// returns through the same path a tab switch uses. Connecting to the bare
|
|
1942
|
-
// wsPath first would open a /ws that a SessionManager 404s (no ?session=),
|
|
1943
|
-
// flashing a disconnect banner and churning the reconnect backoff — hence no
|
|
1944
|
-
// startup connect below when an owner is registered. A null (or thrown)
|
|
1945
|
-
// resolution means the bootstrap failed: the owner keeps its retry chrome
|
|
1946
|
-
// alive, and the kernel — which now SEES the failure instead of inferring it
|
|
1947
|
-
// from a missing side effect — dismisses the loading overlay so that chrome
|
|
1948
|
-
// is visible instead of an eternal spinner.
|
|
1949
|
-
void setupFeatures().then(async (outcome) => {
|
|
1950
|
-
if (outcome.status === "failed") {
|
|
1951
|
-
enterFatalStartup(outcome.feature, outcome.cause);
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1954
|
-
if (outcome.status === "aborted") {
|
|
1955
|
-
return;
|
|
1956
|
-
}
|
|
1957
|
-
if (!sessionOwner || isDestroyed()) {
|
|
1958
|
-
return;
|
|
1959
|
-
}
|
|
1960
|
-
let resolved: SessionRef | null = null;
|
|
1961
|
-
try {
|
|
1962
|
-
resolved = await sessionOwner.resolveInitialSession();
|
|
1963
|
-
} catch (err) {
|
|
1964
|
-
reportError(sessionOwnerName, err);
|
|
1965
|
-
}
|
|
1966
|
-
if (isDestroyed()) {
|
|
1967
|
-
return;
|
|
1968
|
-
}
|
|
1969
|
-
if (resolved) {
|
|
1970
|
-
performSwitch(resolved);
|
|
1971
|
-
} else if (!connectionInitiated) {
|
|
1972
|
-
dismissLoadingOverlay();
|
|
1973
|
-
}
|
|
1974
|
-
});
|
|
1975
|
-
|
|
1976
|
-
// --- Connect + focus ---
|
|
1977
|
-
render.updateFontMetrics();
|
|
1978
|
-
composition.positionCompositionView();
|
|
1979
|
-
if (!sessionOwner) {
|
|
1980
|
-
// A single unmanaged terminal has no feature to own its store, so the kernel
|
|
1981
|
-
// hydrates the renderer's implicit one here — before connect(), because the
|
|
1982
|
-
// resume announces what this client already holds (Callbacks.getHaveThrough)
|
|
1983
|
-
// and a store hydrated afterwards has already asked for everything.
|
|
1984
|
-
//
|
|
1985
|
-
// The session id comes from the engine rather than from this library: an
|
|
1986
|
-
// unmanaged terminal's identity is a per-tab, sessionStorage-backed id minted
|
|
1987
|
-
// inside the connection layer, with exactly the semantics a persistence key
|
|
1988
|
-
// needs (stable across a reload and an iOS tab restore, fresh in a genuinely
|
|
1989
|
-
// new tab). Under a session owner the same work happens per session, through
|
|
1990
|
-
// ctx.newLineStore(sessionId).
|
|
1991
|
-
//
|
|
1992
|
-
// Note what is deliberately NOT done: restored content does not dismiss the
|
|
1993
|
-
// loading overlay. It is last session's output until the resume confirms it,
|
|
1994
|
-
// and showing it as live is the same mistake the persisted epoch exists to
|
|
1995
|
-
// prevent. The overlay still lifts on the first real frame — which now
|
|
1996
|
-
// arrives sooner, because the replay behind it is a delta instead of a
|
|
1997
|
-
// refill.
|
|
1998
|
-
if (scrollbackKeeper !== null) {
|
|
1999
|
-
const sessionId = connection.currentSessionId();
|
|
2000
|
-
const restored = scrollbackKeeper.storeFor(sessionId);
|
|
2001
|
-
if (restored.highestIndex() >= 0) {
|
|
2002
|
-
render.bind(restored);
|
|
2003
|
-
// Registered under the SAME id the resumeAck will report, so the guard
|
|
2004
|
-
// can both verify and discard it. This store does not come through
|
|
2005
|
-
// `newLineStore` — an unmanaged terminal has no feature to own it.
|
|
2006
|
-
knownStores.set(sessionId, new WeakRef(restored));
|
|
2007
|
-
unverifiedRestores.add(sessionId);
|
|
2008
|
-
} else {
|
|
2009
|
-
// Nothing usable was stored. Keep the renderer's own store and track
|
|
2010
|
-
// that one instead of swapping in an empty replacement, so this path
|
|
2011
|
-
// changes nothing about a terminal with no snapshot to restore.
|
|
2012
|
-
scrollbackKeeper.track(sessionId, render.boundStore());
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
connection.connect();
|
|
2016
|
-
connectionInitiated = true;
|
|
2017
|
-
}
|
|
2018
|
-
focusTerminal();
|
|
2019
|
-
|
|
2020
|
-
return {
|
|
2021
|
-
focus: focusTerminal,
|
|
2022
|
-
send(bytes) {
|
|
2023
|
-
if (destroyed) {
|
|
2024
|
-
return;
|
|
2025
|
-
}
|
|
2026
|
-
sendBytes(bytes);
|
|
2027
|
-
},
|
|
2028
|
-
reset() {
|
|
2029
|
-
if (destroyed) {
|
|
2030
|
-
return;
|
|
2031
|
-
}
|
|
2032
|
-
render.resetScrollback();
|
|
2033
|
-
render.resetScreen();
|
|
2034
|
-
},
|
|
2035
|
-
reattach() {
|
|
2036
|
-
if (destroyed) {
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
// The old session's content goes first: it belongs to a process that is
|
|
2040
|
-
// gone, and holding it would also put a `haveThrough` on the resume that
|
|
2041
|
-
// claims lines above anything the replacement has committed.
|
|
2042
|
-
render.resetScrollback();
|
|
2043
|
-
render.resetScreen();
|
|
2044
|
-
// Off `ended`, or the banner would keep saying "Session ended" over a
|
|
2045
|
-
// screen this call just blanked. `reconnecting` carries a grace delay, so a
|
|
2046
|
-
// connect that lands promptly shows no banner at all and a slow one reads
|
|
2047
|
-
// as what it is.
|
|
2048
|
-
connState.reconnecting();
|
|
2049
|
-
connection.reconnectNow();
|
|
2050
|
-
},
|
|
2051
|
-
destroy() {
|
|
2052
|
-
if (rootReleased) {
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
rootReleased = true;
|
|
2056
|
-
destroyed = true;
|
|
2057
|
-
cleanupRuntime();
|
|
2058
|
-
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
2059
|
-
// cleanupRuntime() removed the live subtree. A fatal handler may have
|
|
2060
|
-
// rendered replacement UI afterward, so destroy() clears once more.
|
|
2061
|
-
root.replaceChildren();
|
|
2062
|
-
},
|
|
2063
|
-
};
|
|
2064
|
-
}
|
|
1
|
+
import { fadeOutOverlay, renderFatalStartupInto } from "./fatal.js";
|
|
2
|
+
import { createShell, hasLiveShell } from "./shell.js";
|
|
3
|
+
import type { CreateTerminalOptions, TerminalHandle } from "./types.js";
|
|
2065
4
|
|
|
2066
5
|
/** Resolve the mount target. A selector is looked up here, INSIDE
|
|
2067
|
-
* createTerminal's try,
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
* startup-failure surface this library already owns. Taking the selector moves
|
|
2071
|
-
* that failure inside the boundary and deletes the consumer's branch.
|
|
2072
|
-
*
|
|
2073
|
-
* An element is still accepted, because an embedder that CREATED the element
|
|
2074
|
-
* already holds a non-null reference and asking it to invent a selector for its
|
|
2075
|
-
* own div would be worse. The trap was never "passing an element", it was
|
|
2076
|
-
* "passing the result of a lookup". */
|
|
6
|
+
* createTerminal's try, so a missing element is a startup failure this library
|
|
7
|
+
* reports rather than a null-check every consumer restates. An element is still
|
|
8
|
+
* accepted, because an embedder that CREATED the element already holds it. */
|
|
2077
9
|
function resolveRoot(target: HTMLElement | string): HTMLElement {
|
|
2078
10
|
if (typeof target !== "string") {
|
|
2079
11
|
return target;
|
|
@@ -2083,35 +15,16 @@ function resolveRoot(target: HTMLElement | string): HTMLElement {
|
|
|
2083
15
|
throw new Error(`web-terminal-ui: no element matches the mount selector ${target}`);
|
|
2084
16
|
}
|
|
2085
17
|
if (!(found instanceof HTMLElement)) {
|
|
2086
|
-
// An SVG or MathML element can match a selector but
|
|
2087
|
-
//
|
|
2088
|
-
// rather than crashing later on a missing property.
|
|
18
|
+
// An SVG or MathML element can match a selector but has no style/classList
|
|
19
|
+
// contract to host the terminal.
|
|
2089
20
|
throw new Error(`web-terminal-ui: the mount selector ${target} matched a non-HTML element`);
|
|
2090
21
|
}
|
|
2091
22
|
return found;
|
|
2092
23
|
}
|
|
2093
24
|
|
|
2094
|
-
/**
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
* function's boundary existed, which is why a consumer had to wrap the preset
|
|
2098
|
-
* call in its own try/catch and render its own surface. One character of
|
|
2099
|
-
* laziness moves that failure inside. */
|
|
2100
|
-
function resolveFeatures(
|
|
2101
|
-
features: CreateTerminalOptions["features"],
|
|
2102
|
-
): readonly TerminalFeature<unknown>[] {
|
|
2103
|
-
return features === undefined ? [] : features();
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
/** Create the host element the recovery surface uses when the mount target could
|
|
2107
|
-
* not be resolved at all.
|
|
2108
|
-
*
|
|
2109
|
-
* It is a NEW element appended to the body, never the body itself: .wt-root
|
|
2110
|
-
* carries the token defaults, box-sizing, color-scheme and scrollbar treatment,
|
|
2111
|
-
* and .wt-viewport carries `position: fixed; inset: 0` — stamping those on a
|
|
2112
|
-
* host application's own body would reformat a page that is otherwise perfectly
|
|
2113
|
-
* healthy. A dedicated element gets the styling boundary the surface needs
|
|
2114
|
-
* without touching anything the consumer owns. */
|
|
25
|
+
/** The host for the recovery surface when the mount target could not be
|
|
26
|
+
* resolved: a NEW element, never the body, whose styling a .wt-root would
|
|
27
|
+
* reformat. */
|
|
2115
28
|
function createFallbackHost(): HTMLElement {
|
|
2116
29
|
const host = document.createElement("div");
|
|
2117
30
|
host.className = "wt-root wt-viewport";
|
|
@@ -2119,61 +32,66 @@ function createFallbackHost(): HTMLElement {
|
|
|
2119
32
|
return host;
|
|
2120
33
|
}
|
|
2121
34
|
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
35
|
+
function reportFatal(
|
|
36
|
+
opts: CreateTerminalOptions,
|
|
37
|
+
cause: unknown,
|
|
38
|
+
surface: HTMLElement | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
try {
|
|
41
|
+
return opts.onFatalError?.({ phase: "kernel-init", cause, surface }) === true;
|
|
42
|
+
} catch (handlerErr) {
|
|
43
|
+
// A reporting failure must not leave the page blank.
|
|
44
|
+
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build the terminal UI inside `target`. Call at most once per document while
|
|
50
|
+
* the previous terminal is alive: a second call reports `kernel-init` with no
|
|
51
|
+
* surface and throws, touching nothing. A synchronous startup failure renders
|
|
52
|
+
* the recovery surface and still propagates; that surface is modal in viewport
|
|
53
|
+
* layout, so `onFatalError` returning `true` is the path that leaves the page
|
|
54
|
+
* interactive. */
|
|
2126
55
|
export function createTerminal(
|
|
2127
56
|
target: HTMLElement | string,
|
|
2128
57
|
opts: CreateTerminalOptions = {},
|
|
2129
58
|
): TerminalHandle {
|
|
2130
59
|
const layoutMode = opts.layout ?? "viewport";
|
|
2131
|
-
// Declared outside the try so the catch can tell "
|
|
2132
|
-
//
|
|
2133
|
-
// from its parameter, so a caller who passed a null root (reachable from any
|
|
2134
|
-
// untyped call site — an inline <script type="module"> never sees tsc) threw a
|
|
2135
|
-
// SECOND error out of the catch itself, losing the real cause and rendering no
|
|
2136
|
-
// surface at all. There is no null root to pass now, and the unresolved case
|
|
2137
|
-
// has an explicit branch below.
|
|
60
|
+
// Declared outside the try so the catch can tell "never resolved a root" from
|
|
61
|
+
// "had one and the build failed".
|
|
2138
62
|
let root: HTMLElement | undefined;
|
|
63
|
+
let refused = false;
|
|
2139
64
|
try {
|
|
2140
65
|
root = resolveRoot(target);
|
|
2141
|
-
|
|
66
|
+
// A second terminal must not draw on a root the first one may own, so this
|
|
67
|
+
// failure has no surface and no overlay fade.
|
|
68
|
+
if (hasLiveShell(root.ownerDocument)) {
|
|
69
|
+
refused = true;
|
|
70
|
+
const cause = new Error(
|
|
71
|
+
"web-terminal-ui: a terminal already exists in this document; destroy() it first, or use the split option for a second pane",
|
|
72
|
+
);
|
|
73
|
+
reportFatal(opts, cause, undefined);
|
|
74
|
+
throw cause;
|
|
75
|
+
}
|
|
76
|
+
return createShell(root, opts);
|
|
2142
77
|
} catch (cause) {
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
2150
|
-
// console error is the worst outcome available.
|
|
78
|
+
if (refused) {
|
|
79
|
+
throw cause;
|
|
80
|
+
}
|
|
81
|
+
// Container mode declines the fallback host on purpose: an embedded terminal
|
|
82
|
+
// is one panel in someone else's working application, and claiming the
|
|
83
|
+
// viewport to say its mount target is missing would break a page that is
|
|
84
|
+
// otherwise fine. The failure is still delivered and rethrown.
|
|
2151
85
|
const surface = root ?? (layoutMode === "viewport" ? createFallbackHost() : undefined);
|
|
2152
86
|
if (surface !== undefined) {
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
2155
|
-
// fires before any DOM work by design), and every .wt-fatal rule is scoped
|
|
2156
|
-
// :where(.wt-root) -- so without this the recovery surface would render
|
|
2157
|
-
// completely unstyled exactly when it matters most. Idempotent when
|
|
2158
|
-
// buildTerminal already stamped them, and when createFallbackHost did.
|
|
87
|
+
// Every .wt-fatal rule is scoped :where(.wt-root), and a throw can precede
|
|
88
|
+
// the stamp (the owner guard fires before any DOM work).
|
|
2159
89
|
surface.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
|
|
2160
90
|
}
|
|
2161
|
-
// The
|
|
2162
|
-
// consumer's pre-JS spinner, and leaving it up hides the surface below it.
|
|
2163
|
-
// This runs even when no surface is rendered — a spinner over an embedder's
|
|
2164
|
-
// broken panel is a lie either way.
|
|
91
|
+
// The consumer's pre-JS spinner must come down even though nothing was built.
|
|
2165
92
|
fadeOutOverlay(opts.loading);
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
try {
|
|
2169
|
-
handled = opts.onFatalError?.({ phase: "kernel-init", cause, surface }) === true;
|
|
2170
|
-
} catch (handlerErr) {
|
|
2171
|
-
// Same posture as the async phase: a reporting failure must not leave the
|
|
2172
|
-
// page blank, so fall through to the built-in surface.
|
|
2173
|
-
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
2174
|
-
}
|
|
2175
|
-
if (!handled && surface !== undefined) {
|
|
2176
|
-
renderFatalStartupInto(surface, layoutMode);
|
|
93
|
+
if (!reportFatal(opts, cause, surface) && surface !== undefined) {
|
|
94
|
+
renderFatalStartupInto(surface, { modal: layoutMode === "viewport" });
|
|
2177
95
|
}
|
|
2178
96
|
throw cause;
|
|
2179
97
|
}
|