"use client"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal as XTerm } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; import "@xterm/xterm/css/xterm.css"; type TerminalProps = { podName: string; container: string; shell?: string; mode?: "shell" | "attach"; onClose?: () => void; }; export function Terminal({ podName, container, shell = "/bin/sh", mode = "shell", onClose, }: TerminalProps) { const terminalRef = useRef(null); const xtermRef = useRef(null); const wsRef = useRef(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); const searchInputRef = useRef(null); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); const [showSearch, setShowSearch] = useState(false); const [searchTerm, setSearchTerm] = useState(""); useEffect(() => { if (showSearch && searchInputRef.current) { searchInputRef.current.focus(); } }, [showSearch]); useEffect(() => { if (!terminalRef.current) return; const term = new XTerm({ cursorBlink: true, fontSize: 14, fontFamily: 'JetBrains Mono, Fira Code, Menlo, Monaco, "Courier New", monospace', fontWeight: "normal", fontWeightBold: "bold", letterSpacing: 0, lineHeight: 1.2, theme: { background: "#0a0a0a", foreground: "#e0e0e0", cursor: "#00ff00", cursorAccent: "#000000", selectionBackground: "#3a3d41", selectionForeground: "#ffffff", black: "#000000", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c", blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#bfbfbf", brightBlack: "#4d4d4d", brightRed: "#ff6e67", brightGreen: "#5af78e", brightYellow: "#f4f99d", brightBlue: "#caa9fa", brightMagenta: "#ff92d0", brightCyan: "#9aedfe", brightWhite: "#e6e6e6", }, rows: 30, cols: 100, allowProposedApi: true, }); const fitAddon = new FitAddon(); const webLinksAddon = new WebLinksAddon(); const searchAddon = new SearchAddon(); const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); term.loadAddon(searchAddon); term.loadAddon(clipboardAddon); term.loadAddon(unicode11Addon); term.loadAddon(imageAddon); term.unicode.activeVersion = "11"; term.open(terminalRef.current); try { const ligaturesAddon = new LigaturesAddon(); term.loadAddon(ligaturesAddon); } catch (e) { console.warn("Ligatures addon not available:", e); } fitAddon.fit(); xtermRef.current = term; fitAddonRef.current = fitAddon; searchAddonRef.current = searchAddon; setTimeout(() => { try { const webglAddon = new WebglAddon(); term.loadAddon(webglAddon); console.log("WebGL renderer loaded successfully"); } catch (e) { console.warn("WebGL renderer not available, using canvas fallback:", e); } }, 100); term.attachCustomKeyEventHandler((event) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { event.preventDefault(); setShowSearch((prev) => !prev); return false; } return true; }); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.hostname}:3000/api/terminal/exec?podName=${encodeURIComponent(podName)}&container=${encodeURIComponent(container)}&shell=${encodeURIComponent(shell)}&mode=${mode}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { console.log("WebSocket connected"); setConnected(true); term.writeln( `\r\n\x1b[1;32mConnecting to ${mode === "attach" ? "container" : "shell"}...\x1b[0m\r\n`, ); const { cols, rows } = term; ws.send(JSON.stringify({ type: "resize", cols, rows })); }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data); if (message.type === "output") { term.write(message.data); } else if (message.type === "ready") { term.writeln(`\x1b[1;32m${message.data}\x1b[0m\r\n`); } else if (message.type === "error") { term.writeln(`\r\n\x1b[1;31mError: ${message.data}\x1b[0m\r\n`); setError(message.data); } else if (message.type === "close") { term.writeln(`\r\n\x1b[1;33m${message.data}\x1b[0m\r\n`); setConnected(false); } } catch (err) { console.error("Error parsing WebSocket message:", err); } }; ws.onerror = (err) => { console.error("WebSocket error:", err); term.writeln("\r\n\x1b[1;31mWebSocket error\x1b[0m\r\n"); setError("Connection error"); setConnected(false); }; ws.onclose = () => { console.log("WebSocket closed"); term.writeln("\r\n\x1b[1;33mConnection closed\x1b[0m\r\n"); setConnected(false); }; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data })); } }); term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols, rows })); } }); const handleResize = () => { fitAddon.fit(); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); if (ws.readyState === WebSocket.OPEN) { ws.close(); } term.dispose(); }; }, [podName, container, shell, mode]); return (
{connected && (
Connected
)} {error && (
{error}
)} {onClose && ( )}
{showSearch && (
setSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); searchAddonRef.current?.findNext(searchTerm, { caseSensitive: false, wholeWord: false, regex: false, }); } else if (e.key === "Escape") { setShowSearch(false); } }} placeholder="Search..." className="flex-1 px-2 py-1 text-sm bg-muted border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary" />
Press Enter to find next, Esc to close
)}
); }