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
7 changes: 6 additions & 1 deletion web/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 177 additions & 1 deletion web/src/emulator/emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
108 changes: 93 additions & 15 deletions web/src/emulator/xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions web/src/switcher/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down