103 lines
3.0 KiB
TypeScript
103 lines
3.0 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||
|
|
import { Terminal } from "@xterm/xterm";
|
||
|
|
import { FitAddon } from "@xterm/addon-fit";
|
||
|
|
import "@xterm/xterm/css/xterm.css";
|
||
|
|
|
||
|
|
export interface XtermViewHandle {
|
||
|
|
write: (data: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const theme = {
|
||
|
|
background: "#09090b",
|
||
|
|
foreground: "#f4f4f5",
|
||
|
|
cursor: "#f4f4f5",
|
||
|
|
selectionBackground: "#3f3f46",
|
||
|
|
black: "#18181b",
|
||
|
|
red: "#f87171",
|
||
|
|
green: "#4ade80",
|
||
|
|
yellow: "#facc15",
|
||
|
|
blue: "#60a5fa",
|
||
|
|
magenta: "#c084fc",
|
||
|
|
cyan: "#22d3ee",
|
||
|
|
white: "#e4e4e7",
|
||
|
|
brightBlack: "#3f3f46",
|
||
|
|
brightRed: "#fca5a5",
|
||
|
|
brightGreen: "#86efac",
|
||
|
|
brightYellow: "#fde047",
|
||
|
|
brightBlue: "#93c5fd",
|
||
|
|
brightMagenta: "#d8b4fe",
|
||
|
|
brightCyan: "#67e8f9",
|
||
|
|
brightWhite: "#f4f4f5",
|
||
|
|
};
|
||
|
|
|
||
|
|
interface XtermViewProps {
|
||
|
|
/** Written into the terminal as soon as it's created. Only read once, on mount. */
|
||
|
|
initialData?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A read-only xterm.js instance: interprets ANSI colors/control codes from streamed script
|
||
|
|
* output, unlike a plain <pre> which would just show raw escape characters.
|
||
|
|
*
|
||
|
|
* `initialData` is written inside the same effect that creates the Terminal - not via a
|
||
|
|
* ref call from the parent after the fact - specifically because React's Strict Mode (dev
|
||
|
|
* only) mounts, cleans up, and re-mounts every effect once: a fresh Terminal is created on
|
||
|
|
* each real mount, so a "write once" flag in the parent would write to the first (disposed)
|
||
|
|
* instance and silently skip the surviving one, leaving the terminal blank until the next
|
||
|
|
* live write arrives.
|
||
|
|
*/
|
||
|
|
export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
|
||
|
|
function XtermView({ initialData }, ref) {
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const termRef = useRef<Terminal | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!containerRef.current) return;
|
||
|
|
|
||
|
|
const term = new Terminal({
|
||
|
|
convertEol: true,
|
||
|
|
disableStdin: true,
|
||
|
|
cursorBlink: false,
|
||
|
|
fontSize: 12,
|
||
|
|
lineHeight: 1.4,
|
||
|
|
fontFamily: "ui-monospace, Menlo, monospace",
|
||
|
|
theme,
|
||
|
|
});
|
||
|
|
const fitAddon = new FitAddon();
|
||
|
|
term.loadAddon(fitAddon);
|
||
|
|
term.open(containerRef.current);
|
||
|
|
fitAddon.fit();
|
||
|
|
if (initialData) term.write(initialData);
|
||
|
|
termRef.current = term;
|
||
|
|
|
||
|
|
const resizeObserver = new ResizeObserver(() => {
|
||
|
|
try {
|
||
|
|
fitAddon.fit();
|
||
|
|
} catch {
|
||
|
|
// container has zero size (e.g. mid-layout-shift) - next resize will retry
|
||
|
|
}
|
||
|
|
});
|
||
|
|
resizeObserver.observe(containerRef.current);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
resizeObserver.disconnect();
|
||
|
|
term.dispose();
|
||
|
|
termRef.current = null;
|
||
|
|
};
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is intentionally only used on mount
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useImperativeHandle(ref, () => ({
|
||
|
|
write: (data: string) => termRef.current?.write(data),
|
||
|
|
}));
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={containerRef}
|
||
|
|
className="h-[60vh] overflow-hidden rounded-lg bg-zinc-950 p-2"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|