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