| 1 | import { GhosttyModule, mountTerminal, type TerminalHandle } from "@clo/terminal"; |
| 2 | import wasmUrl from "@clo/terminal/ghostty-vt.wasm?url"; |
| 3 | import "@clo/terminal/terminal.css"; |
| 4 | import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; |
| 5 | import { Portal } from "solid-js/web"; |
| 6 | import { reason } from "../api.ts"; |
| 7 | import { VMMenu } from "./VMMenu.tsx"; |
| 8 | import Keyboard from "lucide-solid/icons/keyboard"; |
| 9 | import RefreshCw from "lucide-solid/icons/refresh-cw"; |
| 10 | |
| 11 | let core: Promise<GhosttyModule> | undefined; |
| 12 | |
| 13 | export default function VMSerial(props: { name: string; enabled: boolean; active: boolean; toolbar: HTMLDivElement }) { |
| 14 | const enabled = createMemo(() => props.enabled); |
| 15 | let host!: HTMLDivElement; |
| 16 | let socket: WebSocket | undefined; |
| 17 | let disposed = false; |
| 18 | const [handle, setHandle] = createSignal<TerminalHandle>(); |
| 19 | const [state, setState] = createSignal("Loading…"); |
| 20 | const [problem, setProblem] = createSignal(""); |
| 21 | const [hasOutput, setHasOutput] = createSignal(false); |
| 22 | onMount(async () => { |
| 23 | try { |
| 24 | const module = await (core ??= GhosttyModule.fetch(wasmUrl)); |
| 25 | if (disposed) return; |
| 26 | const mounted = mountTerminal(host, module, { |
| 27 | maxScrollbackBytes: 4 * 1024 * 1024, |
| 28 | onData: (bytes) => { if (socket?.readyState === WebSocket.OPEN) socket.send(new Uint8Array(bytes)); }, |
| 29 | }); |
| 30 | setHandle(mounted); |
| 31 | await mounted.ready; |
| 32 | } catch (failure) { |
| 33 | core = undefined; |
| 34 | if (!disposed) setProblem(`Unable to load the console. ${reason(failure)}`); |
| 35 | } |
| 36 | }); |
| 37 | const connect = () => { |
| 38 | const previous = socket; |
| 39 | socket = undefined; |
| 40 | previous?.close(); |
| 41 | if (!enabled() || !handle()) return; |
| 42 | setState("Connecting…"); |
| 43 | setProblem(""); |
| 44 | const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/serial`, location.href); |
| 45 | url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; |
| 46 | const next = new WebSocket(url); |
| 47 | next.binaryType = "arraybuffer"; |
| 48 | socket = next; |
| 49 | next.onopen = () => { |
| 50 | if (socket !== next || disposed) return; |
| 51 | setState("Connected"); |
| 52 | }; |
| 53 | next.onmessage = (event: MessageEvent<ArrayBuffer>) => { |
| 54 | if (socket === next && !disposed) { |
| 55 | handle()?.write(new Uint8Array(event.data)); |
| 56 | if (event.data.byteLength) setHasOutput(true); |
| 57 | } |
| 58 | }; |
| 59 | next.onclose = (event) => { |
| 60 | if (socket !== next || disposed) return; |
| 61 | setState("Disconnected"); |
| 62 | setProblem(event.reason || "The console disconnected. Check that the VM is running, then reconnect."); |
| 63 | }; |
| 64 | }; |
| 65 | createEffect(connect); |
| 66 | createEffect(() => { if (props.active && state() === "Connected") handle()?.view.element.focus({ preventScroll: true }); }); |
| 67 | onCleanup(() => { disposed = true; socket?.close(); handle()?.dispose(); }); |
| 68 | return <div class="vm-serial" hidden={!props.active}> |
| 69 | <Portal mount={props.toolbar} ref={(element) => { element.className = "vm-serial-tools"; }}> |
| 70 | <Show when={props.active}> |
| 71 | <VMMenu label="Input"> |
| 72 | <button disabled={!props.enabled || state() !== "Connected"} onClick={() => socket?.send(new Uint8Array([3]))}><Keyboard size={14} aria-hidden="true" />Ctrl+C</button> |
| 73 | </VMMenu> |
| 74 | <VMMenu label="View"> |
| 75 | <button disabled={!props.enabled || !handle() || state() === "Connecting…"} onClick={connect}><RefreshCw size={14} aria-hidden="true" />Reconnect</button> |
| 76 | </VMMenu> |
| 77 | <span class="spacer" /> |
| 78 | <span class="vm-console-status" role="status">{props.enabled ? state() : "Off"}</span> |
| 79 | </Show> |
| 80 | </Portal> |
| 81 | <Show when={problem()}><p class="error" role="alert">{problem()}</p></Show> |
| 82 | <div class="vm-serial-surface"> |
| 83 | <div class="vm-serial-terminal" ref={host} inert={!props.enabled || state() !== "Connected"} aria-label={`${props.name} serial console`} /> |
| 84 | <Show when={props.enabled && state() === "Connected" && !hasOutput()}><div class="vm-serial-empty"> |
| 85 | <span>Press Enter to show the prompt</span> |
| 86 | <button class="button" onClick={() => { |
| 87 | socket?.send(new Uint8Array([13])); |
| 88 | handle()?.view.element.focus({ preventScroll: true }); |
| 89 | }}>Show prompt</button> |
| 90 | </div></Show> |
| 91 | </div> |
| 92 | </div>; |
| 93 | } |