@jsenv/terminal-recorder 1.5.56 → 1.5.57

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