Refactor SnippetLibrary and State classes for improved readability and consistency

- Updated formatting and indentation in snippets.js for better code clarity.
- Enhanced error handling and notifications in sendToActiveSession method.
- Improved variable resolution logic for command snippets.
- Added new methods for managing session state in state.js.
- Introduced new split pane functionality in terminal.js for better user experience.
- Enhanced modal handling and user notifications across various components.
This commit is contained in:
Andy Dixon
2026-05-21 12:15:49 +01:00
parent 421bdb9ee8
commit 523d9b02b4
6 changed files with 588 additions and 418 deletions
+42 -13
View File
@@ -604,9 +604,11 @@
*/ */
selectBroadcastTargets() { selectBroadcastTargets() {
const { createElement, escapeHtml } = window.NexusTerminal; const { createElement, escapeHtml } = window.NexusTerminal;
const sessions = this.state.getAllSessions().filter( const sessions = this.state
(s) => s.state === "connected" && s.id !== this.state.activeSessionId, .getAllSessions()
); .filter(
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
);
if (sessions.length === 0) { if (sessions.length === 0) {
this.notifications.error("No other connected sessions to broadcast to"); this.notifications.error("No other connected sessions to broadcast to");
@@ -620,8 +622,13 @@
let listHtml = ""; let listHtml = "";
for (const s of sessions) { for (const s of sessions) {
const profile = this.state.getProfile(s.profileId); const profile = this.state.getProfile(s.profileId);
const label = profile ? (profile.displayName || profile.host) : s.id.substring(0, 8); const label = profile
const checked = currentTargets.size === 0 || currentTargets.has(s.id) ? "checked" : ""; ? profile.displayName || profile.host
: s.id.substring(0, 8);
const checked =
currentTargets.size === 0 || currentTargets.has(s.id)
? "checked"
: "";
listHtml += ` listHtml += `
<label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;"> <label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;">
<input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} /> <input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} />
@@ -663,10 +670,14 @@
modal.querySelector(".modal-close").addEventListener("click", close); modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector(".broadcast-cancel").addEventListener("click", close); modal.querySelector(".broadcast-cancel").addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".broadcast-all").addEventListener("click", () => { modal.querySelector(".broadcast-all").addEventListener("click", () => {
modal.querySelectorAll(".broadcast-target-check").forEach((cb) => { cb.checked = true; }); modal.querySelectorAll(".broadcast-target-check").forEach((cb) => {
cb.checked = true;
});
}); });
modal.querySelector(".broadcast-apply").addEventListener("click", () => { modal.querySelector(".broadcast-apply").addEventListener("click", () => {
@@ -682,11 +693,15 @@
} }
// If all are selected, use null (broadcast to all) // If all are selected, use null (broadcast to all)
this.state.broadcastTargets = selected.size === sessions.length ? null : selected; this.state.broadcastTargets =
selected.size === sessions.length ? null : selected;
if (!this.state.broadcastMode) { if (!this.state.broadcastMode) {
this.toggleBroadcast(); this.toggleBroadcast();
} else { } else {
const targetLabel = selected.size === sessions.length ? "all sessions" : selected.size + " sessions"; const targetLabel =
selected.size === sessions.length
? "all sessions"
: selected.size + " sessions";
this.notifications.info("Broadcasting to " + targetLabel); this.notifications.info("Broadcasting to " + targetLabel);
} }
close(); close();
@@ -1001,7 +1016,11 @@
// Latency indicator — only show for connected SSH sessions. // Latency indicator — only show for connected SSH sessions.
if (latencyEl) { if (latencyEl) {
if (activeSession && activeSession.state === "connected" && activeSession.protocol === "ssh") { if (
activeSession &&
activeSession.state === "connected" &&
activeSession.protocol === "ssh"
) {
this._startLatencyPing(activeSession.id); this._startLatencyPing(activeSession.id);
} else { } else {
this._stopLatencyPing(); this._stopLatencyPing();
@@ -1043,12 +1062,20 @@
const el = document.getElementById("status-latency"); const el = document.getElementById("status-latency");
if (el && this._latencySessionID === sessionID) { if (el && this._latencySessionID === sessionID) {
el.textContent = ms + " ms"; el.textContent = ms + " ms";
el.className = ms < 100 ? "latency-good" : ms < 250 ? "latency-warn" : "latency-bad"; el.className =
ms < 100
? "latency-good"
: ms < 250
? "latency-warn"
: "latency-bad";
} }
} catch (_) { } catch (_) {
// Session may have disconnected — clear indicator. // Session may have disconnected — clear indicator.
const el = document.getElementById("status-latency"); const el = document.getElementById("status-latency");
if (el) { el.textContent = ""; el.className = ""; } if (el) {
el.textContent = "";
el.className = "";
}
this._stopLatencyPing(); this._stopLatencyPing();
} }
}; };
@@ -1370,7 +1397,9 @@
const re = new RegExp(query, caseSensitive ? "g" : "gi"); const re = new RegExp(query, caseSensitive ? "g" : "gi");
const m = text.match(re); const m = text.match(re);
if (m) total += m.length; if (m) total += m.length;
} catch (_) { /* invalid regex */ } } catch (_) {
/* invalid regex */
}
} else { } else {
const searchText = caseSensitive ? text : text.toLowerCase(); const searchText = caseSensitive ? text : text.toLowerCase();
const searchQuery = caseSensitive ? query : query.toLowerCase(); const searchQuery = caseSensitive ? query : query.toLowerCase();
+14
View File
@@ -194,6 +194,20 @@ class CommandPalette {
// Terminal actions (only when a session is active) // Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) { 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({ cmds.push({
id: 'clear-terminal', id: 'clear-terminal',
label: 'Clear Terminal', label: 'Clear Terminal',
+83 -32
View File
@@ -236,7 +236,9 @@
actions.appendChild(goToBtn); actions.appendChild(goToBtn);
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", ""); var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
bookmarkBtn.addEventListener("click", () => this._showBookmarks(sessionId)); bookmarkBtn.addEventListener("click", () =>
this._showBookmarks(sessionId),
);
actions.appendChild(bookmarkBtn); actions.appendChild(bookmarkBtn);
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent"); var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
@@ -496,12 +498,17 @@
} }
} else { } else {
// Single click without modifier — clear all, select this one // Single click without modifier — clear all, select this one
state.el.querySelectorAll(".sftp-file-row.is-selected").forEach(function (r) { state.el
r.classList.remove("is-selected"); .querySelectorAll(".sftp-file-row.is-selected")
}); .forEach(function (r) {
r.classList.remove("is-selected");
});
// If clicking the same entry (and it was the only selection), deselect // If clicking the same entry (and it was the only selection), deselect
if (state.selectedFiles.size === 1 && state.selectedFiles.has(entry.path)) { if (
state.selectedFiles.size === 1 &&
state.selectedFiles.has(entry.path)
) {
state.selectedFiles.clear(); state.selectedFiles.clear();
state.selected = null; state.selected = null;
var ctxBar = this._getCtxBar(sessionId); var ctxBar = this._getCtxBar(sessionId);
@@ -540,16 +547,25 @@
var label = createElement("span", "sftp-ctx-label", { var label = createElement("span", "sftp-ctx-label", {
textContent: isSingle ? entry.name : count + " items selected", textContent: isSingle ? entry.name : count + " items selected",
title: isSingle ? entry.path : entries.map(function (e) { return e.name; }).join(", "), title: isSingle
? entry.path
: entries
.map(function (e) {
return e.name;
})
.join(", "),
}); });
ctxBar.appendChild(label); ctxBar.appendChild(label);
// Download button — only for files (single or batch) // Download button — only for files (single or batch)
var downloadableFiles = entries.filter(function (e) { return !e.isDir; }); var downloadableFiles = entries.filter(function (e) {
return !e.isDir;
});
if (downloadableFiles.length > 0) { if (downloadableFiles.length > 0) {
var dlLabel = downloadableFiles.length === 1 var dlLabel =
? "↓ Download" downloadableFiles.length === 1
: "↓ Download (" + downloadableFiles.length + ")"; ? "↓ Download"
: "↓ Download (" + downloadableFiles.length + ")";
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", { var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
textContent: dlLabel, textContent: dlLabel,
}); });
@@ -664,14 +680,19 @@
} }
} }
if (failed === 0) { if (failed === 0) {
this.app.notifications.success("All " + files.length + " files downloaded"); this.app.notifications.success(
"All " + files.length + " files downloaded",
);
} }
} }
_confirmDeleteBatch(sessionId, entries) { _confirmDeleteBatch(sessionId, entries) {
this._showConfirmModal({ this._showConfirmModal({
title: "Delete " + entries.length + " Items", title: "Delete " + entries.length + " Items",
message: "Permanently delete <strong>" + entries.length + " selected items</strong>?", message:
"Permanently delete <strong>" +
entries.length +
" selected items</strong>?",
confirmLabel: "Delete All", confirmLabel: "Delete All",
isDanger: true, isDanger: true,
onConfirm: function () { onConfirm: function () {
@@ -913,13 +934,25 @@
var listHtml = ""; var listHtml = "";
if (bookmarks.length === 0) { if (bookmarks.length === 0) {
listHtml = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>'; listHtml =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>';
} else { } else {
for (var i = 0; i < bookmarks.length; i++) { for (var i = 0; i < bookmarks.length; i++) {
listHtml += '<div class="sftp-bookmark-row" data-index="' + i + '" data-path="' + escapeHtml(bookmarks[i]) + '">'; listHtml +=
listHtml += '<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(bookmarks[i]) + '</span>'; '<div class="sftp-bookmark-row" data-index="' +
listHtml += '<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' + i + '" title="Remove">✕</button>'; i +
listHtml += '</div>'; '" data-path="' +
escapeHtml(bookmarks[i]) +
'">';
listHtml +=
'<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
escapeHtml(bookmarks[i]) +
"</span>";
listHtml +=
'<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' +
i +
'" title="Remove">✕</button>';
listHtml += "</div>";
} }
} }
@@ -927,14 +960,16 @@
'<div class="modal-header">' + '<div class="modal-header">' +
'<span class="modal-title">Directory Bookmarks</span>' + '<span class="modal-title">Directory Bookmarks</span>' +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' + '<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
'</div>' + "</div>" +
'<div class="modal-body">' + '<div class="modal-body">' +
'<div class="sftp-bookmark-list">' + listHtml + '</div>' + '<div class="sftp-bookmark-list">' +
'</div>' + listHtml +
"</div>" +
"</div>" +
'<div class="modal-footer">' + '<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' + '<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' +
'<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' + '<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' +
'</div>'; "</div>";
backdrop.appendChild(modal); backdrop.appendChild(modal);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
@@ -950,12 +985,18 @@
closed = true; closed = true;
backdrop.classList.remove("is-visible"); backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible"); modal.classList.remove("is-visible");
setTimeout(function () { backdrop.remove(); }, 200); setTimeout(function () {
backdrop.remove();
}, 200);
} }
modal.querySelector(".modal-close").addEventListener("click", close); modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector(".sftp-modal-cancel").addEventListener("click", close); modal
backdrop.addEventListener("click", function (e) { if (e.target === backdrop) close(); }); .querySelector(".sftp-modal-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close();
});
// Navigate to bookmark on click // Navigate to bookmark on click
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) { modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
@@ -965,7 +1006,9 @@
close(); close();
self._navigate(sessionId, path); self._navigate(sessionId, path);
}); });
row.addEventListener("mousedown", function (e) { e.preventDefault(); }); row.addEventListener("mousedown", function (e) {
e.preventDefault();
});
}); });
// Delete bookmark // Delete bookmark
@@ -980,11 +1023,13 @@
}); });
// Add current directory as bookmark // Add current directory as bookmark
modal.querySelector(".sftp-bookmark-add").addEventListener("click", function () { modal
self._addBookmark(sessionId, state.cwd); .querySelector(".sftp-bookmark-add")
close(); .addEventListener("click", function () {
self._showBookmarks(sessionId); self._addBookmark(sessionId, state.cwd);
}); close();
self._showBookmarks(sessionId);
});
} }
_getBookmarks(sessionId) { _getBookmarks(sessionId) {
@@ -1001,7 +1046,10 @@
var bookmarks = this._getBookmarks(sessionId); var bookmarks = this._getBookmarks(sessionId);
if (bookmarks.indexOf(path) === -1) { if (bookmarks.indexOf(path) === -1) {
bookmarks.push(path); bookmarks.push(path);
localStorage.setItem("sftp-bookmarks-" + sessionId, JSON.stringify(bookmarks)); localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
this.app.notifications.success("Bookmarked: " + path); this.app.notifications.success("Bookmarked: " + path);
} else { } else {
this.app.notifications.info("Already bookmarked"); this.app.notifications.info("Already bookmarked");
@@ -1011,7 +1059,10 @@
_removeBookmark(sessionId, index) { _removeBookmark(sessionId, index) {
var bookmarks = this._getBookmarks(sessionId); var bookmarks = this._getBookmarks(sessionId);
bookmarks.splice(index, 1); bookmarks.splice(index, 1);
localStorage.setItem("sftp-bookmarks-" + sessionId, JSON.stringify(bookmarks)); localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
} }
/* ── Transfer progress queue ─────────────────────────────────── */ /* ── Transfer progress queue ─────────────────────────────────── */
+307 -269
View File
@@ -1,20 +1,21 @@
(function() { (function () {
/** /**
* Nexus Terminal — snippet / command library. * Nexus Terminal — snippet / command library.
* Provides a panel for managing and sending reusable commands to active sessions. * Provides a panel for managing and sending reusable commands to active sessions.
*/ */
const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal; const { createElement, escapeHtml, dismissContextMenus } =
window.NexusTerminal;
class SnippetLibrary { class SnippetLibrary {
/** /**
* @param {App} app * @param {App} app
*/ */
constructor(app) { constructor(app) {
this.app = app; this.app = app;
/** @type {Array<Object>} */ /** @type {Array<Object>} */
this.snippets = []; this.snippets = [];
this.searchQuery = ''; this.searchQuery = "";
} }
// ── Public API ─────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────
@@ -23,8 +24,8 @@ class SnippetLibrary {
* Opens the snippet library modal. * Opens the snippet library modal.
*/ */
async open() { async open() {
await this._loadSnippets(); await this._loadSnippets();
this._showModal(); this._showModal();
} }
/** /**
@@ -34,86 +35,86 @@ class SnippetLibrary {
* @param {string} command * @param {string} command
*/ */
sendToActiveSession(command) { sendToActiveSession(command) {
const sessionId = this.app.state.activeSessionId; const sessionId = this.app.state.activeSessionId;
if (!sessionId) { if (!sessionId) {
this.app.notifications.error('No active session'); this.app.notifications.error("No active session");
return; return;
} }
const session = this.app.state.getSession(sessionId); const session = this.app.state.getSession(sessionId);
if (!session || session.state !== 'connected') { if (!session || session.state !== "connected") {
this.app.notifications.error('Session is not connected'); this.app.notifications.error("Session is not connected");
return; return;
} }
// Check for template variables // Check for template variables
const varPattern = /\{\{([^}]+)\}\}/g; const varPattern = /\{\{([^}]+)\}\}/g;
const matches = []; const matches = [];
let match; let match;
while ((match = varPattern.exec(command)) !== null) { while ((match = varPattern.exec(command)) !== null) {
matches.push(match[1].trim()); matches.push(match[1].trim());
}
// Resolve built-in variables first
const builtins = {
hostname: session.host || "unknown",
date: new Date().toISOString().split("T")[0],
time: new Date().toTimeString().split(" ")[0],
timestamp: Math.floor(Date.now() / 1000).toString(),
};
let resolved = command;
const userVars = [];
for (const varName of matches) {
const lower = varName.toLowerCase();
if (builtins[lower] !== undefined) {
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
} else if (!userVars.includes(varName)) {
userVars.push(varName);
} }
}
// Resolve built-in variables first if (userVars.length === 0) {
const builtins = { this._sendCommand(sessionId, resolved);
hostname: (session.host || 'unknown'), return;
date: new Date().toISOString().split('T')[0], }
time: new Date().toTimeString().split(' ')[0],
timestamp: Math.floor(Date.now() / 1000).toString(),
};
let resolved = command; // Prompt for user-defined variables
const userVars = []; this._promptVariables(userVars, (values) => {
for (const varName of matches) { let final = resolved;
const lower = varName.toLowerCase(); for (let i = 0; i < userVars.length; i++) {
if (builtins[lower] !== undefined) { final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
resolved = resolved.replace('{{' + varName + '}}', builtins[lower]);
} else if (!userVars.includes(varName)) {
userVars.push(varName);
}
} }
this._sendCommand(sessionId, final);
if (userVars.length === 0) { });
this._sendCommand(sessionId, resolved);
return;
}
// Prompt for user-defined variables
this._promptVariables(userVars, (values) => {
let final = resolved;
for (let i = 0; i < userVars.length; i++) {
final = final.split('{{' + userVars[i] + '}}').join(values[i] || '');
}
this._sendCommand(sessionId, final);
});
} }
/** @private */ /** @private */
_sendCommand(sessionId, command) { _sendCommand(sessionId, command) {
try { try {
const b64 = btoa(command + '\n'); const b64 = btoa(command + "\n");
window.go.api.Service.WriteSession(sessionId, b64); window.go.api.Service.WriteSession(sessionId, b64);
this.app.notifications.info('Snippet sent'); this.app.notifications.info("Snippet sent");
} catch (err) { } catch (err) {
this.app.notifications.error('Failed to send snippet: ' + err); this.app.notifications.error("Failed to send snippet: " + err);
} }
} }
/** @private */ /** @private */
_promptVariables(varNames, onConfirm) { _promptVariables(varNames, onConfirm) {
const backdrop = createElement('div', 'modal-backdrop'); const backdrop = createElement("div", "modal-backdrop");
const modal = createElement('div', 'modal modal-sm'); const modal = createElement("div", "modal modal-sm");
let inputsHtml = ''; let inputsHtml = "";
for (const v of varNames) { for (const v of varNames) {
inputsHtml += ` inputsHtml += `
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">${escapeHtml(v)}</label> <label class="form-label">${escapeHtml(v)}</label>
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" /> <input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
</div> </div>
`; `;
} }
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Snippet Variables</h3> <h3 class="modal-title">Snippet Variables</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button> <button class="btn btn-icon btn-xs modal-close">&times;</button>
@@ -125,62 +126,66 @@ class SnippetLibrary {
</div> </div>
`; `;
backdrop.appendChild(modal); backdrop.appendChild(modal);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
requestAnimationFrame(() => { requestAnimationFrame(() => {
backdrop.classList.add('is-visible'); backdrop.classList.add("is-visible");
modal.classList.add('is-visible'); modal.classList.add("is-visible");
const firstInput = modal.querySelector('.snippet-var-input'); const firstInput = modal.querySelector(".snippet-var-input");
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}); });
const close = () => { const close = () => {
backdrop.classList.remove('is-visible'); backdrop.classList.remove("is-visible");
modal.classList.remove('is-visible'); modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200); setTimeout(() => backdrop.remove(), 200);
}; };
modal.querySelector('.modal-close').addEventListener('click', close); modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector('.snippet-var-cancel').addEventListener('click', close); modal
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); .querySelector(".snippet-var-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector('.snippet-var-send').addEventListener('click', () => { modal.querySelector(".snippet-var-send").addEventListener("click", () => {
const inputs = modal.querySelectorAll('.snippet-var-input'); const inputs = modal.querySelectorAll(".snippet-var-input");
const values = Array.from(inputs).map((inp) => inp.value); const values = Array.from(inputs).map((inp) => inp.value);
close(); close();
onConfirm(values); onConfirm(values);
}); });
// Enter to send // Enter to send
modal.addEventListener('keydown', (e) => { modal.addEventListener("keydown", (e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
modal.querySelector('.snippet-var-send').click(); modal.querySelector(".snippet-var-send").click();
} }
if (e.key === 'Escape') close(); if (e.key === "Escape") close();
}); });
} }
// ── Data ───────────────────────────────────────────────────────── // ── Data ─────────────────────────────────────────────────────────
/** @private */ /** @private */
async _loadSnippets() { async _loadSnippets() {
try { try {
this.snippets = await window.go.api.Service.ListSnippets() || []; this.snippets = (await window.go.api.Service.ListSnippets()) || [];
} catch (err) { } catch (err) {
this.snippets = []; this.snippets = [];
console.error('Failed to load snippets:', err); console.error("Failed to load snippets:", err);
} }
} }
// ── Modal ──────────────────────────────────────────────────────── // ── Modal ────────────────────────────────────────────────────────
/** @private */ /** @private */
_showModal() { _showModal() {
const backdrop = createElement('div', 'modal-backdrop'); const backdrop = createElement("div", "modal-backdrop");
const modal = createElement('div', 'modal modal-lg'); const modal = createElement("div", "modal modal-lg");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Command Library</h3> <h3 class="modal-title">Command Library</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button> <button class="btn btn-icon btn-xs modal-close">&times;</button>
@@ -201,155 +206,176 @@ class SnippetLibrary {
</div> </div>
`; `;
backdrop.appendChild(modal); backdrop.appendChild(modal);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
requestAnimationFrame(() => { requestAnimationFrame(() => {
backdrop.classList.add('is-visible'); backdrop.classList.add("is-visible");
modal.classList.add('is-visible'); modal.classList.add("is-visible");
}); });
const close = () => { const close = () => {
backdrop.classList.remove('is-visible'); backdrop.classList.remove("is-visible");
modal.classList.remove('is-visible'); modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200); setTimeout(() => backdrop.remove(), 200);
}; };
modal.querySelector('.modal-close').addEventListener('click', close); modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
// Search });
const searchInput = modal.querySelector('.snippet-search');
searchInput.addEventListener('input', () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close);
});
// New snippet button
modal.querySelector('.snippet-add-btn').addEventListener('click', () => {
this._showEditor(modal, null, close);
});
// Search
const searchInput = modal.querySelector(".snippet-search");
searchInput.addEventListener("input", () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close); this._renderList(modal, close);
});
// New snippet button
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
this._showEditor(modal, null, close);
});
this._renderList(modal, close);
} }
/** @private */ /** @private */
_renderList(modal, closeFn) { _renderList(modal, closeFn) {
const listEl = modal.querySelector('.snippet-list'); const listEl = modal.querySelector(".snippet-list");
if (!listEl) return; if (!listEl) return;
listEl.innerHTML = ''; listEl.innerHTML = "";
const filtered = this.snippets.filter((sn) => { const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true; if (!this.searchQuery) return true;
return sn.name.toLowerCase().includes(this.searchQuery) || return (
sn.command.toLowerCase().includes(this.searchQuery) || sn.name.toLowerCase().includes(this.searchQuery) ||
(sn.description || '').toLowerCase().includes(this.searchQuery); sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || "").toLowerCase().includes(this.searchQuery)
);
});
if (filtered.length === 0) {
listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return;
}
for (const sn of filtered) {
const item = createElement("div", "snippet-item", {
dataset: { id: sn.id },
}); });
item.style.cssText =
if (filtered.length === 0) { "padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>'; item.innerHTML = `
return;
}
for (const sn of filtered) {
const item = createElement('div', 'snippet-item', {
dataset: { id: sn.id },
});
item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);';
item.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div> <div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div> <div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
`; `;
item.addEventListener('click', () => this._showDetail(modal, sn, closeFn)); item.addEventListener("click", () =>
item.addEventListener('mousedown', (e) => e.preventDefault()); this._showDetail(modal, sn, closeFn),
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; }); );
item.addEventListener('mouseleave', () => { item.style.background = ''; }); item.addEventListener("mousedown", (e) => e.preventDefault());
listEl.appendChild(item); item.addEventListener("mouseenter", () => {
} item.style.background = "var(--bg-hover)";
});
item.addEventListener("mouseleave", () => {
item.style.background = "";
});
listEl.appendChild(item);
}
} }
/** @private */ /** @private */
_showDetail(modal, snippet, closeFn) { _showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel'); const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return; if (!detailPanel) return;
detailPanel.innerHTML = ''; detailPanel.innerHTML = "";
const nameEl = createElement('div', '', { const nameEl = createElement("div", "", {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`, innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
});
detailPanel.appendChild(nameEl);
if (snippet.description) {
const descEl = createElement("div", "", {
textContent: snippet.description,
}); });
detailPanel.appendChild(nameEl); descEl.style.cssText =
"font-size:var(--text-sm);color:var(--text-secondary);";
detailPanel.appendChild(descEl);
}
if (snippet.description) { const cmdEl = createElement("pre", "", {
const descEl = createElement('div', '', { textContent: snippet.command,
textContent: snippet.description, });
}); cmdEl.style.cssText =
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);'; "flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
detailPanel.appendChild(descEl); detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement("div", "", {});
actions.style.cssText = "display:flex;gap:var(--space-2);";
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send to Session",
});
sendBtn.addEventListener("click", () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Edit",
});
editBtn.addEventListener("click", () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error("Failed to delete: " + err);
} }
});
const cmdEl = createElement('pre', '', { actions.appendChild(sendBtn);
textContent: snippet.command, actions.appendChild(editBtn);
}); actions.appendChild(deleteBtn);
cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;'; detailPanel.appendChild(actions);
detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement('div', '', {});
actions.style.cssText = 'display:flex;gap:var(--space-2);';
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' });
sendBtn.addEventListener('click', () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' });
editBtn.addEventListener('click', () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' });
deleteBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error('Failed to delete: ' + err);
}
});
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
} }
/** @private */ /** @private */
_showEditor(modal, snippet, closeFn) { _showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel'); const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return; if (!detailPanel) return;
detailPanel.innerHTML = ''; detailPanel.innerHTML = "";
const isNew = !snippet; const isNew = !snippet;
const title = isNew ? 'New Snippet' : 'Edit Snippet'; const title = isNew ? "New Snippet" : "Edit Snippet";
detailPanel.innerHTML = ` detailPanel.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div> <div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" /> <input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
</div> </div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" /> <input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
</div> </div>
<div class="form-group" style="flex:1;display:flex;flex-direction:column;"> <div class="form-group" style="flex:1;display:flex;flex-direction:column;">
<label class="form-label">Command</label> <label class="form-label">Command</label>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea> <textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
</div> </div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Folder</label> <label class="form-label">Folder</label>
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" /> <input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
</div> </div>
<div style="display:flex;gap:var(--space-2);"> <div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button> <button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
@@ -357,60 +383,72 @@ class SnippetLibrary {
</div> </div>
`; `;
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => { detailPanel
const name = detailPanel.querySelector('.snippet-ed-name').value.trim(); .querySelector(".snippet-save-btn")
const command = detailPanel.querySelector('.snippet-ed-cmd').value; .addEventListener("click", async () => {
const description = detailPanel.querySelector('.snippet-ed-desc').value.trim(); const name = detailPanel
const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim(); .querySelector(".snippet-ed-name")
.value.trim();
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
const description = detailPanel
.querySelector(".snippet-ed-desc")
.value.trim();
const folder = detailPanel
.querySelector(".snippet-ed-folder")
.value.trim();
if (!name) { if (!name) {
this.app.notifications.error('Name is required'); this.app.notifications.error("Name is required");
return; return;
} }
if (!command) { if (!command) {
this.app.notifications.error('Command is required'); this.app.notifications.error("Command is required");
return; return;
}
try {
const data = {
id: isNew ? "" : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : snippet.tags || [],
};
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
} }
try { this._renderList(modal, closeFn);
const data = { this._showDetail(modal, saved, closeFn);
id: isNew ? '' : snippet.id, this.app.notifications.info(
name, isNew ? "Snippet created" : "Snippet updated",
command, );
description, } catch (err) {
folder, this.app.notifications.error("Failed to save snippet: " + err);
tags: isNew ? [] : (snippet.tags || []), }
};
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated');
} catch (err) {
this.app.notifications.error('Failed to save snippet: ' + err);
}
}); });
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => { detailPanel
if (snippet) { .querySelector(".snippet-cancel-btn")
this._showDetail(modal, snippet, closeFn); .addEventListener("click", () => {
} else { if (snippet) {
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>'; this._showDetail(modal, snippet, closeFn);
} } else {
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
}
}); });
} }
} }
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
})(); })();
+88 -88
View File
@@ -1,38 +1,38 @@
(function() { (function () {
/** /**
* Nexus Terminal — application state management. * Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences, * Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync. * emitting change events so the UI stays in sync.
*/ */
class State { class State {
constructor() { constructor() {
/** @type {Array<Object>} */ /** @type {Array<Object>} */
this.profiles = []; this.profiles = [];
/** @type {Map<string, Object>} sessionId → SessionInfo */ /** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map(); this.sessions = new Map();
/** @type {string|null} */ /** @type {string|null} */
this.activeSessionId = null; this.activeSessionId = null;
/** @type {Object} */ /** @type {Object} */
this.preferences = {}; this.preferences = {};
/** @type {boolean} */ /** @type {boolean} */
this.secretStoreUnlocked = false; this.secretStoreUnlocked = false;
/** @type {boolean} */ /** @type {boolean} */
this.secretStoreInitialised = false; this.secretStoreInitialised = false;
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */ /** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
this.broadcastMode = false; this.broadcastMode = false;
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */ /** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
this.broadcastTargets = null; this.broadcastTargets = null;
/** @type {Map<string, Set<Function>>} event name → callbacks */ /** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map(); this._listeners = new Map();
} }
// ── Event emitter ──────────────────────────────────────────────── // ── Event emitter ────────────────────────────────────────────────
@@ -43,10 +43,10 @@ class State {
* @param {Function} callback * @param {Function} callback
*/ */
on(event, callback) { on(event, callback) {
if (!this._listeners.has(event)) { if (!this._listeners.has(event)) {
this._listeners.set(event, new Set()); this._listeners.set(event, new Set());
} }
this._listeners.get(event).add(callback); this._listeners.get(event).add(callback);
} }
/** /**
@@ -55,10 +55,10 @@ class State {
* @param {Function} callback * @param {Function} callback
*/ */
off(event, callback) { off(event, callback) {
const set = this._listeners.get(event); const set = this._listeners.get(event);
if (set) { if (set) {
set.delete(callback); set.delete(callback);
} }
} }
/** /**
@@ -67,16 +67,16 @@ class State {
* @param {*} data * @param {*} data
*/ */
emit(event, data) { emit(event, data) {
const set = this._listeners.get(event); const set = this._listeners.get(event);
if (set) { if (set) {
for (const cb of set) { for (const cb of set) {
try { try {
cb(data); cb(data);
} catch (err) { } catch (err) {
console.error(`State listener error [${event}]:`, err); console.error(`State listener error [${event}]:`, err);
} }
}
} }
}
} }
// ── Profiles ───────────────────────────────────────────────────── // ── Profiles ─────────────────────────────────────────────────────
@@ -86,8 +86,8 @@ class State {
* @param {Array<Object>} profiles * @param {Array<Object>} profiles
*/ */
setProfiles(profiles) { setProfiles(profiles) {
this.profiles = profiles; this.profiles = profiles;
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
} }
/** /**
@@ -95,9 +95,9 @@ class State {
* @param {Object} profile * @param {Object} profile
*/ */
addProfile(profile) { addProfile(profile) {
this.profiles.push(profile); this.profiles.push(profile);
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:added', profile); this.emit("profile:added", profile);
} }
/** /**
@@ -105,12 +105,12 @@ class State {
* @param {Object} profile * @param {Object} profile
*/ */
updateProfile(profile) { updateProfile(profile) {
const idx = this.profiles.findIndex((p) => p.id === profile.id); const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) { if (idx !== -1) {
this.profiles[idx] = profile; this.profiles[idx] = profile;
} }
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:updated', profile); this.emit("profile:updated", profile);
} }
/** /**
@@ -118,9 +118,9 @@ class State {
* @param {string} id * @param {string} id
*/ */
removeProfile(id) { removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id); this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:removed', id); this.emit("profile:removed", id);
} }
/** /**
@@ -129,7 +129,7 @@ class State {
* @returns {Object|undefined} * @returns {Object|undefined}
*/ */
getProfile(id) { getProfile(id) {
return this.profiles.find((p) => p.id === id); return this.profiles.find((p) => p.id === id);
} }
// ── Sessions ───────────────────────────────────────────────────── // ── Sessions ─────────────────────────────────────────────────────
@@ -139,9 +139,9 @@ class State {
* @param {Object} sessionInfo * @param {Object} sessionInfo
*/ */
addSession(sessionInfo) { addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo); this.sessions.set(sessionInfo.id, sessionInfo);
this.emit('sessions:changed', this.sessions); this.emit("sessions:changed", this.sessions);
this.emit('session:added', sessionInfo); this.emit("session:added", sessionInfo);
} }
/** /**
@@ -150,12 +150,12 @@ class State {
* @param {Object} updates * @param {Object} updates
*/ */
updateSession(id, updates) { updateSession(id, updates) {
const existing = this.sessions.get(id); const existing = this.sessions.get(id);
if (existing) { if (existing) {
Object.assign(existing, updates); Object.assign(existing, updates);
this.emit('sessions:changed', this.sessions); this.emit("sessions:changed", this.sessions);
this.emit('session:updated', existing); this.emit("session:updated", existing);
} }
} }
/** /**
@@ -163,14 +163,15 @@ class State {
* @param {string} id * @param {string} id
*/ */
removeSession(id) { removeSession(id) {
this.sessions.delete(id); this.sessions.delete(id);
if (this.activeSessionId === id) { if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null // Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()]; const remaining = [...this.sessions.keys()];
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null; this.activeSessionId =
} remaining.length > 0 ? remaining[remaining.length - 1] : null;
this.emit('sessions:changed', this.sessions); }
this.emit('session:removed', id); this.emit("sessions:changed", this.sessions);
this.emit("session:removed", id);
} }
/** /**
@@ -179,7 +180,7 @@ class State {
* @returns {Object|undefined} * @returns {Object|undefined}
*/ */
getSession(id) { getSession(id) {
return this.sessions.get(id); return this.sessions.get(id);
} }
/** /**
@@ -187,8 +188,8 @@ class State {
* @param {string|null} id * @param {string|null} id
*/ */
setActiveSession(id) { setActiveSession(id) {
this.activeSessionId = id; this.activeSessionId = id;
this.emit('session:activated', id); this.emit("session:activated", id);
} }
/** /**
@@ -196,7 +197,7 @@ class State {
* @returns {Array<Object>} * @returns {Array<Object>}
*/ */
getAllSessions() { getAllSessions() {
return [...this.sessions.values()]; return [...this.sessions.values()];
} }
// ── Preferences ────────────────────────────────────────────────── // ── Preferences ──────────────────────────────────────────────────
@@ -206,8 +207,8 @@ class State {
* @param {Object} prefs * @param {Object} prefs
*/ */
setPreferences(prefs) { setPreferences(prefs) {
this.preferences = prefs; this.preferences = prefs;
this.emit('preferences:changed', this.preferences); this.emit("preferences:changed", this.preferences);
} }
// ── Secret store ───────────────────────────────────────────────── // ── Secret store ─────────────────────────────────────────────────
@@ -217,8 +218,8 @@ class State {
* @param {boolean} unlocked * @param {boolean} unlocked
*/ */
setSecretStoreState(unlocked) { setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked; this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked); this.emit("secretStore:changed", unlocked);
} }
/** /**
@@ -226,12 +227,11 @@ class State {
* @param {boolean} initialised * @param {boolean} initialised
*/ */
setSecretStoreInitialised(initialised) { setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised; this.secretStoreInitialised = initialised;
this.emit('secretStore:init', initialised); this.emit("secretStore:init", initialised);
} }
} }
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
})(); })();
+54 -16
View File
@@ -574,6 +574,17 @@
action: () => this._showPortForwardPanel(sessionId), action: () => this._showPortForwardPanel(sessionId),
}, },
{ divider: true }, { divider: true },
{
label: "Split Right",
shortcut: modKey + "⇧D",
action: () => this.app.splitPane("vertical"),
},
{
label: "Split Down",
shortcut: modKey + "⇧E",
action: () => this.app.splitPane("horizontal"),
},
{ divider: true },
{ {
label: "Export Output", label: "Export Output",
action: () => this._exportTerminalOutput(sessionId, term), action: () => this._exportTerminalOutput(sessionId, term),
@@ -1236,7 +1247,9 @@
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.app.notifications.success("Terminal output exported & copied to clipboard"); this.app.notifications.success(
"Terminal output exported & copied to clipboard",
);
} }
/** /**
@@ -1248,7 +1261,9 @@
const { createElement, escapeHtml } = window.NexusTerminal; const { createElement, escapeHtml } = window.NexusTerminal;
const session = this.app.state.getSession(sessionId); const session = this.app.state.getSession(sessionId);
if (!session || session.protocol !== "ssh") { if (!session || session.protocol !== "ssh") {
this.app.notifications.error("Port forwarding is only available for SSH sessions"); this.app.notifications.error(
"Port forwarding is only available for SSH sessions",
);
return; return;
} }
@@ -1297,24 +1312,30 @@
}; };
modal.querySelector(".modal-close").addEventListener("click", close); modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
const listEl = modal.querySelector(".portfwd-list"); const listEl = modal.querySelector(".portfwd-list");
const self = this; const self = this;
const renderForwards = async () => { const renderForwards = async () => {
try { try {
const forwards = await window.go.api.Service.ListPortForwards(sessionId) || []; const forwards =
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
listEl.innerHTML = ""; listEl.innerHTML = "";
if (forwards.length === 0) { if (forwards.length === 0) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>'; listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>';
return; return;
} }
for (const fwd of forwards) { for (const fwd of forwards) {
const row = createElement("div", "", {}); const row = createElement("div", "", {});
row.style.cssText = "display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);"; row.style.cssText =
"display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);";
const dirLabel = fwd.direction === "local" ? "L" : "R"; const dirLabel = fwd.direction === "local" ? "L" : "R";
const dirColor = fwd.direction === "local" ? "var(--accent)" : "var(--warning)"; const dirColor =
fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
row.innerHTML = ` row.innerHTML = `
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span> <span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span>
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;"> <span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;">
@@ -1322,21 +1343,26 @@
</span> </span>
${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""} ${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""}
`; `;
const stopBtn = createElement("button", "btn btn-xs btn-danger", { textContent: "Stop" }); const stopBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Stop",
});
stopBtn.addEventListener("click", async () => { stopBtn.addEventListener("click", async () => {
try { try {
await window.go.api.Service.StopPortForward(sessionId, fwd.id); await window.go.api.Service.StopPortForward(sessionId, fwd.id);
self.app.notifications.info("Forward stopped"); self.app.notifications.info("Forward stopped");
renderForwards(); renderForwards();
} catch (err) { } catch (err) {
self.app.notifications.error("Failed to stop: " + (err.message || err)); self.app.notifications.error(
"Failed to stop: " + (err.message || err),
);
} }
}); });
row.appendChild(stopBtn); row.appendChild(stopBtn);
listEl.appendChild(row); listEl.appendChild(row);
} }
} catch (err) { } catch (err) {
listEl.innerHTML = '<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>'; listEl.innerHTML =
'<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>';
} }
}; };
@@ -1345,14 +1371,24 @@
// Add forward button // Add forward button
modal.querySelector(".pf-add-btn").addEventListener("click", async () => { modal.querySelector(".pf-add-btn").addEventListener("click", async () => {
const direction = modal.querySelector(".pf-direction").value; const direction = modal.querySelector(".pf-direction").value;
const bindHost = modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1"; const bindHost =
const bindPort = parseInt(modal.querySelector(".pf-bind-port").value, 10); modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
const destHost = modal.querySelector(".pf-dest-host").value.trim() || "localhost"; const bindPort = parseInt(
const destPort = parseInt(modal.querySelector(".pf-dest-port").value, 10); modal.querySelector(".pf-bind-port").value,
10,
);
const destHost =
modal.querySelector(".pf-dest-host").value.trim() || "localhost";
const destPort = parseInt(
modal.querySelector(".pf-dest-port").value,
10,
);
const label = modal.querySelector(".pf-label").value.trim(); const label = modal.querySelector(".pf-label").value.trim();
if (!bindPort || !destPort) { if (!bindPort || !destPort) {
self.app.notifications.error("Bind port and destination port are required"); self.app.notifications.error(
"Bind port and destination port are required",
);
return; return;
} }
@@ -1373,7 +1409,9 @@
modal.querySelector(".pf-label").value = ""; modal.querySelector(".pf-label").value = "";
renderForwards(); renderForwards();
} catch (err) { } catch (err) {
self.app.notifications.error("Failed to start forward: " + (err.message || err)); self.app.notifications.error(
"Failed to start forward: " + (err.message || err),
);
} }
}); });
} }