import { adopt, define, sheet } from "@nibling/web/internals"; import { sandboxPort } from "../process-table.ts"; import type { Sandbox } from "../sandbox.ts"; import { keyOf } from "../shell/kinds.ts"; import { mainStack, stackOf, type PanelId, type Placement } from "../shell/layout.ts"; import type { Unsubscribe, Workbench } from "../shell/workbench.ts"; const browserSheet = sheet(` :host { display: flex; container: nib-browser / size; } iframe { flex: 1; min-width: 0; min-height: 0; border: 0; background: var(--nib-color-document); } iframe:focus-visible { outline: var(--nib-size-focus-ring) solid var(--nib-color-focus-ring); outline-offset: calc(-1 * var(--nib-size-focus-ring)); } [part="status"], [part="empty"] { flex: 1; margin: 0; padding: var(--nib-space-md); font: var(--nib-font-ui); color: var(--nib-color-text-muted); } [part="empty"] h2 { margin: 0 0 var(--nib-space-xs); font: inherit; font-weight: var(--nib-font-weight-strong); color: var(--nib-color-text); } [part="empty"] p { margin: 0; } `); /** * A page a sandbox's server serves. `http://localhost:/` maps to the URL the kernel gives that * port, on the sandbox's own origin (D27), so the page cannot reach the app's storage. While nothing * listens there it waits, and it reloads each time the port listens again, so restarting a dev server * refreshes its page. A preview bound to no server yet says how to start one instead. */ export class BrowserElement extends HTMLElement { readonly #iframe = document.createElement("iframe"); readonly #status = document.createElement("p"); constructor() { super(); const root = this.attachShadow({ mode: "open", delegatesFocus: true }); adopt(root, browserSheet); this.#status.part = "status"; this.#status.role = "status"; root.append(this.#status, this.#iframe); } connect(sandbox: Sandbox, url: string | null): Unsubscribe { if (url === null) { this.shadowRoot!.replaceChildren(emptyState()); return () => {}; } const port = sandboxPort(url); const parsed = URL.parse(url); this.#iframe.title = parsed?.host ?? url; const waiting = (text: string) => { this.#iframe.hidden = true; this.#iframe.removeAttribute("src"); this.#status.hidden = false; this.#status.textContent = text; }; if (port === undefined || !parsed) { waiting(`${url} is not an address in the sandbox.`); return () => {}; } waiting(`Waiting for ${parsed.host}…`); let stopped = false; let unsubscribe = () => {}; sandbox.kernel.then( (kernel) => { if (stopped) return; const offListen = kernel.onListening((listening, base) => { if (listening !== port) return; this.#status.hidden = true; this.#iframe.hidden = false; this.#iframe.src = `${base}${parsed.pathname.slice(1)}${parsed.search}${parsed.hash}`; }); const offUnlisten = kernel.onUnlistening((closed) => { if (closed === port) waiting(`Waiting for ${parsed.host}…`); }); unsubscribe = () => { offListen(); offUnlisten(); }; }, (error: unknown) => waiting(`The sandbox did not start: ${error instanceof Error ? error.message : String(error)}`), ); return () => { stopped = true; unsubscribe(); }; } } function emptyState(): HTMLElement { const region = document.createElement("section"); region.part = "empty"; region.setAttribute("aria-labelledby", "empty-heading"); const heading = Object.assign(document.createElement("h2"), { id: "empty-heading", textContent: "No server is running" }); const hint = document.createElement("p"); hint.append("Run ", Object.assign(document.createElement("code"), { textContent: "npm run dev" }), " in the terminal, or Run dev in Processes."); region.append(heading, hint); return region; } /** * Opens a browser panel on `url`, or activates the one already showing its origin. Otherwise a preview * bound to no server takes `url`; failing that, a new panel joins the stack that shows pages already * (another browser, else a preview), or else splits off the main stack. */ export function openBrowser(workbench: Workbench, url: string): PanelId { const request = { kind: "browser", input: { url } } as const; const browsers = workbench.panels("browser"); const unbound = browsers.find((panel) => panel.input.url === null); if (unbound && !browsers.some((panel) => keyOf(panel) === keyOf(request))) { workbench.dispatch({ type: "retarget", panel: unbound.id, ...request }); workbench.dispatch({ type: "activate", panel: unbound.id }); return unbound.id; } const { root } = workbench.layout; const shown = [...browsers, ...workbench.panels("preview")].map((panel) => stackOf(root, panel.id)).find(Boolean); const at: Placement = shown ? { stack: shown.id } : { beside: mainStack(root).id, edge: "right" }; return workbench.open(request, at); } declare global { interface HTMLElementTagNameMap { "nib-browser": BrowserElement; } } define("nib-browser", BrowserElement);