(function() {
/**
* 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.
*/
const { createElement, escapeHtml, generateId, $ } = window.NexusTerminal;
/**
* Default values mirroring the Go backend defaults.
*/
const DEFAULTS = {
ssh: {
port: 22,
protocol: 'ssh',
ssh: {
authMethods: ['publickey', 'password', 'keyboard-interactive'],
secretMode: 'saved',
keySecretMode: 'saved',
hostKeyPolicy: 'ask',
terminalType: 'xterm-256color',
keepaliveInterval: 30,
keepaliveCountMax: 3,
compression: false,
jumpHosts: [],
portForwards: [],
startupCommand: '',
envVars: {},
loginScripts: [],
},
},
telnet: {
port: 23,
protocol: 'telnet',
telnet: {
localEcho: false,
lineEnding: 'crlf',
terminalType: 'xterm-256color',
encoding: 'utf-8',
negotiationDebug: false,
lineMode: false,
loginScripts: [],
},
},
raw: {
port: 23,
protocol: 'raw',
raw: {
autoReconnect: false,
lineEnding: 'crlf',
encoding: 'utf-8',
showTimestamps: false,
showDirection: false,
payloadPresets: [],
},
},
github: {
port: 443,
protocol: 'github',
host: 'github.com',
github: {
baseURL: 'https://api.github.com',
secretMode: 'saved',
refreshIntervalSecs: 300,
},
},
};
class ProfileEditor {
/**
* @param {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-backdrop');
const drawer = createElement('div', 'drawer');
// ── Header ──────────────────────────────────────────────
const header = createElement('div', 'drawer-header');
header.innerHTML = `
${isNew ? 'New Connection' : 'Edit Connection'}
`;
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ['ssh', 'telnet', 'raw', 'github'];
const tabBar = createElement('div', 'drawer-protocol-tabs');
for (const proto of protocols) {
const label = proto === 'github' ? 'GitHub' : proto.toUpperCase();
const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, {
textContent: label,
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;
case 'github':
this._renderGitHubSections(profile).forEach((s) => body.appendChild(s));
break;
}
// Terminal section (SSH and Telnet only)
if (profile.protocol !== 'raw' && profile.protocol !== 'github') {
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' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
const deleteBtn = !isNew
? createElement('button', 'btn btn-danger', { textContent: 'Delete' })
: null;
cancelBtn.addEventListener('click', () => this.close());
testBtn.addEventListener('click', () => this._testConnection());
saveBtn.addEventListener('click', () => this._handleSave(false));
connectBtn.addEventListener('click', () => this._handleSave(true));
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this._handleDelete());
}
if (deleteBtn) footer.appendChild(deleteBtn);
footer.appendChild(cancelBtn);
footer.appendChild(testBtn);
footer.appendChild(saveBtn);
footer.appendChild(connectBtn);
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;
// Trigger visibility after append so CSS transitions animate
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
drawer.classList.add('is-visible');
});
// 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) {
const fields = [
this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }),
this._field('text', 'host', 'Host', profile.host, {
placeholder: profile.protocol === 'github' ? 'github.com' : '192.168.1.1 or example.com',
required: true,
}),
];
// Port and username not relevant for GitHub connections
if (profile.protocol !== 'github') {
fields.push(this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }));
fields.push(this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }));
}
fields.push(this._renderGroupField(profile));
fields.push(this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }));
return this._renderSection('General', fields);
}
/**
* Renders a combo-box for the Group field — a text input backed by a
* datalist of existing group names. Typing a new name creates a group.
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderGroupField(profile) {
const group = createElement('div', 'form-group');
const label = createElement('label', 'form-label', {
textContent: 'Group',
for: 'field-folder',
});
group.appendChild(label);
// Collect distinct group names from all profiles
const existingGroups = [...new Set(
this.app.state.profiles
.map((p) => (p.folder || '').trim())
.filter(Boolean)
)].sort((a, b) => a.localeCompare(b));
// Create the datalist so the browser offers autocomplete
const listId = 'folder-suggestions';
const datalist = document.createElement('datalist');
datalist.id = listId;
for (const g of existingGroups) {
const opt = document.createElement('option');
opt.value = g;
datalist.appendChild(opt);
}
group.appendChild(datalist);
const input = createElement('input', 'form-input', {
type: 'text',
id: 'field-folder',
name: 'folder',
value: profile.folder || '',
placeholder: 'e.g. Production, Staging, Home Lab',
});
input.setAttribute('list', listId);
input.setAttribute('autocomplete', 'off');
group.appendChild(input);
const hint = createElement('span', 'form-hint', {
textContent: 'Choose an existing group or type a new name to create one',
});
group.appendChild(hint);
return group;
}
/** @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));
// Port Forwarding
sections.push(this._renderSection('Port Forwarding', [
this._renderPortForwardsList(ssh.portForwards || []),
], 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,
}),
]));
// Login Scripts (expect-style)
sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || []));
// SSH Agent
sections.push(this._renderSection('SSH Agent', [
this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, {
help: 'Authenticate using keys from the local SSH agent (ssh-agent)',
}),
this._field('toggle', 'ssh.agentForwarding', 'Forward Agent to Remote', ssh.agentForwarding, {
help: 'Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)',
}),
], true));
// TOTP / 2FA
sections.push(this._renderTOTPSection(profile));
// Proxy
sections.push(this._renderSection('Proxy', [
this._field('select', 'ssh.proxyType', 'Proxy Type', ssh.proxyType || 'none', {
options: [
{ value: 'none', label: 'None (direct connection)' },
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP CONNECT' },
],
}),
this._field('text', 'ssh.proxyHost', 'Proxy Host', ssh.proxyHost || '', {
placeholder: 'proxy.example.com',
}),
this._field('number', 'ssh.proxyPort', 'Proxy Port', ssh.proxyPort || 1080, { min: 1, max: 65535 }),
this._field('text', 'ssh.proxyUsername', 'Proxy Username', ssh.proxyUsername || '', {
placeholder: 'Optional',
}),
this._field('text', 'ssh.proxyPassword', 'Proxy Password', ssh.proxyPassword || '', {
placeholder: 'Optional',
}),
], true));
// 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),
this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
}),
this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }),
], 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));
// Login Scripts (expect-style)
sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || []));
sections.push(this._renderSection('Reconnection', [
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
}),
this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }),
], 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 */
_renderGitHubSections(profile) {
const sections = [];
const gh = profile.github || DEFAULTS.github.github;
sections.push(this._renderSection('API', [
this._field('text', 'github.baseURL', 'API Base URL', gh.baseURL, {
placeholder: 'https://api.github.com',
}),
this._field('select', 'github.secretMode', 'Token Storage', gh.secretMode, {
options: [
{ value: 'saved', label: 'Save to keychain' },
{ value: 'session-only', label: 'Remember for session' },
{ value: 'ask-every-time', label: 'Ask every time' },
],
}),
]));
sections.push(this._renderSection('Refresh', [
this._field('number', 'github.refreshIntervalSecs', 'Refresh Interval (seconds)', gh.refreshIntervalSecs, {
min: 60, max: 3600,
}),
]));
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
*/
/**
* Renders the TOTP/2FA section with secret management and test button.
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderTOTPSection(profile) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = true;
header.innerHTML = `
▸
TOTP / 2FA
`;
const content = createElement('div', 'form-section-content');
content.style.display = 'none';
const helpText = createElement('div', 'form-help', {
textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.',
});
content.appendChild(helpText);
// TOTP secret input
const group = createElement('div', 'form-group');
const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' });
group.appendChild(label);
const row = createElement('div', 'totp-input-row');
const secretInput = createElement('input', 'form-input', {
type: 'password',
placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)',
id: 'field-ssh.totpSecret',
dataset: { field: 'ssh.totpSecret' },
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
});
// Show/hide toggle
const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', {
textContent: '👁',
title: 'Show/hide secret',
type: 'button',
});
toggleBtn.addEventListener('mousedown', e => e.preventDefault());
toggleBtn.addEventListener('click', () => {
secretInput.type = secretInput.type === 'password' ? 'text' : 'password';
});
// Test button — generates a live code
const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', {
textContent: 'Test',
title: 'Generate a code with this secret',
type: 'button',
});
testBtn.addEventListener('mousedown', e => e.preventDefault());
testBtn.addEventListener('click', async () => {
const profileId = profile.id;
if (profileId) {
try {
const code = await window.go.api.Service.GetTOTPCode(profileId);
testBtn.textContent = code;
setTimeout(() => { testBtn.textContent = 'Test'; }, 5000);
} catch (err) {
// If no saved secret yet, try computing locally using the input value
testBtn.textContent = 'Save first';
setTimeout(() => { testBtn.textContent = 'Test'; }, 2000);
}
}
});
// Save button
const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', {
textContent: 'Save',
title: 'Encrypt and save the TOTP secret',
type: 'button',
});
saveBtn.addEventListener('mousedown', e => e.preventDefault());
saveBtn.addEventListener('click', async () => {
const profileId = profile.id;
const secret = secretInput.value.trim();
if (!profileId) {
NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning');
return;
}
if (!secret) {
// Delete existing secret
try {
await window.go.api.Service.DeleteTOTPSecret(profileId);
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
} catch (err) {
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
}
return;
}
try {
await window.go.api.Service.SetTOTPSecret(profileId, secret);
NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success');
secretInput.value = '';
secretInput.placeholder = '••••••••• (secret saved)';
} catch (err) {
NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error');
}
});
// Delete button
const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', {
textContent: 'Delete',
title: 'Remove the TOTP secret',
type: 'button',
});
deleteBtn.addEventListener('mousedown', e => e.preventDefault());
deleteBtn.addEventListener('click', async () => {
const profileId = profile.id;
if (!profileId) return;
try {
await window.go.api.Service.DeleteTOTPSecret(profileId);
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)';
} catch (err) {
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
}
});
row.appendChild(secretInput);
row.appendChild(toggleBtn);
row.appendChild(testBtn);
row.appendChild(saveBtn);
row.appendChild(deleteBtn);
group.appendChild(row);
content.appendChild(group);
// Check if secret is already saved and show placeholder
if (profile.id) {
(async () => {
try {
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
if (has) {
secretInput.placeholder = '••••••••• (secret saved)';
}
} catch (_) {
// Ignore — might not be unlocked.
}
})();
}
header.addEventListener('mousedown', (e) => e.preventDefault());
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 the login script section (expect-style send/wait steps).
* @param {string} prefix - 'ssh' or 'telnet'
* @param {Array} steps - Array of {type, value, timeout}
* @returns {HTMLElement}
* @private
*/
_renderLoginScriptSection(prefix, steps) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = true;
header.innerHTML = `
▸
Login Script
`;
const content = createElement('div', 'form-section-content');
content.style.display = 'none';
const helpText = createElement('div', 'form-help', {
textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.',
});
content.appendChild(helpText);
// Hidden input to hold JSON
const hiddenInput = createElement('input', '', {
type: 'hidden',
id: `field-${prefix}.loginScripts`,
dataset: { field: `${prefix}.loginScripts` },
});
content.appendChild(hiddenInput);
// List container
const listContainer = createElement('div', 'login-script-list');
content.appendChild(listContainer);
// Working copy
let currentSteps = JSON.parse(JSON.stringify(steps || []));
const renderSteps = () => {
listContainer.innerHTML = '';
hiddenInput.value = JSON.stringify(currentSteps);
if (currentSteps.length === 0) {
const empty = createElement('div', 'login-script-empty', {
textContent: 'No login script steps configured.',
});
listContainer.appendChild(empty);
}
currentSteps.forEach((step, idx) => {
const row = createElement('div', 'login-script-row');
const typeSelect = createElement('select', 'form-input login-script-type');
['wait', 'send'].forEach(t => {
const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' });
if (t === step.type) opt.selected = true;
typeSelect.appendChild(opt);
});
typeSelect.addEventListener('change', () => {
currentSteps[idx].type = typeSelect.value;
hiddenInput.value = JSON.stringify(currentSteps);
renderSteps();
});
const valueInput = createElement('input', 'form-input login-script-value', {
type: 'text',
value: step.value || '',
placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
});
valueInput.addEventListener('input', () => {
currentSteps[idx].value = valueInput.value;
hiddenInput.value = JSON.stringify(currentSteps);
});
row.appendChild(typeSelect);
row.appendChild(valueInput);
// Timeout field for wait steps
if (step.type === 'wait') {
const timeoutInput = createElement('input', 'form-input login-script-timeout', {
type: 'number',
value: step.timeout || 10,
min: 1,
max: 120,
title: 'Timeout (seconds)',
});
timeoutInput.style.width = '60px';
timeoutInput.addEventListener('input', () => {
currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10;
hiddenInput.value = JSON.stringify(currentSteps);
});
const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' });
row.appendChild(timeoutInput);
row.appendChild(timeoutLabel);
}
// Move up/down/remove buttons
const actions = createElement('div', 'login-script-actions');
if (idx > 0) {
const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' });
upBtn.addEventListener('mousedown', e => e.preventDefault());
upBtn.addEventListener('click', () => {
[currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]];
renderSteps();
});
actions.appendChild(upBtn);
}
if (idx < currentSteps.length - 1) {
const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' });
downBtn.addEventListener('mousedown', e => e.preventDefault());
downBtn.addEventListener('click', () => {
[currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]];
renderSteps();
});
actions.appendChild(downBtn);
}
const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' });
removeBtn.addEventListener('mousedown', e => e.preventDefault());
removeBtn.addEventListener('click', () => {
currentSteps.splice(idx, 1);
renderSteps();
});
actions.appendChild(removeBtn);
row.appendChild(actions);
listContainer.appendChild(row);
});
};
renderSteps();
// Add step buttons
const addRow = createElement('div', 'login-script-add-row');
const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' });
addWaitBtn.addEventListener('mousedown', e => e.preventDefault());
addWaitBtn.addEventListener('click', () => {
currentSteps.push({ type: 'wait', value: '', timeout: 10 });
renderSteps();
});
const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' });
addSendBtn.addEventListener('mousedown', e => e.preventDefault());
addSendBtn.addEventListener('click', () => {
currentSteps.push({ type: 'send', value: '', timeout: 0 });
renderSteps();
});
addRow.appendChild(addWaitBtn);
addRow.appendChild(addSendBtn);
content.appendChild(addRow);
header.addEventListener('mousedown', (e) => e.preventDefault());
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;
}
_renderSection(title, fields, startCollapsed = false) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = startCollapsed;
header.innerHTML = `
${collapsed ? '▸' : '▾'}
${escapeHtml(title)}
`;
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);
}
}
// Prevent mousedown from starting a text selection gesture in WKWebView,
// which would swallow the first click and require a double-click.
header.addEventListener('mousedown', (e) => e.preventDefault());
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