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.
This commit is contained in:
@@ -899,3 +899,81 @@
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -419,3 +419,74 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-tertiary);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,9 +213,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also re-fit to catch any layout changes that occurred while connecting.
|
// 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);
|
const fit = () => this.terminals.fitSession(payload.sessionID);
|
||||||
setTimeout(fit, 50);
|
setTimeout(fit, 50);
|
||||||
setTimeout(fit, 300);
|
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
|
// Password/passphrase prompt requests from the backend
|
||||||
window.runtime.EventsOn("session:secret:prompt", async (payload) => {
|
window.runtime.EventsOn("session:secret:prompt", async (payload) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────── */
|
/* ── Resize handle ────────────────────────────────────────────── */
|
||||||
|
|
||||||
_setupResize(sessionId, state) {
|
_setupResize(sessionId, state) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
|
scrollOnUserInput: true,
|
||||||
|
altClickMovesCursor: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load addons
|
// Load addons
|
||||||
@@ -504,6 +506,8 @@
|
|||||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||||
dismissContextMenus();
|
dismissContextMenus();
|
||||||
|
|
||||||
|
const isMac = navigator.platform.indexOf("Mac") !== -1;
|
||||||
|
const modKey = isMac ? "⌘" : "Ctrl+";
|
||||||
const hasSelection = term.hasSelection();
|
const hasSelection = term.hasSelection();
|
||||||
const menu = createElement("div", "context-menu");
|
const menu = createElement("div", "context-menu");
|
||||||
menu.style.left = `${x}px`;
|
menu.style.left = `${x}px`;
|
||||||
@@ -512,6 +516,7 @@
|
|||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: "Copy",
|
label: "Copy",
|
||||||
|
shortcut: modKey + "C",
|
||||||
disabled: !hasSelection,
|
disabled: !hasSelection,
|
||||||
action: () => {
|
action: () => {
|
||||||
const text = term.getSelection();
|
const text = term.getSelection();
|
||||||
@@ -527,6 +532,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Paste",
|
label: "Paste",
|
||||||
|
shortcut: modKey + "V",
|
||||||
action: () => {
|
action: () => {
|
||||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||||
window.runtime
|
window.runtime
|
||||||
@@ -548,10 +554,12 @@
|
|||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: "Select All",
|
label: "Select All",
|
||||||
|
shortcut: modKey + "A",
|
||||||
action: () => term.selectAll(),
|
action: () => term.selectAll(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Find…",
|
label: "Find…",
|
||||||
|
shortcut: modKey + "F",
|
||||||
action: () => this.app._openTerminalSearch(),
|
action: () => this.app._openTerminalSearch(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -561,6 +569,7 @@
|
|||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: "Clear Terminal",
|
label: "Clear Terminal",
|
||||||
|
shortcut: modKey + "K",
|
||||||
action: () => term.clear(),
|
action: () => term.clear(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -574,9 +583,17 @@
|
|||||||
menu.appendChild(createElement("div", "context-menu-divider"));
|
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||||
continue;
|
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,
|
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) {
|
if (entry.disabled) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("context-menu-disabled");
|
btn.classList.add("context-menu-disabled");
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@ func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error {
|
|||||||
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
|
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
|
||||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
|
"direction": "download",
|
||||||
"transferred": p.Transferred,
|
"transferred": p.Transferred,
|
||||||
"total": p.Total,
|
"total": p.Total,
|
||||||
"path": p.Path,
|
"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) {
|
return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) {
|
||||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
|
"direction": "upload",
|
||||||
"transferred": p.Transferred,
|
"transferred": p.Transferred,
|
||||||
"total": p.Total,
|
"total": p.Total,
|
||||||
"path": p.Path,
|
"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) {
|
return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) {
|
||||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
|
"direction": "upload",
|
||||||
"transferred": p.Transferred,
|
"transferred": p.Transferred,
|
||||||
"total": p.Total,
|
"total": p.Total,
|
||||||
"path": p.Path,
|
"path": p.Path,
|
||||||
|
|||||||
@@ -159,8 +159,9 @@ func (c *Client) connectInternal(
|
|||||||
rows := 24
|
rows := 24
|
||||||
modes := ssh.TerminalModes{
|
modes := ssh.TerminalModes{
|
||||||
ssh.ECHO: 1,
|
ssh.ECHO: 1,
|
||||||
ssh.TTY_OP_ISPEED: 14400,
|
ssh.TTY_OP_ISPEED: 115200,
|
||||||
ssh.TTY_OP_OSPEED: 14400,
|
ssh.TTY_OP_OSPEED: 115200,
|
||||||
|
ssh.IUTF8: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
|
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user