@cplieger/web-terminal-ui 2.1.4 → 3.0.1

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/src/status.ts DELETED
@@ -1,228 +0,0 @@
1
- // Connection-state banner. Shows "Reconnecting…" / "Offline" / "Server
2
- // restarted" feedback so users on flaky links understand why typing is
3
- // unresponsive. Hidden by default; appears only when the connection
4
- // state diverges from "open" for more than a brief window (debounced to
5
- // avoid flicker on healthy networks). A "slow connection" indicator is
6
- // intentionally not implemented (see the state machine below).
7
- //
8
- // State machine (matches the `State` union below):
9
- // open → no banner.
10
- // connecting → "Reconnecting…" (initial connect, before first open).
11
- // reconnecting → "Reconnecting…" (after a close, during retry);
12
- // escalates to offline after >3 consecutive failures.
13
- // offline → "Offline".
14
- // restarted → "Server restarted — recent input may have been lost".
15
- // A "slow connection" indicator is intentionally not implemented: it would
16
- // need an RTT signal the engine does not currently expose to consumers.
17
-
18
- const CONNECTING_GRACE_MS = 600;
19
-
20
- // Consecutive failed *initial* connection attempts (before the first screen
21
- // frame, i.e. while `loaded` is still false) after which the "Offline" banner
22
- // is allowed to render anyway. Below this we stay silent and defer to the
23
- // consumer's loading overlay, so a merely-slow first connect raises no false
24
- // alarm; this many failures in a row means the connection genuinely isn't
25
- // coming up and an indefinite silent "Loading…" would be worse than "Offline".
26
- // Aligned with the post-load escalation threshold (consecutiveFailures > 3).
27
- const INITIAL_FAILURE_LIMIT = 4;
28
-
29
- type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
30
-
31
- let banner: HTMLElement | null = null;
32
- let state: State = "connecting";
33
- let stableTimer: ReturnType<typeof setTimeout> | null = null;
34
- let consecutiveFailures = 0;
35
- // True once the first screen frame has rendered (mount calls setLoaded()).
36
- // Until then the initial loading overlay is up and we suppress the
37
- // reconnect/offline banner so the two don't stack — this replaces the old
38
- // `document.getElementById("loading")` probe.
39
- let loaded = false;
40
- // Optional callback supplied by init(): invoked once the initial-connect
41
- // failure limit is reached while still !loaded, so mount can fade its opaque
42
- // loading overlay and let the "Offline" banner underneath become visible.
43
- // mount always supplies this (its dismissLoadingOverlay self-guards when no
44
- // overlay was passed), so onGiveUp is null only when init() is called without
45
- // the callback (e.g. the status unit tests).
46
- let onGiveUp: (() => void) | null = null;
47
-
48
- /** Mark the connection as open. Hides the banner. */
49
- export function open(): void {
50
- consecutiveFailures = 0;
51
- setState("open", 0);
52
- }
53
-
54
- /** Mark the terminal as past its initial load (first screen frame rendered).
55
- * Until this is called the connection banner stays suppressed so it never
56
- * stacks on top of the consumer's loading overlay. */
57
- export function setLoaded(): void {
58
- loaded = true;
59
- }
60
-
61
- /** Mark a reconnect attempt in progress (after a close, before re-open). */
62
- export function reconnecting(): void {
63
- if (!loaded) {
64
- return;
65
- }
66
- setState("reconnecting", CONNECTING_GRACE_MS);
67
- }
68
-
69
- /** Mark the connection as closed; shows banner after grace period. */
70
- export function closed(): void {
71
- consecutiveFailures++;
72
- // Before the first frame, stay suppressed (the loading overlay owns the
73
- // screen) UNTIL we've failed to connect INITIAL_FAILURE_LIMIT times in a
74
- // row — at that point the connection clearly isn't coming up, so let the
75
- // "Offline" banner through rather than leave a silent "Loading…" forever.
76
- if (!loaded && consecutiveFailures < INITIAL_FAILURE_LIMIT) {
77
- return;
78
- }
79
- // Past the initial-failure limit while the loading overlay is still up: tell
80
- // mount to fade it so the "Offline" banner below becomes visible rather than
81
- // staying occluded behind the opaque overlay (which markReady would otherwise
82
- // never remove on a never-connecting socket). Idempotent, so firing on each
83
- // subsequent !loaded close is safe.
84
- if (!loaded) {
85
- onGiveUp?.();
86
- }
87
- setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
88
- }
89
-
90
- /** Surface that the server has restarted (epoch mismatch). The
91
- * caller has already reset bytesSent/bytesAcked; this just shows
92
- * a brief banner so the user knows old input may have been lost. */
93
- export function restarted(): void {
94
- setState("restarted", 0);
95
- // Auto-clear the banner after a few seconds; "open" will re-arrive
96
- // when the next screen frame renders, but we don't want to wait.
97
- setTimeout(() => {
98
- if (state === "restarted") {
99
- setState("open", 0);
100
- }
101
- }, 4000);
102
- }
103
-
104
- function setState(next: State, delay: number): void {
105
- if (stableTimer !== null) {
106
- clearTimeout(stableTimer);
107
- stableTimer = null;
108
- }
109
- if (delay === 0) {
110
- state = next;
111
- applyState();
112
- return;
113
- }
114
- stableTimer = setTimeout(() => {
115
- stableTimer = null;
116
- state = next;
117
- applyState();
118
- }, delay);
119
- }
120
-
121
- /** Surface a brief transient message (e.g. "Copied") using the same
122
- * banner system as connection status. Auto-clears after `ms`. */
123
- export function toast(msg: string, ms = 3000): void {
124
- if (!banner) {
125
- return;
126
- }
127
- banner.textContent = msg;
128
- banner.dataset["state"] = "toast";
129
- banner.classList.add("visible");
130
- scheduleToastDismiss(ms);
131
- }
132
-
133
- let toastDismissTimer: ReturnType<typeof setTimeout> | null = null;
134
- let toastDismissMs = 3000;
135
-
136
- function scheduleToastDismiss(ms: number): void {
137
- toastDismissMs = ms;
138
- if (toastDismissTimer !== null) {
139
- clearTimeout(toastDismissTimer);
140
- }
141
- toastDismissTimer = setTimeout(dismissToast, ms);
142
- }
143
-
144
- function dismissToast(): void {
145
- toastDismissTimer = null;
146
- if (!banner) {
147
- return;
148
- }
149
- // Restore the live connection state. applyState() hides the banner when
150
- // "open" (identical to the old clear) and re-renders "Offline"/"Reconnecting…"
151
- // when a toast was shown over a non-open status, so the status is never stranded.
152
- if (banner.dataset["state"] === "toast") {
153
- applyState();
154
- }
155
- }
156
-
157
- export function init(opts: { banner: HTMLElement; onGiveUp?: () => void }): void {
158
- banner = opts.banner;
159
- onGiveUp = opts.onGiveUp ?? null;
160
- applyState();
161
-
162
- // Hover-pause: stop the auto-dismiss timer while the user hovers
163
- // (gives them time to read longer messages or click actions).
164
- banner.addEventListener("mouseenter", () => {
165
- if (toastDismissTimer !== null) {
166
- clearTimeout(toastDismissTimer);
167
- toastDismissTimer = null;
168
- }
169
- });
170
- banner.addEventListener("mouseleave", () => {
171
- if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
172
- scheduleToastDismiss(toastDismissMs);
173
- }
174
- });
175
-
176
- // Escape-dismiss: pressing Escape while a toast is visible clears it.
177
- document.addEventListener("keydown", (ev) => {
178
- if (
179
- ev.key === "Escape" &&
180
- banner?.classList.contains("visible") &&
181
- banner.dataset["state"] === "toast"
182
- ) {
183
- if (toastDismissTimer !== null) {
184
- clearTimeout(toastDismissTimer);
185
- }
186
- dismissToast();
187
- }
188
- });
189
- }
190
-
191
- function applyState(): void {
192
- if (!banner) {
193
- return;
194
- }
195
- // Stay fully suppressed until the initial load is over (matches the
196
- // `loaded` guard in reconnecting()/closed()): the loading overlay, when
197
- // present, owns the screen until then and a bare page has nothing to show.
198
- // Exception: once INITIAL_FAILURE_LIMIT consecutive initial connects have
199
- // failed, let "Offline" through even while !loaded so a never-connecting
200
- // page doesn't sit on a silent "Loading…" indefinitely.
201
- if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
202
- banner.classList.remove("visible");
203
- banner.textContent = "";
204
- return;
205
- }
206
- switch (state) {
207
- case "open":
208
- banner.classList.remove("visible");
209
- banner.textContent = "";
210
- break;
211
- case "connecting":
212
- case "reconnecting":
213
- banner.textContent = "Reconnecting…";
214
- banner.dataset["state"] = "reconnecting";
215
- banner.classList.add("visible");
216
- break;
217
- case "offline":
218
- banner.textContent = "Offline";
219
- banner.dataset["state"] = "offline";
220
- banner.classList.add("visible");
221
- break;
222
- case "restarted":
223
- banner.textContent = "Server restarted — recent input may have been lost";
224
- banner.dataset["state"] = "restarted";
225
- banner.classList.add("visible");
226
- break;
227
- }
228
- }