import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; import { SerializeAddon } from '@xterm/addon-serialize'; import { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Download, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; import '@xterm/xterm/css/xterm.css'; interface TerminalComponentProps { stackName?: string; } export default function TerminalComponent({ stackName }: TerminalComponentProps) { const terminalRef = useRef(null); const terminalInstance = useRef(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); const serializeAddonRef = useRef(null); const searchInputRef = useRef(null); const wsRef = useRef(null); const [isSearchVisible, setIsSearchVisible] = useState(false); const [searchText, setSearchText] = useState(''); useEffect(() => { if (!terminalRef.current) { console.error('Terminal ref not ready'); return; } // Clean up any existing terminal if (terminalInstance.current) { try { terminalInstance.current.dispose(); } catch { // Ignore dispose errors } } if (wsRef.current) { try { wsRef.current.close(); } catch { // Ignore close errors } } let mounted = true; const initTerminal = () => { if (!mounted || !terminalRef.current) return; try { const term = new Terminal({ cursorBlink: true, convertEol: true, allowProposedApi: true, theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff', black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#ffffff', }, fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace", fontSize: 13, scrollback: 10000, }); const fitAddon = new FitAddon(); const searchAddon = new SearchAddon(); const serializeAddon = new SerializeAddon(); term.loadAddon(fitAddon); term.loadAddon(searchAddon); term.loadAddon(serializeAddon); fitAddonRef.current = fitAddon; searchAddonRef.current = searchAddon; serializeAddonRef.current = serializeAddon; term.open(terminalRef.current); terminalInstance.current = term; // Custom key handler for Ctrl+F term.attachCustomKeyEventHandler((event) => { if (event.ctrlKey && event.key === 'f' && event.type === 'keydown') { event.preventDefault(); setIsSearchVisible(prev => { const next = !prev; if (next) { setTimeout(() => searchInputRef.current?.focus(), 50); } return next; }); return false; } return true; }); // Fit after DOM paint using requestAnimationFrame requestAnimationFrame(() => { if (!mounted || !fitAddonRef.current || !terminalRef.current) return; try { fitAddonRef.current.fit(); } catch (err) { console.error('Error fitting terminal:', err); } }); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const cleanStackName = stackName?.replace(/\.(yml|yaml)$/, ''); const activeNodeId = localStorage.getItem('sencho-active-node') || ''; // If a stackName is provided, connect to the dedicated logs WebSocket // Otherwise, fall back to the generic terminal WebSocket const wsUrl = cleanStackName ? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}` : `${wsProtocol}//${window.location.host}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { if (mounted) { if (!cleanStackName) { // Generic terminal mode - send connect action ws.send(JSON.stringify({ action: 'connectTerminal' })); } // For stack logs mode, the server starts streaming automatically on connection } }; ws.onmessage = (event) => { if (mounted && terminalInstance.current) { const text = typeof event.data === 'string' ? event.data : event.data.toString(); terminalInstance.current.write(text.replace(/\r?\n/g, '\r\n')); } }; ws.onerror = (err) => { console.error('WebSocket error:', err); }; } catch (err) { console.error('Error initializing terminal:', err); } }; // Initialize terminal after a small delay to ensure container is rendered const timeoutId = setTimeout(initTerminal, 50); // Attach ResizeObserver to the terminal's parent container let resizeTimeout: number | undefined; const resizeObserver = new ResizeObserver(() => { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (fitAddonRef.current && terminalRef.current && mounted) { try { fitAddonRef.current.fit(); } catch { // Ignore fit errors during resize } } }, 50); }); if (terminalRef.current.parentElement) { resizeObserver.observe(terminalRef.current.parentElement); } return () => { mounted = false; clearTimeout(timeoutId); resizeObserver.disconnect(); if (wsRef.current) { try { wsRef.current.close(); } catch { // Ignore close errors } wsRef.current = null; } if (terminalInstance.current) { try { terminalInstance.current.dispose(); } catch { // Ignore dispose errors } terminalInstance.current = null; } fitAddonRef.current = null; searchAddonRef.current = null; serializeAddonRef.current = null; }; }, [stackName]); const handleDownload = () => { if (!serializeAddonRef.current) return; const content = serializeAddonRef.current.serialize(); const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); a.download = `logs-${stackName || 'terminal'}-${timestamp}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const handleSearchNext = () => { if (!searchAddonRef.current || !searchText) return; searchAddonRef.current.findNext(searchText); }; const handleSearchPrev = () => { if (!searchAddonRef.current || !searchText) return; searchAddonRef.current.findPrevious(searchText); }; const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (e.shiftKey) handleSearchPrev(); else handleSearchNext(); } else if (e.key === 'Escape') { setIsSearchVisible(false); } }; return (
{/* Floating Action Bar / Search Bar Panel */}
{!isSearchVisible ? ( ) : (
setSearchText(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="Find in terminal..." className="h-7 w-48 border-none focus-visible:ring-0 bg-transparent text-sm min-h-0" />
)}
); }