feat(run): add TerminalView xterm.js component for the PTY transport
- TerminalView.tsx: xterm.js component with WebSocket attachment to /ws-pty/:runId - Test: validates WS connection URL, incoming terminal data, and outgoing keystrokes - Added ResizeObserver stub to test-setup.ts for jsdom environment
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file TerminalView.tsx
|
||||
* @description Renders one lane's live terminal — a real `xterm.js` instance
|
||||
* attached via WebSocket to the server's `/ws-pty/:runId` path (see
|
||||
* server/lib/pty-attach.js), which is itself a `node-pty`-backed
|
||||
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
|
||||
* directions; a JSON text frame carries the initial `resize` on mount and
|
||||
* the server's one-shot `exit` notice when the pane process ends.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
fit.fit();
|
||||
|
||||
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
// Binary PTY output arrives as text here too (the browser WS API
|
||||
// decodes non-Blob/ArrayBuffer frames as strings) — a JSON control
|
||||
// frame is the only thing that starts with `{"type"`.
|
||||
if (event.data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "exit") {
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
}
|
||||
term.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
dataDisposable.dispose();
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||
}
|
||||
Reference in New Issue
Block a user