diff --git a/static/js/app.js b/static/js/app.js index d264b87..eff2373 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1561,50 +1561,59 @@ }); } - function setupClipboardActions() { - const copyBtn = document.getElementById('copySelectionBtn'); - const pasteBtn = document.getElementById('pasteClipboardBtn'); - const saveBtn = document.getElementById('saveTranscriptBtn'); - - if (copyBtn) { - copyBtn.addEventListener('click', () => { - const active = SessionManager.getActiveSession(); - const terminal = SessionManager.getActiveTerminal(); - if (!active || !terminal) { - showNotification('No active session', 'warning'); - return; - } - const selection = terminal ? terminal.getSelection() : ''; - if (!selection) { - showNotification('Nothing selected to copy', 'info'); - return; - } - navigator.clipboard.writeText(selection) - .then(() => showNotification('Selection copied', 'success')) - .catch(() => showNotification('Clipboard access denied', 'error')); - }); + function copyActiveTerminalSelection() { + const active = SessionManager.getActiveSession(); + const terminal = SessionManager.getActiveTerminal(); + if (!active || !terminal) { + showNotification('No active session', 'warning'); + return; } + if (!terminal.hasSelection()) { + showNotification('Nothing selected to copy', 'info'); + return; + } + TerminalManager.copySelectionToClipboard(terminal) + .then(copied => { + showNotification( + copied ? 'Selection copied' : 'Nothing selected to copy', + copied ? 'success' : 'info', + ); + }) + .catch(() => showNotification('Clipboard access denied', 'error')); + } - if (pasteBtn) { - pasteBtn.addEventListener('click', () => { - const active = SessionManager.getActiveSession(); - if (!active) { - showNotification('No active session', 'warning'); - return; - } - navigator.clipboard.readText() - .then(text => { - if (window.socket && text) { - if (window.SSHInput) { - window.SSHInput.send(active, text); - } else { - window.socket.emit('ssh_input', { session_id: active, data: text }); - } - } - }) - .catch(() => showNotification('Clipboard access denied', 'error')); - }); + function pasteClipboardIntoActiveTerminal() { + const active = SessionManager.getActiveSession(); + if (!active) { + showNotification('No active session', 'warning'); + return; + } + if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { + showNotification('Clipboard access denied', 'error'); + return; } + let clipboardText; + try { + clipboardText = navigator.clipboard.readText(); + } catch { + showNotification('Clipboard access denied', 'error'); + return; + } + Promise.resolve(clipboardText) + .then(text => { + if (window.socket && text) { + if (window.SSHInput) { + window.SSHInput.send(active, text); + } else { + window.socket.emit('ssh_input', { session_id: active, data: text }); + } + } + }) + .catch(() => showNotification('Clipboard access denied', 'error')); + } + + function setupClipboardActions() { + const saveBtn = document.getElementById('saveTranscriptBtn'); if (saveBtn) { saveBtn.addEventListener('click', () => { @@ -1884,8 +1893,8 @@ { id: 'manage-keys', labelKey: 'keys.manageKeys', hint: '', action: () => openConnectionAssetManager('keys') }, { id: 'change-password', labelKey: 'auth.changePassword', hint: '', action: () => { window.location.href = APP_ROOT + '/security#password'; } }, { id: 'save-transcript', labelKey: 'terminal.saveTranscript', hint: '', action: () => document.getElementById('saveTranscriptBtn').click() }, - { id: 'copy-selection', labelKey: 'terminal.copySelection', hint: '', action: () => document.getElementById('copySelectionBtn').click() }, - { id: 'paste-clipboard', labelKey: 'terminal.pasteClipboard', hint: '', action: () => document.getElementById('pasteClipboardBtn').click() }, + { id: 'copy-selection', labelKey: 'terminal.copySelection', hint: '', action: copyActiveTerminalSelection }, + { id: 'paste-clipboard', labelKey: 'terminal.pasteClipboard', hint: '', action: pasteClipboardIntoActiveTerminal }, { id: 'keyboard-shortcuts', labelKey: 'shortcuts.title', hint: 'Ctrl+?', action: () => openShortcuts() } ]; const actionMap = new Map(actions.map(action => [action.id, action.action])); diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index d017597..87905fb 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -51,6 +51,56 @@ const TerminalManager = { return key === 'c' ? !terminal.hasSelection() : false; }, + isCopyShortcut(event, isMac) { + if ( + event.type !== 'keydown' + || event.altKey + || event.shiftKey + || (event.key || '').toLowerCase() !== 'c' + ) { + return false; + } + return isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + }, + + copySelectionToClipboard(terminal) { + const selection = terminal?.getSelection?.() || ''; + if (!selection) { + return Promise.resolve(false); + } + + const clipboard = navigator.clipboard; + if (!clipboard || typeof clipboard.writeText !== 'function') { + return Promise.reject(new Error('Clipboard API unavailable')); + } + + try { + return Promise.resolve(clipboard.writeText(selection)).then(() => true); + } catch (error) { + return Promise.reject(error); + } + }, + + handleClipboardKeyEvent(event, terminal, isMac) { + const shouldProcess = this.shouldProcessClipboardKeyEvent( + event, + terminal, + isMac, + ); + if ( + !shouldProcess + && terminal.hasSelection() + && this.isCopyShortcut(event, isMac) + ) { + this.copySelectionToClipboard(terminal).catch(() => { + window.showNotification?.('Clipboard access denied', 'error'); + }); + } + return shouldProcess; + }, + buildTheme() { return { background: this.getCssVar('--term-background', '#1c2128'), @@ -121,7 +171,7 @@ const TerminalManager = { const isMac = this.isMacPlatform(); terminal.attachCustomKeyEventHandler(event => ( - this.shouldProcessClipboardKeyEvent(event, terminal, isMac) + this.handleClipboardKeyEvent(event, terminal, isMac) )); const fitAddon = new FitAddon.FitAddon(); diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js index 7406f01..3be515e 100644 --- a/tests/e2e/session-workspace.spec.js +++ b/tests/e2e/session-workspace.spec.js @@ -313,6 +313,42 @@ async function seedLinuxSession(page, options = {}) { }, options); } +test('terminal selections copy through keyboard and command palette actions', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + await expect(page.locator('.terminal-pane.active .xterm-helper-textarea')).toBeAttached(); + + const selectTerminalOutput = () => page.evaluate(() => { + const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0]; + const terminal = TerminalManager.terminals[terminalKey]; + terminal.selectAll(); + return terminal.getSelection(); + }); + await expect.poll(selectTerminalOutput).toContain('Production Edge'); + const keyboardSelection = await selectTerminalOutput(); + await page.locator('.terminal-pane.active .xterm-helper-textarea').focus(); + await page.keyboard.press('Control+c'); + await expect.poll(() => page.evaluate(() => window.__workspaceClipboard)) + .toBe(keyboardSelection); + + const paletteSelection = await page.evaluate(() => { + window.__workspaceClipboard = null; + const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0]; + const terminal = TerminalManager.terminals[terminalKey]; + terminal.selectAll(); + return terminal.getSelection(); + }); + expect(paletteSelection).not.toBe(''); + await page.locator('#saveTranscriptBtn').focus(); + await page.keyboard.press('Control+k'); + await page.locator('#commandPaletteInput').fill('Copy Selection'); + await page.locator('#commandPaletteInput').press('Enter'); + await expect.poll(() => page.evaluate(() => window.__workspaceClipboard)) + .toBe(paletteSelection); + await expect(page.locator('.notification-success')).toContainText('Selection copied'); + await assertNoExternalRequests(page); +}); + test('single-session workspace keeps terminal primary with on-demand Files, Diagnostics, and Notes', async ({ page }, testInfo) => { await login(page); await seedLinuxSession(page); diff --git a/tests/js/terminal-manager-layout.test.js b/tests/js/terminal-manager-layout.test.js index 1ad4700..cdec868 100644 --- a/tests/js/terminal-manager-layout.test.js +++ b/tests/js/terminal-manager-layout.test.js @@ -39,6 +39,61 @@ test('virtual keyboard detection follows visual viewport occlusion, not browser assert.equal(TerminalManager.isVirtualKeyboardVisible(0, 640), false); }); +test('copy shortcuts write xterm selection directly to the clipboard', async () => { + const writes = []; + global.navigator.clipboard = { + writeText(text) { + writes.push(text); + return Promise.resolve(); + }, + }; + const terminal = { + hasSelection: () => true, + getSelection: () => 'selected terminal output', + }; + + const shouldProcess = TerminalManager.handleClipboardKeyEvent({ + type: 'keydown', + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + }, terminal, false); + + assert.equal(shouldProcess, false); + assert.equal(TerminalManager.handleClipboardKeyEvent({ + type: 'keydown', + key: 'c', + ctrlKey: false, + metaKey: true, + altKey: false, + shiftKey: false, + }, terminal, true), false); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(writes, [ + 'selected terminal output', + 'selected terminal output', + ]); + delete global.navigator.clipboard; +}); + +test('Ctrl+C without a selection remains terminal interrupt input', () => { + const terminal = { + hasSelection: () => false, + getSelection: () => '', + }; + + assert.equal(TerminalManager.handleClipboardKeyEvent({ + type: 'keydown', + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + }, terminal, false), true); +}); + test('touch dragging normal terminal scrollback moves xterm lines directly', () => { const listeners = new Map(); const dispatched = [];