new additions : What's new
✅ Rust PTY backend (portable-pty) - pty_spawn / pty_write / pty_resize / pty_kill commands - Output streamed via pty:data Tauri events (base64-encoded bytes) - Spawns /bin/bash (falls back to /bin/bash), starts in /home/code ✅ xterm.js terminal card (full ANSI + true color) ✅ Toolbar (top-left) with + Note / + Terminal ✅ Shared useDragHandle hook (notes + terminals) ✅ Terminal font-size scales with canvas zoom → crisp at any zoom ✅ Terminal auto-refits when card resizes or zoom changes
This commit is contained in:
130
src/canvas/cards/TerminalCardView.tsx
Normal file
130
src/canvas/cards/TerminalCardView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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 { 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()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user