Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules/
out/
.vscode/

multi_ssh_config.yaml
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@
<label>
<input type="checkbox" id="send-to-all"> Send to All
</label>
<select id="host-group-select" style="margin-left: 20px;">
<option value="">Select Host Group</option>
</select>
</div>
<div class="terminals-container" id="terminals-container">
<!-- Terminals will be dynamically added here -->
Expand Down
103 changes: 60 additions & 43 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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({
Expand All @@ -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();
Expand All @@ -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;
}
11 changes: 8 additions & 3 deletions multi_ssh_config.sample.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
hosts:
- host1
- host2
---
hostGroups:
someHosts:
- host1
- host2
otherHosts:
- host2
- host3
terminalHeight: 300
terminalMinWidth: 600
Loading