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