1/** @jsxImportSource solid-js */
2
3import { createEffect, onCleanup, splitProps, untrack, type JSX } from "solid-js";
4
5import { ansiToHtml } from "./ansi.ts";
6import type { GhosttyModule } from "./ghostty/module.ts";
7import { mountTerminal, type MountTerminalOptions, type TerminalHandle } from "./mount.ts";
8
9export 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. */
15export function AnsiText(props: AnsiTextProps): JSX.Element {
16 const [local, attributes] = splitProps(props, ["text"]);
17 return <span {...attributes} innerHTML={ansiToHtml(local.text)} />;
18}
19
20export 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. */
30export 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}