| 1 | // Thin React binding over Terminal + TerminalView. Behavior lives in the |
| 2 | // headless modules; this file only manages lifecycle. |
| 3 | |
| 4 | import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from "react"; |
| 5 | |
| 6 | import { ansiToHtml } from "./ansi.ts"; |
| 7 | import type { GhosttyModule } from "./ghostty/module.ts"; |
| 8 | import { mountTerminal, type MountTerminalOptions, type TerminalHandle } from "./mount.ts"; |
| 9 | |
| 10 | export interface AnsiTextProps extends Omit< |
| 11 | HTMLAttributes<HTMLSpanElement>, |
| 12 | "children" | "dangerouslySetInnerHTML" |
| 13 | > { |
| 14 | text: string; |
| 15 | } |
| 16 | |
| 17 | /** Static ANSI text with no terminal viewport, input, or scroll behavior. */ |
| 18 | export function AnsiText({ text, ...props }: AnsiTextProps) { |
| 19 | return <span {...props} dangerouslySetInnerHTML={{ __html: ansiToHtml(text) }} />; |
| 20 | } |
| 21 | |
| 22 | export interface TerminalPaneProps extends MountTerminalOptions { |
| 23 | module: GhosttyModule; |
| 24 | /** @deprecated Use `scrollbar`; retained for existing consumers. */ |
| 25 | initialScrollbar?: boolean; |
| 26 | /** Receives the mounted handle and may return caller cleanup for unmount/remount. */ |
| 27 | onReady?: (handle: TerminalHandle) => void | (() => void); |
| 28 | className?: string; |
| 29 | style?: CSSProperties; |
| 30 | } |
| 31 | |
| 32 | export function TerminalPane(props: TerminalPaneProps) { |
| 33 | const hostRef = useRef<HTMLDivElement | null>(null); |
| 34 | const propsRef = useRef(props); |
| 35 | propsRef.current = props; |
| 36 | |
| 37 | useEffect(() => { |
| 38 | const host = hostRef.current; |
| 39 | if (host === null) return; |
| 40 | const p = propsRef.current; |
| 41 | const handle = mountTerminal(host, p.module, { |
| 42 | ...p, |
| 43 | onData: (bytes) => propsRef.current.onData?.(bytes), |
| 44 | onBeforeKey: (event) => propsRef.current.onBeforeKey?.(event) ?? true, |
| 45 | onResize: (size) => propsRef.current.onResize?.(size), |
| 46 | onTitleChanged: (title) => propsRef.current.onTitleChanged?.(title), |
| 47 | onPwdChanged: (pwd) => propsRef.current.onPwdChanged?.(pwd), |
| 48 | onBell: () => propsRef.current.onBell?.(), |
| 49 | onShellMark: (mark) => propsRef.current.onShellMark?.(mark), |
| 50 | onCommandStart: (command) => propsRef.current.onCommandStart?.(command), |
| 51 | onCommandEnd: (command) => propsRef.current.onCommandEnd?.(command), |
| 52 | onClipboardWrite: (write) => propsRef.current.onClipboardWrite?.(write), |
| 53 | ...(p.scrollbar !== undefined || p.initialScrollbar !== undefined |
| 54 | ? { scrollbar: p.scrollbar ?? p.initialScrollbar } |
| 55 | : {}), |
| 56 | }); |
| 57 | let cleanup: void | (() => void); |
| 58 | try { |
| 59 | cleanup = p.onReady?.(handle); |
| 60 | } catch (error) { |
| 61 | handle.dispose(); |
| 62 | throw error; |
| 63 | } |
| 64 | return () => { |
| 65 | try { |
| 66 | cleanup?.(); |
| 67 | } finally { |
| 68 | handle.dispose(); |
| 69 | } |
| 70 | }; |
| 71 | }, [props.module]); |
| 72 | |
| 73 | return ( |
| 74 | <div |
| 75 | className={`clo-term-mount${props.className === undefined ? "" : ` ${props.className}`}`} |
| 76 | style={props.style} |
| 77 | > |
| 78 | <div ref={hostRef} style={{ width: "100%", height: "100%" }} /> |
| 79 | </div> |
| 80 | ); |
| 81 | } |