@jsenv/core 41.5.35 → 41.5.38

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.
@@ -0,0 +1,1459 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Server clients inspector</title>
7
+ <style>body {
8
+ color: #111827;
9
+ background: #f3f4f6;
10
+ margin: 0;
11
+ padding: 24px;
12
+ font: 14px / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
13
+ }
14
+
15
+ h1 {
16
+ margin: 0 0 4px;
17
+ font-size: 1.4rem;
18
+ }
19
+
20
+ h2 {
21
+ color: #6b7280;
22
+ text-transform: uppercase;
23
+ letter-spacing: .04em;
24
+ margin: 26px 0 8px;
25
+ font-size: .95rem;
26
+ }
27
+
28
+ p.intro {
29
+ color: #6b7280;
30
+ margin: 0 0 8px;
31
+ }
32
+
33
+ table {
34
+ border-collapse: collapse;
35
+ background: #fff;
36
+ border-radius: 8px;
37
+ width: 100%;
38
+ overflow: hidden;
39
+ box-shadow: 0 1px 3px #0000001a;
40
+ }
41
+
42
+ th, td {
43
+ text-align: left;
44
+ vertical-align: middle;
45
+ border-bottom: 1px solid #eef2f7;
46
+ padding: 10px 14px;
47
+ }
48
+
49
+ th {
50
+ color: #6b7280;
51
+ text-transform: uppercase;
52
+ letter-spacing: .04em;
53
+ background: #f9fafb;
54
+ font-size: .78rem;
55
+ }
56
+
57
+ tr:last-child td {
58
+ border-bottom: none;
59
+ }
60
+
61
+ .empty {
62
+ color: #9ca3af;
63
+ text-align: center;
64
+ padding: 22px;
65
+ }
66
+
67
+ .muted {
68
+ color: #9ca3af;
69
+ }
70
+
71
+ .when {
72
+ color: #374151;
73
+ white-space: nowrap;
74
+ }
75
+
76
+ .url {
77
+ color: #374151;
78
+ text-align: left;
79
+ text-overflow: ellipsis;
80
+ white-space: nowrap;
81
+ vertical-align: middle;
82
+ direction: rtl;
83
+ max-width: 240px;
84
+ display: inline-block;
85
+ overflow: hidden;
86
+ }
87
+
88
+ .tag {
89
+ color: #4338ca;
90
+ background: #eef2ff;
91
+ border-radius: 999px;
92
+ padding: 1px 7px;
93
+ font-size: .72rem;
94
+ font-weight: 600;
95
+ display: inline-block;
96
+ }
97
+
98
+ .tag.on {
99
+ color: #047857;
100
+ background: #d1fae5;
101
+ }
102
+
103
+ .tag.ellip {
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ vertical-align: middle;
107
+ max-width: 320px;
108
+ overflow: hidden;
109
+ }
110
+
111
+ .actions {
112
+ flex-wrap: wrap;
113
+ align-items: center;
114
+ gap: 6px;
115
+ display: flex;
116
+ }
117
+
118
+ .btn {
119
+ color: #374151;
120
+ font: inherit;
121
+ cursor: pointer;
122
+ background: #f3f4f6;
123
+ border: 1px solid #d1d5db;
124
+ border-radius: 6px;
125
+ padding: 2px 9px;
126
+ font-size: .75rem;
127
+ }
128
+
129
+ .btn:hover {
130
+ background: #e5e7eb;
131
+ }
132
+
133
+ .btn.primary {
134
+ color: #fff;
135
+ background: #2563eb;
136
+ border-color: #2563eb;
137
+ }
138
+
139
+ .btn.primary:hover {
140
+ background: #1d4ed8;
141
+ }
142
+
143
+ .monitor {
144
+ color: #2563eb;
145
+ white-space: nowrap;
146
+ margin-left: 4px;
147
+ padding: 2px 4px;
148
+ font-size: .8rem;
149
+ font-weight: 600;
150
+ text-decoration: none;
151
+ }
152
+
153
+ .monitor:hover {
154
+ text-decoration: underline;
155
+ }
156
+
157
+ dialog {
158
+ border: none;
159
+ border-radius: 10px;
160
+ width: min(90vw, 640px);
161
+ padding: 0;
162
+ box-shadow: 0 20px 60px #0000004d;
163
+ }
164
+
165
+ dialog::backdrop {
166
+ background: #0006;
167
+ }
168
+
169
+ .dlg-head {
170
+ border-bottom: 1px solid #eef2f7;
171
+ align-items: center;
172
+ gap: 8px;
173
+ padding: 12px 16px;
174
+ display: flex;
175
+ }
176
+
177
+ .dlg-head .spacer {
178
+ flex: 1;
179
+ }
180
+
181
+ .dlg-body {
182
+ max-height: 60vh;
183
+ padding: 14px 16px;
184
+ overflow: auto;
185
+ }
186
+
187
+ .dlg-body pre {
188
+ white-space: pre-wrap;
189
+ word-break: break-all;
190
+ margin: 0;
191
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
192
+ font-size: 12px;
193
+ }
194
+
195
+ ul.list {
196
+ margin: 0;
197
+ padding: 0;
198
+ list-style: none;
199
+ }
200
+
201
+ ul.list li {
202
+ border-bottom: 1px solid #f1f5f9;
203
+ padding: 8px 0;
204
+ }
205
+
206
+ ul.list li:last-child {
207
+ border-bottom: none;
208
+ }
209
+
210
+ .url2 {
211
+ color: #374151;
212
+ word-break: break-all;
213
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
214
+ font-size: 12px;
215
+ }
216
+
217
+ .ttl {
218
+ font-weight: 600;
219
+ }
220
+ </style>
221
+ </head>
222
+ <body>
223
+ <h1>Server clients inspector</h1>
224
+ <p class="intro">
225
+ Every browser that has connected to this dev server since it started. The
226
+ columns are a résumé; use the actions to dig in or monitor a client's
227
+ logs.
228
+ </p>
229
+
230
+ <h2>This browser</h2>
231
+ <table>
232
+ <thead id="head"></thead>
233
+ <tbody id="self-rows"></tbody>
234
+ </table>
235
+
236
+ <h2>Other clients</h2>
237
+ <table>
238
+ <thead id="head2"></thead>
239
+ <tbody id="other-rows"></tbody>
240
+ </table>
241
+
242
+ <h2>Headless browsers</h2>
243
+ <p class="intro">
244
+ Browsers driven by a program — a test run, a screenshot script — rather
245
+ than by someone looking at them.
246
+ </p>
247
+ <table>
248
+ <thead id="head3"></thead>
249
+ <tbody id="headless-rows"></tbody>
250
+ </table>
251
+
252
+ <dialog id="dlg">
253
+ <div class="dlg-head">
254
+ <strong id="dlg-title"></strong>
255
+ <span class="spacer"></span>
256
+ <button class="btn" id="dlg-copy">Copy</button>
257
+ <button class="btn" id="dlg-close">Close</button>
258
+ </div>
259
+ <div class="dlg-body" id="dlg-body"></div>
260
+ </dialog>
261
+
262
+ <script type="module">/*
263
+ * "Which page?", asked the same way everywhere.
264
+ *
265
+ * The .html files the dev server serves (GET /.internal/pages.json, see
266
+ * html_pages.js), as a tree one walks — or, the moment something is typed, as
267
+ * a flat list of what matches. Toggle what kind of page one is after, Enter to
268
+ * take the row one is looking at. What happens to
269
+ * that row is the caller's business — cmd+K goes there in this tab, the clients
270
+ * dashboard sends another browser there — and that is the only difference
271
+ * between the two.
272
+ *
273
+ * Lives here, next to html_pages.js, for the reason written there: the list has
274
+ * more than one reader, and so does the way of reading it. A second picker
275
+ * drawn by hand would drift from this one the day either changes.
276
+ *
277
+ * A tree rather than a list of paths, because the paths are mostly the same
278
+ * path: folding every directory that holds a single thing turns
279
+ * "packages/frontend/navi/src/layout/demos/…" repeated forty times into one
280
+ * line holding forty files — and the packages become something one can see,
281
+ * count and fold away rather than something to type.
282
+ *
283
+ * It can be opened on top of any page (the switcher injects itself into all of
284
+ * them), so everything it draws lives in a shadow root: the page's own CSS
285
+ * cannot reach it and it cannot reach the page's.
286
+ */
287
+
288
+ const PAGES_ENDPOINT = "/.internal/pages.json";
289
+ const STORAGE_KEY = "jsenv_page_picker";
290
+ // What kind of page it is, as the server reads it from where the file sits and
291
+ // what it is called (see html_pages.js): something tried out, something shown,
292
+ // or a page like any other.
293
+ const KINDS = [
294
+ { id: "demo", icon: "🎬", label: "demos" },
295
+ { id: "experiment", icon: "🧪", label: "experiments" },
296
+ { id: "page", icon: "📄", label: "pages" },
297
+ ];
298
+ // Demos alone to begin with: they are what one comes here for nine times out of
299
+ // ten, and a tree opening on five hundred files is a tree nobody reads. The
300
+ // other two are one click away, and the choice is remembered.
301
+ const DEFAULT_KIND_STATE = { demo: true, experiment: false, page: false };
302
+
303
+ let pagesPromise = null;
304
+ // Once per page load: the list is a filesystem scan behind a short cache
305
+ // server-side, and a picker opened twice in a row is the same list.
306
+ const loadPages = () => {
307
+ pagesPromise ||= fetch(PAGES_ENDPOINT)
308
+ .then((response) => response.json())
309
+ .catch(() => []);
310
+ return pagesPromise;
311
+ };
312
+
313
+ // A page url as the list spells it (see html_pages.js: "/" + the path from the
314
+ // root), out of anything holding one — a location, an absolute url, a path.
315
+ // Decoded, because a name with a space or an accent travels percent-encoded and
316
+ // would never match the list.
317
+ const asPageUrl = (url) => {
318
+ if (!url) {
319
+ return null;
320
+ }
321
+ try {
322
+ return decodeURIComponent(new URL(url, window.location.origin).pathname);
323
+ } catch {
324
+ return String(url);
325
+ }
326
+ };
327
+
328
+ const STYLE_TEXT = /* css */ `
329
+ :host {
330
+ position: fixed;
331
+ inset: 0;
332
+ z-index: 2147483647;
333
+ display: block;
334
+ font-family: system-ui, sans-serif;
335
+ }
336
+ .backdrop {
337
+ position: absolute;
338
+ inset: 0;
339
+ background: rgba(15, 23, 42, 0.45);
340
+ }
341
+ .panel {
342
+ position: absolute;
343
+ top: 10vh;
344
+ left: 50%;
345
+ display: flex;
346
+ width: min(640px, calc(100vw - 32px));
347
+ max-height: 70vh;
348
+ flex-direction: column;
349
+ color: light-dark(#0f172a, #e2e8f0);
350
+ background: light-dark(white, #1e293b);
351
+ border-radius: 10px;
352
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
353
+ color-scheme: light dark;
354
+ translate: -50% 0;
355
+ overflow: hidden;
356
+ }
357
+ .title {
358
+ padding: 12px 16px 0;
359
+ font-weight: 600;
360
+ font-size: 13px;
361
+ }
362
+ input {
363
+ padding: 14px 16px;
364
+ color: inherit;
365
+ font-size: 15px;
366
+ font-family: inherit;
367
+ background: transparent;
368
+ border: none;
369
+ outline: none;
370
+ }
371
+ .kinds {
372
+ display: flex;
373
+ padding: 0 12px 10px;
374
+ gap: 6px;
375
+ border-bottom: 1px solid light-dark(#e2e8f0, #334155);
376
+ }
377
+ .kind_toggle {
378
+ display: flex;
379
+ padding: 4px 9px;
380
+ align-items: center;
381
+ gap: 5px;
382
+ color: light-dark(#475569, #cbd5e1);
383
+ font-size: 12px;
384
+ font-family: inherit;
385
+ background: light-dark(#f1f5f9, #0f172a);
386
+ border: 1px solid transparent;
387
+ border-radius: 999px;
388
+ cursor: pointer;
389
+ }
390
+ .kind_toggle[data-on] {
391
+ color: light-dark(#1d4ed8, #bfdbfe);
392
+ background: light-dark(#dbeafe, #1e3a8a);
393
+ border-color: light-dark(#93c5fd, #3b82f6);
394
+ }
395
+ .kind_toggle .count {
396
+ margin: 0;
397
+ padding: 0;
398
+ opacity: 0.7;
399
+ }
400
+ ul {
401
+ margin: 0;
402
+ padding: 6px;
403
+ list-style: none;
404
+ overflow-y: auto;
405
+ }
406
+ li {
407
+ display: flex;
408
+ padding: 5px 8px;
409
+ align-items: center;
410
+ gap: 6px;
411
+ font-size: 13px;
412
+ font-family: ui-monospace, monospace;
413
+ white-space: nowrap;
414
+ border-radius: 6px;
415
+ cursor: pointer;
416
+ }
417
+ /* A file IS a link when going there is something the browser can do — an
418
+ href, so cmd/ctrl+click opens it in a tab and the browser's own menu offers
419
+ the rest. It fills its row so the whole line stays the target. */
420
+ li > .row {
421
+ display: flex;
422
+ min-width: 0;
423
+ flex: 1;
424
+ align-items: center;
425
+ gap: 6px;
426
+ color: inherit;
427
+ text-decoration: none;
428
+ }
429
+ /* The page the picker was opened on, so one opened blind says where it starts
430
+ from. Under the selection rule below, which must win when the two are the
431
+ same row. */
432
+ li[data-here] {
433
+ background: light-dark(#eff6ff, #172554);
434
+ }
435
+ li[data-here] .strong {
436
+ color: light-dark(#1d4ed8, #93c5fd);
437
+ }
438
+ li[data-current] {
439
+ color: white;
440
+ background: light-dark(#2563eb, #3b82f6);
441
+ }
442
+ /* Selected AND the page one is on: the selection owns the row's colours,
443
+ accents included, or the name would be dark blue on blue. */
444
+ li[data-current] .strong {
445
+ color: inherit;
446
+ }
447
+ li[data-current] .dim,
448
+ li[data-current] .count {
449
+ color: inherit;
450
+ opacity: 0.85;
451
+ }
452
+ .twisty {
453
+ width: 1em;
454
+ flex: none;
455
+ opacity: 0.6;
456
+ }
457
+ .kind_icon {
458
+ width: 1.2em;
459
+ flex: none;
460
+ font-size: 12px;
461
+ }
462
+ /* The name is what one reads; the road to it is context — grey, but grey one
463
+ can still read: on the dark panel #64748b sat barely above the background. */
464
+ .dim {
465
+ color: light-dark(#94a3b8, #94a3b8);
466
+ }
467
+ .strong {
468
+ font-weight: 600;
469
+ }
470
+ /* The directory part of a flat search row. It shrinks before the name does,
471
+ and from the left: the end of the road is the part that tells rows apart. */
472
+ .path {
473
+ min-width: 0;
474
+ flex: 0 1 auto;
475
+ text-overflow: ellipsis;
476
+ white-space: nowrap;
477
+ direction: rtl;
478
+ overflow: hidden;
479
+ }
480
+ .path > bdo {
481
+ direction: ltr;
482
+ unicode-bidi: bidi-override;
483
+ }
484
+ .here {
485
+ margin-left: auto;
486
+ padding-left: 8px;
487
+ color: light-dark(#1d4ed8, #93c5fd);
488
+ font-size: 11px;
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.04em;
491
+ }
492
+ li[data-current] .here {
493
+ color: inherit;
494
+ opacity: 0.85;
495
+ }
496
+ .count {
497
+ margin-left: auto;
498
+ padding-left: 8px;
499
+ color: light-dark(#94a3b8, #94a3b8);
500
+ font-size: 11px;
501
+ font-variant-numeric: tabular-nums;
502
+ }
503
+ .empty {
504
+ padding: 14px 16px;
505
+ color: light-dark(#64748b, #94a3b8);
506
+ font-size: 13px;
507
+ cursor: default;
508
+ }
509
+ `;
510
+
511
+ const readStoredState = () => {
512
+ try {
513
+ const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "{}");
514
+ return {
515
+ kinds: { ...DEFAULT_KIND_STATE, ...stored.kinds },
516
+ // What was folded AWAY is what is remembered, not what was opened: a
517
+ // directory that appears later (a new package, a new demos/) is open like
518
+ // everything else rather than hidden by a memory that predates it.
519
+ collapsed: new Set(
520
+ Array.isArray(stored.collapsed) ? stored.collapsed : [],
521
+ ),
522
+ };
523
+ } catch {
524
+ return { kinds: { ...DEFAULT_KIND_STATE }, collapsed: new Set() };
525
+ }
526
+ };
527
+ const writeStoredState = (state) => {
528
+ try {
529
+ window.localStorage.setItem(
530
+ STORAGE_KEY,
531
+ JSON.stringify({ kinds: state.kinds, collapsed: [...state.collapsed] }),
532
+ );
533
+ } catch {
534
+ // private mode, quota — the picker works, it just forgets
535
+ }
536
+ };
537
+
538
+ /*
539
+ * The tree, built from the paths and folded where folding loses nothing: a
540
+ * directory holding a single directory and no file of its own is merged with
541
+ * it, so one line reads "docs/users/b_dev/" instead of three. What is left is
542
+ * the shape of the choices there are to make.
543
+ */
544
+ const buildTree = (pages) => {
545
+ const root = { segments: [], path: "", directories: new Map(), files: [] };
546
+ // The directories that ARE a package (the server found the package.json, see
547
+ // html_pages.js): in a monorepo that is what one is looking for, so it is
548
+ // written plainly while the rest of the road stays grey.
549
+ const packagePathSet = new Set(
550
+ pages.map((page) => page.packageUrl).filter(Boolean),
551
+ );
552
+ for (const page of pages) {
553
+ const segments = page.url.split("/").filter(Boolean);
554
+ const fileName = segments.pop();
555
+ let node = root;
556
+ for (const segment of segments) {
557
+ let child = node.directories.get(segment);
558
+ if (!child) {
559
+ const path = `${node.path}/${segment}`;
560
+ child = {
561
+ // Segment by segment rather than one string: folding glues several
562
+ // directories into one row, and only the one that IS a package is
563
+ // written plainly (see renderRow).
564
+ segments: [{ name: segment, isPackage: packagePathSet.has(path) }],
565
+ path,
566
+ directories: new Map(),
567
+ files: [],
568
+ };
569
+ node.directories.set(segment, child);
570
+ }
571
+ node = child;
572
+ }
573
+ node.files.push({ ...page, name: fileName });
574
+ }
575
+
576
+ const fold = (node) => {
577
+ for (const child of node.directories.values()) {
578
+ fold(child);
579
+ }
580
+ // Nothing of its own and a single way down: the two are one step.
581
+ while (node.files.length === 0 && node.directories.size === 1) {
582
+ const [only] = node.directories.values();
583
+ node.segments = [...node.segments, ...only.segments];
584
+ node.path = only.path;
585
+ node.directories = only.directories;
586
+ node.files = only.files;
587
+ }
588
+ node.files.sort((a, b) => a.name.localeCompare(b.name));
589
+ return node;
590
+ };
591
+ return fold(root);
592
+ };
593
+
594
+ // The whole of a folded row, for sorting and nothing else.
595
+ const nodeName = (node) =>
596
+ node.segments.map((segment) => segment.name).join("/");
597
+
598
+ // What is left of a node once the filters have had their say — used both to
599
+ // decide whether to draw it and to say how much is under it.
600
+ const countMatches = (node, matches) => {
601
+ let count = 0;
602
+ for (const file of node.files) {
603
+ if (matches(file)) {
604
+ count++;
605
+ }
606
+ }
607
+ for (const child of node.directories.values()) {
608
+ count += countMatches(child, matches);
609
+ }
610
+ return count;
611
+ };
612
+
613
+ // The road down to a file, as node paths — what has to be open for that file
614
+ // to be a row at all.
615
+ const pathTo = (node, matches, trail = []) => {
616
+ if (node.files.some(matches)) {
617
+ return trail;
618
+ }
619
+ for (const child of node.directories.values()) {
620
+ const found = pathTo(child, matches, [...trail, child.path]);
621
+ if (found) {
622
+ return found;
623
+ }
624
+ }
625
+ return null;
626
+ };
627
+
628
+ /*
629
+ * Opens the picker and returns a handle on it: { close, isOpen }.
630
+ *
631
+ * - title: what the choice is for, when the caller is not the page itself
632
+ * ("Navigate macOS chrome to…"); the switcher, which IS the page, needs none.
633
+ * - placeholder: the line inside the search field.
634
+ * - hereUrl: the page to mark "here" and to start on — where the picker is
635
+ * opened from, whatever "from" means to the caller (this tab, another
636
+ * browser's tab).
637
+ * - getHref: a url the row can be a real link to, when going there is something
638
+ * this browser can do. Left out, rows are plain rows — a picker that pilots
639
+ * another browser must not offer this one a way to follow.
640
+ * - onPick(page): the row was taken (Enter, plain click). The picker has closed
641
+ * by then: what happens next is elsewhere, here or in another browser. Not
642
+ * called for cmd/ctrl+Enter (nor cmd+click) on a row that has an href: the
643
+ * browser opens it in another tab and the picker stays open.
644
+ * - onEdit(page): an extra action on the selected row, bound to cmd/ctrl+E.
645
+ * - onClose(): the picker is gone, whether taken, escaped or clicked away.
646
+ */
647
+ const openPagePicker = ({
648
+ title,
649
+ placeholder = "Go to page…",
650
+ hereUrl,
651
+ getHref,
652
+ onPick,
653
+ onEdit,
654
+ onClose,
655
+ }) => {
656
+ const state = readStoredState();
657
+ const host = document.createElement("div");
658
+ const shadow = host.attachShadow({ mode: "open" });
659
+ const style = document.createElement("style");
660
+ style.textContent = STYLE_TEXT;
661
+ const backdrop = document.createElement("div");
662
+ backdrop.className = "backdrop";
663
+ const panel = document.createElement("div");
664
+ panel.className = "panel";
665
+ const input = document.createElement("input");
666
+ input.type = "search";
667
+ input.placeholder = placeholder;
668
+ input.setAttribute("aria-label", title || placeholder);
669
+ const kindsRow = document.createElement("div");
670
+ kindsRow.className = "kinds";
671
+ const list = document.createElement("ul");
672
+ if (title) {
673
+ const titleElement = document.createElement("div");
674
+ titleElement.className = "title";
675
+ titleElement.textContent = title;
676
+ panel.append(titleElement);
677
+ }
678
+ panel.append(input, kindsRow, list);
679
+ shadow.append(style, backdrop, panel);
680
+ document.body.append(host);
681
+
682
+ const focusedBefore = document.activeElement;
683
+ let tree = null;
684
+ // The rows as drawn, in order: what ↑/↓ walks and what Enter acts on.
685
+ let rows = [];
686
+ let currentIndex = 0;
687
+ const here = asPageUrl(hereUrl);
688
+ const isHere = (file) => Boolean(here) && file.url === here;
689
+ // Folded away, but holding the page one starts on: opened for this session
690
+ // only and never written down — what the reader folded is still what they
691
+ // folded the next time they come here from somewhere else.
692
+ const revealed = new Set();
693
+ let closed = false;
694
+
695
+ const close = () => {
696
+ if (closed) {
697
+ return;
698
+ }
699
+ closed = true;
700
+ host.remove();
701
+ document.removeEventListener("keydown", onKeyDown, true);
702
+ if (focusedBefore && focusedBefore.isConnected) {
703
+ focusedBefore.focus();
704
+ }
705
+ onClose?.();
706
+ };
707
+ const searchNeedle = () => input.value.trim().toLowerCase();
708
+ const matchesText = (file) => {
709
+ const needle = searchNeedle();
710
+ return needle ? file.url.toLowerCase().includes(needle) : true;
711
+ };
712
+ const matchesFilters = (file) => state.kinds[file.kind] && matchesText(file);
713
+ const toggleCollapsed = (path) => {
714
+ if (state.collapsed.has(path)) {
715
+ state.collapsed.delete(path);
716
+ } else {
717
+ state.collapsed.add(path);
718
+ }
719
+ writeStoredState(state);
720
+ render();
721
+ };
722
+ const pick = (file) => {
723
+ // Closed first: the answer to "which page" has been given, and what the
724
+ // caller does with it (navigate, pilot another browser) is not something to
725
+ // do behind a panel still standing.
726
+ close();
727
+ onPick?.(file);
728
+ };
729
+ // cmd/ctrl+Enter is the keyboard saying what cmd+click says with the mouse:
730
+ // that row, but not here — it opens in another tab and the picker stays
731
+ // exactly as it is, for the next one. Only where the row is a real link: a
732
+ // picker that pilots another browser has no url this one can follow, and
733
+ // there Enter means the same with the key as without it.
734
+ const openInNewTab = (file) => {
735
+ const href = getHref ? getHref(file) : null;
736
+ if (!href) {
737
+ return false;
738
+ }
739
+ // noopener, so the new tab starts on its own rather than as this one's
740
+ // auxiliary: no handle back on this window, and — what matters here — no
741
+ // copy of this tab's sessionStorage, which says the switcher is open and
742
+ // would have it open again over there.
743
+ window.open(href, "_blank", "noopener");
744
+ return true;
745
+ };
746
+ const activate = (row, event) => {
747
+ if (!row) {
748
+ return;
749
+ }
750
+ if (row.type === "file") {
751
+ if (event && isCommandKey(event) && openInNewTab(row.file)) {
752
+ return;
753
+ }
754
+ pick(row.file);
755
+ return;
756
+ }
757
+ toggleCollapsed(row.node.path);
758
+ };
759
+
760
+ const renderKinds = () => {
761
+ kindsRow.textContent = "";
762
+ for (const kind of KINDS) {
763
+ const button = document.createElement("button");
764
+ button.type = "button";
765
+ button.className = "kind_toggle";
766
+ if (state.kinds[kind.id]) {
767
+ button.setAttribute("data-on", "");
768
+ }
769
+ const label = document.createElement("span");
770
+ label.textContent = `${kind.icon} ${kind.label}`;
771
+ const count = document.createElement("span");
772
+ count.className = "count";
773
+ // How many there would be, not how many there are: a count that answered
774
+ // to its own toggle would read 0 for everything switched off.
775
+ count.textContent = tree
776
+ ? countMatches(
777
+ tree,
778
+ (file) => file.kind === kind.id && matchesText(file),
779
+ )
780
+ : 0;
781
+ button.append(label, count);
782
+ button.addEventListener("mousedown", (event) => {
783
+ event.preventDefault();
784
+ state.kinds[kind.id] = !state.kinds[kind.id];
785
+ writeStoredState(state);
786
+ render();
787
+ });
788
+ kindsRow.append(button);
789
+ }
790
+ };
791
+
792
+ const buildRows = () => {
793
+ const nextRows = [];
794
+ // While something is typed the tree steps aside: what one is looking at is
795
+ // the matches, not the folders that happen to hold them — so the matches
796
+ // are a flat list, each carrying its own road, and the folding (untouched)
797
+ // is waiting where it was when the field empties again.
798
+ if (searchNeedle() !== "") {
799
+ const matches = [];
800
+ const collect = (node) => {
801
+ for (const file of node.files) {
802
+ if (matchesFilters(file)) {
803
+ matches.push(file);
804
+ }
805
+ }
806
+ for (const child of node.directories.values()) {
807
+ collect(child);
808
+ }
809
+ };
810
+ collect(tree);
811
+ matches.sort((a, b) => a.url.localeCompare(b.url));
812
+ for (const file of matches) {
813
+ nextRows.push({ type: "file", file, depth: 0, showPath: true });
814
+ }
815
+ return nextRows;
816
+ }
817
+ const walk = (node, depth) => {
818
+ const directories = [...node.directories.values()].sort((a, b) =>
819
+ nodeName(a).localeCompare(nodeName(b)),
820
+ );
821
+ for (const child of directories) {
822
+ const count = countMatches(child, matchesFilters);
823
+ if (count === 0) {
824
+ continue;
825
+ }
826
+ const collapsed =
827
+ state.collapsed.has(child.path) && !revealed.has(child.path);
828
+ nextRows.push({
829
+ type: "directory",
830
+ node: child,
831
+ depth,
832
+ count,
833
+ collapsed,
834
+ });
835
+ if (!collapsed) {
836
+ walk(child, depth + 1);
837
+ }
838
+ }
839
+ for (const file of node.files) {
840
+ if (matchesFilters(file)) {
841
+ nextRows.push({ type: "file", file, depth });
842
+ }
843
+ }
844
+ };
845
+ walk(tree, 0);
846
+ return nextRows;
847
+ };
848
+
849
+ const renderRow = (row, index) => {
850
+ const item = document.createElement("li");
851
+ item.style.paddingLeft = `${8 + row.depth * 14}px`;
852
+ if (index === currentIndex) {
853
+ item.setAttribute("data-current", "");
854
+ }
855
+ if (row.type === "directory") {
856
+ const twisty = document.createElement("span");
857
+ twisty.className = "twisty";
858
+ twisty.textContent = row.collapsed ? "▸" : "▾";
859
+ // One box for the whole path, so the row's own gap does not fall between
860
+ // two halves of the same name.
861
+ const path = document.createElement("span");
862
+ for (const segment of row.node.segments) {
863
+ const name = document.createElement("span");
864
+ name.className = segment.isPackage ? "strong" : "dim";
865
+ name.textContent = `${segment.name}/`;
866
+ path.append(name);
867
+ }
868
+ item.append(twisty, path);
869
+ const count = document.createElement("span");
870
+ count.className = "count";
871
+ count.textContent = row.count;
872
+ item.append(count);
873
+ item.addEventListener("mousedown", (event) => {
874
+ event.preventDefault();
875
+ currentIndex = index;
876
+ activate(row);
877
+ });
878
+ return item;
879
+ }
880
+ const href = getHref ? getHref(row.file) : null;
881
+ const rowContent = document.createElement(href ? "a" : "span");
882
+ rowContent.className = "row";
883
+ if (href) {
884
+ rowContent.href = href;
885
+ // For the same reason cmd/ctrl+Enter opens with noopener: whatever way
886
+ // the browser is asked for another tab, that tab is another page and not
887
+ // a copy of this one's session.
888
+ rowContent.rel = "noopener";
889
+ }
890
+ const icon = document.createElement("span");
891
+ icon.className = "kind_icon";
892
+ icon.textContent =
893
+ KINDS.find((kind) => kind.id === row.file.kind)?.icon || "📄";
894
+ icon.title = row.file.kind;
895
+ const name = document.createElement("span");
896
+ name.className = "strong";
897
+ name.textContent = row.file.name;
898
+ if (row.showPath) {
899
+ // A flat row is its own road: the directory part rides along, grey, and
900
+ // gives way (ellipsis on its left, where the repetition lives) before the
901
+ // name ever would.
902
+ const path = document.createElement("span");
903
+ path.className = "dim path";
904
+ // The rtl container puts the ellipsis on the left; the bdo keeps the
905
+ // characters themselves reading left to right inside it.
906
+ const pathText = document.createElement("bdo");
907
+ pathText.setAttribute("dir", "ltr");
908
+ pathText.textContent = row.file.url.slice(1, -row.file.name.length);
909
+ path.append(pathText);
910
+ rowContent.append(icon, path, name);
911
+ } else {
912
+ rowContent.append(icon, name);
913
+ }
914
+ if (isHere(row.file)) {
915
+ item.setAttribute("data-here", "");
916
+ const hereLabel = document.createElement("span");
917
+ hereLabel.className = "here";
918
+ hereLabel.textContent = "here";
919
+ rowContent.append(hereLabel);
920
+ }
921
+ item.append(rowContent);
922
+ item.addEventListener("click", (event) => {
923
+ currentIndex = index;
924
+ // Anything but a plain left click on a real link is the browser's
925
+ // business — a new tab, a new window, a download: the picker stays
926
+ // exactly as it is, still open, for the next one.
927
+ if (
928
+ href &&
929
+ (event.button !== 0 ||
930
+ event.metaKey ||
931
+ event.ctrlKey ||
932
+ event.shiftKey ||
933
+ event.altKey)
934
+ ) {
935
+ return;
936
+ }
937
+ // Taking the row goes through the same door as Enter, whatever it was
938
+ // drawn as: the href is there for the browser's own menu, not to be the
939
+ // way it normally happens.
940
+ event.preventDefault();
941
+ pick(row.file);
942
+ });
943
+ return item;
944
+ };
945
+
946
+ const render = () => {
947
+ renderKinds();
948
+ rows = buildRows();
949
+ if (currentIndex >= rows.length) {
950
+ currentIndex = 0;
951
+ }
952
+ list.textContent = "";
953
+ if (rows.length === 0) {
954
+ const empty = document.createElement("li");
955
+ empty.className = "empty";
956
+ empty.textContent = "Nothing matches.";
957
+ list.append(empty);
958
+ return;
959
+ }
960
+ for (const [index, row] of rows.entries()) {
961
+ list.append(renderRow(row, index));
962
+ }
963
+ };
964
+ const moveCurrent = (delta) => {
965
+ if (rows.length === 0) {
966
+ return;
967
+ }
968
+ currentIndex = (currentIndex + delta + rows.length) % rows.length;
969
+ render();
970
+ list.children[currentIndex]?.scrollIntoView({ block: "nearest" });
971
+ };
972
+
973
+ // In the capture phase, and on the document: while this is open it IS the
974
+ // page as far as the keyboard goes, and whatever the page below listens for
975
+ // must not answer at the same time.
976
+ function onKeyDown(event) {
977
+ const stop = () => {
978
+ event.preventDefault();
979
+ event.stopPropagation();
980
+ };
981
+ if (event.key === "Escape") {
982
+ stop();
983
+ close();
984
+ return;
985
+ }
986
+ if (event.key === "Enter") {
987
+ stop();
988
+ activate(rows[currentIndex], event);
989
+ return;
990
+ }
991
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
992
+ stop();
993
+ moveCurrent(event.key === "ArrowDown" ? 1 : -1);
994
+ return;
995
+ }
996
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
997
+ const row = rows[currentIndex];
998
+ // A file has nothing to fold, and a folder already the right way round
999
+ // has nothing to do: the caret keeps the key in both cases.
1000
+ if (!row || row.type !== "directory") {
1001
+ return;
1002
+ }
1003
+ const wantCollapsed = event.key === "ArrowLeft";
1004
+ if (row.collapsed === wantCollapsed) {
1005
+ return;
1006
+ }
1007
+ stop();
1008
+ toggleCollapsed(row.node.path);
1009
+ return;
1010
+ }
1011
+ if (onEdit && isEditorKey(event)) {
1012
+ // Taken whatever the row is: let go of on a directory it would reach the
1013
+ // page below and act on the page one came from, which is not what a key
1014
+ // pressed inside an open picker can be asking for.
1015
+ stop();
1016
+ const row = rows[currentIndex];
1017
+ if (!row || row.type !== "file") {
1018
+ return;
1019
+ }
1020
+ const { file } = row;
1021
+ close();
1022
+ onEdit(file);
1023
+ }
1024
+ }
1025
+ document.addEventListener("keydown", onKeyDown, true);
1026
+ backdrop.addEventListener("mousedown", close);
1027
+ input.addEventListener("input", () => {
1028
+ currentIndex = 0;
1029
+ render();
1030
+ });
1031
+ input.focus();
1032
+
1033
+ loadPages().then((pages) => {
1034
+ if (closed) {
1035
+ return; // closed while the list was still coming
1036
+ }
1037
+ tree = buildTree(pages);
1038
+ // Opened on a page that is in the list: it is selected and scrolled to, so
1039
+ // the tree starts from where the reader already is rather than from the top
1040
+ // of a tree they then have to find themselves in.
1041
+ for (const path of pathTo(tree, isHere) || []) {
1042
+ revealed.add(path);
1043
+ }
1044
+ render();
1045
+ const hereIndex = rows.findIndex(
1046
+ (row) => row.type === "file" && isHere(row.file),
1047
+ );
1048
+ if (hereIndex !== -1) {
1049
+ currentIndex = hereIndex;
1050
+ render();
1051
+ list.children[currentIndex]?.scrollIntoView({ block: "center" });
1052
+ }
1053
+ });
1054
+
1055
+ return {
1056
+ close,
1057
+ isOpen: () => !closed,
1058
+ };
1059
+ };
1060
+
1061
+ // cmd on mac, ctrl elsewhere — the same split every editor makes.
1062
+ const isCommandKey = (event) =>
1063
+ window.navigator.platform.toLowerCase().includes("mac")
1064
+ ? event.metaKey && !event.ctrlKey
1065
+ : event.ctrlKey && !event.metaKey;
1066
+
1067
+ // E for edit, next to K for the same reason the two belong together: K asks
1068
+ // "which page", E asks "where does this page live".
1069
+ const isEditorKey = (event) =>
1070
+ (event.key === "e" || event.key === "E") && isCommandKey(event);
1071
+
1072
+ const selfRows = document.getElementById("self-rows");
1073
+ const otherRows = document.getElementById("other-rows");
1074
+ const headlessRows = document.getElementById("headless-rows");
1075
+ // The monitoring script (injected into this page too) stores our own id.
1076
+ let selfId = null;
1077
+ try {
1078
+ selfId = localStorage.getItem("jsenv_client_id");
1079
+ } catch {
1080
+ // storage blocked — we just won't single ourselves out
1081
+ }
1082
+
1083
+ const headHtml = `<tr>
1084
+ <th>First seen</th><th>OS</th><th>Browser</th><th>IP</th><th>Active tab</th>
1085
+ <th>Last activity</th><th>Logs</th><th></th>
1086
+ </tr>`;
1087
+ document.getElementById("head").innerHTML = headHtml;
1088
+ document.getElementById("head2").innerHTML = headHtml;
1089
+ document.getElementById("head3").innerHTML = headHtml;
1090
+ const COLSPAN = 8;
1091
+
1092
+ const ago = (ts) => {
1093
+ const s = Math.round((Date.now() - ts) / 1000);
1094
+ if (s < 5) return "just now";
1095
+ if (s < 60) return `${s}s ago`;
1096
+ const m = Math.round(s / 60);
1097
+ if (m < 60) return `${m}m ago`;
1098
+ const h = Math.round(m / 60);
1099
+ return `${h}h ago`;
1100
+ };
1101
+ const pad2 = (n) => String(n).padStart(2, "0");
1102
+ // Absolute date+time, kept for the "first seen" tooltip.
1103
+ const fmtDate = (ts) => {
1104
+ const d = new Date(ts);
1105
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
1106
+ };
1107
+ // Human relative time for "first seen" ("5 minutes ago", "yesterday"),
1108
+ // rendered with the largest unit that fits. Re-rendered every few seconds
1109
+ // (see the interval below) so it stays fresh.
1110
+ const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, {
1111
+ numeric: "auto",
1112
+ });
1113
+ const RELATIVE_UNITS = [
1114
+ ["year", 31536000],
1115
+ ["month", 2592000],
1116
+ ["week", 604800],
1117
+ ["day", 86400],
1118
+ ["hour", 3600],
1119
+ ["minute", 60],
1120
+ ];
1121
+ const relativeTime = (ts) => {
1122
+ const deltaSeconds = Math.round((ts - Date.now()) / 1000); // <0 = past
1123
+ const abs = deltaSeconds < 0 ? -deltaSeconds : deltaSeconds;
1124
+ for (const [unit, secondsInUnit] of RELATIVE_UNITS) {
1125
+ if (abs >= secondsInUnit) {
1126
+ return relativeTimeFormat.format(
1127
+ Math.round(deltaSeconds / secondsInUnit),
1128
+ unit,
1129
+ );
1130
+ }
1131
+ }
1132
+ return relativeTimeFormat.format(deltaSeconds, "second");
1133
+ };
1134
+ const escapeHtml = (str) =>
1135
+ String(str).replace(
1136
+ /[&<>"]/g,
1137
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
1138
+ );
1139
+ // Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
1140
+ // "0/432"); the full value stays in the stored/reported data.
1141
+ const roundNumbers = (text) =>
1142
+ String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
1143
+ const shortUrl = (url) => {
1144
+ try {
1145
+ const u = new URL(url);
1146
+ return `${u.pathname}${u.search}${u.hash}` || "/";
1147
+ } catch {
1148
+ return url;
1149
+ }
1150
+ };
1151
+ // Last path segment of a URL ("…/8_picker_wheel_demo.html" → the filename),
1152
+ // for compact activity labels. Non-URLs pass through unchanged.
1153
+ const fileName = (url) => {
1154
+ try {
1155
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
1156
+ return segments.length ? segments[segments.length - 1] : "/";
1157
+ } catch {
1158
+ return url;
1159
+ }
1160
+ };
1161
+ // The compact form of an activity detail for the "Last activity" column:
1162
+ // URL-valued details collapse to their filename, a request keeps its
1163
+ // method, coordinates lose sub-pixel noise. The full detail stays in the
1164
+ // tooltip and the activity dialog / monitor.
1165
+ const compactDetail = (type, detail) => {
1166
+ if (!detail) {
1167
+ return "";
1168
+ }
1169
+ if (type === "load" || type === "navigation") {
1170
+ return fileName(detail);
1171
+ }
1172
+ if (type === "request") {
1173
+ const spaceIndex = detail.indexOf(" ");
1174
+ return spaceIndex === -1
1175
+ ? fileName(detail)
1176
+ : `${detail.slice(0, spaceIndex)} ${fileName(detail.slice(spaceIndex + 1))}`;
1177
+ }
1178
+ return roundNumbers(detail);
1179
+ };
1180
+ const RUNTIME_LABELS = {
1181
+ ios_safari: "iOS Safari",
1182
+ chrome: "Chrome",
1183
+ edge: "Edge",
1184
+ opera: "Opera",
1185
+ samsung: "Samsung Internet",
1186
+ firefox: "Firefox",
1187
+ safari: "Safari",
1188
+ node: "Node",
1189
+ };
1190
+ const named = ({ name, version } = {}) => {
1191
+ if (!name || name === "unknown") {
1192
+ return '<span class="muted">unknown</span>';
1193
+ }
1194
+ const label = RUNTIME_LABELS[name] || name;
1195
+ const v = version && version !== "unknown" ? ` ${version}` : "";
1196
+ return escapeHtml(`${label}${v}`);
1197
+ };
1198
+
1199
+ const firstSeenCell = (d) =>
1200
+ `<span class="when" title="${escapeHtml(fmtDate(d.firstSeen))}">${escapeHtml(relativeTime(d.firstSeen))}</span>`;
1201
+ const tabCell = (d) => {
1202
+ if (!d.activeTab) {
1203
+ return '<span class="muted">—</span>';
1204
+ }
1205
+ const url = d.activeTab.url || "";
1206
+ return `<span class="url" title="${escapeHtml(url)}"><bdi dir="ltr">${escapeHtml(shortUrl(url))}</bdi></span>`;
1207
+ };
1208
+ const activityCell = (d) => {
1209
+ const a = d.lastActivity;
1210
+ if (!a) {
1211
+ return '<span class="muted">—</span>';
1212
+ }
1213
+ const compact = a.detail
1214
+ ? `${a.type}: ${compactDetail(a.type, a.detail)}`
1215
+ : a.type;
1216
+ const full = a.detail ? `${a.type}: ${a.detail}` : a.type;
1217
+ return `<span class="tag ellip" title="${escapeHtml(full)}">${escapeHtml(compact)}</span> <span class="muted">${ago(a.ts)}</span>`;
1218
+ };
1219
+ const actionsCell = (d) => {
1220
+ const id = escapeHtml(d.id);
1221
+ return `<div class="actions">
1222
+ <button class="btn primary" data-action="navigate" data-id="${id}">Navigate…</button>
1223
+ <button class="btn" data-action="reload" data-id="${id}">Reload</button>
1224
+ <button class="btn" data-action="tabs" data-id="${id}">Tabs</button>
1225
+ <button class="btn" data-action="activity" data-id="${id}">Activity</button>
1226
+ <button class="btn" data-action="ua" data-id="${id}">User agent</button>
1227
+ <a class="monitor" href="/.internal/client?id=${encodeURIComponent(d.id)}">Monitor →</a>
1228
+ </div>`;
1229
+ };
1230
+ // The ip says how the client reaches the server, which is what tells the
1231
+ // main client (this machine, via localhost) from a device on the network.
1232
+ const ipCell = (d) => {
1233
+ if (!d.ip) {
1234
+ return '<span class="muted">—</span>';
1235
+ }
1236
+ const label = d.local ? "localhost" : d.ip;
1237
+ return `<span title="${escapeHtml(d.ip)}">${escapeHtml(label)}</span>`;
1238
+ };
1239
+ const rowHtml = (d) => {
1240
+ return `<tr>
1241
+ <td>${firstSeenCell(d)}</td>
1242
+ <td>${named(d.os)}</td>
1243
+ <td>${named(d.runtime)}</td>
1244
+ <td>${ipCell(d)}</td>
1245
+ <td>${tabCell(d)}</td>
1246
+ <td>${activityCell(d)}</td>
1247
+ <td>${d.logCount}</td>
1248
+ <td>${actionsCell(d)}</td>
1249
+ </tr>`;
1250
+ };
1251
+
1252
+ let lastClients = [];
1253
+ const render = (clients) => {
1254
+ lastClients = clients;
1255
+ const self = selfId ? clients.find((d) => d.id === selfId) : null;
1256
+ // The client someone is using right now comes first; among the quiet
1257
+ // ones, the newest. A client that never reported an activity sorts
1258
+ // after those that did.
1259
+ const byRecency = (a, b) => {
1260
+ const aActivity = a.lastActivity ? a.lastActivity.ts : 0;
1261
+ const bActivity = b.lastActivity ? b.lastActivity.ts : 0;
1262
+ if (aActivity !== bActivity) {
1263
+ return bActivity - aActivity;
1264
+ }
1265
+ return b.firstSeen - a.firstSeen;
1266
+ };
1267
+ // A headless browser is a program's, not a person's: it comes and goes
1268
+ // with a test run, so it gets its own table instead of pushing the
1269
+ // clients someone is actually browsing with out of sight.
1270
+ const others = clients
1271
+ .filter((d) => d !== self && !d.headless)
1272
+ .sort(byRecency);
1273
+ const headless = clients
1274
+ .filter((d) => d !== self && d.headless)
1275
+ .sort(byRecency);
1276
+ selfRows.innerHTML = self
1277
+ ? rowHtml(self)
1278
+ : `<tr><td class="empty" colspan="${COLSPAN}">This browser isn't registered yet — it will appear shortly.</td></tr>`;
1279
+ otherRows.innerHTML = others.length
1280
+ ? others.map(rowHtml).join("")
1281
+ : `<tr><td class="empty" colspan="${COLSPAN}">No other client has connected.</td></tr>`;
1282
+ headlessRows.innerHTML = headless.length
1283
+ ? headless.map(rowHtml).join("")
1284
+ : `<tr><td class="empty" colspan="${COLSPAN}">No headless browser has connected.</td></tr>`;
1285
+ };
1286
+
1287
+ // Dialogs (all tabs / recent activity / user agent).
1288
+ const dlg = document.getElementById("dlg");
1289
+ const dlgTitle = document.getElementById("dlg-title");
1290
+ const dlgBody = document.getElementById("dlg-body");
1291
+ const dlgCopy = document.getElementById("dlg-copy");
1292
+ document.getElementById("dlg-close").onclick = () => dlg.close();
1293
+ const openDialog = (title, html, copyText) => {
1294
+ dlgTitle.textContent = title;
1295
+ dlgBody.innerHTML = html;
1296
+ if (copyText === undefined) {
1297
+ dlgCopy.style.display = "none";
1298
+ } else {
1299
+ dlgCopy.style.display = "";
1300
+ dlgCopy.textContent = "Copy";
1301
+ dlgCopy.onclick = async () => {
1302
+ try {
1303
+ await window.navigator.clipboard.writeText(copyText);
1304
+ dlgCopy.textContent = "Copied!";
1305
+ setTimeout(() => {
1306
+ dlgCopy.textContent = "Copy";
1307
+ }, 1200);
1308
+ } catch {
1309
+ // clipboard blocked — ignore
1310
+ }
1311
+ };
1312
+ }
1313
+ dlg.showModal();
1314
+ };
1315
+
1316
+ const fetchClient = async (id) => {
1317
+ try {
1318
+ return await (
1319
+ await fetch(`/.internal/client.json?id=${encodeURIComponent(id)}`)
1320
+ ).json();
1321
+ } catch {
1322
+ return null;
1323
+ }
1324
+ };
1325
+ const tabsHtml = (tabs) => {
1326
+ if (!tabs.length) {
1327
+ return '<p class="muted">No tab.</p>';
1328
+ }
1329
+ return `<ul class="list">${tabs
1330
+ .slice()
1331
+ .sort((a, b) => b.visible - a.visible || b.lastSeen - a.lastSeen)
1332
+ .map(
1333
+ (t) => `<li>
1334
+ <div>${t.visible ? '<span class="tag on">active</span> ' : ""}<span class="ttl">${escapeHtml(t.title) || '<span class="muted">(no title)</span>'}</span> <span class="muted">${ago(t.lastSeen)}</span></div>
1335
+ <div class="url2">${escapeHtml(t.url)}</div>
1336
+ </li>`,
1337
+ )
1338
+ .join("")}</ul>`;
1339
+ };
1340
+ const activitiesHtml = (activities) => {
1341
+ if (!activities.length) {
1342
+ return '<p class="muted">No activity yet.</p>';
1343
+ }
1344
+ return `<ul class="list">${activities
1345
+ .slice()
1346
+ .reverse()
1347
+ .map(
1348
+ (a) => `<li>
1349
+ <div><span class="tag">${escapeHtml(a.type)}</span> <span class="muted">${ago(a.ts)}</span></div>
1350
+ ${a.detail ? `<div class="url2">${escapeHtml(roundNumbers(a.detail))}</div>` : ""}
1351
+ </li>`,
1352
+ )
1353
+ .join("")}</ul>`;
1354
+ };
1355
+
1356
+ // Pilot a client: POST a command; the server broadcasts it and the
1357
+ // matching reporter runs it (navigate/reload) on the targeted tab.
1358
+ const sendCommand = async (payload) => {
1359
+ try {
1360
+ await fetch("/.internal/clients/command", {
1361
+ method: "POST",
1362
+ headers: { "content-type": "application/json" },
1363
+ body: JSON.stringify(payload),
1364
+ });
1365
+ } catch {
1366
+ // dev server gone — ignore
1367
+ }
1368
+ };
1369
+ const shortLabel = (d) => {
1370
+ const parts = [d.os && d.os.name, d.runtime && d.runtime.name].filter(
1371
+ Boolean,
1372
+ );
1373
+ return parts.length ? parts.join(" ") : d.id.slice(0, 8);
1374
+ };
1375
+ // Which page to send a client to is the same question cmd+K asks on any
1376
+ // dev-served page, so it is the same tree that answers it (see
1377
+ // protocol_file/client/page_picker.js) — filters, folding and all. Only
1378
+ // the ending differs: the url goes to the client's active tab (tabId from
1379
+ // activeTab, so no other tab moves) instead of to this window. No href on
1380
+ // the rows for that very reason: a click here must not take THIS browser
1381
+ // to a page meant for another one.
1382
+ const openNavigateDialog = (client) => {
1383
+ openPagePicker({
1384
+ title: `Navigate ${shortLabel(client)} to…`,
1385
+ placeholder: "Go to page…",
1386
+ hereUrl: client.activeTab ? client.activeTab.url : null,
1387
+ onPick: (page) => {
1388
+ sendCommand({
1389
+ clientId: client.id,
1390
+ tabId: client.activeTab ? client.activeTab.id : null,
1391
+ type: "navigate",
1392
+ url: page.url,
1393
+ });
1394
+ },
1395
+ });
1396
+ };
1397
+
1398
+ document.addEventListener("click", async (event) => {
1399
+ const button = event.target.closest("button[data-action]");
1400
+ if (!button) {
1401
+ return;
1402
+ }
1403
+ const id = button.getAttribute("data-id");
1404
+ const action = button.getAttribute("data-action");
1405
+ if (action === "ua") {
1406
+ const client = lastClients.find((d) => d.id === id);
1407
+ const ua = (client && client.userAgent) || "";
1408
+ openDialog("User agent", `<pre>${escapeHtml(ua)}</pre>`, ua);
1409
+ return;
1410
+ }
1411
+ if (action === "navigate") {
1412
+ const client = lastClients.find((d) => d.id === id);
1413
+ if (client) {
1414
+ openNavigateDialog(client);
1415
+ }
1416
+ return;
1417
+ }
1418
+ if (action === "reload") {
1419
+ const client = lastClients.find((d) => d.id === id);
1420
+ sendCommand({
1421
+ clientId: id,
1422
+ tabId: client && client.activeTab ? client.activeTab.id : null,
1423
+ type: "reload",
1424
+ });
1425
+ return;
1426
+ }
1427
+ const detail = await fetchClient(id);
1428
+ if (!detail) {
1429
+ return;
1430
+ }
1431
+ if (action === "tabs") {
1432
+ openDialog(`Tabs (${detail.tabs.length})`, tabsHtml(detail.tabs));
1433
+ } else if (action === "activity") {
1434
+ openDialog("Recent activity", activitiesHtml(detail.activities));
1435
+ }
1436
+ });
1437
+
1438
+ // This page is cooked through the dev server, so it gets both the jsenv
1439
+ // server-events client (window.__server_events__, injected first — the
1440
+ // same channel autoreload rides) and the monitoring script (so opening
1441
+ // this page counts as a connected client). The server pushes the full
1442
+ // list on every change; no polling. Initial snapshot via the JSON route.
1443
+ (async () => {
1444
+ try {
1445
+ render(await (await fetch("/.internal/clients.json")).json());
1446
+ } catch {
1447
+ // dev server unreachable — the live channel will fill the list in.
1448
+ }
1449
+ })();
1450
+
1451
+ window.__server_events__.listenEvents({
1452
+ clients_list: (event) => render(event.data),
1453
+ });
1454
+
1455
+ // Refresh relative "last activity" times between server pushes.
1456
+ setInterval(() => render(lastClients), 5000);
1457
+ </script>
1458
+ </body>
1459
+ </html>