gesso-devtools 0.1.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/index.d.ts +1076 -0
- package/dist/index.js +3014 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3014 @@
|
|
|
1
|
+
import { UI_FRAME_PHASES, applyPatch, formatAge, isChannelClientMessage, isChannelHostMessage, markInstant, markNow, measureSpan, performanceMarksEnabled, portHandle } from "gesso-framework";
|
|
2
|
+
//#region src/codeFrame.ts
|
|
3
|
+
/**
|
|
4
|
+
* Cuts `context` lines either side of a position out of a source file.
|
|
5
|
+
*
|
|
6
|
+
* This is the part of an overlay that a stack trace cannot replace: a
|
|
7
|
+
* file and a line number send a person to their editor, while the line
|
|
8
|
+
* itself is often the whole answer — a `.length` on something that is
|
|
9
|
+
* undefined reads as the bug the moment it is on screen.
|
|
10
|
+
*
|
|
11
|
+
* Tabs are expanded to two spaces so the caret column below the line
|
|
12
|
+
* lands where the character does. Nothing else is transformed; the text
|
|
13
|
+
* reaches the DOM as text, never as markup.
|
|
14
|
+
*/
|
|
15
|
+
function codeFrame(source, line, column, context = 2) {
|
|
16
|
+
const lines = source.split("\n");
|
|
17
|
+
if (line < 1 || line > lines.length) return null;
|
|
18
|
+
const first = Math.max(1, line - context);
|
|
19
|
+
const last = Math.min(lines.length, line + context);
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let number = first; number <= last; number++) out.push({
|
|
22
|
+
number,
|
|
23
|
+
text: expandTabs(lines[number - 1]),
|
|
24
|
+
target: number === line
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
lines: out,
|
|
28
|
+
column: expandedColumn(lines[line - 1], column)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const TAB_WIDTH = 2;
|
|
32
|
+
function expandTabs(text) {
|
|
33
|
+
return text.replace(/\t/g, " ".repeat(TAB_WIDTH));
|
|
34
|
+
}
|
|
35
|
+
/** Where a column lands once the tabs before it have been expanded. */
|
|
36
|
+
function expandedColumn(text, column) {
|
|
37
|
+
let expanded = 1;
|
|
38
|
+
for (let i = 0; i < Math.min(column - 1, text.length); i++) expanded += text[i] === " " ? TAB_WIDTH : 1;
|
|
39
|
+
return expanded;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/sourceMap.ts
|
|
43
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
44
|
+
/** Character code → 6-bit value, with -1 for anything not base64. */
|
|
45
|
+
const BASE64_VALUE = (() => {
|
|
46
|
+
const table = (/* @__PURE__ */ new Int8Array(128)).fill(-1);
|
|
47
|
+
for (let i = 0; i < 64; i++) table[BASE64.charCodeAt(i)] = i;
|
|
48
|
+
return table;
|
|
49
|
+
})();
|
|
50
|
+
/**
|
|
51
|
+
* Decodes the `mappings` string into one array of segments per
|
|
52
|
+
* generated line.
|
|
53
|
+
*
|
|
54
|
+
* Segments carrying only a generated column — a run of output with no
|
|
55
|
+
* original position, which is what a bundler emits for code it
|
|
56
|
+
* synthesised — are dropped rather than kept with nulls. A lookup that
|
|
57
|
+
* lands in one should fall back to the nearest earlier real mapping,
|
|
58
|
+
* and dropping them is how that happens without a second case.
|
|
59
|
+
*/
|
|
60
|
+
function decodeMappings(mappings) {
|
|
61
|
+
const lines = [];
|
|
62
|
+
let line = [];
|
|
63
|
+
let sourceIndex = 0;
|
|
64
|
+
let sourceLine = 0;
|
|
65
|
+
let sourceColumn = 0;
|
|
66
|
+
let generatedColumn = 0;
|
|
67
|
+
const fields = [];
|
|
68
|
+
let index = 0;
|
|
69
|
+
while (index < mappings.length) {
|
|
70
|
+
const char = mappings[index];
|
|
71
|
+
if (char === ";") {
|
|
72
|
+
lines.push(line);
|
|
73
|
+
line = [];
|
|
74
|
+
generatedColumn = 0;
|
|
75
|
+
index++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === ",") {
|
|
79
|
+
index++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
fields.length = 0;
|
|
83
|
+
while (index < mappings.length && mappings[index] !== "," && mappings[index] !== ";") {
|
|
84
|
+
let value = 0;
|
|
85
|
+
let shift = 1;
|
|
86
|
+
let digit;
|
|
87
|
+
do {
|
|
88
|
+
const code = mappings.charCodeAt(index);
|
|
89
|
+
digit = code < 128 ? BASE64_VALUE[code] : -1;
|
|
90
|
+
if (digit < 0) return lines;
|
|
91
|
+
index++;
|
|
92
|
+
value += (digit & 31) * shift;
|
|
93
|
+
shift *= 32;
|
|
94
|
+
} while ((digit & 32) !== 0);
|
|
95
|
+
fields.push(value % 2 === 1 ? -Math.floor(value / 2) : Math.floor(value / 2));
|
|
96
|
+
}
|
|
97
|
+
generatedColumn += fields[0];
|
|
98
|
+
if (fields.length >= 4) {
|
|
99
|
+
sourceIndex += fields[1];
|
|
100
|
+
sourceLine += fields[2];
|
|
101
|
+
sourceColumn += fields[3];
|
|
102
|
+
line.push([
|
|
103
|
+
generatedColumn,
|
|
104
|
+
sourceIndex,
|
|
105
|
+
sourceLine,
|
|
106
|
+
sourceColumn
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push(line);
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A decoded map, answering "which line of which source is this?".
|
|
115
|
+
*
|
|
116
|
+
* Decoding is done once in the constructor because a page that shows
|
|
117
|
+
* one error usually shows several from the same file, and each of them
|
|
118
|
+
* asks about a handful of positions.
|
|
119
|
+
*/
|
|
120
|
+
var SourceMapConsumer = class {
|
|
121
|
+
lines;
|
|
122
|
+
sources;
|
|
123
|
+
contents;
|
|
124
|
+
constructor(map) {
|
|
125
|
+
this.lines = decodeMappings(map.mappings);
|
|
126
|
+
const root = map.sourceRoot ?? "";
|
|
127
|
+
this.sources = map.sources.map((source) => joinSourceRoot(root, source ?? "<unknown>"));
|
|
128
|
+
this.contents = map.sourcesContent ?? [];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Maps a generated position — 1-based line and column, as every
|
|
132
|
+
* engine writes them in a stack — back to an original one.
|
|
133
|
+
*
|
|
134
|
+
* Returns the last mapping at or before the column, which is the
|
|
135
|
+
* definition of a source map's coverage: a mapping holds until the
|
|
136
|
+
* next one starts. Null when the line has no mappings at all.
|
|
137
|
+
*/
|
|
138
|
+
lookup(line, column) {
|
|
139
|
+
const segments = this.lines[line - 1];
|
|
140
|
+
if (segments === void 0 || segments.length === 0) return null;
|
|
141
|
+
const target = column - 1;
|
|
142
|
+
let low = 0;
|
|
143
|
+
let high = segments.length - 1;
|
|
144
|
+
let found = -1;
|
|
145
|
+
while (low <= high) {
|
|
146
|
+
const middle = low + high >> 1;
|
|
147
|
+
if (segments[middle][0] <= target) {
|
|
148
|
+
found = middle;
|
|
149
|
+
low = middle + 1;
|
|
150
|
+
} else high = middle - 1;
|
|
151
|
+
}
|
|
152
|
+
const segment = segments[found === -1 ? 0 : found];
|
|
153
|
+
const source = this.sources[segment[1]];
|
|
154
|
+
if (source === void 0) return null;
|
|
155
|
+
return {
|
|
156
|
+
source,
|
|
157
|
+
line: segment[2] + 1,
|
|
158
|
+
column: segment[3] + 1,
|
|
159
|
+
content: this.contents[segment[1]] ?? null
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** The original text of a source, when the map inlined it. */
|
|
163
|
+
contentFor(source) {
|
|
164
|
+
const index = this.sources.indexOf(source);
|
|
165
|
+
if (index === -1) return null;
|
|
166
|
+
return this.contents[index] ?? null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* The `sourceMappingURL` a script declares, or null.
|
|
171
|
+
*
|
|
172
|
+
* The last one wins, and the search is a `lastIndexOf` over the whole
|
|
173
|
+
* text rather than a regex over the tail: an inlined map is a single
|
|
174
|
+
* comment megabytes long, so "near the end" is not where its opening
|
|
175
|
+
* is.
|
|
176
|
+
*/
|
|
177
|
+
function parseSourceMappingUrl(script) {
|
|
178
|
+
const start = script.lastIndexOf("//# sourceMappingURL=");
|
|
179
|
+
if (start === -1) return null;
|
|
180
|
+
const from = start + 21;
|
|
181
|
+
const end = script.indexOf("\n", from);
|
|
182
|
+
const url = (end === -1 ? script.slice(from) : script.slice(from, end)).trim();
|
|
183
|
+
return url === "" ? null : url;
|
|
184
|
+
}
|
|
185
|
+
/** Reads a `data:` URL's payload, base64 or percent-encoded. */
|
|
186
|
+
function decodeDataUrl(url) {
|
|
187
|
+
const comma = url.indexOf(",");
|
|
188
|
+
if (comma === -1) return null;
|
|
189
|
+
const meta = url.slice(0, comma);
|
|
190
|
+
const payload = url.slice(comma + 1);
|
|
191
|
+
if (!meta.includes(";base64")) return decodeURIComponent(payload);
|
|
192
|
+
const binary = atob(payload);
|
|
193
|
+
const bytes = new Uint8Array(binary.length);
|
|
194
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
195
|
+
return new TextDecoder().decode(bytes);
|
|
196
|
+
}
|
|
197
|
+
/** Applies `sourceRoot`, which is a prefix and not a URL join. */
|
|
198
|
+
function joinSourceRoot(root, source) {
|
|
199
|
+
if (root === "" || /^[a-z]+:\/\//i.test(source)) return source;
|
|
200
|
+
return root.endsWith("/") ? `${root}${source}` : `${root}/${source}`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Fetches and caches the map for each script a stack mentions.
|
|
204
|
+
*
|
|
205
|
+
* Every method resolves rather than rejects: this runs while
|
|
206
|
+
* something has already gone wrong, and an overlay that throws while
|
|
207
|
+
* explaining a throw is worse than an overlay showing a raw stack.
|
|
208
|
+
* A script with no map, a map that 404s and a map that is not JSON all
|
|
209
|
+
* come back as null, and the caller shows what the engine gave it.
|
|
210
|
+
*/
|
|
211
|
+
var SourceMapStore = class {
|
|
212
|
+
cache = /* @__PURE__ */ new Map();
|
|
213
|
+
load;
|
|
214
|
+
constructor(load = defaultLoad) {
|
|
215
|
+
this.load = load;
|
|
216
|
+
}
|
|
217
|
+
/** The consumer for a script URL, fetched at most once per store. */
|
|
218
|
+
consumerFor(scriptUrl) {
|
|
219
|
+
const cached = this.cache.get(scriptUrl);
|
|
220
|
+
if (cached !== void 0) return cached;
|
|
221
|
+
const pending = this.resolve(scriptUrl).catch(() => null);
|
|
222
|
+
this.cache.set(scriptUrl, pending);
|
|
223
|
+
return pending;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Where a position in a script was written, following the chain of
|
|
227
|
+
* maps as far as it goes.
|
|
228
|
+
*
|
|
229
|
+
* One lookup is not enough, and the reason took a browser to find. A
|
|
230
|
+
* frame inside `gesso-framework` names Vite's optimised dependency
|
|
231
|
+
* bundle; that bundle's map points at the package's own
|
|
232
|
+
* `dist/index.js`, because the optimiser does not chain to the map
|
|
233
|
+
* the package ships; and it is the package's map that knows about
|
|
234
|
+
* `src/app/worker/RenderWorkerApp.ts`. Stopping after one step gave
|
|
235
|
+
* a frame in a bundled file with a five-figure line number, which is
|
|
236
|
+
* exactly the thing shipping the maps was meant to prevent.
|
|
237
|
+
*
|
|
238
|
+
* So each answer is resolved against the map that gave it and asked
|
|
239
|
+
* again, until a source has no map of its own — which is the source
|
|
240
|
+
* somebody wrote. `depth` is a guard against a map that names
|
|
241
|
+
* itself; four is more levels than any real toolchain stacks.
|
|
242
|
+
*/
|
|
243
|
+
async originalFor(scriptUrl, line, column, depth = 4) {
|
|
244
|
+
let url = scriptUrl;
|
|
245
|
+
let position = null;
|
|
246
|
+
for (let step = 0; step < depth; step++) {
|
|
247
|
+
const next = (await this.consumerFor(url))?.lookup(line, column) ?? null;
|
|
248
|
+
if (next === null) return position;
|
|
249
|
+
const resolved = absolute(next.source, url);
|
|
250
|
+
position = {
|
|
251
|
+
...next,
|
|
252
|
+
source: resolved
|
|
253
|
+
};
|
|
254
|
+
if (resolved === url) return position;
|
|
255
|
+
url = resolved;
|
|
256
|
+
line = next.line;
|
|
257
|
+
column = next.column;
|
|
258
|
+
}
|
|
259
|
+
return position;
|
|
260
|
+
}
|
|
261
|
+
async resolve(scriptUrl) {
|
|
262
|
+
const mapUrl = parseSourceMappingUrl(await this.load(scriptUrl));
|
|
263
|
+
if (mapUrl === null) return null;
|
|
264
|
+
const json = mapUrl.startsWith("data:") ? decodeDataUrl(mapUrl) : await this.load(new URL(mapUrl, scriptUrl).toString());
|
|
265
|
+
if (json === null) return null;
|
|
266
|
+
const map = JSON.parse(json);
|
|
267
|
+
if (typeof map.mappings !== "string" || !Array.isArray(map.sources)) return null;
|
|
268
|
+
return new SourceMapConsumer(map);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* A source name resolved against the script whose map named it.
|
|
273
|
+
*
|
|
274
|
+
* A map's `sources` are relative to the map, and the map is beside the
|
|
275
|
+
* script often enough that the script is the right base. When it is
|
|
276
|
+
* not a URL at all — a webpack `webpack://` name, an absolute path —
|
|
277
|
+
* `new URL` throws and the name is kept as it was, which still reads
|
|
278
|
+
* correctly and simply ends the chain.
|
|
279
|
+
*/
|
|
280
|
+
function absolute(source, base) {
|
|
281
|
+
try {
|
|
282
|
+
return new URL(source, base).toString();
|
|
283
|
+
} catch {
|
|
284
|
+
return source;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function defaultLoad(url) {
|
|
288
|
+
const response = await fetch(url);
|
|
289
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
290
|
+
return response.text();
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/stackTrace.ts
|
|
294
|
+
/**
|
|
295
|
+
* `at fn (url:line:col)`, `at url:line:col`, and the `fn@url:line:col`
|
|
296
|
+
* that Firefox and Safari write.
|
|
297
|
+
*
|
|
298
|
+
* Three engines write three formats and there is no standard, so this
|
|
299
|
+
* is deliberately loose: anything ending in `:digits:digits` is a
|
|
300
|
+
* location, and whatever came before it is a name. A line that does
|
|
301
|
+
* not match is kept as `raw` and shown unparsed rather than dropped —
|
|
302
|
+
* a `[native code]` or `eval` frame is still evidence.
|
|
303
|
+
*/
|
|
304
|
+
const CHROME_FRAME = /^\s*at\s+(?:(?<fn>.*?)\s+\()?(?<url>.+?):(?<line>\d+):(?<column>\d+)\)?\s*$/;
|
|
305
|
+
const MOZILLA_FRAME = /^(?<fn>[^@]*)@(?<url>.+?):(?<line>\d+):(?<column>\d+)\s*$/;
|
|
306
|
+
/**
|
|
307
|
+
* Splits a stack string into frames, dropping the message lines an
|
|
308
|
+
* engine puts above them.
|
|
309
|
+
*
|
|
310
|
+
* The message is dropped rather than parsed because the caller already
|
|
311
|
+
* has it: `onError` reports the message and the stack separately, and
|
|
312
|
+
* a V8 stack repeats the message in its first line.
|
|
313
|
+
*/
|
|
314
|
+
function parseStack(stack) {
|
|
315
|
+
const frames = [];
|
|
316
|
+
for (const raw of stack.split("\n")) {
|
|
317
|
+
const line = raw.trimEnd();
|
|
318
|
+
if (line.trim() === "") continue;
|
|
319
|
+
const match = CHROME_FRAME.exec(line) ?? MOZILLA_FRAME.exec(line);
|
|
320
|
+
if (match?.groups === void 0) {
|
|
321
|
+
if (frames.length > 0) frames.push({
|
|
322
|
+
raw: line.trim(),
|
|
323
|
+
fn: null,
|
|
324
|
+
location: null,
|
|
325
|
+
original: null
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const { fn, url, line: lineNumber, column } = match.groups;
|
|
330
|
+
const name = fn === void 0 ? "" : fn.trim();
|
|
331
|
+
frames.push({
|
|
332
|
+
raw: line.trim(),
|
|
333
|
+
fn: name === "" || name === "Object.<anonymous>" ? null : name,
|
|
334
|
+
location: {
|
|
335
|
+
url,
|
|
336
|
+
line: Number(lineNumber),
|
|
337
|
+
column: Number(column)
|
|
338
|
+
},
|
|
339
|
+
original: null
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return frames;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Fills in `original` for every frame whose script has a source map.
|
|
346
|
+
*
|
|
347
|
+
* Maps are fetched once per script and the frames of one stack usually
|
|
348
|
+
* name two or three scripts, so this is a couple of requests. It never
|
|
349
|
+
* rejects: a frame that cannot be mapped keeps its compiled location,
|
|
350
|
+
* which is what the console would have shown anyway.
|
|
351
|
+
*
|
|
352
|
+
* `originalFor` rather than one `lookup`, because a frame inside a
|
|
353
|
+
* published package is two maps deep: the bundler's map of the
|
|
354
|
+
* dependency bundle, then the package's own. See its comment.
|
|
355
|
+
*/
|
|
356
|
+
async function mapStack(frames, store) {
|
|
357
|
+
return Promise.all(frames.map(async (frame) => {
|
|
358
|
+
if (frame.location === null) return frame;
|
|
359
|
+
const original = await store.originalFor(frame.location.url, frame.location.line, frame.location.column);
|
|
360
|
+
return original === null ? frame : {
|
|
361
|
+
...frame,
|
|
362
|
+
original
|
|
363
|
+
};
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The first frame worth putting a code frame under.
|
|
368
|
+
*
|
|
369
|
+
* Frames inside the framework are skipped while any application frame
|
|
370
|
+
* remains, because an error thrown from a component surfaces through
|
|
371
|
+
* several layers of runtime and the runtime is almost never where the
|
|
372
|
+
* bug is. If every frame is a framework frame, the first one wins —
|
|
373
|
+
* the framework is then genuinely the answer.
|
|
374
|
+
*/
|
|
375
|
+
function primaryFrame(frames) {
|
|
376
|
+
const located = frames.filter((frame) => frame.location !== null);
|
|
377
|
+
if (located.length === 0) return null;
|
|
378
|
+
return located.find((frame) => {
|
|
379
|
+
const path = frame.original?.source ?? frame.location?.url ?? "";
|
|
380
|
+
return !/(^|\/)(node_modules|packages\/(core|framework|components))\//.test(path);
|
|
381
|
+
}) ?? located[0];
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* A path short enough to read in a header: same-origin prefix and
|
|
385
|
+
* query string removed, `node_modules` collapsed to the package.
|
|
386
|
+
*
|
|
387
|
+
* The query matters more than it looks. A dev server rewrites imports
|
|
388
|
+
* with cache-busting parameters — `/src/App.tsx?t=1724965201` — and a
|
|
389
|
+
* stack full of those is unreadable for a reason that has nothing to
|
|
390
|
+
* do with the error.
|
|
391
|
+
*/
|
|
392
|
+
function shortenPath(url, origin) {
|
|
393
|
+
let path = url;
|
|
394
|
+
if (origin !== void 0 && path.startsWith(origin)) path = path.slice(origin.length);
|
|
395
|
+
const query = path.indexOf("?");
|
|
396
|
+
if (query !== -1) path = path.slice(0, query);
|
|
397
|
+
const modules = path.lastIndexOf("/node_modules/");
|
|
398
|
+
if (modules !== -1) path = path.slice(modules + 14);
|
|
399
|
+
return path.replace(/^\/@fs/, "").replace(/^\.\//, "");
|
|
400
|
+
}
|
|
401
|
+
/** `path:line:column` for a frame, mapped when it could be. */
|
|
402
|
+
function formatFrame(frame, origin) {
|
|
403
|
+
if (frame.original !== null) return `${shortenPath(frame.original.source, origin)}:${frame.original.line}:${frame.original.column}`;
|
|
404
|
+
if (frame.location !== null) return `${shortenPath(frame.location.url, origin)}:${frame.location.line}:${frame.location.column}`;
|
|
405
|
+
return frame.raw;
|
|
406
|
+
}
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/ErrorOverlay.ts
|
|
409
|
+
/** What each origin is called, and the sentence under the message. */
|
|
410
|
+
const ORIGINS = {
|
|
411
|
+
uncaught: {
|
|
412
|
+
label: "render worker",
|
|
413
|
+
note: "Nothing caught this, so it ran outside any message from the shell: module scope, a callback the shell did not drive, or a frame the clock paced itself. If it was a frame, its work was already taken off the dirty set, so what you see may be stale."
|
|
414
|
+
},
|
|
415
|
+
message: {
|
|
416
|
+
label: "render worker",
|
|
417
|
+
note: "Thrown while the runtime was handling a message from the shell. A frame usually arrives that way, so this is most often a frame that did not finish, and what you see may be stale. Where it was an input or a resize instead, that one message was dropped."
|
|
418
|
+
},
|
|
419
|
+
renderer: {
|
|
420
|
+
label: "renderer",
|
|
421
|
+
note: "The backend refused to draw. Layout and state are unaffected; the surface is not being updated."
|
|
422
|
+
},
|
|
423
|
+
listener: {
|
|
424
|
+
label: "event listener",
|
|
425
|
+
note: "The dispatcher caught this, so the event still reached the rest of the tree and the application is running. What did not happen is whatever this handler was for."
|
|
426
|
+
},
|
|
427
|
+
channel: {
|
|
428
|
+
label: "channel",
|
|
429
|
+
note: "A channel’s worker or its patch stream threw. The view is intact; the data behind it has stopped arriving."
|
|
430
|
+
},
|
|
431
|
+
window: {
|
|
432
|
+
label: "main thread",
|
|
433
|
+
note: ""
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* The error overlay: what a worker threw, drawn over the app that was
|
|
438
|
+
* running when it threw.
|
|
439
|
+
*
|
|
440
|
+
* A canvas UI has no equivalent of a page that stops rendering. When a
|
|
441
|
+
* render worker throws, the last good frame stays on screen — pixels
|
|
442
|
+
* that look exactly like a working application — and the only witness
|
|
443
|
+
* is a console message on a thread the developer has to know to
|
|
444
|
+
* select. `WorkerApp` already forwards those errors to `onError`;
|
|
445
|
+
* this is that callback, with the stack put back through the source
|
|
446
|
+
* maps and the offending line quoted.
|
|
447
|
+
*
|
|
448
|
+
* It is a development tool and it makes a development tool's trade:
|
|
449
|
+
* it fetches source maps, keeps every distinct error of the session,
|
|
450
|
+
* and covers the application it is reporting on.
|
|
451
|
+
*/
|
|
452
|
+
var ErrorOverlay = class {
|
|
453
|
+
host;
|
|
454
|
+
/**
|
|
455
|
+
* The document the host belongs to, rather than the global one.
|
|
456
|
+
*
|
|
457
|
+
* The same rule `SemanticsMirror` follows: everything this class
|
|
458
|
+
* builds hangs off the element it was handed, so it works in a
|
|
459
|
+
* second window and can be driven by a fake document in a spec —
|
|
460
|
+
* which is the only way to test it in a suite that runs in Node.
|
|
461
|
+
*/
|
|
462
|
+
doc;
|
|
463
|
+
view;
|
|
464
|
+
container;
|
|
465
|
+
root;
|
|
466
|
+
panel;
|
|
467
|
+
maps;
|
|
468
|
+
echo;
|
|
469
|
+
entries = [];
|
|
470
|
+
restoreHostPosition;
|
|
471
|
+
shown = -1;
|
|
472
|
+
disposed = false;
|
|
473
|
+
detachWindow = null;
|
|
474
|
+
constructor(host, options = {}) {
|
|
475
|
+
this.host = host;
|
|
476
|
+
this.doc = host.ownerDocument;
|
|
477
|
+
this.view = this.doc.defaultView;
|
|
478
|
+
this.maps = options.sourceMaps ?? new SourceMapStore();
|
|
479
|
+
this.echo = options.echoToConsole !== false;
|
|
480
|
+
const position = this.view?.getComputedStyle(host).position ?? "static";
|
|
481
|
+
this.restoreHostPosition = position === "static" ? host.style.position : null;
|
|
482
|
+
if (position === "static") host.style.position = "relative";
|
|
483
|
+
this.container = this.doc.createElement("div");
|
|
484
|
+
this.container.style.display = "none";
|
|
485
|
+
this.root = this.container.attachShadow({ mode: "open" });
|
|
486
|
+
const style = this.doc.createElement("style");
|
|
487
|
+
style.textContent = STYLES$4;
|
|
488
|
+
this.panel = this.doc.createElement("div");
|
|
489
|
+
this.panel.className = "panel";
|
|
490
|
+
this.panel.tabIndex = -1;
|
|
491
|
+
this.panel.setAttribute("role", "alert");
|
|
492
|
+
const scrim = this.doc.createElement("div");
|
|
493
|
+
scrim.className = "scrim";
|
|
494
|
+
scrim.appendChild(this.panel);
|
|
495
|
+
this.root.append(style, scrim);
|
|
496
|
+
host.appendChild(this.container);
|
|
497
|
+
}
|
|
498
|
+
/** How many distinct errors have been reported. */
|
|
499
|
+
get count() {
|
|
500
|
+
return this.entries.length;
|
|
501
|
+
}
|
|
502
|
+
/** True while the overlay is covering the app. */
|
|
503
|
+
get visible() {
|
|
504
|
+
return this.container.style.display !== "none";
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Reports an error, in the shape `WorkerApp`'s `onError` hands it
|
|
508
|
+
* over, so the whole wiring is `onError: overlay.report`.
|
|
509
|
+
*
|
|
510
|
+
* Bound as a field rather than a method for exactly that: it is
|
|
511
|
+
* passed as a callback far more often than it is called.
|
|
512
|
+
*/
|
|
513
|
+
report = (message, stack, origin = "window") => {
|
|
514
|
+
if (this.disposed) return;
|
|
515
|
+
if (this.echo) console.error(`[gesso: ${origin}] ${message}`, stack ?? "");
|
|
516
|
+
const existing = this.entries.find((entry) => entry.message === message && entry.stack === stack);
|
|
517
|
+
if (existing !== void 0) {
|
|
518
|
+
existing.repeats++;
|
|
519
|
+
if (this.visible && this.entries[this.shown] === existing) this.render();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const entry = {
|
|
523
|
+
message,
|
|
524
|
+
stack,
|
|
525
|
+
origin,
|
|
526
|
+
repeats: 1,
|
|
527
|
+
frames: stack === void 0 ? [] : parseStack(stack),
|
|
528
|
+
mapped: false,
|
|
529
|
+
code: null
|
|
530
|
+
};
|
|
531
|
+
this.entries.push(entry);
|
|
532
|
+
this.shown = this.entries.length - 1;
|
|
533
|
+
this.show();
|
|
534
|
+
this.resolveSources(entry);
|
|
535
|
+
};
|
|
536
|
+
/** Reports a thrown value, which is usually but not always an Error. */
|
|
537
|
+
reportError = (error, origin = "window") => {
|
|
538
|
+
if (error instanceof Error) {
|
|
539
|
+
this.report(error.message, error.stack, origin);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.report(String(error), void 0, origin);
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
545
|
+
* Catches what this thread throws, too.
|
|
546
|
+
*
|
|
547
|
+
* The single-thread configuration runs components here, and even in
|
|
548
|
+
* the worker configuration the shell around the app can throw. Returns
|
|
549
|
+
* a function that stops listening; `dispose` calls it as well.
|
|
550
|
+
*/
|
|
551
|
+
captureWindowErrors(target = this.view) {
|
|
552
|
+
if (target === null) return () => {};
|
|
553
|
+
const onError = (event) => {
|
|
554
|
+
this.reportError(event.error ?? event.message, "window");
|
|
555
|
+
};
|
|
556
|
+
const onRejection = (event) => {
|
|
557
|
+
this.reportError(event.reason, "window");
|
|
558
|
+
};
|
|
559
|
+
target.addEventListener("error", onError);
|
|
560
|
+
target.addEventListener("unhandledrejection", onRejection);
|
|
561
|
+
const detach = () => {
|
|
562
|
+
target.removeEventListener("error", onError);
|
|
563
|
+
target.removeEventListener("unhandledrejection", onRejection);
|
|
564
|
+
this.detachWindow = null;
|
|
565
|
+
};
|
|
566
|
+
this.detachWindow = detach;
|
|
567
|
+
return detach;
|
|
568
|
+
}
|
|
569
|
+
/** Hides the overlay. The errors are kept and can be shown again. */
|
|
570
|
+
hide() {
|
|
571
|
+
this.container.style.display = "none";
|
|
572
|
+
this.doc.removeEventListener("keydown", this.handleKeyDown, true);
|
|
573
|
+
}
|
|
574
|
+
/** Hides the overlay and forgets every error it was holding. */
|
|
575
|
+
clear() {
|
|
576
|
+
this.entries.length = 0;
|
|
577
|
+
this.shown = -1;
|
|
578
|
+
this.hide();
|
|
579
|
+
}
|
|
580
|
+
dispose() {
|
|
581
|
+
this.disposed = true;
|
|
582
|
+
this.detachWindow?.();
|
|
583
|
+
this.doc.removeEventListener("keydown", this.handleKeyDown, true);
|
|
584
|
+
this.container.remove();
|
|
585
|
+
if (this.restoreHostPosition !== null) this.host.style.position = this.restoreHostPosition;
|
|
586
|
+
}
|
|
587
|
+
show() {
|
|
588
|
+
this.container.style.display = "block";
|
|
589
|
+
this.render();
|
|
590
|
+
this.doc.addEventListener("keydown", this.handleKeyDown, true);
|
|
591
|
+
this.panel.focus();
|
|
592
|
+
}
|
|
593
|
+
handleKeyDown = (event) => {
|
|
594
|
+
if (event.key === "Escape") {
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
event.stopPropagation();
|
|
597
|
+
this.hide();
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* Maps the entry's stack and quotes the line it points at.
|
|
602
|
+
*
|
|
603
|
+
* Deliberately after the first paint: the raw stack is on screen
|
|
604
|
+
* within a frame of the error, and the mapped one replaces it when
|
|
605
|
+
* the network answers. A developer looking at an error should never
|
|
606
|
+
* be waiting on a fetch to see it.
|
|
607
|
+
*/
|
|
608
|
+
async resolveSources(entry) {
|
|
609
|
+
if (entry.frames.length === 0) return;
|
|
610
|
+
entry.frames = await mapStack(entry.frames, this.maps);
|
|
611
|
+
entry.mapped = true;
|
|
612
|
+
const frame = primaryFrame(entry.frames);
|
|
613
|
+
const content = frame?.original?.content ?? null;
|
|
614
|
+
if (frame?.original != null && content !== null) entry.code = codeFrame(content, frame.original.line, frame.original.column);
|
|
615
|
+
if (!this.disposed && this.visible && this.entries[this.shown] === entry) this.render();
|
|
616
|
+
}
|
|
617
|
+
/** `element`, bound to this overlay's document. */
|
|
618
|
+
el = (tag, options = {}) => element(this.doc, tag, options);
|
|
619
|
+
/** A header button, bound to this overlay's document. */
|
|
620
|
+
button = (label, title, onClick) => button(this.doc, label, title, onClick);
|
|
621
|
+
render() {
|
|
622
|
+
const entry = this.entries[this.shown];
|
|
623
|
+
if (entry === void 0) return;
|
|
624
|
+
const origin = ORIGINS[entry.origin];
|
|
625
|
+
this.panel.replaceChildren();
|
|
626
|
+
const header = this.el("header");
|
|
627
|
+
header.append(this.el("span", {
|
|
628
|
+
className: "badge",
|
|
629
|
+
text: origin.label
|
|
630
|
+
}));
|
|
631
|
+
if (entry.repeats > 1) header.append(this.el("span", {
|
|
632
|
+
className: "repeats",
|
|
633
|
+
text: `×${entry.repeats}`
|
|
634
|
+
}));
|
|
635
|
+
const spacer = this.el("span", { className: "spacer" });
|
|
636
|
+
header.append(spacer);
|
|
637
|
+
if (this.entries.length > 1) header.append(this.button("‹", "Previous error", () => this.step(-1)), this.el("span", {
|
|
638
|
+
className: "count",
|
|
639
|
+
text: `${this.shown + 1} / ${this.entries.length}`
|
|
640
|
+
}), this.button("›", "Next error", () => this.step(1)));
|
|
641
|
+
header.append(this.button("Copy", "Copy this error to the clipboard", () => this.copy(entry)), this.button("✕", "Dismiss", () => this.hide()));
|
|
642
|
+
this.panel.append(header, this.el("h1", { text: entry.message }));
|
|
643
|
+
if (origin.note !== "") this.panel.append(this.el("p", {
|
|
644
|
+
className: "note",
|
|
645
|
+
text: origin.note
|
|
646
|
+
}));
|
|
647
|
+
if (entry.code !== null) this.panel.append(renderCodeFrame(this.doc, entry.code));
|
|
648
|
+
if (entry.frames.length > 0) {
|
|
649
|
+
const list = this.el("ol", { className: "stack" });
|
|
650
|
+
for (const frame of entry.frames) {
|
|
651
|
+
const item = this.el("li");
|
|
652
|
+
if (frame.location === null) item.append(this.el("span", {
|
|
653
|
+
className: "raw",
|
|
654
|
+
text: frame.raw
|
|
655
|
+
}));
|
|
656
|
+
else item.append(this.el("span", {
|
|
657
|
+
className: "fn",
|
|
658
|
+
text: frame.fn ?? "(anonymous)"
|
|
659
|
+
}), this.el("span", {
|
|
660
|
+
className: "at",
|
|
661
|
+
text: formatFrame(frame, this.view?.location.origin)
|
|
662
|
+
}));
|
|
663
|
+
list.append(item);
|
|
664
|
+
}
|
|
665
|
+
this.panel.append(list);
|
|
666
|
+
} else if (entry.stack !== void 0) this.panel.append(this.el("pre", {
|
|
667
|
+
className: "stack raw",
|
|
668
|
+
text: entry.stack
|
|
669
|
+
}));
|
|
670
|
+
const footer = this.el("footer");
|
|
671
|
+
footer.append(this.el("span", { text: entry.mapped || entry.frames.length === 0 ? "Escape dismisses this." : "Resolving source maps…" }));
|
|
672
|
+
this.panel.append(footer);
|
|
673
|
+
}
|
|
674
|
+
step(delta) {
|
|
675
|
+
const next = this.shown + delta;
|
|
676
|
+
if (next < 0 || next >= this.entries.length) return;
|
|
677
|
+
this.shown = next;
|
|
678
|
+
this.render();
|
|
679
|
+
this.resolveSources(this.entries[next]);
|
|
680
|
+
}
|
|
681
|
+
copy(entry) {
|
|
682
|
+
const frames = entry.frames.map((frame) => frame.location === null ? frame.raw : ` at ${frame.fn ?? "(anonymous)"} (${formatFrame(frame)})`);
|
|
683
|
+
const text = [entry.message, ...frames].join("\n");
|
|
684
|
+
this.view?.navigator.clipboard?.writeText(text).catch(() => {});
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* Mounts an error overlay over an application's host element.
|
|
689
|
+
*
|
|
690
|
+
* const overlay = mountErrorOverlay(host);
|
|
691
|
+
* createApp({ renderWorker, onError: overlay.report });
|
|
692
|
+
* overlay.captureWindowErrors();
|
|
693
|
+
*/
|
|
694
|
+
function mountErrorOverlay(host, options = {}) {
|
|
695
|
+
return new ErrorOverlay(host, options);
|
|
696
|
+
}
|
|
697
|
+
function renderCodeFrame(doc, frame) {
|
|
698
|
+
const el = (tag, options = {}) => element(doc, tag, options);
|
|
699
|
+
const width = String(frame.lines[frame.lines.length - 1].number).length;
|
|
700
|
+
const block = el("pre", { className: "code" });
|
|
701
|
+
for (const line of frame.lines) {
|
|
702
|
+
const row = el("div", { className: line.target ? "line target" : "line" });
|
|
703
|
+
row.append(el("span", {
|
|
704
|
+
className: "gutter",
|
|
705
|
+
text: String(line.number).padStart(width, " ")
|
|
706
|
+
}), el("span", {
|
|
707
|
+
className: "text",
|
|
708
|
+
text: line.text
|
|
709
|
+
}));
|
|
710
|
+
block.append(row);
|
|
711
|
+
if (line.target) {
|
|
712
|
+
const caret = el("div", { className: "line caret" });
|
|
713
|
+
caret.append(el("span", {
|
|
714
|
+
className: "gutter",
|
|
715
|
+
text: " ".repeat(width)
|
|
716
|
+
}), el("span", {
|
|
717
|
+
className: "text",
|
|
718
|
+
text: `${" ".repeat(Math.max(0, frame.column - 1))}^`
|
|
719
|
+
}));
|
|
720
|
+
block.append(caret);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return block;
|
|
724
|
+
}
|
|
725
|
+
function element(doc, tag, options = {}) {
|
|
726
|
+
const node = doc.createElement(tag);
|
|
727
|
+
if (options.className !== void 0) node.className = options.className;
|
|
728
|
+
if (options.text !== void 0) node.textContent = options.text;
|
|
729
|
+
return node;
|
|
730
|
+
}
|
|
731
|
+
function button(doc, label, title, onClick) {
|
|
732
|
+
const node = element(doc, "button", { text: label });
|
|
733
|
+
node.type = "button";
|
|
734
|
+
node.title = title;
|
|
735
|
+
node.setAttribute("aria-label", title);
|
|
736
|
+
node.addEventListener("click", onClick);
|
|
737
|
+
return node;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The overlay's own styles, inside its shadow root.
|
|
741
|
+
*
|
|
742
|
+
* Shadow rather than a stylesheet in the page: an overlay that
|
|
743
|
+
* inherited the application's CSS would be unreadable in exactly the
|
|
744
|
+
* applications whose CSS is broken, and one that leaked its own rules
|
|
745
|
+
* would be a development tool changing the thing being developed.
|
|
746
|
+
*
|
|
747
|
+
* Dark and monospace whatever the page is, because this is a tool
|
|
748
|
+
* rather than a part of the app, and it should never be mistaken for
|
|
749
|
+
* one.
|
|
750
|
+
*/
|
|
751
|
+
const STYLES$4 = `
|
|
752
|
+
:host { all: initial; }
|
|
753
|
+
.scrim {
|
|
754
|
+
position: absolute;
|
|
755
|
+
inset: 0;
|
|
756
|
+
z-index: 2147483000;
|
|
757
|
+
display: flex;
|
|
758
|
+
align-items: center;
|
|
759
|
+
justify-content: center;
|
|
760
|
+
padding: 16px;
|
|
761
|
+
box-sizing: border-box;
|
|
762
|
+
background: rgba(12, 12, 16, 0.72);
|
|
763
|
+
backdrop-filter: blur(2px);
|
|
764
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
765
|
+
font-size: 12px;
|
|
766
|
+
line-height: 1.5;
|
|
767
|
+
color: #e6e6ea;
|
|
768
|
+
overflow: auto;
|
|
769
|
+
}
|
|
770
|
+
.panel {
|
|
771
|
+
width: min(860px, 100%);
|
|
772
|
+
max-height: 100%;
|
|
773
|
+
overflow: auto;
|
|
774
|
+
box-sizing: border-box;
|
|
775
|
+
padding: 16px 18px 12px;
|
|
776
|
+
border: 1px solid #7f1d1d;
|
|
777
|
+
border-radius: 8px;
|
|
778
|
+
background: #17171c;
|
|
779
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
|
780
|
+
outline: none;
|
|
781
|
+
}
|
|
782
|
+
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
783
|
+
.spacer { flex: 1; }
|
|
784
|
+
.badge {
|
|
785
|
+
padding: 2px 8px;
|
|
786
|
+
border-radius: 999px;
|
|
787
|
+
background: #7f1d1d;
|
|
788
|
+
color: #fee2e2;
|
|
789
|
+
text-transform: uppercase;
|
|
790
|
+
letter-spacing: 0.06em;
|
|
791
|
+
font-size: 10px;
|
|
792
|
+
}
|
|
793
|
+
.repeats { color: #fca5a5; }
|
|
794
|
+
.count { color: #9a9aa5; }
|
|
795
|
+
button {
|
|
796
|
+
font: inherit;
|
|
797
|
+
color: #cfcfd6;
|
|
798
|
+
background: #24242c;
|
|
799
|
+
border: 1px solid #34343e;
|
|
800
|
+
border-radius: 5px;
|
|
801
|
+
padding: 2px 8px;
|
|
802
|
+
cursor: pointer;
|
|
803
|
+
}
|
|
804
|
+
button:hover { background: #2e2e38; color: #fff; }
|
|
805
|
+
button:focus-visible { outline: 2px solid #f87171; outline-offset: 1px; }
|
|
806
|
+
h1 {
|
|
807
|
+
margin: 0 0 8px;
|
|
808
|
+
font-size: 14px;
|
|
809
|
+
font-weight: 600;
|
|
810
|
+
color: #fca5a5;
|
|
811
|
+
white-space: pre-wrap;
|
|
812
|
+
word-break: break-word;
|
|
813
|
+
}
|
|
814
|
+
.note { margin: 0 0 12px; color: #9a9aa5; max-width: 68ch; }
|
|
815
|
+
.code {
|
|
816
|
+
margin: 0 0 12px;
|
|
817
|
+
padding: 10px 12px;
|
|
818
|
+
background: #101015;
|
|
819
|
+
border: 1px solid #26262f;
|
|
820
|
+
border-radius: 6px;
|
|
821
|
+
overflow-x: auto;
|
|
822
|
+
}
|
|
823
|
+
.code .line { white-space: pre; }
|
|
824
|
+
.code .gutter { color: #55555f; margin-right: 12px; user-select: none; }
|
|
825
|
+
.code .target { background: rgba(127, 29, 29, 0.35); }
|
|
826
|
+
.code .caret .text { color: #f87171; }
|
|
827
|
+
ol.stack { margin: 0; padding: 0; list-style: none; }
|
|
828
|
+
ol.stack li { display: flex; gap: 10px; padding: 1px 0; }
|
|
829
|
+
ol.stack .fn { color: #d4d4dc; flex: 0 0 auto; }
|
|
830
|
+
ol.stack .at { color: #8b8b96; word-break: break-all; }
|
|
831
|
+
ol.stack .raw { color: #6f6f7a; }
|
|
832
|
+
pre.stack { margin: 0; white-space: pre-wrap; color: #8b8b96; }
|
|
833
|
+
footer { margin-top: 12px; color: #6f6f7a; }
|
|
834
|
+
`;
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region src/ActionLog.ts
|
|
837
|
+
function createActionLog(options = {}) {
|
|
838
|
+
return new Recorder(options.limit ?? 200);
|
|
839
|
+
}
|
|
840
|
+
function now() {
|
|
841
|
+
return typeof performance === "undefined" ? Date.now() : performance.now();
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Sends each new entry as it is recorded, and returns a function that
|
|
845
|
+
* stops.
|
|
846
|
+
*
|
|
847
|
+
* `subscribe` says only that something changed, so the sequence number
|
|
848
|
+
* is what tells a new entry from the ones already sent; a `clear`
|
|
849
|
+
* resets it, and a bounded log retiring old entries does not. Both
|
|
850
|
+
* routes to a panel need exactly this: the hook, for a log in the
|
|
851
|
+
* page, and the render worker tap, for one on the other side of a
|
|
852
|
+
* thread.
|
|
853
|
+
*/
|
|
854
|
+
function forwardNewEntries(log, send) {
|
|
855
|
+
let lastSeq = -1;
|
|
856
|
+
const flush = () => {
|
|
857
|
+
const entries = log.entries;
|
|
858
|
+
if (entries.length === 0) {
|
|
859
|
+
lastSeq = -1;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
for (const entry of entries) if (entry.seq > lastSeq) {
|
|
863
|
+
lastSeq = entry.seq;
|
|
864
|
+
send(entry);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
flush();
|
|
868
|
+
return log.subscribe(flush);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* One channel with the recorder in the middle of it.
|
|
872
|
+
*
|
|
873
|
+
* It holds two states rather than one. `live` follows every patch that
|
|
874
|
+
* arrives, whether or not the view was allowed to see it, so going
|
|
875
|
+
* live is a single set per key instead of a backlog to drain. `base`
|
|
876
|
+
* is the state as of the oldest entry still on the timeline, which is
|
|
877
|
+
* what keeps a bounded log able to reconstruct exactly.
|
|
878
|
+
*/
|
|
879
|
+
var TappedChannel = class {
|
|
880
|
+
name;
|
|
881
|
+
toProvider;
|
|
882
|
+
toReplica;
|
|
883
|
+
recorder;
|
|
884
|
+
base = /* @__PURE__ */ new Map();
|
|
885
|
+
live = /* @__PURE__ */ new Map();
|
|
886
|
+
open = true;
|
|
887
|
+
constructor(name, initial, toProvider, toReplica, recorder) {
|
|
888
|
+
this.name = name;
|
|
889
|
+
this.toProvider = toProvider;
|
|
890
|
+
this.toReplica = toReplica;
|
|
891
|
+
this.recorder = recorder;
|
|
892
|
+
for (const [key, value] of Object.entries(initial)) {
|
|
893
|
+
this.base.set(key, value);
|
|
894
|
+
this.live.set(key, value);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/** The replica's end: commands out, and the sync that starts it all. */
|
|
898
|
+
fromReplica(data) {
|
|
899
|
+
if (this.open && isChannelClientMessage(data) && data.type === "channel:command") {
|
|
900
|
+
markInstant(`command ${this.name}.${data.command}`, data.payload);
|
|
901
|
+
this.recorder.record({
|
|
902
|
+
kind: "command",
|
|
903
|
+
channel: this.name,
|
|
904
|
+
command: data.command,
|
|
905
|
+
payload: data.payload
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
this.toProvider(data);
|
|
909
|
+
}
|
|
910
|
+
/** The owning thread's end: patches and errors. */
|
|
911
|
+
fromProvider(data) {
|
|
912
|
+
if (this.open && isChannelHostMessage(data)) {
|
|
913
|
+
if (data.type === "channel:patch") {
|
|
914
|
+
for (const patch of data.patches) this.live.set(patch.projection, applyPatch(this.live.get(patch.projection), patch));
|
|
915
|
+
markInstant(`patch ${this.name}`, projectionsOf(data.patches));
|
|
916
|
+
this.recorder.record({
|
|
917
|
+
kind: "patch",
|
|
918
|
+
channel: this.name,
|
|
919
|
+
patches: data.patches,
|
|
920
|
+
keys: projectionsOf(data.patches)
|
|
921
|
+
});
|
|
922
|
+
if (this.recorder.pinnedTo !== null) return;
|
|
923
|
+
} else this.recorder.record({
|
|
924
|
+
kind: "error",
|
|
925
|
+
channel: this.name,
|
|
926
|
+
message: data.message
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
this.toReplica(data);
|
|
930
|
+
}
|
|
931
|
+
/** Folds a dropped entry's patches into the reconstruction base. */
|
|
932
|
+
absorb(patches) {
|
|
933
|
+
for (const patch of patches) this.base.set(patch.projection, applyPatch(this.base.get(patch.projection), patch));
|
|
934
|
+
}
|
|
935
|
+
/** Puts a whole state on the wire, exactly as a resync is answered. */
|
|
936
|
+
show(state) {
|
|
937
|
+
const patches = [];
|
|
938
|
+
for (const [projection, value] of state) patches.push({
|
|
939
|
+
op: "set",
|
|
940
|
+
projection,
|
|
941
|
+
path: [],
|
|
942
|
+
value
|
|
943
|
+
});
|
|
944
|
+
if (patches.length > 0) this.toReplica({
|
|
945
|
+
type: "channel:patch",
|
|
946
|
+
patches
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
close() {
|
|
950
|
+
this.open = false;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
function projectionsOf(patches) {
|
|
954
|
+
const keys = [];
|
|
955
|
+
for (const patch of patches) if (!keys.includes(patch.projection)) keys.push(patch.projection);
|
|
956
|
+
return keys;
|
|
957
|
+
}
|
|
958
|
+
var Recorder = class {
|
|
959
|
+
limit;
|
|
960
|
+
tapped = [];
|
|
961
|
+
log = [];
|
|
962
|
+
listeners = /* @__PURE__ */ new Set();
|
|
963
|
+
seq = 0;
|
|
964
|
+
pinned = null;
|
|
965
|
+
/** The input being dispatched, if one is, and the label it was opened with. */
|
|
966
|
+
openLabel = null;
|
|
967
|
+
openCause = null;
|
|
968
|
+
causeId = 0;
|
|
969
|
+
/** Commands awaiting their patches, by channel. */
|
|
970
|
+
awaiting = /* @__PURE__ */ new Map();
|
|
971
|
+
/**
|
|
972
|
+
* The cause shared by everything recorded since the last frame
|
|
973
|
+
* entry: undefined when nothing has been, null when two causes have.
|
|
974
|
+
*/
|
|
975
|
+
frameCause = void 0;
|
|
976
|
+
recordedSinceFrame = false;
|
|
977
|
+
constructor(limit) {
|
|
978
|
+
this.limit = limit;
|
|
979
|
+
}
|
|
980
|
+
get entries() {
|
|
981
|
+
return this.log;
|
|
982
|
+
}
|
|
983
|
+
get channels() {
|
|
984
|
+
return this.tapped.map((channel) => channel.name);
|
|
985
|
+
}
|
|
986
|
+
get pinnedTo() {
|
|
987
|
+
return this.pinned;
|
|
988
|
+
}
|
|
989
|
+
tap(handle, tokens) {
|
|
990
|
+
const initials = new Map(tokens.map((token) => [token.name, token.initial]));
|
|
991
|
+
return {
|
|
992
|
+
open: (key) => {
|
|
993
|
+
const real = handle.open(key);
|
|
994
|
+
const relay = new MessageChannel();
|
|
995
|
+
const channel = new TappedChannel(key, initials.get(key) ?? {}, (message) => real.postMessage(message), (message) => relay.port2.postMessage(message), this);
|
|
996
|
+
real.onmessage = (event) => channel.fromProvider(event.data);
|
|
997
|
+
relay.port2.onmessage = (event) => channel.fromReplica(event.data);
|
|
998
|
+
this.tapped.push(channel);
|
|
999
|
+
return relay.port1;
|
|
1000
|
+
},
|
|
1001
|
+
get spawned() {
|
|
1002
|
+
return handle.spawned;
|
|
1003
|
+
},
|
|
1004
|
+
terminate: () => {
|
|
1005
|
+
handle.terminate();
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
tapPort(port, token) {
|
|
1010
|
+
let listener = null;
|
|
1011
|
+
const channel = new TappedChannel(token.name, token.initial, (message) => port.postMessage(message), (message) => listener?.({ data: message }), this);
|
|
1012
|
+
port.onmessage = (event) => channel.fromProvider(event.data);
|
|
1013
|
+
this.tapped.push(channel);
|
|
1014
|
+
return {
|
|
1015
|
+
postMessage(message) {
|
|
1016
|
+
channel.fromReplica(message);
|
|
1017
|
+
},
|
|
1018
|
+
get onmessage() {
|
|
1019
|
+
return listener;
|
|
1020
|
+
},
|
|
1021
|
+
set onmessage(next) {
|
|
1022
|
+
listener = next;
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
cause(label) {
|
|
1027
|
+
const previousLabel = this.openLabel;
|
|
1028
|
+
const previousCause = this.openCause;
|
|
1029
|
+
this.openLabel = label;
|
|
1030
|
+
this.openCause = null;
|
|
1031
|
+
let closed = false;
|
|
1032
|
+
return () => {
|
|
1033
|
+
if (closed) return;
|
|
1034
|
+
closed = true;
|
|
1035
|
+
this.openLabel = previousLabel;
|
|
1036
|
+
this.openCause = previousCause;
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
frame(id) {
|
|
1040
|
+
if (!this.recordedSinceFrame) return;
|
|
1041
|
+
this.recordedSinceFrame = false;
|
|
1042
|
+
const cause = this.frameCause ?? void 0;
|
|
1043
|
+
this.frameCause = void 0;
|
|
1044
|
+
for (const [channel, pending] of this.awaiting) if (pending.claimed) this.awaiting.delete(channel);
|
|
1045
|
+
this.append({
|
|
1046
|
+
kind: "frame",
|
|
1047
|
+
frame: id,
|
|
1048
|
+
...cause === void 0 ? {} : { cause }
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
record(entry) {
|
|
1052
|
+
const cause = this.causeFor(entry);
|
|
1053
|
+
this.append(cause === void 0 ? entry : {
|
|
1054
|
+
...entry,
|
|
1055
|
+
cause
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Which cause an entry belongs to.
|
|
1060
|
+
*
|
|
1061
|
+
* A command belongs to the input being dispatched, which is known
|
|
1062
|
+
* rather than guessed. A patch belongs to the command it answers,
|
|
1063
|
+
* which is not: the barrier carries no request id, so what stands
|
|
1064
|
+
* for one is "the last command on this channel that nothing has
|
|
1065
|
+
* answered yet". It is right whenever a channel is answering one
|
|
1066
|
+
* thing at a time, which is what a channel driven by a person does,
|
|
1067
|
+
* and it is wrong for a channel that also emits on its own between
|
|
1068
|
+
* the command and its answer. The panel says `via` rather than
|
|
1069
|
+
* `from` for that reason.
|
|
1070
|
+
*/
|
|
1071
|
+
causeFor(entry) {
|
|
1072
|
+
if (entry.kind === "command") {
|
|
1073
|
+
if (this.openLabel === null) {
|
|
1074
|
+
this.awaiting.delete(entry.channel);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
this.openCause ??= {
|
|
1078
|
+
id: ++this.causeId,
|
|
1079
|
+
label: this.openLabel
|
|
1080
|
+
};
|
|
1081
|
+
this.awaiting.set(entry.channel, {
|
|
1082
|
+
cause: this.openCause,
|
|
1083
|
+
claimed: false
|
|
1084
|
+
});
|
|
1085
|
+
return this.openCause;
|
|
1086
|
+
}
|
|
1087
|
+
const pending = this.awaiting.get(entry.channel);
|
|
1088
|
+
const cause = this.openCause ?? pending?.cause;
|
|
1089
|
+
if (cause !== void 0 && pending?.cause === cause) pending.claimed = true;
|
|
1090
|
+
return cause;
|
|
1091
|
+
}
|
|
1092
|
+
append(entry) {
|
|
1093
|
+
const complete = {
|
|
1094
|
+
...entry,
|
|
1095
|
+
seq: this.seq++,
|
|
1096
|
+
at: now()
|
|
1097
|
+
};
|
|
1098
|
+
if (complete.kind !== "frame") {
|
|
1099
|
+
this.recordedSinceFrame = true;
|
|
1100
|
+
const cause = complete.cause;
|
|
1101
|
+
if (cause !== void 0) this.frameCause = this.frameCause === void 0 || this.frameCause === cause ? cause : null;
|
|
1102
|
+
}
|
|
1103
|
+
this.log.push(complete);
|
|
1104
|
+
while (this.log.length > this.limit) this.drop();
|
|
1105
|
+
this.notify();
|
|
1106
|
+
}
|
|
1107
|
+
jumpTo(seq) {
|
|
1108
|
+
this.pinned = seq;
|
|
1109
|
+
for (const channel of this.tapped) channel.show(seq === null ? channel.live : this.stateAt(channel, seq));
|
|
1110
|
+
this.notify();
|
|
1111
|
+
}
|
|
1112
|
+
clear() {
|
|
1113
|
+
this.jumpTo(null);
|
|
1114
|
+
while (this.log.length > 0) this.drop();
|
|
1115
|
+
this.awaiting.clear();
|
|
1116
|
+
this.frameCause = void 0;
|
|
1117
|
+
this.recordedSinceFrame = false;
|
|
1118
|
+
this.notify();
|
|
1119
|
+
}
|
|
1120
|
+
subscribe(listener) {
|
|
1121
|
+
this.listeners.add(listener);
|
|
1122
|
+
return () => this.listeners.delete(listener);
|
|
1123
|
+
}
|
|
1124
|
+
dispose() {
|
|
1125
|
+
this.jumpTo(null);
|
|
1126
|
+
for (const channel of this.tapped) channel.close();
|
|
1127
|
+
this.tapped.length = 0;
|
|
1128
|
+
this.log.length = 0;
|
|
1129
|
+
this.pinned = null;
|
|
1130
|
+
this.awaiting.clear();
|
|
1131
|
+
this.openLabel = null;
|
|
1132
|
+
this.openCause = null;
|
|
1133
|
+
this.frameCause = void 0;
|
|
1134
|
+
this.recordedSinceFrame = false;
|
|
1135
|
+
this.listeners.clear();
|
|
1136
|
+
}
|
|
1137
|
+
/** Retires the oldest entry, leaving the reconstruction exact. */
|
|
1138
|
+
drop() {
|
|
1139
|
+
const dropped = this.log.shift();
|
|
1140
|
+
if (dropped?.kind !== "patch") return;
|
|
1141
|
+
for (const channel of this.tapped) if (channel.name === dropped.channel) channel.absorb(dropped.patches);
|
|
1142
|
+
}
|
|
1143
|
+
stateAt(channel, seq) {
|
|
1144
|
+
const state = new Map(channel.base);
|
|
1145
|
+
for (const entry of this.log) {
|
|
1146
|
+
if (entry.seq > seq) break;
|
|
1147
|
+
if (entry.kind !== "patch" || entry.channel !== channel.name) continue;
|
|
1148
|
+
for (const patch of entry.patches) state.set(patch.projection, applyPatch(state.get(patch.projection), patch));
|
|
1149
|
+
}
|
|
1150
|
+
return state;
|
|
1151
|
+
}
|
|
1152
|
+
notify() {
|
|
1153
|
+
for (const listener of this.listeners) listener();
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/ActionLogPanel.ts
|
|
1158
|
+
/** Longest a payload or a value is printed before it is cut. */
|
|
1159
|
+
const VALUE_CHARS = 60;
|
|
1160
|
+
function mountActionLogPanel(host, log, options = {}) {
|
|
1161
|
+
const doc = host.ownerDocument;
|
|
1162
|
+
const view = doc.defaultView;
|
|
1163
|
+
const position = view?.getComputedStyle(host).position ?? "static";
|
|
1164
|
+
const restore = position === "static" ? host.style.position : null;
|
|
1165
|
+
if (position === "static") host.style.position = "relative";
|
|
1166
|
+
const container = doc.createElement("div");
|
|
1167
|
+
const root = container.attachShadow({ mode: "open" });
|
|
1168
|
+
const style = doc.createElement("style");
|
|
1169
|
+
style.textContent = STYLES$3;
|
|
1170
|
+
const panel = doc.createElement("div");
|
|
1171
|
+
panel.className = `panel ${options.corner ?? "bottom-right"}`;
|
|
1172
|
+
panel.hidden = true;
|
|
1173
|
+
const header = doc.createElement("header");
|
|
1174
|
+
const title = doc.createElement("span");
|
|
1175
|
+
title.className = "title";
|
|
1176
|
+
title.textContent = "actions";
|
|
1177
|
+
const state = doc.createElement("span");
|
|
1178
|
+
state.className = "state";
|
|
1179
|
+
const live = doc.createElement("button");
|
|
1180
|
+
live.type = "button";
|
|
1181
|
+
live.textContent = "Live";
|
|
1182
|
+
live.addEventListener("click", () => log.jumpTo(null));
|
|
1183
|
+
const clear = doc.createElement("button");
|
|
1184
|
+
clear.type = "button";
|
|
1185
|
+
clear.textContent = "Clear";
|
|
1186
|
+
clear.addEventListener("click", () => log.clear());
|
|
1187
|
+
header.append(title, state, live, clear);
|
|
1188
|
+
const list = doc.createElement("ol");
|
|
1189
|
+
list.className = "entries";
|
|
1190
|
+
const note = doc.createElement("p");
|
|
1191
|
+
note.className = "note";
|
|
1192
|
+
panel.append(header, list, note);
|
|
1193
|
+
root.append(style, panel);
|
|
1194
|
+
host.appendChild(container);
|
|
1195
|
+
let visible = false;
|
|
1196
|
+
let scheduled = 0;
|
|
1197
|
+
const render = () => {
|
|
1198
|
+
const entries = log.entries;
|
|
1199
|
+
const pinned = log.pinnedTo;
|
|
1200
|
+
const following = list.scrollHeight - list.scrollTop - list.clientHeight < 4;
|
|
1201
|
+
state.textContent = pinned === null ? "live" : `step ${pinned}`;
|
|
1202
|
+
state.className = pinned === null ? "state" : "state pinned";
|
|
1203
|
+
live.disabled = pinned === null;
|
|
1204
|
+
list.textContent = "";
|
|
1205
|
+
const origin = entries[0]?.at ?? 0;
|
|
1206
|
+
for (const entry of entries) list.append(row(doc, entry, origin, pinned, (seq) => log.jumpTo(seq)));
|
|
1207
|
+
if (entries.length === 0) note.textContent = "Nothing has crossed a tapped channel yet.";
|
|
1208
|
+
else if (pinned === null) note.textContent = "Click a step to put the view back to what it showed then.";
|
|
1209
|
+
else note.textContent = "The view is showing an earlier step. The application worker is not rewound: it is still running, its patches are being recorded and held, and Live catches the view up to it.";
|
|
1210
|
+
if (following) list.scrollTop = list.scrollHeight;
|
|
1211
|
+
};
|
|
1212
|
+
const schedule = () => {
|
|
1213
|
+
if (!visible || scheduled !== 0) return;
|
|
1214
|
+
const draw = () => {
|
|
1215
|
+
scheduled = 0;
|
|
1216
|
+
render();
|
|
1217
|
+
};
|
|
1218
|
+
scheduled = view === null ? setTimeout(draw, 16) : view.requestAnimationFrame(draw);
|
|
1219
|
+
};
|
|
1220
|
+
const unsubscribe = log.subscribe(schedule);
|
|
1221
|
+
return {
|
|
1222
|
+
setVisible(next) {
|
|
1223
|
+
visible = next;
|
|
1224
|
+
panel.hidden = !next;
|
|
1225
|
+
if (next) render();
|
|
1226
|
+
},
|
|
1227
|
+
get visible() {
|
|
1228
|
+
return visible;
|
|
1229
|
+
},
|
|
1230
|
+
dispose() {
|
|
1231
|
+
unsubscribe();
|
|
1232
|
+
container.remove();
|
|
1233
|
+
if (restore !== null) host.style.position = restore;
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* One step.
|
|
1239
|
+
*
|
|
1240
|
+
* The direction glyph is the first thing on the line because it is the
|
|
1241
|
+
* first thing anyone wants: `↑` is a command the view sent, `↓` is
|
|
1242
|
+
* what came back. A log that printed both the same way would need to
|
|
1243
|
+
* be read to be understood, and this one is meant to be skimmed.
|
|
1244
|
+
*/
|
|
1245
|
+
function row(doc, entry, origin, pinned, jump) {
|
|
1246
|
+
const item = doc.createElement("li");
|
|
1247
|
+
item.className = `entry ${entry.kind}${entry.seq === pinned ? " at" : ""}`;
|
|
1248
|
+
item.tabIndex = 0;
|
|
1249
|
+
item.setAttribute("role", "button");
|
|
1250
|
+
const time = doc.createElement("span");
|
|
1251
|
+
time.className = "time";
|
|
1252
|
+
time.textContent = formatOffset(entry.at - origin);
|
|
1253
|
+
const arrow = doc.createElement("span");
|
|
1254
|
+
arrow.className = "arrow";
|
|
1255
|
+
arrow.textContent = actionGlyph(entry);
|
|
1256
|
+
const text = doc.createElement("span");
|
|
1257
|
+
text.className = "text";
|
|
1258
|
+
text.textContent = describeActionEntry(entry);
|
|
1259
|
+
item.append(time, arrow, text);
|
|
1260
|
+
if (entry.cause !== void 0) {
|
|
1261
|
+
const cause = doc.createElement("span");
|
|
1262
|
+
cause.className = "cause";
|
|
1263
|
+
cause.textContent = `#${entry.cause.id}`;
|
|
1264
|
+
cause.title = entry.cause.label;
|
|
1265
|
+
item.append(cause);
|
|
1266
|
+
}
|
|
1267
|
+
const activate = () => jump(entry.seq);
|
|
1268
|
+
item.addEventListener("click", activate);
|
|
1269
|
+
item.addEventListener("keydown", (event) => {
|
|
1270
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1271
|
+
event.preventDefault();
|
|
1272
|
+
activate();
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
return item;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* The direction, as one glyph: up for what the view sent, down for
|
|
1279
|
+
* what came back, a square for the frame that drew it.
|
|
1280
|
+
*/
|
|
1281
|
+
function actionGlyph(entry) {
|
|
1282
|
+
switch (entry.kind) {
|
|
1283
|
+
case "command": return "↑";
|
|
1284
|
+
case "patch": return "↓";
|
|
1285
|
+
case "frame": return "▣";
|
|
1286
|
+
case "error": return "!";
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/** One entry as a line: the command and its payload, the patched keys, the frame, or the error. */
|
|
1290
|
+
function describeActionEntry(entry) {
|
|
1291
|
+
switch (entry.kind) {
|
|
1292
|
+
case "command": return `${entry.channel}.${entry.command}(${entry.payload === void 0 ? "" : print(entry.payload)})`;
|
|
1293
|
+
case "patch": return `${entry.channel} ${entry.keys.join(", ")}`;
|
|
1294
|
+
case "frame": return `frame ${entry.frame}`;
|
|
1295
|
+
case "error": return `${entry.channel} ${entry.message}`;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function print(value) {
|
|
1299
|
+
let text;
|
|
1300
|
+
try {
|
|
1301
|
+
text = JSON.stringify(value) ?? String(value);
|
|
1302
|
+
} catch {
|
|
1303
|
+
text = String(value);
|
|
1304
|
+
}
|
|
1305
|
+
return text.length > VALUE_CHARS ? `${text.slice(0, 59)}…` : text;
|
|
1306
|
+
}
|
|
1307
|
+
function formatOffset(ms) {
|
|
1308
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
1309
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1310
|
+
}
|
|
1311
|
+
const STYLES$3 = `
|
|
1312
|
+
:host { all: initial; }
|
|
1313
|
+
.panel {
|
|
1314
|
+
position: absolute;
|
|
1315
|
+
z-index: 2147482000;
|
|
1316
|
+
display: flex;
|
|
1317
|
+
flex-direction: column;
|
|
1318
|
+
width: 320px;
|
|
1319
|
+
max-height: 60%;
|
|
1320
|
+
margin: 12px;
|
|
1321
|
+
border-radius: 8px;
|
|
1322
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1323
|
+
background: rgba(13, 17, 23, 0.94);
|
|
1324
|
+
color: #e6edf3;
|
|
1325
|
+
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1326
|
+
}
|
|
1327
|
+
.panel[hidden] { display: none; }
|
|
1328
|
+
.top-left { top: 0; left: 0; }
|
|
1329
|
+
.top-right { top: 0; right: 0; }
|
|
1330
|
+
.bottom-left { bottom: 0; left: 0; }
|
|
1331
|
+
.bottom-right { bottom: 0; right: 0; }
|
|
1332
|
+
header {
|
|
1333
|
+
display: flex;
|
|
1334
|
+
align-items: center;
|
|
1335
|
+
gap: 6px;
|
|
1336
|
+
padding: 8px 10px;
|
|
1337
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
1338
|
+
}
|
|
1339
|
+
.title {
|
|
1340
|
+
flex: 1;
|
|
1341
|
+
font-size: 10px;
|
|
1342
|
+
text-transform: uppercase;
|
|
1343
|
+
letter-spacing: 0.08em;
|
|
1344
|
+
color: #8b949e;
|
|
1345
|
+
}
|
|
1346
|
+
.state { color: #56d364; }
|
|
1347
|
+
.state.pinned { color: #e3b341; }
|
|
1348
|
+
button {
|
|
1349
|
+
cursor: pointer;
|
|
1350
|
+
padding: 2px 8px;
|
|
1351
|
+
border-radius: 5px;
|
|
1352
|
+
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
1353
|
+
background: #21262d;
|
|
1354
|
+
color: #e6edf3;
|
|
1355
|
+
font: inherit;
|
|
1356
|
+
}
|
|
1357
|
+
button:hover:not(:disabled) { background: #30363d; color: #fff; }
|
|
1358
|
+
button:disabled { cursor: default; opacity: 0.45; }
|
|
1359
|
+
.entries {
|
|
1360
|
+
flex: 1;
|
|
1361
|
+
overflow: auto;
|
|
1362
|
+
margin: 0;
|
|
1363
|
+
padding: 4px 0;
|
|
1364
|
+
list-style: none;
|
|
1365
|
+
}
|
|
1366
|
+
.entry {
|
|
1367
|
+
display: flex;
|
|
1368
|
+
gap: 6px;
|
|
1369
|
+
padding: 1px 10px;
|
|
1370
|
+
cursor: pointer;
|
|
1371
|
+
overflow-wrap: anywhere;
|
|
1372
|
+
}
|
|
1373
|
+
.entry:hover { background: rgba(110, 118, 129, 0.25); }
|
|
1374
|
+
.entry:focus-visible { outline: 1px solid #79c0ff; outline-offset: -1px; }
|
|
1375
|
+
.entry.at { background: rgba(227, 179, 65, 0.22); }
|
|
1376
|
+
.time { width: 44px; flex: none; text-align: right; color: #6e7681; }
|
|
1377
|
+
.arrow { width: 8px; flex: none; }
|
|
1378
|
+
.command .arrow { color: #d2a8ff; }
|
|
1379
|
+
.patch .arrow { color: #79c0ff; }
|
|
1380
|
+
.frame .arrow { color: #7ee787; }
|
|
1381
|
+
.frame .text { color: #8b949e; }
|
|
1382
|
+
.error .arrow { color: #ff7b72; }
|
|
1383
|
+
.error .text { color: #ff7b72; }
|
|
1384
|
+
.text { flex: 1; }
|
|
1385
|
+
.cause { flex: none; color: #ffa657; }
|
|
1386
|
+
.note {
|
|
1387
|
+
margin: 0;
|
|
1388
|
+
padding: 6px 10px;
|
|
1389
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
1390
|
+
color: #8b949e;
|
|
1391
|
+
}
|
|
1392
|
+
`;
|
|
1393
|
+
//#endregion
|
|
1394
|
+
//#region src/RenderWorkerTap.ts
|
|
1395
|
+
function tapRenderWorker(log, options = {}) {
|
|
1396
|
+
const host = options.host ?? globalThis;
|
|
1397
|
+
const previousOnMessage = host.onmessage;
|
|
1398
|
+
const originalPostMessage = host.postMessage;
|
|
1399
|
+
const previousPostMessage = originalPostMessage.bind(host);
|
|
1400
|
+
let application;
|
|
1401
|
+
let disposed = false;
|
|
1402
|
+
host.onmessage = (event) => {
|
|
1403
|
+
const message = event.data;
|
|
1404
|
+
if (message?.type === "init" && message.appPort !== void 0) application = portHandle(message.appPort);
|
|
1405
|
+
const label = inputLabel(message);
|
|
1406
|
+
if (label === null) {
|
|
1407
|
+
previousOnMessage?.(event);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
const close = log.cause(label);
|
|
1411
|
+
const measuring = performanceMarksEnabled();
|
|
1412
|
+
const started = measuring ? markNow() : 0;
|
|
1413
|
+
try {
|
|
1414
|
+
previousOnMessage?.(event);
|
|
1415
|
+
} finally {
|
|
1416
|
+
close();
|
|
1417
|
+
if (measuring) measureSpan(`input ${label}`, started, markNow());
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
host.postMessage = (message, transfer) => {
|
|
1421
|
+
if (message?.type === "frame" && typeof message.frame === "number") log.frame(message.frame);
|
|
1422
|
+
if (transfer === void 0) {
|
|
1423
|
+
previousPostMessage(message);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
previousPostMessage(message, transfer);
|
|
1427
|
+
};
|
|
1428
|
+
const stopForwarding = options.forward === false ? () => {} : forwardNewEntries(log, (entry) => {
|
|
1429
|
+
previousPostMessage({
|
|
1430
|
+
type: "devtools",
|
|
1431
|
+
event: {
|
|
1432
|
+
kind: "action",
|
|
1433
|
+
entry
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
return {
|
|
1438
|
+
applicationWorker(tokens) {
|
|
1439
|
+
return {
|
|
1440
|
+
open(key) {
|
|
1441
|
+
if (application === void 0) throw new Error(`Channel '${key}' was opened before the shell sent its application worker port. tapRenderWorker must be called before renderRoot handles \`init\`, and the shell must have been given an appLogicWorker.`);
|
|
1442
|
+
const real = application.open(key);
|
|
1443
|
+
const token = tokens.find((candidate) => candidate.name === key);
|
|
1444
|
+
if (token === void 0) return real;
|
|
1445
|
+
return log.tapPort(real, token);
|
|
1446
|
+
},
|
|
1447
|
+
get spawned() {
|
|
1448
|
+
return application !== void 0;
|
|
1449
|
+
},
|
|
1450
|
+
terminate() {}
|
|
1451
|
+
};
|
|
1452
|
+
},
|
|
1453
|
+
dispose() {
|
|
1454
|
+
if (disposed) return;
|
|
1455
|
+
disposed = true;
|
|
1456
|
+
stopForwarding();
|
|
1457
|
+
host.onmessage = previousOnMessage;
|
|
1458
|
+
host.postMessage = originalPostMessage;
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* What to call the cause an input starts, or null for a message that
|
|
1464
|
+
* is not an input.
|
|
1465
|
+
*
|
|
1466
|
+
* Position is in the label because two presses in different places are
|
|
1467
|
+
* two different causes to the person who made them, and the ids alone
|
|
1468
|
+
* do not say which was which.
|
|
1469
|
+
*/
|
|
1470
|
+
function inputLabel(message) {
|
|
1471
|
+
switch (message?.type) {
|
|
1472
|
+
case "pointerDown":
|
|
1473
|
+
case "pointerUp":
|
|
1474
|
+
case "pointerMove": return `${message.type} (${Math.round(message.x)}, ${Math.round(message.y)})`;
|
|
1475
|
+
case "pointerCancel": return "pointerCancel";
|
|
1476
|
+
case "wheel": return `wheel (${Math.round(message.deltaX)}, ${Math.round(message.deltaY)})`;
|
|
1477
|
+
case "keyDown": return `keyDown ${message.key}`;
|
|
1478
|
+
case "keyUp": return `keyUp ${message.key}`;
|
|
1479
|
+
case "beforeInput": return `beforeInput ${message.inputType}`;
|
|
1480
|
+
case "paste": return "paste";
|
|
1481
|
+
case "semanticsAction": return `semantics ${message.action.action}`;
|
|
1482
|
+
default: return null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/NodePicker.ts
|
|
1487
|
+
/** The events a pick has to swallow for the application not to see it. */
|
|
1488
|
+
const SWALLOWED = [
|
|
1489
|
+
"pointerdown",
|
|
1490
|
+
"pointerup",
|
|
1491
|
+
"mousedown",
|
|
1492
|
+
"mouseup",
|
|
1493
|
+
"click"
|
|
1494
|
+
];
|
|
1495
|
+
function createNodePicker(options) {
|
|
1496
|
+
let listener = null;
|
|
1497
|
+
let armed = false;
|
|
1498
|
+
let attached = false;
|
|
1499
|
+
const swallow = (event) => {
|
|
1500
|
+
event.preventDefault();
|
|
1501
|
+
event.stopImmediatePropagation();
|
|
1502
|
+
if (event.type !== "pointerup") return;
|
|
1503
|
+
const id = options.hovered();
|
|
1504
|
+
if (id !== null) listener?.(id);
|
|
1505
|
+
};
|
|
1506
|
+
const attach = () => {
|
|
1507
|
+
if (attached) return;
|
|
1508
|
+
attached = true;
|
|
1509
|
+
for (const type of SWALLOWED) options.host.addEventListener(type, swallow, true);
|
|
1510
|
+
};
|
|
1511
|
+
const detach = () => {
|
|
1512
|
+
if (!attached) return;
|
|
1513
|
+
attached = false;
|
|
1514
|
+
for (const type of SWALLOWED) options.host.removeEventListener(type, swallow, true);
|
|
1515
|
+
};
|
|
1516
|
+
return {
|
|
1517
|
+
setEnabled(enabled) {
|
|
1518
|
+
armed = enabled;
|
|
1519
|
+
if (enabled) attach();
|
|
1520
|
+
else detach();
|
|
1521
|
+
},
|
|
1522
|
+
get enabled() {
|
|
1523
|
+
return armed;
|
|
1524
|
+
},
|
|
1525
|
+
onPick(next) {
|
|
1526
|
+
listener = next;
|
|
1527
|
+
},
|
|
1528
|
+
dispose() {
|
|
1529
|
+
detach();
|
|
1530
|
+
listener = null;
|
|
1531
|
+
armed = false;
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
//#endregion
|
|
1536
|
+
//#region src/NodeReportView.ts
|
|
1537
|
+
/**
|
|
1538
|
+
* A `UiNodeReport` as DOM: the node inspector's body, shared with the
|
|
1539
|
+
* devtools panel so a node read in a corner of the canvas and a node
|
|
1540
|
+
* picked from a tree are described in the same words.
|
|
1541
|
+
*/
|
|
1542
|
+
function renderNodeReport(doc, report, options = {}) {
|
|
1543
|
+
const out = [heading(doc, `${report.type} ${report.id}`), line(doc, "box", `${round(report.box.width)} × ${round(report.box.height)} at (${round(report.box.x)}, ${round(report.box.y)})`)];
|
|
1544
|
+
if (report.owners.length > 0) out.push(line(doc, "rendered by", report.owners.map((owner) => owner.name).join(" inside ")));
|
|
1545
|
+
if (report.modifiers.length > 0) out.push(line(doc, "modifiers", report.modifiers.join(", ")));
|
|
1546
|
+
if (report.listens.length > 0) out.push(line(doc, "listens", report.listens.join(", ")));
|
|
1547
|
+
if (report.beneath.length > 0) out.push(line(doc, "beneath", report.beneath.map((under) => {
|
|
1548
|
+
const owner = under.owner === void 0 ? "" : ` (${under.owner})`;
|
|
1549
|
+
const listens = under.listens.length === 0 ? "" : ` · ${under.listens.join(", ")}`;
|
|
1550
|
+
return `${under.type}${owner}${listens}`;
|
|
1551
|
+
}).join("; ")));
|
|
1552
|
+
if (report.semantics !== void 0) {
|
|
1553
|
+
const parts = [
|
|
1554
|
+
report.semantics.role,
|
|
1555
|
+
report.semantics.label === void 0 ? void 0 : `"${report.semantics.label}"`,
|
|
1556
|
+
report.semantics.value,
|
|
1557
|
+
report.semantics.states?.join(", ")
|
|
1558
|
+
].filter((part) => part !== void 0 && part !== "");
|
|
1559
|
+
if (parts.length > 0) out.push(line(doc, "semantics", parts.join(" · ")));
|
|
1560
|
+
}
|
|
1561
|
+
if (report.props.length > 0) out.push(section(doc, "props"), propList(doc, report.props, options));
|
|
1562
|
+
if (report.environment.length > 0) {
|
|
1563
|
+
const list = doc.createElement("dl");
|
|
1564
|
+
list.className = "rows";
|
|
1565
|
+
for (const entry of report.environment) {
|
|
1566
|
+
const term = doc.createElement("dt");
|
|
1567
|
+
term.textContent = entry.key;
|
|
1568
|
+
term.className = entry.provided ? "provided" : "";
|
|
1569
|
+
const value = doc.createElement("dd");
|
|
1570
|
+
value.textContent = entry.value;
|
|
1571
|
+
if (entry.provided) value.append(note(doc, "provided here"));
|
|
1572
|
+
list.append(term, value);
|
|
1573
|
+
}
|
|
1574
|
+
out.push(section(doc, "environment"), list);
|
|
1575
|
+
}
|
|
1576
|
+
const explanation = doc.createElement("pre");
|
|
1577
|
+
explanation.className = "explanation";
|
|
1578
|
+
explanation.textContent = report.explanation;
|
|
1579
|
+
out.push(section(doc, "layout"), explanation);
|
|
1580
|
+
return out;
|
|
1581
|
+
}
|
|
1582
|
+
function heading(doc, text) {
|
|
1583
|
+
const element = doc.createElement("h1");
|
|
1584
|
+
element.textContent = text;
|
|
1585
|
+
return element;
|
|
1586
|
+
}
|
|
1587
|
+
function section(doc, text) {
|
|
1588
|
+
const element = doc.createElement("h2");
|
|
1589
|
+
element.textContent = text;
|
|
1590
|
+
return element;
|
|
1591
|
+
}
|
|
1592
|
+
function line(doc, label, value) {
|
|
1593
|
+
const element = doc.createElement("p");
|
|
1594
|
+
const name = doc.createElement("span");
|
|
1595
|
+
name.className = "label";
|
|
1596
|
+
name.textContent = `${label} `;
|
|
1597
|
+
element.append(name, doc.createTextNode(value));
|
|
1598
|
+
return element;
|
|
1599
|
+
}
|
|
1600
|
+
function note(doc, text) {
|
|
1601
|
+
const element = doc.createElement("span");
|
|
1602
|
+
element.className = "note";
|
|
1603
|
+
element.textContent = ` ${text}`;
|
|
1604
|
+
return element;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* The props, with the ones a modifier or a binding is driving marked.
|
|
1608
|
+
*
|
|
1609
|
+
* The mark is the point of the list. A declared value and a value a
|
|
1610
|
+
* modifier wrote over it look identical on the node, and the second is
|
|
1611
|
+
* the one that surprises people: B1's cascade is invisible until
|
|
1612
|
+
* something says which of the two is on screen.
|
|
1613
|
+
*/
|
|
1614
|
+
function propList(doc, props, options) {
|
|
1615
|
+
const list = doc.createElement("dl");
|
|
1616
|
+
list.className = "rows";
|
|
1617
|
+
for (const prop of props) {
|
|
1618
|
+
const term = doc.createElement("dt");
|
|
1619
|
+
term.textContent = prop.name;
|
|
1620
|
+
term.className = prop.origin;
|
|
1621
|
+
const value = doc.createElement("dd");
|
|
1622
|
+
value.textContent = prop.value;
|
|
1623
|
+
if (prop.source !== void 0) value.append(note(doc, prop.source));
|
|
1624
|
+
if (prop.stream !== void 0) {
|
|
1625
|
+
const stream = doc.createElement("span");
|
|
1626
|
+
stream.className = "stream";
|
|
1627
|
+
const emittedAt = prop.stream.emittedAt;
|
|
1628
|
+
const age = emittedAt === null ? "no value yet" : `${formatAge(Math.max(0, Date.now() - emittedAt))} ago`;
|
|
1629
|
+
stream.textContent = `${`${prop.stream.emissions}×`} · ${age}${prop.stream.connected ? "" : " · disconnected"}`;
|
|
1630
|
+
stream.title = `${prop.stream.source} last emitted ${prop.stream.value}`;
|
|
1631
|
+
value.append(stream);
|
|
1632
|
+
}
|
|
1633
|
+
if (options.onEditProp !== void 0) makeEditable(doc, value, prop, options.onEditProp);
|
|
1634
|
+
list.append(term, value);
|
|
1635
|
+
}
|
|
1636
|
+
return list;
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Turns a prop's value into a field on a click, and writes it back on
|
|
1640
|
+
* Enter.
|
|
1641
|
+
*
|
|
1642
|
+
* Typed rather than stepped, because the values are of every kind a
|
|
1643
|
+
* property can hold and a spinner would only serve the numbers. The
|
|
1644
|
+
* text is read as JSON when it parses and as a plain string when it
|
|
1645
|
+
* does not, so `12`, `"#ff0000"`, `#ff0000` and `[8, 4]` all mean what
|
|
1646
|
+
* they look like. Empty removes the property.
|
|
1647
|
+
*
|
|
1648
|
+
* Escape and blur both cancel: this writes into a running application,
|
|
1649
|
+
* and a value committed by looking away is not what anyone meant.
|
|
1650
|
+
*/
|
|
1651
|
+
function makeEditable(doc, cell, prop, commit) {
|
|
1652
|
+
cell.classList.add("editable");
|
|
1653
|
+
cell.title = "Click to edit";
|
|
1654
|
+
cell.addEventListener("click", () => {
|
|
1655
|
+
if (cell.querySelector("input") !== null) return;
|
|
1656
|
+
const field = doc.createElement("input");
|
|
1657
|
+
field.type = "text";
|
|
1658
|
+
field.className = "edit";
|
|
1659
|
+
field.value = prop.value;
|
|
1660
|
+
cell.textContent = "";
|
|
1661
|
+
cell.append(field);
|
|
1662
|
+
field.focus();
|
|
1663
|
+
field.select();
|
|
1664
|
+
const cancel = () => {
|
|
1665
|
+
cell.textContent = prop.value;
|
|
1666
|
+
};
|
|
1667
|
+
field.addEventListener("keydown", (event) => {
|
|
1668
|
+
if (event.key === "Escape") {
|
|
1669
|
+
cancel();
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (event.key !== "Enter") return;
|
|
1673
|
+
const text = field.value.trim();
|
|
1674
|
+
commit(prop.name, text === "" ? null : parseValue(text));
|
|
1675
|
+
});
|
|
1676
|
+
field.addEventListener("blur", cancel);
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
/** JSON where it parses, the text itself where it does not. */
|
|
1680
|
+
function parseValue(text) {
|
|
1681
|
+
try {
|
|
1682
|
+
return JSON.parse(text);
|
|
1683
|
+
} catch {
|
|
1684
|
+
return text;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function round(value) {
|
|
1688
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* The rules the report's elements use, for any stylesheet that shows one.
|
|
1692
|
+
*
|
|
1693
|
+
* Colours are the devtools panel's custom properties with the dark
|
|
1694
|
+
* palette as fallback, so the corner inspector, which defines none of
|
|
1695
|
+
* them, is unchanged, and the panel recolours the same report by
|
|
1696
|
+
* defining them.
|
|
1697
|
+
*/
|
|
1698
|
+
const NODE_REPORT_STYLES = `
|
|
1699
|
+
h1 { margin: 0 0 6px; font-size: 12px; color: var(--gd-accent, #79c0ff); overflow-wrap: anywhere; }
|
|
1700
|
+
h2 {
|
|
1701
|
+
margin: 10px 0 4px;
|
|
1702
|
+
font-size: 10px;
|
|
1703
|
+
text-transform: uppercase;
|
|
1704
|
+
letter-spacing: 0.08em;
|
|
1705
|
+
color: var(--gd-muted, #8b949e);
|
|
1706
|
+
}
|
|
1707
|
+
p { margin: 0 0 2px; }
|
|
1708
|
+
.label { color: var(--gd-muted, #8b949e); }
|
|
1709
|
+
.rows { display: grid; grid-template-columns: auto 1fr; gap: 0 8px; margin: 0; }
|
|
1710
|
+
dt { color: var(--gd-muted, #8b949e); overflow-wrap: anywhere; }
|
|
1711
|
+
dt.modifier { color: var(--gd-purple, #d2a8ff); }
|
|
1712
|
+
dt.binding { color: var(--gd-green, #7ee787); }
|
|
1713
|
+
dt.provided { color: var(--gd-orange, #ffa657); }
|
|
1714
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
1715
|
+
dd.editable { cursor: pointer; border-radius: 3px; }
|
|
1716
|
+
dd.editable:hover { background: var(--gd-bg-hover, #21262d); }
|
|
1717
|
+
.edit {
|
|
1718
|
+
width: 100%;
|
|
1719
|
+
box-sizing: border-box;
|
|
1720
|
+
border: 1px solid var(--gd-accent, #79c0ff);
|
|
1721
|
+
border-radius: 3px;
|
|
1722
|
+
padding: 0 3px;
|
|
1723
|
+
background: var(--gd-bg, #0d1117);
|
|
1724
|
+
color: var(--gd-text, #e6edf3);
|
|
1725
|
+
font: inherit;
|
|
1726
|
+
}
|
|
1727
|
+
.note { color: var(--gd-faint, #6e7681); }
|
|
1728
|
+
.stream { display: block; color: var(--gd-green, #7ee787); }
|
|
1729
|
+
.explanation { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--gd-text-strong, #c9d1d9); }
|
|
1730
|
+
`;
|
|
1731
|
+
//#endregion
|
|
1732
|
+
//#region src/PanelProtocol.ts
|
|
1733
|
+
/**
|
|
1734
|
+
* Two ports joined in memory, delivering synchronously.
|
|
1735
|
+
*
|
|
1736
|
+
* For a panel mounted in the page it inspects, and for specs: the
|
|
1737
|
+
* hook and the panel are exercised end to end with nothing between
|
|
1738
|
+
* them but a function call.
|
|
1739
|
+
*/
|
|
1740
|
+
function createDirectPorts() {
|
|
1741
|
+
const toPanel = /* @__PURE__ */ new Set();
|
|
1742
|
+
const toPage = /* @__PURE__ */ new Set();
|
|
1743
|
+
let open = true;
|
|
1744
|
+
return {
|
|
1745
|
+
page: {
|
|
1746
|
+
post(message) {
|
|
1747
|
+
if (open) for (const listener of Array.from(toPanel)) listener(message);
|
|
1748
|
+
},
|
|
1749
|
+
onMessage(listener) {
|
|
1750
|
+
toPage.add(listener);
|
|
1751
|
+
return () => toPage.delete(listener);
|
|
1752
|
+
},
|
|
1753
|
+
close() {
|
|
1754
|
+
open = false;
|
|
1755
|
+
toPage.clear();
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
panel: {
|
|
1759
|
+
post(message) {
|
|
1760
|
+
if (open) for (const listener of Array.from(toPage)) listener(message);
|
|
1761
|
+
},
|
|
1762
|
+
onMessage(listener) {
|
|
1763
|
+
toPanel.add(listener);
|
|
1764
|
+
return () => toPanel.delete(listener);
|
|
1765
|
+
},
|
|
1766
|
+
close() {
|
|
1767
|
+
open = false;
|
|
1768
|
+
toPanel.clear();
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
const ENVELOPE_SOURCE = "gesso-devtools";
|
|
1774
|
+
function isEnvelope(value, to) {
|
|
1775
|
+
const envelope = value;
|
|
1776
|
+
return typeof envelope === "object" && envelope !== null && envelope.source === "gesso-devtools" && envelope.to === to && "message" in envelope;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* The page's end of a conversation over its own window.
|
|
1780
|
+
*
|
|
1781
|
+
* A content script shares the page's window but not its JavaScript, so
|
|
1782
|
+
* `window.postMessage` to the page's own origin is the one channel
|
|
1783
|
+
* they have. Messages are posted to that origin rather than to `*`,
|
|
1784
|
+
* and the page hears only envelopes addressed to it, so its own posts
|
|
1785
|
+
* come straight back through the same listener and are dropped.
|
|
1786
|
+
*/
|
|
1787
|
+
function windowPagePort(win) {
|
|
1788
|
+
return windowPort(win, "page", "panel");
|
|
1789
|
+
}
|
|
1790
|
+
/** The other end, for whatever sits in the page beside it (an extension's content script). */
|
|
1791
|
+
function windowPanelPort(win) {
|
|
1792
|
+
return windowPort(win, "panel", "page");
|
|
1793
|
+
}
|
|
1794
|
+
function windowPort(win, me, them) {
|
|
1795
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1796
|
+
const origin = win.location?.origin;
|
|
1797
|
+
const target = origin === void 0 || origin === "null" || origin === "" ? "*" : origin;
|
|
1798
|
+
const handle = (event) => {
|
|
1799
|
+
if (!isEnvelope(event.data, me)) return;
|
|
1800
|
+
const message = event.data.message;
|
|
1801
|
+
for (const listener of Array.from(listeners)) listener(message);
|
|
1802
|
+
};
|
|
1803
|
+
win.addEventListener("message", handle);
|
|
1804
|
+
let open = true;
|
|
1805
|
+
return {
|
|
1806
|
+
post(message) {
|
|
1807
|
+
if (open) {
|
|
1808
|
+
const envelope = {
|
|
1809
|
+
source: ENVELOPE_SOURCE,
|
|
1810
|
+
to: them,
|
|
1811
|
+
message
|
|
1812
|
+
};
|
|
1813
|
+
win.postMessage(envelope, target);
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
onMessage(listener) {
|
|
1817
|
+
listeners.add(listener);
|
|
1818
|
+
return () => listeners.delete(listener);
|
|
1819
|
+
},
|
|
1820
|
+
close() {
|
|
1821
|
+
open = false;
|
|
1822
|
+
listeners.clear();
|
|
1823
|
+
win.removeEventListener("message", handle);
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
//#endregion
|
|
1828
|
+
//#region src/DevtoolsHook.ts
|
|
1829
|
+
/** The property the hook is kept under. */
|
|
1830
|
+
const HOOK_PROPERTY = "__GESSO_DEVTOOLS__";
|
|
1831
|
+
/**
|
|
1832
|
+
* The page's hook, created on first use.
|
|
1833
|
+
*
|
|
1834
|
+
* With a window, the hook is stored on it and the `postMessage`
|
|
1835
|
+
* transport is attached once; without one (`null`), a fresh hook with
|
|
1836
|
+
* no transport, for specs and for a panel in the same page.
|
|
1837
|
+
*/
|
|
1838
|
+
function getDevtoolsHook(win = defaultWindow()) {
|
|
1839
|
+
if (win === null) return createHook();
|
|
1840
|
+
const existing = win[HOOK_PROPERTY];
|
|
1841
|
+
if (existing !== void 0) return existing;
|
|
1842
|
+
const hook = createHook();
|
|
1843
|
+
win[HOOK_PROPERTY] = hook;
|
|
1844
|
+
hook.attach(windowPagePort(win));
|
|
1845
|
+
return hook;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Connects an application to the page's devtools hook, so a panel can
|
|
1849
|
+
* find it. Returns a function that disconnects it; call it when the
|
|
1850
|
+
* application is disposed.
|
|
1851
|
+
*/
|
|
1852
|
+
function connectDevtools(app, options = {}) {
|
|
1853
|
+
const { window: win, ...rest } = options;
|
|
1854
|
+
return getDevtoolsHook(win).register(app, rest);
|
|
1855
|
+
}
|
|
1856
|
+
function createHook() {
|
|
1857
|
+
const registered = /* @__PURE__ */ new Map();
|
|
1858
|
+
const ports = /* @__PURE__ */ new Set();
|
|
1859
|
+
let nextId = 1;
|
|
1860
|
+
const infos = () => [...registered.values()].map((entry) => entry.info);
|
|
1861
|
+
const broadcast = (message) => {
|
|
1862
|
+
for (const port of Array.from(ports)) port.post(message);
|
|
1863
|
+
};
|
|
1864
|
+
const handle = (message, from) => {
|
|
1865
|
+
if (message.type === "hello") {
|
|
1866
|
+
from.post({
|
|
1867
|
+
type: "apps",
|
|
1868
|
+
apps: infos()
|
|
1869
|
+
});
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (message.type === "pick") {
|
|
1873
|
+
registered.get(message.app)?.picker?.setEnabled(message.enabled);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
registered.get(message.app)?.app.devtools(message.request);
|
|
1877
|
+
};
|
|
1878
|
+
return {
|
|
1879
|
+
get apps() {
|
|
1880
|
+
return infos();
|
|
1881
|
+
},
|
|
1882
|
+
register(app, options = {}) {
|
|
1883
|
+
const id = `app-${nextId++}`;
|
|
1884
|
+
const info = {
|
|
1885
|
+
id,
|
|
1886
|
+
name: options.name ?? defaultName()
|
|
1887
|
+
};
|
|
1888
|
+
app.onDevtools((event) => broadcast({
|
|
1889
|
+
type: "event",
|
|
1890
|
+
app: id,
|
|
1891
|
+
event
|
|
1892
|
+
}));
|
|
1893
|
+
const stopActions = forwardActions(options.actions, (entry) => broadcast({
|
|
1894
|
+
type: "action",
|
|
1895
|
+
app: id,
|
|
1896
|
+
entry
|
|
1897
|
+
}));
|
|
1898
|
+
const picker = options.picker;
|
|
1899
|
+
picker?.onPick((picked) => broadcast({
|
|
1900
|
+
type: "picked",
|
|
1901
|
+
app: id,
|
|
1902
|
+
id: picked
|
|
1903
|
+
}));
|
|
1904
|
+
const release = () => {
|
|
1905
|
+
app.onDevtools(null);
|
|
1906
|
+
stopActions();
|
|
1907
|
+
picker?.onPick(null);
|
|
1908
|
+
picker?.setEnabled(false);
|
|
1909
|
+
};
|
|
1910
|
+
registered.set(id, {
|
|
1911
|
+
info,
|
|
1912
|
+
app,
|
|
1913
|
+
picker,
|
|
1914
|
+
release
|
|
1915
|
+
});
|
|
1916
|
+
broadcast({
|
|
1917
|
+
type: "apps",
|
|
1918
|
+
apps: infos()
|
|
1919
|
+
});
|
|
1920
|
+
return () => {
|
|
1921
|
+
const entry = registered.get(id);
|
|
1922
|
+
if (entry === void 0) return;
|
|
1923
|
+
registered.delete(id);
|
|
1924
|
+
entry.release();
|
|
1925
|
+
broadcast({
|
|
1926
|
+
type: "apps",
|
|
1927
|
+
apps: infos()
|
|
1928
|
+
});
|
|
1929
|
+
};
|
|
1930
|
+
},
|
|
1931
|
+
attach(port) {
|
|
1932
|
+
ports.add(port);
|
|
1933
|
+
const stop = port.onMessage((message) => handle(message, port));
|
|
1934
|
+
return () => {
|
|
1935
|
+
stop();
|
|
1936
|
+
ports.delete(port);
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
/** Sends each new action log entry as it is recorded, when there is a log to read. */
|
|
1942
|
+
function forwardActions(log, send) {
|
|
1943
|
+
return log === void 0 ? () => {} : forwardNewEntries(log, send);
|
|
1944
|
+
}
|
|
1945
|
+
function defaultWindow() {
|
|
1946
|
+
return typeof window === "undefined" ? null : window;
|
|
1947
|
+
}
|
|
1948
|
+
function defaultName() {
|
|
1949
|
+
if (typeof document !== "undefined" && document.title !== "") return document.title;
|
|
1950
|
+
return "app";
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/FrameProfiler.ts
|
|
1954
|
+
/**
|
|
1955
|
+
* A colour per phase.
|
|
1956
|
+
*
|
|
1957
|
+
* Ordered as the phases run, and deliberately not a gradient: the
|
|
1958
|
+
* question a strip answers is "which phase is that", so the bands have
|
|
1959
|
+
* to be told apart at four pixels wide.
|
|
1960
|
+
*/
|
|
1961
|
+
const PHASE_COLORS = {
|
|
1962
|
+
ticks: "#d2a8ff",
|
|
1963
|
+
patches: "#79c0ff",
|
|
1964
|
+
environment: "#56d364",
|
|
1965
|
+
virtualize: "#e3b341",
|
|
1966
|
+
layout: "#f0883e",
|
|
1967
|
+
semantics: "#ff7b72",
|
|
1968
|
+
render: "#a5d6ff"
|
|
1969
|
+
};
|
|
1970
|
+
/** Above this, a frame missed a 60 Hz deadline. */
|
|
1971
|
+
const BUDGET_MS = 16.7;
|
|
1972
|
+
function mountFrameProfiler(host, options = {}) {
|
|
1973
|
+
const doc = host.ownerDocument;
|
|
1974
|
+
const view = doc.defaultView;
|
|
1975
|
+
const capacity = options.history ?? 180;
|
|
1976
|
+
const redrawMs = options.redrawMs ?? 250;
|
|
1977
|
+
const docked = options.layout === "docked";
|
|
1978
|
+
const position = docked ? "relative" : view?.getComputedStyle(host).position ?? "static";
|
|
1979
|
+
const restore = position === "static" ? host.style.position : null;
|
|
1980
|
+
if (position === "static") host.style.position = "relative";
|
|
1981
|
+
const container = doc.createElement("div");
|
|
1982
|
+
const root = container.attachShadow({ mode: "open" });
|
|
1983
|
+
const style = doc.createElement("style");
|
|
1984
|
+
style.textContent = STYLES$2;
|
|
1985
|
+
const panel = doc.createElement("div");
|
|
1986
|
+
panel.className = docked ? "panel docked" : `panel ${options.corner ?? "top-right"}`;
|
|
1987
|
+
panel.hidden = true;
|
|
1988
|
+
const readout = doc.createElement("div");
|
|
1989
|
+
readout.className = "readout";
|
|
1990
|
+
const strip = doc.createElement("canvas");
|
|
1991
|
+
strip.className = "strip";
|
|
1992
|
+
strip.width = capacity * 2;
|
|
1993
|
+
strip.height = 96;
|
|
1994
|
+
const legend = doc.createElement("div");
|
|
1995
|
+
legend.className = "legend";
|
|
1996
|
+
panel.append(readout, strip, legend);
|
|
1997
|
+
root.append(style, panel);
|
|
1998
|
+
host.appendChild(container);
|
|
1999
|
+
const samples = [];
|
|
2000
|
+
let previousAt = null;
|
|
2001
|
+
let visible = false;
|
|
2002
|
+
let timer = null;
|
|
2003
|
+
const draw = () => {
|
|
2004
|
+
const ctx = strip.getContext("2d");
|
|
2005
|
+
if (ctx === null || samples.length === 0) return;
|
|
2006
|
+
const width = strip.width;
|
|
2007
|
+
const height = strip.height;
|
|
2008
|
+
ctx.clearRect(0, 0, width, height);
|
|
2009
|
+
const peak = Math.max(BUDGET_MS, ...samples.map((sample) => sample.total));
|
|
2010
|
+
const barWidth = width / capacity;
|
|
2011
|
+
samples.forEach((sample, index) => {
|
|
2012
|
+
const x = index * barWidth;
|
|
2013
|
+
let y = height;
|
|
2014
|
+
for (const phase of UI_FRAME_PHASES) {
|
|
2015
|
+
const ms = sample.phases[phase];
|
|
2016
|
+
if (ms <= 0) continue;
|
|
2017
|
+
const bar = ms / peak * height;
|
|
2018
|
+
ctx.fillStyle = PHASE_COLORS[phase];
|
|
2019
|
+
ctx.fillRect(x, y - bar, Math.max(1, barWidth - .5), bar);
|
|
2020
|
+
y -= bar;
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
const budgetY = height - BUDGET_MS / peak * height;
|
|
2024
|
+
ctx.strokeStyle = view?.getComputedStyle(panel).getPropertyValue("--gd-budget").trim() || "rgba(255, 255, 255, 0.35)";
|
|
2025
|
+
ctx.lineWidth = 1;
|
|
2026
|
+
ctx.beginPath();
|
|
2027
|
+
ctx.moveTo(0, budgetY + .5);
|
|
2028
|
+
ctx.lineTo(width, budgetY + .5);
|
|
2029
|
+
ctx.stroke();
|
|
2030
|
+
const totals = summarize(samples);
|
|
2031
|
+
readout.textContent = "";
|
|
2032
|
+
readout.append(stat(doc, "fps", totals.fps.toFixed(0)), stat(doc, "frame", `${totals.meanTotal.toFixed(2)} ms`), stat(doc, "worst", `${totals.worstTotal.toFixed(1)} ms`), stat(doc, "gap", `${totals.worstGap.toFixed(0)} ms`), stat(doc, "input", totals.worstInput === null ? "—" : `${totals.worstInput.toFixed(0)} ms`), stat(doc, "peak", `${peak.toFixed(1)} ms`));
|
|
2033
|
+
legend.textContent = "";
|
|
2034
|
+
for (const phase of UI_FRAME_PHASES) {
|
|
2035
|
+
const worst = totals.worstPhase[phase];
|
|
2036
|
+
if (worst <= 0) continue;
|
|
2037
|
+
const entry = doc.createElement("span");
|
|
2038
|
+
entry.className = "entry";
|
|
2039
|
+
const swatch = doc.createElement("i");
|
|
2040
|
+
swatch.style.background = PHASE_COLORS[phase];
|
|
2041
|
+
entry.append(swatch, doc.createTextNode(`${phase} ${worst.toFixed(2)}`));
|
|
2042
|
+
legend.append(entry);
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
return {
|
|
2046
|
+
report(metrics) {
|
|
2047
|
+
const gap = previousAt === null ? 0 : metrics.at - previousAt;
|
|
2048
|
+
previousAt = metrics.at;
|
|
2049
|
+
samples.push({
|
|
2050
|
+
phases: metrics.phases,
|
|
2051
|
+
total: metrics.durationMs,
|
|
2052
|
+
gap,
|
|
2053
|
+
input: metrics.inputLatencyMs
|
|
2054
|
+
});
|
|
2055
|
+
if (samples.length > capacity) samples.splice(0, samples.length - capacity);
|
|
2056
|
+
},
|
|
2057
|
+
get visible() {
|
|
2058
|
+
return visible;
|
|
2059
|
+
},
|
|
2060
|
+
setVisible(next) {
|
|
2061
|
+
if (visible === next) return;
|
|
2062
|
+
visible = next;
|
|
2063
|
+
panel.hidden = !next;
|
|
2064
|
+
if (next) {
|
|
2065
|
+
draw();
|
|
2066
|
+
timer = setInterval(draw, redrawMs);
|
|
2067
|
+
} else if (timer !== null) {
|
|
2068
|
+
clearInterval(timer);
|
|
2069
|
+
timer = null;
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
dispose() {
|
|
2073
|
+
if (timer !== null) clearInterval(timer);
|
|
2074
|
+
container.remove();
|
|
2075
|
+
if (restore !== null) host.style.position = restore;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* The window's numbers.
|
|
2081
|
+
*
|
|
2082
|
+
* Peak rather than mean for the phases, for the reason the playground's
|
|
2083
|
+
* status line already gives: patches and environment run on a small
|
|
2084
|
+
* minority of frames, so a mean would report them as idle on exactly
|
|
2085
|
+
* the frames where they were the cost.
|
|
2086
|
+
*/
|
|
2087
|
+
function summarize(samples) {
|
|
2088
|
+
const worstPhase = {
|
|
2089
|
+
ticks: 0,
|
|
2090
|
+
patches: 0,
|
|
2091
|
+
environment: 0,
|
|
2092
|
+
virtualize: 0,
|
|
2093
|
+
layout: 0,
|
|
2094
|
+
semantics: 0,
|
|
2095
|
+
render: 0
|
|
2096
|
+
};
|
|
2097
|
+
let totalSum = 0;
|
|
2098
|
+
let worstTotal = 0;
|
|
2099
|
+
let worstGap = 0;
|
|
2100
|
+
let worstInput = null;
|
|
2101
|
+
let gapSum = 0;
|
|
2102
|
+
let gaps = 0;
|
|
2103
|
+
for (const sample of samples) {
|
|
2104
|
+
totalSum += sample.total;
|
|
2105
|
+
worstTotal = Math.max(worstTotal, sample.total);
|
|
2106
|
+
if (sample.gap > 0) {
|
|
2107
|
+
worstGap = Math.max(worstGap, sample.gap);
|
|
2108
|
+
gapSum += sample.gap;
|
|
2109
|
+
gaps++;
|
|
2110
|
+
}
|
|
2111
|
+
if (sample.input !== null) worstInput = Math.max(worstInput ?? 0, sample.input);
|
|
2112
|
+
for (const phase of UI_FRAME_PHASES) worstPhase[phase] = Math.max(worstPhase[phase], sample.phases[phase]);
|
|
2113
|
+
}
|
|
2114
|
+
return {
|
|
2115
|
+
fps: gaps === 0 ? 0 : 1e3 / (gapSum / gaps),
|
|
2116
|
+
meanTotal: samples.length === 0 ? 0 : totalSum / samples.length,
|
|
2117
|
+
worstTotal,
|
|
2118
|
+
worstGap,
|
|
2119
|
+
worstInput,
|
|
2120
|
+
worstPhase
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
function stat(doc, label, value) {
|
|
2124
|
+
const element = doc.createElement("span");
|
|
2125
|
+
element.className = "stat";
|
|
2126
|
+
const name = doc.createElement("i");
|
|
2127
|
+
name.textContent = label;
|
|
2128
|
+
element.append(name, doc.createTextNode(value));
|
|
2129
|
+
return element;
|
|
2130
|
+
}
|
|
2131
|
+
const STYLES$2 = `
|
|
2132
|
+
:host { all: initial; }
|
|
2133
|
+
.panel {
|
|
2134
|
+
position: absolute;
|
|
2135
|
+
z-index: 2147482000;
|
|
2136
|
+
width: 300px;
|
|
2137
|
+
pointer-events: none;
|
|
2138
|
+
margin: 12px;
|
|
2139
|
+
padding: 8px 10px;
|
|
2140
|
+
border-radius: 8px;
|
|
2141
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
2142
|
+
background: rgba(13, 17, 23, 0.94);
|
|
2143
|
+
color: #e6edf3;
|
|
2144
|
+
font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2145
|
+
}
|
|
2146
|
+
.panel[hidden] { display: none; }
|
|
2147
|
+
.panel.docked { position: static; width: auto; margin: 0; pointer-events: auto; border: none; background: transparent; color: var(--gd-text, #e6edf3); }
|
|
2148
|
+
.panel.docked .stat i, .panel.docked .legend { color: var(--gd-muted, #8b949e); }
|
|
2149
|
+
.panel.docked .strip { height: 96px; }
|
|
2150
|
+
.top-left { top: 0; left: 0; }
|
|
2151
|
+
.top-right { top: 0; right: 0; }
|
|
2152
|
+
.bottom-left { bottom: 0; left: 0; }
|
|
2153
|
+
.bottom-right { bottom: 0; right: 0; }
|
|
2154
|
+
.strip { display: block; width: 100%; height: 48px; margin: 6px 0; }
|
|
2155
|
+
.readout { display: flex; flex-wrap: wrap; gap: 2px 10px; }
|
|
2156
|
+
.stat i { color: #8b949e; font-style: normal; margin-right: 4px; }
|
|
2157
|
+
.legend { display: flex; flex-wrap: wrap; gap: 2px 8px; color: #8b949e; }
|
|
2158
|
+
.entry { display: inline-flex; align-items: center; gap: 3px; }
|
|
2159
|
+
.entry i { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
|
|
2160
|
+
`;
|
|
2161
|
+
//#endregion
|
|
2162
|
+
//#region src/TreeRows.ts
|
|
2163
|
+
/** The rows of the tree with `expanded` nodes unfolded, in document order. */
|
|
2164
|
+
function treeRows(root, expanded) {
|
|
2165
|
+
const rows = [];
|
|
2166
|
+
const visit = (node, depth, owner) => {
|
|
2167
|
+
const own = node.component ?? owner;
|
|
2168
|
+
const expandable = node.children.length > 0;
|
|
2169
|
+
const isExpanded = expandable && expanded.has(node.id);
|
|
2170
|
+
rows.push({
|
|
2171
|
+
node,
|
|
2172
|
+
depth,
|
|
2173
|
+
expandable,
|
|
2174
|
+
expanded: isExpanded,
|
|
2175
|
+
...own === void 0 ? {} : { owner: own }
|
|
2176
|
+
});
|
|
2177
|
+
if (isExpanded) for (const child of node.children) visit(child, depth + 1, own);
|
|
2178
|
+
};
|
|
2179
|
+
visit(root, 0, void 0);
|
|
2180
|
+
return rows;
|
|
2181
|
+
}
|
|
2182
|
+
/** The ids of every node with children down to `depth` levels, for a first unfolding. */
|
|
2183
|
+
function idsToDepth(root, depth) {
|
|
2184
|
+
const ids = [];
|
|
2185
|
+
const visit = (node, level) => {
|
|
2186
|
+
if (level >= depth || node.children.length === 0) return;
|
|
2187
|
+
ids.push(node.id);
|
|
2188
|
+
for (const child of node.children) visit(child, level + 1);
|
|
2189
|
+
};
|
|
2190
|
+
visit(root, 0);
|
|
2191
|
+
return ids;
|
|
2192
|
+
}
|
|
2193
|
+
/** The ids of the ancestors of `id`, root first, or null when the tree has no such node. */
|
|
2194
|
+
function pathTo(root, id) {
|
|
2195
|
+
const path = [];
|
|
2196
|
+
const visit = (node) => {
|
|
2197
|
+
if (node.id === id) return true;
|
|
2198
|
+
path.push(node.id);
|
|
2199
|
+
for (const child of node.children) if (visit(child)) return true;
|
|
2200
|
+
path.pop();
|
|
2201
|
+
return false;
|
|
2202
|
+
};
|
|
2203
|
+
return visit(root) ? path : null;
|
|
2204
|
+
}
|
|
2205
|
+
/** One row's label, as the tree prints it. */
|
|
2206
|
+
function rowLabel(row) {
|
|
2207
|
+
const { node } = row;
|
|
2208
|
+
const parts = [node.type];
|
|
2209
|
+
if (node.component !== void 0) parts.push(`<${node.component}>`);
|
|
2210
|
+
if (node.text !== void 0) parts.push(`"${node.text}"`);
|
|
2211
|
+
return parts.join(" ");
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/DevtoolsPanel.ts
|
|
2215
|
+
const TABS = [
|
|
2216
|
+
{
|
|
2217
|
+
id: "tree",
|
|
2218
|
+
label: "Tree"
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
id: "streams",
|
|
2222
|
+
label: "Streams"
|
|
2223
|
+
},
|
|
2224
|
+
{
|
|
2225
|
+
id: "console",
|
|
2226
|
+
label: "Console"
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
id: "frames",
|
|
2230
|
+
label: "Frames"
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
id: "actions",
|
|
2234
|
+
label: "Actions"
|
|
2235
|
+
}
|
|
2236
|
+
];
|
|
2237
|
+
function mountDevtoolsPanel(host, port, options = {}) {
|
|
2238
|
+
const doc = host.ownerDocument;
|
|
2239
|
+
const openDepth = options.openDepth ?? 4;
|
|
2240
|
+
const consoleLimit = options.consoleLimit ?? 500;
|
|
2241
|
+
const actionLimit = options.actionLimit ?? 300;
|
|
2242
|
+
const container = doc.createElement("div");
|
|
2243
|
+
container.className = "gesso-devtools";
|
|
2244
|
+
const applyTheme = (theme) => {
|
|
2245
|
+
if (theme === "auto") delete container.dataset["theme"];
|
|
2246
|
+
else container.dataset["theme"] = theme;
|
|
2247
|
+
};
|
|
2248
|
+
applyTheme(options.theme ?? "auto");
|
|
2249
|
+
const root = container.attachShadow({ mode: "open" });
|
|
2250
|
+
const style = doc.createElement("style");
|
|
2251
|
+
style.textContent = STYLES$1;
|
|
2252
|
+
root.append(style);
|
|
2253
|
+
host.appendChild(container);
|
|
2254
|
+
const toolbar = el(doc, "div", "toolbar");
|
|
2255
|
+
const picker = doc.createElement("select");
|
|
2256
|
+
picker.className = "picker";
|
|
2257
|
+
picker.hidden = true;
|
|
2258
|
+
const tabs = el(doc, "div", "tabs");
|
|
2259
|
+
const tabButtons = /* @__PURE__ */ new Map();
|
|
2260
|
+
for (const tab of TABS) {
|
|
2261
|
+
const button = doc.createElement("button");
|
|
2262
|
+
button.type = "button";
|
|
2263
|
+
button.textContent = tab.label;
|
|
2264
|
+
button.addEventListener("click", () => showTab(tab.id));
|
|
2265
|
+
tabButtons.set(tab.id, button);
|
|
2266
|
+
tabs.append(button);
|
|
2267
|
+
}
|
|
2268
|
+
const inspectToggle = doc.createElement("label");
|
|
2269
|
+
inspectToggle.className = "toggle";
|
|
2270
|
+
const inspectBox = doc.createElement("input");
|
|
2271
|
+
inspectBox.type = "checkbox";
|
|
2272
|
+
inspectToggle.append(inspectBox, doc.createTextNode(" Inspect layout"));
|
|
2273
|
+
const pickToggle = doc.createElement("label");
|
|
2274
|
+
pickToggle.className = "toggle";
|
|
2275
|
+
const pickBox = doc.createElement("input");
|
|
2276
|
+
pickBox.type = "checkbox";
|
|
2277
|
+
pickToggle.append(pickBox, doc.createTextNode(" Pick"));
|
|
2278
|
+
pickToggle.title = "Click a node on the canvas to select it here. The click does not reach the application.";
|
|
2279
|
+
const marksToggle = doc.createElement("label");
|
|
2280
|
+
marksToggle.className = "toggle";
|
|
2281
|
+
const marksBox = doc.createElement("input");
|
|
2282
|
+
marksBox.type = "checkbox";
|
|
2283
|
+
marksToggle.append(marksBox, doc.createTextNode(" Marks"));
|
|
2284
|
+
marksToggle.title = "Name the frame phases and the channel traffic for the browser's own profiler, so a performance recording shows what Gesso was doing. Off by default: a frame nobody is profiling pays nothing.";
|
|
2285
|
+
const status = el(doc, "span", "status");
|
|
2286
|
+
toolbar.append(picker, tabs, inspectToggle, pickToggle, marksToggle, status);
|
|
2287
|
+
root.append(toolbar);
|
|
2288
|
+
const views = el(doc, "div", "views");
|
|
2289
|
+
const treeView = el(doc, "div", "view tree-view");
|
|
2290
|
+
const treePane = el(doc, "div", "tree");
|
|
2291
|
+
const reportPane = el(doc, "div", "report");
|
|
2292
|
+
treeView.append(treePane, reportPane);
|
|
2293
|
+
const consoleView = el(doc, "div", "view console-view");
|
|
2294
|
+
const consoleList = el(doc, "ol", "log");
|
|
2295
|
+
const consoleBar = el(doc, "div", "bar");
|
|
2296
|
+
const clearConsole = doc.createElement("button");
|
|
2297
|
+
clearConsole.type = "button";
|
|
2298
|
+
clearConsole.textContent = "Clear";
|
|
2299
|
+
consoleBar.append(clearConsole);
|
|
2300
|
+
consoleView.append(consoleBar, consoleList);
|
|
2301
|
+
const framesView = el(doc, "div", "view frames-view");
|
|
2302
|
+
const actionsView = el(doc, "div", "view actions-view");
|
|
2303
|
+
const actionsList = el(doc, "ol", "log");
|
|
2304
|
+
actionsView.append(actionsList);
|
|
2305
|
+
const streamsView = el(doc, "div", "view streams-view");
|
|
2306
|
+
const streamsBar = el(doc, "div", "bar");
|
|
2307
|
+
const streamsTotal = el(doc, "span", "total");
|
|
2308
|
+
const resetStreams = doc.createElement("button");
|
|
2309
|
+
resetStreams.type = "button";
|
|
2310
|
+
resetStreams.textContent = "Mark";
|
|
2311
|
+
resetStreams.title = "Take the counts as they are now, so what follows reads as a change from here.";
|
|
2312
|
+
streamsBar.append(streamsTotal, resetStreams);
|
|
2313
|
+
const streamsList = el(doc, "div", "streams");
|
|
2314
|
+
streamsView.append(streamsBar, streamsList);
|
|
2315
|
+
views.append(treeView, streamsView, consoleView, framesView, actionsView);
|
|
2316
|
+
root.append(views);
|
|
2317
|
+
const viewFor = {
|
|
2318
|
+
tree: treeView,
|
|
2319
|
+
streams: streamsView,
|
|
2320
|
+
console: consoleView,
|
|
2321
|
+
frames: framesView,
|
|
2322
|
+
actions: actionsView
|
|
2323
|
+
};
|
|
2324
|
+
let apps = [];
|
|
2325
|
+
let current = null;
|
|
2326
|
+
let tab = "tree";
|
|
2327
|
+
let snapshot = null;
|
|
2328
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
2329
|
+
let openedOnce = false;
|
|
2330
|
+
let selectedId = null;
|
|
2331
|
+
let hoveredOnCanvas = null;
|
|
2332
|
+
let highlighted = null;
|
|
2333
|
+
let report = null;
|
|
2334
|
+
const consoleEntries = [];
|
|
2335
|
+
const actionEntries = [];
|
|
2336
|
+
let actionOrigin = null;
|
|
2337
|
+
let profiler = null;
|
|
2338
|
+
/**
|
|
2339
|
+
* Counts as of the last "Mark", by component anchor.
|
|
2340
|
+
*
|
|
2341
|
+
* A leak is not a number, it is a number that climbs: forty
|
|
2342
|
+
* subscriptions under a list is either right or wrong depending on
|
|
2343
|
+
* the list, and forty more after navigating away and back is wrong
|
|
2344
|
+
* whatever the list. So the view shows the count and the change
|
|
2345
|
+
* since a moment the person chose.
|
|
2346
|
+
*/
|
|
2347
|
+
let streamBaseline = /* @__PURE__ */ new Map();
|
|
2348
|
+
let baselineTotal = null;
|
|
2349
|
+
const request = (message) => {
|
|
2350
|
+
if (current !== null) port.post({
|
|
2351
|
+
type: "request",
|
|
2352
|
+
app: current.id,
|
|
2353
|
+
request: message
|
|
2354
|
+
});
|
|
2355
|
+
};
|
|
2356
|
+
const subscribe = () => {
|
|
2357
|
+
request({
|
|
2358
|
+
kind: "console",
|
|
2359
|
+
enabled: true
|
|
2360
|
+
});
|
|
2361
|
+
request({
|
|
2362
|
+
kind: "watchTree",
|
|
2363
|
+
enabled: true
|
|
2364
|
+
});
|
|
2365
|
+
request({
|
|
2366
|
+
kind: "watchFrames",
|
|
2367
|
+
enabled: tab === "frames"
|
|
2368
|
+
});
|
|
2369
|
+
request({
|
|
2370
|
+
kind: "inspector",
|
|
2371
|
+
enabled: inspectBox.checked
|
|
2372
|
+
});
|
|
2373
|
+
};
|
|
2374
|
+
const unsubscribe = () => {
|
|
2375
|
+
request({
|
|
2376
|
+
kind: "console",
|
|
2377
|
+
enabled: false
|
|
2378
|
+
});
|
|
2379
|
+
request({
|
|
2380
|
+
kind: "watchTree",
|
|
2381
|
+
enabled: false
|
|
2382
|
+
});
|
|
2383
|
+
request({
|
|
2384
|
+
kind: "watchFrames",
|
|
2385
|
+
enabled: false
|
|
2386
|
+
});
|
|
2387
|
+
request({
|
|
2388
|
+
kind: "inspector",
|
|
2389
|
+
enabled: false
|
|
2390
|
+
});
|
|
2391
|
+
request({
|
|
2392
|
+
kind: "select",
|
|
2393
|
+
id: null
|
|
2394
|
+
});
|
|
2395
|
+
request({
|
|
2396
|
+
kind: "highlight",
|
|
2397
|
+
id: null
|
|
2398
|
+
});
|
|
2399
|
+
request({
|
|
2400
|
+
kind: "marks",
|
|
2401
|
+
enabled: false
|
|
2402
|
+
});
|
|
2403
|
+
if (current !== null && pickBox.checked) port.post({
|
|
2404
|
+
type: "pick",
|
|
2405
|
+
app: current.id,
|
|
2406
|
+
enabled: false
|
|
2407
|
+
});
|
|
2408
|
+
};
|
|
2409
|
+
const setStatus = (text) => {
|
|
2410
|
+
status.textContent = text;
|
|
2411
|
+
};
|
|
2412
|
+
const showApp = (info) => {
|
|
2413
|
+
if (current?.id === info?.id) return;
|
|
2414
|
+
if (current !== null) unsubscribe();
|
|
2415
|
+
current = info;
|
|
2416
|
+
snapshot = null;
|
|
2417
|
+
expanded.clear();
|
|
2418
|
+
openedOnce = false;
|
|
2419
|
+
selectedId = null;
|
|
2420
|
+
highlighted = null;
|
|
2421
|
+
hoveredOnCanvas = null;
|
|
2422
|
+
report = null;
|
|
2423
|
+
consoleEntries.length = 0;
|
|
2424
|
+
actionEntries.length = 0;
|
|
2425
|
+
actionOrigin = null;
|
|
2426
|
+
streamBaseline = /* @__PURE__ */ new Map();
|
|
2427
|
+
baselineTotal = null;
|
|
2428
|
+
renderTree();
|
|
2429
|
+
renderReport();
|
|
2430
|
+
renderConsole();
|
|
2431
|
+
renderActions();
|
|
2432
|
+
renderStreams();
|
|
2433
|
+
if (info === null) {
|
|
2434
|
+
setStatus("No Gesso application connected. Call connectDevtools(app) in the page.");
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
setStatus("");
|
|
2438
|
+
picker.value = info.id;
|
|
2439
|
+
subscribe();
|
|
2440
|
+
};
|
|
2441
|
+
const showTab = (next) => {
|
|
2442
|
+
tab = next;
|
|
2443
|
+
for (const [id, button] of tabButtons) button.className = id === next ? "active" : "";
|
|
2444
|
+
for (const [id, view] of Object.entries(viewFor)) view.hidden = id !== next;
|
|
2445
|
+
request({
|
|
2446
|
+
kind: "watchFrames",
|
|
2447
|
+
enabled: next === "frames"
|
|
2448
|
+
});
|
|
2449
|
+
if (next === "frames") {
|
|
2450
|
+
profiler ??= mountFrameProfiler(framesView, { layout: "docked" });
|
|
2451
|
+
profiler.setVisible(true);
|
|
2452
|
+
} else profiler?.setVisible(false);
|
|
2453
|
+
};
|
|
2454
|
+
const renderTree = () => {
|
|
2455
|
+
treePane.textContent = "";
|
|
2456
|
+
if (snapshot === null) return;
|
|
2457
|
+
if (!openedOnce) {
|
|
2458
|
+
openedOnce = true;
|
|
2459
|
+
for (const id of idsToDepth(snapshot.root, openDepth)) expanded.add(id);
|
|
2460
|
+
}
|
|
2461
|
+
const rows = treeRows(snapshot.root, expanded);
|
|
2462
|
+
for (const row of rows) {
|
|
2463
|
+
const line = el(doc, "div", "row");
|
|
2464
|
+
if (row.node.id === selectedId) line.classList.add("selected");
|
|
2465
|
+
if (row.node.id === hoveredOnCanvas) line.classList.add("hovered");
|
|
2466
|
+
line.style.paddingLeft = `${8 + row.depth * 12}px`;
|
|
2467
|
+
line.dataset["id"] = row.node.id;
|
|
2468
|
+
const disclosure = el(doc, "span", "disclosure");
|
|
2469
|
+
disclosure.textContent = row.expandable ? row.expanded ? "▾" : "▸" : "";
|
|
2470
|
+
disclosure.addEventListener("click", (event) => {
|
|
2471
|
+
event.stopPropagation();
|
|
2472
|
+
if (expanded.has(row.node.id)) expanded.delete(row.node.id);
|
|
2473
|
+
else expanded.add(row.node.id);
|
|
2474
|
+
renderTree();
|
|
2475
|
+
});
|
|
2476
|
+
const type = el(doc, "span", "type");
|
|
2477
|
+
type.textContent = row.node.type;
|
|
2478
|
+
line.append(disclosure, type);
|
|
2479
|
+
if (row.node.component !== void 0) {
|
|
2480
|
+
const component = el(doc, "span", "component");
|
|
2481
|
+
component.textContent = `<${row.node.component}>`;
|
|
2482
|
+
line.append(component);
|
|
2483
|
+
}
|
|
2484
|
+
if (row.node.text !== void 0) {
|
|
2485
|
+
const text = el(doc, "span", "text");
|
|
2486
|
+
text.textContent = `"${row.node.text}"`;
|
|
2487
|
+
line.append(text);
|
|
2488
|
+
}
|
|
2489
|
+
const id = el(doc, "span", "id");
|
|
2490
|
+
id.textContent = row.node.id;
|
|
2491
|
+
line.append(id);
|
|
2492
|
+
line.title = rowLabel(row) + (row.owner === void 0 ? "" : ` · rendered by ${row.owner}`);
|
|
2493
|
+
line.addEventListener("mouseenter", () => setHighlight(row.node.id));
|
|
2494
|
+
line.addEventListener("click", () => select(row.node.id));
|
|
2495
|
+
treePane.append(line);
|
|
2496
|
+
}
|
|
2497
|
+
const count = el(doc, "div", "count");
|
|
2498
|
+
count.textContent = `${snapshot.nodes} nodes`;
|
|
2499
|
+
treePane.append(count);
|
|
2500
|
+
};
|
|
2501
|
+
treePane.addEventListener("mouseleave", () => setHighlight(null));
|
|
2502
|
+
const setHighlight = (id) => {
|
|
2503
|
+
if (highlighted === id) return;
|
|
2504
|
+
highlighted = id;
|
|
2505
|
+
request({
|
|
2506
|
+
kind: "highlight",
|
|
2507
|
+
id
|
|
2508
|
+
});
|
|
2509
|
+
};
|
|
2510
|
+
const select = (id) => {
|
|
2511
|
+
selectedId = id;
|
|
2512
|
+
request({
|
|
2513
|
+
kind: "select",
|
|
2514
|
+
id
|
|
2515
|
+
});
|
|
2516
|
+
if (id === null) {
|
|
2517
|
+
report = null;
|
|
2518
|
+
renderReport();
|
|
2519
|
+
}
|
|
2520
|
+
renderTree();
|
|
2521
|
+
};
|
|
2522
|
+
const reveal = (id) => {
|
|
2523
|
+
if (snapshot === null) return;
|
|
2524
|
+
const path = pathTo(snapshot.root, id);
|
|
2525
|
+
if (path === null) return;
|
|
2526
|
+
for (const ancestor of path) expanded.add(ancestor);
|
|
2527
|
+
renderTree();
|
|
2528
|
+
[...treePane.children].find((child) => child.dataset["id"] === id)?.scrollIntoView?.({ block: "nearest" });
|
|
2529
|
+
};
|
|
2530
|
+
/**
|
|
2531
|
+
* Live subscriptions per component, from the tree.
|
|
2532
|
+
*
|
|
2533
|
+
* Aggregated here rather than reported per component by the runtime,
|
|
2534
|
+
* because the tree already says which component a node belongs to
|
|
2535
|
+
* and already arrives whenever the count changes. Nodes above every
|
|
2536
|
+
* component anchor are the runtime's own and are listed under the
|
|
2537
|
+
* root.
|
|
2538
|
+
*/
|
|
2539
|
+
const componentStreams = () => {
|
|
2540
|
+
if (snapshot === null) return [];
|
|
2541
|
+
const totals = /* @__PURE__ */ new Map();
|
|
2542
|
+
const visit = (node, owner) => {
|
|
2543
|
+
const here = node.component === void 0 ? owner : totals.get(node.id) ?? {
|
|
2544
|
+
name: node.component,
|
|
2545
|
+
anchorId: node.id,
|
|
2546
|
+
count: 0
|
|
2547
|
+
};
|
|
2548
|
+
if (node.component !== void 0) totals.set(node.id, here);
|
|
2549
|
+
here.count += node.subscriptions ?? 0;
|
|
2550
|
+
for (const child of node.children) visit(child, here);
|
|
2551
|
+
};
|
|
2552
|
+
const root = {
|
|
2553
|
+
name: "(root)",
|
|
2554
|
+
anchorId: snapshot.root.id,
|
|
2555
|
+
count: 0
|
|
2556
|
+
};
|
|
2557
|
+
totals.set(snapshot.root.id, root);
|
|
2558
|
+
visit(snapshot.root, root);
|
|
2559
|
+
return [...totals.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
2560
|
+
};
|
|
2561
|
+
const renderStreams = () => {
|
|
2562
|
+
streamsList.textContent = "";
|
|
2563
|
+
if (snapshot === null) {
|
|
2564
|
+
streamsTotal.textContent = "Waiting for a tree.";
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const change = baselineTotal === null ? "" : ` (${signed(snapshot.subscriptions - baselineTotal)} since the mark)`;
|
|
2568
|
+
streamsTotal.textContent = `${snapshot.subscriptions} live subscriptions in the graph${change}`;
|
|
2569
|
+
const rows = componentStreams();
|
|
2570
|
+
if (rows.length === 0) {
|
|
2571
|
+
const empty = el(doc, "p", "empty");
|
|
2572
|
+
empty.textContent = "Nothing is subscribed.";
|
|
2573
|
+
streamsList.append(empty);
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
const largest = rows[0]?.count ?? 0;
|
|
2577
|
+
for (const entry of rows) {
|
|
2578
|
+
const row = el(doc, "div", "stream-row");
|
|
2579
|
+
const name = el(doc, "span", "component");
|
|
2580
|
+
name.textContent = entry.name;
|
|
2581
|
+
const bar = el(doc, "span", "bar-track");
|
|
2582
|
+
const fill = el(doc, "span", "bar-fill");
|
|
2583
|
+
fill.style.width = `${largest === 0 ? 0 : Math.round(entry.count / largest * 100)}%`;
|
|
2584
|
+
bar.append(fill);
|
|
2585
|
+
const count = el(doc, "span", "count");
|
|
2586
|
+
const before = streamBaseline.get(entry.anchorId);
|
|
2587
|
+
const delta = before === void 0 || before === entry.count ? "" : ` ${signed(entry.count - before)}`;
|
|
2588
|
+
count.textContent = `${entry.count}${delta}`;
|
|
2589
|
+
if (delta !== "") count.classList.add("changed");
|
|
2590
|
+
row.append(name, bar, count);
|
|
2591
|
+
row.title = entry.anchorId;
|
|
2592
|
+
row.addEventListener("mouseenter", () => setHighlight(entry.anchorId));
|
|
2593
|
+
row.addEventListener("click", () => {
|
|
2594
|
+
showTab("tree");
|
|
2595
|
+
reveal(entry.anchorId);
|
|
2596
|
+
select(entry.anchorId);
|
|
2597
|
+
});
|
|
2598
|
+
streamsList.append(row);
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2601
|
+
streamsList.addEventListener("mouseleave", () => setHighlight(null));
|
|
2602
|
+
resetStreams.addEventListener("click", () => {
|
|
2603
|
+
streamBaseline = new Map(componentStreams().map((entry) => [entry.anchorId, entry.count]));
|
|
2604
|
+
baselineTotal = snapshot?.subscriptions ?? null;
|
|
2605
|
+
renderStreams();
|
|
2606
|
+
});
|
|
2607
|
+
const renderReport = () => {
|
|
2608
|
+
reportPane.textContent = "";
|
|
2609
|
+
if (report === null) {
|
|
2610
|
+
const empty = el(doc, "p", "empty");
|
|
2611
|
+
empty.textContent = selectedId === null ? "Select a node in the tree to read its report." : "The selected node has left the tree.";
|
|
2612
|
+
reportPane.append(empty);
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
const id = report.id;
|
|
2616
|
+
reportPane.append(...renderNodeReport(doc, report, { onEditProp: (name, value) => request({
|
|
2617
|
+
kind: "setProp",
|
|
2618
|
+
id,
|
|
2619
|
+
name,
|
|
2620
|
+
value
|
|
2621
|
+
}) }));
|
|
2622
|
+
};
|
|
2623
|
+
const renderConsole = () => {
|
|
2624
|
+
consoleList.textContent = "";
|
|
2625
|
+
for (const entry of consoleEntries) {
|
|
2626
|
+
const item = doc.createElement("li");
|
|
2627
|
+
if ("error" in entry) {
|
|
2628
|
+
item.className = "entry error";
|
|
2629
|
+
item.append(badge(doc, "render"), badge(doc, entry.source, "source"), doc.createTextNode(entry.message));
|
|
2630
|
+
if (entry.stack !== void 0) {
|
|
2631
|
+
const stack = doc.createElement("pre");
|
|
2632
|
+
stack.textContent = entry.stack;
|
|
2633
|
+
item.append(stack);
|
|
2634
|
+
}
|
|
2635
|
+
} else {
|
|
2636
|
+
item.className = `entry ${entry.level}`;
|
|
2637
|
+
item.append(badge(doc, entry.thread), doc.createTextNode(entry.args.join(" ")));
|
|
2638
|
+
}
|
|
2639
|
+
consoleList.append(item);
|
|
2640
|
+
}
|
|
2641
|
+
consoleList.scrollTop = consoleList.scrollHeight;
|
|
2642
|
+
};
|
|
2643
|
+
clearConsole.addEventListener("click", () => {
|
|
2644
|
+
consoleEntries.length = 0;
|
|
2645
|
+
renderConsole();
|
|
2646
|
+
});
|
|
2647
|
+
const renderActions = () => {
|
|
2648
|
+
actionsList.textContent = "";
|
|
2649
|
+
if (actionEntries.length === 0) {
|
|
2650
|
+
const empty = el(doc, "li", "empty");
|
|
2651
|
+
empty.textContent = "No store actions. Pass an ActionLog to connectDevtools to see commands and patches here.";
|
|
2652
|
+
actionsList.append(empty);
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
for (const entry of actionEntries) {
|
|
2656
|
+
const item = doc.createElement("li");
|
|
2657
|
+
item.className = `entry ${entry.kind}`;
|
|
2658
|
+
const time = el(doc, "span", "time");
|
|
2659
|
+
time.textContent = `+${((entry.at - (actionOrigin ?? entry.at)) / 1e3).toFixed(2)}s`;
|
|
2660
|
+
item.append(time, badge(doc, entry.kind), doc.createTextNode(describeActionEntry(entry)));
|
|
2661
|
+
if (entry.cause !== void 0) {
|
|
2662
|
+
const cause = el(doc, "span", "cause");
|
|
2663
|
+
cause.textContent = ` #${entry.cause.id} ${entry.cause.label}`;
|
|
2664
|
+
item.append(cause);
|
|
2665
|
+
}
|
|
2666
|
+
actionsList.append(item);
|
|
2667
|
+
}
|
|
2668
|
+
actionsList.scrollTop = actionsList.scrollHeight;
|
|
2669
|
+
};
|
|
2670
|
+
const onEvent = (event) => {
|
|
2671
|
+
switch (event.kind) {
|
|
2672
|
+
case "tree":
|
|
2673
|
+
snapshot = event.tree;
|
|
2674
|
+
renderTree();
|
|
2675
|
+
renderStreams();
|
|
2676
|
+
break;
|
|
2677
|
+
case "action":
|
|
2678
|
+
pushAction(event.entry);
|
|
2679
|
+
break;
|
|
2680
|
+
case "report":
|
|
2681
|
+
if (event.id === selectedId) {
|
|
2682
|
+
report = event.report;
|
|
2683
|
+
if (report === null) selectedId = null;
|
|
2684
|
+
renderReport();
|
|
2685
|
+
renderTree();
|
|
2686
|
+
}
|
|
2687
|
+
break;
|
|
2688
|
+
case "hover":
|
|
2689
|
+
hoveredOnCanvas = event.report?.id ?? null;
|
|
2690
|
+
if (hoveredOnCanvas !== null) reveal(hoveredOnCanvas);
|
|
2691
|
+
else renderTree();
|
|
2692
|
+
break;
|
|
2693
|
+
case "console":
|
|
2694
|
+
consoleEntries.push(event.entry);
|
|
2695
|
+
if (consoleEntries.length > consoleLimit) consoleEntries.splice(0, consoleEntries.length - consoleLimit);
|
|
2696
|
+
renderConsole();
|
|
2697
|
+
break;
|
|
2698
|
+
case "error":
|
|
2699
|
+
consoleEntries.push({
|
|
2700
|
+
error: true,
|
|
2701
|
+
message: event.message,
|
|
2702
|
+
source: event.source,
|
|
2703
|
+
at: Date.now(),
|
|
2704
|
+
...event.stack === void 0 ? {} : { stack: event.stack }
|
|
2705
|
+
});
|
|
2706
|
+
renderConsole();
|
|
2707
|
+
break;
|
|
2708
|
+
case "frame": profiler?.report(event.metrics);
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
const onMessage = (message) => {
|
|
2712
|
+
if (message.type === "apps") {
|
|
2713
|
+
apps = message.apps;
|
|
2714
|
+
picker.textContent = "";
|
|
2715
|
+
for (const info of apps) {
|
|
2716
|
+
const option = doc.createElement("option");
|
|
2717
|
+
option.value = info.id;
|
|
2718
|
+
option.textContent = info.name;
|
|
2719
|
+
picker.append(option);
|
|
2720
|
+
}
|
|
2721
|
+
picker.hidden = apps.length < 2;
|
|
2722
|
+
const still = apps.find((info) => info.id === current?.id);
|
|
2723
|
+
showApp(still ?? apps[0] ?? null);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (message.app !== current?.id) return;
|
|
2727
|
+
if (message.type === "event") {
|
|
2728
|
+
onEvent(message.event);
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
if (message.type === "picked") {
|
|
2732
|
+
showTab("tree");
|
|
2733
|
+
reveal(message.id);
|
|
2734
|
+
select(message.id);
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
pushAction(message.entry);
|
|
2738
|
+
};
|
|
2739
|
+
/** One action log entry, from either route: the page's log or the render worker's. */
|
|
2740
|
+
const pushAction = (entry) => {
|
|
2741
|
+
actionOrigin ??= entry.at;
|
|
2742
|
+
actionEntries.push(entry);
|
|
2743
|
+
if (actionEntries.length > actionLimit) actionEntries.splice(0, actionEntries.length - actionLimit);
|
|
2744
|
+
renderActions();
|
|
2745
|
+
};
|
|
2746
|
+
picker.addEventListener("change", () => {
|
|
2747
|
+
const info = apps.find((candidate) => candidate.id === picker.value);
|
|
2748
|
+
if (info !== void 0) showApp(info);
|
|
2749
|
+
});
|
|
2750
|
+
inspectBox.addEventListener("change", () => request({
|
|
2751
|
+
kind: "inspector",
|
|
2752
|
+
enabled: inspectBox.checked
|
|
2753
|
+
}));
|
|
2754
|
+
pickBox.addEventListener("change", () => {
|
|
2755
|
+
if (current === null) return;
|
|
2756
|
+
port.post({
|
|
2757
|
+
type: "pick",
|
|
2758
|
+
app: current.id,
|
|
2759
|
+
enabled: pickBox.checked
|
|
2760
|
+
});
|
|
2761
|
+
request({
|
|
2762
|
+
kind: "inspector",
|
|
2763
|
+
enabled: pickBox.checked || inspectBox.checked
|
|
2764
|
+
});
|
|
2765
|
+
});
|
|
2766
|
+
marksBox.addEventListener("change", () => request({
|
|
2767
|
+
kind: "marks",
|
|
2768
|
+
enabled: marksBox.checked
|
|
2769
|
+
}));
|
|
2770
|
+
const stop = port.onMessage(onMessage);
|
|
2771
|
+
showTab("tree");
|
|
2772
|
+
showApp(null);
|
|
2773
|
+
port.post({ type: "hello" });
|
|
2774
|
+
return {
|
|
2775
|
+
get app() {
|
|
2776
|
+
return current;
|
|
2777
|
+
},
|
|
2778
|
+
show(appId) {
|
|
2779
|
+
const info = apps.find((candidate) => candidate.id === appId);
|
|
2780
|
+
if (info !== void 0) showApp(info);
|
|
2781
|
+
},
|
|
2782
|
+
setTheme: applyTheme,
|
|
2783
|
+
dispose() {
|
|
2784
|
+
if (current !== null) unsubscribe();
|
|
2785
|
+
stop();
|
|
2786
|
+
profiler?.dispose();
|
|
2787
|
+
container.remove();
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
function el(doc, tag, className) {
|
|
2792
|
+
const element = doc.createElement(tag);
|
|
2793
|
+
element.className = className;
|
|
2794
|
+
return element;
|
|
2795
|
+
}
|
|
2796
|
+
/** A change with its sign, so a rise reads as one at a glance. */
|
|
2797
|
+
function signed(delta) {
|
|
2798
|
+
return delta > 0 ? `+${delta}` : String(delta);
|
|
2799
|
+
}
|
|
2800
|
+
function badge(doc, text, extra = "") {
|
|
2801
|
+
const element = el(doc, "span", `badge ${text} ${extra}`.trim());
|
|
2802
|
+
element.textContent = text;
|
|
2803
|
+
return element;
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* The two palettes, as custom properties on the shadow host.
|
|
2807
|
+
*
|
|
2808
|
+
* Light is the default set and dark overrides it, twice: once for a
|
|
2809
|
+
* host that asked for dark, and once for a viewer who prefers it when
|
|
2810
|
+
* the host did not say. The report view and the docked profiler read
|
|
2811
|
+
* the same properties with the dark values as fallbacks, so the
|
|
2812
|
+
* in-page corner tools, which define none of them, look as they did.
|
|
2813
|
+
*/
|
|
2814
|
+
const DARK_TOKENS = `
|
|
2815
|
+
--gd-bg: #0d1117;
|
|
2816
|
+
--gd-bg-raised: #161b22;
|
|
2817
|
+
--gd-bg-hover: #21262d;
|
|
2818
|
+
--gd-border: #30363d;
|
|
2819
|
+
--gd-text: #e6edf3;
|
|
2820
|
+
--gd-text-strong: #c9d1d9;
|
|
2821
|
+
--gd-muted: #8b949e;
|
|
2822
|
+
--gd-faint: #6e7681;
|
|
2823
|
+
--gd-fainter: #484f58;
|
|
2824
|
+
--gd-accent: #79c0ff;
|
|
2825
|
+
--gd-info: #a5d6ff;
|
|
2826
|
+
--gd-purple: #d2a8ff;
|
|
2827
|
+
--gd-green: #7ee787;
|
|
2828
|
+
--gd-orange: #ffa657;
|
|
2829
|
+
--gd-red: #ff7b72;
|
|
2830
|
+
--gd-yellow: #e3b341;
|
|
2831
|
+
--gd-row-hovered: #1f2a3a;
|
|
2832
|
+
--gd-row-selected: #1f3b5c;
|
|
2833
|
+
--gd-budget: rgba(255, 255, 255, 0.35);
|
|
2834
|
+
`;
|
|
2835
|
+
const STYLES$1 = `
|
|
2836
|
+
:host { all: initial; display: block; height: 100%;
|
|
2837
|
+
--gd-bg: #ffffff;
|
|
2838
|
+
--gd-bg-raised: #f1f3f4;
|
|
2839
|
+
--gd-bg-hover: #e8eaed;
|
|
2840
|
+
--gd-border: #dadce0;
|
|
2841
|
+
--gd-text: #202124;
|
|
2842
|
+
--gd-text-strong: #3c4043;
|
|
2843
|
+
--gd-muted: #5f6368;
|
|
2844
|
+
--gd-faint: #80868b;
|
|
2845
|
+
--gd-fainter: #9aa0a6;
|
|
2846
|
+
--gd-accent: #1a73e8;
|
|
2847
|
+
--gd-info: #185abc;
|
|
2848
|
+
--gd-purple: #8430ce;
|
|
2849
|
+
--gd-green: #188038;
|
|
2850
|
+
--gd-orange: #b06000;
|
|
2851
|
+
--gd-red: #c5221f;
|
|
2852
|
+
--gd-yellow: #a05f00;
|
|
2853
|
+
--gd-row-hovered: #e8f0fe;
|
|
2854
|
+
--gd-row-selected: #d2e3fc;
|
|
2855
|
+
--gd-budget: rgba(0, 0, 0, 0.35);
|
|
2856
|
+
}
|
|
2857
|
+
:host([data-theme="dark"]) { ${DARK_TOKENS} }
|
|
2858
|
+
@media (prefers-color-scheme: dark) {
|
|
2859
|
+
:host(:not([data-theme="light"])) { ${DARK_TOKENS} }
|
|
2860
|
+
}
|
|
2861
|
+
.toolbar {
|
|
2862
|
+
display: flex;
|
|
2863
|
+
align-items: center;
|
|
2864
|
+
gap: 10px;
|
|
2865
|
+
padding: 4px 8px;
|
|
2866
|
+
border-bottom: 1px solid var(--gd-border);
|
|
2867
|
+
background: var(--gd-bg-raised);
|
|
2868
|
+
color: var(--gd-text);
|
|
2869
|
+
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2870
|
+
}
|
|
2871
|
+
.tabs { display: flex; gap: 2px; }
|
|
2872
|
+
.tabs button, .bar button {
|
|
2873
|
+
border: 1px solid transparent;
|
|
2874
|
+
border-radius: 4px;
|
|
2875
|
+
padding: 2px 8px;
|
|
2876
|
+
background: transparent;
|
|
2877
|
+
color: var(--gd-muted);
|
|
2878
|
+
font: inherit;
|
|
2879
|
+
cursor: pointer;
|
|
2880
|
+
}
|
|
2881
|
+
.tabs button:hover, .bar button:hover { color: var(--gd-text); background: var(--gd-bg-hover); }
|
|
2882
|
+
.tabs button.active { color: var(--gd-text); border-color: var(--gd-border); background: var(--gd-bg); }
|
|
2883
|
+
.picker { font: inherit; background: var(--gd-bg); color: var(--gd-text); border: 1px solid var(--gd-border); border-radius: 4px; }
|
|
2884
|
+
.toggle { display: inline-flex; align-items: center; gap: 2px; color: var(--gd-muted); cursor: pointer; }
|
|
2885
|
+
.status { margin-left: auto; color: var(--gd-muted); }
|
|
2886
|
+
.views { height: calc(100% - 30px); background: var(--gd-bg); color: var(--gd-text); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
2887
|
+
.view { height: 100%; }
|
|
2888
|
+
.view[hidden] { display: none; }
|
|
2889
|
+
.tree-view { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(240px, 1fr); }
|
|
2890
|
+
.tree { overflow: auto; border-right: 1px solid var(--gd-border); padding: 4px 0; }
|
|
2891
|
+
.row { display: flex; gap: 6px; white-space: nowrap; padding: 1px 8px; cursor: pointer; }
|
|
2892
|
+
.row:hover { background: var(--gd-bg-raised); }
|
|
2893
|
+
.row.hovered { background: var(--gd-row-hovered); }
|
|
2894
|
+
.row.selected { background: var(--gd-row-selected); }
|
|
2895
|
+
.disclosure { width: 10px; color: var(--gd-muted); }
|
|
2896
|
+
.type { color: var(--gd-accent); }
|
|
2897
|
+
.component { color: var(--gd-purple); }
|
|
2898
|
+
.text { color: var(--gd-info); overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
|
|
2899
|
+
.id { color: var(--gd-fainter); }
|
|
2900
|
+
.count { padding: 4px 8px; color: var(--gd-faint); }
|
|
2901
|
+
.report { overflow: auto; padding: 8px 12px; }
|
|
2902
|
+
.empty { color: var(--gd-muted); margin: 0; padding: 4px 8px; }
|
|
2903
|
+
.console-view { display: flex; flex-direction: column; }
|
|
2904
|
+
.bar { padding: 4px 8px; border-bottom: 1px solid var(--gd-border); }
|
|
2905
|
+
.log { flex: 1; margin: 0; padding: 4px 0; list-style: none; overflow: auto; }
|
|
2906
|
+
.entry { padding: 1px 8px; border-bottom: 1px solid var(--gd-bg-raised); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
2907
|
+
.entry.warn { color: var(--gd-yellow); }
|
|
2908
|
+
.entry.error { color: var(--gd-red); }
|
|
2909
|
+
.entry.debug, .entry.info { color: var(--gd-muted); }
|
|
2910
|
+
.entry pre { margin: 2px 0 0 0; color: var(--gd-muted); white-space: pre-wrap; }
|
|
2911
|
+
.badge {
|
|
2912
|
+
display: inline-block;
|
|
2913
|
+
min-width: 36px;
|
|
2914
|
+
margin-right: 6px;
|
|
2915
|
+
padding: 0 4px;
|
|
2916
|
+
border-radius: 3px;
|
|
2917
|
+
background: var(--gd-bg-hover);
|
|
2918
|
+
color: var(--gd-muted);
|
|
2919
|
+
font-size: 10px;
|
|
2920
|
+
text-align: center;
|
|
2921
|
+
}
|
|
2922
|
+
.badge.app { color: var(--gd-green); }
|
|
2923
|
+
.badge.render { color: var(--gd-accent); }
|
|
2924
|
+
.badge.command { color: var(--gd-purple); }
|
|
2925
|
+
.badge.patch { color: var(--gd-accent); }
|
|
2926
|
+
.badge.frame { color: var(--gd-green); }
|
|
2927
|
+
.badge.source { color: var(--gd-orange); }
|
|
2928
|
+
.time { color: var(--gd-faint); margin-right: 6px; }
|
|
2929
|
+
.cause { color: var(--gd-orange); }
|
|
2930
|
+
.entry.frame { color: var(--gd-muted); }
|
|
2931
|
+
.frames-view { padding: 8px; }
|
|
2932
|
+
/* Column, so the list inside scrolls itself rather than overflowing
|
|
2933
|
+
the pane: without it the log grew past the bottom of the panel and
|
|
2934
|
+
the newest entries could not be reached. */
|
|
2935
|
+
.actions-view { display: flex; flex-direction: column; }
|
|
2936
|
+
.streams-view { display: flex; flex-direction: column; }
|
|
2937
|
+
.streams-view .bar { display: flex; align-items: center; gap: 8px; }
|
|
2938
|
+
.streams-view .total { flex: 1; color: var(--gd-muted); }
|
|
2939
|
+
.streams { flex: 1; overflow: auto; padding: 4px 0; }
|
|
2940
|
+
.stream-row { display: flex; align-items: center; gap: 8px; padding: 1px 8px; cursor: pointer; }
|
|
2941
|
+
.stream-row:hover { background: var(--gd-bg-raised); }
|
|
2942
|
+
.stream-row .component { color: var(--gd-purple); white-space: nowrap; }
|
|
2943
|
+
.bar-track { flex: 1; height: 6px; border-radius: 3px; background: var(--gd-bg-raised); overflow: hidden; }
|
|
2944
|
+
.bar-fill { display: block; height: 100%; background: var(--gd-accent); }
|
|
2945
|
+
.stream-row .count { width: 76px; text-align: right; color: var(--gd-muted); white-space: nowrap; }
|
|
2946
|
+
.stream-row .count.changed { color: var(--gd-orange); }
|
|
2947
|
+
${NODE_REPORT_STYLES}
|
|
2948
|
+
`;
|
|
2949
|
+
//#endregion
|
|
2950
|
+
//#region src/NodeInspector.ts
|
|
2951
|
+
function mountNodeInspector(host, options = {}) {
|
|
2952
|
+
const doc = host.ownerDocument;
|
|
2953
|
+
const position = doc.defaultView?.getComputedStyle(host).position ?? "static";
|
|
2954
|
+
const restore = position === "static" ? host.style.position : null;
|
|
2955
|
+
if (position === "static") host.style.position = "relative";
|
|
2956
|
+
const container = doc.createElement("div");
|
|
2957
|
+
const root = container.attachShadow({ mode: "open" });
|
|
2958
|
+
const style = doc.createElement("style");
|
|
2959
|
+
style.textContent = STYLES;
|
|
2960
|
+
const panel = doc.createElement("div");
|
|
2961
|
+
panel.className = `panel ${options.corner ?? "bottom-left"}`;
|
|
2962
|
+
panel.hidden = true;
|
|
2963
|
+
root.append(style, panel);
|
|
2964
|
+
host.appendChild(container);
|
|
2965
|
+
const render = (report) => {
|
|
2966
|
+
panel.textContent = "";
|
|
2967
|
+
panel.append(...renderNodeReport(doc, report));
|
|
2968
|
+
};
|
|
2969
|
+
return {
|
|
2970
|
+
set(report) {
|
|
2971
|
+
if (report === null) {
|
|
2972
|
+
panel.hidden = true;
|
|
2973
|
+
panel.textContent = "";
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
render(report);
|
|
2977
|
+
panel.hidden = false;
|
|
2978
|
+
},
|
|
2979
|
+
dispose() {
|
|
2980
|
+
container.remove();
|
|
2981
|
+
if (restore !== null) host.style.position = restore;
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
const STYLES = `
|
|
2986
|
+
:host { all: initial; }
|
|
2987
|
+
.panel {
|
|
2988
|
+
position: absolute;
|
|
2989
|
+
z-index: 2147482000;
|
|
2990
|
+
max-width: 380px;
|
|
2991
|
+
max-height: 70%;
|
|
2992
|
+
overflow: auto;
|
|
2993
|
+
/* The person is hovering the canvas underneath: the panel showing
|
|
2994
|
+
what is under the pointer must never be what is under the pointer. */
|
|
2995
|
+
pointer-events: none;
|
|
2996
|
+
margin: 12px;
|
|
2997
|
+
padding: 10px 12px;
|
|
2998
|
+
border-radius: 8px;
|
|
2999
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
3000
|
+
background: rgba(13, 17, 23, 0.94);
|
|
3001
|
+
color: #e6edf3;
|
|
3002
|
+
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
3003
|
+
}
|
|
3004
|
+
.panel[hidden] { display: none; }
|
|
3005
|
+
.top-left { top: 0; left: 0; }
|
|
3006
|
+
.top-right { top: 0; right: 0; }
|
|
3007
|
+
.bottom-left { bottom: 0; left: 0; }
|
|
3008
|
+
.bottom-right { bottom: 0; right: 0; }
|
|
3009
|
+
${NODE_REPORT_STYLES}
|
|
3010
|
+
`;
|
|
3011
|
+
//#endregion
|
|
3012
|
+
export { ENVELOPE_SOURCE, ErrorOverlay, HOOK_PROPERTY, NODE_REPORT_STYLES, SourceMapConsumer, SourceMapStore, actionGlyph, codeFrame, connectDevtools, createActionLog, createDirectPorts, createNodePicker, decodeMappings, describeActionEntry, formatFrame, forwardNewEntries, getDevtoolsHook, idsToDepth, inputLabel, isEnvelope, mapStack, mountActionLogPanel, mountDevtoolsPanel, mountErrorOverlay, mountFrameProfiler, mountNodeInspector, parseSourceMappingUrl, parseStack, pathTo, primaryFrame, renderNodeReport, rowLabel, shortenPath, summarize, tapRenderWorker, treeRows, windowPagePort, windowPanelPort };
|
|
3013
|
+
|
|
3014
|
+
//# sourceMappingURL=index.js.map
|