From 79e6f20d3f41b69f9f585faf6c9f8fa0c7617479 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 17 Apr 2026 00:00:00 +0100 Subject: [PATCH] Add right-click context menu on terminal Right-clicking the terminal viewport shows a context menu with: - Copy (enabled when text is selected) - Paste (reads from clipboard) - Select All - Find (opens the search bar) - Clear Terminal - Reset Terminal Uses the existing context-menu CSS classes for consistency with the tab context menu. WKWebView mousedown prevention on menu items to avoid deselecting text before copy. --- frontend/src/js/terminal.js | 105 ++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index d89102b..7fe574c 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -68,6 +68,15 @@ class TerminalManager { const container = document.getElementById(containerId); term.open(container); + // Right-click context menu on the terminal viewport + const termEl = container.querySelector('.xterm'); + if (termEl) { + termEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY); + }); + } + // Force a synchronous layout reflow so the container reports its // true dimensions, then fit the terminal to fill the space. // eslint-disable-next-line no-unused-expressions @@ -315,6 +324,102 @@ class TerminalManager { // ── Private helpers ────────────────────────────────────────────── + /** + * Shows a right-click context menu for a terminal. + * @param {string} sessionId + * @param {Terminal} term + * @param {number} x + * @param {number} y + * @private + */ + _showTerminalContextMenu(sessionId, term, x, y) { + const { createElement, dismissContextMenus } = window.NexusTerminal; + dismissContextMenus(); + + const hasSelection = term.hasSelection(); + const menu = createElement('div', 'context-menu'); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + const items = [ + { + label: 'Copy', + disabled: !hasSelection, + action: () => { + const text = term.getSelection(); + if (text) { + if (window.runtime && window.runtime.ClipboardSetText) { + window.runtime.ClipboardSetText(text); + } else { + navigator.clipboard.writeText(text).catch(function() {}); + } + this.app.notifications.info('Copied to clipboard'); + } + }, + }, + { + label: 'Paste', + action: () => { + if (window.runtime && window.runtime.ClipboardGetText) { + window.runtime.ClipboardGetText().then((text) => { + if (text) term.paste(text); + }).catch(function() {}); + } else { + navigator.clipboard.readText().then((text) => { + if (text) term.paste(text); + }).catch(function() {}); + } + }, + }, + { divider: true }, + { + label: 'Select All', + action: () => term.selectAll(), + }, + { + label: 'Find…', + action: () => this.app._openTerminalSearch(), + }, + { divider: true }, + { + label: 'Clear Terminal', + action: () => term.clear(), + }, + { + label: 'Reset Terminal', + action: () => term.reset(), + }, + ]; + + for (const entry of items) { + if (entry.divider) { + menu.appendChild(createElement('div', 'context-menu-divider')); + continue; + } + const btn = createElement('button', 'context-menu-item', { + textContent: entry.label, + }); + if (entry.disabled) { + btn.disabled = true; + btn.classList.add('context-menu-disabled'); + } + btn.addEventListener('click', () => { + dismissContextMenus(); + entry.action(); + }); + // Prevent mousedown from causing text deselection in WKWebView + btn.addEventListener('mousedown', (e) => e.preventDefault()); + menu.appendChild(btn); + } + + document.body.appendChild(menu); + + // Keep within viewport + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`; + if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`; + } + /** * Builds the xterm.js colour theme. * @returns {Object}