diff --git a/.gitignore b/.gitignore index e1a5582..3128e32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ out/ +.vscode/ + multi_ssh_config.yaml diff --git a/AGENTS.md b/AGENTS.md index 4fa3ac5..cf55a58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,7 @@ multi-ssh is an electron app allowing to connect to multiple ssh hosts at the sa ```bash npm run start # Start the whole application ``` + +## Conventions + +- **ipcRenderer frontend binding**: In the `renderer.js` keep all global `ipcRenderer` bindings like `ipcRenderer.on(...)` at the top diff --git a/README.md b/README.md index 7894aed..a057ade 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,13 @@ A multi-SSH terminal application built with Electron, providing a unified interf Create a `multi_ssh_config.yaml` file based on `multi_ssh_config.sample.yaml` with your SSH hosts: ```yaml -hosts: - - user@host1 - - user@host2 - - user@host3 +hostGroups: + someHosts: + - host1 + - host2 + otherHosts: + - host2 + - host3 ``` The config file can be stored in the following locations: diff --git a/index.html b/index.html index 8afcab7..0de54e4 100644 --- a/index.html +++ b/index.html @@ -98,6 +98,9 @@ +
diff --git a/main.js b/main.js index 48fc2ba..46c6977 100644 --- a/main.js +++ b/main.js @@ -7,7 +7,7 @@ const yaml = require("js-yaml"); // Keep a global reference of the window object let mainWindow; -let ptyProcesses = []; // Array of terminal processes +let ptyProcesses = {}; // Object of terminal processes keyed by hostname let config; // Will be loaded later function loadConfig() { @@ -43,26 +43,16 @@ app.whenReady().then(() => { createWindow(); }); -ipcMain.on("renderer-ready", () => { - initializeAllTerminals(); -}); - // IPC handlers for terminal communication -ipcMain.on("terminal-input", (_event, { index, data }) => { - if (ptyProcesses[index]) { - ptyProcesses[index].write(data); - } else { - // Fallback if process died - mainWindow?.webContents?.send( - `terminal-data-${index}`, - "\r\n\x1b[91mSSH session not available\x1b[0m\r\n$ ", - ); +ipcMain.on("terminal-input", (_event, { hostname, data }) => { + if (ptyProcesses[hostname]) { + ptyProcesses[hostname].write(data); } }); -ipcMain.on("terminal-resize", (_event, { index, cols, rows }) => { - if (ptyProcesses[index]) { - ptyProcesses[index].resize(cols, rows); +ipcMain.on("terminal-resize", (_event, { hostname, cols, rows }) => { + if (ptyProcesses[hostname]) { + ptyProcesses[hostname].resize(cols, rows); } }); @@ -76,6 +66,21 @@ ipcMain.on("toggle-dev-tools", () => { } }); +ipcMain.on("switch-host-group", (_event, groupName) => { + const hosts = config.hostGroups[groupName]; + terminateAllTerminals(); + + mainWindow?.webContents?.send("clear-all-terminals"); + if (hosts) { + // It can potentially happen that the renderer is not ready with the terminal creation + // but the initializeTerminalsForHosts is already working and is faster so the renderer + // won't show the content then of a terminal (in theory I guess). If this happens, we should + // redesign this process. + mainWindow?.webContents?.send("initialize-terminals", hosts); + initializeTerminalsForHosts(hosts); + } +}); + function createWindow() { // Create the browser window mainWindow = new BrowserWindow({ @@ -97,6 +102,12 @@ function createWindow() { mainWindow.loadFile("index.html"); mainWindow.removeMenu(); + // Emitted when the window is closed + mainWindow.on("closed", () => { + terminateAllTerminals(); + mainWindow = null; + }); + // Show window when ready to prevent visual flash mainWindow.once("ready-to-show", () => { mainWindow.show(); @@ -110,62 +121,68 @@ function createWindow() { } // Wait for renderer ready before initializing PTY }); +} - // Emitted when the window is closed - mainWindow.on("closed", () => { - // Clean up PTY processes - ptyProcesses.forEach((process, _index) => { - if (process && !process.killed) { +function terminateAllTerminals() { + // Clean up PTY processes + Object.values(ptyProcesses).forEach((process) => { + if (process) { + // Remove listeners to prevent any residual callbacks + process.removeAllListeners("data"); + process.removeAllListeners("exit"); + if (!process.killed) { process.kill(); } - }); - ptyProcesses = []; - // Dereference the window object - mainWindow = null; + } }); + + ptyProcesses = {}; } -function initializeAllTerminals() { +function initializeTerminalsForHosts(hosts) { const shell = "ssh"; console.log("Using shell:", shell); - for (let i = 0; i < config.hosts.length; i++) { + for (const hostname of hosts) { try { - initializeTerminal(i, shell, config.hosts[i]); + initializeTerminal(hostname, shell); } catch (error) { - console.error(`Failed to spawn SSH session ${i} process:`, error); - mainWindow?.webContents?.send( - `terminal-data-${i}`, - `\r\n\x1b[91mFailed to start SSH session ${i + 1}: ${error.message}\x1b[0m\r\n`, + console.error( + `Failed to spawn SSH session for ${hostname} process:`, + error, ); + mainWindow?.webContents?.send("terminal-data", { + hostname, + data: `\r\n\x1b[91mFailed to start SSH session for ${hostname}: ${error.message}\x1b[0m\r\n`, + }); } } } // Initialize PTY processes -function initializeTerminal(index, shell, host) { +function initializeTerminal(hostname, shell) { // Use node-pty for proper pseudo-terminal functionality - const ptyProcess = pty.spawn(shell, [host], { + const ptyProcess = pty.spawn(shell, [hostname], { name: "xterm-256color", cols: 80, rows: 24, cwd: process.env.HOME || process.env.USERPROFILE || "/", env: process.env, }); + ptyProcesses[hostname] = ptyProcess; // Send data from PTY to renderer ptyProcess.onData((data) => { - mainWindow?.webContents?.send(`terminal-data-${index}`, data); + mainWindow?.webContents?.send("terminal-data", { hostname, data }); }); // Handle process exit - ptyProcess.onExit(({ exitCode, signal }) => { - mainWindow?.webContents?.send(`terminal-exit-${index}`, { - code: exitCode, - signal, + ptyProcess.onExit(({ exitCode, _signal }) => { + const exitMessage = `\r\n\x1b[91mSSH session to ${hostname} exited with code: ${exitCode}\x1b[0m\r\n`; + mainWindow?.webContents?.send("terminal-data", { + hostname, + data: exitMessage, }); - ptyProcesses[index] = null; + delete ptyProcesses[hostname]; }); - - ptyProcesses[index] = ptyProcess; } diff --git a/multi_ssh_config.sample.yaml b/multi_ssh_config.sample.yaml index 8262c0e..5c4cbec 100644 --- a/multi_ssh_config.sample.yaml +++ b/multi_ssh_config.sample.yaml @@ -1,5 +1,10 @@ -hosts: - - host1 - - host2 +--- +hostGroups: + someHosts: + - host1 + - host2 + otherHosts: + - host2 + - host3 terminalHeight: 300 terminalMinWidth: 600 diff --git a/renderer.js b/renderer.js index 952d12d..9ecc243 100644 --- a/renderer.js +++ b/renderer.js @@ -5,30 +5,35 @@ const { FitAddon } = require("@xterm/addon-fit"); // Terminal variables let config; -const terminals = []; -const fitAddons = []; +let terminals = {}; // Object of terminals keyed by hostname +let fitAddons = {}; // Object of fit addons keyed by hostname let lastPasteTime = 0; const PASTE_DEBOUNCE_MS = 100; -// Display version information -document.addEventListener("DOMContentLoaded", () => { - if (config) { - initializeTerminals(); +// Listen for F12 to toggle dev tools +window.addEventListener("keydown", (event) => { + if (event.key === "F12") { + ipcRenderer.send("toggle-dev-tools"); + event.preventDefault(); } }); -// Listen for config +// Handle window resize +window.addEventListener("resize", () => { + Object.values(fitAddons).forEach((fitAddon) => { + if (fitAddon) { + fitAddon.fit(); + } + }); +}); + +// ipcRenderer.on(...) will listen for messages from the backend process + ipcRenderer.on("config", (_event, receivedConfig) => { config = receivedConfig; - if ( - document.readyState === "complete" || - document.readyState === "interactive" - ) { - initializeTerminals(); - } + populateHostGroupDropdown(); }); -// Listen for config error ipcRenderer.on("config-error", (_event, errorMessage) => { const container = document.getElementById("terminals-container"); if (container) { @@ -36,198 +41,215 @@ ipcRenderer.on("config-error", (_event, errorMessage) => { } }); +ipcRenderer.on("initialize-terminals", (_event, hosts) => { + initializeTerminals(hosts); +}); + +ipcRenderer.on("clear-all-terminals", () => { + clearAllTerminals(); +}); + +ipcRenderer.on("terminal-data", (_event, { hostname, data }) => { + if (terminals[hostname]) { + terminals[hostname].write(data); + terminals[hostname].scrollToBottom(); + } +}); + +// Listen for clear command +ipcRenderer.on("terminal-clear", (_event, { hostname }) => { + if (terminals[hostname]) { + terminals[hostname].clear(); + } +}); + +// Populate host group dropdown +function populateHostGroupDropdown() { + const select = document.getElementById("host-group-select"); + select.innerHTML = ''; + for (const groupName in config.hostGroups) { + const option = document.createElement("option"); + option.value = groupName; + option.textContent = groupName; + select.appendChild(option); + } + select.addEventListener("change", (event) => { + const groupName = event.target.value; + if (groupName) { + ipcRenderer.send("switch-host-group", groupName); + } + }); +} + // Initialize xterm.js terminals -function initializeTerminals() { +function initializeTerminals(hosts) { const container = document.getElementById("terminals-container"); if (!container) { return; } - const sendToAllCheckbox = document.getElementById("send-to-all"); - let sendToAll = false; - sendToAllCheckbox.addEventListener("change", () => { - sendToAll = sendToAllCheckbox.checked; - }); - - for (let i = 0; i < config.hosts.length; i++) { - // Create wrapper - const wrapper = document.createElement("div"); - wrapper.className = "terminal-wrapper"; - wrapper.style.flex = `1 1 ${config.terminalMinWidth}px`; - wrapper.style.height = `${config.terminalHeight}px`; - - // Create title bar - const titleBar = document.createElement("div"); - titleBar.className = "terminal-title-bar"; - titleBar.textContent = config.hosts[i]; - wrapper.appendChild(titleBar); - - // Create terminal container - const termContainer = document.createElement("div"); - termContainer.className = "terminal-container"; - wrapper.appendChild(termContainer); - - // Create terminal div - const termDiv = document.createElement("div"); - termDiv.className = "terminal"; - termDiv.id = `terminal-${i}`; - termContainer.appendChild(termDiv); + for (const hostname of hosts) { + const { wrapper, terminal, fitAddon } = createSingleTerminal( + hostname, + config, + ); container.appendChild(wrapper); + terminals[hostname] = terminal; + fitAddons[hostname] = fitAddon; - // Create terminal instance - const terminal = new Terminal({ - cursorBlink: true, - cursorStyle: "block", - fontFamily: - '"Cascadia Code", "Fira Code", "Source Code Pro", "Monaco", "Menlo", "Ubuntu Mono", monospace', - fontSize: 14, - fontWeight: "normal", - fontWeightBold: "bold", - lineHeight: 1.2, - letterSpacing: 0, - theme: { - background: "#0c0c0c", - foreground: "#ffffff", - cursor: "#ffffff", - cursorAccent: "#000000", - selection: "rgba(255, 255, 255, 0.3)", - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#ffffff", - }, - allowProposedApi: true, - }); - - // Create fit addon - const fitAddon = new FitAddon(); - terminal.loadAddon(fitAddon); - - // Open terminal in the container - terminal.open(termDiv); + // Focus the first terminal + if (hostname === hosts[0]) { + terminal.focus(); + } + } +} - // Fit terminal to container - fitAddon.fit(); +// Create single terminal +function createSingleTerminal(hostname, config) { + // Create wrapper + const wrapper = document.createElement("div"); + wrapper.className = "terminal-wrapper"; + wrapper.style.flex = `1 1 ${config.terminalMinWidth}px`; + wrapper.style.height = `${config.terminalHeight}px`; + + // Create title bar + const titleBar = document.createElement("div"); + titleBar.className = "terminal-title-bar"; + titleBar.textContent = hostname; + wrapper.appendChild(titleBar); + + // Create terminal container + const termContainer = document.createElement("div"); + termContainer.className = "terminal-container"; + wrapper.appendChild(termContainer); + + // Create terminal div + const termDiv = document.createElement("div"); + termDiv.className = "terminal"; + termDiv.id = `terminal-${hostname}`; + termContainer.appendChild(termDiv); + + // Create terminal instance + const terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontFamily: + '"Cascadia Code", "Fira Code", "Source Code Pro", "Monaco", "Menlo", "Ubuntu Mono", monospace', + fontSize: 14, + fontWeight: "normal", + fontWeightBold: "bold", + lineHeight: 1.2, + letterSpacing: 0, + theme: { + background: "#0c0c0c", + foreground: "#ffffff", + cursor: "#ffffff", + cursorAccent: "#000000", + selection: "rgba(255, 255, 255, 0.3)", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#ffffff", + }, + allowProposedApi: true, + }); - // Handle copy/paste keyboard shortcuts - terminal.attachCustomKeyEventHandler((event) => { - // Handle copy (Ctrl+C or Cmd+C on Mac) - if ((event.ctrlKey || event.metaKey) && event.key === "c") { - // Only copy if there's a selection, otherwise let Ctrl+C work as interrupt - if (terminal.hasSelection()) { - navigator.clipboard.writeText(terminal.getSelection()); - return false; // Prevent default behavior - } - return true; // Allow Ctrl+C to work as interrupt if no selection - } + // Create fit addon + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); - // Handle paste (Ctrl+V or Cmd+V on Mac) - if ((event.ctrlKey || event.metaKey) && event.key === "v") { - if (Date.now() - lastPasteTime < PASTE_DEBOUNCE_MS) return true; - lastPasteTime = Date.now(); - navigator.clipboard.readText().then((text) => { - if (sendToAll) { - for (let j = 0; j < config.hosts.length; j++) { - ipcRenderer.send("terminal-input", { - index: j, - data: text, - }); - } - } else { - ipcRenderer.send("terminal-input", { - index: i, - data: text, - }); - } - }); - return true; // Having false here for some reason adds the text twice... - } + // Open terminal in the container + terminal.open(termDiv); - return true; // Allow all other keys to work normally - }); + // Fit terminal to container after layout is complete + requestAnimationFrame(() => { + fitAddon.fit(); + }); - // Handle terminal input - terminal.onData((data) => { - if (sendToAll) { - for (let j = 0; j < config.hosts.length; j++) { - ipcRenderer.send("terminal-input", { index: j, data }); - } - } else { - ipcRenderer.send("terminal-input", { index: i, data }); + // Handle copy/paste keyboard shortcuts + terminal.attachCustomKeyEventHandler((event) => { + // Handle copy (Ctrl+C or Cmd+C on Mac) + if ((event.ctrlKey || event.metaKey) && event.key === "c") { + // Only copy if there's a selection, otherwise let Ctrl+C work as interrupt + if (terminal.hasSelection()) { + navigator.clipboard.writeText(terminal.getSelection()); + return false; // Prevent default behavior } - }); - - // Handle terminal resize - terminal.onResize(({ cols, rows }) => { - ipcRenderer.send("terminal-resize", { index: i, cols, rows }); - }); - - terminals[i] = terminal; - fitAddons[i] = fitAddon; + return true; // Allow Ctrl+C to work as interrupt if no selection + } - // Focus the first terminal - if (i === 0) { - terminal.focus(); + // Handle paste (Ctrl+V or Cmd+V on Mac) + if ((event.ctrlKey || event.metaKey) && event.key === "v") { + if (Date.now() - lastPasteTime < PASTE_DEBOUNCE_MS) return true; + lastPasteTime = Date.now(); + navigator.clipboard.readText().then((text) => { + sendTerminalInput(text, hostname); + }); + return true; // Having false here for some reason adds the text twice... } - } - // Handle window resize - window.addEventListener("resize", () => { - terminals.forEach((_terminal, i) => { - if (fitAddons[i]) { - fitAddons[i].fit(); - } - }); + return true; // Allow all other keys to work normally }); - // Notify main that renderer is ready - ipcRenderer.send("renderer-ready"); + // Handle terminal input + terminal.onData((data) => { + sendTerminalInput(data, hostname); + }); - // Listen for data from main process - for (let i = 0; i < config.hosts.length; i++) { - ipcRenderer.on(`terminal-data-${i}`, (_event, data) => { - if (terminals[i]) { - terminals[i].write(data); - terminals[i].scrollToBottom(); - } + // Handle terminal resize + terminal.onResize(({ cols, rows }) => { + ipcRenderer.send("terminal-resize", { + hostname: hostname, + cols, + rows, }); + }); - // Listen for clear command - ipcRenderer.on(`terminal-clear-${i}`, () => { - if (terminals[i]) { - terminals[i].clear(); - } - }); + return { wrapper, terminal, fitAddon }; +} - // Handle terminal exit - ipcRenderer.on(`terminal-exit-${i}`, (_event, { code, _signal }) => { - if (terminals[i]) { - terminals[i].write( - `\r\n\x1b[91mSSH session to ${config.hosts[i]} exited with code: ${code}\x1b[0m\r\n`, - ); - terminals[i].scrollToBottom(); - } +// Send terminal input to all hosts or specific host +function sendTerminalInput(data, hostname) { + const sendToAll = document.getElementById("send-to-all").checked; + if (sendToAll) { + for (const host of Object.keys(terminals)) { + ipcRenderer.send("terminal-input", { + hostname: host, + data, + }); + } + } else { + ipcRenderer.send("terminal-input", { + hostname, + data, }); } } -// Listen for F12 to toggle dev tools -window.addEventListener("keydown", (event) => { - if (event.key === "F12") { - ipcRenderer.send("toggle-dev-tools"); - event.preventDefault(); +function clearAllTerminals() { + const container = document.getElementById("terminals-container"); + if (container) { + container.innerHTML = ""; } -}); + // Clear terminal objects + Object.values(terminals).forEach((terminal) => { + if (terminal) { + terminal.dispose(); + } + }); + terminals = {}; + fitAddons = {}; +}