@atolljs/devtools 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/fw-logos.js +14 -0
  4. package/app/index.html +161 -2
  5. package/app/main.js +897 -222
  6. package/app/panels/actions.js +324 -0
  7. package/app/panels/audit-rules.js +728 -0
  8. package/app/panels/audits.js +438 -0
  9. package/app/panels/inspect-island.js +351 -0
  10. package/app/panels/memory-values.js +287 -0
  11. package/app/panels/performance.js +304 -0
  12. package/app/panels/reactivity.js +271 -0
  13. package/app/panels/recorder.js +344 -0
  14. package/app/panels/recording-format.js +166 -0
  15. package/app/panels/route.d.ts +44 -0
  16. package/app/panels/route.js +264 -0
  17. package/app/panels/shell.js +789 -0
  18. package/app/panels/toast.js +38 -0
  19. package/dist/bin.js +704 -5
  20. package/dist/chunk-57NGHQUL.js +344 -0
  21. package/dist/chunk-6E54FXKS.js +495 -0
  22. package/dist/chunk-WIL2P7HG.js +661 -0
  23. package/dist/client.d.ts +7 -0
  24. package/dist/control.d.ts +4 -0
  25. package/dist/index.js +2 -1
  26. package/dist/node.js +2 -1
  27. package/dist/nodeWs.d.ts +1 -0
  28. package/dist/otel.d.ts +35 -0
  29. package/dist/otel.js +87 -0
  30. package/dist/otelMap.d.ts +232 -0
  31. package/dist/otelTransport.d.ts +69 -0
  32. package/dist/overlay.d.ts +16 -2
  33. package/dist/probes.d.ts +7 -0
  34. package/dist/protocol.d.ts +46 -4
  35. package/dist/server.d.ts +19 -0
  36. package/dist/server.js +61 -3
  37. package/package.json +6 -2
  38. package/src/bin.ts +27 -2
  39. package/src/broadcast.ts +6 -0
  40. package/src/client.ts +26 -2
  41. package/src/control.ts +51 -0
  42. package/src/nodeWs.ts +7 -1
  43. package/src/otel.ts +140 -0
  44. package/src/otelMap.ts +661 -0
  45. package/src/otelTransport.ts +286 -0
  46. package/src/overlay.ts +239 -47
  47. package/src/probes.ts +155 -0
  48. package/src/protocol.ts +52 -4
  49. package/src/server.ts +83 -3
  50. package/dist/chunk-4N3NBFL3.js +0 -474
@@ -0,0 +1,264 @@
1
+ // Pure helpers for the dashboard shell (panels/shell.js): hash routes,
2
+ // palette fuzzy matching, `g <letter>` view resolution, nav overflow math.
3
+ // No DOM access here, so test/route.test.ts can exercise everything.
4
+
5
+ /** Query params a route may carry, in the order formatRoute writes them. */
6
+ export const ROUTE_PARAMS = ['session', 'island', 'worker'];
7
+
8
+ // `|` and `@` are legal in a fragment and keep island/worker keys readable
9
+ // (`island=s1|nested@1`); everything else goes through encodeURIComponent.
10
+ const enc = (v) => encodeURIComponent(v).replace(/%7C/gi, '|').replace(/%40/g, '@');
11
+ const dec = (v) => {
12
+ try { return decodeURIComponent(v); } catch { return v; }
13
+ };
14
+
15
+ /**
16
+ * `#/<view>[/<sub>][?session=…&island=…&worker=…]` → { view, sub, params },
17
+ * or null when the hash isn't a route. Unknown params are kept.
18
+ */
19
+ export function parseRoute(hash) {
20
+ if (typeof hash !== 'string') return null;
21
+ const h = hash.replace(/^#/, '');
22
+ if (!h.startsWith('/')) return null;
23
+ const q = h.indexOf('?');
24
+ const path = q >= 0 ? h.slice(1, q) : h.slice(1);
25
+ const [view, sub] = path.split('/').filter(Boolean).map(dec);
26
+ if (!view) return null;
27
+ const params = {};
28
+ if (q >= 0) {
29
+ for (const pair of h.slice(q + 1).split('&')) {
30
+ if (!pair) continue;
31
+ const eq = pair.indexOf('=');
32
+ const k = dec(eq >= 0 ? pair.slice(0, eq) : pair);
33
+ if (k) params[k] = eq >= 0 ? dec(pair.slice(eq + 1)) : '';
34
+ }
35
+ }
36
+ return { view, sub: sub ?? null, params };
37
+ }
38
+
39
+ /** Inverse of parseRoute. Empty/nullish params are dropped. */
40
+ export function formatRoute({ view, sub = null, params = {} }) {
41
+ let s = `#/${enc(view)}${sub ? `/${enc(sub)}` : ''}`;
42
+ const keys = [
43
+ ...ROUTE_PARAMS.filter((k) => k in params),
44
+ ...Object.keys(params).filter((k) => !ROUTE_PARAMS.includes(k)).sort(),
45
+ ];
46
+ const qs = keys
47
+ .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
48
+ .map((k) => `${enc(k)}=${enc(String(params[k]))}`);
49
+ return qs.length ? `${s}?${qs.join('&')}` : s;
50
+ }
51
+
52
+ /** Dashboard state keys are `${session}|…`: the part after the session. */
53
+ export const keyTail = (k) => {
54
+ const i = k.indexOf('|');
55
+ return i >= 0 ? k.slice(i + 1) : k;
56
+ };
57
+
58
+ /**
59
+ * Resolve a routed entity key against the live key set: exact match first
60
+ * (same session still around), else the same tail in another session
61
+ * (the app reloaded and got a new session id), preferring live ones.
62
+ */
63
+ export function resolveKey(keys, want, isLive = () => true) {
64
+ if (!want) return null;
65
+ const all = [...keys];
66
+ if (all.includes(want)) return want;
67
+ const tail = keyTail(want);
68
+ const same = all.filter((k) => keyTail(k) === tail);
69
+ return same.find(isLive) ?? same[0] ?? null;
70
+ }
71
+
72
+ /* ── fuzzy matching ─────────────────────────────────────────────────────── */
73
+
74
+ const SEP = /[\s\-_/|@#~.:›,()[\]]/;
75
+ const isBoundary = (text, i) =>
76
+ i === 0 || SEP.test(text[i - 1]) ||
77
+ (text[i] >= 'A' && text[i] <= 'Z' && text[i - 1] >= 'a' && text[i - 1] <= 'z');
78
+
79
+ /**
80
+ * Score `query` against `text` (case-insensitive). Substring hits beat
81
+ * scattered subsequences; prefix and word-boundary hits score higher.
82
+ * Returns { score, idx } (matched char indices, for highlighting) or null.
83
+ */
84
+ export function fuzzyMatch(query, text) {
85
+ const q = String(query ?? '').trim().toLowerCase();
86
+ const raw = String(text ?? '');
87
+ if (!q) return { score: 0, idx: [] };
88
+ const t = raw.toLowerCase();
89
+ const at = t.indexOf(q);
90
+ if (at >= 0) {
91
+ // prefer a boundary occurrence if there is one later in the string
92
+ let best = at;
93
+ for (let i = at; i >= 0; i = t.indexOf(q, i + 1)) {
94
+ if (isBoundary(raw, i)) { best = i; break; }
95
+ }
96
+ const score = 100 + (best === 0 ? 40 : 0) + (isBoundary(raw, best) ? 25 : 0)
97
+ - best * 0.5 - (t.length - q.length) * 0.05;
98
+ return { score, idx: Array.from({ length: q.length }, (_, k) => best + k) };
99
+ }
100
+ const idx = [];
101
+ let score = 0;
102
+ let ti = 0;
103
+ for (const c of q) {
104
+ if (c === ' ') continue;
105
+ // prefer the next boundary occurrence of c, else the next occurrence
106
+ let hit = -1;
107
+ for (let i = ti; i < t.length; i++) {
108
+ if (t[i] !== c) continue;
109
+ if (hit < 0) hit = i;
110
+ if (isBoundary(raw, i)) { hit = i; break; }
111
+ // don't skip far ahead hunting for a boundary
112
+ if (i - hit > 8) break;
113
+ }
114
+ if (hit < 0) return null;
115
+ const prev = idx[idx.length - 1];
116
+ score += 10;
117
+ if (prev !== undefined && hit === prev + 1) score += 8;
118
+ if (isBoundary(raw, hit)) score += 6;
119
+ if (prev !== undefined) score -= Math.min(10, (hit - prev - 1) * 0.5);
120
+ idx.push(hit);
121
+ ti = hit + 1;
122
+ }
123
+ return { score: score - (t.length - idx.length) * 0.05, idx };
124
+ }
125
+
126
+ /**
127
+ * Rank palette items `{ title, group?, hint?, keywords? }`. Every
128
+ * whitespace-separated token must match the title or (at a discount) the
129
+ * group/hint/keywords. Highlight indices come from title matches only.
130
+ * Returns [{ item, score, idx }] best first; an empty query keeps order.
131
+ */
132
+ export function rankItems(query, items) {
133
+ const tokens = String(query ?? '').trim().split(/\s+/).filter(Boolean);
134
+ if (!tokens.length) return items.map((item) => ({ item, score: 0, idx: [] }));
135
+ const out = [];
136
+ for (const item of items) {
137
+ const title = String(item.title ?? '');
138
+ const extra = [item.group, item.hint, item.keywords].filter(Boolean).join(' ');
139
+ let score = 0;
140
+ const idx = [];
141
+ let ok = true;
142
+ // whole-query title match first: "js heap" should light up "JS heap"
143
+ const whole = tokens.length > 1 ? fuzzyMatch(tokens.join(' '), title) : null;
144
+ if (whole && whole.score >= 100) {
145
+ out.push({ item, score: whole.score * tokens.length, idx: whole.idx });
146
+ continue;
147
+ }
148
+ for (const tok of tokens) {
149
+ const m = fuzzyMatch(tok, title);
150
+ const e = extra ? fuzzyMatch(tok, extra) : null;
151
+ if (m && (!e || m.score >= e.score * 0.5)) { score += m.score; idx.push(...m.idx); }
152
+ else if (e) score += e.score * 0.5;
153
+ else { ok = false; break; }
154
+ }
155
+ if (ok) out.push({ item, score, idx: [...new Set(idx)].sort((a, b) => a - b) });
156
+ }
157
+ return out.sort((a, b) => b.score - a.score);
158
+ }
159
+
160
+ /**
161
+ * Bucket ranked results by `item.group`. Groups listed in `order` come in
162
+ * that order when the query is empty; with a query, groups sort by their
163
+ * best hit. `perGroup` caps each bucket.
164
+ */
165
+ export function groupResults(ranked, { order = [], perGroup = Infinity, byScore = false } = {}) {
166
+ const groups = new Map();
167
+ for (const r of ranked) {
168
+ const g = r.item.group ?? 'Commands';
169
+ if (!groups.has(g)) groups.set(g, []);
170
+ const list = groups.get(g);
171
+ if (list.length < perGroup) list.push(r);
172
+ }
173
+ const rank = (g) => {
174
+ const i = order.indexOf(g);
175
+ return i >= 0 ? i : order.length;
176
+ };
177
+ return [...groups.entries()]
178
+ .map(([group, items]) => ({ group, items }))
179
+ .sort((a, b) =>
180
+ byScore
181
+ ? b.items[0].score - a.items[0].score || rank(a.group) - rank(b.group)
182
+ : rank(a.group) - rank(b.group));
183
+ }
184
+
185
+ /* ── keyboard ───────────────────────────────────────────────────────────── */
186
+
187
+ /** `g <letter>` → the view it names (only when that view exists). */
188
+ export const G_VIEWS = {
189
+ d: 'dashboard',
190
+ p: 'performance',
191
+ t: 'tasks',
192
+ n: 'network',
193
+ m: 'memory',
194
+ r: 'reactivity',
195
+ a: 'audits',
196
+ l: 'log',
197
+ };
198
+
199
+ /**
200
+ * Resolve a `g` letter against registered views `[{ id, label }]`: the
201
+ * G_VIEWS name by id, then by id/label prefix (panels may name their view
202
+ * `perf` or label it "Performance"). Unmapped letters fall back to the
203
+ * first view whose label starts with that letter.
204
+ */
205
+ export function viewForLetter(letter, views) {
206
+ const l = String(letter ?? '').toLowerCase();
207
+ if (!/^[a-z]$/.test(l)) return null;
208
+ const name = G_VIEWS[l];
209
+ if (name) {
210
+ const stem = name.slice(0, 4);
211
+ const v = views.find((x) => x.id === name) ??
212
+ views.find((x) => x.id.toLowerCase().startsWith(stem)) ??
213
+ views.find((x) => String(x.label ?? '').toLowerCase().startsWith(stem));
214
+ return v ? v.id : null;
215
+ }
216
+ const v = views.find((x) => String(x.label ?? x.id).toLowerCase().startsWith(l));
217
+ return v ? v.id : null;
218
+ }
219
+
220
+ /** The letter `g <letter>` uses for a view id, or null. */
221
+ export function letterForView(id, views) {
222
+ for (const l of Object.keys(G_VIEWS)) if (viewForLetter(l, views) === id) return l;
223
+ const v = views.find((x) => x.id === id);
224
+ const first = String(v?.label ?? id).toLowerCase()[0];
225
+ return first && viewForLetter(first, views) === id ? first : null;
226
+ }
227
+
228
+ /** True when a keystroke belongs to a text field, not to shortcuts. */
229
+ export function isTypingTarget(el) {
230
+ if (!el || typeof el !== 'object') return false;
231
+ const tag = String(el.tagName ?? '').toUpperCase();
232
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
233
+ if (tag === 'INPUT') {
234
+ const type = String(el.type ?? 'text').toLowerCase();
235
+ return !['button', 'checkbox', 'radio', 'range', 'color', 'submit', 'reset', 'file', 'image'].includes(type);
236
+ }
237
+ return el.isContentEditable === true;
238
+ }
239
+
240
+ /* ── nav overflow ───────────────────────────────────────────────────────── */
241
+
242
+ /**
243
+ * Which nav buttons to fold into the "More" menu. `widths` in nav order,
244
+ * `avail` the nav's width, `active` the selected index (always kept
245
+ * visible), `moreW` the More button's width. Keeps a contiguous prefix
246
+ * plus the active tab. Returns the hidden indices, ascending.
247
+ */
248
+ export function layoutNav(widths, avail, active, moreW) {
249
+ const total = widths.reduce((a, w) => a + w, 0);
250
+ if (total <= avail) return [];
251
+ let budget = avail - moreW;
252
+ const keep = new Set();
253
+ if (active >= 0 && active < widths.length) {
254
+ keep.add(active);
255
+ budget -= widths[active];
256
+ }
257
+ for (let i = 0; i < widths.length; i++) {
258
+ if (i === active) continue;
259
+ if (widths[i] > budget) break;
260
+ keep.add(i);
261
+ budget -= widths[i];
262
+ }
263
+ return widths.map((_, i) => i).filter((i) => !keep.has(i));
264
+ }