Files
nexus/frontend/src/js/commands.js
T

447 lines
12 KiB
JavaScript

(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;
class CommandPalette {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = "";
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = "";
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
/**
* Returns the full list of available commands.
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: "new-ssh",
label: "New SSH Connection",
section: "Actions",
shortcut: "⌘N",
action: () => this.app.profileEditor.openNew("ssh"),
});
cmds.push({
id: "new-telnet",
label: "New Telnet Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("telnet"),
});
cmds.push({
id: "new-raw",
label: "New RAW Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("raw"),
});
// Quick connect
cmds.push({
id: "quick-connect",
label: "Quick Connect…",
section: "Actions",
action: () => this.app.quickConnect(),
});
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Tab actions
cmds.push({
id: "close-tab",
label: "Close Tab",
section: "Tabs",
shortcut: "⌘W",
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: "next-tab",
label: "Next Tab",
section: "Tabs",
shortcut: "⌘Tab",
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: "prev-tab",
label: "Previous Tab",
section: "Tabs",
shortcut: "⌘⇧Tab",
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
id: "export-profiles",
label: "Export Profiles",
section: "Application",
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: "lock-secrets",
label: "Lock Secret Store",
section: "Security",
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
},
});
}
// 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: "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;
}
/**
* Returns commands filtered and sorted by the current query.
* @param {string} query
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
* Executes a command and closes the palette.
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === "function") {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement("div", "palette-overlay");
const palette = createElement("div", "palette");
// Search input
const input = createElement("input", "palette-input", {
type: "text",
placeholder: "Type a command…",
autocomplete: "off",
spellcheck: "false",
});
palette.appendChild(input);
// Results list
const list = createElement("div", "palette-list");
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(""));
// Focus the input
input.focus();
// Event handlers
input.addEventListener("input", () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll(".palette-item");
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items);
break;
case "ArrowUp":
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case "Enter": {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
}
/**
* Renders the filtered command list.
* @param {HTMLElement} list
* @param {Array<Object>} commands
* @private
*/
_renderList(list, commands) {
list.innerHTML = "";
let currentSection = null;
if (commands.length === 0) {
const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl);
}
const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
// Label
const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
}
/**
* Highlights the currently selected item and scrolls it into view.
* @param {NodeListOf<Element>} items
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle("palette-item-active", i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: "nearest" });
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
})();