@cplieger/web-terminal-ui 7.3.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -18
- package/css/30-tabs.css +52 -13
- 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 +13 -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,1434 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LineStore,
|
|
3
|
+
connectStatusStream,
|
|
4
|
+
type EventSourceLike,
|
|
5
|
+
} from "@cplieger/web-terminal-engine";
|
|
6
|
+
import {
|
|
7
|
+
applyTheme,
|
|
8
|
+
buildPane,
|
|
9
|
+
type PaneKernel,
|
|
10
|
+
type PaneServices,
|
|
11
|
+
type StoreRegistry,
|
|
12
|
+
} from "./pane.js";
|
|
13
|
+
import { createStatusShare } from "./status-share.js";
|
|
14
|
+
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
15
|
+
import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
|
|
16
|
+
import {
|
|
17
|
+
createCleanupScope,
|
|
18
|
+
createFeatureHost,
|
|
19
|
+
type CleanupScope,
|
|
20
|
+
type FeatureSetupOutcome,
|
|
21
|
+
} from "./feature-host.js";
|
|
22
|
+
import { createScrollbackKeeper } from "./scrollback.js";
|
|
23
|
+
import { optionalPositiveIntOption } from "./options.js";
|
|
24
|
+
import { clampRatio, isNarrow, isPaneSide, MIN_SPLIT_AREA_PX } from "./layout-policy.js";
|
|
25
|
+
import { fadeOutOverlay, renderFatalStartupInto } from "./fatal.js";
|
|
26
|
+
import { browserAttentionSinks, createAttention, type AttentionSurface } from "./attention.js";
|
|
27
|
+
import { createRegions, type Regions } from "./regions.js";
|
|
28
|
+
import { createAnnouncer, createTablist, type Announcer, type PaneTablist } from "./a11y.js";
|
|
29
|
+
import { createSplitHandle, type SplitHandle } from "./split-handle.js";
|
|
30
|
+
import { windowOf } from "./realm.js";
|
|
31
|
+
import type {
|
|
32
|
+
AttentionOptions,
|
|
33
|
+
AttentionReporter,
|
|
34
|
+
CreateTerminalOptions,
|
|
35
|
+
PaneSide,
|
|
36
|
+
ShellContext,
|
|
37
|
+
SplitController,
|
|
38
|
+
SplitState,
|
|
39
|
+
TerminalContext,
|
|
40
|
+
TerminalEvents,
|
|
41
|
+
TerminalFeature,
|
|
42
|
+
TerminalHandle,
|
|
43
|
+
TerminalStartupFailure,
|
|
44
|
+
Unsubscribe,
|
|
45
|
+
} from "./types.js";
|
|
46
|
+
|
|
47
|
+
// The title, the overlay, the status stream and the notification permission are
|
|
48
|
+
// the DOCUMENT's, so their owner is one per document; the key IS the document.
|
|
49
|
+
const liveShells = new WeakMap<Document, true>();
|
|
50
|
+
|
|
51
|
+
/** Whether `doc` already holds a terminal that has not been destroyed. */
|
|
52
|
+
export function hasLiveShell(doc: Document): boolean {
|
|
53
|
+
return liveShells.has(doc);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the feature list. `features` is a FUNCTION so a preset that throws does
|
|
57
|
+
* so inside createTerminal's failure boundary. */
|
|
58
|
+
function resolveFeatures(
|
|
59
|
+
features: CreateTerminalOptions["features"],
|
|
60
|
+
): readonly TerminalFeature<unknown>[] {
|
|
61
|
+
return features === undefined ? [] : features();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `split` option as an untyped caller (an inline page script) may spell it. */
|
|
65
|
+
function resolveSplitOption(value: unknown): boolean {
|
|
66
|
+
if (value === undefined) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== "boolean") {
|
|
70
|
+
throw new Error("web-terminal-ui: split must be true or false");
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRatio(value: unknown): value is number {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A CSS `<time>` (`0.2s`, `200ms`) in milliseconds; 0 for anything else. */
|
|
80
|
+
function cssTimeMs(value: string): number {
|
|
81
|
+
const m = /^\s*(\d*\.?\d+)(m?s)\s*$/.exec(value);
|
|
82
|
+
if (m === null) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
const n = Number(m[1]);
|
|
86
|
+
return m[2] === "s" ? n * 1000 : n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The controller a terminal built without the split option carries. */
|
|
90
|
+
function disabledSplit(): SplitController {
|
|
91
|
+
const state: SplitState = {
|
|
92
|
+
open: false,
|
|
93
|
+
collapsed: false,
|
|
94
|
+
ratio: 0.5,
|
|
95
|
+
committedRatio: 0.5,
|
|
96
|
+
selected: "left",
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
enabled: false,
|
|
100
|
+
state: () => state,
|
|
101
|
+
isOpen: () => false,
|
|
102
|
+
canOpen: () => false,
|
|
103
|
+
open: () => false,
|
|
104
|
+
close: () => false,
|
|
105
|
+
closeSide: () => false,
|
|
106
|
+
setRatio: () => false,
|
|
107
|
+
onChange: () => () => undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const IMPLICIT_STORE_KEY = "\u0000implicit";
|
|
112
|
+
const SESSION_ID_KEYS = ["vterm-session-id", "vterm-session-id:2"] as const;
|
|
113
|
+
|
|
114
|
+
/** One pane's place in the shell: its root, its kernel once built, and the side
|
|
115
|
+
* the shell has assigned it. */
|
|
116
|
+
interface Slot {
|
|
117
|
+
/** 0 for the pane built with the terminal, 1 for the one the first open builds. */
|
|
118
|
+
readonly index: 0 | 1;
|
|
119
|
+
side: PaneSide;
|
|
120
|
+
readonly root: HTMLElement;
|
|
121
|
+
/** Null before the build and after a build that threw. */
|
|
122
|
+
kernel: PaneKernel | null;
|
|
123
|
+
/** The pane's startup failed and its root is the recovery surface. */
|
|
124
|
+
failed: boolean;
|
|
125
|
+
loadingFailed: boolean;
|
|
126
|
+
baseTitle: string;
|
|
127
|
+
connection: PaneKernel["connection"] | null;
|
|
128
|
+
tablist: PaneTablist | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type PaneFailure =
|
|
132
|
+
| { readonly phase: "kernel-init"; readonly cause: unknown }
|
|
133
|
+
| { readonly phase: "feature-setup"; readonly feature: string; readonly cause: unknown };
|
|
134
|
+
|
|
135
|
+
/** A close whose columns are still sliding: the model is closed already and the
|
|
136
|
+
* display finishes when the transition ends, on the fallback timer, or on
|
|
137
|
+
* `settle()`. */
|
|
138
|
+
interface ClosingTransition {
|
|
139
|
+
/** The ratio the columns slide to: 1 when the left pane survives, 0 when the right does. */
|
|
140
|
+
readonly toward: 0 | 1;
|
|
141
|
+
/** Finish the display now. */
|
|
142
|
+
settle(): void;
|
|
143
|
+
/** Drop the pending finish without running it. */
|
|
144
|
+
abort(): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A shell-scoped feature's registration on every built pane and on every pane
|
|
148
|
+
* built later, released as one. */
|
|
149
|
+
interface PaneRegistration {
|
|
150
|
+
readonly register: (p: PaneKernel) => Unsubscribe;
|
|
151
|
+
readonly offs: Map<PaneKernel, Unsubscribe>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build the terminal in `root`: the shell that owns the document-level facts
|
|
155
|
+
* (title, loading overlay, status stream, notifier, scrollback keeper, the
|
|
156
|
+
* shell-scoped features), its panes, and the feature lifecycle. Without the
|
|
157
|
+
* split option `root` is the one pane's root; with it `root` is the shell root
|
|
158
|
+
* holding a pane root per pane and the shared chrome. Registers the document;
|
|
159
|
+
* `destroy()` releases it. Transactional: a throw releases everything acquired
|
|
160
|
+
* before it, the registration included. */
|
|
161
|
+
export function createShell(root: HTMLElement, opts: CreateTerminalOptions): TerminalHandle {
|
|
162
|
+
// One list of what the shell holds, drained by the throw path and the destroy
|
|
163
|
+
// path alike, so a half-built shell cannot leak or keep its document claimed.
|
|
164
|
+
const held = createCleanupScope((err) => {
|
|
165
|
+
console.error("web-terminal-ui: release failed during teardown", err);
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
return createShellInto(root, opts, held);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
held.drain();
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createShellInto(
|
|
176
|
+
root: HTMLElement,
|
|
177
|
+
opts: CreateTerminalOptions,
|
|
178
|
+
held: CleanupScope,
|
|
179
|
+
): TerminalHandle {
|
|
180
|
+
const featureList = resolveFeatures(opts.features);
|
|
181
|
+
const layoutMode = opts.layout ?? "viewport";
|
|
182
|
+
const splitEnabled = resolveSplitOption(opts.split);
|
|
183
|
+
// Every page service binds to the ROOT's realm, never the importing one, so
|
|
184
|
+
// a terminal in another document owns that document's title and lifecycle.
|
|
185
|
+
const doc = root.ownerDocument;
|
|
186
|
+
const win = windowOf(doc);
|
|
187
|
+
// Read before any pane or attention mark writes the title: a pane built later
|
|
188
|
+
// starts from the served title, not from a title carrying another pane's base
|
|
189
|
+
// and the attention count.
|
|
190
|
+
const servedTitle = doc.title;
|
|
191
|
+
let destroyed = false;
|
|
192
|
+
// A live read for post-await and post-callback checks: a plain variable read is
|
|
193
|
+
// narrowed to always-false by TS CFA, which cannot model destroy() firing
|
|
194
|
+
// during an await or inside a consumer's handler.
|
|
195
|
+
const isDestroyed = (): boolean => destroyed;
|
|
196
|
+
|
|
197
|
+
// At most one feature owns the first connect, whichever registration it uses.
|
|
198
|
+
for (const f of featureList) {
|
|
199
|
+
if (f.sessionOwner !== undefined && f.paneLayoutOwner !== undefined) {
|
|
200
|
+
throw new Error(`web-terminal-ui: ${f.name} registers both sessionOwner and paneLayoutOwner`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const owners = featureList.filter(
|
|
204
|
+
(f) => f.sessionOwner !== undefined || f.paneLayoutOwner !== undefined,
|
|
205
|
+
);
|
|
206
|
+
if (owners.length > 1) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const owner = owners[0];
|
|
212
|
+
if (splitEnabled && owner?.paneLayoutOwner === undefined) {
|
|
213
|
+
throw new Error("web-terminal-ui: split requires the tabs feature (tabs() or a tabbed preset)");
|
|
214
|
+
}
|
|
215
|
+
const managed = owner !== undefined;
|
|
216
|
+
const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
|
|
217
|
+
// The terminal writes each feature object's `api` and resolves `ctx.use` on
|
|
218
|
+
// its identity, so a pane feature object serves one pane only.
|
|
219
|
+
const usedPaneFeatures = new WeakSet<TerminalFeature<unknown>>();
|
|
220
|
+
for (const f of featureList) {
|
|
221
|
+
if (f.scope !== "shell") {
|
|
222
|
+
usedPaneFeatures.add(f);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
liveShells.set(doc, true);
|
|
227
|
+
held.push(() => {
|
|
228
|
+
liveShells.delete(doc);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const shellRoot = root;
|
|
232
|
+
let shellRegions: Regions | null = null;
|
|
233
|
+
let announcer: Announcer | null = null;
|
|
234
|
+
if (splitEnabled) {
|
|
235
|
+
// Written here AND by each kernel on its pane root: `.wt-root` redeclares
|
|
236
|
+
// every token, so a theme written only here would be reset at the pane.
|
|
237
|
+
applyTheme(shellRoot, opts.theme);
|
|
238
|
+
shellRoot.classList.add(
|
|
239
|
+
"wt-root",
|
|
240
|
+
"wt-split",
|
|
241
|
+
layoutMode === "container" ? "wt-container" : "wt-viewport",
|
|
242
|
+
);
|
|
243
|
+
shellRoot.replaceChildren();
|
|
244
|
+
held.push(() => {
|
|
245
|
+
shellRoot.classList.remove(
|
|
246
|
+
"wt-narrow",
|
|
247
|
+
"wt-split-open",
|
|
248
|
+
"wt-split-collapsed",
|
|
249
|
+
"wt-split-closing",
|
|
250
|
+
);
|
|
251
|
+
shellRoot.style.removeProperty("--wt-split-ratio");
|
|
252
|
+
shellRoot.replaceChildren();
|
|
253
|
+
});
|
|
254
|
+
const regions = createRegions(shellRoot);
|
|
255
|
+
shellRegions = regions;
|
|
256
|
+
held.push(() => {
|
|
257
|
+
regions.destroy();
|
|
258
|
+
});
|
|
259
|
+
const shellAnnouncer = createAnnouncer(shellRoot);
|
|
260
|
+
announcer = shellAnnouncer;
|
|
261
|
+
held.push(() => {
|
|
262
|
+
shellAnnouncer.destroy();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const paneOpts: CreateTerminalOptions = splitEnabled ? { ...opts, layout: "container" } : opts;
|
|
266
|
+
|
|
267
|
+
const loadingStatus = attachLoadingStatus(opts.loading, {
|
|
268
|
+
...DEFAULT_LOADING_MESSAGES,
|
|
269
|
+
...opts.loadingMessages,
|
|
270
|
+
});
|
|
271
|
+
held.push(() => {
|
|
272
|
+
loadingStatus.stop();
|
|
273
|
+
});
|
|
274
|
+
let overlayDismissed = false;
|
|
275
|
+
function dismissLoadingOverlay(): void {
|
|
276
|
+
loadingStatus.stop();
|
|
277
|
+
if (!opts.loading || overlayDismissed) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
overlayDismissed = true;
|
|
281
|
+
fadeOutOverlay(opts.loading);
|
|
282
|
+
}
|
|
283
|
+
// Only a pane with a live connect can still deliver a first frame; an empty or
|
|
284
|
+
// hidden one never reports failure, so it must not hold the overlay up.
|
|
285
|
+
function settleLoading(): void {
|
|
286
|
+
const pending = slots.some((s) => !s.loadingFailed && s.kernel?.connectionInitiated() === true);
|
|
287
|
+
if (!pending && slots.some((s) => s.loadingFailed)) {
|
|
288
|
+
dismissLoadingOverlay();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const slots: Slot[] = [];
|
|
293
|
+
const slotAt = (side: PaneSide): Slot | undefined => slots.find((s) => s.side === side);
|
|
294
|
+
const paneAt = (side: PaneSide): PaneKernel | undefined => slotAt(side)?.kernel ?? undefined;
|
|
295
|
+
const builtPanes = (): PaneKernel[] => {
|
|
296
|
+
const out: PaneKernel[] = [];
|
|
297
|
+
for (const side of ["left", "right"] as const) {
|
|
298
|
+
const p = paneAt(side);
|
|
299
|
+
if (p) {
|
|
300
|
+
out.push(p);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
};
|
|
305
|
+
const connections = (): PaneKernel["connection"][] =>
|
|
306
|
+
slots.flatMap((s) => (s.connection === null ? [] : [s.connection]));
|
|
307
|
+
let selectedSide: PaneSide = "left";
|
|
308
|
+
const selectionListeners = new Set<(side: PaneSide) => void>();
|
|
309
|
+
const panesListeners = new Set<() => void>();
|
|
310
|
+
let closing: ClosingTransition | null = null;
|
|
311
|
+
held.push(() => {
|
|
312
|
+
closing?.abort();
|
|
313
|
+
closing = null;
|
|
314
|
+
});
|
|
315
|
+
const selectedPane = (): PaneKernel => {
|
|
316
|
+
const p = paneAt(selectedSide) ?? slots[0]?.kernel ?? undefined;
|
|
317
|
+
if (!p) {
|
|
318
|
+
throw new Error("web-terminal-ui: the terminal has no pane");
|
|
319
|
+
}
|
|
320
|
+
return p;
|
|
321
|
+
};
|
|
322
|
+
function paintSide(slot: Slot): void {
|
|
323
|
+
slot.root.classList.toggle("wt-side-left", slot.side === "left");
|
|
324
|
+
slot.root.classList.toggle("wt-side-right", slot.side === "right");
|
|
325
|
+
}
|
|
326
|
+
// The side is the model's at once; its grid column follows when no closing
|
|
327
|
+
// transition holds the columns where they were.
|
|
328
|
+
function setSide(slot: Slot, side: PaneSide): void {
|
|
329
|
+
slot.side = side;
|
|
330
|
+
if (closing === null) {
|
|
331
|
+
paintSide(slot);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function firePanesChange(): void {
|
|
335
|
+
for (const cb of [...panesListeners]) {
|
|
336
|
+
cb();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let titleMark = "";
|
|
341
|
+
function paintTitle(): void {
|
|
342
|
+
const next =
|
|
343
|
+
titleMark + (slotAt(selectedSide)?.baseTitle ?? slots[0]?.baseTitle ?? servedTitle);
|
|
344
|
+
// The title doubles as the browser-tab label and the bookmark name, and this
|
|
345
|
+
// runs on every status sweep, so an unchanged assignment must not churn it.
|
|
346
|
+
if (doc.title !== next) {
|
|
347
|
+
doc.title = next;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const attentionSurfaces = new Set<AttentionSurface>();
|
|
351
|
+
// The badge is OS-level and the icon links outlive the terminal; the base
|
|
352
|
+
// title belongs to the document.
|
|
353
|
+
held.push(() => {
|
|
354
|
+
for (const surface of attentionSurfaces) {
|
|
355
|
+
surface.dispose();
|
|
356
|
+
}
|
|
357
|
+
attentionSurfaces.clear();
|
|
358
|
+
});
|
|
359
|
+
function attention(options: AttentionOptions): AttentionReporter {
|
|
360
|
+
// A setup still running past destroy() gets a reporter that writes nothing.
|
|
361
|
+
if (destroyed) {
|
|
362
|
+
return { report: () => undefined };
|
|
363
|
+
}
|
|
364
|
+
const surface = createAttention(
|
|
365
|
+
browserAttentionSinks(
|
|
366
|
+
(text) => {
|
|
367
|
+
titleMark = text;
|
|
368
|
+
paintTitle();
|
|
369
|
+
},
|
|
370
|
+
options,
|
|
371
|
+
doc,
|
|
372
|
+
),
|
|
373
|
+
win,
|
|
374
|
+
);
|
|
375
|
+
attentionSurfaces.add(surface);
|
|
376
|
+
return surface;
|
|
377
|
+
}
|
|
378
|
+
// The stream opens on the root's window, not the importing page's.
|
|
379
|
+
const eventSourceIn = (url: string): EventSourceLike => {
|
|
380
|
+
const es = new win.EventSource(url);
|
|
381
|
+
return {
|
|
382
|
+
addEventListener: (type, listener) => {
|
|
383
|
+
es.addEventListener(type, listener as EventListener);
|
|
384
|
+
},
|
|
385
|
+
close: () => {
|
|
386
|
+
es.close();
|
|
387
|
+
},
|
|
388
|
+
get readyState() {
|
|
389
|
+
return es.readyState;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
const statusShare = createStatusShare((path, callbacks) =>
|
|
394
|
+
connectStatusStream(path, callbacks, eventSourceIn),
|
|
395
|
+
);
|
|
396
|
+
held.push(() => {
|
|
397
|
+
statusShare.dispose();
|
|
398
|
+
});
|
|
399
|
+
const notifications = createNotifier(browserNotifierEnv(doc));
|
|
400
|
+
|
|
401
|
+
let splitOpen = false;
|
|
402
|
+
let collapsed = false;
|
|
403
|
+
let committedRatio = 0.5;
|
|
404
|
+
/** The share a drag is previewing, unclamped so a pane can dip under the
|
|
405
|
+
* minimum before the release decides between a close and a snap back. */
|
|
406
|
+
let previewRatio: number | null = null;
|
|
407
|
+
const changeListeners = new Set<(state: SplitState) => void>();
|
|
408
|
+
const rowWidth = (): number => shellRoot.clientWidth;
|
|
409
|
+
// Two ratios: a resize must keep both panes at the minimum without rewriting
|
|
410
|
+
// the share the person chose, which the record holds.
|
|
411
|
+
const effectiveRatio = (): number =>
|
|
412
|
+
splitOpen ? (previewRatio ?? clampRatio(committedRatio, rowWidth())) : 0.5;
|
|
413
|
+
const splitState = (): SplitState => ({
|
|
414
|
+
open: splitOpen,
|
|
415
|
+
collapsed,
|
|
416
|
+
ratio: effectiveRatio(),
|
|
417
|
+
committedRatio,
|
|
418
|
+
selected: selectedSide,
|
|
419
|
+
});
|
|
420
|
+
function applyRatioVar(): void {
|
|
421
|
+
shellRoot.style.setProperty("--wt-split-ratio", String(closing?.toward ?? effectiveRatio()));
|
|
422
|
+
}
|
|
423
|
+
/** How long a close slides, from the same token the stylesheet's transition
|
|
424
|
+
* reads; 0 without `wt-animate`, and the change is then instant. */
|
|
425
|
+
function closingDurationMs(): number {
|
|
426
|
+
if (!shellRoot.classList.contains("wt-animate")) {
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
return cssTimeMs(win.getComputedStyle(shellRoot).getPropertyValue("--dur-standard"));
|
|
430
|
+
}
|
|
431
|
+
function beginClosing(toward: 0 | 1, durationMs: number, tail: () => void): void {
|
|
432
|
+
shellRoot.classList.add("wt-split-closing");
|
|
433
|
+
const stop = (): void => {
|
|
434
|
+
win.clearTimeout(timer);
|
|
435
|
+
shellRoot.removeEventListener("transitionend", onEnd);
|
|
436
|
+
closing = null;
|
|
437
|
+
};
|
|
438
|
+
const settle = (): void => {
|
|
439
|
+
stop();
|
|
440
|
+
tail();
|
|
441
|
+
};
|
|
442
|
+
const onEnd = (ev: TransitionEvent): void => {
|
|
443
|
+
if (ev.target === shellRoot && ev.propertyName === "grid-template-columns") {
|
|
444
|
+
settle();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
shellRoot.addEventListener("transitionend", onEnd);
|
|
448
|
+
// An engine that applies a track-list change at once fires no transitionend.
|
|
449
|
+
const timer = win.setTimeout(settle, durationMs);
|
|
450
|
+
closing = { toward, settle, abort: stop };
|
|
451
|
+
}
|
|
452
|
+
function fireSplitChange(): void {
|
|
453
|
+
if (changeListeners.size === 0) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const state = splitState();
|
|
457
|
+
for (const cb of [...changeListeners]) {
|
|
458
|
+
cb(state);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Selection never rests on an empty, failed or hidden pane, enforced by `inert`
|
|
462
|
+
// on such a pane root and by the refusal in `select`; an invalid value names no
|
|
463
|
+
// pane. The Tab stop is decided here too, because `inert` alone would leave an
|
|
464
|
+
// empty pane's input reachable on an engine without it.
|
|
465
|
+
function syncFocusReach(slot: Slot): void {
|
|
466
|
+
if (!splitEnabled || slot.kernel === null) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const state = slot.kernel.state();
|
|
470
|
+
slot.root.toggleAttribute(
|
|
471
|
+
"inert",
|
|
472
|
+
state === "empty" ||
|
|
473
|
+
state === "hidden" ||
|
|
474
|
+
(collapsed && state === "shown" && slot.side !== selectedSide),
|
|
475
|
+
);
|
|
476
|
+
slot.kernel.setTabStop(splitOpen && state === "shown");
|
|
477
|
+
}
|
|
478
|
+
function syncSelection(): void {
|
|
479
|
+
for (const slot of slots) {
|
|
480
|
+
if (splitEnabled) {
|
|
481
|
+
const selected = slot.side === selectedSide;
|
|
482
|
+
slot.root.classList.toggle("wt-pane-selected", selected);
|
|
483
|
+
slot.tablist?.setDescription(
|
|
484
|
+
splitOpen
|
|
485
|
+
? `${slot.side === "left" ? "Left" : "Right"} terminal${selected ? ", selected" : ""}`
|
|
486
|
+
: "",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
syncFocusReach(slot);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const anyShown = (): boolean => builtPanes().some((p) => p.state() === "shown");
|
|
493
|
+
/** Move selection to `side`, whatever that pane's state: the one transition
|
|
494
|
+
* every indicator, listener and the record follow. */
|
|
495
|
+
function applySelection(side: PaneSide): void {
|
|
496
|
+
if (side === selectedSide) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
selectedSide = side;
|
|
500
|
+
paintTitle();
|
|
501
|
+
syncSelection();
|
|
502
|
+
announcer?.announce(side === "left" ? "Left terminal selected" : "Right terminal selected");
|
|
503
|
+
for (const cb of [...selectionListeners]) {
|
|
504
|
+
cb(side);
|
|
505
|
+
}
|
|
506
|
+
fireSplitChange();
|
|
507
|
+
}
|
|
508
|
+
// While no pane shows a tab (both emptied, a replacement on its way), selection
|
|
509
|
+
// may rest on the empty pane the replacement is headed for, so the handle faces
|
|
510
|
+
// where the new tab will land.
|
|
511
|
+
function select(side: PaneSide): boolean {
|
|
512
|
+
if (!isPaneSide(side)) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
const state = paneAt(side)?.state();
|
|
516
|
+
if (state !== "shown" && !(state === "empty" && !anyShown())) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
applySelection(side);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
function targetFor(): PaneSide {
|
|
523
|
+
for (const p of builtPanes()) {
|
|
524
|
+
if (p.state() === "empty") {
|
|
525
|
+
return p.side;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return selectedSide;
|
|
529
|
+
}
|
|
530
|
+
// A focused element that leaves the layout drops focus to the body, so the
|
|
531
|
+
// survivor takes it first.
|
|
532
|
+
function keepFocusOn(survivor: Slot, leaving: (el: Element) => boolean): void {
|
|
533
|
+
const active = doc.activeElement;
|
|
534
|
+
if (active !== null && leaving(active)) {
|
|
535
|
+
survivor.kernel?.focus();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Only the handle and the hidden or new root move: a live pane moved in the
|
|
539
|
+
// DOM would lose focus and scroll state, and an empty hidden one has neither.
|
|
540
|
+
function placeAfter(first: Slot, second: Slot): void {
|
|
541
|
+
first.root.after(...(handle === null ? [] : [handle.element]), second.root);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Every store handed out, held WEAKLY and keyed by session, so a closed tab's
|
|
545
|
+
* store stays collectable. */
|
|
546
|
+
const knownStores = new Map<string, WeakRef<LineStore>>();
|
|
547
|
+
/** Restored scrollbacks not yet confirmed by a resume for THAT session. */
|
|
548
|
+
const unverifiedRestores = new Set<string>();
|
|
549
|
+
function liveStores(): LineStore[] {
|
|
550
|
+
const out: LineStore[] = [];
|
|
551
|
+
for (const [key, ref] of knownStores) {
|
|
552
|
+
const store = ref.deref();
|
|
553
|
+
if (store === undefined) {
|
|
554
|
+
knownStores.delete(key);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
out.push(store);
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
/** The server epoch a pane knew for a session it forgot while the tab lives on
|
|
562
|
+
* (a snap, a close of the split): the next pane to show it is seeded from here,
|
|
563
|
+
* and a save filed after the pane is gone still finds it. */
|
|
564
|
+
const knownEpoch = new Map<string, number>();
|
|
565
|
+
const keeper =
|
|
566
|
+
opts.persistScrollback !== undefined
|
|
567
|
+
? createScrollbackKeeper(
|
|
568
|
+
opts.persistScrollback,
|
|
569
|
+
scrollbackLines,
|
|
570
|
+
{
|
|
571
|
+
adoptPersistedEpoch(sessionId, epoch) {
|
|
572
|
+
for (const c of connections()) {
|
|
573
|
+
c.adoptPersistedEpoch(sessionId, epoch);
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
serverEpochOf(sessionId) {
|
|
577
|
+
for (const c of connections()) {
|
|
578
|
+
const epoch = c.serverEpochOf(sessionId);
|
|
579
|
+
if (epoch !== 0) {
|
|
580
|
+
return epoch;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return knownEpoch.get(sessionId) ?? 0;
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
win,
|
|
587
|
+
)
|
|
588
|
+
: null;
|
|
589
|
+
held.push(() => {
|
|
590
|
+
keeper?.stop();
|
|
591
|
+
knownStores.clear();
|
|
592
|
+
unverifiedRestores.clear();
|
|
593
|
+
knownEpoch.clear();
|
|
594
|
+
});
|
|
595
|
+
const stores: StoreRegistry = {
|
|
596
|
+
persisting: keeper !== null,
|
|
597
|
+
newLineStore(sessionId) {
|
|
598
|
+
const track = (store: LineStore, key: string): LineStore => {
|
|
599
|
+
knownStores.set(key, new WeakRef(store));
|
|
600
|
+
return store;
|
|
601
|
+
};
|
|
602
|
+
if (keeper !== null) {
|
|
603
|
+
if (sessionId !== undefined) {
|
|
604
|
+
const store = keeper.storeFor(sessionId);
|
|
605
|
+
if (store.highestIndex() >= 0) {
|
|
606
|
+
unverifiedRestores.add(sessionId);
|
|
607
|
+
}
|
|
608
|
+
return track(store, sessionId);
|
|
609
|
+
}
|
|
610
|
+
keeper.noteMissingSessionId();
|
|
611
|
+
}
|
|
612
|
+
return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
|
|
613
|
+
},
|
|
614
|
+
implicitStore(sessionId, current) {
|
|
615
|
+
if (keeper === null) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const restored = keeper.storeFor(sessionId);
|
|
619
|
+
if (restored.highestIndex() >= 0) {
|
|
620
|
+
knownStores.set(sessionId, new WeakRef(restored));
|
|
621
|
+
unverifiedRestores.add(sessionId);
|
|
622
|
+
return restored;
|
|
623
|
+
}
|
|
624
|
+
keeper.track(sessionId, current);
|
|
625
|
+
return null;
|
|
626
|
+
},
|
|
627
|
+
verified(sessionId) {
|
|
628
|
+
unverifiedRestores.delete(sessionId);
|
|
629
|
+
},
|
|
630
|
+
discardUnverified(scope, caller) {
|
|
631
|
+
const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
|
|
632
|
+
for (const id of ids) {
|
|
633
|
+
if (!unverifiedRestores.delete(id)) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const store = knownStores.get(id)?.deref();
|
|
637
|
+
const holder =
|
|
638
|
+
store === undefined
|
|
639
|
+
? paneAt(caller)
|
|
640
|
+
: builtPanes().find((p) => p.render.boundStore() === store);
|
|
641
|
+
if (holder !== undefined) {
|
|
642
|
+
holder.reset();
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
store?.reset();
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
backgroundStores(bound) {
|
|
649
|
+
return liveStores().filter((store) => store !== bound);
|
|
650
|
+
},
|
|
651
|
+
flush() {
|
|
652
|
+
keeper?.flush();
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
function dropSession(id: string): void {
|
|
656
|
+
for (const c of connections()) {
|
|
657
|
+
c.forgetSession(id);
|
|
658
|
+
}
|
|
659
|
+
knownEpoch.delete(id);
|
|
660
|
+
keeper?.forget(id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const shellKeydown: ((ev: KeyboardEvent) => boolean)[] = [];
|
|
664
|
+
const errorHandlers = new Set<(feature: string, err: unknown) => void>();
|
|
665
|
+
const paneRegistrations = new Set<PaneRegistration>();
|
|
666
|
+
held.push(() => {
|
|
667
|
+
shellKeydown.length = 0;
|
|
668
|
+
errorHandlers.clear();
|
|
669
|
+
selectionListeners.clear();
|
|
670
|
+
panesListeners.clear();
|
|
671
|
+
changeListeners.clear();
|
|
672
|
+
paneRegistrations.clear();
|
|
673
|
+
});
|
|
674
|
+
function reportError(feature: string, err: unknown): void {
|
|
675
|
+
if (errorHandlers.size === 0) {
|
|
676
|
+
console.error(`web-terminal-ui: feature "${feature}" error`, err);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
for (const fn of [...errorHandlers]) {
|
|
680
|
+
try {
|
|
681
|
+
fn(feature, err);
|
|
682
|
+
} catch (handlerErr) {
|
|
683
|
+
// A reporting failure must not turn the feature failure into an
|
|
684
|
+
// unhandled rejection or stop fatal-startup cleanup.
|
|
685
|
+
console.error("web-terminal-ui: feature error handler failed", handlerErr);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function onError(fn: (feature: string, err: unknown) => void): Unsubscribe {
|
|
690
|
+
errorHandlers.add(fn);
|
|
691
|
+
return () => errorHandlers.delete(fn);
|
|
692
|
+
}
|
|
693
|
+
function onEveryPane(register: (p: PaneKernel) => Unsubscribe): Unsubscribe {
|
|
694
|
+
const reg: PaneRegistration = { register, offs: new Map() };
|
|
695
|
+
for (const p of builtPanes()) {
|
|
696
|
+
reg.offs.set(p, register(p));
|
|
697
|
+
}
|
|
698
|
+
paneRegistrations.add(reg);
|
|
699
|
+
return () => {
|
|
700
|
+
paneRegistrations.delete(reg);
|
|
701
|
+
for (const off of reg.offs.values()) {
|
|
702
|
+
off();
|
|
703
|
+
}
|
|
704
|
+
reg.offs.clear();
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const split: SplitController = splitEnabled
|
|
709
|
+
? {
|
|
710
|
+
enabled: true,
|
|
711
|
+
state: splitState,
|
|
712
|
+
isOpen: () => splitOpen,
|
|
713
|
+
canOpen: () => !splitOpen && !destroyed && rowWidth() >= MIN_SPLIT_AREA_PX,
|
|
714
|
+
open: openSplit,
|
|
715
|
+
close: closeSplit,
|
|
716
|
+
closeSide,
|
|
717
|
+
setRatio,
|
|
718
|
+
onChange(cb) {
|
|
719
|
+
changeListeners.add(cb);
|
|
720
|
+
return () => changeListeners.delete(cb);
|
|
721
|
+
},
|
|
722
|
+
}
|
|
723
|
+
: disabledSplit();
|
|
724
|
+
const shellContext: ShellContext = {
|
|
725
|
+
root: shellRoot,
|
|
726
|
+
pane: (side) => paneAt(side) ?? null,
|
|
727
|
+
panes: builtPanes,
|
|
728
|
+
selected: () => selectedSide,
|
|
729
|
+
select,
|
|
730
|
+
onSelectionChange(cb) {
|
|
731
|
+
selectionListeners.add(cb);
|
|
732
|
+
return () => selectionListeners.delete(cb);
|
|
733
|
+
},
|
|
734
|
+
onPanesChange(cb) {
|
|
735
|
+
panesListeners.add(cb);
|
|
736
|
+
return () => panesListeners.delete(cb);
|
|
737
|
+
},
|
|
738
|
+
targetFor,
|
|
739
|
+
dropSessionExcept(sessionId, side) {
|
|
740
|
+
for (const slot of slots) {
|
|
741
|
+
if (slot.side !== side) {
|
|
742
|
+
slot.connection?.forgetSession(sessionId);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
knownEpoch.delete(sessionId);
|
|
746
|
+
keeper?.forget(sessionId);
|
|
747
|
+
},
|
|
748
|
+
attention,
|
|
749
|
+
subscribeStatus: (path, callbacks) => statusShare.subscribe(path, callbacks),
|
|
750
|
+
notifications,
|
|
751
|
+
split,
|
|
752
|
+
restoreSplit: (committed, selected) =>
|
|
753
|
+
splitEnabled && isPaneSide(selected) && openSplitAt(committed, selected),
|
|
754
|
+
};
|
|
755
|
+
// One row controller over every pane's tabpanel: a chip names the panel of the
|
|
756
|
+
// pane showing it, or the one a click would fill.
|
|
757
|
+
const rowTablist = createTablist(
|
|
758
|
+
(side) => slotAt(side ?? targetFor())?.tablist ?? slots[0]?.tablist ?? null,
|
|
759
|
+
);
|
|
760
|
+
const handle: SplitHandle | null = splitEnabled ? createSplitHandle(shellContext) : null;
|
|
761
|
+
if (handle !== null) {
|
|
762
|
+
held.push(() => {
|
|
763
|
+
handle.dispose();
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const isHandle = (el: Element): boolean => el === handle?.element;
|
|
767
|
+
|
|
768
|
+
function servicesFor(slot: Slot): PaneServices {
|
|
769
|
+
return {
|
|
770
|
+
get side() {
|
|
771
|
+
return slot.side;
|
|
772
|
+
},
|
|
773
|
+
shell: shellContext,
|
|
774
|
+
sessionIdKey: SESSION_ID_KEYS[slot.index],
|
|
775
|
+
managed,
|
|
776
|
+
scrollbackLines,
|
|
777
|
+
narrowProbe: () => isNarrow(shellRoot.clientWidth, slot.root.clientHeight),
|
|
778
|
+
titleBase(text) {
|
|
779
|
+
slot.baseTitle = text;
|
|
780
|
+
paintTitle();
|
|
781
|
+
},
|
|
782
|
+
loading: {
|
|
783
|
+
reason: (message) => {
|
|
784
|
+
loadingStatus.reason(message);
|
|
785
|
+
},
|
|
786
|
+
firstFrame: dismissLoadingOverlay,
|
|
787
|
+
failed() {
|
|
788
|
+
slot.loadingFailed = true;
|
|
789
|
+
settleLoading();
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
onSessionEnded() {
|
|
793
|
+
// The host's turn is LAST and a throwing handler cannot take the banner
|
|
794
|
+
// down with it.
|
|
795
|
+
if (opts.onSessionEnded !== undefined) {
|
|
796
|
+
try {
|
|
797
|
+
opts.onSessionEnded();
|
|
798
|
+
} catch (err) {
|
|
799
|
+
console.error("web-terminal-ui: onSessionEnded handler failed", err);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
registerTablist(controller) {
|
|
804
|
+
slot.tablist = controller;
|
|
805
|
+
},
|
|
806
|
+
tablist: () => rowTablist,
|
|
807
|
+
onKeydown(ev) {
|
|
808
|
+
for (const h of shellKeydown) {
|
|
809
|
+
if (h(ev)) {
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return false;
|
|
814
|
+
},
|
|
815
|
+
onPointerDown() {
|
|
816
|
+
select(slot.side);
|
|
817
|
+
},
|
|
818
|
+
onFocusIn() {
|
|
819
|
+
// An engine without `inert` still delivers focus into an empty pane.
|
|
820
|
+
if (!select(slot.side)) {
|
|
821
|
+
const selected = paneAt(selectedSide);
|
|
822
|
+
if (selected !== undefined && selected !== slot.kernel) {
|
|
823
|
+
selected.focus();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
onSessionChange() {
|
|
828
|
+
syncFocusReach(slot);
|
|
829
|
+
settleLoading();
|
|
830
|
+
},
|
|
831
|
+
noteEpoch(sessionId, epoch) {
|
|
832
|
+
if (epoch !== 0) {
|
|
833
|
+
knownEpoch.set(sessionId, epoch);
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
knownEpoch: (sessionId) => knownEpoch.get(sessionId) ?? 0,
|
|
837
|
+
registerConnection(connection) {
|
|
838
|
+
slot.connection = connection;
|
|
839
|
+
},
|
|
840
|
+
stores,
|
|
841
|
+
dropSession,
|
|
842
|
+
reportError,
|
|
843
|
+
onError,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function newSlot(index: 0 | 1, side: PaneSide, slotRoot: HTMLElement): Slot {
|
|
847
|
+
const slot: Slot = {
|
|
848
|
+
index,
|
|
849
|
+
side,
|
|
850
|
+
root: slotRoot,
|
|
851
|
+
kernel: null,
|
|
852
|
+
failed: false,
|
|
853
|
+
loadingFailed: false,
|
|
854
|
+
baseTitle: servedTitle,
|
|
855
|
+
connection: null,
|
|
856
|
+
tablist: null,
|
|
857
|
+
};
|
|
858
|
+
if (splitEnabled) {
|
|
859
|
+
slotRoot.classList.add("wt-split-pane");
|
|
860
|
+
setSide(slot, side);
|
|
861
|
+
}
|
|
862
|
+
return slot;
|
|
863
|
+
}
|
|
864
|
+
function buildKernel(slot: Slot): PaneKernel {
|
|
865
|
+
const kernel = buildPane(slot.root, paneOpts, servicesFor(slot));
|
|
866
|
+
slot.kernel = kernel;
|
|
867
|
+
held.push(() => {
|
|
868
|
+
kernel.cleanupRuntime();
|
|
869
|
+
});
|
|
870
|
+
for (const reg of paneRegistrations) {
|
|
871
|
+
reg.offs.set(kernel, reg.register(kernel));
|
|
872
|
+
}
|
|
873
|
+
return kernel;
|
|
874
|
+
}
|
|
875
|
+
// A pane leaving the layout with the only shown tab hands it to an empty live
|
|
876
|
+
// survivor through the layout owner, so the terminal still shows one tab.
|
|
877
|
+
function rehomeShownTab(leaving: Slot, survivor: Slot): void {
|
|
878
|
+
const onlyShown = leaving.kernel?.session.id ?? null;
|
|
879
|
+
if (onlyShown !== null && survivor.kernel?.state() === "empty") {
|
|
880
|
+
owner?.paneLayoutOwner?.showIn(survivor.side, onlyShown);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** A second pane that fails while the split is closed has no place on screen,
|
|
884
|
+
* so it is discarded at once and the next open builds a fresh one. When the
|
|
885
|
+
* close kept the failing pane as the single view, the healthy hidden pane
|
|
886
|
+
* returns as the selected single view and takes the shown tab first. */
|
|
887
|
+
function discardClosedPane(slot: Slot, survivor: Slot): void {
|
|
888
|
+
closing?.settle();
|
|
889
|
+
if (survivor.kernel?.state() === "hidden") {
|
|
890
|
+
survivor.root.classList.remove("wt-pane-hidden");
|
|
891
|
+
survivor.kernel.setHidden(false);
|
|
892
|
+
setSide(survivor, "left");
|
|
893
|
+
setSide(slot, "right");
|
|
894
|
+
selectedSide = "left";
|
|
895
|
+
rehomeShownTab(slot, survivor);
|
|
896
|
+
}
|
|
897
|
+
keepFocusOn(survivor, (el) => slot.root.contains(el));
|
|
898
|
+
teardownSlot(slot);
|
|
899
|
+
paintTitle();
|
|
900
|
+
syncSelection();
|
|
901
|
+
}
|
|
902
|
+
/** The second pane's startup failure, which a pane feature can deliver after
|
|
903
|
+
* the pane was shown and selected: the tab must not go down with the kernel,
|
|
904
|
+
* nor typing keep routing to it. While the split is open the pane's root stays
|
|
905
|
+
* in the grid as the recovery surface and the terminal keeps running on the
|
|
906
|
+
* first pane. */
|
|
907
|
+
function failPane(slot: Slot, failure: PaneFailure): void {
|
|
908
|
+
if (destroyed) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
slot.failed = true;
|
|
912
|
+
const survivor = slots.find((s) => s !== slot);
|
|
913
|
+
const discarded = !splitOpen && survivor !== undefined;
|
|
914
|
+
if (discarded) {
|
|
915
|
+
discardClosedPane(slot, survivor);
|
|
916
|
+
} else {
|
|
917
|
+
if (survivor !== undefined) {
|
|
918
|
+
rehomeShownTab(slot, survivor);
|
|
919
|
+
}
|
|
920
|
+
slot.kernel?.markFailed();
|
|
921
|
+
slot.kernel?.cleanupRuntime();
|
|
922
|
+
// A build that threw before stamping them still needs the panel's style scope.
|
|
923
|
+
slot.root.classList.add("wt-root", "wt-container");
|
|
924
|
+
slot.root.removeAttribute("inert");
|
|
925
|
+
if (slot.side === selectedSide && survivor !== undefined) {
|
|
926
|
+
applySelection(survivor.side);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
slot.loadingFailed = true;
|
|
930
|
+
settleLoading();
|
|
931
|
+
const surface = discarded ? undefined : slot.root;
|
|
932
|
+
const report: TerminalStartupFailure =
|
|
933
|
+
failure.phase === "kernel-init"
|
|
934
|
+
? { phase: "kernel-init", cause: failure.cause, surface }
|
|
935
|
+
: { phase: "feature-setup", feature: failure.feature, cause: failure.cause, surface };
|
|
936
|
+
let handled = false;
|
|
937
|
+
try {
|
|
938
|
+
handled = opts.onFatalError?.(report) === true;
|
|
939
|
+
} catch (handlerErr) {
|
|
940
|
+
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
941
|
+
}
|
|
942
|
+
// Non-modal: a top-layer dialog would make the tab row inert and the shared
|
|
943
|
+
// split button, this pane's one close path, unreachable.
|
|
944
|
+
if (!handled && !discarded && !isDestroyed()) {
|
|
945
|
+
renderFatalStartupInto(slot.root, { modal: false, idSuffix: "-2" });
|
|
946
|
+
}
|
|
947
|
+
firePanesChange();
|
|
948
|
+
}
|
|
949
|
+
async function setupPaneFeatures(
|
|
950
|
+
slot: Slot,
|
|
951
|
+
features: readonly TerminalFeature<unknown>[],
|
|
952
|
+
): Promise<void> {
|
|
953
|
+
const kernel = slot.kernel;
|
|
954
|
+
if (kernel === null) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
for (const feature of features) {
|
|
958
|
+
if (destroyed || kernel.isDestroyed()) {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const outcome = await kernel.setupFeature(feature);
|
|
962
|
+
if (outcome.status === "failed") {
|
|
963
|
+
failPane(slot, { phase: "feature-setup", feature: outcome.feature, cause: outcome.cause });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (outcome.status === "aborted") {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function buildSecondary(slot: Slot): void {
|
|
972
|
+
let paneFeatures: TerminalFeature<unknown>[];
|
|
973
|
+
try {
|
|
974
|
+
paneFeatures = resolveFeatures(opts.features).filter((f) => f.scope !== "shell");
|
|
975
|
+
for (const f of paneFeatures) {
|
|
976
|
+
if (usedPaneFeatures.has(f)) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
`web-terminal-ui: features() must return new feature objects for each pane; ${f.name} was already used by another pane`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
for (const f of paneFeatures) {
|
|
983
|
+
usedPaneFeatures.add(f);
|
|
984
|
+
}
|
|
985
|
+
buildKernel(slot);
|
|
986
|
+
} catch (cause) {
|
|
987
|
+
failPane(slot, { phase: "kernel-init", cause });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
void setupPaneFeatures(slot, paneFeatures);
|
|
991
|
+
}
|
|
992
|
+
function teardownSlot(slot: Slot): void {
|
|
993
|
+
const kernel = slot.kernel;
|
|
994
|
+
if (kernel !== null) {
|
|
995
|
+
for (const reg of paneRegistrations) {
|
|
996
|
+
reg.offs.get(kernel)?.();
|
|
997
|
+
reg.offs.delete(kernel);
|
|
998
|
+
}
|
|
999
|
+
kernel.destroy();
|
|
1000
|
+
}
|
|
1001
|
+
slot.root.remove();
|
|
1002
|
+
const i = slots.indexOf(slot);
|
|
1003
|
+
if (i >= 0) {
|
|
1004
|
+
slots.splice(i, 1);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const primary = newSlot(0, "left", splitEnabled ? doc.createElement("div") : root);
|
|
1009
|
+
slots.push(primary);
|
|
1010
|
+
if (handle !== null) {
|
|
1011
|
+
shellRoot.prepend(primary.root, handle.element);
|
|
1012
|
+
}
|
|
1013
|
+
const primaryKernel = buildKernel(primary);
|
|
1014
|
+
syncSelection();
|
|
1015
|
+
|
|
1016
|
+
function openSplit(): boolean {
|
|
1017
|
+
return openSplitAt(0.5, "left");
|
|
1018
|
+
}
|
|
1019
|
+
function openSplitAt(ratio: number, selected: PaneSide): boolean {
|
|
1020
|
+
if (splitOpen || destroyed || !isRatio(ratio)) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
closing?.settle();
|
|
1024
|
+
const showing = slotAt("left") ?? primary;
|
|
1025
|
+
let other = slots.find((s) => s !== showing);
|
|
1026
|
+
if (other === undefined) {
|
|
1027
|
+
other = newSlot(1, "right", doc.createElement("div"));
|
|
1028
|
+
slots.push(other);
|
|
1029
|
+
}
|
|
1030
|
+
setSide(showing, "left");
|
|
1031
|
+
setSide(other, "right");
|
|
1032
|
+
selectedSide = "left";
|
|
1033
|
+
placeAfter(showing, other);
|
|
1034
|
+
other.root.classList.remove("wt-pane-hidden");
|
|
1035
|
+
other.kernel?.setHidden(false);
|
|
1036
|
+
splitOpen = true;
|
|
1037
|
+
collapsed = rowWidth() < MIN_SPLIT_AREA_PX;
|
|
1038
|
+
committedRatio = ratio;
|
|
1039
|
+
previewRatio = null;
|
|
1040
|
+
shellRoot.classList.add("wt-split-open");
|
|
1041
|
+
shellRoot.classList.toggle("wt-split-collapsed", collapsed);
|
|
1042
|
+
applyRatioVar();
|
|
1043
|
+
if (other.kernel === null && !other.failed) {
|
|
1044
|
+
buildSecondary(other);
|
|
1045
|
+
}
|
|
1046
|
+
// A restored selection is part of the open, not a change to announce; a
|
|
1047
|
+
// side whose pane failed to build holds nothing to select.
|
|
1048
|
+
if (slotAt(selected)?.failed !== true) {
|
|
1049
|
+
selectedSide = selected;
|
|
1050
|
+
}
|
|
1051
|
+
paintTitle();
|
|
1052
|
+
syncSelection();
|
|
1053
|
+
for (const p of builtPanes()) {
|
|
1054
|
+
p.announceSize();
|
|
1055
|
+
}
|
|
1056
|
+
announcer?.announce("Split open");
|
|
1057
|
+
firePanesChange();
|
|
1058
|
+
fireSplitChange();
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
/** `survivor` fills the view; `leaving` is emptied and hidden, or torn down when
|
|
1062
|
+
* its startup failed so the next open builds a fresh one. The model closes at
|
|
1063
|
+
* once; with `wt-animate` the columns slide first and the display follows. */
|
|
1064
|
+
function finishClose(survivor: Slot, leaving: Slot): void {
|
|
1065
|
+
keepFocusOn(survivor, (el) => leaving.root.contains(el) || isHandle(el));
|
|
1066
|
+
leaving.kernel?.clearActiveSession();
|
|
1067
|
+
leaving.kernel?.setHidden(true);
|
|
1068
|
+
const durationMs = leaving.failed || collapsed ? 0 : closingDurationMs();
|
|
1069
|
+
const tail = (): void => {
|
|
1070
|
+
if (leaving.failed) {
|
|
1071
|
+
teardownSlot(leaving);
|
|
1072
|
+
} else {
|
|
1073
|
+
leaving.root.classList.add("wt-pane-hidden");
|
|
1074
|
+
placeAfter(survivor, leaving);
|
|
1075
|
+
}
|
|
1076
|
+
for (const slot of slots) {
|
|
1077
|
+
paintSide(slot);
|
|
1078
|
+
}
|
|
1079
|
+
shellRoot.classList.remove("wt-split-open", "wt-split-closing");
|
|
1080
|
+
applyRatioVar();
|
|
1081
|
+
survivor.kernel?.announceSize();
|
|
1082
|
+
};
|
|
1083
|
+
const animated = durationMs > 0;
|
|
1084
|
+
if (animated) {
|
|
1085
|
+
beginClosing(survivor.side === "left" ? 1 : 0, durationMs, tail);
|
|
1086
|
+
}
|
|
1087
|
+
setSide(survivor, "left");
|
|
1088
|
+
setSide(leaving, "right");
|
|
1089
|
+
selectedSide = "left";
|
|
1090
|
+
splitOpen = false;
|
|
1091
|
+
collapsed = false;
|
|
1092
|
+
committedRatio = 0.5;
|
|
1093
|
+
previewRatio = null;
|
|
1094
|
+
shellRoot.classList.remove("wt-split-collapsed");
|
|
1095
|
+
applyRatioVar();
|
|
1096
|
+
paintTitle();
|
|
1097
|
+
syncSelection();
|
|
1098
|
+
if (!animated) {
|
|
1099
|
+
tail();
|
|
1100
|
+
}
|
|
1101
|
+
announcer?.announce("Split closed");
|
|
1102
|
+
firePanesChange();
|
|
1103
|
+
fireSplitChange();
|
|
1104
|
+
}
|
|
1105
|
+
function closeSplit(): boolean {
|
|
1106
|
+
if (!splitOpen || destroyed) {
|
|
1107
|
+
return false;
|
|
1108
|
+
}
|
|
1109
|
+
const survivor = slotAt(selectedSide) ?? primary;
|
|
1110
|
+
const leaving = slots.find((s) => s !== survivor);
|
|
1111
|
+
if (leaving === undefined) {
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
finishClose(survivor, leaving);
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
function closeSide(side: PaneSide): boolean {
|
|
1118
|
+
if (!isPaneSide(side) || !splitOpen || destroyed) {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
const squeezed = slotAt(side);
|
|
1122
|
+
const survivor = slots.find((s) => s !== squeezed);
|
|
1123
|
+
if (squeezed === undefined || survivor === undefined || survivor.failed) {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
rehomeShownTab(squeezed, survivor);
|
|
1127
|
+
finishClose(survivor, squeezed);
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
function setRatio(ratio: number, commit: boolean): boolean {
|
|
1131
|
+
if (!isRatio(ratio) || !splitOpen || collapsed || destroyed) {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
if (commit) {
|
|
1135
|
+
committedRatio = ratio;
|
|
1136
|
+
previewRatio = null;
|
|
1137
|
+
} else {
|
|
1138
|
+
previewRatio = ratio;
|
|
1139
|
+
}
|
|
1140
|
+
applyRatioVar();
|
|
1141
|
+
fireSplitChange();
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
function paintShellNarrow(): void {
|
|
1145
|
+
shellRoot.classList.toggle(
|
|
1146
|
+
"wt-narrow",
|
|
1147
|
+
isNarrow(shellRoot.clientWidth, shellRoot.clientHeight),
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
function onShellResize(): void {
|
|
1151
|
+
if (destroyed) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
paintShellNarrow();
|
|
1155
|
+
for (const p of builtPanes()) {
|
|
1156
|
+
p.updateNarrow();
|
|
1157
|
+
}
|
|
1158
|
+
if (!splitOpen) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const wasCollapsed = collapsed;
|
|
1162
|
+
collapsed = rowWidth() < MIN_SPLIT_AREA_PX;
|
|
1163
|
+
shellRoot.classList.toggle("wt-split-collapsed", collapsed);
|
|
1164
|
+
applyRatioVar();
|
|
1165
|
+
if (collapsed !== wasCollapsed) {
|
|
1166
|
+
const selected = slotAt(selectedSide);
|
|
1167
|
+
if (collapsed && selected !== undefined) {
|
|
1168
|
+
keepFocusOn(
|
|
1169
|
+
selected,
|
|
1170
|
+
(el) => isHandle(el) || slots.some((s) => s !== selected && s.root.contains(el)),
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
syncSelection();
|
|
1174
|
+
// A collapsed pane shares the visible cell, so the one size it sends is
|
|
1175
|
+
// the full width it will have if the split closes; a `display: none` pane
|
|
1176
|
+
// would send the minimum grid to a live PTY.
|
|
1177
|
+
for (const p of builtPanes()) {
|
|
1178
|
+
p.announceSize();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
fireSplitChange();
|
|
1182
|
+
}
|
|
1183
|
+
if (splitEnabled) {
|
|
1184
|
+
paintShellNarrow();
|
|
1185
|
+
const observer = new win.ResizeObserver(onShellResize);
|
|
1186
|
+
observer.observe(shellRoot);
|
|
1187
|
+
held.push(() => {
|
|
1188
|
+
observer.disconnect();
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const shellHost = createFeatureHost(reportError);
|
|
1193
|
+
held.push(() => {
|
|
1194
|
+
shellHost.teardownAll();
|
|
1195
|
+
});
|
|
1196
|
+
const renderFacade: TerminalContext["render"] = {
|
|
1197
|
+
setPredictedCursor: (row, col, active) => {
|
|
1198
|
+
selectedPane().render.setPredictedCursor(row, col, active);
|
|
1199
|
+
},
|
|
1200
|
+
getCursorPx: () => selectedPane().render.getCursorPx(),
|
|
1201
|
+
computeSize: () => selectedPane().render.computeSize(),
|
|
1202
|
+
bind: (store, o) => {
|
|
1203
|
+
selectedPane().render.bind(store, o);
|
|
1204
|
+
},
|
|
1205
|
+
captureViewMemory: () => selectedPane().render.captureViewMemory(),
|
|
1206
|
+
boundStore: () => selectedPane().render.boundStore(),
|
|
1207
|
+
getHighestIndex: () => selectedPane().render.getHighestIndex(),
|
|
1208
|
+
pendingRowCount: () => selectedPane().render.pendingRowCount(),
|
|
1209
|
+
};
|
|
1210
|
+
const scrollFacade: TerminalContext["scroll"] = {
|
|
1211
|
+
scrollToBottom: () => {
|
|
1212
|
+
selectedPane().scroll.scrollToBottom();
|
|
1213
|
+
},
|
|
1214
|
+
isUserScrolledUp: () => selectedPane().scroll.isUserScrolledUp(),
|
|
1215
|
+
currentScrollTop: () => selectedPane().scroll.currentScrollTop(),
|
|
1216
|
+
restoreView: (view) => {
|
|
1217
|
+
selectedPane().scroll.restoreView(view);
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
const modesFacade: TerminalContext["modes"] = {
|
|
1221
|
+
isBracketedPaste: () => selectedPane().modes.isBracketedPaste(),
|
|
1222
|
+
isApplicationCursor: () => selectedPane().modes.isApplicationCursor(),
|
|
1223
|
+
getMouseMode: () => selectedPane().modes.getMouseMode(),
|
|
1224
|
+
isMouseSGR: () => selectedPane().modes.isMouseSGR(),
|
|
1225
|
+
isMousePixels: () => selectedPane().modes.isMousePixels(),
|
|
1226
|
+
isFocusReporting: () => selectedPane().modes.isFocusReporting(),
|
|
1227
|
+
isApplicationKeypad: () => selectedPane().modes.isApplicationKeypad(),
|
|
1228
|
+
isReverseVideo: () => selectedPane().modes.isReverseVideo(),
|
|
1229
|
+
getKeyboardFlags: () => selectedPane().modes.getKeyboardFlags(),
|
|
1230
|
+
};
|
|
1231
|
+
const shellLayout = (): { narrow: boolean; coarse: boolean } => ({
|
|
1232
|
+
narrow: isNarrow(shellRoot.clientWidth, shellRoot.clientHeight),
|
|
1233
|
+
coarse: typeof win.matchMedia === "function" && win.matchMedia("(pointer: coarse)").matches,
|
|
1234
|
+
});
|
|
1235
|
+
/** The context a shell-scoped feature receives: every pane-facing member
|
|
1236
|
+
* resolves the SELECTED pane at call time. */
|
|
1237
|
+
function makeShellContext(featureName: string, scope: CleanupScope): TerminalContext {
|
|
1238
|
+
const track = (off: Unsubscribe): Unsubscribe => {
|
|
1239
|
+
scope.push(off);
|
|
1240
|
+
return off;
|
|
1241
|
+
};
|
|
1242
|
+
return {
|
|
1243
|
+
region: (name, slot) =>
|
|
1244
|
+
shellRegions === null ? primaryKernel.region(name, slot) : shellRegions.region(name, slot),
|
|
1245
|
+
surface: () => selectedPane().surface(),
|
|
1246
|
+
send: (bytes) => {
|
|
1247
|
+
selectedPane().send(bytes);
|
|
1248
|
+
},
|
|
1249
|
+
paste: (text) => {
|
|
1250
|
+
selectedPane().paste(text);
|
|
1251
|
+
},
|
|
1252
|
+
registerInputTransform: (fn) => track(onEveryPane((p) => p.addInputTransform(fn))),
|
|
1253
|
+
registerInputObserver: (fn) => track(onEveryPane((p) => p.addInputObserver(fn))),
|
|
1254
|
+
registerKeydown(fn) {
|
|
1255
|
+
shellKeydown.push(fn);
|
|
1256
|
+
return track(() => {
|
|
1257
|
+
const i = shellKeydown.indexOf(fn);
|
|
1258
|
+
if (i >= 0) {
|
|
1259
|
+
shellKeydown.splice(i, 1);
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
},
|
|
1263
|
+
render: renderFacade,
|
|
1264
|
+
scroll: scrollFacade,
|
|
1265
|
+
modes: modesFacade,
|
|
1266
|
+
session: {
|
|
1267
|
+
get id() {
|
|
1268
|
+
return selectedPane().session.id;
|
|
1269
|
+
},
|
|
1270
|
+
size: () => selectedPane().session.size(),
|
|
1271
|
+
highestIndex: () => selectedPane().session.highestIndex(),
|
|
1272
|
+
},
|
|
1273
|
+
shell: shellContext,
|
|
1274
|
+
on: <K extends keyof TerminalEvents>(e: K, fn: (p: TerminalEvents[K]) => void) =>
|
|
1275
|
+
track(onEveryPane((p) => p.busOn(featureName, e, fn))),
|
|
1276
|
+
defer(release) {
|
|
1277
|
+
scope.push(release);
|
|
1278
|
+
},
|
|
1279
|
+
use<A>(feature: TerminalFeature<A>): A | undefined {
|
|
1280
|
+
return shellHost.use(feature) as A | undefined;
|
|
1281
|
+
},
|
|
1282
|
+
toast: (message, ms) => {
|
|
1283
|
+
selectedPane().toast(message, ms);
|
|
1284
|
+
},
|
|
1285
|
+
announce: (message, politeness) => {
|
|
1286
|
+
selectedPane().announce(message, politeness);
|
|
1287
|
+
},
|
|
1288
|
+
loadingReason: (message) => {
|
|
1289
|
+
loadingStatus.reason(message);
|
|
1290
|
+
},
|
|
1291
|
+
tablist: () => rowTablist,
|
|
1292
|
+
newLineStore: (sessionId) => stores.newLineStore(sessionId),
|
|
1293
|
+
layout: shellLayout,
|
|
1294
|
+
notifySwitch(session) {
|
|
1295
|
+
// A session is never shown twice: a click on a shown tab selects its pane.
|
|
1296
|
+
const showing = builtPanes().find((p) => p.session.id === session.id);
|
|
1297
|
+
if (showing !== undefined) {
|
|
1298
|
+
select(showing.side);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const target = paneAt(targetFor()) ?? primaryKernel;
|
|
1302
|
+
target.notifySwitch(session);
|
|
1303
|
+
select(target.side);
|
|
1304
|
+
},
|
|
1305
|
+
dropSession,
|
|
1306
|
+
onError: (fn) => track(onError(fn)),
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
let rootReleased = false;
|
|
1311
|
+
function cleanupRuntime(): void {
|
|
1312
|
+
if (destroyed) {
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
destroyed = true;
|
|
1316
|
+
// The tracked stores are about to become unreachable, and the connections
|
|
1317
|
+
// whose epochs the save is filed under go with the drain.
|
|
1318
|
+
keeper?.flush();
|
|
1319
|
+
held.drain();
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function enterFatalStartup(feature: string, cause: unknown): void {
|
|
1323
|
+
if (destroyed) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
primaryKernel.markFailed();
|
|
1327
|
+
cleanupRuntime();
|
|
1328
|
+
dismissLoadingOverlay();
|
|
1329
|
+
let handled = false;
|
|
1330
|
+
try {
|
|
1331
|
+
handled =
|
|
1332
|
+
opts.onFatalError?.({ phase: "feature-setup", feature, cause, surface: root }) === true;
|
|
1333
|
+
} catch (handlerErr) {
|
|
1334
|
+
console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
|
|
1335
|
+
}
|
|
1336
|
+
// A handler may call destroy() while taking over.
|
|
1337
|
+
if (!handled && !rootReleased) {
|
|
1338
|
+
renderFatalStartupInto(root, { modal: layoutMode === "viewport" });
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Features set up in the background; first paint is never gated on them.
|
|
1343
|
+
async function setupAll(): Promise<FeatureSetupOutcome> {
|
|
1344
|
+
for (const feature of featureList) {
|
|
1345
|
+
if (destroyed) {
|
|
1346
|
+
return { status: "aborted" };
|
|
1347
|
+
}
|
|
1348
|
+
const outcome =
|
|
1349
|
+
feature.scope === "shell"
|
|
1350
|
+
? await shellHost.setup(
|
|
1351
|
+
feature,
|
|
1352
|
+
(scope) => makeShellContext(feature.name, scope),
|
|
1353
|
+
() => destroyed,
|
|
1354
|
+
)
|
|
1355
|
+
: await primaryKernel.setupFeature(feature);
|
|
1356
|
+
if (outcome.status !== "ready") {
|
|
1357
|
+
return outcome;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return { status: "ready" };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Under an owner the shell drives the first connect: a bare connect to the
|
|
1364
|
+
// wsPath would open a socket a session-gated server 404s. A null or false
|
|
1365
|
+
// resolution means the bootstrap failed; the owner keeps its retry chrome
|
|
1366
|
+
// alive and the overlay comes down so it is visible.
|
|
1367
|
+
void setupAll().then(async (outcome) => {
|
|
1368
|
+
if (outcome.status === "failed") {
|
|
1369
|
+
enterFatalStartup(outcome.feature, outcome.cause);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (outcome.status === "aborted" || owner === undefined || isDestroyed()) {
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
let shown = false;
|
|
1376
|
+
try {
|
|
1377
|
+
if (owner.sessionOwner !== undefined) {
|
|
1378
|
+
const resolved = await owner.sessionOwner.resolveInitialSession();
|
|
1379
|
+
if (resolved && !isDestroyed()) {
|
|
1380
|
+
primaryKernel.notifySwitch(resolved);
|
|
1381
|
+
shown = true;
|
|
1382
|
+
}
|
|
1383
|
+
} else if (owner.paneLayoutOwner !== undefined) {
|
|
1384
|
+
shown = await owner.paneLayoutOwner.resolveInitialLayout();
|
|
1385
|
+
}
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
reportError(owner.name, err);
|
|
1388
|
+
}
|
|
1389
|
+
if (isDestroyed()) {
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (!shown && !builtPanes().some((p) => p.connectionInitiated())) {
|
|
1393
|
+
dismissLoadingOverlay();
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
return {
|
|
1398
|
+
focus() {
|
|
1399
|
+
selectedPane().focus();
|
|
1400
|
+
},
|
|
1401
|
+
send(bytes) {
|
|
1402
|
+
if (destroyed) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
selectedPane().send(bytes);
|
|
1406
|
+
},
|
|
1407
|
+
reset() {
|
|
1408
|
+
if (destroyed) {
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
selectedPane().reset();
|
|
1412
|
+
},
|
|
1413
|
+
reattach() {
|
|
1414
|
+
if (destroyed) {
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
selectedPane().reattach();
|
|
1418
|
+
},
|
|
1419
|
+
destroy() {
|
|
1420
|
+
if (rootReleased) {
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
rootReleased = true;
|
|
1424
|
+
cleanupRuntime();
|
|
1425
|
+
for (const slot of slots) {
|
|
1426
|
+
slot.kernel?.destroy();
|
|
1427
|
+
}
|
|
1428
|
+
if (splitEnabled) {
|
|
1429
|
+
shellRoot.classList.remove("wt-root", "wt-split", "wt-viewport", "wt-container");
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1432
|
+
...(splitEnabled ? { split } : {}),
|
|
1433
|
+
};
|
|
1434
|
+
}
|