@smithers-orchestrator/pi-plugin 0.16.9
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/LICENSE +21 -0
- package/package.json +64 -0
- package/src/SmithersPiRunContext.ts +7 -0
- package/src/api/SmithersPiHttpClient.ts +86 -0
- package/src/api/approve.ts +23 -0
- package/src/api/cancel.ts +14 -0
- package/src/api/deny.ts +23 -0
- package/src/api/getFrames.ts +14 -0
- package/src/api/getStatus.ts +11 -0
- package/src/api/listRuns.ts +20 -0
- package/src/api/resume.ts +19 -0
- package/src/api/runWorkflow.ts +20 -0
- package/src/api/streamEvents.ts +11 -0
- package/src/buildSmithersPiSystemPrompt.ts +120 -0
- package/src/extension.ts +571 -0
- package/src/index.d.ts +443 -0
- package/src/index.ts +18 -0
- package/src/runtime/DevToolsClient.ts +528 -0
- package/src/runtime/DevToolsStore.ts +927 -0
- package/src/views/FrameScrubber.ts +72 -0
- package/src/views/Header.ts +144 -0
- package/src/views/NodeInspector.ts +221 -0
- package/src/views/RunInspector.ts +232 -0
- package/src/views/RunTree.ts +404 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import { applyDelta } from "@smithers-orchestrator/devtools";
|
|
2
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
3
|
+
import type { DevToolsDelta, DevToolsNode, DevToolsSnapshot } from "@smithers-orchestrator/protocol";
|
|
4
|
+
import { DevToolsClient } from "./DevToolsClient.js";
|
|
5
|
+
|
|
6
|
+
type DevToolsGapResync = {
|
|
7
|
+
fromSeq: number;
|
|
8
|
+
toSeq: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type RunStateView = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
type SnapshotWithRunState = DevToolsSnapshot & {
|
|
14
|
+
runState?: RunStateView;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type DevToolsRuntimeEvent =
|
|
18
|
+
| { version: 1; kind: "snapshot"; snapshot: SnapshotWithRunState }
|
|
19
|
+
| { version: 1; kind: "delta"; delta: DevToolsDelta }
|
|
20
|
+
| { version: 1; kind: "gapResync"; gapResync: DevToolsGapResync };
|
|
21
|
+
|
|
22
|
+
type ConnectionState =
|
|
23
|
+
| { kind: "disconnected" }
|
|
24
|
+
| { kind: "connecting" }
|
|
25
|
+
| { kind: "streaming" }
|
|
26
|
+
| { kind: "error"; error: Error };
|
|
27
|
+
|
|
28
|
+
type GhostNodeRecord = {
|
|
29
|
+
key: string;
|
|
30
|
+
node: DevToolsNode;
|
|
31
|
+
mountedFrameNo: number;
|
|
32
|
+
unmountedFrameNo: number;
|
|
33
|
+
unmountedSeq: number;
|
|
34
|
+
capturedAtMs: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type StoreOptions = {
|
|
38
|
+
client?: DevToolsClient;
|
|
39
|
+
ghostNodeCap?: number;
|
|
40
|
+
staleBannerDelayMs?: number;
|
|
41
|
+
toastSink?: (message: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type LiveRunDevToolsMode = { kind: "live" } | { kind: "historical"; frameNo: number };
|
|
45
|
+
|
|
46
|
+
type StoreListener = (store: DevToolsStore) => void;
|
|
47
|
+
|
|
48
|
+
const DEFAULT_GHOST_NODE_CAP = 256;
|
|
49
|
+
const DEFAULT_STALE_BANNER_DELAY_MS = 2_000;
|
|
50
|
+
|
|
51
|
+
function cloneNode(node: DevToolsNode) {
|
|
52
|
+
return structuredClone(node);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cloneSnapshot(snapshot: SnapshotWithRunState) {
|
|
56
|
+
return structuredClone(snapshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findNode(root: DevToolsNode | undefined, id: number): DevToolsNode | undefined {
|
|
60
|
+
if (!root) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length > 0) {
|
|
65
|
+
const node = stack.pop();
|
|
66
|
+
if (!node) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (node.id === id) {
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
for (let index = node.children.length - 1; index >= 0; index -= 1) {
|
|
73
|
+
stack.push(node.children[index]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectGhostKeys(node: DevToolsNode, keys: Set<string>) {
|
|
80
|
+
const key = ghostMapKey(node);
|
|
81
|
+
if (key) {
|
|
82
|
+
keys.add(key);
|
|
83
|
+
}
|
|
84
|
+
for (const child of node.children) {
|
|
85
|
+
collectGhostKeys(child, keys);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ghostMapKey(node: DevToolsNode) {
|
|
90
|
+
const nodeId = node.task?.nodeId;
|
|
91
|
+
return nodeId && nodeId.length > 0 ? nodeId : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function selectionKey(node: DevToolsNode) {
|
|
95
|
+
return ghostMapKey(node) ?? `selected:${node.id}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stateString(node: DevToolsNode | undefined) {
|
|
99
|
+
const raw = node?.props.state;
|
|
100
|
+
return typeof raw === "string" ? raw : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeState(raw: string | undefined) {
|
|
104
|
+
return (raw ?? "unknown").trim().toLowerCase().replace(/[_\s]/g, "-");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runStatusForRoot(root: DevToolsNode | undefined, fallback: string) {
|
|
108
|
+
switch (normalizeState(stateString(root))) {
|
|
109
|
+
case "running":
|
|
110
|
+
case "in-progress":
|
|
111
|
+
return "running";
|
|
112
|
+
case "waitingapproval":
|
|
113
|
+
case "waiting-approval":
|
|
114
|
+
case "blocked":
|
|
115
|
+
return "waiting-approval";
|
|
116
|
+
case "finished":
|
|
117
|
+
case "complete":
|
|
118
|
+
case "completed":
|
|
119
|
+
case "success":
|
|
120
|
+
case "succeeded":
|
|
121
|
+
case "done":
|
|
122
|
+
return "finished";
|
|
123
|
+
case "failed":
|
|
124
|
+
case "error":
|
|
125
|
+
return "failed";
|
|
126
|
+
case "cancelled":
|
|
127
|
+
case "canceled":
|
|
128
|
+
return "cancelled";
|
|
129
|
+
default:
|
|
130
|
+
return fallback;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runStatusForSnapshot(snapshot: SnapshotWithRunState, fallback: string) {
|
|
135
|
+
const state = snapshot.runState?.state;
|
|
136
|
+
if (typeof state === "string") {
|
|
137
|
+
return runStatusForRoot({ ...snapshot.root, props: { state } }, fallback);
|
|
138
|
+
}
|
|
139
|
+
return runStatusForRoot(snapshot.root, fallback);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sleep(ms: number, signal: AbortSignal) {
|
|
143
|
+
return new Promise<void>((resolve) => {
|
|
144
|
+
if (signal.aborted) {
|
|
145
|
+
resolve();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
signal.removeEventListener("abort", onAbort);
|
|
150
|
+
resolve();
|
|
151
|
+
}, ms);
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class ReconnectBackoff {
|
|
161
|
+
attempt = 0;
|
|
162
|
+
readonly initialDelayMs = 1_000;
|
|
163
|
+
readonly maxDelayMs = 30_000;
|
|
164
|
+
|
|
165
|
+
currentDelayMs() {
|
|
166
|
+
if (this.attempt <= 0) {
|
|
167
|
+
return this.initialDelayMs;
|
|
168
|
+
}
|
|
169
|
+
return Math.min(this.initialDelayMs * 2 ** (this.attempt - 1), this.maxDelayMs);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
recordFailure() {
|
|
173
|
+
this.attempt += 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
reset() {
|
|
177
|
+
this.attempt = 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class DevToolsStore {
|
|
182
|
+
readonly client: DevToolsClient;
|
|
183
|
+
readonly ghostNodeCap: number;
|
|
184
|
+
readonly staleBannerDelayMs: number;
|
|
185
|
+
tree: DevToolsNode | undefined;
|
|
186
|
+
seq = 0;
|
|
187
|
+
lastEventAt: Date | undefined;
|
|
188
|
+
selectedNodeId: number | undefined;
|
|
189
|
+
isGhost = false;
|
|
190
|
+
connectionState: ConnectionState = { kind: "disconnected" };
|
|
191
|
+
staleSince: Date | undefined;
|
|
192
|
+
isStaleBannerVisible = false;
|
|
193
|
+
ghostNodes = new Map<string, GhostNodeRecord>();
|
|
194
|
+
mode: LiveRunDevToolsMode = { kind: "live" };
|
|
195
|
+
latestFrameNo = 0;
|
|
196
|
+
scrubError: Error | undefined;
|
|
197
|
+
rewindError: Error | undefined;
|
|
198
|
+
rewindInFlight = false;
|
|
199
|
+
runningNodeCount = 0;
|
|
200
|
+
runningNodeIds = new Set<string>();
|
|
201
|
+
eventsApplied = 0;
|
|
202
|
+
reconnectCount = 0;
|
|
203
|
+
decodeErrorCount = 0;
|
|
204
|
+
runSupportsRetry = true;
|
|
205
|
+
runStatus = "unknown";
|
|
206
|
+
runStateView: RunStateView | undefined;
|
|
207
|
+
lastToastMessage: string | undefined;
|
|
208
|
+
lastAuditRowId: string | undefined;
|
|
209
|
+
runId: string | undefined;
|
|
210
|
+
bufferedLiveEvents = 0;
|
|
211
|
+
|
|
212
|
+
private readonly listeners = new Set<StoreListener>();
|
|
213
|
+
private readonly toastSink: (message: string) => void;
|
|
214
|
+
private readonly backoff = new ReconnectBackoff();
|
|
215
|
+
private streamAbort: AbortController | undefined;
|
|
216
|
+
private staleBannerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
private shouldReconnect = false;
|
|
218
|
+
private stateRunId: string | undefined;
|
|
219
|
+
private selectedNodeGhostKey: string | undefined;
|
|
220
|
+
private readonly lastSeqSeenByRunId = new Map<string, number>();
|
|
221
|
+
private readonly mountedFrameByGhostKey = new Map<string, number>();
|
|
222
|
+
private readonly ghostEvictionOrder: string[] = [];
|
|
223
|
+
private liveSnapshot: SnapshotWithRunState | undefined;
|
|
224
|
+
private liveLatestFrameNo = 0;
|
|
225
|
+
private awaitingSnapshotAfterGapResync = false;
|
|
226
|
+
|
|
227
|
+
constructor(options: StoreOptions = {}) {
|
|
228
|
+
this.client = options.client ?? new DevToolsClient();
|
|
229
|
+
this.ghostNodeCap = Math.max(1, options.ghostNodeCap ?? this.resolvedGhostNodeCap());
|
|
230
|
+
this.staleBannerDelayMs = Math.max(1, options.staleBannerDelayMs ?? DEFAULT_STALE_BANNER_DELAY_MS);
|
|
231
|
+
this.toastSink = options.toastSink ?? (() => undefined);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get heartbeatAgeMs() {
|
|
235
|
+
if (!this.lastEventAt) {
|
|
236
|
+
return Number.MAX_SAFE_INTEGER;
|
|
237
|
+
}
|
|
238
|
+
return Date.now() - this.lastEventAt.getTime();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get selectedNode() {
|
|
242
|
+
if (this.selectedNodeId !== undefined) {
|
|
243
|
+
const active = findNode(this.tree, this.selectedNodeId);
|
|
244
|
+
if (active) {
|
|
245
|
+
return active;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (this.isGhost && this.selectedNodeGhostKey) {
|
|
249
|
+
return this.ghostNodes.get(this.selectedNodeGhostKey)?.node;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
get selectedGhostRecord() {
|
|
255
|
+
if (!this.isGhost || !this.selectedNodeGhostKey) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
return this.ghostNodes.get(this.selectedNodeGhostKey);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
get displayedFrameNo() {
|
|
262
|
+
return this.mode.kind === "historical" ? this.mode.frameNo : this.latestFrameNo;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get isRunFinished() {
|
|
266
|
+
return this.runStatus === "finished" || this.runStatus === "failed" || this.runStatus === "cancelled";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get isRewindEligible() {
|
|
270
|
+
return this.mode.kind === "historical" && !this.isRunFinished && !this.rewindInFlight;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
subscribe(listener: StoreListener) {
|
|
274
|
+
this.listeners.add(listener);
|
|
275
|
+
return () => this.listeners.delete(listener);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
connect(runId: string) {
|
|
279
|
+
this.streamAbort?.abort();
|
|
280
|
+
this.clearStaleBannerTimer();
|
|
281
|
+
|
|
282
|
+
const preservingExistingRunState = this.stateRunId === runId;
|
|
283
|
+
this.runId = runId;
|
|
284
|
+
this.shouldReconnect = true;
|
|
285
|
+
this.connectionState = { kind: "connecting" };
|
|
286
|
+
this.backoff.reset();
|
|
287
|
+
this.mode = { kind: "live" };
|
|
288
|
+
this.scrubError = undefined;
|
|
289
|
+
this.rewindError = undefined;
|
|
290
|
+
this.rewindInFlight = false;
|
|
291
|
+
this.bufferedLiveEvents = 0;
|
|
292
|
+
this.staleSince = undefined;
|
|
293
|
+
this.isStaleBannerVisible = false;
|
|
294
|
+
this.awaitingSnapshotAfterGapResync = false;
|
|
295
|
+
this.lastToastMessage = undefined;
|
|
296
|
+
this.lastAuditRowId = undefined;
|
|
297
|
+
|
|
298
|
+
if (!preservingExistingRunState) {
|
|
299
|
+
this.resetForNewRun(runId);
|
|
300
|
+
} else {
|
|
301
|
+
this.syncDisplayedTreeWithLive();
|
|
302
|
+
this.updateGhostState();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.startStream(runId, preservingExistingRunState ? this.lastSeenSeq(runId) : undefined);
|
|
306
|
+
this.emit();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
disconnect() {
|
|
310
|
+
this.shouldReconnect = false;
|
|
311
|
+
this.streamAbort?.abort();
|
|
312
|
+
this.streamAbort = undefined;
|
|
313
|
+
this.clearStaleBannerTimer();
|
|
314
|
+
this.connectionState = { kind: "disconnected" };
|
|
315
|
+
this.staleSince = undefined;
|
|
316
|
+
this.isStaleBannerVisible = false;
|
|
317
|
+
this.runId = undefined;
|
|
318
|
+
this.emit();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
applyEvent(event: DevToolsRuntimeEvent) {
|
|
322
|
+
switch (event.kind) {
|
|
323
|
+
case "snapshot":
|
|
324
|
+
this.applySnapshot(event.snapshot);
|
|
325
|
+
break;
|
|
326
|
+
case "delta":
|
|
327
|
+
this.applyDeltaEvent(event.delta);
|
|
328
|
+
break;
|
|
329
|
+
case "gapResync":
|
|
330
|
+
this.applyGapResync(event.gapResync);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.lastEventAt = new Date();
|
|
335
|
+
this.eventsApplied += 1;
|
|
336
|
+
this.markStreamHealthy();
|
|
337
|
+
|
|
338
|
+
if (this.mode.kind === "historical") {
|
|
339
|
+
this.bufferedLiveEvents += 1;
|
|
340
|
+
} else {
|
|
341
|
+
this.updateGhostState();
|
|
342
|
+
}
|
|
343
|
+
this.emit();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
applyGapResync(gapResync: DevToolsGapResync) {
|
|
347
|
+
const preservedTree = this.tree ? cloneNode(this.tree) : undefined;
|
|
348
|
+
const preservedSeq = this.seq;
|
|
349
|
+
this.liveSnapshot = undefined;
|
|
350
|
+
this.awaitingSnapshotAfterGapResync = true;
|
|
351
|
+
this.lastSeqSeenByRunId.set(this.runId ?? "", gapResync.toSeq);
|
|
352
|
+
|
|
353
|
+
if (this.mode.kind === "live") {
|
|
354
|
+
this.tree = preservedTree;
|
|
355
|
+
this.seq = preservedSeq;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
applySnapshot(snapshot: SnapshotWithRunState) {
|
|
360
|
+
if (!this.applySnapshotToLiveState(snapshot)) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.latestFrameNo = this.liveLatestFrameNo;
|
|
364
|
+
if (this.mode.kind === "live") {
|
|
365
|
+
this.syncDisplayedTreeWithLive();
|
|
366
|
+
this.updateGhostState();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
applyDeltaEvent(delta: DevToolsDelta) {
|
|
371
|
+
if (!this.applyDeltaToLiveState(delta)) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.latestFrameNo = Math.max(this.latestFrameNo, this.liveLatestFrameNo);
|
|
375
|
+
if (this.mode.kind === "live") {
|
|
376
|
+
this.syncDisplayedTreeWithLive();
|
|
377
|
+
this.updateGhostState();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async scrubTo(frameNo: number) {
|
|
382
|
+
if (!this.runId) {
|
|
383
|
+
this.scrubError = new SmithersError("PI_RUN_NOT_FOUND", "Missing runId.");
|
|
384
|
+
this.emit();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const targetFrame = Math.max(0, Math.floor(frameNo));
|
|
389
|
+
if (this.latestFrameNo > 0 && targetFrame >= this.latestFrameNo) {
|
|
390
|
+
this.returnToLive();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.mode = { kind: "historical", frameNo: targetFrame };
|
|
395
|
+
try {
|
|
396
|
+
const snapshot = await this.client.getDevToolsSnapshot(this.runId, targetFrame);
|
|
397
|
+
this.tree = snapshot.root;
|
|
398
|
+
this.seq = snapshot.seq;
|
|
399
|
+
this.mode = { kind: "historical", frameNo: snapshot.frameNo };
|
|
400
|
+
this.scrubError = undefined;
|
|
401
|
+
this.refreshRunningState();
|
|
402
|
+
this.updateGhostState();
|
|
403
|
+
} catch (error) {
|
|
404
|
+
this.scrubError = error instanceof Error ? error : new Error(String(error));
|
|
405
|
+
}
|
|
406
|
+
this.emit();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
returnToLive() {
|
|
410
|
+
if (this.mode.kind !== "historical") {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
this.mode = { kind: "live" };
|
|
414
|
+
this.scrubError = undefined;
|
|
415
|
+
this.bufferedLiveEvents = 0;
|
|
416
|
+
this.syncDisplayedTreeWithLive();
|
|
417
|
+
this.updateGhostState();
|
|
418
|
+
if (this.runId && this.shouldReconnect) {
|
|
419
|
+
this.requestResync(this.runId);
|
|
420
|
+
}
|
|
421
|
+
this.emit();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async rewind(frameNo: number, confirm = false) {
|
|
425
|
+
if (!confirm || this.rewindInFlight) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (!this.runId) {
|
|
429
|
+
this.rewindError = new SmithersError("PI_RUN_NOT_FOUND", "Missing runId.");
|
|
430
|
+
this.emit();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (this.isRunFinished) {
|
|
434
|
+
this.rewindError = new SmithersError("PI_REWIND_FAILED", "Run is no longer live; rewind is unavailable.");
|
|
435
|
+
this.emit();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (this.mode.kind !== "historical") {
|
|
439
|
+
this.rewindError = new SmithersError("PI_CONFIRMATION_REQUIRED", "Choose a historical frame before rewinding.");
|
|
440
|
+
this.emit();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.rewindInFlight = true;
|
|
445
|
+
this.rewindError = undefined;
|
|
446
|
+
this.emit();
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const result = await this.client.rewind(this.runId, frameNo, true);
|
|
450
|
+
const snapshot = await this.client.getDevToolsSnapshot(this.runId);
|
|
451
|
+
this.applySnapshotToLiveState(snapshot);
|
|
452
|
+
this.pruneGhostNodesForRewind(frameNo);
|
|
453
|
+
this.mode = { kind: "live" };
|
|
454
|
+
this.bufferedLiveEvents = 0;
|
|
455
|
+
this.scrubError = undefined;
|
|
456
|
+
this.rewindError = undefined;
|
|
457
|
+
this.syncDisplayedTreeWithLive();
|
|
458
|
+
this.updateGhostState();
|
|
459
|
+
this.lastAuditRowId = typeof result.auditRowId === "string" ? result.auditRowId : undefined;
|
|
460
|
+
this.lastToastMessage = this.lastAuditRowId
|
|
461
|
+
? `Rewound to frame ${frameNo}. Audit: ${this.lastAuditRowId}`
|
|
462
|
+
: `Rewound to frame ${frameNo}.`;
|
|
463
|
+
this.toastSink(this.lastToastMessage);
|
|
464
|
+
this.requestResync(this.runId);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
this.rewindError = error instanceof Error ? error : new Error(String(error));
|
|
467
|
+
} finally {
|
|
468
|
+
this.rewindInFlight = false;
|
|
469
|
+
this.emit();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
clearHistoricalError() {
|
|
474
|
+
this.scrubError = undefined;
|
|
475
|
+
this.emit();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
clearRewindError() {
|
|
479
|
+
this.rewindError = undefined;
|
|
480
|
+
this.emit();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
selectNode(nodeId: number | undefined) {
|
|
484
|
+
this.selectedNodeId = nodeId;
|
|
485
|
+
if (nodeId !== undefined) {
|
|
486
|
+
const node = findNode(this.tree, nodeId);
|
|
487
|
+
if (node) {
|
|
488
|
+
this.selectedNodeGhostKey = selectionKey(node);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
this.updateGhostState();
|
|
492
|
+
this.emit();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
clearSelection() {
|
|
496
|
+
this.selectedNodeId = undefined;
|
|
497
|
+
this.selectedNodeGhostKey = undefined;
|
|
498
|
+
this.isGhost = false;
|
|
499
|
+
this.emit();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
clearHistory() {
|
|
503
|
+
this.ghostNodes.clear();
|
|
504
|
+
this.ghostEvictionOrder.length = 0;
|
|
505
|
+
this.mountedFrameByGhostKey.clear();
|
|
506
|
+
this.updateGhostState();
|
|
507
|
+
this.emit();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
isGhostNode(node: DevToolsNode) {
|
|
511
|
+
const key = ghostMapKey(node);
|
|
512
|
+
return key ? this.ghostNodes.has(key) : false;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
ghostRecord(node: DevToolsNode) {
|
|
516
|
+
const key = ghostMapKey(node);
|
|
517
|
+
return key ? this.ghostNodes.get(key) : undefined;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
retryNode(_nodeId: string) {
|
|
521
|
+
if (!this.runSupportsRetry) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private startStream(runId: string, afterSeq?: number) {
|
|
527
|
+
this.streamAbort?.abort();
|
|
528
|
+
const abort = new AbortController();
|
|
529
|
+
this.streamAbort = abort;
|
|
530
|
+
void this.consumeStream(runId, afterSeq, abort.signal);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async consumeStream(runId: string, afterSeq: number | undefined, signal: AbortSignal) {
|
|
534
|
+
let nextAfterSeq = afterSeq;
|
|
535
|
+
while (this.shouldReconnect && !signal.aborted) {
|
|
536
|
+
this.connectionState = { kind: "connecting" };
|
|
537
|
+
this.emit();
|
|
538
|
+
try {
|
|
539
|
+
for await (const event of this.client.streamDevTools(runId, nextAfterSeq, signal)) {
|
|
540
|
+
if (signal.aborted) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (this.connectionState.kind === "connecting") {
|
|
544
|
+
this.connectionState = { kind: "streaming" };
|
|
545
|
+
}
|
|
546
|
+
this.applyEvent(event);
|
|
547
|
+
}
|
|
548
|
+
if (signal.aborted || !this.shouldReconnect) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
this.markConnectionInterrupted();
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (signal.aborted || !this.shouldReconnect) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
557
|
+
if (err.message.includes("DevTools event")) {
|
|
558
|
+
this.decodeErrorCount += 1;
|
|
559
|
+
nextAfterSeq = undefined;
|
|
560
|
+
}
|
|
561
|
+
this.connectionState = { kind: "error", error: err };
|
|
562
|
+
this.markConnectionInterrupted();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.backoff.recordFailure();
|
|
566
|
+
this.reconnectCount += 1;
|
|
567
|
+
nextAfterSeq = nextAfterSeq ?? this.lastSeenSeq(runId);
|
|
568
|
+
const delayMs = this.backoff.currentDelayMs();
|
|
569
|
+
this.emit();
|
|
570
|
+
await sleep(delayMs, signal);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private requestResync(runId: string) {
|
|
575
|
+
if (!this.shouldReconnect) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
this.streamAbort?.abort();
|
|
579
|
+
this.awaitingSnapshotAfterGapResync = false;
|
|
580
|
+
this.startStream(runId, undefined);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private applySnapshotToLiveState(snapshot: SnapshotWithRunState) {
|
|
584
|
+
if (this.runId && snapshot.runId !== this.runId) {
|
|
585
|
+
this.disconnect();
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
if (snapshot.seq <= (this.liveSnapshot?.seq ?? 0) && this.liveSnapshot && !this.awaitingSnapshotAfterGapResync) {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (this.liveSnapshot) {
|
|
593
|
+
this.captureGhostNodesRemovedBySnapshot(
|
|
594
|
+
this.liveSnapshot.root,
|
|
595
|
+
snapshot.root,
|
|
596
|
+
snapshot.frameNo,
|
|
597
|
+
snapshot.seq,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.awaitingSnapshotAfterGapResync = false;
|
|
602
|
+
this.liveSnapshot = cloneSnapshot(snapshot);
|
|
603
|
+
this.liveLatestFrameNo = Math.max(this.liveLatestFrameNo, snapshot.frameNo);
|
|
604
|
+
this.runStateView = snapshot.runState;
|
|
605
|
+
this.runStatus = runStatusForSnapshot(snapshot, this.runStatus);
|
|
606
|
+
this.recordMountedFrames(snapshot.root, snapshot.frameNo);
|
|
607
|
+
this.pruneGhostNodesNowActive(snapshot.root);
|
|
608
|
+
this.stateRunId = snapshot.runId;
|
|
609
|
+
this.lastSeqSeenByRunId.set(snapshot.runId, snapshot.seq);
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private applyDeltaToLiveState(delta: DevToolsDelta) {
|
|
614
|
+
if (this.awaitingSnapshotAfterGapResync) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
if (this.liveSnapshot && delta.seq <= this.liveSnapshot.seq) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
if (this.liveSnapshot && delta.baseSeq !== this.liveSnapshot.seq) {
|
|
621
|
+
if (this.runId) {
|
|
622
|
+
this.requestResync(this.runId);
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
this.captureGhostNodesFromDelta(delta);
|
|
628
|
+
try {
|
|
629
|
+
const base = this.liveSnapshot ?? {
|
|
630
|
+
version: 1 as const,
|
|
631
|
+
runId: this.runId ?? "",
|
|
632
|
+
frameNo: delta.baseSeq,
|
|
633
|
+
seq: delta.baseSeq,
|
|
634
|
+
root: undefined as unknown as DevToolsNode,
|
|
635
|
+
};
|
|
636
|
+
this.liveSnapshot = applyDelta(base, delta) as SnapshotWithRunState;
|
|
637
|
+
this.liveLatestFrameNo = Math.max(this.liveLatestFrameNo, this.liveSnapshot.frameNo, delta.seq);
|
|
638
|
+
this.recordMountedFramesFromDelta(delta, this.liveLatestFrameNo);
|
|
639
|
+
if (this.runId) {
|
|
640
|
+
this.lastSeqSeenByRunId.set(this.runId, delta.seq);
|
|
641
|
+
}
|
|
642
|
+
this.runStatus = runStatusForRoot(this.liveSnapshot.root, this.runStatus);
|
|
643
|
+
this.pruneGhostNodesNowActive(this.liveSnapshot.root);
|
|
644
|
+
return true;
|
|
645
|
+
} catch {
|
|
646
|
+
if (this.runId) {
|
|
647
|
+
this.requestResync(this.runId);
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private syncDisplayedTreeWithLive() {
|
|
654
|
+
this.tree = this.liveSnapshot?.root;
|
|
655
|
+
this.seq = this.liveSnapshot?.seq ?? 0;
|
|
656
|
+
this.latestFrameNo = this.liveLatestFrameNo;
|
|
657
|
+
this.refreshRunningState();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private refreshRunningState() {
|
|
661
|
+
const ids = new Set<string>();
|
|
662
|
+
let count = 0;
|
|
663
|
+
const walk = (node: DevToolsNode) => {
|
|
664
|
+
if (node.type === "task" && node.children.length === 0 && normalizeState(stateString(node)) === "running") {
|
|
665
|
+
count += 1;
|
|
666
|
+
if (node.task?.nodeId) {
|
|
667
|
+
ids.add(node.task.nodeId);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const child of node.children) {
|
|
671
|
+
walk(child);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
if (this.tree) {
|
|
675
|
+
walk(this.tree);
|
|
676
|
+
}
|
|
677
|
+
this.runningNodeCount = count;
|
|
678
|
+
this.runningNodeIds = ids;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private resetForNewRun(runId: string) {
|
|
682
|
+
this.stateRunId = runId;
|
|
683
|
+
this.tree = undefined;
|
|
684
|
+
this.seq = 0;
|
|
685
|
+
this.liveSnapshot = undefined;
|
|
686
|
+
this.latestFrameNo = 0;
|
|
687
|
+
this.liveLatestFrameNo = 0;
|
|
688
|
+
this.runStatus = "unknown";
|
|
689
|
+
this.runStateView = undefined;
|
|
690
|
+
this.runningNodeCount = 0;
|
|
691
|
+
this.runningNodeIds.clear();
|
|
692
|
+
this.selectedNodeId = undefined;
|
|
693
|
+
this.selectedNodeGhostKey = undefined;
|
|
694
|
+
this.ghostNodes.clear();
|
|
695
|
+
this.ghostEvictionOrder.length = 0;
|
|
696
|
+
this.mountedFrameByGhostKey.clear();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private lastSeenSeq(runId: string) {
|
|
700
|
+
if (this.stateRunId !== runId || !this.liveSnapshot || this.liveSnapshot.seq <= 0) {
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
return Math.max(this.lastSeqSeenByRunId.get(runId) ?? 0, this.liveSnapshot.seq);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private markConnectionInterrupted() {
|
|
707
|
+
if (!this.staleSince) {
|
|
708
|
+
this.staleSince = new Date();
|
|
709
|
+
}
|
|
710
|
+
this.scheduleStaleBannerReveal();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private markStreamHealthy() {
|
|
714
|
+
this.connectionState = { kind: "streaming" };
|
|
715
|
+
this.backoff.reset();
|
|
716
|
+
this.clearStaleBannerTimer();
|
|
717
|
+
this.staleSince = undefined;
|
|
718
|
+
this.isStaleBannerVisible = false;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private scheduleStaleBannerReveal() {
|
|
722
|
+
const staleSince = this.staleSince;
|
|
723
|
+
if (!staleSince) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
this.clearStaleBannerTimer();
|
|
727
|
+
this.staleBannerTimer = setTimeout(() => {
|
|
728
|
+
if (this.staleSince?.getTime() === staleSince.getTime() && this.connectionState.kind !== "streaming") {
|
|
729
|
+
this.isStaleBannerVisible = true;
|
|
730
|
+
this.emit();
|
|
731
|
+
}
|
|
732
|
+
}, this.staleBannerDelayMs);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private clearStaleBannerTimer() {
|
|
736
|
+
if (this.staleBannerTimer) {
|
|
737
|
+
clearTimeout(this.staleBannerTimer);
|
|
738
|
+
this.staleBannerTimer = undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private updateGhostState() {
|
|
743
|
+
if (this.selectedNodeId === undefined) {
|
|
744
|
+
this.isGhost = false;
|
|
745
|
+
this.selectedNodeGhostKey = undefined;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const activeNode = findNode(this.tree, this.selectedNodeId);
|
|
749
|
+
if (activeNode) {
|
|
750
|
+
this.selectedNodeGhostKey = selectionKey(activeNode);
|
|
751
|
+
this.isGhost = false;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (this.selectedNodeGhostKey && this.ghostNodes.has(this.selectedNodeGhostKey)) {
|
|
755
|
+
this.isGhost = true;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
this.selectedNodeId = undefined;
|
|
759
|
+
this.selectedNodeGhostKey = undefined;
|
|
760
|
+
this.isGhost = false;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private recordMountedFrames(root: DevToolsNode, frameNo: number) {
|
|
764
|
+
const key = ghostMapKey(root);
|
|
765
|
+
if (key) {
|
|
766
|
+
this.mountedFrameByGhostKey.set(
|
|
767
|
+
key,
|
|
768
|
+
Math.min(this.mountedFrameByGhostKey.get(key) ?? frameNo, frameNo),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
for (const child of root.children) {
|
|
772
|
+
this.recordMountedFrames(child, frameNo);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private recordMountedFramesFromDelta(delta: DevToolsDelta, frameNo: number) {
|
|
777
|
+
for (const op of delta.ops) {
|
|
778
|
+
if (op.op === "addNode" || op.op === "replaceRoot") {
|
|
779
|
+
this.recordMountedFrames(op.node, frameNo);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private captureGhostNodesRemovedBySnapshot(
|
|
785
|
+
previousRoot: DevToolsNode,
|
|
786
|
+
nextRoot: DevToolsNode,
|
|
787
|
+
unmountedFrameNo: number,
|
|
788
|
+
unmountedSeq: number,
|
|
789
|
+
) {
|
|
790
|
+
const nextKeys = new Set<string>();
|
|
791
|
+
collectGhostKeys(nextRoot, nextKeys);
|
|
792
|
+
this.registerRemovedGhostNodes(previousRoot, nextKeys, unmountedFrameNo, unmountedSeq);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private registerRemovedGhostNodes(
|
|
796
|
+
node: DevToolsNode,
|
|
797
|
+
activeKeys: Set<string>,
|
|
798
|
+
unmountedFrameNo: number,
|
|
799
|
+
unmountedSeq: number,
|
|
800
|
+
) {
|
|
801
|
+
const key = ghostMapKey(node);
|
|
802
|
+
if (key && !activeKeys.has(key)) {
|
|
803
|
+
this.registerGhostSubtree(node, unmountedFrameNo, unmountedSeq);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
for (const child of node.children) {
|
|
807
|
+
this.registerRemovedGhostNodes(child, activeKeys, unmountedFrameNo, unmountedSeq);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private captureGhostNodesFromDelta(delta: DevToolsDelta) {
|
|
812
|
+
const root = this.liveSnapshot?.root;
|
|
813
|
+
if (!root) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const unmountedFrameNo = Math.max(this.liveLatestFrameNo, delta.seq);
|
|
817
|
+
for (const op of delta.ops) {
|
|
818
|
+
if (op.op === "removeNode") {
|
|
819
|
+
if (root.id === op.id) {
|
|
820
|
+
this.registerGhostSubtree(root, unmountedFrameNo, delta.seq);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
const removed = findNode(root, op.id);
|
|
824
|
+
if (removed) {
|
|
825
|
+
this.registerGhostSubtree(removed, unmountedFrameNo, delta.seq);
|
|
826
|
+
}
|
|
827
|
+
} else if (op.op === "replaceRoot") {
|
|
828
|
+
this.captureGhostNodesRemovedBySnapshot(root, op.node, unmountedFrameNo, delta.seq);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private registerGhostSubtree(node: DevToolsNode, unmountedFrameNo: number, unmountedSeq: number) {
|
|
834
|
+
this.registerGhostNode(node, unmountedFrameNo, unmountedSeq);
|
|
835
|
+
for (const child of node.children) {
|
|
836
|
+
this.registerGhostSubtree(child, unmountedFrameNo, unmountedSeq);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private registerGhostNode(node: DevToolsNode, unmountedFrameNo: number, unmountedSeq: number) {
|
|
841
|
+
const key = ghostMapKey(node);
|
|
842
|
+
if (!key) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const mountedFrameNo = this.mountedFrameByGhostKey.get(key) ?? unmountedFrameNo;
|
|
846
|
+
this.ghostNodes.set(key, {
|
|
847
|
+
key,
|
|
848
|
+
node: cloneNode(node),
|
|
849
|
+
mountedFrameNo,
|
|
850
|
+
unmountedFrameNo,
|
|
851
|
+
unmountedSeq,
|
|
852
|
+
capturedAtMs: Date.now(),
|
|
853
|
+
});
|
|
854
|
+
const existing = this.ghostEvictionOrder.indexOf(key);
|
|
855
|
+
if (existing >= 0) {
|
|
856
|
+
this.ghostEvictionOrder.splice(existing, 1);
|
|
857
|
+
}
|
|
858
|
+
this.ghostEvictionOrder.push(key);
|
|
859
|
+
this.enforceGhostBudget();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private enforceGhostBudget() {
|
|
863
|
+
const keysToEvict: string[] = [];
|
|
864
|
+
while (this.ghostNodes.size - keysToEvict.length > this.ghostNodeCap && this.ghostEvictionOrder.length > 0) {
|
|
865
|
+
const key = this.ghostEvictionOrder.shift();
|
|
866
|
+
if (key) {
|
|
867
|
+
keysToEvict.push(key);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
this.removeGhostRecords(keysToEvict);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private pruneGhostNodesNowActive(root: DevToolsNode | undefined) {
|
|
874
|
+
if (!root) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const activeKeys = new Set<string>();
|
|
878
|
+
collectGhostKeys(root, activeKeys);
|
|
879
|
+
this.removeGhostRecords([...this.ghostNodes.keys()].filter((key) => activeKeys.has(key)), false);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private pruneGhostNodesForRewind(targetFrameNo: number) {
|
|
883
|
+
this.removeGhostRecords(
|
|
884
|
+
[...this.ghostNodes.values()]
|
|
885
|
+
.filter((record) => record.mountedFrameNo > targetFrameNo)
|
|
886
|
+
.map((record) => record.key),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private removeGhostRecords(keys: string[], removeMountTracking = true) {
|
|
891
|
+
if (keys.length === 0) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const keySet = new Set(keys);
|
|
895
|
+
for (const key of keySet) {
|
|
896
|
+
this.ghostNodes.delete(key);
|
|
897
|
+
if (removeMountTracking) {
|
|
898
|
+
this.mountedFrameByGhostKey.delete(key);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (let index = this.ghostEvictionOrder.length - 1; index >= 0; index -= 1) {
|
|
902
|
+
if (keySet.has(this.ghostEvictionOrder[index])) {
|
|
903
|
+
this.ghostEvictionOrder.splice(index, 1);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (this.selectedNodeGhostKey && keySet.has(this.selectedNodeGhostKey)) {
|
|
907
|
+
if (this.selectedNodeId !== undefined && findNode(this.tree, this.selectedNodeId)) {
|
|
908
|
+
this.isGhost = false;
|
|
909
|
+
this.selectedNodeGhostKey = undefined;
|
|
910
|
+
} else {
|
|
911
|
+
this.clearSelection();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private resolvedGhostNodeCap() {
|
|
917
|
+
const raw = process.env.SMITHERS_DEVTOOLS_GHOST_CAP;
|
|
918
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
919
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GHOST_NODE_CAP;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private emit() {
|
|
923
|
+
for (const listener of this.listeners) {
|
|
924
|
+
listener(this);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|