@cplieger/web-terminal-ui 7.3.2 → 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 +21 -0
- 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
package/src/composition.ts
CHANGED
|
@@ -1,78 +1,24 @@
|
|
|
1
|
-
// IME / composition support.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// phrase is finalised.
|
|
9
|
-
//
|
|
10
|
-
// What this module does:
|
|
11
|
-
// 1. On compositionstart, mark composing=true and capture textarea
|
|
12
|
-
// selection bounds. Show the floating compositionView at the
|
|
13
|
-
// cursor with the in-progress text.
|
|
14
|
-
// 2. On compositionupdate, mirror the in-progress text into
|
|
15
|
-
// compositionView (\u200E LTR markers, RTL CSS direction so long
|
|
16
|
-
// compositions track their tail).
|
|
17
|
-
// 3. On compositionend, set a setTimeout(0) finaliser. The
|
|
18
|
-
// compositionend.data property is unreliable on Chromium (Korean
|
|
19
|
-
// ending consonant moves to next char on vowel input, etc.); the
|
|
20
|
-
// textarea value at next tick has the truth.
|
|
21
|
-
// 4. While composing, callers' keydown handler must early-return
|
|
22
|
-
// (handled via isComposing()). xterm.js takes the same approach.
|
|
23
|
-
//
|
|
24
|
-
// Positioning: the compositionView is absolutely positioned inside
|
|
25
|
-
// `termWrap`. The IME UI (browser-rendered popup with candidate words)
|
|
26
|
-
// uses the textarea as its anchor; we move the textarea to the cursor
|
|
27
|
-
// for the same reason.
|
|
1
|
+
// IME / composition support, after xterm.js's CompositionHelper: the browser
|
|
2
|
+
// fires compositionstart, compositionupdate(s) and compositionend while an IME
|
|
3
|
+
// (CJK, Vietnamese, dictation, an autocorrect bar) is active, and without a
|
|
4
|
+
// handler the user sees nothing until the phrase is finalised. The in-progress
|
|
5
|
+
// text is mirrored into a floating view at the cursor; the textarea is moved
|
|
6
|
+
// there too because the IME's candidate popup anchors on it. compositionend's
|
|
7
|
+
// `data` is unreliable on Chromium, so the textarea value is read one tick later.
|
|
28
8
|
|
|
29
9
|
import { resetToPlaceholder } from "./input-placeholder.js";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let compositionStart = 0;
|
|
40
|
-
let compositionSuffix = "";
|
|
41
|
-
|
|
42
|
-
// A composition that never ENDS used to stop the terminal accepting input at
|
|
43
|
-
// all. `composing` gates both the kernel's `input` and `keydown` listeners, and
|
|
44
|
-
// nothing cleared it: no blur handler, no timer. So a keyboard that opened a
|
|
45
|
-
// composition and never closed it left every subsequent keystroke, Enter
|
|
46
|
-
// included, dropped on the floor — reported on Android Chrome with SwiftKey as
|
|
47
|
-
// "typing does not work", recoverable only by tapping a word suggestion, which
|
|
48
|
-
// is what finally fired compositionend.
|
|
49
|
-
//
|
|
50
|
-
// Not a SwiftKey quirk to special-case. Chromium has a filed defect for
|
|
51
|
-
// compositionend going missing (crbug 446714223, in EditContext rather than a
|
|
52
|
-
// textarea, so it is adjacent evidence and not proof of this exact path), and
|
|
53
|
-
// CodeMirror synthesises the event outright for Safari dead keys, which is a
|
|
54
|
-
// working implementation treating the same absence as real.
|
|
55
|
-
//
|
|
56
|
-
// The fix is a STALENESS READ rather than a timer. A composition the user is
|
|
57
|
-
// typing into keeps firing compositionupdate, so its clock keeps refreshing; an
|
|
58
|
-
// abandoned one stops and ages out, releasing the gate. UI Events promises one
|
|
59
|
-
// or more compositionupdate events and one whenever the DOM is updated as part
|
|
60
|
-
// of the composition, not strictly one per key, so this is a heuristic about
|
|
61
|
-
// activity rather than a guaranteed per-keystroke signal. Deliberately NOT
|
|
62
|
-
// refreshed by `input` events: in this failure the user IS typing and those
|
|
63
|
-
// events ARE arriving, so refreshing there would defeat the mechanism.
|
|
64
|
-
//
|
|
65
|
-
// Five seconds is the number Slate's android-input-manager uses for its own
|
|
66
|
-
// composition-gone-quiet bound (a DOM flush decision, not an input gate, so the
|
|
67
|
-
// value is borrowed and the job is not the same). It is generous because the
|
|
68
|
-
// residual risk runs the other way: a composition paused longer than this and
|
|
69
|
-
// then resumed can duplicate its first character. That trade is deliberate —
|
|
70
|
-
// the failure being replaced loses ALL input until an unrelated event fires.
|
|
10
|
+
import { windowOf } from "./kernel/realm.js";
|
|
11
|
+
|
|
12
|
+
/** A composition that never ends would gate every later keystroke out, and it
|
|
13
|
+
* happens: Android Chrome with SwiftKey fires no compositionend until a word
|
|
14
|
+
* suggestion is tapped (crbug 446714223 is adjacent evidence). So the latch is
|
|
15
|
+
* read against a STALENESS clock refreshed by compositionupdate alone, never by
|
|
16
|
+
* `input` (in this failure the user IS typing). Five seconds is Slate's
|
|
17
|
+
* composition-gone-quiet bound; the residual risk is one duplicated character
|
|
18
|
+
* after a pause, against losing all input until an unrelated event fires. */
|
|
71
19
|
const COMPOSITION_IDLE_MS = 5000;
|
|
72
20
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
export function init(opts: {
|
|
21
|
+
export interface CompositionOptions {
|
|
76
22
|
textarea: HTMLTextAreaElement;
|
|
77
23
|
compositionView: HTMLElement;
|
|
78
24
|
getCursorPx: () => { left: number; top: number; cellH: number };
|
|
@@ -80,191 +26,173 @@ export function init(opts: {
|
|
|
80
26
|
/** The kernel's single bracketed-paste+normalize funnel; the native iOS-callout
|
|
81
27
|
* paste routes through it rather than re-composing the funnel here. */
|
|
82
28
|
paste: (text: string) => void;
|
|
83
|
-
}
|
|
84
|
-
textarea = opts.textarea;
|
|
85
|
-
compositionView = opts.compositionView;
|
|
86
|
-
getCursorPx = opts.getCursorPx;
|
|
87
|
-
send = opts.send;
|
|
88
|
-
paste = opts.paste;
|
|
29
|
+
}
|
|
89
30
|
|
|
90
|
-
|
|
91
|
-
textarea.
|
|
92
|
-
|
|
93
|
-
|
|
31
|
+
/** One pane's IME state: the latch every input listener gates on, the floating
|
|
32
|
+
* view, and the textarea placement. */
|
|
33
|
+
export interface Composition {
|
|
34
|
+
/** True while an IME composition is in progress, reconciled first: a
|
|
35
|
+
* composition quiet for COMPOSITION_IDLE_MS is expired here. */
|
|
36
|
+
isComposing(): boolean;
|
|
37
|
+
/** The RAW latch: is a composition open at all, regardless of staleness. Read
|
|
38
|
+
* before `isComposing()` by the keydown guard, because keyCode 229 means "an
|
|
39
|
+
* IME claimed this key" only while a composition is genuinely open. */
|
|
40
|
+
isCompositionOpen(): boolean;
|
|
41
|
+
/** Abort any in-flight composition without sending, and clear the textarea.
|
|
42
|
+
* Also neutralizes a just-fired compositionend whose deferred send is still
|
|
43
|
+
* pending, so it cannot land on whoever is active after a switch. */
|
|
44
|
+
cancelComposition(): void;
|
|
45
|
+
/** Place the view and the textarea at the terminal cursor. */
|
|
46
|
+
positionCompositionView(): void;
|
|
47
|
+
/** Release the listeners and any pending send. */
|
|
48
|
+
teardown(): void;
|
|
94
49
|
}
|
|
95
50
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
51
|
+
export function createComposition(opts: CompositionOptions): Composition {
|
|
52
|
+
const { textarea, compositionView, getCursorPx, send, paste } = opts;
|
|
53
|
+
// The textarea's own clock and timers: the deferred send belongs to the
|
|
54
|
+
// document the terminal is mounted in, not the importing page.
|
|
55
|
+
const win = windowOf(textarea.ownerDocument);
|
|
56
|
+
let composing = false;
|
|
57
|
+
let compositionStart = 0;
|
|
58
|
+
let compositionSuffix = "";
|
|
59
|
+
let lastCompositionActivity = 0;
|
|
60
|
+
let pendingSend: number | null = null;
|
|
61
|
+
|
|
62
|
+
function expireComposition(): void {
|
|
63
|
+
composing = false;
|
|
64
|
+
lastCompositionActivity = 0;
|
|
65
|
+
compositionView.textContent = "";
|
|
66
|
+
compositionView.classList.remove("active");
|
|
108
67
|
}
|
|
109
|
-
|
|
68
|
+
|
|
69
|
+
function isComposing(): boolean {
|
|
70
|
+
if (pendingSend !== null) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (!composing) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (win.Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
expireComposition();
|
|
110
80
|
return false;
|
|
111
81
|
}
|
|
112
|
-
if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
expireComposition();
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
82
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return composing || sendingComposition;
|
|
126
|
-
}
|
|
83
|
+
function clearPendingSend(): void {
|
|
84
|
+
if (pendingSend !== null) {
|
|
85
|
+
win.clearTimeout(pendingSend);
|
|
86
|
+
pendingSend = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
127
89
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
compositionView.textContent = "";
|
|
137
|
-
compositionView.classList.remove("active");
|
|
138
|
-
}
|
|
90
|
+
function cancelComposition(): void {
|
|
91
|
+
composing = false;
|
|
92
|
+
clearPendingSend();
|
|
93
|
+
lastCompositionActivity = 0;
|
|
94
|
+
compositionView.textContent = "";
|
|
95
|
+
compositionView.classList.remove("active");
|
|
96
|
+
resetToPlaceholder(textarea);
|
|
97
|
+
}
|
|
139
98
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
resetToPlaceholder(textarea);
|
|
154
|
-
}
|
|
99
|
+
function positionCompositionView(): void {
|
|
100
|
+
const { left, top, cellH } = getCursorPx();
|
|
101
|
+
compositionView.style.left = `${left}px`;
|
|
102
|
+
compositionView.style.top = `${top}px`;
|
|
103
|
+
compositionView.style.height = `${cellH}px`;
|
|
104
|
+
compositionView.style.lineHeight = `${cellH}px`;
|
|
105
|
+
// The textarea shares the view's content coordinates and .term is pinned to
|
|
106
|
+
// the visual viewport, so this keeps the focused input at the VISIBLE cursor,
|
|
107
|
+
// above the keyboard, where iOS wants it and does not scroll the page.
|
|
108
|
+
textarea.style.left = `${left}px`;
|
|
109
|
+
textarea.style.top = `${top}px`;
|
|
110
|
+
textarea.style.height = `${cellH}px`;
|
|
111
|
+
}
|
|
155
112
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
/* no-op after teardown */
|
|
169
|
-
};
|
|
170
|
-
paste = () => {
|
|
171
|
-
/* no-op after teardown */
|
|
172
|
-
};
|
|
173
|
-
getCursorPx = () => ({ left: 0, top: 0, cellH: 0 });
|
|
174
|
-
}
|
|
113
|
+
function onStart(): void {
|
|
114
|
+
composing = true;
|
|
115
|
+
lastCompositionActivity = win.Date.now();
|
|
116
|
+
const start = textarea.selectionStart;
|
|
117
|
+
const end = textarea.selectionEnd;
|
|
118
|
+
compositionStart = Math.min(start, end);
|
|
119
|
+
const compositionEnd = Math.max(start, end);
|
|
120
|
+
compositionSuffix = textarea.value.substring(compositionEnd);
|
|
121
|
+
compositionView.textContent = "";
|
|
122
|
+
compositionView.classList.add("active");
|
|
123
|
+
positionCompositionView();
|
|
124
|
+
}
|
|
175
125
|
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
compositionSuffix = textarea.value.substring(compositionEnd);
|
|
184
|
-
compositionView.textContent = "";
|
|
185
|
-
compositionView.classList.add("active");
|
|
186
|
-
positionCompositionView();
|
|
187
|
-
}
|
|
126
|
+
function onUpdate(ev: CompositionEvent): void {
|
|
127
|
+
lastCompositionActivity = win.Date.now();
|
|
128
|
+
// LTR marks plus direction:rtl on the view show a long composition's tail
|
|
129
|
+
// instead of clipping its start (xterm.js's pattern).
|
|
130
|
+
compositionView.textContent = `\u200E${ev.data}\u200E`;
|
|
131
|
+
positionCompositionView();
|
|
132
|
+
}
|
|
188
133
|
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
134
|
+
function onEnd(): void {
|
|
135
|
+
compositionView.classList.remove("active");
|
|
136
|
+
composing = false;
|
|
137
|
+
lastCompositionActivity = 0;
|
|
138
|
+
clearPendingSend();
|
|
139
|
+
const startSnapshot = compositionStart;
|
|
140
|
+
const suffixSnapshot = compositionSuffix;
|
|
141
|
+
pendingSend = win.setTimeout(() => {
|
|
142
|
+
pendingSend = null;
|
|
143
|
+
const value = textarea.value;
|
|
144
|
+
const valueEnd =
|
|
145
|
+
suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
|
|
146
|
+
? value.length - suffixSnapshot.length
|
|
147
|
+
: value.length;
|
|
148
|
+
const composed = value
|
|
149
|
+
.substring(startSnapshot, Math.max(startSnapshot, valueEnd))
|
|
150
|
+
.replace(/\u00A0/g, " ");
|
|
151
|
+
if (composed.length > 0) {
|
|
152
|
+
send(composed);
|
|
153
|
+
}
|
|
154
|
+
resetToPlaceholder(textarea);
|
|
155
|
+
}, 0);
|
|
156
|
+
}
|
|
200
157
|
|
|
201
|
-
function
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
lastCompositionActivity = 0;
|
|
205
|
-
// The compositionend event fires before the textarea reflects the
|
|
206
|
-
// final value on most browsers (Chromium especially). Defer one tick
|
|
207
|
-
// and read the textarea then; xterm.js's pattern.
|
|
208
|
-
sendingComposition = true;
|
|
209
|
-
const startSnapshot = compositionStart;
|
|
210
|
-
const suffixSnapshot = compositionSuffix;
|
|
211
|
-
setTimeout(() => {
|
|
212
|
-
if (!sendingComposition) {
|
|
158
|
+
function onPaste(ev: ClipboardEvent): void {
|
|
159
|
+
// iOS has no Ctrl+Shift+V; its callout menu fires this on the focused textarea.
|
|
160
|
+
if (!ev.clipboardData) {
|
|
213
161
|
return;
|
|
214
162
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
|
|
219
|
-
? value.length - suffixSnapshot.length
|
|
220
|
-
: value.length;
|
|
221
|
-
const composed = value
|
|
222
|
-
.substring(startSnapshot, Math.max(startSnapshot, valueEnd))
|
|
223
|
-
.replace(/\u00A0/g, " ");
|
|
224
|
-
if (composed.length > 0) {
|
|
225
|
-
send(composed);
|
|
163
|
+
const raw = ev.clipboardData.getData("text/plain");
|
|
164
|
+
if (raw === "") {
|
|
165
|
+
return;
|
|
226
166
|
}
|
|
167
|
+
ev.preventDefault();
|
|
168
|
+
ev.stopPropagation();
|
|
169
|
+
paste(raw);
|
|
227
170
|
resetToPlaceholder(textarea);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
171
|
+
}
|
|
230
172
|
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
173
|
+
// One signal over every listener, so a registration that throws leaves the
|
|
174
|
+
// ones before it released with it.
|
|
175
|
+
const wired = new AbortController();
|
|
176
|
+
const { signal } = wired;
|
|
177
|
+
function teardown(): void {
|
|
178
|
+
cancelComposition();
|
|
179
|
+
wired.abort();
|
|
237
180
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
181
|
+
try {
|
|
182
|
+
textarea.addEventListener("compositionstart", onStart, { signal });
|
|
183
|
+
textarea.addEventListener("compositionupdate", onUpdate, { signal });
|
|
184
|
+
textarea.addEventListener("compositionend", onEnd, { signal });
|
|
185
|
+
textarea.addEventListener("paste", onPaste, { signal });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
teardown();
|
|
188
|
+
throw err;
|
|
241
189
|
}
|
|
242
|
-
ev.preventDefault();
|
|
243
|
-
ev.stopPropagation();
|
|
244
|
-
paste(raw);
|
|
245
|
-
// Restore the placeholder so the subsequent `input` event (some
|
|
246
|
-
// browsers fire it after paste) and held-Backspace auto-repeat keep
|
|
247
|
-
// working.
|
|
248
|
-
resetToPlaceholder(textarea);
|
|
249
|
-
}
|
|
250
190
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
compositionView.style.top = `${top}px`;
|
|
259
|
-
compositionView.style.height = `${cellH}px`;
|
|
260
|
-
compositionView.style.lineHeight = `${cellH}px`;
|
|
261
|
-
// The textarea is position:absolute in the same scroll/content space as the
|
|
262
|
-
// composition view (see the .term-input CSS note), so the cursor's content
|
|
263
|
-
// coordinates place it at the cursor row directly. Because .term is pinned to
|
|
264
|
-
// the visual viewport (viewport.ts), that keeps the textarea at the VISIBLE
|
|
265
|
-
// cursor, above the keyboard — where iOS wants the focused input so it doesn't
|
|
266
|
-
// scroll the page to reveal it.
|
|
267
|
-
textarea.style.left = `${left}px`;
|
|
268
|
-
textarea.style.top = `${top}px`;
|
|
269
|
-
textarea.style.height = `${cellH}px`;
|
|
191
|
+
return {
|
|
192
|
+
isComposing,
|
|
193
|
+
isCompositionOpen: () => composing || pendingSend !== null,
|
|
194
|
+
cancelComposition,
|
|
195
|
+
positionCompositionView,
|
|
196
|
+
teardown,
|
|
197
|
+
};
|
|
270
198
|
}
|
|
@@ -1,77 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* session's live status through its API. It is a
|
|
5
|
-
* pure data source with no chrome of its own; the tabs feature consumes it via
|
|
6
|
-
* ctx.use to render per-tab activity dots and drop exited/removed tabs. The
|
|
7
|
-
* server pushes an initial snapshot on every (re)open — but the snapshot
|
|
8
|
-
* carries no tombstones for sessions a REPLACEMENT server never knew (manager
|
|
9
|
-
* restart), so the stream-open signal is surfaced via onStreamOpen and the
|
|
10
|
-
* consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
|
|
11
|
-
* zombie tabs.
|
|
12
|
-
*
|
|
13
|
-
* @module
|
|
14
|
-
*/
|
|
1
|
+
// The server's snapshot on a (re)open carries no tombstones for sessions a
|
|
2
|
+
// REPLACEMENT server never knew, so the open is surfaced through onStreamOpen
|
|
3
|
+
// for a consumer to reconcile on.
|
|
15
4
|
|
|
16
|
-
import { connectStatusStream } from "@cplieger/web-terminal-engine";
|
|
17
5
|
import type { SessionStatus } from "@cplieger/web-terminal-engine";
|
|
18
6
|
import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
|
|
19
7
|
|
|
20
8
|
const DEFAULT_EVENTS_PATH = "/api/sessions/events";
|
|
21
9
|
|
|
22
|
-
/** The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* from the last event seen rather than from the network, so it is undefined for
|
|
27
|
-
* a session no event has covered yet (the initial snapshot arrives
|
|
28
|
-
* asynchronously after setup) and for one the stream has since reported
|
|
29
|
-
* `removed`. A caller that needs "eventually" rather than "now" subscribes. */
|
|
10
|
+
/** The live status of every session the server knows about. `current` answers
|
|
11
|
+
* from the last event seen, so it is undefined for a session no event has
|
|
12
|
+
* covered yet and for one reported `removed`; a caller that needs "eventually"
|
|
13
|
+
* subscribes. */
|
|
30
14
|
export interface ActivityMonitorApi {
|
|
31
|
-
/**
|
|
32
|
-
* exited/crashed, plus removed), including the OSC 9;4 percentage and any OSC
|
|
33
|
-
* 9 notification the event carries. Fired for the initial snapshot and each
|
|
34
|
-
* change. */
|
|
15
|
+
/** Every status update, the initial snapshot included. */
|
|
35
16
|
onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
|
|
36
17
|
/** The last known status for a session, or undefined. */
|
|
37
18
|
current(id: string): SessionStatus | undefined;
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
* whose fresh snapshot cannot tombstone sessions it never knew). A
|
|
41
|
-
* subscriber attaching after an open has already happened is called
|
|
42
|
-
* immediately (catch-up), so a late consumer never misses the initial
|
|
43
|
-
* open. Optional so alternative monitor implementations remain valid. */
|
|
19
|
+
/** Every stream (re)open; a subscriber attaching after one has happened is
|
|
20
|
+
* called at once. Optional so an alternative monitor stays valid. */
|
|
44
21
|
onStreamOpen?(cb: () => void): Unsubscribe;
|
|
45
22
|
}
|
|
46
23
|
|
|
47
|
-
/** Build the activityMonitor feature.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* `GET /api/sessions` instead when it is absent.
|
|
53
|
-
*
|
|
54
|
-
* `eventsPath` overrides the endpoint (default `/api/sessions/events`). Order it
|
|
55
|
-
* BEFORE any feature that reads its API through `ctx.use`, since a feature's
|
|
56
|
-
* setup can only see peers already set up. Teardown closes the stream and drops
|
|
57
|
-
* every subscriber and cached status.
|
|
58
|
-
*
|
|
59
|
-
* A throwing subscriber is caught and logged rather than propagated: it must not
|
|
60
|
-
* skip the remaining subscribers, and it must not reach the engine's SSE reader
|
|
61
|
-
* and kill the stream for everyone. */
|
|
24
|
+
/** Build the activityMonitor feature. On a server without the SSE endpoint the
|
|
25
|
+
* stream never opens and no subscriber fires, which is why `tabs` treats the
|
|
26
|
+
* monitor as optional. Order it BEFORE any feature that reads it through
|
|
27
|
+
* `ctx.use`. A throwing subscriber is logged, never propagated: it must not
|
|
28
|
+
* skip the remaining subscribers or kill the stream for everyone. */
|
|
62
29
|
export function activityMonitor(
|
|
63
30
|
opts: { eventsPath?: string } = {},
|
|
64
31
|
): TerminalFeature<ActivityMonitorApi> {
|
|
65
32
|
return {
|
|
66
33
|
name: "activityMonitor",
|
|
67
|
-
|
|
34
|
+
scope: "shell",
|
|
35
|
+
setup(ctx) {
|
|
68
36
|
const path = opts.eventsPath ?? DEFAULT_EVENTS_PATH;
|
|
69
37
|
const statuses = new Map<string, SessionStatus>();
|
|
70
38
|
const subs = new Set<(s: SessionStatus) => void>();
|
|
71
39
|
const openSubs = new Set<() => void>();
|
|
72
40
|
let opens = 0;
|
|
73
41
|
|
|
74
|
-
const
|
|
42
|
+
const offStream = ctx.shell.subscribeStatus(path, {
|
|
75
43
|
onOpen() {
|
|
76
44
|
opens++;
|
|
77
45
|
for (const cb of [...openSubs]) {
|
|
@@ -92,14 +60,12 @@ export function activityMonitor(
|
|
|
92
60
|
try {
|
|
93
61
|
cb(s);
|
|
94
62
|
} catch (err) {
|
|
95
|
-
// Isolate a throwing subscriber so it neither skips the
|
|
96
|
-
// remaining subscribers nor propagates into the engine's
|
|
97
|
-
// SSE reader (mirrors the kernel bus's ctx.on wrapping).
|
|
98
63
|
console.error("web-terminal-ui: activityMonitor subscriber threw", err);
|
|
99
64
|
}
|
|
100
65
|
}
|
|
101
66
|
},
|
|
102
67
|
});
|
|
68
|
+
ctx.defer(offStream);
|
|
103
69
|
|
|
104
70
|
return {
|
|
105
71
|
api: {
|
|
@@ -113,13 +79,12 @@ export function activityMonitor(
|
|
|
113
79
|
onStreamOpen(cb) {
|
|
114
80
|
openSubs.add(cb);
|
|
115
81
|
if (opens > 0) {
|
|
116
|
-
cb();
|
|
82
|
+
cb();
|
|
117
83
|
}
|
|
118
84
|
return () => openSubs.delete(cb);
|
|
119
85
|
},
|
|
120
86
|
},
|
|
121
87
|
teardown() {
|
|
122
|
-
stream.close();
|
|
123
88
|
subs.clear();
|
|
124
89
|
openSubs.clear();
|
|
125
90
|
statuses.clear();
|
|
@@ -1,45 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
* animations feature: opt-in motion for the chrome surfaces.
|
|
3
|
-
* Without it the chrome surfaces are instant (no transitions); with
|
|
4
|
-
* it, and when the user has not asked for reduced motion, it enables the shared
|
|
5
|
-
* animation vocabulary by marking the terminal root, which the feature
|
|
6
|
-
* stylesheets key their transitions off. A few pieces of motion animate
|
|
7
|
-
* unconditionally regardless of this feature (terminal-core cursor/SGR blink and
|
|
8
|
-
* the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
|
|
9
|
-
* pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
|
|
10
|
-
* still defeats all of it. It tracks prefers-reduced-motion live so toggling the
|
|
11
|
-
* OS setting takes effect.
|
|
12
|
-
*
|
|
13
|
-
* @module
|
|
14
|
-
*/
|
|
15
|
-
|
|
1
|
+
import { windowOf } from "../kernel/realm.js";
|
|
16
2
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
17
3
|
|
|
18
|
-
/** Build the animations feature. Exposes no API
|
|
19
|
-
* `wt-animate` class on the
|
|
20
|
-
*
|
|
21
|
-
* chrome.
|
|
22
|
-
*
|
|
23
|
-
* Position in the feature list does not matter (nothing reads it through
|
|
24
|
-
* `ctx.use`). The class is applied during setup, re-evaluated whenever the
|
|
25
|
-
* `prefers-reduced-motion` media query changes, and removed on teardown along
|
|
26
|
-
* with the listener. */
|
|
4
|
+
/** Build the animations feature. Exposes no API; its whole effect is the
|
|
5
|
+
* `wt-animate` class on the shell root, which the shipped stylesheets gate their
|
|
6
|
+
* transitions on, tracking prefers-reduced-motion live. */
|
|
27
7
|
export function animations(): TerminalFeature {
|
|
28
8
|
return {
|
|
29
9
|
name: "animations",
|
|
10
|
+
scope: "shell",
|
|
30
11
|
setup(ctx) {
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
12
|
+
const root = ctx.shell.root;
|
|
13
|
+
const mq = windowOf(root.ownerDocument).matchMedia("(prefers-reduced-motion: reduce)");
|
|
34
14
|
const apply = (): void => {
|
|
35
|
-
root
|
|
15
|
+
root.classList.toggle("wt-animate", !mq.matches);
|
|
36
16
|
};
|
|
37
17
|
apply();
|
|
38
18
|
mq.addEventListener("change", apply);
|
|
19
|
+
ctx.defer(() => {
|
|
20
|
+
mq.removeEventListener("change", apply);
|
|
21
|
+
});
|
|
39
22
|
return {
|
|
40
23
|
teardown() {
|
|
41
|
-
|
|
42
|
-
root?.classList.remove("wt-animate");
|
|
24
|
+
root.classList.remove("wt-animate");
|
|
43
25
|
},
|
|
44
26
|
};
|
|
45
27
|
},
|