Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client

- go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid
- cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e)
- internal/app/app.go: lifecycle (Startup/Shutdown/DomReady)
- internal/models/models.go: full model definitions (Profile, SSHConfig,
  TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.)
- internal/models/defaults.go: default values and NewProfile factory
- internal/validation/validation.go: thorough field-level validation
- frontend/embed.go: embedded frontend assets
- Updated existing storage, secrets, config to match new model structure
This commit is contained in:
2026-04-15 00:00:00 +01:00
commit 35bc9c75b1
36 changed files with 6226 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
// Package frontend provides the embedded frontend assets for the Wails application.
package frontend
import "embed"
// Assets contains the built frontend files from the dist directory.
//
//go:embed all:dist
var Assets embed.FS
+18
View File
@@ -0,0 +1,18 @@
{
"name": "nexus-terminal-frontend",
"version": "0.1.0",
"private": true,
"description": "Frontend for Nexus Terminal desktop SSH client",
"scripts": {
"install:assets": "node scripts/copy-assets.js",
"build": "node scripts/build.js",
"dev": "node scripts/build.js --watch",
"postinstall": "npm run install:assets"
},
"dependencies": {
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-web-links": "0.9.0"
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* build.js — Build script for the Nexus Terminal frontend.
*
* In production mode (no flags): copies assets and validates that all
* required files are in place.
*
* In watch mode (--watch): copies assets, then watches for source changes
* and prints a notification when files change. Wails dev mode handles the
* actual live-reload; this script simply keeps the process alive so the
* frontend:dev:watcher contract is satisfied.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const frontendDir = path.resolve(__dirname, '..');
const watchMode = process.argv.includes('--watch');
// Files that must exist after a successful build
const requiredFiles = [
'dist/index.html',
'src/css/lib/xterm.css',
'src/js/lib/xterm.min.js',
'src/js/lib/xterm-addon-fit.min.js',
'src/js/lib/xterm-addon-search.min.js',
'src/js/lib/xterm-addon-web-links.min.js',
];
/**
* Runs the asset copy script to ensure library files are present.
*/
function copyAssets() {
console.log('[build] Copying library assets…');
execSync('node scripts/copy-assets.js', {
cwd: frontendDir,
stdio: 'inherit',
});
}
/**
* Validates that every required file exists.
* Returns true if all files are present.
*/
function validate() {
console.log('[build] Validating required files…');
let valid = true;
for (const relPath of requiredFiles) {
const full = path.join(frontendDir, relPath);
if (!fs.existsSync(full)) {
console.error(` ✗ Missing: ${relPath}`);
valid = false;
} else {
console.log(` ✓ ${relPath}`);
}
}
return valid;
}
/**
* Watches the src/ directory for changes (development mode).
* Wails dev mode picks up the changes; we just keep the process alive.
*/
function watch() {
const srcDir = path.join(frontendDir, 'src');
console.log(`[build] Watching ${srcDir} for changes…`);
// Use fs.watch recursively where supported (macOS, Windows).
// On Linux, this falls back to non-recursive — acceptable for dev use.
try {
fs.watch(srcDir, { recursive: true }, (_event, filename) => {
if (filename) {
const stamp = new Date().toLocaleTimeString('en-GB');
console.log(`[build] ${stamp} — changed: ${filename}`);
}
});
} catch (err) {
console.warn('[build] Recursive watch not supported; polling fallback.');
// Keep the process alive even if watch fails
}
// Prevent the process from exiting
setInterval(() => {}, 60_000);
}
// --- Main ---
try {
copyAssets();
} catch (err) {
console.error('[build] Asset copy failed:', err.message);
process.exit(1);
}
if (!validate()) {
console.error('[build] Validation failed — some required files are missing.');
process.exit(1);
}
console.log('[build] Frontend build complete.');
if (watchMode) {
watch();
}
+83
View File
@@ -0,0 +1,83 @@
/**
* copy-assets.js — Copies xterm library assets from node_modules into
* the frontend source tree so they can be served directly by Wails.
*
* Run automatically via the postinstall hook in package.json.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const frontendDir = path.resolve(__dirname, '..');
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
const assets = [
{
src: 'xterm/css/xterm.css',
dest: 'src/css/lib/xterm.css',
},
{
src: 'xterm/lib/xterm.js',
dest: 'src/js/lib/xterm.min.js',
},
{
src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
dest: 'src/js/lib/xterm-addon-fit.min.js',
},
{
src: 'xterm-addon-search/lib/xterm-addon-search.js',
dest: 'src/js/lib/xterm-addon-search.min.js',
},
{
src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
dest: 'src/js/lib/xterm-addon-web-links.min.js',
},
];
/**
* Ensures the parent directory of the given file path exists.
*/
function ensureDir(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Copies a single asset, logging the result.
*/
function copyAsset(entry) {
const srcPath = path.join(frontendDir, 'node_modules', entry.src);
const destPath = path.join(frontendDir, entry.dest);
if (!fs.existsSync(srcPath)) {
console.error(` ✗ Source not found: ${entry.src}`);
return false;
}
ensureDir(destPath);
fs.copyFileSync(srcPath, destPath);
console.log(` ✓ ${entry.src} → ${entry.dest}`);
return true;
}
// --- Main ---
console.log('[copy-assets] Copying xterm library files…');
let failures = 0;
for (const entry of assets) {
if (!copyAsset(entry)) {
failures++;
}
}
if (failures > 0) {
console.error(`\n[copy-assets] ${failures} file(s) could not be copied.`);
console.error('[copy-assets] Have you run "npm install" first?');
process.exit(1);
}
console.log('[copy-assets] All assets copied successfully.');
+124
View File
@@ -0,0 +1,124 @@
/**
* Nexus Terminal — toast notification system.
* Provides non-blocking, auto-dismissing notifications that stack
* in the top-right corner of the viewport.
*/
import { createElement, escapeHtml } from './utils.js';
/** Map of notification type to SVG icon path data. */
const ICONS = {
success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
};
export class Notifications {
constructor() {
/** @type {HTMLElement} */
this.container = document.getElementById('notifications');
if (!this.container) {
// Create the container if it does not yet exist
this.container = createElement('div', 'notifications-container', { id: 'notifications' });
document.body.appendChild(this.container);
}
}
/**
* Displays a toast notification.
* @param {string} message - Notification text.
* @param {'info'|'success'|'warning'|'error'} [type='info'] - Severity.
* @param {number} [duration=4000] - Auto-dismiss delay in ms. Pass 0 to keep until dismissed.
*/
show(message, type = 'info', duration = 4000) {
const toast = createElement('div', `toast toast-${type}`);
// Icon
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
iconSvg.setAttribute('class', 'toast-icon');
iconSvg.setAttribute('viewBox', '0 0 24 24');
iconSvg.setAttribute('fill', 'none');
iconSvg.setAttribute('stroke', 'currentColor');
iconSvg.setAttribute('stroke-width', '2');
const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
iconPath.setAttribute('stroke-linecap', 'round');
iconPath.setAttribute('stroke-linejoin', 'round');
iconPath.setAttribute('d', ICONS[type] || ICONS.info);
iconSvg.appendChild(iconPath);
toast.appendChild(iconSvg);
// Message
const msg = createElement('span', 'toast-message', { textContent: message });
toast.appendChild(msg);
// Dismiss button
const close = createElement('button', 'toast-close', { textContent: '×', 'aria-label': 'Dismiss' });
close.addEventListener('click', () => this._dismiss(toast));
toast.appendChild(close);
// Insert at the top of the stack
this.container.prepend(toast);
// Trigger enter animation on next frame
requestAnimationFrame(() => {
toast.classList.add('toast-enter');
});
// Auto-dismiss
if (duration > 0) {
toast._timer = setTimeout(() => this._dismiss(toast), duration);
}
}
/**
* Shorthand: success notification.
* @param {string} message
*/
success(message) {
this.show(message, 'success');
}
/**
* Shorthand: error notification (longer display time).
* @param {string} message
*/
error(message) {
this.show(message, 'error', 6000);
}
/**
* Shorthand: warning notification.
* @param {string} message
*/
warning(message) {
this.show(message, 'warning');
}
/**
* Shorthand: informational notification.
* @param {string} message
*/
info(message) {
this.show(message, 'info');
}
/**
* Animates out and removes a toast element.
* @param {HTMLElement} toast
* @private
*/
_dismiss(toast) {
if (toast._dismissed) return;
toast._dismissed = true;
clearTimeout(toast._timer);
toast.classList.add('toast-exit');
toast.addEventListener('animationend', () => toast.remove(), { once: true });
// Safety net — remove after 500 ms even if animation event does not fire
setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 500);
}
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Nexus Terminal — secret/passphrase prompt dialogs.
* Provides modal prompts for collecting passwords, master-vault passphrases,
* and SSH host-key verification confirmations.
*/
import { createElement, escapeHtml } from './utils.js';
export class SecretPrompt {
constructor(app) {
/** @type {import('./app.js').App} */
this.app = app;
/** @type {HTMLElement|null} Currently open overlay. */
this._overlay = null;
}
// ── Generic password prompt ──────────────────────────────────────
/**
* Displays a modal with a single password field and returns the entered value.
* Rejects if the user cancels.
* @param {string} title - Dialog heading.
* @param {string} message - Instructional text shown above the input.
* @returns {Promise<string>}
*/
prompt(title, message) {
return new Promise((resolve, reject) => {
this._close(); // dismiss any existing prompt
const overlay = createElement('div', 'modal-overlay');
const modal = createElement('div', 'modal modal-secret');
// Header
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
// Body
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">${escapeHtml(message)}</p>
<div class="form-group">
<input type="password" class="form-input secret-input"
autocomplete="off" spellcheck="false" placeholder="Enter value…" />
</div>
`;
modal.appendChild(body);
// Footer
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const submitBtn = createElement('button', 'btn btn-primary', { textContent: 'Submit' });
footer.appendChild(cancelBtn);
footer.appendChild(submitBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
const input = modal.querySelector('.secret-input');
input.focus();
const handleSubmit = () => {
const value = input.value;
this._close();
resolve(value);
};
const handleCancel = () => {
this._close();
reject(new Error('User cancelled'));
};
submitBtn.addEventListener('click', handleSubmit);
cancelBtn.addEventListener('click', handleCancel);
header.querySelector('.modal-close').addEventListener('click', handleCancel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) handleCancel();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
});
});
}
// ── Master password (vault initialisation / unlock) ──────────────
/**
* Prompts for the master vault password.
* When `isNew` is true, a confirmation field is shown.
* @param {boolean} isNew - Whether this is the first time (create vault).
* @returns {Promise<string>} The master password.
*/
promptMasterPassword(isNew) {
return new Promise((resolve, reject) => {
this._close();
const overlay = createElement('div', 'modal-overlay');
const modal = createElement('div', 'modal modal-secret');
const title = isNew ? 'Create Master Password' : 'Unlock Secret Store';
const description = isNew
? 'Choose a strong master password. This protects all saved credentials.'
: 'Enter your master password to unlock the secret store.';
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
let formHtml = `
<p class="secret-message">${escapeHtml(description)}</p>
<div class="form-group">
<label class="form-label">Master password</label>
<input type="password" class="form-input master-password"
autocomplete="off" spellcheck="false" placeholder="Password…" />
</div>
`;
if (isNew) {
formHtml += `
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input master-confirm"
autocomplete="off" spellcheck="false" placeholder="Confirm…" />
</div>
<p class="form-hint secret-error" style="display:none;"></p>
`;
}
body.innerHTML = formHtml;
modal.appendChild(body);
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const submitBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Unlock' });
footer.appendChild(cancelBtn);
footer.appendChild(submitBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
const passInput = modal.querySelector('.master-password');
const confirmInput = modal.querySelector('.master-confirm');
const errorEl = modal.querySelector('.secret-error');
passInput.focus();
const handleSubmit = () => {
const pw = passInput.value;
if (!pw) {
passInput.classList.add('input-error');
return;
}
if (isNew) {
const confirm = confirmInput.value;
if (pw !== confirm) {
errorEl.textContent = 'Passwords do not match.';
errorEl.style.display = 'block';
confirmInput.classList.add('input-error');
return;
}
}
this._close();
resolve(pw);
};
const handleCancel = () => {
this._close();
reject(new Error('User cancelled'));
};
submitBtn.addEventListener('click', handleSubmit);
cancelBtn.addEventListener('click', handleCancel);
header.querySelector('.modal-close').addEventListener('click', handleCancel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) handleCancel();
});
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
passInput.addEventListener('keydown', handleKeydown);
if (confirmInput) {
confirmInput.addEventListener('keydown', handleKeydown);
}
});
}
// ── SSH host-key verification ────────────────────────────────────
/**
* Asks the user whether to accept an SSH host key.
* @param {string} host
* @param {string} keyType - e.g. "ssh-ed25519"
* @param {string} fingerprint - SHA256 fingerprint
* @returns {Promise<boolean>} true = accept, false = reject.
*/
promptHostKeyVerification(host, keyType, fingerprint) {
return new Promise((resolve) => {
this._close();
const overlay = createElement('div', 'modal-overlay');
const modal = createElement('div', 'modal modal-secret modal-hostkey');
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">Unknown Host Key</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">
The authenticity of host <strong>${escapeHtml(host)}</strong> cannot be established.
</p>
<div class="hostkey-details">
<div class="hostkey-row">
<span class="hostkey-label">Key type</span>
<code class="hostkey-value">${escapeHtml(keyType)}</code>
</div>
<div class="hostkey-row">
<span class="hostkey-label">Fingerprint</span>
<code class="hostkey-value">${escapeHtml(fingerprint)}</code>
</div>
</div>
<p class="secret-message">
Are you sure you want to continue connecting?
</p>
`;
modal.appendChild(body);
const footer = createElement('div', 'modal-footer');
const rejectBtn = createElement('button', 'btn btn-secondary', { textContent: 'Reject' });
const acceptBtn = createElement('button', 'btn btn-primary', { textContent: 'Accept & Save' });
footer.appendChild(rejectBtn);
footer.appendChild(acceptBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
acceptBtn.focus();
acceptBtn.addEventListener('click', () => {
this._close();
resolve(true);
});
rejectBtn.addEventListener('click', () => {
this._close();
resolve(false);
});
header.querySelector('.modal-close').addEventListener('click', () => {
this._close();
resolve(false);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this._close();
resolve(false);
}
});
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this._close();
resolve(false);
}
});
});
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Closes and removes the current overlay, if any.
* @private
*/
_close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Nexus Terminal — sidebar module.
* Renders the profile list with search, sections (favourites, recent, all),
* and context menus.
*/
import { createElement, escapeHtml, fuzzyMatch, protocolColour, dismissContextMenus, truncate } from './utils.js';
/** Maximum number of profiles shown in the "Recent" section. */
const RECENT_LIMIT = 5;
export class Sidebar {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
this.el = document.getElementById('sidebar-profiles');
this.searchQuery = '';
// Section collapse state
this.collapsedSections = new Set();
// Dismiss context menus on any outside click
document.addEventListener('click', () => dismissContextMenus());
}
// ── Public API ───────────────────────────────────────────────────
/**
* Full re-render of the sidebar profile list.
*/
render() {
if (!this.el) return;
this.el.innerHTML = '';
const profiles = this._filteredProfiles();
// Favourites
const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) {
this._renderSection('Favourites', 'favourites', favourites);
}
// Recent
const recent = profiles
.filter((p) => p.lastConnected)
.sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected))
.slice(0, RECENT_LIMIT);
if (recent.length > 0) {
this._renderSection('Recent', 'recent', recent);
}
// All connections
this._renderSection('All Connections', 'all', profiles);
// Empty state
if (profiles.length === 0) {
const empty = createElement('div', 'sidebar-empty');
empty.innerHTML = `
<p class="sidebar-empty-text">No profiles found</p>
`;
this.el.appendChild(empty);
}
}
/**
* Filters the displayed profiles by query.
* @param {string} query
*/
search(query) {
this.searchQuery = query.trim();
this.render();
}
/**
* Highlights the sidebar item for the currently active profile.
* @param {string|null} profileId
*/
highlightActive(profileId) {
this.el.querySelectorAll('.sidebar-item').forEach((item) => {
item.classList.toggle('sidebar-item-active', item.dataset.profileId === profileId);
});
}
/**
* Toggles the collapsed state of a named section.
* @param {string} sectionName
*/
toggleSection(sectionName) {
if (this.collapsedSections.has(sectionName)) {
this.collapsedSections.delete(sectionName);
} else {
this.collapsedSections.add(sectionName);
}
this.render();
}
// ── Rendering helpers ────────────────────────────────────────────
/**
* Renders a collapsible section of profiles.
* @param {string} title
* @param {string} key
* @param {Array<Object>} profiles
* @private
*/
_renderSection(title, key, profiles) {
const section = createElement('div', 'sidebar-section');
const isCollapsed = this.collapsedSections.has(key);
// Section header
const header = createElement('div', 'sidebar-section-header');
header.innerHTML = `
<span class="sidebar-section-chevron ${isCollapsed ? 'collapsed' : ''}">${isCollapsed ? '▸' : '▾'}</span>
<span class="sidebar-section-title">${escapeHtml(title)}</span>
<span class="sidebar-section-count">${profiles.length}</span>
`;
header.addEventListener('click', () => this.toggleSection(key));
section.appendChild(header);
// Items
if (!isCollapsed) {
const list = createElement('div', 'sidebar-section-list');
for (const profile of profiles) {
list.appendChild(this.renderProfileItem(profile));
}
section.appendChild(list);
}
this.el.appendChild(section);
}
/**
* Creates a single sidebar list item for a profile.
* @param {Object} profile
* @returns {HTMLElement}
*/
renderProfileItem(profile) {
const item = createElement('div', 'sidebar-item', {
dataset: { profileId: profile.id },
});
// Highlight if this profile's session is active
const activeSession = this.app.state.activeSessionId
? this.app.state.getSession(this.app.state.activeSessionId)
: null;
if (activeSession && activeSession.profileId === profile.id) {
item.classList.add('sidebar-item-active');
}
// Protocol colour dot
const dot = createElement('span', `sidebar-dot ${protocolColour(profile.protocol)}`);
item.appendChild(dot);
// Text content
const textWrapper = createElement('div', 'sidebar-item-text');
const nameEl = createElement('span', 'sidebar-item-name', {
textContent: profile.displayName || profile.host || 'Untitled',
});
textWrapper.appendChild(nameEl);
const subtitle = profile.host
? `${profile.host}:${profile.port}`
: profile.protocol.toUpperCase();
const subEl = createElement('span', 'sidebar-item-sub', {
textContent: truncate(subtitle, 30),
});
textWrapper.appendChild(subEl);
item.appendChild(textWrapper);
// Favourite star
if (profile.favourite) {
const star = createElement('span', 'sidebar-star', { textContent: '★' });
item.appendChild(star);
}
// Click → connect
item.addEventListener('click', () => {
this.app.connectProfile(profile.id);
});
// Double-click → edit
item.addEventListener('dblclick', (e) => {
e.stopPropagation();
this.app.profileEditor.openEdit(profile.id);
});
// Right-click → context menu
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.renderContextMenu(profile, e.clientX, e.clientY);
});
return item;
}
/**
* Shows a right-click context menu for a profile.
* @param {Object} profile
* @param {number} x - Viewport X.
* @param {number} y - Viewport Y.
*/
renderContextMenu(profile, x, y) {
dismissContextMenus();
const menu = createElement('div', 'context-menu');
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const items = [
{ label: 'Connect', action: () => this.app.connectProfile(profile.id) },
{ label: 'Edit', action: () => this.app.profileEditor.openEdit(profile.id) },
{ label: 'Duplicate', action: () => this._duplicateProfile(profile) },
{ divider: true },
{
label: profile.favourite ? 'Remove from Favourites' : 'Add to Favourites',
action: () => this._toggleFavourite(profile),
},
{ divider: true },
{
label: 'Delete',
className: 'context-menu-danger',
action: () => this._deleteProfile(profile),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement('div', 'context-menu-divider'));
continue;
}
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
textContent: entry.label,
});
btn.addEventListener('click', () => {
dismissContextMenus();
entry.action();
});
menu.appendChild(btn);
}
// Keep menu within viewport
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
}
if (rect.bottom > window.innerHeight) {
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
}
}
// ── Actions ──────────────────────────────────────────────────────
/**
* Toggles the favourite status of a profile.
* @param {Object} profile
* @private
*/
async _toggleFavourite(profile) {
try {
const newVal = !profile.favourite;
await window.go.api.Service.SetFavourite(profile.id, newVal);
profile.favourite = newVal;
this.app.state.updateProfile(profile);
this.render();
} catch (err) {
this.app.notifications.error('Failed to update favourite: ' + err);
}
}
/**
* Duplicates a profile.
* @param {Object} profile
* @private
*/
async _duplicateProfile(profile) {
try {
const dup = await window.go.api.Service.DuplicateProfile(profile.id);
this.app.state.addProfile(dup);
this.render();
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
} catch (err) {
this.app.notifications.error('Failed to duplicate profile: ' + err);
}
}
/**
* Deletes a profile after confirmation.
* @param {Object} profile
* @private
*/
async _deleteProfile(profile) {
const ok = confirm(`Delete "${profile.displayName || profile.host}"?\nThis cannot be undone.`);
if (!ok) return;
try {
await window.go.api.Service.DeleteProfile(profile.id);
this.app.state.removeProfile(profile.id);
this.render();
this.app.notifications.success('Profile deleted');
} catch (err) {
this.app.notifications.error('Failed to delete profile: ' + err);
}
}
// ── Filtering ────────────────────────────────────────────────────
/**
* Returns profiles filtered by the current search query.
* @returns {Array<Object>}
* @private
*/
_filteredProfiles() {
if (!this.searchQuery) return this.app.state.profiles;
return this.app.state.profiles
.map((p) => {
// Match against display name, host, tags, and protocol
const fields = [
p.displayName || '',
p.host || '',
(p.tags || []).join(' '),
p.protocol || '',
].join(' ');
const result = fuzzyMatch(fields, this.searchQuery);
return { profile: p, ...result };
})
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.map((r) => r.profile);
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
export class State {
constructor() {
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {string|null} */
this.activeSessionId = null;
/** @type {Object} */
this.preferences = {};
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
}
// ── Event emitter ────────────────────────────────────────────────
/**
* Subscribes to a state change event.
* @param {string} event
* @param {Function} callback
*/
on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
}
/**
* Unsubscribes from a state change event.
* @param {string} event
* @param {Function} callback
*/
off(event, callback) {
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
}
/**
* Emits a state change event to all subscribers.
* @param {string} event
* @param {*} data
*/
emit(event, data) {
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
}
}
// ── Profiles ─────────────────────────────────────────────────────
/**
* Replaces the entire profiles list.
* @param {Array<Object>} profiles
*/
setProfiles(profiles) {
this.profiles = profiles;
this.emit('profiles:changed', this.profiles);
}
/**
* Adds a single profile.
* @param {Object} profile
*/
addProfile(profile) {
this.profiles.push(profile);
this.emit('profiles:changed', this.profiles);
this.emit('profile:added', profile);
}
/**
* Updates an existing profile in place.
* @param {Object} profile
*/
updateProfile(profile) {
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit('profiles:changed', this.profiles);
this.emit('profile:updated', profile);
}
/**
* Removes a profile by ID.
* @param {string} id
*/
removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit('profiles:changed', this.profiles);
this.emit('profile:removed', id);
}
/**
* Returns a profile by ID, or undefined.
* @param {string} id
* @returns {Object|undefined}
*/
getProfile(id) {
return this.profiles.find((p) => p.id === id);
}
// ── Sessions ─────────────────────────────────────────────────────
/**
* Registers a new session.
* @param {Object} sessionInfo
*/
addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit('sessions:changed', this.sessions);
this.emit('session:added', sessionInfo);
}
/**
* Merges partial updates into an existing session.
* @param {string} id
* @param {Object} updates
*/
updateSession(id, updates) {
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit('sessions:changed', this.sessions);
this.emit('session:updated', existing);
}
}
/**
* Removes a session.
* @param {string} id
*/
removeSession(id) {
this.sessions.delete(id);
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
}
this.emit('sessions:changed', this.sessions);
this.emit('session:removed', id);
}
/**
* Returns session info by ID, or undefined.
* @param {string} id
* @returns {Object|undefined}
*/
getSession(id) {
return this.sessions.get(id);
}
/**
* Sets the currently visible/active session.
* @param {string|null} id
*/
setActiveSession(id) {
this.activeSessionId = id;
this.emit('session:activated', id);
}
/**
* Returns an ordered array of all sessions.
* @returns {Array<Object>}
*/
getAllSessions() {
return [...this.sessions.values()];
}
// ── Preferences ──────────────────────────────────────────────────
/**
* Replaces the entire preferences object.
* @param {Object} prefs
*/
setPreferences(prefs) {
this.preferences = prefs;
this.emit('preferences:changed', this.preferences);
}
// ── Secret store ─────────────────────────────────────────────────
/**
* Updates the secret store state.
* @param {boolean} unlocked
*/
setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked);
}
/**
* Marks whether the secret store has been initialised (vault exists).
* @param {boolean} initialised
*/
setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised;
this.emit('secretStore:init', initialised);
}
}
+546
View File
@@ -0,0 +1,546 @@
/**
* Nexus Terminal — tab management.
* Handles the tab bar: adding, removing, reordering, renaming,
* pinning tabs, and context menus.
*/
import { createElement, escapeHtml, protocolColour, stateLabel, dismissContextMenus } from './utils.js';
export class TabManager {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement} The tab bar container. */
this.tabBar = document.getElementById('tab-bar');
/** @type {HTMLElement} The container for terminal/viewer panels. */
this.panelContainer = document.getElementById('panel-container');
/** @type {string[]} Ordered list of session IDs. */
this.order = [];
this._setupDragAndDrop();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Adds a new tab for a session and makes it active.
* @param {Object} sessionInfo
*/
addTab(sessionInfo) {
this.order.push(sessionInfo.id);
this._renderTab(sessionInfo);
// Create panel container for this session's terminal/viewer
const panel = createElement('div', 'session-panel', {
id: `panel-${sessionInfo.id}`,
dataset: { sessionId: sessionInfo.id },
});
panel.style.display = 'none';
this.panelContainer.appendChild(panel);
this.setActive(sessionInfo.id);
this._updateEmptyState();
}
/**
* Removes a tab and its panel.
* @param {string} sessionId
*/
removeTab(sessionId) {
const idx = this.order.indexOf(sessionId);
if (idx !== -1) this.order.splice(idx, 1);
// Remove tab element
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
if (tabEl) tabEl.remove();
// Remove panel
const panel = document.getElementById(`panel-${sessionId}`);
if (panel) panel.remove();
// If this was the active tab, activate the nearest neighbour
if (this.app.state.activeSessionId === sessionId) {
const next = this.order.length > 0
? this.order[Math.min(idx, this.order.length - 1)]
: null;
if (next) {
this.setActive(next);
} else {
this.app.state.setActiveSession(null);
}
}
this._updateEmptyState();
}
/**
* Switches the visible tab/panel to the given session.
* @param {string} sessionId
*/
setActive(sessionId) {
this.app.state.setActiveSession(sessionId);
// Highlight active tab
this.tabBar.querySelectorAll('.tab').forEach((t) => {
t.classList.toggle('tab-active', t.dataset.sessionId === sessionId);
});
// Show corresponding panel, hide others
this.panelContainer.querySelectorAll('.session-panel').forEach((p) => {
p.style.display = p.dataset.sessionId === sessionId ? 'flex' : 'none';
});
// Notify sidebar
const session = this.app.state.getSession(sessionId);
if (session) {
this.app.sidebar.highlightActive(session.profileId);
}
// Fit the terminal for the newly active session
this.app.terminals.fitActive();
}
/**
* Closes the currently active tab.
*/
closeActive() {
if (this.app.state.activeSessionId) {
this.closeTab(this.app.state.activeSessionId);
}
}
/**
* Disconnects and closes a tab by session ID.
* @param {string} sessionId
*/
async closeTab(sessionId) {
const session = this.app.state.getSession(sessionId);
// Ask for confirmation if connected and the preference is set
if (session && session.state === 'connected' && this.app.state.preferences.confirmOnDisconnect) {
const ok = confirm('Disconnect and close this session?');
if (!ok) return;
}
try {
await window.go.api.Service.CloseSession(sessionId);
} catch (_) {
// Session may already be closed — carry on
}
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
this.app.state.removeSession(sessionId);
this.removeTab(sessionId);
}
/**
* Renames a tab.
* @param {string} sessionId
* @param {string} name
*/
renameTab(sessionId, name) {
this.app.state.updateSession(sessionId, { tabName: name });
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
if (tabEl) {
tabEl.querySelector('.tab-name').textContent = name;
}
}
/**
* Pins a tab (moves it to the front).
* @param {string} sessionId
*/
pinTab(sessionId) {
this.app.state.updateSession(sessionId, { pinned: true });
this._reorderDOM();
this._updateTabStyles(sessionId);
}
/**
* Unpins a tab.
* @param {string} sessionId
*/
unpinTab(sessionId) {
this.app.state.updateSession(sessionId, { pinned: false });
this._reorderDOM();
this._updateTabStyles(sessionId);
}
/**
* Moves a tab one position to the left.
* @param {string} sessionId
*/
moveLeft(sessionId) {
const idx = this.order.indexOf(sessionId);
if (idx > 0) {
[this.order[idx - 1], this.order[idx]] = [this.order[idx], this.order[idx - 1]];
this._reorderDOM();
}
}
/**
* Moves a tab one position to the right.
* @param {string} sessionId
*/
moveRight(sessionId) {
const idx = this.order.indexOf(sessionId);
if (idx >= 0 && idx < this.order.length - 1) {
[this.order[idx], this.order[idx + 1]] = [this.order[idx + 1], this.order[idx]];
this._reorderDOM();
}
}
/**
* Opens a new session with the same profile as the given tab.
* @param {string} sessionId
*/
async duplicateTab(sessionId) {
const session = this.app.state.getSession(sessionId);
if (session && session.profileId) {
await this.app.connectProfile(session.profileId);
}
}
/**
* Updates the visual state of a tab when session state changes.
* @param {string} sessionId
* @param {string} state - connecting | connected | disconnected | error
* @param {string} [error]
*/
updateSessionState(sessionId, state, error) {
this.app.state.updateSession(sessionId, { state, error: error || '' });
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
if (!tabEl) return;
const dot = tabEl.querySelector('.tab-status');
if (dot) {
dot.className = `tab-status status-${state}`;
dot.title = stateLabel(state);
}
if (state === 'error' && error) {
this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`);
}
}
/**
* Handles a session closed event from the backend.
* @param {string} sessionId
*/
handleSessionClosed(sessionId) {
this.updateSessionState(sessionId, 'disconnected');
}
/**
* Shows a right-click context menu for a tab.
* @param {string} sessionId
* @param {number} x
* @param {number} y
*/
showContextMenu(sessionId, x, y) {
dismissContextMenus();
const session = this.app.state.getSession(sessionId);
if (!session) return;
const menu = createElement('div', 'context-menu');
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const isPinned = session.pinned;
const items = [
{ label: 'Rename…', action: () => this._promptRename(sessionId) },
{ label: 'Duplicate', action: () => this.duplicateTab(sessionId) },
{ divider: true },
{ label: isPinned ? 'Unpin' : 'Pin', action: () => isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId) },
{ label: 'Move Left', action: () => this.moveLeft(sessionId) },
{ label: 'Move Right', action: () => this.moveRight(sessionId) },
{ divider: true },
{
label: 'Reconnect',
action: () => this._reconnect(sessionId),
disabled: session.state === 'connected' || session.state === 'connecting',
},
{ divider: true },
{ label: 'Close', className: 'context-menu-danger', action: () => this.closeTab(sessionId) },
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement('div', 'context-menu-divider'));
continue;
}
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
textContent: entry.label,
});
if (entry.disabled) {
btn.disabled = true;
btn.classList.add('context-menu-disabled');
}
btn.addEventListener('click', () => {
dismissContextMenus();
entry.action();
});
menu.appendChild(btn);
}
document.body.appendChild(menu);
// Keep within viewport
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`;
if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`;
}
/**
* Returns the current tab ordering.
* @returns {string[]}
*/
getTabOrder() {
return [...this.order];
}
/**
* Returns the session ID of the next tab (wrapping round).
* @returns {string|null}
*/
getNextTab() {
if (this.order.length === 0) return null;
const idx = this.order.indexOf(this.app.state.activeSessionId);
return this.order[(idx + 1) % this.order.length];
}
/**
* Returns the session ID of the previous tab (wrapping round).
* @returns {string|null}
*/
getPrevTab() {
if (this.order.length === 0) return null;
const idx = this.order.indexOf(this.app.state.activeSessionId);
return this.order[(idx - 1 + this.order.length) % this.order.length];
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Creates and appends a tab element.
* @param {Object} sessionInfo
* @private
*/
_renderTab(sessionInfo) {
const tab = createElement('div', 'tab', {
dataset: { sessionId: sessionInfo.id },
draggable: 'true',
});
// Protocol badge
const badge = createElement('span', `tab-badge ${protocolColour(sessionInfo.protocol)}`, {
textContent: sessionInfo.protocol.toUpperCase(),
});
tab.appendChild(badge);
// Name
const name = createElement('span', 'tab-name', {
textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(),
});
tab.appendChild(name);
// Status dot
const status = createElement('span', `tab-status status-${sessionInfo.state || 'connecting'}`, {
title: stateLabel(sessionInfo.state),
});
tab.appendChild(status);
// Pin indicator
if (sessionInfo.pinned) {
tab.classList.add('tab-pinned');
}
// Close button
const closeBtn = createElement('button', 'tab-close', {
textContent: '×',
'aria-label': 'Close tab',
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeTab(sessionInfo.id);
});
tab.appendChild(closeBtn);
// Click → activate
tab.addEventListener('click', () => this.setActive(sessionInfo.id));
// Right-click → context menu
tab.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.showContextMenu(sessionInfo.id, e.clientX, e.clientY);
});
this.tabBar.appendChild(tab);
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Reorders the tab-bar DOM elements to match `this.order`,
* with pinned tabs always first.
* @private
*/
_reorderDOM() {
// Sort: pinned first, then by order index
const sorted = [...this.order].sort((a, b) => {
const sa = this.app.state.getSession(a);
const sb = this.app.state.getSession(b);
const pinnedA = sa?.pinned ? 0 : 1;
const pinnedB = sb?.pinned ? 0 : 1;
if (pinnedA !== pinnedB) return pinnedA - pinnedB;
return this.order.indexOf(a) - this.order.indexOf(b);
});
for (const id of sorted) {
const el = this.tabBar.querySelector(`[data-session-id="${id}"]`);
if (el) this.tabBar.appendChild(el);
}
}
/**
* Updates the visual pin state on a tab element.
* @param {string} sessionId
* @private
*/
_updateTabStyles(sessionId) {
const session = this.app.state.getSession(sessionId);
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
if (tabEl && session) {
tabEl.classList.toggle('tab-pinned', !!session.pinned);
}
}
/**
* Shows/hides the empty-state panel based on whether tabs exist.
* @private
*/
_updateEmptyState() {
const empty = document.getElementById('empty-state');
if (empty) {
empty.style.display = this.order.length === 0 ? 'flex' : 'none';
}
}
/**
* Prompts the user to rename a tab.
* @param {string} sessionId
* @private
*/
_promptRename(sessionId) {
const session = this.app.state.getSession(sessionId);
const current = session?.tabName || '';
const name = prompt('Tab name:', current);
if (name !== null && name.trim()) {
this.renameTab(sessionId, name.trim());
}
}
/**
* Attempts to reconnect a disconnected session.
* @param {string} sessionId
* @private
*/
async _reconnect(sessionId) {
const session = this.app.state.getSession(sessionId);
if (!session || !session.profileId) return;
// Close the current tab, then open a fresh connection
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
this.app.state.removeSession(sessionId);
this.removeTab(sessionId);
await this.app.connectProfile(session.profileId);
}
/**
* Returns the display label for a session's tab.
* @param {string} sessionId
* @returns {string}
* @private
*/
_tabLabel(sessionId) {
const session = this.app.state.getSession(sessionId);
return session?.tabName || 'Session';
}
// ── Drag and drop ────────────────────────────────────────────────
/**
* Sets up drag-and-drop reordering on the tab bar.
* @private
*/
_setupDragAndDrop() {
if (!this.tabBar) return;
let draggedId = null;
this.tabBar.addEventListener('dragstart', (e) => {
const tab = e.target.closest('.tab');
if (!tab) return;
draggedId = tab.dataset.sessionId;
tab.classList.add('tab-dragging');
e.dataTransfer.effectAllowed = 'move';
});
this.tabBar.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('.tab');
if (!target || target.dataset.sessionId === draggedId) return;
const rect = target.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
target.classList.toggle('tab-drop-before', e.clientX < midX);
target.classList.toggle('tab-drop-after', e.clientX >= midX);
});
this.tabBar.addEventListener('dragleave', (e) => {
const target = e.target.closest('.tab');
if (target) {
target.classList.remove('tab-drop-before', 'tab-drop-after');
}
});
this.tabBar.addEventListener('drop', (e) => {
e.preventDefault();
const target = e.target.closest('.tab');
if (!target || !draggedId) return;
target.classList.remove('tab-drop-before', 'tab-drop-after');
const targetId = target.dataset.sessionId;
if (targetId === draggedId) return;
// Reorder
const fromIdx = this.order.indexOf(draggedId);
const toIdx = this.order.indexOf(targetId);
if (fromIdx === -1 || toIdx === -1) return;
this.order.splice(fromIdx, 1);
this.order.splice(toIdx, 0, draggedId);
this._reorderDOM();
});
this.tabBar.addEventListener('dragend', () => {
this.tabBar.querySelectorAll('.tab').forEach((t) => {
t.classList.remove('tab-dragging', 'tab-drop-before', 'tab-drop-after');
});
draggedId = null;
});
}
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Nexus Terminal — xterm.js terminal manager.
* Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions.
* Handles data routing, resizing, and search.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
export class TerminalManager {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → { term, fitAddon, searchAddon, observer }.
* @type {Map<string, Object>}
*/
this.terminals = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Creates and opens an xterm.js terminal inside the given container.
* Wires up data input → backend and container resizing.
*
* @param {string} sessionId
* @param {string} containerId - DOM id of the panel element.
* @param {Object} profile - The connection profile (used for font/theme prefs).
* @returns {Terminal} The xterm.js Terminal instance.
*/
createTerminal(sessionId, containerId, profile) {
const term = new Terminal({
theme: this._buildTheme(),
fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
fontSize: profile.terminalFontSize || 14,
scrollback: profile.terminalScrollback || 10000,
cursorStyle: profile.terminalCursorStyle || 'block',
cursorBlink: true,
allowProposedApi: true,
convertEol: false,
windowsMode: false,
});
// Load addons
const fitAddon = new FitAddon.FitAddon();
const searchAddon = new SearchAddon.SearchAddon();
let webLinksAddon = null;
term.loadAddon(fitAddon);
term.loadAddon(searchAddon);
// WebLinksAddon may not always be available
try {
webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
});
term.loadAddon(webLinksAddon);
} catch (_) {
// Not critical — links just won't be clickable
}
// Open the terminal in the DOM container
const container = document.getElementById(containerId);
term.open(container);
// Initial fit
requestAnimationFrame(() => fitAddon.fit());
// Forward user keystrokes to the backend
term.onData((data) => {
try {
const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64);
} catch (err) {
console.error('WriteSession error:', err);
}
});
// Notify backend when terminal dimensions change
term.onResize(({ cols, rows }) => {
try {
window.go.api.Service.ResizeSession(sessionId, cols, rows);
} catch (err) {
console.error('ResizeSession error:', err);
}
});
// Re-fit when the container itself resizes
const observer = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch (_) {
// Ignore transient errors during layout shifts
}
});
observer.observe(container);
this.terminals.set(sessionId, {
term,
fitAddon,
searchAddon,
webLinksAddon,
observer,
});
return term;
}
/**
* Writes incoming backend data (base64-encoded) to the appropriate terminal.
* @param {string} sessionId
* @param {string} base64Data
*/
handleData(sessionId, base64Data) {
const entry = this.terminals.get(sessionId);
if (!entry) return;
try {
const raw = atob(base64Data);
entry.term.write(raw);
} catch (err) {
console.error('Failed to decode session data:', err);
}
}
/**
* Tears down a terminal instance and frees resources.
* @param {string} sessionId
*/
destroyTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (!entry) return;
entry.observer.disconnect();
entry.term.dispose();
this.terminals.delete(sessionId);
}
/**
* Shows the terminal for a given session, hiding all others.
* @param {string} sessionId
*/
showTerminal(sessionId) {
for (const [id, entry] of this.terminals) {
const panel = document.getElementById(`panel-${id}`);
if (panel) {
panel.style.display = id === sessionId ? 'flex' : 'none';
}
}
// Re-fit the newly visible terminal
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
});
}
}
/**
* Re-fits the currently active terminal to its container.
*/
fitActive() {
const id = this.app.state.activeSessionId;
if (!id) return;
const entry = this.terminals.get(id);
if (entry) {
requestAnimationFrame(() => {
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
});
}
}
/**
* Searches within a terminal using the search addon.
* @param {string} sessionId
* @param {string} query
* @param {Object} [options]
* @param {boolean} [options.regex=false]
* @param {boolean} [options.caseSensitive=false]
* @param {boolean} [options.wholeWord=false]
* @returns {boolean} Whether a match was found.
*/
search(sessionId, query, options = {}) {
const entry = this.terminals.get(sessionId);
if (!entry) return false;
if (!query) {
entry.searchAddon.clearDecorations();
return false;
}
return entry.searchAddon.findNext(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
decorations: {
matchBackground: '#ffa72633',
activeMatchBackground: '#4ecdc466',
matchBorder: '#ffa726',
activeMatchBorder: '#4ecdc4',
},
});
}
/**
* Finds the previous search result.
* @param {string} sessionId
* @param {string} query
* @param {Object} [options]
* @returns {boolean}
*/
searchPrevious(sessionId, query, options = {}) {
const entry = this.terminals.get(sessionId);
if (!entry || !query) return false;
return entry.searchAddon.findPrevious(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
});
}
/**
* Clears the terminal buffer and viewport.
* @param {string} sessionId
*/
clearTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.clear();
}
/**
* Fully resets the terminal (clears buffer, restores default state).
* @param {string} sessionId
*/
resetTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.reset();
}
/**
* Focuses the terminal so it can receive keyboard input.
* @param {string} sessionId
*/
focusTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.focus();
}
// ── Private helpers ──────────────────────────────────────────────
/**
* Builds the xterm.js colour theme.
* @returns {Object}
* @private
*/
_buildTheme() {
return {
background: '#1a1a2e',
foreground: '#e8e8f0',
cursor: '#4ecdc4',
cursorAccent: '#1a1a2e',
selectionBackground: 'rgba(78, 205, 196, 0.3)',
selectionForeground: '#ffffff',
black: '#1a1a2e',
red: '#ef5350',
green: '#66bb6a',
yellow: '#ffa726',
blue: '#42a5f5',
magenta: '#ab47bc',
cyan: '#4ecdc4',
white: '#e8e8f0',
brightBlack: '#464670',
brightRed: '#ff7043',
brightGreen: '#81c784',
brightYellow: '#ffcc02',
brightBlue: '#64b5f6',
brightMagenta: '#ce93d8',
brightCyan: '#80deea',
brightWhite: '#ffffff',
};
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Nexus Terminal — utility functions.
* General-purpose helpers used throughout the application.
*/
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
export function $(selector) {
return document.querySelector(selector);
}
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
export function $$(selector) {
return document.querySelectorAll(selector);
}
/**
* Creates a DOM element with optional class name and attributes.
* @param {string} tag - HTML tag name.
* @param {string} [className] - Space-separated class names.
* @param {Object} [attrs] - Attribute key/value pairs.
* @returns {HTMLElement}
*/
export function createElement(tag, className, attrs = {}) {
const el = document.createElement(tag);
if (className) {
el.className = className;
}
for (const [key, value] of Object.entries(attrs)) {
if (key === 'textContent') {
el.textContent = value;
} else if (key === 'innerHTML') {
el.innerHTML = value;
} else if (key === 'dataset') {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
return el;
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - Raw string.
* @returns {string} Escaped string safe for innerHTML.
*/
export function escapeHtml(str) {
if (!str) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return str.replace(/[&<>"']/g, (ch) => map[ch]);
}
/**
* Formats a Date (or ISO string) into a concise timestamp.
* @param {Date|string} date
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/
export function formatTimestamp(date) {
if (!date) return '';
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const hours = String(d.getHours()).padStart(2, '0');
const mins = String(d.getMinutes()).padStart(2, '0');
const secs = String(d.getSeconds()).padStart(2, '0');
if (isToday) {
return `${hours}:${mins}:${secs}`;
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
}
/**
* Returns a debounced version of the given function.
* @param {Function} fn
* @param {number} ms - Delay in milliseconds.
* @returns {Function}
*/
export function debounce(fn, ms) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
/**
* Performs a simple fuzzy match — checks whether every character in
* the query appears in order within the text (case-insensitive).
* @param {string} text
* @param {string} query
* @returns {{ match: boolean, score: number }}
*/
export function fuzzyMatch(text, query) {
if (!query) return { match: true, score: 1 };
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let textIdx = 0;
let score = 0;
let consecutiveBonus = 0;
for (let qi = 0; qi < lowerQuery.length; qi++) {
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
}
return { match: true, score };
}
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
export function base64Encode(str) {
return btoa(str);
}
/**
* Base64-decodes a string.
* @param {string} str
* @returns {string}
*/
export function base64Decode(str) {
return atob(str);
}
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
export function generateId() {
return crypto.randomUUID();
}
/**
* Formats a byte count into a human-friendly string.
* @param {number} bytes
* @returns {string} e.g. "1.23 KB"
*/
export function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
/**
* Truncates a string to the given length, appending an ellipsis if needed.
* @param {string} str
* @param {number} len
* @returns {string}
*/
export function truncate(str, len) {
if (!str || str.length <= len) return str || '';
return str.slice(0, len) + '…';
}
/**
* Removes any open context menus from the DOM.
*/
export function dismissContextMenus() {
document.querySelectorAll('.context-menu').forEach((m) => m.remove());
}
/**
* Returns the protocol badge colour class.
* @param {string} protocol - 'ssh', 'telnet', or 'raw'.
* @returns {string}
*/
export function protocolColour(protocol) {
switch (protocol) {
case 'ssh': return 'protocol-ssh';
case 'telnet': return 'protocol-telnet';
case 'raw': return 'protocol-raw';
default: return '';
}
}
/**
* Returns a human-friendly label for a session state.
* @param {string} state
* @returns {string}
*/
export function stateLabel(state) {
switch (state) {
case 'connecting': return 'Connecting';
case 'connected': return 'Connected';
case 'disconnected': return 'Disconnected';
case 'error': return 'Error';
default: return state || 'Unknown';
}
}