@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,280 @@
1
+ import { clampRatio, MIN_PANE_PX, SPLIT_GUTTER_PX } from "./layout-policy.js";
2
+ import { windowOf } from "./realm.js";
3
+ import type { PaneSide, ShellContext, SplitState, Unsubscribe } from "./types.js";
4
+
5
+ /** A press that travels less than this is a tap, not a drag. */
6
+ const DRAG_START_PX = 8;
7
+ /** At most one resize pair per this interval while a drag or a held key moves
8
+ * the divider; the engine drops a size equal to the last sent. */
9
+ const RESIZE_INTERVAL_MS = 100;
10
+ /** One arrow press moves the divider this far: two fallback cells of 8 px. */
11
+ const KEY_STEP_PX = 16;
12
+
13
+ const NUDGE_KEYS: ReadonlySet<string> = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
14
+
15
+ /** The handle as the shell holds it. */
16
+ export interface SplitHandle {
17
+ /** The `role="separator"` element, kept between the two pane roots. */
18
+ readonly element: HTMLElement;
19
+ dispose(): void;
20
+ }
21
+
22
+ /** What the handle reads off the shell: the root it measures, the controller it
23
+ * drives, and the panes it resizes. */
24
+ export type SplitHandleShell = Pick<ShellContext, "root" | "split" | "panes">;
25
+
26
+ interface Drag {
27
+ readonly pointerId: number;
28
+ readonly startX: number;
29
+ readonly startLeftPx: number;
30
+ /** The pane row minus the gutter: the width the ratio is a share of. */
31
+ readonly span: number;
32
+ /** Whether the pointer has travelled DRAG_START_PX yet. */
33
+ moving: boolean;
34
+ /** The pane under the minimum right now: dimmed, and closed on release. */
35
+ squeezed: PaneSide | null;
36
+ /** Whether any move of this drag put a pane under the minimum. */
37
+ dipped: boolean;
38
+ }
39
+
40
+ function clamp01(value: number): number {
41
+ return Math.min(Math.max(value, 0), 1);
42
+ }
43
+
44
+ /** The grip handle between the two panes: a focusable separator a pointer drags
45
+ * and the arrow keys nudge. The caller places the element between the pane
46
+ * roots; the stylesheet shows it only while the split is open and not
47
+ * collapsed. Transactional: a throw during construction releases the
48
+ * subscription and the listeners taken before it. */
49
+ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
50
+ const root = shell.root;
51
+ const doc = root.ownerDocument;
52
+ const win = windowOf(doc);
53
+ const split = shell.split;
54
+ const own = new AbortController();
55
+
56
+ const el = doc.createElement("div");
57
+ el.className = "wt-split-handle";
58
+ el.setAttribute("role", "separator");
59
+ el.setAttribute("aria-orientation", "vertical");
60
+ el.setAttribute("aria-label", "Pane divider");
61
+ el.tabIndex = 0;
62
+
63
+ const bounds = (): { min: number; max: number } => {
64
+ const width = root.clientWidth;
65
+ return { min: clampRatio(0, width), max: clampRatio(1, width) };
66
+ };
67
+ const percent = (ratio: number): string => String(Math.round(ratio * 100));
68
+ function paint(state: SplitState): void {
69
+ const { min, max } = bounds();
70
+ el.setAttribute("aria-valuenow", percent(state.ratio));
71
+ el.setAttribute("aria-valuemin", percent(min));
72
+ el.setAttribute("aria-valuemax", percent(max));
73
+ el.dataset["faces"] = state.selected;
74
+ if (drag !== null && (!state.open || state.collapsed)) {
75
+ abandonDrag();
76
+ }
77
+ }
78
+
79
+ let lastSentAt = -Infinity;
80
+ let trailing: number | null = null;
81
+ function announceNow(): void {
82
+ lastSentAt = win.Date.now();
83
+ for (const pane of shell.panes()) {
84
+ pane.announceSize();
85
+ }
86
+ }
87
+ function cancelTrailing(): void {
88
+ if (trailing !== null) {
89
+ win.clearTimeout(trailing);
90
+ trailing = null;
91
+ }
92
+ }
93
+ function announceThrottled(): void {
94
+ const elapsed = win.Date.now() - lastSentAt;
95
+ if (elapsed >= RESIZE_INTERVAL_MS) {
96
+ cancelTrailing();
97
+ announceNow();
98
+ return;
99
+ }
100
+ trailing ??= win.setTimeout(() => {
101
+ trailing = null;
102
+ announceNow();
103
+ }, RESIZE_INTERVAL_MS - elapsed);
104
+ }
105
+ function flushAnnounce(): void {
106
+ cancelTrailing();
107
+ announceNow();
108
+ }
109
+
110
+ function squeezedAt(ratio: number, span: number): PaneSide | null {
111
+ const leftPx = ratio * span;
112
+ if (leftPx < MIN_PANE_PX) {
113
+ return "left";
114
+ }
115
+ return span - leftPx < MIN_PANE_PX ? "right" : null;
116
+ }
117
+ function paintSqueezed(side: PaneSide | null): void {
118
+ for (const s of ["left", "right"] as const) {
119
+ root
120
+ .querySelector(`:scope > .wt-split-pane.wt-side-${s}`)
121
+ ?.classList.toggle("wt-pane-closing", s === side);
122
+ }
123
+ }
124
+
125
+ let drag: Drag | null = null;
126
+ let gesture: AbortController | null = null;
127
+ function endDrag(): void {
128
+ gesture?.abort();
129
+ gesture = null;
130
+ drag = null;
131
+ el.classList.remove("wt-handle-held");
132
+ }
133
+ /** The split closed or collapsed under the pointer: the drag decides nothing. */
134
+ function abandonDrag(): void {
135
+ endDrag();
136
+ cancelTrailing();
137
+ paintSqueezed(null);
138
+ }
139
+ function onPointerDown(ev: PointerEvent): void {
140
+ const state = split.state();
141
+ if (drag !== null || ev.button !== 0 || !state.open || state.collapsed) {
142
+ return;
143
+ }
144
+ // Keeps focus in the terminal and stops a text selection from starting.
145
+ ev.preventDefault();
146
+ try {
147
+ el.setPointerCapture(ev.pointerId);
148
+ } catch {
149
+ /* a pointer the browser no longer tracks; the window listeners follow it */
150
+ }
151
+ const span = root.clientWidth - SPLIT_GUTTER_PX;
152
+ drag = {
153
+ pointerId: ev.pointerId,
154
+ startX: ev.clientX,
155
+ startLeftPx: state.ratio * span,
156
+ span,
157
+ moving: false,
158
+ squeezed: null,
159
+ dipped: false,
160
+ };
161
+ el.classList.add("wt-handle-held");
162
+ gesture = new AbortController();
163
+ const opts = { signal: gesture.signal };
164
+ win.addEventListener("pointermove", onPointerMove, opts);
165
+ win.addEventListener("pointerup", onPointerEnd, opts);
166
+ win.addEventListener("pointercancel", onPointerEnd, opts);
167
+ }
168
+ function onPointerMove(ev: PointerEvent): void {
169
+ const d = drag;
170
+ if (d?.pointerId !== ev.pointerId) {
171
+ return;
172
+ }
173
+ const dx = ev.clientX - d.startX;
174
+ if (!d.moving) {
175
+ if (Math.abs(dx) < DRAG_START_PX) {
176
+ return;
177
+ }
178
+ d.moving = true;
179
+ }
180
+ const ratio = clamp01((d.startLeftPx + dx) / d.span);
181
+ split.setRatio(ratio, false);
182
+ d.squeezed = squeezedAt(ratio, d.span);
183
+ if (d.squeezed !== null) {
184
+ d.dipped = true;
185
+ }
186
+ paintSqueezed(d.squeezed);
187
+ announceThrottled();
188
+ }
189
+ function onPointerEnd(ev: PointerEvent): void {
190
+ const d = drag;
191
+ if (d?.pointerId !== ev.pointerId) {
192
+ return;
193
+ }
194
+ endDrag();
195
+ if (!d.moving) {
196
+ return;
197
+ }
198
+ cancelTrailing();
199
+ paintSqueezed(null);
200
+ if (d.squeezed !== null && split.closeSide(d.squeezed)) {
201
+ return;
202
+ }
203
+ // A drag that dipped under the minimum was a close gesture, so releasing
204
+ // above it, or a close the split refuses, cancels the drag rather than
205
+ // committing a share at or past the threshold.
206
+ const state = split.state();
207
+ split.setRatio(d.dipped ? state.committedRatio : state.ratio, true);
208
+ announceNow();
209
+ }
210
+ let nudgePending = false;
211
+ function onKeydown(ev: KeyboardEvent): void {
212
+ if (!NUDGE_KEYS.has(ev.key)) {
213
+ return;
214
+ }
215
+ const state = split.state();
216
+ if (!state.open || state.collapsed) {
217
+ return;
218
+ }
219
+ ev.preventDefault();
220
+ const span = root.clientWidth - SPLIT_GUTTER_PX;
221
+ const { min, max } = bounds();
222
+ let next: number;
223
+ if (ev.key === "ArrowLeft") {
224
+ next = (Math.round(state.ratio * span) - KEY_STEP_PX) / span;
225
+ } else if (ev.key === "ArrowRight") {
226
+ next = (Math.round(state.ratio * span) + KEY_STEP_PX) / span;
227
+ } else {
228
+ next = ev.key === "Home" ? min : max;
229
+ }
230
+ // The separator declares aria-valuemin and aria-valuemax as the values that
231
+ // exist, so a key never crosses them, and a held key has no release to
232
+ // confirm a close with; closing from the keyboard is the split button's.
233
+ next = Math.min(Math.max(next, min), max);
234
+ if (next === state.ratio) {
235
+ return;
236
+ }
237
+ split.setRatio(next, false);
238
+ nudgePending = true;
239
+ announceThrottled();
240
+ }
241
+ // A key's release edge is the analogue of the pointer's release, so a held key
242
+ // writes one record.
243
+ function commitNudge(): void {
244
+ if (!nudgePending) {
245
+ return;
246
+ }
247
+ nudgePending = false;
248
+ flushAnnounce();
249
+ split.setRatio(split.state().ratio, true);
250
+ }
251
+ function onKeyup(ev: KeyboardEvent): void {
252
+ if (NUDGE_KEYS.has(ev.key)) {
253
+ commitNudge();
254
+ }
255
+ }
256
+
257
+ let offChange: Unsubscribe = () => undefined;
258
+ function dispose(): void {
259
+ own.abort();
260
+ endDrag();
261
+ cancelTrailing();
262
+ nudgePending = false;
263
+ offChange();
264
+ el.remove();
265
+ }
266
+ try {
267
+ paint(split.state());
268
+ offChange = split.onChange(paint);
269
+ const opts = { signal: own.signal };
270
+ el.addEventListener("pointerdown", onPointerDown, opts);
271
+ el.addEventListener("keydown", onKeydown, opts);
272
+ el.addEventListener("keyup", onKeyup, opts);
273
+ el.addEventListener("blur", commitNudge, opts);
274
+ } catch (err) {
275
+ dispose();
276
+ throw err;
277
+ }
278
+
279
+ return { element: el, dispose };
280
+ }
@@ -0,0 +1,100 @@
1
+ // One status stream per path for the whole page, shared by every subscriber:
2
+ // the server caps its stream subscribers, so two features that both want the
3
+ // live session set must not cost two connections.
4
+
5
+ import type { StatusStream, StatusStreamCallbacks } from "@cplieger/web-terminal-engine";
6
+ import type { Unsubscribe } from "./types.js";
7
+
8
+ /** Opens the server's status stream at a path; the engine's `connectStatusStream`
9
+ * in production. */
10
+ export type StatusConnector = (path: string, callbacks: StatusStreamCallbacks) => StatusStream;
11
+
12
+ /** The shell's shared status streams. */
13
+ export interface StatusShare {
14
+ /** Subscribe to the stream at `path`. The first subscriber opens it, the last
15
+ * one's unsubscribe closes it; a subscriber joining a stream that has already
16
+ * opened has its `onOpen` called at once, since the stream carries only future
17
+ * changes and that subscriber missed the open it would resync on. A callback
18
+ * that throws is logged and never propagated, from this call as from the stream. */
19
+ subscribe(path: string, callbacks: StatusStreamCallbacks): Unsubscribe;
20
+ /** Close every stream. A later unsubscribe releases nothing and throws nothing. */
21
+ dispose(): void;
22
+ }
23
+
24
+ interface SharedStream {
25
+ readonly stream: StatusStream;
26
+ readonly subscribers: Set<StatusStreamCallbacks>;
27
+ opened: boolean;
28
+ }
29
+
30
+ function deliver(cb: StatusStreamCallbacks, call: (cb: StatusStreamCallbacks) => void): void {
31
+ try {
32
+ call(cb);
33
+ } catch (err) {
34
+ console.error("web-terminal-ui: status stream subscriber threw", err);
35
+ }
36
+ }
37
+
38
+ function fanOut(
39
+ subscribers: Set<StatusStreamCallbacks>,
40
+ call: (cb: StatusStreamCallbacks) => void,
41
+ ): void {
42
+ for (const cb of [...subscribers]) {
43
+ deliver(cb, call);
44
+ }
45
+ }
46
+
47
+ export function createStatusShare(connect: StatusConnector): StatusShare {
48
+ const streams = new Map<string, SharedStream>();
49
+
50
+ function open(path: string): SharedStream {
51
+ const subscribers = new Set<StatusStreamCallbacks>();
52
+ const shared: SharedStream = {
53
+ subscribers,
54
+ opened: false,
55
+ stream: connect(path, {
56
+ onOpen() {
57
+ shared.opened = true;
58
+ fanOut(subscribers, (cb) => cb.onOpen?.());
59
+ },
60
+ onStatus(status) {
61
+ fanOut(subscribers, (cb) => {
62
+ cb.onStatus(status);
63
+ });
64
+ },
65
+ onError() {
66
+ fanOut(subscribers, (cb) => cb.onError?.());
67
+ },
68
+ }),
69
+ };
70
+ streams.set(path, shared);
71
+ return shared;
72
+ }
73
+
74
+ return {
75
+ subscribe(path, callbacks) {
76
+ const shared = streams.get(path) ?? open(path);
77
+ shared.subscribers.add(callbacks);
78
+ if (shared.opened) {
79
+ deliver(callbacks, (cb) => cb.onOpen?.());
80
+ }
81
+ return () => {
82
+ shared.subscribers.delete(callbacks);
83
+ if (shared.subscribers.size > 0) {
84
+ return;
85
+ }
86
+ if (streams.get(path) === shared) {
87
+ streams.delete(path);
88
+ shared.stream.close();
89
+ }
90
+ };
91
+ },
92
+ dispose() {
93
+ for (const shared of streams.values()) {
94
+ shared.subscribers.clear();
95
+ shared.stream.close();
96
+ }
97
+ streams.clear();
98
+ },
99
+ };
100
+ }