@vibecook/ghosttea-react 0.6.2 → 0.8.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 +8 -0
- package/dist/TerminalSurface.d.ts +4 -1
- package/dist/TerminalSurface.d.ts.map +1 -1
- package/dist/TerminalSurface.js +6 -3
- package/dist/TerminalSurface.js.map +1 -1
- package/dist/bindings/action-route.d.ts.map +1 -1
- package/dist/bindings/action-route.js +4 -0
- package/dist/bindings/action-route.js.map +1 -1
- package/dist/bindings/ghostty-bindings.d.ts +13 -0
- package/dist/bindings/ghostty-bindings.d.ts.map +1 -1
- package/dist/bindings/ghostty-bindings.js +57 -0
- package/dist/bindings/ghostty-bindings.js.map +1 -1
- package/dist/bindings/index.d.ts +1 -1
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +1 -1
- package/dist/bindings/index.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/renderers/types.d.ts +6 -0
- package/dist/renderers/types.d.ts.map +1 -1
- package/dist/renderers/types.js +3 -0
- package/dist/renderers/types.js.map +1 -1
- package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
- package/dist/renderers/webgpu-renderer.js +33 -4
- package/dist/renderers/webgpu-renderer.js.map +1 -1
- package/dist/runtime.d.ts +40 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +458 -32
- package/dist/runtime.js.map +1 -1
- package/dist/terminal-bindings.d.ts +2 -2
- package/dist/terminal-bindings.d.ts.map +1 -1
- package/dist/terminal-bindings.js +2 -1
- package/dist/terminal-bindings.js.map +1 -1
- package/dist/terminal-render.worker.js +93 -7
- package/dist/terminal-render.worker.js.map +2 -2
- package/dist/worker-messages.d.ts +26 -1
- package/dist/worker-messages.d.ts.map +1 -1
- package/dist/workspace/RemoteSessionBanner.d.ts +22 -0
- package/dist/workspace/RemoteSessionBanner.d.ts.map +1 -0
- package/dist/workspace/RemoteSessionBanner.js +108 -0
- package/dist/workspace/RemoteSessionBanner.js.map +1 -0
- package/dist/workspace/RemoteSessionPalette.d.ts +5 -1
- package/dist/workspace/RemoteSessionPalette.d.ts.map +1 -1
- package/dist/workspace/RemoteSessionPalette.js +19 -15
- package/dist/workspace/RemoteSessionPalette.js.map +1 -1
- package/dist/workspace/Workspace.d.ts +25 -2
- package/dist/workspace/Workspace.d.ts.map +1 -1
- package/dist/workspace/Workspace.js +125 -27
- package/dist/workspace/Workspace.js.map +1 -1
- package/dist/workspace/index.d.ts +4 -2
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js +3 -1
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/pane-layout.d.ts +9 -2
- package/dist/workspace/pane-layout.d.ts.map +1 -1
- package/dist/workspace/pane-layout.js +37 -11
- package/dist/workspace/pane-layout.js.map +1 -1
- package/dist/workspace/remote-banner.d.ts +36 -0
- package/dist/workspace/remote-banner.d.ts.map +1 -0
- package/dist/workspace/remote-banner.js +108 -0
- package/dist/workspace/remote-banner.js.map +1 -0
- package/dist/workspace/workspace-model.d.ts +2 -0
- package/dist/workspace/workspace-model.d.ts.map +1 -1
- package/dist/workspace/workspace-model.js +6 -1
- package/dist/workspace/workspace-model.js.map +1 -1
- package/dist/workspace.css +93 -0
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -10,10 +10,23 @@ function sameSessionActivity(left, right) {
|
|
|
10
10
|
left.foregroundProcessGroupId === right.foregroundProcessGroupId &&
|
|
11
11
|
left.observedAtMs === right.observedAtMs);
|
|
12
12
|
}
|
|
13
|
+
/** States that leave a screen on display which the host is no longer updating. */
|
|
14
|
+
export function showsStaleScreen(state) {
|
|
15
|
+
return state !== "live" && state !== "opening";
|
|
16
|
+
}
|
|
17
|
+
/** Whether a pane is showing stale content: frozen outright, or live but not yet redrawn. */
|
|
18
|
+
export function sessionIsFrozen(state) {
|
|
19
|
+
return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
|
|
20
|
+
}
|
|
13
21
|
const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
|
|
14
22
|
const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
|
|
15
23
|
const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
|
|
24
|
+
const CONFIG_PROTOCOL_MINOR = 10;
|
|
25
|
+
const REMOTE_LIFECYCLE_PROTOCOL_MINOR = 12;
|
|
26
|
+
const CONTROL_REVISION_CAS_PROTOCOL_MINOR = 13;
|
|
16
27
|
const DEFAULT_FRAME_SUBSCRIPTION_GRACE_MS = 1_000;
|
|
28
|
+
/** A one-shot resume covers a 20 s dial plus the attach handshake. */
|
|
29
|
+
const RECONNECT_REQUEST_TIMEOUT_MS = 60_000;
|
|
17
30
|
export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
|
|
18
31
|
return new Promise((resolve, reject) => {
|
|
19
32
|
const timeout = window.setTimeout(() => {
|
|
@@ -61,7 +74,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
61
74
|
#scrollbarByHandle = new Map();
|
|
62
75
|
#focusByView = new Map();
|
|
63
76
|
#views = new Map();
|
|
77
|
+
#remoteSessions = new Map();
|
|
78
|
+
#controlBySession = new Map();
|
|
79
|
+
/** Sessions whose current outage episode has already produced a full snapshot. */
|
|
80
|
+
#recoveredSessions = new Set();
|
|
81
|
+
#remoteLifecycleSupported = false;
|
|
82
|
+
#controlRevisionCasSupported = false;
|
|
64
83
|
#rendererBackend = "starting";
|
|
84
|
+
#configSnapshot;
|
|
85
|
+
#configProtocolSupported = false;
|
|
65
86
|
#metadataTimers = new Map();
|
|
66
87
|
#resync;
|
|
67
88
|
#performanceRequestId = 1;
|
|
@@ -112,6 +133,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
112
133
|
else if (data.type === "frame-resync-complete") {
|
|
113
134
|
this.#resync.complete(data.sessionHandle);
|
|
114
135
|
}
|
|
136
|
+
else if (data.type === "frame-committed") {
|
|
137
|
+
this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
|
|
138
|
+
}
|
|
115
139
|
else if (data.type === "catalog-pressure") {
|
|
116
140
|
console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
|
|
117
141
|
this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
|
|
@@ -229,20 +253,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
229
253
|
});
|
|
230
254
|
this.#control.addEventListener("control-changed", (event) => {
|
|
231
255
|
const detail = event.detail;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
256
|
+
this.#applyController(detail.sessionId, { viewId: detail.controllerViewId, controlEpoch: detail.controlEpoch }, detail.cols, detail.rows);
|
|
257
|
+
});
|
|
258
|
+
this.#control.addEventListener("control-state", (event) => {
|
|
259
|
+
const detail = event.detail;
|
|
260
|
+
this.#applyController(detail.sessionId, detail.controller, detail.cols, detail.rows, detail.controlRevision);
|
|
261
|
+
});
|
|
262
|
+
this.#control.addEventListener("remote-session-state-changed", (event) => {
|
|
263
|
+
const detail = event.detail;
|
|
264
|
+
this.#applyRemoteSessionState(detail.sessionId, detail, false);
|
|
265
|
+
});
|
|
266
|
+
this.#control.addEventListener("view-state-changed", (event) => {
|
|
267
|
+
const detail = event.detail;
|
|
268
|
+
this.#applyViewState(detail.viewId, detail);
|
|
269
|
+
});
|
|
270
|
+
this.#control.addEventListener("config-changed", (event) => {
|
|
271
|
+
const detail = event.detail;
|
|
272
|
+
this.#installConfig(detail.config);
|
|
246
273
|
});
|
|
247
274
|
this.#frames = ports.frames;
|
|
248
275
|
this.#frames.onmessage = ({ data }) => {
|
|
@@ -292,9 +319,44 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
292
319
|
if (hello.type !== "hello" || hello.protocolMajor !== PROTOCOL_MAJOR)
|
|
293
320
|
throw new Error("ghosttead protocol mismatch");
|
|
294
321
|
this.#frameSubscriptionAcksSupported = hello.protocolMinor >= FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR;
|
|
322
|
+
this.#configProtocolSupported = hello.protocolMinor >= CONFIG_PROTOCOL_MINOR;
|
|
323
|
+
this.#remoteLifecycleSupported = hello.protocolMinor >= REMOTE_LIFECYCLE_PROTOCOL_MINOR;
|
|
324
|
+
this.#controlRevisionCasSupported = hello.protocolMinor >= CONTROL_REVISION_CAS_PROTOCOL_MINOR;
|
|
325
|
+
if (this.#configProtocolSupported && hello.configRevision !== undefined) {
|
|
326
|
+
await this.#refreshConfig();
|
|
327
|
+
}
|
|
295
328
|
await this.#queueFrameSubscriptionSync();
|
|
296
329
|
console.info("[terminal-runtime] authenticated ghosttead protocol");
|
|
297
330
|
}
|
|
331
|
+
get configSnapshot() {
|
|
332
|
+
return this.#configSnapshot;
|
|
333
|
+
}
|
|
334
|
+
async getConfig() {
|
|
335
|
+
await this.connect();
|
|
336
|
+
return this.#configSnapshot;
|
|
337
|
+
}
|
|
338
|
+
async reloadConfig() {
|
|
339
|
+
await this.connect();
|
|
340
|
+
const response = await this.#control.request({ type: "reload-config" });
|
|
341
|
+
if (response.type !== "config")
|
|
342
|
+
throw new Error("ghosttead returned an unexpected configuration response");
|
|
343
|
+
this.#installConfig(response.config);
|
|
344
|
+
return response.config;
|
|
345
|
+
}
|
|
346
|
+
#installConfig(config) {
|
|
347
|
+
if (this.#configSnapshot?.revision === config.revision)
|
|
348
|
+
return;
|
|
349
|
+
this.#configSnapshot = config;
|
|
350
|
+
this.dispatchEvent(new CustomEvent("config-changed", { detail: config }));
|
|
351
|
+
}
|
|
352
|
+
async #refreshConfig() {
|
|
353
|
+
if (!this.#configProtocolSupported || !this.#control)
|
|
354
|
+
return;
|
|
355
|
+
const response = await this.#control.request({ type: "get-config" });
|
|
356
|
+
if (response.type !== "config")
|
|
357
|
+
throw new Error("ghosttead returned an unexpected configuration response");
|
|
358
|
+
this.#installConfig(response.config);
|
|
359
|
+
}
|
|
298
360
|
#handleFrameChannelControl(message) {
|
|
299
361
|
if (!message || typeof message !== "object")
|
|
300
362
|
return false;
|
|
@@ -573,7 +635,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
573
635
|
for (const session of this.#sessionByHandle.values()) {
|
|
574
636
|
before.set(session.id, session);
|
|
575
637
|
}
|
|
576
|
-
const sessions = await
|
|
638
|
+
const [sessions] = await Promise.all([
|
|
639
|
+
this.listSessions(),
|
|
640
|
+
this.#refreshConfig(),
|
|
641
|
+
this.#reconcileRemoteSessions(),
|
|
642
|
+
]);
|
|
577
643
|
const known = new Set();
|
|
578
644
|
for (const session of sessions) {
|
|
579
645
|
known.add(session.id);
|
|
@@ -622,7 +688,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
622
688
|
}
|
|
623
689
|
catch (error) {
|
|
624
690
|
if (!this.#disposed)
|
|
625
|
-
console.error("[terminal-runtime] failed to resynchronize
|
|
691
|
+
console.error("[terminal-runtime] failed to resynchronize state after lost events", error);
|
|
626
692
|
}
|
|
627
693
|
}
|
|
628
694
|
async listRemoteHosts() {
|
|
@@ -639,7 +705,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
639
705
|
throw new Error("ghosttead returned an unexpected response");
|
|
640
706
|
return response.sessions;
|
|
641
707
|
}
|
|
642
|
-
async openRemoteSession(deviceId, remoteSessionId, cols, rows) {
|
|
708
|
+
async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
|
|
643
709
|
await this.connect();
|
|
644
710
|
const response = await this.#control.request({
|
|
645
711
|
type: "open-remote-session",
|
|
@@ -652,6 +718,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
652
718
|
if (response.type !== "session-created")
|
|
653
719
|
throw new Error("ghosttead could not open the remote session");
|
|
654
720
|
this.registerSession(response.session);
|
|
721
|
+
// The session counts as remote from here, before any daemon event, so its
|
|
722
|
+
// input is never queued. A daemon on this minor reports `opening` until
|
|
723
|
+
// the first snapshot lands, so start there and let its events take over;
|
|
724
|
+
// an older daemon reports nothing at all, and seeding `opening` against it
|
|
725
|
+
// would block input forever. The sequence below is under every real event.
|
|
726
|
+
this.#remoteSessions.set(response.session.id, {
|
|
727
|
+
sessionId: response.session.id,
|
|
728
|
+
state: this.#remoteLifecycleSupported ? "opening" : "live",
|
|
729
|
+
reason: null,
|
|
730
|
+
exit: null,
|
|
731
|
+
lifecycleSeq: -1,
|
|
732
|
+
deviceId,
|
|
733
|
+
deviceName,
|
|
734
|
+
attempt: null,
|
|
735
|
+
nextRetryMs: null,
|
|
736
|
+
lastContactMs: null,
|
|
737
|
+
observedAt: performance.now(),
|
|
738
|
+
awaitingRecoveryFrame: false,
|
|
739
|
+
});
|
|
655
740
|
return response.session;
|
|
656
741
|
}
|
|
657
742
|
async #refreshSession(sessionHandle) {
|
|
@@ -660,7 +745,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
660
745
|
return;
|
|
661
746
|
if (!this.#control)
|
|
662
747
|
throw new Error(`Session ${sessionHandle} is not ready for frame resynchronization`);
|
|
663
|
-
|
|
748
|
+
// A refresh of a remote session re-attaches and resynchronizes over the
|
|
749
|
+
// network, so it needs the same budget as a one-shot reconnect.
|
|
750
|
+
const response = await this.#control.request({ type: "refresh-session", sessionId: session.id }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
664
751
|
if (response.type !== "ok")
|
|
665
752
|
throw new Error("ghosttead rejected frame resynchronization");
|
|
666
753
|
}
|
|
@@ -707,8 +794,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
707
794
|
desiredCols: undefined,
|
|
708
795
|
desiredRows: undefined,
|
|
709
796
|
pendingInput: [],
|
|
797
|
+
lastViewStateSeq: undefined,
|
|
798
|
+
lastAttachmentEpoch: undefined,
|
|
799
|
+
claimedEpoch: undefined,
|
|
800
|
+
claimedRevision: 0,
|
|
710
801
|
};
|
|
711
802
|
this.#views.set(viewId, view);
|
|
803
|
+
const remote = this.#remoteSessions.get(sessionId);
|
|
804
|
+
if (remote && remote.state !== "live")
|
|
805
|
+
this.#setCursorFrozen(sessionId, true);
|
|
712
806
|
void subscription.ready
|
|
713
807
|
.then(() => {
|
|
714
808
|
const current = this.#views.get(viewId);
|
|
@@ -727,8 +821,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
727
821
|
const current = this.#views.get(viewId);
|
|
728
822
|
if (current !== view)
|
|
729
823
|
return;
|
|
730
|
-
|
|
731
|
-
|
|
824
|
+
const applied = this.#applyViewState(viewId, {
|
|
825
|
+
...(response.viewStateSeq !== undefined ? { viewStateSeq: response.viewStateSeq } : {}),
|
|
826
|
+
viewState: "attached",
|
|
827
|
+
attachmentEpoch: response.attachmentEpoch,
|
|
828
|
+
readWrite: response.readWrite,
|
|
829
|
+
error: null,
|
|
830
|
+
retryable: null,
|
|
831
|
+
});
|
|
832
|
+
if (!applied)
|
|
833
|
+
return;
|
|
732
834
|
this.dispatchEvent(new CustomEvent("view-attached", {
|
|
733
835
|
detail: { sessionId, sessionHandle, viewId, readWrite: response.readWrite },
|
|
734
836
|
}));
|
|
@@ -782,17 +884,301 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
782
884
|
},
|
|
783
885
|
};
|
|
784
886
|
}
|
|
785
|
-
|
|
887
|
+
get remoteLifecycleSupported() {
|
|
888
|
+
return this.#remoteLifecycleSupported;
|
|
889
|
+
}
|
|
890
|
+
/** Last known lifecycle of a remote session; undefined for local sessions. */
|
|
891
|
+
remoteSession(sessionId) {
|
|
892
|
+
return this.#remoteSessions.get(sessionId);
|
|
893
|
+
}
|
|
894
|
+
#applyController(sessionId, controller, cols, rows, revision = 0) {
|
|
895
|
+
const previous = this.#controlBySession.get(sessionId);
|
|
896
|
+
// A revisioned announcement older than what is cached is a queued view of
|
|
897
|
+
// the past, and must be dropped whole. Keeping its payload while raising
|
|
898
|
+
// the revision would manufacture a state that never existed — "nobody
|
|
899
|
+
// holds control, as of the newest revision" — and that reads as an empty
|
|
900
|
+
// seat the funnel may claim. The compare-and-swap would not catch it
|
|
901
|
+
// either: the expectation would be genuinely current, so the host would
|
|
902
|
+
// accept a claim that takes control from the real holder.
|
|
903
|
+
if (revision >= 1 && previous !== undefined && revision < previous.revision)
|
|
904
|
+
return;
|
|
905
|
+
this.#controlBySession.set(sessionId, {
|
|
906
|
+
controller,
|
|
907
|
+
// A legacy `control-changed` carries no revision; keep the last one so a
|
|
908
|
+
// downgrade never looks like the controller was cleared at a newer one.
|
|
909
|
+
// Unrevisioned announcements cannot be ordered against each other, so
|
|
910
|
+
// they stay last-write-wins — the only semantics available for them.
|
|
911
|
+
revision: Math.max(revision, previous?.revision ?? 0),
|
|
912
|
+
});
|
|
913
|
+
for (const [viewId, view] of this.#views) {
|
|
914
|
+
if (view.sessionId !== sessionId)
|
|
915
|
+
continue;
|
|
916
|
+
if (!controller || controller.viewId !== viewId) {
|
|
917
|
+
view.controlEpoch = undefined;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
view.controlEpoch = controller.controlEpoch;
|
|
921
|
+
if (view.desiredCols !== undefined &&
|
|
922
|
+
view.desiredRows !== undefined &&
|
|
923
|
+
(view.desiredCols !== cols || view.desiredRows !== rows)) {
|
|
924
|
+
this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// A cleared controller is the one case worth re-evaluating: the pane that
|
|
928
|
+
// still holds focus may now take control back.
|
|
929
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
930
|
+
this.#maybeReclaim(viewId);
|
|
931
|
+
}
|
|
932
|
+
*#viewIdsForSession(sessionId) {
|
|
933
|
+
for (const [viewId, view] of this.#views) {
|
|
934
|
+
if (view.sessionId === sessionId)
|
|
935
|
+
yield viewId;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The single funnel for taking resize control (§4.2.3). Every condition that
|
|
940
|
+
* gates a claim re-enters here when it changes, because no one event is
|
|
941
|
+
* enough: recovery marks a view attached before its session reaches live, and
|
|
942
|
+
* the focus setter suppresses repeat `true` updates, so a claim keyed on
|
|
943
|
+
* either alone would be skipped and never retried.
|
|
944
|
+
*
|
|
945
|
+
* At most one claim per attachment epoch, plus one more each time the
|
|
946
|
+
* controller is cleared at a newer revision.
|
|
947
|
+
*
|
|
948
|
+
* A host that reports real revisions gets a compare-and-swap, so a claim or
|
|
949
|
+
* a clear that landed since this client last looked rejects the attempt
|
|
950
|
+
* instead of silently overwriting it. The outcome is asymmetric and needs no
|
|
951
|
+
* loop of its own: the daemon announces the resulting controller state, this
|
|
952
|
+
* funnel re-runs on it, and another pane holding control simply fails the
|
|
953
|
+
* guard above while an empty seat at a newer revision passes the one below.
|
|
954
|
+
*/
|
|
955
|
+
#maybeReclaim(viewId) {
|
|
786
956
|
const view = this.#views.get(viewId);
|
|
787
957
|
if (!view || view.readWrite === false)
|
|
788
958
|
return;
|
|
789
|
-
|
|
959
|
+
const attachmentEpoch = view.attachmentEpoch;
|
|
960
|
+
if (attachmentEpoch === undefined)
|
|
961
|
+
return;
|
|
962
|
+
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
963
|
+
return;
|
|
964
|
+
if (this.#focusByView.get(viewId) !== true)
|
|
965
|
+
return;
|
|
966
|
+
const remote = this.#remoteSessions.get(view.sessionId);
|
|
967
|
+
if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
|
|
968
|
+
return;
|
|
969
|
+
const control = this.#controlBySession.get(view.sessionId);
|
|
970
|
+
const controller = control?.controller ?? null;
|
|
971
|
+
// Never take control from another pane. Our own name on the record is not
|
|
972
|
+
// a reason to stop: after a resume it is the previous incarnation's, and
|
|
973
|
+
// the new attachment epoch below is what decides.
|
|
974
|
+
if (controller && controller.viewId !== viewId)
|
|
975
|
+
return;
|
|
976
|
+
const revision = control?.revision ?? 0;
|
|
977
|
+
// One claim per attachment epoch. A controller *cleared* at a newer
|
|
978
|
+
// revision earns one more; the seat being confirmed as ours does not.
|
|
979
|
+
if (view.claimedEpoch === attachmentEpoch && (controller !== null || view.claimedRevision >= revision))
|
|
980
|
+
return;
|
|
981
|
+
const session = this.#sessionByHandle.get(view.sessionHandle);
|
|
982
|
+
if (!session)
|
|
983
|
+
return;
|
|
984
|
+
view.claimedEpoch = attachmentEpoch;
|
|
985
|
+
view.claimedRevision = revision;
|
|
986
|
+
this.#control?.notify({
|
|
987
|
+
type: "focus-and-resize",
|
|
988
|
+
sessionId: session.id,
|
|
989
|
+
viewId,
|
|
990
|
+
attachmentEpoch,
|
|
991
|
+
cols: view.desiredCols,
|
|
992
|
+
rows: view.desiredRows,
|
|
993
|
+
// Revision 0 is the "legacy, unknown" sentinel, never a real observation
|
|
994
|
+
// to swap against: a host without revisions only understands the
|
|
995
|
+
// unconditional claim, so the field stays off. The negotiated minor is
|
|
996
|
+
// checked too, because a cached revision could outlive a daemon
|
|
997
|
+
// downgrade and a daemon that predates the field would ignore it —
|
|
998
|
+
// turning a swap the caller believes in into a silent overwrite.
|
|
999
|
+
...(this.#controlRevisionCasSupported && revision >= 1 ? { expectedControlRevision: revision } : {}),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* The only path that mutates per-view attachment state, whether the update
|
|
1004
|
+
* arrived as an event, a reconciliation, or an attach response. A sequence
|
|
1005
|
+
* at or below the last applied one is a delayed transition from an abandoned
|
|
1006
|
+
* attempt and is dropped; an update without a sequence comes from a daemon
|
|
1007
|
+
* older than the fence and applies directly.
|
|
1008
|
+
*/
|
|
1009
|
+
#applyViewState(viewId, update) {
|
|
1010
|
+
const view = this.#views.get(viewId);
|
|
1011
|
+
if (!view)
|
|
1012
|
+
return false;
|
|
1013
|
+
if (update.viewStateSeq !== undefined) {
|
|
1014
|
+
if (view.lastViewStateSeq !== undefined && update.viewStateSeq <= view.lastViewStateSeq)
|
|
1015
|
+
return false;
|
|
1016
|
+
view.lastViewStateSeq = update.viewStateSeq;
|
|
1017
|
+
}
|
|
1018
|
+
const remote = this.#remoteSessions.has(view.sessionId);
|
|
1019
|
+
if (update.viewState === "attached" && update.attachmentEpoch !== null) {
|
|
1020
|
+
// Nothing typed against the previous incarnation may ride out on a
|
|
1021
|
+
// freshly armed epoch.
|
|
1022
|
+
if (remote)
|
|
1023
|
+
view.pendingInput.length = 0;
|
|
1024
|
+
view.attachmentEpoch = update.attachmentEpoch;
|
|
1025
|
+
view.lastAttachmentEpoch = update.attachmentEpoch;
|
|
1026
|
+
if (update.readWrite !== null)
|
|
1027
|
+
view.readWrite = update.readWrite;
|
|
1028
|
+
this.#maybeReclaim(viewId);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
view.pendingInput.length = 0;
|
|
1032
|
+
view.attachmentEpoch = undefined;
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
#applyRemoteSessionState(sessionId, lifecycle, authoritative) {
|
|
1036
|
+
const previous = this.#remoteSessions.get(sessionId);
|
|
1037
|
+
if (previous) {
|
|
1038
|
+
// A reconciliation is the source of truth and may restate its sequence;
|
|
1039
|
+
// a pushed event at a sequence already seen is stale.
|
|
1040
|
+
const stale = authoritative
|
|
1041
|
+
? lifecycle.lifecycleSeq < previous.lifecycleSeq
|
|
1042
|
+
: lifecycle.lifecycleSeq <= previous.lifecycleSeq;
|
|
1043
|
+
if (stale)
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
// Lifecycle events and frames travel independent channels, so a recovery
|
|
1047
|
+
// snapshot can commit either side of the live transition. An episode's
|
|
1048
|
+
// latch is cleared when it begins and set by any full snapshot, which lets
|
|
1049
|
+
// both arrival orders resolve without either one having to come first.
|
|
1050
|
+
if (showsStaleScreen(lifecycle.state) && (previous === undefined || !showsStaleScreen(previous.state))) {
|
|
1051
|
+
this.#recoveredSessions.delete(sessionId);
|
|
1052
|
+
}
|
|
1053
|
+
// Coming back from a frozen state, the screen on display is still the one
|
|
1054
|
+
// from before the outage. Keep it marked stale until the recovered stream
|
|
1055
|
+
// has committed a full frame; `opening` never had a screen to distrust.
|
|
1056
|
+
const awaitingRecoveryFrame = lifecycle.state === "live" &&
|
|
1057
|
+
previous !== undefined &&
|
|
1058
|
+
(previous.awaitingRecoveryFrame || showsStaleScreen(previous.state)) &&
|
|
1059
|
+
!this.#recoveredSessions.has(sessionId);
|
|
1060
|
+
const state = {
|
|
1061
|
+
...lifecycle,
|
|
1062
|
+
sessionId,
|
|
1063
|
+
observedAt: performance.now(),
|
|
1064
|
+
awaitingRecoveryFrame,
|
|
1065
|
+
};
|
|
1066
|
+
this.#remoteSessions.set(sessionId, state);
|
|
1067
|
+
if (state.state !== "live") {
|
|
1068
|
+
for (const view of this.#views.values()) {
|
|
1069
|
+
if (view.sessionId === sessionId)
|
|
1070
|
+
view.pendingInput.length = 0;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
this.#setCursorFrozen(sessionId, sessionIsFrozen(state));
|
|
1074
|
+
this.dispatchEvent(new CustomEvent("remote-session-state", { detail: state }));
|
|
1075
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
1076
|
+
this.#maybeReclaim(viewId);
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* A committed frame is only evidence of recovery if it carries a whole
|
|
1081
|
+
* screen: a partial update repaints part of the stale one, which is exactly
|
|
1082
|
+
* what would thaw a pane too early.
|
|
1083
|
+
*/
|
|
1084
|
+
#recordCommittedFrame(sessionHandle, fullSnapshot) {
|
|
1085
|
+
if (!fullSnapshot)
|
|
1086
|
+
return;
|
|
1087
|
+
const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
|
|
1088
|
+
if (sessionId === undefined || !this.#remoteSessions.has(sessionId))
|
|
1089
|
+
return;
|
|
1090
|
+
this.#recoveredSessions.add(sessionId);
|
|
1091
|
+
this.#thawRecoveredSession(sessionId);
|
|
1092
|
+
}
|
|
1093
|
+
/** What the pane shows is current again, so stop marking it stale. */
|
|
1094
|
+
#thawRecoveredSession(sessionId) {
|
|
1095
|
+
const state = this.#remoteSessions.get(sessionId);
|
|
1096
|
+
if (!state || !state.awaitingRecoveryFrame || !this.#recoveredSessions.has(sessionId))
|
|
1097
|
+
return;
|
|
1098
|
+
const thawed = { ...state, awaitingRecoveryFrame: false };
|
|
1099
|
+
this.#remoteSessions.set(sessionId, thawed);
|
|
1100
|
+
this.#setCursorFrozen(sessionId, sessionIsFrozen(thawed));
|
|
1101
|
+
this.dispatchEvent(new CustomEvent("remote-session-state", { detail: thawed }));
|
|
1102
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
1103
|
+
this.#maybeReclaim(viewId);
|
|
1104
|
+
}
|
|
1105
|
+
/** Hold the cursor steady while the replica is frozen, without disturbing focus. */
|
|
1106
|
+
#setCursorFrozen(sessionId, frozen) {
|
|
1107
|
+
for (const [viewId, view] of this.#views) {
|
|
1108
|
+
if (view.sessionId === sessionId)
|
|
1109
|
+
this.#postWorker({ type: "cursor-frozen", surfaceId: viewId, frozen });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async #remoteSessionStateRequest(command, timeoutMs) {
|
|
1113
|
+
await this.connect();
|
|
1114
|
+
if (!this.#remoteLifecycleSupported)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const response = await this.#control.request(command, timeoutMs);
|
|
1117
|
+
if (response.type !== "remote-session-state") {
|
|
1118
|
+
throw new Error("ghosttead returned an unexpected remote session state");
|
|
1119
|
+
}
|
|
1120
|
+
this.#applyRemoteSessionState(command.sessionId, response, true);
|
|
1121
|
+
this.#applyController(command.sessionId, response.controller, response.cols, response.rows, response.controlRevision);
|
|
1122
|
+
for (const view of response.views)
|
|
1123
|
+
this.#applyViewState(view.viewId, view);
|
|
1124
|
+
return this.#remoteSessions.get(command.sessionId);
|
|
1125
|
+
}
|
|
1126
|
+
/** Rebuild this client's state for one remote session from the daemon. */
|
|
1127
|
+
async getRemoteSessionState(sessionId) {
|
|
1128
|
+
return this.#remoteSessionStateRequest({ type: "get-remote-session-state", sessionId }, 10_000);
|
|
1129
|
+
}
|
|
1130
|
+
async reconnectRemoteSession(sessionId) {
|
|
1131
|
+
return this.#remoteSessionStateRequest({ type: "reconnect-remote-session", sessionId }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
1132
|
+
}
|
|
1133
|
+
async retryRemoteView(sessionId, viewId) {
|
|
1134
|
+
await this.connect();
|
|
1135
|
+
if (!this.#remoteLifecycleSupported)
|
|
1136
|
+
return;
|
|
1137
|
+
const response = await this.#control.request({ type: "retry-remote-view", sessionId, viewId }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
1138
|
+
if (response.type !== "view-state")
|
|
1139
|
+
throw new Error("ghosttead returned an unexpected view state");
|
|
1140
|
+
this.#applyViewState(response.viewId, response);
|
|
1141
|
+
}
|
|
1142
|
+
#reconcileRemoteSessions() {
|
|
1143
|
+
if (!this.#remoteLifecycleSupported)
|
|
1144
|
+
return Promise.resolve();
|
|
1145
|
+
return Promise.all([...this.#remoteSessions.keys()].map((sessionId) => this.getRemoteSessionState(sessionId).catch((error) => {
|
|
1146
|
+
if (!this.#disposed) {
|
|
1147
|
+
console.warn(`[terminal-runtime] could not reconcile remote session ${sessionId}`, error);
|
|
1148
|
+
}
|
|
1149
|
+
})));
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* `silent` marks operations that drop without the keystroke hint: focus and
|
|
1153
|
+
* geometry updates are cosmetic, and pointer gestures are already answered
|
|
1154
|
+
* by the pane's frozen treatment.
|
|
1155
|
+
*/
|
|
1156
|
+
#sendViewInput(viewId, operation, silent = false) {
|
|
1157
|
+
const view = this.#views.get(viewId);
|
|
1158
|
+
if (!view || view.readWrite === false)
|
|
1159
|
+
return;
|
|
1160
|
+
const remote = this.#remoteSessions.get(view.sessionId);
|
|
1161
|
+
const attachmentEpoch = view.attachmentEpoch;
|
|
1162
|
+
// Input for a remote session is dropped with feedback rather than queued:
|
|
1163
|
+
// replaying a keystroke across an outage would deliver it to a screen the
|
|
1164
|
+
// user has never seen. The queue below survives only for a local session's
|
|
1165
|
+
// mount-to-attach gap, which is a local IPC round-trip.
|
|
1166
|
+
if (remote && (remote.state !== "live" || attachmentEpoch === undefined)) {
|
|
1167
|
+
view.pendingInput.length = 0;
|
|
1168
|
+
if (!silent)
|
|
1169
|
+
this.#reportSuppressedInput(view.sessionId, viewId, remote.state);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (attachmentEpoch === undefined) {
|
|
790
1173
|
if (view.pendingInput.length < 256)
|
|
791
1174
|
view.pendingInput.push(operation);
|
|
792
1175
|
return;
|
|
793
1176
|
}
|
|
794
1177
|
view.inputSequence += 1;
|
|
795
|
-
operation(
|
|
1178
|
+
operation(attachmentEpoch, view.inputSequence);
|
|
1179
|
+
}
|
|
1180
|
+
#reportSuppressedInput(sessionId, viewId, state) {
|
|
1181
|
+
this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
|
|
796
1182
|
}
|
|
797
1183
|
sendText(sessionId, viewId, text) {
|
|
798
1184
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
@@ -813,17 +1199,18 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
813
1199
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
814
1200
|
}
|
|
815
1201
|
sendMouse(sessionId, viewId, event) {
|
|
816
|
-
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }));
|
|
1202
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
|
|
817
1203
|
}
|
|
818
1204
|
scroll(sessionId, viewId, rows) {
|
|
819
1205
|
if (rows === 0)
|
|
820
1206
|
return;
|
|
821
|
-
|
|
1207
|
+
// Scrolling is host-side input, so it is simply inert while frozen.
|
|
1208
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
|
|
822
1209
|
}
|
|
823
1210
|
scrollTo(sessionId, viewId, row) {
|
|
824
1211
|
if (!Number.isSafeInteger(row) || row < 0)
|
|
825
1212
|
return;
|
|
826
|
-
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }));
|
|
1213
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
|
|
827
1214
|
}
|
|
828
1215
|
scrollbar(sessionHandle) {
|
|
829
1216
|
return this.#scrollbarByHandle.get(sessionHandle);
|
|
@@ -849,6 +1236,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
849
1236
|
cursor: rgb(theme.cursor),
|
|
850
1237
|
});
|
|
851
1238
|
}
|
|
1239
|
+
setEffects(sessionHandle, effects, surfaceId) {
|
|
1240
|
+
this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
|
|
1241
|
+
}
|
|
852
1242
|
setSelection(sessionHandle, selection, surfaceId) {
|
|
853
1243
|
this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
|
|
854
1244
|
}
|
|
@@ -869,6 +1259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
869
1259
|
if (view) {
|
|
870
1260
|
view.desiredCols = cols;
|
|
871
1261
|
view.desiredRows = rows;
|
|
1262
|
+
// An explicit claim is the funnel's outcome, not a competing path.
|
|
1263
|
+
view.claimedEpoch = view.attachmentEpoch;
|
|
1264
|
+
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
872
1265
|
}
|
|
873
1266
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
874
1267
|
if (!session)
|
|
@@ -882,7 +1275,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
882
1275
|
cols,
|
|
883
1276
|
rows,
|
|
884
1277
|
});
|
|
885
|
-
});
|
|
1278
|
+
}, true);
|
|
886
1279
|
}
|
|
887
1280
|
setFocused(sessionHandle, viewId, focused, cols, rows) {
|
|
888
1281
|
const view = this.#views.get(viewId);
|
|
@@ -890,8 +1283,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
890
1283
|
view.desiredCols = cols;
|
|
891
1284
|
view.desiredRows = rows;
|
|
892
1285
|
}
|
|
893
|
-
if (this.#focusByView.get(viewId) === focused)
|
|
1286
|
+
if (this.#focusByView.get(viewId) === focused) {
|
|
1287
|
+
// Focus has not moved, so the claim below will not run — but an epoch or
|
|
1288
|
+
// controller change since the last update may have made one possible,
|
|
1289
|
+
// and nothing else would ever retry it after a resume.
|
|
1290
|
+
this.#maybeReclaim(viewId);
|
|
894
1291
|
return;
|
|
1292
|
+
}
|
|
895
1293
|
this.#focusByView.set(viewId, focused);
|
|
896
1294
|
this.#postWorker({ type: "focus", surfaceId: viewId, sessionHandle, focused });
|
|
897
1295
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
@@ -907,6 +1305,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
907
1305
|
focused,
|
|
908
1306
|
});
|
|
909
1307
|
if (focused) {
|
|
1308
|
+
// Taking focus is a deliberate claim, and counts as this epoch's.
|
|
1309
|
+
if (view) {
|
|
1310
|
+
view.claimedEpoch = attachmentEpoch;
|
|
1311
|
+
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
1312
|
+
}
|
|
910
1313
|
this.#control?.notify({
|
|
911
1314
|
type: "focus-and-resize",
|
|
912
1315
|
sessionId: session.id,
|
|
@@ -916,18 +1319,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
916
1319
|
rows,
|
|
917
1320
|
});
|
|
918
1321
|
}
|
|
919
|
-
});
|
|
1322
|
+
}, true);
|
|
920
1323
|
}
|
|
921
1324
|
async copySelection(sessionId, viewId, selection, selectAll = false) {
|
|
922
1325
|
await this.connect();
|
|
923
1326
|
const view = this.#views.get(viewId);
|
|
924
|
-
if (!view || view.sessionId !== sessionId
|
|
1327
|
+
if (!view || view.sessionId !== sessionId)
|
|
1328
|
+
return "";
|
|
1329
|
+
// A frozen replica stays copyable: offline the daemon answers from the
|
|
1330
|
+
// retained snapshot and authorizes by ownership, so the last epoch this
|
|
1331
|
+
// view held is enough to name the attachment.
|
|
1332
|
+
const attachmentEpoch = view.attachmentEpoch ?? view.lastAttachmentEpoch;
|
|
1333
|
+
if (attachmentEpoch === undefined)
|
|
925
1334
|
return "";
|
|
926
1335
|
const response = await this.#control.request({
|
|
927
1336
|
type: "selection-text",
|
|
928
1337
|
sessionId,
|
|
929
1338
|
viewId,
|
|
930
|
-
attachmentEpoch
|
|
1339
|
+
attachmentEpoch,
|
|
931
1340
|
startColumn: selection.anchor.column,
|
|
932
1341
|
startRow: selection.anchor.row,
|
|
933
1342
|
endColumn: selection.focus.column,
|
|
@@ -936,6 +1345,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
936
1345
|
});
|
|
937
1346
|
if (response.type !== "selection-text")
|
|
938
1347
|
throw new Error("ghosttead returned an unexpected selection response");
|
|
1348
|
+
// Offline the daemon can only reach the retained screen, so say so rather
|
|
1349
|
+
// than letting a short copy read as the whole scrollback.
|
|
1350
|
+
if (response.scope !== undefined) {
|
|
1351
|
+
this.dispatchEvent(new CustomEvent("selection-scope", {
|
|
1352
|
+
detail: { sessionId, viewId, scope: response.scope },
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
939
1355
|
if (response.text)
|
|
940
1356
|
this.#platform.writeClipboard(response.text);
|
|
941
1357
|
return response.text;
|
|
@@ -990,6 +1406,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
990
1406
|
}
|
|
991
1407
|
this.#sessionByHandle.delete(handle);
|
|
992
1408
|
this.#handleBySessionId.delete(sessionId);
|
|
1409
|
+
this.#remoteSessions.delete(sessionId);
|
|
1410
|
+
this.#controlBySession.delete(sessionId);
|
|
1411
|
+
this.#recoveredSessions.delete(sessionId);
|
|
993
1412
|
if (subscriptionChanged) {
|
|
994
1413
|
this.#frameSubscriptionVersion += 1;
|
|
995
1414
|
this.#syncFrameSubscriptionsInBackground();
|
|
@@ -1010,8 +1429,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1010
1429
|
return;
|
|
1011
1430
|
view.desiredCols = cols;
|
|
1012
1431
|
view.desiredRows = rows;
|
|
1013
|
-
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
1432
|
+
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
|
|
1433
|
+
// Dimensions are one of the funnel's conditions: a pane that measured
|
|
1434
|
+
// itself while uncontrolled may now be able to take control.
|
|
1435
|
+
this.#maybeReclaim(viewId);
|
|
1014
1436
|
return;
|
|
1437
|
+
}
|
|
1015
1438
|
this.#sendResize(viewId, view, cols, rows);
|
|
1016
1439
|
}
|
|
1017
1440
|
dispose() {
|
|
@@ -1047,6 +1470,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1047
1470
|
}
|
|
1048
1471
|
this.#performanceRequests.clear();
|
|
1049
1472
|
this.#views.clear();
|
|
1473
|
+
this.#remoteSessions.clear();
|
|
1474
|
+
this.#controlBySession.clear();
|
|
1475
|
+
this.#recoveredSessions.clear();
|
|
1050
1476
|
this.#focusByView.clear();
|
|
1051
1477
|
this.#mountGenerationBySurface.clear();
|
|
1052
1478
|
this.#mouseTrackingByHandle.clear();
|