diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbb3904f..fc82affa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -585,6 +585,12 @@ importers: '@swmansion/popcorn': specifier: workspace:* version: link:../../popcorn/js + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 devDependencies: esbuild: specifier: 0.28.0 @@ -2473,6 +2479,14 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -6711,6 +6725,12 @@ snapshots: '@webgpu/types@0.1.69': {} + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 diff --git a/popdoc/README.md b/popdoc/README.md index c329caed..1d7ee3ca 100644 --- a/popdoc/README.md +++ b/popdoc/README.md @@ -45,6 +45,17 @@ To make a block runnable, use the `elixir-popcorn` fence: ``` ```` +For interactive IEx examples, use `iex-popcorn`. Clicking an `iex>` prompt runs the command in an on-page xterm.js terminal (you can also type freely there). The terminal can also be opened on any page with the floating `iex` button in the bottom-right corner. Its `Clear` button wipes the screen only, while `Reset` restarts the whole Popcorn runtime: variables, modules, and eval-block state are shared in one VM, so they reset together: + +```` +```iex-popcorn +iex> x = 1 + 1 +2 +iex> x * 10 +20 +``` +```` + Then generate docs normally: ```bash diff --git a/popdoc/e2e/fixture/README.md b/popdoc/e2e/fixture/README.md index b6ad5719..a573fbf4 100644 --- a/popdoc/e2e/fixture/README.md +++ b/popdoc/e2e/fixture/README.md @@ -38,6 +38,31 @@ Example.hello() Example.divide_all(10, [2, 5, 0]) ``` +## IEx session + +Click an `iex>` prompt to run it in the on-page terminal, or open the terminal +any time with the floating `iex` button in the bottom-right corner. You can also +type freely there. `Clear` wipes the screen; `Reset` restarts the whole Popcorn +runtime (variables, modules, and eval-block state). + +```iex-popcorn +iex> x = 1 + 1 +2 +iex> x * 10 +20 +``` + +```iex-popcorn +iex> Enum.sort([3, 2, 1]) +[1, 2, 3] +iex> total = +...> [10, 20, 30] +...> |> Enum.sum() +60 +iex> total * 2 +120 +``` + Build the docs with: ```bash diff --git a/popdoc/e2e/popdoc.spec.ts b/popdoc/e2e/popdoc.spec.ts index 9ad3439e..bba8bdf5 100644 --- a/popdoc/e2e/popdoc.spec.ts +++ b/popdoc/e2e/popdoc.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page, Locator } from "@playwright/test"; +import { test, expect, devices, Page, Locator } from "@playwright/test"; const RUN_READY_TIMEOUT_MS = 60_000; const EVAL_TIMEOUT_MS = 60_000; @@ -21,6 +21,23 @@ async function runBlock(block: Locator) { await expect(run).toBeEnabled({ timeout: EVAL_TIMEOUT_MS }); } +// Prompts are decorated before the WASM runtime finishes booting, but click +// handlers exist only once popdoc marks them bound — interacting earlier is +// silently lost. +async function awaitPromptReady(prompt: Locator) { + await expect(prompt).toHaveAttribute("data-popdoc-iex-bound", "true", { + timeout: RUN_READY_TIMEOUT_MS, + }); +} + +async function runPrompt(prompt: Locator) { + await awaitPromptReady(prompt); + await prompt.click(); + await expect(prompt).toHaveAttribute("data-iex-state", "success", { + timeout: EVAL_TIMEOUT_MS, + }); +} + test("decorates elixir-popcorn blocks and enables Run once Popcorn is ready", async ({ page, }) => { @@ -104,3 +121,239 @@ test("renders error with a stacktrace toggle for nested failures", async ({ await expect(trace).toBeVisible(); await expect(trace).toContainText(/Example|safe_div|check_denominator/); }); + +test("decorates iex-popcorn prompts as clickable and opens the IEx terminal", async ({ + page, +}) => { + const prompt = page.locator(".popdoc-iex-prompt").first(); + await expect(prompt).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS }); + await expect(prompt).toHaveAttribute("title", "Run in IEx"); + + const terminal = page.locator(".popdoc-terminal"); + await expect(terminal).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS }); + await expect(terminal).not.toHaveClass(/popdoc-terminal--open/); + + await runPrompt(prompt); + await expect(terminal).toHaveClass(/popdoc-terminal--open/); +}); + +test("resets the IEx session, wiping the screen and prompt state", async ({ + page, +}) => { + const prompt = page.locator(".popdoc-iex-prompt").first(); + await runPrompt(prompt); + + const reset = page.locator(".popdoc-terminal-btn", { hasText: "Reset" }); + await reset.click(); + await expect(prompt).not.toHaveAttribute("data-iex-state"); + + // The restarted runtime prints a fresh prompt; the old output is gone. + const rows = page.locator(".popdoc-terminal .xterm-rows"); + await expect(rows).toContainText("iex(", { timeout: EVAL_TIMEOUT_MS }); + await expect(rows).not.toContainText("x = 1 + 1"); +}); + +test("clears only the terminal screen, keeping session and prompt state", async ({ + page, +}) => { + const prompt = page.locator(".popdoc-iex-prompt").first(); + await runPrompt(prompt); + + const clear = page.locator(".popdoc-terminal-btn", { hasText: "Clear" }); + await clear.click(); + + const rows = page.locator(".popdoc-terminal .xterm-rows"); + await expect(rows).not.toContainText("x = 1 + 1"); + // clear() keeps the cursor's line, so the live prompt survives. + await expect(rows).toContainText("iex("); + await expect(prompt).toHaveAttribute("data-iex-state", "success"); + await expect(page.locator(".popdoc-terminal")).toHaveClass( + /popdoc-terminal--open/, + ); +}); + +test("shows the iex launcher and opens the terminal from it", async ({ + page, +}) => { + const launcher = page.locator(".popdoc-iex-launcher"); + await expect(launcher).toBeVisible({ timeout: RUN_READY_TIMEOUT_MS }); + + const terminal = page.locator(".popdoc-terminal"); + await launcher.click(); + await expect(terminal).toHaveClass(/popdoc-terminal--open/); + await expect(launcher).toBeHidden(); + + await page.locator(".popdoc-terminal-btn", { hasText: "✕" }).click(); + await expect(terminal).not.toHaveClass(/popdoc-terminal--open/); + await expect(launcher).toBeVisible(); +}); + +test("launcher starts an IEx session on pages without popcorn blocks", async ({ + page, +}) => { + await page.goto("/Example.html"); + await page.waitForLoadState("networkidle"); + + const launcher = page.locator(".popdoc-iex-launcher"); + await expect(launcher).toBeVisible({ timeout: RUN_READY_TIMEOUT_MS }); + await launcher.click(); + + await expect(page.locator(".popdoc-terminal")).toHaveClass( + /popdoc-terminal--open/, + ); + await expect(page.locator(".popdoc-terminal .xterm-rows")).toContainText( + "iex(", + { timeout: EVAL_TIMEOUT_MS }, + ); +}); + +test("collapses and closes the IEx terminal", async ({ page }) => { + const prompt = page.locator(".popdoc-iex-prompt").first(); + await awaitPromptReady(prompt); + await prompt.click(); + + const terminal = page.locator(".popdoc-terminal"); + await expect(terminal).toHaveClass(/popdoc-terminal--open/); + + const collapse = page.locator(".popdoc-terminal-collapse"); + await collapse.click(); + await expect(terminal).toHaveClass(/popdoc-terminal--collapsed/); + + await collapse.click(); + await expect(terminal).not.toHaveClass(/popdoc-terminal--collapsed/); + + await page.locator(".popdoc-terminal-btn", { hasText: "✕" }).click(); + await expect(terminal).not.toHaveClass(/popdoc-terminal--open/); +}); + +test("reveals the next iex> prompt only after the previous one succeeds", async ({ + page, +}) => { + const block = page.locator("pre.popcorn-iex").first(); + const prompts = block.locator(".popdoc-iex-prompt"); + await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS }); + + const first = prompts.nth(0); + const second = prompts.nth(1); + + await expect(first).toHaveClass(/popdoc-iex-prompt--runnable/); + await expect(second).not.toHaveClass(/popdoc-iex-prompt--runnable/); + + await runPrompt(first); + await expect(first).not.toHaveClass(/popdoc-iex-prompt--runnable/); + await expect(second).toHaveClass(/popdoc-iex-prompt--runnable/); +}); + +test("scopes iex> execution to each code block independently", async ({ + page, +}) => { + const blocks = page.locator("pre.popcorn-iex"); + await expect(blocks).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS }); + + const secondBlockPrompt = blocks.nth(1).locator(".popdoc-iex-prompt").first(); + await expect(secondBlockPrompt).toHaveClass(/popdoc-iex-prompt--runnable/); + + await runPrompt(secondBlockPrompt); + + const firstBlockPrompts = blocks.nth(0).locator(".popdoc-iex-prompt"); + await expect(firstBlockPrompts.nth(0)).not.toHaveAttribute("data-iex-state"); + await expect(firstBlockPrompts.nth(1)).not.toHaveAttribute("data-iex-state"); +}); + +test("clicking a later iex> in the same block runs earlier prompts first", async ({ + page, +}) => { + const block = page.locator("pre.popcorn-iex").first(); + const prompts = block.locator(".popdoc-iex-prompt"); + await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS }); + + await runPrompt(prompts.nth(1)); + // The chain runs earlier prompts first, so by now the first one succeeded. + await expect(prompts.nth(0)).toHaveAttribute("data-iex-state", "success"); +}); + +test("marks continuation lines and runs the whole multi-line command", async ({ + page, +}) => { + const block = page.locator("pre.popcorn-iex").nth(1); + const conts = block.locator(".popdoc-iex-prompt--cont"); + await expect(conts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS }); + + // Bound state is marked on the main prompt; conts are wired in the same + // pass. + await awaitPromptReady( + block.locator(".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)").first(), + ); + await conts.first().click(); + await expect(conts.first()).toHaveAttribute("data-iex-state", "success", { + timeout: EVAL_TIMEOUT_MS, + }); + + const pills = block.locator( + ".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)", + ); + await expect(pills.nth(0)).toHaveAttribute("data-iex-state", "success"); + await expect(pills.nth(1)).toHaveAttribute("data-iex-state", "success"); + await expect(pills.nth(2)).toHaveClass(/popdoc-iex-prompt--runnable/); +}); + +test("shows the run affordance without hover", async ({ page }) => { + const runnable = page.locator(".popdoc-iex-prompt--runnable").first(); + await expect(runnable).toBeAttached({ timeout: RUN_READY_TIMEOUT_MS }); + + const icon = runnable.locator(".popdoc-iex-icon"); + const content = await icon.evaluate( + (el) => getComputedStyle(el, "::before").content, + ); + expect(content).toBe('"▶"'); +}); + +test("keeps decorations out of the copied text", async ({ page }) => { + const block = page.locator("pre.popcorn-iex").first(); + await expect(block.locator(".popdoc-iex-prompt").first()).toBeAttached({ + timeout: RUN_READY_TIMEOUT_MS, + }); + + const code = block.locator("code"); + const text = await code.evaluate((el) => + Array.from(el.children) + .map((c) => c.textContent) + .join(""), + ); + expect(text).toContain("iex> "); + expect(text).not.toMatch(/[▶✓]/); +}); + +test.describe("touch", () => { + // defaultBrowserType cannot change inside a describe (forces a new worker); + // keep the Pixel 7 viewport/touch traits and let the project pick the browser. + const { defaultBrowserType: _browser, ...pixel7 } = devices["Pixel 7"]; + test.use(pixel7); + + test("prompt runs on tap", async ({ page }) => { + const prompt = page.locator(".popdoc-iex-prompt").first(); + await awaitPromptReady(prompt); + await prompt.tap(); + await expect(prompt).toHaveAttribute("data-iex-state", "success", { + timeout: EVAL_TIMEOUT_MS, + }); + }); +}); + +test("previews the chain of commands that will run on hover", async ({ + page, +}) => { + const block = page.locator("pre.popcorn-iex").first(); + const prompts = block.locator( + ".popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)", + ); + await expect(prompts).toHaveCount(2, { timeout: RUN_READY_TIMEOUT_MS }); + + // Hover mirroring is bound together with the click handlers. + await awaitPromptReady(prompts.nth(1)); + await prompts.nth(1).hover(); + await expect(prompts.nth(0)).toHaveClass(/popdoc-iex-hover-chain/); + + await page.mouse.move(0, 0); + await expect(prompts.nth(0)).not.toHaveClass(/popdoc-iex-hover-chain/); +}); diff --git a/popdoc/js/build.mjs b/popdoc/js/build.mjs index 5aa99dc4..99bb8fbf 100644 --- a/popdoc/js/build.mjs +++ b/popdoc/js/build.mjs @@ -1,13 +1,16 @@ import * as esbuild from "esbuild"; -import { copyFile, mkdir } from "fs/promises"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { createRequire } from "module"; import { popcorn } from "@swmansion/popcorn/esbuild"; import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); const rootDir = resolve(__dirname, ".."); const assetsDir = resolve(rootDir, "assets"); const bundlePath = resolve(rootDir, "wasm/out/bundle.avm"); +const xtermCssSrc = require.resolve("@xterm/xterm/css/xterm.css"); await mkdir(assetsDir, { recursive: true }); @@ -19,6 +22,14 @@ await esbuild.build({ plugins: [popcorn({ bundlePaths: [bundlePath] })], }); -await copyFile(resolve(__dirname, "src/popdoc.css"), resolve(assetsDir, "popdoc.css")); +const [xtermCss, popdocCss] = await Promise.all([ + readFile(xtermCssSrc, "utf8"), + readFile(resolve(__dirname, "src/popdoc.css"), "utf8"), +]); + +await writeFile( + resolve(assetsDir, "popdoc.css"), + `${xtermCss}\n${popdocCss}`, +); console.log("[popdoc] runtime scaffold built into assets/"); diff --git a/popdoc/js/package.json b/popdoc/js/package.json index ab216df0..21522a12 100644 --- a/popdoc/js/package.json +++ b/popdoc/js/package.json @@ -6,7 +6,9 @@ "build": "node build.mjs" }, "dependencies": { - "@swmansion/popcorn": "workspace:*" + "@swmansion/popcorn": "workspace:*", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" }, "devDependencies": { "esbuild": "0.28.0" diff --git a/popdoc/js/src/iex.js b/popdoc/js/src/iex.js new file mode 100644 index 00000000..e9822a37 --- /dev/null +++ b/popdoc/js/src/iex.js @@ -0,0 +1,290 @@ +import { + ensureTerminal, + openTerminal, + getTerm, + initTerminalPrompt, + resetTerminalState, + runSnippetInTerminal, + getTerminalGeneration, +} from "./terminal.js"; +import { EVAL_TIMEOUT_MS, errorMessage } from "./eval.js"; +import { getPopcorn } from "./popdoc.js"; +import { instantiate, TPL_IEX_ICON } from "./templates.js"; + +const IEX_BLOCK_SEL = "pre.popcorn-iex"; +const RUNNING_INDICATOR_MS = 200; + +const IEX_PROMPT_RE = /^iex(?:\(\d+\))?>/; +const CONT_PROMPT_RE = /^\.\.\.(?:\(\d+\))?>/; + +let iexBusy = false; + +export const iexCommands = []; +let iexReady = false; +let startPromise = null; + +function getBlockCommands(blockEl) { + return iexCommands.filter((cmd) => cmd.blockEl === blockEl); +} + +function cmdEls(cmd) { + return [cmd.promptEl, ...cmd.contEls]; +} + +function setCmdState(cmd, state) { + cmd.state = state ?? "not_run"; + for (const el of cmdEls(cmd)) { + if (state) { + el.setAttribute("data-iex-state", state); + } else { + el.removeAttribute("data-iex-state"); + } + } +} + +function updateBlockRunnable(blockEl) { + const cmds = getBlockCommands(blockEl); + const next = cmds.find((cmd) => cmd.state !== "success"); + for (const cmd of cmds) { + for (const el of cmdEls(cmd)) { + el.classList.toggle("popdoc-iex-prompt--runnable", cmd === next); + } + } +} + +function updateAllRunnable() { + const blocks = new Set(iexCommands.map((cmd) => cmd.blockEl)); + for (const blockEl of blocks) { + updateBlockRunnable(blockEl); + } +} + +function getIexCommandsToRun(cmd) { + const blockCmds = getBlockCommands(cmd.blockEl); + const index = blockCmds.indexOf(cmd); + if (index < 0) return []; + + const pending = blockCmds.findIndex((c) => c.state !== "success"); + const first = pending >= 0 && pending < index ? pending : index; + return blockCmds.slice(first, index + 1); +} + +async function runIex(cmd) { + if (iexBusy) return; + if (!iexCommands.includes(cmd)) return; + + // Claim the shell before the first await so a second click doesn't start + // a parallel chain. + iexBusy = true; + try { + await startIexSession(); + openTerminal(); + + const xterm = getTerm(); + + if (cmd.state === "success") { + xterm?.scrollToBottom?.(); + return; + } + + const gen = getTerminalGeneration(); + for (const current of getIexCommandsToRun(cmd)) { + // Delay the spinner so fast commands don't flicker. + const showRunning = setTimeout(() => { + // A reset may have cleared all states while we waited. + if (gen === getTerminalGeneration()) setCmdState(current, "running"); + }, RUNNING_INDICATOR_MS); + + let outcome; + try { + outcome = await runSnippetInTerminal(current.code.trimEnd()); + } catch (error) { + outcome = { ok: false, reason: errorMessage(error) }; + } finally { + clearTimeout(showRunning); + } + + // Commands sent before a reset must not mark state on the new shell. + if (gen !== getTerminalGeneration() || outcome.stale) return; + + setCmdState(current, outcome.ok ? "success" : "failure"); + updateBlockRunnable(current.blockEl); + // The terminal already rendered the failure. + if (!outcome.ok) break; + } + } finally { + iexBusy = false; + } +} + +// Plain iex blocks (no `iex-popcorn` fence) get the runnable block's gutter so +// `iex>` sits at the same column in both, even though only one is clickable. +function alignPlainIexBlocks() { + const seen = new Set(); + for (const gpEl of document.querySelectorAll("pre:not(.popcorn-iex) .gp")) { + const preEl = gpEl.closest("pre"); + if (seen.has(preEl)) continue; + seen.add(preEl); + if (IEX_PROMPT_RE.test(gpEl.textContent.trimStart())) { + preEl.classList.add("popdoc-iex-aligned"); + } + } +} + +export function decorateIexBlocks() { + alignPlainIexBlocks(); + + for (let i = iexCommands.length - 1; i >= 0; i--) { + if (!document.contains(iexCommands[i].promptEl)) { + iexCommands.splice(i, 1); + } + } + + for (const preEl of document.querySelectorAll(IEX_BLOCK_SEL)) { + if (preEl.dataset.popdocIexProcessed === "true") continue; + preEl.dataset.popdocIexProcessed = "true"; + + const commandsJson = preEl.dataset.popcornIexCommands; + if (!commandsJson) continue; + + let commands; + try { + commands = JSON.parse(commandsJson); + } catch (error) { + console.warn( + "popdoc: unreadable iex command list, block left inert", + preEl, + error, + ); + continue; + } + + // Makeup marks both "iex>" and "...>" prompts as .gp spans; group each + // "iex>" with its continuation lines so the whole command reacts as one. + const gpEls = [...preEl.querySelectorAll(".gp")].map((gpEl) => ({ + gpEl, + text: gpEl.textContent.trimStart(), + })); + const promptCount = gpEls.filter(({ text }) => + IEX_PROMPT_RE.test(text), + ).length; + + if (promptCount !== commands.length) { + console.warn( + "popdoc: iex command/prompt mismatch, block left inert", + preEl, + ); + continue; + } + + let currentCmd = null; + let commandIndex = 0; + for (const { gpEl, text } of gpEls) { + if (IEX_PROMPT_RE.test(text)) { + gpEl.classList.add("popdoc-iex-prompt"); + gpEl.title = "Run in IEx"; + gpEl.setAttribute("aria-label", "Run in IEx"); + gpEl.setAttribute("role", "button"); + gpEl.tabIndex = 0; + gpEl.prepend(instantiate(TPL_IEX_ICON)); + currentCmd = { + code: commands[commandIndex], + promptEl: gpEl, + contEls: [], + blockEl: preEl, + state: "not_run", + }; + iexCommands.push(currentCmd); + commandIndex += 1; + } else if (CONT_PROMPT_RE.test(text) && currentCmd) { + gpEl.classList.add("popdoc-iex-prompt", "popdoc-iex-prompt--cont"); + gpEl.title = "Run in IEx"; + currentCmd.contEls.push(gpEl); + } else { + currentCmd = null; + } + } + + updateBlockRunnable(preEl); + } +} + +export function addIexClickHandlers() { + for (const cmd of iexCommands) { + const { promptEl } = cmd; + if (promptEl.dataset.popdocIexBound === "true") continue; + promptEl.dataset.popdocIexBound = "true"; + const run = () => { + runIex(cmd).catch((error) => { + console.error("popdoc: failed to run the iex command:", error); + }); + }; + const els = cmdEls(cmd); + const setHover = (hovered) => { + for (const el of els) { + el.classList.toggle("popdoc-iex-hover", hovered); + } + const chain = new Set( + hovered ? getIexCommandsToRun(cmd).filter((c) => c !== cmd) : [], + ); + for (const other of getBlockCommands(cmd.blockEl)) { + const on = chain.has(other); + for (const el of cmdEls(other)) { + el.classList.toggle("popdoc-iex-hover-chain", on); + } + } + }; + for (const el of els) { + el.addEventListener("click", run); + // Prompt spans sit on separate lines, so CSS :hover cannot cover the + // whole command. + el.addEventListener("mouseenter", () => setHover(true)); + el.addEventListener("mouseleave", () => setHover(false)); + } + promptEl.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + run(); + } + }); + } +} + +export async function startIexSession() { + if (iexReady) return; + // Concurrent callers share one start_iex call; a second one would print + // a second prompt. + if (!startPromise) { + startPromise = (async () => { + const gen = getTerminalGeneration(); + ensureTerminal(); + const result = await getPopcorn().call(["start_iex"], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + // A reset happened while start_iex was in flight; leave the new + // session alone. + if (gen !== getTerminalGeneration()) return; + if (!result.ok) { + throw new Error(`Failed to start IEx: ${errorMessage(result.error)}`); + } + iexReady = true; + initTerminalPrompt(); + })(); + } + try { + await startPromise; + } finally { + // Clear so a failed start can be retried on the next call. + startPromise = null; + } +} + +export function resetIexSession() { + iexReady = false; + startPromise = null; + resetTerminalState(); + for (const cmd of iexCommands) { + setCmdState(cmd, null); + } + updateAllRunnable(); +} diff --git a/popdoc/js/src/popdoc.css b/popdoc/js/src/popdoc.css index 657ccf23..70f1fdc3 100644 --- a/popdoc/js/src/popdoc.css +++ b/popdoc/js/src/popdoc.css @@ -7,11 +7,19 @@ --popdoc-accent: var(--mainDark); --popdoc-ok: var(--tipHeading); --popdoc-err: var(--errorHeading); + --popdoc-z-terminal: 250; } +/* ExDoc declares its dark palette on `body.dark`, one level below :root. A + var() is substituted at the element that declares it, so the wrappers above + resolve against and would stay light forever, so every ExDoc token + popdoc wraps has to be re-declared here to pick up the dark value. */ body.dark { + --popdoc-bg: var(--codeBackground); + --popdoc-border: var(--codeBorder); --popdoc-text-tertiary: var(--gray400); - --popdoc-border: var(--gray700); + --popdoc-accent: var(--mainLight); + --popdoc-ok: var(--tipHeading); --popdoc-err: #ff5385; } @@ -40,33 +48,39 @@ body.dark .popdoc-stacktrace { padding: calc(var(--popdoc-space) * 2) 0; } -.popdoc-run { - display: inline-flex; - align-items: center; - gap: var(--popdoc-space); - padding: 0.2em 0.75em; - font: inherit; - font-size: 0.85em; - line-height: 1.4; +.popdoc-run, +.popdoc-iex-launcher { color: #fff; background: var(--popdoc-accent); border: 1px solid transparent; - border-radius: 5px; cursor: pointer; transition: filter 0.15s ease, opacity 0.15s ease; } -.popdoc-run:hover:not(:disabled) { +.popdoc-run:hover:not(:disabled), +.popdoc-iex-launcher:hover:not(:disabled) { filter: brightness(1.1); } -.popdoc-run:disabled { +.popdoc-run:disabled, +.popdoc-iex-launcher:disabled { cursor: wait; opacity: 0.55; } +.popdoc-run { + display: inline-flex; + align-items: center; + gap: var(--popdoc-space); + padding: 0.2em 0.75em; + font: inherit; + font-size: 0.85em; + line-height: 1.4; + border-radius: 5px; +} + .popdoc-run::before { content: "▶"; font-size: 0.7em; @@ -252,6 +266,412 @@ body.dark .popdoc-stacktrace { opacity: 0.5; } +/* Clickable iex> prompts in documentation */ + +/* The ▶ marker lives in a gutter carved out of the block instead of inline, + so `iex>`, its continuations and the result lines all keep one column. + .popdoc-iex-aligned (added by JS to plain iex blocks) gets the same gutter + so prompts sit at the same x whether or not the block is runnable. */ +pre.popcorn-iex > code, +pre.popdoc-iex-aligned > code { + display: block; + padding-left: 1.4em; +} + +pre.popcorn-iex .gp.popdoc-iex-prompt { + cursor: pointer; + position: relative; + vertical-align: baseline; + outline: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + transition: + color 0.15s ease, + opacity 0.15s ease; +} + +/* Continuations: connector bar in the gutter instead of a glyph. Drawn with + ::after so hit-testing (automation, assistive tech) lands on the text. */ +pre.popcorn-iex .gp.popdoc-iex-prompt--cont::after { + content: ""; + position: absolute; + right: 100%; + margin-right: 0.72em; /* centered under the ▶ column above */ + top: -0.55em; /* bridge the line gap upward */ + bottom: 0.1em; + width: 2px; + border-radius: 1px; + background: color-mix(in srgb, currentColor 30%, transparent); + pointer-events: none; +} + +/* Glyph: sits left of the prompt, inside the gutter; never shifts the code. + Padding widens the hit area; it is part of the prompt's click target. */ +pre.popcorn-iex .popdoc-iex-icon { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-50%); + width: 0.9em; + /* right padding spans the gap to `iex>`, so glyph + gap + prompt are one + uninterrupted click target */ + padding: 0.3em 0.45em 0.3em 0.25em; + box-sizing: content-box; + text-align: center; + font-size: 0.8em; + line-height: 1; + opacity: 0.4; + cursor: pointer; + transition: opacity 0.15s ease; +} + +pre.popcorn-iex .popdoc-iex-icon::before { + content: "▶"; +} + +/* "Run next" cue at rest, no hover required (also on continuation lines). */ +pre.popcorn-iex .gp.popdoc-iex-prompt--runnable { + color: var(--popdoc-accent); +} + +pre.popcorn-iex .gp.popdoc-iex-prompt--runnable .popdoc-iex-icon { + opacity: 0.85; +} + +/* States (setCmdState puts data-iex-state on prompt AND cont els) */ +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="running"] { + cursor: wait; + opacity: 0.75; +} + +/* Loader: the glyph becomes a spinning ring while the shell is busy. */ +pre.popcorn-iex + .gp.popdoc-iex-prompt[data-iex-state="running"] + .popdoc-iex-icon::before { + content: ""; + display: block; + box-sizing: border-box; + width: 0.85em; + height: 0.85em; + margin: 0 auto; + border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: popdoc-iex-spin 0.7s linear infinite; +} + +@keyframes popdoc-iex-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + pre.popcorn-iex + .gp.popdoc-iex-prompt[data-iex-state="running"] + .popdoc-iex-icon::before { + animation: none; + } +} + +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="success"] { + color: var(--popdoc-ok); +} + +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state] .popdoc-iex-icon { + opacity: 0.9; +} + +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="success"] .popdoc-iex-icon::before { + content: "✓"; +} + +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="failure"] { + color: var(--popdoc-err); +} + +pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="failure"] .popdoc-iex-icon::before { + content: "!"; +} + +/* Hover: color only; .popdoc-iex-hover (JS mirror) lights the whole command */ +@media (hover: hover) { + pre.popcorn-iex .gp.popdoc-iex-prompt:hover, + pre.popcorn-iex .gp.popdoc-iex-prompt.popdoc-iex-hover { + color: var(--popdoc-accent); + } + + pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="success"]:hover, + pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="success"].popdoc-iex-hover { + color: var(--popdoc-ok); + opacity: 0.8; + } + + /* A command that already ran keeps its result glyph on hover; only the + colour reacts, so ✓ / ! are never masked by ▶ or ↓. */ + pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="failure"]:hover, + pre.popcorn-iex .gp.popdoc-iex-prompt[data-iex-state="failure"].popdoc-iex-hover { + color: var(--popdoc-err); + opacity: 0.8; + } + + pre.popcorn-iex .gp.popdoc-iex-prompt:hover .popdoc-iex-icon, + pre.popcorn-iex .gp.popdoc-iex-prompt.popdoc-iex-hover .popdoc-iex-icon { + opacity: 1; + } + + pre.popcorn-iex .gp.popdoc-iex-prompt--cont:hover::after, + pre.popcorn-iex .gp.popdoc-iex-prompt--cont.popdoc-iex-hover::after { + background: color-mix(in srgb, currentColor 55%, transparent); + } + + /* chain-run preview: earlier pending commands that would auto-run. + Skipped for commands that already ran; their ✓ / ! takes priority. */ + pre.popcorn-iex + .gp.popdoc-iex-prompt.popdoc-iex-hover-chain:not([data-iex-state]) { + color: color-mix(in srgb, var(--popdoc-accent) 60%, transparent); + } + + pre.popcorn-iex + .gp.popdoc-iex-prompt.popdoc-iex-hover-chain:not([data-iex-state]) + .popdoc-iex-icon { + opacity: 0.75; + } + + /* ↓ marks commands that auto-run on the way to the clicked one. */ + pre.popcorn-iex + .gp.popdoc-iex-prompt.popdoc-iex-hover-chain:not([data-iex-state]) + .popdoc-iex-icon::before { + content: "↓"; + font-size: 1.05em; + } +} + +pre.popcorn-iex .gp.popdoc-iex-prompt:focus-visible { + outline: 2px solid color-mix(in srgb, var(--popdoc-accent) 45%, transparent); + outline-offset: 2px; + border-radius: 3px; +} + +/* Tap feedback without hover */ +pre.popcorn-iex .gp.popdoc-iex-prompt:active { + opacity: 0.6; +} + +/* Touch: everything already visible at rest; just grow the target */ +@media (hover: none), (pointer: coarse) { + pre.popcorn-iex .gp.popdoc-iex-prompt:not(.popdoc-iex-prompt--cont)::after { + content: ""; + position: absolute; + inset: -0.5em -0.35em; /* invisible tap-target extender */ + } +} + +/* Terminal (IEx panel, ExDoc code theme) */ + +/* Terminal color palette. xterm.js cannot read CSS; terminal.js bridges + these variables into its theme object; edit colors here, not in JS. + Matches ExDoc Makeup: dark-gray prompts (.gp), purple for atoms/exprs. */ +:root { + --popdoc-term-background: var(--codeBackground, #f5f5f5); + --popdoc-term-foreground: #4d4d4c; + --popdoc-term-cursor: var(--mainDark, #6c5ce7); + --popdoc-term-cursor-accent: var(--popdoc-term-cursor); + --popdoc-term-selection-background: var(--gray200, #d4d4d4); + --popdoc-term-black: #4d4d4d; + --popdoc-term-red: #a40000; + --popdoc-term-green: #408200; + --popdoc-term-yellow: #a06600; + --popdoc-term-blue: #0000cf; + --popdoc-term-magenta: #5c35cc; + --popdoc-term-cyan: #5c35cc; + --popdoc-term-white: #4d4d4c; + --popdoc-term-bright-black: #4d4d4d; + --popdoc-term-bright-red: #c00; + --popdoc-term-bright-green: #408200; + --popdoc-term-bright-yellow: #a06600; + --popdoc-term-bright-blue: #0000cf; + --popdoc-term-bright-magenta: #7c5cbf; + --popdoc-term-bright-cyan: #7c5cbf; + --popdoc-term-bright-white: #1a1a1a; +} + +body.dark { + --popdoc-term-background: var(--codeBackground, #1c1c1c); + --popdoc-term-foreground: #dce1e6; + --popdoc-term-cursor: var(--main, #8e7ce6); + --popdoc-term-cursor-accent: var(--popdoc-term-cursor); + --popdoc-term-selection-background: var(--gray700, #3f3f3f); + --popdoc-term-black: #969386; + --popdoc-term-red: #ff5385; + --popdoc-term-green: #a6e22e; + --popdoc-term-yellow: #e6db74; + --popdoc-term-blue: #66d9ef; + --popdoc-term-magenta: #ae81ff; + --popdoc-term-cyan: #ae81ff; + --popdoc-term-white: #dce1e6; + --popdoc-term-bright-black: #969386; + --popdoc-term-bright-red: #ff5385; + --popdoc-term-bright-green: #a6e22e; + --popdoc-term-bright-yellow: #e6db74; + --popdoc-term-bright-blue: #66d9ef; + --popdoc-term-bright-magenta: #c4a0ff; + --popdoc-term-bright-cyan: #c4a0ff; + --popdoc-term-bright-white: #ffffff; +} + +.popdoc-terminal { + display: none; + position: fixed; + bottom: 0; + right: 1.5rem; + width: 640px; + max-width: calc(100vw - 3rem); + height: min(52vh, 480px); + background: var(--popdoc-bg); + border: 1px solid var(--popdoc-border); + border-bottom: none; + border-radius: 6px 6px 0 0; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12); + font-family: var(--monoFontFamily, monospace); + font-size: 0.875em; + color: var(--textBody, inherit); + z-index: var(--popdoc-z-terminal); + flex-direction: column; + overflow: hidden; +} + +.popdoc-terminal--open { + display: flex; +} + +.popdoc-terminal--collapsed { + height: auto; +} + +.popdoc-terminal--collapsed .popdoc-terminal-body { + display: none; +} + +.popdoc-terminal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(var(--popdoc-space) * 2) calc(var(--popdoc-space) * 3); + border-bottom: 1px solid var(--popdoc-border); + background: var(--popdoc-bg); + flex-shrink: 0; +} + +.popdoc-terminal--collapsed .popdoc-terminal-header { + border-bottom: none; +} + +.popdoc-terminal--collapsed .popdoc-terminal-btn[data-action="clear"], +.popdoc-terminal--collapsed .popdoc-terminal-btn[data-action="reset"] { + display: none; +} + +.popdoc-terminal-title { + font-size: 0.85em; + font-weight: 600; + color: var(--popdoc-text-tertiary); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.popdoc-terminal-actions { + display: flex; + gap: calc(var(--popdoc-space) * 2); +} + +.popdoc-terminal-btn { + padding: 0.15em 0.55em; + background: transparent; + border: 1px solid var(--popdoc-border); + border-radius: 4px; + color: var(--popdoc-text-tertiary); + font: inherit; + font-size: 0.85em; + cursor: pointer; + transition: + color 0.15s, + border-color 0.15s, + background-color 0.15s; +} + +.popdoc-terminal-btn:hover { + color: inherit; + border-color: currentColor; + background: color-mix(in srgb, var(--popdoc-accent) 8%, transparent); +} + +.popdoc-terminal-body { + flex: 1; + min-height: 0; + padding: calc(var(--popdoc-space) * 3) calc(var(--popdoc-space) * 2) + calc(var(--popdoc-space) * 5) calc(var(--popdoc-space) * 3); + display: flex; + flex-direction: column; + background: var(--popdoc-bg); + scrollbar-color: var(--popdoc-accent) var(--popdoc-bg); +} + +.popdoc-terminal-mount { + flex: 1; + min-height: 0; + width: 100%; + position: relative; +} + +.popdoc-terminal-mount .xterm { + height: 100% !important; + padding: 0; +} + +.popdoc-terminal-mount .xterm-viewport { + overflow-y: auto !important; +} + +/* ExDoc theme-scopes the tokens --popdoc-bg/--popdoc-border wrap, so the base + rules already resolve to the dark palette; only these deltas are needed. */ +body.dark .popdoc-terminal { + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.45); +} + +body.dark .popdoc-terminal-btn { + color: var(--gray300); +} + +body.dark .popdoc-terminal-btn:hover { + border-color: var(--mainLight); + background: color-mix(in srgb, var(--main) 16%, transparent); +} + +/* Floating launcher: opens the terminal from any page */ + +.popdoc-iex-launcher { + position: fixed; + bottom: 1rem; + right: 1.5rem; + z-index: calc(var(--popdoc-z-terminal) - 1); + padding: 0.35em 0.9em; + font-family: var(--monoFontFamily, monospace); + font-size: 0.85rem; + font-weight: 600; + border-radius: 999px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.popdoc-iex-launcher:focus-visible { + outline: 2px solid var(--popdoc-accent); + outline-offset: 2px; +} + +body.popdoc-terminal-open .popdoc-iex-launcher { + display: none; +} + /* Mobile: stack rows; let snippet wrap and remove the rigid grid. */ @media (max-width: 600px) { .popdoc-header { @@ -279,4 +699,15 @@ body.dark .popdoc-stacktrace { .popdoc-cell-result { flex-basis: 100%; } + + .popdoc-terminal { + right: 0; + left: 0; + width: 100%; + max-width: 100%; + } + + .popdoc-iex-launcher { + right: 1rem; + } } diff --git a/popdoc/js/src/popdoc.js b/popdoc/js/src/popdoc.js index bf22b52d..0edab784 100644 --- a/popdoc/js/src/popdoc.js +++ b/popdoc/js/src/popdoc.js @@ -1,36 +1,124 @@ import { Popcorn } from "@swmansion/popcorn"; -import { runCode } from "./eval.js"; -import { instantiate, TPL_BLOCK } from "./templates.js"; +import { runCode, errorMessage } from "./eval.js"; +import { + iexCommands, + decorateIexBlocks, + addIexClickHandlers, + startIexSession, + resetIexSession, +} from "./iex.js"; +import { instantiate, TPL_BLOCK, TPL_LAUNCHER } from "./templates.js"; +import { + getTerm, + getTerminalGeneration, + openTerminal, + writeSystemError, +} from "./terminal.js"; const BUNDLES_SEL = 'meta[name="popcorn-user-bundle"]'; const EVAL_BLOCK_SEL = "pre.popcorn-eval code"; +const SESSION_RESTART_RETRY_MS = 500; +const SESSION_RESTART_DEADLINE_MS = 30_000; let popcornInstance = null; +let popcornInitPromise = null; +// Live instance shared with every module. Reset swaps it, so consumers read +// it at call time instead of capturing it in closures at bind time. export function getPopcorn() { return popcornInstance; } +// Bumped per exdoc:loaded so auto block ids never collide across SPA +// navigations; the VM (and its per-block sessions) outlives the page. +let pageEpoch = 0; + +// The lib transparently reloads its iframe when the VM crashes (heartbeat +// loss, abort): a fresh runtime boots behind the same instance, so all +// session-derived UI state is stale and must be dropped. +function handleRuntimeReload(reason) { + resetIexSession(); + writeSystemError( + `popdoc: the runtime restarted (${reason}); session state was lost`, + ); + restartIexSession(); +} + +function restartIexSession() { + if (iexCommands.length === 0 && !getTerm()) return; + const gen = getTerminalGeneration(); + const deadline = Date.now() + SESSION_RESTART_DEADLINE_MS; + + const attempt = async () => { + if (gen !== getTerminalGeneration()) return; + try { + await startIexSession(); + } catch (error) { + if (gen !== getTerminalGeneration()) return; + if (Date.now() < deadline) { + setTimeout(attempt, SESSION_RESTART_RETRY_MS); + } else { + console.error("popdoc: failed to restart the IEx session:", error); + writeSystemError( + "popdoc: could not restart the IEx session; press Reset to retry", + ); + } + } + }; + + attempt(); +} async function initPopcorn() { - const bundlePaths = ["./bundle.avm"]; - try { - // TODO: maybe simplify by making `consumer.avm` mandatory - const userBundles = document.querySelectorAll(BUNDLES_SEL); - for (const bundleMeta of userBundles) { - bundlePaths.push(bundleMeta.content); + if (popcornInstance) return popcornInstance; + if (popcornInitPromise) return popcornInitPromise; + + popcornInitPromise = (async () => { + const bundlePaths = ["./bundle.avm"]; + try { + // TODO: maybe simplify by making `consumer.avm` mandatory + const userBundles = document.querySelectorAll(BUNDLES_SEL); + for (const bundleMeta of userBundles) { + bundlePaths.push(bundleMeta.content); + } + + popcornInstance = await Popcorn.init({ + debug: true, + bundlePaths: [...new Set(bundlePaths)], + onReload: handleRuntimeReload, + }); + window.popcorn = popcornInstance; + return popcornInstance; + } catch (e) { + popcornInitPromise = null; + console.error("Failed to initialize Popcorn runtime:", e); + throw e; } + })(); - return Popcorn.init({ - debug: true, - bundlePaths: [...new Set(bundlePaths)], - }); - } catch (e) { - console.error("Failed to initialize Popcorn runtime:", e); - throw e; + return popcornInitPromise; +} + +// Reset tears down the whole runtime on purpose: the terminal and the eval +// blocks share one VM, so bindings, modules, and eval-block sessions all go +// together. (AtomVM has no code server, so this is also the only way to +// unload modules defined in the shell.) +export async function reinitPopcorn() { + // Invalidate queued and in-flight work BEFORE tearing the runtime down, so + // their failures die silently instead of rendering into the fresh screen. + resetIexSession(); + if (popcornInstance) { + try { popcornInstance.deinit(); } catch (_) {} + popcornInstance = null; + popcornInitPromise = null; + } + await initPopcorn(); + if (iexCommands.length > 0 || getTerm()) { + await startIexSession(); } } function decorateBlocks() { + pageEpoch += 1; let blockIndex = 0; const blocks = []; @@ -40,7 +128,9 @@ function decorateBlocks() { preEl.dataset.popdocProcessed = "true"; const blockId = - preEl.id.length > 0 ? preEl.id : `popdoc-eval-${++blockIndex}`; + preEl.id.length > 0 + ? preEl.id + : `popdoc-eval-${pageEpoch}-${++blockIndex}`; const wrapper = instantiate(TPL_BLOCK); preEl.insertAdjacentElement("afterend", wrapper); @@ -65,9 +155,55 @@ function addClickHandlers(blocks) { } } +let launcherEl = null; + +// Fixed "iex" pill so the terminal is reachable from any page. +function ensureIexLauncher() { + if (launcherEl && document.body.contains(launcherEl)) return launcherEl; + + launcherEl = instantiate(TPL_LAUNCHER); + launcherEl.addEventListener("click", async () => { + launcherEl.disabled = true; + try { + await initPopcorn(); + await startIexSession(); + openTerminal(); + } catch (error) { + console.error("popdoc: failed to open the IEx terminal:", error); + } finally { + launcherEl.disabled = false; + } + }); + + document.body.appendChild(launcherEl); + return launcherEl; +} + window.addEventListener("exdoc:loaded", async () => { const blocks = decorateBlocks(); - popcornInstance = await initPopcorn(); - window.popcorn = popcornInstance; + decorateIexBlocks(); + const popcorn = await initPopcorn(); + + // Eval-block sessions from the previous page are unreachable now; drop + // them before any new Run can start (the GenServer handles calls in + // order, so this cannot outrun a fresh parse_elixir). + popcorn + .call(["clear_sessions"]) + .catch((error) => + console.error("popdoc: failed to clear stale sessions:", error), + ); + addClickHandlers(blocks); + ensureIexLauncher(); + // Prompts must be clickable even if the session fails to start below — + // clicking lazily revives it. + addIexClickHandlers(); + + if (iexCommands.length > 0) { + try { + await startIexSession(); + } catch (error) { + console.error("popdoc: failed to start the IEx session:", error); + } + } }); diff --git a/popdoc/js/src/templates.js b/popdoc/js/src/templates.js index cb57c387..44d819d4 100644 --- a/popdoc/js/src/templates.js +++ b/popdoc/js/src/templates.js @@ -73,3 +73,28 @@ export const TPL_STACKTRACE_TOGGLE = tpl(` export const TPL_STACKTRACE = tpl(` `); + +export const TPL_IEX_ICON = tpl(` + +`); + +export const TPL_LAUNCHER = tpl(` + +`); + +export const TPL_TERMINAL = tpl(` +
+
+ IEx +
+ + + + +
+
+
+
+
+
+`); diff --git a/popdoc/js/src/terminal.js b/popdoc/js/src/terminal.js new file mode 100644 index 00000000..9af628a6 --- /dev/null +++ b/popdoc/js/src/terminal.js @@ -0,0 +1,436 @@ +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { instantiate, TPL_TERMINAL } from "./templates.js"; +import { + startLogCapture, + ensureTrailingNewline, + errorMessage, + EVAL_TIMEOUT_MS, +} from "./eval.js"; +import { getPopcorn, reinitPopcorn } from "./popdoc.js"; + +let terminalEl = null; +let term = null; +let fitAddon = null; +let collapseBtn = null; +let resizeObserver = null; + +// JS owns the input line: echo, prompt rendering, the continuation buffer, +// and the prompt counter (bumped on ok/error, held on incomplete, matching +// IEx). Elixir only sees complete submissions via ["iex_eval", code]. +let promptNumber = 1; +let currentLine = ""; +let pendingLines = []; +let evalChain = Promise.resolve(); +let evalInFlight = false; +// True while the session has a live prompt on screen; typing is ignored +// between a reset and the restarted session's first prompt. +let sessionReady = false; +// Bumped on reset; evals submitted before a reset must not run against the +// new session or write into the fresh terminal. +let terminalGeneration = 0; + +// Magenta maps to the popdoc purple accent in both terminal themes. +const PROMPT_STYLE = "\x1b[1;35m"; +const CONT_PROMPT_STYLE = "\x1b[2;35m"; + +function promptText() { + return `${PROMPT_STYLE}iex(${promptNumber})>\x1b[0m `; +} + +function contPromptText() { + return `${CONT_PROMPT_STYLE}...(${promptNumber})>\x1b[0m `; +} + +export function initTerminalPrompt() { + promptNumber = 1; + currentLine = ""; + pendingLines = []; + sessionReady = true; + term?.write(promptText()); +} + +export function resetTerminalState() { + terminalGeneration += 1; + promptNumber = 1; + currentLine = ""; + pendingLines = []; + evalInFlight = false; + sessionReady = false; +} + +export function getTerminalGeneration() { + return terminalGeneration; +} + +function toCrlf(text) { + return text.replace(/\r?\n/g, "\r\n"); +} + +function writeLogs(logs) { + for (const message of logs.stdout) { + term.write(toCrlf(ensureTrailingNewline(message))); + } + for (const message of logs.stderr) { + term.write(`\x1b[31m${toCrlf(ensureTrailingNewline(message))}\x1b[0m`); + } +} + +function writeError(error) { + const label = + error?.type != null ? error.type : (error?.kind ?? "error"); + const message = error?.message ?? "unknown error"; + term.write(`\x1b[31m** (${label}) ${toCrlf(message)}\x1b[0m\r\n`); + if (error?.stacktrace && error.stacktrace.length > 0) { + term.write(`\x1b[2m${toCrlf(error.stacktrace)}\x1b[0m\r\n`); + } +} + +function reportEvalFailure(reason) { + pendingLines = []; + term.write(`\x1b[31m${toCrlf(reason)}\x1b[0m\r\n`); + term.write(promptText()); +} + +// For popdoc's own messages, as opposed to evaluation output. +export function writeSystemError(message) { + term?.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`); +} + +function interruptLine() { + term.write("\x1b[2m^C\x1b[0m\r\n"); + currentLine = ""; + pendingLines = []; + term.write(promptText()); +} + +// Serialize evals: typed input and markdown iex> clicks share one session, +// so they must never interleave. +function enqueueEval(fn) { + const run = evalChain.then(fn); + // Keep the chain alive when fn rejects. + evalChain = run.then( + () => {}, + () => {}, + ); + return run; +} + +const STALE_OUTCOME = { ok: false, reason: "runtime was reset", stale: true }; + +// Evaluates `code`; the caller has already echoed the input. Owns all +// failure rendering and returns {ok, reason?, incomplete?, stale?}. Evals +// submitted before a reset (stale `gen`) die silently. +async function evalOnce(code, gen) { + if (gen !== terminalGeneration) return STALE_OUTCOME; + evalInFlight = true; + try { + const stopLogCapture = startLogCapture(); + let result; + try { + result = await getPopcorn().call(["iex_eval", code], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + } catch (error) { + stopLogCapture(); + if (gen !== terminalGeneration) return STALE_OUTCOME; + const reason = errorMessage(error); + if (term) reportEvalFailure(reason); + return { ok: false, reason }; + } + const logs = stopLogCapture(); + + if (gen !== terminalGeneration) return STALE_OUTCOME; + + if (!result.ok) { + const reason = errorMessage(result.error); + reportEvalFailure(reason); + return { ok: false, reason }; + } + + const data = result.data ?? {}; + writeLogs(logs); + + if (data.status === "incomplete") { + term.write(contPromptText()); + return { ok: false, reason: "incomplete expression", incomplete: true }; + } + + promptNumber += 1; + pendingLines = []; + + if (data.status === "ok") { + term.write(`${data.result}\r\n`); + term.write(promptText()); + return { ok: true }; + } + + writeError(data.error); + term.write(promptText()); + return { ok: false, reason: data.error?.message ?? "evaluation failed" }; + } finally { + // A stale eval must not unlock the new generation's input. + if (gen === terminalGeneration) evalInFlight = false; + } +} + +async function submitLine() { + if (pendingLines.length === 0 && currentLine.trim().length === 0) { + currentLine = ""; + term.write(promptText()); + return; + } + pendingLines.push(currentLine); + currentLine = ""; + const code = pendingLines.join("\n"); + const gen = terminalGeneration; + await enqueueEval(() => evalOnce(code, gen)); +} + +// Runs a markdown iex> command as if typed and reports an outcome for the +// per-prompt status UI. +export function runSnippetInTerminal(code) { + const gen = terminalGeneration; + return enqueueEval(async () => { + if (!term) return { ok: false, reason: "terminal is not open" }; + if (gen !== terminalGeneration) return STALE_OUTCOME; + + if (currentLine.length > 0 || pendingLines.length > 0) { + interruptLine(); + } + + const lines = code.split("\n"); + term.write(`${lines[0]}\r\n`); + for (const line of lines.slice(1)) { + term.write(`${contPromptText()}${line}\r\n`); + } + + const outcome = await evalOnce(code, gen); + if (outcome.incomplete) { + // A markdown command is a complete unit; an incomplete one is a doc bug. + pendingLines = []; + writeSystemError(`popdoc: incomplete command in the docs: \`${lines[0]}\``); + term.write(promptText()); + } + return outcome; + }); +} + +export function getTerm() { + return term; +} + +function isDarkMode() { + return document.body.classList.contains("dark"); +} + +// xterm cannot read CSS, so bridge the --popdoc-term-* custom properties +// into its theme object. Keys missing from the CSS fall back to xterm +// defaults. +const THEME_KEYS = [ + "background", + "foreground", + "cursor", + "cursorAccent", + "selectionBackground", + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "brightBlack", + "brightRed", + "brightGreen", + "brightYellow", + "brightBlue", + "brightMagenta", + "brightCyan", + "brightWhite", +]; + +function themeVar(key) { + return `--popdoc-term-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`; +} + +function terminalTheme() { + const styles = getComputedStyle(document.body); + const theme = {}; + for (const key of THEME_KEYS) { + const value = styles.getPropertyValue(themeVar(key)).trim(); + if (value.length > 0) theme[key] = value; + } + return theme; +} + +function terminalOptions() { + const styles = getComputedStyle(document.body); + const mono = + styles.getPropertyValue("--monoFontFamily").trim() || "monospace"; + + return { + cursorBlink: true, + scrollback: 10000, + smoothScrollDuration: 100, + fontFamily: mono, + theme: terminalTheme(), + }; +} + +function observeResize(mount) { + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(() => { + fitAddon?.fit(); + }); + resizeObserver.observe(mount); +} + +function syncTerminalTheme() { + if (term?.options) { + term.options.theme = terminalTheme(); + } +} + +let themeObserver = null; +let lastDarkMode = null; + +// ExDoc's theme toggle flips the "dark" class on . Other body class +// changes fire the observer too, so only re-theme when the dark flag flipped. +function observeThemeChanges() { + if (themeObserver) return; + lastDarkMode = isDarkMode(); + themeObserver = new MutationObserver(() => { + const dark = isDarkMode(); + if (dark === lastDarkMode) return; + lastDarkMode = dark; + syncTerminalTheme(); + }); + themeObserver.observe(document.body, { + attributes: true, + attributeFilter: ["class"], + }); +} + +function setCollapsedUi(collapsed) { + if (!collapseBtn) return; + collapseBtn.textContent = collapsed ? "□" : "─"; + collapseBtn.title = collapsed ? "Expand" : "Collapse"; +} + +// Strip whole escape sequences first (Delete/Home/End/F-keys arrive as CSI +// or SS3 sequences that would otherwise leak "[3~"-style residue once the +// bare ESC byte is removed), then remaining control bytes. Tabs survive: +// stripping them from pastes silently corrupts code and string literals. +function sanitizeSegment(seg) { + return seg + .replace(/\x1b(?:\[[0-?]*[ -\/]*[@-~]|O[@-~])/g, "") + .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); +} + +export function ensureTerminal() { + if (terminalEl && term) return; + + terminalEl = instantiate(TPL_TERMINAL); + const mount = terminalEl.querySelector(".popdoc-terminal-mount"); + collapseBtn = terminalEl.querySelector('[data-action="collapse"]'); + + terminalEl + .querySelector('[data-action="clear"]') + .addEventListener("click", () => { + // Like the shell `clear`: wipe the scrollback, keep the cursor's line. + term?.clear(); + }); + + terminalEl + .querySelector('[data-action="reset"]') + .addEventListener("click", async () => { + // The wiped prompt is stale; the restarted session prints a fresh one. + if (term) term.reset(); + try { + await reinitPopcorn(); + } catch (error) { + console.error("popdoc: failed to restart the runtime:", error); + } + }); + + collapseBtn.addEventListener("click", () => { + const collapsed = terminalEl.classList.toggle("popdoc-terminal--collapsed"); + setCollapsedUi(collapsed); + if (!collapsed && fitAddon) fitAddon.fit(); + }); + + terminalEl + .querySelector('[data-action="close"]') + .addEventListener("click", () => { + terminalEl.classList.remove( + "popdoc-terminal--open", + "popdoc-terminal--collapsed", + ); + document.body.classList.remove("popdoc-terminal-open"); + setCollapsedUi(false); + }); + + document.body.appendChild(terminalEl); + + term = new Terminal(terminalOptions()); + fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(mount); + fitAddon.fit(); + observeResize(mount); + observeThemeChanges(); + + term.onData(async (data) => { + // No tab completion. + if (data === "\t") return; + // Input is locked while an eval runs and while a reset reboots the + // session. + if (evalInFlight || !sessionReady) return; + + if (data === "\x7f") { + if (currentLine.length > 0) { + // Drop the last code point (surrogate pairs are two UTF-16 units) + // and erase its cells (astral glyphs like emoji render two columns + // wide; BMP wide glyphs are still mis-erased by one cell). + const chars = Array.from(currentLine); + const removed = chars.pop(); + currentLine = chars.join(""); + term.write(removed.length === 2 ? "\b\b \b\b" : "\b \b"); + } + return; + } + + if (data === "\x03") { + interruptLine(); + return; + } + + // A chunk may be a single keystroke or a whole paste; newlines submit + // line-by-line, so pastes use the same continuation pipeline as typing. + const parts = data.split(/\r\n|\r|\n/); + for (let i = 0; i < parts.length; i++) { + const seg = sanitizeSegment(parts[i]); + if (seg.length > 0) { + currentLine += seg; + term.write(seg); + } + if (i < parts.length - 1) { + term.write("\r\n"); + await submitLine(); + } + } + }); +} + +export function openTerminal() { + if (!terminalEl) return; + terminalEl.classList.add("popdoc-terminal--open"); + terminalEl.classList.remove("popdoc-terminal--collapsed"); + document.body.classList.add("popdoc-terminal-open"); + setCollapsedUi(false); + if (fitAddon) fitAddon.fit(); +}