diff --git a/frontend/index.html b/frontend/index.html index a8ad843..379ef1d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -267,6 +267,7 @@ Disconnected + 0 sessions + + + + `; + + 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 @@ ' 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 = () => { diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js index df6adf8..b5c1e68 100644 --- a/frontend/src/js/sftp.js +++ b/frontend/src/js/sftp.js @@ -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 " + entries.length + " selected items?", + 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 = '
No bookmarks yet. Navigate to a directory and click "Bookmark Current".
'; + } else { + for (var i = 0; i < bookmarks.length; i++) { + listHtml += '
'; + listHtml += '' + escapeHtml(bookmarks[i]) + ''; + listHtml += ''; + listHtml += '
'; + } + } + + modal.innerHTML = + '' + + '' + + ''; + + 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 ─────────────────────────────────── */ /** diff --git a/frontend/src/js/snippets.js b/frontend/src/js/snippets.js index c902fef..e02784e 100644 --- a/frontend/src/js/snippets.js +++ b/frontend/src/js/snippets.js @@ -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 += ` +
+ + +
+ `; + } + + modal.innerHTML = ` + + + + `; + + 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 */ diff --git a/frontend/src/js/state.js b/frontend/src/js/state.js index b689b28..f8b0697 100644 --- a/frontend/src/js/state.js +++ b/frontend/src/js/state.js @@ -28,6 +28,9 @@ class State { /** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */ this.broadcastMode = false; + /** @type {Set|null} Session IDs to broadcast to. Null means all. */ + this.broadcastTargets = null; + /** @type {Map>} event name → callbacks */ this._listeners = new Map(); } diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index 7e193b9..79d6086 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -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); + }); }); } diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index dc787e6..4936036 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -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 = ` + + + `; + + 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 = '
No active port forwards
'; + 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 = ` + ${dirLabel} + + ${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort} + + ${fwd.label ? `${escapeHtml(fwd.label)}` : ""} + `; + 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 = '
Failed to load forwards
'; + } + }; + + 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} diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 33f97c6..7fe3def 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -105,6 +105,8 @@ export function NewProfile(arg1:string):Promise; export function PinSession(arg1:string,arg2:boolean):Promise; +export function PingSession(arg1:string):Promise; + export function ReconnectSession(arg1:string):Promise; export function RefreshGitHubRepos(arg1:string):Promise>; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index afe90fd..9bdd955 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -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); } diff --git a/internal/api/api.go b/internal/api/api.go index a7e4e22..71d3c69 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 19da74a..2d5d6fb 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index 9324a3c..35e58e0 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -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()