@cplieger/web-terminal-ui 5.2.2 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -11
- package/css/00-tokens.css +3 -3
- package/css/02-terminal.css +38 -7
- package/css/30-tabs.css +79 -18
- package/css/31-switcher.css +23 -1
- package/css/40-animations.css +18 -0
- package/css/page.css +8 -8
- package/package.json +23 -7
- package/src/features/context-menu.ts +159 -197
- package/src/features/menu-position.ts +13 -7
- package/src/features/scroll-to-bottom.ts +16 -5
- package/src/features/tabs/index.ts +177 -51
- package/src/features/tabs/ink-centre.ts +1 -1
- package/src/features/tabs/model.ts +15 -2
- package/src/index.ts +4 -0
- package/src/kernel/gesture.ts +32 -0
- package/src/kernel/kernel.ts +431 -18
- package/src/kernel/options.ts +58 -0
- package/src/kernel/scrollback-storage.ts +316 -0
- package/src/kernel/scrollback.ts +333 -0
- package/src/kernel/types.ts +209 -4
- package/src/viewport.ts +29 -12
package/src/kernel/kernel.ts
CHANGED
|
@@ -20,14 +20,24 @@
|
|
|
20
20
|
// default the previous UI already had); adding mouse tracking with focus
|
|
21
21
|
// suppression is a tracked follow-up.
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
render,
|
|
25
|
+
scroll,
|
|
26
|
+
connection,
|
|
27
|
+
keyboard,
|
|
28
|
+
modes,
|
|
29
|
+
LineStore,
|
|
30
|
+
} from "@cplieger/web-terminal-engine";
|
|
24
31
|
import * as composition from "../composition.js";
|
|
25
32
|
import * as viewport from "../viewport.js";
|
|
26
33
|
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
|
|
27
34
|
import { createBus } from "./bus.js";
|
|
35
|
+
import { optionalPositiveIntOption } from "./options.js";
|
|
36
|
+
import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
|
|
28
37
|
import { createRegions } from "./regions.js";
|
|
29
38
|
import { createAnnouncer, createTablist } from "./a11y.js";
|
|
30
39
|
import { createConnState } from "./conn-state.js";
|
|
40
|
+
import { createScrollbackKeeper } from "./scrollback.js";
|
|
31
41
|
import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
|
|
32
42
|
import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
|
|
33
43
|
import type {
|
|
@@ -43,14 +53,15 @@ import type {
|
|
|
43
53
|
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
44
54
|
|
|
45
55
|
const DEFAULT_WS_PATH = "/ws";
|
|
46
|
-
const DEFAULT_FONT_READY = '14px "
|
|
56
|
+
const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
|
|
57
|
+
|
|
47
58
|
const TOAST_MS = 3000;
|
|
48
|
-
const TAP_MOVEMENT_PX = 10;
|
|
49
59
|
// A touch that focuses the input (opens the soft keyboard) must be a genuine
|
|
50
60
|
// tap: short and low-movement. A longer hold is a long-press, which belongs to
|
|
51
61
|
// native text selection / the context menu — so tap-to-focus bows out above
|
|
52
|
-
// this duration and never steals a long-press or a selection.
|
|
53
|
-
|
|
62
|
+
// this duration and never steals a long-press or a selection. The thresholds
|
|
63
|
+
// live in gesture.ts because the contextMenu feature classifies the other side
|
|
64
|
+
// of the same boundary from them.
|
|
54
65
|
// The narrow-layout breakpoints, in ROOT pixels. The single source of the
|
|
55
66
|
// numbers: the kernel mirrors the resulting fact into the .wt-narrow root
|
|
56
67
|
// class for CSS (paired there with pointer-coarseness media queries where
|
|
@@ -166,6 +177,21 @@ function buildTerminal(
|
|
|
166
177
|
): TerminalHandle {
|
|
167
178
|
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
168
179
|
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
180
|
+
// The one resolved retained-line cap: the engine renderer's implicit store
|
|
181
|
+
// and every ctx.newLineStore() (the tabs switching cache) both honor it, so
|
|
182
|
+
// a consumer sets its memory budget in exactly one place.
|
|
183
|
+
// Invalid reads as OMITTED, so the engine's own default applies — and the engine
|
|
184
|
+
// additionally floors the cap at the live screen, so even a tiny valid value
|
|
185
|
+
// cannot truncate the visible window.
|
|
186
|
+
const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
|
|
187
|
+
// Scrollback persistence (off unless the consumer supplied storage). One
|
|
188
|
+
// keeper serves the whole terminal: the kernel's implicit store below and
|
|
189
|
+
// every per-session store a session-owning feature creates through
|
|
190
|
+
// ctx.newLineStore(sessionId).
|
|
191
|
+
const scrollbackKeeper =
|
|
192
|
+
opts.persistScrollback !== undefined
|
|
193
|
+
? createScrollbackKeeper(opts.persistScrollback, scrollbackLines)
|
|
194
|
+
: null;
|
|
169
195
|
// At most one feature owns session selection (fail fast, before any DOM
|
|
170
196
|
// work): the kernel drives the first connect through its registration.
|
|
171
197
|
const owners = featureList.filter((f) => f.sessionOwner !== undefined);
|
|
@@ -330,6 +356,89 @@ function buildTerminal(
|
|
|
330
356
|
|
|
331
357
|
// --- Loading lifecycle ---
|
|
332
358
|
let ready = false;
|
|
359
|
+
/**
|
|
360
|
+
* Every LineStore this kernel hands out, held WEAKLY and keyed by session. A
|
|
361
|
+
* closed tab's store must stay collectable, and a weak registry cannot leak or
|
|
362
|
+
* go stale the way a strong map paired with an unregister call can — the store
|
|
363
|
+
* outlives the tab record in more than one teardown order.
|
|
364
|
+
*
|
|
365
|
+
* Keyed rather than a bare set because two features need to reach a SPECIFIC
|
|
366
|
+
* session's store, not just "all of them": the restore guard below, and the
|
|
367
|
+
* browse-cache sweep. The implicit store (a bare kernel with no session owner)
|
|
368
|
+
* has no id, so it takes a reserved key no session can collide with.
|
|
369
|
+
*/
|
|
370
|
+
const knownStores = new Map<string, WeakRef<LineStore>>();
|
|
371
|
+
const IMPLICIT_STORE_KEY = "\u0000implicit";
|
|
372
|
+
/** The live stores, pruning entries whose store has been collected. */
|
|
373
|
+
function liveStores(): LineStore[] {
|
|
374
|
+
const out: LineStore[] = [];
|
|
375
|
+
for (const [key, ref] of knownStores) {
|
|
376
|
+
const store = ref.deref();
|
|
377
|
+
if (store === undefined) {
|
|
378
|
+
knownStores.delete(key); // the tab closed and its store was collected
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
out.push(store);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Which restored scrollbacks have not yet been confirmed against the live
|
|
387
|
+
// server. A session enters when its snapshot is hydrated and leaves on the
|
|
388
|
+
// first resumeAck for THAT session; in between, the content is last session's —
|
|
389
|
+
// plausible but unconfirmed.
|
|
390
|
+
//
|
|
391
|
+
// Per SESSION, and that is the whole point. Two booleans stood here, and under
|
|
392
|
+
// the tabs preset (which every reference app uses) they could not do the job:
|
|
393
|
+
// N sessions are hydrated at boot and all set the same flag, the active
|
|
394
|
+
// session's first ack cleared it for everyone, and the reset reached only the
|
|
395
|
+
// renderer-BOUND store. So tabs 2..N were neither verified nor discardable for
|
|
396
|
+
// the life of the page, and the exact failure this guard exists to prevent —
|
|
397
|
+
// the previous run's output under a "Session ended" banner — stayed reachable
|
|
398
|
+
// by switching tabs. The claimed invariant ("only ever discards a restore,
|
|
399
|
+
// never live content") also did not hold: a background tab's restore
|
|
400
|
+
// authorised a reset of the bound tab's store.
|
|
401
|
+
const unverifiedRestores = new Set<string>();
|
|
402
|
+
/** Drop restored scrollback that will never be confirmed.
|
|
403
|
+
*
|
|
404
|
+
* The epoch check makes a stale restore self-correcting ONLY when a resumeAck
|
|
405
|
+
* arrives: that is what compares the seeded epoch and fires the restart reset.
|
|
406
|
+
* Two closes reach `markReady` without one — a process-exited 4001 (the session
|
|
407
|
+
* is gone, which is what a container restart leaves behind) and a wire-revision
|
|
408
|
+
* refusal (a cached client one revision behind, which is what an image update
|
|
409
|
+
* leaves behind). Both are exactly the restart case, and without this the
|
|
410
|
+
* overlay lifts over the PREVIOUS run's output under a "Session ended" banner —
|
|
411
|
+
* the one path where the design's stated worst case was reachable.
|
|
412
|
+
*
|
|
413
|
+
* SCOPE matters because the two triggers differ: a 4001 says one session's
|
|
414
|
+
* process is gone, so only that session's restore is condemned; a wire refusal
|
|
415
|
+
* says this client cannot talk to this server at all, so every unverified
|
|
416
|
+
* restore is. Discarding page-wide on a 4001 would wipe restores for sessions
|
|
417
|
+
* that are perfectly alive.
|
|
418
|
+
*
|
|
419
|
+
* Only ever discards a restore, never live content: a session leaves the set
|
|
420
|
+
* the moment its resume is served, and a session that was never hydrated never
|
|
421
|
+
* enters it. */
|
|
422
|
+
function discardUnverifiedRestore(scope: { session: string } | "page"): void {
|
|
423
|
+
const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
|
|
424
|
+
const bound = render.boundStore();
|
|
425
|
+
for (const id of ids) {
|
|
426
|
+
if (!unverifiedRestores.delete(id)) {
|
|
427
|
+
continue; // never hydrated, or already verified
|
|
428
|
+
}
|
|
429
|
+
const store = knownStores.get(id)?.deref();
|
|
430
|
+
if (store === undefined || store === bound) {
|
|
431
|
+
// The BOUND store goes through the renderer so the DOM is reconciled;
|
|
432
|
+
// an unknown id can only be the implicit store, which is also bound.
|
|
433
|
+
render.resetScrollback();
|
|
434
|
+
render.resetScreen();
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
// A background store has no DOM: resetting it is enough, and the next
|
|
438
|
+
// bind rebuilds from whatever it then holds.
|
|
439
|
+
store.reset();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
333
442
|
let firstFrameRendered = false;
|
|
334
443
|
let fontsLoaded = false;
|
|
335
444
|
let wsOpen = false;
|
|
@@ -376,6 +485,14 @@ function buildTerminal(
|
|
|
376
485
|
composition.positionCompositionView();
|
|
377
486
|
bus.emit("render:cursor", undefined);
|
|
378
487
|
},
|
|
488
|
+
// Demand-paged scrollback (engine docs/paged-scrollback.md §5.4). The
|
|
489
|
+
// renderer owns the DECISION (which gap the reader is approaching, and how
|
|
490
|
+
// much to ask for); the transport owns the request. Wrapped rather than
|
|
491
|
+
// passed by reference so neither module's identity leaks into the other's
|
|
492
|
+
// options object.
|
|
493
|
+
requestHistory: (fromAbs, maxLines) => connection.requestHistory(fromAbs, maxLines),
|
|
494
|
+
historyBudget: () => connection.historyBudget(),
|
|
495
|
+
...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
|
|
379
496
|
});
|
|
380
497
|
render.updateFontMetrics();
|
|
381
498
|
|
|
@@ -392,20 +509,84 @@ function buildTerminal(
|
|
|
392
509
|
onUserScrollChange(scrolledUp) {
|
|
393
510
|
bus.emit("scroll:state", { scrolledUp });
|
|
394
511
|
},
|
|
512
|
+
// Every position change, not only a follow/hold toggle: a reader scrolling
|
|
513
|
+
// WITHIN history never toggles follow, and that is exactly when paging has
|
|
514
|
+
// to work. The trigger re-evaluates its own guards, so firing for the
|
|
515
|
+
// browser's own clamps as well as gestures costs nothing.
|
|
516
|
+
onScrollPosition: () => {
|
|
517
|
+
render.maybeFetchHistory();
|
|
518
|
+
},
|
|
395
519
|
});
|
|
396
520
|
|
|
521
|
+
// The size this client would announce right now, or null when it cannot
|
|
522
|
+
// measure trustworthily yet. Both the resume-time announce (the engine's
|
|
523
|
+
// Callbacks.initialSize) and the open-time send below read it, so there is one
|
|
524
|
+
// definition of "measurable" rather than two that can disagree.
|
|
525
|
+
//
|
|
526
|
+
// Unmeasurable means either web fonts are still loading (cell metrics would be
|
|
527
|
+
// wrong, so cols/rows would be) or a viewport transition is in flight (an iOS
|
|
528
|
+
// keyboard slide or rotation, whose intermediate geometry is provisional). In
|
|
529
|
+
// both cases announcing costs a second resize once the real size is known, and
|
|
530
|
+
// therefore a second redraw from any program that repaints on SIGWINCH.
|
|
531
|
+
function measurableSize(): { cols: number; rows: number } | null {
|
|
532
|
+
if (!fontsLoaded || viewport.isInTransition()) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
render.updateFontMetrics();
|
|
536
|
+
return render.computeSize();
|
|
537
|
+
}
|
|
538
|
+
|
|
397
539
|
function maybeSendFirstResize(): void {
|
|
398
|
-
if (!
|
|
540
|
+
if (!wsOpen || measurableSize() === null) {
|
|
399
541
|
return;
|
|
400
542
|
}
|
|
401
|
-
render.updateFontMetrics();
|
|
402
543
|
connection.sendResize();
|
|
403
544
|
}
|
|
404
545
|
|
|
405
546
|
connection.init({
|
|
406
547
|
computeSize: render.computeSize,
|
|
407
548
|
getHaveThrough: render.getHighestIndex,
|
|
408
|
-
|
|
549
|
+
// --- Demand-paged scrollback (engine docs/paged-scrollback.md §4-5) ---
|
|
550
|
+
// The transport is store-blind and viewport-blind by design, so every one of
|
|
551
|
+
// these forwards a decision only the renderer can make. Without them the
|
|
552
|
+
// engine's paging machinery is inert: the server still declares the
|
|
553
|
+
// capability and still bounds its replay, but nothing ever asks for a page.
|
|
554
|
+
//
|
|
555
|
+
// How much replay this client wants on attach: no more than it intends to
|
|
556
|
+
// keep resident. The server bounds the replay regardless, and the client
|
|
557
|
+
// sends the same number, so its replay-jump prediction and the server's
|
|
558
|
+
// actual start agree.
|
|
559
|
+
getReplayMax: render.replayMaxForResume,
|
|
560
|
+
onHistoryReply: render.handleHistoryReply,
|
|
561
|
+
// ONE transition per ack, carrying the values this socket SENT. It must not
|
|
562
|
+
// be split into separate callbacks: the store's five steps (epoch reset,
|
|
563
|
+
// bounds, cap flip, replay-jump prediction, budget pass) are ordered against
|
|
564
|
+
// each other, and an implementer with five hooks can interleave them.
|
|
565
|
+
onResumeTransition: render.applyResumeTransition,
|
|
566
|
+
// The solicited window: the store's permission to admit lines below its
|
|
567
|
+
// stale-re-send watermark, opened for exactly the range in flight and closed
|
|
568
|
+
// when the reply lands.
|
|
569
|
+
noteSolicited: render.noteSolicited,
|
|
570
|
+
clearSolicited: render.clearSolicited,
|
|
571
|
+
// A denied request (paced by the token bucket, or a data timeout) re-runs the
|
|
572
|
+
// FULL trigger rather than replaying the denied range: by the time the bucket
|
|
573
|
+
// refills the gap may have healed, or the session may have entered alt.
|
|
574
|
+
onHistoryRetry: () => {
|
|
575
|
+
render.maybeFetchHistory();
|
|
576
|
+
},
|
|
577
|
+
onResumeBounds(committed, oldest) {
|
|
578
|
+
// A resume VERIFIES the restored store of the session it belongs to: the
|
|
579
|
+
// server has answered under a known epoch, so that content is confirmed
|
|
580
|
+
// rather than merely plausible. Recorded here because onResumeBounds rides
|
|
581
|
+
// every resumeAck, which is also where the epoch comparison happens. Scoped
|
|
582
|
+
// to the socket's OWN session — one ack cannot vouch for a tab it never
|
|
583
|
+
// talked to.
|
|
584
|
+
unverifiedRestores.delete(connection.currentSessionId());
|
|
585
|
+
render.noteResumeBounds(committed, oldest);
|
|
586
|
+
},
|
|
587
|
+
// Announced BEFORE the resume so the server's snapshot and history replay
|
|
588
|
+
// come back at this client's geometry (engine Callbacks.initialSize).
|
|
589
|
+
initialSize: measurableSize,
|
|
409
590
|
wsPath,
|
|
410
591
|
onMessage(msg) {
|
|
411
592
|
if (msg.type === "screen") {
|
|
@@ -459,6 +640,7 @@ function buildTerminal(
|
|
|
459
640
|
// wedge. Then surface the end state: "Session ended", not a flapping
|
|
460
641
|
// "Reconnecting…", since no reconnect is coming. The final screen (when
|
|
461
642
|
// the server delivered it) stays rendered behind the banner.
|
|
643
|
+
discardUnverifiedRestore({ session: connection.currentSessionId() });
|
|
462
644
|
markReady();
|
|
463
645
|
connState.ended();
|
|
464
646
|
},
|
|
@@ -466,6 +648,7 @@ function buildTerminal(
|
|
|
466
648
|
// Compatibility refusal is terminal for this page instance. Lower the
|
|
467
649
|
// pre-JS overlay even if no screen frame arrived, then leave the
|
|
468
650
|
// actionable banner visible while the engine suppresses reconnects.
|
|
651
|
+
discardUnverifiedRestore("page");
|
|
469
652
|
markReady();
|
|
470
653
|
connState.incompatible();
|
|
471
654
|
},
|
|
@@ -614,7 +797,7 @@ function buildTerminal(
|
|
|
614
797
|
if (e.pointerType !== "touch") {
|
|
615
798
|
return;
|
|
616
799
|
}
|
|
617
|
-
if ((e.target
|
|
800
|
+
if (isLinkTarget(e.target)) {
|
|
618
801
|
return;
|
|
619
802
|
}
|
|
620
803
|
const dx = Math.abs(e.clientX - pointerDownX);
|
|
@@ -657,13 +840,47 @@ function buildTerminal(
|
|
|
657
840
|
termWrap.addEventListener(
|
|
658
841
|
"mousedown",
|
|
659
842
|
(e) => {
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
if (lastPointerType === "touch"
|
|
666
|
-
|
|
843
|
+
// Touch is the platform's press, start to finish: its own selection UI
|
|
844
|
+
// (long-press word select, the drag handles, the OS callout) owns a press
|
|
845
|
+
// that lands on a selection, and it never uses HTML5 drag-and-drop. So a
|
|
846
|
+
// touch press gets nothing here but the keyboard preservation below, and
|
|
847
|
+
// the selection policy that follows is deliberately mouse-and-pen only.
|
|
848
|
+
if (lastPointerType === "touch") {
|
|
849
|
+
// Cancel the synthetic mousedown after a touch tap so iOS keeps the
|
|
850
|
+
// keyboard up (xterm.js focus-preservation pattern, scoped to touch).
|
|
851
|
+
// Skip it when a fine pointer is present (iPad + trackpad / Magic
|
|
852
|
+
// Keyboard): there is no soft keyboard to protect, and suppressing the
|
|
853
|
+
// mousedown was defeating the native focus, so the terminal needed
|
|
854
|
+
// several taps to focus.
|
|
855
|
+
if (!hasFinePointer()) {
|
|
856
|
+
e.preventDefault();
|
|
857
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// Terminal text is display-only, never draggable content — but a browser
|
|
861
|
+
// does not know that, and a bare left press that lands INSIDE the current
|
|
862
|
+
// selection is the one gesture where it matters: Blink and Gecko both read
|
|
863
|
+
// it as the start of a native drag-and-drop of the selected text, so the
|
|
864
|
+
// press neither collapses the selection nor begins a new one, and the
|
|
865
|
+
// release drops nothing (there is no drop target). Drag over text you just
|
|
866
|
+
// selected and the selection is stuck: no new selection, and not even a
|
|
867
|
+
// click clears it, because a real mouse always moves a pixel or two and
|
|
868
|
+
// that is enough to re-enter the drag path. Collapsing the selection here,
|
|
869
|
+
// before the browser resolves the gesture, is what keeps it out: with
|
|
870
|
+
// nothing selected under the press both engines take their ordinary select
|
|
871
|
+
// path and the drag is never considered. A press OUTSIDE the selection
|
|
872
|
+
// collapses it natively anyway, so this only ever pre-empts the browser,
|
|
873
|
+
// never contradicts it — no hit test against the selection needed.
|
|
874
|
+
// Non-left and modified presses are left alone: a right-click has to keep
|
|
875
|
+
// the selection for the context menu's Copy, a middle-click for the
|
|
876
|
+
// primary selection it pastes, Shift-click extends a selection, and
|
|
877
|
+
// Ctrl-drag adds a second range in Firefox.
|
|
878
|
+
if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const pressSel = window.getSelection();
|
|
882
|
+
if (pressSel && !pressSel.isCollapsed) {
|
|
883
|
+
pressSel.removeAllRanges();
|
|
667
884
|
}
|
|
668
885
|
},
|
|
669
886
|
{ signal },
|
|
@@ -695,8 +912,11 @@ function buildTerminal(
|
|
|
695
912
|
root,
|
|
696
913
|
suppressKeyboardInset: hasFinePointer,
|
|
697
914
|
onSettled() {
|
|
698
|
-
|
|
699
|
-
|
|
915
|
+
// The settle is the authoritative size for this geometry: measurableSize
|
|
916
|
+
// re-measures the font metrics and now reports a viewport that has stopped
|
|
917
|
+
// moving, so this is the one resize a keyboard slide or rotation should
|
|
918
|
+
// cost. sendResize deduplicates, so a settle that changed nothing is free.
|
|
919
|
+
if (measurableSize() !== null) {
|
|
700
920
|
connection.sendResize();
|
|
701
921
|
}
|
|
702
922
|
composition.positionCompositionView();
|
|
@@ -726,16 +946,124 @@ function buildTerminal(
|
|
|
726
946
|
onFontSettled();
|
|
727
947
|
}
|
|
728
948
|
|
|
949
|
+
// --- Browse-cache TTL (engine docs/paged-scrollback.md §5.6) ---
|
|
950
|
+
//
|
|
951
|
+
// Paged-in history is disposable by construction: recovery is one fetch. The
|
|
952
|
+
// ENGINE provides the mechanism and this layer owns the clock, because the
|
|
953
|
+
// engine has no notion of a page or a tab. Eviction is by INACTIVITY, never
|
|
954
|
+
// eagerly, so that rapid scrolling in any direction stays instant.
|
|
955
|
+
//
|
|
956
|
+
// The visible-page drop is CONDITIONAL inside the store: a reader parked on
|
|
957
|
+
// cached rows is inactive while looking straight at them, so the store skips
|
|
958
|
+
// and this timer retries. A hidden page has no reader, so its drop is
|
|
959
|
+
// unconditional.
|
|
960
|
+
const BROWSE_CACHE_TTL_MS = 5 * 60_000;
|
|
961
|
+
/**
|
|
962
|
+
* Every store EXCEPT the renderer-bound one, pruning refs whose store has been
|
|
963
|
+
* collected. A background tab has no reader, which is what lets both callers
|
|
964
|
+
* below drop its cache without a position to protect.
|
|
965
|
+
*/
|
|
966
|
+
const backgroundStores = (bound: LineStore): LineStore[] => {
|
|
967
|
+
const out: LineStore[] = [];
|
|
968
|
+
for (const store of liveStores()) {
|
|
969
|
+
if (store !== bound) {
|
|
970
|
+
out.push(store);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return out;
|
|
974
|
+
};
|
|
975
|
+
const browseSweep = window.setInterval(() => {
|
|
976
|
+
// The BOUND store is the one with a reader, so its drop is conditional and
|
|
977
|
+
// goes through the renderer — the only layer that knows where that reader is.
|
|
978
|
+
const bound = render.boundStore();
|
|
979
|
+
if (
|
|
980
|
+
render.browseCacheSize() > 0 &&
|
|
981
|
+
Date.now() - render.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
982
|
+
) {
|
|
983
|
+
render.dropBrowseCache(document.visibilityState === "visible");
|
|
984
|
+
}
|
|
985
|
+
// Every OTHER store belongs to a background tab. Without this pass the sweep
|
|
986
|
+
// only ever reached the visible tab and every background tab's cache was
|
|
987
|
+
// immortal for the life of the page — up to the engine's whole cache budget
|
|
988
|
+
// per tab, on the phone this feature exists for.
|
|
989
|
+
for (const store of backgroundStores(bound)) {
|
|
990
|
+
if (
|
|
991
|
+
store.browseCacheSize() > 0 &&
|
|
992
|
+
Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
|
|
993
|
+
) {
|
|
994
|
+
store.dropBrowseCache(-1, false); // no reader: no position to exempt
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}, 60_000);
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Drop every cache unconditionally, TTL ignored. The LAST CHANCE handler: it
|
|
1001
|
+
* runs when the page is about to stop executing entirely, which is the one
|
|
1002
|
+
* state the sweep above cannot cover — a frozen page runs no code at all, so
|
|
1003
|
+
* without this its caches stay resident for as long as the freeze lasts, and a
|
|
1004
|
+
* discard then throws them away unread. Ten to twenty megabytes (estimate), for
|
|
1005
|
+
* an unbounded time, for nobody.
|
|
1006
|
+
*
|
|
1007
|
+
* Unconditional is right HERE and wrong on the return transition, and the
|
|
1008
|
+
* difference is which way the reader is walking. `visibilitychange` to visible
|
|
1009
|
+
* fires as a reader ARRIVES, so dropping there deletes the rows they are about
|
|
1010
|
+
* to look at (that drop existed and was removed). `freeze` fires as the page
|
|
1011
|
+
* stops being a running program, with no reader and none imminent — and the
|
|
1012
|
+
* cache is disposable by construction, so the cost of being wrong is one fetch
|
|
1013
|
+
* on a return that may never come.
|
|
1014
|
+
*/
|
|
1015
|
+
const dropEveryBrowseCache = (): void => {
|
|
1016
|
+
const bound = render.boundStore();
|
|
1017
|
+
render.dropBrowseCache(false); // through the renderer: it schedules the reconcile
|
|
1018
|
+
for (const store of backgroundStores(bound)) {
|
|
1019
|
+
store.dropBrowseCache(-1, false);
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
// Two hooks for one state, because no single one covers both engines: `freeze`
|
|
1023
|
+
// is Chrome's Page Lifecycle signal, and `pagehide` with `persisted` is entry
|
|
1024
|
+
// into the back/forward cache — the Safari path, on the platform this feature
|
|
1025
|
+
// is for. Either can fire without the other.
|
|
1026
|
+
document.addEventListener(
|
|
1027
|
+
"freeze",
|
|
1028
|
+
() => {
|
|
1029
|
+
// Persist BEFORE dropping. The keeper already wrote on `visibilitychange`
|
|
1030
|
+
// to hidden, but a hidden page's socket keeps delivering, so the tail can
|
|
1031
|
+
// have advanced since — and `freeze` is the last code that runs, so a write
|
|
1032
|
+
// skipped here is a write that never happens. The order matters only in that
|
|
1033
|
+
// direction: the snapshot excludes browse cache by classification, so
|
|
1034
|
+
// flushing first costs nothing and dropping first could lose a page of live
|
|
1035
|
+
// tail on a page that never wakes.
|
|
1036
|
+
scrollbackKeeper?.flush();
|
|
1037
|
+
dropEveryBrowseCache();
|
|
1038
|
+
},
|
|
1039
|
+
{ signal },
|
|
1040
|
+
);
|
|
1041
|
+
signal.addEventListener("abort", () => {
|
|
1042
|
+
clearInterval(browseSweep);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
729
1045
|
// --- Reconnect-on-wake ---
|
|
730
1046
|
document.addEventListener(
|
|
731
1047
|
"visibilitychange",
|
|
732
1048
|
() => {
|
|
733
1049
|
if (document.visibilityState === "visible") {
|
|
1050
|
+
// No cache drop on the RETURN transition. An earlier version enforced the
|
|
1051
|
+
// TTL the throttled hidden period owed, with hidden-page semantics
|
|
1052
|
+
// (unconditional) — which deleted the rows the returning reader was
|
|
1053
|
+
// parked on, in the one moment they are certain to look at them. The page
|
|
1054
|
+
// is visible again the instant this fires, so the visible-page rule
|
|
1055
|
+
// applies, and that is exactly what the periodic sweep does within its
|
|
1056
|
+
// next tick. There is nothing left for this branch to add.
|
|
734
1057
|
if (connectionInitiated) {
|
|
735
1058
|
connection.reconnectNow();
|
|
736
1059
|
}
|
|
737
1060
|
focusTerminal();
|
|
1061
|
+
return;
|
|
738
1062
|
}
|
|
1063
|
+
// Hidden. This and pagehide are the last callbacks that reliably run
|
|
1064
|
+
// before a discard, and a discard is the case scrollback persistence
|
|
1065
|
+
// exists for, so write now rather than waiting for the background pass.
|
|
1066
|
+
scrollbackKeeper?.flush();
|
|
739
1067
|
},
|
|
740
1068
|
{ signal },
|
|
741
1069
|
);
|
|
@@ -749,6 +1077,22 @@ function buildTerminal(
|
|
|
749
1077
|
},
|
|
750
1078
|
{ signal },
|
|
751
1079
|
);
|
|
1080
|
+
window.addEventListener(
|
|
1081
|
+
"pagehide",
|
|
1082
|
+
(event) => {
|
|
1083
|
+
// Both halves of the last-chance write: visibilitychange fires when the
|
|
1084
|
+
// page is backgrounded, pagehide when it is unloaded or frozen into
|
|
1085
|
+
// bfcache. Chrome's page-lifecycle guidance is explicit that pagehide is
|
|
1086
|
+
// not guaranteed to run, which is why the keeper also saves on a timer.
|
|
1087
|
+
scrollbackKeeper?.flush();
|
|
1088
|
+
if (event.persisted) {
|
|
1089
|
+
// Into the back/forward cache: the page is frozen, so this is the last
|
|
1090
|
+
// code that runs until it is restored — if it ever is.
|
|
1091
|
+
dropEveryBrowseCache();
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
{ signal },
|
|
1095
|
+
);
|
|
752
1096
|
window.addEventListener(
|
|
753
1097
|
"online",
|
|
754
1098
|
() => {
|
|
@@ -870,6 +1214,30 @@ function buildTerminal(
|
|
|
870
1214
|
loadingStatus.reason(message);
|
|
871
1215
|
},
|
|
872
1216
|
tablist: () => tablistController,
|
|
1217
|
+
newLineStore: (sessionId) => {
|
|
1218
|
+
// Registered on EVERY path, because this factory is the only way a store
|
|
1219
|
+
// enters the app and the browse-cache sweep has to be able to reach a
|
|
1220
|
+
// background tab's store (the renderer only ever sees the bound one).
|
|
1221
|
+
const track = (store: LineStore, key: string): LineStore => {
|
|
1222
|
+
knownStores.set(key, new WeakRef(store));
|
|
1223
|
+
return store;
|
|
1224
|
+
};
|
|
1225
|
+
if (scrollbackKeeper !== null) {
|
|
1226
|
+
if (sessionId !== undefined) {
|
|
1227
|
+
// Hydrated from storage when a usable entry exists, and tracked for
|
|
1228
|
+
// saving either way. Necessarily before this session connects: the
|
|
1229
|
+
// caller is building the tab, and the kernel's switch (which opens
|
|
1230
|
+
// the socket) happens afterwards.
|
|
1231
|
+
const store = scrollbackKeeper.storeFor(sessionId);
|
|
1232
|
+
if (store.highestIndex() >= 0) {
|
|
1233
|
+
unverifiedRestores.add(sessionId);
|
|
1234
|
+
}
|
|
1235
|
+
return track(store, sessionId);
|
|
1236
|
+
}
|
|
1237
|
+
scrollbackKeeper.noteMissingSessionId();
|
|
1238
|
+
}
|
|
1239
|
+
return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
|
|
1240
|
+
},
|
|
873
1241
|
layout: () => ({
|
|
874
1242
|
narrow: isNarrow(),
|
|
875
1243
|
coarse:
|
|
@@ -880,6 +1248,11 @@ function buildTerminal(
|
|
|
880
1248
|
},
|
|
881
1249
|
dropSession(id) {
|
|
882
1250
|
connection.forgetSession(id);
|
|
1251
|
+
// A closed tab's scrollback should not outlive it. This is also the only
|
|
1252
|
+
// collection path that runs promptly; the keeper's age bound covers the
|
|
1253
|
+
// case this one cannot see, which is a tab the browser discarded without
|
|
1254
|
+
// ever running a close.
|
|
1255
|
+
scrollbackKeeper?.forget(id);
|
|
883
1256
|
},
|
|
884
1257
|
onError(fn) {
|
|
885
1258
|
errorHandlers.add(fn);
|
|
@@ -925,6 +1298,11 @@ function buildTerminal(
|
|
|
925
1298
|
return;
|
|
926
1299
|
}
|
|
927
1300
|
runtimeCleaned = true;
|
|
1301
|
+
// Before anything is torn down: a deliberate teardown (a host closing an
|
|
1302
|
+
// embedded panel, a reload path calling destroy()) is still a page the user
|
|
1303
|
+
// may come back to, and the tracked stores are about to become unreachable.
|
|
1304
|
+
scrollbackKeeper?.flush();
|
|
1305
|
+
scrollbackKeeper?.stop();
|
|
928
1306
|
teardownFeatures();
|
|
929
1307
|
kernelAbort.abort();
|
|
930
1308
|
narrowObserver?.disconnect();
|
|
@@ -1075,6 +1453,41 @@ function buildTerminal(
|
|
|
1075
1453
|
render.updateFontMetrics();
|
|
1076
1454
|
composition.positionCompositionView();
|
|
1077
1455
|
if (!sessionOwner) {
|
|
1456
|
+
// A single unmanaged terminal has no feature to own its store, so the kernel
|
|
1457
|
+
// hydrates the renderer's implicit one here — before connect(), because the
|
|
1458
|
+
// resume announces what this client already holds (Callbacks.getHaveThrough)
|
|
1459
|
+
// and a store hydrated afterwards has already asked for everything.
|
|
1460
|
+
//
|
|
1461
|
+
// The session id comes from the engine rather than from this library: an
|
|
1462
|
+
// unmanaged terminal's identity is a per-tab, sessionStorage-backed id minted
|
|
1463
|
+
// inside the connection layer, with exactly the semantics a persistence key
|
|
1464
|
+
// needs (stable across a reload and an iOS tab restore, fresh in a genuinely
|
|
1465
|
+
// new tab). Under a session owner the same work happens per session, through
|
|
1466
|
+
// ctx.newLineStore(sessionId).
|
|
1467
|
+
//
|
|
1468
|
+
// Note what is deliberately NOT done: restored content does not dismiss the
|
|
1469
|
+
// loading overlay. It is last session's output until the resume confirms it,
|
|
1470
|
+
// and showing it as live is the same mistake the persisted epoch exists to
|
|
1471
|
+
// prevent. The overlay still lifts on the first real frame — which now
|
|
1472
|
+
// arrives sooner, because the replay behind it is a delta instead of a
|
|
1473
|
+
// refill.
|
|
1474
|
+
if (scrollbackKeeper !== null) {
|
|
1475
|
+
const sessionId = connection.currentSessionId();
|
|
1476
|
+
const restored = scrollbackKeeper.storeFor(sessionId);
|
|
1477
|
+
if (restored.highestIndex() >= 0) {
|
|
1478
|
+
render.bind(restored);
|
|
1479
|
+
// Registered under the SAME id the resumeAck will report, so the guard
|
|
1480
|
+
// can both verify and discard it. This store does not come through
|
|
1481
|
+
// `newLineStore` — an unmanaged terminal has no feature to own it.
|
|
1482
|
+
knownStores.set(sessionId, new WeakRef(restored));
|
|
1483
|
+
unverifiedRestores.add(sessionId);
|
|
1484
|
+
} else {
|
|
1485
|
+
// Nothing usable was stored. Keep the renderer's own store and track
|
|
1486
|
+
// that one instead of swapping in an empty replacement, so this path
|
|
1487
|
+
// changes nothing about a terminal with no snapshot to restore.
|
|
1488
|
+
scrollbackKeeper.track(sessionId, render.boundStore());
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1078
1491
|
connection.connect();
|
|
1079
1492
|
connectionInitiated = true;
|
|
1080
1493
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Validation for the consumer's numeric options, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// The policy is a product decision, not a formatting choice: an out-of-range
|
|
4
|
+
// numeric option is REJECTED with a warning and the library default applies. It
|
|
5
|
+
// is deliberately not clamped, because clamping hides the mistake — and every
|
|
6
|
+
// one of these knobs has a plausible typo that would silently reconfigure
|
|
7
|
+
// durability rather than fail (a zero `lines` persists nothing; a zero
|
|
8
|
+
// `maxAgeMs` expires everything instantly; a zero `scrollbackLines` would zero
|
|
9
|
+
// the scrollback budget).
|
|
10
|
+
//
|
|
11
|
+
// It lives here because three modules were enforcing it independently and one of
|
|
12
|
+
// them did not warn, so the SAME typo was reported or swallowed depending on
|
|
13
|
+
// which option carried it — `localScrollbackStorage({ maxAgeMs: 0 })` silently
|
|
14
|
+
// took the default while `persistScrollback: { maxAgeMs: 0 }` said so out loud.
|
|
15
|
+
// One class of mistake cannot have two reporting policies.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A positive integer, or `fallback` with a warning naming `path`.
|
|
19
|
+
*
|
|
20
|
+
* `path` is the full option path as the consumer wrote it
|
|
21
|
+
* (`"persistScrollback.maxAgeMs"`, `"localScrollbackStorage.maxBytes"`), so the
|
|
22
|
+
* warning points at their code rather than at ours.
|
|
23
|
+
*/
|
|
24
|
+
export function positiveIntOption(
|
|
25
|
+
value: number | undefined,
|
|
26
|
+
fallback: number,
|
|
27
|
+
path: string,
|
|
28
|
+
): number {
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
33
|
+
console.warn(`web-terminal-ui: ignoring invalid ${path} ${String(value)}`);
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A positive integer, or `undefined` with a warning naming `path`.
|
|
41
|
+
*
|
|
42
|
+
* The same policy for a knob whose absence is meaningful: `scrollbackLines`
|
|
43
|
+
* omitted means "the engine's choice", which is a different statement from any
|
|
44
|
+
* value this library could substitute, so an invalid one must read as omitted.
|
|
45
|
+
*/
|
|
46
|
+
export function optionalPositiveIntOption(
|
|
47
|
+
value: number | undefined,
|
|
48
|
+
path: string,
|
|
49
|
+
): number | undefined {
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
54
|
+
console.warn(`web-terminal-ui: ignoring invalid ${path} ${String(value)}`);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|