'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import '@xterm/xterm/css/xterm.css'; import { Button } from './ui/button'; import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; interface TerminalProps { scriptPath: string; onClose: () => void; mode?: 'local' | 'ssh'; server?: any; isUpdate?: boolean; isShell?: boolean; isBackup?: boolean; containerId?: string; storage?: string; backupStorage?: string; } interface TerminalMessage { type: 'start' | 'output' | 'error' | 'end'; data: string; timestamp: number; } export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isClient, setIsClient] = useState(false); const [mobileInput, setMobileInput] = useState(''); const [showMobileInput, setShowMobileInput] = useState(false); const [lastInputSent, setLastInputSent] = useState(null); const [isMobile, setIsMobile] = useState(false); const [isStopped, setIsStopped] = useState(false); const [isTerminalReady, setIsTerminalReady] = useState(false); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); const inputHandlerRef = useRef<((data: string) => void) | null>(null); const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); const isConnectingRef = useRef(false); const hasConnectedRef = useRef(false); const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; const handleMessage = useCallback((message: TerminalMessage) => { if (!xtermRef.current) return; const timestamp = new Date(message.timestamp).toLocaleTimeString(); const prefix = `[${timestamp}] `; switch (message.type) { case 'start': xtermRef.current.writeln(`${prefix}[START] ${message.data}`); setIsRunning(true); break; case 'output': // Write directly to terminal - xterm.js handles ANSI codes natively xtermRef.current.write(message.data); break; case 'error': // Check if this looks like ANSI terminal output (contains escape codes) if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) { // This is likely terminal output sent to stderr, treat it as normal output xtermRef.current.write(message.data); } else if (message.data.includes('TERM environment variable not set')) { // This is a common warning, treat as normal output xtermRef.current.write(message.data); } else if (message.data.includes('exit code') && message.data.includes('clear')) { // This is a script error, show it with error prefix xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`); } else { // This is a real error, show it with error prefix xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`); } break; case 'end': setIsRunning(false); // Check if this is an LXC creation script const isLxcCreation = scriptPath.includes('ct/') || scriptPath.includes('create_lxc') || (containerId != null) || scriptName.includes('lxc') || scriptName.includes('container'); if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) { // Display prominent LXC creation completion message xtermRef.current.writeln(''); xtermRef.current.writeln('#########################################'); xtermRef.current.writeln('########## LXC CREATION FINISHED ########'); xtermRef.current.writeln('#########################################'); xtermRef.current.writeln(''); } else { xtermRef.current.writeln(`${prefix}✅ ${message.data}`); } break; } }, [scriptPath, containerId, scriptName]); // Ensure we're on the client side useEffect(() => { setIsClient(true); // Detect mobile on mount setIsMobile(window.innerWidth < 768); }, []); useEffect(() => { // Only initialize on client side if (!isClient || !terminalRef.current || xtermRef.current) return; // Store ref value to avoid stale closure const terminalElement = terminalRef.current; // Use setTimeout to ensure DOM is fully ready const initTerminal = async () => { if (!terminalElement || xtermRef.current) return; // Dynamically import xterm modules to avoid SSR issues const { Terminal: XTerm } = await import('@xterm/xterm'); const { FitAddon } = await import('@xterm/addon-fit'); const { WebLinksAddon } = await import('@xterm/addon-web-links'); // Use the mobile state const terminal = new XTerm({ theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff', cursorAccent: '#0d1117', // Let ANSI colors work naturally - only define basic colors black: '#484f58', red: '#f85149', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39d353', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ff7b72', brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d364', brightWhite: '#f0f6fc', }, fontSize: isMobile ? 7 : 14, fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace', cursorBlink: true, cursorStyle: 'block', scrollback: 1000, tabStopWidth: 4, allowTransparency: false, convertEol: true, disableStdin: false, macOptionIsMeta: false, rightClickSelectsWord: false, wordSeparator: ' ()[]{}\'"`<>|', // Better ANSI handling allowProposedApi: true, // Force proper terminal behavior for interactive applications // Use smaller dimensions on mobile but ensure proper fit cols: isMobile ? 45 : 80, rows: isMobile ? 18 : 24, }); // Add addons const fitAddon = new FitAddon(); const webLinksAddon = new WebLinksAddon(); terminal.loadAddon(fitAddon); terminal.loadAddon(webLinksAddon); // Enable better ANSI handling terminal.options.allowProposedApi = true; // Open terminal terminal.open(terminalElement); // Ensure proper terminal rendering setTimeout(() => { terminal.refresh(0, terminal.rows - 1); // Ensure cursor is properly positioned terminal.focus(); // Force focus on the terminal element terminalElement.focus(); terminalElement.click(); // Add click handler to ensure terminal stays focused const focusHandler = () => { terminal.focus(); terminalElement.focus(); }; terminalElement.addEventListener('click', focusHandler); // Store the handler for cleanup (terminalElement as any).focusHandler = focusHandler; }, 100); // Fit after a small delay to ensure proper sizing setTimeout(() => { fitAddon.fit(); // Force fit multiple times for mobile to ensure proper sizing if (isMobile) { setTimeout(() => { fitAddon.fit(); setTimeout(() => { fitAddon.fit(); }, 200); }, 300); } }, 100); // Add resize listener for mobile responsiveness const handleResize = () => { if (fitAddonRef.current) { setTimeout(() => { fitAddonRef.current.fit(); }, 50); } }; window.addEventListener('resize', handleResize); // Store the handler for cleanup (terminalElement as any).resizeHandler = handleResize; // Store references xtermRef.current = terminal; fitAddonRef.current = fitAddon; // Mark terminal as ready setIsTerminalReady(true); return () => { terminal.dispose(); }; }; // Initialize with a small delay const timeoutId = setTimeout(() => { void initTerminal(); }, 50); return () => { clearTimeout(timeoutId); if (terminalElement && (terminalElement as any).resizeHandler) { window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any); } if (terminalElement && (terminalElement as any).focusHandler) { terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any); } if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; fitAddonRef.current = null; setIsTerminalReady(false); } }; }, [isClient, isMobile]); // Handle terminal input with current executionId useEffect(() => { if (!isTerminalReady || !xtermRef.current) { return; } const terminal = xtermRef.current; const handleData = (data: string) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const message = { action: 'input', executionId, input: data }; wsRef.current.send(JSON.stringify(message)); } }; // Store the handler reference inputHandlerRef.current = handleData; terminal.onData(handleData); return () => { // Clear the handler reference inputHandlerRef.current = null; }; }, [executionId, isTerminalReady]); // Depend on terminal ready state useEffect(() => { // Prevent multiple connections in React Strict Mode if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) { return; } // Close any existing connection first if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } isConnectingRef.current = true; const isInitialConnection = !hasConnectedRef.current; hasConnectedRef.current = true; // Small delay to prevent rapid reconnection const connectWithDelay = () => { // Connect to WebSocket const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setIsConnected(true); isConnectingRef.current = false; // Only auto-start on initial connection, not on reconnections if (isInitialConnection && !isRunning) { // Generate a new execution ID for the initial run const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; setExecutionId(newExecutionId); const message = { action: 'start', scriptPath, executionId: newExecutionId, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage }; ws.send(JSON.stringify(message)); } }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data as string) as TerminalMessage; handleMessage(message); } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onclose = (_event) => { setIsConnected(false); setIsRunning(false); isConnectingRef.current = false; }; ws.onerror = (error) => { console.error('WebSocket error:', error); console.error('WebSocket readyState:', ws.readyState); setIsConnected(false); isConnectingRef.current = false; }; }; // Add small delay to prevent rapid reconnection const timeoutId = setTimeout(connectWithDelay, 100); return () => { clearTimeout(timeoutId); isConnectingRef.current = false; hasConnectedRef.current = false; if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { wsRef.current.close(); } }; }, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps const startScript = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { // Generate a new execution ID for each script run const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; setExecutionId(newExecutionId); setIsStopped(false); wsRef.current.send(JSON.stringify({ action: 'start', scriptPath, executionId: newExecutionId, mode, server, isUpdate, isShell, containerId })); } }; const stopScript = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { setIsStopped(true); setIsRunning(false); wsRef.current.send(JSON.stringify({ action: 'stop', executionId })); } }; const clearOutput = () => { if (xtermRef.current) { xtermRef.current.clear(); } }; const sendInput = (input: string) => { setLastInputSent(input); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const message = { action: 'input', executionId, input: input }; wsRef.current.send(JSON.stringify(message)); // Clear the feedback after 2 seconds setTimeout(() => setLastInputSent(null), 2000); } }; const handleMobileInput = (input: string) => { sendInput(input); setMobileInput(''); }; const handleEnterKey = () => { sendInput('\r'); }; // Don't render on server side if (!isClient) { return (
{scriptName}
Loading terminal...
); } return (
{/* Terminal Header */}
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
{isConnected ? 'Connected' : 'Disconnected'}
{/* Terminal Output */}
{/* Mobile Input Controls - Only show on mobile */}
Mobile Input {lastInputSent && ( Sent: {lastInputSent === '\r' ? 'Enter' : lastInputSent === ' ' ? 'Space' : lastInputSent === '\b' ? 'Backspace' : lastInputSent === '\x1b[A' ? 'Up' : lastInputSent === '\x1b[B' ? 'Down' : lastInputSent === '\x1b[C' ? 'Right' : lastInputSent === '\x1b[D' ? 'Left' : lastInputSent} )}
{showMobileInput && (
{/* Navigation Buttons */}
{/* Left/Right Navigation Buttons */}
{/* Action Buttons */}
{/* Custom Input */}
setMobileInput(e.target.value)} placeholder="Type command..." className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" onKeyPress={(e) => { if (e.key === 'Enter') { handleMobileInput(mobileInput); } }} disabled={!isConnected} />
)}
{/* Terminal Controls */}
); }