@cplieger/web-terminal-ui 7.3.2 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -18
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -1,28 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import { modes } from "@cplieger/web-terminal-engine";
|
|
16
|
-
import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
|
|
1
|
+
// Showing a tab re-points a pane's renderer at the tab's cached store before the
|
|
2
|
+
// reconnect, so the last-known screen paints instantly and the delta arrives
|
|
3
|
+
// after.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PaneHandle,
|
|
7
|
+
PaneLayoutOwnerRegistration,
|
|
8
|
+
PaneSide,
|
|
9
|
+
TerminalContext,
|
|
10
|
+
TerminalFeature,
|
|
11
|
+
Unsubscribe,
|
|
12
|
+
} from "../../kernel/types.js";
|
|
13
|
+
import { isPaneSide, MIN_SPLIT_AREA_PX } from "../../kernel/layout-policy.js";
|
|
14
|
+
import { windowOf } from "../../kernel/realm.js";
|
|
17
15
|
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
18
16
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
19
17
|
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
20
18
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
21
19
|
import { centreChipLabels } from "./ink-centre.js";
|
|
22
20
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
23
|
-
import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
21
|
+
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
24
22
|
import {
|
|
25
|
-
ACTIVE_TAB_KEY,
|
|
26
23
|
CUE_SEEN_KEY,
|
|
27
24
|
MAX_PERSISTED_CUE_SEEN,
|
|
28
25
|
MAX_PINNED_NAME,
|
|
@@ -33,6 +30,7 @@ import {
|
|
|
33
30
|
compareTabOrder,
|
|
34
31
|
createSessionAPI,
|
|
35
32
|
createTombstones,
|
|
33
|
+
cueIconName,
|
|
36
34
|
foldedCueStatus,
|
|
37
35
|
hasPinnedName,
|
|
38
36
|
isCueStatus,
|
|
@@ -47,10 +45,9 @@ import {
|
|
|
47
45
|
sanitizePinnedName,
|
|
48
46
|
serializeCueSeen,
|
|
49
47
|
statusPhrase,
|
|
48
|
+
summarizeCues,
|
|
50
49
|
tabAccessibleName,
|
|
51
50
|
} from "./model.js";
|
|
52
|
-
import { NO_ATTENTION, browserAttentionEnv, createAttention, summarize } from "./attention.js";
|
|
53
|
-
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
54
51
|
import {
|
|
55
52
|
REORDER_MOVE_EPS_PX,
|
|
56
53
|
REORDER_REST_MS,
|
|
@@ -65,6 +62,7 @@ import {
|
|
|
65
62
|
paintProgress,
|
|
66
63
|
paintStatusDot,
|
|
67
64
|
pick,
|
|
65
|
+
splitButtonHTML,
|
|
68
66
|
switchButtonHTML,
|
|
69
67
|
} from "./strip.js";
|
|
70
68
|
import {
|
|
@@ -82,177 +80,94 @@ import {
|
|
|
82
80
|
} from "./switcher.js";
|
|
83
81
|
|
|
84
82
|
const DEFAULT_API_BASE = "/api/sessions";
|
|
85
|
-
// The mobile
|
|
86
|
-
//
|
|
87
|
-
// orientation: a landscape phone is wide but short, and the kernel's narrow
|
|
88
|
-
// fact covers both). A big touchscreen (an iPad) and every fine-pointer device
|
|
89
|
-
// (a desktop, or an iPad with a trackpad / Magic Keyboard) get the multi-tab
|
|
90
|
-
// top strip instead — the switcher's single-giant-tab layout wastes a big
|
|
91
|
-
// screen (an iPad was getting the phone UI). The narrow half of that fact is
|
|
92
|
-
// the kernel's .wt-narrow root class / ctx.layout().narrow (kernel-owned
|
|
93
|
-
// breakpoint constants, root-size driven); CSS pairs it with
|
|
94
|
-
// (pointer: coarse) where touch matters.
|
|
95
|
-
// Default cadence for the no-activityMonitor polling fallback.
|
|
83
|
+
// The mobile switcher is used only on a narrow coarse-pointer device, in EITHER
|
|
84
|
+
// orientation; a big touchscreen and every fine-pointer device get the strip.
|
|
96
85
|
const DEFAULT_POLL_MS = 4000;
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// a relative path — dropping a tab on iPadOS loaded /<session-id> instead of
|
|
101
|
-
// reordering. A custom type also keeps the id out of the system pasteboard when
|
|
102
|
-
// the drag leaves the browser, which is what MDN's recommended-drag-types
|
|
103
|
-
// guidance prescribes for data specific to one application.
|
|
86
|
+
// A PRIVATE drag type, not text/plain: WebKit resolves dropped plain text as a
|
|
87
|
+
// URL and NAVIGATES to it when no handler cancels the drop, so a bare session id
|
|
88
|
+
// loaded /<session-id> on iPadOS. It also keeps the id out of the pasteboard.
|
|
104
89
|
const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
105
|
-
// Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
|
|
106
|
-
// the shared point-anchored positioner (menu-position.ts), shared with the
|
|
107
|
-
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
108
90
|
|
|
109
|
-
/** The value a peer
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* tells you the attempt finished and not that it succeeded (read `list` for that).
|
|
115
|
-
* Both are safe to call more than once per gesture — `create` shares an in-flight
|
|
116
|
-
* create, so the duplicate activation an iPad trackpad delivers opens one terminal
|
|
117
|
-
* rather than two. `switchTo` is local and synchronous, and ignores an unknown id.
|
|
118
|
-
*
|
|
119
|
-
* `list` is a snapshot, not a live view — it does not update, so re-read it rather
|
|
120
|
-
* than holding onto the array, and treat it as display data: the tab a user is
|
|
121
|
-
* looking at is this feature's own state and mutating it goes through the members
|
|
122
|
-
* above. */
|
|
91
|
+
/** The value a peer or a host reads through `ctx.use(tabs(...))`. `create` and
|
|
92
|
+
* `close` are server round trips and NEITHER rejects: a refusal is toasted and
|
|
93
|
+
* the promise resolves anyway, so read `list` for the outcome. `create` shares
|
|
94
|
+
* an in-flight create, so a duplicate activation opens one terminal. `list` is a
|
|
95
|
+
* snapshot of display data, not a live view. */
|
|
123
96
|
export interface TabsApi {
|
|
124
|
-
/** Spawn a fresh session and
|
|
125
|
-
* flight share that create, so one gesture opens
|
|
97
|
+
/** Spawn a fresh session and show it where a tab click would land it. Calls
|
|
98
|
+
* made while a create is in flight share that create, so one gesture opens
|
|
99
|
+
* exactly one terminal. */
|
|
126
100
|
create(): Promise<void>;
|
|
127
101
|
/** Close a session (kills its process) and drop its tab + cache. */
|
|
128
102
|
close(id: string): Promise<void>;
|
|
129
|
-
/**
|
|
103
|
+
/** Show a tab: in the first empty pane, else in the selected pane; a tab a
|
|
104
|
+
* pane already shows has that pane selected instead. */
|
|
130
105
|
switchTo(id: string): void;
|
|
131
|
-
/**
|
|
106
|
+
/** Show a tab on one side of the split, opening it when closed; whatever that
|
|
107
|
+
* side showed becomes an ordinary tab, the tab's old side is emptied, and the
|
|
108
|
+
* tab's pane is selected. False when refused: an unknown id, an invalid side,
|
|
109
|
+
* or a split the pane row is too narrow to open. */
|
|
110
|
+
snap(id: string, side: PaneSide): boolean;
|
|
111
|
+
/** The current tabs, first-to-last by creation; `active` marks a tab a pane
|
|
112
|
+
* shows. */
|
|
132
113
|
list(): readonly { id: string; title: string; active: boolean }[];
|
|
133
114
|
}
|
|
134
115
|
|
|
135
|
-
/** Options for the tabs feature.
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* feature list, since `ctx.use` only resolves a peer the kernel has already set
|
|
140
|
-
* up. Both are optional and their absence degrades rather than fails: no monitor
|
|
141
|
-
* means polling instead of the status SSE, no toolbar means no keyboard button in
|
|
142
|
-
* the mobile bar. `buildTabbed` in presets/tabbed.ts is the worked example. */
|
|
116
|
+
/** Options for the tabs feature. The two feature-valued members must be the SAME
|
|
117
|
+
* values the composition includes, ordered before tabs (`ctx.use` only resolves
|
|
118
|
+
* a peer already set up) and marked `scope: "shell"`, since a pane feature's api
|
|
119
|
+
* is not visible at the shell. Both degrade rather than fail when absent. */
|
|
143
120
|
export interface TabsOptions {
|
|
144
121
|
/** REST base for the session API (default "/api/sessions"). */
|
|
145
122
|
apiBase?: string;
|
|
146
|
-
/** The activityMonitor feature value,
|
|
147
|
-
*
|
|
148
|
-
* falls back to polling the session list (see pollMs). */
|
|
123
|
+
/** The activityMonitor feature value, for live status dots and dropping
|
|
124
|
+
* exited tabs. Without it, tabs polls the session list (see pollMs). */
|
|
149
125
|
activityMonitor?: TerminalFeature<ActivityMonitorApi>;
|
|
150
|
-
/** Poll interval in ms for the no-activityMonitor fallback
|
|
151
|
-
* SSE, tabs re-lists GET /api/sessions on this cadence to refresh dots and
|
|
152
|
-
* titles and drop reaped tabs (section 22.5). Ignored when activityMonitor is
|
|
153
|
-
* present. Default 4000. */
|
|
126
|
+
/** Poll interval in ms for the no-activityMonitor fallback (default 4000). */
|
|
154
127
|
pollMs?: number;
|
|
155
|
-
/** The mobileToolbar feature value,
|
|
156
|
-
* keyboard button that opens the key grid
|
|
157
|
-
* the bar shows no keyboard button (e.g. a desktop-only consumer). The
|
|
158
|
-
* toolbar should be built with { externalToggle: true } so its own top-right
|
|
159
|
-
* toggle is hidden and the grid opens above the bar. */
|
|
128
|
+
/** The mobileToolbar feature value, built with { externalToggle: true }, so
|
|
129
|
+
* the mobile switcher bar renders a keyboard button that opens the key grid. */
|
|
160
130
|
keyboardToggle?: TerminalFeature<MobileToolbarApi>;
|
|
161
|
-
/** Presume every session reports activity (an agent shell
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* far enough to first report — the server's sticky reportsActivity flag
|
|
165
|
-
* then merely confirms. Default false (evidence-driven reveal: a plain
|
|
166
|
-
* shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
|
|
131
|
+
/** Presume every session reports activity (an agent shell): each tab's dot is
|
|
132
|
+
* visible as idle from creation instead of popping in when the agent first
|
|
133
|
+
* reports. Default false, so a plain shell keeps clean, label-only tabs. */
|
|
167
134
|
presumeReports?: boolean;
|
|
168
135
|
/** Swap the page's icon links to a status variant while a background session
|
|
169
|
-
* holds an unacknowledged cue
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* `link[rel=icon]` whose filename starts with `favicon`, three variants must
|
|
176
|
-
* be served alongside it with `-input`, `-done` and `-alert` inserted after
|
|
177
|
-
* that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
|
|
178
|
-
* needs `/favicon-input-32x32.png`). Generate them with whatever tooling you
|
|
179
|
-
* like as long as it writes exactly those names, and assert their presence in
|
|
180
|
-
* the APP's own tests — the library cannot check a file it does not ship, and
|
|
181
|
-
* a missing variant is a blank tab icon.
|
|
182
|
-
*
|
|
183
|
-
* Not every platform honours it: Safari caches the first icon it fetched and
|
|
184
|
-
* ignores later changes, and an installed app has no tab icon at all. The
|
|
185
|
-
* title count is not gated on this, so those cases lose nothing.
|
|
186
|
-
*
|
|
187
|
-
* The links are restored on `pagehide` as well as on teardown, because a
|
|
188
|
-
* browser remembers one icon per URL and renders it for the bookmark, the
|
|
189
|
-
* history row and the new-tab tile. Best-effort there, since the write races
|
|
190
|
-
* the unload. Deliberately not on `freeze`: a frozen background tab is still in
|
|
191
|
-
* the strip rendering its icon, which is the case this exists for. */
|
|
136
|
+
* holds an unacknowledged cue. Default false, because it needs assets the
|
|
137
|
+
* library cannot ship: for every `link[rel=icon]` whose filename starts with
|
|
138
|
+
* `favicon`, variants with `-input`, `-done` and `-alert` inserted after that
|
|
139
|
+
* token must be served (`/favicon-32x32.png` needs `/favicon-input-32x32.png`),
|
|
140
|
+
* and a missing one is a blank tab icon. Safari ignores later icon changes; the
|
|
141
|
+
* title count is not gated on this, so nothing is lost there. */
|
|
192
142
|
attentionIcons?: boolean;
|
|
193
143
|
}
|
|
194
144
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
|
|
203
|
-
// does not match `any-pointer: fine`).
|
|
204
|
-
/** Session creation can be legitimately and TEMPORARILY refused, which is not the
|
|
205
|
-
* same event as a broken server. web-terminal-kiro answers 503 with
|
|
206
|
-
* `Retry-After: 5` and a body message while its tool engine installs the
|
|
207
|
-
* manifest's tools on first boot, a window its own HEALTHCHECK budgets 20
|
|
208
|
-
* minutes for. That state used to reach the user as the same fixed "Couldn't
|
|
209
|
-
* open a terminal" toast as a 500, with no retry: the page looked broken while
|
|
210
|
-
* the server was deliberately waiting, and `/api/health` reported healthy at the
|
|
211
|
-
* same time, so the two channels contradicted each other.
|
|
212
|
-
*
|
|
213
|
-
* So honour what the server published: retry on its own schedule, and repeat its
|
|
214
|
-
* own explanation rather than inventing library wording for a host-specific
|
|
215
|
-
* condition. Only 503 retries; a 429 rate limit, a 4xx, or a 500 still fails
|
|
216
|
-
* fast, because those are not "come back shortly".
|
|
217
|
-
*
|
|
218
|
-
* The bound is ELAPSED TIME, not an attempt count. An attempt cap interacts
|
|
219
|
-
* badly with the server's hint: at web-terminal-kiro's `Retry-After: 5` a dozen
|
|
220
|
-
* attempts is only a minute, while the window this exists for can run twenty
|
|
221
|
-
* (toolbelt's boot job is bounded at 30 minutes, which is why that app's
|
|
222
|
-
* HEALTHCHECK carries --start-period=20m), so the retry would give up long
|
|
223
|
-
* before the server was ready and the whole fix would miss its case. Waiting is
|
|
224
|
-
* cheap here because every iteration sleeps at least the server's hint, so this
|
|
225
|
-
* is never a hot loop. The user is re-told periodically rather than once,
|
|
226
|
-
* because a page that silently retries for twenty minutes is its own kind of
|
|
227
|
-
* broken. */
|
|
145
|
+
/** Session creation can be TEMPORARILY refused: a host answers 503 with
|
|
146
|
+
* `Retry-After` and a body message while it installs tools on first boot, a
|
|
147
|
+
* window that can run twenty minutes. Only 503 retries, on the server's own
|
|
148
|
+
* schedule and repeating its own explanation. The bound is ELAPSED TIME, not an
|
|
149
|
+
* attempt count, which at `Retry-After: 5` would give up in a minute; every
|
|
150
|
+
* iteration sleeps at least the hint, so this is never a hot loop, and the user
|
|
151
|
+
* is re-told periodically. */
|
|
228
152
|
const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
|
|
229
153
|
const CREATE_RETRY_FALLBACK_MS = 5000;
|
|
230
154
|
const CREATE_RETRY_REANNOUNCE_MS = 60000;
|
|
231
155
|
|
|
232
|
-
/**
|
|
233
|
-
*
|
|
234
|
-
* socket drops) must not leave a "Catching up" badge on screen forever, and the
|
|
235
|
-
* reconnect that follows will arm a fresh one. */
|
|
156
|
+
/** A backlog that never drains (the socket drops mid-replay) must not leave a
|
|
157
|
+
* "Catching up" badge on screen forever. */
|
|
236
158
|
const CATCHUP_MAX_MS = 30000;
|
|
237
|
-
/**
|
|
238
|
-
*
|
|
239
|
-
* "queue is empty" test declares victory several times per restore; this
|
|
240
|
-
* hysteresis is what turns it into one honest completion signal. */
|
|
159
|
+
/** The render queue empties BETWEEN the server's replay chunks, so a bare "queue
|
|
160
|
+
* is empty" test declares victory several times per restore. */
|
|
241
161
|
const CATCHUP_SETTLE_MS = 250;
|
|
242
|
-
/**
|
|
243
|
-
*
|
|
244
|
-
* telling the user about; ordinary streaming queues a handful of rows per frame
|
|
245
|
-
* and must never arm it (nor pay for the completion poll). */
|
|
162
|
+
/** The renderer builds at most 300 rows per frame, so a backlog above this needs
|
|
163
|
+
* multiple frames; ordinary streaming must never arm the cue. */
|
|
246
164
|
const CATCHUP_MIN_BACKLOG = 400;
|
|
247
165
|
|
|
248
|
-
/**
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
* never fatal: the dot simply lights again, exactly as it behaved before
|
|
252
|
-
* acknowledgements were remembered. */
|
|
253
|
-
function readCueSeen(): Map<string, CueStatus> {
|
|
166
|
+
/** The acknowledged background-tab cues, or an empty map when storage throws or
|
|
167
|
+
* holds nothing trustworthy: the dot simply lights again. */
|
|
168
|
+
function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
|
|
254
169
|
try {
|
|
255
|
-
return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
|
|
170
|
+
return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
|
|
256
171
|
} catch {
|
|
257
172
|
return new Map<string, CueStatus>();
|
|
258
173
|
}
|
|
@@ -260,31 +175,54 @@ function readCueSeen(): Map<string, CueStatus> {
|
|
|
260
175
|
|
|
261
176
|
/** writeCueSeen persists acknowledgements, best-effort: a full quota must not
|
|
262
177
|
* break the dismissal the user just performed on screen. */
|
|
263
|
-
function writeCueSeen(
|
|
178
|
+
function writeCueSeen(
|
|
179
|
+
win: Pick<Window, "localStorage">,
|
|
180
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
181
|
+
): void {
|
|
264
182
|
try {
|
|
265
|
-
localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
183
|
+
win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
266
184
|
} catch {
|
|
267
185
|
/* storage unavailable or full — the dismissal still holds for this page */
|
|
268
186
|
}
|
|
269
187
|
}
|
|
270
188
|
|
|
189
|
+
/** A wait the feature's teardown ends early: the timer goes with the feature. */
|
|
190
|
+
function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const timer = win.setTimeout(() => {
|
|
193
|
+
signal.removeEventListener("abort", onAbort);
|
|
194
|
+
resolve();
|
|
195
|
+
}, ms);
|
|
196
|
+
const onAbort = (): void => {
|
|
197
|
+
win.clearTimeout(timer);
|
|
198
|
+
resolve();
|
|
199
|
+
};
|
|
200
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Create a session, honouring the server's retry schedule, for as long as
|
|
205
|
+
* `signal` stays live: an abort ends the wait and throws its reason, and a
|
|
206
|
+
* create that finishes after the abort is closed rather than returned, because
|
|
207
|
+
* the page that asked for it has no tab left to hold it. */
|
|
271
208
|
async function createSessionHonouringRetry(
|
|
272
|
-
api: { create: () => Promise<SessionInfo> },
|
|
209
|
+
api: { create: () => Promise<SessionInfo>; close: (id: string) => Promise<void> },
|
|
273
210
|
ctx: TerminalContext,
|
|
274
|
-
|
|
211
|
+
signal: AbortSignal,
|
|
275
212
|
): Promise<SessionInfo> {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
// refuses inside the first millisecond
|
|
280
|
-
// sentinel read that as "never spoken" and re-announced on the next refusal —
|
|
281
|
-
// the one thing the throttle below exists to prevent.
|
|
213
|
+
const win = windowOf(ctx.shell.root.ownerDocument);
|
|
214
|
+
const startedAt = win.Date.now();
|
|
215
|
+
// Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
|
|
216
|
+
// machine refuses inside the first millisecond.
|
|
282
217
|
let lastAnnouncedAt: number | null = null;
|
|
283
218
|
for (;;) {
|
|
219
|
+
signal.throwIfAborted();
|
|
220
|
+
let info: SessionInfo;
|
|
284
221
|
try {
|
|
285
|
-
|
|
222
|
+
info = await api.create();
|
|
286
223
|
} catch (err) {
|
|
287
|
-
|
|
224
|
+
signal.throwIfAborted();
|
|
225
|
+
const elapsed = win.Date.now() - startedAt;
|
|
288
226
|
if (
|
|
289
227
|
!(err instanceof SessionAPIError) ||
|
|
290
228
|
err.status !== 503 ||
|
|
@@ -292,37 +230,30 @@ async function createSessionHonouringRetry(
|
|
|
292
230
|
) {
|
|
293
231
|
throw err;
|
|
294
232
|
}
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// a toast storm.
|
|
298
|
-
//
|
|
299
|
-
// The suffix says the page is WAITING, not recovering from a failure. It
|
|
300
|
-
// used to say "retrying", which read as an error loop when the server had
|
|
301
|
-
// only said "not yet" -- and the first announcement fires before any retry
|
|
302
|
-
// has happened at all, so a user two seconds into a boot was told
|
|
303
|
-
// "retrying" about a request that had been made exactly once.
|
|
233
|
+
// "waiting", not "retrying": the server only said "not yet", and the first
|
|
234
|
+
// announcement fires before any retry has happened.
|
|
304
235
|
if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
305
236
|
lastAnnouncedAt = elapsed;
|
|
306
237
|
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
307
238
|
ctx.toast(`${reason}; waiting…`, 8000);
|
|
308
239
|
ctx.announce(`${reason}; waiting…`);
|
|
309
240
|
}
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// actually see before the first frame -- the toast and the banner both live
|
|
313
|
-
// inside .wt-root and paint under it. It replaces text in place rather than
|
|
314
|
-
// stacking notifications, so there is no storm to throttle, and repeating
|
|
315
|
-
// the same string is idempotent. This is what turns the black screen of a
|
|
316
|
-
// twenty-minute tools install into a screen that says why it is waiting.
|
|
241
|
+
// Unthrottled: the overlay is the only surface visible before the first
|
|
242
|
+
// frame, and it replaces text in place.
|
|
317
243
|
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}; waiting…`);
|
|
318
|
-
await
|
|
319
|
-
|
|
244
|
+
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (signal.aborted) {
|
|
248
|
+
void api.close(info.id).catch((err: unknown) => {
|
|
249
|
+
console.warn(
|
|
250
|
+
`web-terminal-ui: session ${info.id} was created after teardown and could not be closed`,
|
|
251
|
+
err,
|
|
252
|
+
);
|
|
320
253
|
});
|
|
321
|
-
|
|
322
|
-
if (isTornDown()) {
|
|
323
|
-
throw err;
|
|
324
|
-
}
|
|
254
|
+
signal.throwIfAborted();
|
|
325
255
|
}
|
|
256
|
+
return info;
|
|
326
257
|
}
|
|
327
258
|
}
|
|
328
259
|
|
|
@@ -348,79 +279,64 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
|
348
279
|
}
|
|
349
280
|
|
|
350
281
|
/** Build the tabs feature.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* The arrangement of the strip is SERVER state (the `order` field), while the
|
|
359
|
-
* active tab and the dismissed notification cues are per-viewer and live in
|
|
360
|
-
* `localStorage` — so a phone reordering tabs moves them on the desktop, and
|
|
361
|
-
* switching tabs on one does not move the other. Teardown removes all chrome,
|
|
362
|
-
* releases the per-tab caches, and leaves the sessions themselves running on the
|
|
363
|
-
* server; closing a tab is what kills a process. */
|
|
282
|
+
* Requires a server that speaks the session API (`GET`/`POST`/`DELETE` on
|
|
283
|
+
* `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
|
|
284
|
+
* It registers as the terminal's `paneLayoutOwner`, so it decides which session
|
|
285
|
+
* the first connect attaches to; two owners fail at `kernel-init`. The strip's
|
|
286
|
+
* order and the pane layout are SERVER state every viewer shares; the dismissed
|
|
287
|
+
* cues are per-viewer in `localStorage`. Teardown removes the chrome and leaves
|
|
288
|
+
* the sessions running; closing a tab is what kills a process. */
|
|
364
289
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
365
290
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
366
291
|
const presumeReports = opts.presumeReports ?? false;
|
|
367
|
-
// reportsOf floors the server's per-session reportsActivity flag with the
|
|
368
|
-
// consumer's presumption (see TabsOptions.presumeReports): an agent shell
|
|
369
|
-
// shows the idle dot from tab creation instead of waiting out the agent's
|
|
370
|
-
// boot-to-first-OSC-9;4 window.
|
|
371
292
|
const reportsOf = (reports?: boolean): boolean => presumeReports || (reports ?? false);
|
|
372
|
-
// The session REST client (model.ts): every call timeout-bounded, list
|
|
373
|
-
// shape-guarded, title persistence fire-and-forget.
|
|
374
|
-
const api = createSessionAPI(apiBase);
|
|
375
293
|
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
// resolveInitialSession() once setup completes and performs the first switch
|
|
380
|
-
// itself. The registration must exist on the feature VALUE (read before
|
|
381
|
-
// setup), while the bootstrap needs setup-scoped state — so it delegates to
|
|
382
|
-
// a closure setup() wires. resolveImpl is nulled on teardown.
|
|
383
|
-
let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
|
|
384
|
-
// Set on teardown so an in-flight create RETRY (which sleeps between attempts,
|
|
385
|
-
// potentially across a multi-minute install window) cannot resurrect a session
|
|
386
|
-
// for a feature that is already gone.
|
|
387
|
-
let tornDown = false;
|
|
294
|
+
// The registration must exist on the feature VALUE, read before setup, while
|
|
295
|
+
// its members need setup-scoped state; setup() wires the closure.
|
|
296
|
+
let live: PaneLayoutOwnerRegistration | null = null;
|
|
388
297
|
return {
|
|
389
298
|
name: "tabs",
|
|
390
|
-
|
|
391
|
-
|
|
299
|
+
scope: "shell",
|
|
300
|
+
paneLayoutOwner: {
|
|
301
|
+
resolveInitialLayout: () => live?.resolveInitialLayout() ?? Promise.resolve(false),
|
|
302
|
+
shownIn: (side) => live?.shownIn(side) ?? null,
|
|
303
|
+
showIn: (side, id) => live?.showIn(side, id) ?? false,
|
|
392
304
|
},
|
|
393
|
-
// Synchronous setup: the chrome mounts immediately; the async session
|
|
394
|
-
// bootstrap that used to live here is the kernel-driven resolver above.
|
|
395
305
|
setup(ctx: TerminalContext) {
|
|
306
|
+
const doc = ctx.shell.root.ownerDocument;
|
|
307
|
+
const win = windowOf(doc);
|
|
308
|
+
const api = createSessionAPI(apiBase, win);
|
|
309
|
+
// Every request continuation and retry wait checks this after its await, so
|
|
310
|
+
// nothing this feature started can touch the chrome, a pane or the server
|
|
311
|
+
// once the feature is gone.
|
|
312
|
+
const lifetime = new AbortController();
|
|
313
|
+
ctx.defer(() => {
|
|
314
|
+
lifetime.abort();
|
|
315
|
+
});
|
|
316
|
+
// A live read: a plain property read is narrowed to always-false by TS CFA,
|
|
317
|
+
// which cannot model the abort firing during an await.
|
|
318
|
+
const tornDown = (): boolean => lifetime.signal.aborted;
|
|
396
319
|
const tablist = ctx.tablist();
|
|
397
320
|
const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
|
|
398
321
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
// closeKeyGrid and the sticky-Ctrl armed reflect update every one.
|
|
322
|
+
// Every keyboard button (the switcher's and the strip's) reflects the grid's
|
|
323
|
+
// open state and the sticky-Ctrl armed state.
|
|
402
324
|
const kbButtons: HTMLElement[] = [];
|
|
403
|
-
// makeNewButton / makeKbButton are the shared control factories (goals 2 &
|
|
404
|
-
// 3): one "+" and one keyboard-button implementation, each built + wired
|
|
405
|
-
// once and reused for the desktop strip and the mobile switcher. The "+"
|
|
406
|
-
// spawns a terminal; the keyboard button toggles the key grid (via the
|
|
407
|
-
// keyboardToggle feature, read lazily so feature ordering does not matter)
|
|
408
|
-
// and reflects its open state on every keyboard button.
|
|
409
325
|
function makeNewButton(cls: string): HTMLElement {
|
|
410
|
-
const btn = fromHTML(newButtonHTML(cls));
|
|
326
|
+
const btn = fromHTML(doc, newButtonHTML(cls));
|
|
411
327
|
// Keeps the keyboard on the terminal and paints its own press state;
|
|
412
328
|
// holdFocusOnPress explains why those two are one job. It does NOT make
|
|
413
329
|
// one press one activation — that is create()'s in-flight coalescing,
|
|
414
330
|
// for the opposite failure.
|
|
415
|
-
holdFocusOnPress(btn);
|
|
331
|
+
ctx.defer(holdFocusOnPress(btn));
|
|
416
332
|
btn.addEventListener("click", () => {
|
|
417
333
|
void create();
|
|
418
334
|
});
|
|
419
335
|
return btn;
|
|
420
336
|
}
|
|
421
337
|
function makeKbButton(cls: string): HTMLElement {
|
|
422
|
-
const btn = fromHTML(kbButtonHTML(cls));
|
|
423
|
-
holdFocusOnPress(btn);
|
|
338
|
+
const btn = fromHTML(doc, kbButtonHTML(cls));
|
|
339
|
+
ctx.defer(holdFocusOnPress(btn));
|
|
424
340
|
btn.addEventListener("click", () => {
|
|
425
341
|
const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
|
|
426
342
|
if (!kb) {
|
|
@@ -436,63 +352,86 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
436
352
|
kbButtons.push(btn);
|
|
437
353
|
return btn;
|
|
438
354
|
}
|
|
355
|
+
// Both split buttons (the strip's and the switcher's) toggle the split and
|
|
356
|
+
// reflect its state; a terminal built without the split option has none.
|
|
357
|
+
const splitButtons: HTMLElement[] = [];
|
|
358
|
+
function makeSplitButton(cls: string): HTMLElement | null {
|
|
359
|
+
if (!ctx.shell.split.enabled) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const btn = fromHTML(doc, splitButtonHTML(cls));
|
|
363
|
+
ctx.defer(holdFocusOnPress(btn));
|
|
364
|
+
btn.addEventListener("click", () => {
|
|
365
|
+
const split = ctx.shell.split;
|
|
366
|
+
if (split.isOpen()) {
|
|
367
|
+
split.close();
|
|
368
|
+
} else {
|
|
369
|
+
split.open();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
splitButtons.push(btn);
|
|
373
|
+
return btn;
|
|
374
|
+
}
|
|
375
|
+
function rowFitsSplit(): boolean {
|
|
376
|
+
return ctx.shell.root.clientWidth >= MIN_SPLIT_AREA_PX;
|
|
377
|
+
}
|
|
378
|
+
function paintSplitButtons(): void {
|
|
379
|
+
const open = ctx.shell.split.isOpen();
|
|
380
|
+
const hidden = !rowFitsSplit();
|
|
381
|
+
for (const b of splitButtons) {
|
|
382
|
+
b.hidden = hidden;
|
|
383
|
+
b.setAttribute("aria-expanded", String(open));
|
|
384
|
+
}
|
|
385
|
+
// Two selected tabs are a valid state exactly while two panes are shown.
|
|
386
|
+
if (open) {
|
|
387
|
+
scroller.setAttribute("aria-multiselectable", "true");
|
|
388
|
+
} else {
|
|
389
|
+
scroller.removeAttribute("aria-multiselectable");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
439
392
|
// makeSwitchButton builds the mobile switcher's dedicated open/close
|
|
440
393
|
// button (its notification dot is painted by paintSwitchDot). Like the
|
|
441
394
|
// other bar buttons it goes through holdFocusOnPress; its click toggles the
|
|
442
395
|
// list (toggleSwitcher opens when collapsed, closes when expanded).
|
|
443
396
|
function makeSwitchButton(): HTMLElement {
|
|
444
|
-
const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
|
|
445
|
-
holdFocusOnPress(btn);
|
|
397
|
+
const btn = fromHTML(doc, switchButtonHTML("wt-switcher-switch wt-btn"));
|
|
398
|
+
ctx.defer(holdFocusOnPress(btn));
|
|
446
399
|
btn.addEventListener("click", () => {
|
|
447
400
|
toggleSwitcher();
|
|
448
401
|
});
|
|
449
402
|
return btn;
|
|
450
403
|
}
|
|
451
404
|
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
// keyboard button sit OUTSIDE the scroller as fixed bar items in the
|
|
456
|
-
// order [scroller | + | kb], so an overflowing tab list can never push
|
|
457
|
-
// or scroll either control away. The scroller shrink-wraps its content
|
|
458
|
-
// (CSS flex: 0 1 auto), so while the tabs fit the "+" trails the last
|
|
459
|
-
// tab exactly as if it were in the list, while the kb button (a wide
|
|
460
|
-
// touchscreen; hidden on a fine pointer) stays pinned at the bar's FAR
|
|
461
|
-
// right edge via margin-left: auto whatever the tab count. Once the
|
|
462
|
-
// tabs overflow, the scroller caps at the remaining bar width and the
|
|
463
|
-
// "+" packs right, up against the kb. Both controls are built + wired
|
|
464
|
-
// by the same shared factories as the mobile switcher's; the kb button
|
|
465
|
-
// is CSS-gated to a wide touchscreen and un-hidden below only when a
|
|
466
|
-
// keyboardToggle is wired. addTabChrome appends each tab to the
|
|
467
|
-
// scroller's end.
|
|
405
|
+
// The bar never scrolls; the inner scroller holds ONLY the tabs, and the
|
|
406
|
+
// "+" and keyboard buttons sit outside it as fixed bar items, so an
|
|
407
|
+
// overflowing tab list can never push or scroll either control away.
|
|
468
408
|
const slot = ctx.region("top-bar", "tabs");
|
|
469
|
-
const bar =
|
|
409
|
+
const bar = doc.createElement("div");
|
|
470
410
|
bar.className = "wt-tab-bar";
|
|
471
411
|
slot.appendChild(bar);
|
|
472
|
-
const scroller =
|
|
412
|
+
const scroller = doc.createElement("div");
|
|
473
413
|
scroller.className = "wt-tab-scroll";
|
|
474
414
|
scroller.setAttribute("role", "tablist");
|
|
475
415
|
bar.appendChild(scroller);
|
|
476
416
|
const newBtn = makeNewButton("wt-tab-new");
|
|
477
417
|
bar.appendChild(newBtn);
|
|
418
|
+
const deskSplit = makeSplitButton("wt-tab-split wt-btn");
|
|
419
|
+
if (deskSplit !== null) {
|
|
420
|
+
bar.appendChild(deskSplit);
|
|
421
|
+
}
|
|
478
422
|
const deskKb = makeKbButton("wt-tab-kb wt-btn");
|
|
479
423
|
bar.appendChild(deskKb);
|
|
480
|
-
// A vertical
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
// BAR so the empty strip area and the fixed controls translate too.
|
|
484
|
-
// Horizontal-dominant deltas (a trackpad pan) keep native handling, and
|
|
485
|
-
// a wheel over a non-overflowing strip falls through untouched. The
|
|
486
|
-
// listener is non-passive because a translated tick must preventDefault
|
|
487
|
-
// so an embedding page (wt-container mode) does not also scroll.
|
|
424
|
+
// A vertical wheel over the strip scrolls the tab list horizontally, the
|
|
425
|
+
// affordance browser tab bars train. Non-passive because a translated tick
|
|
426
|
+
// must preventDefault so an embedding page does not also scroll.
|
|
488
427
|
bar.addEventListener(
|
|
489
428
|
"wheel",
|
|
490
429
|
(e) => {
|
|
491
430
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
|
|
492
|
-
return;
|
|
431
|
+
return;
|
|
493
432
|
}
|
|
494
433
|
if (scroller.scrollWidth <= scroller.clientWidth) {
|
|
495
|
-
return;
|
|
434
|
+
return;
|
|
496
435
|
}
|
|
497
436
|
e.preventDefault();
|
|
498
437
|
// deltaMode: 0 = pixels, 1 = lines (Firefox wheel), 2 = pages.
|
|
@@ -507,28 +446,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
507
446
|
{ passive: false },
|
|
508
447
|
);
|
|
509
448
|
|
|
510
|
-
//
|
|
511
|
-
// the
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
449
|
+
// Every pane's surface clears the strip, the one a later open builds too;
|
|
450
|
+
// the measured strip height is published on the shell ROOT, not a surface:
|
|
451
|
+
// the scroll-to-bottom button sits in a sibling region, so a property set on
|
|
452
|
+
// .term would not reach it and it would overlap the strip.
|
|
453
|
+
const markSurfaces = (): void => {
|
|
454
|
+
for (const p of ctx.shell.panes()) {
|
|
455
|
+
p.surface().classList.add("wt-with-tabbar");
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
markSurfaces();
|
|
459
|
+
ctx.defer(() => {
|
|
460
|
+
for (const p of ctx.shell.panes()) {
|
|
461
|
+
p.surface().classList.remove("wt-with-tabbar");
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
const varRoot = ctx.shell.root;
|
|
465
|
+
const barResize = new win.ResizeObserver(() => {
|
|
526
466
|
varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
527
467
|
});
|
|
528
468
|
barResize.observe(bar);
|
|
469
|
+
ctx.defer(() => {
|
|
470
|
+
barResize.disconnect();
|
|
471
|
+
});
|
|
529
472
|
|
|
530
|
-
|
|
531
|
-
const switcher = fromHTML(SWITCHER_HTML);
|
|
473
|
+
const switcher = fromHTML(doc, SWITCHER_HTML);
|
|
532
474
|
const swList = pick(switcher, ".wt-switcher-list");
|
|
533
475
|
const swBar = pick(switcher, ".wt-switcher-bar");
|
|
534
476
|
const swCurrent = pick(switcher, ".wt-switcher-current");
|
|
@@ -549,23 +491,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
549
491
|
// swipe: the content (dot + label) and the close (x). Moving both keeps the
|
|
550
492
|
// whole active-tab chip sliding as one, rather than the close staying put.
|
|
551
493
|
const swipeEls = [swInner, swClose];
|
|
552
|
-
// The mobile "+", keyboard, and switcher buttons: built + wired by
|
|
553
|
-
// SAME shared factories as the desktop strip's, then appended to the
|
|
554
|
-
// row so the order stays current-wrap | keyboard | switch | "+".
|
|
555
|
-
// the controls means one implementation placed per layout rather
|
|
556
|
-
// duplicated markup. The switch button sits BETWEEN the
|
|
494
|
+
// The mobile "+", keyboard, split and switcher buttons: built + wired by
|
|
495
|
+
// the SAME shared factories as the desktop strip's, then appended to the
|
|
496
|
+
// bar row so the order stays current-wrap | keyboard | split | switch | "+".
|
|
497
|
+
// Unifying the controls means one implementation placed per layout rather
|
|
498
|
+
// than duplicated markup. The switch button sits BETWEEN the split and "+":
|
|
557
499
|
// it toggles the list and carries the moved background-tab attention cue.
|
|
558
500
|
const swKb = makeKbButton("wt-switcher-kb wt-btn");
|
|
501
|
+
const swSplit = makeSplitButton("wt-switcher-split wt-btn");
|
|
559
502
|
const swSwitch = makeSwitchButton();
|
|
560
503
|
const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
|
|
561
504
|
const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
|
|
562
|
-
swBar.append(swKb, swSwitch, swNew);
|
|
563
|
-
// Latest-wins
|
|
564
|
-
//
|
|
565
|
-
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
566
|
-
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
|
|
567
|
-
// cue-worthy set is CueStatus (input / done / crashed / failed — the states
|
|
568
|
-
// that want the user), declared once in model.ts beside isCueStatus.
|
|
505
|
+
swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
|
|
506
|
+
// Latest-wins cue for the switch button's dot, cleared when the list opens or
|
|
507
|
+
// the raising tab is visited or closed; "" is no pending cue.
|
|
569
508
|
let switchNotify: "" | CueStatus = "";
|
|
570
509
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
571
510
|
// swipe or any switch) acknowledges it without opening the list.
|
|
@@ -576,7 +515,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
576
515
|
// latch in the snapshot it sends on every open, so without this a
|
|
577
516
|
// dismissed dot came back on the next load — and on every SSE reconnect,
|
|
578
517
|
// which on a phone is just returning to a backgrounded page.
|
|
579
|
-
const cueSeen = readCueSeen();
|
|
518
|
+
const cueSeen = readCueSeen(win);
|
|
580
519
|
// markCueSeen records that this viewer has seen `id` holding `status`:
|
|
581
520
|
// either because that tab is the one on screen, or because the tray listing
|
|
582
521
|
// every tab's dot was opened. A non-cue status is not an acknowledgeable
|
|
@@ -599,7 +538,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
599
538
|
}
|
|
600
539
|
cueSeen.delete(oldest);
|
|
601
540
|
}
|
|
602
|
-
writeCueSeen(cueSeen);
|
|
541
|
+
writeCueSeen(win, cueSeen);
|
|
603
542
|
// An acknowledgement can change the unseen count without touching the tab
|
|
604
543
|
// list, so syncChrome's sweep is not enough on its own: opening the tray
|
|
605
544
|
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
@@ -610,111 +549,51 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
610
549
|
// (a new working phase, an exit) and when the session goes away.
|
|
611
550
|
function forgetCueSeen(id: string): void {
|
|
612
551
|
if (cueSeen.delete(id)) {
|
|
613
|
-
writeCueSeen(cueSeen);
|
|
552
|
+
writeCueSeen(win, cueSeen);
|
|
614
553
|
paintAttention();
|
|
615
554
|
}
|
|
616
555
|
}
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// gestures.
|
|
623
|
-
const notifier = createNotifier(browserNotifierEnv());
|
|
624
|
-
// The out-of-page surfaces for the SAME unseen-cue set the switch dot
|
|
625
|
-
// shows: the document-title count, the installed app's icon badge, and the
|
|
626
|
-
// tab icon. Constructed here so every capability decision is made once,
|
|
627
|
-
// and fed only through paintAttention below.
|
|
628
|
-
const attention = createAttention(
|
|
629
|
-
browserAttentionEnv((text) => {
|
|
630
|
-
ctx.titlePrefix(text);
|
|
631
|
-
}, opts.attentionIcons === true),
|
|
632
|
-
);
|
|
633
|
-
// pageVisible is the "can the user see this page at all" test, and
|
|
634
|
-
// visibilityState is the only reliable one: document.hasFocus() is false for
|
|
635
|
-
// a visible-but-unfocused window, where the terminal IS on screen. Same
|
|
636
|
-
// signal notify.ts reads, for the same decision.
|
|
556
|
+
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
557
|
+
// shows, fed only through paintAttention below.
|
|
558
|
+
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
559
|
+
// visibilityState, not document.hasFocus(), which is false for a
|
|
560
|
+
// visible-but-unfocused window where the terminal IS on screen.
|
|
637
561
|
function pageVisible(): boolean {
|
|
638
|
-
return
|
|
562
|
+
return doc.visibilityState !== "hidden";
|
|
639
563
|
}
|
|
640
564
|
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
// Scoped to the ACTIVE tab only. A wholesale clear on becoming visible is the
|
|
647
|
-
// obvious shortcut and it is wrong: the tab the user returns to is not
|
|
648
|
-
// necessarily the one that was waiting, and blanking a background tab's cue
|
|
649
|
-
// it never saw is how a viewer loses the thing it came back for.
|
|
565
|
+
// The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
|
|
566
|
+
// a hidden page raised the out-of-page surfaces and is acknowledged when the
|
|
567
|
+
// user can see the terminal. The SHOWN tabs only: blanking a background
|
|
568
|
+
// tab's cue the viewer never saw loses the thing they came back for.
|
|
650
569
|
function onPageVisible(): void {
|
|
651
|
-
if (!pageVisible()
|
|
570
|
+
if (!pageVisible()) {
|
|
652
571
|
return;
|
|
653
572
|
}
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
document.addEventListener("visibilitychange", onPageVisible);
|
|
660
|
-
|
|
661
|
-
// onPageGone hands the page's own icon back before the page GOES AWAY,
|
|
662
|
-
// which is a different question from the page being hidden and needs a
|
|
663
|
-
// different event. A browser remembers ONE icon per URL and renders it for
|
|
664
|
-
// the bookmark, the history row and the new-tab tile, so a tab closed while
|
|
665
|
-
// a cue was lit leaves a status variant standing in for the app until the
|
|
666
|
-
// page is next loaded.
|
|
667
|
-
//
|
|
668
|
-
// `pagehide`, and deliberately NOT `freeze`. freeze fires for a background
|
|
669
|
-
// tab the browser is conserving resources on (Android after five minutes in
|
|
670
|
-
// the background; desktop for a collapsed tab group and, since Chrome 133, a
|
|
671
|
-
// CPU-heavy tab under Energy Saver), and that tab is STILL in the strip
|
|
672
|
-
// rendering its icon — restoring there would blank the cue in exactly the
|
|
673
|
-
// case the cue exists for. pagehide fires on unload, tab close and bfcache
|
|
674
|
-
// entry, where no strip entry is left to render.
|
|
675
|
-
//
|
|
676
|
-
// Through apply() rather than the icon sink directly, because the sinks are
|
|
677
|
-
// change-gated on the last applied value: writing behind that memo would
|
|
678
|
-
// leave it believing the variant is still up, and a page restored from the
|
|
679
|
-
// bfcache would then never repaint it. Which is what onPageBack is for — a
|
|
680
|
-
// bfcache restore re-runs the fold, so the cue returns for a page that did
|
|
681
|
-
// not actually go away. Best-effort on a real unload, where the icon write
|
|
682
|
-
// races the teardown, and a no-op when nothing was lit.
|
|
683
|
-
function onPageGone(): void {
|
|
684
|
-
attention.apply(NO_ATTENTION);
|
|
685
|
-
}
|
|
686
|
-
function onPageBack(event: PageTransitionEvent): void {
|
|
687
|
-
if (event.persisted) {
|
|
688
|
-
paintAttention();
|
|
573
|
+
for (const t of tabList) {
|
|
574
|
+
if (isShown(t.id)) {
|
|
575
|
+
markCueSeen(t.id, statusOf(t));
|
|
576
|
+
}
|
|
689
577
|
}
|
|
690
578
|
}
|
|
691
|
-
|
|
692
|
-
|
|
579
|
+
doc.addEventListener("visibilitychange", onPageVisible);
|
|
580
|
+
ctx.defer(() => {
|
|
581
|
+
doc.removeEventListener("visibilitychange", onPageVisible);
|
|
582
|
+
});
|
|
693
583
|
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// A FOLD rather than incremental bookkeeping, for the reason applyServerOrder
|
|
698
|
-
// is one: there is no state of its own to get out of step, so no path can
|
|
699
|
-
// leave it stale, and it is cheap enough to run on every tick (a loop over a
|
|
700
|
-
// handful of tabs, then sinks that no-op when nothing changed). The dot is
|
|
701
|
-
// the store — cueStatusOf reads each tab's status back off it — so this and
|
|
702
|
-
// the raise in applyStatus fold the same value.
|
|
584
|
+
// A FOLD over the tab list rather than incremental bookkeeping: no state of
|
|
585
|
+
// its own can go stale, and the shell no-ops when nothing changed.
|
|
703
586
|
function paintAttention(): void {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
cueSeen,
|
|
708
|
-
),
|
|
587
|
+
const { count, worst } = summarizeCues(
|
|
588
|
+
tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
|
|
589
|
+
cueSeen,
|
|
709
590
|
);
|
|
591
|
+
attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
|
|
710
592
|
}
|
|
711
593
|
function paintSwitchDot(): void {
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
// colours it exactly like the tabs' own dots. Its tooltip comes from the
|
|
716
|
-
// same wording map as theirs, so the aggregate cue names the state it is
|
|
717
|
-
// showing rather than being a coloured dot with no explanation.
|
|
594
|
+
// The dot carries .wt-status-dot, so data-status colours it like the tabs'
|
|
595
|
+
// own dots, and its tooltip comes from their wording map, so the aggregate
|
|
596
|
+
// cue names the state it shows.
|
|
718
597
|
if (switchNotify === "") {
|
|
719
598
|
delete swSwitchDot.dataset["status"];
|
|
720
599
|
swSwitchDot.removeAttribute("title");
|
|
@@ -731,11 +610,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
731
610
|
switchNotifyId = null;
|
|
732
611
|
paintSwitchDot();
|
|
733
612
|
}
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
// only opening the list used to clear it (the reported "swiping to the
|
|
738
|
-
// concerned tab leaves the dot lit").
|
|
613
|
+
// The pending cue is resolved when its session is reached (a switch, a swipe
|
|
614
|
+
// arriving on it) and moot when the session ceases to exist (a close, a
|
|
615
|
+
// reap); opening the list alone does not clear it.
|
|
739
616
|
function acknowledgeSwitchNotify(id: string): void {
|
|
740
617
|
if (switchNotifyId === id) {
|
|
741
618
|
clearSwitchNotify();
|
|
@@ -745,7 +622,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
745
622
|
// The keyboard buttons open the key grid; show them only when a toolbar is
|
|
746
623
|
// wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
|
|
747
624
|
// feature ordering does not matter.
|
|
748
|
-
let offArmed: (() => void) | undefined;
|
|
749
625
|
if (opts.keyboardToggle) {
|
|
750
626
|
// Un-hide every keyboard button; the mobile one then shows in the
|
|
751
627
|
// switcher bar, the desktop one is CSS-gated to a wide touchscreen.
|
|
@@ -766,64 +642,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
766
642
|
}
|
|
767
643
|
};
|
|
768
644
|
reflectArmed(kbApi.isCtrlArmed());
|
|
769
|
-
|
|
645
|
+
ctx.defer(kbApi.onCtrlArmedChange(reflectArmed));
|
|
770
646
|
}
|
|
771
647
|
}
|
|
772
648
|
// Measured optical centring for every chip label in both layouts: writes
|
|
773
649
|
// --label-ink-shift onto the strip and the switcher from the line box THIS
|
|
774
650
|
// engine produced for THIS font at THIS size, rather than the em constant
|
|
775
651
|
// in 00-tokens.css that can only be right at one size (see ink-centre.ts).
|
|
776
|
-
|
|
652
|
+
ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
|
|
777
653
|
|
|
778
654
|
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
779
655
|
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
780
|
-
const root = ctx.
|
|
781
|
-
root
|
|
782
|
-
// Reserve the collapsed bar row's height so terminal content stops above
|
|
783
|
-
//
|
|
656
|
+
const root = ctx.shell.root;
|
|
657
|
+
root.classList.add("wt-tabbed");
|
|
658
|
+
// Reserve the collapsed bar row's height so terminal content stops above
|
|
659
|
+
// it: viewport.ts adds --wt-reserve-bottom to the surface's
|
|
784
660
|
// bottom inset (it reads the var off the surface, which inherits it from
|
|
785
661
|
// the root). Measure the bar row (not the expandable list, which just
|
|
786
662
|
// overlays content). innerHeight - rect.top captures the row plus the
|
|
787
663
|
// safe-area beneath it; the RO fires with the keyboard closed, so the value
|
|
788
664
|
// excludes the keyboard lift (viewport.ts adds that separately). The
|
|
789
665
|
// synthetic visualViewport resize makes viewport.ts recompute immediately.
|
|
790
|
-
const swReserve = new ResizeObserver(() => {
|
|
666
|
+
const swReserve = new win.ResizeObserver(() => {
|
|
791
667
|
const rect = swBar.getBoundingClientRect();
|
|
792
|
-
const px = rect.height > 0 ? Math.max(0, Math.round(
|
|
668
|
+
const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
|
|
793
669
|
varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
|
|
794
|
-
|
|
670
|
+
win.visualViewport?.dispatchEvent(new win.Event("resize"));
|
|
795
671
|
});
|
|
796
672
|
swReserve.observe(swBar);
|
|
673
|
+
ctx.defer(() => {
|
|
674
|
+
swReserve.disconnect();
|
|
675
|
+
});
|
|
797
676
|
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
677
|
+
// Fire-and-forget frames and timers, cancelled together at teardown so no
|
|
678
|
+
// callback writes to a chip or a row this feature no longer owns.
|
|
679
|
+
const frames = new Set<number>();
|
|
680
|
+
const timers = new Set<number>();
|
|
681
|
+
function frame(cb: () => void): void {
|
|
682
|
+
const id = win.requestAnimationFrame(() => {
|
|
683
|
+
frames.delete(id);
|
|
684
|
+
cb();
|
|
685
|
+
});
|
|
686
|
+
frames.add(id);
|
|
687
|
+
}
|
|
688
|
+
function after(ms: number, cb: () => void): void {
|
|
689
|
+
const id = win.setTimeout(() => {
|
|
690
|
+
timers.delete(id);
|
|
691
|
+
cb();
|
|
692
|
+
}, ms);
|
|
693
|
+
timers.add(id);
|
|
694
|
+
}
|
|
695
|
+
ctx.defer(() => {
|
|
696
|
+
for (const id of frames) {
|
|
697
|
+
win.cancelAnimationFrame(id);
|
|
698
|
+
}
|
|
699
|
+
for (const id of timers) {
|
|
700
|
+
win.clearTimeout(id);
|
|
701
|
+
}
|
|
702
|
+
frames.clear();
|
|
703
|
+
timers.clear();
|
|
704
|
+
});
|
|
821
705
|
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
const tabMenu = document.createElement("div");
|
|
706
|
+
// The tab menu is built on demand (as context-menu.ts builds its own) so
|
|
707
|
+
// each item targets the right-clicked tab and its disabled state reflects
|
|
708
|
+
// that tab's position.
|
|
709
|
+
const tabMenu = doc.createElement("div");
|
|
827
710
|
tabMenu.className = "wt-tab-menu";
|
|
828
711
|
tabMenu.setAttribute("role", "menu");
|
|
829
712
|
ctx.region("overlay", "tab-menu").appendChild(tabMenu);
|
|
@@ -838,24 +721,53 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
838
721
|
// server listing (the SSE re-open snapshot, or the poll's GET
|
|
839
722
|
// /api/sessions) that predates the server reaping the session does not
|
|
840
723
|
// re-adopt (flash back) a closed tab.
|
|
841
|
-
const tombstones = createTombstones();
|
|
724
|
+
const tombstones = createTombstones(() => win.Date.now());
|
|
842
725
|
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
843
726
|
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
844
727
|
// same elements from their old slots to their new ones (the rotation).
|
|
845
728
|
const rowEls = new Map<string, HTMLElement>();
|
|
846
|
-
|
|
729
|
+
// Which tab each pane shows is the PANES' fact, read from them rather than
|
|
730
|
+
// mirrored: the shell empties a pane on a close of the split without asking.
|
|
731
|
+
function shownIn(side: PaneSide): string | null {
|
|
732
|
+
const pane = ctx.shell.pane(side);
|
|
733
|
+
return pane !== null && pane.state() === "shown" ? pane.session.id : null;
|
|
734
|
+
}
|
|
735
|
+
function sideOf(id: string): PaneSide | null {
|
|
736
|
+
for (const p of ctx.shell.panes()) {
|
|
737
|
+
if (p.state() === "shown" && p.session.id === id) {
|
|
738
|
+
return p.side;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const isShown = (id: string): boolean => sideOf(id) !== null;
|
|
744
|
+
const anyShown = (): boolean => ctx.shell.panes().some((p) => p.state() === "shown");
|
|
745
|
+
/** The tab that receives typing: the selected pane's. */
|
|
746
|
+
const selectedId = (): string | null => shownIn(ctx.shell.selected());
|
|
747
|
+
const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
|
|
748
|
+
// Set by a bootstrap that showed nothing, cleared by the first showIn: the
|
|
749
|
+
// one case a status event may pick a tab to show.
|
|
750
|
+
let bootShowedNothing = false;
|
|
751
|
+
// The coalesced layout write (scheduleLayoutWrite): one PUT per macrotask,
|
|
752
|
+
// one in flight at a time, and a change during the flight sends the latest
|
|
753
|
+
// state once it lands, so an older record can never become the server's.
|
|
754
|
+
let layoutWrite: number | null = null;
|
|
755
|
+
let layoutInFlight: Promise<void> | null = null;
|
|
756
|
+
let layoutDirty = false;
|
|
757
|
+
// The record is read once and never written back by a read.
|
|
758
|
+
let applyingRecord = false;
|
|
759
|
+
let warnedLayout = false;
|
|
847
760
|
let draggingEl: HTMLElement | null = null;
|
|
848
|
-
// --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
|
|
849
761
|
// The chips currently carrying an inline displacement from the commit slide.
|
|
850
762
|
// A set plus one settle function, so whatever a slide wrote can always be handed
|
|
851
763
|
// back to the stylesheet — including when a second drag interrupts the first.
|
|
852
764
|
const shifted = new Set<HTMLElement>();
|
|
853
|
-
let shiftTimer:
|
|
765
|
+
let shiftTimer: number | null = null;
|
|
854
766
|
// The no-events fallback timer. It carries its own target, so there is
|
|
855
767
|
// deliberately NO pending-slot field here: one was the cause of the reorder
|
|
856
768
|
// dropping commits (see trackRest). restX is the last pointer position seen, which
|
|
857
769
|
// is how movement is told from stillness.
|
|
858
|
-
let restTimer:
|
|
770
|
+
let restTimer: number | null = null;
|
|
859
771
|
let restX: number | null = null;
|
|
860
772
|
// When the pointer last actually MOVED. A stationary dragover this long after it
|
|
861
773
|
// is believed as a stop (see trackRest).
|
|
@@ -867,7 +779,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
867
779
|
let dropped = false;
|
|
868
780
|
// The chip mid slot-fade, and the timer that ends it.
|
|
869
781
|
let slotFadeEl: HTMLElement | null = null;
|
|
870
|
-
let slotFadeTimer:
|
|
782
|
+
let slotFadeTimer: number | null = null;
|
|
871
783
|
// Gate the new-tab enter animation: tabs present at initial population
|
|
872
784
|
// should not animate in (jarring on load); only tabs added at runtime do.
|
|
873
785
|
let started = false;
|
|
@@ -882,66 +794,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
882
794
|
// row grows+fades in (animateRowIn) rather than rotating.
|
|
883
795
|
let reelReconcile = false;
|
|
884
796
|
let creatingTab = false;
|
|
885
|
-
let collapseClearTimer:
|
|
797
|
+
let collapseClearTimer: number | null = null;
|
|
886
798
|
let hintShown = false;
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
// virtual keyboard. No web API reports a hardware keyboard directly, so we
|
|
891
|
-
// combine two proxies: (1) a fine pointer (a Magic Keyboard carries a
|
|
892
|
-
// trackpad, so an iPad with one matches, as does every desktop; a bare
|
|
893
|
-
// phone / keyboard-less tablet does not) — read live, since a keyboard can
|
|
894
|
-
// be attached/detached; and (2) sawHardwareKey, latched once we observe a
|
|
895
|
-
// keydown only a hardware keyboard emits (covers a trackpad-less keyboard
|
|
896
|
-
// folio). See looksLikeHardwareKey and the keydown observer below.
|
|
799
|
+
// No web API reports a hardware keyboard, so two proxies: a fine pointer
|
|
800
|
+
// (read live, since a keyboard folio with a trackpad can be detached) and a
|
|
801
|
+
// keydown only a hardware keyboard emits (a trackpad-less folio).
|
|
897
802
|
let sawHardwareKey = false;
|
|
898
803
|
const hasFinePointer = (): boolean =>
|
|
899
|
-
typeof
|
|
804
|
+
typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
|
|
900
805
|
const physicalKeyboardLikely = (): boolean => sawHardwareKey || hasFinePointer();
|
|
901
|
-
// Motion opt-out (checked live: the OS setting can change). Gates the
|
|
902
|
-
// interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
|
|
903
806
|
const prefersReduce = (): boolean =>
|
|
904
|
-
|
|
807
|
+
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
905
808
|
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
910
|
-
// never shows two identical labels.
|
|
911
|
-
//
|
|
912
|
-
// The OSC 9;4 percentage prefix ("78% · one") is applied HERE, at render
|
|
913
|
-
// time, and stored nowhere: `display` stays the plain de-duplicated label,
|
|
914
|
-
// so de-duplication compares real names, the rename field opens on the real
|
|
915
|
-
// name, `list()` reports the real name, and clearing the progress needs no
|
|
916
|
-
// cleanup — the next paint simply stops adding it.
|
|
809
|
+
// Recomputes every display label with de-duplication. The percentage prefix
|
|
810
|
+
// is applied at render time and stored nowhere, so de-duplication and the
|
|
811
|
+
// rename field see real names and clearing it needs no cleanup.
|
|
917
812
|
function relabelAll(): void {
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
// text stayed put while the sessions moved underneath it. Dragging one of them
|
|
925
|
-
// changed nothing on screen, and the reorder — which had worked correctly all
|
|
926
|
-
// along, server included — was invisible. It also meant dragging tab A could
|
|
927
|
-
// renumber tab B, which is the clearest sign the number belonged to the wrong
|
|
928
|
-
// thing.
|
|
929
|
-
//
|
|
930
|
-
// A label answers "which session is this", so it has to be a property of the
|
|
931
|
-
// session: stable while other tabs move, and travelling with its own tab. Age is
|
|
932
|
-
// that key. createdAt (the server's, tie-broken by id) rather than the local
|
|
933
|
-
// `born` counter, so every device and every reload agrees on which "workspace"
|
|
934
|
-
// is (2) — `born` is adoption order, which races.
|
|
935
|
-
//
|
|
936
|
-
// Numbering is recomputed over the LIVE group each time, so it stays contiguous:
|
|
937
|
-
// closing "(2)" of three renumbers the survivor rather than leaving a hole. That
|
|
938
|
-
// is a renumber with a visible cause the user just performed, which is the one
|
|
939
|
-
// kind that reads as sensible.
|
|
813
|
+
// The "(k)" suffix is numbered by the session's AGE, never by its slot:
|
|
814
|
+
// numbered by encounter order, the labels stayed put while the sessions
|
|
815
|
+
// moved underneath them, so a reorder was invisible. Server createdAt
|
|
816
|
+
// rather than the local `born` counter, so every device agrees which
|
|
817
|
+
// "workspace" is (2). Recomputed over the LIVE group, so closing "(2)" of
|
|
818
|
+
// three renumbers the survivor.
|
|
940
819
|
const groups = new Map<string, Tab[]>();
|
|
941
820
|
for (const t of tabList) {
|
|
942
821
|
const { text, fallback } = baseLabel(t);
|
|
943
|
-
//
|
|
944
|
-
// "New tab" with no numbering, which is the pre-existing behaviour.
|
|
822
|
+
// Several untitled tabs all read "New tab" with no numbering.
|
|
945
823
|
if (fallback) {
|
|
946
824
|
continue;
|
|
947
825
|
}
|
|
@@ -1015,45 +893,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1015
893
|
termInput()?.focus({ preventScroll: true });
|
|
1016
894
|
}
|
|
1017
895
|
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
// handler runs the input is already blurred and only a pointerdown-time
|
|
1022
|
-
// snapshot still knows where the keyboard came from. Recorded for the whole
|
|
1023
|
-
// chrome by the two pointerdown listeners below (the strip and the
|
|
1024
|
-
// switcher), not per control: chips come and go with sessions, and the fact
|
|
1025
|
-
// belongs to the gesture rather than to what it hit.
|
|
896
|
+
// A press on the chrome focuses the control it hit before the click handler
|
|
897
|
+
// runs, so only a pointerdown-time snapshot knows whether the terminal input
|
|
898
|
+
// held the keyboard; recorded per gesture, since chips come and go.
|
|
1026
899
|
let inputFocusedAtPress = false;
|
|
1027
900
|
function noteChromePress(): void {
|
|
1028
901
|
const el = termInput();
|
|
1029
|
-
inputFocusedAtPress = el !== null &&
|
|
902
|
+
inputFocusedAtPress = el !== null && doc.activeElement === el;
|
|
1030
903
|
}
|
|
1031
904
|
function keyboardParkedOnChrome(): boolean {
|
|
1032
|
-
const active =
|
|
905
|
+
const active = doc.activeElement;
|
|
1033
906
|
return active !== null && (bar.contains(active) || switcher.contains(active));
|
|
1034
907
|
}
|
|
1035
908
|
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
// ask). Skipped on a keyboard-less touchscreen, or every switch would
|
|
1042
|
-
// pop the virtual keyboard (#7).
|
|
1043
|
-
// - The press that drove this switch took the keyboard OFF the terminal —
|
|
1044
|
-
// it focused the chip (or the x, or a switcher row) it pressed, and the
|
|
1045
|
-
// keyboard is still parked there. Handing it back is not NEW focus, so
|
|
1046
|
-
// it pops no soft keyboard: it restores what the press displaced. Both
|
|
1047
|
-
// halves are required for exactly that reason — a switch with no press
|
|
1048
|
-
// behind it (a remote adopt, ensureActive), or a press on a device where
|
|
1049
|
-
// the input was not focused to begin with, must not summon a keyboard.
|
|
1050
|
-
// Leaving the keyboard parked on a chip is not neutral either: the
|
|
1051
|
-
// strip's own keydown handling reads arrows as "switch tab" and Delete
|
|
1052
|
-
// as "close tab", so every keystroke meant for the terminal is eaten.
|
|
1053
|
-
//
|
|
1054
|
-
// Never while a rename field is open: it owns the keyboard until it closes,
|
|
1055
|
-
// and a switch (the leading click of a double-click, a remote-driven
|
|
1056
|
-
// ensureActive) would otherwise yank the caret out of the field.
|
|
909
|
+
// The keyboard goes to the terminal input when a physical keyboard is likely
|
|
910
|
+
// (on a keyboard-less touchscreen every switch would pop the soft keyboard)
|
|
911
|
+
// or when the press behind this switch parked it on the chrome, where the
|
|
912
|
+
// strip's own keydown handling would eat every keystroke; restoring what the
|
|
913
|
+
// press displaced pops no keyboard. Never while a rename field is open.
|
|
1057
914
|
function focusAfterSwitch(): void {
|
|
1058
915
|
if (editingId !== null) {
|
|
1059
916
|
return;
|
|
@@ -1063,42 +920,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1063
920
|
}
|
|
1064
921
|
}
|
|
1065
922
|
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
// overflowed scroller; inline: "nearest" is a no-op when it is already
|
|
1071
|
-
// visible. The typeof guard covers an environment without scrollIntoView.
|
|
923
|
+
// Every shown chip renders active, with no difference between the two panes'
|
|
924
|
+
// (selection is the handle's and the cursor's to show). The selected pane's
|
|
925
|
+
// chip is revealed in the scroller only when THAT TAB CHANGES, so a user
|
|
926
|
+
// browsing a scrolled strip is never yanked back by an unrelated repaint.
|
|
1072
927
|
let lastRevealedActive = "";
|
|
1073
928
|
function paintActive(): void {
|
|
929
|
+
const open = ctx.shell.split.isOpen();
|
|
1074
930
|
for (const t of tabList) {
|
|
1075
|
-
const
|
|
931
|
+
const side = sideOf(t.id);
|
|
932
|
+
const on = side !== null;
|
|
1076
933
|
t.el.classList.toggle("wt-tab-active", on);
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
934
|
+
t.aria.setPanel(side);
|
|
935
|
+
t.aria.setExpanded(open ? on : null);
|
|
936
|
+
// setSelected would undo setEditing(true) on the chip hosting the rename
|
|
937
|
+
// field within a tick; endEdit restores the semantics from the CURRENT
|
|
938
|
+
// state.
|
|
1082
939
|
if (t.id !== editingId) {
|
|
1083
940
|
t.aria.setSelected(on);
|
|
1084
941
|
}
|
|
1085
942
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
943
|
+
const current = selectedId();
|
|
944
|
+
if (current !== null && current !== lastRevealedActive) {
|
|
945
|
+
lastRevealedActive = current;
|
|
946
|
+
const active = tabList.find((t) => t.id === current);
|
|
1089
947
|
if (active && typeof active.el.scrollIntoView === "function") {
|
|
1090
948
|
active.el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1091
949
|
}
|
|
1092
950
|
}
|
|
951
|
+
paintSplitButtons();
|
|
1093
952
|
}
|
|
1094
953
|
|
|
1095
|
-
// syncMobile updates the bottom bar:
|
|
1096
|
-
// needs-input cue. The cue rides the active surface: a
|
|
1097
|
-
// blocked on input is glanceable, and tapping/swiping opens
|
|
1098
|
-
// resolve it
|
|
954
|
+
// syncMobile updates the bottom bar: the selected pane's label + dot, and
|
|
955
|
+
// the aggregate needs-input cue. The cue rides the active surface: a
|
|
956
|
+
// background tab blocked on input is glanceable, and tapping/swiping opens
|
|
957
|
+
// the list to resolve it.
|
|
1099
958
|
function syncMobile(): void {
|
|
1100
|
-
const
|
|
1101
|
-
const active =
|
|
959
|
+
const current = selectedId();
|
|
960
|
+
const active = tabList.find((t) => t.id === current);
|
|
1102
961
|
swLabel.textContent = active ? active.display : "";
|
|
1103
962
|
paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
|
|
1104
963
|
paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
|
|
@@ -1112,7 +971,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1112
971
|
// (select = switch + collapse; x = close). Its dot/label are filled by
|
|
1113
972
|
// updateRow. The element is cached in rowEls and reused across renders.
|
|
1114
973
|
function buildRow(t: Tab): HTMLElement {
|
|
1115
|
-
const row = fromHTML(SWITCHER_ROW_HTML);
|
|
974
|
+
const row = fromHTML(doc, SWITCHER_ROW_HTML);
|
|
1116
975
|
pick(row, ".wt-switcher-row-select").addEventListener("click", () => {
|
|
1117
976
|
collapseSwitcher();
|
|
1118
977
|
switchTo(t.id);
|
|
@@ -1131,16 +990,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1131
990
|
paintActivityMark(pick(row, ".wt-activity-mark"), t.activity, t.activityCount);
|
|
1132
991
|
pick(row, ".wt-switcher-row-label").textContent = t.display;
|
|
1133
992
|
}
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
// rotation can FLIP the same rows between positions. Order is circular
|
|
1138
|
-
// starting AFTER the active tab, so the list reads as the queue that
|
|
1139
|
-
// follows the current one (active #k -> k+1, k+2, ... wrapping around); as
|
|
1140
|
-
// the active tab changes on a swipe, this order rotates by one.
|
|
993
|
+
// Rows are REUSED, never rebuilt, so their identity is stable for the swipe
|
|
994
|
+
// FLIP; the order is circular from the tab after the active one, so a swipe
|
|
995
|
+
// rotates it by one.
|
|
1141
996
|
function renderSwitcherList(): void {
|
|
1142
997
|
const n = tabList.length;
|
|
1143
|
-
const
|
|
998
|
+
const current = selectedId();
|
|
999
|
+
const activeIdx = tabList.findIndex((t) => t.id === current);
|
|
1144
1000
|
const start = activeIdx >= 0 ? activeIdx : 0;
|
|
1145
1001
|
const desired: Tab[] = [];
|
|
1146
1002
|
for (let step = 1; step < n; step++) {
|
|
@@ -1159,7 +1015,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1159
1015
|
if (!keep.has(id)) {
|
|
1160
1016
|
rowEls.delete(id);
|
|
1161
1017
|
if (anim) {
|
|
1162
|
-
animateRowOut(el); // collapse + fade, then remove
|
|
1018
|
+
animateRowOut(el, lifetime.signal); // collapse + fade, then remove
|
|
1163
1019
|
} else {
|
|
1164
1020
|
el.remove();
|
|
1165
1021
|
}
|
|
@@ -1177,55 +1033,41 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1177
1033
|
updateRow(row, t);
|
|
1178
1034
|
swList.appendChild(row);
|
|
1179
1035
|
if (isNew && anim) {
|
|
1180
|
-
animateRowIn(row); // grow + fade in (the tray height follows)
|
|
1036
|
+
animateRowIn(row, lifetime.signal); // grow + fade in (the tray height follows)
|
|
1181
1037
|
}
|
|
1182
1038
|
}
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
// content height regardless of the collapsed max-height:0 clip, so this
|
|
1190
|
-
// is valid whether measured while collapsed (on open, before the expanded
|
|
1191
|
-
// class) or already open (a tab added/closed). Capped at 50dvh (then
|
|
1192
|
-
// overflow-y:auto scrolls). This works without interpolate-size (iOS
|
|
1193
|
-
// Safari lacks it), unlike a height:auto transition.
|
|
1194
|
-
const visH = window.visualViewport?.height ?? window.innerHeight;
|
|
1039
|
+
// The list animates its max-height to the REAL content height, not a fixed
|
|
1040
|
+
// 50dvh: the stretch past the content moved nothing, so the open finished
|
|
1041
|
+
// early and the close started late. scrollHeight is the full height under
|
|
1042
|
+
// the collapsed max-height:0 clip, and this needs no interpolate-size,
|
|
1043
|
+
// which iOS Safari lacks.
|
|
1044
|
+
const visH = win.visualViewport?.height ?? win.innerHeight;
|
|
1195
1045
|
switcher.style.setProperty(
|
|
1196
1046
|
"--wt-list-h",
|
|
1197
1047
|
`${String(Math.min(swList.scrollHeight, Math.round(visH * 0.5)))}px`,
|
|
1198
1048
|
);
|
|
1199
1049
|
}
|
|
1200
1050
|
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
// that might carry a stale reel transform.
|
|
1051
|
+
// Drops the reused-row cache too, so the next expand cannot reuse a row
|
|
1052
|
+
// carrying a stale reel transform.
|
|
1204
1053
|
function clearRows(): void {
|
|
1205
1054
|
endReelNow();
|
|
1206
1055
|
swList.replaceChildren();
|
|
1207
1056
|
rowEls.clear();
|
|
1208
1057
|
}
|
|
1209
1058
|
|
|
1210
|
-
// The
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
// positions and lifts the leaving row out of the flow as an absolute ghost
|
|
1217
|
-
// so the reconcile can't reshuffle the survivors; the returned closure
|
|
1218
|
-
// (run AFTER the reconcile) inverts every row to its old spot and releases
|
|
1219
|
-
// it to the new one, and slides the ghost out. Pixel positions make it
|
|
1220
|
-
// correct regardless of the row gap, list padding, or separator border.
|
|
1059
|
+
// The expanded list rotates as a reel on a swipe: a FLIP over reused rows.
|
|
1060
|
+
// prepareReel (BEFORE the reconcile) snapshots row positions and lifts the
|
|
1061
|
+
// leaving row out of the flow as an absolute ghost so the reconcile cannot
|
|
1062
|
+
// reshuffle the survivors; the returned closure (AFTER it) inverts every
|
|
1063
|
+
// row to its old spot and releases it. Pixel positions, so the row gap and
|
|
1064
|
+
// separators cannot make it wrong.
|
|
1221
1065
|
const REEL_MS = 300;
|
|
1222
|
-
let reelTimer:
|
|
1066
|
+
let reelTimer: number | null = null;
|
|
1223
1067
|
let reelGhost: HTMLElement | null = null;
|
|
1224
|
-
// endReelNow settles any in-flight reel immediately: drop the ghost, clear
|
|
1225
|
-
// the row transforms, and hand overflow/position back to the stylesheet.
|
|
1226
1068
|
function endReelNow(): void {
|
|
1227
1069
|
if (reelTimer !== null) {
|
|
1228
|
-
clearTimeout(reelTimer);
|
|
1070
|
+
win.clearTimeout(reelTimer);
|
|
1229
1071
|
reelTimer = null;
|
|
1230
1072
|
}
|
|
1231
1073
|
if (reelGhost) {
|
|
@@ -1257,7 +1099,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1257
1099
|
firstTops.set(id, el.getBoundingClientRect().top);
|
|
1258
1100
|
}
|
|
1259
1101
|
const listRect = swList.getBoundingClientRect();
|
|
1260
|
-
const st = getComputedStyle(swList);
|
|
1102
|
+
const st = win.getComputedStyle(swList);
|
|
1261
1103
|
const pitch = ghost.getBoundingClientRect().height + (parseFloat(st.rowGap) || 0);
|
|
1262
1104
|
const top = (firstTops.get(leaving.id) ?? listRect.top) - listRect.top + swList.scrollTop;
|
|
1263
1105
|
endReelNow(); // settle a prior reel / the drag preview before this one
|
|
@@ -1292,23 +1134,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1292
1134
|
ghost.style.transition = "none";
|
|
1293
1135
|
ghost.style.transform = "translateY(0)";
|
|
1294
1136
|
ghost.style.opacity = "1";
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
// a JS transform FLIP, not toggled via display:none, so the reliable
|
|
1304
|
-
// path is a real reflow plus an explicit opacity transition.
|
|
1305
|
-
swList.getBoundingClientRect(); // read forces the reflow (commit the from-state)
|
|
1306
|
-
// Couple opacity to the SAME easing + duration as the transform so a
|
|
1307
|
-
// row's fade tracks its DISTANCE from its target slot (each reel row
|
|
1308
|
-
// travels one pitch): a row is transparent a pitch away (at the clipped
|
|
1309
|
-
// edge) and only fully opaque once it settles. Entering rows fade IN as
|
|
1310
|
-
// they rotate in, the leaving row fades OUT as it exits: no hard cutoff
|
|
1311
|
-
// at the list edges and no permanent edge mask.
|
|
1137
|
+
// A forced reflow commits the from-state BEFORE the to-state, or the
|
|
1138
|
+
// browser collapses the two into one recalc and nothing animates. The
|
|
1139
|
+
// end opacity is explicit for the same reason; a bare "" never faded.
|
|
1140
|
+
// @starting-style does not apply: these rows are moved by a JS FLIP, not
|
|
1141
|
+
// toggled through display.
|
|
1142
|
+
swList.getBoundingClientRect();
|
|
1143
|
+
// Opacity shares the transform's easing so a row's fade tracks its
|
|
1144
|
+
// DISTANCE from its slot: transparent a pitch away, opaque once settled.
|
|
1312
1145
|
const trans =
|
|
1313
1146
|
"transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.25s cubic-bezier(0.2, 0, 0, 1)";
|
|
1314
1147
|
for (const el of rowEls.values()) {
|
|
@@ -1319,7 +1152,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1319
1152
|
ghost.style.transition = trans;
|
|
1320
1153
|
ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
|
|
1321
1154
|
ghost.style.opacity = "0";
|
|
1322
|
-
reelTimer = setTimeout(endReelNow, REEL_MS);
|
|
1155
|
+
reelTimer = win.setTimeout(endReelNow, REEL_MS);
|
|
1323
1156
|
};
|
|
1324
1157
|
}
|
|
1325
1158
|
|
|
@@ -1328,12 +1161,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1328
1161
|
relabelAll();
|
|
1329
1162
|
paintActive();
|
|
1330
1163
|
syncMobile();
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
// there). .wt-switcher-multi drives its collapse-when-single / animate-in
|
|
1334
|
-
// -when-a-second-opens motion in CSS (the active chip shrinks to make
|
|
1335
|
-
// room in lockstep, via the flex layout); aria-hidden + tabindex keep the
|
|
1336
|
-
// collapsed button out of the a11y tree and tab order.
|
|
1164
|
+
// A single tab has nothing to switch to; aria-hidden and tabindex keep the
|
|
1165
|
+
// collapsed button out of the a11y tree and the tab order.
|
|
1337
1166
|
const multiTab = tabList.length >= 2;
|
|
1338
1167
|
switcher.classList.toggle("wt-switcher-multi", multiTab);
|
|
1339
1168
|
swSwitch.setAttribute("aria-hidden", multiTab ? "false" : "true");
|
|
@@ -1343,50 +1172,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1343
1172
|
}
|
|
1344
1173
|
maybeSwipeHint();
|
|
1345
1174
|
applyServerOrder();
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
// close, reconcile), so no path can forget the out-of-page surfaces. The
|
|
1349
|
-
// fold is idempotent and the sinks no-op when unchanged, so running it
|
|
1350
|
-
// this often costs a comparison.
|
|
1175
|
+
// Every list mutation ends in syncChrome, so no path can forget the
|
|
1176
|
+
// out-of-page surfaces; the fold is idempotent and the sinks no-op.
|
|
1351
1177
|
paintAttention();
|
|
1352
1178
|
}
|
|
1353
1179
|
|
|
1354
|
-
// The percentage is deliberately NOT written into the
|
|
1355
|
-
// title
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
// choosing whose percentage it shows is arbitrary. Even restricted to the
|
|
1359
|
-
// active tab it churns a surface that doubles as the browser-tab label and
|
|
1360
|
-
// the bookmark name, and it invites reading one session's progress as the
|
|
1361
|
-
// window's. The per-chip prefix carries the same information without the
|
|
1362
|
-
// conflict, because each chip shows its own session. Do not add it back.
|
|
1363
|
-
//
|
|
1364
|
-
// What DOES go there is the unseen-cue COUNT (see paintAttention), and it
|
|
1365
|
-
// is not a softening of this rule: a count names no session, so it needs no
|
|
1366
|
-
// arbitrary choice, and it changes only when a cue is raised or acknowledged
|
|
1367
|
-
// rather than on every progress tick. A per-session value in that prefix
|
|
1368
|
-
// would be this same mistake in a new place.
|
|
1180
|
+
// The percentage is deliberately NOT written into the document title: a page
|
|
1181
|
+
// has ONE title over many sessions, so any rule for whose percentage it shows
|
|
1182
|
+
// is arbitrary, and it churns the browser-tab label and bookmark name. The
|
|
1183
|
+
// unseen-cue COUNT is not a softening of this rule: it names no session.
|
|
1369
1184
|
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
// It hangs off syncChrome — the one function every list mutation already
|
|
1376
|
-
// ends with (reorder, create, adopt, close, reconcile) — so no path can
|
|
1377
|
-
// forget it. Three properties make that safe to run that often:
|
|
1378
|
-
//
|
|
1379
|
-
// - It is a no-op when the strip already matches, decided by one pass over
|
|
1380
|
-
// the list, so the status tick pays a comparison and nothing else.
|
|
1381
|
-
// - It is skipped mid-drag. The pointer owns the strip then, and the DOM
|
|
1382
|
-
// holds a preview that is deliberately not yet in tabList; re-sorting
|
|
1383
|
-
// under the user's finger would fight the gesture.
|
|
1384
|
-
// - It sorts by the same total order adoption inserts by (compareTabOrder),
|
|
1385
|
-
// so the two cannot disagree about where a tab belongs.
|
|
1386
|
-
//
|
|
1387
|
-
// A locally-committed reorder is already applied optimistically, and the
|
|
1388
|
-
// server echoes that same arrangement back, so the echo lands here as a
|
|
1389
|
-
// no-op rather than as a second visible move.
|
|
1185
|
+
// The read half of tab-order sync, hung off syncChrome so no path can forget
|
|
1186
|
+
// it. Safe that often because it is a no-op when the strip already matches,
|
|
1187
|
+
// skipped mid-drag (the DOM then holds a preview not yet in tabList), and
|
|
1188
|
+
// sorted by the same order adoption inserts by.
|
|
1390
1189
|
function applyServerOrder(): void {
|
|
1391
1190
|
if (draggingEl !== null) {
|
|
1392
1191
|
return;
|
|
@@ -1428,16 +1227,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1428
1227
|
}
|
|
1429
1228
|
}
|
|
1430
1229
|
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
// tree only after the collapse animation. It does not render rows or touch
|
|
1435
|
-
// the inline styles the interactive drag uses; expandSwitcher and the drag
|
|
1436
|
-
// release own those.
|
|
1230
|
+
// The RESTING state only: rows leave the a11y tree after the collapse
|
|
1231
|
+
// animation, and the inline styles the interactive drag writes are
|
|
1232
|
+
// expandSwitcher's and the drag release's to own.
|
|
1437
1233
|
function setExpandedState(on: boolean): void {
|
|
1438
1234
|
if (on) {
|
|
1439
1235
|
if (collapseClearTimer !== null) {
|
|
1440
|
-
clearTimeout(collapseClearTimer);
|
|
1236
|
+
win.clearTimeout(collapseClearTimer);
|
|
1441
1237
|
collapseClearTimer = null;
|
|
1442
1238
|
}
|
|
1443
1239
|
expanded = true;
|
|
@@ -1452,9 +1248,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1452
1248
|
switcher.classList.remove("wt-switcher-expanded");
|
|
1453
1249
|
swCurrent.setAttribute("aria-expanded", "false");
|
|
1454
1250
|
if (collapseClearTimer !== null) {
|
|
1455
|
-
clearTimeout(collapseClearTimer);
|
|
1251
|
+
win.clearTimeout(collapseClearTimer);
|
|
1456
1252
|
}
|
|
1457
|
-
collapseClearTimer = setTimeout(() => {
|
|
1253
|
+
collapseClearTimer = win.setTimeout(() => {
|
|
1458
1254
|
collapseClearTimer = null;
|
|
1459
1255
|
if (!expanded) {
|
|
1460
1256
|
clearRows();
|
|
@@ -1504,7 +1300,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1504
1300
|
}
|
|
1505
1301
|
|
|
1506
1302
|
function addTabChrome(info: StatusRecord): Tab {
|
|
1507
|
-
const el = fromHTML(TAB_HTML);
|
|
1303
|
+
const el = fromHTML(doc, TAB_HTML);
|
|
1508
1304
|
const label = el.querySelector<HTMLElement>(".wt-tab-label");
|
|
1509
1305
|
const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
|
|
1510
1306
|
const activityEl = el.querySelector<HTMLElement>(".wt-activity-mark");
|
|
@@ -1523,9 +1319,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1523
1319
|
// strip, where the animation never fires.
|
|
1524
1320
|
if (started) {
|
|
1525
1321
|
el.classList.add("wt-tab-enter");
|
|
1526
|
-
|
|
1322
|
+
after(300, () => {
|
|
1527
1323
|
el.classList.remove("wt-tab-enter");
|
|
1528
|
-
}
|
|
1324
|
+
});
|
|
1529
1325
|
}
|
|
1530
1326
|
|
|
1531
1327
|
const tab: Tab = {
|
|
@@ -1581,13 +1377,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1581
1377
|
}
|
|
1582
1378
|
switchTo(tab.id);
|
|
1583
1379
|
});
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
//
|
|
1587
|
-
// not reachable by touch and this is. The chip's touch-action:manipulation
|
|
1588
|
-
// (CSS) is what stops iOS eating the second tap as double-tap-to-zoom.
|
|
1589
|
-
// The leading click switches to the tab first; Finder selects before
|
|
1590
|
-
// renaming too, so that matches expectation.
|
|
1380
|
+
// Also the TOUCH rename path: a long-press on a draggable chip starts a
|
|
1381
|
+
// reorder drag on iPadOS instead of opening the menu. The chip's
|
|
1382
|
+
// touch-action: manipulation keeps iOS from reading the second tap as a zoom.
|
|
1591
1383
|
el.addEventListener("dblclick", (e) => {
|
|
1592
1384
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1593
1385
|
return;
|
|
@@ -1708,6 +1500,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1708
1500
|
});
|
|
1709
1501
|
el.addEventListener("dragend", () => {
|
|
1710
1502
|
el.classList.remove("wt-tab-dragging");
|
|
1503
|
+
paintDropHalf(null);
|
|
1711
1504
|
if (!dropped) {
|
|
1712
1505
|
// No drop fired, so the drag was abandoned: Escape, or a release the
|
|
1713
1506
|
// browser refused. Put the strip back — tabList still holds the order
|
|
@@ -1723,55 +1516,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1723
1516
|
return tab;
|
|
1724
1517
|
}
|
|
1725
1518
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
// one press on another tab (see create() for the evidence that this
|
|
1733
|
-
// device delivers those). The first activation switches and focuses the
|
|
1734
|
-
// terminal; the second one's mousedown re-focuses the chip and then fell
|
|
1735
|
-
// out here with the keyboard stranded on it — the reported "the
|
|
1736
|
-
// invisible input loses focus when I click another tab", and the same
|
|
1737
|
-
// hole a plain mouse click on the active tab hit on every platform.
|
|
1738
|
-
focusAfterSwitch();
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
1519
|
+
/** Show `id` in the pane on `side`: the pane's current tab becomes an
|
|
1520
|
+
* ordinary tab, a pane elsewhere showing `id` is emptied first (a session is
|
|
1521
|
+
* never shown twice), and the pane is selected. False when `side` names no
|
|
1522
|
+
* pane that can show a tab (absent, failed or hidden) or `id` no tab. */
|
|
1523
|
+
function showIn(side: PaneSide, id: string, dir?: "next" | "prev"): boolean {
|
|
1524
|
+
const pane = ctx.shell.pane(side);
|
|
1741
1525
|
const next = tabList.find((t) => t.id === id);
|
|
1742
|
-
if (!next) {
|
|
1743
|
-
return;
|
|
1526
|
+
if (pane === null || !next) {
|
|
1527
|
+
return false;
|
|
1744
1528
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if (
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1529
|
+
const state = pane.state();
|
|
1530
|
+
if (state === "failed" || state === "hidden") {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
const curId = shownIn(side);
|
|
1534
|
+
if (curId === id) {
|
|
1535
|
+
// The press that delivered this click has already moved the keyboard onto
|
|
1536
|
+
// the chip, so the FOCUS rule still owes an answer or the input stays
|
|
1537
|
+
// stranded (a click on the shown tab, or the second activation of one
|
|
1538
|
+
// press).
|
|
1539
|
+
ctx.shell.select(side);
|
|
1540
|
+
focusAfterSwitch();
|
|
1541
|
+
return true;
|
|
1542
|
+
}
|
|
1543
|
+
const from = sideOf(id);
|
|
1544
|
+
if (from !== null) {
|
|
1545
|
+
const losing = ctx.shell.pane(from);
|
|
1546
|
+
if (losing !== null) {
|
|
1547
|
+
next.view = losing.render.captureViewMemory();
|
|
1548
|
+
losing.clearActiveSession();
|
|
1755
1549
|
}
|
|
1756
1550
|
}
|
|
1757
|
-
//
|
|
1758
|
-
//
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1551
|
+
// A later tab slides in from the right, an earlier one from the left, so a
|
|
1552
|
+
// desktop switch feels like the mobile swipe.
|
|
1553
|
+
let slide = dir;
|
|
1554
|
+
const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
|
|
1555
|
+
const toIdx = tabList.findIndex((t) => t.id === id);
|
|
1556
|
+
if (slide === undefined && fromIdx >= 0) {
|
|
1557
|
+
slide = toIdx > fromIdx ? "next" : "prev";
|
|
1558
|
+
}
|
|
1559
|
+
// A LINE, never surface.scrollTop: the line survives the rebuild and the
|
|
1560
|
+
// background output this tab's session produces before we come back.
|
|
1561
|
+
const cur = curId === null ? undefined : tabList.find((t) => t.id === curId);
|
|
1764
1562
|
if (cur) {
|
|
1765
|
-
cur.view =
|
|
1766
|
-
}
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
// the
|
|
1770
|
-
// into their new slots after it.
|
|
1771
|
-
const fromIdx = tabList.findIndex((t) => t.id === activeId);
|
|
1772
|
-
const toIdx = tabList.findIndex((t) => t.id === next.id);
|
|
1773
|
-
// A one-step move: adjacent, OR a wrap between the first and last tab
|
|
1774
|
-
// (index gap n-1), since the list rotates infinitely.
|
|
1563
|
+
cur.view = pane.render.captureViewMemory();
|
|
1564
|
+
}
|
|
1565
|
+
// The expanded list rotates only for a swipe to an adjacent tab, wrap
|
|
1566
|
+
// between first and last included. prepareReel snapshots the rows BEFORE
|
|
1567
|
+
// the reconcile; the returned closure FLIPs them after it.
|
|
1775
1568
|
const stepGap = Math.abs(toIdx - fromIdx);
|
|
1776
1569
|
let playReel: (() => void) | undefined;
|
|
1777
1570
|
if (
|
|
@@ -1783,207 +1576,218 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1783
1576
|
) {
|
|
1784
1577
|
playReel = prepareReel(slide, next);
|
|
1785
1578
|
}
|
|
1786
|
-
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
//
|
|
1790
|
-
// The view goes in WITH the bind, which is what makes the swap atomic.
|
|
1791
|
-
// The engine's follow flag is GLOBAL (one per kernel), so the first
|
|
1792
|
-
// flush after a bind is gated on whatever state the tab we LEFT was in:
|
|
1793
|
-
// binding a following tab right after being scrolled up in another left
|
|
1794
|
-
// the controller holding, the post-flush stickToBottom() no-op'd, and the
|
|
1795
|
-
// cached screen rendered above the viewport — a black gap until a touch
|
|
1796
|
-
// scrolled it and re-engaged follow (the "content pops down when I
|
|
1797
|
-
// wiggle it" symptom). bind adopts the incoming follow state
|
|
1798
|
-
// synchronously, before the wipe, and re-asserts the incoming reading
|
|
1799
|
-
// POSITION across the rebuild's frames until the line it names has
|
|
1800
|
-
// actually been built. That second half is why this is no longer a
|
|
1801
|
-
// fire-and-forget rAF: a single deferred write landed while only ~301 of
|
|
1802
|
-
// up to 5000 rows existed, so the browser clamped it away and nothing
|
|
1803
|
-
// retried (engine docs/scroll-position-fidelity.md §1.1, §3.3, §3.4).
|
|
1804
|
-
activeId = next.id;
|
|
1805
|
-
// Arriving on the tab that raised the switch-button cue resolves it
|
|
1806
|
-
// (a swipe through the tabs must dismiss the dot, not only opening the
|
|
1807
|
-
// list). Its current latch is acknowledged whether or not it was the
|
|
1808
|
-
// cue's subject: the terminal is now on screen, so a reload must not
|
|
1809
|
-
// notify about a state the user just looked at.
|
|
1579
|
+
bootShowedNothing = false;
|
|
1580
|
+
// The terminal is now on screen, so a reload must not notify about a state
|
|
1581
|
+
// the user just looked at.
|
|
1810
1582
|
acknowledgeSwitchNotify(next.id);
|
|
1811
1583
|
markCueSeen(next.id, next.dot.dataset["status"] ?? "");
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
//
|
|
1820
|
-
//
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1584
|
+
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1585
|
+
// follow flag is one per renderer, so a bind without it gated the first
|
|
1586
|
+
// flush on the state of the tab we LEFT and the cached screen rendered
|
|
1587
|
+
// above the viewport until a touch re-engaged follow.
|
|
1588
|
+
pane.render.bind(next.store, { view: next.view });
|
|
1589
|
+
pane.notifySwitch({ id: next.id });
|
|
1590
|
+
// Mark the reconcile as reel-driven so renderSwitcherList suppresses its
|
|
1591
|
+
// add/remove row animation (the reel owns row motion here); set before the
|
|
1592
|
+
// selection change, whose listener reconciles the list too.
|
|
1593
|
+
reelReconcile = playReel !== undefined;
|
|
1594
|
+
ctx.shell.select(side);
|
|
1595
|
+
scheduleLayoutWrite();
|
|
1596
|
+
// Only when the user is about to wait: a revisited tab with a warm store
|
|
1597
|
+
// paints from cache in one frame and must not flash a cue at every switch.
|
|
1824
1598
|
if (
|
|
1825
|
-
|
|
1826
|
-
|
|
1599
|
+
pane.render.pendingRowCount() > CATCHUP_MIN_BACKLOG ||
|
|
1600
|
+
pane.render.getHighestIndex() < 0
|
|
1827
1601
|
) {
|
|
1828
|
-
armCatchup();
|
|
1602
|
+
armCatchup(pane);
|
|
1829
1603
|
}
|
|
1830
|
-
flashSwitch(slide);
|
|
1831
|
-
// Mark the reconcile as reel-driven so renderSwitcherList suppresses its
|
|
1832
|
-
// add/remove row animation (the reel owns row motion here).
|
|
1833
|
-
reelReconcile = playReel !== undefined;
|
|
1604
|
+
flashSwitch(pane.surface(), slide);
|
|
1834
1605
|
syncChrome(); // reconciles the expanded list into the new order
|
|
1835
1606
|
reelReconcile = false;
|
|
1836
1607
|
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
1837
1608
|
ctx.announce(`Switched to ${next.display}`);
|
|
1838
1609
|
focusAfterSwitch();
|
|
1610
|
+
return true;
|
|
1839
1611
|
}
|
|
1840
1612
|
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
// them. Clearing on the first screen frame fires long before a large
|
|
1853
|
-
// restore has landed (the frame that carries the live window arrives
|
|
1854
|
-
// first). Comparing the store's highest index against the resumeAck's
|
|
1855
|
-
// `committed` fails for the same reason and more sharply: the window frame
|
|
1856
|
-
// delivers the HIGHEST indices, so highest reaches the target while every
|
|
1857
|
-
// history line below it is still in flight. Measured on a 4000-line phone
|
|
1858
|
-
// restore: the cue cleared immediately and stayed clear for the whole fill.
|
|
1859
|
-
// catchupWarranted: the surface still owes the user content. Either rows are
|
|
1860
|
-
// queued for building (a resume replay, a switch rebuild, a large burst), or
|
|
1861
|
-
// the tab holds nothing at all, which means its screen is still on the
|
|
1862
|
-
// network. The second half matters as much as the first: without it, the
|
|
1863
|
-
// case the cue is most needed for — switching into a tab that has never been
|
|
1864
|
-
// viewed — has an empty queue and would never show it.
|
|
1865
|
-
const catchupWarranted = (): boolean =>
|
|
1866
|
-
ctx.render.pendingRowCount() > 0 || ctx.render.getHighestIndex() < 0;
|
|
1613
|
+
// The tab-click rule: a tab a pane shows has that pane selected; any other
|
|
1614
|
+
// tab fills the first empty pane, else replaces the selected pane's tab.
|
|
1615
|
+
function switchTo(id: string, dir?: "next" | "prev"): void {
|
|
1616
|
+
const shownOn = sideOf(id);
|
|
1617
|
+
if (shownOn !== null) {
|
|
1618
|
+
ctx.shell.select(shownOn);
|
|
1619
|
+
focusAfterSwitch();
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
showIn(ctx.shell.targetFor(id), id, dir);
|
|
1623
|
+
}
|
|
1867
1624
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1625
|
+
// The one rule every split entry point reduces to: the named side shows the
|
|
1626
|
+
// tab, whatever was there becomes an ordinary tab, the tab's old side becomes
|
|
1627
|
+
// empty, and the tab's pane is selected.
|
|
1628
|
+
function snap(id: string, side: PaneSide): boolean {
|
|
1629
|
+
if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
const split = ctx.shell.split;
|
|
1633
|
+
if (!split.isOpen() && (!split.canOpen() || !split.open())) {
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
return showIn(side, id);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// A switched-into tab's cached screen is stale until its resume delta lands,
|
|
1640
|
+
// so it must not read as live. The cue measures the RENDER backlog of the
|
|
1641
|
+
// pane it sits in, which is what the user waits on whatever produced it: not
|
|
1642
|
+
// the first screen frame and not the resumeAck's `committed`, because the
|
|
1643
|
+
// window frame delivers the HIGHEST indices first and both cleared a
|
|
1644
|
+
// 4000-line restore at once. The second half covers a never-viewed tab,
|
|
1645
|
+
// whose queue is empty while its screen is on the network.
|
|
1646
|
+
const catchupWarranted = (pane: PaneHandle): boolean =>
|
|
1647
|
+
pane.render.pendingRowCount() > 0 || pane.render.getHighestIndex() < 0;
|
|
1648
|
+
|
|
1649
|
+
interface PaneCatchup {
|
|
1650
|
+
readonly pane: PaneHandle;
|
|
1651
|
+
readonly el: HTMLElement;
|
|
1652
|
+
/** The anti-flicker delay before the cue shows. */
|
|
1653
|
+
timer: number | null;
|
|
1654
|
+
/** The completion poll, on rAF because the renderer has no "queue drained" event. */
|
|
1655
|
+
poll: number | null;
|
|
1656
|
+
emptySince: number;
|
|
1657
|
+
deadline: number;
|
|
1658
|
+
offScreen: Unsubscribe;
|
|
1659
|
+
}
|
|
1660
|
+
const catchups = new Map<PaneHandle, PaneCatchup>();
|
|
1661
|
+
function armCatchup(pane: PaneHandle): void {
|
|
1662
|
+
const c = catchups.get(pane);
|
|
1663
|
+
if (c === undefined) {
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (c.timer === null && !c.el.classList.contains("visible")) {
|
|
1667
|
+
c.timer = win.setTimeout(() => {
|
|
1668
|
+
c.timer = null;
|
|
1669
|
+
// A burst that drains inside the anti-flicker delay has nothing to
|
|
1670
|
+
// report, and the clear path needs CATCHUP_SETTLE_MS of quiet.
|
|
1671
|
+
if (catchupWarranted(pane)) {
|
|
1672
|
+
c.el.classList.add("visible");
|
|
1880
1673
|
}
|
|
1881
1674
|
}, 150);
|
|
1882
1675
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
if (
|
|
1886
|
-
pollCatchup();
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
catchupEmptySince = Date.now();
|
|
1899
|
-
} else if (Date.now() - catchupEmptySince >= CATCHUP_SETTLE_MS) {
|
|
1900
|
-
clearCatchup();
|
|
1676
|
+
c.deadline = win.Date.now() + CATCHUP_MAX_MS;
|
|
1677
|
+
c.emptySince = 0;
|
|
1678
|
+
if (c.poll === null) {
|
|
1679
|
+
pollCatchup(c);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function pollCatchup(c: PaneCatchup): void {
|
|
1683
|
+
c.poll = win.requestAnimationFrame(() => {
|
|
1684
|
+
c.poll = null;
|
|
1685
|
+
if (catchupWarranted(c.pane)) {
|
|
1686
|
+
c.emptySince = 0;
|
|
1687
|
+
} else if (c.emptySince === 0) {
|
|
1688
|
+
c.emptySince = win.Date.now();
|
|
1689
|
+
} else if (win.Date.now() - c.emptySince >= CATCHUP_SETTLE_MS) {
|
|
1690
|
+
clearCatchup(c);
|
|
1901
1691
|
return;
|
|
1902
1692
|
}
|
|
1903
|
-
if (Date.now() >
|
|
1904
|
-
clearCatchup();
|
|
1693
|
+
if (win.Date.now() > c.deadline) {
|
|
1694
|
+
clearCatchup(c);
|
|
1905
1695
|
return;
|
|
1906
1696
|
}
|
|
1907
|
-
pollCatchup();
|
|
1697
|
+
pollCatchup(c);
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
function clearCatchup(c: PaneCatchup): void {
|
|
1701
|
+
if (c.timer !== null) {
|
|
1702
|
+
win.clearTimeout(c.timer);
|
|
1703
|
+
c.timer = null;
|
|
1704
|
+
}
|
|
1705
|
+
if (c.poll !== null) {
|
|
1706
|
+
win.cancelAnimationFrame(c.poll);
|
|
1707
|
+
c.poll = null;
|
|
1708
|
+
}
|
|
1709
|
+
c.emptySince = 0;
|
|
1710
|
+
c.el.classList.remove("visible");
|
|
1711
|
+
}
|
|
1712
|
+
function dropCatchup(c: PaneCatchup): void {
|
|
1713
|
+
clearCatchup(c);
|
|
1714
|
+
c.offScreen();
|
|
1715
|
+
c.el.remove();
|
|
1716
|
+
catchups.delete(c.pane);
|
|
1717
|
+
}
|
|
1718
|
+
// Any frame can reveal a backlog worth telling the user about: a resume
|
|
1719
|
+
// replay after a wake or reconnect, the rebuild after a tab switch, or a
|
|
1720
|
+
// program that dumped thousands of lines at once. Arming off the backlog
|
|
1721
|
+
// itself is what makes the cue fire on a wake; the threshold keeps ordinary
|
|
1722
|
+
// streaming out of it, so the completion poll only exists while there is
|
|
1723
|
+
// something to complete.
|
|
1724
|
+
function watchCatchup(pane: PaneHandle): void {
|
|
1725
|
+
const el = doc.createElement("div");
|
|
1726
|
+
el.className = "wt-catchup";
|
|
1727
|
+
el.setAttribute("role", "status");
|
|
1728
|
+
el.textContent = "Catching up\u2026";
|
|
1729
|
+
pane.region("banner", "catchup").appendChild(el);
|
|
1730
|
+
catchups.set(pane, {
|
|
1731
|
+
pane,
|
|
1732
|
+
el,
|
|
1733
|
+
timer: null,
|
|
1734
|
+
poll: null,
|
|
1735
|
+
emptySince: 0,
|
|
1736
|
+
deadline: 0,
|
|
1737
|
+
offScreen: pane.on("wire:screen", () => {
|
|
1738
|
+
if (pane.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
|
|
1739
|
+
armCatchup(pane);
|
|
1740
|
+
}
|
|
1741
|
+
}),
|
|
1908
1742
|
});
|
|
1909
1743
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1744
|
+
/** A cue for every live pane, the one a later open builds included; a pane
|
|
1745
|
+
* torn down or failed loses its cue with its timers. */
|
|
1746
|
+
function syncCatchups(): void {
|
|
1747
|
+
const live = new Set(ctx.shell.panes().filter((p) => p.state() !== "failed"));
|
|
1748
|
+
for (const c of [...catchups.values()]) {
|
|
1749
|
+
if (!live.has(c.pane)) {
|
|
1750
|
+
dropCatchup(c);
|
|
1751
|
+
}
|
|
1914
1752
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1753
|
+
for (const pane of live) {
|
|
1754
|
+
if (!catchups.has(pane)) {
|
|
1755
|
+
watchCatchup(pane);
|
|
1756
|
+
}
|
|
1918
1757
|
}
|
|
1919
|
-
catchupEmptySince = 0;
|
|
1920
|
-
catchup.classList.remove("visible");
|
|
1921
1758
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
// The class comes off on the animation's OWN end
|
|
1929
|
-
//
|
|
1930
|
-
//
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
// The timer CANNOT be dropped, and three cases are why:
|
|
1936
|
-
// - an interrupted animation fires no animationend, and animationcancel is
|
|
1937
|
-
// not reliably delivered in Blink. A rapid re-switch is exactly that
|
|
1938
|
-
// case, and it is the case the rAF above exists to serve.
|
|
1939
|
-
// - the animations feature is optional and removes .wt-animate under
|
|
1940
|
-
// reduced motion, so no animation runs and no event ever fires.
|
|
1941
|
-
// - a consumer stylesheet could drop the rules entirely.
|
|
1942
|
-
//
|
|
1943
|
-
// The listener filters on the ONE animation name this switch expects AND on
|
|
1944
|
-
// the class still being present. Three notes, because each was argued:
|
|
1945
|
-
// - The name must be the expected one, not any of the three. An animation
|
|
1946
|
-
// that COMPLETED just as a re-switch landed has its event already queued;
|
|
1947
|
-
// the old listener is gone by dispatch time, so the NEW listener receives
|
|
1948
|
-
// it, and a listener accepting all three names would let switch N's
|
|
1949
|
-
// completion end switch N+1's animation a frame in.
|
|
1950
|
-
// - The class check closes the window BEFORE the animation starts. The
|
|
1951
|
-
// listener is attached in this task and the class lands a frame later, so
|
|
1952
|
-
// for one frame a matching `animationend` from anywhere in the subtree
|
|
1953
|
-
// would cancel the pending class-add and skip the animation outright. An
|
|
1954
|
-
// animationend cannot precede its own animation, so requiring the class is
|
|
1955
|
-
// free.
|
|
1956
|
-
// - No generation counter, and no target check. A generation was tried and
|
|
1957
|
-
// removed: `endSwitchAnim` removes the listener synchronously before the
|
|
1958
|
-
// next one is added, so a stale listener cannot receive an event and the
|
|
1959
|
-
// counter could never fire (its red check could not be made to fail). A
|
|
1960
|
-
// target check would couple this feature to the kernel's `.term-output`
|
|
1961
|
-
// markup, which it does not own; the residual risk is a consumer applying
|
|
1962
|
-
// one of these three library-private keyframe names to another element
|
|
1963
|
-
// inside the surface, and the class check already reduces that to "ends a
|
|
1964
|
-
// running switch animation early" rather than "skips it".
|
|
1759
|
+
syncCatchups();
|
|
1760
|
+
ctx.defer(() => {
|
|
1761
|
+
for (const c of [...catchups.values()]) {
|
|
1762
|
+
dropCatchup(c);
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
// The switch class comes off on the animation's OWN end; the timer is the
|
|
1766
|
+
// net, and it cannot be dropped: an interrupted animation fires no
|
|
1767
|
+
// animationend (animationcancel is unreliable in Blink), reduced motion
|
|
1768
|
+
// removes .wt-animate so no event ever fires, and a consumer stylesheet may
|
|
1769
|
+
// drop the rules. The listener filters on the ONE expected animation name
|
|
1770
|
+
// (switch N's queued completion would otherwise end switch N+1's animation)
|
|
1771
|
+
// AND on the class being present (the class lands a frame after the listener).
|
|
1965
1772
|
const SWITCH_ANIM_NET_MS = 360;
|
|
1966
|
-
let switchAnimTimer:
|
|
1773
|
+
let switchAnimTimer: number | null = null;
|
|
1967
1774
|
let switchAnimFrame: number | null = null;
|
|
1968
1775
|
let switchAnimOff: (() => void) | null = null;
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
// a forward switch could animate backwards.
|
|
1979
|
-
function endSwitchAnim(surface: HTMLElement): void {
|
|
1980
|
-
surface.classList.remove(...SWITCH_CLASSES);
|
|
1776
|
+
// The surface mid-animation; a switch in the other pane ends this one first.
|
|
1777
|
+
let switchAnimSurface: HTMLElement | null = null;
|
|
1778
|
+
|
|
1779
|
+
// All three torn down together: a surviving rAF would ADD the previous
|
|
1780
|
+
// direction's class beside the new one, and the cascade's winner is the LAST
|
|
1781
|
+
// rule in the stylesheet, so a forward switch could animate backwards.
|
|
1782
|
+
function endSwitchAnim(): void {
|
|
1783
|
+
switchAnimSurface?.classList.remove(...SWITCH_CLASSES);
|
|
1784
|
+
switchAnimSurface = null;
|
|
1981
1785
|
if (switchAnimTimer !== null) {
|
|
1982
|
-
clearTimeout(switchAnimTimer);
|
|
1786
|
+
win.clearTimeout(switchAnimTimer);
|
|
1983
1787
|
switchAnimTimer = null;
|
|
1984
1788
|
}
|
|
1985
1789
|
if (switchAnimFrame !== null) {
|
|
1986
|
-
cancelAnimationFrame(switchAnimFrame);
|
|
1790
|
+
win.cancelAnimationFrame(switchAnimFrame);
|
|
1987
1791
|
switchAnimFrame = null;
|
|
1988
1792
|
}
|
|
1989
1793
|
if (switchAnimOff !== null) {
|
|
@@ -1992,57 +1796,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1992
1796
|
}
|
|
1993
1797
|
}
|
|
1994
1798
|
|
|
1995
|
-
function flashSwitch(dir?: "next" | "prev"): void {
|
|
1996
|
-
const surface = ctx.surface();
|
|
1799
|
+
function flashSwitch(surface: HTMLElement, dir?: "next" | "prev"): void {
|
|
1997
1800
|
const cls = dir ? (`wt-switching-${dir}` as const) : ("wt-switching" as const);
|
|
1998
|
-
endSwitchAnim(
|
|
1801
|
+
endSwitchAnim();
|
|
1802
|
+
switchAnimSurface = surface;
|
|
1999
1803
|
const expected = SWITCH_ANIMATIONS[cls];
|
|
2000
1804
|
const onAnimEnd = (ev: AnimationEvent): void => {
|
|
2001
1805
|
if (ev.animationName !== expected || !surface.classList.contains(cls)) {
|
|
2002
1806
|
return;
|
|
2003
1807
|
}
|
|
2004
|
-
endSwitchAnim(
|
|
1808
|
+
endSwitchAnim();
|
|
2005
1809
|
};
|
|
2006
1810
|
surface.addEventListener("animationend", onAnimEnd);
|
|
2007
1811
|
switchAnimOff = (): void => {
|
|
2008
1812
|
surface.removeEventListener("animationend", onAnimEnd);
|
|
2009
1813
|
};
|
|
2010
|
-
// The net is armed INSIDE the frame that adds the class
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
|
|
2014
|
-
// and its `endSwitchAnim` would cancel the pending class-add and skip the
|
|
2015
|
-
// animation outright. Armed here, a frame that never runs arms nothing and
|
|
2016
|
-
// leaves no class to strip.
|
|
2017
|
-
switchAnimFrame = requestAnimationFrame(() => {
|
|
1814
|
+
// The net is armed INSIDE the frame that adds the class: rAF pauses in a
|
|
1815
|
+
// hidden document, so a timer armed beside it could fire first and its
|
|
1816
|
+
// endSwitchAnim would cancel the pending class-add and skip the animation.
|
|
1817
|
+
switchAnimFrame = win.requestAnimationFrame(() => {
|
|
2018
1818
|
switchAnimFrame = null;
|
|
2019
1819
|
surface.classList.add(cls);
|
|
2020
|
-
switchAnimTimer = setTimeout(() => {
|
|
1820
|
+
switchAnimTimer = win.setTimeout(() => {
|
|
2021
1821
|
switchAnimTimer = null;
|
|
2022
|
-
endSwitchAnim(
|
|
1822
|
+
endSwitchAnim();
|
|
2023
1823
|
}, SWITCH_ANIM_NET_MS);
|
|
2024
1824
|
});
|
|
2025
1825
|
}
|
|
2026
1826
|
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
// program that dumped thousands of lines at once. Arming off the backlog
|
|
2030
|
-
// itself is what makes the cue fire on a wake, which is where it used to be
|
|
2031
|
-
// blind (it was armed only by an explicit tab switch). The threshold keeps
|
|
2032
|
-
// ordinary streaming out of it entirely, so the completion poll only exists
|
|
2033
|
-
// while there is something to complete.
|
|
2034
|
-
ctx.on("wire:screen", () => {
|
|
2035
|
-
if (ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
|
|
2036
|
-
armCatchup();
|
|
2037
|
-
}
|
|
2038
|
-
});
|
|
2039
|
-
|
|
2040
|
-
// Live window-title updates for the active session: the engine sends a
|
|
2041
|
-
// TITLE frame on the live socket whenever the process changes its OSC 0/2
|
|
2042
|
-
// title, which the kernel republishes as wire:title. baseLabel prefers the
|
|
2043
|
-
// OSC title, so applying it here updates the active tab's label at once
|
|
2044
|
-
// rather than waiting for the next status-SSE/poll sweep (background tabs,
|
|
2045
|
-
// which have no live socket, still refresh their title from that sweep).
|
|
1827
|
+
// A shown session's OSC 0/2 title arrives on its live socket and relabels
|
|
1828
|
+
// the tab at once; a background tab's title waits for the status sweep.
|
|
2046
1829
|
ctx.on("wire:title", ({ session, title }) => {
|
|
2047
1830
|
const t = tabList.find((x) => x.id === session);
|
|
2048
1831
|
// Ignore a blank title (an OSC 0/2 clear the process may emit when it
|
|
@@ -2063,7 +1846,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2063
1846
|
if (n < 2) {
|
|
2064
1847
|
return;
|
|
2065
1848
|
}
|
|
2066
|
-
const
|
|
1849
|
+
const current = selectedId();
|
|
1850
|
+
const idx = tabList.findIndex((t) => t.id === current);
|
|
2067
1851
|
if (idx < 0) {
|
|
2068
1852
|
return;
|
|
2069
1853
|
}
|
|
@@ -2075,73 +1859,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2075
1859
|
}
|
|
2076
1860
|
}
|
|
2077
1861
|
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2089
|
-
//
|
|
2090
|
-
// under the pointer, and one of the two had to stop pretending to be the tab):
|
|
2091
|
-
//
|
|
2092
|
-
// - SWEEPING across the strip rearranges NOTHING. Crossing five tabs used to
|
|
2093
|
-
// move all five, which is the whole complaint.
|
|
2094
|
-
// - COMING TO REST opens the slot (trackRest -> commitSlot): one DOM move,
|
|
2095
|
-
// the displaced chips slide from their old positions to their new ones, and
|
|
2096
|
-
// the slot fades in at its new home.
|
|
2097
|
-
//
|
|
2098
|
-
// Rest is DETECTED, not waited out: REORDER_REST_MS is re-armed by movement, so
|
|
2099
|
-
// it expires a rest window after the last MOVEMENT rather than a fixed time
|
|
2100
|
-
// after a decision. Two shapes were tried and removed before this one, and both
|
|
2101
|
-
// failures are worth keeping: a fixed one-second HOLD (wrong shape for a direct
|
|
2102
|
-
// manipulation — it delayed the one case that should be instant, and grew a
|
|
2103
|
-
// progress bar to explain the wait, which is the tell that the wait should not
|
|
2104
|
-
// have been there), and a sweep-time LEAN of the chips a commit would displace
|
|
2105
|
-
// (it read as a second, competing preview and its distance from the real move
|
|
2106
|
-
// was too small to tell them apart).
|
|
2107
|
-
//
|
|
2108
|
-
// A release never waits either: `drop` commits whatever slot is pending
|
|
2109
|
-
// (the drop handler commits the slot under the pointer), so dropping mid-sweep
|
|
2110
|
-
// still lands the tab exactly where it was headed.
|
|
2111
|
-
//
|
|
2112
|
-
// dropTargetBefore returns the first tab whose horizontal midpoint is past
|
|
2113
|
-
// x (the element the dragged tab should sit before), or null to drop at the
|
|
2114
|
-
// end of the tab list. syncOrderFromDom rebuilds tabList to match the
|
|
2115
|
-
// strip's DOM order after a drag, so position indicators, the switcher, and
|
|
2116
|
-
// close-to-the-right/left all follow the visible order.
|
|
2117
|
-
//
|
|
2118
|
-
// It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
|
|
2119
|
-
// getBoundingClientRect. That is not an optimisation, it is what makes an
|
|
2120
|
-
// animated reorder hit-testable at all: a rect read while a chip is mid-slide
|
|
2121
|
-
// returns the INTERPOLATED position, so the preview's own motion feeds
|
|
2122
|
-
// back into the decision that produced it and the strip oscillates between two
|
|
2123
|
-
// slots for as long as the pointer sits near a boundary. Layout offsets are the
|
|
2124
|
-
// chip's position in the FLOW, which a transform does not affect at all.
|
|
2125
|
-
//
|
|
2126
|
-
// An earlier draft instead cached rects measured "at rest" and re-measured after
|
|
2127
|
-
// each commit. Two things killed it: the cache had to be invalidated on anything
|
|
2128
|
-
// that reflowed the strip (a window or embed resize changes every chip's midpoint
|
|
2129
|
-
// without changing the chip SET, which was the only thing the cache checked), and
|
|
2130
|
-
// it could capture a transform anyway, because a chip adopted mid-drag arrives
|
|
2131
|
-
// mid `.wt-tab-enter` scale. Reading layout needs no cache and cannot go stale.
|
|
2132
|
-
//
|
|
2133
|
-
// Coordinates: chips share the scroller's offsetParent (the scroller is not
|
|
2134
|
-
// positioned, so it is not one), which makes `el.offsetLeft - scroller.offsetLeft`
|
|
2135
|
-
// the chip's offset inside the scroller — and the scroller has no border for that
|
|
2136
|
-
// subtraction to skip. Layout offsets ignore scrolling, so clientX is converted
|
|
2137
|
-
// into the same space by adding scrollLeft back, which is what lets a scrolled
|
|
2138
|
-
// strip hit-test correctly.
|
|
1862
|
+
// A MOVING pointer rearranges nothing and a STOPPED one opens the slot; the
|
|
1863
|
+
// dragged chip stays in the flow as the slot it will land in, drawn as a
|
|
1864
|
+
// dashed outline because the solid copy is the drag image under the pointer.
|
|
1865
|
+
// Rest is detected from movement, not waited out (a fixed hold delayed the
|
|
1866
|
+
// one case that should be instant), and a drop commits the slot under the
|
|
1867
|
+
// pointer at once.
|
|
1868
|
+
|
|
1869
|
+
// The first tab whose midpoint is past x, or null for the end. Hit-tests
|
|
1870
|
+
// LAYOUT geometry, never a rect: a rect read mid-slide is the INTERPOLATED
|
|
1871
|
+
// position, so the preview's own motion fed back into the decision and the
|
|
1872
|
+
// strip oscillated near a boundary. Layout offsets ignore scrolling, so
|
|
1873
|
+
// clientX is converted by adding scrollLeft back.
|
|
2139
1874
|
function dropTargetBefore(clientX: number): HTMLElement | null {
|
|
2140
1875
|
const x = clientX - scroller.getBoundingClientRect().left + scroller.scrollLeft;
|
|
2141
1876
|
const base = scroller.offsetLeft;
|
|
2142
1877
|
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
2143
1878
|
if (el === draggingEl) {
|
|
2144
|
-
continue;
|
|
1879
|
+
continue;
|
|
2145
1880
|
}
|
|
2146
1881
|
if (x < el.offsetLeft - base + el.offsetWidth / 2) {
|
|
2147
1882
|
return el;
|
|
@@ -2150,22 +1885,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2150
1885
|
return null;
|
|
2151
1886
|
}
|
|
2152
1887
|
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2157
|
-
// It writes the individual `translate` property, NOT `transform`, and that is
|
|
2158
|
-
// load-bearing rather than stylistic. Declarations from a running CSS ANIMATION
|
|
2159
|
-
// out-rank normal author declarations, inline style included, so a chip in the
|
|
2160
|
-
// middle of `wt-slot-in` (`transform: scale(0.97)`) or `wt-tab-in`
|
|
2161
|
-
// (`transform: scale(0.82)`) would silently ignore an inline `transform` and
|
|
2162
|
-
// refuse to move: an Escape landing inside the slot fade would snap the dragged
|
|
2163
|
-
// chip home while its siblings slid. `translate` is a separate property that
|
|
2164
|
-
// composes with `transform` instead of fighting it, so the two animations can own
|
|
2165
|
-
// one chip at the same time.
|
|
1888
|
+
// `translate`, NOT `transform`: a running CSS ANIMATION out-ranks inline
|
|
1889
|
+
// style, so a chip mid `wt-slot-in` or `wt-tab-in` (both `transform: scale`)
|
|
1890
|
+
// would ignore an inline transform and refuse to move. `translate` composes
|
|
1891
|
+
// with it instead, so the two can own one chip at the same time.
|
|
2166
1892
|
function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
|
|
2167
1893
|
if (shiftTimer !== null) {
|
|
2168
|
-
clearTimeout(shiftTimer);
|
|
1894
|
+
win.clearTimeout(shiftTimer);
|
|
2169
1895
|
shiftTimer = null;
|
|
2170
1896
|
}
|
|
2171
1897
|
for (const [el, dx] of px) {
|
|
@@ -2174,13 +1900,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2174
1900
|
shifted.add(el);
|
|
2175
1901
|
}
|
|
2176
1902
|
}
|
|
2177
|
-
//
|
|
2178
|
-
// write the same two inline properties, so this one function ends whichever
|
|
2179
|
-
// ran last — called before a fresh measurement, by the settle timer after a
|
|
2180
|
-
// slide, when the drag ends, and on teardown. Idempotent.
|
|
1903
|
+
// Hands every displaced chip back to the stylesheet. Idempotent.
|
|
2181
1904
|
function endShift(): void {
|
|
2182
1905
|
if (shiftTimer !== null) {
|
|
2183
|
-
clearTimeout(shiftTimer);
|
|
1906
|
+
win.clearTimeout(shiftTimer);
|
|
2184
1907
|
shiftTimer = null;
|
|
2185
1908
|
}
|
|
2186
1909
|
for (const el of shifted) {
|
|
@@ -2190,62 +1913,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2190
1913
|
shifted.clear();
|
|
2191
1914
|
}
|
|
2192
1915
|
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
// slot opens.
|
|
2198
|
-
//
|
|
2199
|
-
// Rest is detected from TWO signals, and separating them is what lets the slot
|
|
2200
|
-
// open promptly without a fast sweep committing every slot it crosses:
|
|
2201
|
-
//
|
|
2202
|
-
// - a `dragover` at an unchanged position is POSITIVE evidence of a stop, so it
|
|
2203
|
-
// commits after a short REORDER_STILL_MS confirmation. This is the signal that
|
|
2204
|
-
// normally decides, and the reason the delay is short.
|
|
2205
|
-
// - the absence of events is only a FALLBACK (armRestNet), for a browser that
|
|
2206
|
-
// stops delivering dragover entirely. That one has to out-wait the drag loop's
|
|
2207
|
-
// 350ms cadence, so it is long — and it is rarely what decides.
|
|
2208
|
-
//
|
|
2209
|
-
// One quiet window had to serve both jobs before this, which is why it could not be
|
|
2210
|
-
// shortened: the timer deciding responsiveness was the same one that had to survive
|
|
2211
|
-
// the cadence.
|
|
1916
|
+
// A travelling pointer rearranges nothing; the slot opens when it comes to
|
|
1917
|
+
// REST. A dragover at an unchanged position is POSITIVE evidence of a stop
|
|
1918
|
+
// and commits after the short REORDER_STILL_MS; the absence of events is only
|
|
1919
|
+
// a fallback that must out-wait the drag loop's 350 ms cadence.
|
|
2212
1920
|
function trackRest(clientX: number): void {
|
|
2213
1921
|
const dragged = draggingEl;
|
|
2214
1922
|
if (!dragged) {
|
|
2215
1923
|
return;
|
|
2216
1924
|
}
|
|
2217
|
-
const now = Date.now();
|
|
1925
|
+
const now = win.Date.now();
|
|
2218
1926
|
const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
|
|
2219
1927
|
restX = clientX;
|
|
2220
1928
|
if (moved) {
|
|
2221
1929
|
restMovedAt = now;
|
|
2222
1930
|
}
|
|
2223
|
-
//
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2226
|
-
//
|
|
2227
|
-
// There used to be a `restBefore` holding the pending slot, and both the
|
|
2228
|
-
// already-there branch and the leave-the-strip paths nulled it. So a drag that
|
|
2229
|
-
// passed through "already there" on its way somewhere else lost the pending
|
|
2230
|
-
// state, and the stillness branch below — which then early-returned because no
|
|
2231
|
-
// timer was armed — committed NOTHING when the pointer stopped. The tab stayed on
|
|
2232
|
-
// its previous slot until the user jiggled the mouse to re-arm, which is exactly
|
|
2233
|
-
// the reported "it does not let go of the old drop spot". It hit leftward drags
|
|
2234
|
-
// hardest, because after each commit the dragged chip sits immediately left of
|
|
2235
|
-
// where the pointer is heading, so `nextElementSibling` matches constantly.
|
|
1931
|
+
// Recomputed from THIS event, never a stored pending target: a stored one
|
|
1932
|
+
// was nulled by the already-there branch on the way past, so a stop after it
|
|
1933
|
+
// committed NOTHING until the mouse was jiggled.
|
|
2236
1934
|
const before = dropTargetBefore(clientX);
|
|
2237
|
-
// Already the slot it would land in, so there is nothing to commit. Only the net
|
|
2238
|
-
// is dropped; there is no pending target left to lose.
|
|
2239
1935
|
if (before === dragged.nextElementSibling) {
|
|
2240
1936
|
endRestNet();
|
|
2241
1937
|
return;
|
|
2242
1938
|
}
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
// what lets the slot open promptly instead of out-waiting the drag loop's 350ms
|
|
2246
|
-
// cadence. The elapsed check filters the coincidence where one event of a sweep
|
|
2247
|
-
// lands within REORDER_MOVE_EPS_PX of the previous (a reversal, or a frame whose
|
|
2248
|
-
// motion was almost all vertical); a real stop clears it on the next event.
|
|
1939
|
+
// The elapsed check filters one event of a sweep landing within the epsilon
|
|
1940
|
+
// of the previous (a reversal, or a mostly vertical frame).
|
|
2249
1941
|
if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
|
|
2250
1942
|
endRestNet();
|
|
2251
1943
|
commitSlot(before);
|
|
@@ -2253,65 +1945,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2253
1945
|
}
|
|
2254
1946
|
armRestNet(before);
|
|
2255
1947
|
}
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
// its own target for the same reason trackRest recomputes one — a net whose target
|
|
2259
|
-
// lived in shared mutable state was how the old pending slot got lost. Sized to
|
|
2260
|
-
// out-wait the cadence (see REORDER_REST_MS) because it is the one timer a fast
|
|
2261
|
-
// sweep could otherwise expire between two of its own events.
|
|
1948
|
+
// The no-events fallback for a browser that stops delivering dragover; it
|
|
1949
|
+
// carries its own target for the reason trackRest recomputes one.
|
|
2262
1950
|
function armRestNet(before: HTMLElement | null): void {
|
|
2263
1951
|
endRestNet();
|
|
2264
|
-
restTimer = setTimeout(() => {
|
|
1952
|
+
restTimer = win.setTimeout(() => {
|
|
2265
1953
|
restTimer = null;
|
|
2266
1954
|
commitSlot(before);
|
|
2267
1955
|
}, REORDER_REST_MS);
|
|
2268
1956
|
}
|
|
2269
1957
|
function endRestNet(): void {
|
|
2270
1958
|
if (restTimer !== null) {
|
|
2271
|
-
clearTimeout(restTimer);
|
|
1959
|
+
win.clearTimeout(restTimer);
|
|
2272
1960
|
restTimer = null;
|
|
2273
1961
|
}
|
|
2274
1962
|
}
|
|
2275
1963
|
|
|
2276
|
-
//
|
|
2277
|
-
//
|
|
2278
|
-
//
|
|
2279
|
-
// gesture — because "the strip rearranged, show the rearrangement" is one job,
|
|
2280
|
-
// and a revert that snapped while a commit slid would read as two different
|
|
2281
|
-
// features.
|
|
2282
|
-
//
|
|
2283
|
-
// `hold` is the one chip that must NOT slide. On a commit that is the dragged
|
|
2284
|
-
// chip: it is the slot, the pointer is already carrying a solid copy of it,
|
|
2285
|
-
// and a hole travelling across the strip alongside that copy is two things
|
|
2286
|
-
// moving at once. On a revert nothing is held — the drag image is gone by
|
|
2287
|
-
// then, so the chip has to travel home itself.
|
|
1964
|
+
// FLIP a rearranging mutation so every chip that ends up somewhere new slides
|
|
1965
|
+
// there. `hold` is the one chip that must NOT slide: on a commit the dragged
|
|
1966
|
+
// chip, since the pointer already carries a solid copy of it.
|
|
2288
1967
|
function flipTo(mutate: () => void, hold: HTMLElement | null): void {
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
// inline and no stylesheet gate can reach them; the switcher's release reel
|
|
2292
|
-
// guards its own call site the same way. Read live, so toggling the OS setting
|
|
2293
|
-
// mid-drag takes effect on the very next commit.
|
|
1968
|
+
// The gate belongs HERE and not only in the CSS: these transitions are
|
|
1969
|
+
// written inline, and no stylesheet gate can reach them.
|
|
2294
1970
|
if (prefersReduce()) {
|
|
2295
1971
|
endShift();
|
|
2296
1972
|
mutate();
|
|
2297
1973
|
return;
|
|
2298
1974
|
}
|
|
2299
|
-
//
|
|
2300
|
-
// continues from wherever the first
|
|
2301
|
-
//
|
|
2302
|
-
// same way, and for the same reason. Rects, not layout offsets: this is VISUAL
|
|
2303
|
-
// position, which is what the animation interpolates (hit-testing wants the
|
|
2304
|
-
// opposite, see dropTargetBefore).
|
|
1975
|
+
// Where each chip is right now, MID-SLIDE INCLUDED, so a second commit
|
|
1976
|
+
// continues from wherever the first got to. Rects, because this is VISUAL
|
|
1977
|
+
// position; hit-testing wants layout offsets instead.
|
|
2305
1978
|
const first = new Map<HTMLElement, number>();
|
|
2306
1979
|
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
2307
1980
|
if (el !== hold) {
|
|
2308
1981
|
first.set(el, el.getBoundingClientRect().left);
|
|
2309
1982
|
}
|
|
2310
1983
|
}
|
|
2311
|
-
endShift();
|
|
1984
|
+
endShift();
|
|
2312
1985
|
mutate();
|
|
2313
|
-
// LAST — the new resting layout. No style is written between the reads below,
|
|
2314
|
-
// so they are all served from one layout pass.
|
|
2315
1986
|
const invert = new Map<HTMLElement, number>();
|
|
2316
1987
|
for (const [el, was] of first) {
|
|
2317
1988
|
const dx = el.isConnected ? was - el.getBoundingClientRect().left : 0;
|
|
@@ -2320,53 +1991,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2320
1991
|
}
|
|
2321
1992
|
}
|
|
2322
1993
|
if (invert.size === 0) {
|
|
2323
|
-
return;
|
|
1994
|
+
return;
|
|
2324
1995
|
}
|
|
2325
1996
|
applyShift(invert, "none");
|
|
2326
|
-
// The read forces the reflow that COMMITS the from-state
|
|
2327
|
-
//
|
|
2328
|
-
// no transition runs at all — the exact trap the reel documents.
|
|
1997
|
+
// The read forces the reflow that COMMITS the from-state; without it the two
|
|
1998
|
+
// writes collapse into one recalc and no transition runs.
|
|
2329
1999
|
scroller.getBoundingClientRect();
|
|
2330
2000
|
const rest = new Map<HTMLElement, number>();
|
|
2331
2001
|
for (const el of invert.keys()) {
|
|
2332
2002
|
rest.set(el, 0);
|
|
2333
2003
|
}
|
|
2334
2004
|
applyShift(rest, REORDER_SHIFT_TRANS);
|
|
2335
|
-
shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
|
|
2005
|
+
shiftTimer = win.setTimeout(endShift, REORDER_SETTLE_MS);
|
|
2336
2006
|
}
|
|
2337
2007
|
|
|
2338
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
// It moves the DOM and NOT tabList. That split is what makes the preview a
|
|
2342
|
-
// preview: tabList stays the arrangement the gesture started from for the
|
|
2343
|
-
// whole drag, and only a drop writes it (syncOrderFromDom). Two things fall
|
|
2344
|
-
// out of it for free — a cancel is just "re-project tabList" (revertPreview),
|
|
2345
|
-
// and a syncChrome arriving mid-drag from an unrelated source (a status tick,
|
|
2346
|
-
// an OSC title) renders the committed order rather than flickering through
|
|
2347
|
-
// whatever the pointer is hovering.
|
|
2348
|
-
//
|
|
2349
|
-
// The caller nulls `restTimer` BEFORE calling in (both the rest timer and
|
|
2350
|
-
// flushRest do), so `cancelRest` is already a no-op by the time control arrives
|
|
2351
|
-
// here: an early return must leave nothing behind that only cancelRest would
|
|
2352
|
-
// Nothing exists pre-commit for it to have cleared, and a slide already in flight
|
|
2353
|
-
// is owned by its own settle timer.
|
|
2008
|
+
// Moves the DOM and NOT tabList, which is what makes the preview a preview:
|
|
2009
|
+
// only a drop writes tabList, so a cancel is a re-projection and a syncChrome
|
|
2010
|
+
// arriving mid-drag renders the committed order.
|
|
2354
2011
|
function commitSlot(before: HTMLElement | null): void {
|
|
2355
2012
|
const dragged = draggingEl;
|
|
2356
2013
|
if (!dragged?.isConnected) {
|
|
2357
|
-
return;
|
|
2358
|
-
}
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2361
|
-
//
|
|
2362
|
-
// insertBefore throws NotFoundError on a reference that is no longer a
|
|
2363
|
-
// child, which would abandon the drop mid-way and leave DOM order and tabList
|
|
2364
|
-
// disagreeing. null stays legal: it means "past the last chip".
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
// A session closed elsewhere removes chips while the gesture is open, and
|
|
2017
|
+
// insertBefore throws on a reference that is no longer a child. null stays
|
|
2018
|
+
// legal: past the last chip.
|
|
2365
2019
|
if (before !== null && before.parentNode !== scroller) {
|
|
2366
2020
|
return;
|
|
2367
2021
|
}
|
|
2368
2022
|
if (before === dragged || before === dragged.nextElementSibling) {
|
|
2369
|
-
return;
|
|
2023
|
+
return;
|
|
2370
2024
|
}
|
|
2371
2025
|
flipTo(() => {
|
|
2372
2026
|
scroller.insertBefore(dragged, before);
|
|
@@ -2375,21 +2029,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2375
2029
|
announceTarget(dragged);
|
|
2376
2030
|
}
|
|
2377
2031
|
|
|
2378
|
-
//
|
|
2379
|
-
//
|
|
2380
|
-
//
|
|
2381
|
-
// The old reorder had no revert path at all: dragover moved the chip on every
|
|
2382
|
-
// event and dragend committed whatever the strip happened to be showing, so an
|
|
2383
|
-
// abandoned drag left the tabs rearranged and the only way back was to drag
|
|
2384
|
-
// them again. Reverting needs no saved snapshot, because tabList IS the
|
|
2385
|
-
// snapshot — nothing but a drop writes to it — so the original arrangement is
|
|
2386
|
-
// recovered by re-projecting it, the same projection moveTab uses.
|
|
2032
|
+
// Put the strip back the way the gesture found it. No saved snapshot is
|
|
2033
|
+
// needed: nothing but a drop writes tabList, so it IS the snapshot.
|
|
2387
2034
|
function revertPreview(): void {
|
|
2388
2035
|
const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
|
|
2389
2036
|
const untouched =
|
|
2390
2037
|
chips.length === tabList.length && chips.every((el, i) => tabList[i]?.el === el);
|
|
2391
2038
|
if (untouched) {
|
|
2392
|
-
return;
|
|
2039
|
+
return;
|
|
2393
2040
|
}
|
|
2394
2041
|
flipTo(() => {
|
|
2395
2042
|
for (const t of tabList) {
|
|
@@ -2399,42 +2046,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2399
2046
|
ctx.announce("Move cancelled");
|
|
2400
2047
|
}
|
|
2401
2048
|
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
// restarts nothing), and the read between them is what flushes the removal to
|
|
2405
|
-
// style so the two writes are not collapsed into one. It owns that read rather
|
|
2406
|
-
// than depending on a caller's, so a commit can call it either side of the
|
|
2407
|
-
// FLIP's own reflow.
|
|
2049
|
+
// Re-adding a class an element already carries restarts nothing, and the
|
|
2050
|
+
// layout read between the two writes is what keeps them from collapsing.
|
|
2408
2051
|
function flashSlot(el: HTMLElement): void {
|
|
2409
2052
|
endSlotFade();
|
|
2410
2053
|
el.classList.remove("wt-tab-slotted");
|
|
2411
2054
|
el.getBoundingClientRect();
|
|
2412
2055
|
el.classList.add("wt-tab-slotted");
|
|
2413
2056
|
slotFadeEl = el;
|
|
2414
|
-
slotFadeTimer = setTimeout(() => {
|
|
2057
|
+
slotFadeTimer = win.setTimeout(() => {
|
|
2415
2058
|
slotFadeTimer = null;
|
|
2416
2059
|
endSlotFade();
|
|
2417
2060
|
}, REORDER_SLOT_FADE_MS);
|
|
2418
2061
|
}
|
|
2419
2062
|
function endSlotFade(): void {
|
|
2420
2063
|
if (slotFadeTimer !== null) {
|
|
2421
|
-
clearTimeout(slotFadeTimer);
|
|
2064
|
+
win.clearTimeout(slotFadeTimer);
|
|
2422
2065
|
slotFadeTimer = null;
|
|
2423
2066
|
}
|
|
2424
2067
|
slotFadeEl?.classList.remove("wt-tab-slotted");
|
|
2425
2068
|
slotFadeEl = null;
|
|
2426
2069
|
}
|
|
2427
2070
|
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
2432
|
-
// three times over on a drag that dwelt in three places, sometimes followed by
|
|
2433
|
-
// "Move cancelled" contradicting all of it. So the preview announces a TARGET and
|
|
2434
|
-
// only the drop announces a move.
|
|
2435
|
-
//
|
|
2436
|
-
// The position is read from the DOM because the DOM is what the preview moved;
|
|
2437
|
-
// tabList still holds the order the gesture started from.
|
|
2071
|
+
// A committed slot is a PREVIEW Escape can still undo, so it announces a
|
|
2072
|
+
// TARGET and only the drop announces a move; announcing "Moved" three times
|
|
2073
|
+
// and then "Move cancelled" told a screen-reader user a hover was an action.
|
|
2074
|
+
// The position is read from the DOM, which is what the preview moved.
|
|
2438
2075
|
function slotPosition(el: HTMLElement): number {
|
|
2439
2076
|
return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
|
|
2440
2077
|
}
|
|
@@ -2444,9 +2081,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2444
2081
|
ctx.announce(`Drop position ${String(at)}`);
|
|
2445
2082
|
}
|
|
2446
2083
|
}
|
|
2447
|
-
// announceMoved is the completed move, on drop. The drag path announced nothing
|
|
2448
|
-
// at all before this, while the menu's moveTab announced every move — the same
|
|
2449
|
-
// reorder, one of them silent to anyone who cannot see the strip rearrange.
|
|
2450
2084
|
function announceMoved(el: HTMLElement): void {
|
|
2451
2085
|
const tab = tabList.find((t) => t.el === el);
|
|
2452
2086
|
const at = slotPosition(el);
|
|
@@ -2455,36 +2089,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2455
2089
|
}
|
|
2456
2090
|
}
|
|
2457
2091
|
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
// It deliberately does NOT stop a slide that is already playing. A slide is an
|
|
2462
|
-
// animation with its own settle timer, and cutting it here is exactly what
|
|
2463
|
-
// would make a revert snap home instead of run — dragend fires immediately
|
|
2464
|
-
// after the revert starts. dragstart and teardown add an explicit endShift()
|
|
2465
|
-
// for the two cases where nothing may be left in flight: a fresh gesture must
|
|
2466
|
-
// start from rest, and a torn-down feature may leave no inline style on an
|
|
2467
|
-
// element it no longer owns.
|
|
2092
|
+
// Ends the GESTURE's state but NOT a slide already playing: dragend fires
|
|
2093
|
+
// right after a revert starts, and cutting the slide here would make the
|
|
2094
|
+
// revert snap home instead of run. dragstart and teardown call endShift().
|
|
2468
2095
|
function endReorderPreview(): void {
|
|
2469
2096
|
endRestNet();
|
|
2470
2097
|
restX = null;
|
|
2471
2098
|
endSlotFade();
|
|
2472
2099
|
}
|
|
2473
2100
|
|
|
2474
|
-
//
|
|
2475
|
-
//
|
|
2476
|
-
//
|
|
2477
|
-
//
|
|
2478
|
-
// - the pending reference chip: commitSlot would hand a detached node to
|
|
2479
|
-
// insertBefore. It guards that too, but withdrawing the pending slot here means
|
|
2480
|
-
// a fresh dragover arms against a chip that still exists rather than waiting out
|
|
2481
|
-
// a rest window against one that does not.
|
|
2482
|
-
// - the DRAGGED chip: the gesture is over and there is no source left to deliver
|
|
2483
|
-
// a dragend. A browser is not obliged to fire one for a removed source, and
|
|
2484
|
-
// without this the feature would keep `draggingEl` set to a detached node for
|
|
2485
|
-
// the rest of its life — which the document-level guard reads as "a tab drag is
|
|
2486
|
-
// in progress" and so would preventDefault every unrelated file or text drop on
|
|
2487
|
-
// the page from then on.
|
|
2101
|
+
// For every site that REMOVES a chip while a drag may be open. A browser is
|
|
2102
|
+
// not obliged to fire dragend for a removed source, and a `draggingEl` left
|
|
2103
|
+
// pointing at a detached node would make the document-level guard
|
|
2104
|
+
// preventDefault every unrelated file or text drop from then on.
|
|
2488
2105
|
function abortReorderFor(removed: HTMLElement): void {
|
|
2489
2106
|
if (removed !== draggingEl) {
|
|
2490
2107
|
return;
|
|
@@ -2515,33 +2132,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2515
2132
|
}
|
|
2516
2133
|
}
|
|
2517
2134
|
|
|
2518
|
-
//
|
|
2519
|
-
//
|
|
2520
|
-
//
|
|
2521
|
-
//
|
|
2522
|
-
//
|
|
2523
|
-
// Move left/right), never from the drag preview, which repaints the DOM many
|
|
2524
|
-
// times per gesture. The local strip is already showing the new arrangement,
|
|
2525
|
-
// so this is optimistic; the server echoes the same order back and
|
|
2526
|
-
// applyServerOrder then has nothing to do.
|
|
2527
|
-
//
|
|
2528
|
-
// Best-effort by design, with one exception. A 409 means the server's
|
|
2529
|
-
// session set is not the one just sent — this client has not yet seen a
|
|
2530
|
-
// session created or closed elsewhere — and the answer is to take the
|
|
2531
|
-
// server's word: reconcileOnce adopts what it missed, and the arrangement
|
|
2532
|
-
// that survives is the server's. Re-sending would be a fight this client
|
|
2533
|
-
// cannot win, since its list is the stale one. Any other failure leaves the
|
|
2534
|
-
// strip as the user arranged it for this page and lets the next reorder try
|
|
2535
|
-
// again; a toast for a cosmetic write the user can simply repeat would be
|
|
2536
|
-
// noise, and the arrangement is not lost data.
|
|
2537
|
-
//
|
|
2538
|
-
// It renumbers the tabs BEFORE sending, and that is not bookkeeping: every
|
|
2539
|
-
// tab still carries the position the server gave it, so the applyServerOrder
|
|
2540
|
-
// pass inside the caller's syncChrome would sort the strip straight back and
|
|
2541
|
-
// the tab would snap to where it started. Adopting the new positions locally
|
|
2542
|
-
// is the optimistic half of the write — the server echoes these same numbers
|
|
2543
|
-
// and the echo then lands as a no-op. Against a server with no order route
|
|
2544
|
-
// the numbers simply stay local, which is the old per-page behaviour.
|
|
2135
|
+
// The write half of tab-order sync, called only from the paths that COMMIT
|
|
2136
|
+
// a reorder. It renumbers BEFORE sending: every tab still carries the
|
|
2137
|
+
// server's old position, and the caller's applyServerOrder pass would sort
|
|
2138
|
+
// the strip straight back. A 409 means this client's list is stale, so the
|
|
2139
|
+
// server's word is taken; any other failure leaves the arrangement local.
|
|
2545
2140
|
function publishOrder(): void {
|
|
2546
2141
|
if (tabList.length === 0) {
|
|
2547
2142
|
return;
|
|
@@ -2550,28 +2145,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2550
2145
|
tab.order = i;
|
|
2551
2146
|
});
|
|
2552
2147
|
if (!started) {
|
|
2553
|
-
return;
|
|
2148
|
+
return;
|
|
2554
2149
|
}
|
|
2555
2150
|
const ids = tabList.map((t) => t.id);
|
|
2556
2151
|
void api.setOrder(ids).catch((err: unknown) => {
|
|
2557
|
-
if (err instanceof SessionAPIError && err.status === 409) {
|
|
2152
|
+
if (!tornDown() && err instanceof SessionAPIError && err.status === 409) {
|
|
2558
2153
|
void reconcileOnce();
|
|
2559
2154
|
}
|
|
2560
2155
|
});
|
|
2561
2156
|
}
|
|
2562
2157
|
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2567
|
-
//
|
|
2568
|
-
// which
|
|
2569
|
-
// drag image fixes it: a clone parked directly under .wt-root, outside the
|
|
2570
|
-
// bar's filtered subtree yet still inside the styling boundary so the
|
|
2571
|
-
// scoped .wt-tab rules paint it. It is laid exactly over the real chip, so
|
|
2572
|
-
// it is indistinguishable from it in the unlikely event it ever paints, and
|
|
2573
|
-
// dropped on the next frame — the browser has already snapshotted it by
|
|
2574
|
-
// then (the snapshot is taken when the dragstart handler returns).
|
|
2158
|
+
// WebKit renders NO automatic drag image under an ancestor with a filter or
|
|
2159
|
+
// transform (webkit.org/b/22787), and the strip's backdrop-filter is one, so
|
|
2160
|
+
// the preview came out as broken white geometry on iPadOS. The clone is
|
|
2161
|
+
// parked under .wt-root, outside the filtered subtree but inside the styling
|
|
2162
|
+
// boundary, laid exactly over the chip, and dropped on the next frame, by
|
|
2163
|
+
// which time the browser has snapshotted it.
|
|
2575
2164
|
let dragGhost: HTMLElement | null = null;
|
|
2576
2165
|
function clearDragGhost(): void {
|
|
2577
2166
|
dragGhost?.remove();
|
|
@@ -2598,20 +2187,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2598
2187
|
// relative to the event TARGET, which is the label or the close button
|
|
2599
2188
|
// when the drag starts on one of them.
|
|
2600
2189
|
e.dataTransfer?.setDragImage(ghost, e.clientX - rect.left, e.clientY - rect.top);
|
|
2601
|
-
|
|
2190
|
+
frame(clearDragGhost);
|
|
2602
2191
|
}
|
|
2603
2192
|
|
|
2604
|
-
//
|
|
2605
|
-
// every tab element in list order so the DOM matches.
|
|
2606
|
-
// It is the single-pointer / non-drag alternative to the drag-and-drop
|
|
2607
|
-
// reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
|
|
2608
|
-
// context menu.
|
|
2193
|
+
// The non-drag alternative to the drag-and-drop reorder (WCAG 2.5.7).
|
|
2609
2194
|
function moveTab(id: string, delta: -1 | 1): void {
|
|
2610
|
-
//
|
|
2611
|
-
//
|
|
2612
|
-
// OTHER tab, via a blur the user never performed. Resolve it here instead,
|
|
2613
|
-
// so the outcome is chosen rather than emergent: a blur that arrives as a
|
|
2614
|
-
// side effect of reparenting is not a decision anyone made.
|
|
2195
|
+
// Re-appending every chip reparents a focused field, which blurs it, so an
|
|
2196
|
+
// open edit would be committed by a blur the user never performed.
|
|
2615
2197
|
resolveEdit("blur", false);
|
|
2616
2198
|
const from = tabList.findIndex((t) => t.id === id);
|
|
2617
2199
|
const to = from + delta;
|
|
@@ -2631,65 +2213,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2631
2213
|
ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
|
|
2632
2214
|
}
|
|
2633
2215
|
|
|
2634
|
-
//
|
|
2635
|
-
//
|
|
2636
|
-
//
|
|
2637
|
-
// client's tab set converged on the server, so mobile and desktop never
|
|
2638
|
-
// desync. The caller runs syncChrome.
|
|
2216
|
+
// Add a tab for a session that exists server-side but has no local tab yet
|
|
2217
|
+
// (one created in another browser), so every client's tab set converges on
|
|
2218
|
+
// the server. The caller runs syncChrome.
|
|
2639
2219
|
function adoptSession(info: SessionInfo): void {
|
|
2640
2220
|
if (tabList.find((t) => t.id === info.id)) {
|
|
2641
2221
|
return;
|
|
2642
2222
|
}
|
|
2643
2223
|
if (tombstones.active(info.id)) {
|
|
2644
|
-
return;
|
|
2224
|
+
return;
|
|
2645
2225
|
}
|
|
2646
2226
|
const tab = addTabChrome(info);
|
|
2647
|
-
//
|
|
2648
|
-
//
|
|
2649
|
-
// GET /api/sessions race each other, so arrival order is neither source's
|
|
2650
|
-
// order and is not stable between loads. Against a server that keeps no
|
|
2651
|
-
// order the same call falls back to age, which is creation order. See
|
|
2652
|
-
// compareTabOrder.
|
|
2227
|
+
// Placed by the SERVER's order, not by arrival: the stream's snapshot and
|
|
2228
|
+
// the bootstrap's list race, so arrival order is not stable between loads.
|
|
2653
2229
|
const at = orderedInsertIndex(tabList, info);
|
|
2654
2230
|
tabList.splice(at, 0, tab);
|
|
2655
|
-
// addTabChrome appended the chip
|
|
2656
|
-
// the list when the arrangement put it earlier, so the DOM, the switcher
|
|
2657
|
-
// rows, and the position announcements all read the same order.
|
|
2231
|
+
// addTabChrome appended the chip at the end; the DOM must read in list order.
|
|
2658
2232
|
const after = tabList[at + 1];
|
|
2659
2233
|
if (after) {
|
|
2660
2234
|
scroller.insertBefore(tab.el, after.el);
|
|
2661
2235
|
}
|
|
2662
2236
|
}
|
|
2663
2237
|
|
|
2664
|
-
//
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
// stream / poll adopt the existing sessions below; without this they would
|
|
2670
|
-
// render inert (blank, never connecting) until the user taps a tab. This
|
|
2671
|
-
// is the sibling of the '+'-retry recovery the bootstrap already handles.
|
|
2672
|
-
// A live tab outranks an ENDED one (its dot status is fed by the same
|
|
2673
|
-
// SSE/poll that adopted it); a corpse is only auto-activated when nothing
|
|
2674
|
-
// else exists. "Ended" covers both ways a process goes (exited and
|
|
2675
|
-
// crashed) — a crashed session is exactly as unable to produce output as a
|
|
2676
|
-
// cleanly exited one, so activating it would wedge the page the same way.
|
|
2238
|
+
// The runtime repair for a bootstrap whose list AND create both failed: the
|
|
2239
|
+
// sessions the stream later adopts would otherwise render inert until the
|
|
2240
|
+
// user taps a tab. Gated on that one case, so a pane the closing rules
|
|
2241
|
+
// emptied is never refilled by a status event. A live tab outranks an ENDED
|
|
2242
|
+
// one, which would wedge the page the same way.
|
|
2677
2243
|
function ensureActive(): void {
|
|
2678
|
-
if (
|
|
2679
|
-
return;
|
|
2680
|
-
}
|
|
2681
|
-
// ...but only once the bootstrap has adopted its list. Before that the
|
|
2682
|
-
// view is PARTIAL, and deterministically so: tabs subscribes to the status
|
|
2683
|
-
// stream during setup, before resolveInitialSession issues its
|
|
2684
|
-
// GET /api/sessions, so the SSE snapshot's one-event-per-session arrives
|
|
2685
|
-
// first and this ran on a strip holding only whichever session the server
|
|
2686
|
-
// sent first. Activating it both picked a tab on incomplete information and
|
|
2687
|
-
// silently disabled the saved-tab restore: the bootstrap ladder's first act
|
|
2688
|
-
// is to return early when activeId is already set, so localStorage was
|
|
2689
|
-
// never read and every reload landed on the oldest tab. Boot activation
|
|
2690
|
-
// belongs to that ladder alone, which is the only site with the whole list
|
|
2691
|
-
// AND the saved id; this stays the runtime repair it was written to be.
|
|
2692
|
-
if (!started) {
|
|
2244
|
+
if (!bootShowedNothing || anyShown()) {
|
|
2693
2245
|
return;
|
|
2694
2246
|
}
|
|
2695
2247
|
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
@@ -2698,37 +2250,37 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2698
2250
|
}
|
|
2699
2251
|
}
|
|
2700
2252
|
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2706
|
-
//
|
|
2707
|
-
// Keyboard, so the user got two terminals and had to close one. Sharing the
|
|
2708
|
-
// in-flight promise needs no threshold to make that decision, unlike a
|
|
2709
|
-
// click-level debounce: a duplicate activation lands while the POST is still
|
|
2710
|
-
// open and collapses into it, while a deliberate second tap (the POST
|
|
2711
|
-
// resolves in milliseconds) still opens a second terminal. It also stops "+"
|
|
2712
|
-
// mashing from queueing sessions across the server's 503 install window,
|
|
2713
|
-
// where createSessionHonouringRetry legitimately waits minutes.
|
|
2253
|
+
// One press does not reliably arrive as one activation (two POSTs 0-3 ms
|
|
2254
|
+
// apart from a single "+" tap were measured), and sharing the in-flight
|
|
2255
|
+
// promise needs no threshold: a duplicate lands while the POST is open and
|
|
2256
|
+
// collapses into it, while a deliberate second tap still opens a second
|
|
2257
|
+
// terminal. It also stops "+" mashing from queueing sessions across the
|
|
2258
|
+
// server's retry window. The coalesced second caller's target is ignored.
|
|
2714
2259
|
let creating: Promise<void> | null = null;
|
|
2715
|
-
function create(): Promise<void> {
|
|
2716
|
-
creating ??= openNewSession().finally(() => {
|
|
2260
|
+
function create(target?: PaneSide): Promise<void> {
|
|
2261
|
+
creating ??= openNewSession(target).finally(() => {
|
|
2717
2262
|
creating = null;
|
|
2718
2263
|
});
|
|
2719
2264
|
return creating;
|
|
2720
2265
|
}
|
|
2721
2266
|
|
|
2722
|
-
async function openNewSession(): Promise<void> {
|
|
2267
|
+
async function openNewSession(target?: PaneSide): Promise<void> {
|
|
2268
|
+
// The handle faces the pane the new tab is headed for from the start,
|
|
2269
|
+
// not from the POST's answer.
|
|
2270
|
+
if (target !== undefined && ctx.shell.split.isOpen() && !anyShown()) {
|
|
2271
|
+
ctx.shell.select(target);
|
|
2272
|
+
}
|
|
2723
2273
|
let info: SessionInfo;
|
|
2724
2274
|
try {
|
|
2725
|
-
info = await createSessionHonouringRetry(api, ctx,
|
|
2275
|
+
info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
|
|
2726
2276
|
} catch (err) {
|
|
2277
|
+
if (tornDown()) {
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2727
2280
|
// A create can still fail transiently (network, server error); tell
|
|
2728
2281
|
// the user rather than throwing. A 503 has already been retried on the
|
|
2729
2282
|
// server's own schedule by this point, so reaching here means it never
|
|
2730
|
-
// became ready
|
|
2731
|
-
// rather than the generic message that used to cover both cases.
|
|
2283
|
+
// became ready: say so with the server's own words when it gave any.
|
|
2732
2284
|
ctx.toast(
|
|
2733
2285
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2734
2286
|
? `Couldn't open a terminal: ${err.serverMessage}`
|
|
@@ -2744,17 +2296,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2744
2296
|
tabList.push(tab);
|
|
2745
2297
|
}
|
|
2746
2298
|
// Suppress the swipe reel for a create: the list should grow and fade the
|
|
2747
|
-
// new row in (animateRowIn) rather than rotate.
|
|
2748
|
-
// terminal content and updates the
|
|
2299
|
+
// new row in (animateRowIn) rather than rotate. showIn still slides the
|
|
2300
|
+
// terminal content and updates the shown chip.
|
|
2749
2301
|
creatingTab = true;
|
|
2750
|
-
|
|
2302
|
+
// The asked-for side holds only while that pane is still empty: the split
|
|
2303
|
+
// may have closed or the pane been filled during the round trip.
|
|
2304
|
+
const side =
|
|
2305
|
+
target !== undefined && ctx.shell.pane(target)?.state() === "empty"
|
|
2306
|
+
? target
|
|
2307
|
+
: ctx.shell.targetFor(tab.id);
|
|
2308
|
+
showIn(side, tab.id);
|
|
2751
2309
|
creatingTab = false;
|
|
2752
2310
|
}
|
|
2753
2311
|
|
|
2754
|
-
// dropTab removes a tab's chrome + cache and
|
|
2755
|
-
// remote=true also DELETEs the server session (a user
|
|
2756
|
-
// is a local drop for a session the server already
|
|
2757
|
-
// event or a poll that no longer lists it), so no
|
|
2312
|
+
// dropTab removes a tab's chrome + cache and, for a shown tab, decides what
|
|
2313
|
+
// its pane shows next. remote=true also DELETEs the server session (a user
|
|
2314
|
+
// close); remote=false is a local drop for a session the server already
|
|
2315
|
+
// ended (an SSE removed event or a poll that no longer lists it), so no
|
|
2316
|
+
// redundant DELETE is sent.
|
|
2758
2317
|
async function dropTab(id: string, remote: boolean): Promise<void> {
|
|
2759
2318
|
const idx = tabList.findIndex((t) => t.id === id);
|
|
2760
2319
|
if (idx < 0) {
|
|
@@ -2762,24 +2321,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2762
2321
|
}
|
|
2763
2322
|
// Closing the only remaining tab: spawn its replacement BEFORE removing
|
|
2764
2323
|
// this one, so the strip never empties and the "+" never teleports to the
|
|
2765
|
-
// far left
|
|
2766
|
-
//
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
// close). If create() fails it adds nothing (and toasts), so keep the
|
|
2770
|
-
// existing tab rather than stranding the user on an empty strip.
|
|
2324
|
+
// far left while the create POST is in flight. The replacement lands where
|
|
2325
|
+
// a click would (the other pane, while the split is open), and dropping
|
|
2326
|
+
// the old tab is then an ordinary non-last close, so this intercept does
|
|
2327
|
+
// not re-fire. A user close only.
|
|
2771
2328
|
if (remote && tabList.length === 1 && tabList[0]?.id === id) {
|
|
2772
2329
|
await create();
|
|
2773
|
-
//
|
|
2774
|
-
//
|
|
2775
|
-
// rather than stranding the user on an empty strip. The replacement is
|
|
2776
|
-
// a different session, so dropping the old one is a non-last close and
|
|
2777
|
-
// this intercept does not re-fire.
|
|
2330
|
+
// A failed create adds nothing (and toasts): keep the existing tab
|
|
2331
|
+
// rather than stranding the user on an empty strip.
|
|
2778
2332
|
if (tabList.some((t) => t.id !== id)) {
|
|
2779
2333
|
await dropTab(id, true);
|
|
2780
2334
|
}
|
|
2781
2335
|
return;
|
|
2782
2336
|
}
|
|
2337
|
+
// Which pane shows the tab decides the transaction below; read before
|
|
2338
|
+
// anything moves.
|
|
2339
|
+
const side = sideOf(id);
|
|
2340
|
+
const splitOpen = ctx.shell.split.isOpen();
|
|
2783
2341
|
const [tab] = tabList.splice(idx, 1);
|
|
2784
2342
|
if (!tab) {
|
|
2785
2343
|
return;
|
|
@@ -2800,7 +2358,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2800
2358
|
// sit in storage forever.
|
|
2801
2359
|
acknowledgeSwitchNotify(id);
|
|
2802
2360
|
forgetCueSeen(id);
|
|
2803
|
-
|
|
2361
|
+
ctx.shell.notifications.forget(id);
|
|
2804
2362
|
tab.aria.remove();
|
|
2805
2363
|
// Remove immediately (no exit animation): a lingering element made the
|
|
2806
2364
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
@@ -2810,107 +2368,137 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2810
2368
|
// before, and both stop existing on the next line.
|
|
2811
2369
|
abortReorderFor(tab.el);
|
|
2812
2370
|
tab.el.remove();
|
|
2813
|
-
|
|
2814
|
-
//
|
|
2815
|
-
//
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2371
|
+
// Each connection forgets the session exactly once: the showing pane's
|
|
2372
|
+
// through clearActiveSession (which closes its socket and empties the
|
|
2373
|
+
// pane), every other through dropSessionExcept.
|
|
2374
|
+
if (side !== null && splitOpen) {
|
|
2375
|
+
ctx.shell.pane(side)?.clearActiveSession();
|
|
2376
|
+
ctx.shell.dropSessionExcept(id, side);
|
|
2377
|
+
} else {
|
|
2378
|
+
ctx.dropSession(id);
|
|
2379
|
+
}
|
|
2380
|
+
// A close that empties the expanded list collapses the switcher FIRST, so
|
|
2381
|
+
// the tray animates shut with the last row still in it; `expanded` going off
|
|
2382
|
+
// makes the syncChrome below leave that row for the collapse to sweep away.
|
|
2821
2383
|
if (expanded && tabList.length < 2) {
|
|
2822
2384
|
collapseSwitcher();
|
|
2823
2385
|
}
|
|
2824
2386
|
syncChrome(); // reflect the drop immediately (count, position)
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
}
|
|
2832
|
-
if (activeId === id) {
|
|
2833
|
-
// Switch to a neighbor, or spawn a fresh session if this was the last.
|
|
2387
|
+
// What the panes show next is decided here, before the server's answer:
|
|
2388
|
+
// selection must not sit on an emptied pane for a DELETE round trip.
|
|
2389
|
+
let replacement: Promise<void> | undefined;
|
|
2390
|
+
if (side !== null && !splitOpen) {
|
|
2391
|
+
// One pane: switch to a neighbor, or spawn a fresh session if this was
|
|
2392
|
+
// the last.
|
|
2834
2393
|
const neighbor = tabList[idx] ?? tabList[idx - 1];
|
|
2835
|
-
activeId = null;
|
|
2836
2394
|
if (neighbor) {
|
|
2837
2395
|
switchTo(neighbor.id);
|
|
2838
2396
|
} else {
|
|
2839
|
-
|
|
2397
|
+
replacement = create();
|
|
2398
|
+
}
|
|
2399
|
+
} else if (side !== null) {
|
|
2400
|
+
// While the split is open the pane stays empty and a new tab lands in
|
|
2401
|
+
// the other pane, so a close never promotes a tab nobody clicked.
|
|
2402
|
+
const other = otherSide(side);
|
|
2403
|
+
if (shownIn(other) !== null) {
|
|
2404
|
+
ctx.shell.select(other);
|
|
2405
|
+
focusAfterSwitch();
|
|
2406
|
+
} else if (!anyShown()) {
|
|
2407
|
+
replacement = create(other);
|
|
2840
2408
|
}
|
|
2409
|
+
scheduleLayoutWrite();
|
|
2841
2410
|
}
|
|
2411
|
+
if (remote) {
|
|
2412
|
+
try {
|
|
2413
|
+
await api.close(id);
|
|
2414
|
+
} catch {
|
|
2415
|
+
if (!tornDown()) {
|
|
2416
|
+
ctx.toast("Couldn't close the terminal on the server");
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
await replacement;
|
|
2842
2421
|
}
|
|
2843
2422
|
|
|
2844
2423
|
async function close_(id: string): Promise<void> {
|
|
2845
2424
|
await dropTab(id, true);
|
|
2846
2425
|
}
|
|
2847
2426
|
|
|
2848
|
-
//
|
|
2849
|
-
//
|
|
2850
|
-
// re-homes the active tab to a survivor if it was among them, DELETEs each
|
|
2851
|
-
// server session, and guarantees at least one terminal stays open (a fresh
|
|
2852
|
-
// one is spawned only when every tab was closed). closeAll / closeOthers /
|
|
2853
|
-
// closeToRight / closeToLeft are thin wrappers that pick the id set.
|
|
2427
|
+
// Each tab is a running agent, so closing two or more asks first. This path
|
|
2428
|
+
// drops tabs itself, so every per-tab state dropTab releases is released here.
|
|
2854
2429
|
async function closeMany(ids: readonly string[]): Promise<void> {
|
|
2855
2430
|
const victims = tabList.filter((t) => ids.includes(t.id));
|
|
2856
2431
|
if (victims.length === 0) {
|
|
2857
2432
|
return;
|
|
2858
2433
|
}
|
|
2859
|
-
if (victims.length >= 2 && !
|
|
2434
|
+
if (victims.length >= 2 && !win.confirm(`Close ${String(victims.length)} terminals?`)) {
|
|
2860
2435
|
return;
|
|
2861
2436
|
}
|
|
2862
|
-
const
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
//
|
|
2866
|
-
// focus-on-switch.
|
|
2437
|
+
const selectedBefore = selectedId();
|
|
2438
|
+
const closingSelected = selectedBefore !== null && ids.includes(selectedBefore);
|
|
2439
|
+
const splitOpen = ctx.shell.split.isOpen();
|
|
2440
|
+
// An editor left open on a removed tab keeps editingId alive past its chip
|
|
2441
|
+
// and suppresses focus-on-switch for good.
|
|
2867
2442
|
if (editingId !== null && ids.includes(editingId)) {
|
|
2868
2443
|
endEdit();
|
|
2869
2444
|
}
|
|
2445
|
+
let emptiedPane = false;
|
|
2870
2446
|
for (const t of victims) {
|
|
2871
2447
|
const idx = tabList.indexOf(t);
|
|
2872
2448
|
if (idx >= 0) {
|
|
2873
2449
|
tabList.splice(idx, 1);
|
|
2874
2450
|
}
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
2877
|
-
// closed tab -- mirrors the single-close path in dropTab (h-f2).
|
|
2451
|
+
// A stale status snapshot that predates the server's reap must not
|
|
2452
|
+
// re-adopt a just-closed tab.
|
|
2878
2453
|
tombstones.add(t.id);
|
|
2879
|
-
acknowledgeSwitchNotify(t.id);
|
|
2880
|
-
// ...and its stored acknowledgement goes with it, for the reason dropTab
|
|
2881
|
-
// gives: the session is gone, so the entry would only sit in storage
|
|
2882
|
-
// until the 200-entry cap evicted it. The bulk path drops tabs itself
|
|
2883
|
-
// rather than through dropTab, so every per-session store it touches has
|
|
2884
|
-
// to be listed here too.
|
|
2454
|
+
acknowledgeSwitchNotify(t.id);
|
|
2885
2455
|
forgetCueSeen(t.id);
|
|
2456
|
+
ctx.shell.notifications.forget(t.id);
|
|
2886
2457
|
t.aria.remove();
|
|
2887
|
-
abortReorderFor(t.el); //
|
|
2458
|
+
abortReorderFor(t.el); // a live drag holds this node
|
|
2888
2459
|
t.el.remove();
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
activeId = null;
|
|
2895
|
-
const survivor = tabList[0];
|
|
2896
|
-
if (survivor) {
|
|
2897
|
-
switchTo(survivor.id);
|
|
2460
|
+
const side = sideOf(t.id);
|
|
2461
|
+
if (side !== null && splitOpen) {
|
|
2462
|
+
ctx.shell.pane(side)?.clearActiveSession();
|
|
2463
|
+
ctx.shell.dropSessionExcept(t.id, side);
|
|
2464
|
+
emptiedPane = true;
|
|
2898
2465
|
} else {
|
|
2899
|
-
|
|
2466
|
+
ctx.dropSession(t.id);
|
|
2900
2467
|
}
|
|
2468
|
+
}
|
|
2469
|
+
// Re-home before the DELETEs: selection must not sit on an emptied pane for
|
|
2470
|
+
// a round trip. Split open, the emptied panes stay empty (no promotion).
|
|
2471
|
+
if (!splitOpen && closingSelected && tabList[0]) {
|
|
2472
|
+
switchTo(tabList[0].id);
|
|
2901
2473
|
} else {
|
|
2902
2474
|
syncChrome();
|
|
2475
|
+
if (splitOpen && closingSelected) {
|
|
2476
|
+
const shownSide = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
|
|
2477
|
+
if (shownSide !== undefined) {
|
|
2478
|
+
ctx.shell.select(shownSide);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
if (emptiedPane) {
|
|
2482
|
+
scheduleLayoutWrite();
|
|
2483
|
+
}
|
|
2903
2484
|
}
|
|
2485
|
+
// One terminal always stays open, and an open split always shows one tab.
|
|
2486
|
+
let replacement: Promise<void> | undefined;
|
|
2487
|
+
if (tabList.length === 0 || (splitOpen && !anyShown())) {
|
|
2488
|
+
replacement = create(ctx.shell.panes().find((p) => p.state() === "empty")?.side);
|
|
2489
|
+
}
|
|
2490
|
+
// The person asked for these closes, so every DELETE still goes out past a
|
|
2491
|
+
// teardown; only what the page shows for them stops.
|
|
2904
2492
|
for (const t of victims) {
|
|
2905
2493
|
try {
|
|
2906
2494
|
await api.close(t.id);
|
|
2907
2495
|
} catch {
|
|
2908
|
-
|
|
2496
|
+
if (!tornDown()) {
|
|
2497
|
+
ctx.toast("Couldn't close a terminal on the server");
|
|
2498
|
+
}
|
|
2909
2499
|
}
|
|
2910
2500
|
}
|
|
2911
|
-
|
|
2912
|
-
await create(); // there is always at least one terminal open
|
|
2913
|
-
}
|
|
2501
|
+
await replacement;
|
|
2914
2502
|
}
|
|
2915
2503
|
|
|
2916
2504
|
function closeOthers(id: string): Promise<void> {
|
|
@@ -2930,35 +2518,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2930
2518
|
return closeMany(tabList.map((t) => t.id));
|
|
2931
2519
|
}
|
|
2932
2520
|
|
|
2933
|
-
// --- Inline rename (R5) ---
|
|
2934
2521
|
// The edit surface is the label's OWN box, so the width the user types into
|
|
2935
|
-
// is the width the label will have.
|
|
2936
|
-
//
|
|
2937
|
-
// paste surface.
|
|
2938
|
-
//
|
|
2939
|
-
// editingId is also the stand-down flag for every chip handler that would
|
|
2940
|
-
// otherwise fight the field (click-to-switch, the arrow/Delete keys,
|
|
2941
|
-
// focus-on-switch, the drag) — see the guards at each site.
|
|
2522
|
+
// is the width the label will have. editingId is also the stand-down flag
|
|
2523
|
+
// for every chip handler that would otherwise fight the field.
|
|
2942
2524
|
let editingId: string | null = null;
|
|
2943
2525
|
let editInput: HTMLInputElement | null = null;
|
|
2944
|
-
//
|
|
2945
|
-
//
|
|
2946
|
-
// still be), to the terminal otherwise. Recorded at entry because the exit
|
|
2947
|
-
// paths cannot tell them apart.
|
|
2526
|
+
// Recorded at entry because the exit paths cannot tell a keyboard-started
|
|
2527
|
+
// edit (focus returns to the chip) from a pointer one (to the terminal).
|
|
2948
2528
|
let editFrom: "keyboard" | "pointer" = "pointer";
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
//
|
|
2952
|
-
// order are not one total order, so a record sampled BEFORE our PUT can
|
|
2953
|
-
// arrive after it, and treating it as newer authority would suppress the
|
|
2954
|
-
// request's own rollback and its failure toast.
|
|
2955
|
-
// Counted rather than a Set of ids, because two renames of one tab can be out
|
|
2956
|
-
// at once and the invariant is "no request of OURS is in flight", not "no id
|
|
2957
|
-
// is marked": a bare Set let the first request's completion clear the marker
|
|
2958
|
-
// while the second's PUT was still open, and the second rename then failed
|
|
2959
|
-
// silently. A per-request token would carry more information, but the only
|
|
2960
|
-
// reader asks whether ANY request is out for this id, and the failure handler
|
|
2961
|
-
// already distinguishes its own supersession by (born, nameSeq).
|
|
2529
|
+
// Pinned-name requests in flight, COUNTED per id: two renames of one tab can
|
|
2530
|
+
// be out at once, and a bare Set let the first completion clear the marker
|
|
2531
|
+
// while the second's PUT was still open, so that rename failed silently.
|
|
2962
2532
|
const namesInFlight = new Map<string, number>();
|
|
2963
2533
|
|
|
2964
2534
|
/** restoreFocusAfterEdit sends focus where the entry path implies. The
|
|
@@ -2995,7 +2565,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2995
2565
|
t.el.draggable = true;
|
|
2996
2566
|
// Restore the tab semantics from the CURRENT selected state: the active
|
|
2997
2567
|
// tab can change while an edit is open on a different chip.
|
|
2998
|
-
t.aria.setEditing(false, t.id
|
|
2568
|
+
t.aria.setEditing(false, isShown(t.id));
|
|
2999
2569
|
}
|
|
3000
2570
|
|
|
3001
2571
|
/** commitRename applies a finished edit. An empty value CLEARS the pin, but
|
|
@@ -3073,17 +2643,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3073
2643
|
if (!t) {
|
|
3074
2644
|
return;
|
|
3075
2645
|
}
|
|
3076
|
-
const input =
|
|
2646
|
+
const input = doc.createElement("input");
|
|
3077
2647
|
input.type = "text";
|
|
3078
2648
|
input.className = "wt-tab-rename";
|
|
3079
2649
|
input.setAttribute("aria-label", "Rename terminal");
|
|
3080
|
-
//
|
|
3081
|
-
//
|
|
3082
|
-
//
|
|
3083
|
-
// Since a blur commits a non-empty value, prefilling it meant opening the
|
|
3084
|
-
// editor and clicking away silently pinned presentation text as the user's
|
|
3085
|
-
// chosen name. An unpinned tab therefore starts EMPTY, with the current
|
|
3086
|
-
// label as the placeholder so the user still sees what they are replacing.
|
|
2650
|
+
// The PIN, never the rendered label: a blur commits a non-empty value, so a
|
|
2651
|
+
// prefilled automatic title or de-dup suffix would be pinned by a click-away.
|
|
2652
|
+
// An unpinned tab starts EMPTY with the label as its placeholder.
|
|
3087
2653
|
input.value = t.pinnedTitle ?? "";
|
|
3088
2654
|
input.placeholder = t.display;
|
|
3089
2655
|
// maxLength is a UI affordance only, in UTF-16 code units; the server
|
|
@@ -3107,7 +2673,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3107
2673
|
// Drop the tab semantics while a textbox lives in the chip, and stop the
|
|
3108
2674
|
// chip being draggable: a draggable ancestor interferes with drag-selecting
|
|
3109
2675
|
// text, and dragging the tab you are renaming is meaningless.
|
|
3110
|
-
t.aria.setEditing(true, t.id
|
|
2676
|
+
t.aria.setEditing(true, isShown(t.id));
|
|
3111
2677
|
t.el.draggable = false;
|
|
3112
2678
|
editingId = id;
|
|
3113
2679
|
editInput = input;
|
|
@@ -3145,21 +2711,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3145
2711
|
});
|
|
3146
2712
|
}
|
|
3147
2713
|
|
|
3148
|
-
/**
|
|
3149
|
-
*
|
|
3150
|
-
*
|
|
3151
|
-
*
|
|
3152
|
-
*
|
|
3153
|
-
* -
|
|
3154
|
-
* only mode that may CLEAR a pin from an emptied field.
|
|
3155
|
-
* - `"blur"` commits a non-empty value (Finder and Explorer both do;
|
|
3156
|
-
* discarding on a stray click is hostile) and reverts an empty one — a
|
|
3157
|
-
* blur is loss of focus, not destructive intent, and on a tablet
|
|
3158
|
-
* dismissing the soft keyboard IS a blur.
|
|
3159
|
-
* - `"cancel"` (Escape) applies nothing.
|
|
3160
|
-
*
|
|
3161
|
-
* `focus` is opt-out for the callers that are not a user finishing an edit
|
|
3162
|
-
* (a reorder, a pre-empting second edit): they should not move focus. */
|
|
2714
|
+
/** Close the open edit under the rules for HOW it was closed. `"confirm"`
|
|
2715
|
+
* (Enter) is the only mode that may CLEAR a pin from an emptied field;
|
|
2716
|
+
* `"blur"` commits a non-empty value and reverts an empty one, because a
|
|
2717
|
+
* blur is loss of focus rather than destructive intent, and dismissing a
|
|
2718
|
+
* tablet's soft keyboard IS a blur; `"cancel"` applies nothing. `focus` is
|
|
2719
|
+
* opt-out for a caller that is not the user finishing an edit. */
|
|
3163
2720
|
function resolveEdit(mode: "confirm" | "blur" | "cancel", focus = true): void {
|
|
3164
2721
|
const id = editingId;
|
|
3165
2722
|
if (id === null) {
|
|
@@ -3184,38 +2741,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3184
2741
|
}
|
|
3185
2742
|
}
|
|
3186
2743
|
|
|
3187
|
-
//
|
|
3188
|
-
//
|
|
3189
|
-
//
|
|
3190
|
-
//
|
|
3191
|
-
//
|
|
3192
|
-
|
|
3193
|
-
// raises a context menu from a LONG-PRESS, and the same gesture emits a
|
|
3194
|
-
// click on release which onDocClickMenu would read as a click-away.
|
|
3195
|
-
// menuTouch records whether the press that raised the menu was a finger,
|
|
3196
|
-
// since only touch emits that trailing click.
|
|
3197
|
-
//
|
|
3198
|
-
// The window is a fixed 350ms, so arming it here — mid-press, when the
|
|
3199
|
-
// platform delivers `contextmenu` — only covers a release that comes within
|
|
3200
|
-
// 350ms; hold the chip a beat longer and the menu still dismissed itself on
|
|
3201
|
-
// release. menuOpenedInPress carries the fact that THIS press opened the
|
|
3202
|
-
// menu through to its pointerup, which re-arms on the release edge. The
|
|
3203
|
-
// flag is what keeps that re-arm off an unrelated later tap, whose click is
|
|
3204
|
-
// a genuine dismiss.
|
|
3205
|
-
const menuSwallow = createClickSwallow();
|
|
2744
|
+
// A long-press raises `contextmenu` mid-press and emits a click on release,
|
|
2745
|
+
// which onDocClickMenu would read as a click-away. The swallow window is
|
|
2746
|
+
// fixed, so arming it at `contextmenu` missed a release held a beat longer;
|
|
2747
|
+
// menuOpenedInPress carries the fact through to pointerup, which re-arms on
|
|
2748
|
+
// the release edge, and keeps that re-arm off an unrelated later tap.
|
|
2749
|
+
const menuSwallow = createClickSwallow(win);
|
|
3206
2750
|
let menuTouch = false;
|
|
3207
2751
|
let menuOpenedInPress = false;
|
|
3208
|
-
// One listener on the bar
|
|
3209
|
-
//
|
|
3210
|
-
// gesture's pointer type, and whether it took the keyboard off the
|
|
3211
|
-
// terminal) belong to the gesture, not to a chip.
|
|
2752
|
+
// One listener on the bar: both facts recorded belong to the gesture, not to
|
|
2753
|
+
// a chip, and chips come and go with sessions.
|
|
3212
2754
|
bar.addEventListener(
|
|
3213
2755
|
"pointerdown",
|
|
3214
2756
|
(e) => {
|
|
3215
2757
|
menuTouch = e.pointerType === "touch";
|
|
3216
2758
|
menuOpenedInPress = false;
|
|
3217
2759
|
noteChromePress();
|
|
3218
|
-
|
|
2760
|
+
ctx.shell.notifications.gesture();
|
|
3219
2761
|
},
|
|
3220
2762
|
{ passive: true },
|
|
3221
2763
|
);
|
|
@@ -3237,7 +2779,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3237
2779
|
"pointerdown",
|
|
3238
2780
|
() => {
|
|
3239
2781
|
noteChromePress();
|
|
3240
|
-
|
|
2782
|
+
ctx.shell.notifications.gesture();
|
|
3241
2783
|
},
|
|
3242
2784
|
{ passive: true },
|
|
3243
2785
|
);
|
|
@@ -3246,7 +2788,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3246
2788
|
tabMenu.replaceChildren();
|
|
3247
2789
|
}
|
|
3248
2790
|
function tabMenuItem(label: string, disabled: boolean, run: () => void): void {
|
|
3249
|
-
const b =
|
|
2791
|
+
const b = doc.createElement("button");
|
|
3250
2792
|
b.type = "button";
|
|
3251
2793
|
b.setAttribute("role", "menuitem");
|
|
3252
2794
|
b.textContent = label;
|
|
@@ -3261,7 +2803,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3261
2803
|
tabMenu.appendChild(b);
|
|
3262
2804
|
}
|
|
3263
2805
|
function tabMenuSeparator(): void {
|
|
3264
|
-
const hr =
|
|
2806
|
+
const hr = doc.createElement("div");
|
|
3265
2807
|
hr.className = "wt-tab-menu-sep";
|
|
3266
2808
|
hr.setAttribute("role", "separator");
|
|
3267
2809
|
tabMenu.appendChild(hr);
|
|
@@ -3294,6 +2836,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3294
2836
|
tabMenuItem("Move right", idx >= n - 1, () => {
|
|
3295
2837
|
moveTab(id, 1);
|
|
3296
2838
|
});
|
|
2839
|
+
if (ctx.shell.split.enabled) {
|
|
2840
|
+
const shownOn = sideOf(id);
|
|
2841
|
+
const fits = rowFitsSplit();
|
|
2842
|
+
tabMenuItem("Snap to left", !fits || shownOn === "left", () => {
|
|
2843
|
+
snap(id, "left");
|
|
2844
|
+
});
|
|
2845
|
+
tabMenuItem("Snap to right", !fits || shownOn === "right", () => {
|
|
2846
|
+
snap(id, "right");
|
|
2847
|
+
});
|
|
2848
|
+
tabMenuSeparator();
|
|
2849
|
+
}
|
|
3297
2850
|
tabMenuItem("Close", false, () => {
|
|
3298
2851
|
void close_(id);
|
|
3299
2852
|
});
|
|
@@ -3334,7 +2887,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3334
2887
|
}
|
|
3335
2888
|
let seen = false;
|
|
3336
2889
|
try {
|
|
3337
|
-
seen = localStorage.getItem(SWIPE_HINT_KEY) === "1";
|
|
2890
|
+
seen = win.localStorage.getItem(SWIPE_HINT_KEY) === "1";
|
|
3338
2891
|
} catch {
|
|
3339
2892
|
/* storage unavailable; show once per session via hintShown */
|
|
3340
2893
|
}
|
|
@@ -3342,19 +2895,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3342
2895
|
return;
|
|
3343
2896
|
}
|
|
3344
2897
|
try {
|
|
3345
|
-
localStorage.setItem(SWIPE_HINT_KEY, "1");
|
|
2898
|
+
win.localStorage.setItem(SWIPE_HINT_KEY, "1");
|
|
3346
2899
|
} catch {
|
|
3347
2900
|
/* ignore */
|
|
3348
2901
|
}
|
|
3349
2902
|
ctx.toast("Swipe to switch terminals");
|
|
3350
2903
|
}
|
|
3351
2904
|
|
|
3352
|
-
//
|
|
3353
|
-
//
|
|
3354
|
-
//
|
|
3355
|
-
// one, and its title fields are interchangeable strings that as positional
|
|
3356
|
-
// parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
|
|
3357
|
-
// separately because it is normalised (reportsOf) before it gets here.
|
|
2905
|
+
// Takes the record whole: its title fields are interchangeable strings that
|
|
2906
|
+
// as positional parameters would be swappable. `reports` travels separately
|
|
2907
|
+
// because it is normalised (reportsOf) first.
|
|
3358
2908
|
function applyStatus(rec: StatusRecord, reports: boolean): void {
|
|
3359
2909
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
3360
2910
|
if (!t) {
|
|
@@ -3370,13 +2920,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3370
2920
|
t.order = rec.order;
|
|
3371
2921
|
}
|
|
3372
2922
|
paintStatusDot(t.dot, rec.status, reports);
|
|
3373
|
-
//
|
|
3374
|
-
//
|
|
3375
|
-
//
|
|
3376
|
-
// live bar on every poll tick. The spec's own clear (OSC 9;4;0, or the
|
|
3377
|
-
// abbreviated form) arrives as an explicit -1; the only other clear is
|
|
3378
|
-
// the process ending, applied at render time (renderedProgress). No
|
|
3379
|
-
// timer, and nothing special about 100%.
|
|
2923
|
+
// Only a PRESENT percentage updates the tab: the polling fallback lists
|
|
2924
|
+
// SessionInfo, which carries none, and reading absence as cleared would
|
|
2925
|
+
// blank a live bar every tick. A clear arrives as an explicit -1.
|
|
3380
2926
|
if (rec.progressValue !== undefined) {
|
|
3381
2927
|
t.progress = normalizeProgress(rec.progressValue);
|
|
3382
2928
|
}
|
|
@@ -3393,52 +2939,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3393
2939
|
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
3394
2940
|
}
|
|
3395
2941
|
paintActivityMark(t.activityEl, t.activity, t.activityCount);
|
|
3396
|
-
// A
|
|
3397
|
-
//
|
|
3398
|
-
// request for the next user gesture (see notify.ts).
|
|
2942
|
+
// A program that speaks OSC 9 may also post a notification: arm the
|
|
2943
|
+
// permission request for the next user gesture.
|
|
3399
2944
|
if (reports) {
|
|
3400
|
-
|
|
3401
|
-
}
|
|
3402
|
-
//
|
|
3403
|
-
//
|
|
3404
|
-
//
|
|
3405
|
-
//
|
|
3406
|
-
// list opens. The active surface keeps its own needs-input cue (see
|
|
3407
|
-
// syncMobile); this is the moved + upgraded, glanceable version on the
|
|
3408
|
-
// dedicated button.
|
|
3409
|
-
//
|
|
3410
|
-
// Both statuses are LATCHED server-side, so this runs on re-delivered
|
|
3411
|
-
// state as well as on genuine transitions: every SSE (re)open pushes a
|
|
3412
|
-
// snapshot and the poll fallback re-lists every few seconds. What makes a
|
|
3413
|
-
// dismissal stick across those is cueSeen — a latch this viewer already
|
|
3414
|
-
// acknowledged raises nothing, while a status that moved on drops the
|
|
3415
|
-
// acknowledgement so the next latch is a fresh cue.
|
|
3416
|
-
//
|
|
3417
|
-
// Two inputs, one per question, and the ORDER keeps them apart. The
|
|
3418
|
-
// acknowledgement asks whether this reader has SEEN the tab's real state:
|
|
3419
|
-
// RAW status, first, and only for a cue — a non-cue one has nothing to
|
|
3420
|
-
// acknowledge and has to reach the forget below. The raise and the forget
|
|
3421
|
-
// ask whether anything should be ANNOUNCED: folded. Off the fold the
|
|
3422
|
-
// acknowledgement misses a watched turn until its task ends; off the raw
|
|
3423
|
-
// status the forget makes the "" sentinel pointless.
|
|
2945
|
+
ctx.shell.notifications.arm();
|
|
2946
|
+
}
|
|
2947
|
+
// The switch button's latest-wins dot. Cue statuses are LATCHED server-side
|
|
2948
|
+
// and re-delivered on every snapshot, so cueSeen is what makes a dismissal
|
|
2949
|
+
// stick. The acknowledgement reads the RAW status (a non-cue must reach the
|
|
2950
|
+
// forget below); the raise and the forget read the FOLDED one.
|
|
3424
2951
|
const raw = statusOf(t);
|
|
3425
2952
|
const cue = cueStatusOf(t);
|
|
3426
|
-
if (rec.id
|
|
3427
|
-
//
|
|
3428
|
-
//
|
|
3429
|
-
//
|
|
3430
|
-
// BOTH halves are required, for the reason notify.ts states for its own
|
|
3431
|
-
// suppression rule: keyed on "is this the active tab" alone, this
|
|
3432
|
-
// swallowed the cue of the very session the user left running. That is
|
|
3433
|
-
// the single-session case — one tab is necessarily the active one — and
|
|
3434
|
-
// it is precisely the case the out-of-page surfaces exist for, so a
|
|
3435
|
-
// latch arriving on a HIDDEN page now raises them and is acknowledged
|
|
3436
|
-
// when the user comes back (see onPageVisible).
|
|
2953
|
+
if (isShown(rec.id) && pageVisible() && isCueStatus(raw)) {
|
|
2954
|
+
// BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
|
|
2955
|
+
// the one session the user left running on a hidden page, the case the
|
|
2956
|
+
// out-of-page surfaces exist for.
|
|
3437
2957
|
markCueSeen(rec.id, raw);
|
|
3438
2958
|
} else if (cue === "") {
|
|
3439
|
-
//
|
|
3440
|
-
// the
|
|
3441
|
-
// the cue from scratch the moment the background task ended.
|
|
2959
|
+
// NO INFORMATION: forgetting here would re-raise the cue from scratch
|
|
2960
|
+
// the moment the background task ended.
|
|
3442
2961
|
} else if (!isCueStatus(cue)) {
|
|
3443
2962
|
forgetCueSeen(rec.id);
|
|
3444
2963
|
} else if (isUnseenCue(cue, rec.id, cueSeen)) {
|
|
@@ -3446,158 +2965,234 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3446
2965
|
switchNotifyId = rec.id;
|
|
3447
2966
|
paintSwitchDot();
|
|
3448
2967
|
}
|
|
3449
|
-
//
|
|
3450
|
-
//
|
|
3451
|
-
//
|
|
3452
|
-
//
|
|
3453
|
-
// and overwriting a good label with it dropped an idle tab back to "New
|
|
3454
|
-
// tab". Hold the last known title until a genuine (non-blank) one
|
|
3455
|
-
// arrives; the derived-from-input fallback is likewise sticky.
|
|
3456
|
-
// The typeof check is not redundant with the type: rec comes from
|
|
3457
|
-
// unvalidated server JSON, and a missing title would make .trim() throw
|
|
3458
|
-
// here and abort the caller's whole reconcile loop.
|
|
2968
|
+
// A BLANK title (a status sweep, or the process clearing its OSC 0/2
|
|
2969
|
+
// title) must not drop an idle tab back to "New tab". The typeof check is
|
|
2970
|
+
// live: rec is unvalidated server JSON, and a missing title would abort
|
|
2971
|
+
// the caller's whole reconcile loop.
|
|
3459
2972
|
if (typeof rec.title === "string" && rec.title.trim() !== "") {
|
|
3460
2973
|
t.title = rec.title;
|
|
3461
2974
|
}
|
|
3462
|
-
//
|
|
3463
|
-
//
|
|
3464
|
-
//
|
|
3465
|
-
//
|
|
3466
|
-
// The nameSeq bump is gated on the value actually DIFFERING from what we
|
|
3467
|
-
// believe. The wire carries pinnedTitle on every status event, so bumping
|
|
3468
|
-
// unconditionally would mark an in-flight local rename as superseded by
|
|
3469
|
-
// the server's echo of that same rename — and its failure handler would
|
|
3470
|
-
// then decline to roll back or explain itself. A differing value really is
|
|
3471
|
-
// newer authority (a remote rename or clear), and supersedes.
|
|
2975
|
+
// "" is meaningful here: it is how a clear made in ANOTHER browser reaches
|
|
2976
|
+
// this one. The bump is gated on a DIFFERING value because the wire echoes
|
|
2977
|
+
// pinnedTitle on every event, and an unconditional bump would mark an
|
|
2978
|
+
// in-flight local rename as superseded by its own echo.
|
|
3472
2979
|
if (rec.pinnedTitle !== undefined && rec.pinnedTitle !== (t.pinnedTitle ?? "")) {
|
|
3473
2980
|
t.pinnedTitle = rec.pinnedTitle;
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
3477
|
-
// would suppress that request's rollback and its failure toast.
|
|
2981
|
+
// SSE delivery and REST mutation are not one total order: during a
|
|
2982
|
+
// pending request the record may predate our own PUT, and bumping would
|
|
2983
|
+
// suppress that request's rollback and its failure toast.
|
|
3478
2984
|
if (!namesInFlight.has(rec.id)) {
|
|
3479
2985
|
t.nameSeq++;
|
|
3480
2986
|
}
|
|
3481
2987
|
}
|
|
3482
2988
|
}
|
|
3483
2989
|
|
|
3484
|
-
//
|
|
3485
|
-
//
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
let
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
2990
|
+
// One list reconcile for both status sources: adopt every session the
|
|
2991
|
+
// server lists, then drop tabs it no longer lists. The SSE path runs it on
|
|
2992
|
+
// every (re)open because a RESTARTED manager's snapshot carries no
|
|
2993
|
+
// tombstones for sessions it never knew, so those tabs would otherwise spin
|
|
2994
|
+
// "Reconnecting…" forever. An overlapping call joins the run in flight
|
|
2995
|
+
// rather than starting another, so a caller that awaits it sees a listing.
|
|
2996
|
+
let reconciling: Promise<void> | null = null;
|
|
2997
|
+
const reconcileOnce = (): Promise<void> => {
|
|
2998
|
+
reconciling ??= reconcileNow().finally(() => {
|
|
2999
|
+
reconciling = null;
|
|
3000
|
+
});
|
|
3001
|
+
return reconciling;
|
|
3002
|
+
};
|
|
3003
|
+
async function reconcileNow(): Promise<void> {
|
|
3004
|
+
// The listing is authoritative only for tabs that existed when it was
|
|
3005
|
+
// requested: a tab adopted while the GET was in flight is invisible to
|
|
3006
|
+
// it, and dropping it here cascaded into a duplicate replacement session.
|
|
3007
|
+
const epochAtList = tabEpoch;
|
|
3008
|
+
let list: SessionInfo[];
|
|
3009
|
+
try {
|
|
3010
|
+
list = await api.list();
|
|
3011
|
+
} catch {
|
|
3012
|
+
return; // transient; try again on the next trigger
|
|
3013
|
+
}
|
|
3014
|
+
if (tornDown()) {
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const seen = new Set(list.map((s) => s.id));
|
|
3018
|
+
for (const info of list) {
|
|
3019
|
+
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
3020
|
+
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3021
|
+
}
|
|
3022
|
+
// Already gone server-side, so no DELETE; tabs born after the snapshot
|
|
3023
|
+
// wait for the next reconcile.
|
|
3024
|
+
const gone = tabList
|
|
3025
|
+
.filter((t) => !seen.has(t.id) && t.born <= epochAtList)
|
|
3026
|
+
.map((t) => t.id);
|
|
3027
|
+
for (const id of gone) {
|
|
3028
|
+
await dropTab(id, false);
|
|
3029
|
+
}
|
|
3030
|
+
ensureActive();
|
|
3031
|
+
syncChrome();
|
|
3032
|
+
}
|
|
3033
|
+
function warnLayoutOnce(what: string, err?: unknown): void {
|
|
3034
|
+
if (warnedLayout) {
|
|
3504
3035
|
return;
|
|
3505
3036
|
}
|
|
3506
|
-
|
|
3037
|
+
warnedLayout = true;
|
|
3038
|
+
console.warn(`web-terminal-ui: ${what}; the pane layout is not persisted`, err);
|
|
3039
|
+
}
|
|
3040
|
+
function currentLayout(): PaneLayout | null {
|
|
3041
|
+
const left = ctx.shell.pane("left")?.session.id ?? null;
|
|
3042
|
+
const right = ctx.shell.pane("right")?.session.id ?? null;
|
|
3043
|
+
if (left === null && right === null) {
|
|
3044
|
+
return null;
|
|
3045
|
+
}
|
|
3046
|
+
const split = ctx.shell.split.state();
|
|
3047
|
+
return {
|
|
3048
|
+
left,
|
|
3049
|
+
right,
|
|
3050
|
+
handle: split.committedRatio,
|
|
3051
|
+
selected: split.selected,
|
|
3052
|
+
open: split.open,
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
async function writeLayout(layout: PaneLayout, retryOnStale: boolean): Promise<void> {
|
|
3507
3056
|
try {
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
// invisible to the returned snapshot, and dropping it here cascaded
|
|
3513
|
-
// into a duplicate replacement session (dropTab's last-tab intercept
|
|
3514
|
-
// spawns one) — the double-create boot bug.
|
|
3515
|
-
const epochAtList = tabEpoch;
|
|
3516
|
-
let list: SessionInfo[];
|
|
3517
|
-
try {
|
|
3518
|
-
list = await api.list();
|
|
3519
|
-
} catch {
|
|
3520
|
-
return; // transient; try again on the next trigger
|
|
3057
|
+
await api.setLayout(layout);
|
|
3058
|
+
} catch (err) {
|
|
3059
|
+
if (tornDown()) {
|
|
3060
|
+
return;
|
|
3521
3061
|
}
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3062
|
+
if (err instanceof SessionAPIError && err.status === 409 && retryOnStale) {
|
|
3063
|
+
// A side names a session the server no longer has: this client's view
|
|
3064
|
+
// is behind, so re-list and write the reconciled row once more. The
|
|
3065
|
+
// reconcile's own change is folded into that one write.
|
|
3066
|
+
await reconcileOnce();
|
|
3067
|
+
const reconciled = tornDown() ? null : currentLayout();
|
|
3068
|
+
if (reconciled !== null) {
|
|
3069
|
+
layoutDirty = false;
|
|
3070
|
+
await writeLayout(reconciled, false);
|
|
3071
|
+
}
|
|
3072
|
+
return;
|
|
3526
3073
|
}
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
// round; the next reconcile sees the server truth for them.
|
|
3531
|
-
const gone = tabList
|
|
3532
|
-
.filter((t) => !seen.has(t.id) && t.born <= epochAtList)
|
|
3533
|
-
.map((t) => t.id);
|
|
3534
|
-
for (const id of gone) {
|
|
3535
|
-
await dropTab(id, false);
|
|
3074
|
+
if (err instanceof SessionAPIError && err.status === 404) {
|
|
3075
|
+
warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
|
|
3076
|
+
return;
|
|
3536
3077
|
}
|
|
3537
|
-
|
|
3538
|
-
syncChrome();
|
|
3539
|
-
} finally {
|
|
3540
|
-
reconciling = false;
|
|
3078
|
+
warnLayoutOnce("could not write the pane layout", err);
|
|
3541
3079
|
}
|
|
3542
|
-
}
|
|
3080
|
+
}
|
|
3081
|
+
function flushLayout(): void {
|
|
3082
|
+
layoutDirty = false;
|
|
3083
|
+
const layout = currentLayout();
|
|
3084
|
+
if (layout === null || tornDown()) {
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
layoutInFlight = writeLayout(layout, true).finally(() => {
|
|
3088
|
+
layoutInFlight = null;
|
|
3089
|
+
if (layoutDirty && !tornDown()) {
|
|
3090
|
+
flushLayout();
|
|
3091
|
+
}
|
|
3092
|
+
});
|
|
3093
|
+
}
|
|
3094
|
+
// Every change of the shown tab writes the record; coalesced so a switch
|
|
3095
|
+
// that clears and shows sends one PUT, and held while no tab is shown.
|
|
3096
|
+
function scheduleLayoutWrite(): void {
|
|
3097
|
+
if (tornDown() || applyingRecord) {
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
if (layoutInFlight !== null) {
|
|
3101
|
+
layoutDirty = true;
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
if (layoutWrite !== null) {
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
layoutWrite = win.setTimeout(() => {
|
|
3108
|
+
layoutWrite = null;
|
|
3109
|
+
flushLayout();
|
|
3110
|
+
}, 0);
|
|
3111
|
+
}
|
|
3112
|
+
// Of the split's state, `open`, the remembered handle share and the selected
|
|
3113
|
+
// pane are the record's; a drag preview and a resize clamp are not.
|
|
3114
|
+
let lastSplit = ctx.shell.split.state();
|
|
3115
|
+
ctx.defer(
|
|
3116
|
+
ctx.shell.split.onChange((s) => {
|
|
3117
|
+
const changed =
|
|
3118
|
+
s.open !== lastSplit.open ||
|
|
3119
|
+
s.committedRatio !== lastSplit.committedRatio ||
|
|
3120
|
+
s.selected !== lastSplit.selected;
|
|
3121
|
+
lastSplit = s;
|
|
3122
|
+
if (changed) {
|
|
3123
|
+
scheduleLayoutWrite();
|
|
3124
|
+
}
|
|
3125
|
+
}),
|
|
3126
|
+
);
|
|
3127
|
+
// The shell empties a pane on a close of the split, drops a failed pane's
|
|
3128
|
+
// tab from the shown set and moves selection on its own drivers; the chrome
|
|
3129
|
+
// and the record read the panes, so both follow.
|
|
3130
|
+
ctx.defer(
|
|
3131
|
+
ctx.shell.onPanesChange(() => {
|
|
3132
|
+
markSurfaces();
|
|
3133
|
+
syncCatchups();
|
|
3134
|
+
syncChrome();
|
|
3135
|
+
scheduleLayoutWrite();
|
|
3136
|
+
}),
|
|
3137
|
+
);
|
|
3138
|
+
ctx.defer(ctx.shell.onSelectionChange(syncChrome));
|
|
3139
|
+
if (ctx.shell.split.enabled) {
|
|
3140
|
+
// The controller reports a resize only while the split is open; the
|
|
3141
|
+
// buttons' width rule holds while it is closed too.
|
|
3142
|
+
const rowResize = new win.ResizeObserver(paintSplitButtons);
|
|
3143
|
+
rowResize.observe(ctx.shell.root);
|
|
3144
|
+
ctx.defer(() => {
|
|
3145
|
+
rowResize.disconnect();
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3543
3148
|
if (monitor) {
|
|
3544
|
-
offStatus = monitor.onStatus((s) => {
|
|
3149
|
+
const offStatus = monitor.onStatus((s) => {
|
|
3545
3150
|
if (s.removed) {
|
|
3546
3151
|
void dropTab(s.id, false); // already gone server-side; no DELETE
|
|
3547
3152
|
return;
|
|
3548
3153
|
}
|
|
3549
|
-
// Adopt a session created in another browser so all clients converge.
|
|
3550
|
-
// The status record IS the session's wire shape (SessionStatus extends
|
|
3551
|
-
// SessionInfo), so it flows through whole.
|
|
3552
3154
|
adoptSession(s);
|
|
3553
3155
|
applyStatus(s, reportsOf(s.reportsActivity));
|
|
3554
3156
|
ensureActive();
|
|
3555
3157
|
syncChrome();
|
|
3556
|
-
// A notification is an EVENT
|
|
3557
|
-
//
|
|
3558
|
-
//
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
// the server has a classifier installed.
|
|
3562
|
-
notifier.deliver(s, {
|
|
3563
|
-
sessionIsActive: s.id === activeId,
|
|
3158
|
+
// A notification is an EVENT delivered once, so it is handled here and
|
|
3159
|
+
// not in applyStatus, which also runs on re-delivered STATE. After
|
|
3160
|
+
// syncChrome, so its title is the label the user would see.
|
|
3161
|
+
ctx.shell.notifications.deliver(s, {
|
|
3162
|
+
sessionIsActive: isShown(s.id),
|
|
3564
3163
|
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
3565
|
-
//
|
|
3566
|
-
//
|
|
3567
|
-
// user on the session that raised the notification rather than on
|
|
3568
|
-
// whichever tab they last left active. switchTo already no-ops on an
|
|
3569
|
-
// id that is gone, which is the case that matters here: the prompt may
|
|
3570
|
-
// have been answered, or the tab closed, between post and click.
|
|
3164
|
+
// switchTo no-ops on an id that is gone, which is the case that
|
|
3165
|
+
// matters: the tab may have closed between post and click.
|
|
3571
3166
|
activate: () => {
|
|
3572
3167
|
switchTo(s.id);
|
|
3573
3168
|
},
|
|
3574
3169
|
});
|
|
3575
3170
|
});
|
|
3576
|
-
|
|
3171
|
+
ctx.defer(offStatus);
|
|
3172
|
+
const offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
3577
3173
|
void reconcileOnce();
|
|
3578
3174
|
});
|
|
3175
|
+
if (offStreamOpen) {
|
|
3176
|
+
ctx.defer(offStreamOpen);
|
|
3177
|
+
}
|
|
3579
3178
|
} else {
|
|
3580
3179
|
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
3581
|
-
pollTimer = setInterval(() => {
|
|
3180
|
+
const pollTimer = win.setInterval(() => {
|
|
3582
3181
|
void reconcileOnce();
|
|
3583
3182
|
}, pollMs);
|
|
3183
|
+
ctx.defer(() => {
|
|
3184
|
+
win.clearInterval(pollTimer);
|
|
3185
|
+
});
|
|
3584
3186
|
}
|
|
3585
3187
|
|
|
3586
|
-
//
|
|
3587
|
-
//
|
|
3588
|
-
|
|
3589
|
-
// every client attached to the session — including one that attaches later.
|
|
3590
|
-
// Observe (never consume) keydowns to detect a physical keyboard: a
|
|
3591
|
-
// hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
3592
|
-
// for a keyboard folio with no trackpad (no fine pointer to key off).
|
|
3593
|
-
const offHwKey = ctx.registerKeydown((ev) => {
|
|
3188
|
+
// A hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
3189
|
+
// for a keyboard folio with no trackpad.
|
|
3190
|
+
ctx.registerKeydown((ev) => {
|
|
3594
3191
|
if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
|
|
3595
3192
|
sawHardwareKey = true;
|
|
3596
3193
|
}
|
|
3597
3194
|
return false;
|
|
3598
3195
|
});
|
|
3599
|
-
// Drive the reorder preview while dragging a tab over the strip. A sweep only
|
|
3600
|
-
// rearranges nothing; the slot opens when the pointer comes to rest (trackRest).
|
|
3601
3196
|
bar.addEventListener("dragover", (e) => {
|
|
3602
3197
|
if (!draggingEl) {
|
|
3603
3198
|
return;
|
|
@@ -3608,12 +3203,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3608
3203
|
}
|
|
3609
3204
|
trackRest(e.clientX);
|
|
3610
3205
|
});
|
|
3611
|
-
//
|
|
3612
|
-
//
|
|
3613
|
-
//
|
|
3614
|
-
// at all, dropping a tab on iPadOS navigated the page to /<session-id>
|
|
3615
|
-
// instead of reordering. Chrome and Firefox ignore an unhandled reorder
|
|
3616
|
-
// drop, which is why the strip looked fine on the desktop.
|
|
3206
|
+
// The preventDefault is load-bearing: WebKit's default for an uncancelled
|
|
3207
|
+
// drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
|
|
3208
|
+
// navigated the page to /<session-id>.
|
|
3617
3209
|
bar.addEventListener("drop", (e) => {
|
|
3618
3210
|
const moved = draggingEl;
|
|
3619
3211
|
if (!moved) {
|
|
@@ -3621,27 +3213,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3621
3213
|
}
|
|
3622
3214
|
e.preventDefault();
|
|
3623
3215
|
dropped = true;
|
|
3624
|
-
// A release decides
|
|
3625
|
-
// actually over rather than whatever a timer had pending. commitSlot no-ops when
|
|
3626
|
-
// the tab already sits there, so a drop with nothing to move costs nothing.
|
|
3216
|
+
// A release decides by POSITION, not by whatever slot a timer had pending.
|
|
3627
3217
|
endRestNet();
|
|
3628
3218
|
commitSlot(dropTargetBefore(e.clientX));
|
|
3629
3219
|
syncOrderFromDom();
|
|
3630
|
-
announceMoved(moved);
|
|
3220
|
+
announceMoved(moved);
|
|
3631
3221
|
});
|
|
3632
|
-
//
|
|
3633
|
-
//
|
|
3634
|
-
//
|
|
3635
|
-
//
|
|
3636
|
-
//
|
|
3637
|
-
// one that has stopped moving over the strip — so without this the rest window
|
|
3638
|
-
// would run down and open a slot the pointer had already abandoned.
|
|
3639
|
-
//
|
|
3640
|
-
// relatedTarget is what makes dragleave usable at all: it fires on every
|
|
3641
|
-
// child-to-child transition inside the bar (chip to label, label to close), and
|
|
3642
|
-
// the next target being inside the bar is exactly how those are told apart. A
|
|
3643
|
-
// null relatedTarget means the pointer left the window, which is the case this
|
|
3644
|
-
// exists for.
|
|
3222
|
+
// The strip is docked at the viewport EDGE, so a pointer can leave it by
|
|
3223
|
+
// leaving the window, and then no document dragover fires to withdraw the
|
|
3224
|
+
// pending slot; the rest window would open a slot the pointer abandoned.
|
|
3225
|
+
// dragleave fires on every child-to-child transition inside the bar, and a
|
|
3226
|
+
// null relatedTarget is the pointer leaving the window.
|
|
3645
3227
|
bar.addEventListener("dragleave", (e) => {
|
|
3646
3228
|
if (!draggingEl) {
|
|
3647
3229
|
return;
|
|
@@ -3651,50 +3233,77 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3651
3233
|
endRestNet();
|
|
3652
3234
|
}
|
|
3653
3235
|
});
|
|
3654
|
-
//
|
|
3655
|
-
//
|
|
3656
|
-
//
|
|
3657
|
-
//
|
|
3658
|
-
|
|
3236
|
+
// The half of the pane row a drag is over, or null off the row, over the
|
|
3237
|
+
// switcher, or while the row is too narrow for two panes (the strip is the
|
|
3238
|
+
// caller's early return). An empty pane is `inert`, so a tab dragged over it
|
|
3239
|
+
// hits the shell root underneath.
|
|
3240
|
+
function dropHalf(e: DragEvent): PaneSide | null {
|
|
3241
|
+
const root = ctx.shell.root;
|
|
3242
|
+
const target = e.target as Node;
|
|
3243
|
+
if (
|
|
3244
|
+
!ctx.shell.split.enabled ||
|
|
3245
|
+
!rowFitsSplit() ||
|
|
3246
|
+
!root.contains(target) ||
|
|
3247
|
+
switcher.contains(target)
|
|
3248
|
+
) {
|
|
3249
|
+
return null;
|
|
3250
|
+
}
|
|
3251
|
+
const rect = root.getBoundingClientRect();
|
|
3252
|
+
return e.clientX < rect.left + rect.width / 2 ? "left" : "right";
|
|
3253
|
+
}
|
|
3254
|
+
function paintDropHalf(half: PaneSide | null): void {
|
|
3255
|
+
ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
|
|
3256
|
+
ctx.shell.root.classList.toggle("wt-drop-right", half === "right");
|
|
3257
|
+
}
|
|
3258
|
+
// A tab released anywhere OTHER than the strip must be inert, not a
|
|
3259
|
+
// navigation, so the whole document is a drop target for the life of a tab
|
|
3260
|
+
// drag; over a half of the pane row it is a snap. Gated on draggingEl so a
|
|
3261
|
+
// file dropped on the page is the browser's.
|
|
3659
3262
|
const onDocTabDrop = (e: DragEvent): void => {
|
|
3660
3263
|
if (!draggingEl) {
|
|
3661
3264
|
return;
|
|
3662
3265
|
}
|
|
3663
3266
|
e.preventDefault();
|
|
3664
3267
|
if (bar.contains(e.target as Node)) {
|
|
3665
|
-
|
|
3268
|
+
paintDropHalf(null);
|
|
3269
|
+
return;
|
|
3666
3270
|
}
|
|
3271
|
+
const half = dropHalf(e);
|
|
3667
3272
|
if (e.type === "drop") {
|
|
3668
|
-
//
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
// slot anyway would persist an arrangement chosen at a position the user
|
|
3673
|
-
// visibly left, and worse, the outcome would depend on whether the browser
|
|
3674
|
-
// chose to emit `drop` at all: an accepted document drop committed while a
|
|
3675
|
-
// refused release fell through to dragend and reverted. That distinction is
|
|
3676
|
-
// invisible to the person doing the dragging.
|
|
3677
|
-
//
|
|
3678
|
-
// So: leave `dropped` false, withdraw anything pending, and let dragend run the
|
|
3679
|
-
// revert. Nothing is lost silently — the revert animates and announces, so a
|
|
3680
|
-
// mis-release reads as "that did not take" rather than as a mystery.
|
|
3273
|
+
// A release off the strip CANCELS the reorder: `dropped` stays false and
|
|
3274
|
+
// dragend runs the revert. Committing a slot here would persist an
|
|
3275
|
+
// arrangement chosen at a position the user visibly left, and would
|
|
3276
|
+
// depend on whether the browser emitted `drop` at all.
|
|
3681
3277
|
endRestNet();
|
|
3278
|
+
paintDropHalf(null);
|
|
3279
|
+
if (half !== null) {
|
|
3280
|
+
const id = tabList.find((t) => t.el === draggingEl)?.id;
|
|
3281
|
+
if (id !== undefined) {
|
|
3282
|
+
snap(id, half);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3682
3285
|
return;
|
|
3683
3286
|
}
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3287
|
+
if (e.dataTransfer && half !== null) {
|
|
3288
|
+
e.dataTransfer.dropEffect = "move";
|
|
3289
|
+
}
|
|
3290
|
+
paintDropHalf(half);
|
|
3291
|
+
// No candidate slot exists off the strip; a COMMITTED slot stays put.
|
|
3688
3292
|
endRestNet();
|
|
3689
3293
|
};
|
|
3690
|
-
|
|
3691
|
-
|
|
3294
|
+
doc.addEventListener("dragover", onDocTabDrop);
|
|
3295
|
+
doc.addEventListener("drop", onDocTabDrop);
|
|
3296
|
+
ctx.defer(() => {
|
|
3297
|
+
doc.removeEventListener("dragover", onDocTabDrop);
|
|
3298
|
+
doc.removeEventListener("drop", onDocTabDrop);
|
|
3299
|
+
});
|
|
3692
3300
|
// Active-row close (x): closes the current tab (mirrors a listed row's x).
|
|
3693
3301
|
// stopPropagation so it is not read as a tap/swipe on the row surface.
|
|
3694
3302
|
swClose.addEventListener("click", (e) => {
|
|
3695
3303
|
e.stopPropagation();
|
|
3696
|
-
|
|
3697
|
-
|
|
3304
|
+
const current = selectedId();
|
|
3305
|
+
if (current !== null) {
|
|
3306
|
+
void close_(current);
|
|
3698
3307
|
}
|
|
3699
3308
|
});
|
|
3700
3309
|
// Dismiss the tab context menu on an outside click, on Escape, and on a
|
|
@@ -3707,7 +3316,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3707
3316
|
}
|
|
3708
3317
|
hideTabMenu();
|
|
3709
3318
|
};
|
|
3710
|
-
|
|
3319
|
+
doc.addEventListener("click", onDocClickMenu);
|
|
3320
|
+
ctx.defer(() => {
|
|
3321
|
+
doc.removeEventListener("click", onDocClickMenu);
|
|
3322
|
+
});
|
|
3711
3323
|
// A right-click anywhere other than a tab (the terminal content, elsewhere,
|
|
3712
3324
|
// or a native browser menu) dismisses the tab menu. A right-click ON a tab
|
|
3713
3325
|
// is handled by that tab's own contextmenu handler (which reopens it), and
|
|
@@ -3717,27 +3329,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3717
3329
|
hideTabMenu();
|
|
3718
3330
|
}
|
|
3719
3331
|
};
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
//
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
//
|
|
3728
|
-
// is capture-phase on window, so it sees EVERY scroll in the document —
|
|
3729
|
-
// including the terminal surface auto-scrolling to the bottom on each chunk
|
|
3730
|
-
// of output. Without the check, an agent printing into the TUI closed the
|
|
3731
|
-
// tab menu within a frame of it opening, which is unusable on exactly the
|
|
3732
|
-
// screen the menu exists for (a busy multi-agent strip).
|
|
3332
|
+
doc.addEventListener("contextmenu", onDocContextMenu);
|
|
3333
|
+
ctx.defer(() => {
|
|
3334
|
+
doc.removeEventListener("contextmenu", onDocContextMenu);
|
|
3335
|
+
});
|
|
3336
|
+
// Only a scroll of the strip's own scroller moves the menu's anchor. The
|
|
3337
|
+
// target check is load-bearing: this capture-phase listener sees EVERY
|
|
3338
|
+
// scroll, and without it the terminal auto-scrolling on each chunk of
|
|
3339
|
+
// output closed the menu within a frame of it opening.
|
|
3733
3340
|
const onScrollMenu = (e: Event): void => {
|
|
3734
3341
|
const target = e.target;
|
|
3735
|
-
if (target instanceof Element && target.closest(".wt-tab-scroll")) {
|
|
3342
|
+
if (target instanceof win.Element && target.closest(".wt-tab-scroll")) {
|
|
3736
3343
|
hideTabMenu();
|
|
3737
3344
|
}
|
|
3738
3345
|
};
|
|
3739
|
-
|
|
3740
|
-
|
|
3346
|
+
win.addEventListener("scroll", onScrollMenu, true);
|
|
3347
|
+
ctx.defer(() => {
|
|
3348
|
+
win.removeEventListener("scroll", onScrollMenu, true);
|
|
3349
|
+
});
|
|
3350
|
+
ctx.registerKeydown((ev) => {
|
|
3741
3351
|
if (ev.key === "Escape" && tabMenu.classList.contains("visible")) {
|
|
3742
3352
|
ev.preventDefault();
|
|
3743
3353
|
hideTabMenu();
|
|
@@ -3745,19 +3355,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3745
3355
|
}
|
|
3746
3356
|
return false;
|
|
3747
3357
|
});
|
|
3748
|
-
// A tap on the terminal
|
|
3749
|
-
//
|
|
3750
|
-
//
|
|
3751
|
-
// stops propagation, so the kernel's surface tap-to-focus never fires for
|
|
3752
|
-
// the dismissing tap. Taps inside the switcher or the key grid are left to
|
|
3753
|
-
// their own controls; when nothing is open the tap falls through untouched.
|
|
3358
|
+
// A tap on the terminal dismisses an open mobile overlay (the expanded tab
|
|
3359
|
+
// list or the key grid) rather than opening the keyboard. Capture phase with
|
|
3360
|
+
// stopPropagation, so the surface tap-to-focus never fires for that tap.
|
|
3754
3361
|
const onDocTapDismiss = (e: PointerEvent): void => {
|
|
3755
|
-
// A swipe
|
|
3756
|
-
//
|
|
3757
|
-
//
|
|
3758
|
-
// cannot swallow that window-bubble pointerup -- which, when setPointerCapture
|
|
3759
|
-
// failed and the finger released outside the switcher, would strand gActive=true
|
|
3760
|
-
// and brick all future swipes until reload.
|
|
3362
|
+
// A swipe owns the pointer and resolves in the window-level pointerup;
|
|
3363
|
+
// swallowing that here (when setPointerCapture failed and the finger
|
|
3364
|
+
// released outside the switcher) stranded gActive and bricked every swipe.
|
|
3761
3365
|
if (gActive) {
|
|
3762
3366
|
return;
|
|
3763
3367
|
}
|
|
@@ -3767,11 +3371,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3767
3371
|
return;
|
|
3768
3372
|
}
|
|
3769
3373
|
const target = e.target as HTMLElement | null;
|
|
3770
|
-
// The
|
|
3771
|
-
//
|
|
3772
|
-
// grid, and the button's click then re-opened it — so tapping the button
|
|
3773
|
-
// to CLOSE the grid never worked (a wide-touchscreen / landscape-phone
|
|
3774
|
-
// bug; the switcher's kb button was already exempt via .wt-switcher).
|
|
3374
|
+
// The strip counts as chrome: the desktop keyboard button's own pointerup
|
|
3375
|
+
// otherwise closed the grid here and its click re-opened it.
|
|
3775
3376
|
const inChrome =
|
|
3776
3377
|
target !== null &&
|
|
3777
3378
|
(target.closest(".wt-switcher") !== null ||
|
|
@@ -3787,18 +3388,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3787
3388
|
}
|
|
3788
3389
|
e.stopPropagation();
|
|
3789
3390
|
};
|
|
3790
|
-
|
|
3391
|
+
doc.addEventListener("pointerup", onDocTapDismiss, true);
|
|
3392
|
+
ctx.defer(() => {
|
|
3393
|
+
doc.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
3394
|
+
});
|
|
3791
3395
|
|
|
3792
|
-
//
|
|
3793
|
-
//
|
|
3794
|
-
//
|
|
3795
|
-
//
|
|
3796
|
-
// it snaps: a horizontal drag past a quarter-width commits the switch (else
|
|
3797
|
-
// springs back); a vertical drag past the halfway point snaps the list open
|
|
3798
|
-
// (else closed). A near-stationary release is a tap that toggles the list
|
|
3799
|
-
// (the click listener). swCurrent has touch-action:none so these drags
|
|
3800
|
-
// never scroll the page, and the pointer is captured so a drag that leaves
|
|
3801
|
-
// the bar still delivers its move/up here.
|
|
3396
|
+
// The bar's drag follows the finger live: after the axis lock it previews a
|
|
3397
|
+
// tab switch (horizontal) or grows the tab list (vertical), and snaps on
|
|
3398
|
+
// release past a quarter-width or the halfway point. The pointer is captured
|
|
3399
|
+
// so a drag that leaves the bar still delivers its move/up here.
|
|
3802
3400
|
let gDownX = 0;
|
|
3803
3401
|
let gDownY = 0;
|
|
3804
3402
|
let gAxis: "h" | "v" | null = null;
|
|
@@ -3864,19 +3462,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3864
3462
|
// released without committing, then hands overflow/position back to CSS.
|
|
3865
3463
|
function springRowsBack(): void {
|
|
3866
3464
|
const rows = [...rowEls.values()];
|
|
3867
|
-
|
|
3465
|
+
frame(() => {
|
|
3868
3466
|
for (const el of rows) {
|
|
3869
3467
|
el.style.transition = "transform 0.2s ease-out";
|
|
3870
3468
|
el.style.transform = "translateY(0)";
|
|
3871
3469
|
}
|
|
3872
3470
|
});
|
|
3873
3471
|
if (reelTimer !== null) {
|
|
3874
|
-
clearTimeout(reelTimer);
|
|
3472
|
+
win.clearTimeout(reelTimer);
|
|
3875
3473
|
}
|
|
3876
|
-
reelTimer = setTimeout(endReelNow, 220);
|
|
3474
|
+
reelTimer = win.setTimeout(endReelNow, 220);
|
|
3877
3475
|
}
|
|
3878
3476
|
function endHorizontal(dx: number, releaseT: number, canceled: boolean): void {
|
|
3879
|
-
const width = ctx.surface().clientWidth ||
|
|
3477
|
+
const width = ctx.surface().clientWidth || win.innerWidth;
|
|
3880
3478
|
const dir = dx < 0 ? 1 : -1;
|
|
3881
3479
|
// Commit on a flick (quick + fast + far enough) or once dragged past a
|
|
3882
3480
|
// quarter width; a cancel/capture-loss never commits (springs back). The
|
|
@@ -3897,11 +3495,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3897
3495
|
el.style.transition = spring;
|
|
3898
3496
|
el.style.transform = "";
|
|
3899
3497
|
}
|
|
3900
|
-
|
|
3498
|
+
after(220, () => {
|
|
3901
3499
|
for (const el of swipeEls) {
|
|
3902
3500
|
el.style.transition = "";
|
|
3903
3501
|
}
|
|
3904
|
-
}
|
|
3502
|
+
});
|
|
3905
3503
|
return;
|
|
3906
3504
|
}
|
|
3907
3505
|
// Commit: switchTo slides the incoming terminal in from the side and (when
|
|
@@ -3924,18 +3522,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3924
3522
|
el.style.transition = "none";
|
|
3925
3523
|
el.style.transform = `translateX(${String(Math.round(slide))}px)`;
|
|
3926
3524
|
}
|
|
3927
|
-
|
|
3525
|
+
frame(() => {
|
|
3928
3526
|
for (const el of swipeEls) {
|
|
3929
3527
|
el.style.transition = "transform 0.25s cubic-bezier(0.2, 0, 0, 1)";
|
|
3930
3528
|
el.style.transform = "translateX(0)";
|
|
3931
3529
|
}
|
|
3932
3530
|
});
|
|
3933
|
-
|
|
3531
|
+
after(320, () => {
|
|
3934
3532
|
for (const el of swipeEls) {
|
|
3935
3533
|
el.style.transition = "";
|
|
3936
3534
|
el.style.transform = "";
|
|
3937
3535
|
}
|
|
3938
|
-
}
|
|
3536
|
+
});
|
|
3939
3537
|
}
|
|
3940
3538
|
|
|
3941
3539
|
function beginVertical(): void {
|
|
@@ -3955,7 +3553,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3955
3553
|
// Bound the interactive drag against the VISUAL viewport (the region above the soft
|
|
3956
3554
|
// keyboard), matching the switcher's kb-inset bottom anchor, so the list can't grow
|
|
3957
3555
|
// past the visible area with the keyboard open.
|
|
3958
|
-
const visH =
|
|
3556
|
+
const visH = win.visualViewport?.height ?? win.innerHeight;
|
|
3959
3557
|
gTargetMax = Math.min(swList.scrollHeight, Math.round(visH * 0.5));
|
|
3960
3558
|
gStartMax = expanded ? gTargetMax : 0;
|
|
3961
3559
|
swList.style.maxHeight = `${String(gStartMax)}px`;
|
|
@@ -4012,7 +3610,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4012
3610
|
!canceled &&
|
|
4013
3611
|
Math.abs(dx) >= SWIPE_MIN_PX &&
|
|
4014
3612
|
Math.abs(dx) > Math.abs(dy) * 1.5 &&
|
|
4015
|
-
modes.getMouseMode() === 0
|
|
3613
|
+
ctx.modes.getMouseMode() === 0
|
|
4016
3614
|
) {
|
|
4017
3615
|
// No pointermove locked an axis (a flick with no intermediate move, or
|
|
4018
3616
|
// a synthetic down/up): fall back to a discrete switch from the net
|
|
@@ -4060,7 +3658,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4060
3658
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
4061
3659
|
// Horizontal switch preview, unless a mouse-mode app is capturing
|
|
4062
3660
|
// drags (leave the bar swipe inert then, matching the old gate).
|
|
4063
|
-
if (modes.getMouseMode() !== 0) {
|
|
3661
|
+
if (ctx.modes.getMouseMode() !== 0) {
|
|
4064
3662
|
gActive = false;
|
|
4065
3663
|
swiped = true;
|
|
4066
3664
|
gestureAbort?.abort(); // gesture abandoned; drop the window listeners
|
|
@@ -4120,9 +3718,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4120
3718
|
gestureAbort?.abort();
|
|
4121
3719
|
gestureAbort = new AbortController();
|
|
4122
3720
|
const opts = { passive: true, signal: gestureAbort.signal };
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
3721
|
+
win.addEventListener("pointermove", onGestureMove, opts);
|
|
3722
|
+
win.addEventListener("pointerup", endOnUp, opts);
|
|
3723
|
+
win.addEventListener("pointercancel", endOnCancel, opts);
|
|
4126
3724
|
},
|
|
4127
3725
|
{ passive: true },
|
|
4128
3726
|
);
|
|
@@ -4145,38 +3743,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4145
3743
|
toggleSwitcher();
|
|
4146
3744
|
});
|
|
4147
3745
|
|
|
4148
|
-
// The
|
|
4149
|
-
//
|
|
4150
|
-
//
|
|
4151
|
-
//
|
|
4152
|
-
//
|
|
4153
|
-
//
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
3746
|
+
// The bootstrap (paneLayoutOwner.resolveInitialLayout): list existing
|
|
3747
|
+
// sessions or create the first one, adopt them, pick the start tab from the
|
|
3748
|
+
// server's layout record, and show it. A false return (nothing could be
|
|
3749
|
+
// listed or spawned) keeps the chrome up with the "+" retry alive, and the
|
|
3750
|
+
// terminal dismisses the loading overlay over it. The record is read once
|
|
3751
|
+
// and never written back by a read.
|
|
3752
|
+
const resolveInitialLayout = async (): Promise<boolean> => {
|
|
3753
|
+
const [listed, layoutRead] = await Promise.allSettled([api.list(), api.getLayout()]);
|
|
3754
|
+
if (tornDown()) {
|
|
3755
|
+
return false;
|
|
3756
|
+
}
|
|
3757
|
+
let sessions: SessionInfo[] = listed.status === "fulfilled" ? listed.value : [];
|
|
3758
|
+
let layout: PaneLayout | null = null;
|
|
3759
|
+
if (layoutRead.status === "fulfilled") {
|
|
3760
|
+
layout = layoutRead.value;
|
|
3761
|
+
if (layout === null) {
|
|
3762
|
+
warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
|
|
3763
|
+
}
|
|
3764
|
+
} else {
|
|
3765
|
+
warnLayoutOnce("could not read the pane layout", layoutRead.reason);
|
|
3766
|
+
}
|
|
3767
|
+
// A fresh session unless a LIVE one is listed: an ended session is viewable
|
|
3768
|
+
// history, not a working terminal, and a boot onto one would hang the
|
|
3769
|
+
// loading state. The dead ones are still adopted below.
|
|
4169
3770
|
if (!sessions.some((s) => !isEndedStatus(s.status))) {
|
|
4170
3771
|
try {
|
|
4171
|
-
sessions = [...sessions, await createSessionHonouringRetry(api, ctx,
|
|
3772
|
+
sessions = [...sessions, await createSessionHonouringRetry(api, ctx, lifetime.signal)];
|
|
4172
3773
|
} catch (err) {
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
//
|
|
4177
|
-
//
|
|
4178
|
-
//
|
|
4179
|
-
// carry the server's explanation when it gave one.
|
|
3774
|
+
if (tornDown()) {
|
|
3775
|
+
return false;
|
|
3776
|
+
}
|
|
3777
|
+
// The give-up path (a 503 was already retried on the server's schedule):
|
|
3778
|
+
// toast with the server's explanation and leave the chrome up so "+"
|
|
3779
|
+
// can retry, as the runtime create() does.
|
|
4180
3780
|
ctx.toast(
|
|
4181
3781
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4182
3782
|
? `Couldn't open a terminal: ${err.serverMessage}`
|
|
@@ -4184,122 +3784,115 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4184
3784
|
);
|
|
4185
3785
|
}
|
|
4186
3786
|
}
|
|
4187
|
-
// Adopt
|
|
4188
|
-
//
|
|
4189
|
-
//
|
|
4190
|
-
// already have a tab by the time the list lands. A straight push
|
|
4191
|
-
// doubled every already-adopted session (6 tabs from 3 across a fresh
|
|
4192
|
-
// load), and paintActive then lit both copies of the active id
|
|
4193
|
-
// ("2 active tabs" that move together).
|
|
3787
|
+
// Adopt rather than push: the status stream's open snapshot may already
|
|
3788
|
+
// have given a listed session its tab, since the subscription precedes
|
|
3789
|
+
// this list.
|
|
4194
3790
|
for (const info of sessions) {
|
|
4195
3791
|
adoptSession(info);
|
|
4196
3792
|
}
|
|
4197
3793
|
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
4198
3794
|
started = true;
|
|
4199
|
-
//
|
|
4200
|
-
//
|
|
4201
|
-
//
|
|
4202
|
-
//
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
// callers during boot (see its `started` gate): when it was, the SSE
|
|
4206
|
-
// snapshot won this race on every load and the ladder below never ran.
|
|
4207
|
-
if (activeId !== null) {
|
|
4208
|
-
return null;
|
|
3795
|
+
// A tap on a chip while the list was in flight outranks the record;
|
|
3796
|
+
// ensureActive is deliberately NOT one of these callers during boot (see
|
|
3797
|
+
// its bootShowedNothing gate), or the SSE snapshot would win this race on
|
|
3798
|
+
// every load.
|
|
3799
|
+
if (anyShown()) {
|
|
3800
|
+
return true;
|
|
4209
3801
|
}
|
|
4210
|
-
//
|
|
4211
|
-
//
|
|
4212
|
-
//
|
|
4213
|
-
//
|
|
4214
|
-
// honored only while its session is still live (a reload used to restore
|
|
4215
|
-
// straight onto the corpse of a died-while-away session and wedge
|
|
4216
|
-
// there), and the default is the oldest LIVE tab. Only when nothing is
|
|
4217
|
-
// live (the fresh-spawn above failed too) does a dead tab start — a
|
|
4218
|
-
// frozen final screen with the "Session ended" banner beats a blank
|
|
4219
|
-
// page.
|
|
3802
|
+
// The record's sessions where they still exist, else the oldest LIVE tab:
|
|
3803
|
+
// a reload must not restore onto the corpse of a session that died while
|
|
3804
|
+
// away. Only when nothing is live does a dead tab start, a frozen final
|
|
3805
|
+
// screen beating a blank page.
|
|
4220
3806
|
const liveIds = new Set(sessions.filter((s) => !isEndedStatus(s.status)).map((s) => s.id));
|
|
4221
3807
|
const oldestLive = tabList.find((t) => liveIds.has(t.id));
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
3808
|
+
const saved = (id: string | null): Tab | undefined => {
|
|
3809
|
+
const tab = id === null ? undefined : tabList.find((x) => x.id === id);
|
|
3810
|
+
return tab && (liveIds.has(tab.id) || oldestLive === undefined) ? tab : undefined;
|
|
3811
|
+
};
|
|
3812
|
+
// Shown directly rather than through showIn: a boot announces no switch
|
|
3813
|
+
// and no selection, animates none, and writes no record.
|
|
3814
|
+
const showAtBoot = (side: PaneSide, tab: Tab): boolean => {
|
|
3815
|
+
const pane = ctx.shell.pane(side);
|
|
3816
|
+
if (pane === null) {
|
|
3817
|
+
return false;
|
|
3818
|
+
}
|
|
3819
|
+
pane.render.bind(tab.store);
|
|
3820
|
+
pane.notifySwitch({ id: tab.id });
|
|
3821
|
+
return true;
|
|
3822
|
+
};
|
|
3823
|
+
let shown = false;
|
|
3824
|
+
if (layout?.open === true) {
|
|
3825
|
+
const sides: Record<PaneSide, Tab | undefined> = {
|
|
3826
|
+
left: saved(layout.left),
|
|
3827
|
+
right: saved(layout.right),
|
|
3828
|
+
};
|
|
3829
|
+
// The record's side while it still has a tab to show, else the side that has one.
|
|
3830
|
+
const selected: PaneSide = sides[layout.selected]
|
|
3831
|
+
? layout.selected
|
|
3832
|
+
: sides.left
|
|
3833
|
+
? "left"
|
|
3834
|
+
: "right";
|
|
3835
|
+
applyingRecord = true;
|
|
3836
|
+
try {
|
|
3837
|
+
if ((sides.left || sides.right) && ctx.shell.restoreSplit(layout.handle, selected)) {
|
|
3838
|
+
for (const side of ["left", "right"] as const) {
|
|
3839
|
+
const tab = sides[side];
|
|
3840
|
+
if (tab) {
|
|
3841
|
+
shown = showAtBoot(side, tab) || shown;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
4229
3844
|
}
|
|
3845
|
+
} finally {
|
|
3846
|
+
applyingRecord = false;
|
|
4230
3847
|
}
|
|
4231
|
-
} catch {
|
|
4232
|
-
/* storage unavailable — fall back to the oldest live tab */
|
|
4233
3848
|
}
|
|
4234
|
-
if (!
|
|
4235
|
-
|
|
3849
|
+
if (!shown) {
|
|
3850
|
+
const startTab =
|
|
3851
|
+
saved(
|
|
3852
|
+
layout === null ? null : layout.selected === "right" ? layout.right : layout.left,
|
|
3853
|
+
) ??
|
|
3854
|
+
oldestLive ??
|
|
3855
|
+
tabList[0];
|
|
3856
|
+
shown = startTab !== undefined && showAtBoot(ctx.shell.targetFor(startTab.id), startTab);
|
|
4236
3857
|
}
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
} catch {
|
|
4241
|
-
/* storage unavailable — non-fatal */
|
|
3858
|
+
if (!shown) {
|
|
3859
|
+
bootShowedNothing = true;
|
|
3860
|
+
return false;
|
|
4242
3861
|
}
|
|
4243
|
-
ctx.render.bind(startTab.store);
|
|
4244
3862
|
syncChrome();
|
|
4245
3863
|
focusInput();
|
|
4246
|
-
return
|
|
3864
|
+
return true;
|
|
4247
3865
|
};
|
|
3866
|
+
live = { resolveInitialLayout, shownIn, showIn };
|
|
4248
3867
|
|
|
4249
3868
|
return {
|
|
4250
3869
|
api: {
|
|
4251
3870
|
create,
|
|
4252
3871
|
close: close_,
|
|
4253
3872
|
switchTo,
|
|
4254
|
-
|
|
4255
|
-
|
|
3873
|
+
snap,
|
|
3874
|
+
list: () => tabList.map((t) => ({ id: t.id, title: t.display, active: isShown(t.id) })),
|
|
4256
3875
|
},
|
|
4257
3876
|
teardown() {
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
// destroyed terminal in an embedded panel leaves the document's icon
|
|
4263
|
-
// pointed at a status variant for a tab that no longer exists.
|
|
4264
|
-
// Change-gated, so this is a no-op when nothing was lit.
|
|
4265
|
-
attention.apply(NO_ATTENTION);
|
|
4266
|
-
tornDown = true;
|
|
4267
|
-
resolveImpl = null;
|
|
4268
|
-
offStatus?.();
|
|
4269
|
-
offStreamOpen?.();
|
|
4270
|
-
offHwKey();
|
|
4271
|
-
offArmed?.();
|
|
4272
|
-
offMenuKey();
|
|
4273
|
-
document.removeEventListener("click", onDocClickMenu);
|
|
4274
|
-
document.removeEventListener("visibilitychange", onPageVisible);
|
|
4275
|
-
window.removeEventListener("pagehide", onPageGone);
|
|
4276
|
-
window.removeEventListener("pageshow", onPageBack);
|
|
4277
|
-
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
4278
|
-
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
4279
|
-
document.removeEventListener("dragover", onDocTabDrop);
|
|
4280
|
-
document.removeEventListener("drop", onDocTabDrop);
|
|
4281
|
-
window.removeEventListener("scroll", onScrollMenu, true);
|
|
4282
|
-
if (pollTimer !== null) {
|
|
4283
|
-
clearInterval(pollTimer);
|
|
3877
|
+
live = null;
|
|
3878
|
+
if (layoutWrite !== null) {
|
|
3879
|
+
win.clearTimeout(layoutWrite);
|
|
3880
|
+
layoutWrite = null;
|
|
4284
3881
|
}
|
|
4285
|
-
barResize.disconnect();
|
|
4286
|
-
swReserve.disconnect();
|
|
4287
|
-
stopInkCentring();
|
|
4288
|
-
surface.classList.remove("wt-with-tabbar");
|
|
4289
3882
|
varRoot.style.removeProperty("--wt-tabbar-h");
|
|
4290
|
-
root
|
|
3883
|
+
root.classList.remove("wt-tabbed");
|
|
4291
3884
|
varRoot.style.removeProperty("--wt-reserve-bottom");
|
|
4292
|
-
endSwitchAnim(
|
|
3885
|
+
endSwitchAnim();
|
|
4293
3886
|
if (collapseClearTimer !== null) {
|
|
4294
|
-
clearTimeout(collapseClearTimer);
|
|
3887
|
+
win.clearTimeout(collapseClearTimer);
|
|
4295
3888
|
}
|
|
4296
3889
|
endReelNow();
|
|
4297
3890
|
gestureAbort?.abort(); // drop any in-flight gesture's window listeners
|
|
4298
3891
|
endEdit(); // abandon an open rename without issuing a request
|
|
4299
3892
|
clearDragGhost();
|
|
3893
|
+
paintDropHalf(null);
|
|
4300
3894
|
endReorderPreview();
|
|
4301
3895
|
endShift(); // leave no inline style on a chip this feature no longer owns
|
|
4302
|
-
clearCatchup();
|
|
4303
3896
|
hideTabMenu();
|
|
4304
3897
|
for (const t of tabList) {
|
|
4305
3898
|
t.aria.remove();
|
|
@@ -4312,7 +3905,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4312
3905
|
deskKb.remove();
|
|
4313
3906
|
tabMenu.remove();
|
|
4314
3907
|
switcher.remove();
|
|
4315
|
-
catchup.remove();
|
|
4316
3908
|
},
|
|
4317
3909
|
};
|
|
4318
3910
|
},
|