Skip to content
File

Blob: src/client/components/terminal-pane.tsx

typescript160 lines
1import { useEffect, useRef } from "react";
2import { GhosttyTerminalAdapter } from "../terminal/adapter";
3import { TerminalSocket } from "../terminal/socket";
4import type { SocketState } from "../terminal/socket";
5 
6interface TerminalPaneProps {
7 /** Workspace name — the server derives the actual sandbox ID from user identity + workspace. */
8 workspace: string;
9 sessionId?: string;
10 onStateChange?: (state: SocketState) => void;
11}
12 
13export interface TerminalPaneHandle {
14 sendCtrlC(): void;
15 sendCtrlD(): void;
16 focus(): void;
17}
18 
19/**
20 * TerminalPane — renders a ghostty-web terminal connected to a Cloudflare
21 * Sandbox PTY via WebSocket.
22 *
23 * The component owns the full lifecycle:
24 * create adapter → mount → connect socket → wire I/O → cleanup on unmount
25 *
26 * Reconnecting to the same session resets the terminal before the server
27 * replays its PTY buffer, avoiding duplicate output.
28 */
29export function TerminalPane({ workspace, sessionId, onStateChange }: TerminalPaneProps) {
30 const containerRef = useRef<HTMLDivElement>(null);
31 
32 // Use a ref so the effect closure always has the latest callback
33 // without re-running the effect on every parent render.
34 const onStateChangeRef = useRef(onStateChange);
35 onStateChangeRef.current = onStateChange;
36 
37 useEffect(() => {
38 const container = containerRef.current;
39 if (!container) return;
40 let disposed = false;
41 let socket: TerminalSocket | null = null;
42 
43 // ---- terminal adapter ----
44 const terminal = new GhosttyTerminalAdapter({
45 cursorBlink: true,
46 fontSize: 14,
47 });
48 terminal.mount(container);
49 
50 // ---- wire user input → socket ----
51 terminal.onInput((data) => socket?.sendInput(data));
52 
53 // ---- debounced resize → socket ----
54 let resizeTimer: ReturnType<typeof setTimeout> | null = null;
55 terminal.onResize((cols, rows) => {
56 if (resizeTimer) clearTimeout(resizeTimer);
57 resizeTimer = setTimeout(() => {
58 if (cols > 0 && rows > 0) {
59 socket?.sendResize(cols, rows);
60 }
61 }, 150);
62 });
63 
64 // ---- resize observers ----
65 const observer = new ResizeObserver(() => {
66 requestAnimationFrame(() => terminal.fit());
67 });
68 observer.observe(container);
69 
70 const onWindowResize = () => terminal.fit();
71 window.addEventListener("resize", onWindowResize);
72 
73 // ---- connect ----
74 void (async () => {
75 const initialSize = await terminal.prepareForAttach();
76 if (disposed) return;
77 
78 const proto = location.protocol === "https:" ? "wss:" : "ws:";
79 const params = new URLSearchParams({
80 workspace,
81 cols: String(initialSize.cols),
82 rows: String(initialSize.rows),
83 });
84 if (sessionId) params.set("session", sessionId);
85 const wsUrl = `${proto}//${location.host}/ws/terminal?${params}`;
86 
87 const activeSocket = new TerminalSocket({
88 url: wsUrl,
89 
90 onOutput(data) {
91 terminal.write(new Uint8Array(data));
92 },
93 
94 onStateChange(state, error) {
95 onStateChangeRef.current?.(state);
96 // Reset terminal before server replays the PTY buffer
97 // to prevent duplicate output on reconnect.
98 if (state === "reconnecting") {
99 terminal.reset();
100 }
101 if (error) {
102 console.error("[ccccocc] terminal:", error.message);
103 }
104 },
105 
106 onReady() {
107 // Send actual dimensions once the terminal is live.
108 const { cols, rows } = terminal;
109 if (cols > 0 && rows > 0) {
110 activeSocket.sendResize(cols, rows);
111 }
112 terminal.focus();
113 },
114 
115 onSessionAttached({ resumed, isReconnect }) {
116 if (isReconnect && !resumed) {
117 // Reconnect got a fresh shell — container likely restarted
118 terminal.write(
119 "\r\n\x1b[93m[Container restarted \u2014 previous shell state and files are gone unless persisted]\x1b[0m\r\n",
120 );
121 } else if (isReconnect && resumed) {
122 terminal.write("\r\n\x1b[90m[Resumed existing PTY session]\x1b[0m\r\n");
123 }
124 // First connect: no message needed — the shell prompt speaks for itself
125 },
126 
127 onExit(code, signal) {
128 const sig = signal ? `, signal ${signal}` : "";
129 terminal.write(`\r\n\x1b[90m[Process exited: code ${code}${sig}]\x1b[0m\r\n`);
130 },
131 });
132 
133 socket = activeSocket;
134 if (disposed) {
135 activeSocket.disconnect();
136 return;
137 }
138 activeSocket.connect();
139 })();
140 
141 // ---- cleanup ----
142 return () => {
143 disposed = true;
144 window.removeEventListener("resize", onWindowResize);
145 observer.disconnect();
146 if (resizeTimer) clearTimeout(resizeTimer);
147 socket?.disconnect();
148 terminal.dispose();
149 };
150 }, [workspace, sessionId]);
151 
152 return (
153 <div
154 ref={containerRef}
155 className="terminal-pane bg-canvas overflow-hidden"
156 style={{ width: "100%", height: "100%" }}
157 />
158 );
159}