@cplieger/web-terminal-ui 5.2.3 → 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/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 +22 -6
- 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/types.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
// reads it through a typed token.
|
|
9
9
|
|
|
10
10
|
import type { LoadingMessages } from "./loading-status.js";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ScreenMessage,
|
|
13
|
+
ModesMessage,
|
|
14
|
+
LineStore,
|
|
15
|
+
StoreSnapshot,
|
|
16
|
+
ViewMemory,
|
|
17
|
+
} from "@cplieger/web-terminal-engine";
|
|
12
18
|
|
|
13
19
|
/** Cancels a subscription or registration. Idempotent by convention. */
|
|
14
20
|
export type Unsubscribe = () => void;
|
|
@@ -81,12 +87,29 @@ export interface RenderHandle {
|
|
|
81
87
|
setPredictedCursor(row: number, col: number, active: boolean): void;
|
|
82
88
|
getCursorPx(): { left: number; top: number; cellH: number };
|
|
83
89
|
computeSize(): { cols: number; rows: number };
|
|
84
|
-
/** Point the renderer at a store and rebuild from it (tabs, on switch).
|
|
85
|
-
|
|
90
|
+
/** Point the renderer at a store and rebuild from it (tabs, on switch).
|
|
91
|
+
* `opts.view` is the per-view scroll memory captureViewMemory() returned when
|
|
92
|
+
* this store was last active, and passing it makes the swap ATOMIC: the
|
|
93
|
+
* follow half is adopted synchronously (so the first flush's bottom pin is
|
|
94
|
+
* gated on the INCOMING view, not the outgoing one) and the position half is
|
|
95
|
+
* re-asserted across the rebuild's frames until the row it names is built.
|
|
96
|
+
* Omitting it keeps the pre-3.8 behavior. */
|
|
97
|
+
bind(store: LineStore, opts?: { view?: ViewMemory | null }): void;
|
|
98
|
+
/** Capture the current view as per-view scroll memory: the absolute LINE at
|
|
99
|
+
* the viewport top plus its on-screen offset, not a pixel scrollTop. Null
|
|
100
|
+
* when there is nothing to remember (no content rows, or the alternate
|
|
101
|
+
* screen is active). Pairs with bind's `opts.view`. */
|
|
102
|
+
captureViewMemory(): ViewMemory | null;
|
|
86
103
|
/** The store the renderer is currently bound to. */
|
|
87
104
|
boundStore(): LineStore;
|
|
88
105
|
/** Highest absolute line index the active store holds (-1 if empty). */
|
|
89
106
|
getHighestIndex(): number;
|
|
107
|
+
/** Rows queued for a DOM (re)build but not yet built: non-zero means the
|
|
108
|
+
* surface is still materializing content the store already holds. Reaches
|
|
109
|
+
* zero between a replay's chunks, so it is a "this frame's backlog is
|
|
110
|
+
* drained" signal, not "the restore finished arriving" — pair it with the
|
|
111
|
+
* resume bounds for the latter. */
|
|
112
|
+
pendingRowCount(): number;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
/** The scroll methods features may drive. */
|
|
@@ -98,8 +121,25 @@ export interface ScrollHandle {
|
|
|
98
121
|
* features go through this seam, never the DOM element (engine >= v3). */
|
|
99
122
|
currentScrollTop(): number;
|
|
100
123
|
/** Restore a saved offset; follow/hold re-derives from the resulting scroll
|
|
101
|
-
* event exactly as for a user scroll — the write half of scroll memory.
|
|
124
|
+
* event exactly as for a user scroll — the write half of scroll memory.
|
|
125
|
+
*
|
|
126
|
+
* @deprecated A pixel offset cannot identify a reading position: replayed
|
|
127
|
+
* into a surface whose rows are still building it is silently clamped, and
|
|
128
|
+
* replayed into one whose content grew it points at a different line. Use
|
|
129
|
+
* render.captureViewMemory() + render.bind(store, { view }), which restore a
|
|
130
|
+
* LINE. Removed in the next major. */
|
|
102
131
|
restoreScrollTop(top: number): void;
|
|
132
|
+
/** Restore an offset AND its follow state together, which is the only way to
|
|
133
|
+
* express "holding at the bottom" (a content shrink clamps a scrolled-up reader
|
|
134
|
+
* there without engaging follow, so position alone cannot say it).
|
|
135
|
+
*
|
|
136
|
+
* NOT the per-view scroll-memory API: this is still a PIXEL offset, with the
|
|
137
|
+
* same defect as `restoreScrollTop` above — a rebuild clamps it and a tab whose
|
|
138
|
+
* content grew while backgrounded lands on a different line. `render.bind(store,
|
|
139
|
+
* { view })` with `render.captureViewMemory()` is what a consumer wants; the
|
|
140
|
+
* engine calls this internally from that path. Declared here because it is part
|
|
141
|
+
* of the engine's scroll surface, not because a consumer should reach for it. */
|
|
142
|
+
restoreView(view: { top: number; following: boolean }): void;
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
// --- Typed event bus payloads (section 22.4) ---
|
|
@@ -237,6 +277,22 @@ export interface TerminalContext {
|
|
|
237
277
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
238
278
|
tablist(): TablistController;
|
|
239
279
|
|
|
280
|
+
/** Construct a LineStore honoring the terminal's configured retained-line
|
|
281
|
+
* cap (`CreateTerminalOptions.scrollbackLines`; engine default when unset).
|
|
282
|
+
* A feature that keeps per-session stores (tabs' switching cache) creates
|
|
283
|
+
* every store through this factory, so ONE consumer option governs the
|
|
284
|
+
* kernel's implicit store and every per-tab store alike — the two cannot
|
|
285
|
+
* drift.
|
|
286
|
+
*
|
|
287
|
+
* Pass the session id the store belongs to. It is optional only so an
|
|
288
|
+
* existing feature keeps compiling; a feature that owns sessions should
|
|
289
|
+
* always pass it, because it is what lets the kernel return a store HYDRATED
|
|
290
|
+
* from `CreateTerminalOptions.persistScrollback` (and register it for
|
|
291
|
+
* saving). Omitting it yields a correct but always-empty store, so that
|
|
292
|
+
* feature's sessions silently opt out of persistence — the kernel warns once
|
|
293
|
+
* when persistence is enabled and an id is withheld. */
|
|
294
|
+
newLineStore(sessionId?: string): LineStore;
|
|
295
|
+
|
|
240
296
|
/** The current layout facts a feature keys touch-vs-desktop behavior on.
|
|
241
297
|
* `narrow` is a ROOT compact in EITHER dimension against the kernel's
|
|
242
298
|
* breakpoints: skinny (width, a portrait phone or a narrow embedded panel)
|
|
@@ -379,6 +435,97 @@ export type TerminalStartupFailure =
|
|
|
379
435
|
readonly surface: HTMLElement | undefined;
|
|
380
436
|
};
|
|
381
437
|
|
|
438
|
+
/** One stored scrollback entry: the engine's store snapshot plus the timestamp
|
|
439
|
+
* the library stamps on it.
|
|
440
|
+
*
|
|
441
|
+
* The timestamp is the library's, not the consumer's, because the maximum age
|
|
442
|
+
* is ENFORCED here rather than documented as someone else's job. The case that
|
|
443
|
+
* motivates persisting at all — iOS discarding a backgrounded tab — is exactly
|
|
444
|
+
* the case where no "closed cleanly" path ever runs, so without an age bound
|
|
445
|
+
* the consumer's storage accumulates snapshots for sessions that stopped
|
|
446
|
+
* existing weeks ago. A rejected entry is also dropped through
|
|
447
|
+
* `ScrollbackPersistence.drop`, which makes collection-on-access automatic; a
|
|
448
|
+
* consumer that wants to sweep proactively has `savedAt` to sweep by. */
|
|
449
|
+
export interface PersistedScrollback {
|
|
450
|
+
/** `Date.now()` when the library wrote this entry. */
|
|
451
|
+
readonly savedAt: number;
|
|
452
|
+
/** The engine's plain-data store snapshot (structuredClone-safe). */
|
|
453
|
+
readonly snapshot: StoreSnapshot;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The consumer-supplied storage seam for scrollback persistence
|
|
457
|
+
* (`CreateTerminalOptions.persistScrollback`).
|
|
458
|
+
*
|
|
459
|
+
* Storage is the CONSUMER's, deliberately, for two reasons. Chrome's
|
|
460
|
+
* page-lifecycle guidance is to close IndexedDB connections on freeze because a
|
|
461
|
+
* held connection affects bfcache eligibility, and this library depends on
|
|
462
|
+
* bfcache (it reconnects and re-measures on `pageshow`) — so the library must
|
|
463
|
+
* not own a database. And terminal scrollback contains secrets while browser
|
|
464
|
+
* storage has no meaningful at-rest protection, which makes "where does this
|
|
465
|
+
* live, and for how long" a decision for the application, not its terminal
|
|
466
|
+
* widget.
|
|
467
|
+
*
|
|
468
|
+
* `sessionId` is a real session id, never a key of the consumer's invention:
|
|
469
|
+
* the same id the tabs feature uses, or — for a single unmanaged terminal — the
|
|
470
|
+
* engine's per-tab id from `connection.currentSessionId()`. That is load-bearing
|
|
471
|
+
* rather than tidy, because the id is also what the persisted server epoch is
|
|
472
|
+
* adopted against. A consumer may of course prefix it inside its own storage.
|
|
473
|
+
*
|
|
474
|
+
* `load` is SYNCHRONOUS, which is a real constraint and worth understanding
|
|
475
|
+
* before fighting it: hydration has to complete before the resume goes out, and
|
|
476
|
+
* a resume is not restartable — a store hydrated after `haveThrough` was sent
|
|
477
|
+
* has already lost the argument. A consumer whose storage is asynchronous
|
|
478
|
+
* (IndexedDB) reads its snapshots into memory before calling `createTerminal`
|
|
479
|
+
* and answers from there, which is also the shape that holds no database
|
|
480
|
+
* connection open.
|
|
481
|
+
*
|
|
482
|
+
* Every callback may throw or return nonsense without consequence: the library
|
|
483
|
+
* treats any failure as "nothing was restored" and takes a full resume, exactly
|
|
484
|
+
* as if persistence were switched off. */
|
|
485
|
+
export interface ScrollbackPersistence {
|
|
486
|
+
/** Return the stored entry for a session, or null/undefined when there is
|
|
487
|
+
* none. Called once per session, before that session connects. */
|
|
488
|
+
load(sessionId: string): PersistedScrollback | null | undefined;
|
|
489
|
+
/** Store an entry, replacing any previous one for the session.
|
|
490
|
+
*
|
|
491
|
+
* THROW when nothing was persisted (over quota, storage revoked, an entry the
|
|
492
|
+
* store refuses). Returning normally is taken as "this is on disk" and records
|
|
493
|
+
* a watermark against it; a failure reported that way made the library skip the
|
|
494
|
+
* session until its output advanced again, which is how persistence stopped
|
|
495
|
+
* silently. A throw is caught, warned once, and retried on the next pass. */
|
|
496
|
+
save(sessionId: string, entry: PersistedScrollback): void;
|
|
497
|
+
/** Delete a session's entry. Called when a tab is closed, and when the
|
|
498
|
+
* library rejects a stored entry (too old, unusable, or unverifiable), so a
|
|
499
|
+
* bad or expired entry is not re-read on every load. */
|
|
500
|
+
drop(sessionId: string): void;
|
|
501
|
+
/** Newest lines to persist per session (default 200).
|
|
502
|
+
*
|
|
503
|
+
* A bound rather than the whole store because the cost is a repeated
|
|
504
|
+
* serialize-and-store on a device that may already be under memory pressure,
|
|
505
|
+
* and because the screen plus recent history is what a returning user needs.
|
|
506
|
+
* The default follows the nearest precedent — VS Code's
|
|
507
|
+
* `terminal.integrated.persistentSessionScrollback` restores 100 lines — with
|
|
508
|
+
* headroom, and against measurement: 200 lines is ~60 K characters of a
|
|
509
|
+
* coloured session and serialises in under a millisecond, where 1000 is ~300 K
|
|
510
|
+
* and ~4 ms, paid on every backgrounding and every timer tick while output
|
|
511
|
+
* advances. The depth NOT kept is reported honestly after hydration: the store
|
|
512
|
+
* shows its "earlier output trimmed" marker rather than implying the buffer is
|
|
513
|
+
* complete. Non-integer or non-positive values are ignored. */
|
|
514
|
+
lines?: number;
|
|
515
|
+
/** Maximum age of a stored entry (default 7 days). An entry outside this is
|
|
516
|
+
* neither loaded nor kept. Distance in EITHER direction counts, so a phone
|
|
517
|
+
* whose clock moved cannot leave an entry that never expires. */
|
|
518
|
+
maxAgeMs?: number;
|
|
519
|
+
/** Interval for the background save while content is changing (default 10s).
|
|
520
|
+
*
|
|
521
|
+
* `visibilitychange` to hidden and `pagehide` are the last reliable callbacks
|
|
522
|
+
* before a discard and the library writes on both, but Chrome's page-lifecycle
|
|
523
|
+
* documentation is explicit that `pagehide` is not guaranteed. The timer is
|
|
524
|
+
* what makes a killed tab have a recent-enough snapshot rather than none. It
|
|
525
|
+
* skips sessions whose content has not advanced since their last save. */
|
|
526
|
+
saveIntervalMs?: number;
|
|
527
|
+
}
|
|
528
|
+
|
|
382
529
|
/** Options for createTerminal. */
|
|
383
530
|
export interface CreateTerminalOptions {
|
|
384
531
|
/** The feature list, as a FUNCTION that produces it; omitted means the bare
|
|
@@ -406,6 +553,64 @@ export interface CreateTerminalOptions {
|
|
|
406
553
|
wsPath?: string;
|
|
407
554
|
/** CSS font shorthand awaited before the first resize. */
|
|
408
555
|
fontReady?: string;
|
|
556
|
+
/** Retained scrollback lines per terminal (and per tab under the tabs
|
|
557
|
+
* feature) — the client-side line cap; the engine's default is 5000.
|
|
558
|
+
*
|
|
559
|
+
* This is the page's dominant memory dial: the cap bounds both the styled
|
|
560
|
+
* run arrays each store retains AND the DOM rows the renderer keeps (one
|
|
561
|
+
* `div.term-row` per retained line, no offscreen virtualisation), which in
|
|
562
|
+
* turn bounds the height of the scrolled-contents layer the browser
|
|
563
|
+
* composites. It is a HISTORY budget floored at the live screen: the
|
|
564
|
+
* engine never evicts the current window's rows, so a value at or below
|
|
565
|
+
* the terminal height keeps the full screen and simply retains no
|
|
566
|
+
* scrollback. Choose a value comfortably above the largest expected
|
|
567
|
+
* terminal height (a few multiples of it): near or below the height,
|
|
568
|
+
* batched eviction has no history headroom to work with and degrades
|
|
569
|
+
* back to per-line eviction — the screen stays correct, but the churn
|
|
570
|
+
* the batching exists to remove returns. A memory-constrained consumer (iOS Safari, where the content
|
|
571
|
+
* process is a jetsam victim under system pressure) passes a smaller
|
|
572
|
+
* budget; scrolling back then reaches fewer lines, so this is a consumer
|
|
573
|
+
* policy choice, not a tuning knob the library second-guesses. Applies to
|
|
574
|
+
* the kernel's implicit store (via the engine renderer) and to every store
|
|
575
|
+
* created through `ctx.newLineStore()` (the tabs switching cache).
|
|
576
|
+
* Non-integer or non-positive values are ignored. */
|
|
577
|
+
scrollbackLines?: number;
|
|
578
|
+
/** Persist each session's scrollback across a page discard, through storage
|
|
579
|
+
* the CONSUMER supplies. Omitted means off, which is the default on purpose.
|
|
580
|
+
*
|
|
581
|
+
* What it fixes: a page that is discarded and reloaded otherwise resumes
|
|
582
|
+
* holding nothing, so it asks the server for everything and refills its whole
|
|
583
|
+
* buffer over the wire. On iOS that is the normal case rather than an edge
|
|
584
|
+
* case — Safari evicts backgrounded tabs under memory pressure and returning
|
|
585
|
+
* to one re-runs the page — and the refill is both slow and visible. With a
|
|
586
|
+
* snapshot restored, the resume asks only for what was printed while the tab
|
|
587
|
+
* was gone.
|
|
588
|
+
*
|
|
589
|
+
* Scope it honestly: this helps the FRESH-LOAD case only. A warm reconnect and
|
|
590
|
+
* an in-page tab switch already replay nothing, because their stores never
|
|
591
|
+
* went away.
|
|
592
|
+
*
|
|
593
|
+
* Off by default, and the default is the LIBRARY's rather than a
|
|
594
|
+
* recommendation. `localStorage` is a shared, origin-wide, quota-limited
|
|
595
|
+
* resource, and this package is embedded in host applications that keep their
|
|
596
|
+
* own state there; consuming it uninvited would risk the embedder's writes
|
|
597
|
+
* failing, whose degradation is theirs to design and not ours to assume. Our
|
|
598
|
+
* own failure under quota pressure is graceful (nothing restored, terminal
|
|
599
|
+
* unaffected). An application decides durability for its own users; a library
|
|
600
|
+
* does not decide it for an embedder who never asked. All three reference apps
|
|
601
|
+
* DO enable it — see `localScrollbackStorage`, which is the ready-made answer.
|
|
602
|
+
*
|
|
603
|
+
* Note for a consumer weighing it: enabling this writes terminal output where
|
|
604
|
+
* it is readable from that browser without reaching the server and outlives the
|
|
605
|
+
* tab (bounded by a per-session delete on close and a seven-day expiry). No
|
|
606
|
+
* permission prompt is involved — `localStorage` needs none — and a browser
|
|
607
|
+
* that blocks site data simply restores nothing.
|
|
608
|
+
*
|
|
609
|
+
* It applies to the whole terminal, not one composition: the kernel's implicit
|
|
610
|
+
* store (a single unmanaged terminal) and every store a session-owning feature
|
|
611
|
+
* creates through `ctx.newLineStore(sessionId)` (the tabs cache) are both
|
|
612
|
+
* hydrated and both saved. */
|
|
613
|
+
persistScrollback?: ScrollbackPersistence;
|
|
409
614
|
/** Optional pre-JS loading overlay the kernel fades out on first paint. Give
|
|
410
615
|
* it the `wt-loading` class and a `wt-loading-bar` child and css/page.css
|
|
411
616
|
* styles it; the kernel also writes a progressive status line into it while
|
package/src/viewport.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// 2. While in transition: autoscroll is suppressed, input dimensions
|
|
11
11
|
// may shift, no resize is sent to the server.
|
|
12
12
|
// 3. After SETTLE_MS of no further events, fire onSettled — the caller
|
|
13
|
-
// then sends the final size,
|
|
13
|
+
// then sends the final size, pins to the bottom if still following, etc.
|
|
14
|
+
// The follow state is read AT SETTLE (see startTransition): a latch taken
|
|
15
|
+
// at the start of the burst goes stale, because every further event
|
|
16
|
+
// extends the window without re-reading it.
|
|
14
17
|
|
|
15
18
|
import { scroll } from "@cplieger/web-terminal-engine";
|
|
16
19
|
|
|
@@ -27,7 +30,6 @@ let termWrap: HTMLElement;
|
|
|
27
30
|
let varTarget: HTMLElement;
|
|
28
31
|
let onSettled: ((wasAtBottom: boolean) => void) | null = null;
|
|
29
32
|
let inTransition = false;
|
|
30
|
-
let wasAtBottomAtStart = true;
|
|
31
33
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
32
34
|
// When true, ignore the visualViewport keyboard geometry entirely (a hardware
|
|
33
35
|
// keyboard/trackpad is present, so the soft keyboard never opens). Set at init
|
|
@@ -40,28 +42,43 @@ let suppressKeyboardInset: () => boolean = () => false;
|
|
|
40
42
|
let cleanup: (() => void)[] = [];
|
|
41
43
|
|
|
42
44
|
function startTransition(): void {
|
|
43
|
-
|
|
44
|
-
// not on every event in the burst.
|
|
45
|
-
if (!inTransition) {
|
|
46
|
-
inTransition = true;
|
|
47
|
-
wasAtBottomAtStart = !scroll.isUserScrolledUp();
|
|
48
|
-
}
|
|
49
|
-
|
|
45
|
+
inTransition = true;
|
|
50
46
|
if (settleTimer !== null) {
|
|
51
47
|
clearTimeout(settleTimer);
|
|
52
48
|
}
|
|
53
49
|
settleTimer = setTimeout(() => {
|
|
54
50
|
settleTimer = null;
|
|
55
51
|
inTransition = false;
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
// Read the follow state AT SETTLE, not at the start of the burst. Every
|
|
53
|
+
// viewport event re-arms this timer without re-reading, and on iOS a wake or
|
|
54
|
+
// keyboard slide emits a stream of them, so a latch captured at the start
|
|
55
|
+
// stays true across a user scrolling up mid-burst and then yanks them back
|
|
56
|
+
// down. Asking now is only safe because a content shrink can no longer
|
|
57
|
+
// corrupt the follow state on its own (the engine's scroll controller
|
|
58
|
+
// re-engages follow only on a downward user move); before that, this snap
|
|
59
|
+
// was accidentally repairing the corruption it was also causing.
|
|
60
|
+
const stillFollowing = !scroll.isUserScrolledUp();
|
|
61
|
+
if (stillFollowing) {
|
|
62
|
+
// Pin, do not force. A geometry change can leave scrollTop stale short of
|
|
63
|
+
// the bottom, which the pin fixes; scrollToBottom would additionally
|
|
64
|
+
// OVERRIDE the follow state, which is not this handler's call to make.
|
|
65
|
+
scroll.stickToBottom();
|
|
58
66
|
}
|
|
59
67
|
if (onSettled) {
|
|
60
|
-
onSettled(
|
|
68
|
+
onSettled(stillFollowing);
|
|
61
69
|
}
|
|
62
70
|
}, SETTLE_MS);
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Whether a viewport transition is in flight (an iOS keyboard slide, a rotation,
|
|
75
|
+
* a font-load reflow). Geometry measured now is provisional, so a caller that
|
|
76
|
+
* puts a measurement on the wire should wait for the settle instead.
|
|
77
|
+
*/
|
|
78
|
+
export function isInTransition(): boolean {
|
|
79
|
+
return inTransition;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export function init(opts: {
|
|
66
83
|
termWrap: HTMLElement;
|
|
67
84
|
/** The terminal root (.wt-root): receives the geometry CSS vars the sibling
|