@cplieger/web-terminal-ui 8.5.0 → 8.6.0-dev.10
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 +1 -1
- package/css/00-tokens.css +13 -18
- package/css/01-scope.css +2 -2
- package/css/02-terminal.css +19 -40
- package/css/10-primitives.css +1 -1
- package/css/30-tabs.css +24 -92
- package/css/31-switcher.css +3 -3
- package/css/MANIFEST +3 -3
- package/css/MANIFEST.touch +1 -1
- package/css/page.css +25 -66
- package/package.json +11 -11
- package/scaffold/index.html +1 -1
- package/src/features/tabs/index.ts +662 -295
- package/src/features/tabs/model.ts +95 -103
- package/src/features/tabs/strip.ts +5 -10
- package/src/kernel/attention.ts +53 -13
- package/src/kernel/notify.ts +35 -7
- package/src/kernel/pane.ts +32 -15
- package/src/kernel/shell.ts +1 -1
- package/src/kernel/types.ts +10 -1
- package/src/predict.ts +1 -1
- package/src/presets/agent-tabbed.ts +6 -8
- package/src/presets/tabbed.ts +10 -18
- package/src/presets.ts +8 -7
- package/src/features/tabs/device-snap.ts +0 -56
|
@@ -16,15 +16,18 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
|
16
16
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
17
17
|
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
18
18
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
19
|
-
import { snapToDevicePixels } from "./device-snap.js";
|
|
20
19
|
import { centreChipLabels } from "./ink-centre.js";
|
|
21
20
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
22
21
|
import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
|
|
23
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
formatTabRoute,
|
|
24
|
+
parseTabAlias,
|
|
25
|
+
parseTabRoute,
|
|
26
|
+
type TabAlias,
|
|
27
|
+
type TabRoute,
|
|
28
|
+
} from "./route.js";
|
|
24
29
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
25
30
|
import {
|
|
26
|
-
CUE_SEEN_KEY,
|
|
27
|
-
MAX_PERSISTED_CUE_SEEN,
|
|
28
31
|
MAX_PINNED_NAME,
|
|
29
32
|
PROGRESS_ABSENT,
|
|
30
33
|
SWIPE_HINT_KEY,
|
|
@@ -38,15 +41,14 @@ import {
|
|
|
38
41
|
hasPinnedName,
|
|
39
42
|
isCueStatus,
|
|
40
43
|
isEndedStatus,
|
|
41
|
-
|
|
44
|
+
isUnread,
|
|
42
45
|
normalizeActivity,
|
|
43
46
|
normalizeActivityCount,
|
|
44
47
|
normalizeProgress,
|
|
48
|
+
normalizeSeq,
|
|
45
49
|
orderedInsertIndex,
|
|
46
|
-
parseCueSeen,
|
|
47
50
|
renderedProgress,
|
|
48
51
|
sanitizePinnedName,
|
|
49
|
-
serializeCueSeen,
|
|
50
52
|
statusPhrase,
|
|
51
53
|
summarizeCues,
|
|
52
54
|
tabAccessibleName,
|
|
@@ -169,13 +171,13 @@ export interface TabsOptions {
|
|
|
169
171
|
attentionIcons?: boolean;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
/**
|
|
173
|
-
* `Retry-After` and a body message while it
|
|
174
|
-
* window that can run twenty minutes.
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* is re-told periodically. */
|
|
174
|
+
/** The layout read, the session list and session creation can be TEMPORARILY
|
|
175
|
+
* refused: a host answers 503 with `Retry-After` and a body message while it
|
|
176
|
+
* starts up or restores its sessions, a window that can run twenty minutes.
|
|
177
|
+
* Only 503 retries, on the server's own schedule and repeating its own
|
|
178
|
+
* explanation. The bound is ELAPSED TIME, not an attempt count, which at
|
|
179
|
+
* `Retry-After: 5` would give up in a minute; every iteration sleeps at least
|
|
180
|
+
* the hint, so this is never a hot loop, and the user is re-told periodically. */
|
|
179
181
|
const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
|
|
180
182
|
const CREATE_RETRY_FALLBACK_MS = 5000;
|
|
181
183
|
const CREATE_RETRY_REANNOUNCE_MS = 60000;
|
|
@@ -190,29 +192,6 @@ const CATCHUP_SETTLE_MS = 250;
|
|
|
190
192
|
* multiple frames; ordinary streaming must never arm the cue. */
|
|
191
193
|
const CATCHUP_MIN_BACKLOG = 400;
|
|
192
194
|
|
|
193
|
-
/** The acknowledged background-tab cues, or an empty map when storage throws or
|
|
194
|
-
* holds nothing trustworthy: the dot simply lights again. */
|
|
195
|
-
function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
|
|
196
|
-
try {
|
|
197
|
-
return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
|
|
198
|
-
} catch {
|
|
199
|
-
return new Map<string, CueStatus>();
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** writeCueSeen persists acknowledgements, best-effort: a full quota must not
|
|
204
|
-
* break the dismissal the user just performed on screen. */
|
|
205
|
-
function writeCueSeen(
|
|
206
|
-
win: Pick<Window, "localStorage">,
|
|
207
|
-
seen: ReadonlyMap<string, CueStatus>,
|
|
208
|
-
): void {
|
|
209
|
-
try {
|
|
210
|
-
win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
211
|
-
} catch {
|
|
212
|
-
/* storage unavailable or full — the dismissal still holds for this page */
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
195
|
/** A wait the feature's teardown ends early: the timer goes with the feature. */
|
|
217
196
|
function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
|
|
218
197
|
return new Promise((resolve) => {
|
|
@@ -228,20 +207,87 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
|
|
|
228
207
|
});
|
|
229
208
|
}
|
|
230
209
|
|
|
210
|
+
/** Where a status record came from: the stream, or a list requested when the
|
|
211
|
+
* stream epoch read `listedAt`. */
|
|
212
|
+
type RecordSource = { readonly stream: true } | { readonly listedAt: number };
|
|
213
|
+
|
|
214
|
+
/** How long a run of refusals has waited and when it last told the user. */
|
|
215
|
+
interface RefusalWait {
|
|
216
|
+
readonly startedAt: number;
|
|
217
|
+
// Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
|
|
218
|
+
// machine refuses inside the first millisecond.
|
|
219
|
+
lastAnnouncedAt: number | null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Wait out one temporary refusal: a 503, on the server's own `Retry-After`,
|
|
223
|
+
* repeating its own explanation. False means give up: `err` is not a 503, or
|
|
224
|
+
* the run of refusals has spent `CREATE_RETRY_MAX_TOTAL_MS`. An abort ends the
|
|
225
|
+
* wait early; the caller checks its signal next. */
|
|
226
|
+
async function waitOutRefusal(
|
|
227
|
+
err: unknown,
|
|
228
|
+
wait: RefusalWait,
|
|
229
|
+
ctx: TerminalContext,
|
|
230
|
+
signal: AbortSignal,
|
|
231
|
+
): Promise<boolean> {
|
|
232
|
+
const win = windowOf(ctx.shell.root.ownerDocument);
|
|
233
|
+
const elapsed = win.Date.now() - wait.startedAt;
|
|
234
|
+
if (
|
|
235
|
+
!(err instanceof SessionAPIError) ||
|
|
236
|
+
err.status !== 503 ||
|
|
237
|
+
elapsed >= CREATE_RETRY_MAX_TOTAL_MS
|
|
238
|
+
) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
// "waiting", not "retrying": the server only said "not yet", and the first
|
|
242
|
+
// announcement fires before any retry has happened.
|
|
243
|
+
if (
|
|
244
|
+
wait.lastAnnouncedAt === null ||
|
|
245
|
+
elapsed - wait.lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS
|
|
246
|
+
) {
|
|
247
|
+
wait.lastAnnouncedAt = elapsed;
|
|
248
|
+
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
249
|
+
ctx.toast(`${reason}, waiting…`, 8000);
|
|
250
|
+
ctx.announce(`${reason}, waiting…`);
|
|
251
|
+
}
|
|
252
|
+
// Unthrottled: the overlay is the only surface visible before the first
|
|
253
|
+
// frame, and it replaces text in place.
|
|
254
|
+
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
255
|
+
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const isRefusal = (err: unknown): boolean => err instanceof SessionAPIError && err.status === 503;
|
|
260
|
+
|
|
261
|
+
interface SessionAPIClient {
|
|
262
|
+
list(): Promise<SessionInfo[]>;
|
|
263
|
+
getLayout(): Promise<PaneLayout | null>;
|
|
264
|
+
create(): Promise<SessionInfo>;
|
|
265
|
+
close(id: string): Promise<void>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Close a session a page created after it was torn down: nothing holds it. */
|
|
269
|
+
function closeOrphan(api: Pick<SessionAPIClient, "close">, id: string): void {
|
|
270
|
+
void api.close(id).catch((err: unknown) => {
|
|
271
|
+
console.warn(
|
|
272
|
+
`web-terminal-ui: session ${id} was created after teardown and could not be closed`,
|
|
273
|
+
err,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
231
278
|
/** Create a session, honouring the server's retry schedule, for as long as
|
|
232
279
|
* `signal` stays live: an abort ends the wait and throws its reason, and a
|
|
233
280
|
* create that finishes after the abort is closed rather than returned, because
|
|
234
281
|
* the page that asked for it has no tab left to hold it. */
|
|
235
282
|
async function createSessionHonouringRetry(
|
|
236
|
-
api:
|
|
283
|
+
api: Pick<SessionAPIClient, "create" | "close">,
|
|
237
284
|
ctx: TerminalContext,
|
|
238
285
|
signal: AbortSignal,
|
|
239
286
|
): Promise<SessionInfo> {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let lastAnnouncedAt: number | null = null;
|
|
287
|
+
const wait: RefusalWait = {
|
|
288
|
+
startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
|
|
289
|
+
lastAnnouncedAt: null,
|
|
290
|
+
};
|
|
245
291
|
for (;;) {
|
|
246
292
|
signal.throwIfAborted();
|
|
247
293
|
let info: SessionInfo;
|
|
@@ -249,41 +295,130 @@ async function createSessionHonouringRetry(
|
|
|
249
295
|
info = await api.create();
|
|
250
296
|
} catch (err) {
|
|
251
297
|
signal.throwIfAborted();
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
!(err instanceof SessionAPIError) ||
|
|
255
|
-
err.status !== 503 ||
|
|
256
|
-
elapsed >= CREATE_RETRY_MAX_TOTAL_MS
|
|
257
|
-
) {
|
|
298
|
+
if (!(await waitOutRefusal(err, wait, ctx, signal))) {
|
|
258
299
|
throw err;
|
|
259
300
|
}
|
|
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
301
|
continue;
|
|
273
302
|
}
|
|
274
303
|
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
|
-
});
|
|
304
|
+
closeOrphan(api, info.id);
|
|
281
305
|
signal.throwIfAborted();
|
|
282
306
|
}
|
|
283
307
|
return info;
|
|
284
308
|
}
|
|
285
309
|
}
|
|
286
310
|
|
|
311
|
+
/** What a settled server answered: its sessions, its layout record (null on a
|
|
312
|
+
* 404, an unreadable or a malformed record, each warned), and whether the list
|
|
313
|
+
* itself succeeded. */
|
|
314
|
+
interface Settled {
|
|
315
|
+
readonly sessions: SessionInfo[];
|
|
316
|
+
readonly layout: PaneLayout | null;
|
|
317
|
+
readonly listed: boolean;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const NOTHING_SETTLED: Settled = { sessions: [], layout: null, listed: false };
|
|
321
|
+
|
|
322
|
+
/** settleSessionSet's create failed for good; `settled` is what was listed. */
|
|
323
|
+
class SettleCreateFailed extends Error {
|
|
324
|
+
readonly settled: Settled;
|
|
325
|
+
constructor(cause: unknown, settled: Settled) {
|
|
326
|
+
super("web-terminal-ui: could not create a session", { cause });
|
|
327
|
+
this.name = "SettleCreateFailed";
|
|
328
|
+
this.settled = settled;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Read the layout, then list, then (when nothing live is listed) create one
|
|
333
|
+
* session, starting the turn again after any 503, so a create refused
|
|
334
|
+
* mid-restore never adds a tab beside the restored ones. A non-503 layout read
|
|
335
|
+
* proves the host was not restoring when it was served, so a list issued after
|
|
336
|
+
* it sees the whole restored set. Past `CREATE_RETRY_MAX_TOTAL_MS` a refused
|
|
337
|
+
* layout read or list returns the last good list, or rethrows the 503; a create
|
|
338
|
+
* that fails for good, a spent 503 included, throws `SettleCreateFailed`
|
|
339
|
+
* carrying the list, an abort its reason. */
|
|
340
|
+
async function settleSessionSet(
|
|
341
|
+
api: SessionAPIClient,
|
|
342
|
+
ctx: TerminalContext,
|
|
343
|
+
signal: AbortSignal,
|
|
344
|
+
warnLayout: (what: string, err?: unknown) => void,
|
|
345
|
+
): Promise<Settled> {
|
|
346
|
+
const wait: RefusalWait = {
|
|
347
|
+
startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
|
|
348
|
+
lastAnnouncedAt: null,
|
|
349
|
+
};
|
|
350
|
+
let last: Settled | null = null;
|
|
351
|
+
// True to go round again; false once the refusals spent their budget.
|
|
352
|
+
const waited = async (err: unknown): Promise<boolean> => {
|
|
353
|
+
signal.throwIfAborted();
|
|
354
|
+
const again = await waitOutRefusal(err, wait, ctx, signal);
|
|
355
|
+
signal.throwIfAborted();
|
|
356
|
+
return again;
|
|
357
|
+
};
|
|
358
|
+
for (;;) {
|
|
359
|
+
signal.throwIfAborted();
|
|
360
|
+
let layout: PaneLayout | null = null;
|
|
361
|
+
try {
|
|
362
|
+
layout = await api.getLayout();
|
|
363
|
+
if (layout === null) {
|
|
364
|
+
warnLayout("this server has no GET/PUT /api/sessions/layout route");
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
if (isRefusal(err)) {
|
|
368
|
+
if (await waited(err)) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (last !== null) {
|
|
372
|
+
return last;
|
|
373
|
+
}
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
signal.throwIfAborted();
|
|
377
|
+
warnLayout("could not read the pane layout", err);
|
|
378
|
+
}
|
|
379
|
+
let sessions: SessionInfo[] = [];
|
|
380
|
+
let listed = false;
|
|
381
|
+
try {
|
|
382
|
+
sessions = await api.list();
|
|
383
|
+
listed = true;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (isRefusal(err)) {
|
|
386
|
+
if (await waited(err)) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (last !== null) {
|
|
390
|
+
return last;
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
signal.throwIfAborted();
|
|
395
|
+
}
|
|
396
|
+
const settled: Settled = { sessions, layout, listed };
|
|
397
|
+
if (listed) {
|
|
398
|
+
last = settled;
|
|
399
|
+
}
|
|
400
|
+
// An ended session is viewable history, not a working terminal.
|
|
401
|
+
if (sessions.some((s) => !isEndedStatus(s.status))) {
|
|
402
|
+
return settled;
|
|
403
|
+
}
|
|
404
|
+
let info: SessionInfo;
|
|
405
|
+
try {
|
|
406
|
+
info = await api.create();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (isRefusal(err) && (await waited(err))) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
signal.throwIfAborted();
|
|
412
|
+
throw new SettleCreateFailed(err, settled);
|
|
413
|
+
}
|
|
414
|
+
if (signal.aborted) {
|
|
415
|
+
closeOrphan(api, info.id);
|
|
416
|
+
signal.throwIfAborted();
|
|
417
|
+
}
|
|
418
|
+
return { sessions: [...sessions, info], layout, listed };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
287
422
|
interface ShowOptions {
|
|
288
423
|
readonly dir?: "next" | "prev" | undefined;
|
|
289
424
|
/** Leave the selection where it is, unless the selected pane shows nothing. */
|
|
@@ -301,8 +436,8 @@ type SplitOpening =
|
|
|
301
436
|
* `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
|
|
302
437
|
* It registers as the terminal's `paneLayoutOwner`, so it decides which session
|
|
303
438
|
* the first connect attaches to; two owners fail at `kernel-init`. The strip's
|
|
304
|
-
* order and the pane layout are SERVER state every viewer shares;
|
|
305
|
-
*
|
|
439
|
+
* order and the pane layout are SERVER state every viewer shares; which cues
|
|
440
|
+
* each session's readers have seen is server state too. Teardown removes the chrome and leaves
|
|
306
441
|
* the sessions running; closing a tab is what kills a process. */
|
|
307
442
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
308
443
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
@@ -517,59 +652,92 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
517
652
|
const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
|
|
518
653
|
const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
|
|
519
654
|
swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
|
|
520
|
-
// Latest-wins cue for the switch button's dot, cleared when the list opens
|
|
521
|
-
// the raising tab is visited or closed
|
|
655
|
+
// Latest-wins cue for the switch button's dot, cleared when the list opens,
|
|
656
|
+
// the raising tab is visited or closed, or its occurrence (switchNotifySeq)
|
|
657
|
+
// is read on any window or device; "" is no pending cue.
|
|
522
658
|
let switchNotify: "" | CueStatus = "";
|
|
523
659
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
524
660
|
// swipe or any switch) acknowledges it without opening the list.
|
|
525
661
|
let switchNotifyId: string | null = null;
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
// every tab's dot was opened.
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
662
|
+
let switchNotifySeq = 0;
|
|
663
|
+
// Counts status-stream records; applyStatus compares a list's reading of it
|
|
664
|
+
// with each tab's streamAt to skip a list older than the stream.
|
|
665
|
+
let streamEpoch = 0;
|
|
666
|
+
let warnedSeen = false;
|
|
667
|
+
// The out-of-page surfaces render the SAME unread-cue set the switch dot
|
|
668
|
+
// shows, fed only through paintAttention below.
|
|
669
|
+
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
670
|
+
// markCueSeen acknowledges t's current occurrence for every reader: the tab
|
|
671
|
+
// is on screen, or the tray listing every tab's dot was opened. Every read
|
|
672
|
+
// re-asserts the badge, which may still show a write the platform dropped,
|
|
673
|
+
// and closes the session's banner. The surfaces clear before the round trip,
|
|
674
|
+
// so a cue landing on the watched tab never paints a count. The automatic
|
|
675
|
+
// path (retry false) skips an occurrence whose ack already failed, so a
|
|
676
|
+
// failing server sees one PUT per occurrence rather than one per record.
|
|
677
|
+
function markCueSeen(t: Tab, how: { readonly retry: boolean }): void {
|
|
678
|
+
attention.resync();
|
|
679
|
+
ctx.shell.notifications.retract(t.id);
|
|
680
|
+
t.bannerSeq = 0;
|
|
681
|
+
if (!isCueStatus(statusOf(t)) || t.statusSeq <= Math.max(t.seenSeq, t.ackSeq)) {
|
|
539
682
|
return;
|
|
540
683
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// does. dropTab prunes a session closed while this page was open, but one
|
|
544
|
-
// that vanished while the page was CLOSED leaves an entry nothing else
|
|
545
|
-
// collects; unbounded, that would eventually push the map past the cap and
|
|
546
|
-
// make the parser discard whatever it read last — dropping fresh
|
|
547
|
-
// acknowledgements to keep dead ones.
|
|
548
|
-
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
549
|
-
const oldest = cueSeen.keys().next().value;
|
|
550
|
-
if (oldest === undefined) {
|
|
551
|
-
break;
|
|
552
|
-
}
|
|
553
|
-
cueSeen.delete(oldest);
|
|
684
|
+
if (!how.retry && t.failedSeq === t.statusSeq) {
|
|
685
|
+
return;
|
|
554
686
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
687
|
+
const seq = t.statusSeq;
|
|
688
|
+
t.ackSeq = seq;
|
|
689
|
+
closeReadSurfaces(t);
|
|
559
690
|
paintAttention();
|
|
691
|
+
api.markSeen(t.id, seq).then(
|
|
692
|
+
() => {
|
|
693
|
+
if (tornDown() || !tabList.includes(t)) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// seq came from the server, so a 204 means its floor is at least seq.
|
|
697
|
+
t.seenSeq = Math.max(t.seenSeq, seq);
|
|
698
|
+
if (t.ackSeq <= t.seenSeq) {
|
|
699
|
+
t.ackSeq = 0;
|
|
700
|
+
}
|
|
701
|
+
if (t.failedSeq <= seq) {
|
|
702
|
+
t.failedSeq = 0;
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
(err: unknown) => {
|
|
706
|
+
if (tornDown() || !tabList.includes(t)) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (err instanceof SessionAPIError && err.status === 404) {
|
|
710
|
+
return; // the session is gone; its removal drops the tab
|
|
711
|
+
}
|
|
712
|
+
// A later ack owns ackSeq, and seenSeq is never written here: a
|
|
713
|
+
// broadcast may already carry this ack though its response was lost.
|
|
714
|
+
if (t.ackSeq === seq) {
|
|
715
|
+
t.ackSeq = 0;
|
|
716
|
+
}
|
|
717
|
+
t.failedSeq = Math.max(t.failedSeq, seq);
|
|
718
|
+
if (!warnedSeen) {
|
|
719
|
+
warnedSeen = true;
|
|
720
|
+
console.warn("web-terminal-ui: marking a session read failed", err);
|
|
721
|
+
}
|
|
722
|
+
paintAttention();
|
|
723
|
+
},
|
|
724
|
+
);
|
|
560
725
|
}
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
function
|
|
566
|
-
|
|
567
|
-
|
|
726
|
+
// A surface this page raised for an occurrence closes once the page's read
|
|
727
|
+
// floor reaches that occurrence, keyed on the number rather than the fold: a
|
|
728
|
+
// done held by background activity folds to "" yet may own a banner, and a
|
|
729
|
+
// late ack of an older occurrence must not close one raised for a newer.
|
|
730
|
+
function closeReadSurfaces(t: Tab): void {
|
|
731
|
+
const floor = Math.max(t.seenSeq, t.ackSeq);
|
|
732
|
+
if (t.bannerSeq !== 0 && floor >= t.bannerSeq) {
|
|
733
|
+
ctx.shell.notifications.retract(t.id); // only this page can close its own banners
|
|
734
|
+
t.bannerSeq = 0;
|
|
735
|
+
}
|
|
736
|
+
if (switchNotifyId === t.id && floor >= switchNotifySeq) {
|
|
737
|
+
clearSwitchNotify();
|
|
568
738
|
}
|
|
739
|
+
attention.resync();
|
|
569
740
|
}
|
|
570
|
-
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
571
|
-
// shows, fed only through paintAttention below.
|
|
572
|
-
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
573
741
|
// visibilityState, not document.hasFocus(), which is false for a
|
|
574
742
|
// visible-but-unfocused window where the terminal IS on screen.
|
|
575
743
|
function pageVisible(): boolean {
|
|
@@ -579,29 +747,46 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
579
747
|
// The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
|
|
580
748
|
// a hidden page raised the out-of-page surfaces and is acknowledged when the
|
|
581
749
|
// user can see the terminal. The SHOWN tabs only: blanking a background
|
|
582
|
-
// tab's cue the viewer never saw loses the thing they came back for.
|
|
750
|
+
// tab's cue the viewer never saw loses the thing they came back for. Also
|
|
751
|
+
// the retry point for a failed ack.
|
|
583
752
|
function onPageVisible(): void {
|
|
584
753
|
if (!pageVisible()) {
|
|
585
754
|
return;
|
|
586
755
|
}
|
|
587
756
|
for (const t of tabList) {
|
|
588
757
|
if (isShown(t.id)) {
|
|
589
|
-
markCueSeen(t
|
|
758
|
+
markCueSeen(t, { retry: true });
|
|
590
759
|
}
|
|
591
760
|
}
|
|
592
761
|
}
|
|
593
762
|
doc.addEventListener("visibilitychange", onPageVisible);
|
|
763
|
+
// A desktop window can regain focus while it stays visible, which fires no
|
|
764
|
+
// visibilitychange.
|
|
765
|
+
win.addEventListener("focus", onPageVisible);
|
|
594
766
|
ctx.defer(() => {
|
|
595
767
|
doc.removeEventListener("visibilitychange", onPageVisible);
|
|
768
|
+
win.removeEventListener("focus", onPageVisible);
|
|
596
769
|
});
|
|
597
770
|
|
|
771
|
+
/** The cue fold's view of a tab (isUnread), shared by the count and the raise. */
|
|
772
|
+
function fold(t: Tab): {
|
|
773
|
+
status: string;
|
|
774
|
+
statusSeq: number;
|
|
775
|
+
seenSeq: number;
|
|
776
|
+
ackSeq: number;
|
|
777
|
+
} {
|
|
778
|
+
return {
|
|
779
|
+
status: cueStatusOf(t),
|
|
780
|
+
statusSeq: t.statusSeq,
|
|
781
|
+
seenSeq: t.seenSeq,
|
|
782
|
+
ackSeq: t.ackSeq,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
598
786
|
// A FOLD over the tab list rather than incremental bookkeeping: no state of
|
|
599
787
|
// its own can go stale, and the shell no-ops when nothing changed.
|
|
600
788
|
function paintAttention(): void {
|
|
601
|
-
const { count, worst } = summarizeCues(
|
|
602
|
-
tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
|
|
603
|
-
cueSeen,
|
|
604
|
-
);
|
|
789
|
+
const { count, worst } = summarizeCues(tabList.map(fold));
|
|
605
790
|
attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
|
|
606
791
|
}
|
|
607
792
|
function paintSwitchDot(): void {
|
|
@@ -619,11 +804,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
619
804
|
function clearSwitchNotify(): void {
|
|
620
805
|
switchNotify = "";
|
|
621
806
|
switchNotifyId = null;
|
|
807
|
+
switchNotifySeq = 0;
|
|
622
808
|
paintSwitchDot();
|
|
623
809
|
}
|
|
624
810
|
// The pending cue is resolved when its session is reached (a switch, a swipe
|
|
625
811
|
// arriving on it) and moot when the session ceases to exist (a close, a
|
|
626
|
-
// reap)
|
|
812
|
+
// reap).
|
|
627
813
|
function acknowledgeSwitchNotify(id: string): void {
|
|
628
814
|
if (switchNotifyId === id) {
|
|
629
815
|
clearSwitchNotify();
|
|
@@ -673,12 +859,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
673
859
|
// engine produced for THIS font at THIS size, rather than the em constant
|
|
674
860
|
// in 00-tokens.css that can only be right at one size (see ink-centre.ts).
|
|
675
861
|
ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
|
|
676
|
-
for (const b of splitButtons) {
|
|
677
|
-
const icon = b.querySelector("svg");
|
|
678
|
-
if (icon) {
|
|
679
|
-
ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
862
|
|
|
683
863
|
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
684
864
|
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
@@ -787,6 +967,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
787
967
|
// Set by a bootstrap that showed nothing, cleared by the first showIn: the
|
|
788
968
|
// one case a status event may pick a tab to show.
|
|
789
969
|
let bootShowedNothing = false;
|
|
970
|
+
// While a reconcile that may resync onto a restarted server runs, a tab
|
|
971
|
+
// arriving on the stream is held rather than placed by arrival, which would
|
|
972
|
+
// beat the address and the record to the panes (withPlacementHold). A depth,
|
|
973
|
+
// because two stream reopens inside one long settle each hold.
|
|
974
|
+
let holdDepth = 0;
|
|
975
|
+
let heldPlacements: string[] = [];
|
|
976
|
+
// Whether a resync ran inside the current outermost hold; its release then
|
|
977
|
+
// places none of the held tabs, so a late arrival cannot replace a pane the
|
|
978
|
+
// address and the record restored.
|
|
979
|
+
let holdResynced = false;
|
|
980
|
+
// While a resync settles, the address bar is left alone so the dead tabs it
|
|
981
|
+
// drops write no history entries (scheduleAddress).
|
|
982
|
+
let settling = false;
|
|
790
983
|
// The coalesced layout write (scheduleLayoutWrite): one PUT per macrotask,
|
|
791
984
|
// one in flight at a time, and a change during the flight sends the latest
|
|
792
985
|
// state once it lands, so an older record can never become the server's.
|
|
@@ -813,9 +1006,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
813
1006
|
pageTitle.release();
|
|
814
1007
|
});
|
|
815
1008
|
}
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
// PRESS-time frame, so a move a hold lifted across still ends the drag.
|
|
1009
|
+
// A live drag's viewport is judged against the PRESS-time frame, so a move a
|
|
1010
|
+
// hold lifted across still ends the drag.
|
|
819
1011
|
let draggingEl: HTMLElement | null = null;
|
|
820
1012
|
// The chips currently carrying an inline displacement from the commit slide.
|
|
821
1013
|
// A set plus one settle function, so whatever a slide wrote can always be handed
|
|
@@ -875,9 +1067,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
875
1067
|
const prefersReduce = (): boolean =>
|
|
876
1068
|
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
877
1069
|
|
|
878
|
-
// Recomputes every display label with de-duplication.
|
|
879
|
-
// is applied at render time and stored nowhere, so de-duplication and the
|
|
880
|
-
// rename field see real names and clearing it needs no cleanup.
|
|
1070
|
+
// Recomputes every display label with de-duplication.
|
|
881
1071
|
function relabelAll(): void {
|
|
882
1072
|
// The "(k)" suffix is numbered by the session's AGE, never by its slot:
|
|
883
1073
|
// numbered by encounter order, the labels stayed put while the sessions
|
|
@@ -947,11 +1137,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
947
1137
|
return foldedCueStatus(statusOf(t), t.activity);
|
|
948
1138
|
}
|
|
949
1139
|
|
|
950
|
-
/** shownProgress is a tab's percentage as it may currently be DISPLAYED
|
|
951
|
-
*
|
|
952
|
-
* one the progress channel owns. Those are the only two ways a percentage
|
|
953
|
-
* stops showing — the program's own OSC 9;4;0 (an explicit -1) and a status
|
|
954
|
-
* from another channel; see renderedProgress for why there is no third. */
|
|
1140
|
+
/** shownProgress is a tab's percentage as it may currently be DISPLAYED;
|
|
1141
|
+
* renderedProgress owns when that is. */
|
|
955
1142
|
function shownProgress(t: Tab): number {
|
|
956
1143
|
return renderedProgress(statusOf(t), t.progress);
|
|
957
1144
|
}
|
|
@@ -1078,7 +1265,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1078
1265
|
const heldKeyboard = keyboardAtPressOf(rowClose);
|
|
1079
1266
|
rowClose.addEventListener("click", (e) => {
|
|
1080
1267
|
e.stopPropagation();
|
|
1081
|
-
void dropTab(t.id, true, heldKeyboard());
|
|
1268
|
+
void dropTab(t.id, { remote: true, keyboardHeld: heldKeyboard() });
|
|
1082
1269
|
});
|
|
1083
1270
|
return row;
|
|
1084
1271
|
}
|
|
@@ -1371,9 +1558,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1371
1558
|
// The tray lists EVERY tab with its own status dot, so every latched tab
|
|
1372
1559
|
// is acknowledged here, not just the cue's latest subject — several tabs
|
|
1373
1560
|
// can be latched at once while the dot only ever showed the newest, and an
|
|
1374
|
-
//
|
|
1561
|
+
// unread sibling would stay unread on every device.
|
|
1375
1562
|
for (const t of tabList) {
|
|
1376
|
-
markCueSeen(t
|
|
1563
|
+
markCueSeen(t, { retry: true });
|
|
1377
1564
|
}
|
|
1378
1565
|
clearSwitchNotify();
|
|
1379
1566
|
renderSwitcherList();
|
|
@@ -1452,6 +1639,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1452
1639
|
aria,
|
|
1453
1640
|
view: null,
|
|
1454
1641
|
reports: reportsOf(info.reportsActivity),
|
|
1642
|
+
// The boot list adopts without applyStatus, so these carry its numbers.
|
|
1643
|
+
statusSeq: normalizeSeq(info.statusSeq) ?? 0,
|
|
1644
|
+
seenSeq: normalizeSeq(info.seenSeq) ?? 0,
|
|
1645
|
+
ackSeq: 0,
|
|
1646
|
+
failedSeq: 0,
|
|
1647
|
+
bannerSeq: 0,
|
|
1648
|
+
streamAt: 0,
|
|
1455
1649
|
};
|
|
1456
1650
|
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
1457
1651
|
paintActivityMark(activityEl, tab.activity, tab.activityCount);
|
|
@@ -1540,7 +1734,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1540
1734
|
const heldKeyboard = keyboardAtPressOf(close);
|
|
1541
1735
|
close.addEventListener("click", (e) => {
|
|
1542
1736
|
e.stopPropagation();
|
|
1543
|
-
void dropTab(tab.id, true, heldKeyboard());
|
|
1737
|
+
void dropTab(tab.id, { remote: true, keyboardHeld: heldKeyboard() });
|
|
1544
1738
|
});
|
|
1545
1739
|
// Middle-click closes the tab (#8). Suppress the middle-click default on
|
|
1546
1740
|
// mousedown so the browser's autoscroll/paste affordance does not fire.
|
|
@@ -1669,7 +1863,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1669
1863
|
// The terminal is now on screen, so a reload must not notify about a state
|
|
1670
1864
|
// the user just looked at.
|
|
1671
1865
|
acknowledgeSwitchNotify(next.id);
|
|
1672
|
-
markCueSeen(next
|
|
1866
|
+
markCueSeen(next, { retry: true });
|
|
1673
1867
|
const moveSelection = how.keepSelection !== true || selectedId() === null;
|
|
1674
1868
|
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1675
1869
|
// follow flag is one per renderer, so a bind without it gated the first
|
|
@@ -1685,7 +1879,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1685
1879
|
ctx.shell.select(side);
|
|
1686
1880
|
}
|
|
1687
1881
|
scheduleLayoutWrite();
|
|
1688
|
-
|
|
1882
|
+
scheduleAddress("push");
|
|
1689
1883
|
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1690
1884
|
// paints from cache in one frame and must not flash a cue at every switch.
|
|
1691
1885
|
if (
|
|
@@ -2402,7 +2596,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2402
2596
|
// emptied is never refilled by a status event. A live tab outranks an ENDED
|
|
2403
2597
|
// one, which would wedge the page the same way.
|
|
2404
2598
|
function ensureActive(): void {
|
|
2405
|
-
if (!bootShowedNothing || anyShown()) {
|
|
2599
|
+
if (holdDepth > 0 || !bootShowedNothing || anyShown()) {
|
|
2406
2600
|
return;
|
|
2407
2601
|
}
|
|
2408
2602
|
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
@@ -2444,6 +2638,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2444
2638
|
// This client's own new session can reach the stream before its POST
|
|
2445
2639
|
// answers, so the placement waits until a create in flight has shown its tab.
|
|
2446
2640
|
function showCreatedElsewhere(id: string): void {
|
|
2641
|
+
if (holdDepth > 0) {
|
|
2642
|
+
heldPlacements.push(id);
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2447
2645
|
if (creating !== null) {
|
|
2448
2646
|
void creating.then(() => {
|
|
2449
2647
|
showCreatedElsewhere(id);
|
|
@@ -2467,11 +2665,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2467
2665
|
// the user rather than throwing. A 503 has already been retried on the
|
|
2468
2666
|
// server's own schedule by this point, so reaching here means it never
|
|
2469
2667
|
// 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
|
-
);
|
|
2668
|
+
toastCreateFailure(err);
|
|
2475
2669
|
return;
|
|
2476
2670
|
}
|
|
2477
2671
|
// The status SSE may have adopted this session during the POST round-trip
|
|
@@ -2509,13 +2703,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2509
2703
|
}
|
|
2510
2704
|
|
|
2511
2705
|
// 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
|
-
|
|
2706
|
+
// its pane shows next. remote also DELETEs the session (a user close);
|
|
2707
|
+
// without it the server already ended it. keyboardHeld: the terminal input
|
|
2708
|
+
// held the keyboard when the press on this tab's close button began, so
|
|
2709
|
+
// focus returns to it. quiet: the caller (a resync) places what the panes
|
|
2710
|
+
// show next, so no pane transaction or focus move runs.
|
|
2711
|
+
async function dropTab(
|
|
2712
|
+
id: string,
|
|
2713
|
+
opts: { remote: boolean; keyboardHeld?: boolean; quiet?: boolean },
|
|
2714
|
+
): Promise<void> {
|
|
2715
|
+
const { remote, keyboardHeld = false, quiet = false } = opts;
|
|
2519
2716
|
const idx = tabList.findIndex((t) => t.id === id);
|
|
2520
2717
|
if (idx < 0) {
|
|
2521
2718
|
return;
|
|
@@ -2530,7 +2727,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2530
2727
|
// A failed create adds nothing (and toasts): keep the existing tab
|
|
2531
2728
|
// rather than stranding the user on an empty strip.
|
|
2532
2729
|
if (tabList.some((t) => t.id !== id)) {
|
|
2533
|
-
await dropTab(id, true);
|
|
2730
|
+
await dropTab(id, { remote: true });
|
|
2534
2731
|
}
|
|
2535
2732
|
return;
|
|
2536
2733
|
}
|
|
@@ -2556,11 +2753,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2556
2753
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
2557
2754
|
tombstones.add(id);
|
|
2558
2755
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
2559
|
-
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2560
|
-
// acknowledgement goes too — the session is gone, so the entry would only
|
|
2561
|
-
// sit in storage forever.
|
|
2756
|
+
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2562
2757
|
acknowledgeSwitchNotify(id);
|
|
2563
|
-
forgetCueSeen(id);
|
|
2564
2758
|
ctx.shell.notifications.forget(id);
|
|
2565
2759
|
tab.aria.remove();
|
|
2566
2760
|
// Remove immediately (no exit animation): a lingering element made the
|
|
@@ -2573,8 +2767,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2573
2767
|
tab.el.remove();
|
|
2574
2768
|
// Each connection forgets the session exactly once: the showing pane's
|
|
2575
2769
|
// through clearActiveSession (which closes its socket and empties the
|
|
2576
|
-
// pane), every other through dropSessionExcept.
|
|
2577
|
-
|
|
2770
|
+
// pane), every other through dropSessionExcept. A quiet drop empties a
|
|
2771
|
+
// single view's pane too, since no switch will replace what it shows.
|
|
2772
|
+
if (side !== null && (splitOpen || quiet)) {
|
|
2578
2773
|
ctx.shell.pane(side)?.clearActiveSession();
|
|
2579
2774
|
ctx.shell.dropSessionExcept(id, side);
|
|
2580
2775
|
} else {
|
|
@@ -2589,23 +2784,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2589
2784
|
syncChrome(); // reflect the drop immediately (count, position)
|
|
2590
2785
|
// What the panes show next is decided here, before the server's answer:
|
|
2591
2786
|
// 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();
|
|
2787
|
+
if (!quiet) {
|
|
2788
|
+
placeAfterDrop(idx, side, splitOpen);
|
|
2789
|
+
if (typing || keyboardHeld) {
|
|
2790
|
+
focusInput();
|
|
2599
2791
|
}
|
|
2600
|
-
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2601
|
-
if (neighbor) {
|
|
2602
|
-
switchTo(neighbor.id);
|
|
2603
|
-
} else {
|
|
2604
|
-
replacement = create();
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
if (typing || keyboardHeld) {
|
|
2608
|
-
focusInput();
|
|
2609
2792
|
}
|
|
2610
2793
|
if (remote) {
|
|
2611
2794
|
try {
|
|
@@ -2616,11 +2799,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2616
2799
|
}
|
|
2617
2800
|
}
|
|
2618
2801
|
}
|
|
2619
|
-
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// The pane transaction for a dropped tab that `side` showed (null: none).
|
|
2805
|
+
function placeAfterDrop(idx: number, side: PaneSide | null, splitOpen: boolean): void {
|
|
2806
|
+
if (side === null) {
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (splitOpen && shownIn(otherSide(side)) !== null) {
|
|
2810
|
+
ctx.shell.split.closeSide(side);
|
|
2811
|
+
focusAfterSwitch();
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (splitOpen) {
|
|
2815
|
+
ctx.shell.split.close();
|
|
2816
|
+
}
|
|
2817
|
+
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2818
|
+
if (neighbor) {
|
|
2819
|
+
switchTo(neighbor.id);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
// Only the server removes a last tab here (a user's close of it took the
|
|
2823
|
+
// create-first branch of dropTab). Its server either restarted or saw
|
|
2824
|
+
// another device replace it, so settle rather than create a second tab.
|
|
2825
|
+
// Not awaited: this may run inside the reconcile slot itself.
|
|
2826
|
+
void inReconcileSlot(() => resync([]));
|
|
2620
2827
|
}
|
|
2621
2828
|
|
|
2622
2829
|
async function close_(id: string): Promise<void> {
|
|
2623
|
-
await dropTab(id, true);
|
|
2830
|
+
await dropTab(id, { remote: true });
|
|
2624
2831
|
}
|
|
2625
2832
|
|
|
2626
2833
|
// Each tab is a running agent, so closing two or more asks first. This path
|
|
@@ -2651,7 +2858,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2651
2858
|
// re-adopt a just-closed tab.
|
|
2652
2859
|
tombstones.add(t.id);
|
|
2653
2860
|
acknowledgeSwitchNotify(t.id);
|
|
2654
|
-
forgetCueSeen(t.id);
|
|
2655
2861
|
ctx.shell.notifications.forget(t.id);
|
|
2656
2862
|
t.aria.remove();
|
|
2657
2863
|
abortReorderFor(t.el); // a live drag holds this node
|
|
@@ -3087,13 +3293,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3087
3293
|
|
|
3088
3294
|
// Takes the record whole: its title fields are interchangeable strings that
|
|
3089
3295
|
// as positional parameters would be swappable. `reports` travels separately
|
|
3090
|
-
// because it is normalised (reportsOf) first.
|
|
3091
|
-
|
|
3296
|
+
// because it is normalised (reportsOf) first. `source` says whether the
|
|
3297
|
+
// record came off the stream or from a list requested at stream epoch
|
|
3298
|
+
// `listedAt`.
|
|
3299
|
+
function applyStatus(rec: StatusRecord, reports: boolean, source: RecordSource): void {
|
|
3092
3300
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
3093
3301
|
if (!t) {
|
|
3094
3302
|
return;
|
|
3095
3303
|
}
|
|
3096
|
-
|
|
3304
|
+
if ("stream" in source) {
|
|
3305
|
+
t.streamAt = streamEpoch;
|
|
3306
|
+
}
|
|
3307
|
+
// The stream-owned tuple is skipped from a list older than the stream's
|
|
3308
|
+
// last record for the tab, or carrying an older occurrence: anything such a
|
|
3309
|
+
// list could add arrives on the stream anyway, and applying it can hide a
|
|
3310
|
+
// cue no later frame restores.
|
|
3311
|
+
const seq = normalizeSeq(rec.statusSeq);
|
|
3312
|
+
const stale =
|
|
3313
|
+
("listedAt" in source && t.streamAt > source.listedAt) ||
|
|
3314
|
+
(seq !== null && seq < t.statusSeq);
|
|
3315
|
+
if (!stale) {
|
|
3316
|
+
applyStreamTuple(t, rec, reports, seq);
|
|
3317
|
+
}
|
|
3097
3318
|
// The server's shared position. Only an explicitly PRESENT value updates
|
|
3098
3319
|
// the tab, for the same reason as progressValue below: an engine that
|
|
3099
3320
|
// keeps no order sends no field, and reading that absence as "position 0"
|
|
@@ -3108,53 +3329,33 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3108
3329
|
const alias = parseTabAlias(rec.alias) ?? undefined;
|
|
3109
3330
|
if (alias !== t.alias) {
|
|
3110
3331
|
t.alias = alias;
|
|
3111
|
-
|
|
3332
|
+
scheduleAddress("replace");
|
|
3112
3333
|
}
|
|
3113
3334
|
}
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
t.progress = normalizeProgress(rec.progressValue);
|
|
3120
|
-
}
|
|
3121
|
-
paintProgress(t.progressEl, shownProgress(t));
|
|
3122
|
-
// The host's secondary activity, read with the same PRESENT-only guard and
|
|
3123
|
-
// for the same reason: the polling fallback lists SessionInfo, and a
|
|
3124
|
-
// pre-release server carries neither field at all, so reading absence as
|
|
3125
|
-
// "no background task" would blank a live mark on every tick. An empty
|
|
3126
|
-
// string IS the withdrawal, and it arrives explicitly.
|
|
3127
|
-
if (rec.activity !== undefined) {
|
|
3128
|
-
t.activity = normalizeActivity(rec.activity);
|
|
3129
|
-
}
|
|
3130
|
-
if (rec.activityCount !== undefined) {
|
|
3131
|
-
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
3335
|
+
// The seen floor only rises, so a lower value is an older record.
|
|
3336
|
+
const seen = normalizeSeq(rec.seenSeq);
|
|
3337
|
+
if (seen !== null && seen > t.seenSeq) {
|
|
3338
|
+
t.seenSeq = seen;
|
|
3339
|
+
closeReadSurfaces(t);
|
|
3132
3340
|
}
|
|
3133
|
-
|
|
3134
|
-
//
|
|
3135
|
-
// permission request for the next user gesture.
|
|
3341
|
+
// A program that reports activity may also post an OSC 9 notification:
|
|
3342
|
+
// arm the permission request for the next user gesture.
|
|
3136
3343
|
if (reports) {
|
|
3137
3344
|
ctx.shell.notifications.arm();
|
|
3138
3345
|
}
|
|
3139
|
-
// The switch button's latest-wins dot.
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
const cue = cueStatusOf(t);
|
|
3145
|
-
if (isShown(rec.id) && pageVisible() && isCueStatus(raw)) {
|
|
3346
|
+
// The switch button's latest-wins dot. The acknowledgement reads the RAW
|
|
3347
|
+
// status; the raise reads the FOLDED one, so a done held by background
|
|
3348
|
+
// activity raises nothing.
|
|
3349
|
+
const folded = fold(t);
|
|
3350
|
+
if (isShown(rec.id) && pageVisible() && isCueStatus(statusOf(t))) {
|
|
3146
3351
|
// BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
|
|
3147
3352
|
// the one session the user left running on a hidden page, the case the
|
|
3148
3353
|
// out-of-page surfaces exist for.
|
|
3149
|
-
markCueSeen(
|
|
3150
|
-
} else if (
|
|
3151
|
-
|
|
3152
|
-
// the moment the background task ended.
|
|
3153
|
-
} else if (!isCueStatus(cue)) {
|
|
3154
|
-
forgetCueSeen(rec.id);
|
|
3155
|
-
} else if (isUnseenCue(cue, rec.id, cueSeen)) {
|
|
3156
|
-
switchNotify = cue;
|
|
3354
|
+
markCueSeen(t, { retry: false });
|
|
3355
|
+
} else if (isUnread(folded)) {
|
|
3356
|
+
switchNotify = folded.status;
|
|
3157
3357
|
switchNotifyId = rec.id;
|
|
3358
|
+
switchNotifySeq = t.statusSeq;
|
|
3158
3359
|
paintSwitchDot();
|
|
3159
3360
|
}
|
|
3160
3361
|
// A BLANK title (a status sweep, or the process clearing its OSC 0/2
|
|
@@ -3173,24 +3374,99 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3173
3374
|
}
|
|
3174
3375
|
}
|
|
3175
3376
|
|
|
3377
|
+
// The fields only the stream decides: the status and its dot, its
|
|
3378
|
+
// occurrence number, the reveal gate, the percentage and the activity pair.
|
|
3379
|
+
function applyStreamTuple(
|
|
3380
|
+
t: Tab,
|
|
3381
|
+
rec: StatusRecord,
|
|
3382
|
+
reports: boolean,
|
|
3383
|
+
seq: number | null,
|
|
3384
|
+
): void {
|
|
3385
|
+
t.reports = reports;
|
|
3386
|
+
if (seq !== null) {
|
|
3387
|
+
t.statusSeq = seq;
|
|
3388
|
+
}
|
|
3389
|
+
paintStatusDot(t.dot, rec.status, reports);
|
|
3390
|
+
// Only a PRESENT percentage updates the tab: the polling fallback lists
|
|
3391
|
+
// SessionInfo, which carries none, and reading absence as cleared would
|
|
3392
|
+
// blank a live bar every tick. A clear arrives as an explicit -1.
|
|
3393
|
+
if (rec.progressValue !== undefined) {
|
|
3394
|
+
t.progress = normalizeProgress(rec.progressValue);
|
|
3395
|
+
}
|
|
3396
|
+
paintProgress(t.progressEl, shownProgress(t));
|
|
3397
|
+
// The host's secondary activity, read with the same PRESENT-only guard and
|
|
3398
|
+
// for the same reason: the polling fallback lists SessionInfo, and a
|
|
3399
|
+
// pre-release server carries neither field at all, so reading absence as
|
|
3400
|
+
// "no background task" would blank a live mark on every tick. An empty
|
|
3401
|
+
// string IS the withdrawal, and it arrives explicitly.
|
|
3402
|
+
if (rec.activity !== undefined) {
|
|
3403
|
+
t.activity = normalizeActivity(rec.activity);
|
|
3404
|
+
}
|
|
3405
|
+
if (rec.activityCount !== undefined) {
|
|
3406
|
+
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
3407
|
+
}
|
|
3408
|
+
paintActivityMark(t.activityEl, t.activity, t.activityCount);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3176
3411
|
// One list reconcile for both status sources: adopt every session the
|
|
3177
3412
|
// server lists, then drop tabs it no longer lists. The SSE path runs it on
|
|
3178
3413
|
// every (re)open because a RESTARTED manager's snapshot carries no
|
|
3179
3414
|
// tombstones for sessions it never knew, so those tabs would otherwise spin
|
|
3180
|
-
// "Reconnecting…" forever.
|
|
3181
|
-
//
|
|
3415
|
+
// "Reconnecting…" forever. reconcileOnce joins the run in flight, so a
|
|
3416
|
+
// caller that awaits it sees a listing; inReconcileSlot queues behind it.
|
|
3182
3417
|
let reconciling: Promise<void> | null = null;
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3418
|
+
// Runs fn in the single reconcile slot: it never joins, it queues behind the
|
|
3419
|
+
// run in flight. The slot never rejects, so a failed run cannot skip the
|
|
3420
|
+
// runs queued behind it, and only the run still stored clears it.
|
|
3421
|
+
function inReconcileSlot(fn: () => Promise<void>): Promise<void> {
|
|
3422
|
+
const slot: Promise<void> = (reconciling ?? Promise.resolve())
|
|
3423
|
+
.then(() => fn())
|
|
3424
|
+
.catch((err: unknown) => {
|
|
3425
|
+
console.warn("web-terminal-ui: tab reconcile failed", err);
|
|
3426
|
+
})
|
|
3427
|
+
.finally(() => {
|
|
3428
|
+
if (reconciling === slot) {
|
|
3429
|
+
reconciling = null;
|
|
3430
|
+
}
|
|
3431
|
+
});
|
|
3432
|
+
reconciling = slot;
|
|
3433
|
+
return slot;
|
|
3434
|
+
}
|
|
3435
|
+
const reconcileOnce = (): Promise<void> => reconciling ?? inReconcileSlot(reconcileNow);
|
|
3436
|
+
// Placement of a tab arriving on the stream waits until fn ends. Then, unless
|
|
3437
|
+
// a resync ran meanwhile, the held tabs still open are placed as they would
|
|
3438
|
+
// have been; either way a page that booted onto nothing gets its repair.
|
|
3439
|
+
async function withPlacementHold(fn: () => Promise<void>): Promise<void> {
|
|
3440
|
+
holdDepth++;
|
|
3441
|
+
try {
|
|
3442
|
+
await fn();
|
|
3443
|
+
} finally {
|
|
3444
|
+
holdDepth--;
|
|
3445
|
+
if (holdDepth === 0) {
|
|
3446
|
+
const held = holdResynced ? [] : heldPlacements;
|
|
3447
|
+
heldPlacements = [];
|
|
3448
|
+
holdResynced = false;
|
|
3449
|
+
for (const id of held) {
|
|
3450
|
+
if (tabList.some((t) => t.id === id)) {
|
|
3451
|
+
showCreatedElsewhere(id);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
ensureActive();
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
// While settling, the dead tabs a resync drops must write no history entry.
|
|
3459
|
+
function scheduleAddress(mode: "push" | "replace"): void {
|
|
3460
|
+
if (!settling) {
|
|
3461
|
+
address?.schedule(mode);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3189
3464
|
async function reconcileNow(): Promise<void> {
|
|
3190
3465
|
// The listing is authoritative only for tabs that existed when it was
|
|
3191
3466
|
// requested: a tab adopted while the GET was in flight is invisible to
|
|
3192
3467
|
// it, and dropping it here cascaded into a duplicate replacement session.
|
|
3193
3468
|
const epochAtList = tabEpoch;
|
|
3469
|
+
const listedAt = streamEpoch;
|
|
3194
3470
|
let list: SessionInfo[];
|
|
3195
3471
|
try {
|
|
3196
3472
|
list = await api.list();
|
|
@@ -3201,9 +3477,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3201
3477
|
return;
|
|
3202
3478
|
}
|
|
3203
3479
|
const seen = new Set(list.map((s) => s.id));
|
|
3480
|
+
// Every tab the listing could have named is missing from it: the server
|
|
3481
|
+
// restarted (or replaced every tab). Decided before the adopt loop, so the
|
|
3482
|
+
// tabs it lists are placed by the address and the record, not by arrival.
|
|
3483
|
+
// Because this branch takes every such case, the gone loop below drops a
|
|
3484
|
+
// strict subset of the known tabs and never reaches dropTab's last-tab arm.
|
|
3485
|
+
const known = tabList.filter((t) => t.born <= epochAtList).map((t) => t.id);
|
|
3486
|
+
if (known.length > 0 && known.every((id) => !seen.has(id))) {
|
|
3487
|
+
await resync(known);
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3204
3490
|
for (const info of list) {
|
|
3205
3491
|
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
3206
|
-
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3492
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3207
3493
|
}
|
|
3208
3494
|
// Already gone server-side, so no DELETE; tabs born after the snapshot
|
|
3209
3495
|
// wait for the next reconcile.
|
|
@@ -3211,11 +3497,64 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3211
3497
|
.filter((t) => !seen.has(t.id) && t.born <= epochAtList)
|
|
3212
3498
|
.map((t) => t.id);
|
|
3213
3499
|
for (const id of gone) {
|
|
3214
|
-
await dropTab(id, false);
|
|
3500
|
+
await dropTab(id, { remote: false });
|
|
3215
3501
|
}
|
|
3216
3502
|
ensureActive();
|
|
3217
3503
|
syncChrome();
|
|
3218
3504
|
}
|
|
3505
|
+
// Rebuild the page onto a server whose sessions replaced `known` (a restart,
|
|
3506
|
+
// or the server's last tab removed): drop them without placing anything,
|
|
3507
|
+
// settle the new set, then place it the way a boot does, by the address and
|
|
3508
|
+
// the record. Runs inside the reconcile slot.
|
|
3509
|
+
async function resync(known: readonly string[]): Promise<void> {
|
|
3510
|
+
await withPlacementHold(async () => {
|
|
3511
|
+
holdResynced = true;
|
|
3512
|
+
const typing = keyboardInPaneInput();
|
|
3513
|
+
settling = true;
|
|
3514
|
+
let placed: "kept" | "shown" | "none" = "none";
|
|
3515
|
+
try {
|
|
3516
|
+
for (const id of known) {
|
|
3517
|
+
await dropTab(id, { remote: false, quiet: true });
|
|
3518
|
+
}
|
|
3519
|
+
// showSettled reopens a split through restoreSplit, which refuses an
|
|
3520
|
+
// open one; closed under applyingRecord so no record write goes out.
|
|
3521
|
+
if (ctx.shell.split.isOpen()) {
|
|
3522
|
+
applyingRecord = true;
|
|
3523
|
+
try {
|
|
3524
|
+
ctx.shell.split.close();
|
|
3525
|
+
} finally {
|
|
3526
|
+
applyingRecord = false;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
const listedAt = streamEpoch;
|
|
3530
|
+
const settled = await settleOrToast();
|
|
3531
|
+
if (tornDown()) {
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
for (const info of settled.sessions) {
|
|
3535
|
+
adoptSession(info);
|
|
3536
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3537
|
+
}
|
|
3538
|
+
// Read now, so an address the user navigated to during the wait wins.
|
|
3539
|
+
const route =
|
|
3540
|
+
address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
|
|
3541
|
+
// No stale-tab toast: the user did not open this address in this act.
|
|
3542
|
+
placed = showSettled(settled, route, false);
|
|
3543
|
+
} finally {
|
|
3544
|
+
settling = false;
|
|
3545
|
+
if (placed === "none") {
|
|
3546
|
+
bootShowedNothing = true;
|
|
3547
|
+
}
|
|
3548
|
+
if (!tornDown()) {
|
|
3549
|
+
syncChrome();
|
|
3550
|
+
if (typing) {
|
|
3551
|
+
focusInput();
|
|
3552
|
+
}
|
|
3553
|
+
scheduleAddress("replace");
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3219
3558
|
function warnLayoutOnce(what: string, err?: unknown): void {
|
|
3220
3559
|
if (warnedLayout) {
|
|
3221
3560
|
return;
|
|
@@ -3411,7 +3750,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3411
3750
|
syncCatchups();
|
|
3412
3751
|
syncChrome();
|
|
3413
3752
|
scheduleLayoutWrite();
|
|
3414
|
-
|
|
3753
|
+
scheduleAddress("push");
|
|
3415
3754
|
}),
|
|
3416
3755
|
);
|
|
3417
3756
|
ctx.defer(ctx.shell.onSelectionChange(syncChrome));
|
|
@@ -3427,17 +3766,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3427
3766
|
if (monitor) {
|
|
3428
3767
|
const offStatus = monitor.onStatus((s) => {
|
|
3429
3768
|
if (s.removed) {
|
|
3430
|
-
void dropTab(s.id, false); // already gone server-side; no DELETE
|
|
3769
|
+
void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
|
|
3431
3770
|
return;
|
|
3432
3771
|
}
|
|
3772
|
+
streamEpoch++;
|
|
3433
3773
|
adoptSession(s);
|
|
3434
|
-
applyStatus(s, reportsOf(s.reportsActivity));
|
|
3774
|
+
applyStatus(s, reportsOf(s.reportsActivity), { stream: true });
|
|
3435
3775
|
ensureActive();
|
|
3436
3776
|
syncChrome();
|
|
3437
3777
|
// A notification is an EVENT delivered once, so it is handled here and
|
|
3438
3778
|
// not in applyStatus, which also runs on re-delivered STATE. After
|
|
3439
3779
|
// syncChrome, so its title is the label the user would see.
|
|
3440
|
-
ctx.shell.notifications.deliver(s, {
|
|
3780
|
+
const posted = ctx.shell.notifications.deliver(s, {
|
|
3441
3781
|
sessionIsActive: isShown(s.id),
|
|
3442
3782
|
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
3443
3783
|
// switchTo no-ops on an id that is gone, which is the case that
|
|
@@ -3446,10 +3786,27 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3446
3786
|
switchTo(s.id);
|
|
3447
3787
|
},
|
|
3448
3788
|
});
|
|
3789
|
+
const shownFor = posted ? tabList.find((t) => t.id === s.id) : undefined;
|
|
3790
|
+
if (shownFor) {
|
|
3791
|
+
// The event's own number: a skipped tuple leaves the tab's newer.
|
|
3792
|
+
shownFor.bannerSeq = normalizeSeq(s.statusSeq) ?? shownFor.statusSeq;
|
|
3793
|
+
}
|
|
3449
3794
|
});
|
|
3450
3795
|
ctx.defer(offStatus);
|
|
3796
|
+
// The first open joins the boot's reconcile. A reopen queues a fresh one
|
|
3797
|
+
// under a placement hold, so its decision comes from a list issued after
|
|
3798
|
+
// the reopen and a restarted server's tabs are placed by resync.
|
|
3799
|
+
let streamOpens = 0;
|
|
3451
3800
|
const offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
3452
|
-
|
|
3801
|
+
streamOpens++;
|
|
3802
|
+
const settled =
|
|
3803
|
+
streamOpens === 1
|
|
3804
|
+
? reconcileOnce()
|
|
3805
|
+
: withPlacementHold(() => inReconcileSlot(reconcileNow));
|
|
3806
|
+
void settled.then(() => {
|
|
3807
|
+
attention.resync();
|
|
3808
|
+
onPageVisible(); // re-acks the shown tabs, now reconciled
|
|
3809
|
+
});
|
|
3453
3810
|
});
|
|
3454
3811
|
if (offStreamOpen) {
|
|
3455
3812
|
ctx.defer(offStreamOpen);
|
|
@@ -4257,62 +4614,85 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4257
4614
|
toggleSwitcher();
|
|
4258
4615
|
});
|
|
4259
4616
|
|
|
4260
|
-
// The bootstrap (paneLayoutOwner.resolveInitialLayout):
|
|
4261
|
-
//
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4617
|
+
// The bootstrap (paneLayoutOwner.resolveInitialLayout): settle the session
|
|
4618
|
+
// set (creating the first session when nothing live is listed), adopt it,
|
|
4619
|
+
// pick the start tab and show it. A false return (nothing could be listed
|
|
4620
|
+
// or spawned) keeps the chrome up with the "+" retry alive, and the
|
|
4264
4621
|
// terminal dismisses the loading overlay over it. The record is read once
|
|
4265
4622
|
// and never written back by a read.
|
|
4266
4623
|
const resolveInitialLayout = async (): Promise<boolean> => {
|
|
4267
|
-
const
|
|
4624
|
+
const settled = await settleOrToast();
|
|
4268
4625
|
if (tornDown()) {
|
|
4269
4626
|
return false;
|
|
4270
4627
|
}
|
|
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
4628
|
// Adopt rather than push: the status stream's open snapshot may already
|
|
4302
4629
|
// have given a listed session its tab, since the subscription precedes
|
|
4303
4630
|
// this list.
|
|
4304
|
-
for (const info of sessions) {
|
|
4631
|
+
for (const info of settled.sessions) {
|
|
4305
4632
|
adoptSession(info);
|
|
4306
4633
|
}
|
|
4307
4634
|
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
4308
4635
|
started = true;
|
|
4309
|
-
//
|
|
4310
|
-
//
|
|
4311
|
-
|
|
4312
|
-
|
|
4636
|
+
// Read only with the server's list in hand, so a failed list never
|
|
4637
|
+
// declares a tab gone, and only once settled, so a wait keeps its intent.
|
|
4638
|
+
const route = address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
|
|
4639
|
+
switch (showSettled(settled, route, address?.bootOrigin() === "deeplink")) {
|
|
4640
|
+
case "kept":
|
|
4641
|
+
address?.start(settled.listed);
|
|
4642
|
+
return true;
|
|
4643
|
+
case "shown":
|
|
4644
|
+
syncChrome();
|
|
4645
|
+
focusInput();
|
|
4646
|
+
address?.start(settled.listed);
|
|
4647
|
+
return true;
|
|
4648
|
+
case "none":
|
|
4649
|
+
bootShowedNothing = true;
|
|
4650
|
+
address?.start(false);
|
|
4651
|
+
return false;
|
|
4652
|
+
}
|
|
4653
|
+
};
|
|
4654
|
+
|
|
4655
|
+
// settleSessionSet, toasting a failure the way a failed create always has
|
|
4656
|
+
// and carrying on with whatever was listed.
|
|
4657
|
+
async function settleOrToast(): Promise<Settled> {
|
|
4658
|
+
try {
|
|
4659
|
+
return await settleSessionSet(api, ctx, lifetime.signal, warnLayoutOnce);
|
|
4660
|
+
} catch (err) {
|
|
4661
|
+
if (tornDown()) {
|
|
4662
|
+
return NOTHING_SETTLED;
|
|
4663
|
+
}
|
|
4664
|
+
const failure = err instanceof SettleCreateFailed ? err : null;
|
|
4665
|
+
toastCreateFailure(failure === null ? err : failure.cause);
|
|
4666
|
+
return failure?.settled ?? NOTHING_SETTLED;
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
// The give-up path (a 503 was already retried on the server's schedule):
|
|
4671
|
+
// toast with the server's explanation and leave the chrome up so "+" can
|
|
4672
|
+
// retry.
|
|
4673
|
+
function toastCreateFailure(err: unknown): void {
|
|
4674
|
+
ctx.toast(
|
|
4675
|
+
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4676
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
4677
|
+
: "Could not open a terminal",
|
|
4678
|
+
);
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
// Place a settled set: "kept" when a pane already shows a tab (a tap while
|
|
4682
|
+
// the list was in flight outranks the record), "shown" when the address,
|
|
4683
|
+
// the record or a fallback placed one, "none" when nothing could be shown.
|
|
4684
|
+
// toastStale says whether an address naming a closed tab is news to the user.
|
|
4685
|
+
function showSettled(
|
|
4686
|
+
settled: Settled,
|
|
4687
|
+
route: TabRoute | null,
|
|
4688
|
+
toastStale: boolean,
|
|
4689
|
+
): "kept" | "shown" | "none" {
|
|
4690
|
+
const { sessions, layout } = settled;
|
|
4691
|
+
// ensureActive is deliberately NOT one of this function's callers during
|
|
4692
|
+
// boot (see its bootShowedNothing gate), or the SSE snapshot would win
|
|
4693
|
+
// this race on every load.
|
|
4313
4694
|
if (anyShown()) {
|
|
4314
|
-
|
|
4315
|
-
return true;
|
|
4695
|
+
return "kept";
|
|
4316
4696
|
}
|
|
4317
4697
|
// The record's sessions where they still exist, else the oldest LIVE tab:
|
|
4318
4698
|
// a reload must not restore onto the corpse of a session that died while
|
|
@@ -4337,13 +4717,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4337
4717
|
};
|
|
4338
4718
|
let shown = false;
|
|
4339
4719
|
// 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) {
|
|
4720
|
+
// explicit instruction for this load.
|
|
4721
|
+
if (route !== null) {
|
|
4347
4722
|
const [wantLeft, wantRight] =
|
|
4348
4723
|
route.kind === "single" ? [route.tab, null] : [route.left, route.right];
|
|
4349
4724
|
const fromRoute = (alias: TabAlias | null): Tab | undefined => {
|
|
@@ -4358,7 +4733,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4358
4733
|
// refused here only because a boot never lands on a corpse.
|
|
4359
4734
|
const gone = (alias: TabAlias | null): boolean =>
|
|
4360
4735
|
alias !== null && tabByAlias(alias) === undefined;
|
|
4361
|
-
if ((gone(wantLeft) || gone(wantRight)) &&
|
|
4736
|
+
if ((gone(wantLeft) || gone(wantRight)) && toastStale) {
|
|
4362
4737
|
ctx.toast(STALE_TAB_NOTICE);
|
|
4363
4738
|
}
|
|
4364
4739
|
applyingRecord = true;
|
|
@@ -4427,16 +4802,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4427
4802
|
tabList[0];
|
|
4428
4803
|
shown = startTab !== undefined && showAtBoot(ctx.shell.targetFor(startTab.id), startTab);
|
|
4429
4804
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
address?.start(false);
|
|
4433
|
-
return false;
|
|
4434
|
-
}
|
|
4435
|
-
syncChrome();
|
|
4436
|
-
focusInput();
|
|
4437
|
-
address?.start(listed.status === "fulfilled");
|
|
4438
|
-
return true;
|
|
4439
|
-
};
|
|
4805
|
+
return shown ? "shown" : "none";
|
|
4806
|
+
}
|
|
4440
4807
|
live = { resolveInitialLayout, shownIn, showIn };
|
|
4441
4808
|
|
|
4442
4809
|
return {
|