feat: add latency indicator and broadcast target selection

- Added a latency indicator to the status bar, displaying round-trip time for SSH sessions.
- Implemented functionality to select specific sessions for broadcasting input, allowing users to manage broadcast targets.
- Enhanced SFTP functionality with bookmark management, enabling users to bookmark directories and navigate to them easily.
- Introduced a modal for user-defined snippet variables, allowing dynamic command execution with placeholders.
- Added port forwarding management for SSH sessions, including the ability to start and stop port forwards.
- Improved terminal output export functionality, allowing users to download or copy terminal output to clipboard.
This commit is contained in:
Andy Dixon
2026-05-21 12:09:43 +01:00
parent c9b5eb10eb
commit 421bdb9ee8
14 changed files with 866 additions and 45 deletions
+1
View File
@@ -267,6 +267,7 @@
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-latency"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<button
+14
View File
@@ -169,6 +169,20 @@
background: var(--text-tertiary);
}
/* Latency indicator */
#status-latency {
font-variant-numeric: tabular-nums;
}
#status-latency.latency-good {
color: var(--success);
}
#status-latency.latency-warn {
color: var(--warning);
}
#status-latency.latency-bad {
color: var(--error);
}
/* -----------------------------------------------------------------------
Resize handle (between sidebar and workspace)
----------------------------------------------------------------------- */
+22
View File
@@ -490,3 +490,25 @@
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Bookmark list
----------------------------------------------------------------------- */
.sftp-bookmark-list {
max-height: 250px;
overflow-y: auto;
}
.sftp-bookmark-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.sftp-bookmark-row:hover {
background: var(--bg-hover);
}
+207 -10
View File
@@ -515,6 +515,15 @@
this._bindClick("btn-recordings", () => this.recordings.openBrowser());
this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
this._bindClick("btn-broadcast", () => this.toggleBroadcast());
// Right-click on broadcast button opens target selector
const broadcastBtn = document.getElementById("btn-broadcast");
if (broadcastBtn) {
broadcastBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
this.selectBroadcastTargets();
});
}
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
this._bindClick("btn-empty-quick", () => this.quickConnect());
@@ -573,6 +582,9 @@
*/
toggleBroadcast() {
this.state.broadcastMode = !this.state.broadcastMode;
if (!this.state.broadcastMode) {
this.state.broadcastTargets = null;
}
const btn = document.getElementById("btn-broadcast");
if (btn) {
btn.classList.toggle("is-active", this.state.broadcastMode);
@@ -587,6 +599,100 @@
);
}
/**
* Opens a modal to select which sessions receive broadcast input.
*/
selectBroadcastTargets() {
const { createElement, escapeHtml } = window.NexusTerminal;
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");
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
const currentTargets = this.state.broadcastTargets || new Set();
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" : "";
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} />
<span style="color:var(--text-primary);">${escapeHtml(label)}</span>
</label>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Broadcast Targets</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-2);">
Select which sessions should receive broadcast input:
</p>
${listHtml}
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost broadcast-cancel">Cancel</button>
<button class="btn btn-sm btn-secondary broadcast-all">Select All</button>
<button class="btn btn-sm btn-primary broadcast-apply">Apply & Enable</button>
</div>
`;
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);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector(".broadcast-cancel").addEventListener("click", 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.querySelector(".broadcast-apply").addEventListener("click", () => {
const checks = modal.querySelectorAll(".broadcast-target-check");
const selected = new Set();
checks.forEach((cb) => {
if (cb.checked) selected.add(cb.dataset.sessionId);
});
if (selected.size === 0) {
this.notifications.error("Select at least one target session");
return;
}
// If all are selected, use null (broadcast to all)
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";
this.notifications.info("Broadcasting to " + targetLabel);
}
close();
});
}
// ── Connection lifecycle ─────────────────────────────────────────
/**
@@ -873,6 +979,7 @@
updateStatusBar() {
const connEl = document.getElementById("status-connection");
const countEl = document.getElementById("status-session-count");
const latencyEl = document.getElementById("status-latency");
const vaultEl = document.getElementById("status-vault");
const updateEl = document.getElementById("status-update");
if (!connEl) return;
@@ -892,6 +999,16 @@
'<span class="status-dot is-disconnected"></span> No active session';
}
// Latency indicator — only show for connected SSH sessions.
if (latencyEl) {
if (activeSession && activeSession.state === "connected" && activeSession.protocol === "ssh") {
this._startLatencyPing(activeSession.id);
} else {
this._stopLatencyPing();
latencyEl.textContent = "";
}
}
if (countEl) countEl.textContent = `${connected}/${total} sessions`;
if (vaultEl) {
vaultEl.textContent = this.state.secretStoreUnlocked
@@ -913,6 +1030,41 @@
}
}
_startLatencyPing(sessionID) {
// Don't restart if already pinging the same session.
if (this._latencySessionID === sessionID && this._latencyTimer) return;
this._stopLatencyPing();
this._latencySessionID = sessionID;
const doPing = async () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
try {
const ms = await window.go.api.Service.PingSession(sessionID);
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";
}
} catch (_) {
// Session may have disconnected — clear indicator.
const el = document.getElementById("status-latency");
if (el) { el.textContent = ""; el.className = ""; }
this._stopLatencyPing();
}
};
doPing();
this._latencyTimer = setInterval(doPing, 10000);
}
_stopLatencyPing() {
if (this._latencyTimer) {
clearInterval(this._latencyTimer);
this._latencyTimer = null;
}
this._latencySessionID = null;
}
async checkForUpdates(silent) {
if (!window.go || !window.go.api || !window.go.api.Service) {
return;
@@ -1191,29 +1343,74 @@
const searchOpts = () => ({ caseSensitive, regex: useRegex });
let matchIndex = 0;
let totalMatches = 0;
const updateCount = (found) => {
if (!input.value) {
count.textContent = "";
matchIndex = 0;
totalMatches = 0;
return;
}
if (found) {
matchIndex++;
// Count total matches by searching the full buffer
const entry = this.terminals.terminals.get(sessionId);
if (entry) {
const buffer = entry.term.buffer.active;
let total = 0;
const query = input.value;
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (useRegex) {
try {
const re = new RegExp(query, caseSensitive ? "g" : "gi");
const m = text.match(re);
if (m) total += m.length;
} catch (_) { /* invalid regex */ }
} else {
const searchText = caseSensitive ? text : text.toLowerCase();
const searchQuery = caseSensitive ? query : query.toLowerCase();
let pos = 0;
while ((pos = searchText.indexOf(searchQuery, pos)) !== -1) {
total++;
pos += searchQuery.length;
}
}
}
totalMatches = total;
if (matchIndex > totalMatches) matchIndex = 1;
count.textContent = matchIndex + " of " + totalMatches;
} else {
count.textContent = "Match found";
}
} else {
matchIndex = 0;
totalMatches = 0;
count.textContent = "No matches";
}
count.style.color = found ? "" : "var(--error)";
};
const doSearch = () => {
const found = this.terminals.search(
sessionId,
input.value,
searchOpts(),
);
count.textContent = input.value
? found
? "Match found"
: "No matches"
: "";
updateCount(found);
};
const doPrev = () => {
matchIndex = Math.max(0, matchIndex - 2);
const found = this.terminals.searchPrevious(
sessionId,
input.value,
searchOpts(),
);
count.textContent = input.value
? found
? "Match found"
: "No matches"
: "";
updateCount(found);
};
const closeSearch = () => {
+248 -18
View File
@@ -231,6 +231,14 @@
var actions = createElement("div", "sftp-toolbar-actions");
var goToBtn = this._toolbarBtn("⤳", "Go to path", "");
goToBtn.addEventListener("click", () => this._promptGoToPath(sessionId));
actions.appendChild(goToBtn);
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
bookmarkBtn.addEventListener("click", () => this._showBookmarks(sessionId));
actions.appendChild(bookmarkBtn);
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
actions.appendChild(mkdirBtn);
@@ -426,7 +434,7 @@
if (!isParent) {
row.addEventListener("click", function (e) {
e.stopPropagation();
self._selectFile(sessionId, entry, row);
self._selectFile(sessionId, entry, row, e);
});
}
@@ -468,52 +476,90 @@
/* ── Selection & context actions ──────────────────────────────── */
_selectFile(sessionId, entry, rowEl) {
_selectFile(sessionId, entry, rowEl, event) {
var state = this.panels.get(sessionId);
if (!state) return;
// Deselect previous
var prev = state.el.querySelector(".sftp-file-row.is-selected");
if (prev) prev.classList.remove("is-selected");
// Initialise multi-select set if needed
if (!state.selectedFiles) state.selectedFiles = new Map();
// If clicking the same entry, deselect
if (state.selected === entry.path) {
var multiKey = event && (event.metaKey || event.ctrlKey);
if (multiKey) {
// Toggle this entry in the multi-selection
if (state.selectedFiles.has(entry.path)) {
state.selectedFiles.delete(entry.path);
rowEl.classList.remove("is-selected");
} else {
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
} 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");
});
// If clicking the same entry (and it was the only selection), deselect
if (state.selectedFiles.size === 1 && state.selectedFiles.has(entry.path)) {
state.selectedFiles.clear();
state.selected = null;
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
return;
}
state.selected = entry.path;
state.selectedFiles.clear();
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
this._showContextBar(sessionId, entry);
}
_showContextBar(sessionId, entry) {
state.selected = state.selectedFiles.size > 0 ? entry.path : null;
this._showContextBar(sessionId, null);
}
_showContextBar(sessionId, _unused) {
var state = this.panels.get(sessionId);
if (!state) return;
var ctxBar = this._getCtxBar(sessionId);
if (!ctxBar) return;
if (!state.selectedFiles || state.selectedFiles.size === 0) {
ctxBar.style.display = "none";
return;
}
ctxBar.innerHTML = "";
ctxBar.style.display = "flex";
var self = this;
var entries = Array.from(state.selectedFiles.values());
var count = entries.length;
var isSingle = count === 1;
var entry = entries[0];
var label = createElement("span", "sftp-ctx-label", {
textContent: entry.name,
title: entry.path,
textContent: isSingle ? entry.name : count + " items selected",
title: isSingle ? entry.path : entries.map(function (e) { return e.name; }).join(", "),
});
ctxBar.appendChild(label);
var self = this;
if (!entry.isDir) {
// Download button — only for files (single or batch)
var downloadableFiles = entries.filter(function (e) { return !e.isDir; });
if (downloadableFiles.length > 0) {
var dlLabel = downloadableFiles.length === 1
? "↓ Download"
: "↓ Download (" + downloadableFiles.length + ")";
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
textContent: "↓ Download",
textContent: dlLabel,
});
dlBtn.addEventListener("click", function () {
self._download(sessionId, entry.path);
self._downloadBatch(sessionId, downloadableFiles);
});
ctxBar.appendChild(dlBtn);
}
if (isSingle) {
var renameBtn = createElement("button", "sftp-ctx-btn", {
textContent: "✎ Rename",
});
@@ -521,12 +567,18 @@
self._promptRename(sessionId, entry);
});
ctxBar.appendChild(renameBtn);
}
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
textContent: "✕ Delete",
textContent: delLabel,
});
delBtn.addEventListener("click", function () {
if (isSingle) {
self._confirmDelete(sessionId, entry);
} else {
self._confirmDeleteBatch(sessionId, entries);
}
});
ctxBar.appendChild(delBtn);
}
@@ -598,6 +650,54 @@
}
}
async _downloadBatch(sessionId, files) {
this.app.notifications.info("Downloading " + files.length + " files…");
var failed = 0;
for (var i = 0; i < files.length; i++) {
try {
await window.go.api.Service.SFTPDownload(sessionId, files[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Download failed: " + files[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
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>?",
confirmLabel: "Delete All",
isDanger: true,
onConfirm: function () {
this._deleteBatch(sessionId, entries);
}.bind(this),
});
}
async _deleteBatch(sessionId, entries) {
var failed = 0;
for (var i = 0; i < entries.length; i++) {
try {
await window.go.api.Service.SFTPRemove(sessionId, entries[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Delete failed: " + entries[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success("Deleted " + entries.length + " items");
}
await this._refresh(sessionId);
}
/* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */
_promptMkdir(sessionId) {
@@ -784,6 +884,136 @@
});
}
/* ── Go to path & Bookmarks ───────────────────────────────────── */
_promptGoToPath(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
this._showInputModal({
title: "Go to Path",
placeholder: "/path/to/directory",
initialValue: state.cwd,
confirmLabel: "Navigate",
onConfirm: function (value) {
var path = value.trim();
if (path) this._navigate(sessionId, path);
}.bind(this),
});
}
_showBookmarks(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var self = this;
var bookmarks = this._getBookmarks(sessionId);
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm");
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>';
} 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>';
}
}
modal.innerHTML =
'<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 class="modal-body">' +
'<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>';
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
var closed = false;
function close() {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
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(); });
// Navigate to bookmark on click
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
row.addEventListener("click", function (e) {
if (e.target.classList.contains("sftp-bookmark-del")) return;
var path = row.dataset.path;
close();
self._navigate(sessionId, path);
});
row.addEventListener("mousedown", function (e) { e.preventDefault(); });
});
// Delete bookmark
modal.querySelectorAll(".sftp-bookmark-del").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
var idx = parseInt(btn.dataset.index, 10);
self._removeBookmark(sessionId, idx);
close();
self._showBookmarks(sessionId);
});
});
// Add current directory as bookmark
modal.querySelector(".sftp-bookmark-add").addEventListener("click", function () {
self._addBookmark(sessionId, state.cwd);
close();
self._showBookmarks(sessionId);
});
}
_getBookmarks(sessionId) {
try {
var key = "sftp-bookmarks-" + sessionId;
var stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (_) {
return [];
}
}
_addBookmark(sessionId, path) {
var bookmarks = this._getBookmarks(sessionId);
if (bookmarks.indexOf(path) === -1) {
bookmarks.push(path);
localStorage.setItem("sftp-bookmarks-" + sessionId, JSON.stringify(bookmarks));
this.app.notifications.success("Bookmarked: " + path);
} else {
this.app.notifications.info("Already bookmarked");
}
}
_removeBookmark(sessionId, index) {
var bookmarks = this._getBookmarks(sessionId);
bookmarks.splice(index, 1);
localStorage.setItem("sftp-bookmarks-" + sessionId, JSON.stringify(bookmarks));
}
/* ── Transfer progress queue ─────────────────────────────────── */
/**
+110
View File
@@ -29,6 +29,8 @@ class SnippetLibrary {
/**
* Sends a snippet command to the active session.
* Supports {{variable}} placeholders that prompt the user for values.
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
* @param {string} command
*/
sendToActiveSession(command) {
@@ -42,6 +44,51 @@ class SnippetLibrary {
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());
}
// 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);
}
}
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 */
_sendCommand(sessionId, command) {
try {
const b64 = btoa(command + '\n');
window.go.api.Service.WriteSession(sessionId, b64);
@@ -51,6 +98,69 @@ class SnippetLibrary {
}
}
/** @private */
_promptVariables(varNames, onConfirm) {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
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 = `
<div class="modal-header">
<h3 class="modal-title">Snippet Variables</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">${inputsHtml}</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
</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();
});
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('.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();
});
}
// ── Data ─────────────────────────────────────────────────────────
/** @private */
+3
View File
@@ -28,6 +28,9 @@ class State {
/** @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 {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
}
+9 -2
View File
@@ -116,11 +116,18 @@
this.app.sidebar.highlightActive(session.profileId);
}
// Fit the terminal for the newly active session
this.app.terminals.fitActive();
// Fit and refresh the terminal for the newly active session.
// Double-RAF ensures the panel has layout dimensions before fitting.
requestAnimationFrame(() => {
this.app.terminals.fitSession(sessionId);
const entry = this.app.terminals.terminals.get(sessionId);
if (entry) {
entry.term.refresh(0, entry.term.rows - 1);
}
requestAnimationFrame(() => {
this.app.terminals.focusTerminal(sessionId);
});
});
}
/**
+193 -1
View File
@@ -152,10 +152,13 @@
const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64);
// Broadcast to all other connected sessions if enabled
// Broadcast to other connected sessions if enabled
if (this.app.state.broadcastMode) {
const targets = this.app.state.broadcastTargets;
for (const [otherId] of this.terminals) {
if (otherId === sessionId) continue;
// If targets are set, only broadcast to selected sessions
if (targets && !targets.has(otherId)) continue;
const otherSession = this.app.state.getSession(otherId);
if (otherSession && otherSession.state === "connected") {
try {
@@ -566,7 +569,15 @@
label: "Send Snippet…",
action: () => this.app.snippets.open(),
},
{
label: "Port Forwards…",
action: () => this._showPortForwardPanel(sessionId),
},
{ divider: true },
{
label: "Export Output",
action: () => this._exportTerminalOutput(sessionId, term),
},
{
label: "Clear Terminal",
shortcut: modKey + "K",
@@ -1186,6 +1197,187 @@
return btoa(binary);
}
/**
* Exports the terminal scrollback buffer as plain text.
* @param {string} sessionId
* @param {Terminal} term
* @private
*/
_exportTerminalOutput(sessionId, term) {
const { createElement } = window.NexusTerminal;
const buffer = term.buffer.active;
const lines = [];
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) lines.push(line.translateToString(true));
}
const text = lines.join("\n").trimEnd();
// Use Wails save dialog if available
if (window.runtime && window.runtime.SaveFileDialog) {
// Copy to clipboard as a fallback
}
// Copy to clipboard
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(text);
} else {
navigator.clipboard.writeText(text).catch(function () {});
}
// Also offer download via blob
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "terminal-output-" + sessionId.substring(0, 8) + ".txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.app.notifications.success("Terminal output exported & copied to clipboard");
}
/**
* Shows the port forwarding management panel for an SSH session.
* @param {string} sessionId
* @private
*/
_showPortForwardPanel(sessionId) {
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");
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Port Forwards</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body" style="min-height:200px;">
<div class="portfwd-list" style="margin-bottom:var(--space-3);"></div>
<div class="portfwd-add" style="border-top:1px solid var(--border-subtle);padding-top:var(--space-3);">
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);margin-bottom:var(--space-2);">Add New Forward</div>
<div style="display:grid;grid-template-columns:auto 1fr 60px 20px 1fr 60px;gap:var(--space-2);align-items:center;">
<select class="form-input pf-direction" style="height:28px;font-size:var(--text-xs);">
<option value="local">Local →</option>
<option value="remote">Remote ←</option>
</select>
<input class="form-input pf-bind-host" type="text" placeholder="127.0.0.1" value="127.0.0.1" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-bind-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
<span style="text-align:center;color:var(--text-tertiary);">→</span>
<input class="form-input pf-dest-host" type="text" placeholder="localhost" value="localhost" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-dest-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);">
<input class="form-input pf-label" type="text" placeholder="Label (optional)" style="flex:1;height:28px;font-size:var(--text-xs);" />
<button class="btn btn-sm btn-primary pf-add-btn">Start Forward</button>
</div>
</div>
</div>
`;
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);
};
modal.querySelector(".modal-close").addEventListener("click", 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) || [];
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>';
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);";
const dirLabel = fwd.direction === "local" ? "L" : "R";
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;">
${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort}
</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" });
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));
}
});
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>';
}
};
renderForwards();
// 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 label = modal.querySelector(".pf-label").value.trim();
if (!bindPort || !destPort) {
self.app.notifications.error("Bind port and destination port are required");
return;
}
try {
await window.go.api.Service.StartPortForward(sessionId, {
id: "",
direction: direction,
bindHost: bindHost,
bindPort: bindPort,
destHost: destHost,
destPort: destPort,
enabled: true,
label: label,
});
self.app.notifications.success("Forward started");
modal.querySelector(".pf-bind-port").value = "";
modal.querySelector(".pf-dest-port").value = "";
modal.querySelector(".pf-label").value = "";
renderForwards();
} catch (err) {
self.app.notifications.error("Failed to start forward: " + (err.message || err));
}
});
}
/**
* Builds the xterm.js colour theme.
* @returns {Object}
+2
View File
@@ -105,6 +105,8 @@ export function NewProfile(arg1:string):Promise<models.Profile>;
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
export function PingSession(arg1:string):Promise<number>;
export function ReconnectSession(arg1:string):Promise<void>;
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
+4
View File
@@ -206,6 +206,10 @@ export function PinSession(arg1, arg2) {
return window['go']['api']['Service']['PinSession'](arg1, arg2);
}
export function PingSession(arg1) {
return window['go']['api']['Service']['PingSession'](arg1);
}
export function ReconnectSession(arg1) {
return window['go']['api']['Service']['ReconnectSession'](arg1);
}
+5
View File
@@ -931,6 +931,11 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error {
return s.sessions.StopPortForward(sessionID, forwardID)
}
// PingSession sends a keepalive request and returns the round-trip time in milliseconds.
func (s *Service) PingSession(sessionID string) (int64, error) {
return s.sessions.PingSession(sessionID)
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
+16
View File
@@ -1205,6 +1205,22 @@ func (m *Manager) StopPortForward(sessionID string, forwardID string) error {
return nil
}
// PingSession sends a keepalive request to the server and returns the RTT in milliseconds.
func (m *Manager) PingSession(sessionID string) (int64, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return 0, fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.sshClient == nil {
return 0, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
}
return sess.sshClient.Ping()
}
// ---------------------------------------------------------------------------
// Internal: helpers
// ---------------------------------------------------------------------------
+18
View File
@@ -262,6 +262,24 @@ func (c *Client) Resize(cols, rows int) error {
return session.WindowChange(rows, cols)
}
// Ping sends a keepalive request and returns the round-trip time in milliseconds.
func (c *Client) Ping() (int64, error) {
c.mu.Lock()
if !c.connected || c.sshClient == nil {
c.mu.Unlock()
return 0, fmt.Errorf("not connected")
}
client := c.sshClient
c.mu.Unlock()
start := time.Now()
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
return 0, err
}
return time.Since(start).Milliseconds(), nil
}
// RunCommand executes a single command using a new SSH session.
func (c *Client) RunCommand(command string) (string, error) {
c.mu.Lock()