mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
@@ -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', () => {
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 = ''
|
||||
}
|
||||
@@ -303,6 +303,7 @@ export type UISlice = {
|
||||
| 'general'
|
||||
| 'browser'
|
||||
| 'appearance'
|
||||
| 'input'
|
||||
| 'tasks'
|
||||
| 'terminal'
|
||||
| 'computer-use'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user