133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import type { TerminalCard } from "../types";
|
|
import { useViewport } from "../viewport";
|
|
import { useDragHandle } from "../useDragHandle";
|
|
import { ResizeHandles } from "./ResizeHandles";
|
|
import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
|
|
|
|
const BASE_FONT_SIZE = 13;
|
|
|
|
interface Props {
|
|
card: TerminalCard;
|
|
onUpdate: (patch: Partial<TerminalCard>) => void;
|
|
}
|
|
|
|
export function TerminalCardView({ card, onUpdate }: Props) {
|
|
const vp = useViewport();
|
|
const drag = useDragHandle(card, (p) => onUpdate(p));
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const ptyIdRef = useRef<string>(card.ptyId);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let unlistenData: (() => void) | undefined;
|
|
let unlistenExit: (() => void) | undefined;
|
|
let disposed = false;
|
|
|
|
const term = new Terminal({
|
|
fontSize: BASE_FONT_SIZE * vp.scale,
|
|
fontFamily: 'Menlo, "Cascadia Code", Consolas, monospace',
|
|
theme: {
|
|
background: "#1d1d23",
|
|
foreground: "#e8e8ec",
|
|
cursor: "#e8e8ec",
|
|
},
|
|
cursorBlink: true,
|
|
allowProposedApi: true,
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(containerRef.current!);
|
|
fit.fit();
|
|
termRef.current = term;
|
|
fitRef.current = fit;
|
|
|
|
(async () => {
|
|
let id = ptyIdRef.current;
|
|
if (!id) {
|
|
id = await pty.spawn({ cols: term.cols, rows: term.rows });
|
|
if (disposed) {
|
|
await pty.kill(id);
|
|
return;
|
|
}
|
|
ptyIdRef.current = id;
|
|
onUpdate({ ptyId: id });
|
|
}
|
|
|
|
unlistenData = await pty.onData((e) => {
|
|
if (e.id !== id) return;
|
|
term.write(base64ToBytes(e.data));
|
|
});
|
|
unlistenExit = await pty.onExit((e) => {
|
|
if (e.id !== id) return;
|
|
term.write("\r\n\x1b[2m[process exited]\x1b[0m\r\n");
|
|
});
|
|
|
|
term.onData((data) => {
|
|
pty.write(id!, stringToBase64(data)).catch((err) => console.error("pty_write", err));
|
|
});
|
|
term.onResize(({ cols, rows }) => {
|
|
pty.resize(id!, cols, rows).catch(() => {});
|
|
});
|
|
|
|
setReady(true);
|
|
})();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
unlistenData?.();
|
|
unlistenExit?.();
|
|
term.dispose();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Update xterm font size when canvas zoom changes so text stays crisp.
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
const term = termRef.current;
|
|
const fit = fitRef.current;
|
|
if (!term || !fit) return;
|
|
term.options.fontSize = BASE_FONT_SIZE * vp.scale;
|
|
fit.fit();
|
|
}, [vp.scale, ready]);
|
|
|
|
// Refit on card resize.
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
fitRef.current?.fit();
|
|
}, [card.width, card.height, ready]);
|
|
|
|
return (
|
|
<div
|
|
className="card terminal-card"
|
|
style={
|
|
{
|
|
left: vp.x + card.x * vp.scale,
|
|
top: vp.y + card.y * vp.scale,
|
|
width: card.width * vp.scale,
|
|
height: card.height * vp.scale,
|
|
zIndex: card.z,
|
|
"--scale": vp.scale,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<div className="card-header" {...drag}>
|
|
terminal
|
|
</div>
|
|
<div
|
|
className="card-body terminal-body"
|
|
ref={containerRef}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onWheel={(e) => e.stopPropagation()}
|
|
/>
|
|
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<TerminalCard>)} />
|
|
</div>
|
|
);
|
|
}
|