@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.
- package/README.md +408 -71
- package/app/api.js +68 -0
- package/app/index.html +161 -2
- package/app/main.js +877 -127
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +153 -27
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
// Dashboard shell: command palette, keyboard shortcuts + help overlay,
|
|
2
|
+
// hash routing (deep links, back/forward, last view per context), the
|
|
3
|
+
// responsive nav ("More" overflow menu), collapsible session sidebar,
|
|
4
|
+
// scrollable tables, view intros, wait-for-app banner, first-run tip.
|
|
5
|
+
//
|
|
6
|
+
// Loads last (PANELS in main.js) so every panel's views, subviews and
|
|
7
|
+
// palette items exist; still reads views/subviews from the DOM at use
|
|
8
|
+
// time, so anything added later shows up too. Never assumes a top-level
|
|
9
|
+
// window: the same app runs in the overlay iframe, a tab, and the Chrome
|
|
10
|
+
// DevTools extension panel (`body.ext`, packages/devtools-extension).
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
formatRoute, groupResults, isTypingTarget, keyTail, layoutNav, letterForView,
|
|
14
|
+
parseRoute, rankItems, resolveKey, viewForLetter,
|
|
15
|
+
} from './route.js';
|
|
16
|
+
|
|
17
|
+
const LS = 'atoll-devtools:';
|
|
18
|
+
const store = {
|
|
19
|
+
get(k, d) {
|
|
20
|
+
try { const v = localStorage.getItem(LS + k); return v === null ? d : JSON.parse(v); } catch { return d; }
|
|
21
|
+
},
|
|
22
|
+
set(k, v) {
|
|
23
|
+
try { localStorage.setItem(LS + k, JSON.stringify(v)); } catch { /* storage off (sandboxed frame) */ }
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
|
|
28
|
+
const MOD = isMac ? '⌘' : 'Ctrl';
|
|
29
|
+
const inFrame = (() => { try { return window.parent !== window; } catch { return true; } })();
|
|
30
|
+
|
|
31
|
+
/** What each core view shows and what populates it (checked against src/). */
|
|
32
|
+
const CORE_INTROS = {
|
|
33
|
+
dashboard:
|
|
34
|
+
'The app map plus pool, worker and island tables, built from <code>pool:init</code>, ' +
|
|
35
|
+
'<code>worker:spawn</code> and <code>island:mount</code>. Click a node or row to open its inspector. ' +
|
|
36
|
+
'Workers only report if <code>initDevtools()</code> ran before their pool spawned.',
|
|
37
|
+
tasks:
|
|
38
|
+
'Every pool task call: queue wait and run time per worker slot, with per-task aggregates. ' +
|
|
39
|
+
'Fills as the app calls methods on a <code>connectWorker</code> client.',
|
|
40
|
+
network:
|
|
41
|
+
'Per-second channel traffic (tasks, island ops, fetches) and a log of real <code>fetch()</code> calls ' +
|
|
42
|
+
'from the main thread and workers. XHR is not captured; <code>connectDevtools({ network: false })</code> turns the probe off.',
|
|
43
|
+
memory:
|
|
44
|
+
'Shared-memory writes need a <code>defineSharedMemory</code> contract passed to <code>connectWorker</code> as ' +
|
|
45
|
+
'<code>sharedMemory</code>. JS heap is sampled every few seconds (Chrome <code>measureUserAgentSpecificMemory</code> / ' +
|
|
46
|
+
'<code>performance.memory</code>, Node <code>process.memoryUsage()</code>).',
|
|
47
|
+
log: 'The raw event stream, one line per event as it arrives.',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GROUP_ORDER = ['Views', 'Subviews', 'Commands', 'Sessions', 'Islands', 'Workers', 'Pools'];
|
|
51
|
+
|
|
52
|
+
const textOf = (html) => {
|
|
53
|
+
const d = document.createElement('div');
|
|
54
|
+
d.innerHTML = html;
|
|
55
|
+
return d.textContent ?? '';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function setup(api) {
|
|
59
|
+
const { state, esc } = api;
|
|
60
|
+
// DevTools extension panel: also an iframe, but owned by DevTools rather
|
|
61
|
+
// than a host app, so it keeps real history and skips the overlay's
|
|
62
|
+
// parent messaging. Ctrl/Cmd+Shift+K opens the palette there as well.
|
|
63
|
+
const ext = document.body.classList.contains('ext');
|
|
64
|
+
const embedded = inFrame && !ext;
|
|
65
|
+
const PAL_KEYS = ext ? [MOD, 'Shift', 'K'] : [MOD, 'K'];
|
|
66
|
+
const ctx = api.isMini ? 'mini' : ext ? 'ext' : 'full';
|
|
67
|
+
const local = () => document.body.classList.contains('local');
|
|
68
|
+
const navEl = document.querySelector('.panels > nav');
|
|
69
|
+
|
|
70
|
+
/* ── DOM readers (live: panels may add views/subviews any time) ─────── */
|
|
71
|
+
|
|
72
|
+
const viewButtons = () => [...navEl.querySelectorAll('button[data-view]')];
|
|
73
|
+
const labelOf = (b) =>
|
|
74
|
+
[...b.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim() ||
|
|
75
|
+
b.dataset.view || b.dataset.sub || '';
|
|
76
|
+
const views = () => viewButtons().map((b) => ({ id: b.dataset.view, label: labelOf(b), btn: b }));
|
|
77
|
+
const viewExists = (id) => !!document.getElementById(`view-${id}`) && viewButtons().some((b) => b.dataset.view === id);
|
|
78
|
+
const activeView = () => navEl.querySelector('button[data-view].on')?.dataset.view ?? null;
|
|
79
|
+
const subnavOf = (id) => document.getElementById(`view-${id}`)?.querySelector(':scope > .subnav') ?? null;
|
|
80
|
+
const subButtons = (id) => [...(subnavOf(id)?.querySelectorAll('button[data-sub]') ?? [])];
|
|
81
|
+
const visible = (b) => b.style.display !== 'none' && !b.hidden;
|
|
82
|
+
const activeSub = (id) => subnavOf(id)?.querySelector('button[data-sub].on')?.dataset.sub ?? null;
|
|
83
|
+
// Default sub = the first one (mini: the Map tab). A route omits it.
|
|
84
|
+
const firstSub = (id) => subButtons(id).find(visible)?.dataset.sub ?? null;
|
|
85
|
+
const viewLabel = (id) => views().find((v) => v.id === id)?.label ?? id;
|
|
86
|
+
|
|
87
|
+
/* ── routing ─────────────────────────────────────────────────────────── */
|
|
88
|
+
|
|
89
|
+
let suppress = 0; // >0 while applying a route: don't write one back
|
|
90
|
+
let pending = null; // { session?, island?, worker? } waiting for data
|
|
91
|
+
let lastNavKey = '';
|
|
92
|
+
let lastHash = null;
|
|
93
|
+
let syncQueued = false;
|
|
94
|
+
|
|
95
|
+
const isLiveSess = (sid) => state.sessions.get(sid)?.closed !== true;
|
|
96
|
+
|
|
97
|
+
function currentRoute() {
|
|
98
|
+
const view = activeView();
|
|
99
|
+
if (!view) return null;
|
|
100
|
+
const sub = activeSub(view);
|
|
101
|
+
const params = {};
|
|
102
|
+
if (state.sel && !local()) params.session = state.sel;
|
|
103
|
+
if (view === 'dashboard' && sub === 'tv-island' && state.inspect) params.island = state.inspect;
|
|
104
|
+
if (view === 'dashboard' && sub === 'tv-worker' && state.inspectWorker) params.worker = state.inspectWorker;
|
|
105
|
+
return { view, sub: sub && sub !== firstSub(view) ? sub : null, params };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeRoute() {
|
|
109
|
+
syncQueued = false;
|
|
110
|
+
if (suppress || pending) return;
|
|
111
|
+
const r = currentRoute();
|
|
112
|
+
if (!r) return;
|
|
113
|
+
const h = formatRoute(r);
|
|
114
|
+
const navKey = `${r.view}/${r.sub ?? ''}/${r.params.island ?? ''}/${r.params.worker ?? ''}`;
|
|
115
|
+
if (h === lastHash && h === location.hash) { lastNavKey = navKey; return; } // every render: stay cheap
|
|
116
|
+
lastHash = h;
|
|
117
|
+
if (h !== location.hash) {
|
|
118
|
+
// The flyout is an iframe: pushState there would add entries to the
|
|
119
|
+
// HOST page's joint history (its Back button would flip devtools tabs).
|
|
120
|
+
const push = !embedded && navKey !== lastNavKey && lastNavKey !== '';
|
|
121
|
+
try { history[push ? 'pushState' : 'replaceState'](null, '', h); } catch { /* sandboxed */ }
|
|
122
|
+
}
|
|
123
|
+
lastNavKey = navKey;
|
|
124
|
+
store.set(`last:${ctx}`, formatRoute({ view: r.view, sub: r.sub }));
|
|
125
|
+
if (embedded) {
|
|
126
|
+
try { window.parent.postMessage({ type: 'atoll-devtools:route', hash: h }, '*'); } catch { /* detached */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const scheduleWrite = () => {
|
|
130
|
+
if (syncQueued || suppress) return;
|
|
131
|
+
syncQueued = true;
|
|
132
|
+
queueMicrotask(writeRoute);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function applyRoute(r) {
|
|
136
|
+
if (!r || !viewExists(r.view)) return false;
|
|
137
|
+
suppress++;
|
|
138
|
+
try {
|
|
139
|
+
pending = null;
|
|
140
|
+
const p = r.params ?? {};
|
|
141
|
+
const want = {};
|
|
142
|
+
if (!local()) {
|
|
143
|
+
if (p.session && state.sessions.has(p.session)) state.sel = p.session;
|
|
144
|
+
else if (p.session) want.session = p.session;
|
|
145
|
+
else state.sel = null;
|
|
146
|
+
}
|
|
147
|
+
if (r.view === 'dashboard' && r.sub === 'tv-island' && p.island) want.island = p.island;
|
|
148
|
+
else if (r.view === 'dashboard' && r.sub === 'tv-worker' && p.worker) want.worker = p.worker;
|
|
149
|
+
const sub = r.sub && subButtons(r.view).some((b) => b.dataset.sub === r.sub) ? r.sub : firstSub(r.view);
|
|
150
|
+
if (want.island || want.worker) api.openView(r.view);
|
|
151
|
+
else if (sub) api.openSub(r.view, sub);
|
|
152
|
+
else api.openView(r.view);
|
|
153
|
+
if (Object.keys(want).length) pending = want;
|
|
154
|
+
} finally {
|
|
155
|
+
suppress--;
|
|
156
|
+
}
|
|
157
|
+
tryPending();
|
|
158
|
+
lastNavKey = '';
|
|
159
|
+
if (!pending) writeRoute();
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Apply routed selections once their session / island / worker shows up. */
|
|
164
|
+
function tryPending() {
|
|
165
|
+
if (!pending || suppress) return;
|
|
166
|
+
const p = pending;
|
|
167
|
+
if (p.session && state.sessions.has(p.session)) {
|
|
168
|
+
state.sel = p.session;
|
|
169
|
+
delete p.session;
|
|
170
|
+
api.scheduleRender();
|
|
171
|
+
}
|
|
172
|
+
let open = null;
|
|
173
|
+
if (p.island) {
|
|
174
|
+
const k = resolveKey(state.islands.keys(), p.island, (x) => isLiveSess(x.split('|')[0]) && !state.islands.get(x)?.ended);
|
|
175
|
+
if (k) { delete p.island; state.inspect = k; open = 'tv-island'; }
|
|
176
|
+
}
|
|
177
|
+
if (p.worker) {
|
|
178
|
+
const k = resolveKey(state.workers.keys(), p.worker, (x) => isLiveSess(x.split('|')[0]) && !state.workers.get(x)?.dead);
|
|
179
|
+
if (k) { delete p.worker; state.inspectWorker = k; open = 'tv-worker'; }
|
|
180
|
+
}
|
|
181
|
+
if (!Object.keys(p).length) pending = null;
|
|
182
|
+
if (open) {
|
|
183
|
+
suppress++;
|
|
184
|
+
try { api.openSub('dashboard', open); } finally { suppress--; }
|
|
185
|
+
}
|
|
186
|
+
if (!pending) scheduleWrite();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const applyHash = () => {
|
|
190
|
+
// popstate + hashchange both fire on one traversal; the 2nd is a no-op
|
|
191
|
+
const cur = currentRoute();
|
|
192
|
+
if (!pending && cur && location.hash === formatRoute(cur)) return;
|
|
193
|
+
const r = parseRoute(location.hash);
|
|
194
|
+
if (r) applyRoute(r);
|
|
195
|
+
};
|
|
196
|
+
window.addEventListener('popstate', applyHash);
|
|
197
|
+
window.addEventListener('hashchange', applyHash);
|
|
198
|
+
|
|
199
|
+
api.onNavigate(() => {
|
|
200
|
+
if (suppress) return;
|
|
201
|
+
pending = null; // the user went somewhere else: drop a stale deep link
|
|
202
|
+
scheduleWrite();
|
|
203
|
+
scheduleNavLayout();
|
|
204
|
+
});
|
|
205
|
+
api.onRender(() => {
|
|
206
|
+
if (pending) tryPending();
|
|
207
|
+
else if (!suppress) scheduleWrite(); // selection / inspector changes → replaceState
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/* ── responsive nav: fold overflowing tabs into "More ▾" ───────────── */
|
|
211
|
+
|
|
212
|
+
const more = document.createElement('button');
|
|
213
|
+
more.type = 'button';
|
|
214
|
+
more.className = 'sh-more';
|
|
215
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
216
|
+
more.setAttribute('aria-expanded', 'false');
|
|
217
|
+
more.title = 'More views';
|
|
218
|
+
more.innerHTML = 'More ▾<span class="nbadge"></span>';
|
|
219
|
+
more.style.display = 'none';
|
|
220
|
+
navEl.append(more);
|
|
221
|
+
|
|
222
|
+
let navSig = '';
|
|
223
|
+
let navRaf = 0;
|
|
224
|
+
function layoutNavNow() {
|
|
225
|
+
navRaf = 0;
|
|
226
|
+
if (navEl.lastElementChild !== more) navEl.append(more); // addView appends after it
|
|
227
|
+
const btns = viewButtons();
|
|
228
|
+
const sig = `${navEl.clientWidth}|${btns.map((b) => `${b.dataset.view}:${b.querySelector('.nbadge')?.textContent ?? ''}:${b.classList.contains('on') ? 1 : 0}`).join(',')}`;
|
|
229
|
+
if (sig === navSig) return;
|
|
230
|
+
navSig = sig;
|
|
231
|
+
btns.forEach((b) => b.classList.remove('sh-ovf'));
|
|
232
|
+
more.style.display = '';
|
|
233
|
+
const avail = navEl.clientWidth;
|
|
234
|
+
if (!avail) { more.style.display = 'none'; return; } // hidden frame: measure later
|
|
235
|
+
const widths = btns.map((b) => b.getBoundingClientRect().width);
|
|
236
|
+
const moreW = more.getBoundingClientRect().width + 4;
|
|
237
|
+
const active = btns.findIndex((b) => b.classList.contains('on'));
|
|
238
|
+
const hidden = layoutNav(widths, avail, active, moreW);
|
|
239
|
+
for (const i of hidden) btns[i].classList.add('sh-ovf');
|
|
240
|
+
more.style.display = hidden.length ? '' : 'none';
|
|
241
|
+
updateMoreBadge();
|
|
242
|
+
}
|
|
243
|
+
function scheduleNavLayout() {
|
|
244
|
+
if (!navRaf) navRaf = requestAnimationFrame(layoutNavNow);
|
|
245
|
+
}
|
|
246
|
+
function updateMoreBadge() {
|
|
247
|
+
let sum = 0;
|
|
248
|
+
let flag = false;
|
|
249
|
+
for (const b of navEl.querySelectorAll('button.sh-ovf')) {
|
|
250
|
+
const t = b.querySelector('.nbadge')?.textContent ?? '';
|
|
251
|
+
if (!t) continue;
|
|
252
|
+
const n = Number(t);
|
|
253
|
+
if (Number.isFinite(n)) sum += n; else flag = true;
|
|
254
|
+
}
|
|
255
|
+
more.querySelector('.nbadge').textContent = flag ? '!' : sum ? String(sum) : '';
|
|
256
|
+
}
|
|
257
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(scheduleNavLayout).observe(navEl);
|
|
258
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
259
|
+
new MutationObserver(scheduleNavLayout).observe(navEl, { childList: true, subtree: true, characterData: true });
|
|
260
|
+
}
|
|
261
|
+
scheduleNavLayout();
|
|
262
|
+
|
|
263
|
+
let menu = null;
|
|
264
|
+
const closeMenu = (focusBack = false) => {
|
|
265
|
+
if (!menu) return;
|
|
266
|
+
menu.remove();
|
|
267
|
+
menu = null;
|
|
268
|
+
more.setAttribute('aria-expanded', 'false');
|
|
269
|
+
if (focusBack) more.focus();
|
|
270
|
+
};
|
|
271
|
+
more.addEventListener('click', (e) => {
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
if (menu) return closeMenu();
|
|
274
|
+
menu = document.createElement('div');
|
|
275
|
+
menu.className = 'sh-menu';
|
|
276
|
+
menu.setAttribute('role', 'menu');
|
|
277
|
+
for (const b of navEl.querySelectorAll('button.sh-ovf')) {
|
|
278
|
+
const item = document.createElement('button');
|
|
279
|
+
item.type = 'button';
|
|
280
|
+
item.setAttribute('role', 'menuitem');
|
|
281
|
+
if (b.classList.contains('on')) item.className = 'on';
|
|
282
|
+
const badge = b.querySelector('.nbadge')?.textContent ?? '';
|
|
283
|
+
item.innerHTML = `<span>${esc(labelOf(b))}</span><span class="nbadge">${esc(badge)}</span>`;
|
|
284
|
+
if (b.title) item.title = b.title;
|
|
285
|
+
item.onclick = () => { closeMenu(); api.openView(b.dataset.view); };
|
|
286
|
+
menu.append(item);
|
|
287
|
+
}
|
|
288
|
+
document.body.append(menu);
|
|
289
|
+
const r = more.getBoundingClientRect();
|
|
290
|
+
const w = menu.getBoundingClientRect().width;
|
|
291
|
+
menu.style.top = `${Math.round(r.bottom + 2)}px`;
|
|
292
|
+
menu.style.left = `${Math.max(4, Math.min(r.right - w, innerWidth - w - 4))}px`;
|
|
293
|
+
more.setAttribute('aria-expanded', 'true');
|
|
294
|
+
menu.addEventListener('keydown', (ev) => {
|
|
295
|
+
const items = [...menu.querySelectorAll('button')];
|
|
296
|
+
const i = items.indexOf(document.activeElement);
|
|
297
|
+
if (ev.key === 'ArrowDown') { ev.preventDefault(); items[(i + 1) % items.length]?.focus(); }
|
|
298
|
+
else if (ev.key === 'ArrowUp') { ev.preventDefault(); items[(i - 1 + items.length) % items.length]?.focus(); }
|
|
299
|
+
else if (ev.key === 'Tab') closeMenu();
|
|
300
|
+
});
|
|
301
|
+
menu.querySelector('button')?.focus();
|
|
302
|
+
});
|
|
303
|
+
document.addEventListener('click', (e) => {
|
|
304
|
+
if (menu && !menu.contains(e.target)) closeMenu();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/* ── collapsible session sidebar (aggregate, full page only) ────────── */
|
|
308
|
+
|
|
309
|
+
const sideBtn = document.getElementById('sideToggle');
|
|
310
|
+
const setSide = (open) => {
|
|
311
|
+
document.body.classList.toggle('noside', !open);
|
|
312
|
+
sideBtn?.setAttribute('aria-expanded', String(open));
|
|
313
|
+
store.set('sidebar', open);
|
|
314
|
+
scheduleNavLayout();
|
|
315
|
+
};
|
|
316
|
+
setSide(store.get('sidebar', true));
|
|
317
|
+
if (sideBtn) sideBtn.onclick = () => setSide(document.body.classList.contains('noside'));
|
|
318
|
+
const sidebarApplies = () => !local() && !api.isMini;
|
|
319
|
+
|
|
320
|
+
/* ── tables scroll sideways instead of stretching the layout ───────── */
|
|
321
|
+
|
|
322
|
+
const wrapTable = (t) => {
|
|
323
|
+
const p = t.parentElement;
|
|
324
|
+
if (!p || p.classList.contains('sh-tscroll') || t.closest('.sh-help')) return;
|
|
325
|
+
const w = document.createElement('div');
|
|
326
|
+
w.className = 'sh-tscroll';
|
|
327
|
+
p.insertBefore(w, t);
|
|
328
|
+
w.append(t);
|
|
329
|
+
};
|
|
330
|
+
const panelsEl = document.querySelector('.panels');
|
|
331
|
+
panelsEl.querySelectorAll('table').forEach(wrapTable);
|
|
332
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
333
|
+
// Panels may build tables later; skip row churn (tbody innerHTML) cheaply.
|
|
334
|
+
new MutationObserver((recs) => {
|
|
335
|
+
for (const r of recs) {
|
|
336
|
+
for (const n of r.addedNodes) {
|
|
337
|
+
if (n.nodeType !== 1) continue;
|
|
338
|
+
const tag = n.nodeName;
|
|
339
|
+
if (tag === 'TR' || tag === 'TD' || tag === 'TBODY' || tag === 'THEAD' || tag === 'TH') continue;
|
|
340
|
+
if (tag === 'TABLE') wrapTable(n);
|
|
341
|
+
else if (n.firstElementChild) n.querySelectorAll('table').forEach(wrapTable);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}).observe(panelsEl, { childList: true, subtree: true });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ── view intros + wait-for-app banner ─────────────────────────────── */
|
|
348
|
+
|
|
349
|
+
const dismissed = new Set(store.get('intros-dismissed', []));
|
|
350
|
+
const introEls = new Map();
|
|
351
|
+
function placeIntro(id) {
|
|
352
|
+
const section = document.getElementById(`view-${id}`);
|
|
353
|
+
const html = api.viewIntros.get(id);
|
|
354
|
+
let el = introEls.get(id);
|
|
355
|
+
if (!section || !html || dismissed.has(id)) { el?.remove(); return; }
|
|
356
|
+
if (!el) {
|
|
357
|
+
el = document.createElement('div');
|
|
358
|
+
el.className = 'sh-intro';
|
|
359
|
+
el.dataset.intro = id;
|
|
360
|
+
introEls.set(id, el);
|
|
361
|
+
}
|
|
362
|
+
el.innerHTML = `<span>${html}</span><button type="button" class="sh-x" title="Dismiss" aria-label="Dismiss this intro">×</button>`;
|
|
363
|
+
el.querySelector('button').onclick = () => {
|
|
364
|
+
dismissed.add(id);
|
|
365
|
+
store.set('intros-dismissed', [...dismissed]);
|
|
366
|
+
el.remove();
|
|
367
|
+
};
|
|
368
|
+
if (el.parentElement !== section || section.firstElementChild !== el) section.prepend(el);
|
|
369
|
+
}
|
|
370
|
+
(api.hooks.intro ??= []).push((id) => placeIntro(id));
|
|
371
|
+
for (const [id, html] of Object.entries(CORE_INTROS)) {
|
|
372
|
+
if (!api.viewIntros.has(id)) api.viewIntros.set(id, html);
|
|
373
|
+
}
|
|
374
|
+
for (const id of api.viewIntros.keys()) placeIntro(id);
|
|
375
|
+
|
|
376
|
+
const wait = document.createElement('div');
|
|
377
|
+
wait.className = 'sh-wait';
|
|
378
|
+
wait.setAttribute('role', 'status');
|
|
379
|
+
wait.innerHTML = api.transport === 'broadcast'
|
|
380
|
+
? '<span>Waiting for an app on this origin. Open it with <code>?__atoll_devtools</code> in the URL ' +
|
|
381
|
+
'(<code>initDevtools()</code>), or call <code>connectDevtools()</code>, before its pools spawn.</span>'
|
|
382
|
+
: api.transport === 'extension'
|
|
383
|
+
? '<span>No atoll session on the inspected page. Open it with <code>?__atoll_devtools</code> in the URL ' +
|
|
384
|
+
'(<code>initDevtools()</code>), or call <code>connectDevtools()</code>, before its pools spawn: this panel ' +
|
|
385
|
+
'picks it up on its own. <a href="https://jwhenry3.github.io/atolljs/consumer/devtools/" target="_blank" ' +
|
|
386
|
+
'rel="noopener">Devtools docs</a></span>'
|
|
387
|
+
: '<span>No sessions yet. Browser apps connect with <code>connectDevtools({ url })</code> pointed at this server; ' +
|
|
388
|
+
'Node apps run with <code>ATOLL_DEVTOOLS=1</code> and <code>initDevtools()</code> from <code>@atolljs/devtools/node</code>.</span>';
|
|
389
|
+
wait.hidden = true;
|
|
390
|
+
wait.style.display = 'none';
|
|
391
|
+
navEl.after(wait);
|
|
392
|
+
const shownAt = performance.now();
|
|
393
|
+
|
|
394
|
+
const updateWait = () => {
|
|
395
|
+
// replay usually lands within a few hundred ms: don't flash the banner
|
|
396
|
+
const none = state.sessions.size === 0 && performance.now() - shownAt > 1500;
|
|
397
|
+
if (wait.style.display !== (none ? '' : 'none')) wait.style.display = none ? '' : 'none';
|
|
398
|
+
};
|
|
399
|
+
api.onRender(() => {
|
|
400
|
+
// intros a panel re-rendered away (section.innerHTML) come back
|
|
401
|
+
for (const [id, el] of introEls) if (!el.isConnected && !dismissed.has(id)) placeIntro(id);
|
|
402
|
+
updateWait();
|
|
403
|
+
});
|
|
404
|
+
api.onTick(updateWait);
|
|
405
|
+
|
|
406
|
+
/* ── command palette ───────────────────────────────────────────────── */
|
|
407
|
+
|
|
408
|
+
const gKeys = (id) => {
|
|
409
|
+
const l = letterForView(id, views());
|
|
410
|
+
return l ? ['g', l] : null;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
function collectItems() {
|
|
414
|
+
const items = [];
|
|
415
|
+
for (const v of views()) {
|
|
416
|
+
items.push({
|
|
417
|
+
id: `view:${v.id}`, group: 'Views', title: v.label,
|
|
418
|
+
hint: textOf(api.viewIntros.get(v.id) ?? v.btn.title ?? ''),
|
|
419
|
+
keys: gKeys(v.id), run: () => api.openView(v.id),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
for (const v of views()) {
|
|
423
|
+
for (const b of subButtons(v.id)) {
|
|
424
|
+
if (!visible(b)) continue;
|
|
425
|
+
items.push({
|
|
426
|
+
id: `sub:${v.id}/${b.dataset.sub}`, group: 'Subviews', title: `${v.label} › ${labelOf(b)}`,
|
|
427
|
+
run: () => api.openSub(v.id, b.dataset.sub),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const p of api.palette) {
|
|
432
|
+
if (p.when && !p.when()) continue;
|
|
433
|
+
items.push({ ...p, group: p.group ?? 'Commands' });
|
|
434
|
+
}
|
|
435
|
+
const sessName = (sid) => state.sessions.get(sid)?.name ?? sid;
|
|
436
|
+
if (!local() && state.sessions.size) {
|
|
437
|
+
if (state.sel) {
|
|
438
|
+
items.push({ id: 'sess:*', group: 'Sessions', title: 'All live sessions', run: () => { state.sel = null; api.render(); } });
|
|
439
|
+
}
|
|
440
|
+
for (const s of state.sessions.values()) {
|
|
441
|
+
items.push({
|
|
442
|
+
id: `sess:${s.id}`, group: 'Sessions', title: s.name ?? s.id,
|
|
443
|
+
hint: `${s.runtime ?? ''}${s.closed ? ' · ended' : ''}${state.sel === s.id ? ' · selected' : ''}`,
|
|
444
|
+
run: () => { state.sel = s.id; api.render(); },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const multi = state.sessions.size > 1;
|
|
449
|
+
const live = (k, dead) => (isLiveSess(k.split('|')[0]) && !dead ? 0 : 1);
|
|
450
|
+
const byLive = (arr) => arr.sort((a, b) => a[2] - b[2]).slice(0, 300);
|
|
451
|
+
for (const [k, i] of byLive([...state.islands].map(([k, i]) => [k, i, live(k, i.ended)]))) {
|
|
452
|
+
items.push({
|
|
453
|
+
id: `island:${k}`, group: 'Islands', title: keyTail(k),
|
|
454
|
+
hint: [i.app, i.fw, multi ? sessName(k.split('|')[0]) : '', i.ended ? 'unmounted' : ''].filter(Boolean).join(' · '),
|
|
455
|
+
run: () => { state.inspect = k; api.openSub('dashboard', 'tv-island'); },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
for (const [k, w] of byLive([...state.workers].map(([k, w]) => [k, w, live(k, w.dead)]))) {
|
|
459
|
+
const [, poolId, slot] = k.split('|');
|
|
460
|
+
items.push({
|
|
461
|
+
id: `worker:${k}`, group: 'Workers', title: `${poolId}#${slot}`,
|
|
462
|
+
hint: [`${w.tasks} tasks`, multi ? sessName(k.split('|')[0]) : '', w.dead ? 'down' : ''].filter(Boolean).join(' · '),
|
|
463
|
+
run: () => { state.inspectWorker = k; api.openSub('dashboard', 'tv-worker'); },
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
for (const [k, p] of state.pools) {
|
|
467
|
+
items.push({
|
|
468
|
+
id: `pool:${k}`, group: 'Pools', title: p.label ?? p.poolId,
|
|
469
|
+
hint: [p.dedicated ? 'dedicated worker' : `${p.size ?? '?'} workers`, multi ? sessName(k.split('|')[0]) : ''].filter(Boolean).join(' · '),
|
|
470
|
+
run: () => {
|
|
471
|
+
if (!local()) state.sel = k.split('|')[0];
|
|
472
|
+
api.openSub('dashboard', 'tv-pools');
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return items;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const pal = document.createElement('div');
|
|
480
|
+
pal.className = 'sh-modal';
|
|
481
|
+
pal.hidden = true;
|
|
482
|
+
pal.innerHTML =
|
|
483
|
+
'<div class="sh-card" role="dialog" aria-modal="true" aria-label="Command palette">' +
|
|
484
|
+
'<input type="text" role="combobox" aria-expanded="true" aria-controls="sh-list" aria-autocomplete="list" ' +
|
|
485
|
+
'placeholder="Jump to a view, island, worker, pool or command…" spellcheck="false" autocomplete="off">' +
|
|
486
|
+
'<div class="sh-list" id="sh-list" role="listbox"></div>' +
|
|
487
|
+
'<div class="sh-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span>' +
|
|
488
|
+
'<span><kbd>Esc</kbd> close</span><span><kbd>?</kbd> all shortcuts</span></div></div>';
|
|
489
|
+
document.body.append(pal);
|
|
490
|
+
const palInput = pal.querySelector('input');
|
|
491
|
+
const palList = pal.querySelector('.sh-list');
|
|
492
|
+
let palItems = [];
|
|
493
|
+
let flat = [];
|
|
494
|
+
let sel = 0;
|
|
495
|
+
let returnFocus = null;
|
|
496
|
+
|
|
497
|
+
const hl = (title, idx) => {
|
|
498
|
+
if (!idx.length) return esc(title);
|
|
499
|
+
const on = new Set(idx);
|
|
500
|
+
let out = '';
|
|
501
|
+
for (let i = 0; i < title.length; i++) out += on.has(i) ? `<mark>${esc(title[i])}</mark>` : esc(title[i]);
|
|
502
|
+
return out;
|
|
503
|
+
};
|
|
504
|
+
const kbdHtml = (keys) => (keys ? (Array.isArray(keys) ? keys : [keys]).map((k) => `<kbd>${esc(k)}</kbd>`).join('') : '');
|
|
505
|
+
|
|
506
|
+
function renderPalette() {
|
|
507
|
+
const q = palInput.value;
|
|
508
|
+
const ranked = rankItems(q, palItems);
|
|
509
|
+
// panel-registered groups (e.g. a recorder's) sit right after Commands
|
|
510
|
+
const custom = [...new Set(palItems.map((i) => i.group))].filter((g) => !GROUP_ORDER.includes(g));
|
|
511
|
+
const at = GROUP_ORDER.indexOf('Commands') + 1;
|
|
512
|
+
const order = [...GROUP_ORDER.slice(0, at), ...custom, ...GROUP_ORDER.slice(at)];
|
|
513
|
+
const groups = groupResults(ranked, { order, perGroup: q.trim() ? 12 : 8, byScore: !!q.trim() });
|
|
514
|
+
flat = groups.flatMap((g) => g.items);
|
|
515
|
+
sel = Math.min(sel, Math.max(0, flat.length - 1));
|
|
516
|
+
if (!flat.length) {
|
|
517
|
+
palList.innerHTML = `<div class="sh-none">No match for “${esc(q)}”</div>`;
|
|
518
|
+
palInput.removeAttribute('aria-activedescendant');
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
let n = 0;
|
|
522
|
+
palList.innerHTML = groups.map((g) =>
|
|
523
|
+
`<div class="sh-grp" role="presentation">${esc(g.group)}</div>` +
|
|
524
|
+
g.items.map((r) => {
|
|
525
|
+
const i = n++;
|
|
526
|
+
return `<div class="sh-item${i === sel ? ' on' : ''}" role="option" id="sh-opt-${i}" data-i="${i}" aria-selected="${i === sel}">` +
|
|
527
|
+
`<span class="t">${hl(String(r.item.title ?? ''), r.idx)}</span>` +
|
|
528
|
+
`<span class="h">${esc(r.item.hint ?? '')}</span>${kbdHtml(r.item.keys)}</div>`;
|
|
529
|
+
}).join('')).join('');
|
|
530
|
+
palInput.setAttribute('aria-activedescendant', `sh-opt-${sel}`);
|
|
531
|
+
}
|
|
532
|
+
function moveSel(d) {
|
|
533
|
+
if (!flat.length) return;
|
|
534
|
+
sel = (sel + d + flat.length) % flat.length;
|
|
535
|
+
for (const el of palList.querySelectorAll('.sh-item')) {
|
|
536
|
+
const on = Number(el.dataset.i) === sel;
|
|
537
|
+
el.classList.toggle('on', on);
|
|
538
|
+
el.setAttribute('aria-selected', String(on));
|
|
539
|
+
if (on) el.scrollIntoView({ block: 'nearest' });
|
|
540
|
+
}
|
|
541
|
+
palInput.setAttribute('aria-activedescendant', `sh-opt-${sel}`);
|
|
542
|
+
}
|
|
543
|
+
function runSel(i = sel) {
|
|
544
|
+
const r = flat[i];
|
|
545
|
+
if (!r) return;
|
|
546
|
+
closePalette(false);
|
|
547
|
+
try { r.item.run?.(); } catch (err) { api.toast(err, 'error'); }
|
|
548
|
+
}
|
|
549
|
+
function openPalette(prefill = '') {
|
|
550
|
+
closeAll();
|
|
551
|
+
returnFocus = document.activeElement;
|
|
552
|
+
palItems = collectItems();
|
|
553
|
+
palInput.value = prefill;
|
|
554
|
+
sel = 0;
|
|
555
|
+
pal.hidden = false;
|
|
556
|
+
renderPalette();
|
|
557
|
+
palInput.focus();
|
|
558
|
+
}
|
|
559
|
+
function closePalette(restore = true) {
|
|
560
|
+
if (pal.hidden) return;
|
|
561
|
+
pal.hidden = true;
|
|
562
|
+
if (restore && returnFocus?.isConnected) returnFocus.focus();
|
|
563
|
+
}
|
|
564
|
+
palInput.addEventListener('input', () => { sel = 0; renderPalette(); });
|
|
565
|
+
palInput.addEventListener('keydown', (e) => {
|
|
566
|
+
if (e.key === 'ArrowDown' || (e.key === 'n' && e.ctrlKey)) { e.preventDefault(); moveSel(1); }
|
|
567
|
+
else if (e.key === 'ArrowUp' || (e.key === 'p' && e.ctrlKey)) { e.preventDefault(); moveSel(-1); }
|
|
568
|
+
else if (e.key === 'PageDown') { e.preventDefault(); moveSel(Math.min(8, flat.length - 1 - sel) || 0); }
|
|
569
|
+
else if (e.key === 'PageUp') { e.preventDefault(); moveSel(-Math.min(8, sel)); }
|
|
570
|
+
else if (e.key === 'Enter') { e.preventDefault(); runSel(); }
|
|
571
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePalette(); }
|
|
572
|
+
else if (e.key === 'Tab') { e.preventDefault(); moveSel(e.shiftKey ? -1 : 1); }
|
|
573
|
+
});
|
|
574
|
+
palList.addEventListener('mousemove', (e) => {
|
|
575
|
+
const el = e.target.closest('.sh-item');
|
|
576
|
+
if (el && Number(el.dataset.i) !== sel) moveSel(Number(el.dataset.i) - sel);
|
|
577
|
+
});
|
|
578
|
+
palList.addEventListener('click', (e) => {
|
|
579
|
+
const el = e.target.closest('.sh-item');
|
|
580
|
+
if (el) runSel(Number(el.dataset.i));
|
|
581
|
+
});
|
|
582
|
+
pal.addEventListener('mousedown', (e) => { if (e.target === pal) closePalette(); });
|
|
583
|
+
|
|
584
|
+
/* ── help overlay ──────────────────────────────────────────────────── */
|
|
585
|
+
|
|
586
|
+
const help = document.createElement('div');
|
|
587
|
+
help.className = 'sh-modal';
|
|
588
|
+
help.hidden = true;
|
|
589
|
+
document.body.append(help);
|
|
590
|
+
|
|
591
|
+
function openHelp() {
|
|
592
|
+
closeAll();
|
|
593
|
+
returnFocus = document.activeElement;
|
|
594
|
+
const vs = views();
|
|
595
|
+
const rows = (list) => list.map(([k, d]) => `<tr><td>${k}</td><td>${d}</td></tr>`).join('');
|
|
596
|
+
const keys = [
|
|
597
|
+
[PAL_KEYS.map((k) => `<kbd>${k}</kbd>`).join(' '), 'Command palette: views, subviews, islands, workers, pools, commands'],
|
|
598
|
+
['<kbd>?</kbd>', 'This help'],
|
|
599
|
+
['<kbd>g</kbd> then a letter', 'Go to a view (letters listed under Views)'],
|
|
600
|
+
['<kbd>[</kbd> <kbd>]</kbd>', 'Previous / next sub-tab of the current view'],
|
|
601
|
+
['<kbd>/</kbd>', 'Focus the current view’s search or filter box'],
|
|
602
|
+
['<kbd>Esc</kbd>', 'Close the palette, this help, or a menu'],
|
|
603
|
+
];
|
|
604
|
+
if (api.isMini) keys.push(['<kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>D</kbd>', 'Show / hide the flyout (also works from the app page)']);
|
|
605
|
+
if (sidebarApplies()) keys.push(['<kbd>s</kbd>', 'Show / hide the sessions sidebar']);
|
|
606
|
+
const viewRows = vs.map((v) => {
|
|
607
|
+
const l = letterForView(v.id, vs);
|
|
608
|
+
const about = textOf(api.viewIntros.get(v.id) ?? '') || v.btn.title || '';
|
|
609
|
+
return `<tr><td>${l ? `<kbd>g</kbd> <kbd>${esc(l)}</kbd>` : ''}</td><td><b>${esc(v.label)}</b>` +
|
|
610
|
+
`${about ? `<div class="about">${esc(about)}</div>` : ''}</td></tr>`;
|
|
611
|
+
}).join('');
|
|
612
|
+
const cmds = api.palette.filter((p) => !p.when || p.when());
|
|
613
|
+
help.innerHTML =
|
|
614
|
+
'<div class="sh-card wide" role="dialog" aria-modal="true" aria-labelledby="sh-help-t">' +
|
|
615
|
+
'<div class="sh-head"><span id="sh-help-t">Shortcuts & views</span>' +
|
|
616
|
+
'<button type="button" class="sh-x" aria-label="Close help" title="Close (Esc)">×</button></div>' +
|
|
617
|
+
'<div class="sh-help">' +
|
|
618
|
+
`<div><h3>Keyboard</h3><table>${rows(keys)}</table>` +
|
|
619
|
+
(cmds.length
|
|
620
|
+
? `<h3>Palette commands</h3><table>${cmds.map((p) =>
|
|
621
|
+
`<tr><td>${kbdHtml(p.keys)}</td><td>${esc(p.title)}${p.hint ? ` <span class="about">${esc(p.hint)}</span>` : ''}</td></tr>`).join('')}</table>`
|
|
622
|
+
: '') +
|
|
623
|
+
'<h3>Deep links</h3><div class="about">The address hash tracks where you are: ' +
|
|
624
|
+
'<code>#/<view>[/<sub>][?session=…&island=…&worker=…]</code>. ' +
|
|
625
|
+
'Share it, reload it, or use Back / Forward (full page). “full page ↗” in the flyout opens the same spot.</div></div>' +
|
|
626
|
+
`<div><h3>Views</h3><table>${viewRows}</table></div>` +
|
|
627
|
+
'</div></div>';
|
|
628
|
+
help.querySelector('.sh-x').onclick = () => closeHelp();
|
|
629
|
+
help.hidden = false;
|
|
630
|
+
help.querySelector('.sh-x').focus();
|
|
631
|
+
}
|
|
632
|
+
function closeHelp() {
|
|
633
|
+
if (help.hidden) return;
|
|
634
|
+
help.hidden = true;
|
|
635
|
+
if (returnFocus?.isConnected) returnFocus.focus();
|
|
636
|
+
}
|
|
637
|
+
help.addEventListener('mousedown', (e) => { if (e.target === help) closeHelp(); });
|
|
638
|
+
|
|
639
|
+
const closeAll = () => { closePalette(false); closeHelp(); closeMenu(); closeTip(); };
|
|
640
|
+
|
|
641
|
+
/* ── toolbar ───────────────────────────────────────────────────────── */
|
|
642
|
+
|
|
643
|
+
api.addToolbarButton({
|
|
644
|
+
id: 'sh-palette', label: '⌕', order: 90, title: `Command palette (${PAL_KEYS.join('+')})`,
|
|
645
|
+
onClick: () => openPalette(),
|
|
646
|
+
}).classList.add('icon');
|
|
647
|
+
api.addToolbarButton({
|
|
648
|
+
id: 'sh-help', label: '?', order: 95, title: 'Shortcuts and what each view is for (?)',
|
|
649
|
+
onClick: () => openHelp(),
|
|
650
|
+
}).classList.add('icon');
|
|
651
|
+
for (const id of ['sh-palette', 'sh-help']) {
|
|
652
|
+
const b = document.getElementById(id);
|
|
653
|
+
b?.setAttribute('aria-label', b.title);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* ── built-in palette commands ─────────────────────────────────────── */
|
|
657
|
+
|
|
658
|
+
const fullPageUrl = () => {
|
|
659
|
+
const u = new URL(location.href);
|
|
660
|
+
u.searchParams.delete('mini');
|
|
661
|
+
return u.href;
|
|
662
|
+
};
|
|
663
|
+
api.addPaletteItem({ id: 'shell.help', group: 'Commands', title: 'Show keyboard shortcuts', keys: '?', run: openHelp });
|
|
664
|
+
api.addPaletteItem({
|
|
665
|
+
id: 'shell.sidebar', group: 'Commands', title: 'Toggle sessions sidebar', keys: 's',
|
|
666
|
+
when: sidebarApplies, run: () => setSide(document.body.classList.contains('noside')),
|
|
667
|
+
});
|
|
668
|
+
api.addPaletteItem({
|
|
669
|
+
id: 'shell.fullpage', group: 'Commands', title: 'Open the full-page dashboard here',
|
|
670
|
+
hint: 'same view, new tab', when: () => api.isMini,
|
|
671
|
+
run: () => window.open(fullPageUrl(), '_blank', 'noopener'),
|
|
672
|
+
});
|
|
673
|
+
api.addPaletteItem({
|
|
674
|
+
id: 'shell.copylink', group: 'Commands', title: 'Copy link to this view',
|
|
675
|
+
when: () => !ext, // a chrome-extension:// URL means nothing outside DevTools
|
|
676
|
+
run: () => {
|
|
677
|
+
const url = fullPageUrl();
|
|
678
|
+
Promise.resolve(navigator.clipboard?.writeText(url))
|
|
679
|
+
.then(() => api.toast('Link copied', 'ok'))
|
|
680
|
+
.catch(() => api.toast(url, 'info', 8000));
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
api.addPaletteItem({
|
|
684
|
+
id: 'shell.intros', group: 'Commands', title: 'Show view intros again',
|
|
685
|
+
when: () => dismissed.size > 0,
|
|
686
|
+
run: () => {
|
|
687
|
+
dismissed.clear();
|
|
688
|
+
store.set('intros-dismissed', []);
|
|
689
|
+
for (const id of api.viewIntros.keys()) placeIntro(id);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
/* ── keyboard ──────────────────────────────────────────────────────── */
|
|
694
|
+
|
|
695
|
+
let gAt = 0;
|
|
696
|
+
function focusSearch() {
|
|
697
|
+
const view = document.querySelector('.panels > .view.on');
|
|
698
|
+
if (!view) return false;
|
|
699
|
+
const sel = 'input[type=search], input[data-search], input[placeholder*="filter" i], input[placeholder*="search" i], input[type=text]';
|
|
700
|
+
const el = [...view.querySelectorAll(sel)].find((x) => x.offsetParent !== null && !x.disabled);
|
|
701
|
+
if (!el) return false;
|
|
702
|
+
el.focus();
|
|
703
|
+
el.select?.();
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
function cycleSub(d) {
|
|
707
|
+
const v = activeView();
|
|
708
|
+
const btns = subButtons(v).filter(visible);
|
|
709
|
+
if (btns.length < 2) return;
|
|
710
|
+
const i = btns.findIndex((b) => b.classList.contains('on'));
|
|
711
|
+
btns[(i + d + btns.length) % btns.length].click();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
window.addEventListener('keydown', (e) => {
|
|
715
|
+
const k = e.key;
|
|
716
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && (!e.shiftKey || ext) && k.toLowerCase() === 'k') {
|
|
717
|
+
e.preventDefault();
|
|
718
|
+
if (pal.hidden) openPalette(); else closePalette();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// Alt+Shift+D: same chord the overlay listens for on the host page;
|
|
722
|
+
// focus is inside the iframe here, so ask the parent to toggle.
|
|
723
|
+
if (embedded && e.altKey && e.shiftKey && e.code === 'KeyD') {
|
|
724
|
+
e.preventDefault();
|
|
725
|
+
try { window.parent.postMessage({ type: 'atoll-devtools:toggle' }, '*'); } catch { /* detached */ }
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (k === 'Escape') {
|
|
729
|
+
if (!pal.hidden || !help.hidden || menu || tip) {
|
|
730
|
+
e.preventDefault();
|
|
731
|
+
const wasMenu = !!menu;
|
|
732
|
+
closeAll();
|
|
733
|
+
if (wasMenu) more.focus();
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
738
|
+
if (isTypingTarget(e.target)) return;
|
|
739
|
+
if (!pal.hidden || !help.hidden) return;
|
|
740
|
+
if (gAt && performance.now() - gAt < 1500) {
|
|
741
|
+
gAt = 0;
|
|
742
|
+
const v = viewForLetter(k, views());
|
|
743
|
+
if (v) { e.preventDefault(); api.openView(v); }
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
gAt = 0;
|
|
747
|
+
if (k === '?') { e.preventDefault(); openHelp(); }
|
|
748
|
+
else if (k === 'g') { gAt = performance.now(); }
|
|
749
|
+
else if (k === '/') { if (focusSearch()) e.preventDefault(); }
|
|
750
|
+
else if (k === '[') { e.preventDefault(); cycleSub(-1); }
|
|
751
|
+
else if (k === ']') { e.preventDefault(); cycleSub(1); }
|
|
752
|
+
else if (k === 's' && sidebarApplies()) { e.preventDefault(); setSide(document.body.classList.contains('noside')); }
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
/* ── first-run tip ─────────────────────────────────────────────────── */
|
|
756
|
+
|
|
757
|
+
let tip = null;
|
|
758
|
+
function closeTip() {
|
|
759
|
+
if (!tip) return;
|
|
760
|
+
tip.remove();
|
|
761
|
+
tip = null;
|
|
762
|
+
store.set('onboarded', true);
|
|
763
|
+
}
|
|
764
|
+
if (!store.get('onboarded', false)) {
|
|
765
|
+
setTimeout(() => {
|
|
766
|
+
if (store.get('onboarded', false) || !pal.hidden || !help.hidden) return;
|
|
767
|
+
tip = document.createElement('div');
|
|
768
|
+
tip.className = 'sh-tip';
|
|
769
|
+
tip.setAttribute('role', 'status');
|
|
770
|
+
tip.innerHTML =
|
|
771
|
+
`<span><b>Tip:</b> ${PAL_KEYS.map((k) => `<kbd>${k}</kbd>`).join(' ')} jumps to any view, island or worker. ` +
|
|
772
|
+
'<kbd>?</kbd> lists shortcuts and what each view is for.</span>' +
|
|
773
|
+
'<button type="button" class="act" data-a="palette">Open palette</button>' +
|
|
774
|
+
'<button type="button" class="act" data-a="ok">Got it</button>';
|
|
775
|
+
tip.onclick = (e) => {
|
|
776
|
+
const a = e.target.closest('button')?.dataset.a;
|
|
777
|
+
if (!a) return;
|
|
778
|
+
closeTip();
|
|
779
|
+
if (a === 'palette') openPalette();
|
|
780
|
+
};
|
|
781
|
+
document.body.append(tip);
|
|
782
|
+
}, 1200);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/* ── initial route ─────────────────────────────────────────────────── */
|
|
786
|
+
|
|
787
|
+
const initial = parseRoute(location.hash) ?? parseRoute(store.get(`last:${ctx}`, ''));
|
|
788
|
+
if (!initial || !applyRoute(initial)) writeRoute();
|
|
789
|
+
}
|