shell.js (25564B)
1 // Interactive prompt at the bottom of the about page. Starts from the same 2 // virtual fs as the SSH shell (internal/tui/shell.go, served at /api/fs) and 3 // layers the visitor's own changes on top in localStorage (shell-fs.js). 4 // 5 // It tries to feel like a real terminal over ssh: a little round-trip latency 6 // per command, output that streams in instead of appearing at once, typeahead 7 // that queues while a command runs, ^C to interrupt, double-tab listings. 8 9 import { isEditableTarget } from "./dom.js"; 10 import { COMMAND_NAMES, SPECIAL, commands, longDate, writeFile } from "./shell-cmds.js"; 11 import { STORE_KEY, VFS, displayPath, lookup, resolvePath, sortedChildren } from "./shell-fs.js"; 12 import { expandWord, parse, resolveWord, tokenize } from "./shell-lang.js"; 13 14 const MAX_LINES = 2000; 15 const MAX_HISTORY = 500; 16 const HIST_KEY = "prime8.history.v1"; 17 const LOGIN_KEY = "prime8.lastlogin.v1"; 18 const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches; 19 20 let fsPromise = null; 21 const shellHandlers = { keydown: null, storage: null, click: null, winFocus: null, revealed: null }; 22 23 function loadFS() { 24 if (!fsPromise) { 25 fsPromise = fetch("/api/fs", { credentials: "same-origin" }) 26 .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status))))) 27 .then((data) => new VFS(data)) 28 .catch((err) => { 29 fsPromise = null; 30 throw err; 31 }); 32 } 33 return fsPromise; 34 } 35 36 function store(key, value) { 37 try { 38 window.localStorage.setItem(key, value); 39 } catch { 40 /* private mode, full, ... */ 41 } 42 } 43 44 function load(key) { 45 try { 46 return window.localStorage.getItem(key); 47 } catch { 48 return null; 49 } 50 } 51 52 function sleep(ms) { 53 return new Promise((r) => setTimeout(r, ms)); 54 } 55 56 function jitter(min, max) { 57 return min + Math.random() * (max - min); 58 } 59 60 function nextFrame() { 61 return new Promise((r) => requestAnimationFrame(() => r())); 62 } 63 64 function commonPrefix(words) { 65 if (words.length === 0) return ""; 66 const sorted = [...words].sort(); 67 const first = sorted[0]; 68 const last = sorted[sorted.length - 1]; 69 let i = 0; 70 while (i < first.length && i < last.length && first[i] === last[i]) i++; 71 return first.slice(0, i); 72 } 73 74 function span(cls, text) { 75 const el = document.createElement("span"); 76 el.className = cls; 77 el.textContent = text; 78 return el; 79 } 80 81 function promptNodes(user, path, input) { 82 return [ 83 span("shell-user", user), 84 document.createTextNode("@"), 85 span("shell-host", "prime8"), 86 document.createTextNode(":"), 87 span("shell-path", path), 88 span("shell-dollar", "$"), 89 document.createTextNode(" "), 90 span("shell-cmd", input), 91 ]; 92 } 93 94 export function initShell() { 95 const section = document.getElementById("shell"); 96 if (!section || section.dataset.bound === "true") return; 97 section.dataset.bound = "true"; 98 99 const layout = section.closest(".about-layout"); 100 const historyEl = section.querySelector(".shell-history"); 101 const live = section.querySelector(".shell-live"); 102 const input = section.querySelector(".shell-input"); 103 const pathEl = live?.querySelector(".shell-path"); 104 const beforeEl = live?.querySelector("[data-before]"); 105 const afterEl = live?.querySelector("[data-after]"); 106 const cursorEl = live?.querySelector(".shell-cursor"); 107 if (!historyEl || !live || !input || !pathEl || !beforeEl || !afterEl || !cursorEl) return; 108 109 const initialUser = section.dataset.user || "visitor"; 110 let saved = []; 111 try { 112 saved = JSON.parse(load(HIST_KEY) || "[]"); 113 } catch { 114 saved = []; 115 } 116 117 // --- shell state --- 118 119 const sh = { 120 fs: null, 121 user: initialUser, 122 home: "/home/" + initialUser, 123 cwd: "/home/" + initialUser, 124 oldpwd: "", 125 status: 0, 126 bootTime: Date.now(), 127 history: Array.isArray(saved) ? saved.filter((l) => typeof l === "string").slice(-MAX_HISTORY) : [], 128 vars: {}, 129 get env() { 130 return { 131 HOME: this.home, 132 USER: this.user, 133 LOGNAME: this.user, 134 HOSTNAME: "prime8", 135 SHELL: "/bin/bash", 136 TERM: "xterm-256color", 137 LANG: "en_US.UTF-8", 138 PATH: "/usr/local/bin:/usr/bin:/bin", 139 PWD: this.cwd, 140 ...(this.oldpwd ? { OLDPWD: this.oldpwd } : {}), 141 ...this.vars, 142 "?": this.status, 143 }; 144 }, 145 resolve(p) { 146 return resolvePath(this.home, this.cwd, p); 147 }, 148 chdir(abs) { 149 this.oldpwd = this.cwd; 150 this.cwd = abs; 151 }, 152 setVar(k, v) { 153 this.vars[k] = v; 154 }, 155 unsetVar(k) { 156 delete this.vars[k]; 157 }, 158 clearHistory() { 159 this.history.length = 0; 160 store(HIST_KEY, "[]"); 161 }, 162 }; 163 164 let recallIndex = sh.history.length; 165 let draft = ""; 166 let busy = false; 167 let job = null; 168 const typeahead = []; 169 let lastTab = ""; 170 171 function scrollToPrompt() { 172 live.scrollIntoView({ block: "nearest" }); 173 } 174 175 let lastValue = null; 176 let lastCaret = -1; 177 function render() { 178 pathEl.textContent = displayPath(sh.home, sh.cwd); 179 const value = input.value; 180 const caret = input.selectionStart ?? value.length; 181 lastValue = value; 182 lastCaret = caret; 183 beforeEl.textContent = value.slice(0, caret); 184 cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0"; 185 afterEl.textContent = value.slice(caret + 1); 186 holdCursor(); 187 } 188 189 // Real terminals restart the blink on every keystroke, so the cursor stays 190 // solid while you type and only starts blinking again once you pause. 191 let blinkTimer = 0; 192 function holdCursor() { 193 cursorEl.classList.add("solid"); 194 clearTimeout(blinkTimer); 195 blinkTimer = setTimeout(() => cursorEl.classList.remove("solid"), 530); 196 } 197 198 function setInput(value, caret = value.length) { 199 input.value = value; 200 input.setSelectionRange(caret, caret); 201 render(); 202 } 203 204 function trim() { 205 while (historyEl.childElementCount > MAX_LINES) historyEl.firstElementChild?.remove(); 206 } 207 208 function appendLine(nodes, cls) { 209 const line = document.createElement("div"); 210 line.className = cls; 211 if (typeof nodes === "string") line.textContent = nodes; 212 else line.append(...nodes); 213 historyEl.appendChild(line); 214 trim(); 215 return line; 216 } 217 218 function clearScreen() { 219 term.reset(); 220 historyEl.replaceChildren(); 221 layout?.classList.add("shell-cleared"); 222 } 223 224 // --- terminal output: queued lines drained a few per frame --- 225 226 const term = { 227 q: [], 228 open: null, 229 pumping: null, 230 write(text, cls = "") { 231 const parts = text.split("\n"); 232 parts.forEach((p, i) => { 233 const end = i < parts.length - 1; 234 if (!end && p === "") return; 235 this.q.push({ text: p, cls, end }); 236 }); 237 this.kick(); 238 }, 239 line(nodes, cls = "") { 240 this.q.push({ nodes, cls, end: true }); 241 this.kick(); 242 }, 243 kick() { 244 if (!this.pumping) this.pumping = this.pump(); 245 }, 246 emit(item) { 247 let el = this.open; 248 if (!el) { 249 el = appendLine("", "shell-out shell-stream" + (item.cls === "err" ? " shell-err" : "")); 250 } 251 if (item.nodes) el.append(...item.nodes); 252 else if (item.text) el.append(document.createTextNode(item.text)); 253 if (item.end) { 254 if (!el.textContent) el.textContent = " "; 255 this.open = null; 256 } else this.open = el; 257 }, 258 async pump() { 259 // Yield once so push() has stored this promise before we can clear it; 260 // a synchronous finish would leave a settled promise in `pumping` and 261 // drain() would spin on it forever. 262 await null; 263 while (this.q.length) { 264 const n = reducedMotion() ? this.q.length : Math.max(2, Math.ceil(this.q.length / 24)); 265 for (let i = 0; i < n && this.q.length; i++) this.emit(this.q.shift()); 266 scrollToPrompt(); 267 if (this.q.length) await nextFrame(); 268 } 269 this.pumping = null; 270 }, 271 async drain() { 272 while (this.pumping) await this.pumping; 273 }, 274 endLine() { 275 this.open = null; 276 }, 277 reset() { 278 this.q.length = 0; 279 this.open = null; 280 }, 281 }; 282 283 // --- running commands --- 284 285 function newJob() { 286 const j = { aborted: false, wakers: new Set(), exit: false }; 287 j.sleep = (ms) => 288 new Promise((resolve) => { 289 if (j.aborted) return resolve(false); 290 const wake = () => { 291 clearTimeout(t); 292 resolve(false); 293 }; 294 const t = setTimeout(() => { 295 j.wakers.delete(wake); 296 resolve(true); 297 }, ms); 298 j.wakers.add(wake); 299 }); 300 j.abort = () => { 301 j.aborted = true; 302 for (const w of j.wakers) w(); 303 j.wakers.clear(); 304 }; 305 return j; 306 } 307 308 function expand(words) { 309 const argv = []; 310 for (const raw of words) { 311 const w = resolveWord(raw, sh.env); 312 if (!w.quoted && w.v === "") continue; 313 argv.push(...expandWord(w, sh.fs.root, sh.home, sh.cwd)); 314 } 315 return argv; 316 } 317 318 function shErr(msg) { 319 term.write(`bash: ${msg}\n`, "err"); 320 } 321 322 async function runCommand(cmd, stdin, last) { 323 const argv = expand(cmd.words); 324 325 // NAME=value with no command sets a shell variable 326 const assign = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s; 327 if (argv.length && argv.every((a) => assign.test(a)) && cmd.redirs.length === 0) { 328 for (const a of argv) { 329 const m = assign.exec(a); 330 sh.setVar(m[1], m[2]); 331 } 332 return { status: 0, stdout: "" }; 333 } 334 while (argv.length > 1 && assign.test(argv[0])) argv.shift(); 335 336 let input = stdin; 337 let outFile = null; 338 let errFile = null; 339 let errToOut = false; 340 for (const r of cmd.redirs) { 341 if (r.op === "2>&1") { 342 errToOut = true; 343 continue; 344 } 345 const targets = expand([r.target]); 346 if (targets.length !== 1) { 347 shErr(`${resolveWord(r.target, sh.env).v}: ambiguous redirect`); 348 return { status: 1, stdout: "" }; 349 } 350 const t = targets[0]; 351 if (r.op === "<") { 352 const n = sh.fs.get(sh.resolve(t)); 353 if (!n || n.dir) { 354 shErr(`${t}: ${n ? "Is a directory" : "No such file or directory"}`); 355 return { status: 1, stdout: "" }; 356 } 357 input = n.content; 358 continue; 359 } 360 const append = r.op.endsWith(">>"); 361 // the shell creates (and truncates) the target before the command runs 362 const why = writeFile(sh, t, "", true); 363 if (why) { 364 shErr(`${t}: ${why}`); 365 return { status: 1, stdout: "" }; 366 } 367 if (!append) writeFile(sh, t, "", false); 368 if (r.op.startsWith("2")) errFile = { path: t, buf: "" }; 369 else outFile = { path: t, buf: "" }; 370 } 371 372 if (argv.length === 0) return { status: 0, stdout: "" }; 373 const name = argv[0]; 374 const toTerm = last && !outFile; 375 let piped = ""; 376 const out = (s) => { 377 if (outFile) outFile.buf += s; 378 else if (toTerm) term.write(s); 379 else piped += s; 380 }; 381 const err = (s) => { 382 if (errToOut) out(s); 383 else if (errFile) errFile.buf += s; 384 else term.write(s, "err"); 385 }; 386 387 let status; 388 if (SPECIAL.has(name)) { 389 if (name === "clear" || name === "reset") { 390 if (toTerm) { 391 await term.drain(); 392 clearScreen(); 393 } 394 } else job.exit = true; 395 status = 0; 396 } else if (commands[name]) { 397 const ctx = { 398 argv, 399 stdin: input, 400 tty: toTerm, 401 sh, 402 out, 403 err, 404 line: (nodes) => term.line(nodes), 405 span, 406 sleep: job.sleep, 407 }; 408 try { 409 status = await commands[name](ctx); 410 } catch (e) { 411 err(`${name}: ${e && e.message ? e.message : "internal error"}\n`); 412 status = 1; 413 } 414 } else if (name.includes("/")) { 415 const n = sh.fs.get(sh.resolve(name)); 416 err(`bash: ${name}: ${n ? (n.dir ? "Is a directory" : "Permission denied") : "No such file or directory"}\n`); 417 status = n ? 126 : 127; 418 } else { 419 err(`${name}: command not found\n`); 420 status = 127; 421 } 422 423 for (const f of [outFile, errFile]) { 424 if (!f || !f.buf) continue; 425 const why = writeFile(sh, f.path, f.buf, true); 426 if (why) shErr(`${f.path}: ${why}`); 427 } 428 return { status: status ?? 0, stdout: piped }; 429 } 430 431 async function runLine(line) { 432 const tk = tokenize(line, sh.env); 433 if (tk.error) { 434 shErr(tk.error); 435 sh.status = 2; 436 return; 437 } 438 const parsed = parse(tk.toks); 439 if (parsed.error) { 440 shErr(parsed.error); 441 sh.status = 2; 442 return; 443 } 444 for (const item of parsed.list) { 445 if (item.conn === "&&" && sh.status !== 0) continue; 446 if (item.conn === "||" && sh.status === 0) continue; 447 let stdin = null; 448 for (let i = 0; i < item.pipeline.length; i++) { 449 const r = await runCommand(item.pipeline[i], stdin, i === item.pipeline.length - 1); 450 stdin = r.stdout; 451 sh.status = r.status; 452 if (job.aborted || job.exit) break; 453 } 454 term.endLine(); 455 if (job.aborted) { 456 sh.status = 130; 457 return; 458 } 459 if (job.exit) return; 460 } 461 } 462 463 function remember(line) { 464 if (!line.trim() || line.startsWith(" ") || sh.history[sh.history.length - 1] === line) return; 465 sh.history.push(line); 466 if (sh.history.length > MAX_HISTORY) sh.history.shift(); 467 store(HIST_KEY, JSON.stringify(sh.history)); 468 } 469 470 async function execute(line) { 471 remember(line); 472 recallIndex = sh.history.length; 473 draft = ""; 474 const first = line.trim().split(/\s+/)[0] || ""; 475 // like the TUI, clear wipes the screen without echoing itself 476 if (first !== "clear" && first !== "reset") appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), line), "shell-prompt"); 477 if (!line.trim()) return; 478 479 job = newJob(); 480 // network round trip; the first command also waits on the real fetch 481 const rtt = job.sleep(jitter(18, 55)); 482 try { 483 sh.fs = await loadFS(); 484 } catch { 485 await rtt; 486 term.write("prime8: filesystem unavailable, try again in a bit\n", "err"); 487 return; 488 } 489 if (sh.fs.user && sh.fs.user !== sh.user) { 490 sh.user = sh.fs.user; 491 sh.home = sh.fs.home; 492 sh.cwd = sh.home; 493 } 494 await rtt; 495 if (!job.aborted) await runLine(line); 496 await term.drain(); 497 term.endLine(); 498 if (!sh.fs.commit()) term.write("bash: write error: No space left on device\n", "err"); 499 await term.drain(); 500 if (job.exit) { 501 job = null; 502 disconnect(); 503 } 504 } 505 506 async function submit(line) { 507 if (busy) { 508 typeahead.push(line); 509 return; 510 } 511 busy = true; 512 live.classList.add("busy"); 513 try { 514 typeahead.unshift(line); 515 while (typeahead.length && !live.hidden) await execute(typeahead.shift()); 516 } finally { 517 typeahead.length = 0; 518 job = null; 519 busy = false; 520 live.classList.remove("busy"); 521 render(); 522 scrollToPrompt(); 523 } 524 } 525 526 function interrupt() { 527 if (!busy || !job) return false; 528 typeahead.length = 0; 529 job.abort(); 530 term.reset(); 531 appendLine("^C", "shell-out shell-stream"); 532 scrollToPrompt(); 533 return true; 534 } 535 536 // --- completion --- 537 538 async function complete() { 539 const value = input.value.slice(0, input.selectionStart ?? input.value.length); 540 const rest = input.value.slice(value.length); 541 const idx = Math.max(value.lastIndexOf(" "), value.lastIndexOf("|"), value.lastIndexOf(";"), value.lastIndexOf(">"), value.lastIndexOf("<")); 542 const word = value.slice(idx + 1); 543 const before = value.slice(0, idx + 1); 544 const commandPos = !/\S/.test(before.replace(/.*[|;&]/, "")); 545 let names; 546 let dirPart = ""; 547 let suffix = (m) => m + " "; 548 if (commandPos && !word.includes("/")) { 549 names = COMMAND_NAMES.filter((c) => c.startsWith(word)); 550 } else { 551 let fs; 552 try { 553 fs = await loadFS(); 554 } catch { 555 return; 556 } 557 const slash = word.lastIndexOf("/"); 558 dirPart = slash >= 0 ? word.slice(0, slash + 1) : ""; 559 const base = slash >= 0 ? word.slice(slash + 1) : word; 560 const dir = lookup(fs.root, resolvePath(sh.home, sh.cwd, dirPart || ".")); 561 if (!dir || !dir.dir) return; 562 const kids = sortedChildren(dir).filter((c) => c.name.startsWith(base) && (base.startsWith(".") || !c.name.startsWith("."))); 563 names = kids.map((c) => c.name + (c.dir ? "/" : "")); 564 suffix = (m) => (m.endsWith("/") ? m : m + " "); 565 } 566 if (names.length === 0) return; 567 if (names.length === 1) { 568 const done = before + dirPart + suffix(names[0]); 569 setInput(done + rest, done.length); 570 lastTab = ""; 571 return; 572 } 573 const p = commonPrefix(names); 574 if (p.length > word.length - dirPart.length) { 575 const done = before + dirPart + p; 576 setInput(done + rest, done.length); 577 lastTab = ""; 578 return; 579 } 580 // second tab with nothing more to fill: list the candidates, like bash 581 if (lastTab === input.value) { 582 appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value), "shell-prompt"); 583 appendLine(names.join(" "), "shell-out"); 584 scrollToPrompt(); 585 lastTab = ""; 586 return; 587 } 588 lastTab = input.value; 589 } 590 591 // --- session --- 592 593 function disconnect() { 594 term.reset(); 595 historyEl.replaceChildren(); 596 layout?.classList.add("shell-cleared"); 597 appendLine("logout", "shell-out"); 598 appendLine("Connection to prime8.dev closed.", "shell-out shell-dim"); 599 const again = document.createElement("button"); 600 again.type = "button"; 601 again.className = "shell-reconnect"; 602 again.textContent = "[ reconnect ]"; 603 again.addEventListener("click", reconnect, { once: true }); 604 const row = document.createElement("div"); 605 row.className = "shell-out"; 606 row.appendChild(again); 607 historyEl.appendChild(row); 608 live.hidden = true; 609 input.blur(); 610 again.focus({ preventScroll: true }); 611 } 612 613 async function reconnect() { 614 historyEl.replaceChildren(); 615 // ssh handshake 616 await sleep(jitter(250, 700)); 617 const prev = load(LOGIN_KEY); 618 store(LOGIN_KEY, String(Date.now())); 619 if (prev && !Number.isNaN(Number(prev))) appendLine(`Last login: ${longDate(new Date(Number(prev)))} from web`, "shell-out shell-dim"); 620 sh.cwd = sh.home; 621 sh.oldpwd = ""; 622 sh.status = 0; 623 live.hidden = false; 624 setInput(""); 625 input.focus({ preventScroll: true }); 626 scrollToPrompt(); 627 } 628 629 function deleteWord() { 630 const caret = input.selectionStart ?? input.value.length; 631 const head = input.value.slice(0, caret).replace(/\S+\s*$/, ""); 632 const tail = input.value.slice(caret); 633 setInput(head + tail, head.length); 634 } 635 636 input.addEventListener("keydown", (e) => { 637 if (e.isComposing) return; 638 if (e.key !== "Tab") lastTab = ""; 639 const ctrl = e.ctrlKey && !e.metaKey && !e.altKey; 640 if (ctrl) { 641 switch (e.key.toLowerCase()) { 642 case "l": 643 e.preventDefault(); 644 if (!busy) clearScreen(); 645 return; 646 case "a": 647 e.preventDefault(); 648 setInput(input.value, 0); 649 return; 650 case "e": 651 e.preventDefault(); 652 setInput(input.value); 653 return; 654 case "u": { 655 e.preventDefault(); 656 const caret = input.selectionStart ?? 0; 657 setInput(input.value.slice(caret), 0); 658 return; 659 } 660 case "k": { 661 e.preventDefault(); 662 const caret = input.selectionStart ?? input.value.length; 663 setInput(input.value.slice(0, caret), caret); 664 return; 665 } 666 case "w": 667 e.preventDefault(); 668 deleteWord(); 669 return; 670 case "c": 671 if (input.selectionStart !== input.selectionEnd) return; // let copy work 672 e.preventDefault(); 673 if (interrupt()) { 674 setInput(""); 675 return; 676 } 677 appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value + "^C"), "shell-prompt"); 678 setInput(""); 679 recallIndex = sh.history.length; 680 scrollToPrompt(); 681 return; 682 case "d": 683 e.preventDefault(); 684 if (input.value === "" && !busy) disconnect(); 685 return; 686 default: 687 return; 688 } 689 } 690 691 // A focused prompt types digits like any terminal. Tab on an empty prompt 692 // has nothing to complete, so it gives focus back and cycles tabs. 693 if (input.value === "" && !busy && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === "Tab") { 694 e.preventDefault(); 695 e.stopPropagation(); 696 input.blur(); 697 document.dispatchEvent(new KeyboardEvent("keydown", { key: e.key, shiftKey: e.shiftKey, bubbles: true, cancelable: true })); 698 return; 699 } 700 701 switch (e.key) { 702 case "Enter": { 703 e.preventDefault(); 704 const line = input.value; 705 setInput(""); 706 submit(line); 707 break; 708 } 709 case "Tab": 710 // keep the tab-cycling shortcut out of the prompt 711 e.preventDefault(); 712 e.stopPropagation(); 713 if (!busy) complete(); 714 break; 715 case "ArrowUp": 716 e.preventDefault(); 717 e.stopPropagation(); 718 if (busy) break; 719 if (recallIndex === sh.history.length) draft = input.value; 720 if (recallIndex > 0) setInput(sh.history[--recallIndex]); 721 break; 722 case "ArrowDown": 723 e.preventDefault(); 724 e.stopPropagation(); 725 if (busy) break; 726 if (recallIndex < sh.history.length) { 727 recallIndex++; 728 setInput(recallIndex === sh.history.length ? draft : sh.history[recallIndex]); 729 } 730 break; 731 case "Escape": 732 if (input.value) setInput(""); 733 else input.blur(); 734 break; 735 default: 736 break; 737 } 738 }); 739 740 input.addEventListener("input", render); 741 input.addEventListener("select", render); 742 // Only a real caret move re-renders: render() rewrites the cursor spans, 743 // which itself fires selectionchange, and re-rendering on that kept the 744 // cursor pinned solid forever (it never got to blink). 745 document.addEventListener("selectionchange", () => { 746 if (document.activeElement !== input) return; 747 if (input.value === lastValue && input.selectionStart === lastCaret) return; 748 render(); 749 }); 750 input.addEventListener("focus", () => { 751 section.classList.add("focused"); 752 render(); 753 loadFS().catch(() => {}); 754 }); 755 input.addEventListener("blur", () => section.classList.remove("focused")); 756 757 // another tab changed the files: pick them up 758 if (shellHandlers.storage) window.removeEventListener("storage", shellHandlers.storage); 759 shellHandlers.storage = (e) => { 760 if (e.key !== STORE_KEY || !sh.fs || busy) return; 761 sh.fs.load(); 762 }; 763 window.addEventListener("storage", shellHandlers.storage); 764 765 // Typing with nothing focused goes to the prompt, but the tab/help 766 // shortcuts (digits, ?, space, arrows, tab) keep working, like the TUI. 767 if (shellHandlers.keydown) document.removeEventListener("keydown", shellHandlers.keydown); 768 shellHandlers.keydown = (e) => { 769 if (!section.isConnected) { 770 document.removeEventListener("keydown", shellHandlers.keydown); 771 shellHandlers.keydown = null; 772 return; 773 } 774 if (live.hidden || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return; 775 if (isEditableTarget(e.target) || window.isLightboxOpen?.()) return; 776 const active = document.activeElement; 777 if (active && active !== document.body && active !== document.documentElement) return; 778 if (document.getElementById("help-overlay")?.classList.contains("visible")) return; 779 if (e.key.length !== 1 || /[0-9? ]/.test(e.key)) return; 780 e.preventDefault(); 781 input.focus({ preventScroll: true }); 782 setInput(input.value + e.key); 783 scrollToPrompt(); 784 }; 785 document.addEventListener("keydown", shellHandlers.keydown); 786 787 if (!load(LOGIN_KEY)) store(LOGIN_KEY, String(Date.now())); 788 render(); 789 790 // The prompt takes focus only when asked: typing a letter (above) or a 791 // click anywhere below the top bar. It is never grabbed on load, on a tab switch 792 // or on window focus, so the page shortcuts keep the keyboard by default. 793 // Touch screens are skipped, since focus pops the on-screen keyboard. 794 const finePointer = window.matchMedia("(pointer: fine)").matches; 795 const focusPrompt = () => { 796 if (!section.isConnected || live.hidden || !finePointer) return; 797 const active = document.activeElement; 798 // a clicked tab keeps focus after the swap; that still counts as idle 799 const idle = !active || active === document.body || active === document.documentElement || 800 !!active.closest?.("#tabs"); 801 if (idle && document.visibilityState === "visible") input.focus({ preventScroll: true }); 802 }; 803 804 for (const [ev, key, target] of [["click", "click", document], ["focus", "winFocus", window], ["reveal:done", "revealed", document]]) { 805 if (shellHandlers[key]) target.removeEventListener(ev, shellHandlers[key]); 806 } 807 shellHandlers.click = (e) => { 808 if (!section.isConnected || e.button !== 0) return; 809 if (e.target.closest?.("header, #tabs")) return; // the top bar keeps its own clicks 810 if (e.target.closest?.("a, button, input, textarea, select, label, summary, [tabindex], #help-overlay")) return; 811 if (String(window.getSelection?.() || "")) return; 812 if (window.isLightboxOpen?.()) return; 813 focusPrompt(); 814 }; 815 shellHandlers.winFocus = null; 816 shellHandlers.revealed = null; 817 document.addEventListener("click", shellHandlers.click); 818 }