diff --git a/backend/server/dist/index.js b/backend/server/dist/index.js index d0f86cb..e8f567c 100644 --- a/backend/server/dist/index.js +++ b/backend/server/dist/index.js @@ -18,6 +18,7 @@ const http_1 = require("http"); const socket_io_1 = require("socket.io"); const zod_1 = require("zod"); const utils_1 = require("./utils"); +const terminal_1 = require("./terminal"); dotenv_1.default.config(); const app = (0, express_1.default)(); const port = process.env.PORT || 4000; @@ -28,6 +29,7 @@ const io = new socket_io_1.Server(httpServer, { origin: "*", }, }); +const terminals = {}; const handshakeSchema = zod_1.z.object({ userId: zod_1.z.string(), sandboxId: zod_1.z.string(), @@ -70,6 +72,7 @@ io.on("connection", (socket) => __awaiter(void 0, void 0, void 0, function* () { const file = sandboxFiles.fileData.find((f) => f.id === fileId); if (!file) return; + console.log("get file " + file.id + ": ", file.data.slice(0, 10) + "..."); callback(file.data); }); // todo: send diffs + debounce for efficiency @@ -88,6 +91,21 @@ io.on("connection", (socket) => __awaiter(void 0, void 0, void 0, function* () { file.id = newName; yield (0, utils_1.renameFile)(fileId, newName, file.data); })); + socket.on("createTerminal", ({ id }) => { + console.log("creating terminal (" + id + ")"); + terminals[id] = new terminal_1.Pty(socket, id); + }); + socket.on("terminalData", ({ id, data }) => { + console.log(`Received data for terminal ${id}: ${data}`); + if (!terminals[id]) { + console.log("terminal not found"); + console.log("terminals", terminals); + return; + } + console.log(`Writing to terminal ${id}`); + terminals[id].write(data); + }); + socket.on("disconnect", () => { }); })); httpServer.listen(port, () => { console.log(`Server running on port ${port}`); diff --git a/backend/server/dist/terminal.js b/backend/server/dist/terminal.js new file mode 100644 index 0000000..b56bb6b --- /dev/null +++ b/backend/server/dist/terminal.js @@ -0,0 +1,35 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Pty = void 0; +const node_pty_1 = require("node-pty"); +const os_1 = __importDefault(require("os")); +class Pty { + constructor(socket, id) { + this.socket = socket; + this.shell = os_1.default.platform() === "win32" ? "cmd.exe" : "bash"; + this.ptyProcess = (0, node_pty_1.spawn)(this.shell, [], { + name: "xterm", + cols: 100, + cwd: `/temp`, + // env: process.env as { [key: string]: string }, + }); + this.ptyProcess.onData((data) => { + console.log("onData", data); + this.send(data); + }); + // this.write("hello world") + } + write(data) { + console.log("writing data", data); + this.ptyProcess.write(data); + } + send(data) { + this.socket.emit("terminalResponse", { + data: Buffer.from(data, "utf-8"), + }); + } +} +exports.Pty = Pty; diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index 5aab554..c412099 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "node-pty": "^1.0.0", "socket.io": "^4.7.5", "zod": "^3.22.4" }, @@ -977,6 +978,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nan": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", + "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -985,6 +991,15 @@ "node": ">= 0.6" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/nodemon": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index 9503e3e..82a6650 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "node-pty": "^1.0.0", "socket.io": "^4.7.5", "zod": "^3.22.4" }, diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 2a8d72b..a707a1a 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -6,6 +6,7 @@ import { Server } from "socket.io" import { z } from "zod" import { User } from "./types" import { getSandboxFiles, renameFile, saveFile } from "./utils" +import { Pty } from "./terminal" dotenv.config() @@ -19,6 +20,8 @@ const io = new Server(httpServer, { }, }) +const terminals: { [id: string]: Pty } = {} + const handshakeSchema = z.object({ userId: z.string(), sandboxId: z.string(), @@ -76,6 +79,7 @@ io.on("connection", async (socket) => { const file = sandboxFiles.fileData.find((f) => f.id === fileId) if (!file) return + console.log("get file " + file.id + ": ", file.data.slice(0, 10) + "...") callback(file.data) }) @@ -96,6 +100,26 @@ io.on("connection", async (socket) => { await renameFile(fileId, newName, file.data) }) + + socket.on("createTerminal", ({ id }: { id: string }) => { + console.log("creating terminal (" + id + ")") + terminals[id] = new Pty(socket, id) + }) + + socket.on("terminalData", ({ id, data }: { id: string; data: string }) => { + console.log(`Received data for terminal ${id}: ${data}`) + + if (!terminals[id]) { + console.log("terminal not found") + console.log("terminals", terminals) + return + } + + console.log(`Writing to terminal ${id}`) + terminals[id].write(data) + }) + + socket.on("disconnect", () => {}) }) httpServer.listen(port, () => { diff --git a/backend/server/src/terminal.ts b/backend/server/src/terminal.ts new file mode 100644 index 0000000..6d984ae --- /dev/null +++ b/backend/server/src/terminal.ts @@ -0,0 +1,49 @@ +import { spawn, IPty } from "node-pty" +import { Socket } from "socket.io" +import os from "os" + +export class Pty { + socket: Socket + ptyProcess: IPty + shell: string + + constructor(socket: Socket, id: string) { + this.socket = socket + this.shell = os.platform() === "win32" ? "cmd.exe" : "bash" + + this.ptyProcess = spawn(this.shell, [], { + name: "xterm", + cols: 100, + cwd: `/temp`, + // env: process.env as { [key: string]: string }, + }) + + this.ptyProcess.onData((data) => { + console.log("onData", data) + this.send(data) + }) + + // this.write("hello world") + } + + write(data: string) { + console.log("writing data", data) + + this.ptyProcess.write(data) + } + + send(data: string) { + this.socket.emit("terminalResponse", { + data: Buffer.from(data, "utf-8"), + }) + } + + // kill() { + // console.log("killing terminal") + + // if (os.platform() !== "win32") { + // this.ptyProcess.kill() + // return + // } + // } +} diff --git a/frontend/components/editor/index.tsx b/frontend/components/editor/index.tsx index 68f842f..cfa39bb 100644 --- a/frontend/components/editor/index.tsx +++ b/frontend/components/editor/index.tsx @@ -25,6 +25,12 @@ import { TFile, TFileData, TFolder, TTab } from "./sidebar/types" import { io } from "socket.io-client" import { processFileType } from "@/lib/utils" import { toast } from "sonner" +import EditorTerminal from "./terminal" + +import { Terminal } from "@xterm/xterm" +import { FitAddon } from "@xterm/addon-fit" + +import { decodeTerminalResponse } from "@/lib/utils" export default function CodeEditor({ userId, @@ -33,6 +39,8 @@ export default function CodeEditor({ userId: string sandboxId: string }) { + const clerk = useClerk() + const editorRef = useRef(null) const handleEditorMount: OnMount = (editor, monaco) => { @@ -73,7 +81,7 @@ export default function CodeEditor({ } }, [tabs, activeId]) - // WS event handlers ------------ + // WS event handlers: // connection/disconnection effect useEffect(() => { @@ -86,21 +94,28 @@ export default function CodeEditor({ // event listener effect useEffect(() => { - function onLoadedEvent(files: (TFolder | TFile)[]) { + const onConnect = () => {} + + const onDisconnect = () => {} + + const onLoadedEvent = (files: (TFolder | TFile)[]) => { console.log("onLoadedEvent") setFiles(files) } + socket.on("connect", onConnect) + + socket.on("disconnect", onDisconnect) socket.on("loaded", onLoadedEvent) return () => { + socket.off("connect", onConnect) + socket.off("disconnect", onDisconnect) socket.off("loaded", onLoadedEvent) } }, []) - // ------------ - - const clerk = useClerk() + // Helper functions: const selectFile = (tab: TTab) => { setTabs((prev) => { @@ -274,7 +289,9 @@ export default function CodeEditor({ Node Console -
+
+ {socket ? : null} +
diff --git a/frontend/components/editor/sidebar/index.tsx b/frontend/components/editor/sidebar/index.tsx index 7e18f1c..6c6b26a 100644 --- a/frontend/components/editor/sidebar/index.tsx +++ b/frontend/components/editor/sidebar/index.tsx @@ -24,60 +24,67 @@ export default function Sidebar({ const [creatingNew, setCreatingNew] = useState<"file" | "folder" | null>(null) return ( -
-
-
Explorer
-
- - - {/* Todo: Implement file searching */} - {/* + + {/* Todo: Implement file searching */} + {/* */} +
+
+
+ {files.length === 0 ? ( +
+ +
+ ) : ( + <> + {files.map((child) => + child.type === "file" ? ( + + ) : ( + + ) + )} + {creatingNew !== null ? ( + setCreatingNew(null)} + /> + ) : null} + + )}
-
- {files.length === 0 ? ( -
- -
- ) : ( - <> - {files.map((child) => - child.type === "file" ? ( - - ) : ( - - ) - )} - {creatingNew !== null ? ( - setCreatingNew(null)} - /> - ) : null} - - )} +
+ {/*
+ {connected ? "Connected" : "Not connected"} +
*/}
) diff --git a/frontend/components/editor/terminal.tsx b/frontend/components/editor/terminal.tsx new file mode 100644 index 0000000..ced83c1 --- /dev/null +++ b/frontend/components/editor/terminal.tsx @@ -0,0 +1,79 @@ +"use client" + +import { Terminal } from "@xterm/xterm" +import { FitAddon } from "@xterm/addon-fit" + +import { useEffect, useRef, useState } from "react" +import { Socket } from "socket.io-client" +import { decodeTerminalResponse } from "@/lib/utils" + +export default function EditorTerminal({ socket }: { socket: Socket }) { + const terminalRef = useRef(null) + const [term, setTerm] = useState(null) + + useEffect(() => { + if (!terminalRef.current) return + + const terminal = new Terminal({ + cursorBlink: false, + }) + + setTerm(terminal) + + return () => { + if (terminal) terminal.dispose() + } + }, []) + + useEffect(() => { + if (!term) return + + const onConnect = () => { + console.log("Connected to server", socket.connected) + setTimeout(() => { + console.log("sending createTerminal") + socket.emit("createTerminal", { id: "testId" }) + }, 500) + } + + const onTerminalResponse = (data: Buffer) => { + console.log("received data", decodeTerminalResponse(data)) + term.write(decodeTerminalResponse(data)) + } + + socket.on("connect", onConnect) + + if (terminalRef.current) { + socket.on("terminalResponse", onTerminalResponse) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.open(terminalRef.current) + fitAddon.fit() + setTerm(term) + } + const disposable = term.onData((data) => { + console.log("sending data", data) + socket.emit("terminalData", { + id: "testId", + data, + }) + }) + + socket.emit("terminalData", { + data: "\n", + }) + + return () => { + socket.off("connect", onConnect) + socket.off("terminalResponse", onTerminalResponse) + disposable.dispose() + } + }, [term, terminalRef.current]) + + return ( +
+
+
+ ) +} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index e0a02f3..5e3b034 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -14,3 +14,7 @@ export function processFileType(file: string) { if (ending) return ending return "plaintext" } + +export function decodeTerminalResponse(buffer: Buffer): string { + return buffer.toString("utf-8") +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56a263f..115e167 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,8 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "geist": "^1.3.0", @@ -1454,6 +1456,19 @@ "@types/send": "*" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==" + }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16a7417..8a39e20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "geist": "^1.3.0",