feat: add integrated terminal with xterm.js and shell sessions
This commit is contained in:
Generated
+17
@@ -1743,6 +1743,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -4787,6 +4802,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import cors from "cors";
|
|||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
||||||
import workspaceRouter from "./routes/workspace.js";
|
import workspaceRouter from "./routes/workspace.js";
|
||||||
import dockerRouter from "./routes/docker.js";
|
import dockerRouter from "./routes/docker.js";
|
||||||
import editorRouter from "./routes/editor.js";
|
import editorRouter from "./routes/editor.js";
|
||||||
|
import terminalRouter from "./routes/terminal.js";
|
||||||
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -28,6 +31,7 @@ app.get("/api/health", (_req, res) => {
|
|||||||
app.use("/api/workspace", workspaceRouter);
|
app.use("/api/workspace", workspaceRouter);
|
||||||
app.use("/api/docker", dockerRouter);
|
app.use("/api/docker", dockerRouter);
|
||||||
app.use("/api/editor", editorRouter);
|
app.use("/api/editor", editorRouter);
|
||||||
|
app.use("/api/terminal", terminalRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
@@ -35,6 +39,21 @@ app.get("*path", (_req, res) => {
|
|||||||
res.sendFile(path.join(frontendDist, "index.html"));
|
res.sendFile(path.join(frontendDist, "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket, head) => {
|
||||||
|
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||||
|
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||||
|
if (termMatch) {
|
||||||
|
const sessionId = termMatch[1];
|
||||||
|
const termWss = new WebSocketServer({ noServer: true });
|
||||||
|
termWss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
if (!attachWebSocket(sessionId, ws)) {
|
||||||
|
createTerminalSession(sessionId);
|
||||||
|
attachWebSocket(sessionId, ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||||
server.listen(PORT, "0.0.0.0", () => {
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
createTerminalSession,
|
||||||
|
listSessions,
|
||||||
|
destroySession,
|
||||||
|
} from "../services/terminal.service.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/sessions", (_req, res) => {
|
||||||
|
res.json({ sessions: listSessions() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/sessions", (req, res) => {
|
||||||
|
const id = (req.body.id as string) || `term-${Date.now()}`;
|
||||||
|
createTerminalSession(id);
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/sessions/:id", (req, res) => {
|
||||||
|
destroySession(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { spawn, ChildProcess } from "child_process";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
interface TerminalSession {
|
||||||
|
id: string;
|
||||||
|
process: ChildProcess;
|
||||||
|
ws: WebSocket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, TerminalSession>();
|
||||||
|
|
||||||
|
export function createTerminalSession(id: string): TerminalSession {
|
||||||
|
const proc = spawn("/bin/bash", ["-l"], {
|
||||||
|
env: { ...process.env, TERM: "xterm-256color" },
|
||||||
|
cwd: process.env.WORKSPACE_PATH || "/workspace",
|
||||||
|
});
|
||||||
|
const session: TerminalSession = { id, process: proc, ws: null };
|
||||||
|
sessions.set(id, session);
|
||||||
|
proc.on("exit", () => sessions.delete(id));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachWebSocket(sessionId: string, ws: WebSocket): boolean {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return false;
|
||||||
|
session.ws = ws;
|
||||||
|
session.process.stdout?.on("data", (data: Buffer) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data.toString());
|
||||||
|
});
|
||||||
|
session.process.stderr?.on("data", (data: Buffer) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data.toString());
|
||||||
|
});
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
session.process.stdin?.write(data.toString());
|
||||||
|
});
|
||||||
|
ws.on("close", () => {
|
||||||
|
session.ws = null;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(id: string): void {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (session) {
|
||||||
|
session.process.kill();
|
||||||
|
sessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): string[] {
|
||||||
|
return Array.from(sessions.keys());
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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 { Outlet } from "react-router-dom";
|
||||||
|
import { Terminal } from "../components/terminal/Terminal";
|
||||||
|
|
||||||
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||||
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||||
<header
|
<header
|
||||||
@@ -18,6 +22,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{sidebar && (
|
{sidebar && (
|
||||||
<aside
|
<aside
|
||||||
@@ -34,6 +39,32 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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={{
|
||||||
|
height: "300px",
|
||||||
|
borderTop: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Terminal sessionId="main" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user