Implement node-pty for xtermjs (#1)

Reviewed-on: #1
This commit is contained in:
Quinten Kock 2025-11-18 01:46:15 +01:00
parent bf697bd98e
commit 437c9356d7
10 changed files with 289 additions and 10 deletions

19
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -94,7 +94,6 @@ vanX.list(EditorTabs, editors, EditorGrid);
document.addEventListener("keyup", (e) => {
if (e.key === "t" && e.altKey) {
console.log("Opening terminal");
addTerminal();
}
});

View File

@ -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();
}

4
src/main/forge-vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/// <reference types="@electron-forge/plugin-vite/vite-env" />
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
declare const MAIN_WINDOW_VITE_NAME: string;

View File

@ -8,8 +8,10 @@ import {
getOpenedFiles,
showConfirmDialog,
} from "./fileOperations";
import { terminalManager } from "./pty";
import path from "node:path";
import started from "electron-squirrel-startup";
/// <reference types="./forge-vite-env.d.ts" />
// 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 () {

78
src/main/pty.ts Normal file
View File

@ -0,0 +1,78 @@
import * as pty from "node-pty";
export interface TerminalInstance {
ptyProcess: pty.IPty;
}
export class TerminalManager {
private terminals: Map<string, TerminalInstance> = 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();

View File

@ -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<string, (data: string) => void>();
const terminalExitCallbacks = new Map<string, (exitCode: number) => 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<FolderTree | null>,
@ -43,4 +62,33 @@ contextBridge.exposeInMainWorld("electronAPI", {
title,
buttons,
) as Promise<string>,
// Terminal operations
createTerminal: (shell?: string, args?: string[]) =>
ipcRenderer.invoke("terminal:create", shell, args) as Promise<string>,
resizeTerminal: (id: string, cols: number, rows: number) =>
ipcRenderer.invoke(
"terminal:resize",
id,
cols,
rows,
) as Promise<boolean>,
writeToTerminal: (id: string, data: string) =>
ipcRenderer.invoke("terminal:write", id, data) as Promise<boolean>,
closeTerminal: (id: string) =>
ipcRenderer.invoke("terminal:close", id) as Promise<boolean>,
// 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);
},
});

22
src/types/global.d.ts vendored
View File

@ -34,6 +34,28 @@ declare global {
title: string,
buttons: string[],
) => Promise<string>;
// Terminal operations
createTerminal: (
shell?: string,
args?: string[],
) => Promise<string>;
resizeTerminal: (
id: string,
cols: number,
rows: number,
) => Promise<boolean>;
writeToTerminal: (id: string, data: string) => Promise<boolean>;
closeTerminal: (id: string) => Promise<boolean>;
onTerminalData: (
id: string,
callback: (data: string) => void,
) => () => void;
onTerminalExit: (
id: string,
callback: (exitCode: number) => void,
) => () => void;
removeAllTerminalListeners: () => void;
};
}
}

View File

@ -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"],
},
},
});