| 1 | /** @jsxImportSource solid-js */ |
| 2 | |
| 3 | import { createEffect, onCleanup, splitProps, untrack, type JSX } from "solid-js"; |
| 4 | |
| 5 | import { ansiToHtml } from "./ansi.ts"; |
| 6 | import type { GhosttyModule } from "./ghostty/module.ts"; |
| 7 | import { mountTerminal, type MountTerminalOptions, type TerminalHandle } from "./mount.ts"; |
| 8 | |
| 9 | export type AnsiTextProps = { text: string } & Omit< |
| 10 | JSX.HTMLAttributes<HTMLSpanElement>, |
| 11 | "children" | "innerHTML" |
| 12 | >; |
| 13 | |
| 14 | /** Static ANSI text with no terminal viewport, input, or scroll behavior. */ |
| 15 | export function AnsiText(props: AnsiTextProps): JSX.Element { |
| 16 | const [local, attributes] = splitProps(props, ["text"]); |
| 17 | return <span {...attributes} innerHTML={ansiToHtml(local.text)} />; |
| 18 | } |
| 19 | |
| 20 | export interface TerminalPaneProps extends MountTerminalOptions { |
| 21 | module: GhosttyModule; |
| 22 | /** Receives the mounted handle and may return caller cleanup for unmount/remount. */ |
| 23 | onReady?: (handle: TerminalHandle) => void | (() => void); |
| 24 | class?: string; |
| 25 | classList?: Record<string, boolean | undefined>; |
| 26 | style?: JSX.CSSProperties | string; |
| 27 | } |
| 28 | |
| 29 | /** Solid lifecycle adapter over `mountTerminal`. Creation options are read when `module` mounts. */ |
| 30 | export function TerminalPane(props: TerminalPaneProps): JSX.Element { |
| 31 | let host: HTMLDivElement | undefined; |
| 32 | |
| 33 | createEffect(() => { |
| 34 | const module = props.module; |
| 35 | const container = host; |
| 36 | if (container === undefined) throw new Error("solid terminal host did not mount"); |
| 37 | const options = untrack<MountTerminalOptions>(() => ({ |
| 38 | ...props, |
| 39 | onData: (bytes) => props.onData?.(bytes), |
| 40 | onBeforeKey: (event) => props.onBeforeKey?.(event) ?? true, |
| 41 | onResize: (size) => props.onResize?.(size), |
| 42 | onTitleChanged: (title) => props.onTitleChanged?.(title), |
| 43 | onPwdChanged: (pwd) => props.onPwdChanged?.(pwd), |
| 44 | onBell: () => props.onBell?.(), |
| 45 | onShellMark: (mark) => props.onShellMark?.(mark), |
| 46 | onCommandStart: (command) => props.onCommandStart?.(command), |
| 47 | onCommandEnd: (command) => props.onCommandEnd?.(command), |
| 48 | onClipboardWrite: (write) => props.onClipboardWrite?.(write), |
| 49 | })); |
| 50 | const handle = mountTerminal(container, module, options); |
| 51 | let cleanup: void | (() => void); |
| 52 | onCleanup(() => { |
| 53 | try { |
| 54 | cleanup?.(); |
| 55 | } finally { |
| 56 | handle.dispose(); |
| 57 | } |
| 58 | }); |
| 59 | try { |
| 60 | cleanup = untrack(() => props.onReady?.(handle)); |
| 61 | } catch (error) { |
| 62 | handle.dispose(); |
| 63 | throw error; |
| 64 | } |
| 65 | }); |
| 66 | |
| 67 | return ( |
| 68 | <div |
| 69 | class={[ |
| 70 | "clo-term-mount", |
| 71 | props.class, |
| 72 | ...Object.entries(props.classList ?? {}) |
| 73 | .filter(([name, enabled]) => name !== "clo-term-mount" && enabled) |
| 74 | .map(([name]) => name), |
| 75 | ] |
| 76 | .filter(Boolean) |
| 77 | .join(" ")} |
| 78 | style={props.style} |
| 79 | > |
| 80 | <div ref={(element) => (host = element)} style={{ width: "100%", height: "100%" }} /> |
| 81 | </div> |
| 82 | ); |
| 83 | } |