nandi/oripublic Fork 0
4166f8fba899b222fab287c398dcab9bf6f6e5c9
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

TerminalPane.tsx · 94 lines · 2.6 KBTypeScript Blame HistoryRaw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/**
 * TerminalPane is an interactive shell in the browser: xterm.js on the front,
 * a real PTY on the backend, joined by the /ws/terminal WebSocket (raw output
 * as binary frames, input and resizes as JSON — see the protocol reference).
 *
 * The pane is kept mounted (hidden, not unmounted) while other tabs are
 * active, so the shell session survives tab switches.
 *
 * @example
 * <TerminalPane />
 */

import { useEffect, useRef } from "react";
import "@xterm/xterm/css/xterm.css";

/** terminalWsUrl derives the terminal endpoint from the page's origin. */
export function terminalWsUrl(): string {
	const scheme = window.location.protocol === "https:" ? "wss" : "ws";
	return `${scheme}://${window.location.host}/ws/terminal`;
}

export function TerminalPane() {
	const containerRef = useRef<HTMLDivElement>(null);

	useEffect(() => {
		const container = containerRef.current;
		if (!container) {
			return;
		}
		let disposed = false;
		let cleanup: (() => void) | undefined;

		// xterm is heavy: loaded on first use only.
		Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]).then(
			([{ Terminal }, { FitAddon }]) => {
				if (disposed) {
					return;
				}
				// Always dark, IDE-style, whatever the app theme.
				const term = new Terminal({
					fontSize: 13,
					cursorBlink: true,
					theme: { background: "#1e1e1e" },
				});
				const fit = new FitAddon();
				term.loadAddon(fit);
				term.open(container);
				fit.fit();

				const socket = new WebSocket(terminalWsUrl());
				socket.binaryType = "arraybuffer";
				socket.onmessage = (event) =>
					term.write(new Uint8Array(event.data as ArrayBuffer));
				socket.onopen = () =>
					socket.send(
						JSON.stringify({
							type: "resize",
							cols: term.cols,
							rows: term.rows,
						}),
					);
				socket.onclose = () => term.write("\r\n[terminal session closed]\r\n");

				const inputSubscription = term.onData((data) => {
					if (socket.readyState === WebSocket.OPEN) {
						socket.send(JSON.stringify({ type: "input", data }));
					}
				});
				const resizeSubscription = term.onResize(({ cols, rows }) => {
					if (socket.readyState === WebSocket.OPEN) {
						socket.send(JSON.stringify({ type: "resize", cols, rows }));
					}
				});
				const observer = new ResizeObserver(() => fit.fit());
				observer.observe(container);

				cleanup = () => {
					observer.disconnect();
					inputSubscription.dispose();
					resizeSubscription.dispose();
					socket.close();
					term.dispose();
				};
			},
		);

		return () => {
			disposed = true;
			cleanup?.();
		};
	}, []);

	return <div className="terminal" ref={containerRef} />;
}