@cplieger/web-terminal-ui 4.6.0 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/css/02-terminal.css +9 -4
- package/css/30-tabs.css +120 -10
- package/css/31-switcher.css +19 -4
- package/package.json +7 -4
- package/src/features/context-menu.ts +9 -12
- package/src/features/menu-position.ts +29 -0
- package/src/features/tabs/index.ts +708 -99
- package/src/features/tabs/model.ts +284 -182
- package/src/kernel/a11y.ts +16 -0
- package/src/kernel/kernel.ts +119 -47
- package/src/kernel/types.ts +41 -11
- package/src/presets/agent-tabbed.ts +4 -6
- package/src/presets/tabbed.ts +8 -3
|
@@ -13,16 +13,23 @@ import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/
|
|
|
13
13
|
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
14
14
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
15
15
|
import { fromHTML } from "../dom.js";
|
|
16
|
-
import { placeMenuAt } from "../menu-position.js";
|
|
16
|
+
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
17
17
|
import type { SessionInfo, Tab } from "./model.js";
|
|
18
18
|
import {
|
|
19
19
|
ACTIVE_TAB_KEY,
|
|
20
|
+
MAX_PINNED_NAME,
|
|
20
21
|
STATUS_EXITED,
|
|
21
22
|
SWIPE_HINT_KEY,
|
|
23
|
+
SessionAPIError,
|
|
24
|
+
TAB_ORDER_KEY,
|
|
22
25
|
baseLabel,
|
|
23
|
-
createInputTitleDeriver,
|
|
24
26
|
createSessionAPI,
|
|
25
27
|
createTombstones,
|
|
28
|
+
hasPinnedName,
|
|
29
|
+
orderedInsertIndex,
|
|
30
|
+
parseTabOrder,
|
|
31
|
+
sanitizePinnedName,
|
|
32
|
+
serializeTabOrder,
|
|
26
33
|
} from "./model.js";
|
|
27
34
|
import {
|
|
28
35
|
TAB_HTML,
|
|
@@ -59,6 +66,14 @@ const DEFAULT_API_BASE = "/api/sessions";
|
|
|
59
66
|
// (pointer: coarse) where touch matters.
|
|
60
67
|
// Default cadence for the no-activityMonitor polling fallback.
|
|
61
68
|
const DEFAULT_POLL_MS = 4000;
|
|
69
|
+
// The drag data type carrying a reordered tab's session id. A PRIVATE type, not
|
|
70
|
+
// text/plain, deliberately: WebKit resolves dropped plain text as a URL and
|
|
71
|
+
// NAVIGATES to it when no handler cancels the drop, so a bare session id read as
|
|
72
|
+
// a relative path — dropping a tab on iPadOS loaded /<session-id> instead of
|
|
73
|
+
// reordering. A custom type also keeps the id out of the system pasteboard when
|
|
74
|
+
// the drag leaves the browser, which is what MDN's recommended-drag-types
|
|
75
|
+
// guidance prescribes for data specific to one application.
|
|
76
|
+
const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
62
77
|
// Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
|
|
63
78
|
// the shared point-anchored positioner (menu-position.ts), shared with the
|
|
64
79
|
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
@@ -92,14 +107,6 @@ export interface TabsOptions {
|
|
|
92
107
|
* toolbar should be built with { externalToggle: true } so its own top-right
|
|
93
108
|
* toggle is hidden and the grid opens above the bar. */
|
|
94
109
|
keyboardToggle?: TerminalFeature<MobileToolbarApi>;
|
|
95
|
-
/** Prefer the input-derived title over the process OSC 0/2 title. Default
|
|
96
|
-
* false (OSC-first: a program that sets its own window title wins, else the
|
|
97
|
-
* last submitted line). Set true for an agent shell whose program emits a
|
|
98
|
-
* non-empty but useless OSC title (kiro-cli under web-terminal-kiro): the label then
|
|
99
|
-
* follows the latest submitted line (live) or the persisted client title (on
|
|
100
|
-
* reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
|
|
101
|
-
* enables this. */
|
|
102
|
-
preferInputTitle?: boolean;
|
|
103
110
|
/** Presume every session reports activity (an agent shell, where the program
|
|
104
111
|
* always emits OSC 9;4 progress): each tab's dot is visible as idle from
|
|
105
112
|
* creation instead of popping in seconds later when the agent has booted
|
|
@@ -118,6 +125,105 @@ export interface TabsOptions {
|
|
|
118
125
|
// to latch a "physical keyboard present" flag that also covers a keyboard-only
|
|
119
126
|
// Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
|
|
120
127
|
// does not match `any-pointer: fine`).
|
|
128
|
+
/** Session creation can be legitimately and TEMPORARILY refused, which is not the
|
|
129
|
+
* same event as a broken server. web-terminal-kiro answers 503 with
|
|
130
|
+
* `Retry-After: 5` and a body message while its tool engine installs the
|
|
131
|
+
* manifest's tools on first boot, a window its own HEALTHCHECK budgets 20
|
|
132
|
+
* minutes for. That state used to reach the user as the same fixed "Couldn't
|
|
133
|
+
* open a terminal" toast as a 500, with no retry: the page looked broken while
|
|
134
|
+
* the server was deliberately waiting, and `/api/health` reported healthy at the
|
|
135
|
+
* same time, so the two channels contradicted each other.
|
|
136
|
+
*
|
|
137
|
+
* So honour what the server published: retry on its own schedule, and repeat its
|
|
138
|
+
* own explanation rather than inventing library wording for a host-specific
|
|
139
|
+
* condition. Only 503 retries; a 429 rate limit, a 4xx, or a 500 still fails
|
|
140
|
+
* fast, because those are not "come back shortly".
|
|
141
|
+
*
|
|
142
|
+
* The bound is ELAPSED TIME, not an attempt count. An attempt cap interacts
|
|
143
|
+
* badly with the server's hint: at web-terminal-kiro's `Retry-After: 5` a dozen
|
|
144
|
+
* attempts is only a minute, while the window this exists for can run twenty
|
|
145
|
+
* (toolbelt's boot job is bounded at 30 minutes, which is why that app's
|
|
146
|
+
* HEALTHCHECK carries --start-period=20m), so the retry would give up long
|
|
147
|
+
* before the server was ready and the whole fix would miss its case. Waiting is
|
|
148
|
+
* cheap here because every iteration sleeps at least the server's hint, so this
|
|
149
|
+
* is never a hot loop. The user is re-told periodically rather than once,
|
|
150
|
+
* because a page that silently retries for twenty minutes is its own kind of
|
|
151
|
+
* broken. */
|
|
152
|
+
const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
|
|
153
|
+
const CREATE_RETRY_FALLBACK_MS = 5000;
|
|
154
|
+
const CREATE_RETRY_REANNOUNCE_MS = 60000;
|
|
155
|
+
|
|
156
|
+
/** orderSignature collapses an arrangement into one comparable string, so the
|
|
157
|
+
* persist path can decide "did the order change?" with a string compare instead
|
|
158
|
+
* of re-serializing and re-writing on every status tick. NUL is the separator
|
|
159
|
+
* because a session id can never contain one. */
|
|
160
|
+
function orderSignature(ids: readonly string[]): string {
|
|
161
|
+
return ids.join("\u0000");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** readSavedOrder loads the persisted tab arrangement, or [] when there is none
|
|
165
|
+
* to trust. Storage itself can throw — Safari in private mode, a disabled
|
|
166
|
+
* third-party context, an embedder's iframe — and an unreadable arrangement is
|
|
167
|
+
* never fatal: the strip falls back to the server's creation order, exactly as
|
|
168
|
+
* it behaved before arrangements were persisted. */
|
|
169
|
+
function readSavedOrder(): string[] {
|
|
170
|
+
try {
|
|
171
|
+
return parseTabOrder(localStorage.getItem(TAB_ORDER_KEY));
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** writeSavedOrder persists an arrangement, best-effort: a full quota or an
|
|
178
|
+
* unavailable store must not break a reorder the user just performed on screen.
|
|
179
|
+
* The in-memory arrangement stays authoritative for this page either way. */
|
|
180
|
+
function writeSavedOrder(ids: readonly string[]): void {
|
|
181
|
+
try {
|
|
182
|
+
localStorage.setItem(TAB_ORDER_KEY, serializeTabOrder(ids));
|
|
183
|
+
} catch {
|
|
184
|
+
/* storage unavailable or full — the order still holds for this page */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function createSessionHonouringRetry(
|
|
189
|
+
api: { create: () => Promise<SessionInfo> },
|
|
190
|
+
ctx: TerminalContext,
|
|
191
|
+
isTornDown: () => boolean,
|
|
192
|
+
): Promise<SessionInfo> {
|
|
193
|
+
const startedAt = Date.now();
|
|
194
|
+
let lastAnnouncedAt = 0;
|
|
195
|
+
for (;;) {
|
|
196
|
+
try {
|
|
197
|
+
return await api.create();
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const elapsed = Date.now() - startedAt;
|
|
200
|
+
if (
|
|
201
|
+
!(err instanceof SessionAPIError) ||
|
|
202
|
+
err.status !== 503 ||
|
|
203
|
+
elapsed >= CREATE_RETRY_MAX_TOTAL_MS
|
|
204
|
+
) {
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
// First refusal always speaks; after that only every
|
|
208
|
+
// CREATE_RETRY_REANNOUNCE_MS, so a multi-minute wait is neither silent nor
|
|
209
|
+
// a toast storm.
|
|
210
|
+
if (lastAnnouncedAt === 0 || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
211
|
+
lastAnnouncedAt = elapsed;
|
|
212
|
+
const waiting = err.serverMessage ?? "Server is not ready yet";
|
|
213
|
+
ctx.toast(`${waiting}; retrying`, 8000);
|
|
214
|
+
ctx.announce(`${waiting}; retrying`);
|
|
215
|
+
}
|
|
216
|
+
await new Promise((resolve) => {
|
|
217
|
+
window.setTimeout(resolve, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS);
|
|
218
|
+
});
|
|
219
|
+
// A reload or destroy during the wait must not resurrect a create.
|
|
220
|
+
if (isTornDown()) {
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
121
227
|
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
122
228
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
123
229
|
return true;
|
|
@@ -141,7 +247,6 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
|
141
247
|
|
|
142
248
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
143
249
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
144
|
-
const preferInputTitle = opts.preferInputTitle ?? false;
|
|
145
250
|
const presumeReports = opts.presumeReports ?? false;
|
|
146
251
|
// reportsOf floors the server's per-session reportsActivity flag with the
|
|
147
252
|
// consumer's presumption (see TabsOptions.presumeReports): an agent shell
|
|
@@ -160,6 +265,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
160
265
|
// setup), while the bootstrap needs setup-scoped state — so it delegates to
|
|
161
266
|
// a closure setup() wires. resolveImpl is nulled on teardown.
|
|
162
267
|
let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
|
|
268
|
+
// Set on teardown so an in-flight create RETRY (which sleeps between attempts,
|
|
269
|
+
// potentially across a multi-minute install window) cannot resurrect a session
|
|
270
|
+
// for a feature that is already gone.
|
|
271
|
+
let tornDown = false;
|
|
163
272
|
return {
|
|
164
273
|
name: "tabs",
|
|
165
274
|
sessionOwner: {
|
|
@@ -463,6 +572,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
463
572
|
// /api/sessions) that predates the server reaping the session does not
|
|
464
573
|
// re-adopt (flash back) a closed tab.
|
|
465
574
|
const tombstones = createTombstones();
|
|
575
|
+
// The user's persisted tab arrangement (model.ts TAB_ORDER_KEY): read ONCE
|
|
576
|
+
// at setup, before any session can be adopted, so every adopt — from the
|
|
577
|
+
// status-stream snapshot or from the bootstrap's list, in either order —
|
|
578
|
+
// can place its tab at the saved position. Read-only for the life of the
|
|
579
|
+
// page: it is the arrangement to RESTORE, and the live strip must not
|
|
580
|
+
// overwrite it (see persistOrder).
|
|
581
|
+
const savedOrder: readonly string[] = readSavedOrder();
|
|
582
|
+
// Signature of the arrangement currently on disk, so persistOrder can run
|
|
583
|
+
// from syncChrome (which fires on every status tick) and still write only
|
|
584
|
+
// when the sequence actually changed. Seeded from what was read, so a
|
|
585
|
+
// window that simply restored an arrangement writes nothing.
|
|
586
|
+
let persistedSig = orderSignature(savedOrder);
|
|
466
587
|
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
467
588
|
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
468
589
|
// same elements from their old slots to their new ones (the rotation).
|
|
@@ -514,14 +635,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
514
635
|
// read "New tab" without a numeric suffix.
|
|
515
636
|
const counts = new Map<string, number>();
|
|
516
637
|
for (const t of tabList) {
|
|
517
|
-
const { text, fallback } = baseLabel(t
|
|
638
|
+
const { text, fallback } = baseLabel(t);
|
|
518
639
|
if (!fallback) {
|
|
519
640
|
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
520
641
|
}
|
|
521
642
|
}
|
|
522
643
|
const seen = new Map<string, number>();
|
|
523
644
|
for (const t of tabList) {
|
|
524
|
-
const { text, fallback } = baseLabel(t
|
|
645
|
+
const { text, fallback } = baseLabel(t);
|
|
525
646
|
let display = text;
|
|
526
647
|
if (!fallback && (counts.get(text) ?? 0) > 1) {
|
|
527
648
|
const k = (seen.get(text) ?? 0) + 1;
|
|
@@ -536,26 +657,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
536
657
|
}
|
|
537
658
|
}
|
|
538
659
|
|
|
539
|
-
// --- Fallback title derivation from the user's submitted lines ---
|
|
540
|
-
// The line-editor state machine lives in model.ts
|
|
541
|
-
// (createInputTitleDeriver); this wires its submissions to the active
|
|
542
|
-
// tab. Used only when the process sets no OSC window title of its own
|
|
543
|
-
// (baseLabel prefers tab.title). The kernel routes input to the active
|
|
544
|
-
// session, so an input observer's bytes belong to activeId. Every
|
|
545
|
-
// non-empty submitted line updates the fallback title (the last one
|
|
546
|
-
// wins) and is persisted server-side (engine PUT .../title) so a reload
|
|
547
|
-
// and other devices recover this "latest message" label; the engine uses
|
|
548
|
-
// it only when the program emits no OSC title.
|
|
549
|
-
const titleDeriver = createInputTitleDeriver((line) => {
|
|
550
|
-
const t = tabList.find((x) => x.id === activeId);
|
|
551
|
-
if (!t) {
|
|
552
|
-
return; // no active tab
|
|
553
|
-
}
|
|
554
|
-
t.derived = line;
|
|
555
|
-
void api.setTitle(t.id, t.derived);
|
|
556
|
-
syncChrome();
|
|
557
|
-
});
|
|
558
|
-
|
|
559
660
|
function focusInput(): void {
|
|
560
661
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
561
662
|
}
|
|
@@ -572,7 +673,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
572
673
|
for (const t of tabList) {
|
|
573
674
|
const on = t.id === activeId;
|
|
574
675
|
t.el.classList.toggle("wt-tab-active", on);
|
|
575
|
-
|
|
676
|
+
// setSelected re-adds aria-selected and the roving tabindex, which would
|
|
677
|
+
// undo setEditing(true) on the chip hosting the rename field — and
|
|
678
|
+
// syncChrome runs on every status event, so that happens within a tick.
|
|
679
|
+
// The chip regains its semantics from endEdit's setEditing(false), which
|
|
680
|
+
// reads the CURRENT selected state.
|
|
681
|
+
if (t.id !== editingId) {
|
|
682
|
+
t.aria.setSelected(on);
|
|
683
|
+
}
|
|
576
684
|
}
|
|
577
685
|
if (activeId && activeId !== lastRevealedActive) {
|
|
578
686
|
lastRevealedActive = activeId;
|
|
@@ -832,6 +940,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
832
940
|
renderSwitcherList();
|
|
833
941
|
}
|
|
834
942
|
maybeSwipeHint();
|
|
943
|
+
persistOrder();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// persistOrder stores the strip's current arrangement so it survives a
|
|
947
|
+
// reload. It hangs off syncChrome — the one function every list mutation
|
|
948
|
+
// already ends with (reorder, create, adopt, close, reconcile) — rather than
|
|
949
|
+
// off the two reorder call sites, so a future path that rearranges tabs
|
|
950
|
+
// cannot forget to persist. Three guards make that safe:
|
|
951
|
+
//
|
|
952
|
+
// - `started`: nothing is written while the initial population runs. The
|
|
953
|
+
// strip grows one tab at a time there, and each intermediate state is a
|
|
954
|
+
// partial arrangement, NOT a user decision — writing them meant a reload
|
|
955
|
+
// landing mid-population could store a one-tab order and lose the real
|
|
956
|
+
// one. After the bootstrap every change is a genuine change.
|
|
957
|
+
// - the signature compare: syncChrome also runs on every status tick, so
|
|
958
|
+
// only a changed id sequence reaches storage.
|
|
959
|
+
// - a non-empty list: an empty strip carries no arrangement, and a
|
|
960
|
+
// transient empty state (the last tab closed just before its replacement
|
|
961
|
+
// lands, a reconcile against a restarted server) must not erase the
|
|
962
|
+
// stored order.
|
|
963
|
+
//
|
|
964
|
+
// savedOrder is deliberately NOT reassigned here. It is the ARRANGEMENT
|
|
965
|
+
// READ AT LOAD, which is what adoption places tabs by; overwriting it with
|
|
966
|
+
// the live strip discards the position of every session that has not been
|
|
967
|
+
// adopted yet — which is precisely what a strip still filling up is full of.
|
|
968
|
+
function persistOrder(): void {
|
|
969
|
+
if (!started || tabList.length === 0) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const ids = tabList.map((t) => t.id);
|
|
973
|
+
const sig = orderSignature(ids);
|
|
974
|
+
if (sig === persistedSig) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
persistedSig = sig;
|
|
978
|
+
// Writing the LIVE strip also prunes: ids whose sessions are gone never
|
|
979
|
+
// reach storage again, so the stored arrangement cannot grow forever.
|
|
980
|
+
writeSavedOrder(ids);
|
|
835
981
|
}
|
|
836
982
|
|
|
837
983
|
// closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
|
|
@@ -944,7 +1090,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
944
1090
|
id: info.id,
|
|
945
1091
|
born: ++tabEpoch,
|
|
946
1092
|
title: info.title,
|
|
947
|
-
|
|
1093
|
+
pinnedTitle: info.pinnedTitle,
|
|
1094
|
+
nameSeq: 0,
|
|
948
1095
|
display: "",
|
|
949
1096
|
createdAt: info.createdAt,
|
|
950
1097
|
store: new LineStore(),
|
|
@@ -958,24 +1105,53 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
958
1105
|
};
|
|
959
1106
|
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
960
1107
|
// once the tab is in tabList and syncChrome runs).
|
|
961
|
-
tab.display = baseLabel(tab
|
|
1108
|
+
tab.display = baseLabel(tab).text;
|
|
962
1109
|
label.textContent = tab.display;
|
|
963
1110
|
aria.setLabel(tab.display);
|
|
964
1111
|
el.addEventListener("click", (e) => {
|
|
965
1112
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
966
1113
|
return; // handled by the close button
|
|
967
1114
|
}
|
|
1115
|
+
if (editingId === tab.id) {
|
|
1116
|
+
return; // the rename field owns this chip
|
|
1117
|
+
}
|
|
968
1118
|
switchTo(tab.id);
|
|
969
1119
|
});
|
|
1120
|
+
// Double-click renames, which is also the TOUCH entry path (a double-tap):
|
|
1121
|
+
// a long-press on a chip starts a reorder drag on iPadOS rather than
|
|
1122
|
+
// opening the context menu, because the chip is draggable — so the menu is
|
|
1123
|
+
// not reachable by touch and this is. The chip's touch-action:manipulation
|
|
1124
|
+
// (CSS) is what stops iOS eating the second tap as double-tap-to-zoom.
|
|
1125
|
+
// The leading click switches to the tab first; Finder selects before
|
|
1126
|
+
// renaming too, so that matches expectation.
|
|
1127
|
+
el.addEventListener("dblclick", (e) => {
|
|
1128
|
+
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
e.preventDefault();
|
|
1132
|
+
beginEdit(tab.id, "pointer");
|
|
1133
|
+
});
|
|
970
1134
|
// Keyboard interaction for the ARIA tabs pattern (WCAG 2.1.1): arrows
|
|
971
1135
|
// move selection (wrapping), Home/End jump to the boundaries, Delete
|
|
972
|
-
// closes the focused tab
|
|
973
|
-
// registerTab manages (selected tab is
|
|
1136
|
+
// closes the focused tab, F2 renames (the Explorer convention). Pairs with
|
|
1137
|
+
// the roving tabindex the kernel's registerTab manages (selected tab is
|
|
1138
|
+
// tabIndex 0, others -1).
|
|
974
1139
|
el.addEventListener("keydown", (e) => {
|
|
975
1140
|
const current = tabList.indexOf(tab);
|
|
976
1141
|
if (current < 0) {
|
|
977
1142
|
return;
|
|
978
1143
|
}
|
|
1144
|
+
// While this chip hosts the rename field, every key belongs to the field
|
|
1145
|
+
// (belt-and-braces with the field's own stopPropagation).
|
|
1146
|
+
if (editingId === tab.id) {
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (e.key === "F2") {
|
|
1151
|
+
e.preventDefault();
|
|
1152
|
+
beginEdit(tab.id, "keyboard");
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
979
1155
|
|
|
980
1156
|
if (e.key === "Delete") {
|
|
981
1157
|
e.preventDefault();
|
|
@@ -1021,6 +1197,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1021
1197
|
}
|
|
1022
1198
|
});
|
|
1023
1199
|
el.addEventListener("auxclick", (e) => {
|
|
1200
|
+
if (editingId === tab.id) {
|
|
1201
|
+
return; // middle-click in a text field pastes on X11; it must not close the tab
|
|
1202
|
+
}
|
|
1024
1203
|
if (e.button === 1) {
|
|
1025
1204
|
e.preventDefault();
|
|
1026
1205
|
void close_(tab.id);
|
|
@@ -1030,25 +1209,39 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1030
1209
|
// the browser's own menu; the strip is hidden on a coarse pointer, so
|
|
1031
1210
|
// this is desktop-only in practice.
|
|
1032
1211
|
el.addEventListener("contextmenu", (e) => {
|
|
1212
|
+
if (editingId === tab.id) {
|
|
1213
|
+
// Let the browser's own text menu open: it is where Paste, Select All
|
|
1214
|
+
// and Undo live, and the tab menu would offer Close and Move mid-edit.
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1033
1217
|
e.preventDefault();
|
|
1034
1218
|
showTabMenu(e.clientX, e.clientY, tab.id);
|
|
1035
1219
|
});
|
|
1036
1220
|
// Drag-and-drop reorder on the desktop strip. The bar's dragover moves
|
|
1037
|
-
// this element live; dragend
|
|
1221
|
+
// this element live; drop/dragend commit the new order into tabList.
|
|
1038
1222
|
el.draggable = true;
|
|
1039
1223
|
el.addEventListener("dragstart", (e) => {
|
|
1224
|
+
if (editingId === tab.id) {
|
|
1225
|
+
e.preventDefault(); // renaming: a drag would fight caret selection
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1040
1228
|
draggingEl = el;
|
|
1229
|
+
// Snapshot the preview from the PRISTINE chip, before the dim class.
|
|
1230
|
+
setDragGhost(e, el);
|
|
1041
1231
|
el.classList.add("wt-tab-dragging");
|
|
1042
1232
|
if (e.dataTransfer) {
|
|
1043
1233
|
e.dataTransfer.effectAllowed = "move";
|
|
1044
|
-
// Firefox requires drag data to be set for the drag to start
|
|
1045
|
-
|
|
1234
|
+
// Firefox requires drag data to be set for the drag to start; the
|
|
1235
|
+
// private type keeps the payload undroppable everywhere else (see
|
|
1236
|
+
// TAB_DRAG_TYPE).
|
|
1237
|
+
e.dataTransfer.setData(TAB_DRAG_TYPE, tab.id);
|
|
1046
1238
|
}
|
|
1047
1239
|
hideTabMenu();
|
|
1048
1240
|
});
|
|
1049
1241
|
el.addEventListener("dragend", () => {
|
|
1050
1242
|
el.classList.remove("wt-tab-dragging");
|
|
1051
1243
|
draggingEl = null;
|
|
1244
|
+
clearDragGhost(); // belt-and-braces: the rAF normally got there first
|
|
1052
1245
|
syncOrderFromDom();
|
|
1053
1246
|
});
|
|
1054
1247
|
return tab;
|
|
@@ -1114,7 +1307,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1114
1307
|
} catch {
|
|
1115
1308
|
/* storage unavailable (private mode / disabled) — non-fatal */
|
|
1116
1309
|
}
|
|
1117
|
-
titleDeriver.reset(); // a partial line typed in the old tab does not carry over
|
|
1118
1310
|
ctx.render.bind(next.store);
|
|
1119
1311
|
ctx.notifySwitch({ id: next.id });
|
|
1120
1312
|
armCatchup();
|
|
@@ -1147,11 +1339,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1147
1339
|
}
|
|
1148
1340
|
});
|
|
1149
1341
|
ctx.announce(`Switched to ${next.display}`);
|
|
1150
|
-
if (physicalKeyboardLikely()) {
|
|
1342
|
+
if (physicalKeyboardLikely() && editingId === null) {
|
|
1151
1343
|
// A physical keyboard is (likely) present, so focus the input on
|
|
1152
1344
|
// switch — switch and type immediately (the iPad + Magic Keyboard
|
|
1153
1345
|
// ask). On a keyboard-less touchscreen this is skipped, or every
|
|
1154
|
-
// switch would pop the virtual keyboard (#7).
|
|
1346
|
+
// switch would pop the virtual keyboard (#7). Skipped entirely while a
|
|
1347
|
+
// rename field is open: a switch (e.g. the leading click of a
|
|
1348
|
+
// double-click, or a remote-driven ensureActive) would otherwise yank
|
|
1349
|
+
// the caret out of the field the user is typing in.
|
|
1155
1350
|
focusInput();
|
|
1156
1351
|
}
|
|
1157
1352
|
}
|
|
@@ -1270,12 +1465,59 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1270
1465
|
}
|
|
1271
1466
|
}
|
|
1272
1467
|
|
|
1468
|
+
// --- Drag preview (the ghost) ---
|
|
1469
|
+
// WebKit renders NO automatic drag image for an element whose ancestor
|
|
1470
|
+
// carries a filter or transform (webkit.org/b/22787) — and the strip's own
|
|
1471
|
+
// backdrop-filter is exactly such an ancestor, so on iPadOS the preview
|
|
1472
|
+
// came out as broken white geometry instead of the chip (desktop Chrome,
|
|
1473
|
+
// which snapshots the element regardless, always looked right). An EXPLICIT
|
|
1474
|
+
// drag image fixes it: a clone parked directly under .wt-root, outside the
|
|
1475
|
+
// bar's filtered subtree yet still inside the styling boundary so the
|
|
1476
|
+
// scoped .wt-tab rules paint it. It is laid exactly over the real chip, so
|
|
1477
|
+
// it is indistinguishable from it in the unlikely event it ever paints, and
|
|
1478
|
+
// dropped on the next frame — the browser has already snapshotted it by
|
|
1479
|
+
// then (the snapshot is taken when the dragstart handler returns).
|
|
1480
|
+
let dragGhost: HTMLElement | null = null;
|
|
1481
|
+
function clearDragGhost(): void {
|
|
1482
|
+
dragGhost?.remove();
|
|
1483
|
+
dragGhost = null;
|
|
1484
|
+
}
|
|
1485
|
+
function setDragGhost(e: DragEvent, el: HTMLElement): void {
|
|
1486
|
+
clearDragGhost();
|
|
1487
|
+
const rect = el.getBoundingClientRect();
|
|
1488
|
+
const rootRect = varRoot.getBoundingClientRect();
|
|
1489
|
+
const ghost = el.cloneNode(true) as HTMLElement;
|
|
1490
|
+
ghost.classList.add("wt-tab-ghost");
|
|
1491
|
+
// A duplicated role="tab" must not reach assistive tech for the frame the
|
|
1492
|
+
// clone exists (CSS keeps it non-interactive).
|
|
1493
|
+
ghost.setAttribute("aria-hidden", "true");
|
|
1494
|
+
ghost.style.left = `${String(rect.left - rootRect.left)}px`;
|
|
1495
|
+
ghost.style.top = `${String(rect.top - rootRect.top)}px`;
|
|
1496
|
+
// The chip's width comes from a flex-shrink in the strip; out of that
|
|
1497
|
+
// flex context the clone would snap back to the unshrunk 300px.
|
|
1498
|
+
ghost.style.width = `${String(rect.width)}px`;
|
|
1499
|
+
ghost.style.height = `${String(rect.height)}px`;
|
|
1500
|
+
varRoot.appendChild(ghost);
|
|
1501
|
+
dragGhost = ghost;
|
|
1502
|
+
// Offset from the chip's own box, not e.offsetX/offsetY: those are
|
|
1503
|
+
// relative to the event TARGET, which is the label or the close button
|
|
1504
|
+
// when the drag starts on one of them.
|
|
1505
|
+
e.dataTransfer?.setDragImage(ghost, e.clientX - rect.left, e.clientY - rect.top);
|
|
1506
|
+
requestAnimationFrame(clearDragGhost);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1273
1509
|
// moveTab shifts a tab one slot left or right in tabList and re-appends
|
|
1274
1510
|
// every tab element in list order so the DOM matches.
|
|
1275
1511
|
// It is the single-pointer / non-drag alternative to the drag-and-drop
|
|
1276
1512
|
// reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
|
|
1277
1513
|
// context menu.
|
|
1278
1514
|
function moveTab(id: string, delta: -1 | 1): void {
|
|
1515
|
+
// Reordering re-appends every chip, and reparenting the focused field blurs
|
|
1516
|
+
// it — so an open edit would be committed as a side effect of moving some
|
|
1517
|
+
// OTHER tab, via a blur the user never performed. Resolve it here instead,
|
|
1518
|
+
// so the outcome is chosen and testable rather than emergent (happy-dom
|
|
1519
|
+
// does not reproduce the reparent-blur, so no test could catch it).
|
|
1520
|
+
resolveEdit("blur", false);
|
|
1279
1521
|
const from = tabList.findIndex((t) => t.id === id);
|
|
1280
1522
|
const to = from + delta;
|
|
1281
1523
|
if (from < 0 || to < 0 || to >= tabList.length) {
|
|
@@ -1305,7 +1547,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1305
1547
|
if (tombstones.active(info.id)) {
|
|
1306
1548
|
return; // just closed here; ignore a stale listing until the server reaps it
|
|
1307
1549
|
}
|
|
1308
|
-
|
|
1550
|
+
const tab = addTabChrome(info);
|
|
1551
|
+
// Place the tab where the user's stored arrangement says it goes, not
|
|
1552
|
+
// where it happened to arrive: the status-stream snapshot and the
|
|
1553
|
+
// bootstrap's GET /api/sessions race each other, so arrival order is not
|
|
1554
|
+
// the user's order (and is not even stable between loads). A session the
|
|
1555
|
+
// arrangement does not know is appended, which is what a genuinely new
|
|
1556
|
+
// tab wants.
|
|
1557
|
+
const at = orderedInsertIndex(
|
|
1558
|
+
tabList.map((t) => t.id),
|
|
1559
|
+
savedOrder,
|
|
1560
|
+
info.id,
|
|
1561
|
+
);
|
|
1562
|
+
tabList.splice(at, 0, tab);
|
|
1563
|
+
// addTabChrome appended the chip to the scroller's end; move it to match
|
|
1564
|
+
// the list when the arrangement put it earlier, so the DOM, the switcher
|
|
1565
|
+
// rows, and the position announcements all read the same order.
|
|
1566
|
+
const after = tabList[at + 1];
|
|
1567
|
+
if (after) {
|
|
1568
|
+
scroller.insertBefore(tab.el, after.el);
|
|
1569
|
+
}
|
|
1309
1570
|
}
|
|
1310
1571
|
|
|
1311
1572
|
// Activate the first tab when nothing is active. The bootstrap normally
|
|
@@ -1332,11 +1593,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1332
1593
|
async function create(): Promise<void> {
|
|
1333
1594
|
let info: SessionInfo;
|
|
1334
1595
|
try {
|
|
1335
|
-
info = await api
|
|
1336
|
-
} catch {
|
|
1596
|
+
info = await createSessionHonouringRetry(api, ctx, () => tornDown);
|
|
1597
|
+
} catch (err) {
|
|
1337
1598
|
// A create can still fail transiently (network, server error); tell
|
|
1338
|
-
// the user rather than throwing.
|
|
1339
|
-
|
|
1599
|
+
// the user rather than throwing. A 503 has already been retried on the
|
|
1600
|
+
// server's own schedule by this point, so reaching here means it never
|
|
1601
|
+
// became ready -- say so with the server's own words when it gave any,
|
|
1602
|
+
// rather than the generic message that used to cover both cases.
|
|
1603
|
+
ctx.toast(
|
|
1604
|
+
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
1605
|
+
? `Couldn't open a terminal: ${err.serverMessage}`
|
|
1606
|
+
: "Couldn't open a terminal",
|
|
1607
|
+
);
|
|
1340
1608
|
return;
|
|
1341
1609
|
}
|
|
1342
1610
|
// The status SSE may have adopted this session during the POST round-trip
|
|
@@ -1387,6 +1655,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1387
1655
|
if (!tab) {
|
|
1388
1656
|
return;
|
|
1389
1657
|
}
|
|
1658
|
+
// A tab removed mid-edit (a user close, an SSE removal, or the list
|
|
1659
|
+
// reconcile dropping it) abandons the edit with no request: the field
|
|
1660
|
+
// lives inside the chip and goes away with it, so the state must not
|
|
1661
|
+
// outlive it.
|
|
1662
|
+
if (editingId === id) {
|
|
1663
|
+
endEdit();
|
|
1664
|
+
}
|
|
1390
1665
|
// Tombstone this id briefly so a stale status snapshot/poll that predates
|
|
1391
1666
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
1392
1667
|
tombstones.add(id);
|
|
@@ -1448,6 +1723,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1448
1723
|
return;
|
|
1449
1724
|
}
|
|
1450
1725
|
const closingActive = activeId !== null && ids.includes(activeId);
|
|
1726
|
+
// The bulk-close path drops tabs itself rather than through dropTab, so it
|
|
1727
|
+
// needs the same mid-edit guard: an open editor on a tab being removed must
|
|
1728
|
+
// be abandoned, or editingId outlives its chip and permanently suppresses
|
|
1729
|
+
// focus-on-switch.
|
|
1730
|
+
if (editingId !== null && ids.includes(editingId)) {
|
|
1731
|
+
endEdit();
|
|
1732
|
+
}
|
|
1451
1733
|
for (const t of victims) {
|
|
1452
1734
|
const idx = tabList.indexOf(t);
|
|
1453
1735
|
if (idx >= 0) {
|
|
@@ -1504,10 +1786,270 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1504
1786
|
return closeMany(tabList.map((t) => t.id));
|
|
1505
1787
|
}
|
|
1506
1788
|
|
|
1789
|
+
// --- Inline rename (R5) ---
|
|
1790
|
+
// The edit surface is the label's OWN box, so the width the user types into
|
|
1791
|
+
// is the width the label will have. An <input>, not contenteditable: real
|
|
1792
|
+
// text-input semantics, IME support, a mobile keyboard, and no rich-HTML
|
|
1793
|
+
// paste surface.
|
|
1794
|
+
//
|
|
1795
|
+
// editingId is also the stand-down flag for every chip handler that would
|
|
1796
|
+
// otherwise fight the field (click-to-switch, the arrow/Delete keys,
|
|
1797
|
+
// focus-on-switch, the drag) — see the guards at each site.
|
|
1798
|
+
let editingId: string | null = null;
|
|
1799
|
+
let editInput: HTMLInputElement | null = null;
|
|
1800
|
+
// Where focus goes when the edit ends. R9: back to the chip when the user
|
|
1801
|
+
// arrived from the keyboard (F2 — they were navigating the strip and should
|
|
1802
|
+
// still be), to the terminal otherwise. Recorded at entry because the exit
|
|
1803
|
+
// paths cannot tell them apart.
|
|
1804
|
+
let editFrom: "keyboard" | "pointer" = "pointer";
|
|
1805
|
+
// Tab ids with a pinned-name request in flight. While an id is here, a status
|
|
1806
|
+
// record carrying a DIFFERENT value is applied for display but does not
|
|
1807
|
+
// supersede the request: SSE delivery order and REST mutation order are not
|
|
1808
|
+
// one total order, so a record sampled BEFORE our PUT can arrive after it,
|
|
1809
|
+
// and treating it as newer authority would suppress the request's own
|
|
1810
|
+
// rollback and its failure toast.
|
|
1811
|
+
const namesInFlight = new Set<string>();
|
|
1812
|
+
|
|
1813
|
+
/** restoreFocusAfterEdit sends focus where the entry path implies. The
|
|
1814
|
+
* terminal branch is gated exactly like switchTo's focus-on-switch: on a
|
|
1815
|
+
* keyboard-less touchscreen, focusing the hidden textarea pops the soft
|
|
1816
|
+
* keyboard, which is not what finishing a rename by double-tap should do. */
|
|
1817
|
+
function restoreFocusAfterEdit(el: HTMLElement | undefined): void {
|
|
1818
|
+
if (editFrom === "keyboard" && el) {
|
|
1819
|
+
el.focus();
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (physicalKeyboardLikely()) {
|
|
1823
|
+
focusInput();
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** endEdit tears the field down and restores the chip. It never issues a
|
|
1828
|
+
* request; each caller decides what to commit first. */
|
|
1829
|
+
function endEdit(): void {
|
|
1830
|
+
const id = editingId;
|
|
1831
|
+
editingId = null;
|
|
1832
|
+
const input = editInput;
|
|
1833
|
+
editInput = null;
|
|
1834
|
+
if (id === null) {
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const t = tabList.find((x) => x.id === id);
|
|
1838
|
+
input?.remove();
|
|
1839
|
+
if (!t) {
|
|
1840
|
+
return; // the tab went away mid-edit; its chrome is already gone
|
|
1841
|
+
}
|
|
1842
|
+
t.label.hidden = false;
|
|
1843
|
+
t.el.classList.remove("wt-tab-editing");
|
|
1844
|
+
t.el.draggable = true;
|
|
1845
|
+
// Restore the tab semantics from the CURRENT selected state: the active
|
|
1846
|
+
// tab can change while an edit is open on a different chip.
|
|
1847
|
+
t.aria.setEditing(false, t.id === activeId);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** commitRename applies a finished edit. An empty value CLEARS the pin, but
|
|
1851
|
+
* only on an explicit confirm — see beginEdit's Enter/blur split. */
|
|
1852
|
+
function commitRename(id: string, raw: string): boolean {
|
|
1853
|
+
const t = tabList.find((x) => x.id === id);
|
|
1854
|
+
if (!t) {
|
|
1855
|
+
return false;
|
|
1856
|
+
}
|
|
1857
|
+
// Sanitize client-side rather than trusting the round-trip: strip control
|
|
1858
|
+
// characters and bound the length exactly as the server does.
|
|
1859
|
+
const name = sanitizePinnedName(raw);
|
|
1860
|
+
const before = t.pinnedTitle ?? "";
|
|
1861
|
+
if (name === before) {
|
|
1862
|
+
return false; // nothing to do; no request, no toast
|
|
1863
|
+
}
|
|
1864
|
+
// Optimistic: paint it now, guarded by a monotonic counter AND the tab's
|
|
1865
|
+
// birth epoch so a slow response cannot roll back a newer rename, a later
|
|
1866
|
+
// clear, a remote update, or — if this id were ever reused by a fresh
|
|
1867
|
+
// session — a different tab entirely.
|
|
1868
|
+
const seq = ++t.nameSeq;
|
|
1869
|
+
const born = t.born;
|
|
1870
|
+
t.pinnedTitle = name;
|
|
1871
|
+
syncChrome();
|
|
1872
|
+
// Announce the OUTCOME, which for a clear is whatever the automatic
|
|
1873
|
+
// sources now yield — and that can be the "New tab" fallback, in which
|
|
1874
|
+
// case claiming an automatic name would be false.
|
|
1875
|
+
if (name !== "") {
|
|
1876
|
+
ctx.announce(`Renamed to ${name}`);
|
|
1877
|
+
} else {
|
|
1878
|
+
const { text, fallback } = baseLabel(t);
|
|
1879
|
+
ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
|
|
1880
|
+
}
|
|
1881
|
+
namesInFlight.add(id);
|
|
1882
|
+
const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
|
|
1883
|
+
void request
|
|
1884
|
+
.catch(() => {
|
|
1885
|
+
const cur = tabList.find((x) => x.id === id);
|
|
1886
|
+
if (cur?.born !== born || cur.nameSeq !== seq) {
|
|
1887
|
+
return; // gone, reused, or superseded: the newer state stands
|
|
1888
|
+
}
|
|
1889
|
+
cur.pinnedTitle = before;
|
|
1890
|
+
syncChrome();
|
|
1891
|
+
ctx.toast("Couldn't save the terminal name");
|
|
1892
|
+
})
|
|
1893
|
+
.finally(() => {
|
|
1894
|
+
namesInFlight.delete(id);
|
|
1895
|
+
});
|
|
1896
|
+
return true;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** beginEdit swaps a chip's label for a text field. Idempotent per tab; a
|
|
1900
|
+
* second tab entering edit commits the first. */
|
|
1901
|
+
function beginEdit(id: string, from: "keyboard" | "pointer"): void {
|
|
1902
|
+
if (editingId === id) {
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
if (editingId !== null) {
|
|
1906
|
+
// Entering edit elsewhere resolves the open one under the blur rules —
|
|
1907
|
+
// this is not an Enter, so it must not be able to CLEAR a pin from a
|
|
1908
|
+
// field the user happened to empty. Focus is about to move to the new
|
|
1909
|
+
// field, so it is not restored here.
|
|
1910
|
+
resolveEdit("blur", false);
|
|
1911
|
+
}
|
|
1912
|
+
editFrom = from;
|
|
1913
|
+
const t = tabList.find((x) => x.id === id);
|
|
1914
|
+
if (!t) {
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const input = document.createElement("input");
|
|
1918
|
+
input.type = "text";
|
|
1919
|
+
input.className = "wt-tab-rename";
|
|
1920
|
+
input.setAttribute("aria-label", "Rename terminal");
|
|
1921
|
+
// Prefill with the PIN, not the rendered label. t.display is presentation
|
|
1922
|
+
// output: for an unpinned tab it is the automatic title, the "New tab"
|
|
1923
|
+
// fallback, or a de-duplication suffix relabelAll generated (`auth (2)`).
|
|
1924
|
+
// Since a blur commits a non-empty value, prefilling it meant opening the
|
|
1925
|
+
// editor and clicking away silently pinned presentation text as the user's
|
|
1926
|
+
// chosen name. An unpinned tab therefore starts EMPTY, with the current
|
|
1927
|
+
// label as the placeholder so the user still sees what they are replacing.
|
|
1928
|
+
input.value = t.pinnedTitle ?? "";
|
|
1929
|
+
input.placeholder = t.display;
|
|
1930
|
+
// maxLength is a UI affordance only, in UTF-16 code units; the server
|
|
1931
|
+
// counts runes, so sanitizePinnedName (code points) is the real bound.
|
|
1932
|
+
input.maxLength = MAX_PINNED_NAME;
|
|
1933
|
+
// iPadOS is the device this affordance was designed for, and its defaults
|
|
1934
|
+
// are wrong for a short identifier: autocapitalize would upper-case the
|
|
1935
|
+
// first letter of every tab name and autocorrect would rewrite it on
|
|
1936
|
+
// commit. enterkeyhint labels the soft keyboard's action key.
|
|
1937
|
+
input.autocapitalize = "off";
|
|
1938
|
+
input.spellcheck = false;
|
|
1939
|
+
input.setAttribute("autocorrect", "off");
|
|
1940
|
+
input.enterKeyHint = "done";
|
|
1941
|
+
// A tab shrinks toward a 100px floor once the strip is full, and a 100px
|
|
1942
|
+
// edit box is unusable, so the edited chip is given its full width for the
|
|
1943
|
+
// duration — on ENTRY only, never per keystroke, so the strip does not
|
|
1944
|
+
// jitter as the user types.
|
|
1945
|
+
t.el.classList.add("wt-tab-editing");
|
|
1946
|
+
t.label.hidden = true;
|
|
1947
|
+
t.el.insertBefore(input, t.label.nextSibling);
|
|
1948
|
+
// Drop the tab semantics while a textbox lives in the chip, and stop the
|
|
1949
|
+
// chip being draggable: a draggable ancestor interferes with drag-selecting
|
|
1950
|
+
// text, and dragging the tab you are renaming is meaningless.
|
|
1951
|
+
t.aria.setEditing(true, t.id === activeId);
|
|
1952
|
+
t.el.draggable = false;
|
|
1953
|
+
editingId = id;
|
|
1954
|
+
editInput = input;
|
|
1955
|
+
if (typeof t.el.scrollIntoView === "function") {
|
|
1956
|
+
t.el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1957
|
+
}
|
|
1958
|
+
input.focus();
|
|
1959
|
+
input.select();
|
|
1960
|
+
ctx.announce(`Renaming ${t.display}`);
|
|
1961
|
+
|
|
1962
|
+
input.addEventListener("keydown", (e) => {
|
|
1963
|
+
// The chip's own keydown handler treats arrows as tab switching and
|
|
1964
|
+
// Delete as close; the field must own them. stopPropagation is the
|
|
1965
|
+
// narrowest fix and keeps the chip's handler unaware of edit mode.
|
|
1966
|
+
e.stopPropagation();
|
|
1967
|
+
if (e.key === "Enter") {
|
|
1968
|
+
e.preventDefault();
|
|
1969
|
+
resolveEdit("confirm");
|
|
1970
|
+
} else if (e.key === "Escape") {
|
|
1971
|
+
e.preventDefault();
|
|
1972
|
+
resolveEdit("cancel");
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
// A click inside the field must not reach the chip's switch-on-click.
|
|
1976
|
+
input.addEventListener("click", (e) => {
|
|
1977
|
+
e.stopPropagation();
|
|
1978
|
+
});
|
|
1979
|
+
input.addEventListener("blur", () => {
|
|
1980
|
+
if (editingId !== id) {
|
|
1981
|
+
return; // already resolved by Enter/Escape/teardown
|
|
1982
|
+
}
|
|
1983
|
+
// A blur is not the user asking for focus to go anywhere in particular,
|
|
1984
|
+
// so it does not move it.
|
|
1985
|
+
resolveEdit("blur", false);
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** resolveEdit closes the open edit and applies its value under the rules for
|
|
1990
|
+
* HOW it was closed. One function rather than a commit path per caller,
|
|
1991
|
+
* because the empty-value rule differs and every caller that got it wrong
|
|
1992
|
+
* did so by hand-inlining a variant:
|
|
1993
|
+
*
|
|
1994
|
+
* - `"confirm"` (Enter) is the only deliberate confirmation, so it is the
|
|
1995
|
+
* only mode that may CLEAR a pin from an emptied field.
|
|
1996
|
+
* - `"blur"` commits a non-empty value (Finder and Explorer both do;
|
|
1997
|
+
* discarding on a stray click is hostile) and reverts an empty one — a
|
|
1998
|
+
* blur is loss of focus, not destructive intent, and on a tablet
|
|
1999
|
+
* dismissing the soft keyboard IS a blur.
|
|
2000
|
+
* - `"cancel"` (Escape) applies nothing.
|
|
2001
|
+
*
|
|
2002
|
+
* `focus` is opt-out for the callers that are not a user finishing an edit
|
|
2003
|
+
* (a reorder, a pre-empting second edit): they should not move focus. */
|
|
2004
|
+
function resolveEdit(mode: "confirm" | "blur" | "cancel", focus = true): void {
|
|
2005
|
+
const id = editingId;
|
|
2006
|
+
if (id === null) {
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const value = editInput?.value ?? "";
|
|
2010
|
+
const t = tabList.find((x) => x.id === id);
|
|
2011
|
+
const label = t?.display ?? "";
|
|
2012
|
+
endEdit();
|
|
2013
|
+
if (mode === "cancel") {
|
|
2014
|
+
ctx.announce(`Rename cancelled, keeping ${label}`);
|
|
2015
|
+
} else if (mode === "confirm" || value.trim() !== "") {
|
|
2016
|
+
// A confirm that changes nothing must still close the narrative: without
|
|
2017
|
+
// this a screen reader hears "Renaming X" and then silence, which is
|
|
2018
|
+
// indistinguishable from the edit still being open.
|
|
2019
|
+
if (!commitRename(id, value) && mode === "confirm") {
|
|
2020
|
+
ctx.announce(`Rename finished, keeping ${label}`);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
if (focus) {
|
|
2024
|
+
restoreFocusAfterEdit(t?.el);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1507
2028
|
// hideTabMenu / showTabMenu drive the right-click menu. showTabMenu rebuilds
|
|
1508
2029
|
// the items for the target tab (disabled states reflect its position) and
|
|
1509
2030
|
// clamps into the visible viewport, flipping above the pointer near the
|
|
1510
2031
|
// bottom edge (mirrors context-menu.ts).
|
|
2032
|
+
//
|
|
2033
|
+
// menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
|
|
2034
|
+
// raises a context menu from a LONG-PRESS, and the same gesture emits a
|
|
2035
|
+
// click on release which onDocClickMenu would read as a click-away. The
|
|
2036
|
+
// terminal menu has guarded this since it shipped; this one did not, so a
|
|
2037
|
+
// long-pressed tab menu closed itself on release — on the one platform
|
|
2038
|
+
// where the desktop strip is the chrome you touch. menuTouch records
|
|
2039
|
+
// whether the press that raised the menu was a finger, since only touch
|
|
2040
|
+
// emits that trailing click.
|
|
2041
|
+
const menuSwallow = createClickSwallow();
|
|
2042
|
+
let menuTouch = false;
|
|
2043
|
+
// One listener on the bar rather than one per chip: chips are created and
|
|
2044
|
+
// destroyed as sessions come and go, and the pointer type belongs to the
|
|
2045
|
+
// gesture, not to a chip.
|
|
2046
|
+
bar.addEventListener(
|
|
2047
|
+
"pointerdown",
|
|
2048
|
+
(e) => {
|
|
2049
|
+
menuTouch = e.pointerType === "touch";
|
|
2050
|
+
},
|
|
2051
|
+
{ passive: true },
|
|
2052
|
+
);
|
|
1511
2053
|
function hideTabMenu(): void {
|
|
1512
2054
|
tabMenu.classList.remove("visible");
|
|
1513
2055
|
tabMenu.replaceChildren();
|
|
@@ -1527,13 +2069,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1527
2069
|
}
|
|
1528
2070
|
tabMenu.appendChild(b);
|
|
1529
2071
|
}
|
|
2072
|
+
function tabMenuSeparator(): void {
|
|
2073
|
+
const hr = document.createElement("div");
|
|
2074
|
+
hr.className = "wt-tab-menu-sep";
|
|
2075
|
+
hr.setAttribute("role", "separator");
|
|
2076
|
+
tabMenu.appendChild(hr);
|
|
2077
|
+
}
|
|
1530
2078
|
function showTabMenu(x: number, y: number, id: string): void {
|
|
1531
2079
|
hideTabMenu();
|
|
1532
2080
|
const idx = tabList.findIndex((t) => t.id === id);
|
|
1533
|
-
|
|
2081
|
+
const target = tabList[idx];
|
|
2082
|
+
if (idx < 0 || !target) {
|
|
1534
2083
|
return;
|
|
1535
2084
|
}
|
|
1536
2085
|
const n = tabList.length;
|
|
2086
|
+
// The naming pair goes FIRST, farthest from the accidental-close zone.
|
|
2087
|
+
tabMenuItem("Rename\u2026", false, () => {
|
|
2088
|
+
beginEdit(id, "pointer");
|
|
2089
|
+
});
|
|
2090
|
+
// Always present, disabled when there is no pin — matching the menu's
|
|
2091
|
+
// existing rule for Move left / Close to the right. Not for consistency's
|
|
2092
|
+
// own sake: an item that appeared only sometimes would shift every close
|
|
2093
|
+
// item up or down by a row depending on whether the tab happens to be
|
|
2094
|
+
// renamed, so right-clicking two tabs and clicking the same point could
|
|
2095
|
+
// reset a name on one and close tabs on the other.
|
|
2096
|
+
tabMenuItem("Use automatic name", !hasPinnedName(target), () => {
|
|
2097
|
+
commitRename(id, ""); // the same operation an emptied, confirmed edit performs
|
|
2098
|
+
});
|
|
2099
|
+
tabMenuSeparator();
|
|
1537
2100
|
tabMenuItem("Move left", idx <= 0, () => {
|
|
1538
2101
|
moveTab(id, -1);
|
|
1539
2102
|
});
|
|
@@ -1560,6 +2123,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1560
2123
|
// the visual viewport; flip above the pointer near the bottom edge).
|
|
1561
2124
|
tabMenu.classList.add("visible");
|
|
1562
2125
|
placeMenuAt(tabMenu, x, y);
|
|
2126
|
+
if (menuTouch) {
|
|
2127
|
+
menuSwallow.arm();
|
|
2128
|
+
}
|
|
1563
2129
|
}
|
|
1564
2130
|
|
|
1565
2131
|
// One-time "swipe to switch" hint on first multi-tab state, mobile only.
|
|
@@ -1589,22 +2155,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1589
2155
|
ctx.toast("Swipe to switch terminals");
|
|
1590
2156
|
}
|
|
1591
2157
|
|
|
1592
|
-
// applyStatus updates one tab's dot (status + reveal) +
|
|
1593
|
-
// record (shared by the SSE monitor and the polling fallback).
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
reports: boolean,
|
|
1601
|
-
): void {
|
|
1602
|
-
const t = tabList.find((tab) => tab.id === id);
|
|
2158
|
+
// applyStatus updates one tab's dot (status + reveal) + titles from a status
|
|
2159
|
+
// record (shared by the SSE monitor and the polling fallback). It takes the
|
|
2160
|
+
// record whole rather than as positional fields: both callers already hold
|
|
2161
|
+
// one, and its title fields are interchangeable strings that as positional
|
|
2162
|
+
// parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
|
|
2163
|
+
// separately because it is normalised (reportsOf) before it gets here.
|
|
2164
|
+
function applyStatus(rec: SessionInfo, reports: boolean): void {
|
|
2165
|
+
const t = tabList.find((tab) => tab.id === rec.id);
|
|
1603
2166
|
if (!t) {
|
|
1604
2167
|
return;
|
|
1605
2168
|
}
|
|
1606
2169
|
t.reports = reports;
|
|
1607
|
-
paintStatusDot(t.dot, status, reports);
|
|
2170
|
+
paintStatusDot(t.dot, rec.status, reports);
|
|
1608
2171
|
// Latest-wins background-tab notification for the switch button's dot: a
|
|
1609
2172
|
// background tab (not the active one) reaching "input" (needs you) or
|
|
1610
2173
|
// "done" (turn finished) raises the cue in that colour; each qualifying
|
|
@@ -1612,9 +2175,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1612
2175
|
// list opens. The active surface keeps its own needs-input cue (see
|
|
1613
2176
|
// syncMobile); this is the moved + upgraded, glanceable version on the
|
|
1614
2177
|
// dedicated button.
|
|
1615
|
-
if (id !== activeId && (status === "input" || status === "done")) {
|
|
1616
|
-
switchNotify = status;
|
|
1617
|
-
switchNotifyId = id;
|
|
2178
|
+
if (rec.id !== activeId && (rec.status === "input" || rec.status === "done")) {
|
|
2179
|
+
switchNotify = rec.status;
|
|
2180
|
+
switchNotifyId = rec.id;
|
|
1618
2181
|
paintSwitchDot();
|
|
1619
2182
|
}
|
|
1620
2183
|
// Record the raw server title; the displayed label (fallback + de-dup)
|
|
@@ -1624,15 +2187,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1624
2187
|
// and overwriting a good label with it dropped an idle tab back to "New
|
|
1625
2188
|
// tab". Hold the last known title until a genuine (non-blank) one
|
|
1626
2189
|
// arrives; the derived-from-input fallback is likewise sticky.
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
//
|
|
1634
|
-
|
|
1635
|
-
|
|
2190
|
+
// The typeof check is not redundant with the type: rec comes from
|
|
2191
|
+
// unvalidated server JSON, and a missing title would make .trim() throw
|
|
2192
|
+
// here and abort the caller's whole reconcile loop.
|
|
2193
|
+
if (typeof rec.title === "string" && rec.title.trim() !== "") {
|
|
2194
|
+
t.title = rec.title;
|
|
2195
|
+
}
|
|
2196
|
+
// The pinned name is likewise authoritative and un-guarded, and "" is the
|
|
2197
|
+
// meaningful value: it is how a clear made in ANOTHER browser reaches this
|
|
2198
|
+
// one. A blank-guard here would make a remote un-rename invisible.
|
|
2199
|
+
//
|
|
2200
|
+
// The nameSeq bump is gated on the value actually DIFFERING from what we
|
|
2201
|
+
// believe. The wire carries pinnedTitle on every status event, so bumping
|
|
2202
|
+
// unconditionally would mark an in-flight local rename as superseded by
|
|
2203
|
+
// the server's echo of that same rename — and its failure handler would
|
|
2204
|
+
// then decline to roll back or explain itself. A differing value really is
|
|
2205
|
+
// newer authority (a remote rename or clear), and supersedes.
|
|
2206
|
+
if (rec.pinnedTitle !== undefined && rec.pinnedTitle !== (t.pinnedTitle ?? "")) {
|
|
2207
|
+
t.pinnedTitle = rec.pinnedTitle;
|
|
2208
|
+
// Only a value arriving with no request of ours in flight is newer
|
|
2209
|
+
// AUTHORITY. During a pending request the record may predate our own PUT
|
|
2210
|
+
// (SSE delivery and REST mutation are not one total order), and bumping
|
|
2211
|
+
// would suppress that request's rollback and its failure toast.
|
|
2212
|
+
if (!namesInFlight.has(rec.id)) {
|
|
2213
|
+
t.nameSeq++;
|
|
2214
|
+
}
|
|
1636
2215
|
}
|
|
1637
2216
|
}
|
|
1638
2217
|
|
|
@@ -1677,13 +2256,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1677
2256
|
const seen = new Set(list.map((s) => s.id));
|
|
1678
2257
|
for (const info of list) {
|
|
1679
2258
|
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
1680
|
-
applyStatus(
|
|
1681
|
-
info.id,
|
|
1682
|
-
info.status,
|
|
1683
|
-
info.title,
|
|
1684
|
-
info.clientTitle,
|
|
1685
|
-
reportsOf(info.reportsActivity),
|
|
1686
|
-
);
|
|
2259
|
+
applyStatus(info, reportsOf(info.reportsActivity));
|
|
1687
2260
|
}
|
|
1688
2261
|
// A tab the server no longer lists was reaped/closed elsewhere (or
|
|
1689
2262
|
// died with a restarted manager): drop it locally (no DELETE — it
|
|
@@ -1708,15 +2281,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1708
2281
|
return;
|
|
1709
2282
|
}
|
|
1710
2283
|
// Adopt a session created in another browser so all clients converge.
|
|
1711
|
-
// The status record IS the session's wire shape (SessionStatus
|
|
1712
|
-
//
|
|
1713
|
-
// carries the persisted clientTitle: the SSE snapshot usually beats
|
|
1714
|
-
// the initial GET /api/sessions (whose adoptSession then dedups and
|
|
1715
|
-
// never re-applies its fields), so dropping it here left every
|
|
1716
|
-
// SSE-adopted tab without its persisted title — in preferInputTitle
|
|
1717
|
-
// mode the label then fell back to "New tab" on reload.
|
|
2284
|
+
// The status record IS the session's wire shape (SessionStatus extends
|
|
2285
|
+
// SessionInfo), so it flows through whole.
|
|
1718
2286
|
adoptSession(s);
|
|
1719
|
-
applyStatus(s
|
|
2287
|
+
applyStatus(s, reportsOf(s.reportsActivity));
|
|
1720
2288
|
ensureActive();
|
|
1721
2289
|
syncChrome();
|
|
1722
2290
|
});
|
|
@@ -1731,10 +2299,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1731
2299
|
}
|
|
1732
2300
|
|
|
1733
2301
|
// --- Event wiring ---
|
|
1734
|
-
//
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
2302
|
+
// No input observer. The ENGINE derives a session's name from the input
|
|
2303
|
+
// stream when its host asked for that (terminal.WithInputTitle), so no preset
|
|
2304
|
+
// does per-keystroke title work in the browser, and the name is identical for
|
|
2305
|
+
// every client attached to the session — including one that attaches later.
|
|
1738
2306
|
// Observe (never consume) keydowns to detect a physical keyboard: a
|
|
1739
2307
|
// hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
1740
2308
|
// for a keyboard folio with no trackpad (no fine pointer to key off).
|
|
@@ -1744,7 +2312,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1744
2312
|
}
|
|
1745
2313
|
return false;
|
|
1746
2314
|
});
|
|
1747
|
-
// Live reorder while dragging a tab over the strip (dragend
|
|
2315
|
+
// Live reorder while dragging a tab over the strip (drop/dragend commit it).
|
|
1748
2316
|
bar.addEventListener("dragover", (e) => {
|
|
1749
2317
|
if (!draggingEl) {
|
|
1750
2318
|
return;
|
|
@@ -1755,6 +2323,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1755
2323
|
}
|
|
1756
2324
|
scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX));
|
|
1757
2325
|
});
|
|
2326
|
+
// Commit on drop — and, load-bearing, CANCEL the drop's default action. An
|
|
2327
|
+
// uncancelled drop leaves the browser free to act on the drag payload
|
|
2328
|
+
// itself, and WebKit's action is to LOAD it as a URL: with no drop handler
|
|
2329
|
+
// at all, dropping a tab on iPadOS navigated the page to /<session-id>
|
|
2330
|
+
// instead of reordering. Chrome and Firefox ignore an unhandled reorder
|
|
2331
|
+
// drop, which is why the strip looked fine on the desktop.
|
|
2332
|
+
bar.addEventListener("drop", (e) => {
|
|
2333
|
+
if (!draggingEl) {
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
e.preventDefault();
|
|
2337
|
+
syncOrderFromDom();
|
|
2338
|
+
});
|
|
2339
|
+
// A tab released anywhere OTHER than the strip — over the terminal, over
|
|
2340
|
+
// any other chrome — must be inert as well, not a navigation, so claim the
|
|
2341
|
+
// whole document as a drop target for the life of a tab drag and swallow
|
|
2342
|
+
// the drop there too. dragend still commits whatever order the strip was
|
|
2343
|
+
// previewing. Gated on draggingEl so an unrelated drag (a file dropped on
|
|
2344
|
+
// the page) is left entirely to the browser.
|
|
2345
|
+
const onDocTabDrop = (e: DragEvent): void => {
|
|
2346
|
+
if (draggingEl) {
|
|
2347
|
+
e.preventDefault();
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
document.addEventListener("dragover", onDocTabDrop);
|
|
2351
|
+
document.addEventListener("drop", onDocTabDrop);
|
|
1758
2352
|
// Active-row close (x): closes the current tab (mirrors a listed row's x).
|
|
1759
2353
|
// stopPropagation so it is not read as a tap/swipe on the row surface.
|
|
1760
2354
|
swClose.addEventListener("click", (e) => {
|
|
@@ -1766,6 +2360,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1766
2360
|
// Dismiss the tab context menu on an outside click, on Escape, and on
|
|
1767
2361
|
// scroll (it is anchored to a viewport point, so a scroll would detach it).
|
|
1768
2362
|
const onDocClickMenu = (): void => {
|
|
2363
|
+
// The trailing click of the long-press that just opened the menu is that
|
|
2364
|
+
// gesture's own release, not a click-away.
|
|
2365
|
+
if (menuSwallow.swallowing()) {
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
1769
2368
|
hideTabMenu();
|
|
1770
2369
|
};
|
|
1771
2370
|
document.addEventListener("click", onDocClickMenu);
|
|
@@ -2214,14 +2813,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2214
2813
|
// them by hand).
|
|
2215
2814
|
if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
|
|
2216
2815
|
try {
|
|
2217
|
-
sessions = [...sessions, await api
|
|
2218
|
-
} catch {
|
|
2816
|
+
sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
|
|
2817
|
+
} catch (err) {
|
|
2219
2818
|
// Match the runtime create() path: toast and leave the chrome up so
|
|
2220
2819
|
// "+" can retry. Any exited sessions stay adopted (frozen screen +
|
|
2221
2820
|
// "Session ended" is still better than a blank page). A throw here
|
|
2222
2821
|
// would also be survivable (the kernel treats a rejected resolver
|
|
2223
|
-
// as null), but the toast is the better UX.
|
|
2224
|
-
|
|
2822
|
+
// as null), but the toast is the better UX. A 503 was already
|
|
2823
|
+
// retried on the server's schedule, so this is the give-up path;
|
|
2824
|
+
// carry the server's explanation when it gave one.
|
|
2825
|
+
ctx.toast(
|
|
2826
|
+
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2827
|
+
? `Couldn't open a terminal: ${err.serverMessage}`
|
|
2828
|
+
: "Couldn't open a terminal",
|
|
2829
|
+
);
|
|
2225
2830
|
}
|
|
2226
2831
|
}
|
|
2227
2832
|
// Adopt (dedup) rather than blindly push. The status SSE pushes a
|
|
@@ -2292,16 +2897,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2292
2897
|
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
2293
2898
|
},
|
|
2294
2899
|
teardown() {
|
|
2900
|
+
tornDown = true;
|
|
2295
2901
|
resolveImpl = null;
|
|
2296
2902
|
offStatus?.();
|
|
2297
2903
|
offStreamOpen?.();
|
|
2298
|
-
offInput();
|
|
2299
2904
|
offHwKey();
|
|
2300
2905
|
offArmed?.();
|
|
2301
2906
|
offMenuKey();
|
|
2302
2907
|
document.removeEventListener("click", onDocClickMenu);
|
|
2303
2908
|
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
2304
2909
|
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
2910
|
+
document.removeEventListener("dragover", onDocTabDrop);
|
|
2911
|
+
document.removeEventListener("drop", onDocTabDrop);
|
|
2305
2912
|
window.removeEventListener("scroll", onScrollMenu, true);
|
|
2306
2913
|
if (pollTimer !== null) {
|
|
2307
2914
|
clearInterval(pollTimer);
|
|
@@ -2320,6 +2927,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2320
2927
|
}
|
|
2321
2928
|
endReelNow();
|
|
2322
2929
|
gestureAbort?.abort(); // drop any in-flight gesture's window listeners
|
|
2930
|
+
endEdit(); // abandon an open rename without issuing a request
|
|
2931
|
+
clearDragGhost();
|
|
2323
2932
|
clearCatchup();
|
|
2324
2933
|
hideTabMenu();
|
|
2325
2934
|
for (const t of tabList) {
|