feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client

Production-minded Wails v2 desktop application with:
- Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive)
- Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding)
- RAW TCP client (binary-safe, text/hex/split views, payload presets)
- SQLite storage with migrations and repository abstraction
- Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback
- Known hosts verification (strict/ask/accept-new/insecure)
- Session manager with multi-session lifecycle and Wails events
- Dark charcoal Material-inspired design system (CSS custom properties)
- Tabbed session management with rename, reorder, pin, context menu
- Profile editor with protocol-aware form sections
- Command palette, notifications, settings modal
- JSON import/export, audit logging, preferences
- 50+ tests across validation, secrets, storage, encoding, sessions
- Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
2026-04-15 00:00:00 +01:00
parent c767c1640e
commit 5bff1d9f3e
17 changed files with 5644 additions and 45 deletions
+487
View File
@@ -0,0 +1,487 @@
/**
* Nexus Terminal — main application entry point.
* Initialises the application shell, loads profiles, and sets up event listeners.
*/
import { State } from './state.js';
import { Sidebar } from './sidebar.js';
import { TabManager } from './tabs.js';
import { TerminalManager } from './terminal.js';
import { ProfileEditor } from './profiles.js';
import { RawViewer } from './raw.js';
import { Notifications } from './notifications.js';
import { Settings } from './settings.js';
import { CommandPalette } from './commands.js';
import { SecretPrompt } from './secrets.js';
import { stateLabel } from './utils.js';
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);
}
/**
* Bootstraps the application after the DOM is ready.
*/
async init() {
await this.loadProfiles();
await this.loadPreferences();
await this.checkSecretStore();
this.setupEvents();
this.setupKeyboard();
this.setupUI();
this.updateStatusBar();
}
// ── Data loading ─────────────────────────────────────────────────
/**
* 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);
}
}
/**
* 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);
}
}
/**
* Queries the secret store status (initialised / unlocked).
*/
async checkSecretStore() {
try {
const status = await window.go.api.Service.SecretStoreStatus();
this.state.setSecretStoreInitialised(status?.initialised || false);
this.state.setSecretStoreState(status?.unlocked || false);
} catch (_) {
// Secret store may not be available yet
}
}
// ── Wails event listeners ────────────────────────────────────────
/**
* Registers listeners for backend events emitted via the Wails runtime.
*/
setupEvents() {
// Incoming session data (terminal output)
window.runtime.EventsOn('session:data', (payload) => {
this.terminals.handleData(payload.sessionID, payload.data);
});
// Session state transitions (connecting → connected → disconnected)
window.runtime.EventsOn('session:state', (payload) => {
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
this.updateStatusBar();
});
// Session closed by backend
window.runtime.EventsOn('session:closed', (payload) => {
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);
});
// 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', {
sessionID: payload.sessionID,
value,
});
} catch (_) {
window.runtime.EventsEmit('session:secret:response', {
sessionID: payload.sessionID,
value: '',
cancelled: true,
});
}
});
}
// ── Keyboard shortcuts ───────────────────────────────────────────
/**
* Registers global keyboard shortcuts.
*/
setupKeyboard() {
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+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+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+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+F — find in terminal
if (mod && e.key === 'f') {
e.preventDefault();
this._openTerminalSearch();
return;
}
// Escape — close any open overlays
if (e.key === 'Escape') {
this.commandPalette.close();
}
});
}
// ── UI event wiring ──────────────────────────────────────────────
/**
* Binds click handlers to static UI elements.
*/
setupUI() {
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
this._bindClick('btn-settings', () => this.settings.open());
this._bindClick('btn-import', () => this.importProfiles());
this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
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));
}
// Window resize → re-fit active terminal
window.addEventListener('resize', () => this.terminals.fitActive());
}
// ── Connection lifecycle ─────────────────────────────────────────
/**
* Opens a new session for the given profile ID.
* @param {string} profileId
*/
async connectProfile(profileId) {
const profile = this.state.getProfile(profileId);
if (!profile) {
this.notifications.error('Profile not found');
return;
}
try {
const sessionInfo = await window.go.api.Service.Connect(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 {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
this.notifications.info(`Connecting to ${profile.displayName || profile.host}…`);
} catch (err) {
this.notifications.error('Connection failed: ' + err);
}
}
/**
* Prompts the user for a host/port and opens a quick connection.
*/
async quickConnect() {
const input = prompt('Quick connect (host[:port]):');
if (!input || !input.trim()) return;
const parts = input.trim().split(':');
const host = parts[0];
const port = parts[1] ? parseInt(parts[1], 10) : 22;
const protocol = this.state.preferences.defaultProtocol || 'ssh';
try {
const sessionInfo = await window.go.api.Service.QuickConnect(protocol, host, port);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
if (protocol === 'raw') {
this.rawViewer.createViewer(sessionInfo.id, panelId, { protocol, raw: {} });
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, { protocol });
}
} catch (err) {
this.notifications.error('Quick connect failed: ' + err);
}
}
// ── Import / Export ──────────────────────────────────────────────
/**
* Triggers a profile import dialog.
*/
async importProfiles() {
try {
const result = await window.go.api.Service.ImportProfilesDialog();
if (result && result.count > 0) {
await this.loadProfiles();
this.notifications.success(`Imported ${result.count} profile(s)`);
}
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Import failed: ' + err);
}
}
}
/**
* Triggers a profile export dialog.
*/
async exportProfiles() {
try {
await window.go.api.Service.ExportProfilesDialog();
this.notifications.success('Profiles exported');
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Export failed: ' + err);
}
}
}
// ── Status bar ───────────────────────────────────────────────────
/**
* Updates the status bar with current session info.
*/
updateStatusBar() {
const bar = document.getElementById('status-bar');
if (!bar) return;
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;
// Left: active session info
const left = bar.querySelector('.status-left') || bar;
if (activeSession) {
const profile = this.state.getProfile(activeSession.profileId);
left.textContent = [
activeSession.protocol?.toUpperCase(),
profile ? `${profile.host}:${profile.port}` : '',
stateLabel(activeSession.state),
].filter(Boolean).join(' — ');
} else {
left.textContent = 'No active session';
}
// Right: session count
const right = bar.querySelector('.status-right');
if (right) {
right.textContent = `${connected}/${total} connected`;
}
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Applies loaded preferences to the UI.
* @private
*/
_applyPreferences() {
const prefs = this.state.preferences;
// Sidebar width
const sidebar = document.getElementById('sidebar');
if (sidebar && prefs.sidebarWidth) {
sidebar.style.width = `${prefs.sidebarWidth}px`;
}
// Status bar visibility
const statusBar = document.getElementById('status-bar');
if (statusBar) {
statusBar.style.display = prefs.showStatusBar === false ? 'none' : 'flex';
}
}
/**
* Opens an inline search bar for the active terminal.
* @private
*/
_openTerminalSearch() {
const sessionId = this.state.activeSessionId;
if (!sessionId) return;
// Check if search bar already exists
const panel = document.getElementById(`panel-${sessionId}`);
if (!panel || panel.querySelector('.terminal-search-bar')) return;
const bar = document.createElement('div');
bar.className = 'terminal-search-bar';
bar.innerHTML = `
<input type="text" class="form-input terminal-search-input" placeholder="Find…" />
<button class="btn btn-xs btn-outline terminal-search-prev">▲</button>
<button class="btn btn-xs btn-outline terminal-search-next">▼</button>
<button class="btn btn-xs btn-outline terminal-search-close">✕</button>
`;
const input = bar.querySelector('.terminal-search-input');
const closeBtn = bar.querySelector('.terminal-search-close');
const prevBtn = bar.querySelector('.terminal-search-prev');
const nextBtn = bar.querySelector('.terminal-search-next');
const doSearch = () => this.terminals.search(sessionId, input.value);
const doPrev = () => this.terminals.searchPrevious(sessionId, input.value);
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.shiftKey ? doPrev() : doSearch();
}
if (e.key === 'Escape') {
bar.remove();
this.terminals.focusTerminal(sessionId);
}
});
nextBtn.addEventListener('click', doSearch);
prevBtn.addEventListener('click', doPrev);
closeBtn.addEventListener('click', () => {
bar.remove();
this.terminals.focusTerminal(sessionId);
});
panel.prepend(bar);
input.focus();
}
/**
* Safely binds a click handler to an element by ID (no-op if the element does not exist).
* @param {string} id - Element ID.
* @param {Function} handler
* @private
*/
_bindClick(id, handler) {
const el = document.getElementById(id);
if (el) el.addEventListener('click', handler);
}
}
// ── Bootstrap ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const app = new App();
app.init().catch((err) => console.error('App initialisation failed:', err));
});
+381
View File
@@ -0,0 +1,381 @@
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
import { createElement, escapeHtml, fuzzyMatch, $ } from './utils.js';
export class CommandPalette {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = '';
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = '';
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
/**
* Returns the full list of available commands.
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: 'Connections',
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
// Quick connect
cmds.push({
id: 'quick-connect',
label: 'Quick Connect…',
section: 'Actions',
action: () => this.app.quickConnect(),
});
// Tab actions
cmds.push({
id: 'close-tab',
label: 'Close Tab',
section: 'Tabs',
shortcut: '⌘W',
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: 'next-tab',
label: 'Next Tab',
section: 'Tabs',
shortcut: '⌘Tab',
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: 'prev-tab',
label: 'Previous Tab',
section: 'Tabs',
shortcut: '⌘⇧Tab',
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: 'settings',
label: 'Settings',
section: 'Application',
action: () => this.app.settings.open(),
});
cmds.push({
id: 'import-profiles',
label: 'Import Profiles',
section: 'Application',
action: () => this.app.importProfiles(),
});
cmds.push({
id: 'export-profiles',
label: 'Export Profiles',
section: 'Application',
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
}
/**
* Returns commands filtered and sorted by the current query.
* @param {string} query
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
* Executes a command and closes the palette.
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === 'function') {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement('div', 'palette-overlay');
const palette = createElement('div', 'palette');
// Search input
const input = createElement('input', 'palette-input', {
type: 'text',
placeholder: 'Type a command…',
autocomplete: 'off',
spellcheck: 'false',
});
palette.appendChild(input);
// Results list
const list = createElement('div', 'palette-list');
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(''));
// Focus the input
input.focus();
// Event handlers
input.addEventListener('input', () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener('keydown', (e) => {
const items = list.querySelectorAll('.palette-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
this._highlightItem(items);
break;
case 'ArrowUp':
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case 'Enter': {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case 'Escape':
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close();
});
}
/**
* Renders the filtered command list.
* @param {HTMLElement} list
* @param {Array<Object>} commands
* @private
*/
_renderList(list, commands) {
list.innerHTML = '';
let currentSection = null;
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
item._command = cmd;
// Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
}
}
/**
* Highlights the currently selected item and scrolls it into view.
* @param {NodeListOf<Element>} items
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
}
}
+898
View File
@@ -0,0 +1,898 @@
/**
* Nexus Terminal — connection profile editor.
* Renders a slide-out drawer for creating and editing connection profiles.
* Dynamically switches form sections based on the selected protocol.
*/
import { createElement, escapeHtml, generateId, $ } from './utils.js';
/**
* Default values mirroring the Go backend defaults.
*/
const DEFAULTS = {
ssh: {
port: 22,
protocol: 'ssh',
ssh: {
authMethods: ['publickey', 'password', 'keyboard-interactive'],
secretMode: 'ask-every-time',
keySecretMode: 'ask-every-time',
hostKeyPolicy: 'ask',
terminalType: 'xterm-256color',
keepaliveInterval: 30,
keepaliveCountMax: 3,
compression: false,
jumpHosts: [],
startupCommand: '',
envVars: {},
},
},
telnet: {
port: 23,
protocol: 'telnet',
telnet: {
localEcho: false,
lineEnding: 'crlf',
terminalType: 'xterm-256color',
encoding: 'utf-8',
negotiationDebug: false,
lineMode: false,
},
},
raw: {
port: 23,
protocol: 'raw',
raw: {
autoReconnect: false,
lineEnding: 'crlf',
encoding: 'utf-8',
showTimestamps: false,
showDirection: false,
payloadPresets: [],
},
},
};
export class ProfileEditor {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this.drawer = null;
/** @type {Object|null} The profile currently being edited. */
this._profile = null;
/** @type {boolean} */
this._isNew = false;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the editor with a blank profile of the given protocol.
* @param {string} [protocol='ssh']
*/
openNew(protocol = 'ssh') {
const profile = this._defaultProfile(protocol);
this._isNew = true;
this._profile = profile;
this.render(profile, true);
}
/**
* Opens the editor pre-populated with an existing profile.
* @param {string} profileId
*/
openEdit(profileId) {
const profile = this.app.state.getProfile(profileId);
if (!profile) {
this.app.notifications.error('Profile not found');
return;
}
this._isNew = false;
this._profile = JSON.parse(JSON.stringify(profile)); // deep clone
this.render(this._profile, false);
}
/**
* Renders (or re-renders) the editor drawer.
* @param {Object} profile
* @param {boolean} isNew
*/
render(profile, isNew) {
this.close();
const overlay = createElement('div', 'drawer-overlay');
const drawer = createElement('div', 'drawer');
// ── Header ──────────────────────────────────────────────
const header = createElement('div', 'drawer-header');
header.innerHTML = `
<h2 class="drawer-title">${isNew ? 'New Connection' : 'Edit Connection'}</h2>
<button class="drawer-close" aria-label="Close">&times;</button>
`;
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ['ssh', 'telnet', 'raw'];
const tabBar = createElement('div', 'drawer-protocol-tabs');
for (const proto of protocols) {
const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, {
textContent: proto.toUpperCase(),
dataset: { proto },
});
btn.addEventListener('click', () => this.handleProtocolChange(proto));
tabBar.appendChild(btn);
}
drawer.appendChild(tabBar);
// ── Scrollable body ─────────────────────────────────────
const body = createElement('div', 'drawer-body');
// General section (common to all protocols)
body.appendChild(this._renderGeneralSection(profile));
// Protocol-specific sections
switch (profile.protocol) {
case 'ssh':
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
break;
case 'telnet':
this._renderTelnetSections(profile).forEach((s) => body.appendChild(s));
break;
case 'raw':
this._renderRAWSections(profile).forEach((s) => body.appendChild(s));
break;
}
// Terminal section (SSH and Telnet only)
if (profile.protocol !== 'raw') {
body.appendChild(this._renderTerminalSection(profile));
}
// Notes & Tags section (common)
body.appendChild(this._renderNotesSection(profile));
drawer.appendChild(body);
// ── Footer ──────────────────────────────────────────────
const footer = createElement('div', 'drawer-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test Connection' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
cancelBtn.addEventListener('click', () => this.close());
testBtn.addEventListener('click', () => this._testConnection());
saveBtn.addEventListener('click', () => this._handleSave());
footer.appendChild(cancelBtn);
footer.appendChild(testBtn);
footer.appendChild(saveBtn);
drawer.appendChild(footer);
// ── Events ──────────────────────────────────────────────
header.querySelector('.drawer-close').addEventListener('click', () => this.close());
overlay.addEventListener('click', () => this.close());
overlay.appendChild(drawer);
document.body.appendChild(overlay);
this.drawer = overlay;
// Prevent overlay click from reaching through the drawer
drawer.addEventListener('click', (e) => e.stopPropagation());
// Focus the first input
const firstInput = drawer.querySelector('input, select, textarea');
if (firstInput) firstInput.focus();
}
/**
* Closes the editor drawer.
*/
close() {
if (this.drawer) {
this.drawer.remove();
this.drawer = null;
}
}
/**
* Handles protocol change from the tab bar.
* @param {string} protocol
*/
handleProtocolChange(protocol) {
if (this._profile.protocol === protocol) return;
// Preserve common fields, reset protocol-specific ones
this._collectFormValues();
const base = this._defaultProfile(protocol);
base.id = this._profile.id;
base.displayName = this._profile.displayName;
base.description = this._profile.description;
base.host = this._profile.host;
base.username = this._profile.username;
base.tags = this._profile.tags;
base.notes = this._profile.notes;
base.favourite = this._profile.favourite;
this._profile = base;
this.render(this._profile, this._isNew);
}
// ── Section renderers ────────────────────────────────────────────
/** @private */
_renderGeneralSection(profile) {
return this._renderSection('General', [
this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }),
this._field('text', 'host', 'Host', profile.host, { placeholder: '192.168.1.1 or example.com', required: true }),
this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }),
this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }),
this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }),
]);
}
/** @private */
_renderSSHSections(profile) {
const ssh = profile.ssh || {};
const sections = [];
// Authentication
sections.push(this._renderSection('Authentication', [
this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, {
options: [
{ value: 'saved', label: 'Saved in vault' },
{ value: 'session-only', label: 'Session only' },
{ value: 'ask-every-time', label: 'Ask every time' },
],
}),
this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), {
help: 'Comma-separated: publickey, password, keyboard-interactive',
}),
]));
// Key & Passphrase
sections.push(this._renderSection('Key & Passphrase', [
this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, {
placeholder: '~/.ssh/id_ed25519',
}),
this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, {
options: [
{ value: 'saved', label: 'Saved in vault' },
{ value: 'session-only', label: 'Session only' },
{ value: 'ask-every-time', label: 'Ask every time' },
],
}),
]));
// Host Verification
sections.push(this._renderSection('Host Verification', [
this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, {
options: [
{ value: 'strict', label: 'Strict (reject unknown)' },
{ value: 'ask', label: 'Ask (prompt on unknown)' },
{ value: 'accept-new', label: 'Accept new keys' },
{ value: 'insecure', label: 'Insecure (accept all)' },
],
}),
]));
// Jump Hosts
sections.push(this._renderSection('Jump Hosts', [
this._renderJumpHostsList(ssh.jumpHosts || []),
], true));
// Startup
sections.push(this._renderSection('Startup', [
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
placeholder: 'Optional command to run after connecting',
}),
this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), {
placeholder: 'KEY=VALUE (one per line)',
rows: 3,
}),
]));
// Advanced
sections.push(this._renderSection('Advanced', [
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
], true));
return sections;
}
/** @private */
_renderTelnetSections(profile) {
const tel = profile.telnet || {};
const sections = [];
sections.push(this._renderSection('Login', [
this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho),
this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode),
]));
sections.push(this._renderSection('Terminal & Encoding', [
this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }),
this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, {
options: [
{ value: 'utf-8', label: 'UTF-8' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'iso-8859-1', label: 'ISO 8859-1' },
],
}),
]));
sections.push(this._renderSection('Echo & Line Endings', [
this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, {
options: [
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
{ value: 'lf', label: 'LF (\\n)' },
{ value: 'cr', label: 'CR (\\r)' },
{ value: 'none', label: 'None' },
],
}),
]));
sections.push(this._renderSection('Negotiation', [
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
], true));
return sections;
}
/** @private */
_renderRAWSections(profile) {
const raw = profile.raw || {};
const sections = [];
sections.push(this._renderSection('Encoding & Framing', [
this._field('select', 'raw.encoding', 'Encoding', raw.encoding, {
options: [
{ value: 'utf-8', label: 'UTF-8' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'hex', label: 'Hex' },
],
}),
this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, {
options: [
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
{ value: 'lf', label: 'LF (\\n)' },
{ value: 'cr', label: 'CR (\\r)' },
{ value: 'none', label: 'None' },
],
}),
]));
sections.push(this._renderSection('Display', [
this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps),
this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection),
]));
sections.push(this._renderSection('Payload Presets', [
this._renderPayloadPresetsList(raw.payloadPresets || []),
], true));
sections.push(this._renderSection('Timeouts & Reconnect', [
this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }),
this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }),
this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect),
]));
return sections;
}
/** @private */
_renderTerminalSection(profile) {
return this._renderSection('Terminal', [
this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}),
this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }),
this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }),
this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, {
options: [
{ value: 'block', label: 'Block' },
{ value: 'underline', label: 'Underline' },
{ value: 'bar', label: 'Bar' },
],
}),
]);
}
/** @private */
_renderNotesSection(profile) {
return this._renderSection('Notes & Tags', [
this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), {
placeholder: 'production, london, vpn',
help: 'Comma-separated tags for filtering',
}),
this._field('textarea', 'notes', 'Notes', profile.notes, {
placeholder: 'Free-form notes…',
rows: 4,
}),
]);
}
// ── Form helpers ─────────────────────────────────────────────────
/**
* Renders a collapsible form section.
* @param {string} title
* @param {HTMLElement[]} fields
* @param {boolean} [startCollapsed=false]
* @returns {HTMLElement}
* @private
*/
_renderSection(title, fields, startCollapsed = false) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = startCollapsed;
header.innerHTML = `
<span class="form-section-chevron">${collapsed ? '▸' : '▾'}</span>
<span class="form-section-title">${escapeHtml(title)}</span>
`;
const content = createElement('div', 'form-section-content');
if (collapsed) content.style.display = 'none';
for (const field of fields) {
if (field instanceof HTMLElement) {
content.appendChild(field);
}
}
header.addEventListener('click', () => {
collapsed = !collapsed;
content.style.display = collapsed ? 'none' : 'block';
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
});
section.appendChild(header);
section.appendChild(content);
return section;
}
/**
* Renders a single form field.
* @param {string} type - text | number | select | toggle | textarea | tags | file
* @param {string} name - Dot-notated field name, e.g. "ssh.secretMode".
* @param {string} label
* @param {*} value
* @param {Object} [opts]
* @returns {HTMLElement}
* @private
*/
_field(type, name, label, value, opts = {}) {
const group = createElement('div', 'form-group');
// Label
const labelEl = createElement('label', 'form-label', {
textContent: label,
for: `field-${name}`,
});
group.appendChild(labelEl);
// Input element based on type
let input;
switch (type) {
case 'text':
case 'file':
input = createElement('input', 'form-input', {
type: type === 'file' ? 'text' : 'text',
id: `field-${name}`,
name,
value: value || '',
placeholder: opts.placeholder || '',
});
if (opts.required) input.required = true;
break;
case 'number':
input = createElement('input', 'form-input', {
type: 'number',
id: `field-${name}`,
name,
value: value != null ? String(value) : '',
});
if (opts.min != null) input.min = opts.min;
if (opts.max != null) input.max = opts.max;
if (opts.required) input.required = true;
break;
case 'select': {
input = createElement('select', 'form-select', {
id: `field-${name}`,
name,
});
for (const opt of (opts.options || [])) {
const option = createElement('option', '', {
value: opt.value,
textContent: opt.label,
});
if (opt.value === value) option.selected = true;
input.appendChild(option);
}
break;
}
case 'toggle': {
const wrapper = createElement('div', 'form-toggle-wrapper');
input = createElement('input', 'form-toggle', {
type: 'checkbox',
id: `field-${name}`,
name,
});
input.checked = !!value;
const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` });
wrapper.appendChild(input);
wrapper.appendChild(slider);
group.appendChild(wrapper);
// Help text
if (opts.help) {
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
}
return group;
}
case 'textarea':
input = createElement('textarea', 'form-textarea', {
id: `field-${name}`,
name,
placeholder: opts.placeholder || '',
});
input.rows = opts.rows || 3;
input.textContent = value || '';
break;
case 'tags':
input = createElement('input', 'form-input', {
type: 'text',
id: `field-${name}`,
name,
value: value || '',
placeholder: opts.placeholder || '',
});
break;
default:
input = createElement('input', 'form-input', {
type: 'text',
id: `field-${name}`,
name,
value: value || '',
});
}
group.appendChild(input);
// Help text
if (opts.help) {
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
}
return group;
}
/**
* Renders the jump-hosts list editor.
* @param {Array<Object>} jumpHosts
* @returns {HTMLElement}
* @private
*/
_renderJumpHostsList(jumpHosts) {
const wrapper = createElement('div', 'jump-hosts-list');
for (let i = 0; i < jumpHosts.length; i++) {
const jh = jumpHosts[i];
const row = createElement('div', 'jump-host-row');
row.innerHTML = `
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
`;
row.querySelector('.jump-host-remove').addEventListener('click', () => {
jumpHosts.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
}
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' });
addBtn.addEventListener('click', () => {
jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' });
this.render(this._profile, this._isNew);
});
wrapper.appendChild(addBtn);
return wrapper;
}
/**
* Renders the payload-presets list editor.
* @param {Array<Object>} presets
* @returns {HTMLElement}
* @private
*/
_renderPayloadPresetsList(presets) {
const wrapper = createElement('div', 'payload-presets-list');
for (let i = 0; i < presets.length; i++) {
const ps = presets[i];
const row = createElement('div', 'preset-row');
row.innerHTML = `
<input class="form-input form-input-sm" name="preset-${i}-name" value="${escapeHtml(ps.name || '')}" placeholder="Name" />
<input class="form-input form-input-sm" name="preset-${i}-payload" value="${escapeHtml(ps.payload || '')}" placeholder="Payload" />
<select class="form-select form-select-sm" name="preset-${i}-format">
<option value="text" ${ps.format === 'text' ? 'selected' : ''}>Text</option>
<option value="hex" ${ps.format === 'hex' ? 'selected' : ''}>Hex</option>
<option value="escaped" ${ps.format === 'escaped' ? 'selected' : ''}>Escaped</option>
</select>
<button class="btn btn-icon btn-danger preset-remove" data-index="${i}" title="Remove">&times;</button>
`;
row.querySelector('.preset-remove').addEventListener('click', () => {
presets.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
}
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' });
addBtn.addEventListener('click', () => {
presets.push({ id: generateId(), name: '', payload: '', format: 'text' });
this.render(this._profile, this._isNew);
});
wrapper.appendChild(addBtn);
return wrapper;
}
// ── Form data collection ─────────────────────────────────────────
/**
* Reads current form values into this._profile.
* @private
*/
_collectFormValues() {
if (!this.drawer) return;
const val = (name) => {
const el = this.drawer.querySelector(`[name="${name}"]`);
if (!el) return undefined;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
return el.value;
};
const p = this._profile;
// General
p.displayName = val('displayName') || '';
p.host = val('host') || '';
p.port = val('port') || p.port;
p.username = val('username') || '';
p.description = val('description') || '';
p.terminalFontFamily = val('terminalFontFamily') || '';
p.terminalFontSize = val('terminalFontSize') || 14;
p.terminalScrollback = val('terminalScrollback') || 10000;
p.terminalCursorStyle = val('terminalCursorStyle') || 'block';
p.connectTimeout = val('connectTimeout') || 30;
p.readTimeout = val('readTimeout') || 0;
// Tags
const tagsStr = val('tags');
if (tagsStr !== undefined) {
p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
}
// Notes
const notes = val('notes');
if (notes !== undefined) p.notes = notes;
// SSH-specific
if (p.protocol === 'ssh' && p.ssh) {
p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode;
p.ssh.privateKeyPath = val('ssh.privateKeyPath') || '';
p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode;
p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy;
p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color';
p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30;
p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3;
p.ssh.compression = val('ssh.compression') || false;
p.ssh.startupCommand = val('ssh.startupCommand') || '';
const authStr = val('ssh.authMethods');
if (authStr !== undefined) {
p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean);
}
// Env vars from textarea
const envText = val('ssh.envVarsText');
if (envText !== undefined) {
p.ssh.envVars = {};
envText.split('\n').forEach((line) => {
const eq = line.indexOf('=');
if (eq > 0) {
p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
});
}
// Jump hosts
const jh = [];
let ji = 0;
while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) {
jh.push({
host: val(`jumpHost-${ji}-host`) || '',
port: val(`jumpHost-${ji}-port`) || 22,
username: val(`jumpHost-${ji}-username`) || '',
authMethod: 'publickey',
secretMode: 'ask-every-time',
privateKeyPath: '',
});
ji++;
}
p.ssh.jumpHosts = jh;
}
// Telnet-specific
if (p.protocol === 'telnet' && p.telnet) {
p.telnet.localEcho = val('telnet.localEcho') || false;
p.telnet.lineMode = val('telnet.lineMode') || false;
p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color';
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
}
// RAW-specific
if (p.protocol === 'raw' && p.raw) {
p.raw.encoding = val('raw.encoding') || 'utf-8';
p.raw.lineEnding = val('raw.lineEnding') || 'crlf';
p.raw.showTimestamps = val('raw.showTimestamps') || false;
p.raw.showDirection = val('raw.showDirection') || false;
p.raw.autoReconnect = val('raw.autoReconnect') || false;
// Presets
const presets = [];
let pi = 0;
while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) {
presets.push({
id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(),
name: val(`preset-${pi}-name`) || '',
payload: val(`preset-${pi}-payload`) || '',
format: val(`preset-${pi}-format`) || 'text',
});
pi++;
}
p.raw.payloadPresets = presets;
}
}
// ── Validation ───────────────────────────────────────────────────
/**
* Validates the collected profile. Returns an array of error strings (empty = valid).
* @param {Object} profile
* @returns {string[]}
* @private
*/
_validate(profile) {
const errors = [];
if (!profile.host || !profile.host.trim()) {
errors.push('Host is required.');
}
if (!profile.port || profile.port < 1 || profile.port > 65535) {
errors.push('Port must be between 1 and 65535.');
}
return errors;
}
// ── Actions ──────────────────────────────────────────────────────
/** @private */
async _handleSave() {
this._collectFormValues();
const errors = this._validate(this._profile);
if (errors.length > 0) {
this.app.notifications.warning(errors.join(' '));
return;
}
try {
if (this._isNew) {
const saved = await window.go.api.Service.CreateProfile(this._profile);
this.app.state.addProfile(saved);
this.app.notifications.success('Profile created');
} else {
const saved = await window.go.api.Service.UpdateProfile(this._profile);
this.app.state.updateProfile(saved);
this.app.notifications.success('Profile updated');
}
this.app.sidebar.render();
this.close();
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
}
/** @private */
async _testConnection() {
this._collectFormValues();
const errors = this._validate(this._profile);
if (errors.length > 0) {
this.app.notifications.warning(errors.join(' '));
return;
}
this.app.notifications.info('Testing connection…');
try {
const result = await window.go.api.Service.TestConnection(this._profile);
if (result && result.success) {
this.app.notifications.success('Connection successful!');
} else {
this.app.notifications.error('Connection failed: ' + (result?.error || 'Unknown error'));
}
} catch (err) {
this.app.notifications.error('Test failed: ' + err);
}
}
// ── Utilities ────────────────────────────────────────────────────
/**
* Returns a default profile object for the given protocol.
* @param {string} protocol
* @returns {Object}
* @private
*/
_defaultProfile(protocol) {
const base = {
id: generateId(),
displayName: '',
description: '',
protocol,
host: '',
port: 0,
username: '',
tags: [],
folder: '',
environment: '',
notes: '',
favourite: false,
lastConnected: null,
connectTimeout: 30,
readTimeout: 0,
terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
terminalFontSize: 14,
terminalScrollback: 10000,
terminalCursorStyle: 'block',
ssh: null,
telnet: null,
raw: null,
};
const defaults = DEFAULTS[protocol] || {};
return { ...base, ...defaults };
}
/**
* Converts an envVars map into "KEY=VALUE\n" text.
* @param {Object} envVars
* @returns {string}
* @private
*/
_envVarsToText(envVars) {
if (!envVars) return '';
return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n');
}
}
+564
View File
@@ -0,0 +1,564 @@
/**
* Nexus Terminal — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
import { createElement, escapeHtml, formatTimestamp, formatBytes } from './utils.js';
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
export class RawViewer {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Creates a RAW viewer inside the given panel container.
* @param {string} sessionId
* @param {string} containerId - DOM id of the panel element.
* @param {Object} profile
*/
createViewer(sessionId, containerId, profile) {
const container = document.getElementById(containerId);
if (!container) return;
const state = {
mode: 'text', // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: 'text', // text | hex | escaped
};
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement('div', 'raw-toolbar');
// Mode toggle
const modeGroup = createElement('div', 'raw-mode-group');
for (const mode of ['text', 'hex', 'split']) {
const btn = createElement('button', `raw-mode-btn ${mode === state.mode ? 'active' : ''}`, {
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
});
btn.addEventListener('click', () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Controls
const controls = createElement('div', 'raw-controls');
const pauseBtn = createElement('button', 'btn btn-sm btn-outline raw-pause', { textContent: '⏸ Pause' });
pauseBtn.addEventListener('click', () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const clearBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '✕ Clear' });
clearBtn.addEventListener('click', () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const saveBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '💾 Save' });
saveBtn.addEventListener('click', () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
// Byte counter
const counter = createElement('span', 'raw-counter', { textContent: '0 B' });
controls.appendChild(counter);
toolbar.appendChild(controls);
container.appendChild(toolbar);
// ── Data display ─────────────────────────────────────────
const display = createElement('div', 'raw-display');
container.appendChild(display);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
}
/**
* Processes incoming data from the backend.
* @param {Object} payload - { sessionID, data, direction, timestamp }
*/
handleData(payload) {
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || '';
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const entry = {
direction: payload.direction || 'recv', // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
}
/**
* Toggles pause/resume on the data display.
* @param {string} sessionId
*/
togglePause(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
viewer.pauseBtn.classList.toggle('active', viewer.paused);
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
}
/**
* Clears the data buffer and display.
* @param {string} sessionId
*/
clearBuffer(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = '';
viewer.counter.textContent = '0 B';
}
/**
* Prompts the user to save the capture buffer.
* @param {string} sessionId
*/
async saveCapture(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning('Nothing to save');
return;
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === 'send' ? 'TX' : 'RX';
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join('\n');
await window.go.api.Service.SaveCapture(sessionId, content);
this.app.notifications.success('Capture saved');
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
}
/**
* Sends data from the send bar to the backend.
* @param {string} sessionId
*/
async send(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const input = viewer.container.querySelector('.raw-send-input');
const formatSelect = viewer.container.querySelector('.raw-send-format');
if (!input) return;
const rawValue = input.value;
if (!rawValue) return;
const format = formatSelect?.value || 'text';
let dataToSend;
switch (format) {
case 'hex':
dataToSend = this._hexToString(rawValue);
break;
case 'escaped':
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
try {
const b64 = btoa(dataToSend);
await window.go.api.Service.WriteSession(sessionId, b64);
input.value = '';
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: 'send',
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error('Send failed: ' + err);
}
}
/**
* Tears down a viewer and frees resources.
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Renders the send bar at the bottom of the viewer.
* @param {string} sessionId
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSendBar(sessionId, profile) {
const bar = createElement('div', 'raw-send-bar');
// Format selector
const formatSelect = createElement('select', 'form-select form-select-sm raw-send-format');
for (const [value, label] of [['text', 'Text'], ['hex', 'Hex'], ['escaped', 'Escaped']]) {
const opt = createElement('option', '', { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Input
const input = createElement('input', 'form-input raw-send-input', {
type: 'text',
placeholder: 'Type data to send…',
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.send(sessionId);
});
bar.appendChild(input);
// Quick-insert buttons for common control bytes
const quickBtns = createElement('div', 'raw-quick-btns');
const controlBytes = [
{ label: 'NUL', value: '\\x00' },
{ label: 'CR', value: '\\r' },
{ label: 'LF', value: '\\n' },
{ label: 'ETX', value: '\\x03' },
{ label: 'EOT', value: '\\x04' },
];
for (const cb of controlBytes) {
const btn = createElement('button', 'btn btn-xs btn-outline', { textContent: cb.label, title: cb.value });
btn.addEventListener('click', () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
}
bar.appendChild(quickBtns);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement('select', 'form-select form-select-sm raw-preset-select');
const defaultOpt = createElement('option', '', { value: '', textContent: 'Presets…' });
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement('option', '', { value: ps.payload, textContent: ps.name });
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
}
presetSelect.addEventListener('change', () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.value = '';
input.focus();
}
});
bar.appendChild(presetSelect);
}
// Send button
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
sendBtn.addEventListener('click', () => this.send(sessionId));
bar.appendChild(sendBtn);
return bar;
}
/**
* Renders a single data entry into the display area.
* @param {Object} viewer
* @param {Object} entry
* @private
*/
_renderEntry(viewer, entry) {
let el;
switch (viewer.mode) {
case 'hex':
el = this._renderHexView(entry);
break;
case 'split':
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
}
/**
* Re-renders all buffered entries (e.g. after mode change or resume).
* @param {Object} viewer
* @private
*/
_renderAllEntries(viewer) {
viewer.display.innerHTML = '';
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
}
/**
* Renders a text-mode entry.
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderTextView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
let prefix = '';
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
}
const pre = createElement('pre', 'raw-text');
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
}
/**
* Renders a hex-mode entry.
* @param {Object} entry
* @returns {HTMLElement}
* @private
*/
_renderHexView(entry) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const pre = createElement('pre', 'raw-hex');
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
}
/**
* Renders a split-mode entry (text + hex side by side).
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSplitView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
// Direction/timestamp header
const header = createElement('div', 'raw-split-header');
let headerText = '';
if (raw.showTimestamps) headerText += formatTimestamp(entry.timestamp) + ' ';
headerText += entry.direction === 'send' ? '▲ TX' : '▼ RX';
header.textContent = headerText;
row.appendChild(header);
// Split panes
const panes = createElement('div', 'raw-split-panes');
const textPane = createElement('pre', 'raw-text raw-split-text');
textPane.textContent = entry.data;
panes.appendChild(textPane);
const hexPane = createElement('pre', 'raw-hex raw-split-hex');
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
row.appendChild(panes);
return row;
}
/**
* Formats a Uint8Array into a traditional hex dump:
* offset | hex bytes | ASCII representation.
* @param {Uint8Array} bytes
* @returns {string}
*/
formatHexDump(bytes) {
const lines = [];
const bytesPerLine = 16;
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
// Offset column
const offsetStr = offset.toString(16).padStart(8, '0');
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, '0'));
} else {
hexParts.push(' ');
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push('');
}
const hexStr = hexParts.join(' ');
// ASCII column
let ascii = '';
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join('\n');
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Changes the display mode (text, hex, split) and re-renders.
* @param {string} sessionId
* @param {string} mode
* @private
*/
_setMode(sessionId, mode) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.mode = mode;
// Update active button
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
this._renderAllEntries(viewer);
}
/**
* Scrolls the data display to the bottom.
* @param {Object} viewer
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
* Converts a hex string (e.g. "48656c6c6f") into a character string.
* @param {string} hex
* @returns {string}
* @private
*/
_hexToString(hex) {
const cleaned = hex.replace(/\s+/g, '');
let str = '';
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
}
/**
* Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes.
* @param {string} str
* @returns {string}
* @private
*/
_unescapeString(str) {
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case 'n': return '\n';
case 'r': return '\r';
case 't': return '\t';
case '0': return '\0';
case '\\': return '\\';
default:
// \\xHH
if (seq.startsWith('x')) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
return match;
}
});
}
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Nexus Terminal — settings modal.
* Renders a tabbed settings dialog for application-wide preferences,
* terminal defaults, security, known-hosts management, and about info.
*/
import { createElement, escapeHtml, formatTimestamp, $ } from './utils.js';
/** Settings tab definitions. */
const TABS = [
{ key: 'general', label: 'General' },
{ key: 'terminal', label: 'Terminal' },
{ key: 'security', label: 'Security' },
{ key: 'knownhosts', label: 'Known Hosts' },
{ key: 'about', label: 'About' },
];
export class Settings {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Currently active tab key. */
this._activeTab = 'general';
/** @type {Object} Working copy of preferences. */
this._prefs = {};
/** @type {Array<Object>} Cached known hosts list. */
this._knownHosts = [];
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the settings modal.
*/
async open() {
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
this._activeTab = 'general';
try {
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
} catch (_) {
this._knownHosts = [];
}
this._render();
}
/**
* Closes the settings modal.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
/**
* Saves the modified preferences to the backend.
*/
async save() {
this._collectValues();
try {
await window.go.api.Service.SavePreferences(this._prefs);
this.app.state.setPreferences(this._prefs);
this.app.notifications.success('Settings saved');
this.close();
} catch (err) {
this.app.notifications.error('Failed to save settings: ' + err);
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
this.close();
const overlay = createElement('div', 'modal-overlay');
const modal = createElement('div', 'modal modal-settings');
// Header
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">Settings</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
header.querySelector('.modal-close').addEventListener('click', () => this.close());
modal.appendChild(header);
// Tab bar
const tabBar = createElement('div', 'settings-tab-bar');
for (const tab of TABS) {
const btn = createElement('button', `settings-tab ${tab.key === this._activeTab ? 'active' : ''}`, {
textContent: tab.label,
dataset: { tab: tab.key },
});
btn.addEventListener('click', () => {
this._activeTab = tab.key;
this._renderBody(modal);
tabBar.querySelectorAll('.settings-tab').forEach((t) => {
t.classList.toggle('active', t.dataset.tab === tab.key);
});
});
tabBar.appendChild(btn);
}
modal.appendChild(tabBar);
// Body (tab content)
const body = createElement('div', 'modal-body settings-body');
modal.appendChild(body);
this._renderTabContent(body);
// Footer
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' });
cancelBtn.addEventListener('click', () => this.close());
saveBtn.addEventListener('click', () => this.save());
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
modal.addEventListener('click', (e) => e.stopPropagation());
document.body.appendChild(overlay);
this._overlay = overlay;
}
/**
* Re-renders only the body when the active tab changes.
* @param {HTMLElement} modal
* @private
*/
_renderBody(modal) {
const body = modal.querySelector('.settings-body');
if (body) this._renderTabContent(body);
}
/**
* Renders the content for the currently active tab.
* @param {HTMLElement} body
* @private
*/
_renderTabContent(body) {
body.innerHTML = '';
switch (this._activeTab) {
case 'general': this._renderGeneralTab(body); break;
case 'terminal': this._renderTerminalTab(body); break;
case 'security': this._renderSecurityTab(body); break;
case 'knownhosts': this._renderKnownHostsTab(body); break;
case 'about': this._renderAboutTab(body); break;
}
}
// ── Tab content renderers ────────────────────────────────────────
/**
* General preferences tab.
* @param {HTMLElement} body
* @private
*/
_renderGeneralTab(body) {
const p = this._prefs;
body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, {
options: [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
],
}));
body.appendChild(this._settingField('select', 'defaultProtocol', 'Default Protocol', p.defaultProtocol, {
options: [
{ value: 'ssh', label: 'SSH' },
{ value: 'telnet', label: 'Telnet' },
{ value: 'raw', label: 'RAW' },
],
}));
body.appendChild(this._settingField('toggle', 'confirmOnClose', 'Confirm on close', p.confirmOnClose));
body.appendChild(this._settingField('toggle', 'confirmOnDisconnect', 'Confirm on disconnect', p.confirmOnDisconnect));
body.appendChild(this._settingField('toggle', 'showStatusBar', 'Show status bar', p.showStatusBar));
body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 }));
}
/**
* Terminal defaults tab.
* @param {HTMLElement} body
* @private
*/
_renderTerminalTab(body) {
const p = this._prefs;
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}));
body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 }));
body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 }));
body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, {
options: [
{ value: 'block', label: 'Block' },
{ value: 'underline', label: 'Underline' },
{ value: 'bar', label: 'Bar' },
],
}));
}
/**
* Security settings tab.
* @param {HTMLElement} body
* @private
*/
_renderSecurityTab(body) {
const storeState = this.app.state.secretStoreUnlocked ? 'Unlocked' : 'Locked';
const storeInit = this.app.state.secretStoreInitialised;
const statusRow = createElement('div', 'settings-row');
statusRow.innerHTML = `
<span class="settings-label">Secret store</span>
<span class="settings-value ${this.app.state.secretStoreUnlocked ? 'text-success' : 'text-muted'}">${storeState}</span>
`;
body.appendChild(statusRow);
if (!storeInit) {
const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' });
initBtn.addEventListener('click', async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(true);
await window.go.api.Service.InitialiseSecretStore(pw);
this.app.state.setSecretStoreInitialised(true);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store created');
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Failed to initialise: ' + err);
}
}
});
body.appendChild(initBtn);
} else if (!this.app.state.secretStoreUnlocked) {
const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' });
unlockBtn.addEventListener('click', async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
});
body.appendChild(unlockBtn);
} else {
const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' });
lockBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
this._renderTabContent(body);
} catch (err) {
this.app.notifications.error('Lock failed: ' + err);
}
});
body.appendChild(lockBtn);
}
}
/**
* Known-hosts management tab.
* @param {HTMLElement} body
* @private
*/
_renderKnownHostsTab(body) {
if (this._knownHosts.length === 0) {
body.appendChild(createElement('p', 'text-muted', { textContent: 'No known hosts recorded.' }));
return;
}
const table = createElement('table', 'settings-table');
table.innerHTML = `
<thead>
<tr>
<th>Host</th>
<th>Port</th>
<th>Key Type</th>
<th>Fingerprint</th>
<th>First Seen</th>
<th></th>
</tr>
</thead>
`;
const tbody = createElement('tbody');
for (const kh of this._knownHosts) {
const tr = createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(kh.host)}</td>
<td>${kh.port}</td>
<td><code>${escapeHtml(kh.keyType)}</code></td>
<td class="fingerprint-cell" title="${escapeHtml(kh.fingerprint)}"><code>${escapeHtml(kh.fingerprint.slice(0, 20))}…</code></td>
<td>${formatTimestamp(kh.firstSeen)}</td>
<td></td>
`;
const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' });
deleteBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.DeleteKnownHost(kh.id);
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
this._renderTabContent(body);
this.app.notifications.success('Known host removed');
} catch (err) {
this.app.notifications.error('Failed to remove: ' + err);
}
});
tr.querySelector('td:last-child').appendChild(deleteBtn);
tbody.appendChild(tr);
}
table.appendChild(tbody);
body.appendChild(table);
}
/**
* About tab showing application info.
* @param {HTMLElement} body
* @private
*/
_renderAboutTab(body) {
body.innerHTML = `
<div class="about-section">
<h3 class="about-title">Nexus Terminal</h3>
<p class="about-version">Version 1.0.0</p>
<p class="about-desc">A premium desktop SSH, Telnet, and RAW TCP client built with Wails.</p>
<div class="about-links">
<p>AES-256-GCM encrypted credential vault</p>
<p>Platform-native keychain integration</p>
<p>xterm.js terminal emulation</p>
</div>
</div>
`;
}
// ── Form helpers ─────────────────────────────────────────────────
/**
* Renders a single settings form field.
* @param {string} type
* @param {string} name
* @param {string} label
* @param {*} value
* @param {Object} [opts]
* @returns {HTMLElement}
* @private
*/
_settingField(type, name, label, value, opts = {}) {
const group = createElement('div', 'form-group settings-field');
const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
group.appendChild(labelEl);
let input;
switch (type) {
case 'text':
input = createElement('input', 'form-input', {
type: 'text',
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
placeholder: opts.placeholder || '',
});
break;
case 'number':
input = createElement('input', 'form-input', {
type: 'number',
id: `setting-${name}`,
name: `setting-${name}`,
value: value != null ? String(value) : '',
});
if (opts.min != null) input.min = opts.min;
if (opts.max != null) input.max = opts.max;
break;
case 'select':
input = createElement('select', 'form-select', {
id: `setting-${name}`,
name: `setting-${name}`,
});
for (const opt of (opts.options || [])) {
const option = createElement('option', '', { value: opt.value, textContent: opt.label });
if (opt.value === value) option.selected = true;
input.appendChild(option);
}
break;
case 'toggle': {
const wrapper = createElement('div', 'form-toggle-wrapper');
input = createElement('input', 'form-toggle', {
type: 'checkbox',
id: `setting-${name}`,
name: `setting-${name}`,
});
input.checked = !!value;
const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` });
wrapper.appendChild(input);
wrapper.appendChild(slider);
group.appendChild(wrapper);
return group;
}
default:
input = createElement('input', 'form-input', {
type: 'text',
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
});
}
group.appendChild(input);
return group;
}
/**
* Reads current form field values back into the working preferences copy.
* @private
*/
_collectValues() {
if (!this._overlay) return;
const val = (name) => {
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
if (!el) return undefined;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
return el.value;
};
const fields = [
'theme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
'defaultScrollback', 'defaultCursorStyle',
];
for (const field of fields) {
const v = val(field);
if (v !== undefined) {
this._prefs[field] = v;
}
}
}
}