@cplieger/web-terminal-ui 7.3.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -18
- package/css/30-tabs.css +52 -13
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The window of a document that has one, with that realm's constructors
|
|
2
|
+
* (`win.Node`, `win.ResizeObserver`), so a value from another document is
|
|
3
|
+
* checked against its own realm; a document with no browsing context (a parsed
|
|
4
|
+
* or created document) cannot lay out or focus a terminal. */
|
|
5
|
+
export function windowOf(doc: Document): Window & typeof globalThis {
|
|
6
|
+
const win = doc.defaultView;
|
|
7
|
+
if (win === null) {
|
|
8
|
+
throw new Error("web-terminal-ui: the mount target's document has no window");
|
|
9
|
+
}
|
|
10
|
+
return win;
|
|
11
|
+
}
|
package/src/kernel/regions.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
// Layout regions
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// direction, z-order, and the keyboard lift, so features cannot invent their own
|
|
7
|
-
// placement or collide. DOM order within a region always equals visual order, so
|
|
8
|
-
// focus order never diverges from reading order (WCAG 2.4.3): each slot is a
|
|
9
|
-
// wrapper inserted in a declared order, and a feature appends into its slot.
|
|
1
|
+
// Layout regions: named containers with fixed geometry, stacking and the
|
|
2
|
+
// keyboard-inset lift. A feature appends its chrome into a slot of one region
|
|
3
|
+
// and decides nothing about placement, so features cannot collide. DOM order
|
|
4
|
+
// within a region equals visual order (WCAG 2.4.3): slots are inserted in a
|
|
5
|
+
// declared order.
|
|
10
6
|
|
|
11
7
|
import type { RegionName, RegionSlot } from "./types.js";
|
|
12
8
|
|
|
@@ -66,9 +62,10 @@ export function createRegions(root: HTMLElement): Regions {
|
|
|
66
62
|
const containers = new Map<RegionName, HTMLElement>();
|
|
67
63
|
const slots = new Map<string, HTMLElement>(); // "region:slot" -> slot element
|
|
68
64
|
const unlistedSeen = new Map<string, number>();
|
|
65
|
+
const doc = root.ownerDocument;
|
|
69
66
|
|
|
70
67
|
for (const name of REGION_NAMES) {
|
|
71
|
-
const el =
|
|
68
|
+
const el = doc.createElement("div");
|
|
72
69
|
el.className = `wt-region wt-region-${name}`;
|
|
73
70
|
el.dataset["region"] = name;
|
|
74
71
|
root.appendChild(el);
|
|
@@ -85,7 +82,7 @@ export function createRegions(root: HTMLElement): Regions {
|
|
|
85
82
|
if (!container) {
|
|
86
83
|
throw new Error(`web-terminal-ui: unknown region ${name}`);
|
|
87
84
|
}
|
|
88
|
-
const el =
|
|
85
|
+
const el = doc.createElement("div");
|
|
89
86
|
el.className = `wt-slot wt-slot-${slot}`;
|
|
90
87
|
el.dataset["slot"] = slot;
|
|
91
88
|
// Insert in declared slot order: before the first existing child whose rank
|
package/src/kernel/scrollback.ts
CHANGED
|
@@ -1,46 +1,43 @@
|
|
|
1
|
-
// Scrollback persistence: the
|
|
2
|
-
// persistScrollback.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// snapshot is taken, WHETHER a stored one may be trusted, and the age bound. The
|
|
9
|
-
// consumer owns where the bytes live, because a library that opened its own
|
|
10
|
-
// IndexedDB would hold a connection that costs bfcache eligibility — which this
|
|
11
|
-
// kernel depends on — and because how long a terminal's output may sit on disk
|
|
12
|
-
// is an application decision.
|
|
13
|
-
//
|
|
14
|
-
// It serves both compositions through one object: the kernel hydrates its
|
|
15
|
-
// implicit store for a single unmanaged terminal, and a session-owning feature
|
|
16
|
-
// gets a hydrated store per session from ctx.newLineStore(sessionId). Anything
|
|
17
|
-
// tracked here is saved; nothing else is.
|
|
1
|
+
// Scrollback persistence: the shell's half of CreateTerminalOptions.
|
|
2
|
+
// persistScrollback. The engine supplies the data (LineStore snapshots) and the
|
|
3
|
+
// shell supplies the identity of the server process it belongs to (the
|
|
4
|
+
// SessionEpochs port); this module owns WHEN a snapshot is taken, WHETHER a
|
|
5
|
+
// stored one may be trusted, and the age bound. The consumer owns where the
|
|
6
|
+
// bytes live, because a library that opened its own IndexedDB would hold a
|
|
7
|
+
// connection that costs bfcache eligibility, which this kernel depends on.
|
|
18
8
|
|
|
19
|
-
import { LineStore
|
|
9
|
+
import { LineStore } from "@cplieger/web-terminal-engine";
|
|
20
10
|
import { positiveIntOption } from "./options.js";
|
|
21
11
|
import type { ScrollbackPersistence } from "./types.js";
|
|
22
12
|
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
13
|
+
/** The connection facts the keeper needs, answered by the shell over every pane's
|
|
14
|
+
* connection: a tab's store follows the tab into whichever pane shows it. */
|
|
15
|
+
export interface SessionEpochs {
|
|
16
|
+
/** Seed the epoch a stored snapshot was taken under, before the session
|
|
17
|
+
* connects, so the first resumeAck has a value to compare against. */
|
|
18
|
+
adoptPersistedEpoch(sessionId: string, epoch: number): void;
|
|
19
|
+
/** The server epoch the session is currently known to belong to; 0 when it
|
|
20
|
+
* has not resumed yet. */
|
|
21
|
+
serverEpochOf(sessionId: string): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The timers and clock the keeper runs on: the mounted window's, so the save
|
|
25
|
+
* cadence and the age check follow the terminal's own clock. */
|
|
26
|
+
export interface ScrollbackClock {
|
|
27
|
+
setInterval(handler: () => void, ms: number): number;
|
|
28
|
+
clearInterval(id: number): void;
|
|
29
|
+
readonly Date: Pick<DateConstructor, "now">;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Newest lines persisted per session when the consumer names no bound. Not the
|
|
33
|
+
* store's cap: a write happens on every backgrounding and on a timer while
|
|
34
|
+
* output advances, and 200 lines serialise in under a millisecond (~24 K
|
|
35
|
+
* characters plain, ~60 K coloured) against up to 4 ms at 1000. VS Code
|
|
36
|
+
* restores 100 by default. */
|
|
36
37
|
const DEFAULT_PERSIST_LINES = 200;
|
|
37
|
-
/** How long a stored entry may be used.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* Exported within the kernel so a storage implementation that must SWEEP its own
|
|
41
|
-
* orphans (scrollback-storage.ts) bounds them by the same number the keeper
|
|
42
|
-
* refuses to load past. Two constants here would drift into a store that keeps
|
|
43
|
-
* entries nothing will ever read, or deletes ones still in use. */
|
|
38
|
+
/** How long a stored entry may be used. Exported so a storage implementation
|
|
39
|
+
* that sweeps its own orphans bounds them by the same number the keeper refuses
|
|
40
|
+
* to load past. */
|
|
44
41
|
export const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
45
42
|
/** Background save cadence while content is advancing. */
|
|
46
43
|
const DEFAULT_SAVE_INTERVAL_MS = 10_000;
|
|
@@ -76,61 +73,35 @@ interface StoredEntry {
|
|
|
76
73
|
/** `Date.now()` at the write. Finite. */
|
|
77
74
|
readonly savedAt: number;
|
|
78
75
|
/** The snapshot's server epoch, or null when the entry names no server
|
|
79
|
-
* process.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* and is a corrupt or hand-edited entry wearing the shape of a real one.
|
|
85
|
-
*
|
|
86
|
-
* NOT 0-for-unknown. The engine spells "unknown" as 0 on its own surface and
|
|
87
|
-
* that is its contract, but inheriting the spelling here put the rejection
|
|
88
|
-
* value inside the range of accepted values, so `epoch > 0` and `epoch >= 0`
|
|
89
|
-
* could not differ: at `epoch === 0` both yielded 0 and both were rejected
|
|
90
|
-
* downstream. Out-of-band restores the distinction, and 0 goes back to being
|
|
91
|
-
* a value the boundary refuses rather than the way it says no. */
|
|
76
|
+
* process. A positive integer only: the value is adopted as a session's
|
|
77
|
+
* IDENTITY (a process-start timestamp in nanoseconds), so a negative or
|
|
78
|
+
* fractional one is a corrupt entry. Null rather than the engine's
|
|
79
|
+
* 0-for-unknown, which would put the rejection value inside the accepted
|
|
80
|
+
* range. */
|
|
92
81
|
readonly serverEpoch: number | null;
|
|
93
|
-
/** Still the engine's to parse
|
|
94
|
-
*
|
|
95
|
-
* shape check here would be a second owner of one obligation. */
|
|
82
|
+
/** Still the engine's to parse: `LineStore.fromSnapshot` takes `unknown` and
|
|
83
|
+
* owns what a snapshot contains. */
|
|
96
84
|
readonly snapshot: unknown;
|
|
97
85
|
}
|
|
98
86
|
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*
|
|
108
|
-
* The parameter type carries the caller's proof: `hydrate` must separate "no
|
|
109
|
-
* entry" from "bad entry" before calling, because those two have different
|
|
110
|
-
* handling, so non-nullishness is already established. That is why there is no
|
|
111
|
-
* object test here and why there must not be one — a property read on any other
|
|
112
|
-
* primitive yields undefined and the field checks below reject it, so a `typeof
|
|
113
|
-
* entry !== "object"` test would be a second statement of a thing the signature
|
|
114
|
-
* already guarantees. This function had two of them, one per field it read, and
|
|
115
|
-
* neither could be observed: 15 of the file's 19 surviving mutants were those
|
|
116
|
-
* two guards and their bodies.
|
|
117
|
-
*/
|
|
118
|
-
// Spelled `{}` rather than `NonNullable<unknown>`: the two are the same type, and
|
|
119
|
-
// only the second is a type OPERATION, which `no-generated-empty-object-type`
|
|
120
|
-
// (typescript-eslint 8.70+) reports even where resolving to `{}` is the point.
|
|
121
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- "any non-nullish value" is the intended type; the paragraph above says why there is no object test here
|
|
87
|
+
/** Read a stored entry, or null when it cannot be used. The value has been
|
|
88
|
+
* outside this program's memory, so the declared `load` return type is an
|
|
89
|
+
* assumption. The parameter type carries the caller's proof of non-nullishness,
|
|
90
|
+
* and a property read on any other primitive yields undefined and fails the
|
|
91
|
+
* field checks, so there is no object test here and must not be one. */
|
|
92
|
+
// `{}` rather than `NonNullable<unknown>`: the same type, but only the second is
|
|
93
|
+
// a type OPERATION, which `no-generated-empty-object-type` reports.
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- "any non-nullish value" is the intended type
|
|
122
95
|
function readStoredEntry(entry: {}): StoredEntry | null {
|
|
123
96
|
const rec = entry as Record<string, unknown>;
|
|
124
97
|
const savedAt = rec["savedAt"];
|
|
125
|
-
// Finite, not merely a number: the age comparison below is a subtraction, and
|
|
126
98
|
// `Math.abs(Date.now() - NaN) > maxAgeMs` is false, so a NaN timestamp would
|
|
127
99
|
// read as freshly written forever.
|
|
128
100
|
if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) {
|
|
129
101
|
return null;
|
|
130
102
|
}
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// value can have, and `fromSnapshot` owns what a usable snapshot contains.
|
|
103
|
+
// `?.` is safe on a primitive as well as on null, so one operator covers every
|
|
104
|
+
// shape a stored value can have.
|
|
134
105
|
const snapshot = rec["snapshot"] as Record<string, unknown> | null | undefined;
|
|
135
106
|
const epoch = snapshot?.["serverEpoch"];
|
|
136
107
|
return {
|
|
@@ -144,6 +115,8 @@ function readStoredEntry(entry: {}): StoredEntry | null {
|
|
|
144
115
|
export function createScrollbackKeeper(
|
|
145
116
|
cfg: ScrollbackPersistence,
|
|
146
117
|
storeCap: number | undefined,
|
|
118
|
+
epochs: SessionEpochs,
|
|
119
|
+
clock: ScrollbackClock,
|
|
147
120
|
): ScrollbackKeeper {
|
|
148
121
|
const lines = positiveIntOption(cfg.lines, DEFAULT_PERSIST_LINES, "persistScrollback.lines");
|
|
149
122
|
const maxAgeMs = positiveIntOption(
|
|
@@ -180,34 +153,21 @@ export function createScrollbackKeeper(
|
|
|
180
153
|
}
|
|
181
154
|
}
|
|
182
155
|
|
|
183
|
-
/** Load
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* This function owns exactly two decisions and delegates the rest, which is
|
|
192
|
-
* the point of its shape. It decides whether there is an entry at all, and
|
|
193
|
-
* whether an entry that parsed may be USED (age, and verifiability). What an
|
|
194
|
-
* entry's envelope must look like is `readStoredEntry`'s; what a snapshot must
|
|
195
|
-
* contain is `LineStore.fromSnapshot`'s. Three owners, three depths, no
|
|
196
|
-
* overlap — the arrangement this module did not have when its two readers each
|
|
197
|
-
* re-established that the entry was an object. */
|
|
156
|
+
/** Load a stored entry, dropping it when it cannot be used. Returns the
|
|
157
|
+
* hydrated store, or null to start empty and take a full resume. No rejection
|
|
158
|
+
* throws or reports: starting empty costs one slow restore, while hydrating
|
|
159
|
+
* something unsound shows content from a session that no longer exists. This
|
|
160
|
+
* decides whether an entry exists and whether it may be USED (age,
|
|
161
|
+
* verifiability); the envelope is `readStoredEntry`'s and the snapshot's
|
|
162
|
+
* contents are the engine's. */
|
|
198
163
|
function hydrate(sessionId: string): LineStore | null {
|
|
199
164
|
let raw: unknown;
|
|
200
165
|
try {
|
|
201
166
|
raw = cfg.load(sessionId);
|
|
202
167
|
} catch {
|
|
203
|
-
//
|
|
204
|
-
// `raw` is still undefined, which is exactly what the check below concludes.
|
|
205
|
-
// A `return null` here would be a second owner of "there is no entry".
|
|
168
|
+
// A seam that threw has said there is no entry; `raw` is still undefined.
|
|
206
169
|
}
|
|
207
|
-
//
|
|
208
|
-
// there is nothing to drop. Every path below this line has an entry to
|
|
209
|
-
// account for. This is also what lets `readStoredEntry` take a non-nullish
|
|
210
|
-
// parameter and therefore need no object test.
|
|
170
|
+
// No entry is not a rejection: nothing is dropped.
|
|
211
171
|
if (raw === null || raw === undefined) {
|
|
212
172
|
return null;
|
|
213
173
|
}
|
|
@@ -220,7 +180,7 @@ export function createScrollbackKeeper(
|
|
|
220
180
|
|
|
221
181
|
// Distance in either direction: a clock that moved backwards would otherwise
|
|
222
182
|
// leave an entry whose age never reaches the bound.
|
|
223
|
-
if (Math.abs(Date.now() - entry.savedAt) > maxAgeMs) {
|
|
183
|
+
if (Math.abs(clock.Date.now() - entry.savedAt) > maxAgeMs) {
|
|
224
184
|
drop(sessionId);
|
|
225
185
|
return null;
|
|
226
186
|
}
|
|
@@ -231,22 +191,11 @@ export function createScrollbackKeeper(
|
|
|
231
191
|
return null;
|
|
232
192
|
}
|
|
233
193
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// near 0, so a hydrated store whose epoch cannot be compared would present
|
|
240
|
-
// old content as live AND then refuse the new session's output, because
|
|
241
|
-
// those low indices fall below what the hydrated store believes it evicted.
|
|
242
|
-
// A terminal that fills in slowly is a far better failure than one that is
|
|
243
|
-
// wrong and then permanently blank, so an unverifiable entry is discarded.
|
|
244
|
-
// In practice this never fires: an epoch is missing only from a server that
|
|
245
|
-
// reports none at all, in which case restart detection was never available
|
|
246
|
-
// to anyone.
|
|
247
|
-
//
|
|
248
|
-
// AFTER fromSnapshot, deliberately: reading it earlier would fire the warning
|
|
249
|
-
// below for a snapshot the engine was going to reject anyway.
|
|
194
|
+
// Absolute line indices only mean anything within one server process: a
|
|
195
|
+
// restarted server begins near 0, so a store whose epoch cannot be compared
|
|
196
|
+
// would show old content as live AND refuse the new output as already
|
|
197
|
+
// evicted. Judged AFTER fromSnapshot so the warning never fires for a
|
|
198
|
+
// snapshot the engine was going to reject anyway.
|
|
250
199
|
if (entry.serverEpoch === null) {
|
|
251
200
|
if (!warnedNoEpoch) {
|
|
252
201
|
warnedNoEpoch = true;
|
|
@@ -261,7 +210,7 @@ export function createScrollbackKeeper(
|
|
|
261
210
|
// The epoch must reach the connection layer BEFORE this session's resume, or
|
|
262
211
|
// the first resumeAck records it with nothing to compare against and a
|
|
263
212
|
// restart goes undetected for exactly the content that needed checking.
|
|
264
|
-
|
|
213
|
+
epochs.adoptPersistedEpoch(sessionId, entry.serverEpoch);
|
|
265
214
|
return store;
|
|
266
215
|
}
|
|
267
216
|
|
|
@@ -269,28 +218,21 @@ export function createScrollbackKeeper(
|
|
|
269
218
|
// Save under the epoch this session is CURRENTLY known to belong to. A
|
|
270
219
|
// session that has not resumed yet reports 0, and a snapshot that cannot be
|
|
271
220
|
// verified on the way back in is not worth writing on the way out.
|
|
272
|
-
const epoch =
|
|
221
|
+
const epoch = epochs.serverEpochOf(sessionId);
|
|
273
222
|
if (epoch === 0) {
|
|
274
223
|
return;
|
|
275
224
|
}
|
|
276
225
|
const snapshot = store.snapshot(epoch, lines);
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// just-reset store from erasing a good snapshot. The stale entry that
|
|
280
|
-
// survives instead is self-invalidating, because its epoch no longer
|
|
281
|
-
// matches — which is the whole reason the epoch is persisted.
|
|
226
|
+
// An empty store writes nothing, so a just-reset store cannot erase a good
|
|
227
|
+
// snapshot; the stale entry's epoch no longer matches, which invalidates it.
|
|
282
228
|
if (snapshot === null) {
|
|
283
229
|
return;
|
|
284
230
|
}
|
|
285
231
|
try {
|
|
286
|
-
cfg.save(sessionId, { savedAt: Date.now(), snapshot });
|
|
232
|
+
cfg.save(sessionId, { savedAt: clock.Date.now(), snapshot });
|
|
287
233
|
} catch {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// disk", and recording one here made the background pass skip the session
|
|
291
|
-
// until its output advanced again, which is how a full store stopped
|
|
292
|
-
// persisting silently. Warned once, because a feature that quietly does
|
|
293
|
-
// nothing is worse than one that says why.
|
|
234
|
+
// No watermark: recording one here made the background pass skip a session
|
|
235
|
+
// whose store never reached disk until its output advanced again.
|
|
294
236
|
if (!warnedSaveFailed) {
|
|
295
237
|
warnedSaveFailed = true;
|
|
296
238
|
console.warn(
|
|
@@ -302,36 +244,25 @@ export function createScrollbackKeeper(
|
|
|
302
244
|
savedThrough.set(sessionId, snapshot.highest);
|
|
303
245
|
}
|
|
304
246
|
|
|
305
|
-
/** Save a session whose content has advanced since its last recorded save
|
|
306
|
-
*
|
|
307
|
-
* Absent from `savedThrough` means never saved, which always counts as
|
|
308
|
-
* advanced. */
|
|
247
|
+
/** Save a session whose content has advanced since its last recorded save;
|
|
248
|
+
* never saved always counts as advanced. */
|
|
309
249
|
function saveIfAdvanced(sessionId: string, store: LineStore): void {
|
|
310
250
|
if (store.highestIndex() !== savedThrough.get(sessionId)) {
|
|
311
251
|
saveOne(sessionId, store);
|
|
312
252
|
}
|
|
313
253
|
}
|
|
314
254
|
|
|
315
|
-
/** The background pass.
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* not a free one, and the residue is worth naming: usually the re-sent window
|
|
320
|
-
* corrects a stale row on the next resume, but not when the session kept
|
|
321
|
-
* printing while the page was gone — the window has moved past that row by then,
|
|
322
|
-
* and the replay only covers lines above `haveThrough`, so a stale in-place
|
|
323
|
-
* rewrite (a spinner, a progress line) can sit in restored scrollback until it is
|
|
324
|
-
* evicted. Bounded by one screen height, and it needs the discard path where no
|
|
325
|
-
* lifecycle callback ran. Closing it would mean re-serialising the tail on every
|
|
326
|
-
* screen update, a continuous cost on the device least able to afford it, to fix
|
|
327
|
-
* at most a screenful of cosmetically stale history. */
|
|
255
|
+
/** The background pass. "Advanced" is the highest absolute index, so an
|
|
256
|
+
* in-place rewrite of rows already on screen (a spinner, a progress line) can
|
|
257
|
+
* sit stale in restored scrollback until evicted, bounded by one screen height.
|
|
258
|
+
* Closing that would re-serialise the tail on every screen update. */
|
|
328
259
|
function saveAdvanced(): void {
|
|
329
260
|
for (const [sessionId, store] of tracked) {
|
|
330
261
|
saveIfAdvanced(sessionId, store);
|
|
331
262
|
}
|
|
332
263
|
}
|
|
333
264
|
|
|
334
|
-
const timer = setInterval(saveAdvanced, saveIntervalMs);
|
|
265
|
+
const timer = clock.setInterval(saveAdvanced, saveIntervalMs);
|
|
335
266
|
|
|
336
267
|
return {
|
|
337
268
|
storeFor(sessionId) {
|
|
@@ -360,21 +291,10 @@ export function createScrollbackKeeper(
|
|
|
360
291
|
if (stopped) {
|
|
361
292
|
return;
|
|
362
293
|
}
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// content would, on being backgrounded, write that frozen content over a
|
|
368
|
-
// second page's newer entry — with a fresh timestamp, which also made the
|
|
369
|
-
// stale copy the one the sweep preferred. The next discard then restored the
|
|
370
|
-
// rolled-back entry and replayed thousands of lines, which is the exact
|
|
371
|
-
// symptom this feature exists to remove.
|
|
372
|
-
//
|
|
373
|
-
// A store that has not advanced since its last recorded save has nothing new
|
|
374
|
-
// to write, so skipping it loses nothing. What it gives up is refreshing
|
|
375
|
-
// `savedAt` on a long-idle open session, costing one full replay after the
|
|
376
|
-
// age bound expires — the right trade against silently discarding another
|
|
377
|
-
// page's history.
|
|
294
|
+
// NOT an unconditional write: only a shown session's store advances, and a
|
|
295
|
+
// page writing another session's load-time content over a second page's
|
|
296
|
+
// newer entry (with a fresh timestamp) rolled that entry back. The cost is
|
|
297
|
+
// one full replay for a long-idle session after the age bound expires.
|
|
378
298
|
for (const [sessionId, store] of tracked) {
|
|
379
299
|
saveIfAdvanced(sessionId, store);
|
|
380
300
|
}
|
|
@@ -390,7 +310,7 @@ export function createScrollbackKeeper(
|
|
|
390
310
|
},
|
|
391
311
|
stop() {
|
|
392
312
|
stopped = true;
|
|
393
|
-
clearInterval(timer);
|
|
313
|
+
clock.clearInterval(timer);
|
|
394
314
|
tracked.clear();
|
|
395
315
|
savedThrough.clear();
|
|
396
316
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document selection's text when EVERY range has both ends inside
|
|
3
|
+
* `container`, else "". A range dragged out into the host page, a Firefox
|
|
4
|
+
* Ctrl+drag multi-range selection with one range outside, and a bare caret
|
|
5
|
+
* (no text) all answer "". Range ends are in document order whichever way the
|
|
6
|
+
* user dragged. Known limitation: a container inside a host's shadow root never
|
|
7
|
+
* matches, because getSelection() does not reach into shadow trees.
|
|
8
|
+
*/
|
|
9
|
+
export function selectionTextWithin(container: Element): string {
|
|
10
|
+
const sel = container.ownerDocument.defaultView?.getSelection() ?? null;
|
|
11
|
+
if (sel === null) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
const text = sel.toString();
|
|
15
|
+
if (text.length === 0) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
for (let i = 0; i < sel.rangeCount; i++) {
|
|
19
|
+
const range = sel.getRangeAt(i);
|
|
20
|
+
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return text;
|
|
25
|
+
}
|