diff --git a/README.md b/README.md
index b4698a6..af9c7f3 100644
--- a/README.md
+++ b/README.md
@@ -51,11 +51,11 @@ Built with [Wails v2](https://wails.io/) (Go backend, HTML/CSS/JS frontend, [xte
### What You'll Need
-| Tool | Version | Where to get it |
-|------|---------|-----------------|
-| Go | 1.23+ | [golang.org/dl](https://golang.org/dl/) |
-| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
-| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
+| Tool | Version | Where to get it |
+| --------- | ------- | ---------------------------------------------------------- |
+| Go | 1.23+ | [golang.org/dl](https://golang.org/dl/) |
+| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
+| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
> **Linux users:** you'll also need GTK and WebKit system libraries. Run `wails doctor` to check your setup.
@@ -80,23 +80,23 @@ If you just want to connect to a server without setting up a full profile, press
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
-| Shortcut | Action |
-|----------|--------|
-| Cmd + K | Open command palette |
-| Cmd + N | New connection |
-| Cmd + W | Close current tab |
-| Cmd + Tab | Next tab |
-| Cmd + Shift + Tab | Previous tab |
-| Cmd + 1–9 | Switch to tab by number |
-| Cmd + , | Open settings |
-| Cmd + F | Find in terminal |
-| Cmd + Shift + D | Split pane vertically |
-| Cmd + Shift + E | Split pane horizontally |
-| Cmd + Shift + R | Toggle session recording |
-| Cmd + Shift + B | Toggle broadcast input mode |
-| Cmd + Shift + S | Open snippet library |
-| Cmd + Shift + Q | Quick connect |
-| F11 | Toggle fullscreen (Windows/Linux) |
+| Shortcut | Action |
+| ----------------- | --------------------------------- |
+| Cmd + K | Open command palette |
+| Cmd + N | New connection |
+| Cmd + W | Close current tab |
+| Cmd + Tab | Next tab |
+| Cmd + Shift + Tab | Previous tab |
+| Cmd + 1–9 | Switch to tab by number |
+| Cmd + , | Open settings |
+| Cmd + F | Find in terminal |
+| Cmd + Shift + D | Split pane vertically |
+| Cmd + Shift + E | Split pane horizontally |
+| Cmd + Shift + R | Toggle session recording |
+| Cmd + Shift + B | Toggle broadcast input mode |
+| Cmd + Shift + S | Open snippet library |
+| Cmd + Shift + Q | Quick connect |
+| F11 | Toggle fullscreen (Windows/Linux) |
---
@@ -156,7 +156,7 @@ Press **Cmd+Shift+S** to open the snippet library. Save frequently-used commands
Press **Cmd+F** to open the search overlay on the active terminal. Features include:
- Case-sensitive toggle (**Aa** button)
-- Regular expression toggle (**.*** button)
+- Regular expression toggle (**.\*** button)
- Match count indicator
- Navigate through matches with up/down arrows or Enter/Shift+Enter
@@ -203,13 +203,13 @@ Nexus Terminal takes credential storage seriously. Your passwords and private ke
### How the Encryption Works
-| Detail | Value |
-|--------|-------|
-| Cipher | AES-256-GCM |
+| Detail | Value |
+| -------------- | -------------------------------------------------- |
+| Cipher | AES-256-GCM |
| Key derivation | Argon2id — 128 MiB memory, 3 iterations, 4 threads |
-| Key length | 256 bits (32 bytes) |
-| Salt | 32 random bytes (unique per vault) |
-| Nonce | Random, prepended to ciphertext |
+| Key length | 256 bits (32 bytes) |
+| Salt | 32 random bytes (unique per vault) |
+| Nonce | Random, prepended to ciphertext |
### TOTP/2FA
@@ -272,13 +272,13 @@ frontend/
## Building & Testing
-| Command | What it does |
-|---------|-------------|
-| `make deps` | Install Go modules and npm packages |
+| Command | What it does |
+| ------------ | --------------------------------------------------------- |
+| `make deps` | Install Go modules and npm packages |
| `make build` | Production build → `build/bin/nexus-terminal.app` (macOS) |
-| `make dev` | Development mode with hot reload |
-| `make test` | Run all Go tests (`go test ./... -v -count=1`) |
-| `make lint` | Run golangci-lint |
+| `make dev` | Development mode with hot reload |
+| `make test` | Run all Go tests (`go test ./... -v -count=1`) |
+| `make lint` | Run golangci-lint |
To run tests for a single package:
@@ -322,11 +322,11 @@ Then reopen your terminal so `gcc` is on `PATH` and retry `build.ps1`.
## Data Locations
-| Platform | Database | Vault | Recordings |
-|----------|----------|-------|------------|
-| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
-| Linux | `~/.config/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
-| Windows | `%APPDATA%\NexusTerminal\nexus.db` | `...\vault` | `...\recordings\` |
+| Platform | Database | Vault | Recordings |
+| -------- | ------------------------------------------------------ | ----------- | ----------------- |
+| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
+| Linux | `~/.config/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
+| Windows | `%APPDATA%\NexusTerminal\nexus.db` | `...\vault` | `...\recordings\` |
The database uses SQLite in WAL (Write-Ahead Logging) mode. Migrations are embedded at compile time and applied automatically on startup.
@@ -360,9 +360,11 @@ Check the character encoding setting on the connection profile. Most modern serv
**Build fails with "wails: command not found"**
Install the Wails CLI:
+
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
```
+
Make sure `$GOPATH/bin` (or `$HOME/go/bin`) is in your `PATH`.
---
diff --git a/build/windows/info.json b/build/windows/info.json
new file mode 100644
index 0000000..9727946
--- /dev/null
+++ b/build/windows/info.json
@@ -0,0 +1,15 @@
+{
+ "fixed": {
+ "file_version": "{{.Info.ProductVersion}}"
+ },
+ "info": {
+ "0000": {
+ "ProductVersion": "{{.Info.ProductVersion}}",
+ "CompanyName": "{{.Info.CompanyName}}",
+ "FileDescription": "{{.Info.ProductName}}",
+ "LegalCopyright": "{{.Info.Copyright}}",
+ "ProductName": "{{.Info.ProductName}}",
+ "Comments": "{{.Info.Comments}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest
new file mode 100644
index 0000000..17e1a23
--- /dev/null
+++ b/build/windows/wails.exe.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2,permonitor
+
+
+
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index f0f876f..3adf795 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,104 +1,166 @@
-
+
-
-
-
+
+
+
Nexus Terminal
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
No active sessions
-
Select a connection from the sidebar or create a new one to get started.
-
-
-
-
-
+
+
+
+
+
No active sessions
+
+ Select a connection from the sidebar or create a new one to get
+ started.
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
@@ -215,5 +305,5 @@
-
+
diff --git a/frontend/scripts/copy-assets.js b/frontend/scripts/copy-assets.js
index db8d17b..0e00a16 100644
--- a/frontend/scripts/copy-assets.js
+++ b/frontend/scripts/copy-assets.js
@@ -4,38 +4,38 @@
*
* Run automatically via the postinstall hook in package.json.
*/
-'use strict';
+"use strict";
-const fs = require('fs');
-const path = require('path');
+const fs = require("fs");
+const path = require("path");
-const frontendDir = path.resolve(__dirname, '..');
+const frontendDir = path.resolve(__dirname, "..");
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
const assets = [
{
- src: 'xterm/css/xterm.css',
- dest: 'src/css/lib/xterm.css',
+ src: "xterm/css/xterm.css",
+ dest: "src/css/lib/xterm.css",
},
{
- src: 'xterm/lib/xterm.js',
- dest: 'src/js/lib/xterm.min.js',
+ src: "xterm/lib/xterm.js",
+ dest: "src/js/lib/xterm.min.js",
},
{
- src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
- dest: 'src/js/lib/xterm-addon-fit.min.js',
+ src: "xterm-addon-fit/lib/xterm-addon-fit.js",
+ dest: "src/js/lib/xterm-addon-fit.min.js",
},
{
- src: 'xterm-addon-search/lib/xterm-addon-search.js',
- dest: 'src/js/lib/xterm-addon-search.min.js',
+ src: "xterm-addon-search/lib/xterm-addon-search.js",
+ dest: "src/js/lib/xterm-addon-search.min.js",
},
{
- src: 'xterm-addon-unicode11/lib/xterm-addon-unicode11.js',
- dest: 'src/js/lib/xterm-addon-unicode11.min.js',
+ src: "xterm-addon-unicode11/lib/xterm-addon-unicode11.js",
+ dest: "src/js/lib/xterm-addon-unicode11.min.js",
},
{
- src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
- dest: 'src/js/lib/xterm-addon-web-links.min.js',
+ src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
+ dest: "src/js/lib/xterm-addon-web-links.min.js",
},
];
@@ -53,7 +53,7 @@ function ensureDir(filePath) {
* Copies a single asset, logging the result.
*/
function copyAsset(entry) {
- const srcPath = path.join(frontendDir, 'node_modules', entry.src);
+ const srcPath = path.join(frontendDir, "node_modules", entry.src);
const destPath = path.join(frontendDir, entry.dest);
if (!fs.existsSync(srcPath)) {
@@ -69,7 +69,7 @@ function copyAsset(entry) {
// --- Main ---
-console.log('[copy-assets] Copying xterm library files…');
+console.log("[copy-assets] Copying xterm library files…");
let failures = 0;
for (const entry of assets) {
@@ -84,4 +84,4 @@ if (failures > 0) {
process.exit(1);
}
-console.log('[copy-assets] All assets copied successfully.');
+console.log("[copy-assets] All assets copied successfully.");
diff --git a/frontend/src/css/github.css b/frontend/src/css/github.css
index de684a5..d7ab2e8 100644
--- a/frontend/src/css/github.css
+++ b/frontend/src/css/github.css
@@ -174,6 +174,12 @@
margin-bottom: 2px;
}
+.github-repo-commit {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
.github-repo-name {
font-size: var(--font-size-sm);
font-weight: 600;
@@ -190,6 +196,35 @@
letter-spacing: 0.03em;
}
+.github-repo-actions {
+ margin-left: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+}
+
+.github-repo-download,
+.github-repo-visit {
+ padding: 0 6px;
+ height: 18px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.github-repo-download:hover,
+.github-repo-visit:hover {
+ background: var(--bg-hover);
+ color: var(--protocol-github);
+ border-color: var(--protocol-github);
+}
+
.github-repo-private {
background: rgba(255, 167, 38, 0.12);
color: #ffa726;
diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css
index 7bff9c5..75bd12b 100644
--- a/frontend/src/css/sidebar.css
+++ b/frontend/src/css/sidebar.css
@@ -53,6 +53,34 @@
padding-bottom: var(--space-2);
}
+/* -----------------------------------------------------------------------
+ Bulk actions bar (multi-select)
+ ----------------------------------------------------------------------- */
+.sidebar-bulk-actions {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.sidebar-bulk-count {
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+ font-weight: var(--weight-semibold);
+}
+
+.sidebar-bulk-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+}
+
/* -----------------------------------------------------------------------
Sections (Favourites, Recent, All Connections)
----------------------------------------------------------------------- */
@@ -167,6 +195,15 @@
background: var(--bg-active);
}
+.sidebar-item-selected {
+ background: var(--accent-muted);
+ box-shadow: inset 0 0 0 1px var(--accent);
+}
+
+.sidebar-item-selected .sidebar-item-name {
+ color: var(--text-primary);
+}
+
/* Active / selected state */
.sidebar-item.active {
background: var(--accent-muted);
diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js
index 7c1e7e8..2aef9b0 100644
--- a/frontend/src/js/app.js
+++ b/frontend/src/js/app.js
@@ -1,28 +1,45 @@
-(function() {
-/**
- * Nexus Terminal — main application entry point.
- * Initialises the application shell, loads profiles, and sets up event listeners.
- */
+(function () {
+ /**
+ * Nexus Terminal — main application entry point.
+ * Initialises the application shell, loads profiles, and sets up event listeners.
+ */
-const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, SFTPBrowser, SnippetLibrary, stateLabel } = window.NexusTerminal;
+ const {
+ State,
+ Sidebar,
+ TabManager,
+ TerminalManager,
+ ProfileEditor,
+ RawViewer,
+ Notifications,
+ Settings,
+ CommandPalette,
+ SecretPrompt,
+ SplitPaneManager,
+ RecordingManager,
+ ExportImportUI,
+ SFTPBrowser,
+ SnippetLibrary,
+ stateLabel,
+ } = window.NexusTerminal;
-class App {
+ class App {
constructor() {
- this.state = new State();
- this.notifications = new Notifications();
- this.sidebar = new Sidebar(this);
- this.tabs = new TabManager(this);
- this.terminals = new TerminalManager(this);
- this.profileEditor = new ProfileEditor(this);
- this.rawViewer = new RawViewer(this);
- this.settings = new Settings(this);
- this.commandPalette = new CommandPalette(this);
- this.secretPrompt = new SecretPrompt(this);
- this.splitPanes = new SplitPaneManager(this);
- this.recordings = new RecordingManager(this);
- this.exportImport = new ExportImportUI(this);
- this.sftp = new SFTPBrowser(this);
- this.snippets = new SnippetLibrary(this);
+ this.state = new State();
+ this.notifications = new Notifications();
+ this.sidebar = new Sidebar(this);
+ this.tabs = new TabManager(this);
+ this.terminals = new TerminalManager(this);
+ this.profileEditor = new ProfileEditor(this);
+ this.rawViewer = new RawViewer(this);
+ this.settings = new Settings(this);
+ this.commandPalette = new CommandPalette(this);
+ this.secretPrompt = new SecretPrompt(this);
+ this.splitPanes = new SplitPaneManager(this);
+ this.recordings = new RecordingManager(this);
+ this.exportImport = new ExportImportUI(this);
+ this.sftp = new SFTPBrowser(this);
+ this.snippets = new SnippetLibrary(this);
}
/**
@@ -31,19 +48,19 @@ class App {
* Backend data loading waits for the Wails runtime to be available.
*/
async init() {
- // Phase 1 — bind all UI event handlers immediately (DOM-only, no backend).
- this.setupUI();
- this.setupKeyboard();
- this.updateStatusBar();
+ // Phase 1 — bind all UI event handlers immediately (DOM-only, no backend).
+ this.setupUI();
+ this.setupKeyboard();
+ this.updateStatusBar();
- // Phase 2 — wait for the Wails runtime, then load backend data and bind events.
- await this._waitForWailsRuntime();
- this.setupEvents();
+ // Phase 2 — wait for the Wails runtime, then load backend data and bind events.
+ await this._waitForWailsRuntime();
+ this.setupEvents();
- await this.loadProfiles();
- await this.loadPreferences();
- await this.checkSecretStore();
- this.updateStatusBar();
+ await this.loadProfiles();
+ await this.loadPreferences();
+ await this.checkSecretStore();
+ this.updateStatusBar();
}
/**
@@ -55,21 +72,25 @@ class App {
* @private
*/
_waitForWailsRuntime() {
- return new Promise((resolve) => {
- const maxWait = 5000;
- const start = Date.now();
- const check = () => {
- if (window.go && window.runtime) {
- resolve();
- } else if (Date.now() - start < maxWait) {
- setTimeout(check, 50);
- } else {
- console.warn('Wails runtime not detected after ' + maxWait + 'ms — proceeding anyway');
- resolve();
- }
- };
- check();
- });
+ return new Promise((resolve) => {
+ const maxWait = 5000;
+ const start = Date.now();
+ const check = () => {
+ if (window.go && window.runtime) {
+ resolve();
+ } else if (Date.now() - start < maxWait) {
+ setTimeout(check, 50);
+ } else {
+ console.warn(
+ "Wails runtime not detected after " +
+ maxWait +
+ "ms — proceeding anyway",
+ );
+ resolve();
+ }
+ };
+ check();
+ });
}
// ── Data loading ─────────────────────────────────────────────────
@@ -78,45 +99,46 @@ class App {
* Fetches all connection profiles from the backend and renders the sidebar.
*/
async loadProfiles() {
- try {
- const profiles = await window.go.api.Service.ListProfiles();
- this.state.setProfiles(profiles || []);
- this.sidebar.render();
- } catch (err) {
- this.notifications.error('Failed to load profiles: ' + err);
- }
+ try {
+ const profiles = await window.go.api.Service.ListProfiles();
+ this.state.setProfiles(profiles || []);
+ this.sidebar.render();
+ } catch (err) {
+ this.notifications.error("Failed to load profiles: " + err);
+ }
}
/**
* Loads user preferences from the backend.
*/
async loadPreferences() {
- try {
- const prefs = await window.go.api.Service.GetPreferences();
- this.state.setPreferences(prefs || {});
- this._applyPreferences();
- } catch (err) {
- // Non-fatal — fall back to defaults
- console.warn('Failed to load preferences:', err);
- }
+ try {
+ const prefs = await window.go.api.Service.GetPreferences();
+ this.state.setPreferences(prefs || {});
+ this._applyPreferences();
+ } catch (err) {
+ // Non-fatal — fall back to defaults
+ console.warn("Failed to load preferences:", err);
+ }
}
/**
* Queries the secret store status (initialised / unlocked).
*/
async checkSecretStore() {
- try {
- const initialised = await window.go.api.Service.IsSecretStoreInitialised();
- this.state.setSecretStoreInitialised(!!initialised);
- if (initialised) {
- const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
- this.state.setSecretStoreState(!!unlocked);
- } else {
- this.state.setSecretStoreState(false);
- }
- } catch (_) {
- // Secret store may not be available yet
+ try {
+ const initialised =
+ await window.go.api.Service.IsSecretStoreInitialised();
+ this.state.setSecretStoreInitialised(!!initialised);
+ if (initialised) {
+ const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
+ this.state.setSecretStoreState(!!unlocked);
+ } else {
+ this.state.setSecretStoreState(false);
}
+ } catch (_) {
+ // Secret store may not be available yet
+ }
}
// ── Wails event listeners ────────────────────────────────────────
@@ -126,152 +148,200 @@ class App {
* Guarded: silently skips if the runtime is unavailable.
*/
setupEvents() {
- if (!window.runtime || typeof window.runtime.EventsOn !== 'function') {
- console.warn('window.runtime not available — Wails event listeners not bound');
- return;
+ if (!window.runtime || typeof window.runtime.EventsOn !== "function") {
+ console.warn(
+ "window.runtime not available — Wails event listeners not bound",
+ );
+ return;
+ }
+ // Incoming session data (terminal output)
+ window.runtime.EventsOn("session:data", (payload) => {
+ this.terminals.handleData(payload.sessionID, payload.data);
+ });
+
+ // Session state transitions (connecting → connected → reconnecting → disconnected)
+ window.runtime.EventsOn("session:state", (payload) => {
+ this.tabs.updateSessionState(
+ payload.sessionID,
+ payload.state,
+ payload.error,
+ );
+ this.updateStatusBar();
+
+ if (payload.state === "error" && payload.error) {
+ this._handleSessionError(payload.sessionID, payload.error);
}
- // Incoming session data (terminal output)
- window.runtime.EventsOn('session:data', (payload) => {
- this.terminals.handleData(payload.sessionID, payload.data);
- });
- // Session state transitions (connecting → connected → reconnecting → disconnected)
- window.runtime.EventsOn('session:state', (payload) => {
- this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
- this.updateStatusBar();
+ // Show reconnecting notification
+ if (payload.state === "reconnecting") {
+ const attempt = payload.attempt || 1;
+ const maxRetries = payload.maxRetries || 5;
+ const delaySec = payload.delaySec || 1;
+ this.notifications.info(
+ `Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…`,
+ );
+ }
- // Show reconnecting notification
- if (payload.state === 'reconnecting') {
- const attempt = payload.attempt || 1;
- const maxRetries = payload.maxRetries || 5;
- const delaySec = payload.delaySec || 1;
- this.notifications.info(
- `Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…`
- );
- }
-
- // Once connected, ensure the backend PTY has the correct dimensions.
- // The initial fit happens before the SSH handshake completes, so the
- // onResize callback fires when the backend isn't ready. By the time
- // the connection is established, xterm already has the right cols/rows
- // and fitAddon.fit() won't trigger a new resize event. We must
- // explicitly send the current terminal size to the backend.
- if (payload.state === 'connected') {
- const entry = this.terminals.terminals.get(payload.sessionID);
- if (entry) {
- const { cols, rows } = entry.term;
- try {
- window.go.api.Service.ResizeSession(payload.sessionID, cols, rows);
- } catch (_) { /* noop */ }
- }
- // Also re-fit to catch any layout changes that occurred while connecting.
- const fit = () => this.terminals.fitSession(payload.sessionID);
- setTimeout(fit, 50);
- setTimeout(fit, 300);
- }
- });
-
- // Session closed by backend
- window.runtime.EventsOn('session:closed', (payload) => {
- this.sftp.close(payload.sessionID);
- this.tabs.handleSessionClosed(payload.sessionID);
- this.updateStatusBar();
- });
-
- // RAW TCP data (separate channel for the raw viewer)
- window.runtime.EventsOn('session:raw:data', (payload) => {
- this.rawViewer.handleData(payload);
- });
-
- // GitHub repository data
- window.runtime.EventsOn('session:github:repos', (payload) => {
- window.NexusTerminal.GitHubViewer.updateRepos(
- payload.sessionID, payload.login, payload.repos
- );
- });
-
- // GitHub refresh errors
- window.runtime.EventsOn('session:github:error', (payload) => {
- window.NexusTerminal.GitHubViewer.showError(
- payload.sessionID, payload.error
- );
- });
-
- // Port-forwarding events
- window.runtime.EventsOn('session:forward:started', (payload) => {
- const fwd = payload.forward || {};
- const label = fwd.label || `${fwd.bindHost || ''}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`;
- this.notifications.success(`Forward started: ${label}`);
- });
-
- window.runtime.EventsOn('session:forward:stopped', (payload) => {
- this.notifications.info(`Forward stopped: ${payload.forwardID}`);
- });
-
- window.runtime.EventsOn('session:forward:error', (payload) => {
- this.notifications.error(`Forward error: ${payload.error}`);
- });
-
- // Recording events
- window.runtime.EventsOn('session:recording:started', (payload) => {
- this.recordings.activeSessions.add(payload.sessionID);
- this.recordings._updateRecordingIndicator(payload.sessionID, true);
- });
-
- window.runtime.EventsOn('session:recording:stopped', (payload) => {
- this.recordings.activeSessions.delete(payload.sessionID);
- this.recordings._updateRecordingIndicator(payload.sessionID, false);
- });
-
- // Telnet negotiation debug messages
- window.runtime.EventsOn('session:debug', (payload) => {
- console.debug('[session:debug]', payload);
- });
-
- // Backend ready event (emitted from DomReady)
- window.runtime.EventsOn('backend:ready', () => {
- console.log('Backend is ready');
- });
-
- // Host key verification requests
- window.runtime.EventsOn('session:hostkey:verify', async (payload) => {
+ // Once connected, ensure the backend PTY has the correct dimensions.
+ // The initial fit happens before the SSH handshake completes, so the
+ // onResize callback fires when the backend isn't ready. By the time
+ // the connection is established, xterm already has the right cols/rows
+ // and fitAddon.fit() won't trigger a new resize event. We must
+ // explicitly send the current terminal size to the backend.
+ if (payload.state === "connected") {
+ const entry = this.terminals.terminals.get(payload.sessionID);
+ if (entry) {
+ const { cols, rows } = entry.term;
try {
- const accepted = await this.secretPrompt.promptHostKeyVerification(
- payload.host,
- payload.keyType,
- payload.fingerprint,
- );
- window.runtime.EventsEmit('session:hostkey:response', {
- sessionID: payload.sessionID,
- accepted,
- });
+ window.go.api.Service.ResizeSession(
+ payload.sessionID,
+ cols,
+ rows,
+ );
} catch (_) {
- window.runtime.EventsEmit('session:hostkey:response', {
- sessionID: payload.sessionID,
- accepted: false,
- });
+ /* noop */
}
- });
+ }
+ // Also re-fit to catch any layout changes that occurred while connecting.
+ const fit = () => this.terminals.fitSession(payload.sessionID);
+ setTimeout(fit, 50);
+ setTimeout(fit, 300);
+ }
+ });
- // Password/passphrase prompt requests from the backend
- window.runtime.EventsOn('session:secret:prompt', async (payload) => {
- try {
- const value = await this.secretPrompt.prompt(
- payload.title || 'Credentials Required',
- payload.message || 'Enter your password or passphrase.',
- );
- window.runtime.EventsEmit('session:secret:response', {
- requestID: payload.requestID,
- value,
- });
- } catch (_) {
- window.runtime.EventsEmit('session:secret:response', {
- requestID: payload.requestID,
- value: '',
- cancelled: true,
- });
- }
- });
+ // Session closed by backend
+ window.runtime.EventsOn("session:closed", (payload) => {
+ this.sftp.close(payload.sessionID);
+ this.tabs.handleSessionClosed(payload.sessionID);
+ this.updateStatusBar();
+ });
+
+ // RAW TCP data (separate channel for the raw viewer)
+ window.runtime.EventsOn("session:raw:data", (payload) => {
+ this.rawViewer.handleData(payload);
+ });
+
+ // GitHub repository data
+ window.runtime.EventsOn("session:github:repos", (payload) => {
+ window.NexusTerminal.GitHubViewer.updateRepos(
+ payload.sessionID,
+ payload.login,
+ payload.repos,
+ );
+ });
+
+ // GitHub refresh errors
+ window.runtime.EventsOn("session:github:error", (payload) => {
+ window.NexusTerminal.GitHubViewer.showError(
+ payload.sessionID,
+ payload.error,
+ );
+ });
+
+ // GitHub status updates
+ window.runtime.EventsOn("session:github:status", (payload) => {
+ window.NexusTerminal.GitHubViewer.setStatus(
+ payload.sessionID,
+ payload.status,
+ );
+ });
+
+ // Port-forwarding events
+ window.runtime.EventsOn("session:forward:started", (payload) => {
+ const fwd = payload.forward || {};
+ const label =
+ fwd.label ||
+ `${fwd.bindHost || ""}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`;
+ this.notifications.success(`Forward started: ${label}`);
+ });
+
+ window.runtime.EventsOn("session:forward:stopped", (payload) => {
+ this.notifications.info(`Forward stopped: ${payload.forwardID}`);
+ });
+
+ window.runtime.EventsOn("session:forward:error", (payload) => {
+ this.notifications.error(`Forward error: ${payload.error}`);
+ });
+
+ // Recording events
+ window.runtime.EventsOn("session:recording:started", (payload) => {
+ this.recordings.activeSessions.add(payload.sessionID);
+ this.recordings._updateRecordingIndicator(payload.sessionID, true);
+ });
+
+ window.runtime.EventsOn("session:recording:stopped", (payload) => {
+ this.recordings.activeSessions.delete(payload.sessionID);
+ this.recordings._updateRecordingIndicator(payload.sessionID, false);
+ });
+
+ // Telnet negotiation debug messages
+ window.runtime.EventsOn("session:debug", (payload) => {
+ console.debug("[session:debug]", payload);
+ });
+
+ // Backend ready event (emitted from DomReady)
+ window.runtime.EventsOn("backend:ready", () => {
+ console.log("Backend is ready");
+ });
+
+ // Host key verification requests
+ window.runtime.EventsOn("session:hostkey:verify", async (payload) => {
+ try {
+ const accepted = await this.secretPrompt.promptHostKeyVerification(
+ payload.host,
+ payload.keyType,
+ payload.fingerprint,
+ );
+ window.runtime.EventsEmit("session:hostkey:response", {
+ sessionID: payload.sessionID,
+ accepted,
+ });
+ } catch (_) {
+ window.runtime.EventsEmit("session:hostkey:response", {
+ sessionID: payload.sessionID,
+ accepted: false,
+ });
+ }
+ });
+
+ // Password/passphrase prompt requests from the backend
+ window.runtime.EventsOn("session:secret:prompt", async (payload) => {
+ try {
+ const value = await this.secretPrompt.prompt(
+ payload.title || "Credentials Required",
+ payload.message || "Enter your password or passphrase.",
+ );
+ window.runtime.EventsEmit("session:secret:response", {
+ requestID: payload.requestID,
+ value,
+ });
+ } catch (_) {
+ window.runtime.EventsEmit("session:secret:response", {
+ requestID: payload.requestID,
+ value: "",
+ cancelled: true,
+ });
+ }
+ });
+ }
+
+ /**
+ * Disconnects and removes a session without confirmation prompts.
+ * @param {string} sessionId
+ */
+ async closeSessionImmediate(sessionId) {
+ try {
+ await window.go.api.Service.DisconnectSession(sessionId);
+ } catch (_) {
+ // Ignore failures; we still want to clean up locally.
+ }
+
+ this.terminals.destroyTerminal(sessionId);
+ this.rawViewer.destroyViewer(sessionId);
+ window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
+ this.state.removeSession(sessionId);
+ this.tabs.removeTab(sessionId);
+ this.updateStatusBar();
}
// ── Keyboard shortcuts ───────────────────────────────────────────
@@ -280,127 +350,132 @@ class App {
* Registers global keyboard shortcuts.
*/
setupKeyboard() {
- document.addEventListener('keydown', (e) => {
- const mod = e.ctrlKey || e.metaKey;
+ document.addEventListener("keydown", (e) => {
+ const mod = e.ctrlKey || e.metaKey;
- // Ctrl/Cmd+K — command palette
- if (mod && e.key === 'k') {
- e.preventDefault();
- this.commandPalette.toggle();
- return;
- }
+ // Ctrl/Cmd+K — command palette
+ if (mod && e.key === "k") {
+ e.preventDefault();
+ this.commandPalette.toggle();
+ return;
+ }
- // Ctrl/Cmd+N — new connection
- if (mod && e.key === 'n') {
- e.preventDefault();
- this.profileEditor.openNew();
- return;
- }
+ // Ctrl/Cmd+N — new connection
+ if (mod && e.key === "n") {
+ e.preventDefault();
+ this.profileEditor.openNew();
+ return;
+ }
- // Ctrl/Cmd+W — close active tab
- if (mod && e.key === 'w') {
- e.preventDefault();
- this.tabs.closeActive();
- return;
- }
+ // Ctrl/Cmd+W — close active tab
+ if (mod && e.key === "w") {
+ e.preventDefault();
+ this.tabs.closeActive();
+ return;
+ }
- // Ctrl/Cmd+Tab — next tab
- if (mod && e.key === 'Tab' && !e.shiftKey) {
- e.preventDefault();
- const next = this.tabs.getNextTab();
- if (next) this.tabs.setActive(next);
- return;
- }
+ // Ctrl/Cmd+Tab — next tab
+ if (mod && e.key === "Tab" && !e.shiftKey) {
+ e.preventDefault();
+ const next = this.tabs.getNextTab();
+ if (next) this.tabs.setActive(next);
+ return;
+ }
- // Ctrl/Cmd+Shift+Tab — previous tab
- if (mod && e.key === 'Tab' && e.shiftKey) {
- e.preventDefault();
- const prev = this.tabs.getPrevTab();
- if (prev) this.tabs.setActive(prev);
- return;
- }
+ // Ctrl/Cmd+Shift+Tab — previous tab
+ if (mod && e.key === "Tab" && e.shiftKey) {
+ e.preventDefault();
+ const prev = this.tabs.getPrevTab();
+ if (prev) this.tabs.setActive(prev);
+ return;
+ }
- // Ctrl/Cmd+1-9 — switch to tab by index
- if (mod && e.key >= '1' && e.key <= '9') {
- e.preventDefault();
- const idx = parseInt(e.key, 10) - 1;
- const order = this.tabs.getTabOrder();
- if (idx < order.length) {
- this.tabs.setActive(order[idx]);
- }
- return;
- }
+ // Ctrl/Cmd+1-9 — switch to tab by index
+ if (mod && e.key >= "1" && e.key <= "9") {
+ e.preventDefault();
+ const idx = parseInt(e.key, 10) - 1;
+ const order = this.tabs.getTabOrder();
+ if (idx < order.length) {
+ this.tabs.setActive(order[idx]);
+ }
+ return;
+ }
- // Ctrl/Cmd+, — settings
- if (mod && e.key === ',') {
- e.preventDefault();
- this.settings.open();
- return;
- }
+ // Ctrl/Cmd+, — settings
+ if (mod && e.key === ",") {
+ e.preventDefault();
+ this.settings.open();
+ return;
+ }
- // Ctrl/Cmd+F — find in terminal
- if (mod && e.key === 'f') {
- e.preventDefault();
- this._openTerminalSearch();
- return;
- }
+ // Ctrl/Cmd+F — find in terminal
+ if (mod && e.key === "f") {
+ e.preventDefault();
+ this._openTerminalSearch();
+ return;
+ }
- // F11 — toggle fullscreen (Windows/Linux convention)
- if (e.key === 'F11') {
- e.preventDefault();
- if (window.runtime && typeof window.runtime.WindowFullscreen === 'function') {
- window.runtime.WindowFullscreen();
- }
- return;
- }
+ // F11 — toggle fullscreen (Windows/Linux convention)
+ if (e.key === "F11") {
+ e.preventDefault();
+ if (
+ window.runtime &&
+ typeof window.runtime.WindowFullscreen === "function"
+ ) {
+ window.runtime.WindowFullscreen();
+ }
+ return;
+ }
- // Escape — close any open overlays
- if (e.key === 'Escape') {
- this.commandPalette.close();
- // Also exit fullscreen if active (Windows/Linux)
- if (window.runtime && typeof window.runtime.WindowUnfullscreen === 'function') {
- window.runtime.WindowUnfullscreen();
- }
- }
+ // Escape — close any open overlays
+ if (e.key === "Escape") {
+ this.commandPalette.close();
+ // Also exit fullscreen if active (Windows/Linux)
+ if (
+ window.runtime &&
+ typeof window.runtime.WindowUnfullscreen === "function"
+ ) {
+ window.runtime.WindowUnfullscreen();
+ }
+ }
- // Ctrl/Cmd+Shift+D — split pane vertically (left/right)
- if (mod && e.shiftKey && e.key === 'D') {
- e.preventDefault();
- this.splitPane('vertical');
- return;
- }
+ // Ctrl/Cmd+Shift+D — split pane vertically (left/right)
+ if (mod && e.shiftKey && e.key === "D") {
+ e.preventDefault();
+ this.splitPane("vertical");
+ return;
+ }
- // Ctrl/Cmd+Shift+E — split pane horizontally (top/bottom)
- if (mod && e.shiftKey && e.key === 'E') {
- e.preventDefault();
- this.splitPane('horizontal');
- return;
- }
+ // Ctrl/Cmd+Shift+E — split pane horizontally (top/bottom)
+ if (mod && e.shiftKey && e.key === "E") {
+ e.preventDefault();
+ this.splitPane("horizontal");
+ return;
+ }
- // Ctrl/Cmd+Shift+R — toggle recording on active session
- if (mod && e.shiftKey && e.key === 'R') {
- e.preventDefault();
- if (this.state.activeSessionId) {
- this.recordings.toggle(this.state.activeSessionId);
- }
- return;
- }
+ // Ctrl/Cmd+Shift+R — toggle recording on active session
+ if (mod && e.shiftKey && e.key === "R") {
+ e.preventDefault();
+ if (this.state.activeSessionId) {
+ this.recordings.toggle(this.state.activeSessionId);
+ }
+ return;
+ }
- // Ctrl/Cmd+Shift+B — toggle broadcast mode
- if (mod && e.shiftKey && e.key === 'B') {
- e.preventDefault();
- this.toggleBroadcast();
- return;
- }
+ // Ctrl/Cmd+Shift+B — toggle broadcast mode
+ if (mod && e.shiftKey && e.key === "B") {
+ e.preventDefault();
+ this.toggleBroadcast();
+ return;
+ }
- // Ctrl/Cmd+Shift+S — open snippet library
- if (mod && e.shiftKey && e.key === 'S') {
- e.preventDefault();
- this.snippets.open();
- return;
- }
-
- });
+ // Ctrl/Cmd+Shift+S — open snippet library
+ if (mod && e.shiftKey && e.key === "S") {
+ e.preventDefault();
+ this.snippets.open();
+ return;
+ }
+ });
}
// ── UI event wiring ──────────────────────────────────────────────
@@ -409,28 +484,33 @@ class App {
* Binds click handlers to static UI elements.
*/
setupUI() {
- var btnEl = document.getElementById('btn-new-connection');
- this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
- this._bindClick('btn-settings', () => this.settings.open());
- this._bindClick('btn-import', () => this.importProfiles());
- this._bindClick('btn-export', () => this.exportProfiles());
- this._bindClick('btn-recordings', () => this.recordings.openBrowser());
- this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
- this._bindClick('btn-broadcast', () => this.toggleBroadcast());
- this._bindClick('btn-empty-connect', () => this.profileEditor.openNew());
- this._bindClick('btn-empty-quick', () => this.quickConnect());
+ var btnEl = document.getElementById("btn-new-connection");
+ this._bindClick("btn-new-connection", () => this.profileEditor.openNew());
+ this._bindClick("btn-settings", () => this.settings.open());
+ this._bindClick("btn-import", () => this.importProfiles());
+ this._bindClick("btn-export", () => this.exportProfiles());
+ this._bindClick("btn-recordings", () => this.recordings.openBrowser());
+ this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
+ this._bindClick("btn-broadcast", () => this.toggleBroadcast());
+ this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
+ this._bindClick("btn-empty-quick", () => this.quickConnect());
- const searchInput = document.getElementById('profile-search');
- if (searchInput) {
- searchInput.addEventListener('input', (e) => this.sidebar.search(e.target.value));
- }
+ const searchInput = document.getElementById("profile-search");
+ if (searchInput) {
+ searchInput.addEventListener("input", (e) =>
+ this.sidebar.search(e.target.value),
+ );
+ }
- // Window resize → re-fit active terminal (debounced)
- const { debounce } = window.NexusTerminal;
- window.addEventListener('resize', debounce(() => {
- this.terminals.fitActive();
- this.splitPanes.refitAll();
- }, 100));
+ // Window resize → re-fit active terminal (debounced)
+ const { debounce } = window.NexusTerminal;
+ window.addEventListener(
+ "resize",
+ debounce(() => {
+ this.terminals.fitActive();
+ this.splitPanes.refitAll();
+ }, 100),
+ );
}
// ── Split pane ─────────────────────────────────────────────────
@@ -440,12 +520,12 @@ class App {
* @param {'horizontal'|'vertical'} direction
*/
splitPane(direction) {
- if (!this.state.activeSessionId) return;
- if (direction === 'horizontal') {
- this.splitPanes.splitHorizontal();
- } else {
- this.splitPanes.splitVertical();
- }
+ if (!this.state.activeSessionId) return;
+ if (direction === "horizontal") {
+ this.splitPanes.splitHorizontal();
+ } else {
+ this.splitPanes.splitVertical();
+ }
}
// ── Broadcast mode ─────────────────────────────────────────────
@@ -455,19 +535,19 @@ class App {
* connected sessions simultaneously.
*/
toggleBroadcast() {
- this.state.broadcastMode = !this.state.broadcastMode;
- const btn = document.getElementById('btn-broadcast');
- if (btn) {
- btn.classList.toggle('is-active', this.state.broadcastMode);
- btn.title = this.state.broadcastMode
- ? 'Broadcast Mode ON — keystrokes sent to all sessions (Ctrl+Shift+B)'
- : 'Toggle Broadcast Mode (Ctrl+Shift+B)';
- }
- this.notifications.info(
- this.state.broadcastMode
- ? 'Broadcast mode enabled — input sent to all sessions'
- : 'Broadcast mode disabled'
- );
+ this.state.broadcastMode = !this.state.broadcastMode;
+ const btn = document.getElementById("btn-broadcast");
+ if (btn) {
+ btn.classList.toggle("is-active", this.state.broadcastMode);
+ btn.title = this.state.broadcastMode
+ ? "Broadcast Mode ON — keystrokes sent to all sessions (Ctrl+Shift+B)"
+ : "Toggle Broadcast Mode (Ctrl+Shift+B)";
+ }
+ this.notifications.info(
+ this.state.broadcastMode
+ ? "Broadcast mode enabled — input sent to all sessions"
+ : "Broadcast mode disabled",
+ );
}
// ── Connection lifecycle ─────────────────────────────────────────
@@ -477,64 +557,75 @@ class App {
* @param {string} profileId
*/
async connectProfile(profileId) {
- const profile = this.state.getProfile(profileId);
- if (!profile) {
- this.notifications.error('Profile not found');
- return;
+ const profile = this.state.getProfile(profileId);
+ if (!profile) {
+ this.notifications.error("Profile not found");
+ return;
+ }
+
+ const existingSessions = this.state.getAllSessions().filter((session) => {
+ if (session.profileId !== profileId) return false;
+ return (
+ session.state === "connected" ||
+ session.state === "connecting" ||
+ session.state === "reconnecting"
+ );
+ });
+ if (existingSessions.length > 0) {
+ const preferred =
+ existingSessions.find((s) => s.id === this.state.activeSessionId) ||
+ existingSessions[0];
+ const decision = await this._confirmReuseSession(profile, preferred);
+ if (decision === "existing") {
+ this.tabs.setActive(preferred.id);
+ if (preferred.protocol !== "raw" && preferred.protocol !== "github") {
+ this.terminals.focusTerminal(preferred.id);
+ }
+ return;
+ }
+ if (decision !== "new") return;
+ }
+
+ try {
+ const sessionInfo =
+ await window.go.api.Service.ConnectSession(profileId);
+ this.state.addSession(sessionInfo);
+ this.tabs.addTab(sessionInfo);
+
+ // Create the appropriate viewer
+ const panelId = `panel-${sessionInfo.id}`;
+ if (profile.protocol === "raw") {
+ this.rawViewer.createViewer(sessionInfo.id, panelId, profile);
+ } else if (profile.protocol === "github") {
+ const container = document.getElementById(panelId);
+ if (container) {
+ window.NexusTerminal.GitHubViewer.createViewer(
+ sessionInfo.id,
+ container,
+ profile.displayName || profile.host,
+ );
+ }
+ } else {
+ this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
- const existingSessions = this.state.getAllSessions().filter((session) => {
- if (session.profileId !== profileId) return false;
- return session.state === 'connected' || session.state === 'connecting' || session.state === 'reconnecting';
- });
- if (existingSessions.length > 0) {
- const preferred = existingSessions.find((s) => s.id === this.state.activeSessionId) || existingSessions[0];
- const decision = await this._confirmReuseSession(profile, preferred);
- if (decision === 'existing') {
- this.tabs.setActive(preferred.id);
- if (preferred.protocol !== 'raw' && preferred.protocol !== 'github') {
- this.terminals.focusTerminal(preferred.id);
- }
- return;
- }
- if (decision !== 'new') return;
- }
-
- try {
- const sessionInfo = await window.go.api.Service.ConnectSession(profileId);
- this.state.addSession(sessionInfo);
- this.tabs.addTab(sessionInfo);
-
- // Create the appropriate viewer
- const panelId = `panel-${sessionInfo.id}`;
- if (profile.protocol === 'raw') {
- this.rawViewer.createViewer(sessionInfo.id, panelId, profile);
- } else if (profile.protocol === 'github') {
- const container = document.getElementById(panelId);
- if (container) {
- window.NexusTerminal.GitHubViewer.createViewer(
- sessionInfo.id, container, profile.displayName || profile.host
- );
- }
- } else {
- this.terminals.createTerminal(sessionInfo.id, panelId, profile);
- }
-
- this.notifications.info(`Connecting to ${profile.displayName || profile.host}…`);
- } catch (err) {
- this.notifications.error('Connection failed: ' + err);
- }
+ this.notifications.info(
+ `Connecting to ${profile.displayName || profile.host}…`,
+ );
+ } catch (err) {
+ this.notifications.error("Connection failed: " + err);
+ }
}
/**
* Opens a quick connect dialog for ad-hoc connections without saving a profile.
*/
async quickConnect() {
- const createElement = window.NexusTerminal.createElement;
- const result = await new Promise((resolve) => {
- const backdrop = createElement('div', 'modal-backdrop');
- const modal = createElement('div', 'modal modal-sm');
- modal.innerHTML = `
+ const createElement = window.NexusTerminal.createElement;
+ const result = await new Promise((resolve) => {
+ const backdrop = createElement("div", "modal-backdrop");
+ const modal = createElement("div", "modal modal-sm");
+ modal.innerHTML = `
`;
- backdrop.appendChild(modal);
- document.body.appendChild(backdrop);
- requestAnimationFrame(() => {
- backdrop.classList.add('is-visible');
- modal.classList.add('is-visible');
- });
-
- const protocolSelect = modal.querySelector('.qc-protocol');
- const portInput = modal.querySelector('.qc-port');
- const usernameGroup = modal.querySelector('.qc-username-group');
- const hostInput = modal.querySelector('.qc-host');
-
- // Update port default and username visibility when protocol changes
- protocolSelect.addEventListener('change', () => {
- const proto = protocolSelect.value;
- const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
- portInput.value = defaultPorts[proto] || 22;
- usernameGroup.style.display = proto === 'ssh' ? '' : 'none';
- });
-
- const close = (value) => {
- backdrop.classList.remove('is-visible');
- modal.classList.remove('is-visible');
- setTimeout(() => backdrop.remove(), 200);
- resolve(value);
- };
-
- modal.querySelector('.modal-close').addEventListener('click', () => close(null));
- modal.querySelector('.qc-cancel').addEventListener('click', () => close(null));
- modal.querySelector('.qc-connect').addEventListener('click', () => {
- const host = hostInput.value.trim();
- if (!host) {
- hostInput.focus();
- return;
- }
- close({
- protocol: protocolSelect.value,
- host,
- port: parseInt(portInput.value, 10) || 22,
- username: modal.querySelector('.qc-username').value.trim(),
- });
- });
- backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
- hostInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') modal.querySelector('.qc-connect').click();
- if (e.key === 'Escape') close(null);
- });
-
- setTimeout(() => hostInput.focus(), 50);
+ backdrop.appendChild(modal);
+ document.body.appendChild(backdrop);
+ requestAnimationFrame(() => {
+ backdrop.classList.add("is-visible");
+ modal.classList.add("is-visible");
});
- if (!result) return;
+ const protocolSelect = modal.querySelector(".qc-protocol");
+ const portInput = modal.querySelector(".qc-port");
+ const usernameGroup = modal.querySelector(".qc-username-group");
+ const hostInput = modal.querySelector(".qc-host");
- try {
- const profile = {
- protocol: result.protocol,
- host: result.host,
- port: result.port,
- username: result.username || '',
- displayName: result.host,
- tags: [],
- };
- const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
- this.state.addSession(sessionInfo);
- this.tabs.addTab(sessionInfo);
+ // Update port default and username visibility when protocol changes
+ protocolSelect.addEventListener("change", () => {
+ const proto = protocolSelect.value;
+ const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
+ portInput.value = defaultPorts[proto] || 22;
+ usernameGroup.style.display = proto === "ssh" ? "" : "none";
+ });
- const panelId = `panel-${sessionInfo.id}`;
- if (result.protocol === 'raw') {
- this.rawViewer.createViewer(sessionInfo.id, panelId, { protocol: 'raw', raw: {} });
- } else {
- this.terminals.createTerminal(sessionInfo.id, panelId, { protocol: result.protocol });
- }
+ const close = (value) => {
+ backdrop.classList.remove("is-visible");
+ modal.classList.remove("is-visible");
+ setTimeout(() => backdrop.remove(), 200);
+ resolve(value);
+ };
- this.notifications.info(`Connecting to ${result.host}…`);
- } catch (err) {
- this.notifications.error('Quick connect failed: ' + err);
+ modal
+ .querySelector(".modal-close")
+ .addEventListener("click", () => close(null));
+ modal
+ .querySelector(".qc-cancel")
+ .addEventListener("click", () => close(null));
+ modal.querySelector(".qc-connect").addEventListener("click", () => {
+ const host = hostInput.value.trim();
+ if (!host) {
+ hostInput.focus();
+ return;
+ }
+ close({
+ protocol: protocolSelect.value,
+ host,
+ port: parseInt(portInput.value, 10) || 22,
+ username: modal.querySelector(".qc-username").value.trim(),
+ });
+ });
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) close(null);
+ });
+ hostInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") modal.querySelector(".qc-connect").click();
+ if (e.key === "Escape") close(null);
+ });
+
+ setTimeout(() => hostInput.focus(), 50);
+ });
+
+ if (!result) return;
+
+ try {
+ const profile = {
+ protocol: result.protocol,
+ host: result.host,
+ port: result.port,
+ username: result.username || "",
+ displayName: result.host,
+ tags: [],
+ };
+ const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
+ this.state.addSession(sessionInfo);
+ this.tabs.addTab(sessionInfo);
+
+ const panelId = `panel-${sessionInfo.id}`;
+ if (result.protocol === "raw") {
+ this.rawViewer.createViewer(sessionInfo.id, panelId, {
+ protocol: "raw",
+ raw: {},
+ });
+ } else {
+ this.terminals.createTerminal(sessionInfo.id, panelId, {
+ protocol: result.protocol,
+ });
}
+
+ this.notifications.info(`Connecting to ${result.host}…`);
+ } catch (err) {
+ this.notifications.error("Quick connect failed: " + err);
+ }
}
/**
@@ -653,14 +755,15 @@ class App {
* @private
*/
_confirmReuseSession(profile, session) {
- const createElement = window.NexusTerminal.createElement;
- const label = (profile.displayName || profile.host || 'Untitled').trim();
- const stateLabel = session.state === 'connected' ? 'connected' : 'connecting';
+ const createElement = window.NexusTerminal.createElement;
+ const label = (profile.displayName || profile.host || "Untitled").trim();
+ const stateLabel =
+ session.state === "connected" ? "connected" : "connecting";
- return new Promise((resolve) => {
- const backdrop = createElement('div', 'modal-backdrop');
- const modal = createElement('div', 'modal modal-sm');
- modal.innerHTML = `
+ return new Promise((resolve) => {
+ const backdrop = createElement("div", "modal-backdrop");
+ const modal = createElement("div", "modal modal-sm");
+ modal.innerHTML = `
`;
- backdrop.appendChild(modal);
- document.body.appendChild(backdrop);
- requestAnimationFrame(() => {
- backdrop.classList.add('is-visible');
- modal.classList.add('is-visible');
- });
-
- const close = (val) => {
- backdrop.classList.remove('is-visible');
- modal.classList.remove('is-visible');
- setTimeout(() => backdrop.remove(), 200);
- resolve(val);
- };
-
- modal.querySelector('.modal-close').addEventListener('click', () => close(null));
- modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
- modal.querySelector('.confirm-ok').addEventListener('click', () => close('existing'));
- modal.querySelector('.confirm-new').addEventListener('click', () => close('new'));
- backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
+ backdrop.appendChild(modal);
+ document.body.appendChild(backdrop);
+ requestAnimationFrame(() => {
+ backdrop.classList.add("is-visible");
+ modal.classList.add("is-visible");
});
+
+ const close = (val) => {
+ backdrop.classList.remove("is-visible");
+ modal.classList.remove("is-visible");
+ setTimeout(() => backdrop.remove(), 200);
+ resolve(val);
+ };
+
+ modal
+ .querySelector(".modal-close")
+ .addEventListener("click", () => close(null));
+ modal
+ .querySelector(".confirm-cancel")
+ .addEventListener("click", () => close(null));
+ modal
+ .querySelector(".confirm-ok")
+ .addEventListener("click", () => close("existing"));
+ modal
+ .querySelector(".confirm-new")
+ .addEventListener("click", () => close("new"));
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) close(null);
+ });
+ });
}
// ── Import / Export ──────────────────────────────────────────────
@@ -705,14 +818,14 @@ class App {
* Triggers the encrypted profile import dialog.
*/
async importProfiles() {
- this.exportImport.showImportModal();
+ this.exportImport.showImportModal();
}
/**
* Triggers the encrypted profile export dialog.
*/
async exportProfiles() {
- this.exportImport.showExportModal();
+ this.exportImport.showExportModal();
}
// ── Status bar ───────────────────────────────────────────────────
@@ -721,29 +834,32 @@ class App {
* Updates the status bar with current session info.
*/
updateStatusBar() {
- const connEl = document.getElementById('status-connection');
- const countEl = document.getElementById('status-session-count');
- const vaultEl = document.getElementById('status-vault');
- if (!connEl) return;
+ const connEl = document.getElementById("status-connection");
+ const countEl = document.getElementById("status-session-count");
+ const vaultEl = document.getElementById("status-vault");
+ if (!connEl) return;
- const sessions = this.state.getAllSessions();
- const connected = sessions.filter((s) => s.state === 'connected').length;
- const total = sessions.length;
+ const sessions = this.state.getAllSessions();
+ const connected = sessions.filter((s) => s.state === "connected").length;
+ const total = sessions.length;
- const activeSession = this.state.activeSessionId
- ? this.state.getSession(this.state.activeSessionId)
- : null;
+ const activeSession = this.state.activeSessionId
+ ? this.state.getSession(this.state.activeSessionId)
+ : null;
- if (activeSession) {
- connEl.innerHTML = ` ${stateLabel(activeSession.state)}`;
- } else {
- connEl.innerHTML = ' No active session';
- }
+ if (activeSession) {
+ connEl.innerHTML = ` ${stateLabel(activeSession.state)}`;
+ } else {
+ connEl.innerHTML =
+ ' No active session';
+ }
- if (countEl) countEl.textContent = `${connected}/${total} sessions`;
- if (vaultEl) {
- vaultEl.textContent = this.state.secretStoreUnlocked ? '🔓 Unlocked' : '🔒 Locked';
- }
+ if (countEl) countEl.textContent = `${connected}/${total} sessions`;
+ if (vaultEl) {
+ vaultEl.textContent = this.state.secretStoreUnlocked
+ ? "🔓 Unlocked"
+ : "🔒 Locked";
+ }
}
// ── Internal helpers ─────────────────────────────────────────────
@@ -753,34 +869,172 @@ class App {
* @private
*/
_applyPreferences() {
- const prefs = this.state.preferences;
+ const prefs = this.state.preferences;
- // Sidebar width (drives the grid column width).
- // Use the CSS variable so the workspace never sits underneath the sidebar.
- const root = document.documentElement;
- const sidebar = document.getElementById('sidebar');
- const parsedSidebarWidth = Number(prefs.sidebarWidth);
- if (Number.isFinite(parsedSidebarWidth) && parsedSidebarWidth > 0) {
- const clampedWidth = Math.max(180, Math.min(500, Math.round(parsedSidebarWidth)));
- root.style.setProperty('--sidebar-width', `${clampedWidth}px`);
- } else {
- root.style.removeProperty('--sidebar-width');
- }
- if (sidebar) {
- sidebar.style.width = '';
- }
+ // Sidebar width (drives the grid column width).
+ // Use the CSS variable so the workspace never sits underneath the sidebar.
+ const root = document.documentElement;
+ const sidebar = document.getElementById("sidebar");
+ const parsedSidebarWidth = Number(prefs.sidebarWidth);
+ if (Number.isFinite(parsedSidebarWidth) && parsedSidebarWidth > 0) {
+ const clampedWidth = Math.max(
+ 180,
+ Math.min(500, Math.round(parsedSidebarWidth)),
+ );
+ root.style.setProperty("--sidebar-width", `${clampedWidth}px`);
+ } else {
+ root.style.removeProperty("--sidebar-width");
+ }
+ if (sidebar) {
+ sidebar.style.width = "";
+ }
- // Status bar visibility
- const statusBar = document.getElementById('status-bar');
- if (statusBar) {
- statusBar.style.display = prefs.showStatusBar === false ? 'none' : 'flex';
- }
+ // Status bar visibility
+ const statusBar = document.getElementById("status-bar");
+ if (statusBar) {
+ statusBar.style.display =
+ prefs.showStatusBar === false ? "none" : "flex";
+ }
- // Refit any visible terminal after geometry changes.
- requestAnimationFrame(() => {
- this.terminals.fitActive();
- this.splitPanes.refitAll();
+ // Refit any visible terminal after geometry changes.
+ requestAnimationFrame(() => {
+ this.terminals.fitActive();
+ this.splitPanes.refitAll();
+ });
+ }
+
+ /**
+ * Determines whether an error is a common authentication or host key issue.
+ * @param {string} message
+ * @returns {Object|null}
+ * @private
+ */
+ _classifySessionError(message) {
+ const msg = (message || "").toLowerCase();
+
+ const authPatterns = [
+ "unable to authenticate",
+ "authentication failed",
+ "permission denied",
+ "no supported methods",
+ ];
+ const hostKeyPatterns = [
+ "host key",
+ "fingerprint",
+ ];
+
+ if (authPatterns.some((p) => msg.includes(p))) {
+ return {
+ type: "auth",
+ title: "Authentication Failed",
+ message:
+ "The server rejected the provided credentials. Check username, password, keys, and auth methods.",
+ actions: ["edit", "retry"],
+ };
+ }
+
+ if (hostKeyPatterns.some((p) => msg.includes(p))) {
+ return {
+ type: "hostkey",
+ title: "Host Key Verification Failed",
+ message:
+ "The server's host key could not be verified. Manage known hosts or update the profile's policy.",
+ actions: ["knownhosts", "edit", "retry"],
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Shows a guided action modal for common session errors.
+ * @param {string} sessionId
+ * @param {string} message
+ * @private
+ */
+ _handleSessionError(sessionId, message) {
+ const info = this._classifySessionError(message);
+ if (!info) return;
+
+ const session = this.state.getSession(sessionId);
+ const label = session?.tabName || session?.host || "Session";
+ const body = `${info.message}\n\n${message}`;
+ this._showGuidedErrorModal(info.title, label, body, (action) => {
+ if (action === "edit" && session?.profileId) {
+ this.profileEditor.openEdit(session.profileId);
+ }
+ if (action === "retry") {
+ this.tabs._reconnect(sessionId);
+ }
+ if (action === "knownhosts") {
+ this.settings.openTab("knownhosts");
+ }
+ }, info.actions);
+ }
+
+ /**
+ * Builds and displays a modal with guided action buttons.
+ * @param {string} title
+ * @param {string} label
+ * @param {string} message
+ * @param {Function} onAction
+ * @param {Array} actions
+ * @private
+ */
+ _showGuidedErrorModal(title, label, message, onAction, actions) {
+ const createElement = window.NexusTerminal.createElement;
+ const backdrop = createElement("div", "modal-backdrop");
+ const modal = createElement("div", "modal modal-sm");
+ const safeLabel = window.NexusTerminal.escapeHtml(label || "Session");
+
+ modal.innerHTML = `
+
+
+
+ ${safeLabel}
+
+
+ ${window.NexusTerminal.escapeHtml(message)}
+
+
+
+ `;
+
+ const footer = modal.querySelector(".modal-footer");
+ const addActionBtn = (label, action, primary) => {
+ const btn = createElement("button", `btn btn-sm${primary ? " btn-primary" : ""}`, {
+ textContent: label,
});
+ btn.addEventListener("click", () => close(action));
+ footer.appendChild(btn);
+ };
+
+ addActionBtn("Close", null, false);
+ if (actions.includes("knownhosts")) addActionBtn("Known Hosts", "knownhosts", false);
+ if (actions.includes("edit")) addActionBtn("Edit Profile", "edit", false);
+ if (actions.includes("retry")) addActionBtn("Retry", "retry", true);
+
+ const close = (action) => {
+ backdrop.classList.remove("is-visible");
+ modal.classList.remove("is-visible");
+ setTimeout(() => backdrop.remove(), 200);
+ if (action) onAction(action);
+ };
+
+ modal.querySelector(".modal-close").addEventListener("click", () => close(null));
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) close(null);
+ });
+
+ backdrop.appendChild(modal);
+ document.body.appendChild(backdrop);
+ requestAnimationFrame(() => {
+ backdrop.classList.add("is-visible");
+ modal.classList.add("is-visible");
+ });
}
/**
@@ -788,123 +1042,139 @@ class App {
* @private
*/
_openTerminalSearch() {
- const sessionId = this.state.activeSessionId;
- if (!sessionId) return;
+ const sessionId = this.state.activeSessionId;
+ if (!sessionId) return;
- const panel = document.getElementById(`panel-${sessionId}`);
- if (!panel) return;
+ const panel = document.getElementById(`panel-${sessionId}`);
+ if (!panel) return;
- // If search bar already exists, just focus the input
- const existing = panel.querySelector('.terminal-search');
- if (existing) {
- const input = existing.querySelector('input');
- if (input) input.focus();
- return;
+ // If search bar already exists, just focus the input
+ const existing = panel.querySelector(".terminal-search");
+ if (existing) {
+ const input = existing.querySelector("input");
+ if (input) input.focus();
+ return;
+ }
+
+ const bar = document.createElement("div");
+ bar.className = "terminal-search";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "Find in terminal…";
+ input.className = "terminal-search-input";
+
+ const count = document.createElement("span");
+ count.className = "terminal-search-count";
+ count.textContent = "";
+
+ const prevBtn = document.createElement("button");
+ prevBtn.className = "btn btn-xs btn-ghost";
+ prevBtn.textContent = "▲";
+ prevBtn.title = "Previous match (Shift+Enter)";
+
+ const nextBtn = document.createElement("button");
+ nextBtn.className = "btn btn-xs btn-ghost";
+ nextBtn.textContent = "▼";
+ nextBtn.title = "Next match (Enter)";
+
+ const caseSensBtn = document.createElement("button");
+ caseSensBtn.className = "btn btn-xs btn-ghost terminal-search-toggle";
+ caseSensBtn.textContent = "Aa";
+ caseSensBtn.title = "Toggle case sensitivity";
+ let caseSensitive = false;
+
+ const regexBtn = document.createElement("button");
+ regexBtn.className = "btn btn-xs btn-ghost terminal-search-toggle";
+ regexBtn.textContent = ".*";
+ regexBtn.title = "Toggle regular expression";
+ let useRegex = false;
+
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "btn btn-xs btn-ghost";
+ closeBtn.textContent = "✕";
+ closeBtn.title = "Close (Escape)";
+
+ bar.appendChild(input);
+ bar.appendChild(count);
+ bar.appendChild(prevBtn);
+ bar.appendChild(nextBtn);
+ bar.appendChild(caseSensBtn);
+ bar.appendChild(regexBtn);
+ bar.appendChild(closeBtn);
+
+ const searchOpts = () => ({ caseSensitive, regex: useRegex });
+
+ const doSearch = () => {
+ const found = this.terminals.search(
+ sessionId,
+ input.value,
+ searchOpts(),
+ );
+ count.textContent = input.value
+ ? found
+ ? "Match found"
+ : "No matches"
+ : "";
+ };
+ const doPrev = () => {
+ const found = this.terminals.searchPrevious(
+ sessionId,
+ input.value,
+ searchOpts(),
+ );
+ count.textContent = input.value
+ ? found
+ ? "Match found"
+ : "No matches"
+ : "";
+ };
+
+ const closeSearch = () => {
+ // Clear search decorations
+ const entry = this.terminals.terminals.get(sessionId);
+ if (entry && entry.searchAddon) {
+ entry.searchAddon.clearDecorations();
}
+ bar.classList.remove("is-visible");
+ setTimeout(() => bar.remove(), 200);
+ this.terminals.focusTerminal(sessionId);
+ };
- const bar = document.createElement('div');
- bar.className = 'terminal-search';
+ input.addEventListener("input", doSearch);
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.shiftKey ? doPrev() : doSearch();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ closeSearch();
+ }
+ });
- const input = document.createElement('input');
- input.type = 'text';
- input.placeholder = 'Find in terminal…';
- input.className = 'terminal-search-input';
+ nextBtn.addEventListener("click", doSearch);
+ prevBtn.addEventListener("click", doPrev);
+ closeBtn.addEventListener("click", closeSearch);
- const count = document.createElement('span');
- count.className = 'terminal-search-count';
- count.textContent = '';
+ caseSensBtn.addEventListener("click", () => {
+ caseSensitive = !caseSensitive;
+ caseSensBtn.classList.toggle("is-active", caseSensitive);
+ doSearch();
+ });
- const prevBtn = document.createElement('button');
- prevBtn.className = 'btn btn-xs btn-ghost';
- prevBtn.textContent = '▲';
- prevBtn.title = 'Previous match (Shift+Enter)';
+ regexBtn.addEventListener("click", () => {
+ useRegex = !useRegex;
+ regexBtn.classList.toggle("is-active", useRegex);
+ doSearch();
+ });
- const nextBtn = document.createElement('button');
- nextBtn.className = 'btn btn-xs btn-ghost';
- nextBtn.textContent = '▼';
- nextBtn.title = 'Next match (Enter)';
-
- const caseSensBtn = document.createElement('button');
- caseSensBtn.className = 'btn btn-xs btn-ghost terminal-search-toggle';
- caseSensBtn.textContent = 'Aa';
- caseSensBtn.title = 'Toggle case sensitivity';
- let caseSensitive = false;
-
- const regexBtn = document.createElement('button');
- regexBtn.className = 'btn btn-xs btn-ghost terminal-search-toggle';
- regexBtn.textContent = '.*';
- regexBtn.title = 'Toggle regular expression';
- let useRegex = false;
-
- const closeBtn = document.createElement('button');
- closeBtn.className = 'btn btn-xs btn-ghost';
- closeBtn.textContent = '✕';
- closeBtn.title = 'Close (Escape)';
-
- bar.appendChild(input);
- bar.appendChild(count);
- bar.appendChild(prevBtn);
- bar.appendChild(nextBtn);
- bar.appendChild(caseSensBtn);
- bar.appendChild(regexBtn);
- bar.appendChild(closeBtn);
-
- const searchOpts = () => ({ caseSensitive, regex: useRegex });
-
- const doSearch = () => {
- const found = this.terminals.search(sessionId, input.value, searchOpts());
- count.textContent = input.value ? (found ? 'Match found' : 'No matches') : '';
- };
- const doPrev = () => {
- const found = this.terminals.searchPrevious(sessionId, input.value, searchOpts());
- count.textContent = input.value ? (found ? 'Match found' : 'No matches') : '';
- };
-
- const closeSearch = () => {
- // Clear search decorations
- const entry = this.terminals.terminals.get(sessionId);
- if (entry && entry.searchAddon) {
- entry.searchAddon.clearDecorations();
- }
- bar.classList.remove('is-visible');
- setTimeout(() => bar.remove(), 200);
- this.terminals.focusTerminal(sessionId);
- };
-
- input.addEventListener('input', doSearch);
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.shiftKey ? doPrev() : doSearch();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- closeSearch();
- }
- });
-
- nextBtn.addEventListener('click', doSearch);
- prevBtn.addEventListener('click', doPrev);
- closeBtn.addEventListener('click', closeSearch);
-
- caseSensBtn.addEventListener('click', () => {
- caseSensitive = !caseSensitive;
- caseSensBtn.classList.toggle('is-active', caseSensitive);
- doSearch();
- });
-
- regexBtn.addEventListener('click', () => {
- useRegex = !useRegex;
- regexBtn.classList.toggle('is-active', useRegex);
- doSearch();
- });
-
- panel.appendChild(bar);
- // Trigger the entrance animation
- requestAnimationFrame(() => {
- bar.classList.add('is-visible');
- input.focus();
- });
+ panel.appendChild(bar);
+ // Trigger the entrance animation
+ requestAnimationFrame(() => {
+ bar.classList.add("is-visible");
+ input.focus();
+ });
}
/**
@@ -914,16 +1184,16 @@ class App {
* @private
*/
_bindClick(id, handler) {
- const el = document.getElementById(id);
- if (el) {
- el.addEventListener('click', (e) => {
- try {
- handler(e);
- } catch (err) {
- console.error('Click handler error for', id, err);
- }
- });
- }
+ const el = document.getElementById(id);
+ if (el) {
+ el.addEventListener("click", (e) => {
+ try {
+ handler(e);
+ } catch (err) {
+ console.error("Click handler error for", id, err);
+ }
+ });
+ }
}
/**
@@ -936,11 +1206,11 @@ class App {
* @private
*/
_inputPrompt(title, label, placeholder) {
- const createElement = window.NexusTerminal.createElement;
- return new Promise((resolve) => {
- const backdrop = createElement('div', 'modal-backdrop');
- const modal = createElement('div', 'modal modal-sm');
- modal.innerHTML = `
+ const createElement = window.NexusTerminal.createElement;
+ return new Promise((resolve) => {
+ const backdrop = createElement("div", "modal-backdrop");
+ const modal = createElement("div", "modal modal-sm");
+ modal.innerHTML = `