From e7eb42659cdbad1730fed5f6433db74f498136b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 22:49:41 -0700 Subject: [PATCH] Add primary selection middle-click paste Closes #1898 --- src/main/ghostty/mapper.test.ts | 18 ++ src/main/ghostty/mapper.ts | 7 + src/main/ipc/register-core-handlers.test.ts | 5 +- src/main/ipc/register-core-handlers.ts | 6 +- .../window/attach-main-window-services.ts | 45 +---- .../window/clipboard-ipc-handlers.test.ts | 93 +++++++++ src/main/window/clipboard-ipc-handlers.ts | 51 +++++ src/preload/api-types.ts | 2 + src/preload/index.ts | 4 + src/renderer/src/App.tsx | 8 + .../editor/setup-contextual-copy.ts | 57 +++++- .../src/components/settings/InputPane.tsx | 83 ++++++++ .../src/components/settings/Settings.tsx | 19 ++ .../src/components/settings/setting-labels.ts | 1 + .../components/terminal-pane/TerminalPane.tsx | 75 +++++++ .../components/terminal-pane/pane-helpers.ts | 6 + .../use-terminal-pane-lifecycle.ts | 74 ++++++- .../src/hooks/usePrimarySelectionPaste.ts | 184 ++++++++++++++++++ .../src/lib/primary-selection-capture.ts | 108 ++++++++++ .../src/lib/primary-selection-paste.ts | 149 ++++++++++++++ .../src/lib/primary-selection.test.ts | 131 +++++++++++++ src/renderer/src/lib/primary-selection.ts | 92 +++++++++ src/renderer/src/store/slices/ui.ts | 1 + src/renderer/src/web/web-preload-api.ts | 4 + src/shared/constants.test.ts | 13 +- src/shared/constants.ts | 5 + src/shared/types.ts | 4 + 27 files changed, 1192 insertions(+), 53 deletions(-) create mode 100644 src/main/window/clipboard-ipc-handlers.test.ts create mode 100644 src/main/window/clipboard-ipc-handlers.ts create mode 100644 src/renderer/src/components/settings/InputPane.tsx create mode 100644 src/renderer/src/hooks/usePrimarySelectionPaste.ts create mode 100644 src/renderer/src/lib/primary-selection-capture.ts create mode 100644 src/renderer/src/lib/primary-selection-paste.ts create mode 100644 src/renderer/src/lib/primary-selection.test.ts create mode 100644 src/renderer/src/lib/primary-selection.ts diff --git a/src/main/ghostty/mapper.test.ts b/src/main/ghostty/mapper.test.ts index 5cf53025b53..c3ed4f2631a 100644 --- a/src/main/ghostty/mapper.test.ts +++ b/src/main/ghostty/mapper.test.ts @@ -75,6 +75,24 @@ describe('mapGhosttyToOrca — font & cursor', () => { expect(result.diff).toEqual({}) expect(result.unsupportedKeys).toEqual(['focus-follows-mouse']) }) + + it('maps middle-click-action primary-paste to primary selection paste', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'primary-paste' }) + expect(result.diff).toEqual({ primarySelectionMiddleClickPaste: true }) + expect(result.unsupportedKeys).toEqual([]) + }) + + it('maps middle-click-action ignore to disabled primary selection paste', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'ignore' }) + expect(result.diff).toEqual({ primarySelectionMiddleClickPaste: false }) + expect(result.unsupportedKeys).toEqual([]) + }) + + it('rejects invalid middle-click-action value', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'copy' }) + expect(result.diff).toEqual({}) + expect(result.unsupportedKeys).toEqual(['middle-click-action']) + }) }) describe('mapGhosttyToOrca — macos-option-as-alt', () => { diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 6cc06177d35..496de3cadbf 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -261,6 +261,13 @@ export function mapGhosttyToOrca( return null } return { key: 'terminalFocusFollowsMouse', value: v === 'true' } + }, + + 'middle-click-action': (v) => { + if (v !== 'primary-paste' && v !== 'ignore') { + return null + } + return { key: 'primarySelectionMiddleClickPaste', value: v === 'primary-paste' } } } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 24bba475d3c..21d0836ecf9 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -223,10 +223,13 @@ vi.mock('./claude-accounts', () => ({ })) vi.mock('../window/attach-main-window-services', () => ({ - registerClipboardHandlers: registerClipboardHandlersMock, registerUpdaterHandlers: registerUpdaterHandlersMock })) +vi.mock('../window/clipboard-ipc-handlers', () => ({ + registerClipboardHandlers: registerClipboardHandlersMock +})) + vi.mock('./browser', () => ({ registerBrowserHandlers: registerBrowserHandlersMock, setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 04cf9c0725f..a24399c0e47 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -44,10 +44,8 @@ import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' -import { - registerClipboardHandlers, - registerUpdaterHandlers -} from '../window/attach-main-window-services' +import { registerUpdaterHandlers } from '../window/attach-main-window-services' +import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 32389c5c704..db2c5bd0ffc 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,9 +1,7 @@ /* eslint-disable max-lines -- Why: this file is the central main-window IPC wiring point; splitting it during the mobile release compatibility rebase would increase release risk. */ -import fs from 'node:fs/promises' -import path from 'node:path' import { randomUUID } from 'node:crypto' -import { app, clipboard, ipcMain, nativeImage, session } from 'electron' +import { app, ipcMain, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' @@ -345,47 +343,6 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void { ) } -export function registerClipboardHandlers(): void { - ipcMain.removeHandler('clipboard:readText') - ipcMain.removeHandler('clipboard:writeText') - ipcMain.removeHandler('clipboard:writeImage') - ipcMain.removeHandler('clipboard:saveImageAsTempFile') - - ipcMain.handle('clipboard:readText', () => clipboard.readText()) - // Why: terminals need to detect clipboard images to support tools like Claude - // Code that accept image input via paste. Writes the clipboard image to a - // temp file and returns the path, or null if the clipboard has no image. - ipcMain.handle('clipboard:saveImageAsTempFile', async () => { - const image = clipboard.readImage() - if (image.isEmpty()) { - return null - } - const tempPath = path.join(app.getPath('temp'), `orca-paste-${Date.now()}.png`) - await fs.writeFile(tempPath, image.toPNG()) - return tempPath - }) - ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) - ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { - // Why: only accept validated PNG data URIs to prevent writing arbitrary - // data to the clipboard. The renderer already validates the prefix, but - // defense-in-depth applies here too. - const prefix = 'data:image/png;base64,' - if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) { - return - } - // Why: use createFromBuffer instead of createFromDataURL — the latter - // silently returns an empty image on some macOS + Electron combinations - // when the data URL is large (>500KB). Decoding the base64 manually and - // using createFromBuffer is more reliable. - const buffer = Buffer.from(dataUrl.slice(prefix.length), 'base64') - const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) { - return - } - clipboard.writeImage(image) - }) -} - export function registerUpdaterHandlers(_store: Store): void { ipcMain.removeHandler('updater:getStatus') ipcMain.removeHandler('updater:getVersion') diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts new file mode 100644 index 00000000000..d0f0be0a312 --- /dev/null +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + removeHandlerMock, + handleMock, + clipboardReadTextMock, + clipboardWriteTextMock, + clipboardReadImageMock, + clipboardWriteImageMock, + nativeImageCreateFromBufferMock +} = vi.hoisted(() => ({ + removeHandlerMock: vi.fn(), + handleMock: vi.fn(), + clipboardReadTextMock: vi.fn(), + clipboardWriteTextMock: vi.fn(), + clipboardReadImageMock: vi.fn(), + clipboardWriteImageMock: vi.fn(), + nativeImageCreateFromBufferMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/tmp') + }, + clipboard: { + readText: clipboardReadTextMock, + writeText: clipboardWriteTextMock, + readImage: clipboardReadImageMock, + writeImage: clipboardWriteImageMock + }, + ipcMain: { + removeHandler: removeHandlerMock, + handle: handleMock + }, + nativeImage: { + createFromBuffer: nativeImageCreateFromBufferMock + } +})) + +import { registerClipboardHandlers } from './clipboard-ipc-handlers' + +function getRegisteredHandlers(): Map unknown> { + const handlers = new Map unknown>() + for (const [channel, handler] of handleMock.mock.calls as [ + string, + (...args: unknown[]) => unknown + ][]) { + handlers.set(channel, handler) + } + return handlers +} + +describe('registerClipboardHandlers', () => { + beforeEach(() => { + removeHandlerMock.mockReset() + handleMock.mockReset() + clipboardReadTextMock.mockReset() + clipboardWriteTextMock.mockReset() + clipboardReadImageMock.mockReset() + clipboardWriteImageMock.mockReset() + nativeImageCreateFromBufferMock.mockReset() + }) + + it('registers normal and selection text clipboard IPC handlers', () => { + clipboardReadTextMock.mockImplementation((clipboardType?: string) => + clipboardType === 'selection' ? 'selection text' : 'standard text' + ) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + expect(handlers.get('clipboard:readText')?.()).toBe('standard text') + expect(handlers.get('clipboard:readSelectionText')?.()).toBe('selection text') + handlers.get('clipboard:writeText')?.({}, 'normal text') + handlers.get('clipboard:writeSelectionText')?.({}, 'primary text') + + expect(clipboardReadTextMock).toHaveBeenCalledWith() + expect(clipboardReadTextMock).toHaveBeenCalledWith('selection') + expect(clipboardWriteTextMock).toHaveBeenCalledWith('normal text') + expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') + }) + + it('removes stale clipboard IPC handlers before registering replacements', () => { + registerClipboardHandlers() + + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readSelectionText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeSelectionText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') + }) +}) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts new file mode 100644 index 00000000000..a8cf69ddf76 --- /dev/null +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { app, clipboard, ipcMain, nativeImage } from 'electron' + +export function registerClipboardHandlers(): void { + ipcMain.removeHandler('clipboard:readText') + ipcMain.removeHandler('clipboard:readSelectionText') + ipcMain.removeHandler('clipboard:writeText') + ipcMain.removeHandler('clipboard:writeSelectionText') + ipcMain.removeHandler('clipboard:writeImage') + ipcMain.removeHandler('clipboard:saveImageAsTempFile') + + ipcMain.handle('clipboard:readText', () => clipboard.readText()) + ipcMain.handle('clipboard:readSelectionText', () => clipboard.readText('selection')) + // Why: terminals need to detect clipboard images to support tools like Claude + // Code that accept image input via paste. Writes the clipboard image to a + // temp file and returns the path, or null if the clipboard has no image. + ipcMain.handle('clipboard:saveImageAsTempFile', async () => { + const image = clipboard.readImage() + if (image.isEmpty()) { + return null + } + const tempPath = path.join(app.getPath('temp'), `orca-paste-${Date.now()}.png`) + await fs.writeFile(tempPath, image.toPNG()) + return tempPath + }) + ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) + ipcMain.handle('clipboard:writeSelectionText', (_event, text: string) => + clipboard.writeText(text, 'selection') + ) + ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { + // Why: only accept validated PNG data URIs to prevent writing arbitrary + // data to the clipboard. The renderer already validates the prefix, but + // defense-in-depth applies here too. + const prefix = 'data:image/png;base64,' + if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) { + return + } + // Why: use createFromBuffer instead of createFromDataURL — the latter + // silently returns an empty image on some macOS + Electron combinations + // when the data URL is large (>500KB). Decoding the base64 manually and + // using createFromBuffer is more reliable. + const buffer = Buffer.from(dataUrl.slice(prefix.length), 'base64') + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return + } + clipboard.writeImage(image) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2fff7bc5a65..c37e77db120 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1537,8 +1537,10 @@ export type PreloadApi = { onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise + readSelectionClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise writeClipboardText: (text: string) => Promise + writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise onFileDrop: ( callback: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index fd866dfd21e..6d2ba647dd7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2334,10 +2334,14 @@ const api = { return () => ipcRenderer.removeListener('terminal:zoom', listener) }, readClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readText'), + readSelectionClipboardText: (): Promise => + ipcRenderer.invoke('clipboard:readSelectionText'), saveClipboardImageAsTempFile: (): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile'), writeClipboardText: (text: string): Promise => ipcRenderer.invoke('clipboard:writeText', text), + writeSelectionClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeSelectionText', text), writeClipboardImage: (dataUrl: string): Promise => ipcRenderer.invoke('clipboard:writeImage', dataUrl), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2fd028b4f83..1fe03581033 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,6 +61,10 @@ import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPoll import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' +import { + resolvePrimarySelectionMiddleClickPaste, + usePrimarySelectionPaste +} from './hooks/usePrimarySelectionPaste' import { getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual, @@ -343,6 +347,10 @@ function App(): React.JSX.Element { const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) + const primarySelectionMiddleClickPaste = resolvePrimarySelectionMiddleClickPaste( + settings?.primarySelectionMiddleClickPaste + ) + usePrimarySelectionPaste(primarySelectionMiddleClickPaste) const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const petVisible = useAppStore((s) => s.petVisible) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) diff --git a/src/renderer/src/components/editor/setup-contextual-copy.ts b/src/renderer/src/components/editor/setup-contextual-copy.ts index c9e0b87686e..ce9c0c0768e 100644 --- a/src/renderer/src/components/editor/setup-contextual-copy.ts +++ b/src/renderer/src/components/editor/setup-contextual-copy.ts @@ -1,5 +1,10 @@ import type { editor } from 'monaco-editor' import { formatCopiedSelectionWithContext, getContextualCopyLineRange } from './selection-copy' +import { + PRIMARY_SELECTION_MAX_LENGTH, + isPrimarySelectionEnabled, + setPrimarySelectionText +} from '@/lib/primary-selection' export function setupContextualCopy({ editorInstance, @@ -24,6 +29,7 @@ export function setupContextualCopy({ copyToastTimeoutRef: React.MutableRefObject }): void { let copyHintInterval: number | null = null + let primarySelectionTimer: number | null = null let copyHintWidgetPosition: editor.IContentWidgetPosition | null = null let lastCopiedSelectionKey: string | null = null const copyHintNode = document.createElement('div') @@ -180,6 +186,48 @@ export function setupContextualCopy({ }) } + const updatePrimarySelectionBuffer = (): void => { + const model = editorInstance.getModel() + const selections = editorInstance.getSelections() + if (!isPrimarySelectionEnabled() || !model || !selections?.length) { + return + } + + const sortedSelections = selections.slice().sort((a, b) => { + if (a.startLineNumber !== b.startLineNumber) { + return a.startLineNumber - b.startLineNumber + } + return a.startColumn - b.startColumn + }) + + let totalLength = 0 + for (const selection of sortedSelections) { + if (selection.isEmpty()) { + return + } + totalLength += model.getValueLengthInRange(selection) + if (totalLength > PRIMARY_SELECTION_MAX_LENGTH) { + return + } + } + + setPrimarySelectionText( + sortedSelections.map((selection) => model.getValueInRange(selection)).join(model.getEOL()) + ) + } + + const schedulePrimarySelectionBufferUpdate = (): void => { + if (primarySelectionTimer !== null) { + window.clearTimeout(primarySelectionTimer) + } + // Why: Monaco emits intermediate selection changes during drag; match the + // editor selection clipboard debounce so we don't churn the clipboard. + primarySelectionTimer = window.setTimeout(() => { + primarySelectionTimer = null + updatePrimarySelectionBuffer() + }, 100) + } + const copySelectionWithContext = async (): Promise => { const copiedText = getContextualCopyText() if (!copiedText) { @@ -211,7 +259,10 @@ export function setupContextualCopy({ void copySelectionWithContext() }) - editorInstance.onDidChangeCursorSelection(() => { + editorInstance.onDidChangeCursorSelection((event) => { + if (event.source !== 'restoreState') { + schedulePrimarySelectionBufferUpdate() + } if (getSelectionKey() !== lastCopiedSelectionKey) { lastCopiedSelectionKey = null } @@ -247,6 +298,10 @@ export function setupContextualCopy({ editorDomNode.addEventListener('mouseup', updateCopyHint, true) editorDomNode.addEventListener('keyup', updateCopyHint, true) editorInstance.onDidDispose(() => { + if (primarySelectionTimer !== null) { + window.clearTimeout(primarySelectionTimer) + primarySelectionTimer = null + } editorDomNode.removeEventListener('keydown', handleKeyDown, true) editorDomNode.removeEventListener('mouseup', updateCopyHint, true) editorDomNode.removeEventListener('keyup', updateCopyHint, true) diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx new file mode 100644 index 00000000000..baf7329ef4e --- /dev/null +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -0,0 +1,83 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' +import type { SettingsSearchEntry } from './settings-search' +import { isLinuxUserAgent } from '@/components/terminal-pane/pane-helpers' + +export const INPUT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Middle-click Paste from Selection', + description: + 'On Linux, selected text uses the system selection clipboard. Other platforms use a private buffer when enabled.', + keywords: [ + 'input', + 'editing', + 'selection', + 'primary selection', + 'middle click', + 'middle mouse', + 'paste', + 'clipboard', + 'x11', + 'linux' + ] + } +] + +type InputPaneProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function InputPane({ settings, updateSettings }: InputPaneProps): React.JSX.Element { + const enabled = settings.primarySelectionMiddleClickPaste ?? isLinuxUserAgent() + + return ( +
+ +
+ +

+ On Linux, use the system selection clipboard. On other platforms, use a private buffer + when this is enabled. +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index f488bcfede8..7407f0c057f 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -22,6 +22,7 @@ import { Blocks, Mic, SquareTerminal, + TextCursorInput, UserCog } from 'lucide-react' import type { OrcaHooks } from '../../../../shared/types' @@ -35,6 +36,7 @@ import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane' import { BrowserPane, BROWSER_PANE_SEARCH_ENTRIES } from './BrowserPane' import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane' +import { InputPane, INPUT_PANE_SEARCH_ENTRIES } from './InputPane' import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane' import { TerminalPane } from './TerminalPane' import { useGhosttyImport } from './useGhosttyImport' @@ -91,6 +93,7 @@ type SettingsNavTarget = | 'git' | 'tasks' | 'appearance' + | 'input' | 'terminal' | 'notifications' | 'computer-use' @@ -435,6 +438,13 @@ function Settings(): React.JSX.Element { icon: Palette, searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES }, + { + id: 'input', + title: 'Input & Editing', + description: 'Selection and editing behavior.', + icon: TextCursorInput, + searchEntries: INPUT_PANE_SEARCH_ENTRIES + }, { id: 'terminal', title: 'Terminal', @@ -1043,6 +1053,15 @@ function Settings(): React.JSX.Element { ) : null} + + + + > = { terminalCursorOpacity: 'Cursor Opacity', terminalMouseHideWhileTyping: 'Mouse Hide While Typing', terminalWordSeparator: 'Word Separator', + primarySelectionMiddleClickPaste: 'Middle-click Paste from Selection', terminalFocusFollowsMouse: 'Focus Follows Mouse', terminalColorOverrides: 'Color Overrides', terminalMacOptionAsAlt: 'Option as Alt', diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 7549a530372..31242072b53 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -48,6 +48,7 @@ import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' +import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -1066,6 +1067,78 @@ export default function TerminalPane({ rightClickToPaste }) + const terminalShouldHandleMiddleClick = useCallback( + (target: EventTarget | null): target is Node => { + if (!(target instanceof Element)) { + return false + } + if (target.closest('[data-terminal-search-root]')) { + return false + } + const editable = target.closest( + 'input, textarea, [contenteditable=""], [contenteditable="true"]' + ) + return !editable || editable.classList.contains('xterm-helper-textarea') + }, + [] + ) + + const getPrimarySelectionMiddleClickPane = useCallback( + (target: EventTarget | null) => { + if (!terminalShouldHandleMiddleClick(target)) { + return null + } + const manager = managerRef.current + if (!manager) { + return null + } + const clickedPane = + manager.getPanes().find((pane) => pane.container.contains(target)) ?? + manager.getActivePane() ?? + manager.getPanes()[0] + if (!clickedPane || clickedPane.terminal.modes.mouseTrackingMode !== 'none') { + return null + } + return clickedPane + }, + [terminalShouldHandleMiddleClick] + ) + + const handlePrimarySelectionMiddleMouseDown = useCallback( + (event: React.MouseEvent): void => { + if (event.button !== 1 || !isPrimarySelectionEnabled()) { + return + } + const clickedPane = getPrimarySelectionMiddleClickPane(event.target) + if (!clickedPane) { + return + } + event.preventDefault() + event.stopPropagation() + clickedPane.terminal.focus() + void readPrimarySelectionText().then((text) => { + if (text) { + clickedPane.terminal.paste(text) + } + }) + }, + [getPrimarySelectionMiddleClickPane] + ) + + const handlePrimarySelectionAuxClick = useCallback( + (event: React.MouseEvent): void => { + if ( + event.button === 1 && + isPrimarySelectionEnabled() && + getPrimarySelectionMiddleClickPane(event.target) + ) { + event.preventDefault() + event.stopPropagation() + } + }, + [getPrimarySelectionMiddleClickPane] + ) + const effectiveAppearance = settings ? resolveEffectiveTerminalAppearance(settings, systemPrefersDark) : null @@ -1093,6 +1166,8 @@ export default function TerminalPane({ data-terminal-tab-id={tabId} style={terminalContainerStyle} onContextMenuCapture={contextMenu.onContextMenuCapture} + onMouseDownCapture={handlePrimarySelectionMiddleMouseDown} + onAuxClickCapture={handlePrimarySelectionAuxClick} onDragOver={(e) => { if (e.dataTransfer.types.includes('text/x-orca-file-path')) { e.preventDefault() diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index ae89e797638..5a03ca0954d 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -50,6 +50,12 @@ export function isMacUserAgent( return userAgent.includes('Mac') } +export function isLinuxUserAgent( + userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent +): boolean { + return !isMacUserAgent(userAgent) && !isWindowsUserAgent(userAgent) && userAgent.includes('Linux') +} + // Why: escape rules are a property of the *target* shell receiving the path, // not the client OS. A Windows client dropping onto a Linux SSH worktree must // produce POSIX-quoted output; passing a userAgent string here coupled escape diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 20ab6b285b0..976e519e6e8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: terminal pane lifecycle wiring is intentionally co-located so PTY attach, theme sync, and runtime graph publication remain consistent for live terminals. */ import { useEffect, useRef } from 'react' -import type { IDisposable } from '@xterm/xterm' +import type { IDisposable, Terminal } from '@xterm/xterm' import { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { @@ -44,6 +44,11 @@ import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { e2eConfig } from '@/lib/e2e-config' +import { + PRIMARY_SELECTION_MAX_LENGTH, + isPrimarySelectionEnabled, + setPrimarySelectionText +} from '@/lib/primary-selection' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT, @@ -133,6 +138,21 @@ type UseTerminalPaneLifecycleDeps = { setPaneCount: React.Dispatch> } +function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean { + const range = terminal.getSelectionPosition() + if (!range) { + return false + } + const startY = Math.min(range.start.y, range.end.y) + const endY = Math.max(range.start.y, range.end.y) + const rowSpan = endY - startY + const cellEstimate = + rowSpan === 0 + ? Math.abs(range.end.x - range.start.x) + : rowSpan * terminal.cols + Math.abs(range.end.x - range.start.x) + return cellEstimate > PRIMARY_SELECTION_MAX_LENGTH +} + type SplitStartupPayload = { command: string; env?: Record } type SplitWithStartupDeps = { @@ -218,6 +238,7 @@ export function useTerminalPaneLifecycle({ // Why: read settingsRef at fire time so toggling "copy on select" takes // effect without recreating panes. const selectionDisposablesRef = useRef(new Map()) + const selectionCaptureTimersRef = useRef(new Map()) const mode2031DisposablesRef = useRef(new Map()) const osc52DisposablesRef = useRef(new Map()) const osc7DisposablesRef = useRef(new Map()) @@ -282,6 +303,7 @@ export function useTerminalPaneLifecycle({ const panePtyBindings = panePtyBindingsRef.current const linkDisposables = linkProviderDisposablesRef.current const selectionDisposables = selectionDisposablesRef.current + const selectionCaptureTimers = selectionCaptureTimersRef.current const mouseHideDisposables = mouseHideDisposablesRef.current const worktreePath = useAppStore @@ -474,7 +496,46 @@ export function useTerminalPaneLifecycle({ // Why: skip empty selections so clicking to deselect doesn't clobber // whatever the user last copied elsewhere. const selectionDisposable = pane.terminal.onSelectionChange(() => { - if (!settingsRef.current?.terminalClipboardOnSelect) { + const shouldWritePrimarySelection = isPrimarySelectionEnabled() + const shouldWriteClipboard = settingsRef.current?.terminalClipboardOnSelect === true + if (!shouldWritePrimarySelection && !shouldWriteClipboard) { + return + } + if (!pane.terminal.hasSelection()) { + return + } + if ( + shouldWritePrimarySelection && + !shouldWriteClipboard && + terminalSelectionExceedsPrimaryLimit(pane.terminal) + ) { + return + } + + if (shouldWritePrimarySelection) { + const existingTimer = selectionCaptureTimersRef.current.get(pane.id) + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer) + } + // Why: xterm fires selection changes while dragging; defer the + // primary-selection clipboard write to avoid clipboard churn. + const timer = window.setTimeout(() => { + selectionCaptureTimersRef.current.delete(pane.id) + if (!isPrimarySelectionEnabled() || !pane.terminal.hasSelection()) { + return + } + if (terminalSelectionExceedsPrimaryLimit(pane.terminal)) { + return + } + const selection = pane.terminal.getSelection() + if (selection) { + setPrimarySelectionText(selection) + } + }, 100) + selectionCaptureTimersRef.current.set(pane.id, timer) + } + + if (!shouldWriteClipboard) { return } const selection = pane.terminal.getSelection() @@ -557,6 +618,11 @@ export function useTerminalPaneLifecycle({ selectionDisposable.dispose() selectionDisposablesRef.current.delete(paneId) } + const selectionCaptureTimer = selectionCaptureTimersRef.current.get(paneId) + if (selectionCaptureTimer !== undefined) { + window.clearTimeout(selectionCaptureTimer) + selectionCaptureTimersRef.current.delete(paneId) + } const mode2031Disposables = mode2031DisposablesRef.current.get(paneId) if (mode2031Disposables) { for (const d of mode2031Disposables) { @@ -913,6 +979,10 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } selectionDisposables.clear() + for (const timer of selectionCaptureTimers.values()) { + window.clearTimeout(timer) + } + selectionCaptureTimers.clear() for (const disposable of mouseHideDisposables.values()) { disposable.dispose() } diff --git a/src/renderer/src/hooks/usePrimarySelectionPaste.ts b/src/renderer/src/hooks/usePrimarySelectionPaste.ts new file mode 100644 index 00000000000..97939f66f30 --- /dev/null +++ b/src/renderer/src/hooks/usePrimarySelectionPaste.ts @@ -0,0 +1,184 @@ +import { useEffect } from 'react' +import { isLinuxUserAgent } from '@/components/terminal-pane/pane-helpers' +import { + readPrimarySelectionText, + setPrimarySelectionEnabled, + setPrimarySelectionText +} from '@/lib/primary-selection' +import { + findEditablePrimarySelectionPasteTarget, + pastePrimarySelectionTextIntoTarget, + type EditablePrimarySelectionPasteTarget +} from '@/lib/primary-selection-paste' +import { readCurrentPrimarySelectionText } from '@/lib/primary-selection-capture' + +export function resolvePrimarySelectionMiddleClickPaste( + setting: boolean | undefined, + userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent +): boolean { + return setting ?? isLinuxUserAgent(userAgent) +} + +function captureCurrentSelection(): void { + const text = readCurrentPrimarySelectionText() + if (text) { + setPrimarySelectionText(text) + } +} + +function suppressEvent(event: Event): void { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() +} + +export function usePrimarySelectionPaste(enabled: boolean): void { + useEffect(() => { + setPrimarySelectionEnabled(enabled) + let pendingMiddleTarget: EditablePrimarySelectionPasteTarget | null = null + let pendingMiddleUntil = 0 + + const targetMatchesPending = (target: EventTarget | null): boolean => { + if (!pendingMiddleTarget || !(target instanceof Node)) { + return false + } + return target === pendingMiddleTarget || pendingMiddleTarget.contains(target) + } + + const rememberPendingTarget = (event: MouseEvent): boolean => { + if (event.button !== 1) { + return false + } + const target = findEditablePrimarySelectionPasteTarget(event.target) + if (!target) { + return false + } + pendingMiddleTarget = target + pendingMiddleUntil = Date.now() + 750 + return true + } + + const suppressPendingPasteInput = (event: InputEvent | ClipboardEvent): void => { + const isPasteInputEvent = + typeof InputEvent !== 'function' || + !(event instanceof InputEvent) || + event.inputType === 'insertFromPaste' + if ( + pendingMiddleTarget && + Date.now() <= pendingMiddleUntil && + targetMatchesPending(event.target) && + isPasteInputEvent + ) { + suppressEvent(event) + } + } + + if (!enabled) { + if (!isLinuxUserAgent()) { + return + } + + const onMouseDown = (event: MouseEvent): void => { + rememberPendingTarget(event) + } + const onMouseUp = (event: MouseEvent): void => { + if (event.button === 1) { + // Why: prevent Chromium's native Linux primary paste when disabled + // without blocking terminal apps from receiving middle-click events. + event.preventDefault() + } + pendingMiddleTarget = null + } + const onAuxClick = (event: MouseEvent): void => { + if (event.button === 1) { + // Why: match the mouseup preventer for browsers that surface auxclick. + event.preventDefault() + } + } + + // Why: when users opt out on Linux, Chromium can still perform native + // primary-selection paste unless the middle-click paste pipeline is stopped. + document.addEventListener('mousedown', onMouseDown, true) + document.addEventListener('beforeinput', suppressPendingPasteInput, true) + document.addEventListener('paste', suppressPendingPasteInput, true) + document.addEventListener('mouseup', onMouseUp, true) + document.addEventListener('auxclick', onAuxClick, true) + + return () => { + setPrimarySelectionEnabled(false) + document.removeEventListener('mousedown', onMouseDown, true) + document.removeEventListener('beforeinput', suppressPendingPasteInput, true) + document.removeEventListener('paste', suppressPendingPasteInput, true) + document.removeEventListener('mouseup', onMouseUp, true) + document.removeEventListener('auxclick', onAuxClick, true) + } + } + + let captureTimer: number | null = null + + const scheduleCapture = (): void => { + if (captureTimer !== null) { + window.clearTimeout(captureTimer) + } + captureTimer = window.setTimeout(() => { + captureTimer = null + captureCurrentSelection() + }, 100) + } + + const onMouseDown = (event: MouseEvent): void => { + rememberPendingTarget(event) + } + + const onMouseUp = (event: MouseEvent): void => { + if (event.button !== 1 || !pendingMiddleTarget || Date.now() > pendingMiddleUntil) { + pendingMiddleTarget = null + return + } + + const target = pendingMiddleTarget + pendingMiddleTarget = null + suppressEvent(event) + const point = { + clientX: event.clientX, + clientY: event.clientY + } + void readPrimarySelectionText().then((text) => { + if (!text) { + return + } + pastePrimarySelectionTextIntoTarget(target, text, point) + }) + } + + const onAuxClick = (event: MouseEvent): void => { + if (event.button === 1 && findEditablePrimarySelectionPasteTarget(event.target)) { + suppressEvent(event) + } + } + + document.addEventListener('selectionchange', scheduleCapture) + document.addEventListener('mouseup', scheduleCapture, true) + document.addEventListener('keyup', scheduleCapture, true) + document.addEventListener('mousedown', onMouseDown, true) + document.addEventListener('beforeinput', suppressPendingPasteInput, true) + document.addEventListener('paste', suppressPendingPasteInput, true) + document.addEventListener('mouseup', onMouseUp, true) + document.addEventListener('auxclick', onAuxClick, true) + + return () => { + setPrimarySelectionEnabled(false) + if (captureTimer !== null) { + window.clearTimeout(captureTimer) + } + document.removeEventListener('selectionchange', scheduleCapture) + document.removeEventListener('mouseup', scheduleCapture, true) + document.removeEventListener('keyup', scheduleCapture, true) + document.removeEventListener('mousedown', onMouseDown, true) + document.removeEventListener('beforeinput', suppressPendingPasteInput, true) + document.removeEventListener('paste', suppressPendingPasteInput, true) + document.removeEventListener('mouseup', onMouseUp, true) + document.removeEventListener('auxclick', onAuxClick, true) + } + }, [enabled]) +} diff --git a/src/renderer/src/lib/primary-selection-capture.ts b/src/renderer/src/lib/primary-selection-capture.ts new file mode 100644 index 00000000000..5460de8b3fd --- /dev/null +++ b/src/renderer/src/lib/primary-selection-capture.ts @@ -0,0 +1,108 @@ +import { PRIMARY_SELECTION_MAX_LENGTH } from './primary-selection' + +const TEXT_INPUT_TYPES = new Set(['', 'email', 'password', 'search', 'tel', 'text', 'url']) + +function isTextInputElement(element: Element): element is HTMLInputElement { + return element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type) +} + +export function isPrimarySelectionTextControl( + element: Element +): element is HTMLInputElement | HTMLTextAreaElement { + return isTextInputElement(element) || element instanceof HTMLTextAreaElement +} + +function readTextControlSelection(element: HTMLInputElement | HTMLTextAreaElement): string | null { + if (element instanceof HTMLInputElement && element.type === 'password') { + return null + } + + try { + const start = element.selectionStart + const end = element.selectionEnd + if (start === null || end === null || start === end) { + return null + } + if (Math.abs(end - start) > PRIMARY_SELECTION_MAX_LENGTH) { + return null + } + return element.value.slice(Math.min(start, end), Math.max(start, end)) + } catch { + return null + } +} + +function getRangeTextLengthUpTo(range: Range, maxLength: number): number { + let length = 0 + const root = range.commonAncestorContainer + const ownerDocument = root.ownerDocument ?? document + + const addTextNode = (node: Text): boolean => { + if (!range.intersectsNode(node)) { + return false + } + let start = 0 + let end = node.data.length + if (node === range.startContainer) { + start = range.startOffset + } + if (node === range.endContainer) { + end = range.endOffset + } + length += Math.max(0, end - start) + return length > maxLength + } + + if (root.nodeType === Node.TEXT_NODE) { + addTextNode(root as Text) + return length + } + + const walker = ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + while (node) { + if (addTextNode(node as Text)) { + return length + } + node = walker.nextNode() + } + return length +} + +function selectionTextLengthExceeds(selection: Selection, maxLength: number): boolean { + let length = 0 + for (let index = 0; index < selection.rangeCount; index += 1) { + length += getRangeTextLengthUpTo(selection.getRangeAt(index), maxLength - length) + if (length > maxLength) { + return true + } + } + return false +} + +function readDocumentSelection(): string | null { + const selection = window.getSelection() + if (!selection || selection.isCollapsed) { + return null + } + if (selectionTextLengthExceeds(selection, PRIMARY_SELECTION_MAX_LENGTH)) { + return null + } + const text = selection.toString() + return text.length > 0 ? text : null +} + +export function readCurrentPrimarySelectionText(): string | null { + const activeElement = document.activeElement + if (activeElement instanceof Element) { + const textControl = activeElement.closest('input, textarea') + if (textControl && isPrimarySelectionTextControl(textControl)) { + const text = readTextControlSelection(textControl) + if (text) { + return text + } + } + } + + return readDocumentSelection() +} diff --git a/src/renderer/src/lib/primary-selection-paste.ts b/src/renderer/src/lib/primary-selection-paste.ts new file mode 100644 index 00000000000..658f2673b65 --- /dev/null +++ b/src/renderer/src/lib/primary-selection-paste.ts @@ -0,0 +1,149 @@ +import { isPrimarySelectionTextControl } from './primary-selection-capture' + +export type EditablePrimarySelectionPasteTarget = + | HTMLInputElement + | HTMLTextAreaElement + | HTMLElement + +function dispatchInputEvent(target: Element, text: string): void { + const event = + typeof InputEvent === 'function' + ? new InputEvent('input', { + bubbles: true, + cancelable: false, + data: text, + inputType: 'insertFromPaste' + }) + : new Event('input', { bubbles: true, cancelable: false }) + target.dispatchEvent(event) +} + +function pasteIntoTextControl( + target: HTMLInputElement | HTMLTextAreaElement, + text: string +): boolean { + if (target.disabled || target.readOnly) { + return false + } + try { + target.focus() + const start = target.selectionStart ?? target.value.length + const end = target.selectionEnd ?? start + target.setRangeText(text, Math.min(start, end), Math.max(start, end), 'end') + dispatchInputEvent(target, text) + return true + } catch { + return false + } +} + +type CaretRangeDocument = Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null +} + +function setContentEditableCaretFromPoint( + target: HTMLElement, + point: { clientX: number; clientY: number } +): void { + const ownerDocument = target.ownerDocument + const selection = ownerDocument.getSelection() + if (!selection) { + return + } + + const caretPosition = ownerDocument.caretPositionFromPoint?.(point.clientX, point.clientY) + const range = caretPosition + ? ownerDocument.createRange() + : (ownerDocument as CaretRangeDocument).caretRangeFromPoint?.(point.clientX, point.clientY) + + if (caretPosition && range) { + range.setStart(caretPosition.offsetNode, caretPosition.offset) + range.collapse(true) + } + + if (!range || !target.contains(range.startContainer)) { + return + } + + selection.removeAllRanges() + selection.addRange(range) +} + +function insertTextIntoContentEditable(target: HTMLElement, text: string): boolean { + const ownerDocument = target.ownerDocument + if ( + ownerDocument.queryCommandSupported?.('insertText') && + ownerDocument.execCommand('insertText', false, text) + ) { + return true + } + + const selection = ownerDocument.getSelection() + if (!selection || selection.rangeCount === 0) { + return false + } + + const range = selection.getRangeAt(0) + range.deleteContents() + const textNode = ownerDocument.createTextNode(text) + range.insertNode(textNode) + range.setStartAfter(textNode) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + dispatchInputEvent(target, text) + return true +} + +function pasteIntoContentEditable( + target: HTMLElement, + text: string, + point: { clientX: number; clientY: number } +): boolean { + target.focus() + setContentEditableCaretFromPoint(target, point) + return insertTextIntoContentEditable(target, text) +} + +export function findEditablePrimarySelectionPasteTarget( + target: EventTarget | null +): EditablePrimarySelectionPasteTarget | null { + if (!(target instanceof Element)) { + return null + } + if (target.closest('.xterm-helper-textarea')) { + return null + } + + const textControl = target.closest('input, textarea') + if (textControl && isPrimarySelectionTextControl(textControl)) { + if (textControl.disabled || textControl.readOnly) { + return null + } + return textControl + } + + let element: HTMLElement | null = target instanceof HTMLElement ? target : target.parentElement + while (element) { + if (element.getAttribute('contenteditable') === 'false') { + return null + } + if (element.isContentEditable) { + return element + } + element = element.parentElement + } + + return null +} + +export function pastePrimarySelectionTextIntoTarget( + target: EditablePrimarySelectionPasteTarget, + text: string, + point: { clientX: number; clientY: number } +): boolean { + if (isPrimarySelectionTextControl(target)) { + return pasteIntoTextControl(target, text) + } + return pasteIntoContentEditable(target, text, point) +} diff --git a/src/renderer/src/lib/primary-selection.test.ts b/src/renderer/src/lib/primary-selection.test.ts new file mode 100644 index 00000000000..8633e34ed36 --- /dev/null +++ b/src/renderer/src/lib/primary-selection.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PRIMARY_SELECTION_MAX_LENGTH, + getPrimarySelectionText, + readPrimarySelectionText, + resetPrimarySelectionForTests, + setPrimarySelectionEnabled, + setPrimarySelectionText, + shouldUseSystemPrimarySelectionClipboard +} from './primary-selection' + +describe('primary selection buffer', () => { + beforeEach(() => { + resetPrimarySelectionForTests() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('ignores writes while disabled', () => { + expect(setPrimarySelectionText('hello')).toBe(false) + expect(getPrimarySelectionText()).toBe('') + }) + + it('stores selected text while enabled', () => { + setPrimarySelectionEnabled(true) + + expect(setPrimarySelectionText('hello')).toBe(true) + expect(getPrimarySelectionText()).toBe('hello') + }) + + it('keeps the current buffer when a selection is empty or too large', () => { + setPrimarySelectionEnabled(true) + setPrimarySelectionText('current') + + expect(setPrimarySelectionText('')).toBe(false) + expect(getPrimarySelectionText()).toBe('current') + + expect(setPrimarySelectionText('x'.repeat(PRIMARY_SELECTION_MAX_LENGTH + 1))).toBe(false) + expect(getPrimarySelectionText()).toBe('current') + }) + + it('clears the buffer when disabled', () => { + setPrimarySelectionEnabled(true) + setPrimarySelectionText('hello') + + setPrimarySelectionEnabled(false) + + expect(getPrimarySelectionText()).toBe('') + }) + + it('uses the system selection clipboard on Linux when the preload API exists', async () => { + const readSelectionClipboardText = vi.fn(async () => 'from-system') + const writeSelectionClipboardText = vi.fn(async () => {}) + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' }) + vi.stubGlobal('window', { + api: { + ui: { + readSelectionClipboardText, + writeSelectionClipboardText + } + } + }) + setPrimarySelectionEnabled(true) + + expect(shouldUseSystemPrimarySelectionClipboard()).toBe(true) + expect(setPrimarySelectionText('hello')).toBe(true) + expect(writeSelectionClipboardText).toHaveBeenCalledWith('hello') + await expect(readPrimarySelectionText()).resolves.toBe('from-system') + expect(readSelectionClipboardText).toHaveBeenCalled() + }) + + it('keeps the private buffer on non-Linux platforms', async () => { + const writeSelectionClipboardText = vi.fn(async () => {}) + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + vi.stubGlobal('window', { + api: { + ui: { + readSelectionClipboardText: vi.fn(async () => 'from-system'), + writeSelectionClipboardText + } + } + }) + setPrimarySelectionEnabled(true) + + expect(shouldUseSystemPrimarySelectionClipboard()).toBe(false) + expect(setPrimarySelectionText('hello')).toBe(true) + expect(writeSelectionClipboardText).not.toHaveBeenCalled() + await expect(readPrimarySelectionText()).resolves.toBe('hello') + }) + + it('falls back to the private buffer if the system selection write fails', async () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' }) + vi.stubGlobal('window', { + api: { + ui: { + readSelectionClipboardText: vi.fn(async () => { + throw new Error('read failed') + }), + writeSelectionClipboardText: vi.fn(async () => { + throw new Error('write failed') + }) + } + } + }) + setPrimarySelectionEnabled(true) + + expect(setPrimarySelectionText('hello')).toBe(true) + await Promise.resolve() + await expect(readPrimarySelectionText()).resolves.toBe('hello') + }) + + it('falls back to the mirrored private buffer if the system selection read fails later', async () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' }) + vi.stubGlobal('window', { + api: { + ui: { + readSelectionClipboardText: vi.fn(async () => { + throw new Error('read failed') + }), + writeSelectionClipboardText: vi.fn(async () => {}) + } + } + }) + setPrimarySelectionEnabled(true) + + expect(setPrimarySelectionText('hello')).toBe(true) + await expect(readPrimarySelectionText()).resolves.toBe('hello') + }) +}) diff --git a/src/renderer/src/lib/primary-selection.ts b/src/renderer/src/lib/primary-selection.ts new file mode 100644 index 00000000000..d7908a54935 --- /dev/null +++ b/src/renderer/src/lib/primary-selection.ts @@ -0,0 +1,92 @@ +export const PRIMARY_SELECTION_MAX_LENGTH = 65_536 + +let enabled = false +let primarySelectionText = '' + +type SelectionClipboardApi = { + readSelectionClipboardText: () => Promise + writeSelectionClipboardText: (text: string) => Promise +} + +function isLinuxUserAgent(userAgent: string): boolean { + return !userAgent.includes('Mac') && !userAgent.includes('Windows') && userAgent.includes('Linux') +} + +function getUserAgent(): string { + return typeof navigator === 'undefined' ? '' : navigator.userAgent +} + +function getSelectionClipboardApi(): SelectionClipboardApi | null { + if (typeof window === 'undefined') { + return null + } + const uiApi = window.api?.ui + if ( + typeof uiApi?.readSelectionClipboardText !== 'function' || + typeof uiApi.writeSelectionClipboardText !== 'function' + ) { + return null + } + return uiApi +} + +export function shouldUseSystemPrimarySelectionClipboard(userAgent = getUserAgent()): boolean { + return isLinuxUserAgent(userAgent) && getSelectionClipboardApi() !== null +} + +function canStorePrimarySelectionText(text: string): boolean { + return enabled && text.length > 0 && text.length <= PRIMARY_SELECTION_MAX_LENGTH +} + +export function setPrimarySelectionEnabled(nextEnabled: boolean): void { + enabled = nextEnabled + if (!enabled) { + primarySelectionText = '' + } +} + +export function isPrimarySelectionEnabled(): boolean { + return enabled +} + +export function getPrimarySelectionText(): string { + return enabled ? primarySelectionText : '' +} + +export function setPrimarySelectionText(text: string): boolean { + if (!canStorePrimarySelectionText(text)) { + return false + } + primarySelectionText = text + const selectionClipboardApi = shouldUseSystemPrimarySelectionClipboard() + ? getSelectionClipboardApi() + : null + if (selectionClipboardApi) { + void selectionClipboardApi.writeSelectionClipboardText(text).catch(() => {}) + return true + } + + return true +} + +export async function readPrimarySelectionText(): Promise { + if (!enabled) { + return '' + } + const selectionClipboardApi = shouldUseSystemPrimarySelectionClipboard() + ? getSelectionClipboardApi() + : null + if (!selectionClipboardApi) { + return primarySelectionText + } + try { + return await selectionClipboardApi.readSelectionClipboardText() + } catch { + return primarySelectionText + } +} + +export function resetPrimarySelectionForTests(): void { + enabled = false + primarySelectionText = '' +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 6c8b3af795e..d61a9cebffe 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -303,6 +303,7 @@ export type UISlice = { | 'general' | 'browser' | 'appearance' + | 'input' | 'tasks' | 'terminal' | 'computer-use' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7f09455ea68..cb2cfe3e21a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -693,8 +693,12 @@ function createWebUiApi(): NonNullable['ui']> { writeJson(UI_STORAGE_KEY, next) }, readClipboardText: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''), + readSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), saveClipboardImageAsTempFile: () => Promise.resolve(null), writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(), + writeSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), writeClipboardImage: () => Promise.resolve(), getZoomLevel: () => zoomLevel, setZoomLevel: (level) => { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e5cf126fad5..0025388ea85 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from './constants' +import { getDefaultPrimarySelectionMiddleClickPaste, getDefaultSettings } from './constants' describe('getDefaultSettings', () => { it('enables gitignored file decorations by default', () => { @@ -18,3 +18,14 @@ describe('getDefaultSettings', () => { }) }) }) + +describe('getDefaultPrimarySelectionMiddleClickPaste', () => { + it('enables primary selection paste on Linux by default', () => { + expect(getDefaultPrimarySelectionMiddleClickPaste('linux')).toBe(true) + }) + + it('leaves primary selection paste opt-in on macOS and Windows', () => { + expect(getDefaultPrimarySelectionMiddleClickPaste('darwin')).toBe(false) + expect(getDefaultPrimarySelectionMiddleClickPaste('win32')).toBe(false) + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3e21a440a99..8d7dd9fe948 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -59,6 +59,10 @@ function defaultTerminalFontFamily(): string { } return 'SF Mono' // macOS default } + +export const getDefaultPrimarySelectionMiddleClickPaste = ( + platform = typeof process !== 'undefined' ? process.platform : '' +): boolean => platform === 'linux' /** * Why: ProseMirror builds an in-memory tree for the entire document, so large * markdown files cause noticeable typing lag in the rich editor. Files above @@ -168,6 +172,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, markdownReviewToolsEnabled: true, + primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), terminalFontSize: 14, terminalFontFamily: defaultTerminalFontFamily(), terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, diff --git a/src/shared/types.ts b/src/shared/types.ts index 2fae8cfdb5d..07812e23ac1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1271,6 +1271,10 @@ export type GlobalSettings = { editorMinimapEnabled: boolean /** Whether local markdown review note controls and the review panel are shown. */ markdownReviewToolsEnabled: boolean + /** Why: mirrors X11 primary-selection muscle memory without mutating the + * normal system clipboard; Linux enables it by default, other platforms + * leave middle-click semantics unchanged unless the user opts in. */ + primarySelectionMiddleClickPaste?: boolean terminalFontSize: number terminalFontFamily: string terminalFontWeight: number