Add primary selection middle-click paste

Closes #1898
This commit is contained in:
Neil
2026-05-15 22:49:41 -07:00
committed by GitHub
parent 2ead67c650
commit e7eb42659c
27 changed files with 1192 additions and 53 deletions
+18
View File
@@ -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', () => {
+7
View File
@@ -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' }
}
}
+4 -1
View File
@@ -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,
+2 -4
View File
@@ -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'
+1 -44
View File
@@ -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')
@@ -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<string, (...args: unknown[]) => unknown> {
const handlers = new Map<string, (...args: unknown[]) => 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')
})
})
+51
View File
@@ -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)
})
}
+2
View File
@@ -1537,8 +1537,10 @@ export type PreloadApi = {
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
readSelectionClipboardText: () => Promise<string>
saveClipboardImageAsTempFile: () => Promise<string | null>
writeClipboardText: (text: string) => Promise<void>
writeSelectionClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
onFileDrop: (
callback: (
+4
View File
@@ -2334,10 +2334,14 @@ const api = {
return () => ipcRenderer.removeListener('terminal:zoom', listener)
},
readClipboardText: (): Promise<string> => ipcRenderer.invoke('clipboard:readText'),
readSelectionClipboardText: (): Promise<string> =>
ipcRenderer.invoke('clipboard:readSelectionText'),
saveClipboardImageAsTempFile: (): Promise<string | null> =>
ipcRenderer.invoke('clipboard:saveImageAsTempFile'),
writeClipboardText: (text: string): Promise<void> =>
ipcRenderer.invoke('clipboard:writeText', text),
writeSelectionClipboardText: (text: string): Promise<void> =>
ipcRenderer.invoke('clipboard:writeSelectionText', text),
writeClipboardImage: (dataUrl: string): Promise<void> =>
ipcRenderer.invoke('clipboard:writeImage', dataUrl),
onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) =>
+8
View File
@@ -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)
@@ -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<number | null>
}): 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<boolean> => {
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)
@@ -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<GlobalSettings>) => void
}
export function InputPane({ settings, updateSettings }: InputPaneProps): React.JSX.Element {
const enabled = settings.primarySelectionMiddleClickPaste ?? isLinuxUserAgent()
return (
<section className="space-y-4">
<SearchableSetting
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'
]}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Middle-click Paste from Selection</Label>
<p className="text-xs text-muted-foreground">
On Linux, use the system selection clipboard. On other platforms, use a private buffer
when this is enabled.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() =>
updateSettings({
primarySelectionMiddleClickPaste: !enabled
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
)
}
@@ -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}
</SettingsSection>
<SettingsSection
id="input"
title="Input & Editing"
description="Selection and editing behavior."
searchEntries={INPUT_PANE_SEARCH_ENTRIES}
>
<InputPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="terminal"
title="Terminal"
@@ -10,6 +10,7 @@ export const SETTING_LABELS: Partial<Record<keyof GlobalSettings, string>> = {
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',
@@ -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<HTMLDivElement>): 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<HTMLDivElement>): 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()
@@ -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
@@ -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<React.SetStateAction<number>>
}
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<string, string> }
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<number, IDisposable>())
const selectionCaptureTimersRef = useRef(new Map<number, number>())
const mode2031DisposablesRef = useRef(new Map<number, IDisposable[]>())
const osc52DisposablesRef = useRef(new Map<number, IDisposable>())
const osc7DisposablesRef = useRef(new Map<number, IDisposable>())
@@ -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()
}
@@ -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])
}
@@ -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()
}
@@ -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)
}
@@ -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')
})
})
+92
View File
@@ -0,0 +1,92 @@
export const PRIMARY_SELECTION_MAX_LENGTH = 65_536
let enabled = false
let primarySelectionText = ''
type SelectionClipboardApi = {
readSelectionClipboardText: () => Promise<string>
writeSelectionClipboardText: (text: string) => Promise<void>
}
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<string> {
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 = ''
}
+1
View File
@@ -303,6 +303,7 @@ export type UISlice = {
| 'general'
| 'browser'
| 'appearance'
| 'input'
| 'tasks'
| 'terminal'
| 'computer-use'
+4
View File
@@ -693,8 +693,12 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['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) => {
+12 -1
View File
@@ -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)
})
})
+5
View File
@@ -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,
+4
View File
@@ -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