@atolljs/devtools 0.1.7 → 0.1.8

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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
@@ -0,0 +1,351 @@
1
+ // Island inspector tabs (rendered into #inspExtra): Elements (the island's
2
+ // real DOM via `island.tree`, hover-to-highlight in the page via
3
+ // `island.highlight`), Props (island:props history with diffs) and Events
4
+ // (island:event log with payload previews).
5
+ //
6
+ // Render contract: the inspector hook fires many times a second. The tab
7
+ // DOM is built once per inspected key; afterwards only counts/gates are
8
+ // touched, and lists re-render when their data changed. The tree is only
9
+ // rebuilt when a fetch lands, keeping expanded/selected state.
10
+
11
+ const CSS = `
12
+ .ii { margin: 10px 0 4px; }
13
+ .ii-tabs { display:flex; gap:4px; margin-bottom:8px; }
14
+ .ii-tabs button { background:none; border:1px solid #21262d; color:#8b949e; border-radius:6px; padding:3px 10px; cursor:pointer; font:inherit; font-size:11px; }
15
+ .ii-tabs button.on { color:#c9d1d9; border-color:#58a6ff; background:#161b22; }
16
+ .ii-tabs .n { color:#8b949e; margin-left:4px; }
17
+ .ii-pane { display:none; } .ii-pane.on { display:block; }
18
+ .ii-bar { display:flex; gap:8px; align-items:center; margin-bottom:6px; font-size:11px; flex-wrap:wrap; }
19
+ .ii-bar input[type=text] { background:#0d1117; color:#c9d1d9; border:1px solid #30363d; border-radius:4px; padding:2px 6px; font:inherit; }
20
+ .ii-msg { color:#d29922; font-size:11px; }
21
+ .ii-split { display:grid; grid-template-columns: minmax(0,3fr) minmax(0,2fr); gap:10px; }
22
+ .ii-tree, .ii-detail, .ii-list { border:1px solid #21262d; border-radius:8px; background:#010409; max-height:380px; overflow:auto; padding:4px 6px; font-size:11px; }
23
+ .ii-node > .ii-row { white-space:nowrap; cursor:default; padding:0 4px; border-radius:3px; }
24
+ .ii-node > .ii-row:hover { background:#161b22; }
25
+ .ii-node > .ii-row.sel { background:#1f6feb33; }
26
+ .ii-node > .ii-kids { padding-left:14px; }
27
+ .ii-node.closed > .ii-kids { display:none; }
28
+ .ii-caret { display:inline-block; width:12px; color:#8b949e; cursor:pointer; user-select:none; }
29
+ .ii-tag { color:#7ee787; } .ii-an { color:#79c0ff; } .ii-av { color:#a5d6ff; } .ii-tx { color:#c9d1d9; }
30
+ .ii-more { color:#8b949e; font-style:italic; padding-left:16px; }
31
+ .ii-entry { border-bottom:1px dashed #21262d; padding:4px 0; }
32
+ .ii-entry pre { margin:2px 0 0; white-space:pre-wrap; word-break:break-all; }
33
+ .ii-t { color:#484f58; margin-right:6px; }
34
+ .ii-add { color:#3fb950; } .ii-del { color:#f85149; } .ii-chg { color:#d29922; }
35
+ `;
36
+
37
+ const MAX_PROPS = 100;
38
+ const MAX_EVENTS = 500;
39
+
40
+ const injectCss = () => {
41
+ if (document.getElementById('ii-css')) return;
42
+ const s = document.createElement('style');
43
+ s.id = 'ii-css';
44
+ s.textContent = CSS;
45
+ document.head.appendChild(s);
46
+ };
47
+
48
+ const clock = (t) => {
49
+ const d = new Date(t);
50
+ const p = (n, w = 2) => String(n).padStart(w, '0');
51
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
52
+ };
53
+
54
+ const tryJson = (s) => {
55
+ try { return { ok: true, v: JSON.parse(s) }; } catch { return { ok: false }; }
56
+ };
57
+
58
+ /** Top-level key diff between two props previews ('' when unparseable). */
59
+ const diffProps = (prev, next, esc) => {
60
+ if (prev === undefined) return '<span class="muted">initial</span>';
61
+ const a = tryJson(prev), b = tryJson(next);
62
+ if (!a.ok || !b.ok || typeof a.v !== 'object' || typeof b.v !== 'object' || !a.v || !b.v) {
63
+ return prev === next ? '<span class="muted">unchanged</span>' : '<span class="ii-chg">changed (preview truncated)</span>';
64
+ }
65
+ const out = [];
66
+ for (const k of Object.keys(b.v)) {
67
+ if (!(k in a.v)) out.push(`<span class="ii-add">+ ${esc(k)}: ${esc(JSON.stringify(b.v[k]))}</span>`);
68
+ else if (JSON.stringify(a.v[k]) !== JSON.stringify(b.v[k])) {
69
+ out.push(`<span class="ii-chg">~ ${esc(k)}: ${esc(JSON.stringify(a.v[k]))} → ${esc(JSON.stringify(b.v[k]))}</span>`);
70
+ }
71
+ }
72
+ for (const k of Object.keys(a.v)) if (!(k in b.v)) out.push(`<span class="ii-del">− ${esc(k)}</span>`);
73
+ return out.length ? out.join('<br>') : '<span class="muted">unchanged</span>';
74
+ };
75
+
76
+ export function setup(api) {
77
+ injectCss();
78
+ const { esc } = api;
79
+
80
+ /** islandKey → { props: [{t, preview}], events: [{t, name, payload}] } */
81
+ const hist = new Map();
82
+ const histOf = (k) => {
83
+ let h = hist.get(k);
84
+ if (!h) hist.set(k, (h = { props: [], events: [], seq: 0 }));
85
+ return h;
86
+ };
87
+
88
+ api.onEvent((s, e) => {
89
+ if (e.type === 'island:props') {
90
+ const h = histOf(api.key(s.id, e.instance));
91
+ h.props.push({ t: Date.now(), preview: e.props });
92
+ if (h.props.length > MAX_PROPS) h.props.shift();
93
+ h.seq++;
94
+ } else if (e.type === 'island:event') {
95
+ const h = histOf(api.key(s.id, e.instance));
96
+ h.events.push({ t: Date.now(), name: e.name, payload: e.payload });
97
+ if (h.events.length > MAX_EVENTS) h.events.shift();
98
+ h.seq++;
99
+ }
100
+ });
101
+ api.onReset(() => { hist.clear(); view = null; });
102
+ api.onReconcile((ids) => {
103
+ for (const k of [...hist.keys()]) if (!ids.has(api.sessOf(k))) hist.delete(k);
104
+ });
105
+
106
+ let tab = 'elements';
107
+ /** Per-inspected-key UI state; rebuilt when the key changes. */
108
+ let view = null;
109
+
110
+ const target = (islandKey) => {
111
+ const sid = api.sessOf(islandKey);
112
+ const instance = islandKey.slice(sid.length + 1);
113
+ // Nested islands' handles live in their worker: their DOM is replayed
114
+ // into the top-level island's container, so the tree comes from there.
115
+ const root = instance.split('~')[0];
116
+ return { sid, instance, root, nested: root !== instance };
117
+ };
118
+
119
+ const blocked = (sid) => {
120
+ if (api.livePaused) return 'Viewing a recording: live DOM is unavailable.';
121
+ if (api.state.sessions.get(sid)?.closed) return 'Session has ended: the island DOM is gone.';
122
+ if (!api.hasCommand(sid, 'island.tree')) {
123
+ return "The app doesn't expose 'island.tree' (devtools was off when the island mounted, or an older @atolljs/islands).";
124
+ }
125
+ return null;
126
+ };
127
+
128
+ /* ── Elements ──────────────────────────────────────────────────────────── */
129
+
130
+ const renderAttrs = (n) =>
131
+ n.attrs.map(([k, v]) => ` <span class="ii-an">${esc(k)}</span>=<span class="ii-av">"${esc(v.length > 60 ? `${v.slice(0, 60)}…` : v)}"</span>`).join('');
132
+
133
+ const nodeRow = (n) => {
134
+ if (n.tag === '#text') return `<span class="ii-tx">"${esc(n.text.length > 80 ? `${n.text.slice(0, 80)}…` : n.text)}"</span>`;
135
+ if (n.tag === '#shadow-root') return '<span class="muted">#shadow-root</span>';
136
+ return `<span class="ii-tag">&lt;${esc(n.tag)}</span>${renderAttrs(n)}<span class="ii-tag">&gt;</span>`;
137
+ };
138
+
139
+ const buildTree = (v, root) => {
140
+ const byId = new Map();
141
+ const build = (n, depth) => {
142
+ byId.set(n.id, n);
143
+ const el = document.createElement('div');
144
+ el.className = 'ii-node';
145
+ el.dataset.id = n.id;
146
+ const hasKids = n.children.length > 0 || n.more;
147
+ const open = v.expanded.has(n.id) || (!v.touched.has(n.id) && depth < 3);
148
+ if (hasKids && !open) el.classList.add('closed');
149
+ el.innerHTML =
150
+ `<div class="ii-row${v.selected === n.id ? ' sel' : ''}" data-id="${esc(n.id)}">` +
151
+ `<span class="ii-caret">${hasKids ? (open ? '▾' : '▸') : ''}</span>${nodeRow(n)}</div>`;
152
+ if (hasKids) {
153
+ const kids = document.createElement('div');
154
+ kids.className = 'ii-kids';
155
+ for (const c of n.children) kids.appendChild(build(c, depth + 1));
156
+ if (n.more) kids.insertAdjacentHTML('beforeend', `<div class="ii-more">… ${n.more} more (node cap)</div>`);
157
+ el.appendChild(kids);
158
+ }
159
+ return el;
160
+ };
161
+ v.byId = byId;
162
+ const scroll = v.els.tree.scrollTop;
163
+ v.els.tree.replaceChildren(build(root, 0));
164
+ v.els.tree.scrollTop = scroll;
165
+ };
166
+
167
+ const showDetail = (v) => {
168
+ const n = v.byId?.get(v.selected);
169
+ if (!n) { v.els.detail.innerHTML = '<span class="muted">click a node for its attributes</span>'; return; }
170
+ v.els.detail.innerHTML =
171
+ `<div><b>${esc(n.tag)}</b> <span class="muted">${esc(n.id)}</span></div>` +
172
+ (n.attrs.length
173
+ ? `<table class="tkv" style="margin-top:4px"><tbody>${n.attrs.map(([k, val]) => `<tr><td>${esc(k)}</td><td style="word-break:break-all">${esc(val)}</td></tr>`).join('')}</tbody></table>`
174
+ : '<div class="muted" style="margin-top:4px">no attributes</div>') +
175
+ (n.text !== undefined ? `<pre class="body" style="margin-top:6px">${esc(n.text)}</pre>` : '') +
176
+ `<div class="muted" style="margin-top:6px">${n.children.length} child node(s)${n.more ? ` + ${n.more} omitted` : ''}</div>`;
177
+ };
178
+
179
+ const fetchTree = (v) => {
180
+ const t = target(v.key);
181
+ if (blocked(t.sid) || v.loading) return;
182
+ v.loading = true;
183
+ v.els.status.textContent = 'loading…';
184
+ api.control(t.sid, 'island.tree', { instance: t.root, maxNodes: 4000 }, 5000)
185
+ .then((res) => {
186
+ if (view !== v) return;
187
+ buildTree(v, res.root);
188
+ showDetail(v);
189
+ v.loaded = true;
190
+ v.els.status.textContent = `${res.count} nodes${res.truncated ? ' (capped)' : ''} · ${clock(Date.now())}`;
191
+ })
192
+ .catch((err) => { if (view === v) v.els.status.textContent = `error: ${err.message}`; })
193
+ .finally(() => { v.loading = false; });
194
+ };
195
+
196
+ const highlight = (v, id) => {
197
+ if (v.hoverId === id) return;
198
+ v.hoverId = id;
199
+ const t = target(v.key);
200
+ if (blocked(t.sid)) return;
201
+ api.control(t.sid, 'island.highlight', { instance: t.root, id }, 2000).catch(() => {});
202
+ };
203
+
204
+ /* ── build per key ─────────────────────────────────────────────────────── */
205
+
206
+ const build = (extra, islandKey) => {
207
+ let box = extra.querySelector(':scope > .ii');
208
+ if (!box) {
209
+ box = document.createElement('div');
210
+ box.className = 'ii';
211
+ extra.appendChild(box);
212
+ }
213
+ box.innerHTML = `
214
+ <div class="ii-tabs">
215
+ <button data-tab="elements">Elements</button>
216
+ <button data-tab="props">Props<span class="n" data-n="props"></span></button>
217
+ <button data-tab="events">Events<span class="n" data-n="events"></span></button>
218
+ </div>
219
+ <div class="ii-pane" data-pane="elements">
220
+ <div class="ii-bar">
221
+ <button class="act" data-act="refresh">Refresh</button>
222
+ <label><input type="checkbox" data-act="auto"> auto-refresh (2s)</label>
223
+ <span class="muted" data-el="status"></span>
224
+ </div>
225
+ <div class="ii-msg" data-el="msg"></div>
226
+ <div class="ii-split">
227
+ <div class="ii-tree" data-el="tree"><span class="muted">no tree loaded</span></div>
228
+ <div class="ii-detail" data-el="detail"><span class="muted">click a node for its attributes</span></div>
229
+ </div>
230
+ </div>
231
+ <div class="ii-pane" data-pane="props"><div class="ii-list" data-el="props"></div></div>
232
+ <div class="ii-pane" data-pane="events">
233
+ <div class="ii-bar"><input type="text" data-el="filter" placeholder="filter by event name"></div>
234
+ <div class="ii-list" data-el="events"></div>
235
+ </div>`;
236
+ const q = (sel) => box.querySelector(sel);
237
+ const v = {
238
+ key: islandKey, box, expanded: new Set(), touched: new Set(), selected: null, byId: null,
239
+ hoverId: undefined, loaded: false, loading: false, tried: false, auto: false, lastAuto: 0,
240
+ propsSeq: -1, eventsSeq: -1, filter: '', gate: undefined,
241
+ els: {
242
+ tree: q('[data-el=tree]'), detail: q('[data-el=detail]'), status: q('[data-el=status]'),
243
+ msg: q('[data-el=msg]'), props: q('[data-el=props]'), events: q('[data-el=events]'),
244
+ filter: q('[data-el=filter]'), refresh: q('[data-act=refresh]'), auto: q('[data-act=auto]'),
245
+ nProps: q('[data-n=props]'), nEvents: q('[data-n=events]'),
246
+ },
247
+ };
248
+ const setTab = (name) => {
249
+ tab = name;
250
+ for (const b of box.querySelectorAll('.ii-tabs button')) b.classList.toggle('on', b.dataset.tab === name);
251
+ for (const p of box.querySelectorAll('.ii-pane')) p.classList.toggle('on', p.dataset.pane === name);
252
+ };
253
+ setTab(tab);
254
+ q('.ii-tabs').addEventListener('click', (ev) => {
255
+ const b = ev.target.closest('button[data-tab]');
256
+ if (b) setTab(b.dataset.tab);
257
+ });
258
+ v.els.refresh.onclick = () => fetchTree(v);
259
+ v.els.auto.onchange = () => { v.auto = v.els.auto.checked; };
260
+ v.els.filter.oninput = () => { v.filter = v.els.filter.value.trim().toLowerCase(); v.eventsSeq = -1; renderLists(v, true); };
261
+ v.els.tree.addEventListener('click', (ev) => {
262
+ const row = ev.target.closest('.ii-row');
263
+ if (!row) return;
264
+ const node = row.parentElement;
265
+ const id = row.dataset.id;
266
+ if (ev.target.classList.contains('ii-caret') && node.querySelector(':scope > .ii-kids')) {
267
+ const closed = node.classList.toggle('closed');
268
+ v.touched.add(id);
269
+ if (closed) v.expanded.delete(id); else v.expanded.add(id);
270
+ ev.target.textContent = closed ? '▸' : '▾';
271
+ return;
272
+ }
273
+ v.els.tree.querySelector('.ii-row.sel')?.classList.remove('sel');
274
+ row.classList.add('sel');
275
+ v.selected = id;
276
+ showDetail(v);
277
+ });
278
+ v.els.tree.addEventListener('mouseover', (ev) => {
279
+ const row = ev.target.closest('.ii-row');
280
+ if (row) highlight(v, row.dataset.id);
281
+ });
282
+ v.els.tree.addEventListener('mouseleave', () => highlight(v, null));
283
+ return v;
284
+ };
285
+
286
+ /* ── lists ─────────────────────────────────────────────────────────────── */
287
+
288
+ const renderLists = (v, force = false) => {
289
+ const h = hist.get(v.key) ?? { props: [], events: [] };
290
+ v.els.nProps.textContent = h.props.length ? String(h.props.length) : '';
291
+ v.els.nEvents.textContent = h.events.length ? String(h.events.length) : '';
292
+ if (force || v.propsSeq !== h.props.length + (h.props.at(-1)?.t ?? 0)) {
293
+ v.propsSeq = h.props.length + (h.props.at(-1)?.t ?? 0);
294
+ const rows = [];
295
+ for (let i = h.props.length - 1; i >= 0 && rows.length < 50; i--) {
296
+ const p = h.props[i];
297
+ const parsed = tryJson(p.preview);
298
+ rows.push(
299
+ `<div class="ii-entry"><span class="ii-t">${clock(p.t)}</span>${i === 0 && h.props.length < MAX_PROPS ? '<span class="pill">mount</span>' : ''}` +
300
+ `<div style="margin-top:2px">${diffProps(h.props[i - 1]?.preview, p.preview, esc)}</div>` +
301
+ `<details><summary class="muted">props</summary><pre>${esc(parsed.ok ? JSON.stringify(parsed.v, null, 2) : p.preview)}</pre></details></div>`,
302
+ );
303
+ }
304
+ v.els.props.innerHTML = rows.join('') || '<span class="muted">no island:props yet (emitted on mount and every updateProps while devtools is on)</span>';
305
+ }
306
+ const evSeq = h.events.length + (h.events.at(-1)?.t ?? 0);
307
+ if (force || v.eventsSeq !== evSeq) {
308
+ v.eventsSeq = evSeq;
309
+ const rows = [];
310
+ for (let i = h.events.length - 1; i >= 0 && rows.length < 200; i--) {
311
+ const e = h.events[i];
312
+ if (v.filter && !e.name.toLowerCase().includes(v.filter)) continue;
313
+ rows.push(
314
+ `<div class="ii-entry"><span class="ii-t">${clock(e.t)}</span><b>${esc(e.name)}</b>` +
315
+ (e.payload !== undefined ? `<pre>${esc(e.payload)}</pre>` : '') + '</div>',
316
+ );
317
+ }
318
+ v.els.events.innerHTML = rows.join('') || `<span class="muted">${h.events.length ? 'no events match the filter' : 'no emits yet'}</span>`;
319
+ }
320
+ };
321
+
322
+ /* ── hook ──────────────────────────────────────────────────────────────── */
323
+
324
+ api.onInspectIsland((islandKey, island, { extra }) => {
325
+ if (!view || view.key !== islandKey || !view.box.isConnected) {
326
+ if (view) highlight(view, null);
327
+ view = build(extra, islandKey);
328
+ }
329
+ const v = view;
330
+ const t = target(islandKey);
331
+ const reason = blocked(t.sid);
332
+ const msg = reason ?? (t.nested
333
+ ? `Nested island: its handle lives in the parent worker, so the tree below is the top-level island ${t.root}, which contains this island's replayed DOM.`
334
+ : '');
335
+ if (v.gate !== msg) {
336
+ v.gate = msg;
337
+ v.els.msg.textContent = msg;
338
+ v.els.refresh.disabled = reason !== null;
339
+ v.els.auto.disabled = reason !== null;
340
+ }
341
+ if (!reason && !v.tried) {
342
+ v.tried = true;
343
+ fetchTree(v);
344
+ }
345
+ if (!reason && v.auto && Date.now() - v.lastAuto > 2000) {
346
+ v.lastAuto = Date.now();
347
+ fetchTree(v);
348
+ }
349
+ renderLists(v);
350
+ });
351
+ }
@@ -0,0 +1,287 @@
1
+ // Memory › Values: live shared-memory field values for one session, polled
2
+ // over the control channel (`memory.read`, ~2Hz, only while this sub-tab is
3
+ // visible), with snapshot/diff, dashboard-set watchpoints (`memory.watch`,
4
+ // `memory.unwatch`, `memory.watches`) and the memory:watch-hit log.
5
+
6
+ const CSS = `
7
+ .mvv-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; font-size:11px; }
8
+ .mvv-bar select, .mvv-bar input { background:#0d1117; color:#c9d1d9; border:1px solid #30363d; border-radius:4px; padding:2px 6px; font:inherit; }
9
+ .mvv-msg { color:#d29922; font-size:11px; min-height:1em; margin-bottom:6px; }
10
+ #mvvTable td.v { max-width:520px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
11
+ #mvvTable td.d { color:#8b949e; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
12
+ #mvvTable td.d.chg { color:#d29922; }
13
+ @keyframes mvvFlash { from { background:#1f6feb55; } to { background:transparent; } }
14
+ #mvvTable td.flash { animation: mvvFlash .9s ease-out; }
15
+ .mvv-hits { border:1px solid #21262d; border-radius:8px; max-height:220px; overflow-y:auto; padding:4px 8px; font-size:11px; }
16
+ .mvv-hits div { border-bottom:1px dashed #21262d; padding:2px 0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17
+ .mvv-watch { display:flex; gap:6px; align-items:center; padding:2px 0; font-size:11px; }
18
+ `;
19
+
20
+ const POLL_MS = 500;
21
+ const MAX_HITS = 200;
22
+
23
+ const injectCss = () => {
24
+ if (document.getElementById('mvv-css')) return;
25
+ const s = document.createElement('style');
26
+ s.id = 'mvv-css';
27
+ s.textContent = CSS;
28
+ document.head.appendChild(s);
29
+ };
30
+
31
+ const clock = (t) => {
32
+ const d = new Date(t);
33
+ const p = (n, w = 2) => String(n).padStart(w, '0');
34
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
35
+ };
36
+
37
+ export function setup(api) {
38
+ injectCss();
39
+ const { esc } = api;
40
+
41
+ const div = api.addSubview('memory', {
42
+ id: 'mv-values',
43
+ label: 'Values',
44
+ order: 60,
45
+ html: `
46
+ <h2>Live values <span class="muted">— polled at ~2Hz while this tab is open; changed cells flash</span></h2>
47
+ <div class="mvv-bar">
48
+ <span class="muted" id="mvvSess"></span>
49
+ <button class="act" id="mvvSnap" title="Remember the current values; the diff column compares against them">Snapshot</button>
50
+ <button class="act" id="mvvClearSnap">Clear snapshot</button>
51
+ <span class="muted" id="mvvStatus"></span>
52
+ </div>
53
+ <div class="mvv-msg" id="mvvMsg"></div>
54
+ <table id="mvvTable"><thead><tr>
55
+ <th>field</th><th>contract</th><th>value</th><th>version</th><th>vs snapshot</th>
56
+ </tr></thead><tbody></tbody></table>
57
+ <div class="grid2" style="margin-top:14px">
58
+ <div>
59
+ <h2>Watchpoints <span class="muted">— fire memory:watch-hit on matching writes</span></h2>
60
+ <div class="mvv-bar">
61
+ <select id="mvvPath"><option value="">field…</option></select>
62
+ <input id="mvvRule" type="text" value="change" size="14" title="change | > n | < n | >= n | <= n | == v | != v">
63
+ <button class="act" id="mvvAdd">Add watch</button>
64
+ </div>
65
+ <div id="mvvWatches"><span class="muted">no watches</span></div>
66
+ <div class="muted" style="font-size:11px;margin-top:6px">Rules: change, &gt; n, &lt; n, &gt;= n, &lt;= n, == v, != v (v as JSON, e.g. == "down"). Worker writes are checked when the main thread observes the version bump, so bursts between observations coalesce to the latest value.</div>
67
+ </div>
68
+ <div>
69
+ <h2>Watch hits</h2>
70
+ <div class="mvv-hits" id="mvvHits"><span class="muted">no hits yet</span></div>
71
+ </div>
72
+ </div>`,
73
+ });
74
+ const $ = (id) => div.querySelector(`#${id}`);
75
+ const tbody = $('mvvTable').querySelector('tbody');
76
+
77
+ /** rowKey `${contract}|${path}` → { tr, cells, value, version } */
78
+ const rows = new Map();
79
+ let snapshot = null; // { sid, at, values: Map(rowKey → value) }
80
+ let sid = null;
81
+ let inFlight = false;
82
+ let lastReadAt = 0;
83
+ let watches = [];
84
+ const hits = [];
85
+ let hitsRendered = -1;
86
+ let pathsSig = '';
87
+
88
+ const visible = () => div.classList.contains('on') && div.closest('.view')?.classList.contains('on');
89
+
90
+ const blocked = (s) => {
91
+ if (api.livePaused) return 'Viewing a recording: live values are unavailable.';
92
+ if (!s) return api.liveSessions().length ? 'Several live sessions: select one in the sidebar.' : 'No live session.';
93
+ if (api.state.sessions.get(s)?.closed) return 'Session has ended.';
94
+ if (!api.hasCommand(s, 'memory.read')) {
95
+ return "The app doesn't expose 'memory.read' (no shared-memory contract bound with devtools on, or an older @atolljs/core).";
96
+ }
97
+ return null;
98
+ };
99
+
100
+ const flash = (td) => {
101
+ td.classList.remove('flash');
102
+ void td.offsetWidth; // restart the animation
103
+ td.classList.add('flash');
104
+ };
105
+
106
+ const diffCell = (rowKey, value) => {
107
+ if (!snapshot || snapshot.sid !== sid) return ['', false];
108
+ if (!snapshot.values.has(rowKey)) return ['new', true];
109
+ const was = snapshot.values.get(rowKey);
110
+ return was === value ? ['=', false] : [`was ${was}`, true];
111
+ };
112
+
113
+ const applyRead = (list) => {
114
+ const seen = new Set();
115
+ for (const r of list) {
116
+ const rk = `${r.contract}|${r.path}`;
117
+ seen.add(rk);
118
+ let row = rows.get(rk);
119
+ if (!row) {
120
+ const tr = document.createElement('tr');
121
+ tr.innerHTML = `<td>${esc(r.path)}</td><td class="muted">#${r.contract}</td><td class="v"></td><td></td><td class="d"></td>`;
122
+ const cells = tr.querySelectorAll('td');
123
+ row = { tr, cells, value: undefined, version: undefined };
124
+ rows.set(rk, row);
125
+ tbody.appendChild(tr);
126
+ }
127
+ if (row.value !== r.value) {
128
+ if (row.value !== undefined) flash(row.cells[2]);
129
+ row.cells[2].textContent = r.value;
130
+ row.cells[2].title = r.value;
131
+ row.value = r.value;
132
+ }
133
+ if (row.version !== r.version) {
134
+ row.cells[3].textContent = String(r.version);
135
+ row.version = r.version;
136
+ }
137
+ const [d, chg] = diffCell(rk, r.value);
138
+ if (row.cells[4].textContent !== d) {
139
+ row.cells[4].textContent = d;
140
+ row.cells[4].title = d;
141
+ row.cells[4].classList.toggle('chg', chg);
142
+ }
143
+ }
144
+ for (const [rk, row] of rows) {
145
+ if (!seen.has(rk)) { row.tr.remove(); rows.delete(rk); }
146
+ }
147
+ const paths = [...new Set(list.map((r) => r.path))].sort();
148
+ const sig = paths.join('\n');
149
+ if (sig !== pathsSig) {
150
+ pathsSig = sig;
151
+ const sel = $('mvvPath');
152
+ const cur = sel.value;
153
+ sel.innerHTML = '<option value="">field…</option>' + paths.map((p) => `<option>${esc(p)}</option>`).join('');
154
+ if (paths.includes(cur)) sel.value = cur;
155
+ }
156
+ };
157
+
158
+ const clearRows = () => {
159
+ rows.clear();
160
+ tbody.innerHTML = '';
161
+ pathsSig = '';
162
+ };
163
+
164
+ const renderWatches = () => {
165
+ const box = $('mvvWatches');
166
+ if (!watches.length) { box.innerHTML = '<span class="muted">no watches</span>'; return; }
167
+ box.innerHTML = watches.map((w) =>
168
+ `<div class="mvv-watch"><b>${esc(w.path)}</b> <span>${esc(w.rule)}</span> <span class="muted">${w.hits} hit(s)</span>` +
169
+ `<button class="act" data-unwatch="${esc(w.path)}">remove</button></div>`).join('');
170
+ };
171
+
172
+ const refreshWatches = () => {
173
+ if (blocked(sid)) return;
174
+ api.control(sid, 'memory.watches', {}, 3000).then((list) => { watches = list; renderWatches(); }).catch(() => {});
175
+ };
176
+
177
+ const poll = () => {
178
+ if (!visible()) return;
179
+ const next = api.selectedOrOnlyLive();
180
+ if (next !== sid) {
181
+ sid = next;
182
+ clearRows();
183
+ watches = [];
184
+ renderWatches();
185
+ refreshWatches();
186
+ hitsRendered = -1;
187
+ }
188
+ const reason = blocked(sid);
189
+ $('mvvMsg').textContent = reason ?? '';
190
+ $('mvvSess').textContent = sid ? `session ${api.state.sessions.get(sid)?.name ?? sid}` : '';
191
+ for (const b of [$('mvvSnap'), $('mvvAdd')]) b.disabled = reason !== null;
192
+ renderHits();
193
+ if (reason || inFlight) return;
194
+ inFlight = true;
195
+ const asked = sid;
196
+ api.control(sid, 'memory.read', {}, 3000)
197
+ .then((list) => {
198
+ if (asked !== sid) return;
199
+ applyRead(list);
200
+ lastReadAt = Date.now();
201
+ $('mvvStatus').textContent = `${list.length} field(s) · read ${clock(lastReadAt)}` +
202
+ (snapshot && snapshot.sid === sid ? ` · snapshot ${clock(snapshot.at)}` : '');
203
+ })
204
+ .catch((err) => { $('mvvStatus').textContent = `error: ${err.message}`; })
205
+ .finally(() => { inFlight = false; });
206
+ };
207
+ setInterval(poll, POLL_MS);
208
+
209
+ const takeSnapshot = () => {
210
+ if (!sid) return;
211
+ snapshot = { sid, at: Date.now(), values: new Map([...rows].map(([rk, r]) => [rk, r.value])) };
212
+ for (const [rk, r] of rows) {
213
+ const [d, chg] = diffCell(rk, r.value);
214
+ r.cells[4].textContent = d;
215
+ r.cells[4].classList.toggle('chg', chg);
216
+ }
217
+ };
218
+ const clearSnapshot = () => {
219
+ snapshot = null;
220
+ for (const r of rows.values()) { r.cells[4].textContent = ''; r.cells[4].classList.remove('chg'); }
221
+ };
222
+
223
+ $('mvvSnap').onclick = takeSnapshot;
224
+ $('mvvClearSnap').onclick = clearSnapshot;
225
+ $('mvvAdd').onclick = () => {
226
+ const path = $('mvvPath').value;
227
+ const rule = $('mvvRule').value.trim() || 'change';
228
+ if (!path || blocked(sid)) return;
229
+ api.control(sid, 'memory.watch', { path, rule }, 3000)
230
+ .then((list) => { watches = list; renderWatches(); $('mvvMsg').textContent = ''; })
231
+ .catch((err) => { $('mvvMsg').textContent = err.message; });
232
+ };
233
+ $('mvvWatches').addEventListener('click', (ev) => {
234
+ const b = ev.target.closest('button[data-unwatch]');
235
+ if (!b || blocked(sid)) return;
236
+ api.control(sid, 'memory.unwatch', { path: b.dataset.unwatch }, 3000)
237
+ .then((list) => { watches = list; renderWatches(); })
238
+ .catch((err) => { $('mvvMsg').textContent = err.message; });
239
+ });
240
+
241
+ /* ── watch-hit log ─────────────────────────────────────────────────────── */
242
+
243
+ api.onEvent((s, e) => {
244
+ if (e.type !== 'memory:watch-hit') return;
245
+ hits.unshift({ t: Date.now(), sid: s.id, path: e.path, version: e.version, value: e.value, rule: e.rule });
246
+ if (hits.length > MAX_HITS) hits.pop();
247
+ const w = watches.find((x) => x.path === e.path);
248
+ if (w && s.id === sid) { w.hits++; renderWatches(); }
249
+ });
250
+
251
+ function renderHits() {
252
+ if (hitsRendered === hits.length + (hits[0]?.t ?? 0)) return;
253
+ hitsRendered = hits.length + (hits[0]?.t ?? 0);
254
+ const mine = sid ? hits.filter((h) => h.sid === sid) : hits;
255
+ $('mvvHits').innerHTML = mine.slice(0, 100).map((h) =>
256
+ `<div><span class="logts">${clock(h.t)}</span> <b>${esc(h.path)}</b> ${esc(h.rule)} → ${esc(h.value)} <span class="muted">v${h.version}</span></div>`,
257
+ ).join('') || '<span class="muted">no hits yet</span>';
258
+ }
259
+ api.onRender(() => { if (visible()) renderHits(); });
260
+
261
+ api.onReset(() => {
262
+ clearRows();
263
+ snapshot = null;
264
+ hits.length = 0;
265
+ hitsRendered = -1;
266
+ watches = [];
267
+ sid = null;
268
+ renderWatches();
269
+ });
270
+
271
+ /* ── palette ───────────────────────────────────────────────────────────── */
272
+
273
+ const openValues = () => api.openSub('memory', 'mv-values');
274
+ api.addPaletteItem({ id: 'memory.values', title: 'Memory: open live values', group: 'Memory', run: openValues });
275
+ api.addPaletteItem({
276
+ id: 'memory.snapshot',
277
+ title: 'Memory: snapshot values',
278
+ group: 'Memory',
279
+ hint: 'diff column compares against it',
280
+ run: () => {
281
+ openValues();
282
+ // values land on the next poll when the tab was just opened
283
+ if (rows.size) takeSnapshot(); else setTimeout(takeSnapshot, POLL_MS * 2);
284
+ },
285
+ });
286
+ api.addPaletteItem({ id: 'memory.clearSnapshot', title: 'Memory: clear values snapshot', group: 'Memory', run: clearSnapshot });
287
+ }