mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): right-click pastes once into Codex (#29647)
This commit is contained in:
+40
-1
@@ -9,12 +9,13 @@ describe('installPreviewTerminalRightClickPaste', () => {
|
||||
let container: HTMLElement
|
||||
let selection: string
|
||||
let clearSelection: ReturnType<typeof vi.fn<() => void>>
|
||||
let focus: ReturnType<typeof vi.fn<() => void>>
|
||||
let rightClickToPaste: boolean
|
||||
|
||||
const install = (): (() => void) =>
|
||||
installPreviewTerminalRightClickPaste({
|
||||
container,
|
||||
getTerminal: () => ({ getSelection: () => selection, clearSelection }),
|
||||
getTerminal: () => ({ getSelection: () => selection, clearSelection, focus }),
|
||||
isRightClickToPasteEnabled: () => rightClickToPaste,
|
||||
pasteClipboardText
|
||||
})
|
||||
@@ -32,6 +33,7 @@ describe('installPreviewTerminalRightClickPaste', () => {
|
||||
document.body.appendChild(container)
|
||||
selection = ''
|
||||
clearSelection = vi.fn<() => void>()
|
||||
focus = vi.fn<() => void>()
|
||||
rightClickToPaste = true
|
||||
Object.assign(window, { api: { ui: { writeTerminalClipboardText } } })
|
||||
})
|
||||
@@ -78,6 +80,43 @@ describe('installPreviewTerminalRightClickPaste', () => {
|
||||
expect(pasteClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: xterm reports a right press to a mouse-tracking TUI, and Codex pastes on
|
||||
// that report itself, so the press must never reach xterm's element (#25192).
|
||||
const pressTerminalElement = (
|
||||
init: MouseEventInit
|
||||
): { reachedXterm: boolean; event: MouseEvent } => {
|
||||
const xtermElement = document.createElement('div')
|
||||
container.appendChild(xtermElement)
|
||||
let reachedXterm = false
|
||||
xtermElement.addEventListener('mousedown', () => {
|
||||
reachedXterm = true
|
||||
})
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, ...init })
|
||||
xtermElement.dispatchEvent(event)
|
||||
return { reachedXterm, event }
|
||||
}
|
||||
|
||||
it('claims a right press before xterm sees it and keeps the terminal focused', () => {
|
||||
install()
|
||||
const { reachedXterm, event } = pressTerminalElement({ button: 2 })
|
||||
expect(reachedXterm).toBe(false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(focus).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Ctrl+right press', { button: 2, ctrlKey: true }, true],
|
||||
['left press', { button: 0 }, true],
|
||||
['right press with the setting off', { button: 2 }, false]
|
||||
])('lets a %s reach xterm', (_name, init, enabled) => {
|
||||
rightClickToPaste = enabled
|
||||
install()
|
||||
const { reachedXterm, event } = pressTerminalElement(init)
|
||||
expect(reachedXterm).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through before the terminal exists and stops listening once disposed', () => {
|
||||
const dispose = installPreviewTerminalRightClickPaste({
|
||||
container,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { copyTerminalSelection } from '@/components/terminal-pane/terminal-selection-copy'
|
||||
import type { PreviewTerminalPasteSource } from './preview-terminal-paste'
|
||||
import {
|
||||
claimTerminalRightClickPasteMouseDown,
|
||||
isTerminalRightClickPasteGesture
|
||||
} from '@/components/terminal-pane/terminal-right-click-paste-gesture'
|
||||
|
||||
type PreviewRightClickTerminal = Pick<Terminal, 'getSelection' | 'clearSelection'>
|
||||
type PreviewRightClickTerminal = Pick<Terminal, 'getSelection' | 'clearSelection' | 'focus'>
|
||||
|
||||
/**
|
||||
* Terminal-style right-click for the preview terminal, mirroring the pane's
|
||||
@@ -40,6 +44,16 @@ export function installPreviewTerminalRightClickPaste({
|
||||
}
|
||||
pasteClipboardText(document.activeElement, 'right-click')
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
const terminal = getTerminal()
|
||||
if (terminal && isTerminalRightClickPasteGesture(event, isRightClickToPasteEnabled())) {
|
||||
claimTerminalRightClickPasteMouseDown(event, terminal)
|
||||
}
|
||||
}
|
||||
container.addEventListener('mousedown', onMouseDown, true)
|
||||
container.addEventListener('contextmenu', onContextMenu)
|
||||
return () => container.removeEventListener('contextmenu', onContextMenu)
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', onMouseDown, true)
|
||||
container.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,10 @@ export function TerminalPaneSurface({
|
||||
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
|
||||
style={terminalContainerStyle}
|
||||
onContextMenuCapture={contextMenu.onContextMenuCapture}
|
||||
onMouseDownCapture={handlePrimarySelectionMiddleMouseDown}
|
||||
onMouseDownCapture={(event) => {
|
||||
contextMenu.onMouseDownCapture(event)
|
||||
handlePrimarySelectionMiddleMouseDown(event)
|
||||
}}
|
||||
onAuxClickCapture={handlePrimarySelectionAuxClick}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
type RightClickPasteMouseEvent = {
|
||||
button: number
|
||||
ctrlKey: boolean
|
||||
preventDefault: () => void
|
||||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: with "Right-click to paste" on, Orca owns the right button. xterm would
|
||||
* otherwise report the press to a mouse-tracking TUI, and Codex pastes the
|
||||
* clipboard on that report itself, so one click pasted twice (#25192).
|
||||
* Ctrl+right-click stays the app menu, so that press still reaches xterm.
|
||||
*/
|
||||
export function isTerminalRightClickPasteGesture(
|
||||
event: Pick<RightClickPasteMouseEvent, 'button' | 'ctrlKey'>,
|
||||
rightClickToPaste: boolean
|
||||
): boolean {
|
||||
return rightClickToPaste && event.button === 2 && !event.ctrlKey
|
||||
}
|
||||
|
||||
/** Stop the press before xterm's listener and keep the focus xterm would have taken. */
|
||||
export function claimTerminalRightClickPasteMouseDown(
|
||||
event: RightClickPasteMouseEvent,
|
||||
terminal: { focus: () => void }
|
||||
): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
terminal.focus()
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { TerminalPasteSource } from './terminal-paste-model'
|
||||
import { copyTerminalSelection } from './terminal-selection-copy'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus'
|
||||
import {
|
||||
claimTerminalRightClickPasteMouseDown,
|
||||
isTerminalRightClickPasteGesture
|
||||
} from './terminal-right-click-paste-gesture'
|
||||
|
||||
type UseTerminalContextMenuTriggerDeps = {
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
@@ -20,6 +24,7 @@ type TerminalContextMenuTrigger = {
|
||||
point: { x: number; y: number }
|
||||
menuOpenedAtRef: React.RefObject<number>
|
||||
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onMouseDownCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
|
||||
}
|
||||
|
||||
@@ -90,21 +95,32 @@ export function useTerminalContextMenuTrigger({
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const findClickedPane = (target: EventTarget | null): ManagedPane | null => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
if (!manager || !(target instanceof Node)) {
|
||||
return null
|
||||
}
|
||||
return manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
}
|
||||
|
||||
const onMouseDownCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!isTerminalRightClickPasteGesture(event, rightClickToPaste)) {
|
||||
return
|
||||
}
|
||||
const clickedPane = findClickedPane(event.target)
|
||||
if (clickedPane) {
|
||||
claimTerminalRightClickPasteMouseDown(event, clickedPane.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
if (!managerRef.current || !(target instanceof Node)) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
return
|
||||
}
|
||||
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
openContextMenu(event, clickedPane?.id ?? null, event.currentTarget)
|
||||
openContextMenu(event, findClickedPane(target)?.id ?? null, event.currentTarget)
|
||||
}
|
||||
|
||||
const onPaneTitleContextMenu = (event: React.MouseEvent<HTMLElement>, paneId: number): void => {
|
||||
@@ -116,5 +132,13 @@ export function useTerminalContextMenuTrigger({
|
||||
openContextMenu(event, paneId, boundsElement)
|
||||
}
|
||||
|
||||
return { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu }
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
point,
|
||||
menuOpenedAtRef,
|
||||
onContextMenuCapture,
|
||||
onMouseDownCapture,
|
||||
onPaneTitleContextMenu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type TerminalMenuState = {
|
||||
paneCount: number
|
||||
menuPaneId: number | null
|
||||
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onMouseDownCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
|
||||
onCopy: () => Promise<void>
|
||||
onSelectAll: () => void
|
||||
@@ -132,14 +133,21 @@ export function useTerminalPaneContextMenu({
|
||||
source
|
||||
)
|
||||
|
||||
const { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu } =
|
||||
useTerminalContextMenuTrigger({
|
||||
managerRef,
|
||||
containerRef,
|
||||
contextPaneIdRef,
|
||||
rightClickToPaste,
|
||||
pasteResolvedPane
|
||||
})
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
point,
|
||||
menuOpenedAtRef,
|
||||
onContextMenuCapture,
|
||||
onMouseDownCapture,
|
||||
onPaneTitleContextMenu
|
||||
} = useTerminalContextMenuTrigger({
|
||||
managerRef,
|
||||
containerRef,
|
||||
contextPaneIdRef,
|
||||
rightClickToPaste,
|
||||
pasteResolvedPane
|
||||
})
|
||||
|
||||
const { onSplitRight, onSplitDown } = useTerminalPaneSplitActions({
|
||||
managerRef,
|
||||
@@ -297,6 +305,7 @@ export function useTerminalPaneContextMenu({
|
||||
paneCount,
|
||||
menuPaneId,
|
||||
onContextMenuCapture,
|
||||
onMouseDownCapture,
|
||||
onPaneTitleContextMenu,
|
||||
onCopy,
|
||||
onSelectAll,
|
||||
|
||||
@@ -23,16 +23,20 @@ function keyboardPasteChords(): string[] {
|
||||
: ['Control+V', 'Control+Shift+V', 'Shift+Insert']
|
||||
}
|
||||
|
||||
function pasteEchoScript(runId: string): string {
|
||||
function pasteEchoScript(runId: string, options?: { mouseTracking?: boolean }): string {
|
||||
const mouseTracking = options?.mouseTracking === true
|
||||
return `
|
||||
process.stdin.setEncoding('utf8')
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
||||
process.stdin.resume()
|
||||
let seq = 0
|
||||
const interrupt = String.fromCharCode(3)
|
||||
const esc = String.fromCharCode(27)
|
||||
if (${mouseTracking}) process.stdout.write(esc + '[?1000h' + esc + '[?1006h')
|
||||
process.stdout.write('PASTE_READY_${runId}\\n')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
if (chunk.includes(interrupt)) {
|
||||
if (${mouseTracking}) process.stdout.write(esc + '[?1006l' + esc + '[?1000l')
|
||||
process.exit(0)
|
||||
}
|
||||
seq += 1
|
||||
@@ -312,6 +316,57 @@ test.describe('terminal paste ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('right-click paste into a mouse-tracking terminal app sends the clipboard once and no right-button report', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await installTerminalPtyWriteSpy(electronApp)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-paste-right-click-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, pasteEchoScript(runId, { mouseTracking: true }))
|
||||
let scriptStarted = false
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
scriptStarted = true
|
||||
await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000)
|
||||
await orcaPage.evaluate(() =>
|
||||
window.__store?.getState().updateSettings({ terminalRightClickToPaste: true })
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(() => window.__store?.getState().settings?.terminalRightClickToPaste)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const payload = `ORCA_E2E_RIGHT_CLICK_PASTE_${runId}`
|
||||
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
|
||||
await clearTerminalPtyWriteLog(electronApp)
|
||||
await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
|
||||
await rightClickActiveTerminalSurface(orcaPage)
|
||||
await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000)
|
||||
|
||||
const writes = (await readTerminalPtyWrites(electronApp)).join('')
|
||||
expect(countOccurrences(writes, payload), 'right-click PTY write count').toBe(1)
|
||||
// Why: a TUI that pastes on its own right-button report would paste again (#25192).
|
||||
expect(writes, 'right-button mouse report').not.toContain('\x1b[<2;')
|
||||
} finally {
|
||||
if (scriptStarted) {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('Windows multiline keyboard paste normalizes terminal newlines with one PTY owner', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
|
||||
Reference in New Issue
Block a user