blob: 4ffccfddf34712116746f56a317a42571c9d580f [file] [log] [blame]
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import "./sketch-container-status";
@customElement("sketch-terminal")
export class SketchTerminal extends LitElement {
// Terminal instance
private terminal: Terminal | null = null;
// Terminal fit addon for handling resize
private fitAddon: FitAddon | null = null;
// Terminal EventSource for SSE
private terminalEventSource: EventSource | null = null;
// Terminal ID (always 1 for now, will support 1-9 later)
private terminalId: string = "1";
// Queue for serializing terminal inputs
private terminalInputQueue: string[] = [];
// Flag to track if we're currently processing a terminal input
private processingTerminalInput: boolean = false;
static styles = css`
/* Terminal View Styles */
.terminal-view {
width: 100%;
background-color: #f5f5f5;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 15px;
height: 70vh;
}
.terminal-container {
width: 100%;
height: 100%;
overflow: hidden;
}
`;
constructor() {
super();
this._resizeHandler = this._resizeHandler.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.loadXtermlCSS();
// Setup resize handler
window.addEventListener("resize", this._resizeHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._resizeHandler);
this.closeTerminalConnections();
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
firstUpdated() {
this.initializeTerminal();
}
_resizeHandler() {
if (this.fitAddon) {
this.fitAddon.fit();
// Send resize information to server
this.sendTerminalResize();
}
}
// Load xterm CSS into the shadow DOM
private async loadXtermlCSS() {
try {
// Check if diff2html styles are already loaded
const styleId = "xterm-styles";
if (this.shadowRoot?.getElementById(styleId)) {
return; // Already loaded
}
// Fetch the diff2html CSS
const response = await fetch("static/xterm.css");
if (!response.ok) {
console.error(
`Failed to load xterm CSS: ${response.status} ${response.statusText}`,
);
return;
}
const cssText = await response.text();
// Create a style element and append to shadow DOM
const style = document.createElement("style");
style.id = styleId;
style.textContent = cssText;
this.renderRoot?.appendChild(style);
console.log("xterm CSS loaded into shadow DOM");
} catch (error) {
console.error("Error loading xterm CSS:", error);
}
}
/**
* Initialize the terminal component
* @param terminalContainer The DOM element to contain the terminal
*/
public async initializeTerminal(): Promise<void> {
const terminalContainer = this.renderRoot.querySelector(
"#terminalContainer",
) as HTMLElement;
if (!terminalContainer) {
console.error("Terminal container not found");
return;
}
// If terminal is already initialized, just focus it
if (this.terminal) {
this.terminal.focus();
if (this.fitAddon) {
this.fitAddon.fit();
}
return;
}
// Clear the terminal container
terminalContainer.innerHTML = "";
// Create new terminal instance
this.terminal = new Terminal({
cursorBlink: true,
theme: {
background: "#f5f5f5",
foreground: "#333333",
cursor: "#0078d7",
selectionBackground: "rgba(0, 120, 215, 0.4)",
},
});
// Add fit addon to handle terminal resizing
this.fitAddon = new FitAddon();
this.terminal.loadAddon(this.fitAddon);
// Open the terminal in the container
this.terminal.open(terminalContainer);
// Connect to WebSocket
await this.connectTerminal();
// Fit the terminal to the container
this.fitAddon.fit();
// Focus the terminal
this.terminal.focus();
}
/**
* Connect to terminal events stream
*/
private async connectTerminal(): Promise<void> {
if (!this.terminal) {
return;
}
// Close existing connections if any
this.closeTerminalConnections();
try {
// Connect directly to the SSE endpoint for terminal 1
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
this.terminalEventSource = new EventSource(eventsUrl);
// Handle SSE events
this.terminalEventSource.onopen = () => {
console.log("Terminal SSE connection opened");
this.sendTerminalResize();
};
this.terminalEventSource.onmessage = (event) => {
if (this.terminal) {
// Decode base64 data before writing to terminal
try {
// @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
const decoded = base64ToUint8Array(event.data);
this.terminal.write(decoded);
} catch (e) {
console.error("Error decoding terminal data:", e);
}
}
};
this.terminalEventSource.onerror = (error) => {
console.error("Terminal SSE error:", error);
if (this.terminal) {
this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
}
// Attempt to reconnect if the connection was lost
if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
this.closeTerminalConnections();
}
};
// Send key inputs to the server via POST requests
if (this.terminal) {
this.terminal.onData((data) => {
this.sendTerminalInput(data);
});
}
} catch (error) {
console.error("Failed to connect to terminal:", error);
if (this.terminal) {
this.terminal.write(
`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
);
}
}
}
/**
* Close any active terminal connections
*/
private closeTerminalConnections(): void {
if (this.terminalEventSource) {
this.terminalEventSource.close();
this.terminalEventSource = null;
}
}
/**
* Send input to the terminal
* @param data The input data to send
*/
private async sendTerminalInput(data: string): Promise<void> {
// Add the data to the queue
this.terminalInputQueue.push(data);
// If we're not already processing inputs, start processing
if (!this.processingTerminalInput) {
await this.processTerminalInputQueue();
}
}
/**
* Process the terminal input queue in order
*/
private async processTerminalInputQueue(): Promise<void> {
if (this.terminalInputQueue.length === 0) {
this.processingTerminalInput = false;
return;
}
this.processingTerminalInput = true;
// Concatenate all available inputs from the queue into a single request
let combinedData = "";
// Take all currently available items from the queue
while (this.terminalInputQueue.length > 0) {
combinedData += this.terminalInputQueue.shift()!;
}
try {
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
const response = await fetch(
`${baseUrl}/terminal/input/${this.terminalId}`,
{
method: "POST",
body: combinedData,
headers: {
"Content-Type": "text/plain",
},
},
);
if (!response.ok) {
console.error(
`Failed to send terminal input: ${response.status} ${response.statusText}`,
);
}
} catch (error) {
console.error("Error sending terminal input:", error);
}
// Continue processing the queue (for any new items that may have been added)
await this.processTerminalInputQueue();
}
/**
* Send terminal resize information to the server
*/
private async sendTerminalResize(): Promise<void> {
if (!this.terminal || !this.fitAddon) {
return;
}
// Get terminal dimensions
try {
// Send resize message in a format the server can understand
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
const response = await fetch(
`${baseUrl}/terminal/input/${this.terminalId}`,
{
method: "POST",
body: JSON.stringify({
type: "resize",
cols: this.terminal.cols || 80, // Default to 80 if undefined
rows: this.terminal.rows || 24, // Default to 24 if undefined
}),
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
console.error(
`Failed to send terminal resize: ${response.status} ${response.statusText}`,
);
}
} catch (error) {
console.error("Error sending terminal resize:", error);
}
}
render() {
return html`
<div id="terminalView" class="terminal-view">
<div id="terminalContainer" class="terminal-container"></div>
</div>
`;
}
}
function base64ToUint8Array(base64String) {
// This isn't yet available in Chrome, but Safari has it!
// @ts-ignore
if (Uint8Array.fromBase64) {
// @ts-ignore
return Uint8Array.fromBase64(base64String);
}
const binaryString = atob(base64String);
return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
}
declare global {
interface HTMLElementTagNameMap {
"sketch-terminal": SketchTerminal;
}
}