@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
|
@@ -0,0 +1,1415 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createTerminalEngine,
|
|
3
|
+
keyboard,
|
|
4
|
+
LineStore,
|
|
5
|
+
type Connection,
|
|
6
|
+
type TerminalEngine,
|
|
7
|
+
} from "@cplieger/web-terminal-engine";
|
|
8
|
+
import { createComposition } from "../composition.js";
|
|
9
|
+
import { createViewport } from "../viewport.js";
|
|
10
|
+
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
|
|
11
|
+
import { createBus } from "./bus.js";
|
|
12
|
+
import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
|
|
13
|
+
import { createRegions } from "./regions.js";
|
|
14
|
+
import { createAnnouncer, createPaneTablist, type PaneTablist } from "./a11y.js";
|
|
15
|
+
import { createConnState } from "./conn-state.js";
|
|
16
|
+
import { selectionTextWithin } from "./selection.js";
|
|
17
|
+
import { windowOf } from "./realm.js";
|
|
18
|
+
import {
|
|
19
|
+
createCleanupScope,
|
|
20
|
+
createFeatureHost,
|
|
21
|
+
type CleanupScope,
|
|
22
|
+
type FeatureSetupOutcome,
|
|
23
|
+
} from "./feature-host.js";
|
|
24
|
+
import type {
|
|
25
|
+
CreateTerminalOptions,
|
|
26
|
+
PaneHandle,
|
|
27
|
+
PaneSide,
|
|
28
|
+
SessionRef,
|
|
29
|
+
ShellContext,
|
|
30
|
+
TablistController,
|
|
31
|
+
TerminalContext,
|
|
32
|
+
TerminalEvents,
|
|
33
|
+
TerminalFeature,
|
|
34
|
+
Unsubscribe,
|
|
35
|
+
} from "./types.js";
|
|
36
|
+
|
|
37
|
+
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_WS_PATH = "/ws";
|
|
40
|
+
// The family that carries the CELL METRICS, alone, never --font-mono's list:
|
|
41
|
+
// WebKit settles load() over a list as soon as the FIRST family covering the
|
|
42
|
+
// sample has loaded, so naming the tiling overlay (11 KB, no "M" glyph) would
|
|
43
|
+
// open the gate while the companion the metrics come from is still loading.
|
|
44
|
+
const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
|
|
45
|
+
// Neither document.fonts.load nor document.fonts.ready carries a deadline, and a
|
|
46
|
+
// response held open leaves both pending. 3s is the block period font-display:
|
|
47
|
+
// block buys; once the engine has painted the fallback, the cell on screen IS
|
|
48
|
+
// the fallback cell.
|
|
49
|
+
const FONT_READY_TIMEOUT_MS = 3000;
|
|
50
|
+
const TOAST_MS = 3000;
|
|
51
|
+
// Paged-in history is disposable by construction (recovery is one fetch), so it
|
|
52
|
+
// is evicted by INACTIVITY, never eagerly, to keep rapid scrolling instant.
|
|
53
|
+
const BROWSE_CACHE_TTL_MS = 5 * 60_000;
|
|
54
|
+
|
|
55
|
+
// Kernel-owned core subtree: the display-only output, the hidden textarea (the
|
|
56
|
+
// single keyboard target), and the IME composition view. Static, trusted markup.
|
|
57
|
+
const CORE_TEMPLATE = `
|
|
58
|
+
<div class="term">
|
|
59
|
+
<div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
|
|
60
|
+
<textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
|
|
61
|
+
<div class="composition-view" aria-hidden="true"></div>
|
|
62
|
+
</div>`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The single character this key types, or null when it types none. `codePointAt`
|
|
66
|
+
* consumes a surrogate pair whole, so a key whose first scalar is the whole
|
|
67
|
+
* string is exactly one character; "Shift", "Dead", "F5" and "Process" are not.
|
|
68
|
+
* A key "associated with multiple characters" (macros, some dead-key sequences)
|
|
69
|
+
* returns null and is not recovered, a stated limitation.
|
|
70
|
+
*/
|
|
71
|
+
function typedChar(ev: KeyboardEvent): string | null {
|
|
72
|
+
const first = ev.key.codePointAt(0);
|
|
73
|
+
return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** iOS sends U+00A0 where a space was typed; shared by every send path so one
|
|
77
|
+
* physical key cannot produce two byte sequences. */
|
|
78
|
+
function normalizeTypedText(text: string): string {
|
|
79
|
+
return text.replace(/\u00A0/g, " ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `KeyboardEvent.keyCode` for a key an IME has claimed (VK_PROCESS). Deprecated
|
|
83
|
+
* and the only carrier of this fact: for the key that commits a composition both
|
|
84
|
+
* Blink and WebKit report `key` as the ordinary "Enter", and Safari reports
|
|
85
|
+
* `isComposing` false. Read only while a composition is open, because Android
|
|
86
|
+
* reports 229 for nearly every soft-keyboard key. */
|
|
87
|
+
const IME_COMMIT_KEYCODE = 229;
|
|
88
|
+
|
|
89
|
+
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
90
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
91
|
+
if (!el) {
|
|
92
|
+
throw new Error(`web-terminal-ui: createTerminal failed to build ${selector}`);
|
|
93
|
+
}
|
|
94
|
+
return el;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The overlay service the shell gives each pane. */
|
|
98
|
+
interface PaneLoading {
|
|
99
|
+
reason(message: string): void;
|
|
100
|
+
/** The first frame from ANY pane dismisses the overlay. */
|
|
101
|
+
firstFrame(): void;
|
|
102
|
+
/** Dismisses the overlay once every built pane has failed. */
|
|
103
|
+
failed(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The shell-owned store registry every pane's stores go through: a tab's store
|
|
107
|
+
* belongs to the tab and follows it into whichever pane shows it. */
|
|
108
|
+
export interface StoreRegistry {
|
|
109
|
+
/** Whether the consumer supplied scrollback persistence. */
|
|
110
|
+
readonly persisting: boolean;
|
|
111
|
+
/** `ctx.newLineStore`: a store hydrated from persistence when one exists,
|
|
112
|
+
* tracked for saving and for the browse-cache sweep. */
|
|
113
|
+
newLineStore(sessionId: string | undefined): LineStore;
|
|
114
|
+
/** The unmanaged pane's implicit store: the hydrated store when persistence
|
|
115
|
+
* holds a usable entry (registered under `sessionId`), else null after
|
|
116
|
+
* tracking `current` under that id. */
|
|
117
|
+
implicitStore(sessionId: string, current: LineStore): LineStore | null;
|
|
118
|
+
/** A resume answered under a known epoch verified this session's restore. */
|
|
119
|
+
verified(sessionId: string): void;
|
|
120
|
+
/** Drop restored scrollback that will never be confirmed: one session's (a 4001
|
|
121
|
+
* says one process is gone) or every unverified one (a wire refusal). The
|
|
122
|
+
* renderer holding the store is reconciled; a background store is reset; an
|
|
123
|
+
* unknown id is the calling pane's implicit store. */
|
|
124
|
+
discardUnverified(scope: { session: string } | "page", caller: PaneSide): void;
|
|
125
|
+
/** Every live tracked store except `bound`. */
|
|
126
|
+
backgroundStores(bound: LineStore): LineStore[];
|
|
127
|
+
/** Save every tracked store whose content advanced (the page-lifecycle hooks). */
|
|
128
|
+
flush(): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What the shell hands `buildPane` beyond the consumer's options: the members a
|
|
132
|
+
* feature must not see. */
|
|
133
|
+
export interface PaneServices {
|
|
134
|
+
readonly side: PaneSide;
|
|
135
|
+
readonly shell: ShellContext;
|
|
136
|
+
/** The `sessionStorage` key of this pane's unmanaged session id. */
|
|
137
|
+
readonly sessionIdKey: string;
|
|
138
|
+
/** A session owner or a pane layout owner exists, so no bare startup connect. */
|
|
139
|
+
readonly managed: boolean;
|
|
140
|
+
/** The resolved retained-line cap (`CreateTerminalOptions.scrollbackLines`),
|
|
141
|
+
* honored by the renderer's implicit store and every `ctx.newLineStore()`. */
|
|
142
|
+
readonly scrollbackLines: number | undefined;
|
|
143
|
+
/** The narrow classification for this pane (the shell root's width, this
|
|
144
|
+
* pane's height). */
|
|
145
|
+
narrowProbe(): boolean;
|
|
146
|
+
/** This pane's base title (the served `<title>`, or its program's OSC 0/2). */
|
|
147
|
+
titleBase(text: string): void;
|
|
148
|
+
readonly loading: PaneLoading;
|
|
149
|
+
onSessionEnded(): void;
|
|
150
|
+
/** This pane's tabpanel half of the ARIA seam, as soon as the panel exists. */
|
|
151
|
+
registerTablist(controller: PaneTablist): void;
|
|
152
|
+
/** The tab row's half, for `ctx.tablist()`. */
|
|
153
|
+
tablist(): TablistController;
|
|
154
|
+
/** The shell's keydown chain, run before this pane's own. */
|
|
155
|
+
onKeydown(ev: KeyboardEvent): boolean;
|
|
156
|
+
/** The two selection drivers. */
|
|
157
|
+
onPointerDown(): void;
|
|
158
|
+
onFocusIn(): void;
|
|
159
|
+
/** This pane attached a session or became empty. */
|
|
160
|
+
onSessionChange(): void;
|
|
161
|
+
/** The server epoch this pane's connection knew for a session it is about to
|
|
162
|
+
* forget, so the pane that shows the session next can be seeded with it. */
|
|
163
|
+
noteEpoch(sessionId: string, epoch: number): void;
|
|
164
|
+
/** The epoch a pane recorded for `sessionId` before forgetting it; 0 when none. */
|
|
165
|
+
knownEpoch(sessionId: string): number;
|
|
166
|
+
/** This pane's connection, registered as soon as the engine exists so the
|
|
167
|
+
* shell's store registry can seed it before the pane's first connect. */
|
|
168
|
+
registerConnection(connection: PaneKernel["connection"]): void;
|
|
169
|
+
readonly stores: StoreRegistry;
|
|
170
|
+
/** `ctx.dropSession`: forget the session on every pane and in the keeper. */
|
|
171
|
+
dropSession(id: string): void;
|
|
172
|
+
/** The terminal's one feature-error channel. */
|
|
173
|
+
reportError(feature: string, err: unknown): void;
|
|
174
|
+
onError(fn: (feature: string, err: unknown) => void): Unsubscribe;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The pane as the shell drives it: the feature-facing handle plus the members
|
|
178
|
+
* only the shell calls. */
|
|
179
|
+
export interface PaneKernel extends PaneHandle {
|
|
180
|
+
readonly connection: Pick<
|
|
181
|
+
Connection,
|
|
182
|
+
"forgetSession" | "serverEpochOf" | "adoptPersistedEpoch" | "currentSessionId"
|
|
183
|
+
>;
|
|
184
|
+
setupFeature(feature: TerminalFeature<unknown>): Promise<FeatureSetupOutcome>;
|
|
185
|
+
/** `PaneHandle.on` with the feature the handler belongs to, for error attribution. */
|
|
186
|
+
busOn<K extends keyof TerminalEvents>(
|
|
187
|
+
featureName: string,
|
|
188
|
+
e: K,
|
|
189
|
+
fn: (p: TerminalEvents[K]) => void,
|
|
190
|
+
): Unsubscribe;
|
|
191
|
+
connectionInitiated(): boolean;
|
|
192
|
+
isDestroyed(): boolean;
|
|
193
|
+
layout(): { narrow: boolean; coarse: boolean };
|
|
194
|
+
toast(message: string, ms?: number): void;
|
|
195
|
+
announce(message: string, politeness?: "polite" | "assertive"): void;
|
|
196
|
+
paste(text: string): void;
|
|
197
|
+
addInputTransform(fn: (bytes: Uint8Array) => Uint8Array): Unsubscribe;
|
|
198
|
+
addInputObserver(fn: (bytes: Uint8Array) => void): Unsubscribe;
|
|
199
|
+
updateNarrow(): void;
|
|
200
|
+
/** Drop the LOCAL display: the client-side scrollback and screen. */
|
|
201
|
+
reset(): void;
|
|
202
|
+
reattach(): void;
|
|
203
|
+
/** Release the live runtime, the engine included, leaving the root's boundary
|
|
204
|
+
* classes for a recovery surface. */
|
|
205
|
+
cleanupRuntime(): void;
|
|
206
|
+
/** Record that this pane's startup failed, so `state()` reads `failed`. */
|
|
207
|
+
markFailed(): void;
|
|
208
|
+
/** Record whether a closed split hides this pane, so `state()` reads `hidden`
|
|
209
|
+
* from the moment the split closes rather than from the class the display
|
|
210
|
+
* gets when the closing transition ends. */
|
|
211
|
+
setHidden(value: boolean): void;
|
|
212
|
+
/** Whether this pane's input is a stop in the document's Tab order. On while
|
|
213
|
+
* the split is open and the pane shows a tab, so Tab runs left input, divider,
|
|
214
|
+
* right input, chrome; off otherwise, where typing or a click enters the one
|
|
215
|
+
* pane and Tab from outside lands on the chrome. */
|
|
216
|
+
setTabStop(value: boolean): void;
|
|
217
|
+
/** `cleanupRuntime()` plus the root's classes and children. */
|
|
218
|
+
destroy(): void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The consumer's theme as custom properties on `root`, so the whole subtree
|
|
222
|
+
* inherits them over the token defaults of css/00-tokens.css. */
|
|
223
|
+
export function applyTheme(root: HTMLElement, theme: CreateTerminalOptions["theme"]): void {
|
|
224
|
+
if (!theme) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
228
|
+
if (key.startsWith("--")) {
|
|
229
|
+
root.style.setProperty(key, value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Build one pane kernel inside `root`: the output surface, the hidden textarea
|
|
235
|
+
* that owns the keyboard, one engine instance, and the pane's regions and
|
|
236
|
+
* primitives; everything visible above the raw terminal is an opt-in feature.
|
|
237
|
+
* The pane REPORTS focus and blur to the transport and never writes DEC 1004
|
|
238
|
+
* bytes itself, because the server derives the answer from every attached
|
|
239
|
+
* client's report plus its own hold. Synchronous and transactional: every
|
|
240
|
+
* acquisition pushes its release onto one disposer stack, which a throw drains
|
|
241
|
+
* before it propagates and `cleanupRuntime()` drains on the way out. */
|
|
242
|
+
export function buildPane(
|
|
243
|
+
root: HTMLElement,
|
|
244
|
+
opts: CreateTerminalOptions,
|
|
245
|
+
services: PaneServices,
|
|
246
|
+
): PaneKernel {
|
|
247
|
+
// One list of what this pane holds, drained by the throw path and the destroy
|
|
248
|
+
// path alike, so a half-built pane cannot leak.
|
|
249
|
+
const held = createCleanupScope((err) => {
|
|
250
|
+
console.error("web-terminal-ui: release failed during teardown", err);
|
|
251
|
+
});
|
|
252
|
+
try {
|
|
253
|
+
return buildPaneInto(root, opts, services, held);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
held.drain();
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildPaneInto(
|
|
261
|
+
root: HTMLElement,
|
|
262
|
+
opts: CreateTerminalOptions,
|
|
263
|
+
services: PaneServices,
|
|
264
|
+
held: CleanupScope,
|
|
265
|
+
): PaneKernel {
|
|
266
|
+
const acquire = (release: () => void): void => {
|
|
267
|
+
held.push(release);
|
|
268
|
+
};
|
|
269
|
+
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
270
|
+
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
271
|
+
const scrollbackLines = services.scrollbackLines;
|
|
272
|
+
const encoder = new TextEncoder();
|
|
273
|
+
// The root's realm, never the importing one: a pane in another document reads
|
|
274
|
+
// and listens to that document.
|
|
275
|
+
const doc = root.ownerDocument;
|
|
276
|
+
const win = windowOf(doc);
|
|
277
|
+
// `destroyed` means the runtime is no longer usable, whether through destroy()
|
|
278
|
+
// or a rollback; `rootReleased` (below) is narrower: the boundary classes are
|
|
279
|
+
// gone too. The flag is the stack's own, so a callback the build already armed
|
|
280
|
+
// (the fonts wait) finds it set after a throw as after destroy().
|
|
281
|
+
let destroyed = false;
|
|
282
|
+
acquire(() => {
|
|
283
|
+
destroyed = true;
|
|
284
|
+
});
|
|
285
|
+
const kernelAbort = new AbortController();
|
|
286
|
+
const { signal } = kernelAbort;
|
|
287
|
+
acquire(() => {
|
|
288
|
+
kernelAbort.abort();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
applyTheme(root, opts.theme);
|
|
292
|
+
|
|
293
|
+
// .wt-root scopes every library token and style rule to this subtree; the
|
|
294
|
+
// layout-mode class decides how the root claims space (the full viewport, or
|
|
295
|
+
// the parent element). All chrome positions against the root, never the page.
|
|
296
|
+
const layoutMode = opts.layout ?? "viewport";
|
|
297
|
+
root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
|
|
298
|
+
|
|
299
|
+
const tpl = doc.createElement("template");
|
|
300
|
+
tpl.innerHTML = CORE_TEMPLATE;
|
|
301
|
+
root.replaceChildren(tpl.content);
|
|
302
|
+
acquire(() => {
|
|
303
|
+
root.classList.remove("wt-narrow");
|
|
304
|
+
root.replaceChildren();
|
|
305
|
+
});
|
|
306
|
+
const termWrap = pick(root, ".term");
|
|
307
|
+
const outputEl = pick(root, ".term-output");
|
|
308
|
+
const input = pick(root, ".term-input") as HTMLTextAreaElement;
|
|
309
|
+
const compositionViewEl = pick(root, ".composition-view");
|
|
310
|
+
|
|
311
|
+
const regions = createRegions(root);
|
|
312
|
+
acquire(() => {
|
|
313
|
+
regions.destroy();
|
|
314
|
+
});
|
|
315
|
+
const bus = createBus();
|
|
316
|
+
acquire(() => {
|
|
317
|
+
bus.clear();
|
|
318
|
+
});
|
|
319
|
+
const announcer = createAnnouncer(root);
|
|
320
|
+
acquire(() => {
|
|
321
|
+
announcer.destroy();
|
|
322
|
+
});
|
|
323
|
+
services.registerTablist(createPaneTablist(outputEl));
|
|
324
|
+
|
|
325
|
+
// Narrow = compact in EITHER dimension: skinny (a portrait phone, a narrow
|
|
326
|
+
// panel) or short (a landscape phone). ResizeObserver is a hard requirement
|
|
327
|
+
// of this library, so it is constructed with no guard.
|
|
328
|
+
function updateNarrow(): void {
|
|
329
|
+
root.classList.toggle("wt-narrow", services.narrowProbe());
|
|
330
|
+
}
|
|
331
|
+
updateNarrow();
|
|
332
|
+
const narrowObserver = new win.ResizeObserver(updateNarrow);
|
|
333
|
+
narrowObserver.observe(root);
|
|
334
|
+
acquire(() => {
|
|
335
|
+
narrowObserver.disconnect();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const toastEl = doc.createElement("div");
|
|
339
|
+
toastEl.className = "wt-toast";
|
|
340
|
+
toastEl.setAttribute("role", "status");
|
|
341
|
+
regions.region("banner", "toast").appendChild(toastEl);
|
|
342
|
+
let toastTimer: number | null = null;
|
|
343
|
+
function toast(message: string, ms = TOAST_MS): void {
|
|
344
|
+
toastEl.textContent = message;
|
|
345
|
+
toastEl.classList.add("visible");
|
|
346
|
+
if (toastTimer !== null) {
|
|
347
|
+
win.clearTimeout(toastTimer);
|
|
348
|
+
}
|
|
349
|
+
toastTimer = win.setTimeout(() => {
|
|
350
|
+
toastTimer = null;
|
|
351
|
+
toastEl.classList.remove("visible");
|
|
352
|
+
toastEl.textContent = "";
|
|
353
|
+
}, ms);
|
|
354
|
+
}
|
|
355
|
+
acquire(() => {
|
|
356
|
+
if (toastTimer !== null) {
|
|
357
|
+
win.clearTimeout(toastTimer);
|
|
358
|
+
toastTimer = null;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const reportError = (feature: string, err: unknown): void => {
|
|
363
|
+
services.reportError(feature, err);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const inputTransforms: ((b: Uint8Array) => Uint8Array)[] = [];
|
|
367
|
+
const inputObservers: ((b: Uint8Array) => void)[] = [];
|
|
368
|
+
const keydownHandlers: ((ev: KeyboardEvent) => boolean)[] = [];
|
|
369
|
+
acquire(() => {
|
|
370
|
+
inputTransforms.length = 0;
|
|
371
|
+
inputObservers.length = 0;
|
|
372
|
+
keydownHandlers.length = 0;
|
|
373
|
+
});
|
|
374
|
+
function removeFrom<T>(list: T[], item: T): () => void {
|
|
375
|
+
return () => {
|
|
376
|
+
const i = list.indexOf(item);
|
|
377
|
+
if (i >= 0) {
|
|
378
|
+
list.splice(i, 1);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sendBytes(bytes: Uint8Array): void {
|
|
384
|
+
// A managed pane with no session (before its first attach, or emptied) sends
|
|
385
|
+
// nothing: the connection would mint an unmanaged session id and park the
|
|
386
|
+
// bytes in its outbox for whatever attaches next.
|
|
387
|
+
if (services.managed && activeSession === null) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let out = bytes;
|
|
391
|
+
for (const t of inputTransforms) {
|
|
392
|
+
out = t(out);
|
|
393
|
+
if (out.length === 0) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// sendBinary buffers while disconnected and returns false only when the
|
|
398
|
+
// outbox is full; observers see accepted input only, so predictive echo never
|
|
399
|
+
// paints a character that never reached the server.
|
|
400
|
+
if (!engine.connection.sendBinary(out)) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// User input re-engages follow and snaps to the bottom (GNOME/xterm), and is
|
|
404
|
+
// the ONLY thing that scrolls a held view down; mouse reports bypass this
|
|
405
|
+
// funnel or the view would snap on every motion report.
|
|
406
|
+
engine.scroll.scrollToBottom();
|
|
407
|
+
for (const obs of inputObservers) {
|
|
408
|
+
obs(out);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function sendText(text: string): void {
|
|
412
|
+
sendBytes(encoder.encode(text));
|
|
413
|
+
}
|
|
414
|
+
function paste(text: string): void {
|
|
415
|
+
sendText(bracketTextForPaste(prepareTextForTerminal(text), engine.modes));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let activeSession: SessionRef | null = null;
|
|
419
|
+
// The wake handlers (visibilitychange/pageshow/online) must not open a socket
|
|
420
|
+
// before the first connect: a bare wsPath on a session-gated server 404s, and
|
|
421
|
+
// pageshow fires on the initial load.
|
|
422
|
+
let connectionInitiated = false;
|
|
423
|
+
let failed = false;
|
|
424
|
+
let hidden = false;
|
|
425
|
+
let rootReleased = false;
|
|
426
|
+
|
|
427
|
+
let ready = false;
|
|
428
|
+
let firstFrameRendered = false;
|
|
429
|
+
let fontsLoaded = false;
|
|
430
|
+
let wsOpen = false;
|
|
431
|
+
function markReady(): void {
|
|
432
|
+
if (ready) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
ready = true;
|
|
436
|
+
connState.setLoaded();
|
|
437
|
+
services.loading.firstFrame();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const connState = createConnState({
|
|
441
|
+
onState: (s) => {
|
|
442
|
+
bus.emit("connection:state", s);
|
|
443
|
+
},
|
|
444
|
+
onGiveUp: () => {
|
|
445
|
+
services.loading.failed();
|
|
446
|
+
},
|
|
447
|
+
timers: win,
|
|
448
|
+
});
|
|
449
|
+
acquire(() => {
|
|
450
|
+
connState.destroy();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// The functions above and below read `engine` from a DOM event, a socket
|
|
454
|
+
// message or a timer, none of which fires before createTerminalEngine returns.
|
|
455
|
+
function updateMousePointer(): void {
|
|
456
|
+
termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
|
|
457
|
+
}
|
|
458
|
+
function measurableSize(): { cols: number; rows: number } | null {
|
|
459
|
+
if (!fontsLoaded || viewport.isInTransition()) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
engine.renderer.updateFontMetrics();
|
|
463
|
+
return engine.renderer.computeSize();
|
|
464
|
+
}
|
|
465
|
+
function maybeSendFirstResize(): void {
|
|
466
|
+
if (!wsOpen || measurableSize() === null) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
engine.connection.sendResize();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const engine: TerminalEngine = createTerminalEngine({
|
|
473
|
+
output: outputEl,
|
|
474
|
+
termWrap,
|
|
475
|
+
wsPath,
|
|
476
|
+
sessionIdKey: services.sessionIdKey,
|
|
477
|
+
...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
|
|
478
|
+
onCursorMove: () => {
|
|
479
|
+
composition.positionCompositionView();
|
|
480
|
+
bus.emit("render:cursor", undefined);
|
|
481
|
+
},
|
|
482
|
+
onUserScrollChange: (scrolledUp) => {
|
|
483
|
+
bus.emit("scroll:state", { scrolledUp });
|
|
484
|
+
},
|
|
485
|
+
callbacks: {
|
|
486
|
+
computeSize: () => engine.renderer.computeSize(),
|
|
487
|
+
getReplayMax: () => engine.renderer.replayMaxForResume(),
|
|
488
|
+
onResumeBounds() {
|
|
489
|
+
// A resume VERIFIES the restored store of the socket's OWN session: the
|
|
490
|
+
// server answered under a known epoch. One ack cannot vouch for a tab it
|
|
491
|
+
// never talked to.
|
|
492
|
+
services.stores.verified(engine.connection.currentSessionId());
|
|
493
|
+
},
|
|
494
|
+
initialSize: measurableSize,
|
|
495
|
+
onMessage(msg) {
|
|
496
|
+
if (msg.type === "screen") {
|
|
497
|
+
firstFrameRendered = true;
|
|
498
|
+
if (fontsLoaded) {
|
|
499
|
+
markReady();
|
|
500
|
+
}
|
|
501
|
+
bus.emit("wire:screen", msg);
|
|
502
|
+
} else if (msg.type === "title") {
|
|
503
|
+
// A shell clears its window title when it redraws its prompt after
|
|
504
|
+
// idling, so a blank OSC 0/2 keeps the last good title.
|
|
505
|
+
if (msg.title.trim() !== "") {
|
|
506
|
+
services.titleBase(msg.title);
|
|
507
|
+
}
|
|
508
|
+
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
509
|
+
} else if (msg.type === "modes") {
|
|
510
|
+
updateMousePointer();
|
|
511
|
+
bus.emit("wire:modes", msg);
|
|
512
|
+
} else if (msg.type === "clipboard") {
|
|
513
|
+
bus.emit("wire:clipboard", msg.text);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
onOpen() {
|
|
517
|
+
connState.open();
|
|
518
|
+
wsOpen = true;
|
|
519
|
+
maybeSendFirstResize();
|
|
520
|
+
},
|
|
521
|
+
onConnecting() {
|
|
522
|
+
connState.reconnecting();
|
|
523
|
+
},
|
|
524
|
+
onClose() {
|
|
525
|
+
connState.closed();
|
|
526
|
+
},
|
|
527
|
+
onOutboxFull() {
|
|
528
|
+
connState.closed();
|
|
529
|
+
},
|
|
530
|
+
onProcessExit() {
|
|
531
|
+
// The definitive 4001 close. markReady() first, so an exit that lands
|
|
532
|
+
// before any screen frame still lowers the overlay; then the end state
|
|
533
|
+
// (no reconnect is coming); the host's turn LAST, so a throwing handler
|
|
534
|
+
// cannot leave the page without its banner.
|
|
535
|
+
services.stores.discardUnverified(
|
|
536
|
+
{ session: engine.connection.currentSessionId() },
|
|
537
|
+
services.side,
|
|
538
|
+
);
|
|
539
|
+
markReady();
|
|
540
|
+
connState.ended();
|
|
541
|
+
services.onSessionEnded();
|
|
542
|
+
},
|
|
543
|
+
onWireIncompatible() {
|
|
544
|
+
services.stores.discardUnverified("page", services.side);
|
|
545
|
+
markReady();
|
|
546
|
+
connState.incompatible();
|
|
547
|
+
},
|
|
548
|
+
onServerRestart() {
|
|
549
|
+
engine.renderer.resetScrollback();
|
|
550
|
+
engine.renderer.resetScreen();
|
|
551
|
+
connState.restarted();
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
acquire(() => {
|
|
556
|
+
engine.dispose();
|
|
557
|
+
});
|
|
558
|
+
services.registerConnection(engine.connection);
|
|
559
|
+
engine.renderer.updateFontMetrics();
|
|
560
|
+
// Derived at build as well as from the two triggers, so a pane created over a
|
|
561
|
+
// mode state that already tracks the mouse shows the right pointer at once.
|
|
562
|
+
updateMousePointer();
|
|
563
|
+
|
|
564
|
+
// A press over the display-only output takes focus off the hidden textarea and
|
|
565
|
+
// the click handler puts it back; that blur/focus pair is an artifact of this
|
|
566
|
+
// input model, not a focus change the application should be told about (xterm.js
|
|
567
|
+
// never emits one). So a blur raised inside a press is withheld and the
|
|
568
|
+
// gesture's REAL end state is reported once it resolves.
|
|
569
|
+
let pressHoldsFocus = false;
|
|
570
|
+
let focusResolve: number | null = null;
|
|
571
|
+
function reportFocusNow(): void {
|
|
572
|
+
engine.connection.setClientFocus(
|
|
573
|
+
doc.activeElement !== null && termWrap.contains(doc.activeElement),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
// The focus restore happens in the `click` handler, dispatched after pointerup
|
|
577
|
+
// within the same task, so a task boundary is the first settled point.
|
|
578
|
+
function endPressFocusGesture(): void {
|
|
579
|
+
if (focusResolve !== null) {
|
|
580
|
+
win.clearTimeout(focusResolve);
|
|
581
|
+
}
|
|
582
|
+
focusResolve = win.setTimeout(() => {
|
|
583
|
+
focusResolve = null;
|
|
584
|
+
pressHoldsFocus = false;
|
|
585
|
+
reportFocusNow();
|
|
586
|
+
}, 0);
|
|
587
|
+
}
|
|
588
|
+
acquire(() => {
|
|
589
|
+
if (focusResolve !== null) {
|
|
590
|
+
win.clearTimeout(focusResolve);
|
|
591
|
+
focusResolve = null;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
termWrap.addEventListener(
|
|
595
|
+
"focusin",
|
|
596
|
+
() => {
|
|
597
|
+
engine.connection.setClientFocus(true);
|
|
598
|
+
},
|
|
599
|
+
{ signal },
|
|
600
|
+
);
|
|
601
|
+
// Any focus move into the pane selects it, a control in the pane's own regions
|
|
602
|
+
// included, not only the textarea.
|
|
603
|
+
root.addEventListener(
|
|
604
|
+
"focusin",
|
|
605
|
+
() => {
|
|
606
|
+
services.onFocusIn();
|
|
607
|
+
},
|
|
608
|
+
{ signal },
|
|
609
|
+
);
|
|
610
|
+
termWrap.addEventListener(
|
|
611
|
+
"focusout",
|
|
612
|
+
(ev) => {
|
|
613
|
+
if (ev.relatedTarget instanceof win.Node && termWrap.contains(ev.relatedTarget)) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (pressHoldsFocus) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
engine.connection.setClientFocus(false);
|
|
620
|
+
},
|
|
621
|
+
{ signal },
|
|
622
|
+
);
|
|
623
|
+
reportFocusNow();
|
|
624
|
+
|
|
625
|
+
const composition = createComposition({
|
|
626
|
+
textarea: input,
|
|
627
|
+
compositionView: compositionViewEl,
|
|
628
|
+
getCursorPx: () => engine.renderer.getCursorPx(),
|
|
629
|
+
send: sendText,
|
|
630
|
+
paste,
|
|
631
|
+
});
|
|
632
|
+
acquire(() => {
|
|
633
|
+
composition.teardown();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
resetToPlaceholder(input);
|
|
637
|
+
input.addEventListener(
|
|
638
|
+
"input",
|
|
639
|
+
(e: Event) => {
|
|
640
|
+
if (composition.isComposing()) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const ev = e as InputEvent;
|
|
644
|
+
const inputType = ev.inputType;
|
|
645
|
+
if (
|
|
646
|
+
inputType === "deleteContentBackward" ||
|
|
647
|
+
inputType === "deleteContentForward" ||
|
|
648
|
+
inputType === "deleteWordBackward" ||
|
|
649
|
+
inputType === "deleteWordForward"
|
|
650
|
+
) {
|
|
651
|
+
resetToPlaceholder(input);
|
|
652
|
+
return;
|
|
653
|
+
} else if (inputType === "insertReplacementText") {
|
|
654
|
+
// A spellcheck, autocorrect or system-substitution REWRITE of text the pty
|
|
655
|
+
// already has. A terminal cannot retract bytes, and applying the rewrite
|
|
656
|
+
// forward duplicates (xterm.js #3600: `helo` corrected to `hello` puts
|
|
657
|
+
// `helohello` on the wire), so drop it and leave the typed text standing.
|
|
658
|
+
resetToPlaceholder(input);
|
|
659
|
+
return;
|
|
660
|
+
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
661
|
+
if (inputType === "insertFromPaste") {
|
|
662
|
+
paste(ev.data);
|
|
663
|
+
} else {
|
|
664
|
+
sendText(normalizeTypedText(ev.data));
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
const v = input.value;
|
|
668
|
+
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
669
|
+
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
670
|
+
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
671
|
+
sendText(normalizeTypedText(v));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
resetToPlaceholder(input);
|
|
675
|
+
},
|
|
676
|
+
{ signal },
|
|
677
|
+
);
|
|
678
|
+
input.addEventListener(
|
|
679
|
+
"focus",
|
|
680
|
+
() => {
|
|
681
|
+
termWrap.classList.add("focus");
|
|
682
|
+
},
|
|
683
|
+
{ signal },
|
|
684
|
+
);
|
|
685
|
+
input.addEventListener(
|
|
686
|
+
"blur",
|
|
687
|
+
() => {
|
|
688
|
+
// cancelComposition, not just resetToPlaceholder: `composing` gates both
|
|
689
|
+
// input listeners, so a composition abandoned by losing focus would keep
|
|
690
|
+
// the gate shut when focus came back.
|
|
691
|
+
composition.cancelComposition();
|
|
692
|
+
termWrap.classList.remove("focus");
|
|
693
|
+
},
|
|
694
|
+
{ signal },
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
/** Applies a `mapKeyboardEvent` result identically on BOTH keydown paths (the
|
|
698
|
+
* focused textarea and the document-level type-to-focus fallback), so the two
|
|
699
|
+
* cannot drift: handling only "send" in the fallback silently dropped
|
|
700
|
+
* Shift+PageUp, which is what a reader who just selected scrollback presses. */
|
|
701
|
+
function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): void {
|
|
702
|
+
switch (result.kind) {
|
|
703
|
+
case "send":
|
|
704
|
+
ev.preventDefault();
|
|
705
|
+
sendText(result.bytes);
|
|
706
|
+
return;
|
|
707
|
+
case "scroll-up": {
|
|
708
|
+
ev.preventDefault();
|
|
709
|
+
const h = termWrap.clientHeight;
|
|
710
|
+
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
case "scroll-down": {
|
|
714
|
+
ev.preventDefault();
|
|
715
|
+
const h = termWrap.clientHeight;
|
|
716
|
+
// The maximum offset, not scrollHeight, which is one clientHeight past the
|
|
717
|
+
// end; WebKit does not reliably clamp an out-of-range offset.
|
|
718
|
+
const max = Math.max(0, termWrap.scrollHeight - h);
|
|
719
|
+
termWrap.scrollTop = Math.min(max, termWrap.scrollTop + h);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
case "ignore":
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** The shell's chain first, then this pane's features'. */
|
|
727
|
+
function runKeydownChain(ev: KeyboardEvent): boolean {
|
|
728
|
+
if (services.onKeydown(ev)) {
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
for (const h of keydownHandlers) {
|
|
732
|
+
if (h(ev)) {
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
input.addEventListener(
|
|
740
|
+
"keydown",
|
|
741
|
+
(ev: KeyboardEvent) => {
|
|
742
|
+
// Three gates, in this order. `isComposing` covers every engine except the
|
|
743
|
+
// key that COMMITS a composition (keyCode 229, Safari reports isComposing
|
|
744
|
+
// false), read against the RAW latch before the reconciling read clears it:
|
|
745
|
+
// a CJK user past the idle bound is still committing a real composition.
|
|
746
|
+
if (ev.isComposing) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the only carrier of the IME-commit fact; see IME_COMMIT_KEYCODE
|
|
750
|
+
if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (composition.isComposing()) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (runKeydownChain(ev)) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// An "ignore" needs nothing more: the `input` listener delivers the character.
|
|
760
|
+
applyMappedKey(ev, mapKeyboardEvent(ev, engine.modes));
|
|
761
|
+
},
|
|
762
|
+
{ signal },
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// On touch the output is the native text-selection surface, so this does the
|
|
766
|
+
// MINIMUM: it opens the keyboard on a clean tap and otherwise gets out of the
|
|
767
|
+
// browser's way. `any-pointer: fine` rather than pointerType because iPadOS
|
|
768
|
+
// reports a COARSE primary pointer even with a trackpad attached.
|
|
769
|
+
const hasFinePointer = (): boolean =>
|
|
770
|
+
typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
|
|
771
|
+
let lastPointerType = "mouse";
|
|
772
|
+
let pointerDownX = 0;
|
|
773
|
+
let pointerDownY = 0;
|
|
774
|
+
let pointerDownTime = 0;
|
|
775
|
+
function focusTerminal(): void {
|
|
776
|
+
if (destroyed) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
input.focus({ preventScroll: true });
|
|
780
|
+
}
|
|
781
|
+
// A passive listener on the pane root: a touch that starts a native selection
|
|
782
|
+
// never focuses the textarea, so focus alone would leave the touched pane
|
|
783
|
+
// unselected.
|
|
784
|
+
root.addEventListener(
|
|
785
|
+
"pointerdown",
|
|
786
|
+
() => {
|
|
787
|
+
services.onPointerDown();
|
|
788
|
+
},
|
|
789
|
+
{ passive: true, signal },
|
|
790
|
+
);
|
|
791
|
+
termWrap.addEventListener(
|
|
792
|
+
"pointerdown",
|
|
793
|
+
(e) => {
|
|
794
|
+
lastPointerType = e.pointerType;
|
|
795
|
+
pointerDownX = e.clientX;
|
|
796
|
+
pointerDownY = e.clientY;
|
|
797
|
+
pointerDownTime = e.timeStamp;
|
|
798
|
+
pressHoldsFocus = true;
|
|
799
|
+
},
|
|
800
|
+
{ passive: true, signal },
|
|
801
|
+
);
|
|
802
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
|
|
803
|
+
termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
|
|
804
|
+
}
|
|
805
|
+
termWrap.addEventListener(
|
|
806
|
+
"pointerup",
|
|
807
|
+
(e) => {
|
|
808
|
+
if (e.pointerType !== "touch") {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (isLinkTarget(win, e.target)) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const dx = Math.abs(e.clientX - pointerDownX);
|
|
815
|
+
const dy = Math.abs(e.clientY - pointerDownY);
|
|
816
|
+
// A drag or a long-press is not a tap-to-focus.
|
|
817
|
+
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// A clean tap while text is selected means "done selecting": iOS otherwise
|
|
824
|
+
// leaves the selection stuck, because the synthetic mousedown cancelled
|
|
825
|
+
// below also suppresses the platform's own tap-to-deselect. A deselect tap
|
|
826
|
+
// must not pop the soft keyboard, unless a hardware keyboard is present.
|
|
827
|
+
const sel = doc.getSelection();
|
|
828
|
+
if (sel && !sel.isCollapsed) {
|
|
829
|
+
sel.removeAllRanges();
|
|
830
|
+
if (hasFinePointer()) {
|
|
831
|
+
focusTerminal();
|
|
832
|
+
}
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
focusTerminal();
|
|
836
|
+
},
|
|
837
|
+
{ passive: true, signal },
|
|
838
|
+
);
|
|
839
|
+
termWrap.addEventListener(
|
|
840
|
+
"mousedown",
|
|
841
|
+
(e) => {
|
|
842
|
+
if (lastPointerType === "touch") {
|
|
843
|
+
// Cancel the synthetic mousedown after a touch tap so iOS keeps the
|
|
844
|
+
// keyboard up, except with a fine pointer, where suppressing it defeated
|
|
845
|
+
// the native focus.
|
|
846
|
+
if (!hasFinePointer()) {
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
// A bare left press INSIDE the current selection starts a native
|
|
852
|
+
// drag-and-drop of the selected text in Blink and Gecko, and a real mouse
|
|
853
|
+
// always moves a pixel, so the selection would be stuck. Collapsing it
|
|
854
|
+
// before the browser resolves the gesture keeps it on the select path; a
|
|
855
|
+
// modified or non-left press keeps the selection for its own purpose.
|
|
856
|
+
if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const pressSel = doc.getSelection();
|
|
860
|
+
if (pressSel && !pressSel.isCollapsed) {
|
|
861
|
+
pressSel.removeAllRanges();
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
{ signal },
|
|
865
|
+
);
|
|
866
|
+
termWrap.addEventListener(
|
|
867
|
+
"click",
|
|
868
|
+
(e) => {
|
|
869
|
+
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
870
|
+
if (link) {
|
|
871
|
+
e.preventDefault();
|
|
872
|
+
win.open(link.href, "_blank", "noopener,noreferrer");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (lastPointerType === "touch" && !hasFinePointer()) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const sel = doc.getSelection();
|
|
879
|
+
if (sel && sel.toString().length > 0) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
focusTerminal();
|
|
883
|
+
},
|
|
884
|
+
{ signal },
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// A mouse gesture over `.term-output` leaves focus on the body, so typing
|
|
888
|
+
// silently does nothing (and the two shortcuts on the keydown chain with it).
|
|
889
|
+
// Focusing on the press would collapse the selection, so the browser's model
|
|
890
|
+
// stands and typing takes the keyboard back, as Windows Terminal does.
|
|
891
|
+
const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
|
|
892
|
+
doc.addEventListener(
|
|
893
|
+
"keydown",
|
|
894
|
+
(ev: KeyboardEvent) => {
|
|
895
|
+
// Reachable in one window: the feature teardowns run before the abort that
|
|
896
|
+
// removes this listener.
|
|
897
|
+
if (destroyed) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// A host that handled the key marked it; the platform's own convention.
|
|
901
|
+
if (ev.defaultPrevented) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// Nobody owns the keyboard: focus in a shadow root, a dialog or an ARIA
|
|
905
|
+
// widget all report non-body and all bail.
|
|
906
|
+
if (doc.activeElement !== null && doc.activeElement !== doc.body) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (composition.isComposing()) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (!ownsSelection()) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Features while the selection is STILL INTACT and before the modifier
|
|
916
|
+
// gate: clipboard's Ctrl+Shift+C is a modified key that reads the selection.
|
|
917
|
+
if (runKeydownChain(ev)) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Decided before taking focus, because a bare modifier press (Shift on its
|
|
921
|
+
// way to Shift+click) must leave the selection alone. Computed only for an
|
|
922
|
+
// "ignore", so the encoder always wins a key it maps.
|
|
923
|
+
const result = mapKeyboardEvent(ev, engine.modes);
|
|
924
|
+
const char = result.kind === "ignore" ? typedChar(ev) : null;
|
|
925
|
+
if (result.kind === "ignore" && char === null) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// Modified keys stay with the browser except an AltGr CHARACTER, the way
|
|
929
|
+
// some layouts type it; AltGraph alone is not a reliable signal (Firefox
|
|
930
|
+
// reports it for plain Option on macOS), and Alt+ArrowLeft is browser back.
|
|
931
|
+
const altGraphChar = char !== null && ev.getModifierState("AltGraph");
|
|
932
|
+
if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (ev.key === "Tab") {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
doc.getSelection()?.removeAllRanges();
|
|
939
|
+
focusTerminal();
|
|
940
|
+
// Fail closed: bytes whose effect the user cannot see do not belong in a
|
|
941
|
+
// shell.
|
|
942
|
+
if (doc.activeElement !== input) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (char !== null) {
|
|
946
|
+
// No `input` event is coming for a key that targeted the body, so send it
|
|
947
|
+
// here; preventDefault makes that exactly once BY SPEC.
|
|
948
|
+
ev.preventDefault();
|
|
949
|
+
sendText(normalizeTypedText(char));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
applyMappedKey(ev, result);
|
|
953
|
+
},
|
|
954
|
+
{ signal },
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const viewport = createViewport({
|
|
958
|
+
termWrap,
|
|
959
|
+
root,
|
|
960
|
+
scroll: engine.scroll,
|
|
961
|
+
suppressKeyboardInset: hasFinePointer,
|
|
962
|
+
onSettled() {
|
|
963
|
+
// The settle is the one resize a keyboard slide or rotation should cost;
|
|
964
|
+
// sendResize deduplicates.
|
|
965
|
+
if (measurableSize() !== null) {
|
|
966
|
+
engine.connection.sendResize();
|
|
967
|
+
}
|
|
968
|
+
composition.positionCompositionView();
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
acquire(() => {
|
|
972
|
+
viewport.teardown();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// Both handles are owned so a pane destroyed inside the font wait fires
|
|
976
|
+
// nothing afterwards: the settled callback would fade the consumer's overlay
|
|
977
|
+
// and measure a disposed renderer.
|
|
978
|
+
let fontDeadline: number | null = null;
|
|
979
|
+
let firstResizeFrame: number | null = null;
|
|
980
|
+
acquire(() => {
|
|
981
|
+
if (fontDeadline !== null) {
|
|
982
|
+
win.clearTimeout(fontDeadline);
|
|
983
|
+
fontDeadline = null;
|
|
984
|
+
}
|
|
985
|
+
if (firstResizeFrame !== null) {
|
|
986
|
+
win.cancelAnimationFrame(firstResizeFrame);
|
|
987
|
+
firstResizeFrame = null;
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
const onFontSettled = (): void => {
|
|
991
|
+
if (destroyed) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
fontsLoaded = true;
|
|
995
|
+
if (firstFrameRendered) {
|
|
996
|
+
markReady();
|
|
997
|
+
}
|
|
998
|
+
firstResizeFrame = win.requestAnimationFrame(() => {
|
|
999
|
+
firstResizeFrame = null;
|
|
1000
|
+
maybeSendFirstResize();
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
try {
|
|
1004
|
+
// The race covers the whole wait: a stalled request leaves load() itself
|
|
1005
|
+
// pending, so a deadline armed on the rejection alone would bound one of the
|
|
1006
|
+
// ways a font can hang.
|
|
1007
|
+
const landed: Promise<boolean> = doc.fonts.load(fontReady).then(
|
|
1008
|
+
() => false,
|
|
1009
|
+
(err: unknown) => {
|
|
1010
|
+
console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
|
|
1011
|
+
// One load() over a stack rejects as a UNIT, so the families that ARE
|
|
1012
|
+
// served still land before the first resize is measured.
|
|
1013
|
+
return doc.fonts.ready.then(() => false);
|
|
1014
|
+
},
|
|
1015
|
+
);
|
|
1016
|
+
const deadline = new Promise<boolean>((resolve) => {
|
|
1017
|
+
fontDeadline = win.setTimeout(() => {
|
|
1018
|
+
fontDeadline = null;
|
|
1019
|
+
resolve(true);
|
|
1020
|
+
}, FONT_READY_TIMEOUT_MS);
|
|
1021
|
+
});
|
|
1022
|
+
void Promise.race([landed, deadline]).then((timedOut) => {
|
|
1023
|
+
onFontSettled();
|
|
1024
|
+
if (destroyed || !timedOut) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
// The gate opened on fallback metrics and the swap period is infinite, so
|
|
1028
|
+
// one corrective announce once the bytes land; sendResize deduplicates.
|
|
1029
|
+
void doc.fonts.ready.then(() => {
|
|
1030
|
+
if (destroyed || measurableSize() === null) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
engine.connection.sendResize();
|
|
1034
|
+
});
|
|
1035
|
+
}, onFontSettled);
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
|
|
1038
|
+
onFontSettled();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const browseSweep = win.setInterval(() => {
|
|
1042
|
+
// The BOUND store has a reader, so its drop is conditional and goes through
|
|
1043
|
+
// the renderer; every other store belongs to a background tab. The engine
|
|
1044
|
+
// stamps browse activity with its own module's Date.now, so the age is read
|
|
1045
|
+
// off that clock and not the mounted window's.
|
|
1046
|
+
const bound = engine.renderer.boundStore();
|
|
1047
|
+
if (
|
|
1048
|
+
engine.renderer.browseCacheSize() > 0 &&
|
|
1049
|
+
Date.now() - engine.renderer.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
1050
|
+
) {
|
|
1051
|
+
engine.renderer.dropBrowseCache(doc.visibilityState === "visible");
|
|
1052
|
+
}
|
|
1053
|
+
for (const store of services.stores.backgroundStores(bound)) {
|
|
1054
|
+
if (
|
|
1055
|
+
store.browseCacheSize() > 0 &&
|
|
1056
|
+
Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
1057
|
+
) {
|
|
1058
|
+
store.dropBrowseCache(-1, false);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}, 60_000);
|
|
1062
|
+
acquire(() => {
|
|
1063
|
+
win.clearInterval(browseSweep);
|
|
1064
|
+
});
|
|
1065
|
+
// Unconditional, for the page about to stop executing: a frozen page runs no
|
|
1066
|
+
// code, so its caches would stay resident for the whole freeze. Wrong on the
|
|
1067
|
+
// return transition, where a reader is arriving.
|
|
1068
|
+
const dropEveryBrowseCache = (): void => {
|
|
1069
|
+
const bound = engine.renderer.boundStore();
|
|
1070
|
+
engine.renderer.dropBrowseCache(false);
|
|
1071
|
+
for (const store of services.stores.backgroundStores(bound)) {
|
|
1072
|
+
store.dropBrowseCache(-1, false);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
// `freeze` is Chrome's Page Lifecycle signal; `pagehide` with `persisted` is
|
|
1076
|
+
// the Safari bfcache path. Either can fire without the other.
|
|
1077
|
+
doc.addEventListener(
|
|
1078
|
+
"freeze",
|
|
1079
|
+
() => {
|
|
1080
|
+
// Persist BEFORE dropping: a hidden page's socket keeps delivering, and
|
|
1081
|
+
// `freeze` is the last code that runs.
|
|
1082
|
+
services.stores.flush();
|
|
1083
|
+
dropEveryBrowseCache();
|
|
1084
|
+
},
|
|
1085
|
+
{ signal },
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
doc.addEventListener(
|
|
1089
|
+
"visibilitychange",
|
|
1090
|
+
() => {
|
|
1091
|
+
if (doc.visibilityState === "visible") {
|
|
1092
|
+
if (connectionInitiated) {
|
|
1093
|
+
engine.connection.reconnectNow();
|
|
1094
|
+
}
|
|
1095
|
+
if (services.shell.selected() === services.side) {
|
|
1096
|
+
focusTerminal();
|
|
1097
|
+
}
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
services.stores.flush();
|
|
1101
|
+
},
|
|
1102
|
+
{ signal },
|
|
1103
|
+
);
|
|
1104
|
+
win.addEventListener(
|
|
1105
|
+
"pageshow",
|
|
1106
|
+
() => {
|
|
1107
|
+
if (connectionInitiated) {
|
|
1108
|
+
engine.connection.reconnectNow();
|
|
1109
|
+
}
|
|
1110
|
+
if (services.shell.selected() === services.side) {
|
|
1111
|
+
focusTerminal();
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
{ signal },
|
|
1115
|
+
);
|
|
1116
|
+
win.addEventListener(
|
|
1117
|
+
"pagehide",
|
|
1118
|
+
(event) => {
|
|
1119
|
+
services.stores.flush();
|
|
1120
|
+
if (event.persisted) {
|
|
1121
|
+
dropEveryBrowseCache();
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
{ signal },
|
|
1125
|
+
);
|
|
1126
|
+
win.addEventListener(
|
|
1127
|
+
"online",
|
|
1128
|
+
() => {
|
|
1129
|
+
if (connectionInitiated) {
|
|
1130
|
+
engine.connection.reconnectNow();
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
{ signal },
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
const host = createFeatureHost(reportError);
|
|
1137
|
+
acquire(() => {
|
|
1138
|
+
host.teardownAll();
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
function detachSession(): void {
|
|
1142
|
+
// Make input safe before the socket is re-pointed: end any in-flight IME
|
|
1143
|
+
// composition, clear the textarea, forget a held mouse gesture (the incoming
|
|
1144
|
+
// session's application never saw the press), and let every feature disarm
|
|
1145
|
+
// latched input state (mobileToolbar's sticky-Ctrl).
|
|
1146
|
+
composition.cancelComposition();
|
|
1147
|
+
resetToPlaceholder(input);
|
|
1148
|
+
engine.mouse.disarmGesture();
|
|
1149
|
+
for (const instance of host.instances()) {
|
|
1150
|
+
instance.onDetach?.();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function performSwitch(session: SessionRef): void {
|
|
1155
|
+
// A feature's un-cancelled async can request a switch after destroy().
|
|
1156
|
+
if (destroyed) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
detachSession();
|
|
1160
|
+
activeSession = session;
|
|
1161
|
+
// A session another pane forgot arrives with the epoch that pane knew, or the
|
|
1162
|
+
// first resumeAck has nothing to compare against and a server restart between
|
|
1163
|
+
// the two attaches goes undetected.
|
|
1164
|
+
const epoch = services.knownEpoch(session.id);
|
|
1165
|
+
if (epoch !== 0) {
|
|
1166
|
+
engine.connection.adoptPersistedEpoch(session.id, epoch);
|
|
1167
|
+
}
|
|
1168
|
+
engine.connection.setSession(session.id);
|
|
1169
|
+
// setSession restores the incoming session's mode mirror synchronously and
|
|
1170
|
+
// delivers no modes frame.
|
|
1171
|
+
updateMousePointer();
|
|
1172
|
+
connectionInitiated = true;
|
|
1173
|
+
for (const instance of host.instances()) {
|
|
1174
|
+
instance.onSwitch?.(session);
|
|
1175
|
+
}
|
|
1176
|
+
bus.emit("session:switch", session);
|
|
1177
|
+
services.onSessionChange();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function clearActiveSession(): void {
|
|
1181
|
+
if (destroyed || activeSession === null) {
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const outgoing = activeSession.id;
|
|
1185
|
+
detachSession();
|
|
1186
|
+
services.noteEpoch(outgoing, engine.connection.serverEpochOf(outgoing));
|
|
1187
|
+
engine.connection.forgetSession(outgoing);
|
|
1188
|
+
activeSession = null;
|
|
1189
|
+
// `connectionInitiated` must go false here or the wake handlers reconnect a
|
|
1190
|
+
// pane that holds no session, under a fresh id the server has never seen.
|
|
1191
|
+
connectionInitiated = false;
|
|
1192
|
+
engine.renderer.bind(new LineStore(scrollbackLines));
|
|
1193
|
+
engine.renderer.resetScreen();
|
|
1194
|
+
connState.idle();
|
|
1195
|
+
services.onSessionChange();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const modes = engine.modes;
|
|
1199
|
+
const session = {
|
|
1200
|
+
get id() {
|
|
1201
|
+
return activeSession?.id ?? null;
|
|
1202
|
+
},
|
|
1203
|
+
size: () => engine.renderer.computeSize(),
|
|
1204
|
+
highestIndex: () => engine.renderer.getHighestIndex(),
|
|
1205
|
+
};
|
|
1206
|
+
const layout = (): { narrow: boolean; coarse: boolean } => ({
|
|
1207
|
+
narrow: services.narrowProbe(),
|
|
1208
|
+
coarse: typeof win.matchMedia === "function" && win.matchMedia("(pointer: coarse)").matches,
|
|
1209
|
+
});
|
|
1210
|
+
function busOn<K extends keyof TerminalEvents>(
|
|
1211
|
+
featureName: string,
|
|
1212
|
+
e: K,
|
|
1213
|
+
fn: (p: TerminalEvents[K]) => void,
|
|
1214
|
+
): Unsubscribe {
|
|
1215
|
+
// Wrapped so a throwing feature handler is isolated and attributed.
|
|
1216
|
+
return bus.on(e, (p) => {
|
|
1217
|
+
try {
|
|
1218
|
+
fn(p);
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
reportError(featureName, err);
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function makeContext(featureName: string, scope: CleanupScope): TerminalContext {
|
|
1226
|
+
const track = (off: Unsubscribe): Unsubscribe => {
|
|
1227
|
+
scope.push(off);
|
|
1228
|
+
return off;
|
|
1229
|
+
};
|
|
1230
|
+
return {
|
|
1231
|
+
region: (name, slot) => regions.region(name, slot),
|
|
1232
|
+
surface: () => termWrap,
|
|
1233
|
+
send: sendBytes,
|
|
1234
|
+
paste,
|
|
1235
|
+
registerInputTransform(fn) {
|
|
1236
|
+
inputTransforms.push(fn);
|
|
1237
|
+
return track(removeFrom(inputTransforms, fn));
|
|
1238
|
+
},
|
|
1239
|
+
registerInputObserver(fn) {
|
|
1240
|
+
inputObservers.push(fn);
|
|
1241
|
+
return track(removeFrom(inputObservers, fn));
|
|
1242
|
+
},
|
|
1243
|
+
registerKeydown(fn) {
|
|
1244
|
+
keydownHandlers.push(fn);
|
|
1245
|
+
return track(removeFrom(keydownHandlers, fn));
|
|
1246
|
+
},
|
|
1247
|
+
render: engine.renderer,
|
|
1248
|
+
scroll: engine.scroll,
|
|
1249
|
+
modes,
|
|
1250
|
+
session,
|
|
1251
|
+
shell: services.shell,
|
|
1252
|
+
on: (e, fn) => track(busOn(featureName, e, fn)),
|
|
1253
|
+
defer(release) {
|
|
1254
|
+
scope.push(release);
|
|
1255
|
+
},
|
|
1256
|
+
use<A>(feature: TerminalFeature<A>): A | undefined {
|
|
1257
|
+
return host.use(feature) as A | undefined;
|
|
1258
|
+
},
|
|
1259
|
+
toast,
|
|
1260
|
+
announce: (message, politeness) => {
|
|
1261
|
+
announcer.announce(message, politeness);
|
|
1262
|
+
},
|
|
1263
|
+
loadingReason: (message) => {
|
|
1264
|
+
services.loading.reason(message);
|
|
1265
|
+
},
|
|
1266
|
+
tablist: () => services.tablist(),
|
|
1267
|
+
newLineStore: (sessionId) => services.stores.newLineStore(sessionId),
|
|
1268
|
+
layout,
|
|
1269
|
+
notifySwitch(s) {
|
|
1270
|
+
performSwitch(s);
|
|
1271
|
+
},
|
|
1272
|
+
dropSession(id) {
|
|
1273
|
+
services.dropSession(id);
|
|
1274
|
+
},
|
|
1275
|
+
onError: (fn) => track(services.onError(fn)),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function cleanupRuntime(): void {
|
|
1280
|
+
if (destroyed) {
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
destroyed = true;
|
|
1284
|
+
// A pane torn down while showing a tab hands the tab's epoch on as an emptied
|
|
1285
|
+
// one does: the tab lives on, and the next pane to show it is seeded from it.
|
|
1286
|
+
if (activeSession !== null) {
|
|
1287
|
+
services.noteEpoch(activeSession.id, engine.connection.serverEpochOf(activeSession.id));
|
|
1288
|
+
}
|
|
1289
|
+
activeSession = null;
|
|
1290
|
+
held.drain();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
engine.renderer.updateFontMetrics();
|
|
1294
|
+
composition.positionCompositionView();
|
|
1295
|
+
if (!services.managed) {
|
|
1296
|
+
// A single unmanaged pane has no feature to own its store, so the shell
|
|
1297
|
+
// hydrates the renderer's implicit one here, before connect(), because the
|
|
1298
|
+
// resume announces what this client already holds. Restored content does not
|
|
1299
|
+
// dismiss the overlay: it is last session's output until the resume confirms
|
|
1300
|
+
// it.
|
|
1301
|
+
if (services.stores.persisting) {
|
|
1302
|
+
const sessionId = engine.connection.currentSessionId();
|
|
1303
|
+
const restored = services.stores.implicitStore(sessionId, engine.renderer.boundStore());
|
|
1304
|
+
if (restored !== null) {
|
|
1305
|
+
engine.renderer.bind(restored);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
engine.connection.connect();
|
|
1309
|
+
connectionInitiated = true;
|
|
1310
|
+
}
|
|
1311
|
+
if (services.shell.selected() === services.side) {
|
|
1312
|
+
focusTerminal();
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
get side() {
|
|
1317
|
+
return services.side;
|
|
1318
|
+
},
|
|
1319
|
+
root,
|
|
1320
|
+
surface: () => termWrap,
|
|
1321
|
+
render: engine.renderer,
|
|
1322
|
+
scroll: engine.scroll,
|
|
1323
|
+
modes,
|
|
1324
|
+
session,
|
|
1325
|
+
state() {
|
|
1326
|
+
if (failed) {
|
|
1327
|
+
return "failed";
|
|
1328
|
+
}
|
|
1329
|
+
if (hidden) {
|
|
1330
|
+
return "hidden";
|
|
1331
|
+
}
|
|
1332
|
+
return activeSession === null ? "empty" : "shown";
|
|
1333
|
+
},
|
|
1334
|
+
region: (name, slot) => regions.region(name, slot),
|
|
1335
|
+
on: (e, fn) => busOn("shell", e, fn),
|
|
1336
|
+
busOn,
|
|
1337
|
+
notifySwitch: performSwitch,
|
|
1338
|
+
clearActiveSession,
|
|
1339
|
+
announceSize() {
|
|
1340
|
+
if (!destroyed && fontsLoaded) {
|
|
1341
|
+
engine.connection.sendResize();
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
focus: focusTerminal,
|
|
1345
|
+
send(bytes) {
|
|
1346
|
+
if (destroyed) {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
sendBytes(bytes);
|
|
1350
|
+
},
|
|
1351
|
+
connection: engine.connection,
|
|
1352
|
+
setupFeature: (feature) =>
|
|
1353
|
+
host.setup(
|
|
1354
|
+
feature,
|
|
1355
|
+
(scope) => makeContext(feature.name, scope),
|
|
1356
|
+
() => destroyed,
|
|
1357
|
+
),
|
|
1358
|
+
connectionInitiated: () => connectionInitiated,
|
|
1359
|
+
isDestroyed: () => destroyed,
|
|
1360
|
+
layout,
|
|
1361
|
+
toast,
|
|
1362
|
+
announce: (message, politeness) => {
|
|
1363
|
+
announcer.announce(message, politeness);
|
|
1364
|
+
},
|
|
1365
|
+
paste,
|
|
1366
|
+
addInputTransform(fn) {
|
|
1367
|
+
inputTransforms.push(fn);
|
|
1368
|
+
return removeFrom(inputTransforms, fn);
|
|
1369
|
+
},
|
|
1370
|
+
addInputObserver(fn) {
|
|
1371
|
+
inputObservers.push(fn);
|
|
1372
|
+
return removeFrom(inputObservers, fn);
|
|
1373
|
+
},
|
|
1374
|
+
updateNarrow,
|
|
1375
|
+
reset() {
|
|
1376
|
+
if (destroyed) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
engine.renderer.resetScrollback();
|
|
1380
|
+
engine.renderer.resetScreen();
|
|
1381
|
+
},
|
|
1382
|
+
reattach() {
|
|
1383
|
+
if (destroyed) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
// The old session's content goes first: holding it would put a
|
|
1387
|
+
// `haveThrough` on the resume that claims lines the replacement has never
|
|
1388
|
+
// reached. Off `ended`, or the banner would contradict the blanked screen.
|
|
1389
|
+
engine.renderer.resetScrollback();
|
|
1390
|
+
engine.renderer.resetScreen();
|
|
1391
|
+
connState.reconnecting();
|
|
1392
|
+
engine.connection.reconnectNow();
|
|
1393
|
+
},
|
|
1394
|
+
cleanupRuntime,
|
|
1395
|
+
markFailed() {
|
|
1396
|
+
failed = true;
|
|
1397
|
+
},
|
|
1398
|
+
setHidden(value) {
|
|
1399
|
+
hidden = value;
|
|
1400
|
+
},
|
|
1401
|
+
setTabStop(value) {
|
|
1402
|
+
input.tabIndex = value ? 0 : -1;
|
|
1403
|
+
},
|
|
1404
|
+
destroy() {
|
|
1405
|
+
if (rootReleased) {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
rootReleased = true;
|
|
1409
|
+
cleanupRuntime();
|
|
1410
|
+
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
1411
|
+
// A fatal handler may have rendered replacement UI after the drain.
|
|
1412
|
+
root.replaceChildren();
|
|
1413
|
+
},
|
|
1414
|
+
};
|
|
1415
|
+
}
|