blob: 106f7e254eebbee6a788966e55a1be6b8234b511 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { Terminal } from "@xterm/xterm";
2import { FitAddon } from "@xterm/addon-fit";
3
Sean McCullough71941bd2025-04-18 13:31:48 -07004import { css, html, LitElement } from "lit";
5import { customElement, property, state } from "lit/decorators.js";
6import { DataManager, ConnectionStatus } from "../data";
7import { State, TimelineMessage } from "../types";
8import "./sketch-container-status";
Sean McCullough86b56862025-04-18 13:04:03 -07009
Sean McCullough71941bd2025-04-18 13:31:48 -070010@customElement("sketch-terminal")
Sean McCullough86b56862025-04-18 13:04:03 -070011export class SketchTerminal extends LitElement {
Earl Lee2e463fb2025-04-17 11:22:22 -070012 // Terminal instance
13 private terminal: Terminal | null = null;
14 // Terminal fit addon for handling resize
15 private fitAddon: FitAddon | null = null;
16 // Terminal EventSource for SSE
17 private terminalEventSource: EventSource | null = null;
18 // Terminal ID (always 1 for now, will support 1-9 later)
19 private terminalId: string = "1";
20 // Queue for serializing terminal inputs
21 private terminalInputQueue: string[] = [];
22 // Flag to track if we're currently processing a terminal input
23 private processingTerminalInput: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070024
Sean McCullough86b56862025-04-18 13:04:03 -070025 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070026 /* Terminal View Styles */
27 .terminal-view {
28 width: 100%;
29 background-color: #f5f5f5;
30 border-radius: 8px;
31 overflow: hidden;
32 margin-bottom: 20px;
33 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
34 padding: 15px;
35 height: 70vh;
36 }
Earl Lee2e463fb2025-04-17 11:22:22 -070037
Sean McCullough71941bd2025-04-18 13:31:48 -070038 .terminal-container {
39 width: 100%;
40 height: 100%;
41 overflow: hidden;
42 }
43 `;
Sean McCullough86b56862025-04-18 13:04:03 -070044
45 constructor() {
46 super();
47 this._resizeHandler = this._resizeHandler.bind(this);
48 }
49
50 connectedCallback() {
51 super.connectedCallback();
52 this.loadXtermlCSS();
53 // Setup resize handler
54 window.addEventListener("resize", this._resizeHandler);
55 }
56
57 disconnectedCallback() {
58 super.disconnectedCallback();
59
60 window.removeEventListener("resize", this._resizeHandler);
61
62 this.closeTerminalConnections();
63
64 if (this.terminal) {
65 this.terminal.dispose();
66 this.terminal = null;
67 }
68 this.fitAddon = null;
69 }
70
71 firstUpdated() {
72 this.initializeTerminal();
73 }
74
75 _resizeHandler() {
76 if (this.fitAddon) {
77 this.fitAddon.fit();
78 // Send resize information to server
79 this.sendTerminalResize();
80 }
81 }
82
83 // Load xterm CSS into the shadow DOM
84 private async loadXtermlCSS() {
85 try {
86 // Check if diff2html styles are already loaded
Sean McCullough71941bd2025-04-18 13:31:48 -070087 const styleId = "xterm-styles";
Sean McCullough86b56862025-04-18 13:04:03 -070088 if (this.shadowRoot?.getElementById(styleId)) {
89 return; // Already loaded
90 }
91
92 // Fetch the diff2html CSS
Sean McCullough71941bd2025-04-18 13:31:48 -070093 const response = await fetch("static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -070094
95 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -070096 console.error(
97 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
98 );
Sean McCullough86b56862025-04-18 13:04:03 -070099 return;
100 }
101
102 const cssText = await response.text();
103
104 // Create a style element and append to shadow DOM
Sean McCullough71941bd2025-04-18 13:31:48 -0700105 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700106 style.id = styleId;
107 style.textContent = cssText;
108 this.renderRoot?.appendChild(style);
109
Sean McCullough71941bd2025-04-18 13:31:48 -0700110 console.log("xterm CSS loaded into shadow DOM");
Sean McCullough86b56862025-04-18 13:04:03 -0700111 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700112 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700113 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 }
115
116 /**
117 * Initialize the terminal component
118 * @param terminalContainer The DOM element to contain the terminal
119 */
120 public async initializeTerminal(): Promise<void> {
Sean McCullough71941bd2025-04-18 13:31:48 -0700121 const terminalContainer = this.renderRoot.querySelector(
122 "#terminalContainer",
123 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700124
125 if (!terminalContainer) {
126 console.error("Terminal container not found");
127 return;
128 }
129
130 // If terminal is already initialized, just focus it
131 if (this.terminal) {
132 this.terminal.focus();
133 if (this.fitAddon) {
134 this.fitAddon.fit();
135 }
136 return;
137 }
138
139 // Clear the terminal container
140 terminalContainer.innerHTML = "";
141
142 // Create new terminal instance
143 this.terminal = new Terminal({
144 cursorBlink: true,
145 theme: {
146 background: "#f5f5f5",
147 foreground: "#333333",
148 cursor: "#0078d7",
149 selectionBackground: "rgba(0, 120, 215, 0.4)",
150 },
151 });
152
153 // Add fit addon to handle terminal resizing
154 this.fitAddon = new FitAddon();
155 this.terminal.loadAddon(this.fitAddon);
156
157 // Open the terminal in the container
158 this.terminal.open(terminalContainer);
159
160 // Connect to WebSocket
161 await this.connectTerminal();
162
163 // Fit the terminal to the container
164 this.fitAddon.fit();
165
Earl Lee2e463fb2025-04-17 11:22:22 -0700166 // Focus the terminal
167 this.terminal.focus();
168 }
169
170 /**
171 * Connect to terminal events stream
172 */
173 private async connectTerminal(): Promise<void> {
174 if (!this.terminal) {
175 return;
176 }
177
178 // Close existing connections if any
179 this.closeTerminalConnections();
180
181 try {
182 // Connect directly to the SSE endpoint for terminal 1
183 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700184 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700185 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
186 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700187
Earl Lee2e463fb2025-04-17 11:22:22 -0700188 // Handle SSE events
189 this.terminalEventSource.onopen = () => {
190 console.log("Terminal SSE connection opened");
191 this.sendTerminalResize();
192 };
Sean McCullough86b56862025-04-18 13:04:03 -0700193
Earl Lee2e463fb2025-04-17 11:22:22 -0700194 this.terminalEventSource.onmessage = (event) => {
195 if (this.terminal) {
196 // Decode base64 data before writing to terminal
197 try {
198 const decoded = atob(event.data);
199 this.terminal.write(decoded);
200 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700201 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700202 // Fallback to raw data if decoding fails
203 this.terminal.write(event.data);
204 }
205 }
206 };
Sean McCullough86b56862025-04-18 13:04:03 -0700207
Earl Lee2e463fb2025-04-17 11:22:22 -0700208 this.terminalEventSource.onerror = (error) => {
209 console.error("Terminal SSE error:", error);
210 if (this.terminal) {
211 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
212 }
213 // Attempt to reconnect if the connection was lost
214 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
215 this.closeTerminalConnections();
216 }
217 };
Sean McCullough86b56862025-04-18 13:04:03 -0700218
Earl Lee2e463fb2025-04-17 11:22:22 -0700219 // Send key inputs to the server via POST requests
220 if (this.terminal) {
221 this.terminal.onData((data) => {
222 this.sendTerminalInput(data);
223 });
224 }
225 } catch (error) {
226 console.error("Failed to connect to terminal:", error);
227 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700228 this.terminal.write(
229 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
230 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 }
232 }
233 }
234
235 /**
236 * Close any active terminal connections
237 */
238 private closeTerminalConnections(): void {
239 if (this.terminalEventSource) {
240 this.terminalEventSource.close();
241 this.terminalEventSource = null;
242 }
243 }
244
245 /**
246 * Send input to the terminal
247 * @param data The input data to send
248 */
249 private async sendTerminalInput(data: string): Promise<void> {
250 // Add the data to the queue
251 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700252
Earl Lee2e463fb2025-04-17 11:22:22 -0700253 // If we're not already processing inputs, start processing
254 if (!this.processingTerminalInput) {
255 await this.processTerminalInputQueue();
256 }
257 }
258
259 /**
260 * Process the terminal input queue in order
261 */
262 private async processTerminalInputQueue(): Promise<void> {
263 if (this.terminalInputQueue.length === 0) {
264 this.processingTerminalInput = false;
265 return;
266 }
Sean McCullough86b56862025-04-18 13:04:03 -0700267
Earl Lee2e463fb2025-04-17 11:22:22 -0700268 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700269
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700271 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700272
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 // Take all currently available items from the queue
274 while (this.terminalInputQueue.length > 0) {
275 combinedData += this.terminalInputQueue.shift()!;
276 }
Sean McCullough86b56862025-04-18 13:04:03 -0700277
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 try {
279 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700280 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
281 const response = await fetch(
282 `${baseUrl}/terminal/input/${this.terminalId}`,
283 {
284 method: "POST",
285 body: combinedData,
286 headers: {
287 "Content-Type": "text/plain",
288 },
289 },
290 );
Sean McCullough86b56862025-04-18 13:04:03 -0700291
Earl Lee2e463fb2025-04-17 11:22:22 -0700292 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700293 console.error(
294 `Failed to send terminal input: ${response.status} ${response.statusText}`,
295 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700296 }
297 } catch (error) {
298 console.error("Error sending terminal input:", error);
299 }
Sean McCullough86b56862025-04-18 13:04:03 -0700300
Earl Lee2e463fb2025-04-17 11:22:22 -0700301 // Continue processing the queue (for any new items that may have been added)
302 await this.processTerminalInputQueue();
303 }
304
305 /**
306 * Send terminal resize information to the server
307 */
308 private async sendTerminalResize(): Promise<void> {
309 if (!this.terminal || !this.fitAddon) {
310 return;
311 }
312
313 // Get terminal dimensions
314 try {
315 // Send resize message in a format the server can understand
316 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700317 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
318 const response = await fetch(
319 `${baseUrl}/terminal/input/${this.terminalId}`,
320 {
321 method: "POST",
322 body: JSON.stringify({
323 type: "resize",
324 cols: this.terminal.cols || 80, // Default to 80 if undefined
325 rows: this.terminal.rows || 24, // Default to 24 if undefined
326 }),
327 headers: {
328 "Content-Type": "application/json",
329 },
330 },
331 );
Sean McCullough86b56862025-04-18 13:04:03 -0700332
Earl Lee2e463fb2025-04-17 11:22:22 -0700333 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700334 console.error(
335 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
336 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700337 }
338 } catch (error) {
339 console.error("Error sending terminal resize:", error);
340 }
341 }
342
Sean McCullough86b56862025-04-18 13:04:03 -0700343 render() {
344 return html`
345 <div id="terminalView" class="terminal-view">
Sean McCullough71941bd2025-04-18 13:31:48 -0700346 <div id="terminalContainer" class="terminal-container"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700347 </div>
348 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700349 }
350}
Sean McCullough86b56862025-04-18 13:04:03 -0700351
352declare global {
353 interface HTMLElementTagNameMap {
354 "sketch-terminal": SketchTerminal;
355 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700356}