| 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 | |
| 5 | import { isTerminalLinkActivation } from "~/terminal-links"; |
| 6 | import { readLocalApi } from "~/localApi"; |
| 7 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 8 | |
| 9 | export interface TerminalTheme { |
| 10 | foreground: string; |
| 11 | background: string; |
| 12 | cursor: string; |
| 13 | palette: readonly string[]; |
| 14 | selectionBackground: string; |
| 15 | } |
| 16 | |
| 17 | export interface TerminalSurfaceOptions { |
| 18 | theme: TerminalTheme; |
| 19 | font?: { family?: string; size: number }; |
| 20 | visible?: boolean; |
| 21 | onData: (data: string) => void; |
| 22 | onResize: (cols: number, rows: number) => void; |
| 23 | beforeKey: (event: KeyboardEvent) => boolean; |
| 24 | onLinkActivate: (text: string, event: MouseEvent) => void; |
| 25 | validatePath?: (path: string) => boolean; |
| 26 | canActivateLink?: (text: string) => boolean; |
| 27 | onContextMenu?: (event: MouseEvent) => void; |
| 28 | } |
| 29 | |
| 30 | let modulePromise: Promise<GhosttyModule> | null = null; |
| 31 | const ANSI_NAMES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; |
| 32 | const ANSI_TOKENS = [...ANSI_NAMES, ...ANSI_NAMES.map((name) => `bright-${name}`)]; |
| 33 | const decoder = new TextDecoder(); |
| 34 | |
| 35 | function applyTheme(mount: HTMLElement, theme: TerminalTheme) { |
| 36 | const style = mount.style; |
| 37 | style.setProperty("--clo-term-fg", theme.foreground); |
| 38 | style.setProperty("--clo-term-bg", theme.background); |
| 39 | style.setProperty("--clo-term-cursor", theme.cursor); |
| 40 | style.setProperty("--clo-term-selection", theme.selectionBackground); |
| 41 | theme.palette.forEach((color, index) => |
| 42 | style.setProperty(`--clo-term-ansi-${ANSI_TOKENS[index]}`, color), |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | export class TerminalSurface { |
| 47 | readonly input: HTMLTextAreaElement; |
| 48 | private readonly cleanups: (() => void)[] = []; |
| 49 | private disposed = false; |
| 50 | private replaying = false; |
| 51 | |
| 52 | private constructor( |
| 53 | private readonly mount: HTMLElement, |
| 54 | private readonly handle: TerminalHandle, |
| 55 | private readonly options: TerminalSurfaceOptions, |
| 56 | container: HTMLElement, |
| 57 | ) { |
| 58 | this.input = handle.view.input; |
| 59 | this.input.setAttribute("aria-label", mount.getAttribute("aria-label") ?? "Terminal input"); |
| 60 | const beforeKey = (event: KeyboardEvent) => { |
| 61 | if (event.isComposing || event.keyCode === 229) return; |
| 62 | const copy = |
| 63 | isTerminalLinkActivation(event) && !event.altKey && event.key.toLowerCase() === "c"; |
| 64 | const selection = copy ? this.getSelection() : ""; |
| 65 | if (selection !== "") { |
| 66 | event.preventDefault(); |
| 67 | event.stopImmediatePropagation(); |
| 68 | this.copyText(selection); |
| 69 | } |
| 70 | }; |
| 71 | const contextMenu = (event: MouseEvent) => { |
| 72 | if (event.defaultPrevented) return; |
| 73 | if (options.onContextMenu) { |
| 74 | options.onContextMenu(event); |
| 75 | return; |
| 76 | } |
| 77 | const api = readLocalApi(); |
| 78 | const text = this.getSelection(); |
| 79 | if (!api || text === "") return; |
| 80 | event.preventDefault(); |
| 81 | void api.contextMenu |
| 82 | .show([{ id: "copy", label: "Copy" }], { x: event.clientX, y: event.clientY }) |
| 83 | .then((action) => { |
| 84 | if (action === "copy" && !this.disposed) this.copyText(text); |
| 85 | }) |
| 86 | .catch((error: unknown) => { |
| 87 | if (!this.disposed) |
| 88 | this.write( |
| 89 | `\r\n[terminal] ${error instanceof Error ? error.message : "Unable to open terminal menu"}\r\n`, |
| 90 | ); |
| 91 | }); |
| 92 | }; |
| 93 | mount.addEventListener("keydown", beforeKey, true); |
| 94 | mount.addEventListener("contextmenu", contextMenu); |
| 95 | const focus = () => this.focus(); |
| 96 | container.addEventListener("focus", focus); |
| 97 | this.cleanups.push(() => mount.removeEventListener("keydown", beforeKey, true)); |
| 98 | this.cleanups.push(() => mount.removeEventListener("contextmenu", contextMenu)); |
| 99 | this.cleanups.push(() => container.removeEventListener("focus", focus)); |
| 100 | } |
| 101 | |
| 102 | static async create(container: HTMLElement, options: TerminalSurfaceOptions) { |
| 103 | modulePromise ??= GhosttyModule.fetch(wasmUrl).catch((error: unknown) => { |
| 104 | modulePromise = null; |
| 105 | throw error; |
| 106 | }); |
| 107 | const module = await modulePromise; |
| 108 | const mount = container.ownerDocument.createElement("div"); |
| 109 | mount.style.position = "absolute"; |
| 110 | mount.style.inset = "0"; |
| 111 | const label = container.getAttribute("aria-label"); |
| 112 | if (label !== null) mount.setAttribute("aria-label", label); |
| 113 | applyTheme(mount, options.theme); |
| 114 | const style = mount.style; |
| 115 | style.setProperty("--clo-term-font-size", `${options.font?.size ?? 12}px`); |
| 116 | if (options.font?.family) style.setProperty("--clo-term-font", options.font.family); |
| 117 | container.append(mount); |
| 118 | let surface: TerminalSurface | undefined; |
| 119 | let handle: TerminalHandle | undefined; |
| 120 | try { |
| 121 | handle = mountTerminal(mount, module, { |
| 122 | visible: options.visible !== false, |
| 123 | onData: (bytes) => { |
| 124 | if (!surface?.replaying) options.onData(decoder.decode(bytes)); |
| 125 | }, |
| 126 | onResize: ({ cols, rows }) => options.onResize(cols, rows), |
| 127 | onBeforeKey: (event) => options.beforeKey(event), |
| 128 | maxScrollbackBytes: 16 * 1024 * 1024, |
| 129 | links: { |
| 130 | ...(options.validatePath ? { validate: options.validatePath } : {}), |
| 131 | requireModifier: true, |
| 132 | onActivate: (link, event) => { |
| 133 | if (!isTerminalLinkActivation(event)) return; |
| 134 | if (options.canActivateLink?.(link.target) === false) return; |
| 135 | const target = |
| 136 | link.kind === "path" && link.line !== null |
| 137 | ? `${link.target}:${link.line}${link.column !== null ? `:${link.column}` : ""}` |
| 138 | : link.target; |
| 139 | options.onLinkActivate(target, event); |
| 140 | }, |
| 141 | }, |
| 142 | }); |
| 143 | surface = new TerminalSurface(mount, handle, options, container); |
| 144 | if (container.ownerDocument.activeElement === container) surface.focus(); |
| 145 | await handle.ready; |
| 146 | return surface; |
| 147 | } catch (error) { |
| 148 | if (surface) surface.dispose(); |
| 149 | else { |
| 150 | handle?.dispose(); |
| 151 | mount.remove(); |
| 152 | } |
| 153 | throw error; |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | private copyText(text: string) { |
| 158 | void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { |
| 159 | if (!this.disposed) |
| 160 | this.write( |
| 161 | `\r\n[terminal] ${error instanceof Error ? error.message : "Unable to copy terminal selection"}\r\n`, |
| 162 | ); |
| 163 | }); |
| 164 | } |
| 165 | |
| 166 | write(data: string) { |
| 167 | if (this.disposed) return; |
| 168 | this.handle.write(data); |
| 169 | } |
| 170 | |
| 171 | resetAndWrite(data: string) { |
| 172 | if (this.disposed) return; |
| 173 | this.clearSelection(); |
| 174 | this.replaying = true; |
| 175 | try { |
| 176 | this.handle.write(`\x1bc\x1b[3J${data}`); |
| 177 | this.handle.terminal.scrollToBottom(); |
| 178 | } finally { |
| 179 | this.replaying = false; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | setTheme(theme: TerminalTheme) { |
| 184 | applyTheme(this.mount, theme); |
| 185 | this.handle.view.refreshMetrics(); |
| 186 | } |
| 187 | |
| 188 | setFont(font: { family?: string; size: number }) { |
| 189 | this.mount.style.setProperty("--clo-term-font-size", `${font.size}px`); |
| 190 | if (font.family) this.mount.style.setProperty("--clo-term-font", font.family); |
| 191 | else this.mount.style.removeProperty("--clo-term-font"); |
| 192 | this.handle.view.refreshMetrics(); |
| 193 | } |
| 194 | |
| 195 | fit() { |
| 196 | this.handle.view.refreshMetrics(); |
| 197 | return this.handle.view.fit(); |
| 198 | } |
| 199 | resendSize() { |
| 200 | const { cols, rows } = this.fit(); |
| 201 | this.options.onResize(cols, rows); |
| 202 | } |
| 203 | focus() { |
| 204 | this.handle.view.focus(); |
| 205 | } |
| 206 | setVisible(visible: boolean) { |
| 207 | this.handle.view.setVisible(visible); |
| 208 | } |
| 209 | isAtBottom() { |
| 210 | const scrollbar = this.handle.terminal.scrollbar(); |
| 211 | return scrollbar.offset >= scrollbar.total - scrollbar.len; |
| 212 | } |
| 213 | scrollToBottom() { |
| 214 | this.handle.terminal.scrollToBottom(); |
| 215 | this.handle.view.scheduleFrame(); |
| 216 | } |
| 217 | |
| 218 | getSelection() { |
| 219 | const selection = this.mount.ownerDocument.getSelection(); |
| 220 | if ( |
| 221 | !selection || |
| 222 | !this.mount.contains(selection.anchorNode) || |
| 223 | !this.mount.contains(selection.focusNode) |
| 224 | ) |
| 225 | return ""; |
| 226 | return selection.toString(); |
| 227 | } |
| 228 | |
| 229 | hasSelection() { |
| 230 | return this.getSelection() !== ""; |
| 231 | } |
| 232 | |
| 233 | getSelectionPosition() { |
| 234 | const selection = this.mount.ownerDocument.getSelection(); |
| 235 | if (!selection || !this.hasSelection()) return null; |
| 236 | const row = (node: Node | null) => |
| 237 | (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(".clo-term-row") |
| 238 | ?.dataset.screenRow; |
| 239 | const start = row(selection.anchorNode); |
| 240 | const end = row(selection.focusNode); |
| 241 | if (start === undefined || end === undefined) return null; |
| 242 | return { |
| 243 | start: { y: Math.min(Number(start), Number(end)) }, |
| 244 | end: { y: Math.max(Number(start), Number(end)) }, |
| 245 | }; |
| 246 | } |
| 247 | |
| 248 | clearSelection() { |
| 249 | if (this.hasSelection()) this.mount.ownerDocument.getSelection()?.removeAllRanges(); |
| 250 | } |
| 251 | |
| 252 | selectAll() { |
| 253 | const rows = this.mount.querySelector(".clo-term-rows"); |
| 254 | if (!rows) return; |
| 255 | const range = this.mount.ownerDocument.createRange(); |
| 256 | range.selectNodeContents(rows); |
| 257 | const selection = this.mount.ownerDocument.getSelection(); |
| 258 | selection?.removeAllRanges(); |
| 259 | selection?.addRange(range); |
| 260 | } |
| 261 | |
| 262 | paste(text: string) { |
| 263 | if (!this.input.readOnly) { |
| 264 | this.input.focus({ preventScroll: true }); |
| 265 | this.options.onData(decoder.decode(this.handle.terminal.encodePaste(text))); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | dispose() { |
| 270 | if (this.disposed) return; |
| 271 | this.disposed = true; |
| 272 | this.cleanups.forEach((cleanup) => cleanup()); |
| 273 | this.handle.dispose(); |
| 274 | this.mount.remove(); |
| 275 | } |
| 276 | } |