feat: add VNC support with noVNC integration

- Introduced a new VNC viewer panel in the frontend, allowing users to connect to VNC sessions.
- Implemented VNC connection handling with optional SSH tunneling in the backend.
- Added a new SSH key manager for managing SSH keys within the terminal.
- Created a trigger rules engine to monitor terminal output and trigger actions based on defined patterns.
- Added CSS styles for the VNC viewer and its components.
- Updated database schema to support VNC protocol in profiles.
- Implemented WebSocket proxy for VNC connections to facilitate communication between the frontend and backend.
This commit is contained in:
Andy Dixon
2026-05-21 12:52:57 +01:00
parent 523d9b02b4
commit 96fedb9348
26 changed files with 3162 additions and 315 deletions
+331 -294
View File
@@ -1,27 +1,27 @@
(function() {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
(function () {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
class CommandPalette {
class CommandPalette {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = '';
/** @type {string} Current filter query. */
this._query = "";
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
@@ -30,31 +30,31 @@ class CommandPalette {
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = '';
this._selectedIndex = 0;
this._render();
if (this._overlay) return;
this._query = "";
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
@@ -64,165 +64,184 @@ class CommandPalette {
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: 'Connections',
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
// New connections
cmds.push({
id: "new-ssh",
label: "New SSH Connection",
section: "Actions",
shortcut: "⌘N",
action: () => this.app.profileEditor.openNew("ssh"),
});
cmds.push({
id: "new-telnet",
label: "New Telnet Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("telnet"),
});
cmds.push({
id: "new-raw",
label: "New RAW Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("raw"),
});
// Quick connect
cmds.push({
id: 'quick-connect',
label: 'Quick Connect…',
section: 'Actions',
action: () => this.app.quickConnect(),
});
// Quick connect
cmds.push({
id: "quick-connect",
label: "Quick Connect…",
section: "Actions",
action: () => this.app.quickConnect(),
});
// Tab actions
cmds.push({
id: 'close-tab',
label: 'Close Tab',
section: 'Tabs',
shortcut: '⌘W',
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: 'next-tab',
label: 'Next Tab',
section: 'Tabs',
shortcut: '⌘Tab',
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: 'prev-tab',
label: 'Previous Tab',
section: 'Tabs',
shortcut: '⌘⇧Tab',
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Tab actions
cmds.push({
id: "close-tab",
label: "Close Tab",
section: "Tabs",
shortcut: "⌘W",
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: "next-tab",
label: "Next Tab",
section: "Tabs",
shortcut: "⌘Tab",
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: "prev-tab",
label: "Previous Tab",
section: "Tabs",
shortcut: "⌘⇧Tab",
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
// Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
id: "export-profiles",
label: "Export Profiles",
section: "Application",
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'settings',
label: 'Settings',
section: 'Application',
action: () => this.app.settings.open(),
id: "lock-secrets",
label: "Lock Secret Store",
section: "Security",
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
},
});
}
// SSH Key Manager
cmds.push({
id: "ssh-keys",
label: "SSH Key Manager",
section: "Security",
action: () => this.app.sshKeys.open(),
});
// Trigger Rules
cmds.push({
id: "trigger-rules",
label: "Trigger Rules",
section: "Terminal",
action: () => this.app.triggers.open(),
});
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: "split-vertical",
label: "Split Pane Right",
section: "Terminal",
shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
});
cmds.push({
id: 'import-profiles',
label: 'Import Profiles',
section: 'Application',
action: () => this.app.importProfiles(),
id: "split-horizontal",
label: "Split Pane Down",
section: "Terminal",
shortcut: "⌘⇧E",
action: () => this.app.splitPane("horizontal"),
});
cmds.push({
id: 'export-profiles',
label: 'Export Profiles',
section: 'Application',
action: () => this.app.exportProfiles(),
id: "clear-terminal",
label: "Clear Terminal",
section: "Terminal",
action: () =>
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: "reset-terminal",
label: "Reset Terminal",
section: "Terminal",
action: () =>
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'split-vertical',
label: 'Split Pane Right',
section: 'Terminal',
shortcut: '⌘⇧D',
action: () => this.app.splitPane('vertical'),
});
cmds.push({
id: 'split-horizontal',
label: 'Split Pane Down',
section: 'Terminal',
shortcut: '⌘⇧E',
action: () => this.app.splitPane('horizontal'),
});
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
return cmds;
}
/**
@@ -231,14 +250,17 @@ class CommandPalette {
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
@@ -246,82 +268,85 @@ class CommandPalette {
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === 'function') {
command.action();
}
this.close();
if (command && typeof command.action === "function") {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement('div', 'palette-overlay');
const palette = createElement('div', 'palette');
const overlay = createElement("div", "palette-overlay");
const palette = createElement("div", "palette");
// Search input
const input = createElement('input', 'palette-input', {
type: 'text',
placeholder: 'Type a command…',
autocomplete: 'off',
spellcheck: 'false',
});
// Search input
const input = createElement("input", "palette-input", {
type: "text",
placeholder: "Type a command…",
autocomplete: "off",
spellcheck: "false",
});
palette.appendChild(input);
palette.appendChild(input);
// Results list
const list = createElement('div', 'palette-list');
palette.appendChild(list);
// Results list
const list = createElement("div", "palette-list");
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(''));
// Initial render
this._renderList(list, this.filter(""));
// Focus the input
input.focus();
// Focus the input
input.focus();
// Event handlers
input.addEventListener('input', () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
// Event handlers
input.addEventListener("input", () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener('keydown', (e) => {
const items = list.querySelectorAll('.palette-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
this._highlightItem(items);
break;
case 'ArrowUp':
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case 'Enter': {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case 'Escape':
e.preventDefault();
this.close();
break;
input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll(".palette-item");
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items);
break;
case "ArrowUp":
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case "Enter": {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
});
break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close();
});
// Click outside to close
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
}
/**
@@ -331,52 +356,65 @@ class CommandPalette {
* @private
*/
_renderList(list, commands) {
list.innerHTML = '';
let currentSection = null;
list.innerHTML = "";
let currentSection = null;
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
if (commands.length === 0) {
const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl);
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
// Label
const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label);
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
item._command = cmd;
// Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
// Detail
if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
}
/**
@@ -385,17 +423,16 @@ class CommandPalette {
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
items.forEach((el, i) => {
el.classList.toggle("palette-item-active", i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: "nearest" });
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
})();