From 437c9356d76e755cf6fed51ed9add76dd5bcb1df Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Tue, 18 Nov 2025 01:46:15 +0100 Subject: [PATCH] Implement node-pty for xtermjs (#1) Reviewed-on: https://git.dehosting.club/quintenk/miller/pulls/1 --- package-lock.json | 19 +++++++- package.json | 3 +- src/app/editorgrid.ts | 1 - src/app/terminal.ts | 88 +++++++++++++++++++++++++++++++++--- src/main/forge-vite-env.d.ts | 4 ++ src/main/main.ts | 28 ++++++++++++ src/main/pty.ts | 78 ++++++++++++++++++++++++++++++++ src/preload.ts | 48 ++++++++++++++++++++ src/types/global.d.ts | 22 +++++++++ vite.config.mts | 8 ++++ 10 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 src/main/forge-vite-env.d.ts create mode 100644 src/main/pty.ts diff --git a/package-lock.json b/package-lock.json index a175893..6cab135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "electron-squirrel-startup": "^1.0.1" + "electron-squirrel-startup": "^1.0.1", + "node-pty": "^1.0.0" }, "devDependencies": { "@codemirror/language-data": "^6.5.2", @@ -8895,6 +8896,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/nan": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8989,6 +8996,16 @@ } } }, + "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, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", diff --git a/package.json b/package.json index 4b32522..fa4101d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "vite": "^7.2.2" }, "dependencies": { - "electron-squirrel-startup": "^1.0.1" + "electron-squirrel-startup": "^1.0.1", + "node-pty": "^1.0.0" } } diff --git a/src/app/editorgrid.ts b/src/app/editorgrid.ts index eb0c1b9..209fadb 100644 --- a/src/app/editorgrid.ts +++ b/src/app/editorgrid.ts @@ -94,7 +94,6 @@ vanX.list(EditorTabs, editors, EditorGrid); document.addEventListener("keyup", (e) => { if (e.key === "t" && e.altKey) { - console.log("Opening terminal"); addTerminal(); } }); diff --git a/src/app/terminal.ts b/src/app/terminal.ts index 4da9f9a..6ce15ac 100644 --- a/src/app/terminal.ts +++ b/src/app/terminal.ts @@ -9,6 +9,11 @@ export class Terminal implements Displayable { currentTitle: string = "Terminal"; del: () => void; dom: HTMLElement; + private terminalId: string | null = null; + private fitAddon: FitAddon; + private resizeObserver: ResizeObserver; + private unsubTerminalData?: () => void; + private unsubTerminalExit?: () => void; setDeleteFunction(del: () => void): void { this.del = del; @@ -19,28 +24,97 @@ export class Terminal implements Displayable { } constructor() { - this.term = new xterm.Terminal(); + this.term = new xterm.Terminal({ + // cursorBlink: true, + // fontSize: 14, + // fontFamily: 'Menlo, Monaco, "Courier New", monospace', + }); - const fitAddon = new FitAddon(); - this.term.loadAddon(fitAddon); + this.fitAddon = new FitAddon(); + this.term.loadAddon(this.fitAddon); - this.dom = v.div({ class: "h-full w-full" }); + this.dom = v.div({ class: "h-full w-lg resize-x overflow-x-hidden" }); const loaded = van.state(false); + van.derive(() => { if (loaded.val) { - this.term.open(this.dom); - fitAddon.fit(); - this.term.writeln("Welcome to the terminal!"); + this.initializeTerminal(); } }); loaded.val = true; } + private async initializeTerminal() { + this.term.open(this.dom); + + // Create terminal in main process + try { + this.terminalId = await window.electronAPI.createTerminal(); + + // Set up data handling (subscribe/unsubscribe) + this.unsubTerminalData = window.electronAPI.onTerminalData( + this.terminalId, + (data) => this.term.write(data), + ); + + this.unsubTerminalExit = window.electronAPI.onTerminalExit( + this.terminalId, + (exitCode) => + this.term.writeln( + `\r\n[Process exited with code ${exitCode}]`, + ), + ); + + // Handle user input + this.term.onData((data) => { + if (this.terminalId) { + window.electronAPI.writeToTerminal(this.terminalId, data); + } + }); + + // Set up resize handling + this.resizeObserver = new ResizeObserver(() => { + this.handleResize(); + }); + this.resizeObserver.observe(this.dom); + + // Initial fit + setTimeout(() => { + this.handleResize(); + }, 100); + } catch (error) { + console.error("Failed to initialize terminal:", error); + this.term.writeln( + "Failed to initialize terminal. Check console for details.", + ); + } + } + + private handleResize() { + if ( + this.terminalId && + this.dom.clientWidth > 0 && + this.dom.clientHeight > 0 + ) { + this.fitAddon.fit(); + const { cols, rows } = this.term; + window.electronAPI.resizeTerminal(this.terminalId, cols, rows); + } + } + focus() { this.term.focus(); } close() { + if (this.terminalId) { + window.electronAPI.closeTerminal(this.terminalId); + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.unsubTerminalData) this.unsubTerminalData(); + if (this.unsubTerminalExit) this.unsubTerminalExit(); this.term.dispose(); this.del(); } diff --git a/src/main/forge-vite-env.d.ts b/src/main/forge-vite-env.d.ts new file mode 100644 index 0000000..89410b3 --- /dev/null +++ b/src/main/forge-vite-env.d.ts @@ -0,0 +1,4 @@ +/// + +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; +declare const MAIN_WINDOW_VITE_NAME: string; diff --git a/src/main/main.ts b/src/main/main.ts index fc056e7..5b0384a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,8 +8,10 @@ import { getOpenedFiles, showConfirmDialog, } from "./fileOperations"; +import { terminalManager } from "./pty"; import path from "node:path"; import started from "electron-squirrel-startup"; +/// // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { @@ -94,6 +96,32 @@ app.whenReady().then(() => { }, ); + // Terminal handlers + ipcMain.handle( + "terminal:create", + async (event, shell?: string, args?: string[]) => { + return terminalManager.createTerminal(event, shell, args); + }, + ); + + ipcMain.handle( + "terminal:resize", + async (event, id: string, cols: number, rows: number) => { + return terminalManager.resizeTerminal(id, cols, rows); + }, + ); + + ipcMain.handle( + "terminal:write", + async (event, id: string, data: string) => { + return terminalManager.writeToTerminal(id, data); + }, + ); + + ipcMain.handle("terminal:close", async (event, id: string) => { + return terminalManager.closeTerminal(id); + }); + createWindow(); if (process.platform === "darwin") { app.on("activate", function () { diff --git a/src/main/pty.ts b/src/main/pty.ts new file mode 100644 index 0000000..02f27aa --- /dev/null +++ b/src/main/pty.ts @@ -0,0 +1,78 @@ +import * as pty from "node-pty"; + +export interface TerminalInstance { + ptyProcess: pty.IPty; +} + +export class TerminalManager { + private terminals: Map = new Map(); + private nextId: number = 1; + + createTerminal( + event: Electron.IpcMainInvokeEvent, + shell?: string, + args?: string[], + ): string { + const id = `terminal-${this.nextId++}`; + + // Default shell based on platform + const defaultShell = + process.platform === "win32" ? "powershell.exe" : "/bin/bash"; + const shellToUse = shell || defaultShell; + + const ptyProcess = pty.spawn(shellToUse, args || [], { + name: "xterm-color", + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env, + }); + + const terminal: TerminalInstance = { + ptyProcess, + }; + + ptyProcess.onData((data) => { + console.log(`Terminal ${id} data:`, data); + event.sender.send("terminal:data", id, data); + }); + + ptyProcess.onExit(({ exitCode }) => { + event.sender.send("terminal:exit", id, exitCode); + this.terminals.delete(id); + }); + + this.terminals.set(id, terminal); + return id; + } + + resizeTerminal(id: string, cols: number, rows: number): boolean { + const terminal = this.terminals.get(id); + if (terminal) { + terminal.ptyProcess.resize(cols, rows); + return true; + } + return false; + } + + writeToTerminal(id: string, data: string): boolean { + const terminal = this.terminals.get(id); + if (terminal) { + terminal.ptyProcess.write(data); + return true; + } + return false; + } + + closeTerminal(id: string): boolean { + const terminal = this.terminals.get(id); + if (terminal) { + terminal.ptyProcess.kill(); + this.terminals.delete(id); + return true; + } + return false; + } +} + +export const terminalManager = new TerminalManager(); diff --git a/src/preload.ts b/src/preload.ts index bf4b3f4..56b24dd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,6 +4,25 @@ import { contextBridge, ipcRenderer } from "electron"; import type { FolderTree } from "./types/global"; +// Centralized routing for terminal events: keep a single ipcRenderer listener +// and forward events to subscribed callbacks. Each `onTerminal*` returns an +// unsubscribe function so individual renderer components can remove only +// their own listeners. +const terminalDataCallbacks = new Map void>(); +const terminalExitCallbacks = new Map void>(); + +ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => { + const cb = terminalDataCallbacks.get(id); + if (cb) cb(data); + else console.warn(`No data callback for terminal ${id}`); +}); + +ipcRenderer.on("terminal:exit", (_ev, id: string, exitCode: number) => { + const cb = terminalExitCallbacks.get(id); + if (cb) cb(exitCode); + else console.warn(`No exit callback for terminal ${id}`); +}); + contextBridge.exposeInMainWorld("electronAPI", { openFolder: () => ipcRenderer.invoke("dialog:openFolder") as Promise, @@ -43,4 +62,33 @@ contextBridge.exposeInMainWorld("electronAPI", { title, buttons, ) as Promise, + + // Terminal operations + createTerminal: (shell?: string, args?: string[]) => + ipcRenderer.invoke("terminal:create", shell, args) as Promise, + + resizeTerminal: (id: string, cols: number, rows: number) => + ipcRenderer.invoke( + "terminal:resize", + id, + cols, + rows, + ) as Promise, + + writeToTerminal: (id: string, data: string) => + ipcRenderer.invoke("terminal:write", id, data) as Promise, + + closeTerminal: (id: string) => + ipcRenderer.invoke("terminal:close", id) as Promise, + + // Terminal events (subscribe/unsubscribe) + onTerminalData: (id: string, callback: (data: string) => void) => { + terminalDataCallbacks.set(id, callback); + return () => terminalDataCallbacks.delete(id); + }, + + onTerminalExit: (id: string, callback: (exitCode: number) => void) => { + terminalExitCallbacks.set(id, callback); + return () => terminalExitCallbacks.delete(id); + }, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 1e12101..6470c2c 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -34,6 +34,28 @@ declare global { title: string, buttons: string[], ) => Promise; + + // Terminal operations + createTerminal: ( + shell?: string, + args?: string[], + ) => Promise; + resizeTerminal: ( + id: string, + cols: number, + rows: number, + ) => Promise; + writeToTerminal: (id: string, data: string) => Promise; + closeTerminal: (id: string) => Promise; + onTerminalData: ( + id: string, + callback: (data: string) => void, + ) => () => void; + onTerminalExit: ( + id: string, + callback: (exitCode: number) => void, + ) => () => void; + removeAllTerminalListeners: () => void; }; } } diff --git a/vite.config.mts b/vite.config.mts index a1d8c39..a162315 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,4 +4,12 @@ import tailwindcss from "@tailwindcss/vite"; // https://vitejs.dev/config export default defineConfig({ plugins: [tailwindcss()], + optimizeDeps: { + exclude: ["node-pty"], + }, + build: { + rollupOptions: { + external: ["node-pty"], + }, + }, });