@fluixi/devtools 0.2.0-alpha.1 → 0.2.0-alpha.3

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.
Files changed (96) hide show
  1. package/README.md +80 -2
  2. package/dist/callsite.d.ts +22 -0
  3. package/dist/callsite.d.ts.map +1 -0
  4. package/dist/callsite.js +82 -0
  5. package/dist/change-view.d.ts +10 -0
  6. package/dist/change-view.d.ts.map +1 -0
  7. package/dist/change-view.js +102 -0
  8. package/dist/copy.d.ts +9 -0
  9. package/dist/copy.d.ts.map +1 -0
  10. package/dist/copy.js +52 -0
  11. package/dist/describe.d.ts +3 -0
  12. package/dist/describe.d.ts.map +1 -0
  13. package/dist/describe.js +47 -0
  14. package/dist/detail-panel.d.ts +17 -0
  15. package/dist/detail-panel.d.ts.map +1 -0
  16. package/dist/detail-panel.js +217 -0
  17. package/dist/diff.d.ts +31 -0
  18. package/dist/diff.d.ts.map +1 -0
  19. package/dist/diff.js +132 -0
  20. package/dist/dom.d.ts +81 -0
  21. package/dist/dom.d.ts.map +1 -0
  22. package/dist/dom.js +269 -0
  23. package/dist/export.d.ts +55 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/export.js +90 -0
  26. package/dist/float.d.ts +54 -0
  27. package/dist/float.d.ts.map +1 -0
  28. package/dist/float.js +256 -0
  29. package/dist/format.d.ts +16 -0
  30. package/dist/format.d.ts.map +1 -0
  31. package/dist/format.js +188 -0
  32. package/dist/graph-view.d.ts +79 -2
  33. package/dist/graph-view.d.ts.map +1 -1
  34. package/dist/graph-view.js +866 -50
  35. package/dist/highlight.d.ts +56 -0
  36. package/dist/highlight.d.ts.map +1 -0
  37. package/dist/highlight.js +154 -0
  38. package/dist/history.d.ts +59 -0
  39. package/dist/history.d.ts.map +1 -0
  40. package/dist/history.js +85 -0
  41. package/dist/hook.d.ts +60 -0
  42. package/dist/hook.d.ts.map +1 -0
  43. package/dist/hook.js +142 -0
  44. package/dist/index.d.ts +44 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +25 -1
  47. package/dist/inspector.d.ts +60 -0
  48. package/dist/inspector.d.ts.map +1 -0
  49. package/dist/inspector.js +415 -0
  50. package/dist/instrument.d.ts +147 -5
  51. package/dist/instrument.d.ts.map +1 -1
  52. package/dist/instrument.js +164 -44
  53. package/dist/kind-filter.d.ts +39 -0
  54. package/dist/kind-filter.d.ts.map +1 -0
  55. package/dist/kind-filter.js +151 -0
  56. package/dist/menu.d.ts +37 -0
  57. package/dist/menu.d.ts.map +1 -0
  58. package/dist/menu.js +125 -0
  59. package/dist/observe.d.ts +80 -0
  60. package/dist/observe.d.ts.map +1 -0
  61. package/dist/observe.js +893 -0
  62. package/dist/palette.d.ts +11 -0
  63. package/dist/palette.d.ts.map +1 -0
  64. package/dist/palette.js +27 -0
  65. package/dist/path.d.ts +19 -0
  66. package/dist/path.d.ts.map +1 -0
  67. package/dist/path.js +49 -0
  68. package/dist/plugin.d.ts +121 -0
  69. package/dist/plugin.d.ts.map +1 -0
  70. package/dist/plugin.js +74 -0
  71. package/dist/plugins/context.d.ts +10 -0
  72. package/dist/plugins/context.d.ts.map +1 -0
  73. package/dist/plugins/context.js +80 -0
  74. package/dist/plugins/directives.d.ts +25 -0
  75. package/dist/plugins/directives.d.ts.map +1 -0
  76. package/dist/plugins/directives.js +78 -0
  77. package/dist/plugins/index.d.ts +7 -0
  78. package/dist/plugins/index.d.ts.map +1 -0
  79. package/dist/plugins/index.js +9 -0
  80. package/dist/source.d.ts +72 -0
  81. package/dist/source.d.ts.map +1 -0
  82. package/dist/source.js +153 -0
  83. package/dist/timeline.d.ts +30 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +271 -0
  86. package/dist/tree.d.ts +128 -0
  87. package/dist/tree.d.ts.map +1 -0
  88. package/dist/tree.js +645 -0
  89. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  90. package/dist/value-pane.d.ts +10 -0
  91. package/dist/value-pane.d.ts.map +1 -0
  92. package/dist/value-pane.js +61 -0
  93. package/dist/wire.d.ts +100 -0
  94. package/dist/wire.d.ts.map +1 -0
  95. package/dist/wire.js +93 -0
  96. package/package.json +10 -3
package/dist/tree.js ADDED
@@ -0,0 +1,645 @@
1
+ import { pluginBadge, pluginElementBadge } from './plugin.js';
2
+ import { kindRules } from './palette.js';
3
+ import { shorten } from './format.js';
4
+ /** Kinds that are tags in the source, and so have a place in a tree of them. */
5
+ export const TREE_KINDS = new Set([
6
+ 'component',
7
+ 'control',
8
+ 'router',
9
+ 'handler',
10
+ // A component a plugin recognised as providing a context. Still a tag the author wrote,
11
+ // so leaving it out took it and everything it rendered out of the tree.
12
+ 'provider',
13
+ ]);
14
+ /**
15
+ * Put the tags that rendered back into page order, leaving everything else untouched.
16
+ *
17
+ * Sorting by id alone reads as creation order, which is wrong for a keyed list: rebuilding a
18
+ * row hands the replacement the highest id, so the row you edited jumped to the bottom.
19
+ *
20
+ * Only the slots those tags already occupy are reshuffled. Sorting the whole list by `order`
21
+ * instead sent every value to the end of its band, so editing a todo moved the `left` memo
22
+ * as well, which had nothing to do with the change.
23
+ */
24
+ export function inPageOrder(nodes) {
25
+ const slots = [];
26
+ const placed = [];
27
+ nodes.forEach((node, at) => {
28
+ if (node.order !== undefined) {
29
+ slots.push(at);
30
+ placed.push(node);
31
+ }
32
+ });
33
+ if (placed.length < 2)
34
+ return nodes;
35
+ placed.sort((a, b) => a.order - b.order);
36
+ const out = nodes.slice();
37
+ slots.forEach((slot, i) => (out[slot] = placed[i]));
38
+ return out;
39
+ }
40
+ /** Cached per call: a plugin's badge is computed from the node and asked for twice. */
41
+ const badges = new WeakMap();
42
+ function badgeOf(node) {
43
+ if (!badges.has(node))
44
+ badges.set(node, pluginBadge(node));
45
+ return badges.get(node);
46
+ }
47
+ export function componentTree(graph, options = {}) {
48
+ const wanted = (node) => TREE_KINDS.has(node.kind) || (options.values === true && node.authored !== false);
49
+ const tagged = inPageOrder([...graph.values()].filter(wanted).sort((a, b) => a.id - b.id));
50
+ const made = new Map();
51
+ for (const node of tagged) {
52
+ made.set(node.id, {
53
+ id: node.id,
54
+ label: node.label,
55
+ kind: node.kind,
56
+ children: [],
57
+ ...(TREE_KINDS.has(node.kind) ? {} : { value: true }),
58
+ ...(node.lazy ? { lazy: true } : {}),
59
+ ...(badgeOf(node) ? { badge: badgeOf(node) } : {}),
60
+ });
61
+ }
62
+ /** The nearest ancestor that is a tag, following what created what. */
63
+ const owner = (node) => {
64
+ const seen = new Set([node.id]);
65
+ let at = node.createdIn;
66
+ while (at !== undefined && !seen.has(at)) {
67
+ seen.add(at);
68
+ const found = made.get(at);
69
+ if (found)
70
+ return found;
71
+ at = graph.get(at)?.createdIn;
72
+ }
73
+ return undefined;
74
+ };
75
+ const roots = [];
76
+ for (const node of tagged) {
77
+ const parent = owner(node);
78
+ if (parent)
79
+ parent.children.push(made.get(node.id));
80
+ else
81
+ roots.push(made.get(node.id));
82
+ }
83
+ const term = options.filter?.trim().toLowerCase();
84
+ return term ? keep(roots, term) : roots;
85
+ }
86
+ /** The nodes naming `term`, each still under whatever held it. */
87
+ function keep(nodes, term) {
88
+ const out = [];
89
+ for (const node of nodes) {
90
+ const children = keep(node.children, term);
91
+ const hit = node.label.toLowerCase().includes(term);
92
+ if (hit || children.length)
93
+ out.push({ ...node, children: hit ? node.children : children });
94
+ }
95
+ return out;
96
+ }
97
+ /** Every id in the tree, for a caller that wants to open or shut all of it. */
98
+ export function idsIn(nodes) {
99
+ const out = [];
100
+ const walk = (list) => {
101
+ for (const node of list) {
102
+ out.push(node.id);
103
+ walk(node.children);
104
+ }
105
+ };
106
+ walk(nodes);
107
+ return out;
108
+ }
109
+ const escape = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
110
+ export function createComponentTree(host, options = {}) {
111
+ const empty = options.empty ?? 'nothing rendered yet';
112
+ /** Shut by the reader. Everything else is open, so a new subtree arrives visible. */
113
+ const shut = new Set();
114
+ let signature = '';
115
+ /** Prop groups already given their starting fold, so reopening one is not undone. */
116
+ const defaulted = new Set();
117
+ let marked = null;
118
+ /** Element rows have no id, so what is selected is tracked by row key. */
119
+ let markedKey = null;
120
+ let values = options.values === true;
121
+ let filter = '';
122
+ const doc = host.ownerDocument;
123
+ // In the pane rather than the toolbar: it belongs to the tree, and three hosts would
124
+ // otherwise each need a button of their own for it.
125
+ const bar = doc.createElement('label');
126
+ bar.className = 'fx-tree-bar';
127
+ const box = doc.createElement('input');
128
+ box.type = 'checkbox';
129
+ box.checked = values;
130
+ bar.append(box, doc.createTextNode('values'));
131
+ bar.title = 'List the signals and memos each tag declares';
132
+ const search = doc.createElement('input');
133
+ search.type = 'search';
134
+ search.className = 'fx-tree-search';
135
+ search.placeholder = 'filter by name';
136
+ search.addEventListener('input', () => {
137
+ filter = search.value;
138
+ signature = '';
139
+ });
140
+ const list = doc.createElement('div');
141
+ list.className = 'fx-tree-list';
142
+ host.replaceChildren(bar, search, list);
143
+ /** What was last drawn, so a control can redraw without waiting for the app to move. */
144
+ let last = null;
145
+ box.addEventListener('change', () => {
146
+ values = box.checked;
147
+ signature = '';
148
+ // Clearing the signature only marks it stale. On an idle page nothing calls render
149
+ // again, so ticking the box appeared to do nothing at all until something else changed.
150
+ if (last)
151
+ api.render(last.graph, last.selected);
152
+ });
153
+ const row = (node, depth) => {
154
+ const has = node.children.length > 0;
155
+ const open = has && !shut.has(node.key);
156
+ const twist = has ? (open ? '▾' : '▸') : node.prop ? '' : '·';
157
+ const tag = node.props
158
+ ? `<span class="fx-tree-props-label">props</span><span class="fx-tree-props-count">${node.children.length}</span>`
159
+ : node.prop
160
+ ? `<span class="fx-tree-prop-name">${escape(node.prop.name)}</span>${node.prop.value === undefined
161
+ ? ''
162
+ : `<span class="fx-tree-prop-value">${escape(shorten(node.prop.value, 44))}</span>`}`
163
+ : node.value
164
+ ? escape(node.label)
165
+ : has
166
+ ? `&lt;${escape(node.label)}&gt;`
167
+ : `&lt;${escape(node.label)} /&gt;`;
168
+ const count = has && !open && !node.props ? `<span class="fx-tree-count">${node.children.length}</span>` : '';
169
+ // Says the chunk it came from was fetched separately, which is why it appeared late.
170
+ const chunk = node.lazy ? '<span class="fx-tree-lazy" title="loaded as its own chunk">lazy</span>' : '';
171
+ const mark = node.badge
172
+ ? `<span class="fx-tree-plugin" title="${escape(node.badge)}">${escape(node.badge)}</span>`
173
+ : '';
174
+ return (`<div class="fx-tree-row n-${node.kind}${node.value ? ' is-value' : ''}${node.prop ? ' is-prop' : ''}${node.props ? ' is-props' : ''}"` +
175
+ ` data-key="${escape(node.key)}"${node.id !== undefined ? ` data-id="${node.id}"` : ''}` +
176
+ ` style="--depth:${depth}">` +
177
+ `<button type="button" class="fx-tree-twist" data-twist="${escape(node.key)}"${has ? '' : ' tabindex="-1"'}>${twist}</button>` +
178
+ `<span class="fx-tree-tag"${node.prop?.value ? ` title="${escape(node.prop.name)}=${escape(node.prop.value)}"` : ''}>${tag}</span>${mark}${chunk}${count}` +
179
+ `</div>` +
180
+ (open ? node.children.map((child) => row(child, depth + 1)).join('') : ''));
181
+ };
182
+ /** The element a row stands for, out of what was drawn for it. */
183
+ const pickOf = (rowEl) => {
184
+ const found = shown.get(rowEl.dataset.key ?? '');
185
+ if (!found || found.kind !== 'element')
186
+ return undefined;
187
+ return {
188
+ key: found.key,
189
+ label: found.label,
190
+ ...(found.owner !== undefined ? { owner: found.owner } : {}),
191
+ ...(found.writes?.length ? { writes: found.writes } : {}),
192
+ ...(found.at ? { at: found.at } : {}),
193
+ ...(found.extra ? { extra: found.extra } : {}),
194
+ ...(found.element ? { element: found.element } : {}),
195
+ };
196
+ };
197
+ const onClick = (event) => {
198
+ const target = event.target;
199
+ const twist = target?.closest?.('[data-twist]');
200
+ if (twist) {
201
+ const key = twist.dataset.twist ?? '';
202
+ if (shut.has(key))
203
+ shut.delete(key);
204
+ else
205
+ shut.add(key);
206
+ signature = '';
207
+ event.stopPropagation();
208
+ return;
209
+ }
210
+ const rowEl = target?.closest?.('.fx-tree-row');
211
+ if (!rowEl)
212
+ return;
213
+ if (rowEl.dataset.id) {
214
+ markedKey = null;
215
+ options.onSelect?.(Number(rowEl.dataset.id));
216
+ return;
217
+ }
218
+ // A prop is not a node of its own, so selecting it selects the tag it was written on,
219
+ // which is where its value came from and what the inspector has something to say about.
220
+ const held = shown.get(rowEl.dataset.key ?? '');
221
+ if (held?.props) {
222
+ if (shut.has(held.key))
223
+ shut.delete(held.key);
224
+ else
225
+ shut.add(held.key);
226
+ signature = '';
227
+ if (last)
228
+ api.render(last.graph, last.selected);
229
+ return;
230
+ }
231
+ if (held?.prop) {
232
+ if (held.owner === undefined)
233
+ return;
234
+ markedKey = held.key;
235
+ paint();
236
+ options.onSelect?.(held.owner);
237
+ return;
238
+ }
239
+ const pick = pickOf(rowEl);
240
+ if (!pick)
241
+ return;
242
+ markedKey = pick.key;
243
+ paint();
244
+ options.onPick?.(pick);
245
+ };
246
+ const onDouble = (event) => {
247
+ const rowEl = event.target?.closest?.('.fx-tree-row');
248
+ if (!rowEl)
249
+ return;
250
+ if (rowEl.dataset.id) {
251
+ options.onReveal?.(Number(rowEl.dataset.id));
252
+ return;
253
+ }
254
+ const pick = pickOf(rowEl);
255
+ if (pick)
256
+ options.onRevealPick?.(pick);
257
+ };
258
+ /** Rows by key, so a pointer over one can be answered without searching the tree again. */
259
+ let shown = new Map();
260
+ const onOver = (event) => {
261
+ const rowEl = event.target?.closest?.('.fx-tree-row');
262
+ const found = rowEl ? shown.get(rowEl.dataset.key ?? '') : undefined;
263
+ if (!found) {
264
+ options.onHover?.(null);
265
+ return;
266
+ }
267
+ // The key doubles as a path to the element, for a host that has to ask the page to
268
+ // highlight rather than doing it itself.
269
+ options.onHover?.(found.id ?? null, found.element, found.id === undefined ? found.key : undefined);
270
+ };
271
+ const onOut = () => options.onHover?.(null);
272
+ host.addEventListener('click', onClick);
273
+ host.addEventListener('dblclick', onDouble);
274
+ if (options.onHover) {
275
+ host.addEventListener('pointermove', onOver);
276
+ host.addEventListener('pointerleave', onOut);
277
+ }
278
+ const paint = () => {
279
+ for (const el of list.querySelectorAll('.fx-tree-row')) {
280
+ const row = el;
281
+ const id = row.dataset.id;
282
+ const on = id !== undefined ? Number(id) === marked : row.dataset.key === markedKey;
283
+ el.classList.toggle('on', on);
284
+ }
285
+ };
286
+ /**
287
+ * Build the rows, weaving each tag's DOM into the graph tree.
288
+ *
289
+ * A row goes where its element is rather than where the graph made it. A child component
290
+ * sits inside its parent's DOM, so its row goes there; a value goes inside the element it
291
+ * writes into. The second matters for a store declared at module scope, which belongs to
292
+ * no component but still renders somewhere.
293
+ */
294
+ function rowsFor(nodes, graph) {
295
+ const byId = new Map();
296
+ const build = (node) => {
297
+ // What the tag was handed, ahead of what it produced. A prop is the other half of the
298
+ // story a tag tells: `<For each={todos()}>` says as much through `each` as through the
299
+ // rows it went on to render, and every component takes them.
300
+ const written = values ? (graph.get(node.id)?.props ?? []) : [];
301
+ const listed = written.map((p, i) => ({
302
+ key: `${node.id}=${i}`,
303
+ label: p.name,
304
+ kind: node.kind,
305
+ children: [],
306
+ prop: { name: p.name, ...(p.value === undefined ? {} : { value: p.value }) },
307
+ owner: node.id,
308
+ ...(p.at?.line === undefined
309
+ ? {}
310
+ : { at: { file: p.at.file ?? '', line: p.at.line, column: p.at.column ?? 0 } }),
311
+ }));
312
+ // One folded row rather than a prop per line. A tag with six of them otherwise buries
313
+ // what it rendered, which is the thing the tree is for.
314
+ const group = `${node.id}=props`;
315
+ // Shut the first time it is met, and whatever the reader does with it after that sticks.
316
+ if (listed.length && !defaulted.has(group)) {
317
+ defaulted.add(group);
318
+ shut.add(group);
319
+ }
320
+ const props = listed.length
321
+ ? [{ key: group, label: 'props', kind: node.kind, children: listed, props: true, owner: node.id }]
322
+ : [];
323
+ const row = {
324
+ key: String(node.id),
325
+ label: node.label,
326
+ kind: node.kind,
327
+ id: node.id,
328
+ children: [...props, ...node.children.map(build)],
329
+ ...(node.value ? { value: true } : {}),
330
+ ...(node.lazy ? { lazy: true } : {}),
331
+ ...(node.badge ? { badge: node.badge } : {}),
332
+ };
333
+ byId.set(node.id, row);
334
+ return row;
335
+ };
336
+ const roots = nodes.map(build);
337
+ const domFor = options.domFor;
338
+ if (!domFor)
339
+ return roots;
340
+ const placed = new Set();
341
+ /**
342
+ * Rows already written into the tree. A tag is reachable two ways, down the component
343
+ * nesting and through the element it rendered, and `expand` rewrites the row it is
344
+ * given, so without this the second arrival appends its dom all over again.
345
+ */
346
+ const done = new Set();
347
+ /**
348
+ * How many places in the dom name each tag.
349
+ *
350
+ * A graph read from source has one node per component *declaration*, so two `<Badge/>`
351
+ * both point at it. Drawing only the first would take the second out of the tree, but
352
+ * a running graph gives each use its own node, where a tag named twice really is one
353
+ * subtree reached two ways and the second must go. Counting tells them apart.
354
+ */
355
+ const uses = new Map();
356
+ const tally = (nodes) => {
357
+ for (const node of nodes) {
358
+ if (node.owner !== undefined)
359
+ uses.set(node.owner, (uses.get(node.owner) ?? 0) + 1);
360
+ tally(node.children);
361
+ }
362
+ };
363
+ for (const row of byId.values())
364
+ if (row.id !== undefined)
365
+ tally(domFor(row.id));
366
+ /**
367
+ * The innermost tag row that reads `id`, if one is in this tree.
368
+ *
369
+ * The node doing the reading is often machinery the tree never draws: `For` does not read
370
+ * its list, `mapArray` does, and that is hidden. So climb from whatever reads it to the
371
+ * nearest tag that is drawn, which is the `For`.
372
+ *
373
+ * Deepest wins, so a value read by a component inside a `For` belongs to the component.
374
+ * `skip` keeps the tag that declared it from adopting it back.
375
+ */
376
+ const readerOf = (id, skip) => {
377
+ const drawn = (from) => {
378
+ const seen = new Set();
379
+ let at = from;
380
+ while (at !== undefined && !seen.has(at)) {
381
+ seen.add(at);
382
+ const row = byId.get(at);
383
+ if (row && !row.value && row !== skip)
384
+ return row;
385
+ at = graph.get(at)?.createdIn;
386
+ }
387
+ return undefined;
388
+ };
389
+ let best;
390
+ let deepest = -1;
391
+ for (const [nodeId, node] of graph) {
392
+ if (nodeId === id || !node.deps.has(id))
393
+ continue;
394
+ const row = drawn(nodeId);
395
+ if (!row || row.id === undefined)
396
+ continue;
397
+ const depth = graph.get(row.id)?.depth ?? 0;
398
+ if (depth >= deepest) {
399
+ deepest = depth;
400
+ best = row;
401
+ }
402
+ }
403
+ return best;
404
+ };
405
+ /** The same row again at another place, re-keyed so the two fold independently. */
406
+ const again = (row, key) => ({
407
+ ...row,
408
+ key,
409
+ children: row.children.map((child, i) => again(child, `${key}/${i}`)),
410
+ });
411
+ const expand = (row) => {
412
+ if (row.value || row.id === undefined)
413
+ return row;
414
+ if (done.has(row))
415
+ return row;
416
+ done.add(row);
417
+ const dom = domFor(row.id);
418
+ if (!dom.length) {
419
+ row.children = row.children.map(expand);
420
+ return row;
421
+ }
422
+ const walk = (node, key) => {
423
+ const owner = node.owner === undefined ? undefined : byId.get(node.owner);
424
+ if (owner && owner !== row) {
425
+ if (done.has(owner)) {
426
+ // Named from one place only, so it is already showing where it belongs and
427
+ // drawing it here as well would be the same subtree twice.
428
+ const count = uses.get(node.owner) ?? 0;
429
+ return count > 1 ? again(owner, key) : undefined;
430
+ }
431
+ placed.add(owner);
432
+ return expand(owner);
433
+ }
434
+ const kids = [];
435
+ // Values first. They are the reason this element changes.
436
+ for (const id of node.writes ?? []) {
437
+ const value = byId.get(id);
438
+ if (!value?.value || placed.has(value))
439
+ continue;
440
+ placed.add(value);
441
+ kids.push(value);
442
+ }
443
+ // Then tags that render in here without an element of their own, so they read as
444
+ // being inside it rather than next to it.
445
+ for (const id of node.inside ?? []) {
446
+ const tag = byId.get(id);
447
+ if (!tag || tag === row || placed.has(tag) || done.has(tag))
448
+ continue;
449
+ placed.add(tag);
450
+ kids.push(expand(tag));
451
+ }
452
+ node.children.forEach((child, i) => {
453
+ const kid = walk(child, `${key}/${i}`);
454
+ if (kid)
455
+ kids.push(kid);
456
+ });
457
+ return {
458
+ key,
459
+ label: node.label,
460
+ kind: 'element',
461
+ children: kids,
462
+ owner: row.id,
463
+ ...(node.writes?.length ? { writes: node.writes } : {}),
464
+ ...(node.at ? { at: node.at } : {}),
465
+ ...(node.extra ? { extra: node.extra } : {}),
466
+ ...(pluginElementBadge(node.extra) ? { badge: pluginElementBadge(node.extra) } : {}),
467
+ ...(node.element ? { element: node.element } : {}),
468
+ };
469
+ };
470
+ // The mount container was on the page before anything rendered, so the tag goes inside
471
+ // it rather than the other way round.
472
+ const mount = dom.findIndex((node) => node.container);
473
+ if (mount !== -1) {
474
+ const node = dom[mount];
475
+ row.children = row.children.map(expand);
476
+ return {
477
+ key: `${row.id}:${mount}`,
478
+ label: node.label,
479
+ kind: 'element',
480
+ children: [row],
481
+ owner: row.id,
482
+ ...(node.at ? { at: node.at } : {}),
483
+ ...(node.element ? { element: node.element } : {}),
484
+ };
485
+ }
486
+ const rendered = dom
487
+ .map((node, i) => walk(node, `${row.id}:${i}`))
488
+ .filter((node) => node !== undefined);
489
+ // Anything the dom did not account for: a portal, or a value read across a tag
490
+ // boundary rather than written into an element here.
491
+ //
492
+ // Above the markup, not below it. A signal a nested tag consumes writes nowhere this
493
+ // description reaches, so it never nests beside an element, and appending it left
494
+ // `todos` alone at the bottom of `App` well away from the `For` that reads it.
495
+ const rest = row.children.filter((child) => !placed.has(child)).map(expand);
496
+ // A value the dom cannot place goes under the tag that reads it.
497
+ //
498
+ // `draft` nests under the `<input>` it writes into, because the description names the
499
+ // values writing to each element. `todos` writes into no element here: `For` reads it
500
+ // and renders the rows itself, so it had nowhere to go and sat loose under `App`. The
501
+ // tag that depends on it is the honest place, and it reads the same way as `draft`.
502
+ const lifted = [];
503
+ const spare = [];
504
+ /** Collected per reader so several values keep their order when they go in. */
505
+ const adopt = new Map();
506
+ for (const child of rest) {
507
+ const reader = child.value && child.id !== undefined ? readerOf(child.id, row) : undefined;
508
+ if (reader) {
509
+ const held = adopt.get(reader);
510
+ if (held)
511
+ held.push(child);
512
+ else
513
+ adopt.set(reader, [child]);
514
+ placed.add(child);
515
+ continue;
516
+ }
517
+ (child.value || child.prop || child.props ? lifted : spare).push(child);
518
+ }
519
+ // Ahead of what the tag rendered. A value reads as what the tag was given, so it
520
+ // belongs above the rows it produced rather than after them.
521
+ for (const [reader, values] of adopt)
522
+ reader.children.unshift(...values);
523
+ // Whatever is left: what the tag was given first, then a portal, which renders
524
+ // elsewhere and still reads as part of the markup.
525
+ row.children = [...lifted, ...rendered, ...spare];
526
+ return row;
527
+ };
528
+ // Mapped, not just visited: expanding a root can return a different row, because the
529
+ // container it was mounted into ends up above it.
530
+ const top = roots.map((root) => (placed.has(root) ? root : expand(root)));
531
+ return top.filter((_, i) => !placed.has(roots[i]));
532
+ }
533
+ const api = {
534
+ render(graph, selected = null) {
535
+ last = { graph, selected };
536
+ const roots = rowsFor(componentTree(graph, { values, filter }), graph);
537
+ // Rebuilt only when the shape changes: this runs every frame, and replacing the markup
538
+ // each time would drop whatever the reader was about to click.
539
+ const next = `${values}|${filter}|${[...shut].sort().join(',')}|${roots.map(function label(n) {
540
+ return `${n.key}:${n.label}:${n.children.map(label).join('+')}`;
541
+ }).join(',')}`;
542
+ if (next !== signature) {
543
+ signature = next;
544
+ shown = new Map();
545
+ const index = (list) => {
546
+ for (const node of list) {
547
+ shown.set(node.key, node);
548
+ index(node.children);
549
+ }
550
+ };
551
+ index(roots);
552
+ list.innerHTML = roots.length
553
+ ? roots.map((node) => row(node, 0)).join('')
554
+ : `<span class="fx-tree-empty">${escape(filter ? `nothing named ${filter}` : empty)}</span>`;
555
+ }
556
+ if (selected !== marked) {
557
+ marked = selected;
558
+ paint();
559
+ }
560
+ },
561
+ clear() {
562
+ signature = '';
563
+ marked = null;
564
+ shut.clear();
565
+ list.innerHTML = `<span class="fx-tree-empty">${escape(empty)}</span>`;
566
+ },
567
+ destroy() {
568
+ host.removeEventListener('click', onClick);
569
+ host.removeEventListener('dblclick', onDouble);
570
+ host.removeEventListener('pointermove', onOver);
571
+ host.removeEventListener('pointerleave', onOut);
572
+ host.innerHTML = '';
573
+ },
574
+ };
575
+ return api;
576
+ }
577
+ export const TREE_CSS = `
578
+ .fx-tree-row {
579
+ display: flex; align-items: center; gap: 4px;
580
+ padding: 1px 6px 1px calc(6px + var(--depth, 0) * 12px);
581
+ font-family: ui-monospace, monospace; font-size: 11.5px;
582
+ color: var(--fx-label, #e7e9ee); cursor: pointer; white-space: nowrap;
583
+ }
584
+ .fx-tree-row:hover { background: var(--fx-hover, #1b1e26); }
585
+ .fx-tree-row.on { background: var(--fx-sel, #232838); }
586
+ .fx-tree-twist {
587
+ width: 12px; padding: 0; border: 0; background: none; cursor: pointer;
588
+ color: var(--fx-muted, #8a90a2); font-size: 9px; line-height: 1;
589
+ }
590
+ .fx-tree-plugin {
591
+ font-size: 9px; color: var(--fx-accent, #5eead4);
592
+ border: 1px solid var(--fx-line, #262a35); border-radius: 6px; padding: 0 4px;
593
+ max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
594
+ }
595
+ .fx-tree-lazy {
596
+ font-size: 9px; letter-spacing: .04em; color: var(--fx-router, #7dd3fc);
597
+ border: 1px solid var(--fx-line, #262a35); border-radius: 6px; padding: 0 4px;
598
+ }
599
+ .fx-tree-count {
600
+ font-size: 9.5px; color: var(--fx-muted, #8a90a2);
601
+ border: 1px solid var(--fx-line, #262a35); border-radius: 6px; padding: 0 4px;
602
+ }
603
+ ${kindRules((k) => `.fx-tree-row.n-${k} .fx-tree-tag`, 'color')}
604
+ .fx-tree-bar {
605
+ display: flex; align-items: center; gap: 5px; padding: 2px 8px 5px;
606
+ font-size: 10.5px; color: var(--fx-muted, #8a90a2); cursor: pointer; user-select: none;
607
+ }
608
+ .fx-tree-bar:hover { color: var(--fx-label, #e7e9ee); }
609
+ .fx-tree-bar input { width: 11px; height: 11px; margin: 0; accent-color: var(--fx-accent, #5eead4); }
610
+ .fx-tree-row.n-element:hover .fx-tree-tag { color: var(--fx-label, #e7e9ee); }
611
+ /* A declared value is listed inside the tag that made it, so it reads quieter than a tag. */
612
+ .fx-tree-row.is-value .fx-tree-tag { font-style: italic; opacity: .85; }
613
+ .fx-tree-search {
614
+ width: calc(100% - 16px); margin: 0 8px 4px; padding: 2px 6px;
615
+ font: inherit; font-size: 10.5px;
616
+ color: var(--fx-label, #e7e9ee); background: var(--fx-bg, #0f1115);
617
+ border: 1px solid var(--fx-line, #262a35); border-radius: 4px;
618
+ }
619
+ .fx-tree-search:focus { outline: none; border-color: var(--fx-accent, #5eead4); }
620
+ .fx-tree-empty { display: block; padding: 8px; font-size: 11px; color: var(--fx-muted, #8a90a2); }
621
+ /* A prop reads as what was written on the tag, so it is quieter than the tag itself. */
622
+ /* The row that folds a tag's props away. Dashed, because it stands for something written
623
+ rather than something rendered. */
624
+ .fx-tree-row.is-props .fx-tree-tag {
625
+ display: inline-flex; align-items: center; gap: 5px;
626
+ padding: 0 5px; border: 1px dashed var(--fx-line, #262a35); border-radius: 3px;
627
+ color: var(--fx-muted, #8a90a2); font-size: 10px; letter-spacing: .04em; text-transform: uppercase;
628
+ }
629
+ .fx-tree-row.is-props:hover .fx-tree-tag { border-color: var(--fx-accent, #5eead4); color: var(--fx-label, #e7e9ee); }
630
+ .fx-tree-props-count { opacity: .7; }
631
+
632
+ /* A prop is what was written on the tag above it, so it reads as an aside: the name in a
633
+ quiet chip, the value beside it, and no marker pretending it opens. */
634
+ .fx-tree-row.is-prop .fx-tree-tag { display: inline-flex; align-items: baseline; gap: 5px; min-width: 0; }
635
+ .fx-tree-prop-name {
636
+ flex: none; padding: 0 4px; border-radius: 3px;
637
+ background: var(--fx-chip, rgba(138, 144, 162, .14));
638
+ color: var(--fx-muted, #8a90a2); font-size: 10px; letter-spacing: .02em;
639
+ }
640
+ .fx-tree-row.is-prop:hover .fx-tree-prop-name { color: var(--fx-label, #e7e9ee); }
641
+ .fx-tree-prop-value {
642
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
643
+ color: var(--fx-label, #e7e9ee); opacity: .75;
644
+ }
645
+ `;