diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 8ac917c..f60be83 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -278,7 +278,12 @@ export function Terminal({ } const palette = resolveTheme(themeIdRef.current, prefersDark()) - const emulator = createEmulator({ cols: 80, rows: 24, theme: palette }) + const emulator = createEmulator({ + cols: 80, + rows: 24, + theme: palette, + macKeyboard: isApplePlatform(), + }) // Read by the clipboard callbacks, which settle a promise later and // must not write state into a view that has gone away. let alive = true diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index d453202..5a5ca89 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { Terminal } from '@xterm/xterm' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createXtermEmulator, extractGrid, @@ -505,6 +505,182 @@ describe('Shift+Enter', () => { }) }) +describe('copy and paste chords', () => { + /** + * A mounted terminal with `text` on screen, a keyboard to press, and a + * record of what went to the pty. + * + * Keys are dispatched on the helper textarea for the same reason as in the + * Shift+Enter block above: it is where xterm listens. `keyCode` is set + * because xterm's encoder reads it — a KeyboardEvent built without one + * encodes as nothing and the test would pass for the wrong reason. + */ + async function screen(text: string, opts: { macKeyboard?: boolean } = {}) { + const el = document.createElement('div') + document.body.appendChild(el) + const em = createXtermEmulator({ cols: 40, rows: 6, ...opts }) + em.attachTo(el) + await settled(em, text) + const out: string[] = [] + em.onData((b) => out.push(new TextDecoder().decode(b))) + const textarea = el.querySelector('textarea')! + const press = (key: string, init: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { + key, + keyCode: key.toUpperCase().charCodeAt(0), + bubbles: true, + cancelable: true, + ...init, + }) + textarea.dispatchEvent(event) + return event + } + return { + em, + press, + sent: () => out.join(''), + done: () => { + em.dispose() + el.remove() + }, + } + } + + afterEach(() => { + // jsdom ships no navigator.clipboard; the stubs below must not outlive + // this block into tests that read the absence. + delete (navigator as { clipboard?: unknown }).clipboard + }) + + it('stands aside on Ctrl+C over a selection, so the browser copies', async () => { + // The bug this exists for: xterm encodes Ctrl+C as ETX with no notion of + // a selection, so copying output interrupted the program instead. + const t = await screen('hello world') + t.em.selectWordAt({ col: 0, row: 0 }) + + const event = t.press('c', { ctrlKey: true }) + + // Nothing on the wire — the program under the selection keeps running — + // and the default not prevented, because the browser's own copy is the + // copy. xterm fills the clipboard from its copy listener. + expect(t.sent()).toBe('') + expect(event.defaultPrevented).toBe(false) + t.done() + }) + + it('clears a copied selection, so the next Ctrl+C interrupts', async () => { + // Without this a held selection would make SIGINT unreachable from the + // keyboard: every Ctrl+C would copy, forever. + const t = await screen('hello world') + t.em.selectWordAt({ col: 0, row: 0 }) + + t.press('c', { ctrlKey: true }) + await new Promise((r) => setTimeout(r, 0)) + + expect(t.em.selection()).toBe('') + t.press('c', { ctrlKey: true }) + expect(t.sent()).toBe('\x03') + t.done() + }) + + it('keeps Ctrl+C as the interrupt when nothing is selected', async () => { + const t = await screen('hello') + + t.press('c', { ctrlKey: true }) + + expect(t.sent()).toBe('\x03') + t.done() + }) + + it('keeps Ctrl+C as the interrupt on a Mac keyboard, selection or not', async () => { + // Cmd+C owns copy on a Mac and already works — no ctrlKey, so xterm's + // encoder ignores it and the browser copies. Taking Ctrl+C too would + // swallow the key entirely: macOS browsers do not copy on Ctrl+C, so the + // press would neither copy nor interrupt. + const t = await screen('hello world', { macKeyboard: true }) + t.em.selectWordAt({ col: 0, row: 0 }) + + t.press('c', { ctrlKey: true }) + + expect(t.sent()).toBe('\x03') + t.done() + }) + + it('leaves Cmd+C alone, which is the browser copy working today', async () => { + const t = await screen('hello world') + t.em.selectWordAt({ col: 0, row: 0 }) + + const event = t.press('c', { metaKey: true }) + + expect(t.sent()).toBe('') + expect(event.defaultPrevented).toBe(false) + t.done() + }) + + it('claims Ctrl+Shift+C: copies the selection and clears it', async () => { + const writeText = vi.fn(() => Promise.resolve()) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const t = await screen('hello world') + t.em.selectWordAt({ col: 0, row: 0 }) + + const event = t.press('C', { ctrlKey: true, shiftKey: true }) + await new Promise((r) => setTimeout(r, 0)) + + expect(writeText).toHaveBeenCalledWith('hello') + expect(t.em.selection()).toBe('') + expect(t.sent()).toBe('') + // Claimed even from the browser: on Windows and Linux the unclaimed + // chord opens the DevTools inspector over the terminal. + expect(event.defaultPrevented).toBe(true) + t.done() + }) + + it('swallows Ctrl+Shift+C over no selection rather than opening DevTools', async () => { + const writeText = vi.fn(() => Promise.resolve()) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const t = await screen('hello') + + const event = t.press('C', { ctrlKey: true, shiftKey: true }) + + expect(writeText).not.toHaveBeenCalled() + expect(t.sent()).toBe('') + expect(event.defaultPrevented).toBe(true) + t.done() + }) + + it('keeps the selection when the clipboard write is refused', async () => { + // A failed copy that also cleared the selection would leave nothing on + // the clipboard and nothing to try again with. The selection staying is + // what makes the retry possible. + const writeText = vi.fn(() => Promise.reject(new Error('denied'))) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const t = await screen('hello world') + t.em.selectWordAt({ col: 0, row: 0 }) + + t.press('C', { ctrlKey: true, shiftKey: true }) + await new Promise((r) => setTimeout(r, 0)) + + expect(t.em.selection()).toBe('hello') + t.done() + }) + + it('stands aside on Ctrl+Shift+V, where the browser paste is the paste', async () => { + // Chromium and Firefox on Windows and Linux bind the chord to a + // plain-text paste themselves and fire a trusted paste event at the + // helper textarea, which xterm's own paste handler consumes — bracketed + // paste included. Claiming it and reading the async clipboard instead + // would cost a permission prompt on the platforms that press it, and go + // dead outright on insecure origins. + const t = await screen('hello') + + const event = t.press('V', { ctrlKey: true, shiftKey: true }) + + expect(t.sent()).toBe('') + expect(event.defaultPrevented).toBe(false) + t.done() + }) +}) + describe('device-query suppression', () => { const bytes = (s: string) => new TextEncoder().encode(s) diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index b424cf7..7a8fdb8 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -6,6 +6,13 @@ export interface XtermOptions { cols?: number rows?: number theme?: TerminalTheme + /** + * Whether this is a Mac keyboard, which decides one thing here: Ctrl+C + * stays the interrupt even over a selection, because Cmd+C owns copy on a + * Mac and macOS browsers do not copy on Ctrl+C — claiming it would swallow + * the key: no copy, no interrupt. + */ + macKeyboard?: boolean } /** @@ -94,8 +101,11 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { // through the same guard so there is exactly one place that opens links. term.loadAddon(new WebLinksAddon((_event, uri) => openTerminalLink(uri))) + const encoder = new TextEncoder() + let disposed = false + /* - * Shift+Enter, which xterm has no notion of. + * The keys xterm has no notion of: Shift+Enter, and copy and paste chords. * * Here rather than in the terminal view, and that is the whole argument for * this seam: the view's own keydown handler has no way to put bytes on the @@ -104,24 +114,92 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { * the daemon counts the keystroke as activity exactly as it would any other. * * keydown alone — keyup and keypress arrive for the same press, and either - * would send the sequence a second time. Ctrl, Alt and Cmd are all excluded: - * Ctrl+Shift+Enter is the view's focus-mode chord, and Alt+Enter already - * produces these bytes through xterm's own encoder. + * would send the sequence a second time. */ term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true - if (e.key !== 'Enter' || !e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return true - // xterm keeps the caret in a real textarea, and refusing the event is not - // the same as cancelling it: left to the browser, this Enter writes a line - // into that element and xterm's input listener then reads a value nothing - // typed. xterm's own cancel never runs, because `false` returns above it. - e.preventDefault() - term.input(NEWLINE_CHORD_BYTES, true) - return false - }) - const encoder = new TextEncoder() - let disposed = false + /* + * Shift+Enter. Ctrl, Alt and Cmd are all excluded: Ctrl+Shift+Enter is + * the view's focus-mode chord, and Alt+Enter already produces these + * bytes through xterm's own encoder. + */ + if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { + // xterm keeps the caret in a real textarea, and refusing the event is + // not the same as cancelling it: left to the browser, this Enter writes + // a line into that element and xterm's input listener then reads a + // value nothing typed. xterm's own cancel never runs, because `false` + // returns above it. + e.preventDefault() + term.input(NEWLINE_CHORD_BYTES, true) + return false + } + + // `key` is 'C' when Shift is down and 'c' when it is not; Cmd chords are + // left alone throughout — on a Mac the browser's own copy and paste + // already work, because xterm's encoder ignores metaKey. + const key = e.key.toLowerCase() + if (e.metaKey || e.altKey || !e.ctrlKey) return true + + /* + * Ctrl+C over a selection: stand aside instead of encoding ETX, and the + * browser's own copy runs — xterm fills the clipboard from its copy + * listener on the helper textarea. Not on a Mac (see XtermOptions), and + * never with nothing selected: an empty selection must still interrupt, + * because Ctrl+C is how anybody stops a program. + * + * The clear is deferred past the copy: xterm reads the selection when + * the copy event lands, which is during this keydown's default action, + * so clearing synchronously here would copy nothing. Cleared at all so + * the next Ctrl+C interrupts — a held selection must not make SIGINT + * unreachable from the keyboard. + */ + if (key === 'c' && !e.shiftKey && !opts.macKeyboard && term.hasSelection()) { + setTimeout(() => { + if (!disposed) term.clearSelection() + }, 0) + return false + } + + /* + * Ctrl+Shift+C: copy, claimed on every platform. xterm's encoder + * produces no key for it, so left alone nothing calls preventDefault and + * Chrome opens the DevTools inspector over the terminal — which is also + * why the empty-selection press is swallowed rather than passed on. + */ + if (key === 'c' && e.shiftKey) { + e.preventDefault() + const text = term.getSelection() + if (text !== '') { + // The write can fail — no clipboard API on an insecure origin, or + // permission refused. The selection is only cleared once the text + // is really on the clipboard, so a failed copy leaves something to + // try again with. + void navigator.clipboard?.writeText?.(text).then( + () => { + if (!disposed) term.clearSelection() + }, + () => {}, + ) + } + return false + } + + /* + * Ctrl+Shift+V: stand aside, the chord the fingers that just learned + * Ctrl+Shift+C reach for next. Chromium and Firefox on Windows and + * Linux bind it to a plain-text paste themselves and fire a trusted + * paste event at the helper textarea, which xterm's own paste handler + * consumes — bracketed paste included. Claiming it and reading the + * async clipboard here instead would cost a permission prompt on the + * platforms that press it, and go dead outright on insecure origins, + * where navigator.clipboard does not exist. On a Mac the chord is bound + * to nothing, and Cmd+V already pastes. + */ + if (key === 'v' && e.shiftKey) return false + + return true + }) // The word a long press anchored, in buffer rows: where a drag extends // from, and what it falls back to when the finger comes back inside it. // `to` is the column after the last cell, the way a range end usually is. diff --git a/web/src/switcher/keys.ts b/web/src/switcher/keys.ts index 848ffd1..dc07d75 100644 --- a/web/src/switcher/keys.ts +++ b/web/src/switcher/keys.ts @@ -25,8 +25,9 @@ interface UADataLike { } /** - * Whether this is a Mac keyboard, which decides one thing only: whether Cmd+K - * opens the palette, and which glyphs the hints print. + * Whether this is a Mac keyboard: whether Cmd+K opens the palette, which + * glyphs the hints print, and whether the terminal keeps Ctrl+C as the + * interrupt over a selection (see XtermOptions.macKeyboard). * * `navigator.platform` is deprecated and every engine still answers it, which * for this question is the right trade: the modern replacement is Chromium-only