import { ClipboardAddon } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; import { Unicode11Addon } from '@xterm/addon-unicode11'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; import * as xterm from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { Loader2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import cn from '~/utils/cn'; import { useLiveData } from '~/utils/live-data'; import toast from '~/utils/toast'; interface XTermProps { ipn: TsWasmNet; username: string; hostname: string; } export default function XTerm({ ipn, username, hostname }: XTermProps) { const { pause } = useLiveData(); const container = useRef(null); const term = useRef(null); const inputRef = useRef<((input: string) => void) | null>(null); const [isResizing, setIsResizing] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isFailed, setIsFailed] = useState(false); useEffect(() => { pause(); if (!container.current) { console.error('Container ref is not set'); return; } // Don't create a new terminal if one already exists if (term.current) { console.warn('Terminal already exists, skipping initialization'); return; } const terminal = new xterm.Terminal({ allowProposedApi: true, cursorBlink: true, convertEol: true, fontSize: 14, cols: 80, rows: 24, theme: { background: '#1e1e1e', foreground: '#ffffff', }, }); terminal.loadAddon(new Unicode11Addon()); terminal.loadAddon(new ClipboardAddon()); terminal.loadAddon( new WebLinksAddon((event, uri) => { event.view?.open(uri, '_blank', 'noopener noreferrer'); }), ); terminal.unicode.activeVersion = '11'; const fit = new FitAddon(); terminal.loadAddon(fit); const gl = new WebglAddon(); terminal.loadAddon(gl); gl.onContextLoss(() => { console.warn('WebGL context lost, falling back to canvas rendering'); gl.dispose(); }); fit.fit(); term.current = terminal; terminal.open(container.current!); terminal.focus(); let onUnload: ((e: Event) => void) | null = null; const session = ipn.OpenSSH(hostname, username, { Rows: terminal.rows, Cols: terminal.cols, OnStdout: (data) => terminal.write(data), OnStderr: (data) => { terminal.write(data); console.log('SSH stderr:', data); toast(data); }, OnStdin: (func) => { inputRef.current = func; }, OnConnect: () => { console.log('SSH session connected'); setIsLoading(false); }, OnDisconnect: () => { ro?.disconnect(); terminal.dispose(); if (onUnload) { parent.removeEventListener('unload', onUnload); } console.log('SSH session disconnected'); terminal.writeln('Disconnected from SSH session'); setIsLoading(false); setIsFailed(true); term.current = null; }, }); const ro = new ResizeObserver(() => { if (term.current) { setIsResizing(true); fit.fit(); setTimeout(() => setIsResizing(false), 100); } }); ro.observe(container.current); terminal.onResize(({ cols, rows }) => { console.log(`Terminal resized to ${cols}x${rows}`); session.Resize(cols, rows); }); terminal.onData((data) => { inputRef.current?.(data); }); onUnload = (_) => session.Close(); parent.addEventListener('unload', onUnload); return () => { if (onUnload) { parent.removeEventListener('unload', onUnload); } session.Close(); ro?.disconnect(); terminal.dispose(); term.current = null; inputRef.current = null; console.log('SSH session closed and terminal disposed'); }; }, []); return ( <> {isLoading ? (
) : undefined}
{term.current && isResizing ? (
{term.current.cols}x{term.current.rows}
) : undefined} {isFailed ? (
Failed to connect to SSH session
) : undefined} ); }