@cplieger/web-terminal-ui 8.5.0-dev.1 → 8.6.0-dev.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/package.json +2 -2
- package/src/features/tabs/index.ts +454 -144
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0-dev.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"eslint": "10.12.0",
|
|
63
63
|
"fast-check": "4.10.2",
|
|
64
64
|
"html-validate": "11.16.2",
|
|
65
|
-
"knip": "6.
|
|
65
|
+
"knip": "6.41.0",
|
|
66
66
|
"playwright": "1.64.0",
|
|
67
67
|
"prettier": "3.9.9",
|
|
68
68
|
"stylelint": "17.16.0",
|
|
@@ -20,7 +20,13 @@ import { snapToDevicePixels } from "./device-snap.js";
|
|
|
20
20
|
import { centreChipLabels } from "./ink-centre.js";
|
|
21
21
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
22
22
|
import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
formatTabRoute,
|
|
25
|
+
parseTabAlias,
|
|
26
|
+
parseTabRoute,
|
|
27
|
+
type TabAlias,
|
|
28
|
+
type TabRoute,
|
|
29
|
+
} from "./route.js";
|
|
24
30
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
25
31
|
import {
|
|
26
32
|
CUE_SEEN_KEY,
|
|
@@ -169,9 +175,10 @@ export interface TabsOptions {
|
|
|
169
175
|
attentionIcons?: boolean;
|
|
170
176
|
}
|
|
171
177
|
|
|
172
|
-
/**
|
|
173
|
-
* `Retry-After` and a body message while it
|
|
174
|
-
*
|
|
178
|
+
/** The layout read, the session list and session creation can be TEMPORARILY
|
|
179
|
+
* refused: a host answers 503 with `Retry-After` and a body message while it
|
|
180
|
+
* installs tools on first boot or restores its sessions, a window that can run
|
|
181
|
+
* twenty minutes. Only 503 retries, on the server's own
|
|
175
182
|
* schedule and repeating its own explanation. The bound is ELAPSED TIME, not an
|
|
176
183
|
* attempt count, which at `Retry-After: 5` would give up in a minute; every
|
|
177
184
|
* iteration sleeps at least the hint, so this is never a hot loop, and the user
|
|
@@ -228,20 +235,83 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
|
|
|
228
235
|
});
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
/** How long a run of refusals has waited and when it last told the user. */
|
|
239
|
+
interface RefusalWait {
|
|
240
|
+
readonly startedAt: number;
|
|
241
|
+
// Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
|
|
242
|
+
// machine refuses inside the first millisecond.
|
|
243
|
+
lastAnnouncedAt: number | null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Wait out one temporary refusal: a 503, on the server's own `Retry-After`,
|
|
247
|
+
* repeating its own explanation. False means give up: `err` is not a 503, or
|
|
248
|
+
* the run of refusals has spent `CREATE_RETRY_MAX_TOTAL_MS`. An abort ends the
|
|
249
|
+
* wait early; the caller checks its signal next. */
|
|
250
|
+
async function waitOutRefusal(
|
|
251
|
+
err: unknown,
|
|
252
|
+
wait: RefusalWait,
|
|
253
|
+
ctx: TerminalContext,
|
|
254
|
+
signal: AbortSignal,
|
|
255
|
+
): Promise<boolean> {
|
|
256
|
+
const win = windowOf(ctx.shell.root.ownerDocument);
|
|
257
|
+
const elapsed = win.Date.now() - wait.startedAt;
|
|
258
|
+
if (
|
|
259
|
+
!(err instanceof SessionAPIError) ||
|
|
260
|
+
err.status !== 503 ||
|
|
261
|
+
elapsed >= CREATE_RETRY_MAX_TOTAL_MS
|
|
262
|
+
) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
// "waiting", not "retrying": the server only said "not yet", and the first
|
|
266
|
+
// announcement fires before any retry has happened.
|
|
267
|
+
if (
|
|
268
|
+
wait.lastAnnouncedAt === null ||
|
|
269
|
+
elapsed - wait.lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS
|
|
270
|
+
) {
|
|
271
|
+
wait.lastAnnouncedAt = elapsed;
|
|
272
|
+
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
273
|
+
ctx.toast(`${reason}, waiting…`, 8000);
|
|
274
|
+
ctx.announce(`${reason}, waiting…`);
|
|
275
|
+
}
|
|
276
|
+
// Unthrottled: the overlay is the only surface visible before the first
|
|
277
|
+
// frame, and it replaces text in place.
|
|
278
|
+
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
279
|
+
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const isRefusal = (err: unknown): boolean => err instanceof SessionAPIError && err.status === 503;
|
|
284
|
+
|
|
285
|
+
interface SessionAPIClient {
|
|
286
|
+
list(): Promise<SessionInfo[]>;
|
|
287
|
+
getLayout(): Promise<PaneLayout | null>;
|
|
288
|
+
create(): Promise<SessionInfo>;
|
|
289
|
+
close(id: string): Promise<void>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Close a session a page created after it was torn down: nothing holds it. */
|
|
293
|
+
function closeOrphan(api: Pick<SessionAPIClient, "close">, id: string): void {
|
|
294
|
+
void api.close(id).catch((err: unknown) => {
|
|
295
|
+
console.warn(
|
|
296
|
+
`web-terminal-ui: session ${id} was created after teardown and could not be closed`,
|
|
297
|
+
err,
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
231
302
|
/** Create a session, honouring the server's retry schedule, for as long as
|
|
232
303
|
* `signal` stays live: an abort ends the wait and throws its reason, and a
|
|
233
304
|
* create that finishes after the abort is closed rather than returned, because
|
|
234
305
|
* the page that asked for it has no tab left to hold it. */
|
|
235
306
|
async function createSessionHonouringRetry(
|
|
236
|
-
api:
|
|
307
|
+
api: Pick<SessionAPIClient, "create" | "close">,
|
|
237
308
|
ctx: TerminalContext,
|
|
238
309
|
signal: AbortSignal,
|
|
239
310
|
): Promise<SessionInfo> {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let lastAnnouncedAt: number | null = null;
|
|
311
|
+
const wait: RefusalWait = {
|
|
312
|
+
startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
|
|
313
|
+
lastAnnouncedAt: null,
|
|
314
|
+
};
|
|
245
315
|
for (;;) {
|
|
246
316
|
signal.throwIfAborted();
|
|
247
317
|
let info: SessionInfo;
|
|
@@ -249,41 +319,131 @@ async function createSessionHonouringRetry(
|
|
|
249
319
|
info = await api.create();
|
|
250
320
|
} catch (err) {
|
|
251
321
|
signal.throwIfAborted();
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
!(err instanceof SessionAPIError) ||
|
|
255
|
-
err.status !== 503 ||
|
|
256
|
-
elapsed >= CREATE_RETRY_MAX_TOTAL_MS
|
|
257
|
-
) {
|
|
322
|
+
if (!(await waitOutRefusal(err, wait, ctx, signal))) {
|
|
258
323
|
throw err;
|
|
259
324
|
}
|
|
260
|
-
// "waiting", not "retrying": the server only said "not yet", and the first
|
|
261
|
-
// announcement fires before any retry has happened.
|
|
262
|
-
if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
263
|
-
lastAnnouncedAt = elapsed;
|
|
264
|
-
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
265
|
-
ctx.toast(`${reason}, waiting…`, 8000);
|
|
266
|
-
ctx.announce(`${reason}, waiting…`);
|
|
267
|
-
}
|
|
268
|
-
// Unthrottled: the overlay is the only surface visible before the first
|
|
269
|
-
// frame, and it replaces text in place.
|
|
270
|
-
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
271
|
-
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
272
325
|
continue;
|
|
273
326
|
}
|
|
274
327
|
if (signal.aborted) {
|
|
275
|
-
|
|
276
|
-
console.warn(
|
|
277
|
-
`web-terminal-ui: session ${info.id} was created after teardown and could not be closed`,
|
|
278
|
-
err,
|
|
279
|
-
);
|
|
280
|
-
});
|
|
328
|
+
closeOrphan(api, info.id);
|
|
281
329
|
signal.throwIfAborted();
|
|
282
330
|
}
|
|
283
331
|
return info;
|
|
284
332
|
}
|
|
285
333
|
}
|
|
286
334
|
|
|
335
|
+
/** What a settled server answered: its sessions, its layout record (null on a
|
|
336
|
+
* 404, an unreadable or a malformed record, each warned), and whether the list
|
|
337
|
+
* itself succeeded. */
|
|
338
|
+
interface Settled {
|
|
339
|
+
readonly sessions: SessionInfo[];
|
|
340
|
+
readonly layout: PaneLayout | null;
|
|
341
|
+
readonly listed: boolean;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const NOTHING_SETTLED: Settled = { sessions: [], layout: null, listed: false };
|
|
345
|
+
|
|
346
|
+
/** settleSessionSet's create failed for good; `settled` is what was listed. */
|
|
347
|
+
class SettleCreateFailed extends Error {
|
|
348
|
+
readonly settled: Settled;
|
|
349
|
+
constructor(cause: unknown, settled: Settled) {
|
|
350
|
+
super("web-terminal-ui: could not create a session", { cause });
|
|
351
|
+
this.name = "SettleCreateFailed";
|
|
352
|
+
this.settled = settled;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Read the layout, then list, then (when nothing live is listed) create one
|
|
357
|
+
* session, starting the turn again after any 503, so a
|
|
358
|
+
* create refused mid-restore never adds a tab beside the restored ones. A
|
|
359
|
+
* non-503 layout read proves the host was not restoring when it was served, so
|
|
360
|
+
* a list issued after it sees the whole restored set. Past
|
|
361
|
+
* `CREATE_RETRY_MAX_TOTAL_MS` a refused layout read or list returns the last
|
|
362
|
+
* good list, or rethrows the 503; a create that fails for good, a spent 503
|
|
363
|
+
* included, throws `SettleCreateFailed` carrying the list, an abort its
|
|
364
|
+
* reason. */
|
|
365
|
+
async function settleSessionSet(
|
|
366
|
+
api: SessionAPIClient,
|
|
367
|
+
ctx: TerminalContext,
|
|
368
|
+
signal: AbortSignal,
|
|
369
|
+
warnLayout: (what: string, err?: unknown) => void,
|
|
370
|
+
): Promise<Settled> {
|
|
371
|
+
const wait: RefusalWait = {
|
|
372
|
+
startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
|
|
373
|
+
lastAnnouncedAt: null,
|
|
374
|
+
};
|
|
375
|
+
let last: Settled | null = null;
|
|
376
|
+
// True to go round again; false once the refusals spent their budget.
|
|
377
|
+
const waited = async (err: unknown): Promise<boolean> => {
|
|
378
|
+
signal.throwIfAborted();
|
|
379
|
+
const again = await waitOutRefusal(err, wait, ctx, signal);
|
|
380
|
+
signal.throwIfAborted();
|
|
381
|
+
return again;
|
|
382
|
+
};
|
|
383
|
+
for (;;) {
|
|
384
|
+
signal.throwIfAborted();
|
|
385
|
+
let layout: PaneLayout | null = null;
|
|
386
|
+
try {
|
|
387
|
+
layout = await api.getLayout();
|
|
388
|
+
if (layout === null) {
|
|
389
|
+
warnLayout("this server has no GET/PUT /api/sessions/layout route");
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (isRefusal(err)) {
|
|
393
|
+
if (await waited(err)) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (last !== null) {
|
|
397
|
+
return last;
|
|
398
|
+
}
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
signal.throwIfAborted();
|
|
402
|
+
warnLayout("could not read the pane layout", err);
|
|
403
|
+
}
|
|
404
|
+
let sessions: SessionInfo[] = [];
|
|
405
|
+
let listed = false;
|
|
406
|
+
try {
|
|
407
|
+
sessions = await api.list();
|
|
408
|
+
listed = true;
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (isRefusal(err)) {
|
|
411
|
+
if (await waited(err)) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (last !== null) {
|
|
415
|
+
return last;
|
|
416
|
+
}
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
signal.throwIfAborted();
|
|
420
|
+
}
|
|
421
|
+
const settled: Settled = { sessions, layout, listed };
|
|
422
|
+
if (listed) {
|
|
423
|
+
last = settled;
|
|
424
|
+
}
|
|
425
|
+
// An ended session is viewable history, not a working terminal.
|
|
426
|
+
if (sessions.some((s) => !isEndedStatus(s.status))) {
|
|
427
|
+
return settled;
|
|
428
|
+
}
|
|
429
|
+
let info: SessionInfo;
|
|
430
|
+
try {
|
|
431
|
+
info = await api.create();
|
|
432
|
+
} catch (err) {
|
|
433
|
+
if (isRefusal(err) && (await waited(err))) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
signal.throwIfAborted();
|
|
437
|
+
throw new SettleCreateFailed(err, settled);
|
|
438
|
+
}
|
|
439
|
+
if (signal.aborted) {
|
|
440
|
+
closeOrphan(api, info.id);
|
|
441
|
+
signal.throwIfAborted();
|
|
442
|
+
}
|
|
443
|
+
return { sessions: [...sessions, info], layout, listed };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
287
447
|
interface ShowOptions {
|
|
288
448
|
readonly dir?: "next" | "prev" | undefined;
|
|
289
449
|
/** Leave the selection where it is, unless the selected pane shows nothing. */
|
|
@@ -787,6 +947,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
787
947
|
// Set by a bootstrap that showed nothing, cleared by the first showIn: the
|
|
788
948
|
// one case a status event may pick a tab to show.
|
|
789
949
|
let bootShowedNothing = false;
|
|
950
|
+
// While a reconcile that may resync onto a restarted server runs, a tab
|
|
951
|
+
// arriving on the stream is held rather than placed by arrival, which would
|
|
952
|
+
// beat the address and the record to the panes (withPlacementHold). A depth,
|
|
953
|
+
// because two stream reopens inside one long settle each hold.
|
|
954
|
+
let holdDepth = 0;
|
|
955
|
+
let heldPlacements: string[] = [];
|
|
956
|
+
// Whether a resync ran inside the current outermost hold; its release then
|
|
957
|
+
// places none of the held tabs, so a late arrival cannot replace a pane the
|
|
958
|
+
// address and the record restored.
|
|
959
|
+
let holdResynced = false;
|
|
960
|
+
// While a resync settles, the address bar is left alone so the dead tabs it
|
|
961
|
+
// drops write no history entries (scheduleAddress).
|
|
962
|
+
let settling = false;
|
|
790
963
|
// The coalesced layout write (scheduleLayoutWrite): one PUT per macrotask,
|
|
791
964
|
// one in flight at a time, and a change during the flight sends the latest
|
|
792
965
|
// state once it lands, so an older record can never become the server's.
|
|
@@ -1078,7 +1251,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1078
1251
|
const heldKeyboard = keyboardAtPressOf(rowClose);
|
|
1079
1252
|
rowClose.addEventListener("click", (e) => {
|
|
1080
1253
|
e.stopPropagation();
|
|
1081
|
-
void dropTab(t.id, true, heldKeyboard());
|
|
1254
|
+
void dropTab(t.id, { remote: true, keyboardHeld: heldKeyboard() });
|
|
1082
1255
|
});
|
|
1083
1256
|
return row;
|
|
1084
1257
|
}
|
|
@@ -1540,7 +1713,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1540
1713
|
const heldKeyboard = keyboardAtPressOf(close);
|
|
1541
1714
|
close.addEventListener("click", (e) => {
|
|
1542
1715
|
e.stopPropagation();
|
|
1543
|
-
void dropTab(tab.id, true, heldKeyboard());
|
|
1716
|
+
void dropTab(tab.id, { remote: true, keyboardHeld: heldKeyboard() });
|
|
1544
1717
|
});
|
|
1545
1718
|
// Middle-click closes the tab (#8). Suppress the middle-click default on
|
|
1546
1719
|
// mousedown so the browser's autoscroll/paste affordance does not fire.
|
|
@@ -1685,7 +1858,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1685
1858
|
ctx.shell.select(side);
|
|
1686
1859
|
}
|
|
1687
1860
|
scheduleLayoutWrite();
|
|
1688
|
-
|
|
1861
|
+
scheduleAddress("push");
|
|
1689
1862
|
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1690
1863
|
// paints from cache in one frame and must not flash a cue at every switch.
|
|
1691
1864
|
if (
|
|
@@ -2402,7 +2575,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2402
2575
|
// emptied is never refilled by a status event. A live tab outranks an ENDED
|
|
2403
2576
|
// one, which would wedge the page the same way.
|
|
2404
2577
|
function ensureActive(): void {
|
|
2405
|
-
if (!bootShowedNothing || anyShown()) {
|
|
2578
|
+
if (holdDepth > 0 || !bootShowedNothing || anyShown()) {
|
|
2406
2579
|
return;
|
|
2407
2580
|
}
|
|
2408
2581
|
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
@@ -2444,6 +2617,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2444
2617
|
// This client's own new session can reach the stream before its POST
|
|
2445
2618
|
// answers, so the placement waits until a create in flight has shown its tab.
|
|
2446
2619
|
function showCreatedElsewhere(id: string): void {
|
|
2620
|
+
if (holdDepth > 0) {
|
|
2621
|
+
heldPlacements.push(id);
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2447
2624
|
if (creating !== null) {
|
|
2448
2625
|
void creating.then(() => {
|
|
2449
2626
|
showCreatedElsewhere(id);
|
|
@@ -2467,11 +2644,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2467
2644
|
// the user rather than throwing. A 503 has already been retried on the
|
|
2468
2645
|
// server's own schedule by this point, so reaching here means it never
|
|
2469
2646
|
// became ready: say so with the server's own words when it gave any.
|
|
2470
|
-
|
|
2471
|
-
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2472
|
-
? `Could not open a terminal. ${err.serverMessage}`
|
|
2473
|
-
: "Could not open a terminal",
|
|
2474
|
-
);
|
|
2647
|
+
toastCreateFailure(err);
|
|
2475
2648
|
return;
|
|
2476
2649
|
}
|
|
2477
2650
|
// The status SSE may have adopted this session during the POST round-trip
|
|
@@ -2509,13 +2682,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2509
2682
|
}
|
|
2510
2683
|
|
|
2511
2684
|
// dropTab removes a tab's chrome + cache and, for a shown tab, decides what
|
|
2512
|
-
// its pane shows next. remote
|
|
2513
|
-
//
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
//
|
|
2517
|
-
|
|
2518
|
-
|
|
2685
|
+
// its pane shows next. remote also DELETEs the session (a user close);
|
|
2686
|
+
// without it the server already ended it. keyboardHeld: the terminal input
|
|
2687
|
+
// held the keyboard when the press on this tab's close button began, so
|
|
2688
|
+
// focus returns to it. quiet: the caller (a resync) places what the panes
|
|
2689
|
+
// show next, so no pane transaction or focus move runs.
|
|
2690
|
+
async function dropTab(
|
|
2691
|
+
id: string,
|
|
2692
|
+
opts: { remote: boolean; keyboardHeld?: boolean; quiet?: boolean },
|
|
2693
|
+
): Promise<void> {
|
|
2694
|
+
const { remote, keyboardHeld = false, quiet = false } = opts;
|
|
2519
2695
|
const idx = tabList.findIndex((t) => t.id === id);
|
|
2520
2696
|
if (idx < 0) {
|
|
2521
2697
|
return;
|
|
@@ -2530,7 +2706,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2530
2706
|
// A failed create adds nothing (and toasts): keep the existing tab
|
|
2531
2707
|
// rather than stranding the user on an empty strip.
|
|
2532
2708
|
if (tabList.some((t) => t.id !== id)) {
|
|
2533
|
-
await dropTab(id, true);
|
|
2709
|
+
await dropTab(id, { remote: true });
|
|
2534
2710
|
}
|
|
2535
2711
|
return;
|
|
2536
2712
|
}
|
|
@@ -2573,8 +2749,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2573
2749
|
tab.el.remove();
|
|
2574
2750
|
// Each connection forgets the session exactly once: the showing pane's
|
|
2575
2751
|
// through clearActiveSession (which closes its socket and empties the
|
|
2576
|
-
// pane), every other through dropSessionExcept.
|
|
2577
|
-
|
|
2752
|
+
// pane), every other through dropSessionExcept. A quiet drop empties a
|
|
2753
|
+
// single view's pane too, since no switch will replace what it shows.
|
|
2754
|
+
if (side !== null && (splitOpen || quiet)) {
|
|
2578
2755
|
ctx.shell.pane(side)?.clearActiveSession();
|
|
2579
2756
|
ctx.shell.dropSessionExcept(id, side);
|
|
2580
2757
|
} else {
|
|
@@ -2589,24 +2766,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2589
2766
|
syncChrome(); // reflect the drop immediately (count, position)
|
|
2590
2767
|
// What the panes show next is decided here, before the server's answer:
|
|
2591
2768
|
// selection must not sit on an emptied pane for a DELETE round trip.
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
} else if (side !== null) {
|
|
2597
|
-
if (splitOpen) {
|
|
2598
|
-
ctx.shell.split.close();
|
|
2599
|
-
}
|
|
2600
|
-
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2601
|
-
if (neighbor) {
|
|
2602
|
-
switchTo(neighbor.id);
|
|
2603
|
-
} else {
|
|
2604
|
-
replacement = create();
|
|
2769
|
+
if (!quiet) {
|
|
2770
|
+
placeAfterDrop(idx, side, splitOpen);
|
|
2771
|
+
if (typing || keyboardHeld) {
|
|
2772
|
+
focusInput();
|
|
2605
2773
|
}
|
|
2606
2774
|
}
|
|
2607
|
-
if (typing || keyboardHeld) {
|
|
2608
|
-
focusInput();
|
|
2609
|
-
}
|
|
2610
2775
|
if (remote) {
|
|
2611
2776
|
try {
|
|
2612
2777
|
await api.close(id);
|
|
@@ -2616,11 +2781,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2616
2781
|
}
|
|
2617
2782
|
}
|
|
2618
2783
|
}
|
|
2619
|
-
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// The pane transaction for a dropped tab that `side` showed (null: none).
|
|
2787
|
+
function placeAfterDrop(idx: number, side: PaneSide | null, splitOpen: boolean): void {
|
|
2788
|
+
if (side === null) {
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
if (splitOpen && shownIn(otherSide(side)) !== null) {
|
|
2792
|
+
ctx.shell.split.closeSide(side);
|
|
2793
|
+
focusAfterSwitch();
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (splitOpen) {
|
|
2797
|
+
ctx.shell.split.close();
|
|
2798
|
+
}
|
|
2799
|
+
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2800
|
+
if (neighbor) {
|
|
2801
|
+
switchTo(neighbor.id);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
// Only the server removes a last tab here (a user's close of it took the
|
|
2805
|
+
// create-first branch of dropTab). Its server either restarted or saw
|
|
2806
|
+
// another device replace it, so settle rather than create a second tab.
|
|
2807
|
+
// Not awaited: this may run inside the reconcile slot itself.
|
|
2808
|
+
void inReconcileSlot(() => resync([]));
|
|
2620
2809
|
}
|
|
2621
2810
|
|
|
2622
2811
|
async function close_(id: string): Promise<void> {
|
|
2623
|
-
await dropTab(id, true);
|
|
2812
|
+
await dropTab(id, { remote: true });
|
|
2624
2813
|
}
|
|
2625
2814
|
|
|
2626
2815
|
// Each tab is a running agent, so closing two or more asks first. This path
|
|
@@ -3108,7 +3297,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3108
3297
|
const alias = parseTabAlias(rec.alias) ?? undefined;
|
|
3109
3298
|
if (alias !== t.alias) {
|
|
3110
3299
|
t.alias = alias;
|
|
3111
|
-
|
|
3300
|
+
scheduleAddress("replace");
|
|
3112
3301
|
}
|
|
3113
3302
|
}
|
|
3114
3303
|
paintStatusDot(t.dot, rec.status, reports);
|
|
@@ -3177,15 +3366,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3177
3366
|
// server lists, then drop tabs it no longer lists. The SSE path runs it on
|
|
3178
3367
|
// every (re)open because a RESTARTED manager's snapshot carries no
|
|
3179
3368
|
// tombstones for sessions it never knew, so those tabs would otherwise spin
|
|
3180
|
-
// "Reconnecting…" forever.
|
|
3181
|
-
//
|
|
3369
|
+
// "Reconnecting…" forever. reconcileOnce joins the run in flight, so a
|
|
3370
|
+
// caller that awaits it sees a listing; inReconcileSlot queues behind it.
|
|
3182
3371
|
let reconciling: Promise<void> | null = null;
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3372
|
+
// Runs fn in the single reconcile slot: it never joins, it queues behind the
|
|
3373
|
+
// run in flight. The slot never rejects, so a failed run cannot skip the
|
|
3374
|
+
// runs queued behind it, and only the run still stored clears it.
|
|
3375
|
+
function inReconcileSlot(fn: () => Promise<void>): Promise<void> {
|
|
3376
|
+
const slot: Promise<void> = (reconciling ?? Promise.resolve())
|
|
3377
|
+
.then(() => fn())
|
|
3378
|
+
.catch((err: unknown) => {
|
|
3379
|
+
console.warn("web-terminal-ui: tab reconcile failed", err);
|
|
3380
|
+
})
|
|
3381
|
+
.finally(() => {
|
|
3382
|
+
if (reconciling === slot) {
|
|
3383
|
+
reconciling = null;
|
|
3384
|
+
}
|
|
3385
|
+
});
|
|
3386
|
+
reconciling = slot;
|
|
3387
|
+
return slot;
|
|
3388
|
+
}
|
|
3389
|
+
const reconcileOnce = (): Promise<void> => reconciling ?? inReconcileSlot(reconcileNow);
|
|
3390
|
+
// Placement of a tab arriving on the stream waits until fn ends. Then, unless
|
|
3391
|
+
// a resync ran meanwhile, the held tabs still open are placed as they would
|
|
3392
|
+
// have been; either way a page that booted onto nothing gets its repair.
|
|
3393
|
+
async function withPlacementHold(fn: () => Promise<void>): Promise<void> {
|
|
3394
|
+
holdDepth++;
|
|
3395
|
+
try {
|
|
3396
|
+
await fn();
|
|
3397
|
+
} finally {
|
|
3398
|
+
holdDepth--;
|
|
3399
|
+
if (holdDepth === 0) {
|
|
3400
|
+
const held = holdResynced ? [] : heldPlacements;
|
|
3401
|
+
heldPlacements = [];
|
|
3402
|
+
holdResynced = false;
|
|
3403
|
+
for (const id of held) {
|
|
3404
|
+
if (tabList.some((t) => t.id === id)) {
|
|
3405
|
+
showCreatedElsewhere(id);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
ensureActive();
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
// While settling, the dead tabs a resync drops must write no history entry.
|
|
3413
|
+
function scheduleAddress(mode: "push" | "replace"): void {
|
|
3414
|
+
if (!settling) {
|
|
3415
|
+
address?.schedule(mode);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3189
3418
|
async function reconcileNow(): Promise<void> {
|
|
3190
3419
|
// The listing is authoritative only for tabs that existed when it was
|
|
3191
3420
|
// requested: a tab adopted while the GET was in flight is invisible to
|
|
@@ -3201,6 +3430,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3201
3430
|
return;
|
|
3202
3431
|
}
|
|
3203
3432
|
const seen = new Set(list.map((s) => s.id));
|
|
3433
|
+
// Every tab the listing could have named is missing from it: the server
|
|
3434
|
+
// restarted (or replaced every tab). Decided before the adopt loop, so the
|
|
3435
|
+
// tabs it lists are placed by the address and the record, not by arrival.
|
|
3436
|
+
// Because this branch takes every such case, the gone loop below drops a
|
|
3437
|
+
// strict subset of the known tabs and never reaches dropTab's last-tab arm.
|
|
3438
|
+
const known = tabList.filter((t) => t.born <= epochAtList).map((t) => t.id);
|
|
3439
|
+
if (known.length > 0 && known.every((id) => !seen.has(id))) {
|
|
3440
|
+
await resync(known);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3204
3443
|
for (const info of list) {
|
|
3205
3444
|
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
3206
3445
|
applyStatus(info, reportsOf(info.reportsActivity));
|
|
@@ -3211,11 +3450,63 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3211
3450
|
.filter((t) => !seen.has(t.id) && t.born <= epochAtList)
|
|
3212
3451
|
.map((t) => t.id);
|
|
3213
3452
|
for (const id of gone) {
|
|
3214
|
-
await dropTab(id, false);
|
|
3453
|
+
await dropTab(id, { remote: false });
|
|
3215
3454
|
}
|
|
3216
3455
|
ensureActive();
|
|
3217
3456
|
syncChrome();
|
|
3218
3457
|
}
|
|
3458
|
+
// Rebuild the page onto a server whose sessions replaced `known` (a restart,
|
|
3459
|
+
// or the server's last tab removed): drop them without placing anything,
|
|
3460
|
+
// settle the new set, then place it the way a boot does, by the address and
|
|
3461
|
+
// the record. Runs inside the reconcile slot.
|
|
3462
|
+
async function resync(known: readonly string[]): Promise<void> {
|
|
3463
|
+
await withPlacementHold(async () => {
|
|
3464
|
+
holdResynced = true;
|
|
3465
|
+
const typing = keyboardInPaneInput();
|
|
3466
|
+
settling = true;
|
|
3467
|
+
let placed: "kept" | "shown" | "none" = "none";
|
|
3468
|
+
try {
|
|
3469
|
+
for (const id of known) {
|
|
3470
|
+
await dropTab(id, { remote: false, quiet: true });
|
|
3471
|
+
}
|
|
3472
|
+
// showSettled reopens a split through restoreSplit, which refuses an
|
|
3473
|
+
// open one; closed under applyingRecord so no record write goes out.
|
|
3474
|
+
if (ctx.shell.split.isOpen()) {
|
|
3475
|
+
applyingRecord = true;
|
|
3476
|
+
try {
|
|
3477
|
+
ctx.shell.split.close();
|
|
3478
|
+
} finally {
|
|
3479
|
+
applyingRecord = false;
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
const settled = await settleOrToast();
|
|
3483
|
+
if (tornDown()) {
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
for (const info of settled.sessions) {
|
|
3487
|
+
adoptSession(info);
|
|
3488
|
+
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3489
|
+
}
|
|
3490
|
+
// Read now, so an address the user navigated to during the wait wins.
|
|
3491
|
+
const route =
|
|
3492
|
+
address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
|
|
3493
|
+
// No stale-tab toast: the user did not open this address in this act.
|
|
3494
|
+
placed = showSettled(settled, route, false);
|
|
3495
|
+
} finally {
|
|
3496
|
+
settling = false;
|
|
3497
|
+
if (placed === "none") {
|
|
3498
|
+
bootShowedNothing = true;
|
|
3499
|
+
}
|
|
3500
|
+
if (!tornDown()) {
|
|
3501
|
+
syncChrome();
|
|
3502
|
+
if (typing) {
|
|
3503
|
+
focusInput();
|
|
3504
|
+
}
|
|
3505
|
+
scheduleAddress("replace");
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3219
3510
|
function warnLayoutOnce(what: string, err?: unknown): void {
|
|
3220
3511
|
if (warnedLayout) {
|
|
3221
3512
|
return;
|
|
@@ -3411,7 +3702,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3411
3702
|
syncCatchups();
|
|
3412
3703
|
syncChrome();
|
|
3413
3704
|
scheduleLayoutWrite();
|
|
3414
|
-
|
|
3705
|
+
scheduleAddress("push");
|
|
3415
3706
|
}),
|
|
3416
3707
|
);
|
|
3417
3708
|
ctx.defer(ctx.shell.onSelectionChange(syncChrome));
|
|
@@ -3427,7 +3718,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3427
3718
|
if (monitor) {
|
|
3428
3719
|
const offStatus = monitor.onStatus((s) => {
|
|
3429
3720
|
if (s.removed) {
|
|
3430
|
-
void dropTab(s.id, false); // already gone server-side; no DELETE
|
|
3721
|
+
void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
|
|
3431
3722
|
return;
|
|
3432
3723
|
}
|
|
3433
3724
|
adoptSession(s);
|
|
@@ -3448,8 +3739,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3448
3739
|
});
|
|
3449
3740
|
});
|
|
3450
3741
|
ctx.defer(offStatus);
|
|
3742
|
+
// The first open joins the boot's reconcile. A reopen queues a fresh one
|
|
3743
|
+
// under a placement hold, so its decision comes from a list issued after
|
|
3744
|
+
// the reopen and a restarted server's tabs are placed by resync.
|
|
3745
|
+
let streamOpens = 0;
|
|
3451
3746
|
const offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
3452
|
-
|
|
3747
|
+
streamOpens++;
|
|
3748
|
+
if (streamOpens === 1) {
|
|
3749
|
+
void reconcileOnce();
|
|
3750
|
+
} else {
|
|
3751
|
+
void withPlacementHold(() => inReconcileSlot(reconcileNow));
|
|
3752
|
+
}
|
|
3453
3753
|
});
|
|
3454
3754
|
if (offStreamOpen) {
|
|
3455
3755
|
ctx.defer(offStreamOpen);
|
|
@@ -4257,62 +4557,85 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4257
4557
|
toggleSwitcher();
|
|
4258
4558
|
});
|
|
4259
4559
|
|
|
4260
|
-
// The bootstrap (paneLayoutOwner.resolveInitialLayout):
|
|
4261
|
-
//
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4560
|
+
// The bootstrap (paneLayoutOwner.resolveInitialLayout): settle the session
|
|
4561
|
+
// set (creating the first session when nothing live is listed), adopt it,
|
|
4562
|
+
// pick the start tab and show it. A false return (nothing could be listed
|
|
4563
|
+
// or spawned) keeps the chrome up with the "+" retry alive, and the
|
|
4264
4564
|
// terminal dismisses the loading overlay over it. The record is read once
|
|
4265
4565
|
// and never written back by a read.
|
|
4266
4566
|
const resolveInitialLayout = async (): Promise<boolean> => {
|
|
4267
|
-
const
|
|
4567
|
+
const settled = await settleOrToast();
|
|
4268
4568
|
if (tornDown()) {
|
|
4269
4569
|
return false;
|
|
4270
4570
|
}
|
|
4271
|
-
let sessions: SessionInfo[] = listed.status === "fulfilled" ? listed.value : [];
|
|
4272
|
-
let layout: PaneLayout | null = null;
|
|
4273
|
-
if (layoutRead.status === "fulfilled") {
|
|
4274
|
-
layout = layoutRead.value;
|
|
4275
|
-
if (layout === null) {
|
|
4276
|
-
warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
|
|
4277
|
-
}
|
|
4278
|
-
} else {
|
|
4279
|
-
warnLayoutOnce("could not read the pane layout", layoutRead.reason);
|
|
4280
|
-
}
|
|
4281
|
-
// A fresh session unless a LIVE one is listed: an ended session is viewable
|
|
4282
|
-
// history, not a working terminal, and a boot onto one would hang the
|
|
4283
|
-
// loading state. The dead ones are still adopted below.
|
|
4284
|
-
if (!sessions.some((s) => !isEndedStatus(s.status))) {
|
|
4285
|
-
try {
|
|
4286
|
-
sessions = [...sessions, await createSessionHonouringRetry(api, ctx, lifetime.signal)];
|
|
4287
|
-
} catch (err) {
|
|
4288
|
-
if (tornDown()) {
|
|
4289
|
-
return false;
|
|
4290
|
-
}
|
|
4291
|
-
// The give-up path (a 503 was already retried on the server's schedule):
|
|
4292
|
-
// toast with the server's explanation and leave the chrome up so "+"
|
|
4293
|
-
// can retry, as the runtime create() does.
|
|
4294
|
-
ctx.toast(
|
|
4295
|
-
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4296
|
-
? `Could not open a terminal. ${err.serverMessage}`
|
|
4297
|
-
: "Could not open a terminal",
|
|
4298
|
-
);
|
|
4299
|
-
}
|
|
4300
|
-
}
|
|
4301
4571
|
// Adopt rather than push: the status stream's open snapshot may already
|
|
4302
4572
|
// have given a listed session its tab, since the subscription precedes
|
|
4303
4573
|
// this list.
|
|
4304
|
-
for (const info of sessions) {
|
|
4574
|
+
for (const info of settled.sessions) {
|
|
4305
4575
|
adoptSession(info);
|
|
4306
4576
|
}
|
|
4307
4577
|
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
4308
4578
|
started = true;
|
|
4309
|
-
//
|
|
4310
|
-
//
|
|
4311
|
-
|
|
4312
|
-
|
|
4579
|
+
// Read only with the server's list in hand, so a failed list never
|
|
4580
|
+
// declares a tab gone, and only once settled, so a wait keeps its intent.
|
|
4581
|
+
const route = address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
|
|
4582
|
+
switch (showSettled(settled, route, address?.bootOrigin() === "deeplink")) {
|
|
4583
|
+
case "kept":
|
|
4584
|
+
address?.start(settled.listed);
|
|
4585
|
+
return true;
|
|
4586
|
+
case "shown":
|
|
4587
|
+
syncChrome();
|
|
4588
|
+
focusInput();
|
|
4589
|
+
address?.start(settled.listed);
|
|
4590
|
+
return true;
|
|
4591
|
+
case "none":
|
|
4592
|
+
bootShowedNothing = true;
|
|
4593
|
+
address?.start(false);
|
|
4594
|
+
return false;
|
|
4595
|
+
}
|
|
4596
|
+
};
|
|
4597
|
+
|
|
4598
|
+
// settleSessionSet, toasting a failure the way a failed create always has
|
|
4599
|
+
// and carrying on with whatever was listed.
|
|
4600
|
+
async function settleOrToast(): Promise<Settled> {
|
|
4601
|
+
try {
|
|
4602
|
+
return await settleSessionSet(api, ctx, lifetime.signal, warnLayoutOnce);
|
|
4603
|
+
} catch (err) {
|
|
4604
|
+
if (tornDown()) {
|
|
4605
|
+
return NOTHING_SETTLED;
|
|
4606
|
+
}
|
|
4607
|
+
const failure = err instanceof SettleCreateFailed ? err : null;
|
|
4608
|
+
toastCreateFailure(failure === null ? err : failure.cause);
|
|
4609
|
+
return failure?.settled ?? NOTHING_SETTLED;
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
// The give-up path (a 503 was already retried on the server's schedule):
|
|
4614
|
+
// toast with the server's explanation and leave the chrome up so "+" can
|
|
4615
|
+
// retry.
|
|
4616
|
+
function toastCreateFailure(err: unknown): void {
|
|
4617
|
+
ctx.toast(
|
|
4618
|
+
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4619
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
4620
|
+
: "Could not open a terminal",
|
|
4621
|
+
);
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// Place a settled set: "kept" when a pane already shows a tab (a tap while
|
|
4625
|
+
// the list was in flight outranks the record), "shown" when the address,
|
|
4626
|
+
// the record or a fallback placed one, "none" when nothing could be shown.
|
|
4627
|
+
// toastStale says whether an address naming a closed tab is news to the user.
|
|
4628
|
+
function showSettled(
|
|
4629
|
+
settled: Settled,
|
|
4630
|
+
route: TabRoute | null,
|
|
4631
|
+
toastStale: boolean,
|
|
4632
|
+
): "kept" | "shown" | "none" {
|
|
4633
|
+
const { sessions, layout } = settled;
|
|
4634
|
+
// ensureActive is deliberately NOT one of this function's callers during
|
|
4635
|
+
// boot (see its bootShowedNothing gate), or the SSE snapshot would win
|
|
4636
|
+
// this race on every load.
|
|
4313
4637
|
if (anyShown()) {
|
|
4314
|
-
|
|
4315
|
-
return true;
|
|
4638
|
+
return "kept";
|
|
4316
4639
|
}
|
|
4317
4640
|
// The record's sessions where they still exist, else the oldest LIVE tab:
|
|
4318
4641
|
// a reload must not restore onto the corpse of a session that died while
|
|
@@ -4337,13 +4660,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4337
4660
|
};
|
|
4338
4661
|
let shown = false;
|
|
4339
4662
|
// The address outranks the record when it names an open tab: it is the
|
|
4340
|
-
// explicit instruction for this load.
|
|
4341
|
-
|
|
4342
|
-
const route =
|
|
4343
|
-
address !== null && listed.status === "fulfilled"
|
|
4344
|
-
? parseTabRoute(win.location.hash)
|
|
4345
|
-
: null;
|
|
4346
|
-
if (route !== null && address !== null) {
|
|
4663
|
+
// explicit instruction for this load.
|
|
4664
|
+
if (route !== null) {
|
|
4347
4665
|
const [wantLeft, wantRight] =
|
|
4348
4666
|
route.kind === "single" ? [route.tab, null] : [route.left, route.right];
|
|
4349
4667
|
const fromRoute = (alias: TabAlias | null): Tab | undefined => {
|
|
@@ -4358,7 +4676,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4358
4676
|
// refused here only because a boot never lands on a corpse.
|
|
4359
4677
|
const gone = (alias: TabAlias | null): boolean =>
|
|
4360
4678
|
alias !== null && tabByAlias(alias) === undefined;
|
|
4361
|
-
if ((gone(wantLeft) || gone(wantRight)) &&
|
|
4679
|
+
if ((gone(wantLeft) || gone(wantRight)) && toastStale) {
|
|
4362
4680
|
ctx.toast(STALE_TAB_NOTICE);
|
|
4363
4681
|
}
|
|
4364
4682
|
applyingRecord = true;
|
|
@@ -4427,16 +4745,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4427
4745
|
tabList[0];
|
|
4428
4746
|
shown = startTab !== undefined && showAtBoot(ctx.shell.targetFor(startTab.id), startTab);
|
|
4429
4747
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
address?.start(false);
|
|
4433
|
-
return false;
|
|
4434
|
-
}
|
|
4435
|
-
syncChrome();
|
|
4436
|
-
focusInput();
|
|
4437
|
-
address?.start(listed.status === "fulfilled");
|
|
4438
|
-
return true;
|
|
4439
|
-
};
|
|
4748
|
+
return shown ? "shown" : "none";
|
|
4749
|
+
}
|
|
4440
4750
|
live = { resolveInitialLayout, shownIn, showIn };
|
|
4441
4751
|
|
|
4442
4752
|
return {
|