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:
@@ -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));
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">×</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">×</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">×</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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">×</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user