@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,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
|
+
}
|