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 = ` + + + + `; + + 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 = `