@jsenv/core 41.4.1 → 41.4.2

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,731 @@
1
+ /*
2
+ * cmd+K / ctrl+K on any dev-served page: the .html files the server serves, as a
3
+ * tree one walks, filter as you type, Enter to go there.
4
+ *
5
+ * A tree rather than a list of paths, because the paths are mostly the same
6
+ * path: folding every directory that holds a single thing turns
7
+ * "packages/frontend/navi/src/layout/demos/…" repeated forty times into one
8
+ * line holding forty files — and the packages become something one can see,
9
+ * count and fold away rather than something to type.
10
+ *
11
+ * Injected into every page (see jsenv_plugin_page_switcher.js), which is why it
12
+ * is careful about what it takes:
13
+ * - the key is watched at the end of the bubble, on window, and a press that
14
+ * was already handled (defaultPrevented) is left alone — a page with its own
15
+ * cmd+K keeps it, and gets it first;
16
+ * - nothing is fetched, built or styled until the first time it opens;
17
+ * - everything it renders lives in a shadow root, so the page's own CSS cannot
18
+ * reach it and it cannot reach the page's.
19
+ */
20
+
21
+ // Everything below lives in here rather than at the top level: this file is
22
+ // injected as a classic script, where a top-level `const` becomes a global
23
+ // lexical binding shared with every other script on the page — one named CSS
24
+ // took window.CSS away from the whole page, one named setup collided with
25
+ // another injected client. A function scope owes nothing to anybody.
26
+ (() => {
27
+ const PAGES_ENDPOINT = "/.internal/pages.json";
28
+ const STORAGE_KEY = "jsenv_page_switcher";
29
+ // Open across a reload: this is a dev tool on a page one is editing, and a hot
30
+ // reload in the middle of looking for the next page should not close what one
31
+ // was looking at. In sessionStorage, not localStorage: it says what THIS tab was
32
+ // doing a second ago, which is not something to remember tomorrow.
33
+ const OPEN_KEY = "jsenv_page_switcher_open";
34
+ // What kind of page it is, as the server reads it from where the file sits and
35
+ // what it is called (see html_pages.js): something tried out, something shown,
36
+ // or a page like any other.
37
+ const KINDS = [
38
+ { id: "demo", icon: "🎬", label: "demos" },
39
+ { id: "experiment", icon: "🧪", label: "experiments" },
40
+ { id: "page", icon: "📄", label: "pages" },
41
+ ];
42
+ // Demos alone to begin with: they are what one comes here for nine times out of
43
+ // ten, and a tree opening on five hundred files is a tree nobody reads. The
44
+ // other two are one click away, and the choice is remembered.
45
+ const DEFAULT_KIND_STATE = { demo: true, experiment: false, page: false };
46
+
47
+ // The page this script is running in, as the list spells its urls (see
48
+ // html_pages.js: "/" + the path from the root). Decoded, because a name with a
49
+ // space or an accent reaches location.pathname percent-encoded and would never
50
+ // match the list.
51
+ const currentPageUrl = () => {
52
+ try {
53
+ return decodeURIComponent(window.location.pathname);
54
+ } catch {
55
+ return window.location.pathname;
56
+ }
57
+ };
58
+
59
+ const isSwitcherKey = (event) => {
60
+ if (event.key !== "k" && event.key !== "K") {
61
+ return false;
62
+ }
63
+ // cmd on mac, ctrl elsewhere — the same split every editor makes.
64
+ return window.navigator.platform.toLowerCase().includes("mac")
65
+ ? event.metaKey && !event.ctrlKey
66
+ : event.ctrlKey && !event.metaKey;
67
+ };
68
+
69
+ const STYLE_TEXT = /* css */ `
70
+ :host {
71
+ position: fixed;
72
+ inset: 0;
73
+ z-index: 2147483647;
74
+ display: block;
75
+ font-family: system-ui, sans-serif;
76
+ }
77
+ .backdrop {
78
+ position: absolute;
79
+ inset: 0;
80
+ background: rgba(15, 23, 42, 0.45);
81
+ }
82
+ .panel {
83
+ position: absolute;
84
+ top: 10vh;
85
+ left: 50%;
86
+ display: flex;
87
+ width: min(640px, calc(100vw - 32px));
88
+ max-height: 70vh;
89
+ flex-direction: column;
90
+ color: light-dark(#0f172a, #e2e8f0);
91
+ background: light-dark(white, #1e293b);
92
+ border-radius: 10px;
93
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
94
+ color-scheme: light dark;
95
+ translate: -50% 0;
96
+ overflow: hidden;
97
+ }
98
+ input {
99
+ padding: 14px 16px;
100
+ color: inherit;
101
+ font-size: 15px;
102
+ font-family: inherit;
103
+ background: transparent;
104
+ border: none;
105
+ outline: none;
106
+ }
107
+ .kinds {
108
+ display: flex;
109
+ padding: 0 12px 10px;
110
+ gap: 6px;
111
+ border-bottom: 1px solid light-dark(#e2e8f0, #334155);
112
+ }
113
+ .kind_toggle {
114
+ display: flex;
115
+ padding: 4px 9px;
116
+ align-items: center;
117
+ gap: 5px;
118
+ color: light-dark(#475569, #cbd5e1);
119
+ font-size: 12px;
120
+ font-family: inherit;
121
+ background: light-dark(#f1f5f9, #0f172a);
122
+ border: 1px solid transparent;
123
+ border-radius: 999px;
124
+ cursor: pointer;
125
+ }
126
+ .kind_toggle[data-on] {
127
+ color: light-dark(#1d4ed8, #bfdbfe);
128
+ background: light-dark(#dbeafe, #1e3a8a);
129
+ border-color: light-dark(#93c5fd, #3b82f6);
130
+ }
131
+ .kind_toggle .count {
132
+ margin: 0;
133
+ padding: 0;
134
+ opacity: 0.7;
135
+ }
136
+ ul {
137
+ margin: 0;
138
+ padding: 6px;
139
+ list-style: none;
140
+ overflow-y: auto;
141
+ }
142
+ li {
143
+ display: flex;
144
+ padding: 5px 8px;
145
+ align-items: center;
146
+ gap: 6px;
147
+ font-size: 13px;
148
+ font-family: ui-monospace, monospace;
149
+ white-space: nowrap;
150
+ border-radius: 6px;
151
+ cursor: pointer;
152
+ }
153
+ /* A file IS a link — an href, so cmd/ctrl+click opens it in a tab and the
154
+ browser's own menu offers the rest. It fills its row so the whole line
155
+ stays the target. */
156
+ li > a {
157
+ display: flex;
158
+ min-width: 0;
159
+ flex: 1;
160
+ align-items: center;
161
+ gap: 6px;
162
+ color: inherit;
163
+ text-decoration: none;
164
+ }
165
+ /* The page one is already on, so a switcher opened blind says where it was
166
+ opened from. Under the selection rule below, which must win when the two
167
+ are the same row. */
168
+ li[data-here] {
169
+ background: light-dark(#eff6ff, #172554);
170
+ }
171
+ li[data-here] .strong {
172
+ color: light-dark(#1d4ed8, #93c5fd);
173
+ }
174
+ li[data-current] {
175
+ color: white;
176
+ background: light-dark(#2563eb, #3b82f6);
177
+ }
178
+ /* Selected AND the page one is on: the selection owns the row's colours,
179
+ accents included, or the name would be dark blue on blue. */
180
+ li[data-current] .strong {
181
+ color: inherit;
182
+ }
183
+ li[data-current] .dim,
184
+ li[data-current] .count {
185
+ color: inherit;
186
+ opacity: 0.85;
187
+ }
188
+ .twisty {
189
+ width: 1em;
190
+ flex: none;
191
+ opacity: 0.6;
192
+ }
193
+ .kind_icon {
194
+ width: 1.2em;
195
+ flex: none;
196
+ font-size: 12px;
197
+ }
198
+ /* The name is what one reads; the road to it is context — grey, but grey one
199
+ can still read: on the dark panel #64748b sat barely above the background. */
200
+ .dim {
201
+ color: light-dark(#94a3b8, #94a3b8);
202
+ }
203
+ .strong {
204
+ font-weight: 600;
205
+ }
206
+ .here {
207
+ margin-left: auto;
208
+ padding-left: 8px;
209
+ color: light-dark(#1d4ed8, #93c5fd);
210
+ font-size: 11px;
211
+ text-transform: uppercase;
212
+ letter-spacing: 0.04em;
213
+ }
214
+ li[data-current] .here {
215
+ color: inherit;
216
+ opacity: 0.85;
217
+ }
218
+ .count {
219
+ margin-left: auto;
220
+ padding-left: 8px;
221
+ color: light-dark(#94a3b8, #94a3b8);
222
+ font-size: 11px;
223
+ font-variant-numeric: tabular-nums;
224
+ }
225
+ .empty {
226
+ padding: 14px 16px;
227
+ color: light-dark(#64748b, #94a3b8);
228
+ font-size: 13px;
229
+ cursor: default;
230
+ }
231
+ `;
232
+
233
+ let pagesPromise = null;
234
+ const loadPages = () => {
235
+ // Once per page load: the list is a filesystem scan behind a short cache
236
+ // server-side, and a switcher opened twice in a row is the same list.
237
+ pagesPromise ||= fetch(PAGES_ENDPOINT)
238
+ .then((response) => response.json())
239
+ .catch(() => []);
240
+ return pagesPromise;
241
+ };
242
+
243
+ const readStoredState = () => {
244
+ try {
245
+ const stored = JSON.parse(
246
+ window.localStorage.getItem(STORAGE_KEY) || "{}",
247
+ );
248
+ return {
249
+ kinds: { ...DEFAULT_KIND_STATE, ...stored.kinds },
250
+ // What was folded AWAY is what is remembered, not what was opened: a
251
+ // directory that appears later (a new package, a new demos/) is open like
252
+ // everything else rather than hidden by a memory that predates it.
253
+ collapsed: new Set(
254
+ Array.isArray(stored.collapsed) ? stored.collapsed : [],
255
+ ),
256
+ };
257
+ } catch {
258
+ return { kinds: { ...DEFAULT_KIND_STATE }, collapsed: new Set() };
259
+ }
260
+ };
261
+ const writeStoredState = (state) => {
262
+ try {
263
+ window.localStorage.setItem(
264
+ STORAGE_KEY,
265
+ JSON.stringify({ kinds: state.kinds, collapsed: [...state.collapsed] }),
266
+ );
267
+ } catch {
268
+ // private mode, quota — the switcher works, it just forgets
269
+ }
270
+ };
271
+
272
+ /*
273
+ * The tree, built from the paths and folded where folding loses nothing: a
274
+ * directory holding a single directory and no file of its own is merged with
275
+ * it, so one line reads "docs/users/b_dev/" instead of three. What is left is
276
+ * the shape of the choices there are to make.
277
+ */
278
+ const buildTree = (pages) => {
279
+ const root = { segments: [], path: "", directories: new Map(), files: [] };
280
+ // The directories that ARE a package (the server found the package.json, see
281
+ // html_pages.js): in a monorepo that is what one is looking for, so it is
282
+ // written plainly while the rest of the road stays grey.
283
+ const packagePathSet = new Set(
284
+ pages.map((page) => page.packageUrl).filter(Boolean),
285
+ );
286
+ for (const page of pages) {
287
+ const segments = page.url.split("/").filter(Boolean);
288
+ const fileName = segments.pop();
289
+ let node = root;
290
+ for (const segment of segments) {
291
+ let child = node.directories.get(segment);
292
+ if (!child) {
293
+ const path = `${node.path}/${segment}`;
294
+ child = {
295
+ // Segment by segment rather than one string: folding glues several
296
+ // directories into one row, and only the one that IS a package is
297
+ // written plainly (see renderRow).
298
+ segments: [{ name: segment, isPackage: packagePathSet.has(path) }],
299
+ path,
300
+ directories: new Map(),
301
+ files: [],
302
+ };
303
+ node.directories.set(segment, child);
304
+ }
305
+ node = child;
306
+ }
307
+ node.files.push({ ...page, name: fileName });
308
+ }
309
+
310
+ const fold = (node) => {
311
+ for (const child of node.directories.values()) {
312
+ fold(child);
313
+ }
314
+ // Nothing of its own and a single way down: the two are one step.
315
+ while (node.files.length === 0 && node.directories.size === 1) {
316
+ const [only] = node.directories.values();
317
+ node.segments = [...node.segments, ...only.segments];
318
+ node.path = only.path;
319
+ node.directories = only.directories;
320
+ node.files = only.files;
321
+ }
322
+ node.files.sort((a, b) => a.name.localeCompare(b.name));
323
+ return node;
324
+ };
325
+ return fold(root);
326
+ };
327
+
328
+ // The whole of a folded row, for sorting and nothing else.
329
+ const nodeName = (node) =>
330
+ node.segments.map((segment) => segment.name).join("/");
331
+
332
+ // What is left of a node once the filters have had their say — used both to
333
+ // decide whether to draw it and to say how much is under it.
334
+ const countMatches = (node, matches) => {
335
+ let count = 0;
336
+ for (const file of node.files) {
337
+ if (matches(file)) {
338
+ count++;
339
+ }
340
+ }
341
+ for (const child of node.directories.values()) {
342
+ count += countMatches(child, matches);
343
+ }
344
+ return count;
345
+ };
346
+
347
+ // The road down to a file, as node paths — what has to be open for that file
348
+ // to be a row at all.
349
+ const pathTo = (node, matches, trail = []) => {
350
+ if (node.files.some(matches)) {
351
+ return trail;
352
+ }
353
+ for (const child of node.directories.values()) {
354
+ const found = pathTo(child, matches, [...trail, child.path]);
355
+ if (found) {
356
+ return found;
357
+ }
358
+ }
359
+ return null;
360
+ };
361
+
362
+ const rememberOpen = (isOpen) => {
363
+ try {
364
+ if (isOpen) {
365
+ window.sessionStorage.setItem(OPEN_KEY, "1");
366
+ } else {
367
+ window.sessionStorage.removeItem(OPEN_KEY);
368
+ }
369
+ } catch {
370
+ // private mode, quota — it just will not come back
371
+ }
372
+ };
373
+ const wasOpen = () => {
374
+ try {
375
+ return window.sessionStorage.getItem(OPEN_KEY) === "1";
376
+ } catch {
377
+ return false;
378
+ }
379
+ };
380
+
381
+ let open = null;
382
+
383
+ const openSwitcher = async () => {
384
+ if (open) {
385
+ open.input.select();
386
+ return;
387
+ }
388
+ const state = readStoredState();
389
+ const host = document.createElement("div");
390
+ const shadow = host.attachShadow({ mode: "open" });
391
+ const style = document.createElement("style");
392
+ style.textContent = STYLE_TEXT;
393
+ const backdrop = document.createElement("div");
394
+ backdrop.className = "backdrop";
395
+ const panel = document.createElement("div");
396
+ panel.className = "panel";
397
+ const input = document.createElement("input");
398
+ input.type = "search";
399
+ input.placeholder = "Go to page…";
400
+ input.setAttribute("aria-label", "Go to page");
401
+ const kindsRow = document.createElement("div");
402
+ kindsRow.className = "kinds";
403
+ const list = document.createElement("ul");
404
+ panel.append(input, kindsRow, list);
405
+ shadow.append(style, backdrop, panel);
406
+ document.body.append(host);
407
+
408
+ const focusedBefore = document.activeElement;
409
+ let tree = null;
410
+ // The rows as drawn, in order: what ↑/↓ walks and what Enter acts on.
411
+ let rows = [];
412
+ let currentIndex = 0;
413
+ const here = currentPageUrl();
414
+ const isHere = (file) => file.url === here;
415
+ // Folded away, but holding the page one is on: opened for this session only
416
+ // and never written down — what the reader folded is still what they folded
417
+ // the next time they come here from somewhere else.
418
+ const revealed = new Set();
419
+
420
+ const close = () => {
421
+ open = null;
422
+ rememberOpen(false);
423
+ host.remove();
424
+ document.removeEventListener("keydown", onKeyDown, true);
425
+ if (focusedBefore && focusedBefore.isConnected) {
426
+ focusedBefore.focus();
427
+ }
428
+ };
429
+ const searchNeedle = () => input.value.trim().toLowerCase();
430
+ const matchesText = (file) => {
431
+ const needle = searchNeedle();
432
+ return needle ? file.url.toLowerCase().includes(needle) : true;
433
+ };
434
+ const matchesFilters = (file) =>
435
+ state.kinds[file.kind] && matchesText(file);
436
+ const toggleCollapsed = (path) => {
437
+ if (state.collapsed.has(path)) {
438
+ state.collapsed.delete(path);
439
+ } else {
440
+ state.collapsed.add(path);
441
+ }
442
+ writeStoredState(state);
443
+ render();
444
+ };
445
+ const activate = (row) => {
446
+ if (!row) {
447
+ return;
448
+ }
449
+ if (row.type === "file") {
450
+ // Going somewhere is done with it: what reopens across a reload is a
451
+ // switcher one was still using.
452
+ rememberOpen(false);
453
+ window.location.href = row.file.url;
454
+ return;
455
+ }
456
+ toggleCollapsed(row.node.path);
457
+ };
458
+
459
+ const renderKinds = () => {
460
+ kindsRow.textContent = "";
461
+ for (const kind of KINDS) {
462
+ const button = document.createElement("button");
463
+ button.type = "button";
464
+ button.className = "kind_toggle";
465
+ if (state.kinds[kind.id]) {
466
+ button.setAttribute("data-on", "");
467
+ }
468
+ const label = document.createElement("span");
469
+ label.textContent = `${kind.icon} ${kind.label}`;
470
+ const count = document.createElement("span");
471
+ count.className = "count";
472
+ // How many there would be, not how many there are: a count that answered
473
+ // to its own toggle would read 0 for everything switched off.
474
+ count.textContent = tree
475
+ ? countMatches(
476
+ tree,
477
+ (file) => file.kind === kind.id && matchesText(file),
478
+ )
479
+ : 0;
480
+ button.append(label, count);
481
+ button.addEventListener("mousedown", (event) => {
482
+ event.preventDefault();
483
+ state.kinds[kind.id] = !state.kinds[kind.id];
484
+ writeStoredState(state);
485
+ render();
486
+ });
487
+ kindsRow.append(button);
488
+ }
489
+ };
490
+
491
+ const buildRows = () => {
492
+ const nextRows = [];
493
+ // While something is typed the tree opens itself: what one is looking at is
494
+ // the matches, not the folders that happen to hold them.
495
+ const searching = searchNeedle() !== "";
496
+ const walk = (node, depth) => {
497
+ const directories = [...node.directories.values()].sort((a, b) =>
498
+ nodeName(a).localeCompare(nodeName(b)),
499
+ );
500
+ for (const child of directories) {
501
+ const count = countMatches(child, matchesFilters);
502
+ if (count === 0) {
503
+ continue;
504
+ }
505
+ const collapsed =
506
+ !searching &&
507
+ state.collapsed.has(child.path) &&
508
+ !revealed.has(child.path);
509
+ nextRows.push({
510
+ type: "directory",
511
+ node: child,
512
+ depth,
513
+ count,
514
+ collapsed,
515
+ });
516
+ if (!collapsed) {
517
+ walk(child, depth + 1);
518
+ }
519
+ }
520
+ for (const file of node.files) {
521
+ if (matchesFilters(file)) {
522
+ nextRows.push({ type: "file", file, depth });
523
+ }
524
+ }
525
+ };
526
+ walk(tree, 0);
527
+ return nextRows;
528
+ };
529
+
530
+ const renderRow = (row, index) => {
531
+ const item = document.createElement("li");
532
+ item.style.paddingLeft = `${8 + row.depth * 14}px`;
533
+ if (index === currentIndex) {
534
+ item.setAttribute("data-current", "");
535
+ }
536
+ if (row.type === "directory") {
537
+ const twisty = document.createElement("span");
538
+ twisty.className = "twisty";
539
+ twisty.textContent = row.collapsed ? "▸" : "▾";
540
+ // One box for the whole path, so the row's own gap does not fall between
541
+ // two halves of the same name.
542
+ const path = document.createElement("span");
543
+ for (const segment of row.node.segments) {
544
+ const name = document.createElement("span");
545
+ name.className = segment.isPackage ? "strong" : "dim";
546
+ name.textContent = `${segment.name}/`;
547
+ path.append(name);
548
+ }
549
+ item.append(twisty, path);
550
+ const count = document.createElement("span");
551
+ count.className = "count";
552
+ count.textContent = row.count;
553
+ item.append(count);
554
+ } else {
555
+ const link = document.createElement("a");
556
+ link.href = row.file.url;
557
+ const icon = document.createElement("span");
558
+ icon.className = "kind_icon";
559
+ icon.textContent =
560
+ KINDS.find((kind) => kind.id === row.file.kind)?.icon || "📄";
561
+ icon.title = row.file.kind;
562
+ const name = document.createElement("span");
563
+ name.className = "strong";
564
+ name.textContent = row.file.name;
565
+ link.append(icon, name);
566
+ if (isHere(row.file)) {
567
+ item.setAttribute("data-here", "");
568
+ const hereLabel = document.createElement("span");
569
+ hereLabel.className = "here";
570
+ hereLabel.textContent = "here";
571
+ link.append(hereLabel);
572
+ }
573
+ item.append(link);
574
+ link.addEventListener("click", (event) => {
575
+ currentIndex = index;
576
+ // Anything but a plain left click is the browser's business — a new
577
+ // tab, a new window, a download: the switcher stays exactly as it is,
578
+ // still open, for the next one.
579
+ if (
580
+ event.button !== 0 ||
581
+ event.metaKey ||
582
+ event.ctrlKey ||
583
+ event.shiftKey ||
584
+ event.altKey
585
+ ) {
586
+ return;
587
+ }
588
+ // Going somewhere is done with it (see activate) — and the link does
589
+ // the going, so nothing here has to touch location.
590
+ rememberOpen(false);
591
+ });
592
+ return item;
593
+ }
594
+ item.addEventListener("mousedown", (event) => {
595
+ event.preventDefault();
596
+ currentIndex = index;
597
+ activate(row);
598
+ });
599
+ return item;
600
+ };
601
+
602
+ const render = () => {
603
+ renderKinds();
604
+ rows = buildRows();
605
+ if (currentIndex >= rows.length) {
606
+ currentIndex = 0;
607
+ }
608
+ list.textContent = "";
609
+ if (rows.length === 0) {
610
+ const empty = document.createElement("li");
611
+ empty.className = "empty";
612
+ empty.textContent = "Nothing matches.";
613
+ list.append(empty);
614
+ return;
615
+ }
616
+ for (const [index, row] of rows.entries()) {
617
+ list.append(renderRow(row, index));
618
+ }
619
+ };
620
+ const moveCurrent = (delta) => {
621
+ if (rows.length === 0) {
622
+ return;
623
+ }
624
+ currentIndex = (currentIndex + delta + rows.length) % rows.length;
625
+ render();
626
+ list.children[currentIndex]?.scrollIntoView({ block: "nearest" });
627
+ };
628
+
629
+ // In the capture phase, and on the document: while this is open it IS the
630
+ // page as far as the keyboard goes, and whatever the page below listens for
631
+ // must not answer at the same time.
632
+ function onKeyDown(event) {
633
+ const stop = () => {
634
+ event.preventDefault();
635
+ event.stopPropagation();
636
+ };
637
+ if (event.key === "Escape") {
638
+ stop();
639
+ close();
640
+ return;
641
+ }
642
+ if (event.key === "Enter") {
643
+ stop();
644
+ activate(rows[currentIndex]);
645
+ return;
646
+ }
647
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
648
+ stop();
649
+ moveCurrent(event.key === "ArrowDown" ? 1 : -1);
650
+ return;
651
+ }
652
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
653
+ const row = rows[currentIndex];
654
+ // A file has nothing to fold, and a folder already the right way round
655
+ // has nothing to do: the caret keeps the key in both cases.
656
+ if (!row || row.type !== "directory") {
657
+ return;
658
+ }
659
+ const wantCollapsed = event.key === "ArrowLeft";
660
+ if (row.collapsed === wantCollapsed) {
661
+ return;
662
+ }
663
+ stop();
664
+ toggleCollapsed(row.node.path);
665
+ return;
666
+ }
667
+ if (isSwitcherKey(event)) {
668
+ stop();
669
+ close();
670
+ }
671
+ }
672
+ document.addEventListener("keydown", onKeyDown, true);
673
+ backdrop.addEventListener("mousedown", close);
674
+
675
+ open = { input, close };
676
+ rememberOpen(true);
677
+ input.focus();
678
+
679
+ const pages = await loadPages();
680
+ if (!open) {
681
+ return; // closed while the list was still coming
682
+ }
683
+ tree = buildTree(pages);
684
+ // Opened on the page one is on: it is selected and scrolled to, so the list
685
+ // starts from where the reader already is rather than from the top of a
686
+ // tree they then have to find themselves in.
687
+ for (const path of pathTo(tree, isHere) || []) {
688
+ revealed.add(path);
689
+ }
690
+ render();
691
+ const hereIndex = rows.findIndex(
692
+ (row) => row.type === "file" && isHere(row.file),
693
+ );
694
+ if (hereIndex !== -1) {
695
+ currentIndex = hereIndex;
696
+ render();
697
+ list.children[currentIndex]?.scrollIntoView({ block: "center" });
698
+ }
699
+ input.addEventListener("input", () => {
700
+ currentIndex = 0;
701
+ render();
702
+ });
703
+ };
704
+
705
+ // Last in line, on purpose: on window (the end of the bubble), and registered
706
+ // once the page has loaded — so every listener the page set up while parsing is
707
+ // already in place and has already had this press. If any of them called
708
+ // preventDefault, the key was theirs and nothing happens here.
709
+ const listenSwitcherKey = () => {
710
+ window.addEventListener("keydown", (event) => {
711
+ if (event.defaultPrevented || !isSwitcherKey(event)) {
712
+ return;
713
+ }
714
+ // Ours now: the browser has its own use for cmd+K (the address bar), which
715
+ // it must not get.
716
+ event.preventDefault();
717
+ openSwitcher();
718
+ });
719
+ };
720
+ const setup = () => {
721
+ listenSwitcherKey();
722
+ if (wasOpen()) {
723
+ openSwitcher();
724
+ }
725
+ };
726
+ if (document.readyState === "complete") {
727
+ setup();
728
+ } else {
729
+ window.addEventListener("load", setup, { once: true });
730
+ }
731
+ })();