feat: redo xterm terminal logic

This commit is contained in:
Aarnav Tale
2025-10-02 13:52:06 -04:00
parent 341a0e8f81
commit 9b53711673
6 changed files with 235 additions and 166 deletions
+3 -3
View File
@@ -43,9 +43,9 @@ interface XtermConfig {
cols: number;
timeout?: number;
onStdout: (data: string) => void;
onStderr: (data: string) => void;
onStdin: (func: (input: string) => void) => void;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
+137 -97
View File
@@ -2,153 +2,192 @@ 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';
import '@xterm/xterm/css/xterm.css';
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === 'object') {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (
any.buffer instanceof ArrayBuffer &&
typeof any.byteLength === 'number'
) {
return new Uint8Array(
any.buffer.slice(
any.byteOffset ?? 0,
(any.byteOffset ?? 0) + any.byteLength,
),
);
}
}
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const container = useRef<HTMLDivElement>(null);
const term = useRef<xterm.Terminal>(null);
const inputRef = useRef<((input: string) => void) | null>(null);
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isFailed, setIsFailed] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
if (!container.current) {
console.error('Container ref is not set');
});
useEffect(() => {
if (!divRef.current) {
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({
const currentGen = ++genRef.current;
const term = 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(
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.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();
});
term.unicode.activeVersion = '11';
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.current = terminal;
terminal.open(container.current!);
terminal.focus();
term.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) => {
console.log('SSH session is ready to receive input');
inputRef.current = func;
console.log('Stdin handler set', func);
},
onConnect: () => {
console.log('SSH session connected');
setIsLoading(false);
},
onDisconnect: () => {
ro?.disconnect();
terminal.dispose();
if (onUnload) {
parent.removeEventListener('unload', onUnload);
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stdout');
return;
}
console.log('SSH session disconnected');
terminal.writeln('Disconnected from SSH session');
setIsLoading(false);
setIsFailed(true);
term.current = null;
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stderr');
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onConnect');
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onDisconnect');
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onData');
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (term.current) {
setIsResizing(true);
fit.fit();
setTimeout(() => setIsResizing(false), 100);
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring resize');
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
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);
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
if (onUnload) {
parent.removeEventListener('unload', onUnload);
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
session.Close();
ro?.disconnect();
terminal.dispose();
term.current = null;
inputRef.current = null;
console.log('SSH session closed and terminal disposed');
};
}, []);
}, [ipn, username, hostname]);
return (
<>
@@ -159,19 +198,19 @@ export default function XTerm({ ipn, username, hostname }: XTermProps) {
) : undefined}
<div
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
ref={container}
ref={divRef}
/>
{term.current && isResizing ? (
{termRef.current && isResizing ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
{term.current.cols}x{term.current.rows}
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{isFailed ? (
{error !== null ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
@@ -179,6 +218,7 @@ export default function XTerm({ ipn, username, hostname }: XTermProps) {
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>