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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
@@ -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
@@ -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">×</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 = () => {
|
||||
|
||||
+261
-31
@@ -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,65 +476,109 @@
|
||||
|
||||
/* ── 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) {
|
||||
state.selected = null;
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
if (ctxBar) ctxBar.style.display = "none";
|
||||
return;
|
||||
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.selectedFiles.clear();
|
||||
state.selectedFiles.set(entry.path, entry);
|
||||
rowEl.classList.add("is-selected");
|
||||
}
|
||||
|
||||
state.selected = entry.path;
|
||||
rowEl.classList.add("is-selected");
|
||||
this._showContextBar(sessionId, entry);
|
||||
state.selected = state.selectedFiles.size > 0 ? entry.path : null;
|
||||
this._showContextBar(sessionId, null);
|
||||
}
|
||||
|
||||
_showContextBar(sessionId, entry) {
|
||||
_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);
|
||||
}
|
||||
|
||||
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||
textContent: "✎ Rename",
|
||||
});
|
||||
renameBtn.addEventListener("click", function () {
|
||||
self._promptRename(sessionId, entry);
|
||||
});
|
||||
ctxBar.appendChild(renameBtn);
|
||||
if (isSingle) {
|
||||
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||
textContent: "✎ Rename",
|
||||
});
|
||||
renameBtn.addEventListener("click", function () {
|
||||
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 () {
|
||||
self._confirmDelete(sessionId, entry);
|
||||
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 ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">×</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 */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+10
-3
@@ -116,10 +116,17 @@
|
||||
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.focusTerminal(sessionId);
|
||||
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
@@ -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">×</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}
|
||||
|
||||
Vendored
+2
@@ -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>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user