@cplieger/web-terminal-ui 7.3.2 → 8.0.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/NOTICE +3 -0
- package/README.md +114 -18
- package/THIRD_PARTY_NOTICES.md +7 -0
- 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 +15 -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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { AttentionOptions, AttentionReporter, AttentionState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const NO_ATTENTION: AttentionState = { count: 0, icon: null };
|
|
4
|
+
|
|
5
|
+
/** The sinks a render writes, every one injected. An absent optional sink is a
|
|
6
|
+
* silent no-op: an unsupported surface is a normal state of the world. Two can
|
|
7
|
+
* also fail INVISIBLY (`setBadge` resolves on a desktop that paints no badge,
|
|
8
|
+
* `setIcon` assigns an href Safari ignores), which is why the title is gated on
|
|
9
|
+
* no capability and is the floor: never arrange these as a fallback ladder. */
|
|
10
|
+
export interface AttentionSinks {
|
|
11
|
+
/** Set the mark the document title starts with, or clear it with "". */
|
|
12
|
+
setTitleMark: (text: string) => void;
|
|
13
|
+
setBadge?: ((count: number) => void) | undefined;
|
|
14
|
+
setIcon?: ((variant: string | null) => void) | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The reporter plus its release. */
|
|
18
|
+
export interface AttentionSurface extends AttentionReporter {
|
|
19
|
+
/** Restore every surface and stop following the page lifecycle. */
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The count goes FIRST because a browser tab strip truncates a title to its
|
|
24
|
+
* first few characters; parenthesised digits are the convention every mail and
|
|
25
|
+
* chat client uses, so it needs no legend. */
|
|
26
|
+
export function titleMarkFor(count: number): string {
|
|
27
|
+
return count > 0 ? `(${String(count)}) ` : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** iconVariantHref rewrites an icon URL to its variant: the `favicon` token of
|
|
31
|
+
* the filename gains `-<variant>`, keeping the extension so each link keeps its
|
|
32
|
+
* own format. Null for a filename that does not start with `favicon`, which
|
|
33
|
+
* leaves that link alone rather than pointing it at a 404. */
|
|
34
|
+
export function iconVariantHref(href: string, variant: string): string | null {
|
|
35
|
+
const match = /(^|\/)favicon(?=[-.])/.exec(href);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const at = match.index + match[0].length;
|
|
40
|
+
return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render reports onto `sinks`, change-gated on the last rendered value: the
|
|
44
|
+
* title doubles as the browser-tab label and the bookmark name, and
|
|
45
|
+
* re-assigning an icon href makes some browsers re-fetch it. `pagehide` restores
|
|
46
|
+
* the surfaces (a browser remembers ONE icon per URL for the bookmark and the
|
|
47
|
+
* history row, so a tab closed on a lit cue would leave a status variant standing
|
|
48
|
+
* in for the app), `pageshow` repaints the last report for a page that came back
|
|
49
|
+
* from the back-forward cache, and `freeze` changes nothing because a frozen tab
|
|
50
|
+
* is still in the strip rendering its icon. */
|
|
51
|
+
export function createAttention(sinks: AttentionSinks, win: Window): AttentionSurface {
|
|
52
|
+
let reported: AttentionState = NO_ATTENTION;
|
|
53
|
+
let rendered: AttentionState | null = null;
|
|
54
|
+
|
|
55
|
+
function render(next: AttentionState): void {
|
|
56
|
+
const countChanged = next.count !== rendered?.count;
|
|
57
|
+
const iconChanged = next.icon !== rendered?.icon;
|
|
58
|
+
rendered = next;
|
|
59
|
+
if (countChanged) {
|
|
60
|
+
sinks.setTitleMark(titleMarkFor(next.count));
|
|
61
|
+
sinks.setBadge?.(next.count);
|
|
62
|
+
}
|
|
63
|
+
if (iconChanged) {
|
|
64
|
+
sinks.setIcon?.(next.icon);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onPageGone(): void {
|
|
69
|
+
render(NO_ATTENTION);
|
|
70
|
+
}
|
|
71
|
+
function onPageBack(): void {
|
|
72
|
+
render(reported);
|
|
73
|
+
}
|
|
74
|
+
const listeners = new AbortController();
|
|
75
|
+
const { signal } = listeners;
|
|
76
|
+
try {
|
|
77
|
+
win.addEventListener("pagehide", onPageGone, { signal });
|
|
78
|
+
win.addEventListener("pageshow", onPageBack, { signal });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
listeners.abort();
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
report(state) {
|
|
86
|
+
reported = state;
|
|
87
|
+
render(state);
|
|
88
|
+
},
|
|
89
|
+
dispose() {
|
|
90
|
+
listeners.abort();
|
|
91
|
+
render(NO_ATTENTION);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bind the sinks to `doc`'s browser realm. Every capability decision is made
|
|
97
|
+
* HERE, once, so the render never probes. */
|
|
98
|
+
export function browserAttentionSinks(
|
|
99
|
+
setTitleMark: (text: string) => void,
|
|
100
|
+
opts: AttentionOptions,
|
|
101
|
+
doc: Document,
|
|
102
|
+
): AttentionSinks {
|
|
103
|
+
const sinks: AttentionSinks = { setTitleMark };
|
|
104
|
+
|
|
105
|
+
// Read through `unknown` because the Badging API is absent on most browsers
|
|
106
|
+
// and exists only on an installed app's icon.
|
|
107
|
+
const nav: unknown = doc.defaultView?.navigator;
|
|
108
|
+
const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
|
|
109
|
+
const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
|
|
110
|
+
if (typeof setAppBadge === "function") {
|
|
111
|
+
sinks.setBadge = (count: number): void => {
|
|
112
|
+
// Always a NUMBER, never the spec's bare flag form: iOS renders nothing for
|
|
113
|
+
// `setAppBadge()` with no argument. Zero clears through clearAppBadge where
|
|
114
|
+
// it exists and setAppBadge(0) otherwise (Chrome shipped the former later).
|
|
115
|
+
try {
|
|
116
|
+
const call =
|
|
117
|
+
count > 0
|
|
118
|
+
? (setAppBadge as (n: number) => unknown).call(nav, count)
|
|
119
|
+
: typeof clearAppBadge === "function"
|
|
120
|
+
? (clearAppBadge as () => unknown).call(nav)
|
|
121
|
+
: (setAppBadge as (n: number) => unknown).call(nav, 0);
|
|
122
|
+
// A rejection is an OS that will not paint a badge, not a page fault.
|
|
123
|
+
void Promise.resolve(call).catch(() => undefined);
|
|
124
|
+
} catch {
|
|
125
|
+
/* a synchronous throw is the same non-event */
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (opts.icons) {
|
|
131
|
+
// Every icon link, because which one a browser picks differs (Chrome prefers
|
|
132
|
+
// the SVG). `rel~="icon"` deliberately skips apple-touch-icon: the OS caches
|
|
133
|
+
// it at install time, so a swap cannot reach it.
|
|
134
|
+
const links = [...doc.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
|
|
135
|
+
const originals = new Map<HTMLLinkElement, string>();
|
|
136
|
+
for (const link of links) {
|
|
137
|
+
originals.set(link, link.getAttribute("href") ?? "");
|
|
138
|
+
}
|
|
139
|
+
if (links.length > 0) {
|
|
140
|
+
sinks.setIcon = (variant): void => {
|
|
141
|
+
for (const link of links) {
|
|
142
|
+
const original = originals.get(link) ?? "";
|
|
143
|
+
if (variant === null) {
|
|
144
|
+
link.setAttribute("href", original);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const next = iconVariantHref(original, variant);
|
|
148
|
+
if (next !== null) {
|
|
149
|
+
link.setAttribute("href", next);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return sinks;
|
|
157
|
+
}
|
package/src/kernel/conn-state.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
// Connection-state machine (kernel-owned, design section 22.3). The state, the
|
|
2
|
-
// never-connects give-up, and the loading lifecycle are kernel; the visible
|
|
3
|
-
// banner is the connectionBanner feature, which renders whatever state this
|
|
4
|
-
// emits. This is the DOM-free half of the old status.ts (its banner/toast
|
|
5
|
-
// rendering became the connectionBanner feature and the kernel toast surface).
|
|
6
|
-
//
|
|
7
|
-
// State machine:
|
|
8
|
-
// open -> no banner.
|
|
9
|
-
// connecting -> "Reconnecting..." (initial connect, before first open).
|
|
10
|
-
// reconnecting -> "Reconnecting..." (after a close, during retry);
|
|
11
|
-
// escalates to offline after >3 consecutive failures.
|
|
12
|
-
// offline -> "Offline".
|
|
13
|
-
// restarted -> "Server restarted - recent input may have been lost".
|
|
14
|
-
|
|
15
1
|
import type { ConnState } from "./types.js";
|
|
16
2
|
|
|
17
3
|
const CONNECTING_GRACE_MS = 600;
|
|
@@ -41,6 +27,9 @@ export interface ConnStateMachine {
|
|
|
41
27
|
/** The engine refused an explicitly incompatible wire revision. This
|
|
42
28
|
* terminal state bypasses the loading gate and persists until open(). */
|
|
43
29
|
incompatible(): void;
|
|
30
|
+
/** The pane holds no session: nothing is connecting and nothing is retrying.
|
|
31
|
+
* Left by open() on the next attach. */
|
|
32
|
+
idle(): void;
|
|
44
33
|
/** First screen frame rendered: the loading overlay is done, so the banner
|
|
45
34
|
* may show reconnect state from here on. */
|
|
46
35
|
setLoaded(): void;
|
|
@@ -50,13 +39,22 @@ export interface ConnStateMachine {
|
|
|
50
39
|
destroy(): void;
|
|
51
40
|
}
|
|
52
41
|
|
|
42
|
+
/** The timers the machine schedules on: the mounted window's, so a grace delay
|
|
43
|
+
* runs on the terminal's own clock. */
|
|
44
|
+
export interface ConnStateTimers {
|
|
45
|
+
setTimeout(handler: () => void, ms: number): number;
|
|
46
|
+
clearTimeout(id: number): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
53
49
|
export function createConnState(opts: {
|
|
54
50
|
onState: (s: ConnState) => void;
|
|
55
51
|
onGiveUp?: () => void;
|
|
52
|
+
timers: ConnStateTimers;
|
|
56
53
|
}): ConnStateMachine {
|
|
54
|
+
const { timers } = opts;
|
|
57
55
|
let state: ConnState = "connecting";
|
|
58
|
-
let stableTimer:
|
|
59
|
-
let restartedTimer:
|
|
56
|
+
let stableTimer: number | null = null;
|
|
57
|
+
let restartedTimer: number | null = null;
|
|
60
58
|
let consecutiveFailures = 0;
|
|
61
59
|
let loaded = false;
|
|
62
60
|
|
|
@@ -79,7 +77,7 @@ export function createConnState(opts: {
|
|
|
79
77
|
|
|
80
78
|
function setState(next: ConnState, delay: number): void {
|
|
81
79
|
if (stableTimer !== null) {
|
|
82
|
-
clearTimeout(stableTimer);
|
|
80
|
+
timers.clearTimeout(stableTimer);
|
|
83
81
|
stableTimer = null;
|
|
84
82
|
}
|
|
85
83
|
if (delay === 0) {
|
|
@@ -87,7 +85,7 @@ export function createConnState(opts: {
|
|
|
87
85
|
emit();
|
|
88
86
|
return;
|
|
89
87
|
}
|
|
90
|
-
stableTimer = setTimeout(() => {
|
|
88
|
+
stableTimer = timers.setTimeout(() => {
|
|
91
89
|
stableTimer = null;
|
|
92
90
|
state = next;
|
|
93
91
|
emit();
|
|
@@ -118,9 +116,9 @@ export function createConnState(opts: {
|
|
|
118
116
|
restarted(): void {
|
|
119
117
|
setState("restarted", 0);
|
|
120
118
|
if (restartedTimer !== null) {
|
|
121
|
-
clearTimeout(restartedTimer);
|
|
119
|
+
timers.clearTimeout(restartedTimer);
|
|
122
120
|
}
|
|
123
|
-
restartedTimer = setTimeout(() => {
|
|
121
|
+
restartedTimer = timers.setTimeout(() => {
|
|
124
122
|
restartedTimer = null;
|
|
125
123
|
if (state === "restarted") {
|
|
126
124
|
setState("open", 0);
|
|
@@ -140,6 +138,14 @@ export function createConnState(opts: {
|
|
|
140
138
|
consecutiveFailures = 0;
|
|
141
139
|
setState("incompatible", 0);
|
|
142
140
|
},
|
|
141
|
+
idle(): void {
|
|
142
|
+
consecutiveFailures = 0;
|
|
143
|
+
if (restartedTimer !== null) {
|
|
144
|
+
timers.clearTimeout(restartedTimer);
|
|
145
|
+
restartedTimer = null;
|
|
146
|
+
}
|
|
147
|
+
setState("idle", 0);
|
|
148
|
+
},
|
|
143
149
|
setLoaded(): void {
|
|
144
150
|
loaded = true;
|
|
145
151
|
},
|
|
@@ -148,11 +154,11 @@ export function createConnState(opts: {
|
|
|
148
154
|
},
|
|
149
155
|
destroy(): void {
|
|
150
156
|
if (stableTimer !== null) {
|
|
151
|
-
clearTimeout(stableTimer);
|
|
157
|
+
timers.clearTimeout(stableTimer);
|
|
152
158
|
stableTimer = null;
|
|
153
159
|
}
|
|
154
160
|
if (restartedTimer !== null) {
|
|
155
|
-
clearTimeout(restartedTimer);
|
|
161
|
+
timers.clearTimeout(restartedTimer);
|
|
156
162
|
restartedTimer = null;
|
|
157
163
|
}
|
|
158
164
|
},
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
|
|
2
|
+
import { reloadPage } from "./navigation.js";
|
|
3
|
+
import { windowOf } from "./realm.js";
|
|
4
|
+
|
|
5
|
+
/** Fade out and remove a consumer-supplied loading overlay. */
|
|
6
|
+
export function fadeOutOverlay(ld: HTMLElement | undefined): void {
|
|
7
|
+
if (!ld) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
ld.classList.add("fade");
|
|
11
|
+
const removeOverlay = (): void => {
|
|
12
|
+
ld.remove();
|
|
13
|
+
};
|
|
14
|
+
ld.addEventListener("transitionend", removeOverlay, { once: true });
|
|
15
|
+
windowOf(ld.ownerDocument).setTimeout(removeOverlay, 1500);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FatalPanelOptions {
|
|
19
|
+
message?: string;
|
|
20
|
+
/** `showModal()` (the terminal IS the page) or the bare `open` attribute (an
|
|
21
|
+
* embedded panel, or a pane beside a live one). */
|
|
22
|
+
modal: boolean;
|
|
23
|
+
/** Appended to the panel's two element ids, so two panels in one document keep
|
|
24
|
+
* their `aria-labelledby` targets unique. */
|
|
25
|
+
idSuffix?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render the "Terminal failed to start" panel into `root`, replacing its children. */
|
|
29
|
+
export function renderFatalStartupInto(root: HTMLElement, opts: FatalPanelOptions): void {
|
|
30
|
+
const suffix = opts.idSuffix ?? "";
|
|
31
|
+
const doc = root.ownerDocument;
|
|
32
|
+
const surface = doc.createElement("dialog");
|
|
33
|
+
surface.className = "wt-fatal";
|
|
34
|
+
// alertdialog over the implicit `dialog` role: an urgent message to act on. No
|
|
35
|
+
// `aria-modal`: showModal() conveys modality, and an authored value can
|
|
36
|
+
// contradict the UA.
|
|
37
|
+
surface.setAttribute("role", "alertdialog");
|
|
38
|
+
surface.setAttribute("aria-labelledby", `wt-fatal-title${suffix}`);
|
|
39
|
+
surface.setAttribute("aria-describedby", `wt-fatal-message${suffix}`);
|
|
40
|
+
// Nothing is behind this panel, so reload is the only recovery; Escape must not
|
|
41
|
+
// close it.
|
|
42
|
+
surface.addEventListener("cancel", (ev) => {
|
|
43
|
+
ev.preventDefault();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const card = doc.createElement("div");
|
|
47
|
+
card.className = "wt-fatal-card";
|
|
48
|
+
const title = doc.createElement("h2");
|
|
49
|
+
title.id = `wt-fatal-title${suffix}`;
|
|
50
|
+
title.className = "wt-fatal-title";
|
|
51
|
+
title.textContent = STARTUP_FAILURE_COPY.title;
|
|
52
|
+
const messageEl = doc.createElement("p");
|
|
53
|
+
messageEl.id = `wt-fatal-message${suffix}`;
|
|
54
|
+
messageEl.className = "wt-fatal-message";
|
|
55
|
+
messageEl.textContent = opts.message ?? STARTUP_FAILURE_COPY.message;
|
|
56
|
+
const reloadButton = doc.createElement("button");
|
|
57
|
+
reloadButton.className = "wt-btn wt-fatal-reload";
|
|
58
|
+
reloadButton.type = "button";
|
|
59
|
+
reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
|
|
60
|
+
reloadButton.addEventListener("click", () => {
|
|
61
|
+
reloadPage(windowOf(doc));
|
|
62
|
+
});
|
|
63
|
+
card.append(title, messageEl, reloadButton);
|
|
64
|
+
surface.appendChild(card);
|
|
65
|
+
root.replaceChildren(surface);
|
|
66
|
+
|
|
67
|
+
// showModal() requires a connected element in a document that has a window; a
|
|
68
|
+
// detached root or a created document arrives here from a typed call, where a
|
|
69
|
+
// throw would bury the real startup cause.
|
|
70
|
+
if (opts.modal && surface.isConnected && doc.defaultView !== null) {
|
|
71
|
+
surface.showModal();
|
|
72
|
+
} else {
|
|
73
|
+
surface.open = true;
|
|
74
|
+
}
|
|
75
|
+
reloadButton.focus();
|
|
76
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { FeatureInstance, TerminalContext, TerminalFeature, Unsubscribe } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Releases run last-in first-out: what one feature acquired, or what a pane or
|
|
4
|
+
* the shell holds. */
|
|
5
|
+
export interface CleanupScope {
|
|
6
|
+
/** Hold `release` until the drain. After the drain it runs at once, so a setup
|
|
7
|
+
* still running past its owner's teardown cannot leave anything behind. */
|
|
8
|
+
push(release: Unsubscribe): void;
|
|
9
|
+
drain(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createCleanupScope(reportError: (err: unknown) => void): CleanupScope {
|
|
13
|
+
const releases: Unsubscribe[] = [];
|
|
14
|
+
let drained = false;
|
|
15
|
+
const run = (release: Unsubscribe): void => {
|
|
16
|
+
try {
|
|
17
|
+
release();
|
|
18
|
+
} catch (err) {
|
|
19
|
+
reportError(err);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
push(release) {
|
|
24
|
+
if (drained) {
|
|
25
|
+
run(release);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
releases.push(release);
|
|
29
|
+
},
|
|
30
|
+
drain() {
|
|
31
|
+
drained = true;
|
|
32
|
+
while (releases.length > 0) {
|
|
33
|
+
const release = releases.pop();
|
|
34
|
+
if (release) {
|
|
35
|
+
run(release);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FeatureSetupOutcome =
|
|
43
|
+
| { readonly status: "ready" }
|
|
44
|
+
| { readonly status: "aborted" }
|
|
45
|
+
| { readonly status: "failed"; readonly feature: string; readonly cause: unknown };
|
|
46
|
+
|
|
47
|
+
interface Entry {
|
|
48
|
+
readonly feature: TerminalFeature<unknown>;
|
|
49
|
+
readonly instance: FeatureInstance<unknown>;
|
|
50
|
+
readonly scope: CleanupScope;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FeatureHost {
|
|
54
|
+
/** Set one feature up. The scope exists before `setup` runs, so a throw drains
|
|
55
|
+
* what the feature acquired; a `destroyed()` true after the await tears the
|
|
56
|
+
* instance down instead of registering it. */
|
|
57
|
+
setup(
|
|
58
|
+
feature: TerminalFeature<unknown>,
|
|
59
|
+
makeContext: (scope: CleanupScope) => TerminalContext,
|
|
60
|
+
destroyed: () => boolean,
|
|
61
|
+
): Promise<FeatureSetupOutcome>;
|
|
62
|
+
/** The set-up instances, in setup order. */
|
|
63
|
+
instances(): readonly FeatureInstance<unknown>[];
|
|
64
|
+
/** The api of a set-up feature, or undefined. */
|
|
65
|
+
use(feature: TerminalFeature<unknown>): unknown;
|
|
66
|
+
/** The scope of every setup still pending, then every instance's `teardown()`
|
|
67
|
+
* in reverse order, each followed by its scope. */
|
|
68
|
+
teardownAll(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createFeatureHost(
|
|
72
|
+
reportError: (feature: string, err: unknown) => void,
|
|
73
|
+
): FeatureHost {
|
|
74
|
+
const entries: Entry[] = [];
|
|
75
|
+
const pending = new Set<CleanupScope>();
|
|
76
|
+
const apiMap = new Map<TerminalFeature<unknown>, unknown>();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async setup(feature, makeContext, destroyed) {
|
|
80
|
+
// A feature enters `entries` only after `setup` returns, so the scope is
|
|
81
|
+
// the one owner of what a throwing or still-pending `setup` acquired.
|
|
82
|
+
const scope = createCleanupScope((err) => {
|
|
83
|
+
reportError(feature.name, err);
|
|
84
|
+
});
|
|
85
|
+
pending.add(scope);
|
|
86
|
+
try {
|
|
87
|
+
const instance = await feature.setup(makeContext(scope));
|
|
88
|
+
pending.delete(scope);
|
|
89
|
+
if (destroyed()) {
|
|
90
|
+
try {
|
|
91
|
+
instance.teardown();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
reportError(feature.name, err);
|
|
94
|
+
}
|
|
95
|
+
scope.drain();
|
|
96
|
+
return { status: "aborted" };
|
|
97
|
+
}
|
|
98
|
+
entries.push({ feature, instance, scope });
|
|
99
|
+
(feature as { api?: unknown }).api = instance.api;
|
|
100
|
+
apiMap.set(feature, instance.api);
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
pending.delete(scope);
|
|
103
|
+
scope.drain();
|
|
104
|
+
if (destroyed()) {
|
|
105
|
+
return { status: "aborted" };
|
|
106
|
+
}
|
|
107
|
+
reportError(feature.name, cause);
|
|
108
|
+
console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
|
|
109
|
+
return { status: "failed", feature: feature.name, cause };
|
|
110
|
+
}
|
|
111
|
+
return { status: "ready" };
|
|
112
|
+
},
|
|
113
|
+
instances: () => entries.map((e) => e.instance),
|
|
114
|
+
use: (feature) => apiMap.get(feature),
|
|
115
|
+
teardownAll() {
|
|
116
|
+
for (const scope of pending) {
|
|
117
|
+
scope.drain();
|
|
118
|
+
}
|
|
119
|
+
pending.clear();
|
|
120
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
121
|
+
const entry = entries[i];
|
|
122
|
+
if (!entry) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
entry.instance.teardown();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
reportError(entry.feature.name, err);
|
|
129
|
+
}
|
|
130
|
+
entry.scope.drain();
|
|
131
|
+
}
|
|
132
|
+
entries.length = 0;
|
|
133
|
+
apiMap.clear();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/kernel/gesture.ts
CHANGED
|
@@ -16,17 +16,13 @@ export const TAP_MOVEMENT_PX = 10;
|
|
|
16
16
|
* belongs to native text selection or the context menu. */
|
|
17
17
|
export const TAP_MAX_MS = 500;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* or an application wrote itself.
|
|
29
|
-
*/
|
|
30
|
-
export function isLinkTarget(target: EventTarget | null): boolean {
|
|
31
|
-
return target instanceof Element && target.closest("a[href], .term-link") !== null;
|
|
19
|
+
/** Whether a press landed on a LINK, in which case neither handler claims it:
|
|
20
|
+
* the platform's own link affordances (preview on hold, activate on tap) win.
|
|
21
|
+
* One matcher for both halves of the boundary: the kernel once matched
|
|
22
|
+
* `.term-link` (the engine linkifier's class) while the menu also matched
|
|
23
|
+
* `a[href]`, so an ordinary anchor was a link to one half and not the other.
|
|
24
|
+
* `win` is the target's own realm: an element from another document is not an
|
|
25
|
+
* `Element` of the importing one. */
|
|
26
|
+
export function isLinkTarget(win: Window & typeof globalThis, target: EventTarget | null): boolean {
|
|
27
|
+
return target instanceof win.Element && target.closest("a[href], .term-link") !== null;
|
|
32
28
|
}
|