@cplieger/web-terminal-ui 3.0.3 → 3.1.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 +6 -0
- package/css/00-tokens.css +13 -1
- package/css/02-app.css +24 -120
- package/css/03-primitives.css +82 -0
- package/css/04-feedback.css +166 -0
- package/css/05-tabs.css +310 -0
- package/css/06-mobile.css +335 -0
- package/css/07-animations.css +102 -0
- package/css/MANIFEST +7 -1
- package/package.json +2 -2
- package/src/features/clipboard.ts +10 -0
- package/src/features/connection-banner.ts +1 -1
- package/src/features/context-menu.ts +174 -42
- package/src/features/mobile-toolbar.ts +59 -1
- package/src/features/scroll-to-bottom.ts +22 -6
- package/src/features/tabs.ts +1305 -206
- package/src/kernel/kernel.ts +65 -4
- package/src/kernel/types.ts +8 -0
- package/src/presets.ts +36 -6
- package/src/viewport.ts +16 -1
- package/css/03-v3.css +0 -629
package/src/features/tabs.ts
CHANGED
|
@@ -11,16 +11,42 @@
|
|
|
11
11
|
import { LineStore, modes } from "@cplieger/web-terminal-engine";
|
|
12
12
|
import type { TerminalContext, TerminalFeature, TabHandle } from "../kernel/types.js";
|
|
13
13
|
import type { ActivityMonitorApi } from "./activity-monitor.js";
|
|
14
|
+
import type { MobileToolbarApi } from "./mobile-toolbar.js";
|
|
14
15
|
import { fromHTML } from "./dom.js";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_API_BASE = "/api/sessions";
|
|
17
18
|
// Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
|
|
18
19
|
// this distance switches; a near-stationary release is a tap (opens overview).
|
|
19
20
|
const SWIPE_MIN_PX = 40;
|
|
21
|
+
// Movement (px) before an in-progress bar drag commits to a horizontal
|
|
22
|
+
// (tab-switch preview) or vertical (expand/collapse) axis.
|
|
23
|
+
const AXIS_LOCK_PX = 8;
|
|
24
|
+
// Flick-to-commit thresholds (from @use-gesture's drag defaults, MIT): a release
|
|
25
|
+
// counts as a flick when the gesture was quick, fast, and travelled enough, so a
|
|
26
|
+
// short fast swipe commits the switch/expand even below the halfway distance.
|
|
27
|
+
const SWIPE_VELOCITY = 0.5; // px/ms at release
|
|
28
|
+
const SWIPE_DURATION = 250; // ms, whole-gesture cap for a flick
|
|
29
|
+
// If the finger paused longer than this before lifting, the release velocity is
|
|
30
|
+
// stale (pointerup usually repeats the last pointermove position -> reads ~0),
|
|
31
|
+
// so ignore it and commit by distance instead (@use-gesture issue #332).
|
|
32
|
+
const VELOCITY_STALE_MS = 32;
|
|
33
|
+
// Live drag preview: while an open list is being swiped, it peeks in the swipe
|
|
34
|
+
// direction by this fraction of the finger's horizontal travel, capped at this
|
|
35
|
+
// many pixels — a hint of the coming rotation, not the full shift (the incoming
|
|
36
|
+
// row only appears on release, so a large move looked wrong). The release reel
|
|
37
|
+
// continues from wherever the peek left the rows.
|
|
38
|
+
const PREVIEW_DRAG_RATIO = 0.1;
|
|
39
|
+
const PREVIEW_PEEK_MAX = 10;
|
|
20
40
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
21
41
|
const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
22
42
|
// Default cadence for the no-activityMonitor polling fallback.
|
|
23
43
|
const DEFAULT_POLL_MS = 4000;
|
|
44
|
+
// Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
|
|
45
|
+
// from every viewport edge, and this gap above the pointer when the menu has to
|
|
46
|
+
// flip up (a right-click near the bottom edge) so it is neither clipped nor
|
|
47
|
+
// hidden under the cursor.
|
|
48
|
+
const TAB_MENU_EDGE = 8;
|
|
49
|
+
const TAB_MENU_GAP = 16;
|
|
24
50
|
|
|
25
51
|
/** One session's client-side wire shape (matches terminal.SessionInfo). */
|
|
26
52
|
interface SessionInfo {
|
|
@@ -36,10 +62,6 @@ interface Tab {
|
|
|
36
62
|
* empty before the process sets one. The displayed label is derived from it
|
|
37
63
|
* with a numbered fallback and de-duplication (see relabelAll). */
|
|
38
64
|
title: string;
|
|
39
|
-
/** A stable per-session number for the "Tab N" fallback when the process has
|
|
40
|
-
* set no title. Monotonic across the page so a tab's number never changes
|
|
41
|
-
* under it when a sibling closes. */
|
|
42
|
-
num: number;
|
|
43
65
|
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
44
66
|
display: string;
|
|
45
67
|
createdAt: string;
|
|
@@ -80,49 +102,70 @@ export interface TabsOptions {
|
|
|
80
102
|
* titles and drop reaped tabs (section 22.5). Ignored when activityMonitor is
|
|
81
103
|
* present. Default 4000. */
|
|
82
104
|
pollMs?: number;
|
|
105
|
+
/** The mobileToolbar feature value, so the mobile switcher bar renders a
|
|
106
|
+
* keyboard button that opens the key grid (ctx.use at tap time). Without it
|
|
107
|
+
* the bar shows no keyboard button (e.g. a desktop-only consumer). The
|
|
108
|
+
* toolbar should be built with { externalToggle: true } so its own top-right
|
|
109
|
+
* toggle is hidden and the grid opens above the bar. */
|
|
110
|
+
keyboardToggle?: TerminalFeature<MobileToolbarApi>;
|
|
83
111
|
}
|
|
84
112
|
|
|
113
|
+
// The +/x glyphs are inline SVG (not font glyphs) so they center exactly in
|
|
114
|
+
// their hover box and stay symmetric regardless of the UI font's metrics.
|
|
85
115
|
const TAB_HTML = `
|
|
86
116
|
<div class="wt-tab">
|
|
87
117
|
<span class="wt-tab-dot wt-status-dot" aria-hidden="true"></span>
|
|
88
118
|
<span class="wt-tab-label"></span>
|
|
89
|
-
<button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1"
|
|
119
|
+
<button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
|
|
90
120
|
</div>`;
|
|
91
121
|
|
|
92
|
-
const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal"
|
|
122
|
+
const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>`;
|
|
93
123
|
|
|
94
|
-
// Mobile bottom
|
|
95
|
-
//
|
|
124
|
+
// Mobile bottom bar. One element, two parts stacked in a column: the always-
|
|
125
|
+
// visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
|
|
126
|
+
// and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
|
|
127
|
+
// up and the list fills in beneath it (down to the safe area); swipe-down /
|
|
128
|
+
// tap collapses it back to the bottom. Selecting a listed tab swaps it into the
|
|
129
|
+
// active row. This replaces the old separate modal overview sheet ("one element"
|
|
130
|
+
// per the user): the bar itself lifts rather than opening a distinct surface.
|
|
131
|
+
// The bar is the FIRST child and the list the SECOND: the switcher is bottom-
|
|
132
|
+
// anchored, so a column with the list last grows the container upward, lifting
|
|
133
|
+
// the bar and revealing the list below it (DOM order = visual order top-to-bottom).
|
|
134
|
+
// - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
|
|
135
|
+
// (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
|
|
136
|
+
// right, mirroring the listed rows. No "n / m" counter: the list below is a
|
|
137
|
+
// rotating circular queue, so an absolute position number is meaningless.
|
|
138
|
+
// - .wt-switcher-kb: opens the key grid above the bar (only wired + shown when
|
|
139
|
+
// a keyboardToggle feature is provided).
|
|
140
|
+
// - .wt-switcher-new: the accent "+" that spawns a terminal.
|
|
96
141
|
const SWITCHER_HTML = `
|
|
97
142
|
<div class="wt-switcher" role="group" aria-label="Terminal tabs">
|
|
98
|
-
<
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const SHEET_HTML = `
|
|
111
|
-
<div class="wt-sheet" role="dialog" aria-modal="true" aria-label="Terminals" hidden>
|
|
112
|
-
<div class="wt-sheet-header">
|
|
113
|
-
<span class="wt-sheet-title">Terminals</span>
|
|
114
|
-
<button type="button" class="wt-sheet-new wt-btn">+ New terminal</button>
|
|
143
|
+
<div class="wt-switcher-bar">
|
|
144
|
+
<div class="wt-switcher-current-wrap">
|
|
145
|
+
<button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
|
|
146
|
+
<span class="wt-switcher-current-inner">
|
|
147
|
+
<span class="wt-switcher-dot wt-status-dot" aria-hidden="true"></span>
|
|
148
|
+
<span class="wt-switcher-label"></span>
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
<button type="button" class="wt-switcher-current-close wt-btn" aria-label="Close terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
|
|
152
|
+
</div>
|
|
153
|
+
<button type="button" class="wt-switcher-kb wt-btn" aria-label="Keyboard keys" aria-expanded="false" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M15 15 9.5 9.5M9.5 13V9.5H13"/></svg></button>
|
|
154
|
+
<button type="button" class="wt-switcher-new wt-btn wt-switcher-new-btn" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
115
155
|
</div>
|
|
116
|
-
<ul class="wt-
|
|
156
|
+
<ul class="wt-switcher-list" role="list"></ul>
|
|
117
157
|
</div>`;
|
|
118
158
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
159
|
+
// One row per OTHER tab in the expanded list: a stretched select target (dot +
|
|
160
|
+
// label) with the close (x) laid inside it at the right (two buttons can't nest,
|
|
161
|
+
// so the x is a sibling overlapping the select's reserved right padding).
|
|
162
|
+
const SWITCHER_ROW_HTML = `
|
|
163
|
+
<li class="wt-switcher-row">
|
|
164
|
+
<button type="button" class="wt-switcher-row-select">
|
|
165
|
+
<span class="wt-switcher-row-dot wt-status-dot" aria-hidden="true"></span>
|
|
166
|
+
<span class="wt-switcher-row-label"></span>
|
|
124
167
|
</button>
|
|
125
|
-
<button type="button" class="wt-
|
|
168
|
+
<button type="button" class="wt-switcher-row-close wt-btn" aria-label="Close terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
|
|
126
169
|
</li>`;
|
|
127
170
|
|
|
128
171
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
@@ -137,9 +180,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
137
180
|
}
|
|
138
181
|
async function apiCreate(): Promise<SessionInfo> {
|
|
139
182
|
const r = await fetch(apiBase, { method: "POST" });
|
|
140
|
-
if (r.status === 429) {
|
|
141
|
-
throw new Error("web-terminal-ui: too many sessions");
|
|
142
|
-
}
|
|
143
183
|
if (!r.ok) {
|
|
144
184
|
throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
|
|
145
185
|
}
|
|
@@ -173,37 +213,92 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
173
213
|
const newBtn = fromHTML(NEW_HTML);
|
|
174
214
|
bar.appendChild(newBtn);
|
|
175
215
|
|
|
176
|
-
//
|
|
177
|
-
// does not overlap the
|
|
178
|
-
// so a
|
|
179
|
-
// hidden on a coarse pointer where the switcher
|
|
180
|
-
//
|
|
181
|
-
//
|
|
216
|
+
// Pull the terminal surface up off the fixed BOTTOM strip on desktop so
|
|
217
|
+
// the bar does not overlap the last rows. The surface is position:fixed
|
|
218
|
+
// inset:0, so a bottom offset (gated to a fine pointer in CSS, since the
|
|
219
|
+
// strip is hidden on a coarse pointer where the mobile switcher applies its
|
|
220
|
+
// own inset) clears it. A ResizeObserver keeps the offset in step with the
|
|
221
|
+
// real strip height rather than a hard-coded guess. The measured height is
|
|
222
|
+
// published on the document root (not the surface): the scroll-to-bottom
|
|
223
|
+
// button sits in a sibling region, not inside .term, so a property set on
|
|
224
|
+
// .term would not inherit to it and it would fall back to the 44px guess
|
|
225
|
+
// and overlap the strip. Both .term and the button inherit it from :root.
|
|
182
226
|
const surface = ctx.surface();
|
|
183
|
-
surface.classList.add("wt-with-
|
|
227
|
+
surface.classList.add("wt-with-tabbar");
|
|
184
228
|
const barResize = new ResizeObserver(() => {
|
|
185
|
-
|
|
229
|
+
document.documentElement.style.setProperty(
|
|
230
|
+
"--wt-tabbar-h",
|
|
231
|
+
`${String(bar.offsetHeight)}px`,
|
|
232
|
+
);
|
|
186
233
|
});
|
|
187
234
|
barResize.observe(bar);
|
|
188
235
|
|
|
189
|
-
// --- Mobile bottom
|
|
236
|
+
// --- Mobile bottom bar (bottom-switcher region) ---
|
|
190
237
|
const switcher = fromHTML(SWITCHER_HTML);
|
|
238
|
+
const swList = pick(switcher, ".wt-switcher-list");
|
|
239
|
+
const swBar = pick(switcher, ".wt-switcher-bar");
|
|
191
240
|
const swCurrent = pick(switcher, ".wt-switcher-current");
|
|
241
|
+
// The row's content wrapper: it translates with the finger during a
|
|
242
|
+
// horizontal swipe (the active tab area physically swiping) and slides the
|
|
243
|
+
// incoming tab's label in on commit.
|
|
244
|
+
const swInner = pick(switcher, ".wt-switcher-current-inner");
|
|
192
245
|
const swDot = pick(switcher, ".wt-switcher-dot");
|
|
193
246
|
const swLabel = pick(switcher, ".wt-switcher-label");
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
247
|
+
const swClose = pick(switcher, ".wt-switcher-current-close");
|
|
248
|
+
// The active-tab elements that translate together during a horizontal
|
|
249
|
+
// swipe: the content (dot + label) and the close (x). Moving both keeps the
|
|
250
|
+
// whole active-tab chip sliding as one, rather than the close staying put.
|
|
251
|
+
const swipeEls = [swInner, swClose];
|
|
252
|
+
const swKb = pick(switcher, ".wt-switcher-kb");
|
|
253
|
+
const swNew = pick(switcher, ".wt-switcher-new");
|
|
197
254
|
ctx.region("bottom-switcher", "switcher").appendChild(switcher);
|
|
255
|
+
// The keyboard button opens the key grid; show it only when a toolbar is
|
|
256
|
+
// wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
|
|
257
|
+
// feature ordering does not matter.
|
|
258
|
+
let offArmed: (() => void) | undefined;
|
|
259
|
+
if (opts.keyboardToggle) {
|
|
260
|
+
swKb.hidden = false;
|
|
261
|
+
// Mirror sticky-Ctrl on the keyboard button: when a Ctrl press is armed,
|
|
262
|
+
// invert the button (like the armed Ctrl key) so the pending modifier is
|
|
263
|
+
// visible with the grid closed — the toolbar sets up before tabs, so its
|
|
264
|
+
// API is available now (see the preset ordering note). Also clears on the
|
|
265
|
+
// auto-disarm after a Ctrl byte and on a tab switch (onDetach disarms).
|
|
266
|
+
const kbApi = ctx.use(opts.keyboardToggle);
|
|
267
|
+
if (kbApi) {
|
|
268
|
+
const reflectArmed = (armed: boolean): void => {
|
|
269
|
+
swKb.classList.toggle("wt-armed", armed);
|
|
270
|
+
};
|
|
271
|
+
reflectArmed(kbApi.isCtrlArmed());
|
|
272
|
+
offArmed = kbApi.onCtrlArmedChange(reflectArmed);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
276
|
+
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
277
|
+
const root = ctx.surface().parentElement;
|
|
278
|
+
root?.classList.add("wt-tabbed");
|
|
279
|
+
// Reserve the collapsed bar row's height so terminal content stops above it
|
|
280
|
+
// (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
|
|
281
|
+
// bottom inset. Measure the bar row (not the expandable list, which just
|
|
282
|
+
// overlays content). innerHeight - rect.top captures the row plus the
|
|
283
|
+
// safe-area beneath it; the RO fires with the keyboard closed, so the value
|
|
284
|
+
// excludes the keyboard lift (viewport.ts adds that separately). The
|
|
285
|
+
// synthetic visualViewport resize makes viewport.ts recompute immediately.
|
|
286
|
+
const swReserve = new ResizeObserver(() => {
|
|
287
|
+
const rect = swBar.getBoundingClientRect();
|
|
288
|
+
const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
|
|
289
|
+
document.documentElement.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
|
|
290
|
+
window.visualViewport?.dispatchEvent(new Event("resize"));
|
|
291
|
+
});
|
|
292
|
+
swReserve.observe(swBar);
|
|
198
293
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
294
|
+
// Activity dots are opt-in: shown only when a status source
|
|
295
|
+
// (activityMonitor) is wired, so a generic bash/sh terminal gets clean,
|
|
296
|
+
// label-only tabs. CSS hides .wt-status-dot unless its container carries
|
|
297
|
+
// .wt-status; the agent consumer (vibecli) provides the monitor.
|
|
298
|
+
if (monitor) {
|
|
299
|
+
bar.classList.add("wt-status");
|
|
300
|
+
switcher.classList.add("wt-status");
|
|
301
|
+
}
|
|
207
302
|
|
|
208
303
|
// Catching-up cue: a switched-into tab's cached screen is stale until its
|
|
209
304
|
// resume delta lands, so it must not read as live (sections 12/13). Shown
|
|
@@ -216,23 +311,57 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
216
311
|
ctx.region("banner", "catchup").appendChild(catchup);
|
|
217
312
|
let catchupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
218
313
|
|
|
314
|
+
// --- Desktop right-click tab context menu (overlay region) ---
|
|
315
|
+
// Replaces the old bar "Close all" button with a richer per-tab menu. Built
|
|
316
|
+
// on demand (mirroring context-menu.ts) so each item targets the
|
|
317
|
+
// right-clicked tab and its disabled state reflects that tab's position.
|
|
318
|
+
const tabMenu = document.createElement("div");
|
|
319
|
+
tabMenu.className = "wt-tab-menu";
|
|
320
|
+
tabMenu.setAttribute("role", "menu");
|
|
321
|
+
ctx.region("overlay", "tab-menu").appendChild(tabMenu);
|
|
322
|
+
|
|
219
323
|
const tabList: Tab[] = [];
|
|
324
|
+
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
325
|
+
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
326
|
+
// same elements from their old slots to their new ones (the rotation).
|
|
327
|
+
const rowEls = new Map<string, HTMLElement>();
|
|
220
328
|
let activeId: string | null = null;
|
|
221
|
-
let
|
|
222
|
-
|
|
329
|
+
let draggingEl: HTMLElement | null = null;
|
|
330
|
+
// Gate the new-tab enter animation: tabs present at initial population
|
|
331
|
+
// should not animate in (jarring on load); only tabs added at runtime do.
|
|
332
|
+
let started = false;
|
|
333
|
+
let expanded = false;
|
|
334
|
+
// Interactive horizontal-swipe preview: while a swipe drags with the list
|
|
335
|
+
// open, the rows peek a few pixels in the swipe direction (dragActive); the
|
|
336
|
+
// release reel then continues from wherever they are.
|
|
337
|
+
let dragActive = false;
|
|
338
|
+
// True while a reel's reconcile runs (a swipe switch): renderSwitcherList
|
|
339
|
+
// suppresses its add/remove row animation then, since the reel owns row
|
|
340
|
+
// motion. creatingTab suppresses the reel for a create, so the new listed
|
|
341
|
+
// row grows+fades in (animateRowIn) rather than rotating.
|
|
342
|
+
let reelReconcile = false;
|
|
343
|
+
let creatingTab = false;
|
|
344
|
+
let collapseClearTimer: ReturnType<typeof setTimeout> | null = null;
|
|
223
345
|
let hintShown = false;
|
|
224
|
-
|
|
346
|
+
// On a touch device, focusing the hidden input opens the virtual keyboard.
|
|
347
|
+
// A switch triggered by tapping/swiping the tab UI must not do that (it is
|
|
348
|
+
// disruptive), so the input is focused on a switch only with a fine
|
|
349
|
+
// pointer; on touch the user taps the terminal itself to open the keyboard.
|
|
350
|
+
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
|
351
|
+
// Motion opt-out (checked live: the OS setting can change). Gates the
|
|
352
|
+
// interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
|
|
353
|
+
const prefersReduce = (): boolean =>
|
|
354
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
225
355
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// session), then the process window title (OSC 0/2, e.g. a shell's), then
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
function
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return (tab.derived?.trim() ?? tab.title.trim()) || `Tab ${String(tab.num)}`;
|
|
356
|
+
// baseLabel is a tab's label before de-duplication. Preference order: the
|
|
357
|
+
// title derived from the user's first submitted line (best for a chat like
|
|
358
|
+
// kiro-cli, whose OSC 0 title only ever reflects the cwd for a live
|
|
359
|
+
// session), then the process window title (OSC 0/2, e.g. a shell's), then a
|
|
360
|
+
// plain "New tab". fallback=true marks that last case so relabelAll leaves
|
|
361
|
+
// untitled tabs as "New tab" with no numeric suffix.
|
|
362
|
+
function baseLabel(tab: Tab): { text: string; fallback: boolean } {
|
|
363
|
+
const real = tab.derived?.trim() ?? tab.title.trim();
|
|
364
|
+
return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
|
|
236
365
|
}
|
|
237
366
|
|
|
238
367
|
// relabelAll recomputes every tab's display label with de-duplication:
|
|
@@ -241,20 +370,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
241
370
|
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
242
371
|
// never shows two identical labels.
|
|
243
372
|
function relabelAll(): void {
|
|
373
|
+
// Count only real (non-fallback) labels, so multiple untitled tabs all
|
|
374
|
+
// read "New tab" without a numeric suffix.
|
|
244
375
|
const counts = new Map<string, number>();
|
|
245
376
|
for (const t of tabList) {
|
|
246
|
-
const
|
|
247
|
-
|
|
377
|
+
const { text, fallback } = baseLabel(t);
|
|
378
|
+
if (!fallback) {
|
|
379
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
380
|
+
}
|
|
248
381
|
}
|
|
249
382
|
const seen = new Map<string, number>();
|
|
250
383
|
for (const t of tabList) {
|
|
251
|
-
const
|
|
252
|
-
let display =
|
|
253
|
-
if ((counts.get(
|
|
254
|
-
const k = (seen.get(
|
|
255
|
-
seen.set(
|
|
384
|
+
const { text, fallback } = baseLabel(t);
|
|
385
|
+
let display = text;
|
|
386
|
+
if (!fallback && (counts.get(text) ?? 0) > 1) {
|
|
387
|
+
const k = (seen.get(text) ?? 0) + 1;
|
|
388
|
+
seen.set(text, k);
|
|
256
389
|
if (k > 1) {
|
|
257
|
-
display = `${
|
|
390
|
+
display = `${text} (${String(k)})`;
|
|
258
391
|
}
|
|
259
392
|
}
|
|
260
393
|
t.display = display;
|
|
@@ -341,59 +474,246 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
341
474
|
}
|
|
342
475
|
}
|
|
343
476
|
|
|
344
|
-
// syncMobile updates the bottom
|
|
345
|
-
//
|
|
346
|
-
// on input is glanceable
|
|
477
|
+
// syncMobile updates the bottom bar: active label + dot, and the aggregate
|
|
478
|
+
// needs-input cue. The cue rides the active surface: a background tab
|
|
479
|
+
// blocked on input is glanceable, and tapping/swiping opens the list to
|
|
480
|
+
// resolve it (section 12).
|
|
347
481
|
function syncMobile(): void {
|
|
348
482
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
349
483
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
350
484
|
swLabel.textContent = active ? active.display : "";
|
|
351
485
|
swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
|
|
352
|
-
swPos.textContent =
|
|
353
|
-
tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
|
|
354
|
-
swCount.textContent = String(tabList.length);
|
|
355
486
|
const bgInput = tabList.some(
|
|
356
487
|
(t) => t.id !== activeId && t.dot.dataset["status"] === "input",
|
|
357
488
|
);
|
|
358
489
|
if (bgInput) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
"aria-label",
|
|
362
|
-
"All terminals; a background terminal needs input",
|
|
363
|
-
);
|
|
490
|
+
swCurrent.dataset["attention"] = "input";
|
|
491
|
+
swCurrent.setAttribute("aria-label", "Terminals; a background terminal needs input");
|
|
364
492
|
} else {
|
|
365
|
-
delete
|
|
366
|
-
|
|
493
|
+
delete swCurrent.dataset["attention"];
|
|
494
|
+
swCurrent.removeAttribute("aria-label");
|
|
367
495
|
}
|
|
368
496
|
}
|
|
369
497
|
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
498
|
+
// buildRow creates one expanded-list row for a tab and wires its handlers
|
|
499
|
+
// (select = switch + collapse; x = close). Its dot/label are filled by
|
|
500
|
+
// updateRow. The element is cached in rowEls and reused across renders.
|
|
501
|
+
function buildRow(t: Tab): HTMLElement {
|
|
502
|
+
const row = fromHTML(SWITCHER_ROW_HTML);
|
|
503
|
+
pick(row, ".wt-switcher-row-select").addEventListener("click", () => {
|
|
504
|
+
collapseSwitcher();
|
|
505
|
+
switchTo(t.id);
|
|
506
|
+
});
|
|
507
|
+
pick(row, ".wt-switcher-row-close").addEventListener("click", (e) => {
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
void close_(t.id);
|
|
510
|
+
});
|
|
511
|
+
return row;
|
|
512
|
+
}
|
|
513
|
+
// updateRow refreshes a reused row's live bits (status dot + label).
|
|
514
|
+
function updateRow(row: HTMLElement, t: Tab): void {
|
|
515
|
+
pick(row, ".wt-switcher-row-dot").dataset["status"] = t.dot.dataset["status"] ?? "idle";
|
|
516
|
+
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
517
|
+
}
|
|
518
|
+
// animateRowIn / animateRowOut give a listed tab an enter / leave motion:
|
|
519
|
+
// the row's own max-height grows from 0 (fading in) on add, and collapses
|
|
520
|
+
// to 0 (fading out, then removed) on close. The flex list's height follows
|
|
521
|
+
// the row, so adding/closing a tab animates the tray height rather than
|
|
522
|
+
// snapping. Inline-driven (cleared when done); the caller gates motion.
|
|
523
|
+
const ROW_ANIM_MS = 220;
|
|
524
|
+
const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
|
|
525
|
+
function animateRowIn(row: HTMLElement): void {
|
|
526
|
+
const h = row.getBoundingClientRect().height;
|
|
527
|
+
if (h <= 0) {
|
|
528
|
+
return;
|
|
396
529
|
}
|
|
530
|
+
row.style.overflow = "hidden";
|
|
531
|
+
row.style.transition = "none";
|
|
532
|
+
row.style.maxHeight = "0";
|
|
533
|
+
row.style.opacity = "0";
|
|
534
|
+
requestAnimationFrame(() => {
|
|
535
|
+
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
536
|
+
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
537
|
+
row.style.opacity = "1";
|
|
538
|
+
});
|
|
539
|
+
window.setTimeout(() => {
|
|
540
|
+
row.style.transition = "";
|
|
541
|
+
row.style.maxHeight = "";
|
|
542
|
+
row.style.opacity = "";
|
|
543
|
+
row.style.overflow = "";
|
|
544
|
+
}, ROW_ANIM_MS + 60);
|
|
545
|
+
}
|
|
546
|
+
function animateRowOut(row: HTMLElement): void {
|
|
547
|
+
const h = row.getBoundingClientRect().height;
|
|
548
|
+
row.style.overflow = "hidden";
|
|
549
|
+
row.style.pointerEvents = "none";
|
|
550
|
+
row.style.transition = "none";
|
|
551
|
+
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
552
|
+
row.style.opacity = "1";
|
|
553
|
+
requestAnimationFrame(() => {
|
|
554
|
+
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
555
|
+
row.style.maxHeight = "0";
|
|
556
|
+
row.style.opacity = "0";
|
|
557
|
+
});
|
|
558
|
+
window.setTimeout(() => {
|
|
559
|
+
row.remove();
|
|
560
|
+
}, ROW_ANIM_MS + 60);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// renderSwitcherList reconciles the expanded list to a row per OTHER tab
|
|
564
|
+
// (the active tab lives in the bar row), REUSING existing row elements
|
|
565
|
+
// rather than rebuilding, so element identity is stable and the swipe
|
|
566
|
+
// rotation can FLIP the same rows between positions. Order is circular
|
|
567
|
+
// starting AFTER the active tab, so the list reads as the queue that
|
|
568
|
+
// follows the current one (active #k -> k+1, k+2, ... wrapping around); as
|
|
569
|
+
// the active tab changes on a swipe, this order rotates by one.
|
|
570
|
+
function renderSwitcherList(): void {
|
|
571
|
+
const n = tabList.length;
|
|
572
|
+
const activeIdx = tabList.findIndex((t) => t.id === activeId);
|
|
573
|
+
const start = activeIdx >= 0 ? activeIdx : 0;
|
|
574
|
+
const desired: Tab[] = [];
|
|
575
|
+
for (let step = 1; step < n; step++) {
|
|
576
|
+
const t = tabList[(start + step) % n];
|
|
577
|
+
if (t) {
|
|
578
|
+
desired.push(t);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Animate an incremental add/close only on an already-open list: NOT
|
|
582
|
+
// during the initial expand (expanded is still false while expandSwitcher
|
|
583
|
+
// populates), NOT during a reel (it owns row motion), NOT under reduced
|
|
584
|
+
// motion — those paths reveal/move rows their own way.
|
|
585
|
+
const anim = expanded && !reelReconcile && !prefersReduce();
|
|
586
|
+
const keep = new Set(desired.map((t) => t.id));
|
|
587
|
+
for (const [id, el] of rowEls) {
|
|
588
|
+
if (!keep.has(id)) {
|
|
589
|
+
rowEls.delete(id);
|
|
590
|
+
if (anim) {
|
|
591
|
+
animateRowOut(el); // collapse + fade, then remove
|
|
592
|
+
} else {
|
|
593
|
+
el.remove();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Append in desired order: appendChild moves an existing node, so this
|
|
598
|
+
// both inserts new rows and reorders reused ones into the new sequence.
|
|
599
|
+
for (const t of desired) {
|
|
600
|
+
let row = rowEls.get(t.id);
|
|
601
|
+
const isNew = row === undefined;
|
|
602
|
+
if (!row) {
|
|
603
|
+
row = buildRow(t);
|
|
604
|
+
rowEls.set(t.id, row);
|
|
605
|
+
}
|
|
606
|
+
updateRow(row, t);
|
|
607
|
+
swList.appendChild(row);
|
|
608
|
+
if (isNew && anim) {
|
|
609
|
+
animateRowIn(row); // grow + fade in (the tray height follows)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// clearRows empties the list and drops the reused-row cache (after a
|
|
615
|
+
// collapse), so the next expand rebuilds fresh rather than reusing rows
|
|
616
|
+
// that might carry a stale reel transform.
|
|
617
|
+
function clearRows(): void {
|
|
618
|
+
endReelNow();
|
|
619
|
+
swList.replaceChildren();
|
|
620
|
+
rowEls.clear();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// The circular-queue rotation, as a true reel: when a swipe switches the
|
|
624
|
+
// active tab while the list is expanded, every surviving row slides one
|
|
625
|
+
// slot (the rows visibly rotate past a fixed frame), the row that becomes
|
|
626
|
+
// active exits the leading edge, and the row that was active enters the
|
|
627
|
+
// trailing edge. It is a FLIP over reused row elements: prepareReel (run
|
|
628
|
+
// BEFORE syncChrome reconciles the list) snapshots the current row pixel
|
|
629
|
+
// positions and lifts the leaving row out of the flow as an absolute ghost
|
|
630
|
+
// so the reconcile can't reshuffle the survivors; the returned closure
|
|
631
|
+
// (run AFTER the reconcile) inverts every row to its old spot and releases
|
|
632
|
+
// it to the new one, and slides the ghost out. Pixel positions make it
|
|
633
|
+
// correct regardless of the row gap, list padding, or separator border.
|
|
634
|
+
const REEL_MS = 300;
|
|
635
|
+
let reelTimer: ReturnType<typeof setTimeout> | null = null;
|
|
636
|
+
let reelGhost: HTMLElement | null = null;
|
|
637
|
+
// endReelNow settles any in-flight reel immediately: drop the ghost, clear
|
|
638
|
+
// the row transforms, and hand overflow/position back to the stylesheet.
|
|
639
|
+
function endReelNow(): void {
|
|
640
|
+
if (reelTimer !== null) {
|
|
641
|
+
clearTimeout(reelTimer);
|
|
642
|
+
reelTimer = null;
|
|
643
|
+
}
|
|
644
|
+
if (reelGhost) {
|
|
645
|
+
reelGhost.remove();
|
|
646
|
+
reelGhost = null;
|
|
647
|
+
}
|
|
648
|
+
swList.style.overflow = "";
|
|
649
|
+
swList.style.position = "";
|
|
650
|
+
for (const el of rowEls.values()) {
|
|
651
|
+
el.style.transition = "";
|
|
652
|
+
el.style.transform = "";
|
|
653
|
+
el.style.opacity = "";
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// leaving is the tab becoming active (its row exits the list); the entering
|
|
657
|
+
// row (the previously-active tab) is built by the reconcile that runs
|
|
658
|
+
// between prepareReel and the returned closure, so it needs no argument.
|
|
659
|
+
function prepareReel(dir: "next" | "prev", leaving: Tab): (() => void) | undefined {
|
|
660
|
+
const ghost = rowEls.get(leaving.id);
|
|
661
|
+
if (!ghost) {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
// Capture positions BEFORE settling any in-flight transform, so a live
|
|
665
|
+
// drag preview flows into the reel: each row starts where the finger left
|
|
666
|
+
// it (First includes the preview offset). For a plain flick with no
|
|
667
|
+
// preview, these are simply the rows' rest positions.
|
|
668
|
+
const firstTops = new Map<string, number>();
|
|
669
|
+
for (const [id, el] of rowEls) {
|
|
670
|
+
firstTops.set(id, el.getBoundingClientRect().top);
|
|
671
|
+
}
|
|
672
|
+
const listRect = swList.getBoundingClientRect();
|
|
673
|
+
const st = getComputedStyle(swList);
|
|
674
|
+
const pitch = ghost.getBoundingClientRect().height + (parseFloat(st.rowGap) || 0);
|
|
675
|
+
const top = (firstTops.get(leaving.id) ?? listRect.top) - listRect.top + swList.scrollTop;
|
|
676
|
+
endReelNow(); // settle a prior reel / the drag preview before this one
|
|
677
|
+
// Freeze the frame: clip overflow so rows leaving/entering are masked at
|
|
678
|
+
// the edges, and anchor the leaving row absolutely at its captured spot so
|
|
679
|
+
// the reconcile that follows leaves the survivors where they are.
|
|
680
|
+
swList.style.overflow = "hidden";
|
|
681
|
+
swList.style.position = "relative";
|
|
682
|
+
rowEls.delete(leaving.id); // the reconcile must not touch the ghost
|
|
683
|
+
ghost.style.position = "absolute";
|
|
684
|
+
ghost.style.left = st.paddingLeft;
|
|
685
|
+
ghost.style.right = st.paddingRight;
|
|
686
|
+
ghost.style.top = `${String(Math.round(top))}px`;
|
|
687
|
+
ghost.style.pointerEvents = "none";
|
|
688
|
+
reelGhost = ghost;
|
|
689
|
+
|
|
690
|
+
return () => {
|
|
691
|
+
for (const [id, el] of rowEls) {
|
|
692
|
+
const first = firstTops.get(id);
|
|
693
|
+
let from: number;
|
|
694
|
+
if (first !== undefined) {
|
|
695
|
+
from = first - el.getBoundingClientRect().top; // survivor: old spot -> new
|
|
696
|
+
} else {
|
|
697
|
+
from = dir === "next" ? pitch : -pitch; // newcomer: in from the trailing edge
|
|
698
|
+
el.style.opacity = "0";
|
|
699
|
+
}
|
|
700
|
+
el.style.transition = "none";
|
|
701
|
+
el.style.transform = `translateY(${String(Math.round(from))}px)`;
|
|
702
|
+
}
|
|
703
|
+
const exit = dir === "next" ? -pitch : pitch; // leaving row off the leading edge
|
|
704
|
+
requestAnimationFrame(() => {
|
|
705
|
+
const trans = "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s ease-out";
|
|
706
|
+
for (const el of rowEls.values()) {
|
|
707
|
+
el.style.transition = trans;
|
|
708
|
+
el.style.transform = "translateY(0)";
|
|
709
|
+
el.style.opacity = "";
|
|
710
|
+
}
|
|
711
|
+
ghost.style.transition = trans;
|
|
712
|
+
ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
|
|
713
|
+
ghost.style.opacity = "0";
|
|
714
|
+
});
|
|
715
|
+
reelTimer = setTimeout(endReelNow, REEL_MS);
|
|
716
|
+
};
|
|
397
717
|
}
|
|
398
718
|
|
|
399
719
|
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
@@ -401,36 +721,88 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
401
721
|
relabelAll();
|
|
402
722
|
paintActive();
|
|
403
723
|
syncMobile();
|
|
404
|
-
if (
|
|
405
|
-
|
|
724
|
+
if (expanded) {
|
|
725
|
+
renderSwitcherList();
|
|
406
726
|
}
|
|
407
727
|
maybeSwipeHint();
|
|
408
728
|
}
|
|
409
729
|
|
|
410
|
-
|
|
411
|
-
|
|
730
|
+
// closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
|
|
731
|
+
// open) and resets the switcher's keyboard button state. Used when the tab
|
|
732
|
+
// list expands (the button is hidden then, so the grid must not linger
|
|
733
|
+
// behind it) and when a tap on the terminal dismisses an open grid.
|
|
734
|
+
function closeKeyGrid(): void {
|
|
735
|
+
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
736
|
+
if (kb?.isOpen()) {
|
|
737
|
+
kb.toggle();
|
|
738
|
+
}
|
|
739
|
+
swKb.setAttribute("aria-expanded", "false");
|
|
740
|
+
swKb.classList.remove("wt-active");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// setExpandedState applies the resting expanded/collapsed state: the class
|
|
744
|
+
// that drives the list's max-height (and its padding/border), the aria
|
|
745
|
+
// flag, and (on collapse) the deferred row clear so the rows leave the a11y
|
|
746
|
+
// tree only after the collapse animation. It does not render rows or touch
|
|
747
|
+
// the inline styles the interactive drag uses; expandSwitcher and the drag
|
|
748
|
+
// release own those.
|
|
749
|
+
function setExpandedState(on: boolean): void {
|
|
750
|
+
if (on) {
|
|
751
|
+
if (collapseClearTimer !== null) {
|
|
752
|
+
clearTimeout(collapseClearTimer);
|
|
753
|
+
collapseClearTimer = null;
|
|
754
|
+
}
|
|
755
|
+
expanded = true;
|
|
756
|
+
switcher.classList.add("wt-switcher-expanded");
|
|
757
|
+
swCurrent.setAttribute("aria-expanded", "true");
|
|
758
|
+
// Simplify the crowded bar: close the key grid and hide the keyboard
|
|
759
|
+
// button while the list is open (CSS collapses it and the active row
|
|
760
|
+
// grows to fill). Avoids the grid opening behind the expanded list.
|
|
761
|
+
closeKeyGrid();
|
|
762
|
+
} else {
|
|
763
|
+
expanded = false;
|
|
764
|
+
switcher.classList.remove("wt-switcher-expanded");
|
|
765
|
+
swCurrent.setAttribute("aria-expanded", "false");
|
|
766
|
+
if (collapseClearTimer !== null) {
|
|
767
|
+
clearTimeout(collapseClearTimer);
|
|
768
|
+
}
|
|
769
|
+
collapseClearTimer = setTimeout(() => {
|
|
770
|
+
collapseClearTimer = null;
|
|
771
|
+
if (!expanded) {
|
|
772
|
+
clearRows();
|
|
773
|
+
}
|
|
774
|
+
}, 260);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// expandSwitcher grows the bar to list the other tabs below the active row
|
|
779
|
+
// (swipe-up / tap). No-op with a single tab (nothing to list). Not modal:
|
|
780
|
+
// it never steals focus (focusing the hidden input on touch would pop the
|
|
781
|
+
// keyboard).
|
|
782
|
+
function expandSwitcher(): void {
|
|
783
|
+
if (expanded || tabList.length < 2) {
|
|
412
784
|
return;
|
|
413
785
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
scrim.classList.add("visible");
|
|
418
|
-
sheet.hidden = false;
|
|
419
|
-
sheet.classList.add("visible");
|
|
420
|
-
sheet.querySelector<HTMLElement>("button")?.focus();
|
|
421
|
-
ctx.announce("Terminals list opened");
|
|
786
|
+
renderSwitcherList();
|
|
787
|
+
setExpandedState(true);
|
|
788
|
+
ctx.announce("Terminal list expanded");
|
|
422
789
|
}
|
|
423
790
|
|
|
424
|
-
|
|
425
|
-
|
|
791
|
+
// collapseSwitcher shrinks the bar back to just the active row (swipe-down /
|
|
792
|
+
// tap / select).
|
|
793
|
+
function collapseSwitcher(): void {
|
|
794
|
+
if (!expanded) {
|
|
426
795
|
return;
|
|
427
796
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
797
|
+
setExpandedState(false);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function toggleSwitcher(): void {
|
|
801
|
+
if (expanded) {
|
|
802
|
+
collapseSwitcher();
|
|
803
|
+
} else {
|
|
804
|
+
expandSwitcher();
|
|
805
|
+
}
|
|
434
806
|
}
|
|
435
807
|
|
|
436
808
|
function addTabChrome(info: SessionInfo): Tab {
|
|
@@ -445,11 +817,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
445
817
|
const aria = tablist.registerTab(el);
|
|
446
818
|
// Insert before the "+" button so it stays the last item in the strip.
|
|
447
819
|
bar.insertBefore(el, newBtn);
|
|
820
|
+
// Runtime-added tabs animate in; initial tabs do not (see `started`).
|
|
821
|
+
// The timer (not animationend) also clears the class on the hidden mobile
|
|
822
|
+
// strip, where the animation never fires.
|
|
823
|
+
if (started) {
|
|
824
|
+
el.classList.add("wt-tab-enter");
|
|
825
|
+
setTimeout(() => {
|
|
826
|
+
el.classList.remove("wt-tab-enter");
|
|
827
|
+
}, 300);
|
|
828
|
+
}
|
|
448
829
|
|
|
449
830
|
const tab: Tab = {
|
|
450
831
|
id: info.id,
|
|
451
832
|
title: info.title,
|
|
452
|
-
num: ++tabSeq,
|
|
453
833
|
display: "",
|
|
454
834
|
createdAt: info.createdAt,
|
|
455
835
|
store: new LineStore(),
|
|
@@ -462,7 +842,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
462
842
|
};
|
|
463
843
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
464
844
|
// once the tab is in tabList and syncChrome runs).
|
|
465
|
-
tab.display =
|
|
845
|
+
tab.display = baseLabel(tab).text;
|
|
466
846
|
label.textContent = tab.display;
|
|
467
847
|
aria.setLabel(tab.display);
|
|
468
848
|
el.addEventListener("click", (e) => {
|
|
@@ -475,10 +855,48 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
475
855
|
e.stopPropagation();
|
|
476
856
|
void close_(tab.id);
|
|
477
857
|
});
|
|
858
|
+
// Middle-click closes the tab (#8). Suppress the middle-click default on
|
|
859
|
+
// mousedown so the browser's autoscroll/paste affordance does not fire.
|
|
860
|
+
el.addEventListener("mousedown", (e) => {
|
|
861
|
+
if (e.button === 1) {
|
|
862
|
+
e.preventDefault();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
el.addEventListener("auxclick", (e) => {
|
|
866
|
+
if (e.button === 1) {
|
|
867
|
+
e.preventDefault();
|
|
868
|
+
void close_(tab.id);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
// Right-click opens the tab context menu (desktop). preventDefault stops
|
|
872
|
+
// the browser's own menu; the strip is hidden on a coarse pointer, so
|
|
873
|
+
// this is desktop-only in practice.
|
|
874
|
+
el.addEventListener("contextmenu", (e) => {
|
|
875
|
+
e.preventDefault();
|
|
876
|
+
showTabMenu(e.clientX, e.clientY, tab.id);
|
|
877
|
+
});
|
|
878
|
+
// Drag-and-drop reorder on the desktop strip. The bar's dragover moves
|
|
879
|
+
// this element live; dragend commits the new order into tabList.
|
|
880
|
+
el.draggable = true;
|
|
881
|
+
el.addEventListener("dragstart", (e) => {
|
|
882
|
+
draggingEl = el;
|
|
883
|
+
el.classList.add("wt-tab-dragging");
|
|
884
|
+
if (e.dataTransfer) {
|
|
885
|
+
e.dataTransfer.effectAllowed = "move";
|
|
886
|
+
// Firefox requires drag data to be set for the drag to start.
|
|
887
|
+
e.dataTransfer.setData("text/plain", tab.id);
|
|
888
|
+
}
|
|
889
|
+
hideTabMenu();
|
|
890
|
+
});
|
|
891
|
+
el.addEventListener("dragend", () => {
|
|
892
|
+
el.classList.remove("wt-tab-dragging");
|
|
893
|
+
draggingEl = null;
|
|
894
|
+
syncOrderFromDom();
|
|
895
|
+
});
|
|
478
896
|
return tab;
|
|
479
897
|
}
|
|
480
898
|
|
|
481
|
-
function switchTo(id: string): void {
|
|
899
|
+
function switchTo(id: string, dir?: "next" | "prev"): void {
|
|
482
900
|
if (id === activeId) {
|
|
483
901
|
return;
|
|
484
902
|
}
|
|
@@ -486,6 +904,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
486
904
|
if (!next) {
|
|
487
905
|
return;
|
|
488
906
|
}
|
|
907
|
+
// Derive a slide direction from the index delta when the caller did not
|
|
908
|
+
// give one (a desktop tab click, a sheet select): moving to a later tab
|
|
909
|
+
// slides the incoming content in from the right, an earlier tab from the
|
|
910
|
+
// left, so desktop switches feel like the mobile swipe.
|
|
911
|
+
let slide = dir;
|
|
912
|
+
if (slide === undefined && activeId !== null) {
|
|
913
|
+
const from = tabList.findIndex((t) => t.id === activeId);
|
|
914
|
+
const to = tabList.findIndex((t) => t.id === id);
|
|
915
|
+
if (from >= 0 && to >= 0) {
|
|
916
|
+
slide = to > from ? "next" : "prev";
|
|
917
|
+
}
|
|
918
|
+
}
|
|
489
919
|
const surface = ctx.surface();
|
|
490
920
|
// Detach the current tab: save its scroll memory (keep its cache).
|
|
491
921
|
const cur = tabList.find((t) => t.id === activeId);
|
|
@@ -493,6 +923,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
493
923
|
cur.scrollTop = surface.scrollTop;
|
|
494
924
|
cur.following = !ctx.scroll.isUserScrolledUp();
|
|
495
925
|
}
|
|
926
|
+
// Decide whether to animate the expanded list as a rotation: only a
|
|
927
|
+
// swipe to an adjacent tab while the list is open. prepareReel snapshots
|
|
928
|
+
// the rows BEFORE the reconcile below; the returned closure FLIPs them
|
|
929
|
+
// into their new slots after it.
|
|
930
|
+
const fromIdx = tabList.findIndex((t) => t.id === activeId);
|
|
931
|
+
const toIdx = tabList.findIndex((t) => t.id === next.id);
|
|
932
|
+
// A one-step move: adjacent, OR a wrap between the first and last tab
|
|
933
|
+
// (index gap n-1), since the list rotates infinitely.
|
|
934
|
+
const stepGap = Math.abs(toIdx - fromIdx);
|
|
935
|
+
let playReel: (() => void) | undefined;
|
|
936
|
+
if (
|
|
937
|
+
expanded &&
|
|
938
|
+
!creatingTab &&
|
|
939
|
+
(slide === "next" || slide === "prev") &&
|
|
940
|
+
(stepGap === 1 || stepGap === tabList.length - 1) &&
|
|
941
|
+
!prefersReduce()
|
|
942
|
+
) {
|
|
943
|
+
playReel = prepareReel(slide, next);
|
|
944
|
+
}
|
|
496
945
|
// Attach the next tab: point the renderer at its cached store and
|
|
497
946
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
498
947
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
@@ -501,8 +950,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
501
950
|
ctx.render.bind(next.store);
|
|
502
951
|
ctx.notifySwitch({ id: next.id });
|
|
503
952
|
armCatchup();
|
|
504
|
-
flashSwitch();
|
|
505
|
-
|
|
953
|
+
flashSwitch(slide);
|
|
954
|
+
// Mark the reconcile as reel-driven so renderSwitcherList suppresses its
|
|
955
|
+
// add/remove row animation (the reel owns row motion here).
|
|
956
|
+
reelReconcile = playReel !== undefined;
|
|
957
|
+
syncChrome(); // reconciles the expanded list into the new order
|
|
958
|
+
reelReconcile = false;
|
|
959
|
+
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
506
960
|
// Restore scroll memory best-effort after the async rebuild; a
|
|
507
961
|
// following tab sticks to the bottom on its own.
|
|
508
962
|
const savedTop = next.scrollTop;
|
|
@@ -513,7 +967,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
513
967
|
}
|
|
514
968
|
});
|
|
515
969
|
ctx.announce(`Switched to ${next.display}`);
|
|
516
|
-
|
|
970
|
+
if (!coarse) {
|
|
971
|
+
focusInput(); // desktop only; on touch this would pop the keyboard (#7)
|
|
972
|
+
}
|
|
517
973
|
}
|
|
518
974
|
|
|
519
975
|
// armCatchup shows the "catching up" cue only if the resume delta has not
|
|
@@ -536,14 +992,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
536
992
|
}
|
|
537
993
|
catchup.classList.remove("visible");
|
|
538
994
|
}
|
|
539
|
-
// flashSwitch
|
|
540
|
-
//
|
|
541
|
-
|
|
995
|
+
// flashSwitch plays the switch animation the animations feature keys off
|
|
996
|
+
// (a no-op when animations are absent or reduced-motion is set). With a
|
|
997
|
+
// direction the incoming content slides in from that side (the swipe
|
|
998
|
+
// feel); without one it is a plain cross-fade. The rAF re-adds the class a
|
|
999
|
+
// frame after clearing it so a rapid re-switch restarts the animation.
|
|
1000
|
+
let switchAnimTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1001
|
+
function flashSwitch(dir?: "next" | "prev"): void {
|
|
542
1002
|
const surface = ctx.surface();
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
1003
|
+
const cls = dir ? `wt-switching-${dir}` : "wt-switching";
|
|
1004
|
+
surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
|
|
1005
|
+
if (switchAnimTimer !== null) {
|
|
1006
|
+
clearTimeout(switchAnimTimer);
|
|
1007
|
+
}
|
|
1008
|
+
requestAnimationFrame(() => {
|
|
1009
|
+
surface.classList.add(cls);
|
|
1010
|
+
});
|
|
1011
|
+
switchAnimTimer = setTimeout(() => {
|
|
1012
|
+
switchAnimTimer = null;
|
|
1013
|
+
surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
|
|
1014
|
+
}, 360);
|
|
547
1015
|
}
|
|
548
1016
|
|
|
549
1017
|
// The first screen frame after a switch is the resume delta landing.
|
|
@@ -552,22 +1020,89 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
552
1020
|
});
|
|
553
1021
|
|
|
554
1022
|
// switchRelative moves delta tabs from the active one (swipe left = next).
|
|
1023
|
+
// The direction feeds the slide animation (next slides in from the right,
|
|
1024
|
+
// prev from the left).
|
|
555
1025
|
function switchRelative(delta: number): void {
|
|
1026
|
+
const n = tabList.length;
|
|
1027
|
+
if (n < 2) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
556
1030
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
557
1031
|
if (idx < 0) {
|
|
558
1032
|
return;
|
|
559
1033
|
}
|
|
560
|
-
|
|
1034
|
+
// Wrap around the ends so the list rotates infinitely: swiping past the
|
|
1035
|
+
// last tab lands on the first, and past the first on the last.
|
|
1036
|
+
const next = tabList[(((idx + delta) % n) + n) % n];
|
|
561
1037
|
if (next) {
|
|
562
|
-
switchTo(next.id);
|
|
1038
|
+
switchTo(next.id, delta > 0 ? "next" : "prev");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// dragTargetBefore returns the first tab whose horizontal midpoint is past
|
|
1043
|
+
// x (the element the dragged tab should sit before), or null to drop at the
|
|
1044
|
+
// end (before the "+"). syncOrderFromDom rebuilds tabList to match the
|
|
1045
|
+
// strip's DOM order after a drag, so position indicators, the switcher, and
|
|
1046
|
+
// close-to-the-right/left all follow the visible order.
|
|
1047
|
+
function dragTargetBefore(x: number): HTMLElement | null {
|
|
1048
|
+
for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
|
|
1049
|
+
const rect = el.getBoundingClientRect();
|
|
1050
|
+
if (x < rect.left + rect.width / 2) {
|
|
1051
|
+
return el;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
function syncOrderFromDom(): void {
|
|
1057
|
+
const order: Tab[] = [];
|
|
1058
|
+
for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
1059
|
+
const t = tabList.find((x) => x.el === el);
|
|
1060
|
+
if (t) {
|
|
1061
|
+
order.push(t);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (order.length === tabList.length) {
|
|
1065
|
+
tabList.length = 0;
|
|
1066
|
+
tabList.push(...order);
|
|
1067
|
+
syncChrome();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// adoptSession adds a tab for a session that exists server-side but has no
|
|
1072
|
+
// local tab yet, e.g. one created in another browser (the server pushes it
|
|
1073
|
+
// over the status SSE, and the poll fallback lists it). This keeps every
|
|
1074
|
+
// client's tab set converged on the server, so mobile and desktop never
|
|
1075
|
+
// desync. The caller runs syncChrome.
|
|
1076
|
+
function adoptSession(info: SessionInfo): void {
|
|
1077
|
+
if (tabList.find((t) => t.id === info.id)) {
|
|
1078
|
+
return;
|
|
563
1079
|
}
|
|
1080
|
+
tabList.push(addTabChrome(info));
|
|
564
1081
|
}
|
|
565
1082
|
|
|
566
1083
|
async function create(): Promise<void> {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1084
|
+
let info: SessionInfo;
|
|
1085
|
+
try {
|
|
1086
|
+
info = await apiCreate();
|
|
1087
|
+
} catch {
|
|
1088
|
+
// A create can still fail transiently (network, server error); tell
|
|
1089
|
+
// the user rather than throwing.
|
|
1090
|
+
ctx.toast("Couldn't open a terminal");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
// The status SSE may have adopted this session during the POST round-trip
|
|
1094
|
+
// (server broadcasts the new session to all clients); reuse that tab.
|
|
1095
|
+
let tab = tabList.find((t) => t.id === info.id);
|
|
1096
|
+
if (!tab) {
|
|
1097
|
+
tab = addTabChrome(info);
|
|
1098
|
+
tabList.push(tab);
|
|
1099
|
+
}
|
|
1100
|
+
// Suppress the swipe reel for a create: the list should grow and fade the
|
|
1101
|
+
// new row in (animateRowIn) rather than rotate. switchTo still slides the
|
|
1102
|
+
// terminal content and updates the active chip.
|
|
1103
|
+
creatingTab = true;
|
|
570
1104
|
switchTo(tab.id);
|
|
1105
|
+
creatingTab = false;
|
|
571
1106
|
}
|
|
572
1107
|
|
|
573
1108
|
// dropTab removes a tab's chrome + cache and re-homes the active session.
|
|
@@ -579,14 +1114,47 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
579
1114
|
if (idx < 0) {
|
|
580
1115
|
return;
|
|
581
1116
|
}
|
|
1117
|
+
// Closing the only remaining tab: spawn its replacement BEFORE removing
|
|
1118
|
+
// this one, so the strip never empties and the "+" never teleports to the
|
|
1119
|
+
// far left (then jumps back) while the create POST is in flight. create()
|
|
1120
|
+
// adds the new tab before the "+" and switches to it; dropping the old
|
|
1121
|
+
// one is then an ordinary non-last close (length > 1, so this intercept
|
|
1122
|
+
// does not re-fire -> no unbounded recursion). remote-only (a user
|
|
1123
|
+
// close). If create() fails it adds nothing (and toasts), so keep the
|
|
1124
|
+
// existing tab rather than stranding the user on an empty strip.
|
|
1125
|
+
if (remote && tabList.length === 1 && tabList[0]?.id === id) {
|
|
1126
|
+
await create();
|
|
1127
|
+
// Drop the old tab only if a replacement actually landed (create()
|
|
1128
|
+
// adds nothing and toasts on failure); otherwise keep the existing tab
|
|
1129
|
+
// rather than stranding the user on an empty strip. The replacement is
|
|
1130
|
+
// a different session, so dropping the old one is a non-last close and
|
|
1131
|
+
// this intercept does not re-fire.
|
|
1132
|
+
if (tabList.some((t) => t.id !== id)) {
|
|
1133
|
+
await dropTab(id, true);
|
|
1134
|
+
}
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
582
1137
|
const [tab] = tabList.splice(idx, 1);
|
|
583
1138
|
if (!tab) {
|
|
584
1139
|
return;
|
|
585
1140
|
}
|
|
586
1141
|
tab.aria.remove();
|
|
1142
|
+
// Remove immediately (no exit animation): a lingering element made the
|
|
1143
|
+
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
1144
|
+
// the second slot before shifting left. The strip reflows in one frame.
|
|
587
1145
|
tab.el.remove();
|
|
588
1146
|
ctx.dropSession(id);
|
|
589
|
-
|
|
1147
|
+
// If this close empties the expanded list (only one tab remains),
|
|
1148
|
+
// collapse the switcher FIRST so the whole tray animates shut with the
|
|
1149
|
+
// last row still in it — otherwise it sits open-but-empty with the
|
|
1150
|
+
// separator shown and the keyboard button hidden. Collapsing flips
|
|
1151
|
+
// `expanded` off, so the syncChrome (and any re-home switchTo) below skip
|
|
1152
|
+
// the list reconcile and leave the row for the collapse to sweep away
|
|
1153
|
+
// (clearRows runs after the collapse transition).
|
|
1154
|
+
if (expanded && tabList.length < 2) {
|
|
1155
|
+
collapseSwitcher();
|
|
1156
|
+
}
|
|
1157
|
+
syncChrome(); // reflect the drop immediately (count, position)
|
|
590
1158
|
if (remote) {
|
|
591
1159
|
await apiClose(id);
|
|
592
1160
|
}
|
|
@@ -606,6 +1174,140 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
606
1174
|
await dropTab(id, true);
|
|
607
1175
|
}
|
|
608
1176
|
|
|
1177
|
+
// closeMany closes a set of tabs at once. Each tab is a running agent, so
|
|
1178
|
+
// it confirms when closing two or more. It removes their chrome + cache,
|
|
1179
|
+
// re-homes the active tab to a survivor if it was among them, DELETEs each
|
|
1180
|
+
// server session, and guarantees at least one terminal stays open (a fresh
|
|
1181
|
+
// one is spawned only when every tab was closed). closeAll / closeOthers /
|
|
1182
|
+
// closeToRight / closeToLeft are thin wrappers that pick the id set.
|
|
1183
|
+
async function closeMany(ids: readonly string[]): Promise<void> {
|
|
1184
|
+
const victims = tabList.filter((t) => ids.includes(t.id));
|
|
1185
|
+
if (victims.length === 0) {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (victims.length >= 2 && !window.confirm(`Close ${String(victims.length)} terminals?`)) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const closingActive = activeId !== null && ids.includes(activeId);
|
|
1192
|
+
for (const t of victims) {
|
|
1193
|
+
const idx = tabList.indexOf(t);
|
|
1194
|
+
if (idx >= 0) {
|
|
1195
|
+
tabList.splice(idx, 1);
|
|
1196
|
+
}
|
|
1197
|
+
t.aria.remove();
|
|
1198
|
+
t.el.remove();
|
|
1199
|
+
ctx.dropSession(t.id);
|
|
1200
|
+
}
|
|
1201
|
+
// Re-home the live view before the DELETEs: if the active tab was closed,
|
|
1202
|
+
// attach a survivor; otherwise just refresh the chrome.
|
|
1203
|
+
if (closingActive) {
|
|
1204
|
+
activeId = null;
|
|
1205
|
+
const survivor = tabList[0];
|
|
1206
|
+
if (survivor) {
|
|
1207
|
+
switchTo(survivor.id);
|
|
1208
|
+
} else {
|
|
1209
|
+
syncChrome();
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
syncChrome();
|
|
1213
|
+
}
|
|
1214
|
+
for (const t of victims) {
|
|
1215
|
+
await apiClose(t.id);
|
|
1216
|
+
}
|
|
1217
|
+
if (tabList.length === 0) {
|
|
1218
|
+
await create(); // there is always at least one terminal open
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function closeOthers(id: string): Promise<void> {
|
|
1223
|
+
return closeMany(tabList.filter((t) => t.id !== id).map((t) => t.id));
|
|
1224
|
+
}
|
|
1225
|
+
function closeToRight(id: string): Promise<void> {
|
|
1226
|
+
const idx = tabList.findIndex((t) => t.id === id);
|
|
1227
|
+
return idx < 0 ? Promise.resolve() : closeMany(tabList.slice(idx + 1).map((t) => t.id));
|
|
1228
|
+
}
|
|
1229
|
+
function closeToLeft(id: string): Promise<void> {
|
|
1230
|
+
const idx = tabList.findIndex((t) => t.id === id);
|
|
1231
|
+
return idx < 0 ? Promise.resolve() : closeMany(tabList.slice(0, idx).map((t) => t.id));
|
|
1232
|
+
}
|
|
1233
|
+
// closeAll closes every tab and leaves one fresh terminal (closeMany
|
|
1234
|
+
// confirms since it is destructive).
|
|
1235
|
+
function closeAll(): Promise<void> {
|
|
1236
|
+
return closeMany(tabList.map((t) => t.id));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// hideTabMenu / showTabMenu drive the right-click menu. showTabMenu rebuilds
|
|
1240
|
+
// the items for the target tab (disabled states reflect its position) and
|
|
1241
|
+
// clamps into the visible viewport, flipping above the pointer near the
|
|
1242
|
+
// bottom edge (mirrors context-menu.ts).
|
|
1243
|
+
function hideTabMenu(): void {
|
|
1244
|
+
tabMenu.classList.remove("visible");
|
|
1245
|
+
tabMenu.replaceChildren();
|
|
1246
|
+
}
|
|
1247
|
+
function tabMenuItem(label: string, disabled: boolean, run: () => void): void {
|
|
1248
|
+
const b = document.createElement("button");
|
|
1249
|
+
b.type = "button";
|
|
1250
|
+
b.setAttribute("role", "menuitem");
|
|
1251
|
+
b.textContent = label;
|
|
1252
|
+
if (disabled) {
|
|
1253
|
+
b.disabled = true;
|
|
1254
|
+
} else {
|
|
1255
|
+
b.addEventListener("click", () => {
|
|
1256
|
+
hideTabMenu();
|
|
1257
|
+
run();
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
tabMenu.appendChild(b);
|
|
1261
|
+
}
|
|
1262
|
+
function showTabMenu(x: number, y: number, id: string): void {
|
|
1263
|
+
hideTabMenu();
|
|
1264
|
+
const idx = tabList.findIndex((t) => t.id === id);
|
|
1265
|
+
if (idx < 0) {
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const n = tabList.length;
|
|
1269
|
+
tabMenuItem("Close", false, () => {
|
|
1270
|
+
void close_(id);
|
|
1271
|
+
});
|
|
1272
|
+
tabMenuItem("Close others", n <= 1, () => {
|
|
1273
|
+
void closeOthers(id);
|
|
1274
|
+
});
|
|
1275
|
+
tabMenuItem("Close to the right", idx >= n - 1, () => {
|
|
1276
|
+
void closeToRight(id);
|
|
1277
|
+
});
|
|
1278
|
+
tabMenuItem("Close to the left", idx <= 0, () => {
|
|
1279
|
+
void closeToLeft(id);
|
|
1280
|
+
});
|
|
1281
|
+
tabMenuItem("Close all", false, () => {
|
|
1282
|
+
void closeAll();
|
|
1283
|
+
});
|
|
1284
|
+
// Make visible (so it has measurable size), then clamp within the visible
|
|
1285
|
+
// viewport; position:fixed means x/y are viewport coordinates, and the
|
|
1286
|
+
// visual viewport (when present) excludes the on-screen keyboard.
|
|
1287
|
+
tabMenu.classList.add("visible");
|
|
1288
|
+
const vv = window.visualViewport;
|
|
1289
|
+
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
1290
|
+
const viewTop = vv ? vv.offsetTop : 0;
|
|
1291
|
+
const viewWidth = vv ? vv.width : window.innerWidth;
|
|
1292
|
+
const viewHeight = vv ? vv.height : window.innerHeight;
|
|
1293
|
+
const menuW = tabMenu.offsetWidth;
|
|
1294
|
+
const menuH = tabMenu.offsetHeight;
|
|
1295
|
+
const left = Math.max(
|
|
1296
|
+
viewLeft + TAB_MENU_EDGE,
|
|
1297
|
+
Math.min(x, viewLeft + viewWidth - menuW - TAB_MENU_EDGE),
|
|
1298
|
+
);
|
|
1299
|
+
let top = y;
|
|
1300
|
+
if (y + menuH + TAB_MENU_EDGE > viewTop + viewHeight) {
|
|
1301
|
+
top = y - menuH - TAB_MENU_GAP;
|
|
1302
|
+
}
|
|
1303
|
+
top = Math.max(
|
|
1304
|
+
viewTop + TAB_MENU_EDGE,
|
|
1305
|
+
Math.min(top, viewTop + viewHeight - menuH - TAB_MENU_EDGE),
|
|
1306
|
+
);
|
|
1307
|
+
tabMenu.style.left = `${String(left)}px`;
|
|
1308
|
+
tabMenu.style.top = `${String(top)}px`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
609
1311
|
// One-time "swipe to switch" hint on first multi-tab state, mobile only.
|
|
610
1312
|
function maybeSwipeHint(): void {
|
|
611
1313
|
if (hintShown || tabList.length < 2) {
|
|
@@ -629,7 +1331,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
629
1331
|
} catch {
|
|
630
1332
|
/* ignore */
|
|
631
1333
|
}
|
|
632
|
-
ctx.toast("Swipe
|
|
1334
|
+
ctx.toast("Swipe to switch terminals");
|
|
633
1335
|
}
|
|
634
1336
|
|
|
635
1337
|
// applyStatus updates one tab's dot + title from a status record (shared by
|
|
@@ -660,6 +1362,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
660
1362
|
void dropTab(s.id, false); // already gone server-side; no DELETE
|
|
661
1363
|
return;
|
|
662
1364
|
}
|
|
1365
|
+
// Adopt a session created in another browser so all clients converge.
|
|
1366
|
+
adoptSession({ id: s.id, title: s.title, createdAt: s.createdAt, status: s.status });
|
|
663
1367
|
applyStatus(s.id, s.status, s.title);
|
|
664
1368
|
syncChrome();
|
|
665
1369
|
});
|
|
@@ -674,6 +1378,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
674
1378
|
}
|
|
675
1379
|
const seen = new Set(list.map((s) => s.id));
|
|
676
1380
|
for (const info of list) {
|
|
1381
|
+
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
677
1382
|
applyStatus(info.id, info.status, info.title);
|
|
678
1383
|
}
|
|
679
1384
|
// A tab the server no longer lists was reaped/closed elsewhere: drop it
|
|
@@ -695,79 +1400,448 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
695
1400
|
newBtn.addEventListener("click", () => {
|
|
696
1401
|
void create();
|
|
697
1402
|
});
|
|
698
|
-
|
|
699
|
-
|
|
1403
|
+
// Live reorder while dragging a tab over the strip (dragend commits it).
|
|
1404
|
+
bar.addEventListener("dragover", (e) => {
|
|
1405
|
+
if (!draggingEl) {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
e.preventDefault();
|
|
1409
|
+
if (e.dataTransfer) {
|
|
1410
|
+
e.dataTransfer.dropEffect = "move";
|
|
1411
|
+
}
|
|
1412
|
+
bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
|
|
1413
|
+
});
|
|
1414
|
+
// Keyboard button: open/close the key grid above the bar. The toolbar API
|
|
1415
|
+
// is read lazily (ctx.use) so feature ordering does not matter; the button
|
|
1416
|
+
// is hidden unless a keyboardToggle was provided (set above).
|
|
1417
|
+
swKb.addEventListener("click", () => {
|
|
1418
|
+
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
1419
|
+
if (!kb) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
kb.toggle();
|
|
1423
|
+
const open = kb.isOpen();
|
|
1424
|
+
swKb.setAttribute("aria-expanded", String(open));
|
|
1425
|
+
swKb.classList.toggle("wt-active", open);
|
|
700
1426
|
});
|
|
701
|
-
|
|
702
|
-
|
|
1427
|
+
// Mobile "+": spawn a terminal (mirrors the desktop strip's newBtn).
|
|
1428
|
+
swNew.addEventListener("click", () => {
|
|
703
1429
|
void create();
|
|
704
1430
|
});
|
|
705
|
-
|
|
706
|
-
|
|
1431
|
+
// Active-row close (x): closes the current tab (mirrors a listed row's x).
|
|
1432
|
+
// stopPropagation so it is not read as a tap/swipe on the row surface.
|
|
1433
|
+
swClose.addEventListener("click", (e) => {
|
|
1434
|
+
e.stopPropagation();
|
|
1435
|
+
if (activeId !== null) {
|
|
1436
|
+
void close_(activeId);
|
|
1437
|
+
}
|
|
707
1438
|
});
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1439
|
+
// Dismiss the tab context menu on an outside click, on Escape, and on
|
|
1440
|
+
// scroll (it is anchored to a viewport point, so a scroll would detach it).
|
|
1441
|
+
const onDocClickMenu = (): void => {
|
|
1442
|
+
hideTabMenu();
|
|
1443
|
+
};
|
|
1444
|
+
document.addEventListener("click", onDocClickMenu);
|
|
1445
|
+
// A right-click anywhere other than a tab (the terminal content, elsewhere,
|
|
1446
|
+
// or a native browser menu) dismisses the tab menu. A right-click ON a tab
|
|
1447
|
+
// is handled by that tab's own contextmenu handler (which reopens it), and
|
|
1448
|
+
// fires before this one, so the menu is not immediately re-hidden.
|
|
1449
|
+
const onDocContextMenu = (e: MouseEvent): void => {
|
|
1450
|
+
if (!(e.target as HTMLElement).closest(".wt-tab")) {
|
|
1451
|
+
hideTabMenu();
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
document.addEventListener("contextmenu", onDocContextMenu);
|
|
1455
|
+
const onScrollMenu = (): void => {
|
|
1456
|
+
hideTabMenu();
|
|
1457
|
+
};
|
|
1458
|
+
window.addEventListener("scroll", onScrollMenu, true);
|
|
1459
|
+
const offMenuKey = ctx.registerKeydown((ev) => {
|
|
1460
|
+
if (ev.key === "Escape" && tabMenu.classList.contains("visible")) {
|
|
1461
|
+
ev.preventDefault();
|
|
1462
|
+
hideTabMenu();
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
return false;
|
|
1466
|
+
});
|
|
1467
|
+
// A tap on the terminal/background dismisses an open mobile overlay — the
|
|
1468
|
+
// expanded tab list or the key grid — rather than opening the keyboard, so
|
|
1469
|
+
// tap 1 closes and tap 2 opens the keyboard. Runs in the capture phase and
|
|
1470
|
+
// stops propagation, so the kernel's surface tap-to-focus never fires for
|
|
1471
|
+
// the dismissing tap. Taps inside the switcher or the key grid are left to
|
|
1472
|
+
// their own controls; when nothing is open the tap falls through untouched.
|
|
1473
|
+
const onDocTapDismiss = (e: PointerEvent): void => {
|
|
1474
|
+
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
1475
|
+
const gridOpen = kb?.isOpen() ?? false;
|
|
1476
|
+
if (!expanded && !gridOpen) {
|
|
712
1477
|
return;
|
|
713
1478
|
}
|
|
714
|
-
|
|
1479
|
+
const target = e.target as HTMLElement | null;
|
|
1480
|
+
const inChrome =
|
|
1481
|
+
target !== null &&
|
|
1482
|
+
(target.closest(".wt-switcher") !== null || target.closest(".key-toolbar") !== null);
|
|
1483
|
+
if (inChrome) {
|
|
715
1484
|
return;
|
|
716
1485
|
}
|
|
717
|
-
|
|
718
|
-
(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
1486
|
+
if (expanded) {
|
|
1487
|
+
collapseSwitcher();
|
|
1488
|
+
} else {
|
|
1489
|
+
closeKeyGrid();
|
|
1490
|
+
}
|
|
1491
|
+
e.stopPropagation();
|
|
1492
|
+
};
|
|
1493
|
+
document.addEventListener("pointerup", onDocTapDismiss, true);
|
|
1494
|
+
|
|
1495
|
+
// Interactive drag on the bar (mobile). The gesture follows the finger
|
|
1496
|
+
// live rather than only acting on release: after a small axis-lock move it
|
|
1497
|
+
// commits to horizontal (slide the terminal content to preview a tab
|
|
1498
|
+
// switch) or vertical (grow/shrink the tab list under the bar). On release
|
|
1499
|
+
// it snaps: a horizontal drag past a quarter-width commits the switch (else
|
|
1500
|
+
// springs back); a vertical drag past the halfway point snaps the list open
|
|
1501
|
+
// (else closed). A near-stationary release is a tap that toggles the list
|
|
1502
|
+
// (the click listener). swCurrent has touch-action:none so these drags
|
|
1503
|
+
// never scroll the page, and the pointer is captured so a drag that leaves
|
|
1504
|
+
// the bar still delivers its move/up here.
|
|
1505
|
+
let gDownX = 0;
|
|
1506
|
+
let gDownY = 0;
|
|
1507
|
+
let gAxis: "h" | "v" | null = null;
|
|
1508
|
+
let gActive = false;
|
|
1509
|
+
let gStartMax = 0; // list max-height (px) when a vertical drag began
|
|
1510
|
+
let gTargetMax = 0; // fully-open list height (px) for the current drag
|
|
1511
|
+
let swiped = false; // a drag was handled; the trailing click must not also toggle
|
|
1512
|
+
let gPointerId = -1; // the pointer that owns the drag; stray fingers are ignored
|
|
1513
|
+
let gDownT = 0; // pointerdown timestamp, for the flick duration test
|
|
1514
|
+
let gVX = 0; // last-sample velocity (px/ms) horizontal
|
|
1515
|
+
let gVY = 0; // last-sample velocity (px/ms) vertical
|
|
1516
|
+
let gLastX = 0;
|
|
1517
|
+
let gLastY = 0;
|
|
1518
|
+
let gLastT = 0; // time of the last pointermove sample (for the stale-velocity guard)
|
|
1519
|
+
// Owns the window-level move/end listeners for the current drag. They catch
|
|
1520
|
+
// the gesture wherever the finger goes and however it ends, so the state
|
|
1521
|
+
// machine can never be stranded (gActive stuck true) by a pointerup that
|
|
1522
|
+
// missed swCurrent — the case that bricked swiping until a reload.
|
|
1523
|
+
let gestureAbort: AbortController | null = null;
|
|
1524
|
+
|
|
1525
|
+
// The row content follows the finger during a horizontal drag (the active
|
|
1526
|
+
// tab area physically swiping). The terminal content is NOT dragged: the
|
|
1527
|
+
// old finger-following content translate read as a few-pixel snap, so the
|
|
1528
|
+
// commit instead lets switchTo slide the incoming terminal in from the side.
|
|
1529
|
+
function beginHorizontal(): void {
|
|
1530
|
+
for (const el of swipeEls) {
|
|
1531
|
+
el.style.transition = "none"; // track the finger 1:1
|
|
1532
|
+
}
|
|
1533
|
+
// Preview the list rotation live during the drag when the list is open,
|
|
1534
|
+
// so it nudges with the finger instead of only moving on release (see
|
|
1535
|
+
// moveHorizontal). The rows translate as a group (clipped by the list,
|
|
1536
|
+
// whose box/border stay put); the release reel continues from there.
|
|
1537
|
+
dragActive = expanded && tabList.length >= 2 && !prefersReduce();
|
|
1538
|
+
if (!dragActive) {
|
|
723
1539
|
return;
|
|
724
1540
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1541
|
+
endReelNow(); // cancel any settling reel/spring before previewing
|
|
1542
|
+
swList.style.overflow = "hidden";
|
|
1543
|
+
swList.style.position = "relative";
|
|
1544
|
+
}
|
|
1545
|
+
function moveHorizontal(dx: number): void {
|
|
1546
|
+
// The active-tab chip (dot + label + close) follows the finger 1:1. No
|
|
1547
|
+
// rubber-band: the list is circular, so there is no end to resist.
|
|
1548
|
+
const tx = `translateX(${String(Math.round(dx))}px)`;
|
|
1549
|
+
for (const el of swipeEls) {
|
|
1550
|
+
el.style.transform = tx;
|
|
731
1551
|
}
|
|
732
|
-
|
|
1552
|
+
if (!dragActive) {
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Peek the list a few pixels in the swipe direction — a hint of the
|
|
1556
|
+
// coming rotation, not the full shift (the incoming row only appears on
|
|
1557
|
+
// release, so a large move read wrong). Drag left (dx < 0, next) nudges
|
|
1558
|
+
// rows up; drag right (prev) nudges them down. The release reel continues
|
|
1559
|
+
// from this offset.
|
|
1560
|
+
const p = Math.max(-PREVIEW_PEEK_MAX, Math.min(PREVIEW_PEEK_MAX, dx * PREVIEW_DRAG_RATIO));
|
|
1561
|
+
for (const el of rowEls.values()) {
|
|
1562
|
+
el.style.transition = "none";
|
|
1563
|
+
el.style.transform = `translateY(${String(Math.round(p))}px)`;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// springRowsBack eases the previewed rows back to rest when a drag is
|
|
1567
|
+
// released without committing, then hands overflow/position back to CSS.
|
|
1568
|
+
function springRowsBack(): void {
|
|
1569
|
+
const rows = [...rowEls.values()];
|
|
1570
|
+
requestAnimationFrame(() => {
|
|
1571
|
+
for (const el of rows) {
|
|
1572
|
+
el.style.transition = "transform 0.2s ease-out";
|
|
1573
|
+
el.style.transform = "translateY(0)";
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
if (reelTimer !== null) {
|
|
1577
|
+
clearTimeout(reelTimer);
|
|
1578
|
+
}
|
|
1579
|
+
reelTimer = setTimeout(endReelNow, 220);
|
|
1580
|
+
}
|
|
1581
|
+
function endHorizontal(dx: number, releaseT: number, canceled: boolean): void {
|
|
1582
|
+
const width = ctx.surface().clientWidth || window.innerWidth;
|
|
1583
|
+
const dir = dx < 0 ? 1 : -1;
|
|
1584
|
+
// Commit on a flick (quick + fast + far enough) or once dragged past a
|
|
1585
|
+
// quarter width; a cancel/capture-loss never commits (springs back). The
|
|
1586
|
+
// list is circular, so any switch has a target: ≥2 tabs is the only gate.
|
|
1587
|
+
const paused = releaseT - gLastT > VELOCITY_STALE_MS;
|
|
1588
|
+
const vx = paused ? 0 : Math.abs(gVX);
|
|
1589
|
+
const flick =
|
|
1590
|
+
releaseT - gDownT < SWIPE_DURATION && vx > SWIPE_VELOCITY && Math.abs(dx) > SWIPE_MIN_PX;
|
|
1591
|
+
const commit = !canceled && (flick || Math.abs(dx) >= width * 0.25) && tabList.length >= 2;
|
|
1592
|
+
const wasDrag = dragActive;
|
|
1593
|
+
dragActive = false;
|
|
1594
|
+
if (!commit) {
|
|
1595
|
+
if (wasDrag) {
|
|
1596
|
+
springRowsBack(); // ease the previewed rows back to rest
|
|
1597
|
+
}
|
|
1598
|
+
const spring = prefersReduce() ? "" : "transform 0.2s ease-out";
|
|
1599
|
+
for (const el of swipeEls) {
|
|
1600
|
+
el.style.transition = spring;
|
|
1601
|
+
el.style.transform = "";
|
|
1602
|
+
}
|
|
1603
|
+
window.setTimeout(() => {
|
|
1604
|
+
for (const el of swipeEls) {
|
|
1605
|
+
el.style.transition = "";
|
|
1606
|
+
}
|
|
1607
|
+
}, 220);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
// Commit: switchTo slides the incoming terminal in from the side and (when
|
|
1611
|
+
// the list is open) runs the reel, which continues from wherever the drag
|
|
1612
|
+
// preview left the rows. switchRelative wraps around the ends.
|
|
1613
|
+
switchRelative(dir);
|
|
1614
|
+
if (prefersReduce()) {
|
|
1615
|
+
for (const el of swipeEls) {
|
|
1616
|
+
el.style.transition = "";
|
|
1617
|
+
el.style.transform = "";
|
|
1618
|
+
}
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
// Slide the whole active-tab chip in from the swipe side (next from the
|
|
1622
|
+
// right, prev from the left), every part by the SAME pixel distance so
|
|
1623
|
+
// the close stays locked to the dot + label (a per-element % would move
|
|
1624
|
+
// the narrow close less than the wide label).
|
|
1625
|
+
const slide = (swInner.getBoundingClientRect().width || width) * dir;
|
|
1626
|
+
for (const el of swipeEls) {
|
|
1627
|
+
el.style.transition = "none";
|
|
1628
|
+
el.style.transform = `translateX(${String(Math.round(slide))}px)`;
|
|
1629
|
+
}
|
|
1630
|
+
requestAnimationFrame(() => {
|
|
1631
|
+
for (const el of swipeEls) {
|
|
1632
|
+
el.style.transition = "transform 0.25s cubic-bezier(0.2, 0, 0, 1)";
|
|
1633
|
+
el.style.transform = "translateX(0)";
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
window.setTimeout(() => {
|
|
1637
|
+
for (const el of swipeEls) {
|
|
1638
|
+
el.style.transition = "";
|
|
1639
|
+
el.style.transform = "";
|
|
1640
|
+
}
|
|
1641
|
+
}, 320);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function beginVertical(): void {
|
|
1645
|
+
if (tabList.length < 2 && !expanded) {
|
|
1646
|
+
gTargetMax = 0; // nothing to reveal; the vertical drag is inert
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
if (!expanded) {
|
|
1650
|
+
renderSwitcherList();
|
|
1651
|
+
}
|
|
1652
|
+
// Apply the expanded styling (padding/border) so the reveal matches the
|
|
1653
|
+
// settled look, but drive the height with inline max-height (transition
|
|
1654
|
+
// off, clipping instead of auto-scroll) so it tracks the finger 1:1.
|
|
1655
|
+
switcher.classList.add("wt-switcher-expanded");
|
|
1656
|
+
swList.style.transition = "none";
|
|
1657
|
+
swList.style.overflowY = "hidden";
|
|
1658
|
+
gTargetMax = Math.min(swList.scrollHeight, Math.round(window.innerHeight * 0.5));
|
|
1659
|
+
gStartMax = expanded ? gTargetMax : 0;
|
|
1660
|
+
swList.style.maxHeight = `${String(gStartMax)}px`;
|
|
1661
|
+
}
|
|
1662
|
+
function moveVertical(dy: number): void {
|
|
1663
|
+
if (gTargetMax <= 0) {
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
// Drag up (dy < 0) grows the list; drag down shrinks it.
|
|
1667
|
+
const next = Math.max(0, Math.min(gTargetMax, gStartMax - dy));
|
|
1668
|
+
swList.style.maxHeight = `${String(next)}px`;
|
|
1669
|
+
}
|
|
1670
|
+
function endVertical(dy: number, releaseT: number, canceled: boolean): void {
|
|
1671
|
+
if (gTargetMax <= 0) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
const current = Math.max(0, Math.min(gTargetMax, gStartMax - dy));
|
|
1675
|
+
// A quick flick opens (drag up) or closes (drag down) regardless of how
|
|
1676
|
+
// far it got; otherwise snap to whichever state is nearer. A cancel snaps
|
|
1677
|
+
// to the nearer state (no flick).
|
|
1678
|
+
const paused = releaseT - gLastT > VELOCITY_STALE_MS;
|
|
1679
|
+
const vy = paused ? 0 : Math.abs(gVY);
|
|
1680
|
+
const flick =
|
|
1681
|
+
!canceled &&
|
|
1682
|
+
releaseT - gDownT < SWIPE_DURATION &&
|
|
1683
|
+
vy > SWIPE_VELOCITY &&
|
|
1684
|
+
Math.abs(dy) > SWIPE_MIN_PX;
|
|
1685
|
+
const open = flick ? dy < 0 : current >= gTargetMax / 2;
|
|
1686
|
+
// Hand height back to the class (transition restored) so it snaps to the
|
|
1687
|
+
// settled state from wherever the finger left it.
|
|
1688
|
+
swList.style.transition = "";
|
|
1689
|
+
swList.style.overflowY = "";
|
|
1690
|
+
swList.style.maxHeight = "";
|
|
1691
|
+
if (open) {
|
|
1692
|
+
setExpandedState(true);
|
|
1693
|
+
ctx.announce("Terminal list expanded");
|
|
1694
|
+
} else {
|
|
1695
|
+
setExpandedState(false);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const endGesture = (e: PointerEvent, canceled: boolean): void => {
|
|
1700
|
+
if (!gActive || e.pointerId !== gPointerId) {
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
gActive = false;
|
|
1704
|
+
const dx = e.clientX - gDownX;
|
|
1705
|
+
const dy = e.clientY - gDownY;
|
|
1706
|
+
if (gAxis === "h") {
|
|
1707
|
+
endHorizontal(dx, e.timeStamp, canceled);
|
|
1708
|
+
} else if (gAxis === "v") {
|
|
1709
|
+
endVertical(dy, e.timeStamp, canceled);
|
|
1710
|
+
} else if (
|
|
1711
|
+
!canceled &&
|
|
1712
|
+
Math.abs(dx) >= SWIPE_MIN_PX &&
|
|
1713
|
+
Math.abs(dx) > Math.abs(dy) * 1.5 &&
|
|
1714
|
+
modes.getMouseMode() === 0
|
|
1715
|
+
) {
|
|
1716
|
+
// No pointermove locked an axis (a flick with no intermediate move, or
|
|
1717
|
+
// a synthetic down/up): fall back to a discrete switch from the net
|
|
1718
|
+
// delta so the gesture still resolves.
|
|
1719
|
+
swiped = true;
|
|
1720
|
+
switchRelative(dx < 0 ? 1 : -1);
|
|
1721
|
+
} else if (!canceled && Math.abs(dy) >= SWIPE_MIN_PX && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
|
1722
|
+
swiped = true;
|
|
1723
|
+
if (dy < 0) {
|
|
1724
|
+
expandSwitcher();
|
|
1725
|
+
} else {
|
|
1726
|
+
collapseSwitcher();
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
gAxis = null;
|
|
1730
|
+
// The gesture is over: drop the window move/end listeners for it.
|
|
1731
|
+
gestureAbort?.abort();
|
|
1732
|
+
gestureAbort = null;
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
// The move handler runs on WINDOW (attached for the gesture's life on
|
|
1736
|
+
// pointerdown), so the drag is tracked wherever the finger goes — even if
|
|
1737
|
+
// setPointerCapture failed or the finger left the thin bar. With capture,
|
|
1738
|
+
// the events also bubble here; without it, this is the only path.
|
|
1739
|
+
function onGestureMove(e: PointerEvent): void {
|
|
1740
|
+
if (!gActive || e.pointerId !== gPointerId) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
// Track last-sample velocity for the flick test on release; guard
|
|
1744
|
+
// dt === 0 (high-refresh / coalesced events) against divide-by-zero.
|
|
1745
|
+
const dt = e.timeStamp - gLastT;
|
|
1746
|
+
if (dt > 0) {
|
|
1747
|
+
gVX = (e.clientX - gLastX) / dt;
|
|
1748
|
+
gVY = (e.clientY - gLastY) / dt;
|
|
1749
|
+
gLastX = e.clientX;
|
|
1750
|
+
gLastY = e.clientY;
|
|
1751
|
+
gLastT = e.timeStamp;
|
|
1752
|
+
}
|
|
1753
|
+
const dx = e.clientX - gDownX;
|
|
1754
|
+
const dy = e.clientY - gDownY;
|
|
1755
|
+
if (gAxis === null) {
|
|
1756
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) < AXIS_LOCK_PX) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
1760
|
+
// Horizontal switch preview, unless a mouse-mode app is capturing
|
|
1761
|
+
// drags (leave the bar swipe inert then, matching the old gate).
|
|
1762
|
+
if (modes.getMouseMode() !== 0) {
|
|
1763
|
+
gActive = false;
|
|
1764
|
+
swiped = true;
|
|
1765
|
+
gestureAbort?.abort(); // gesture abandoned; drop the window listeners
|
|
1766
|
+
gestureAbort = null;
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
gAxis = "h";
|
|
1770
|
+
swiped = true;
|
|
1771
|
+
beginHorizontal();
|
|
1772
|
+
} else {
|
|
1773
|
+
gAxis = "v";
|
|
1774
|
+
swiped = true;
|
|
1775
|
+
beginVertical();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (gAxis === "h") {
|
|
1779
|
+
moveHorizontal(dx);
|
|
1780
|
+
} else {
|
|
1781
|
+
moveVertical(dy);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const endOnUp = (e: PointerEvent): void => {
|
|
1785
|
+
endGesture(e, false);
|
|
1786
|
+
};
|
|
1787
|
+
const endOnCancel = (e: PointerEvent): void => {
|
|
1788
|
+
endGesture(e, true);
|
|
1789
|
+
};
|
|
733
1790
|
|
|
734
|
-
// Swipe/tap on the switcher bar: a horizontal swipe switches (gated off
|
|
735
|
-
// under mouse tracking, since mouse-mode apps capture drags); a tap opens
|
|
736
|
-
// the overview. click also covers keyboard/mouse activation.
|
|
737
|
-
let downX = 0;
|
|
738
|
-
let downY = 0;
|
|
739
|
-
let swiped = false;
|
|
740
1791
|
swCurrent.addEventListener(
|
|
741
1792
|
"pointerdown",
|
|
742
1793
|
(e) => {
|
|
743
|
-
|
|
744
|
-
|
|
1794
|
+
if (gActive) {
|
|
1795
|
+
return; // a drag already owns a pointer; ignore a second finger
|
|
1796
|
+
}
|
|
1797
|
+
gDownX = e.clientX;
|
|
1798
|
+
gDownY = e.clientY;
|
|
1799
|
+
gDownT = e.timeStamp;
|
|
1800
|
+
gLastX = e.clientX;
|
|
1801
|
+
gLastY = e.clientY;
|
|
1802
|
+
gLastT = e.timeStamp;
|
|
1803
|
+
gVX = 0;
|
|
1804
|
+
gVY = 0;
|
|
1805
|
+
gPointerId = e.pointerId;
|
|
1806
|
+
gAxis = null;
|
|
1807
|
+
gActive = true;
|
|
745
1808
|
swiped = false;
|
|
1809
|
+
try {
|
|
1810
|
+
swCurrent.setPointerCapture(e.pointerId);
|
|
1811
|
+
} catch {
|
|
1812
|
+
/* capture unavailable; the window listeners below track it anyway */
|
|
1813
|
+
}
|
|
1814
|
+
// Own the move/end on WINDOW for this gesture, so it always resolves —
|
|
1815
|
+
// wherever the finger goes and however it lifts. Relying only on
|
|
1816
|
+
// swCurrent (via pointer capture) stranded gActive=true forever when
|
|
1817
|
+
// capture failed and the release landed on another element, bricking
|
|
1818
|
+
// all future swipes until reload. Torn down in endGesture.
|
|
1819
|
+
gestureAbort?.abort();
|
|
1820
|
+
gestureAbort = new AbortController();
|
|
1821
|
+
const opts = { passive: true, signal: gestureAbort.signal };
|
|
1822
|
+
window.addEventListener("pointermove", onGestureMove, opts);
|
|
1823
|
+
window.addEventListener("pointerup", endOnUp, opts);
|
|
1824
|
+
window.addEventListener("pointercancel", endOnCancel, opts);
|
|
746
1825
|
},
|
|
747
1826
|
{ passive: true },
|
|
748
1827
|
);
|
|
1828
|
+
// If the browser revokes pointer capture mid-drag (device change, system
|
|
1829
|
+
// interruption), end the gesture cleanly as an abort rather than leaving it
|
|
1830
|
+
// stuck — the safety net a hand-rolled drag usually misses. After a normal
|
|
1831
|
+
// pointerup this fires too, but gActive is already false so it no-ops.
|
|
749
1832
|
swCurrent.addEventListener(
|
|
750
|
-
"
|
|
1833
|
+
"lostpointercapture",
|
|
751
1834
|
(e) => {
|
|
752
|
-
|
|
753
|
-
const dy = e.clientY - downY;
|
|
754
|
-
if (
|
|
755
|
-
Math.abs(dx) >= SWIPE_MIN_PX &&
|
|
756
|
-
Math.abs(dx) > Math.abs(dy) * 1.5 &&
|
|
757
|
-
modes.getMouseMode() === 0
|
|
758
|
-
) {
|
|
759
|
-
swiped = true;
|
|
760
|
-
switchRelative(dx < 0 ? 1 : -1);
|
|
761
|
-
}
|
|
1835
|
+
endGesture(e, true);
|
|
762
1836
|
},
|
|
763
1837
|
{ passive: true },
|
|
764
1838
|
);
|
|
765
1839
|
swCurrent.addEventListener("click", () => {
|
|
766
1840
|
if (swiped) {
|
|
767
|
-
swiped = false; // consumed by
|
|
1841
|
+
swiped = false; // consumed by a drag; do not also toggle
|
|
768
1842
|
return;
|
|
769
1843
|
}
|
|
770
|
-
|
|
1844
|
+
toggleSwitcher();
|
|
771
1845
|
});
|
|
772
1846
|
|
|
773
1847
|
// Initial population: list existing sessions, or create the first one.
|
|
@@ -780,9 +1854,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
780
1854
|
if (sessions.length === 0) {
|
|
781
1855
|
sessions = [await apiCreate()];
|
|
782
1856
|
}
|
|
1857
|
+
// Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
|
|
1858
|
+
// of the existing sessions on open, and tabs subscribes (monitor.onStatus)
|
|
1859
|
+
// BEFORE this list resolves, so a session may already have a tab by the
|
|
1860
|
+
// time the list lands. A straight push doubled every already-adopted
|
|
1861
|
+
// session (6 tabs from 3 across a fresh load), and paintActive then lit
|
|
1862
|
+
// both copies of the active id ("2 active tabs" that move together).
|
|
783
1863
|
for (const info of sessions) {
|
|
784
|
-
|
|
1864
|
+
adoptSession(info);
|
|
785
1865
|
}
|
|
1866
|
+
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
1867
|
+
started = true;
|
|
786
1868
|
// Activate the first (oldest) session.
|
|
787
1869
|
const first = tabList[0];
|
|
788
1870
|
if (first) {
|
|
@@ -804,24 +1886,41 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
804
1886
|
teardown() {
|
|
805
1887
|
offStatus?.();
|
|
806
1888
|
offInput();
|
|
1889
|
+
offArmed?.();
|
|
1890
|
+
offMenuKey();
|
|
1891
|
+
document.removeEventListener("click", onDocClickMenu);
|
|
1892
|
+
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
1893
|
+
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
1894
|
+
window.removeEventListener("scroll", onScrollMenu, true);
|
|
807
1895
|
if (pollTimer !== null) {
|
|
808
1896
|
clearInterval(pollTimer);
|
|
809
1897
|
}
|
|
810
1898
|
barResize.disconnect();
|
|
811
|
-
|
|
812
|
-
surface.
|
|
1899
|
+
swReserve.disconnect();
|
|
1900
|
+
surface.classList.remove("wt-with-tabbar");
|
|
1901
|
+
document.documentElement.style.removeProperty("--wt-tabbar-h");
|
|
1902
|
+
root?.classList.remove("wt-tabbed");
|
|
1903
|
+
document.documentElement.style.removeProperty("--wt-reserve-bottom");
|
|
1904
|
+
if (switchAnimTimer !== null) {
|
|
1905
|
+
clearTimeout(switchAnimTimer);
|
|
1906
|
+
}
|
|
1907
|
+
if (collapseClearTimer !== null) {
|
|
1908
|
+
clearTimeout(collapseClearTimer);
|
|
1909
|
+
}
|
|
1910
|
+
endReelNow();
|
|
1911
|
+
gestureAbort?.abort(); // drop any in-flight gesture's window listeners
|
|
813
1912
|
clearCatchup();
|
|
814
|
-
|
|
1913
|
+
hideTabMenu();
|
|
815
1914
|
for (const t of tabList) {
|
|
816
1915
|
t.aria.remove();
|
|
817
1916
|
t.el.remove();
|
|
818
1917
|
}
|
|
819
1918
|
tabList.length = 0;
|
|
1919
|
+
rowEls.clear();
|
|
820
1920
|
bar.remove();
|
|
821
1921
|
newBtn.remove();
|
|
1922
|
+
tabMenu.remove();
|
|
822
1923
|
switcher.remove();
|
|
823
|
-
sheet.remove();
|
|
824
|
-
scrim.remove();
|
|
825
1924
|
catchup.remove();
|
|
826
1925
|
},
|
|
827
1926
|
};
|