@cplieger/web-terminal-ui 2.1.4 → 3.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 +57 -32
- package/css/03-v3.css +590 -0
- package/css/MANIFEST +1 -0
- package/package.json +9 -5
- package/scaffold/index.html +8 -6
- package/src/composition.ts +15 -0
- package/src/features/activity-monitor.ts +64 -0
- package/src/features/animations.ts +29 -0
- package/src/features/clipboard.ts +98 -0
- package/src/features/connection-banner.ts +53 -0
- package/src/features/context-menu.ts +186 -0
- package/src/features/dom.ts +15 -0
- package/src/features/mobile-toolbar.ts +82 -0
- package/src/features/predictive-echo.ts +63 -0
- package/src/features/scroll-to-bottom.ts +49 -0
- package/src/features/tabs.ts +687 -0
- package/src/index.ts +30 -9
- package/src/kernel/a11y.ts +102 -0
- package/src/kernel/bus.ts +61 -0
- package/src/kernel/conn-state.ts +133 -0
- package/src/kernel/kernel.ts +688 -0
- package/src/kernel/regions.ts +119 -0
- package/src/kernel/types.ts +286 -0
- package/src/presets.ts +46 -0
- package/src/mount.ts +0 -903
- package/src/status.ts +0 -228
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
// tabs feature: multiple independent terminals over the one kernel (design
|
|
2
|
+
// sections 5, 6, 12, 22.5). It owns the session set (GET/POST/DELETE
|
|
3
|
+
// /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
|
|
4
|
+
// swap, and the tab chrome on both form factors: the desktop top-bar strip and
|
|
5
|
+
// the mobile bottom-switcher + modal overview sheet. The kernel drives one
|
|
6
|
+
// active session; switching re-points the renderer at the next tab's cached
|
|
7
|
+
// store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
|
|
8
|
+
// (ctx.notifySwitch), so the last-known screen paints instantly and the
|
|
9
|
+
// background delta arrives after.
|
|
10
|
+
|
|
11
|
+
import { LineStore, modes } from "@cplieger/web-terminal-engine";
|
|
12
|
+
import type { TerminalContext, TerminalFeature, TabHandle } from "../kernel/types.js";
|
|
13
|
+
import type { ActivityMonitorApi } from "./activity-monitor.js";
|
|
14
|
+
import { fromHTML } from "./dom.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_API_BASE = "/api/sessions";
|
|
17
|
+
// Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
|
|
18
|
+
// this distance switches; a near-stationary release is a tap (opens overview).
|
|
19
|
+
const SWIPE_MIN_PX = 40;
|
|
20
|
+
// One-time "swipe to switch" hint, remembered across loads.
|
|
21
|
+
const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
22
|
+
// Default cadence for the no-activityMonitor polling fallback.
|
|
23
|
+
const DEFAULT_POLL_MS = 4000;
|
|
24
|
+
|
|
25
|
+
/** One session's client-side wire shape (matches terminal.SessionInfo). */
|
|
26
|
+
interface SessionInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
status: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Tab {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
store: LineStore;
|
|
38
|
+
el: HTMLElement;
|
|
39
|
+
label: HTMLElement;
|
|
40
|
+
dot: HTMLElement;
|
|
41
|
+
aria: TabHandle;
|
|
42
|
+
scrollTop: number;
|
|
43
|
+
following: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TabsApi {
|
|
47
|
+
/** Spawn a fresh session and switch to it. */
|
|
48
|
+
create(): Promise<void>;
|
|
49
|
+
/** Close a session (kills its process) and drop its tab + cache. */
|
|
50
|
+
close(id: string): Promise<void>;
|
|
51
|
+
/** Switch the active tab. */
|
|
52
|
+
switchTo(id: string): void;
|
|
53
|
+
/** The current tabs, active first-to-last by creation. */
|
|
54
|
+
list(): readonly { id: string; title: string; active: boolean }[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TabsOptions {
|
|
58
|
+
/** REST base for the session API (default "/api/sessions"). */
|
|
59
|
+
apiBase?: string;
|
|
60
|
+
/** The activityMonitor feature value, so tabs renders live status dots and
|
|
61
|
+
* drops exited/removed tabs (ctx.use). Without it, dots stay neutral and tabs
|
|
62
|
+
* falls back to polling the session list (see pollMs). */
|
|
63
|
+
activityMonitor?: TerminalFeature<ActivityMonitorApi>;
|
|
64
|
+
/** Poll interval in ms for the no-activityMonitor fallback: without the status
|
|
65
|
+
* SSE, tabs re-lists GET /api/sessions on this cadence to refresh dots and
|
|
66
|
+
* titles and drop reaped tabs (section 22.5). Ignored when activityMonitor is
|
|
67
|
+
* present. Default 4000. */
|
|
68
|
+
pollMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TAB_HTML = `
|
|
72
|
+
<div class="wt-tab">
|
|
73
|
+
<span class="wt-tab-dot wt-status-dot" aria-hidden="true"></span>
|
|
74
|
+
<span class="wt-tab-label"></span>
|
|
75
|
+
<button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1">\u00d7</button>
|
|
76
|
+
</div>`;
|
|
77
|
+
|
|
78
|
+
const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal">+</button>`;
|
|
79
|
+
|
|
80
|
+
// Mobile bottom-switcher: the active tab (dot + label + position) as a tap/swipe
|
|
81
|
+
// surface, plus a >=44px overview control that carries the aggregate badge.
|
|
82
|
+
const SWITCHER_HTML = `
|
|
83
|
+
<div class="wt-switcher" role="group" aria-label="Terminal tabs">
|
|
84
|
+
<button type="button" class="wt-switcher-current" aria-haspopup="dialog">
|
|
85
|
+
<span class="wt-switcher-dot wt-status-dot" aria-hidden="true"></span>
|
|
86
|
+
<span class="wt-switcher-label"></span>
|
|
87
|
+
<span class="wt-switcher-pos" aria-hidden="true"></span>
|
|
88
|
+
</button>
|
|
89
|
+
<button type="button" class="wt-switcher-overview wt-btn" aria-haspopup="dialog" aria-label="All terminals">
|
|
90
|
+
<span class="wt-switcher-count" aria-hidden="true"></span>
|
|
91
|
+
</button>
|
|
92
|
+
</div>`;
|
|
93
|
+
|
|
94
|
+
// Modal overview sheet (kernel `sheet` region): every tab with title + dot +
|
|
95
|
+
// close, plus create. Starts hidden; opened from the switcher.
|
|
96
|
+
const SHEET_HTML = `
|
|
97
|
+
<div class="wt-sheet" role="dialog" aria-modal="true" aria-label="Terminals" hidden>
|
|
98
|
+
<div class="wt-sheet-header">
|
|
99
|
+
<span class="wt-sheet-title">Terminals</span>
|
|
100
|
+
<button type="button" class="wt-sheet-new wt-btn">+ New terminal</button>
|
|
101
|
+
</div>
|
|
102
|
+
<ul class="wt-sheet-list" role="list"></ul>
|
|
103
|
+
</div>`;
|
|
104
|
+
|
|
105
|
+
const SHEET_ROW_HTML = `
|
|
106
|
+
<li class="wt-sheet-row">
|
|
107
|
+
<button type="button" class="wt-sheet-select">
|
|
108
|
+
<span class="wt-sheet-row-dot wt-status-dot" aria-hidden="true"></span>
|
|
109
|
+
<span class="wt-sheet-row-label"></span>
|
|
110
|
+
</button>
|
|
111
|
+
<button type="button" class="wt-sheet-close wt-btn" aria-label="Close terminal">\u00d7</button>
|
|
112
|
+
</li>`;
|
|
113
|
+
|
|
114
|
+
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
115
|
+
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
116
|
+
|
|
117
|
+
async function apiList(): Promise<SessionInfo[]> {
|
|
118
|
+
const r = await fetch(apiBase, { headers: { Accept: "application/json" } });
|
|
119
|
+
if (!r.ok) {
|
|
120
|
+
throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
|
|
121
|
+
}
|
|
122
|
+
return (await r.json()) as SessionInfo[];
|
|
123
|
+
}
|
|
124
|
+
async function apiCreate(): Promise<SessionInfo> {
|
|
125
|
+
const r = await fetch(apiBase, { method: "POST" });
|
|
126
|
+
if (r.status === 429) {
|
|
127
|
+
throw new Error("web-terminal-ui: too many sessions");
|
|
128
|
+
}
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
|
|
131
|
+
}
|
|
132
|
+
return (await r.json()) as SessionInfo;
|
|
133
|
+
}
|
|
134
|
+
async function apiClose(id: string): Promise<void> {
|
|
135
|
+
await fetch(`${apiBase}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
name: "tabs",
|
|
140
|
+
// tabs owns session selection: it resolves the first session id from
|
|
141
|
+
// GET/POST /api/sessions during setup and drives the first connect via
|
|
142
|
+
// ctx.notifySwitch. This tells the kernel not to open a bare /ws at startup
|
|
143
|
+
// (which a SessionManager would 404 for lack of ?session=).
|
|
144
|
+
managesSessions: true,
|
|
145
|
+
async setup(ctx: TerminalContext) {
|
|
146
|
+
const tablist = ctx.tablist();
|
|
147
|
+
const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
|
|
148
|
+
|
|
149
|
+
// --- Desktop tab strip (top-bar region) ---
|
|
150
|
+
const slot = ctx.region("top-bar", "tabs");
|
|
151
|
+
const bar = document.createElement("div");
|
|
152
|
+
bar.className = "wt-tab-bar";
|
|
153
|
+
bar.setAttribute("role", "tablist");
|
|
154
|
+
slot.appendChild(bar);
|
|
155
|
+
const newBtn = fromHTML(NEW_HTML);
|
|
156
|
+
slot.appendChild(newBtn);
|
|
157
|
+
|
|
158
|
+
// --- Mobile bottom-switcher (bottom-switcher region) ---
|
|
159
|
+
const switcher = fromHTML(SWITCHER_HTML);
|
|
160
|
+
const swCurrent = pick(switcher, ".wt-switcher-current");
|
|
161
|
+
const swDot = pick(switcher, ".wt-switcher-dot");
|
|
162
|
+
const swLabel = pick(switcher, ".wt-switcher-label");
|
|
163
|
+
const swPos = pick(switcher, ".wt-switcher-pos");
|
|
164
|
+
const overviewBtn = pick(switcher, ".wt-switcher-overview");
|
|
165
|
+
const swCount = pick(switcher, ".wt-switcher-count");
|
|
166
|
+
ctx.region("bottom-switcher", "switcher").appendChild(switcher);
|
|
167
|
+
|
|
168
|
+
// --- Overview sheet + scrim (sheet region) ---
|
|
169
|
+
const sheetSlot = ctx.region("sheet", "overview");
|
|
170
|
+
const scrim = document.createElement("div");
|
|
171
|
+
scrim.className = "wt-sheet-scrim";
|
|
172
|
+
const sheet = fromHTML(SHEET_HTML);
|
|
173
|
+
const sheetList = pick(sheet, ".wt-sheet-list");
|
|
174
|
+
const sheetNewBtn = pick(sheet, ".wt-sheet-new");
|
|
175
|
+
sheetSlot.append(scrim, sheet);
|
|
176
|
+
|
|
177
|
+
// Catching-up cue: a switched-into tab's cached screen is stale until its
|
|
178
|
+
// resume delta lands, so it must not read as live (sections 12/13). Shown
|
|
179
|
+
// only if the delta has not arrived shortly after a switch; cleared on the
|
|
180
|
+
// first screen frame.
|
|
181
|
+
const catchup = document.createElement("div");
|
|
182
|
+
catchup.className = "wt-catchup";
|
|
183
|
+
catchup.setAttribute("role", "status");
|
|
184
|
+
catchup.textContent = "Catching up\u2026";
|
|
185
|
+
ctx.region("banner", "catchup").appendChild(catchup);
|
|
186
|
+
let catchupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
187
|
+
|
|
188
|
+
const tabList: Tab[] = [];
|
|
189
|
+
let activeId: string | null = null;
|
|
190
|
+
let overviewOpen = false;
|
|
191
|
+
let lastFocus: HTMLElement | null = null;
|
|
192
|
+
let hintShown = false;
|
|
193
|
+
|
|
194
|
+
function focusInput(): void {
|
|
195
|
+
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// paintActive updates the desktop strip's active state.
|
|
199
|
+
function paintActive(): void {
|
|
200
|
+
for (const t of tabList) {
|
|
201
|
+
const on = t.id === activeId;
|
|
202
|
+
t.el.classList.toggle("wt-tab-active", on);
|
|
203
|
+
t.aria.setSelected(on);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// syncMobile updates the bottom switcher: active label + position, the tab
|
|
208
|
+
// count, and the aggregate needs-input badge (so a background tab blocked
|
|
209
|
+
// on input is glanceable on mobile without opening the sheet, section 12).
|
|
210
|
+
function syncMobile(): void {
|
|
211
|
+
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
212
|
+
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
213
|
+
swLabel.textContent = active ? active.title : "";
|
|
214
|
+
swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
|
|
215
|
+
swPos.textContent =
|
|
216
|
+
tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
|
|
217
|
+
swCount.textContent = String(tabList.length);
|
|
218
|
+
const bgInput = tabList.some(
|
|
219
|
+
(t) => t.id !== activeId && t.dot.dataset["status"] === "input",
|
|
220
|
+
);
|
|
221
|
+
if (bgInput) {
|
|
222
|
+
overviewBtn.dataset["attention"] = "input";
|
|
223
|
+
overviewBtn.setAttribute(
|
|
224
|
+
"aria-label",
|
|
225
|
+
"All terminals; a background terminal needs input",
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
delete overviewBtn.dataset["attention"];
|
|
229
|
+
overviewBtn.setAttribute("aria-label", "All terminals");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// renderOverview rebuilds the sheet's tab rows from the current tabList.
|
|
234
|
+
function renderOverview(): void {
|
|
235
|
+
sheetList.replaceChildren();
|
|
236
|
+
for (const t of tabList) {
|
|
237
|
+
const row = fromHTML(SHEET_ROW_HTML);
|
|
238
|
+
const rdot = pick(row, ".wt-sheet-row-dot");
|
|
239
|
+
const rlabel = pick(row, ".wt-sheet-row-label");
|
|
240
|
+
const rselect = pick(row, ".wt-sheet-select");
|
|
241
|
+
const rclose = pick(row, ".wt-sheet-close");
|
|
242
|
+
rdot.dataset["status"] = t.dot.dataset["status"] ?? "idle";
|
|
243
|
+
rlabel.textContent = t.title;
|
|
244
|
+
if (t.id === activeId) {
|
|
245
|
+
row.classList.add("wt-sheet-row-active");
|
|
246
|
+
rselect.setAttribute("aria-current", "true");
|
|
247
|
+
}
|
|
248
|
+
rselect.addEventListener("click", () => {
|
|
249
|
+
// Close first so focus restores off the sheet, then switch (which
|
|
250
|
+
// focuses the terminal) so focus lands in the terminal, not the bar.
|
|
251
|
+
closeOverview();
|
|
252
|
+
switchTo(t.id);
|
|
253
|
+
});
|
|
254
|
+
rclose.addEventListener("click", (e) => {
|
|
255
|
+
e.stopPropagation();
|
|
256
|
+
void close_(t.id);
|
|
257
|
+
});
|
|
258
|
+
sheetList.appendChild(row);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
263
|
+
function syncChrome(): void {
|
|
264
|
+
paintActive();
|
|
265
|
+
syncMobile();
|
|
266
|
+
if (overviewOpen) {
|
|
267
|
+
renderOverview();
|
|
268
|
+
}
|
|
269
|
+
maybeSwipeHint();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function openOverview(): void {
|
|
273
|
+
if (overviewOpen) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
overviewOpen = true;
|
|
277
|
+
lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
278
|
+
renderOverview();
|
|
279
|
+
scrim.classList.add("visible");
|
|
280
|
+
sheet.hidden = false;
|
|
281
|
+
sheet.classList.add("visible");
|
|
282
|
+
sheet.querySelector<HTMLElement>("button")?.focus();
|
|
283
|
+
ctx.announce("Terminals list opened");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function closeOverview(): void {
|
|
287
|
+
if (!overviewOpen) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
overviewOpen = false;
|
|
291
|
+
scrim.classList.remove("visible");
|
|
292
|
+
sheet.classList.remove("visible");
|
|
293
|
+
sheet.hidden = true;
|
|
294
|
+
lastFocus?.focus();
|
|
295
|
+
lastFocus = null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function addTabChrome(info: SessionInfo): Tab {
|
|
299
|
+
const el = fromHTML(TAB_HTML);
|
|
300
|
+
const label = el.querySelector<HTMLElement>(".wt-tab-label");
|
|
301
|
+
const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
|
|
302
|
+
const close = el.querySelector<HTMLButtonElement>(".wt-tab-close");
|
|
303
|
+
if (!label || !dot || !close) {
|
|
304
|
+
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
305
|
+
}
|
|
306
|
+
const title = info.title || "terminal";
|
|
307
|
+
label.textContent = title;
|
|
308
|
+
dot.dataset["status"] = info.status || "idle";
|
|
309
|
+
const aria = tablist.registerTab(el);
|
|
310
|
+
aria.setLabel(title);
|
|
311
|
+
bar.appendChild(el);
|
|
312
|
+
|
|
313
|
+
const tab: Tab = {
|
|
314
|
+
id: info.id,
|
|
315
|
+
title,
|
|
316
|
+
createdAt: info.createdAt,
|
|
317
|
+
store: new LineStore(),
|
|
318
|
+
el,
|
|
319
|
+
label,
|
|
320
|
+
dot,
|
|
321
|
+
aria,
|
|
322
|
+
scrollTop: 0,
|
|
323
|
+
following: true,
|
|
324
|
+
};
|
|
325
|
+
el.addEventListener("click", (e) => {
|
|
326
|
+
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
327
|
+
return; // handled by the close button
|
|
328
|
+
}
|
|
329
|
+
switchTo(tab.id);
|
|
330
|
+
});
|
|
331
|
+
close.addEventListener("click", (e) => {
|
|
332
|
+
e.stopPropagation();
|
|
333
|
+
void close_(tab.id);
|
|
334
|
+
});
|
|
335
|
+
return tab;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function switchTo(id: string): void {
|
|
339
|
+
if (id === activeId) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const next = tabList.find((t) => t.id === id);
|
|
343
|
+
if (!next) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const surface = ctx.surface();
|
|
347
|
+
// Detach the current tab: save its scroll memory (keep its cache).
|
|
348
|
+
const cur = tabList.find((t) => t.id === activeId);
|
|
349
|
+
if (cur) {
|
|
350
|
+
cur.scrollTop = surface.scrollTop;
|
|
351
|
+
cur.following = !ctx.scroll.isUserScrolledUp();
|
|
352
|
+
}
|
|
353
|
+
// Attach the next tab: point the renderer at its cached store and
|
|
354
|
+
// rebuild viewport-first, so the last-known screen paints with no
|
|
355
|
+
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
356
|
+
activeId = next.id;
|
|
357
|
+
ctx.render.bind(next.store);
|
|
358
|
+
ctx.notifySwitch({ id: next.id });
|
|
359
|
+
armCatchup();
|
|
360
|
+
flashSwitch();
|
|
361
|
+
syncChrome();
|
|
362
|
+
// Restore scroll memory best-effort after the async rebuild; a
|
|
363
|
+
// following tab sticks to the bottom on its own.
|
|
364
|
+
const savedTop = next.scrollTop;
|
|
365
|
+
const following = next.following;
|
|
366
|
+
requestAnimationFrame(() => {
|
|
367
|
+
if (!following) {
|
|
368
|
+
surface.scrollTop = savedTop;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
ctx.announce(`Switched to ${next.title}`);
|
|
372
|
+
focusInput();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// armCatchup shows the "catching up" cue only if the resume delta has not
|
|
376
|
+
// landed shortly after a switch; clearCatchup hides it when a screen frame
|
|
377
|
+
// arrives (subscribed below).
|
|
378
|
+
function armCatchup(): void {
|
|
379
|
+
if (catchupTimer !== null) {
|
|
380
|
+
clearTimeout(catchupTimer);
|
|
381
|
+
}
|
|
382
|
+
catchup.classList.remove("visible");
|
|
383
|
+
catchupTimer = setTimeout(() => {
|
|
384
|
+
catchupTimer = null;
|
|
385
|
+
catchup.classList.add("visible");
|
|
386
|
+
}, 150);
|
|
387
|
+
}
|
|
388
|
+
function clearCatchup(): void {
|
|
389
|
+
if (catchupTimer !== null) {
|
|
390
|
+
clearTimeout(catchupTimer);
|
|
391
|
+
catchupTimer = null;
|
|
392
|
+
}
|
|
393
|
+
catchup.classList.remove("visible");
|
|
394
|
+
}
|
|
395
|
+
// flashSwitch adds a class the animations feature keys a brief content fade
|
|
396
|
+
// off (a no-op when animations are absent or reduced-motion is set).
|
|
397
|
+
function flashSwitch(): void {
|
|
398
|
+
const surface = ctx.surface();
|
|
399
|
+
surface.classList.add("wt-switching");
|
|
400
|
+
setTimeout(() => {
|
|
401
|
+
surface.classList.remove("wt-switching");
|
|
402
|
+
}, 300);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The first screen frame after a switch is the resume delta landing.
|
|
406
|
+
ctx.on("wire:screen", () => {
|
|
407
|
+
clearCatchup();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// switchRelative moves delta tabs from the active one (swipe left = next).
|
|
411
|
+
function switchRelative(delta: number): void {
|
|
412
|
+
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
413
|
+
if (idx < 0) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const next = tabList[idx + delta];
|
|
417
|
+
if (next) {
|
|
418
|
+
switchTo(next.id);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function create(): Promise<void> {
|
|
423
|
+
const info = await apiCreate();
|
|
424
|
+
const tab = addTabChrome(info);
|
|
425
|
+
tabList.push(tab);
|
|
426
|
+
switchTo(tab.id);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// dropTab removes a tab's chrome + cache and re-homes the active session.
|
|
430
|
+
// remote=true also DELETEs the server session (a user close); remote=false
|
|
431
|
+
// is a local drop for a session the server already ended (an SSE removed
|
|
432
|
+
// event or a poll that no longer lists it), so no redundant DELETE is sent.
|
|
433
|
+
async function dropTab(id: string, remote: boolean): Promise<void> {
|
|
434
|
+
const idx = tabList.findIndex((t) => t.id === id);
|
|
435
|
+
if (idx < 0) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const [tab] = tabList.splice(idx, 1);
|
|
439
|
+
if (!tab) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
tab.aria.remove();
|
|
443
|
+
tab.el.remove();
|
|
444
|
+
ctx.dropSession(id);
|
|
445
|
+
syncChrome(); // reflect the drop immediately (count, position, sheet)
|
|
446
|
+
if (remote) {
|
|
447
|
+
await apiClose(id);
|
|
448
|
+
}
|
|
449
|
+
if (activeId === id) {
|
|
450
|
+
// Switch to a neighbor, or spawn a fresh session if this was the last.
|
|
451
|
+
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
452
|
+
activeId = null;
|
|
453
|
+
if (neighbor) {
|
|
454
|
+
switchTo(neighbor.id);
|
|
455
|
+
} else {
|
|
456
|
+
await create();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function close_(id: string): Promise<void> {
|
|
462
|
+
await dropTab(id, true);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// One-time "swipe to switch" hint on first multi-tab state, mobile only.
|
|
466
|
+
function maybeSwipeHint(): void {
|
|
467
|
+
if (hintShown || tabList.length < 2) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
hintShown = true;
|
|
471
|
+
if (!window.matchMedia("(pointer: coarse)").matches) {
|
|
472
|
+
return; // desktop: swipe is irrelevant
|
|
473
|
+
}
|
|
474
|
+
let seen = false;
|
|
475
|
+
try {
|
|
476
|
+
seen = localStorage.getItem(SWIPE_HINT_KEY) === "1";
|
|
477
|
+
} catch {
|
|
478
|
+
/* storage unavailable; show once per session via hintShown */
|
|
479
|
+
}
|
|
480
|
+
if (seen) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
localStorage.setItem(SWIPE_HINT_KEY, "1");
|
|
485
|
+
} catch {
|
|
486
|
+
/* ignore */
|
|
487
|
+
}
|
|
488
|
+
ctx.toast("Swipe the bar to switch terminals");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// applyStatus updates one tab's dot + title from a status record (shared by
|
|
492
|
+
// the SSE monitor and the polling fallback).
|
|
493
|
+
function applyStatus(id: string, status: string, title: string | undefined): void {
|
|
494
|
+
const t = tabList.find((tab) => tab.id === id);
|
|
495
|
+
if (!t) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
t.dot.dataset["status"] = status || "idle";
|
|
499
|
+
if (title && title !== t.title) {
|
|
500
|
+
t.title = title;
|
|
501
|
+
t.label.textContent = title;
|
|
502
|
+
t.aria.setLabel(title);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Live status: the activity monitor (SSE push) when present, else a poll of
|
|
507
|
+
// GET /api/sessions. Either way, dots + titles update and vanished sessions
|
|
508
|
+
// drop; the poll additionally learns of a background exit the SSE would
|
|
509
|
+
// have pushed (section 22.5).
|
|
510
|
+
let offStatus: (() => void) | undefined;
|
|
511
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
512
|
+
if (monitor) {
|
|
513
|
+
offStatus = monitor.onStatus((s) => {
|
|
514
|
+
if (s.removed) {
|
|
515
|
+
void dropTab(s.id, false); // already gone server-side; no DELETE
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
applyStatus(s.id, s.status, s.title);
|
|
519
|
+
syncChrome();
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
523
|
+
const pollOnce = async (): Promise<void> => {
|
|
524
|
+
let list: SessionInfo[];
|
|
525
|
+
try {
|
|
526
|
+
list = await apiList();
|
|
527
|
+
} catch {
|
|
528
|
+
return; // transient; try again next tick
|
|
529
|
+
}
|
|
530
|
+
const seen = new Set(list.map((s) => s.id));
|
|
531
|
+
for (const info of list) {
|
|
532
|
+
applyStatus(info.id, info.status, info.title);
|
|
533
|
+
}
|
|
534
|
+
// A tab the server no longer lists was reaped/closed elsewhere: drop it
|
|
535
|
+
// locally (no DELETE — it is already gone).
|
|
536
|
+
const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
|
|
537
|
+
for (const id of gone) {
|
|
538
|
+
await dropTab(id, false);
|
|
539
|
+
}
|
|
540
|
+
syncChrome();
|
|
541
|
+
};
|
|
542
|
+
pollTimer = setInterval(() => {
|
|
543
|
+
void pollOnce();
|
|
544
|
+
}, pollMs);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// --- Event wiring ---
|
|
548
|
+
newBtn.addEventListener("click", () => {
|
|
549
|
+
void create();
|
|
550
|
+
});
|
|
551
|
+
overviewBtn.addEventListener("click", () => {
|
|
552
|
+
openOverview();
|
|
553
|
+
});
|
|
554
|
+
sheetNewBtn.addEventListener("click", () => {
|
|
555
|
+
closeOverview();
|
|
556
|
+
void create();
|
|
557
|
+
});
|
|
558
|
+
scrim.addEventListener("click", () => {
|
|
559
|
+
closeOverview();
|
|
560
|
+
});
|
|
561
|
+
sheet.addEventListener("keydown", (e) => {
|
|
562
|
+
if (e.key === "Escape") {
|
|
563
|
+
e.preventDefault();
|
|
564
|
+
closeOverview();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (e.key !== "Tab") {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const focusables = Array.from(sheet.querySelectorAll<HTMLElement>("button")).filter(
|
|
571
|
+
(el) => !el.hasAttribute("disabled"),
|
|
572
|
+
);
|
|
573
|
+
const firstEl = focusables[0];
|
|
574
|
+
const lastEl = focusables[focusables.length - 1];
|
|
575
|
+
if (!firstEl || !lastEl) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (e.shiftKey && document.activeElement === firstEl) {
|
|
579
|
+
e.preventDefault();
|
|
580
|
+
lastEl.focus();
|
|
581
|
+
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
|
582
|
+
e.preventDefault();
|
|
583
|
+
firstEl.focus();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Swipe/tap on the switcher bar: a horizontal swipe switches (gated off
|
|
588
|
+
// under mouse tracking, since mouse-mode apps capture drags); a tap opens
|
|
589
|
+
// the overview. click also covers keyboard/mouse activation.
|
|
590
|
+
let downX = 0;
|
|
591
|
+
let downY = 0;
|
|
592
|
+
let swiped = false;
|
|
593
|
+
swCurrent.addEventListener(
|
|
594
|
+
"pointerdown",
|
|
595
|
+
(e) => {
|
|
596
|
+
downX = e.clientX;
|
|
597
|
+
downY = e.clientY;
|
|
598
|
+
swiped = false;
|
|
599
|
+
},
|
|
600
|
+
{ passive: true },
|
|
601
|
+
);
|
|
602
|
+
swCurrent.addEventListener(
|
|
603
|
+
"pointerup",
|
|
604
|
+
(e) => {
|
|
605
|
+
const dx = e.clientX - downX;
|
|
606
|
+
const dy = e.clientY - downY;
|
|
607
|
+
if (
|
|
608
|
+
Math.abs(dx) >= SWIPE_MIN_PX &&
|
|
609
|
+
Math.abs(dx) > Math.abs(dy) * 1.5 &&
|
|
610
|
+
modes.getMouseMode() === 0
|
|
611
|
+
) {
|
|
612
|
+
swiped = true;
|
|
613
|
+
switchRelative(dx < 0 ? 1 : -1);
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
{ passive: true },
|
|
617
|
+
);
|
|
618
|
+
swCurrent.addEventListener("click", () => {
|
|
619
|
+
if (swiped) {
|
|
620
|
+
swiped = false; // consumed by the swipe; do not also open the overview
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
openOverview();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Initial population: list existing sessions, or create the first one.
|
|
627
|
+
let sessions: SessionInfo[];
|
|
628
|
+
try {
|
|
629
|
+
sessions = await apiList();
|
|
630
|
+
} catch {
|
|
631
|
+
sessions = [];
|
|
632
|
+
}
|
|
633
|
+
if (sessions.length === 0) {
|
|
634
|
+
sessions = [await apiCreate()];
|
|
635
|
+
}
|
|
636
|
+
for (const info of sessions) {
|
|
637
|
+
tabList.push(addTabChrome(info));
|
|
638
|
+
}
|
|
639
|
+
// Activate the first (oldest) session.
|
|
640
|
+
const first = tabList[0];
|
|
641
|
+
if (first) {
|
|
642
|
+
activeId = first.id;
|
|
643
|
+
ctx.render.bind(first.store);
|
|
644
|
+
ctx.notifySwitch({ id: first.id });
|
|
645
|
+
syncChrome();
|
|
646
|
+
focusInput();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
api: {
|
|
651
|
+
create,
|
|
652
|
+
close: close_,
|
|
653
|
+
switchTo,
|
|
654
|
+
list: () => tabList.map((t) => ({ id: t.id, title: t.title, active: t.id === activeId })),
|
|
655
|
+
},
|
|
656
|
+
teardown() {
|
|
657
|
+
offStatus?.();
|
|
658
|
+
if (pollTimer !== null) {
|
|
659
|
+
clearInterval(pollTimer);
|
|
660
|
+
}
|
|
661
|
+
clearCatchup();
|
|
662
|
+
closeOverview();
|
|
663
|
+
for (const t of tabList) {
|
|
664
|
+
t.aria.remove();
|
|
665
|
+
t.el.remove();
|
|
666
|
+
}
|
|
667
|
+
tabList.length = 0;
|
|
668
|
+
bar.remove();
|
|
669
|
+
newBtn.remove();
|
|
670
|
+
switcher.remove();
|
|
671
|
+
sheet.remove();
|
|
672
|
+
scrim.remove();
|
|
673
|
+
catchup.remove();
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// pick returns a required descendant element or throws (static chrome only).
|
|
681
|
+
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
682
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
683
|
+
if (!el) {
|
|
684
|
+
throw new Error(`web-terminal-ui: tabs chrome missing ${selector}`);
|
|
685
|
+
}
|
|
686
|
+
return el;
|
|
687
|
+
}
|