@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.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +52 -13
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. 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
+ }
@@ -1,12 +1,8 @@
1
- // Layout regions (design section 22.13).
2
- //
3
- // The kernel owns a small layout skeleton: named regions with fixed geometry,
4
- // stacking, and keyboard-inset behavior. Features contribute chrome into a
5
- // region via ctx.region(name, slot); the region owns position, spacing, stack
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 = document.createElement("div");
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 = document.createElement("div");
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
@@ -1,46 +1,43 @@
1
- // Scrollback persistence: the kernel's half of CreateTerminalOptions.
2
- // persistScrollback.
3
- //
4
- // The split is mechanism here, storage in the consumer. The engine supplies the
5
- // data (LineStore.snapshot / LineStore.fromSnapshot, plain structuredClone-safe
6
- // objects) and the identity of the server process it belongs to
7
- // (connection.serverEpochOf / adoptPersistedEpoch); this module owns WHEN a
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, connection } from "@cplieger/web-terminal-engine";
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
- * Newest lines persisted per session when the consumer names no bound.
25
- *
26
- * 200, not the store's own cap. The closest precedent is VS Code's
27
- * `terminal.integrated.persistentSessionScrollback`, which restores 100 lines by
28
- * default — evidence that the screen plus recent history is what a returning user
29
- * needs, and that a full buffer is not. 200 gives headroom over that while keeping
30
- * the cost honest: measured on the real stored envelope, 200 lines is ~24 K
31
- * characters plain and ~60 K coloured, serialising in under a millisecond, against
32
- * ~122 K / ~298 K and up to 4 ms at 1000. Since a write happens on every
33
- * backgrounding and on a timer while output advances, that difference is paid
34
- * repeatedly on the device this feature exists for.
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. A week covers "back to it on Monday"
38
- * while keeping an abandoned session's output from living indefinitely.
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
- * Stricter than "a finite number", because this value is adopted as a
82
- * session's IDENTITY: the server reports a process-start timestamp in
83
- * nanoseconds, so a negative or fractional value cannot have come from one
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. `LineStore.fromSnapshot` takes `unknown` and
94
- * is the authority on a snapshot's contents (engine store.ts:1584), so a
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
- * Read a stored entry, or null when it cannot be used.
101
- *
102
- * `unknown` in, because the value has been outside this program's memory and
103
- * `ScrollbackPersistence.load`'s declared return type is an assumption about a
104
- * consumer's storage rather than a fact about its contents — the library's own
105
- * implementation produces one by `JSON.parse`. A `StoredEntry` out, so every
106
- * later line reads fields instead of re-establishing them.
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
- // Read through a nullable cast rather than a typeof test: `?.` is safe on a
132
- // primitive as well as on null, so one operator covers every shape a stored
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 and validate a stored entry, dropping it when it cannot be used.
184
- * Returns the hydrated store, or null to start empty and take a full resume.
185
- *
186
- * Every rejection below has that same correct handling, which is why none of
187
- * them throws or reports: the worst outcome of starting empty is one slow
188
- * restore, and the worst outcome of hydrating something unsound is a terminal
189
- * that shows content from a session that no longer exists.
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
- // Deliberately empty: a seam that threw has told us there is no entry, and
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
- // "No entry" is silent and is not a rejection: nothing is dropped, because
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
- // Structure checked; now the one rejection that is a real decision rather
235
- // than a validation, and the reason the epoch is read at the boundary but
236
- // JUDGED here. An entry with no server epoch cannot be verified against the
237
- // server it is about to be shown next to, and absolute line indices only
238
- // mean anything within one server process: a restarted server begins again
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
- connection.adoptPersistedEpoch(sessionId, entry.serverEpoch);
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 = connection.serverEpochOf(sessionId);
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
- // null means the store holds nothing. Skipping rather than writing an empty
278
- // entry is the engine's contract, and it matters here: it is what stops a
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
- // Nothing was persisted (over quota, storage revoked, an entry too large).
289
- // Do NOT record a watermark: the whole point of the watermark is "this is on
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
- * "Advanced" is the highest absolute index, so it misses a redraw that rewrote
318
- * rows already on screen without printing new ones. That is a deliberate trade,
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
- // Same predicate as the background pass, and NOT an unconditional write.
364
- // Under the tabs feature every tab gets a store and every one is hydrated at
365
- // load, but only the ACTIVE session's store advances, because there is one
366
- // socket. So a page holding another session's store frozen at its load-time
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
+ }