mobux 0.18.1

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
import { useRef, useLayoutEffect } from "preact/hooks";
import { collectDiagnostics } from "../lib/diagnostics.js";
import { buildIssueUrl } from "../lib/githubIssue.js";

// ── Terminal island ──────────────────────────────────────────────────
//
// Hosts the mobux terminal engine (`/static/terminal.js`) as a real
// component. The engine exports `createTerminal({ node, session, host,
// renderer })` → `{ dispose() }`: config goes in as arguments (no window
// globals), and dispose() tears down everything the engine attached —
// WebSocket, renderer instance, window/document listeners, timers.
//
// The island's job:
//   1. Render the DOM scaffold the engine binds to (#terminal, #reader,
//      #loadquote, the #inputBar ribbon, the #cmdPickList overlay, …).
//   2. Load the renderer's vendor bundle (once per document), then create
//      the engine in an effect and dispose it on unmount. A route-param
//      change is a clean dispose + create — no document reload (#188), and
//      the new engine attaches to exactly the (node, session) in the URL
//      (the #185 regression class).
//
// `CACHE_BUST` mirrors the old Rust page's `?v=` query-param convention on
// <script>/<link> tags, but it's a fixed string — it does no cache-busting
// of its own. What actually guarantees a stale cache never hands back an
// old bundle is `serve_static` in src/main.rs serving every /static/* asset
// `no-store, must-revalidate`; the query param is vestigial and harmless.
const CACHE_BUST = "spa";

// Ribbon bug-report button (#191): grab the current diagnostics bundle and
// open a prefilled GitHub issue in a new tab. Reuses the same bundle/URL
// builder as the fail-hard error page (lib/diagnostics.js, lib/githubIssue.js).
//
// The tab MUST be opened synchronously inside the click gesture: awaiting
// the diagnostics fetches first loses the user activation, and popup
// blockers (Android Chrome — the primary target) silently kill the
// window.open. So open about:blank while the gesture is live, then steer it
// to the issue URL once the bundle resolves. `noopener` can't be passed as a
// window feature here (it makes window.open return null, losing the handle),
// so sever the opener by hand while the tab is still same-origin.
async function openBugReport() {
  const win = window.open("about:blank", "_blank");
  if (win) win.opener = null;
  const diagnostics = await collectDiagnostics();
  const url = buildIssueUrl({ title: "Bug report", diagnostics });
  if (win) {
    win.location = url;
    return;
  }
  // Popup blocked anyway — fall back to navigating this tab.
  window.location.href = url;
}

// Append a classic <script> and resolve when it loads. Deduped per document:
// the vendor bundles pin window globals (window.Terminal / window.Sterk) and
// chime.js self-boots, so each URL loads exactly once no matter how many
// times the island mounts.
const loadedScripts = new Map();
function loadScript(src) {
  if (!loadedScripts.has(src)) {
    loadedScripts.set(
      src,
      new Promise((resolve, reject) => {
        const el = document.createElement("script");
        el.src = src;
        el.async = false; // preserve execution order
        el.onload = () => resolve();
        el.onerror = () => {
          loadedScripts.delete(src); // a later mount may retry
          reject(new Error(`failed to load ${src}`));
        };
        document.body.appendChild(el);
      }),
    );
  }
  return loadedScripts.get(src);
}

function ensureStylesheet(href) {
  if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return;
  const link = document.createElement("link");
  link.rel = "stylesheet";
  link.href = href;
  document.head.appendChild(link);
}

export function TerminalIsland({ node, session }) {
  const rootRef = useRef(null);
  const resizeObsRef = useRef(null);

  useLayoutEffect(() => {
    // The engine attaches to exactly this (node, session) — both come from
    // the route (`#/s/<node>/<name>`), never from the device's selected-node
    // preference, so a stale selection can't re-target a session URL (#185).
    // No node segment ⇒ local host.
    let cancelled = false;
    let engine = null;

    // Resolve the renderer choice, then load the matching vendor bundle +
    // css (once per document) before constructing the engine.
    let renderer = "xterm";
    try {
      const s = localStorage.getItem("mobux:renderer");
      if (s === "sterk" || s === "xterm") renderer = s;
    } catch (_) {}

    const v = `?v=${CACHE_BUST}`;
    const bundle = renderer === "sterk" ? "sterk.bundle.js" : "xterm.bundle.js";

    if (renderer === "xterm") {
      ensureStylesheet(`/static/vendor/xterm.css${v}`);
    }

    (async () => {
      let createTerminal;
      try {
        await loadScript(`/static/vendor/${bundle}${v}`);
        // The engine module is pure (a factory export, no side effects), so
        // the browser's module-map caching is exactly right: first mount
        // fetches it, every later mount reuses it and just calls the factory.
        ({ createTerminal } = await import(
          /* @vite-ignore */ `/static/terminal.js${v}`
        ));
        // chime.js sets up the in-page bell that plays when the SW delivers a
        // push notification. It self-boots via IIFE (attaches to SW messages),
        // so loading it once is enough; it also guards itself via the global
        // it exposes.
        if (!window.__mobuxChime) {
          await loadScript(`/static/chime.js${v}`);
        }
      } catch (e) {
        // Surface boot failure in the loading splash rather than a blank page.
        const q = rootRef.current?.querySelector("#quote");
        if (q) q.textContent = `Terminal failed to load: ${e.message}`;
        return;
      }
      if (cancelled || !rootRef.current) return;

      engine = createTerminal({
        node: node || "",
        session,
        host: rootRef.current,
        renderer,
      });

      // Force a resize once the SPA layout has actually painted. The engine
      // sizes the PTY (cols/rows) from the host element's clientHeight, and it
      // does its own resize at 0ms/100ms after boot — but in the SPA the
      // engine boots while Preact's island subtree is still settling its flex
      // height, so that early measurement can read a too-short host and the
      // backend computes far too few rows (the stranded-status-bar /
      // dead-black bug). The engine already listens on window `resize` →
      // core.resize(), so we reuse that machinery: re-fire a synthetic resize
      // after a double-rAF (one full painted frame later) and again after the
      // host's box settles, via a ResizeObserver, so the initial row count is
      // correct without a rotate/keyboard nudge.
      const kick = () => window.dispatchEvent(new Event("resize"));
      requestAnimationFrame(() => requestAnimationFrame(kick));

      const host = rootRef.current?.querySelector("#terminal");
      if (host && "ResizeObserver" in window) {
        let last = 0;
        const ro = new ResizeObserver(() => {
          const h = host.clientHeight;
          if (h && h !== last) {
            last = h;
            kick();
          }
        });
        ro.observe(host);
        resizeObsRef.current = ro;
      }
    })();

    return () => {
      cancelled = true;
      resizeObsRef.current?.disconnect();
      resizeObsRef.current = null;
      engine?.dispose();
      engine = null;
    };
    // A (node, session) change is a full dispose + create. TerminalPage also
    // keys the island on the pair, so in practice Preact remounts the whole
    // scaffold (fresh splash, fresh #terminal); these deps are the backstop.
  }, [node, session]);

  // The engine binds to these ids (scoped to this subtree via the `host`
  // factory argument); we render them once per mount and hand the subtree
  // to the engine.
  return (
    <div ref={rootRef} class="term-body-spa">
      <div id="terminal" />
      <div id="reader" class="hidden" />
      <div id="loadquote">
        <q id="quote" />
        <br />
        <cite id="qauthor" />
      </div>
      <div id="touchOverlay" />
      <div id="paneIndicator" />
      <div id="cmdOverlayBg" />
      <div id="cmdPickList">
        <div class="cmd-header">
          <h3>tmux</h3>
          <button class="cmd-close" id="cmdCloseBtn" aria-label="Close">
            Close
          </button>
        </div>
        <button class="cmd-item" data-cmd="new-window">
          New window
        </button>
        <button class="cmd-item" data-cmd="kill-window">
          Close window
        </button>
        <div class="cmd-separator" />
        <button class="cmd-item" data-cmd="split-h">
          Split horizontal
        </button>
        <button class="cmd-item" data-cmd="split-v">
          Split vertical
        </button>
        <button class="cmd-item" data-cmd="kill-pane">
          Close pane
        </button>
        <div class="cmd-separator" />
        <button class="cmd-item" data-cmd="next-window">
          Next window
        </button>
        <button class="cmd-item" data-cmd="prev-window">
          Previous window
        </button>
        <button class="cmd-item" data-cmd="next-pane">
          Next pane
        </button>
        <button class="cmd-item" data-cmd="prev-pane">
          Previous pane
        </button>
        <div class="cmd-separator" />
        <button class="cmd-item" data-cmd="zoom-pane">
          Zoom pane
        </button>
      </div>

      <div id="inputBar" class="input-bar hidden">
        <div id="inputRibbon" class="input-ribbon">
          <button id="viewToggleBtn" title="Toggle reader/terminal view">
            📖
          </button>
          <button id="uploadBtn" title="Attach file">
            📎
          </button>
          <button id="micBtn" title="Dictate (speech to text)">
            🎤
          </button>
          <button id="settingsBtn" title="Settings">
            ⚙
          </button>
          {/* Single-action hard reload (#189) — no data-key, so it's ignored
              by the engine's ribbon delegation (input-bar.js only intercepts
              `button[data-key]`); the click is handled directly by Preact. */}
          <button
            id="reloadBtn"
            type="button"
            title="Reload app"
            onClick={() => location.reload()}
          >
            🔄
          </button>
          <button
            id="reportBugBtn"
            title="Report a bug"
            onMouseDown={(e) => e.preventDefault()}
            onClick={openBugReport}
          >
            🐛
          </button>
          <button data-key="\x7f">⌫</button>
          <button data-key="\r">⏎</button>
          <button data-key="\x1b[D">←</button>
          <button data-key="\x1b[C">→</button>
          <button data-key="\x1b[A">↑</button>
          <button data-key="\x1b[B">↓</button>
          <button data-key="\x03">^C</button>
          <button data-key="\x04">^D</button>
          <button data-key="\x1b">Esc</button>
          <button data-key="\t">Tab</button>
          <button data-key="\x1a">^Z</button>
          <button data-key="\x1b[3~">Del</button>
          <button data-key="\x1b[H">Home</button>
          <button data-key="\x1b[F">End</button>
          <button data-key="\x15">^U</button>
          <button data-key="\x0c">^L</button>
          <button data-key="/clear\r">/clear</button>
          <button data-key="/quit\r">/quit</button>
        </div>
        <div
          id="inputToast"
          class="input-toast hidden"
          role="status"
          aria-live="polite"
        />
        <div class="input-row">
          <input
            id="inputText"
            type="text"
            enterkeyhint="send"
            placeholder="Type here…"
            autocomplete="off"
            autocorrect="on"
            autocapitalize="off"
            spellcheck={false}
          />
          <button id="inputSend" class="input-send" title="Send without Enter">
            ▶
          </button>
        </div>
      </div>
    </div>
  );
}