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:
+42
-13
@@ -604,9 +604,11 @@
|
||||
*/
|
||||
selectBroadcastTargets() {
|
||||
const { createElement, escapeHtml } = window.NexusTerminal;
|
||||
const sessions = this.state.getAllSessions().filter(
|
||||
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
|
||||
);
|
||||
const sessions = this.state
|
||||
.getAllSessions()
|
||||
.filter(
|
||||
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
|
||||
);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
this.notifications.error("No other connected sessions to broadcast to");
|
||||
@@ -620,8 +622,13 @@
|
||||
let listHtml = "";
|
||||
for (const s of sessions) {
|
||||
const profile = this.state.getProfile(s.profileId);
|
||||
const label = profile ? (profile.displayName || profile.host) : s.id.substring(0, 8);
|
||||
const checked = currentTargets.size === 0 || currentTargets.has(s.id) ? "checked" : "";
|
||||
const label = profile
|
||||
? profile.displayName || profile.host
|
||||
: s.id.substring(0, 8);
|
||||
const checked =
|
||||
currentTargets.size === 0 || currentTargets.has(s.id)
|
||||
? "checked"
|
||||
: "";
|
||||
listHtml += `
|
||||
<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} />
|
||||
@@ -663,10 +670,14 @@
|
||||
|
||||
modal.querySelector(".modal-close").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.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", () => {
|
||||
@@ -682,11 +693,15 @@
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.toggleBroadcast();
|
||||
} 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);
|
||||
}
|
||||
close();
|
||||
@@ -1001,7 +1016,11 @@
|
||||
|
||||
// Latency indicator — only show for connected SSH sessions.
|
||||
if (latencyEl) {
|
||||
if (activeSession && activeSession.state === "connected" && activeSession.protocol === "ssh") {
|
||||
if (
|
||||
activeSession &&
|
||||
activeSession.state === "connected" &&
|
||||
activeSession.protocol === "ssh"
|
||||
) {
|
||||
this._startLatencyPing(activeSession.id);
|
||||
} else {
|
||||
this._stopLatencyPing();
|
||||
@@ -1043,12 +1062,20 @@
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el && this._latencySessionID === sessionID) {
|
||||
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 (_) {
|
||||
// Session may have disconnected — clear indicator.
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el) { el.textContent = ""; el.className = ""; }
|
||||
if (el) {
|
||||
el.textContent = "";
|
||||
el.className = "";
|
||||
}
|
||||
this._stopLatencyPing();
|
||||
}
|
||||
};
|
||||
@@ -1370,7 +1397,9 @@
|
||||
const re = new RegExp(query, caseSensitive ? "g" : "gi");
|
||||
const m = text.match(re);
|
||||
if (m) total += m.length;
|
||||
} catch (_) { /* invalid regex */ }
|
||||
} catch (_) {
|
||||
/* invalid regex */
|
||||
}
|
||||
} else {
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
||||
|
||||
@@ -194,6 +194,20 @@ class CommandPalette {
|
||||
|
||||
// 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',
|
||||
|
||||
+83
-32
@@ -236,7 +236,9 @@
|
||||
actions.appendChild(goToBtn);
|
||||
|
||||
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
|
||||
bookmarkBtn.addEventListener("click", () => this._showBookmarks(sessionId));
|
||||
bookmarkBtn.addEventListener("click", () =>
|
||||
this._showBookmarks(sessionId),
|
||||
);
|
||||
actions.appendChild(bookmarkBtn);
|
||||
|
||||
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
|
||||
@@ -496,12 +498,17 @@
|
||||
}
|
||||
} else {
|
||||
// Single click without modifier — clear all, select this one
|
||||
state.el.querySelectorAll(".sftp-file-row.is-selected").forEach(function (r) {
|
||||
r.classList.remove("is-selected");
|
||||
});
|
||||
state.el
|
||||
.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 (state.selectedFiles.size === 1 && state.selectedFiles.has(entry.path)) {
|
||||
if (
|
||||
state.selectedFiles.size === 1 &&
|
||||
state.selectedFiles.has(entry.path)
|
||||
) {
|
||||
state.selectedFiles.clear();
|
||||
state.selected = null;
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
@@ -540,16 +547,25 @@
|
||||
|
||||
var label = createElement("span", "sftp-ctx-label", {
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
var dlLabel = downloadableFiles.length === 1
|
||||
? "↓ Download"
|
||||
: "↓ Download (" + downloadableFiles.length + ")";
|
||||
var dlLabel =
|
||||
downloadableFiles.length === 1
|
||||
? "↓ Download"
|
||||
: "↓ Download (" + downloadableFiles.length + ")";
|
||||
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
|
||||
textContent: dlLabel,
|
||||
});
|
||||
@@ -664,14 +680,19 @@
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
this.app.notifications.success("All " + files.length + " files downloaded");
|
||||
this.app.notifications.success(
|
||||
"All " + files.length + " files downloaded",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_confirmDeleteBatch(sessionId, entries) {
|
||||
this._showConfirmModal({
|
||||
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",
|
||||
isDanger: true,
|
||||
onConfirm: function () {
|
||||
@@ -913,13 +934,25 @@
|
||||
|
||||
var listHtml = "";
|
||||
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 {
|
||||
for (var i = 0; i < bookmarks.length; i++) {
|
||||
listHtml += '<div class="sftp-bookmark-row" data-index="' + i + '" 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>';
|
||||
listHtml +=
|
||||
'<div class="sftp-bookmark-row" data-index="' +
|
||||
i +
|
||||
'" 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">' +
|
||||
'<span class="modal-title">Directory Bookmarks</span>' +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
'</div>' +
|
||||
"</div>" +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="sftp-bookmark-list">' + listHtml + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="sftp-bookmark-list">' +
|
||||
listHtml +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="modal-footer">' +
|
||||
'<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>' +
|
||||
'</div>';
|
||||
"</div>";
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
@@ -950,12 +985,18 @@
|
||||
closed = true;
|
||||
backdrop.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(".sftp-modal-cancel").addEventListener("click", close);
|
||||
backdrop.addEventListener("click", function (e) { if (e.target === backdrop) close(); });
|
||||
modal
|
||||
.querySelector(".sftp-modal-cancel")
|
||||
.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
// Navigate to bookmark on click
|
||||
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
|
||||
@@ -965,7 +1006,9 @@
|
||||
close();
|
||||
self._navigate(sessionId, path);
|
||||
});
|
||||
row.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
||||
row.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete bookmark
|
||||
@@ -980,11 +1023,13 @@
|
||||
});
|
||||
|
||||
// Add current directory as bookmark
|
||||
modal.querySelector(".sftp-bookmark-add").addEventListener("click", function () {
|
||||
self._addBookmark(sessionId, state.cwd);
|
||||
close();
|
||||
self._showBookmarks(sessionId);
|
||||
});
|
||||
modal
|
||||
.querySelector(".sftp-bookmark-add")
|
||||
.addEventListener("click", function () {
|
||||
self._addBookmark(sessionId, state.cwd);
|
||||
close();
|
||||
self._showBookmarks(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
_getBookmarks(sessionId) {
|
||||
@@ -1001,7 +1046,10 @@
|
||||
var bookmarks = this._getBookmarks(sessionId);
|
||||
if (bookmarks.indexOf(path) === -1) {
|
||||
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);
|
||||
} else {
|
||||
this.app.notifications.info("Already bookmarked");
|
||||
@@ -1011,7 +1059,10 @@
|
||||
_removeBookmark(sessionId, index) {
|
||||
var bookmarks = this._getBookmarks(sessionId);
|
||||
bookmarks.splice(index, 1);
|
||||
localStorage.setItem("sftp-bookmarks-" + sessionId, JSON.stringify(bookmarks));
|
||||
localStorage.setItem(
|
||||
"sftp-bookmarks-" + sessionId,
|
||||
JSON.stringify(bookmarks),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Transfer progress queue ─────────────────────────────────── */
|
||||
|
||||
+307
-269
@@ -1,20 +1,21 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — snippet / command library.
|
||||
* Provides a panel for managing and sending reusable commands to active sessions.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — snippet / command library.
|
||||
* 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
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
/** @type {Array<Object>} */
|
||||
this.snippets = [];
|
||||
this.searchQuery = '';
|
||||
this.app = app;
|
||||
/** @type {Array<Object>} */
|
||||
this.snippets = [];
|
||||
this.searchQuery = "";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -23,8 +24,8 @@ class SnippetLibrary {
|
||||
* Opens the snippet library modal.
|
||||
*/
|
||||
async open() {
|
||||
await this._loadSnippets();
|
||||
this._showModal();
|
||||
await this._loadSnippets();
|
||||
this._showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,86 +35,86 @@ class SnippetLibrary {
|
||||
* @param {string} command
|
||||
*/
|
||||
sendToActiveSession(command) {
|
||||
const sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) {
|
||||
this.app.notifications.error('No active session');
|
||||
return;
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || session.state !== 'connected') {
|
||||
this.app.notifications.error('Session is not connected');
|
||||
return;
|
||||
}
|
||||
const sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) {
|
||||
this.app.notifications.error("No active session");
|
||||
return;
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || session.state !== "connected") {
|
||||
this.app.notifications.error("Session is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for template variables
|
||||
const varPattern = /\{\{([^}]+)\}\}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = varPattern.exec(command)) !== null) {
|
||||
matches.push(match[1].trim());
|
||||
// Check for template variables
|
||||
const varPattern = /\{\{([^}]+)\}\}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = varPattern.exec(command)) !== null) {
|
||||
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
|
||||
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(),
|
||||
};
|
||||
if (userVars.length === 0) {
|
||||
this._sendCommand(sessionId, resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// 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] || "");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
this._sendCommand(sessionId, final);
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_sendCommand(sessionId, command) {
|
||||
try {
|
||||
const b64 = btoa(command + '\n');
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info('Snippet sent');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to send snippet: ' + err);
|
||||
}
|
||||
try {
|
||||
const b64 = btoa(command + "\n");
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info("Snippet sent");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to send snippet: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_promptVariables(varNames, onConfirm) {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
|
||||
let inputsHtml = '';
|
||||
for (const v of varNames) {
|
||||
inputsHtml += `
|
||||
let inputsHtml = "";
|
||||
for (const v of varNames) {
|
||||
inputsHtml += `
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<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)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Snippet Variables</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
@@ -125,62 +126,66 @@ class SnippetLibrary {
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
const firstInput = modal.querySelector('.snippet-var-input');
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
const firstInput = modal.querySelector(".snippet-var-input");
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.snippet-var-cancel').addEventListener('click', close);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
modal
|
||||
.querySelector(".snippet-var-cancel")
|
||||
.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
modal.querySelector('.snippet-var-send').addEventListener('click', () => {
|
||||
const inputs = modal.querySelectorAll('.snippet-var-input');
|
||||
const values = Array.from(inputs).map((inp) => inp.value);
|
||||
close();
|
||||
onConfirm(values);
|
||||
});
|
||||
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
|
||||
const inputs = modal.querySelectorAll(".snippet-var-input");
|
||||
const values = Array.from(inputs).map((inp) => inp.value);
|
||||
close();
|
||||
onConfirm(values);
|
||||
});
|
||||
|
||||
// Enter to send
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
modal.querySelector('.snippet-var-send').click();
|
||||
}
|
||||
if (e.key === 'Escape') close();
|
||||
});
|
||||
// Enter to send
|
||||
modal.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
modal.querySelector(".snippet-var-send").click();
|
||||
}
|
||||
if (e.key === "Escape") close();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
async _loadSnippets() {
|
||||
try {
|
||||
this.snippets = await window.go.api.Service.ListSnippets() || [];
|
||||
} catch (err) {
|
||||
this.snippets = [];
|
||||
console.error('Failed to load snippets:', err);
|
||||
}
|
||||
try {
|
||||
this.snippets = (await window.go.api.Service.ListSnippets()) || [];
|
||||
} catch (err) {
|
||||
this.snippets = [];
|
||||
console.error("Failed to load snippets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
_showModal() {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Command Library</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
@@ -201,155 +206,176 @@ class SnippetLibrary {
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
});
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', 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);
|
||||
});
|
||||
modal.querySelector(".modal-close").addEventListener("click", 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);
|
||||
});
|
||||
|
||||
this._renderList(modal, close);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_renderList(modal, closeFn) {
|
||||
const listEl = modal.querySelector('.snippet-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '';
|
||||
const listEl = modal.querySelector(".snippet-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = "";
|
||||
|
||||
const filtered = this.snippets.filter((sn) => {
|
||||
if (!this.searchQuery) return true;
|
||||
return sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
sn.command.toLowerCase().includes(this.searchQuery) ||
|
||||
(sn.description || '').toLowerCase().includes(this.searchQuery);
|
||||
const filtered = this.snippets.filter((sn) => {
|
||||
if (!this.searchQuery) return true;
|
||||
return (
|
||||
sn.name.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 },
|
||||
});
|
||||
|
||||
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 = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);';
|
||||
item.innerHTML = `
|
||||
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-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('mousedown', (e) => e.preventDefault());
|
||||
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; });
|
||||
item.addEventListener('mouseleave', () => { item.style.background = ''; });
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
item.addEventListener("click", () =>
|
||||
this._showDetail(modal, sn, closeFn),
|
||||
);
|
||||
item.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
item.addEventListener("mouseenter", () => {
|
||||
item.style.background = "var(--bg-hover)";
|
||||
});
|
||||
item.addEventListener("mouseleave", () => {
|
||||
item.style.background = "";
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_showDetail(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector('.snippet-detail-panel');
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const nameEl = createElement('div', '', {
|
||||
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
|
||||
const nameEl = createElement("div", "", {
|
||||
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 descEl = createElement('div', '', {
|
||||
textContent: snippet.description,
|
||||
});
|
||||
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);';
|
||||
detailPanel.appendChild(descEl);
|
||||
const cmdEl = createElement("pre", "", {
|
||||
textContent: snippet.command,
|
||||
});
|
||||
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(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', '', {
|
||||
textContent: snippet.command,
|
||||
});
|
||||
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(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);
|
||||
actions.appendChild(sendBtn);
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
detailPanel.appendChild(actions);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_showEditor(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector('.snippet-detail-panel');
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const isNew = !snippet;
|
||||
const title = isNew ? 'New Snippet' : 'Edit Snippet';
|
||||
const isNew = !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 class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<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 class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<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 class="form-group" style="flex:1;display:flex;flex-direction:column;">
|
||||
<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 class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<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 style="display:flex;gap:var(--space-2);">
|
||||
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
|
||||
@@ -357,60 +383,72 @@ class SnippetLibrary {
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => {
|
||||
const name = detailPanel.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();
|
||||
detailPanel
|
||||
.querySelector(".snippet-save-btn")
|
||||
.addEventListener("click", async () => {
|
||||
const name = detailPanel
|
||||
.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) {
|
||||
this.app.notifications.error('Name is required');
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
this.app.notifications.error('Command is required');
|
||||
return;
|
||||
if (!name) {
|
||||
this.app.notifications.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
this.app.notifications.error("Command is required");
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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', () => {
|
||||
if (snippet) {
|
||||
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>';
|
||||
}
|
||||
detailPanel
|
||||
.querySelector(".snippet-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
if (snippet) {
|
||||
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
@@ -1,38 +1,38 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — application state management.
|
||||
* Provides a centralised store for profiles, sessions, and preferences,
|
||||
* emitting change events so the UI stays in sync.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — application state management.
|
||||
* Provides a centralised store for profiles, sessions, and preferences,
|
||||
* emitting change events so the UI stays in sync.
|
||||
*/
|
||||
|
||||
class State {
|
||||
class State {
|
||||
constructor() {
|
||||
/** @type {Array<Object>} */
|
||||
this.profiles = [];
|
||||
/** @type {Array<Object>} */
|
||||
this.profiles = [];
|
||||
|
||||
/** @type {Map<string, Object>} sessionId → SessionInfo */
|
||||
this.sessions = new Map();
|
||||
/** @type {Map<string, Object>} sessionId → SessionInfo */
|
||||
this.sessions = new Map();
|
||||
|
||||
/** @type {string|null} */
|
||||
this.activeSessionId = null;
|
||||
/** @type {string|null} */
|
||||
this.activeSessionId = null;
|
||||
|
||||
/** @type {Object} */
|
||||
this.preferences = {};
|
||||
/** @type {Object} */
|
||||
this.preferences = {};
|
||||
|
||||
/** @type {boolean} */
|
||||
this.secretStoreUnlocked = false;
|
||||
/** @type {boolean} */
|
||||
this.secretStoreUnlocked = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.secretStoreInitialised = false;
|
||||
/** @type {boolean} */
|
||||
this.secretStoreInitialised = false;
|
||||
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
|
||||
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
|
||||
this.broadcastTargets = null;
|
||||
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
|
||||
this.broadcastTargets = null;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
// ── Event emitter ────────────────────────────────────────────────
|
||||
@@ -43,10 +43,10 @@ class State {
|
||||
* @param {Function} callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
this._listeners.get(event).add(callback);
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
this._listeners.get(event).add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,10 +55,10 @@ class State {
|
||||
* @param {Function} callback
|
||||
*/
|
||||
off(event, callback) {
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
}
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,16 +67,16 @@ class State {
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
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 ─────────────────────────────────────────────────────
|
||||
@@ -86,8 +86,8 @@ class State {
|
||||
* @param {Array<Object>} profiles
|
||||
*/
|
||||
setProfiles(profiles) {
|
||||
this.profiles = profiles;
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.profiles = profiles;
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,9 +95,9 @@ class State {
|
||||
* @param {Object} profile
|
||||
*/
|
||||
addProfile(profile) {
|
||||
this.profiles.push(profile);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:added', profile);
|
||||
this.profiles.push(profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:added", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,12 +105,12 @@ class State {
|
||||
* @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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +118,9 @@ class State {
|
||||
* @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);
|
||||
this.profiles = this.profiles.filter((p) => p.id !== id);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +129,7 @@ class State {
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
getProfile(id) {
|
||||
return this.profiles.find((p) => p.id === id);
|
||||
return this.profiles.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────
|
||||
@@ -139,9 +139,9 @@ class State {
|
||||
* @param {Object} sessionInfo
|
||||
*/
|
||||
addSession(sessionInfo) {
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:added', sessionInfo);
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:added", sessionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,12 +150,12 @@ class State {
|
||||
* @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);
|
||||
}
|
||||
const existing = this.sessions.get(id);
|
||||
if (existing) {
|
||||
Object.assign(existing, updates);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:updated", existing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,14 +163,15 @@ class State {
|
||||
* @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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +180,7 @@ class State {
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
getSession(id) {
|
||||
return this.sessions.get(id);
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,8 +188,8 @@ class State {
|
||||
* @param {string|null} id
|
||||
*/
|
||||
setActiveSession(id) {
|
||||
this.activeSessionId = id;
|
||||
this.emit('session:activated', id);
|
||||
this.activeSessionId = id;
|
||||
this.emit("session:activated", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +197,7 @@ class State {
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
getAllSessions() {
|
||||
return [...this.sessions.values()];
|
||||
return [...this.sessions.values()];
|
||||
}
|
||||
|
||||
// ── Preferences ──────────────────────────────────────────────────
|
||||
@@ -206,8 +207,8 @@ class State {
|
||||
* @param {Object} prefs
|
||||
*/
|
||||
setPreferences(prefs) {
|
||||
this.preferences = prefs;
|
||||
this.emit('preferences:changed', this.preferences);
|
||||
this.preferences = prefs;
|
||||
this.emit("preferences:changed", this.preferences);
|
||||
}
|
||||
|
||||
// ── Secret store ─────────────────────────────────────────────────
|
||||
@@ -217,8 +218,8 @@ class State {
|
||||
* @param {boolean} unlocked
|
||||
*/
|
||||
setSecretStoreState(unlocked) {
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit('secretStore:changed', unlocked);
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit("secretStore:changed", unlocked);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,12 +227,11 @@ class State {
|
||||
* @param {boolean} initialised
|
||||
*/
|
||||
setSecretStoreInitialised(initialised) {
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit('secretStore:init', initialised);
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit("secretStore:init", initialised);
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
})();
|
||||
|
||||
+54
-16
@@ -574,6 +574,17 @@
|
||||
action: () => this._showPortForwardPanel(sessionId),
|
||||
},
|
||||
{ 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",
|
||||
action: () => this._exportTerminalOutput(sessionId, term),
|
||||
@@ -1236,7 +1247,9 @@
|
||||
document.body.removeChild(a);
|
||||
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 session = this.app.state.getSession(sessionId);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1297,24 +1312,30 @@
|
||||
};
|
||||
|
||||
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 self = this;
|
||||
|
||||
const renderForwards = async () => {
|
||||
try {
|
||||
const forwards = await window.go.api.Service.ListPortForwards(sessionId) || [];
|
||||
const forwards =
|
||||
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
|
||||
listEl.innerHTML = "";
|
||||
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;
|
||||
}
|
||||
for (const fwd of forwards) {
|
||||
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 dirColor = fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
|
||||
const dirColor =
|
||||
fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
|
||||
row.innerHTML = `
|
||||
<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;">
|
||||
@@ -1322,21 +1343,26 @@
|
||||
</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 () => {
|
||||
try {
|
||||
await window.go.api.Service.StopPortForward(sessionId, fwd.id);
|
||||
self.app.notifications.info("Forward stopped");
|
||||
renderForwards();
|
||||
} 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);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
} 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
|
||||
modal.querySelector(".pf-add-btn").addEventListener("click", async () => {
|
||||
const direction = modal.querySelector(".pf-direction").value;
|
||||
const bindHost = modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
|
||||
const bindPort = parseInt(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 bindHost =
|
||||
modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
|
||||
const bindPort = parseInt(
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1373,7 +1409,9 @@
|
||||
modal.querySelector(".pf-label").value = "";
|
||||
renderForwards();
|
||||
} catch (err) {
|
||||
self.app.notifications.error("Failed to start forward: " + (err.message || err));
|
||||
self.app.notifications.error(
|
||||
"Failed to start forward: " + (err.message || err),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user