From 96c603f3f48bef178df498a9ba5996b80ca75b4b Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 21 May 2026 11:28:37 +0100 Subject: [PATCH] Add context menus and SFTP transfer progress handling - Implemented context menu styles in components.css. - Added SFTP transfer progress queue and related styles in sftp.css. - Enhanced app.js to handle SFTP transfer progress events. - Updated sftp.js to manage transfer progress and render the transfer queue. - Modified terminal.js to include context menu shortcuts. - Adjusted manager.go to emit SFTP transfer progress events with direction. - Updated sshclient.go to modify terminal modes for better performance. --- frontend/src/css/components.css | 78 +++++++++++++++++++++++ frontend/src/css/sftp.css | 71 +++++++++++++++++++++ frontend/src/js/app.js | 8 +++ frontend/src/js/sftp.js | 107 ++++++++++++++++++++++++++++++++ frontend/src/js/terminal.js | 19 +++++- internal/sessions/manager.go | 3 + internal/sshclient/sshclient.go | 5 +- 7 files changed, 288 insertions(+), 3 deletions(-) diff --git a/frontend/src/css/components.css b/frontend/src/css/components.css index ee7ff80..236c948 100644 --- a/frontend/src/css/components.css +++ b/frontend/src/css/components.css @@ -899,3 +899,81 @@ @keyframes spin { to { transform: rotate(360deg); } } + +/* ======================================================================= + CONTEXT MENUS + ======================================================================= */ + +.context-menu { + position: fixed; + z-index: var(--z-modal, 1000); + min-width: 180px; + max-width: 260px; + padding: var(--space-1) 0; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.35), + 0 1px 4px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.context-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 6px var(--space-3); + border: none; + background: none; + color: var(--text-primary); + font-size: var(--text-sm); + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); + gap: var(--space-3); +} + +.context-menu-item:hover { + background: var(--bg-hover); +} + +.context-menu-item:active { + background: var(--bg-active); +} + +.context-menu-item.context-menu-disabled { + color: var(--text-disabled); + cursor: not-allowed; +} + +.context-menu-item.context-menu-disabled:hover { + background: none; +} + +.context-menu-item.context-menu-danger { + color: var(--error); +} + +.context-menu-item.context-menu-danger:hover { + background: rgba(239, 83, 80, 0.1); +} + +.context-menu-label { + flex: 1; + min-width: 0; +} + +.context-menu-shortcut { + font-size: 11px; + color: var(--text-tertiary); + flex-shrink: 0; + margin-left: var(--space-3); +} + +.context-menu-divider { + height: 1px; + margin: var(--space-1) 0; + background: var(--border-subtle); +} diff --git a/frontend/src/css/sftp.css b/frontend/src/css/sftp.css index 902dcab..88af7eb 100644 --- a/frontend/src/css/sftp.css +++ b/frontend/src/css/sftp.css @@ -419,3 +419,74 @@ font-size: 10px; color: var(--text-tertiary); } + +/* ----------------------------------------------------------------------- + Transfer progress queue + ----------------------------------------------------------------------- */ +.sftp-transfer-queue { + flex-shrink: 0; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-elevated); + max-height: 140px; + overflow-y: auto; +} + +.sftp-transfer-header { + font-size: 10px; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); + padding: var(--space-1) var(--space-2) 0; +} + +.sftp-transfer-row { + display: flex; + align-items: center; + gap: var(--space-1); + padding: 4px var(--space-2); +} + +.sftp-transfer-icon { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--accent); + flex-shrink: 0; + width: 16px; + text-align: center; +} + +.sftp-transfer-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sftp-transfer-name { + font-size: var(--text-xs); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sftp-transfer-progress { + height: 3px; + background: var(--border-subtle); + border-radius: 2px; + overflow: hidden; +} + +.sftp-transfer-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.15s ease-out; +} + +.sftp-transfer-meta { + font-size: 10px; + color: var(--text-tertiary); +} diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index c2c9760..889349a 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -213,9 +213,12 @@ } } // Also re-fit to catch any layout changes that occurred while connecting. + // Multiple delays help byobu/tmux render correctly after its status + // bar initialisation completes. const fit = () => this.terminals.fitSession(payload.sessionID); setTimeout(fit, 50); setTimeout(fit, 300); + setTimeout(fit, 800); } }); @@ -319,6 +322,11 @@ } }); + // SFTP transfer progress events + window.runtime.EventsOn("session:sftp:progress", (payload) => { + this.sftp.handleTransferProgress(payload); + }); + // Password/passphrase prompt requests from the backend window.runtime.EventsOn("session:secret:prompt", async (payload) => { try { diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js index dffee06..6b78610 100644 --- a/frontend/src/js/sftp.js +++ b/frontend/src/js/sftp.js @@ -784,6 +784,113 @@ }); } + /* ── Transfer progress queue ─────────────────────────────────── */ + + /** + * Handles a transfer progress event from the backend. + * @param {Object} payload - { sessionID, direction, transferred, total, path, done } + */ + handleTransferProgress(payload) { + var state = this.panels.get(payload.sessionID); + if (!state) { + // Panel not open — still track so we can show a notification on completion + if (payload.done) { + var dir = payload.direction === "upload" ? "↑ Uploaded" : "↓ Downloaded"; + var name = payload.path.split("/").pop() || payload.path; + this.app.notifications.success(dir + " " + name); + } + return; + } + + // Initialise transfers map if needed + if (!state.transfers) state.transfers = new Map(); + + var key = payload.direction + ":" + payload.path; + + if (payload.done) { + state.transfers.delete(key); + this._renderTransferQueue(payload.sessionID); + // Refresh file list after upload completes + if (payload.direction === "upload") { + this._refresh(payload.sessionID); + } + return; + } + + state.transfers.set(key, { + direction: payload.direction, + path: payload.path, + name: payload.path.split("/").pop() || payload.path, + transferred: payload.transferred, + total: payload.total, + }); + + this._renderTransferQueue(payload.sessionID); + } + + /** + * Renders the transfer queue panel showing active transfers. + */ + _renderTransferQueue(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + + var queue = state.el.querySelector(".sftp-transfer-queue"); + + if (!state.transfers || state.transfers.size === 0) { + if (queue) queue.remove(); + return; + } + + if (!queue) { + queue = createElement("div", "sftp-transfer-queue"); + // Insert between toolbar and file-list + var fileList = state.el.querySelector(".sftp-file-list"); + state.el.insertBefore(queue, fileList); + } + + queue.innerHTML = ""; + + var header = createElement("div", "sftp-transfer-header"); + header.textContent = "Transfers (" + state.transfers.size + ")"; + queue.appendChild(header); + + var self = this; + state.transfers.forEach(function (transfer) { + var row = createElement("div", "sftp-transfer-row"); + + var icon = createElement("span", "sftp-transfer-icon"); + icon.textContent = transfer.direction === "upload" ? "↑" : "↓"; + row.appendChild(icon); + + var info = createElement("div", "sftp-transfer-info"); + + var nameEl = createElement("span", "sftp-transfer-name", { + textContent: transfer.name, + title: transfer.path, + }); + info.appendChild(nameEl); + + var progress = createElement("div", "sftp-transfer-progress"); + var bar = createElement("div", "sftp-transfer-bar"); + var pct = transfer.total > 0 + ? Math.min(100, Math.round((transfer.transferred / transfer.total) * 100)) + : 0; + bar.style.width = pct + "%"; + progress.appendChild(bar); + info.appendChild(progress); + + var meta = createElement("span", "sftp-transfer-meta"); + var transferredStr = formatBytes(transfer.transferred); + var totalStr = transfer.total > 0 ? formatBytes(transfer.total) : "?"; + meta.textContent = transferredStr + " / " + totalStr + " (" + pct + "%)"; + info.appendChild(meta); + + row.appendChild(info); + queue.appendChild(row); + }); + } + /* ── Resize handle ────────────────────────────────────────────── */ _setupResize(sessionId, state) { diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 00df4fb..dc787e6 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -47,6 +47,8 @@ allowProposedApi: true, convertEol: false, windowsMode: false, + scrollOnUserInput: true, + altClickMovesCursor: true, }); // Load addons @@ -504,6 +506,8 @@ const { createElement, dismissContextMenus } = window.NexusTerminal; dismissContextMenus(); + const isMac = navigator.platform.indexOf("Mac") !== -1; + const modKey = isMac ? "⌘" : "Ctrl+"; const hasSelection = term.hasSelection(); const menu = createElement("div", "context-menu"); menu.style.left = `${x}px`; @@ -512,6 +516,7 @@ const items = [ { label: "Copy", + shortcut: modKey + "C", disabled: !hasSelection, action: () => { const text = term.getSelection(); @@ -527,6 +532,7 @@ }, { label: "Paste", + shortcut: modKey + "V", action: () => { if (window.runtime && window.runtime.ClipboardGetText) { window.runtime @@ -548,10 +554,12 @@ { divider: true }, { label: "Select All", + shortcut: modKey + "A", action: () => term.selectAll(), }, { label: "Find…", + shortcut: modKey + "F", action: () => this.app._openTerminalSearch(), }, { @@ -561,6 +569,7 @@ { divider: true }, { label: "Clear Terminal", + shortcut: modKey + "K", action: () => term.clear(), }, { @@ -574,9 +583,17 @@ menu.appendChild(createElement("div", "context-menu-divider")); continue; } - const btn = createElement("button", "context-menu-item", { + const btn = createElement("button", "context-menu-item"); + const labelSpan = createElement("span", "context-menu-label", { textContent: entry.label, }); + btn.appendChild(labelSpan); + if (entry.shortcut) { + const shortcutSpan = createElement("span", "context-menu-shortcut", { + textContent: entry.shortcut, + }); + btn.appendChild(shortcutSpan); + } if (entry.disabled) { btn.disabled = true; btn.classList.add("context-menu-disabled"); diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 1f6695a..19da74a 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -1069,6 +1069,7 @@ func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error { return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, + "direction": "download", "transferred": p.Transferred, "total": p.Total, "path": p.Path, @@ -1086,6 +1087,7 @@ func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error { return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, + "direction": "upload", "transferred": p.Transferred, "total": p.Total, "path": p.Path, @@ -1103,6 +1105,7 @@ func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) err return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, + "direction": "upload", "transferred": p.Transferred, "total": p.Total, "path": p.Path, diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index cad7962..9324a3c 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -159,8 +159,9 @@ func (c *Client) connectInternal( rows := 24 modes := ssh.TerminalModes{ ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, + ssh.TTY_OP_ISPEED: 115200, + ssh.TTY_OP_OSPEED: 115200, + ssh.IUTF8: 1, } if err := session.RequestPty(termType, rows, cols, modes); err != nil {