Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 52 additions & 43 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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]));
Expand Down
52 changes: 51 additions & 1 deletion static/js/terminal-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions tests/e2e/session-workspace.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions tests/js/terminal-manager-layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down