feat: add integrated terminal with xterm.js and shell sessions
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface TerminalProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function Terminal({ sessionId }: TerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new XTerm({
|
||||
theme: {
|
||||
background: "#121212",
|
||||
foreground: "#f2f2f2",
|
||||
cursor: "#946200",
|
||||
selectionBackground: "#946200",
|
||||
selectionForeground: "#f2f2f2",
|
||||
black: "#121212",
|
||||
brightBlack: "#666666",
|
||||
white: "#f2f2f2",
|
||||
brightWhite: "#ffffff",
|
||||
yellow: "#946200",
|
||||
brightYellow: "#c48800",
|
||||
},
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(containerRef.current);
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/terminal/${sessionId}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write("\r\n\x1b[31m[Connection error]\x1b[0m\r\n");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write("\r\n\x1b[90m[Disconnected]\x1b[0m\r\n");
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
ws.close();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: "#121212" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Terminal } from "../components/terminal/Terminal";
|
||||
|
||||
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||
<header
|
||||
@@ -18,21 +22,48 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebar && (
|
||||
<aside
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebar && (
|
||||
<aside
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{
|
||||
width: "250px",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setTerminalOpen((v) => !v)}
|
||||
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
color: "var(--forge-text-secondary)",
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
}}
|
||||
>
|
||||
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
|
||||
{terminalOpen && (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{
|
||||
width: "250px",
|
||||
borderRight: "1px solid var(--forge-border)",
|
||||
height: "300px",
|
||||
borderTop: "1px solid var(--forge-border)",
|
||||
}}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
<Terminal sessionId="main" />
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user