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 = {};
+}