From 939b3a29d4478628e13ef647efc8f26fcc453ce9 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 17 Nov 2025 08:14:12 +0100 Subject: [PATCH 1/3] Implement termjs (hacky) --- package-lock.json | 19 +++++++- package.json | 3 +- src/app/terminal.ts | 95 +++++++++++++++++++++++++++++++++--- src/main/forge-vite-env.d.ts | 4 ++ src/main/main.ts | 47 ++++++++++++++++++ src/main/pty.ts | 82 +++++++++++++++++++++++++++++++ src/preload.ts | 54 ++++++++++++++++++++ src/types/global.d.ts | 25 ++++++++++ vite.config.mts | 8 +++ 9 files changed, 328 insertions(+), 9 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/terminal.ts b/src/app/terminal.ts index 4da9f9a..3e151a1 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,104 @@ 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( + (id, data) => { + if (id === this.terminalId) { + this.term.write(data); + } + }, + ); + + this.unsubTerminalExit = window.electronAPI.onTerminalExit( + (id, exitCode, signal) => { + if (id === this.terminalId) { + 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 + ) { + console.log("Old size: ", this.term.rows, this.term.cols); + this.fitAddon.fit(); + const { cols, rows } = this.term; + console.log("New size: ", rows, cols); + 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..a7d361d 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,51 @@ app.whenReady().then(() => { }, ); + // Terminal handlers + ipcMain.handle( + "terminal:create", + async (event, shell?: string, args?: string[]) => { + const terminalId = terminalManager.createTerminal(shell, args); + + // Set up data forwarding to the renderer + terminalManager.on("terminal-data", (id, data) => { + if (id === terminalId) { + event.sender.send("terminal:data", id, data); + } + }); + + terminalManager.on("terminal-exit", (id, exitCode, signal) => { + if (id === terminalId) { + event.sender.send("terminal:exit", id, exitCode, signal); + } + }); + + return terminalId; + }, + ); + + 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); + }); + + ipcMain.handle("terminal:list", async () => { + return terminalManager.listTerminals(); + }); + 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..72334cf --- /dev/null +++ b/src/main/pty.ts @@ -0,0 +1,82 @@ +import * as pty from "node-pty"; +import { EventEmitter } from "events"; + +export interface TerminalInstance { + ptyProcess: pty.IPty; +} + +export class TerminalManager extends EventEmitter { + private terminals: Map = new Map(); + private nextId: number = 1; + + createTerminal(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) => { + this.emit("terminal-data", id, data); + }); + + ptyProcess.onExit(({ exitCode, signal }) => { + this.emit("terminal-exit", id, exitCode, signal); + this.terminals.delete(id); + }); + + this.terminals.set(id, terminal); + return id; + } + + getTerminal(id: string): TerminalInstance | undefined { + return this.terminals.get(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; + } + + listTerminals(): string[] { + return Array.from(this.terminals.keys()); + } +} + +export const terminalManager = new TerminalManager(); diff --git a/src/preload.ts b/src/preload.ts index bf4b3f4..2a0b3b0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,6 +4,26 @@ 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 Set<(id: string, data: string) => void>(); +const terminalExitCallbacks = new Set< + (id: string, exitCode: number, signal: string) => void +>(); + +ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => { + for (const cb of terminalDataCallbacks) cb(id, data); +}); + +ipcRenderer.on( + "terminal:exit", + (_ev, id: string, exitCode: number, signal: string) => { + for (const cb of terminalExitCallbacks) cb(id, exitCode, signal); + }, +); + contextBridge.exposeInMainWorld("electronAPI", { openFolder: () => ipcRenderer.invoke("dialog:openFolder") as Promise, @@ -43,4 +63,38 @@ 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, + + listTerminals: () => + ipcRenderer.invoke("terminal:list") as Promise, + + // Terminal events (subscribe/unsubscribe) + onTerminalData: (callback: (id: string, data: string) => void) => { + terminalDataCallbacks.add(callback); + return () => terminalDataCallbacks.delete(callback); + }, + + onTerminalExit: ( + callback: (id: string, exitCode: number, signal: string) => void, + ) => { + terminalExitCallbacks.add(callback); + return () => terminalExitCallbacks.delete(callback); + }, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 1e12101..d44e2c5 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -34,6 +34,31 @@ 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; + listTerminals: () => Promise; + onTerminalData: ( + callback: (id: string, data: string) => void, + ) => () => void; + onTerminalExit: ( + callback: ( + id: string, + exitCode: number, + signal: string, + ) => 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"], + }, + }, }); -- 2.44.2 From a437ccebc943d59130ad0440a9816a92c27bf7d8 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Tue, 18 Nov 2025 01:36:55 +0100 Subject: [PATCH 2/3] simplify terminal code --- src/app/editorgrid.ts | 1 - src/app/terminal.ts | 4 +--- src/main/main.ts | 21 +-------------------- src/main/pty.ts | 23 +++++++++-------------- src/preload.ts | 3 --- src/types/global.d.ts | 1 - 6 files changed, 11 insertions(+), 42 deletions(-) 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 3e151a1..e776506 100644 --- a/src/app/terminal.ts +++ b/src/app/terminal.ts @@ -61,7 +61,7 @@ export class Terminal implements Displayable { ); this.unsubTerminalExit = window.electronAPI.onTerminalExit( - (id, exitCode, signal) => { + (id, exitCode) => { if (id === this.terminalId) { this.term.writeln( `\r\n[Process exited with code ${exitCode}]`, @@ -101,10 +101,8 @@ export class Terminal implements Displayable { this.dom.clientWidth > 0 && this.dom.clientHeight > 0 ) { - console.log("Old size: ", this.term.rows, this.term.cols); this.fitAddon.fit(); const { cols, rows } = this.term; - console.log("New size: ", rows, cols); window.electronAPI.resizeTerminal(this.terminalId, cols, rows); } } diff --git a/src/main/main.ts b/src/main/main.ts index a7d361d..5b0384a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -100,22 +100,7 @@ app.whenReady().then(() => { ipcMain.handle( "terminal:create", async (event, shell?: string, args?: string[]) => { - const terminalId = terminalManager.createTerminal(shell, args); - - // Set up data forwarding to the renderer - terminalManager.on("terminal-data", (id, data) => { - if (id === terminalId) { - event.sender.send("terminal:data", id, data); - } - }); - - terminalManager.on("terminal-exit", (id, exitCode, signal) => { - if (id === terminalId) { - event.sender.send("terminal:exit", id, exitCode, signal); - } - }); - - return terminalId; + return terminalManager.createTerminal(event, shell, args); }, ); @@ -137,10 +122,6 @@ app.whenReady().then(() => { return terminalManager.closeTerminal(id); }); - ipcMain.handle("terminal:list", async () => { - return terminalManager.listTerminals(); - }); - createWindow(); if (process.platform === "darwin") { app.on("activate", function () { diff --git a/src/main/pty.ts b/src/main/pty.ts index 72334cf..62e6d5c 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -1,15 +1,18 @@ import * as pty from "node-pty"; -import { EventEmitter } from "events"; export interface TerminalInstance { ptyProcess: pty.IPty; } -export class TerminalManager extends EventEmitter { +export class TerminalManager { private terminals: Map = new Map(); private nextId: number = 1; - createTerminal(shell?: string, args?: string[]): string { + createTerminal( + event: Electron.IpcMainInvokeEvent, + shell?: string, + args?: string[], + ): string { const id = `terminal-${this.nextId++}`; // Default shell based on platform @@ -30,11 +33,11 @@ export class TerminalManager extends EventEmitter { }; ptyProcess.onData((data) => { - this.emit("terminal-data", id, data); + event.sender.send("terminal:data", id, data); }); - ptyProcess.onExit(({ exitCode, signal }) => { - this.emit("terminal-exit", id, exitCode, signal); + ptyProcess.onExit(({ exitCode }) => { + event.sender.send("terminal:exit", id, exitCode); this.terminals.delete(id); }); @@ -42,10 +45,6 @@ export class TerminalManager extends EventEmitter { return id; } - getTerminal(id: string): TerminalInstance | undefined { - return this.terminals.get(id); - } - resizeTerminal(id: string, cols: number, rows: number): boolean { const terminal = this.terminals.get(id); if (terminal) { @@ -73,10 +72,6 @@ export class TerminalManager extends EventEmitter { } return false; } - - listTerminals(): string[] { - return Array.from(this.terminals.keys()); - } } export const terminalManager = new TerminalManager(); diff --git a/src/preload.ts b/src/preload.ts index 2a0b3b0..c8d97e3 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -82,9 +82,6 @@ contextBridge.exposeInMainWorld("electronAPI", { closeTerminal: (id: string) => ipcRenderer.invoke("terminal:close", id) as Promise, - listTerminals: () => - ipcRenderer.invoke("terminal:list") as Promise, - // Terminal events (subscribe/unsubscribe) onTerminalData: (callback: (id: string, data: string) => void) => { terminalDataCallbacks.add(callback); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d44e2c5..1959226 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -47,7 +47,6 @@ declare global { ) => Promise; writeToTerminal: (id: string, data: string) => Promise; closeTerminal: (id: string) => Promise; - listTerminals: () => Promise; onTerminalData: ( callback: (id: string, data: string) => void, ) => () => void; -- 2.44.2 From 9ec9ff1ab4bf4656ab5ef0a898af953ebff9046d Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Tue, 18 Nov 2025 01:43:27 +0100 Subject: [PATCH 3/3] further simplify and cleanup pty<->terminal comms --- src/app/terminal.ts | 19 +++++++------------ src/main/pty.ts | 1 + src/preload.ts | 35 ++++++++++++++++------------------- src/types/global.d.ts | 10 ++++------ 4 files changed, 28 insertions(+), 37 deletions(-) diff --git a/src/app/terminal.ts b/src/app/terminal.ts index e776506..6ce15ac 100644 --- a/src/app/terminal.ts +++ b/src/app/terminal.ts @@ -53,21 +53,16 @@ export class Terminal implements Displayable { // Set up data handling (subscribe/unsubscribe) this.unsubTerminalData = window.electronAPI.onTerminalData( - (id, data) => { - if (id === this.terminalId) { - this.term.write(data); - } - }, + this.terminalId, + (data) => this.term.write(data), ); this.unsubTerminalExit = window.electronAPI.onTerminalExit( - (id, exitCode) => { - if (id === this.terminalId) { - this.term.writeln( - `\r\n[Process exited with code ${exitCode}]`, - ); - } - }, + this.terminalId, + (exitCode) => + this.term.writeln( + `\r\n[Process exited with code ${exitCode}]`, + ), ); // Handle user input diff --git a/src/main/pty.ts b/src/main/pty.ts index 62e6d5c..02f27aa 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -33,6 +33,7 @@ export class TerminalManager { }; ptyProcess.onData((data) => { + console.log(`Terminal ${id} data:`, data); event.sender.send("terminal:data", id, data); }); diff --git a/src/preload.ts b/src/preload.ts index c8d97e3..56b24dd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -8,21 +8,20 @@ import type { FolderTree } from "./types/global"; // 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 Set<(id: string, data: string) => void>(); -const terminalExitCallbacks = new Set< - (id: string, exitCode: number, signal: string) => void ->(); +const terminalDataCallbacks = new Map void>(); +const terminalExitCallbacks = new Map void>(); ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => { - for (const cb of terminalDataCallbacks) cb(id, data); + 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, signal: string) => { - for (const cb of terminalExitCallbacks) cb(id, exitCode, signal); - }, -); +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: () => @@ -83,15 +82,13 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("terminal:close", id) as Promise, // Terminal events (subscribe/unsubscribe) - onTerminalData: (callback: (id: string, data: string) => void) => { - terminalDataCallbacks.add(callback); - return () => terminalDataCallbacks.delete(callback); + onTerminalData: (id: string, callback: (data: string) => void) => { + terminalDataCallbacks.set(id, callback); + return () => terminalDataCallbacks.delete(id); }, - onTerminalExit: ( - callback: (id: string, exitCode: number, signal: string) => void, - ) => { - terminalExitCallbacks.add(callback); - return () => terminalExitCallbacks.delete(callback); + 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 1959226..6470c2c 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -48,14 +48,12 @@ declare global { writeToTerminal: (id: string, data: string) => Promise; closeTerminal: (id: string) => Promise; onTerminalData: ( - callback: (id: string, data: string) => void, + id: string, + callback: (data: string) => void, ) => () => void; onTerminalExit: ( - callback: ( - id: string, - exitCode: number, - signal: string, - ) => void, + id: string, + callback: (exitCode: number) => void, ) => () => void; removeAllTerminalListeners: () => void; }; -- 2.44.2