From 8d26c2c154ae3906a2de05d8510e1244f6afeab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 15 Jun 2026 22:50:44 +0800 Subject: [PATCH] fix(desktop): support terminal clipboard shortcuts (#837) Tested: cd desktop && bun run test --run src/lib/desktopHost/contract.test.ts src/pages/TerminalSettings.test.tsx src/lib/desktopHost/electronHost.test.ts electron/ipc/capabilities.test.ts Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: bun run check:native Not-tested: bun run check:coverage Confidence: high Scope-risk: moderate --- desktop/electron/ipc/capabilities.test.ts | 3 + desktop/electron/ipc/capabilities.ts | 2 + desktop/electron/ipc/channels.ts | 2 + desktop/electron/main.ts | 4 +- desktop/src/lib/desktopHost/browserHost.ts | 16 +++ desktop/src/lib/desktopHost/contract.test.ts | 1 + .../src/lib/desktopHost/electronHost.test.ts | 14 +++ desktop/src/lib/desktopHost/electronHost.ts | 5 + desktop/src/lib/desktopHost/types.ts | 5 + desktop/src/pages/TerminalSettings.test.tsx | 100 ++++++++++++++++++ desktop/src/pages/TerminalSettings.tsx | 95 ++++++++++++++++- 11 files changed, 245 insertions(+), 2 deletions(-) diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index eb94899a..5a039135 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -21,6 +21,9 @@ describe('Electron IPC capabilities', () => { it('validates structured payloads before they reach ipcRenderer.invoke', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardReadText, undefined)).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, 'paste me')).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 6f65566b..0c1ca754 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -73,6 +73,8 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload, [ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload, [ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke, + [ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload, + [ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload, [ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index a0bbe147..0580ce05 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -2,6 +2,8 @@ export const ELECTRON_IPC_CHANNELS = { appGetVersion: 'desktop:app:get-version', runtimeGetServerUrl: 'desktop:runtime:get-server-url', commandInvoke: 'desktop:command:invoke', + clipboardReadText: 'desktop:clipboard:read-text', + clipboardWriteText: 'desktop:clipboard:write-text', shellOpen: 'desktop:shell:open', shellOpenPath: 'desktop:shell:open-path', traceOpenWindow: 'desktop:trace:open-window', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index ecf22a0a..5be323cb 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, Notification, screen, session, WebContentsView } from 'electron' +import { app, BrowserWindow, clipboard, ipcMain, Notification, screen, session, WebContentsView } from 'electron' import { autoUpdater } from 'electron-updater' import path from 'node:path' import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels' @@ -257,6 +257,8 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion()) registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl()) registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload)) + registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText()) + registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload))) diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index 47194ab6..9851fb61 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -9,6 +9,7 @@ import { buildTraceWindowUrl } from '../traceLaunch' const browserCapabilities: DesktopHostCapabilities = { appMode: false, + clipboard: false, dialogs: false, notifications: false, previewWebview: false, @@ -54,6 +55,21 @@ export const browserHost: DesktopHost = { unsupported('Native commands') }, }, + clipboard: { + async readText() { + if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) { + return navigator.clipboard.readText() + } + unsupported('Reading clipboard text') + }, + async writeText(text) { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return + } + unsupported('Writing clipboard text') + }, + }, events: { async listen() { return noopUnlisten diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts index e394ed56..cb24b341 100644 --- a/desktop/src/lib/desktopHost/contract.test.ts +++ b/desktop/src/lib/desktopHost/contract.test.ts @@ -9,6 +9,7 @@ describe('desktop host contract', () => { expect(browserHost.isDesktop).toBe(false) expect(browserHost.capabilities).toEqual({ appMode: false, + clipboard: false, dialogs: false, notifications: false, previewWebview: false, diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index bd4884ca..0bcb0396 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -23,6 +23,20 @@ describe('electron desktop host', () => { }) }) + it('routes clipboard reads and writes through narrow IPC channels', async () => { + const invoke = vi.fn().mockResolvedValueOnce('from clipboard').mockResolvedValueOnce(undefined) + const host = createElectronHost({ + invoke, + subscribe: vi.fn(), + }) + + await expect(host.clipboard.readText()).resolves.toBe('from clipboard') + await host.clipboard.writeText('to clipboard') + + expect(invoke).toHaveBeenNthCalledWith(1, ELECTRON_IPC_CHANNELS.clipboardReadText, undefined) + expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard') + }) + it('rejects invalid preload payloads before invoking Electron IPC', async () => { const invoke = vi.fn() const host = createElectronHost({ diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index bb04587a..55ab31eb 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -63,6 +63,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { isDesktop: true, capabilities: { appMode: true, + clipboard: true, dialogs: true, notifications: true, previewWebview: true, @@ -81,6 +82,10 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { commands: { invoke: (command, args) => invoke(ELECTRON_IPC_CHANNELS.commandInvoke, { command, args }), }, + clipboard: { + readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText), + writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text), + }, events: { listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler), }, diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index 0fb99a9f..d0005a9f 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -7,6 +7,7 @@ export type DesktopHostKind = 'browser' | 'electron' export type DesktopHostCapability = | 'appMode' + | 'clipboard' | 'dialogs' | 'notifications' | 'previewWebview' @@ -153,6 +154,10 @@ export type DesktopHost = { commands: { invoke(command: string, args?: Record): Promise } + clipboard: { + readText(): Promise + writeText(text: string): Promise + } events: { listen(eventName: string, handler: (payload: T) => void): Promise } diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 3b90eca2..0a528a94 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -16,6 +16,10 @@ const terminalMocks = vi.hoisted(() => { write: vi.fn(), writeln: vi.fn(), clear: vi.fn(), + focus: vi.fn(), + getSelection: vi.fn(), + hasSelection: vi.fn(), + paste: vi.fn(), } const fitInstance = { fit: vi.fn(), @@ -86,6 +90,12 @@ describe('TerminalSettings', () => { terminalMocks.terminalInstance.write.mockClear() terminalMocks.terminalInstance.writeln.mockClear() terminalMocks.terminalInstance.clear.mockClear() + terminalMocks.terminalInstance.focus.mockClear() + terminalMocks.terminalInstance.getSelection.mockReset() + terminalMocks.terminalInstance.hasSelection.mockReset() + terminalMocks.terminalInstance.paste.mockClear() + terminalMocks.terminalInstance.getSelection.mockReturnValue('') + terminalMocks.terminalInstance.hasSelection.mockReturnValue(false) terminalMocks.fitInstance.fit.mockClear() terminalMocks.onOutput.mockResolvedValue(vi.fn()) terminalMocks.onExit.mockResolvedValue(vi.fn()) @@ -215,6 +225,96 @@ describe('TerminalSettings', () => { expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') }) + it('copies the terminal selection through the desktop clipboard on Windows shortcuts', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + terminalMocks.terminalInstance.hasSelection.mockReturnValue(true) + terminalMocks.terminalInstance.getSelection.mockReturnValue('Microsoft Windows [Version 10.0.19045]') + const writeText = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + capabilities: { ...browserHost.capabilities, clipboard: true }, + clipboard: { + readText: vi.fn(), + writeText, + }, + } + + render() + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled()) + + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'c', ctrlKey: true }) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith('Microsoft Windows [Version 10.0.19045]') + }) + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'C', ctrlKey: true, shiftKey: true }) + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', ctrlKey: true }) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledTimes(3) + }) + expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled() + }) + + it('pastes clipboard text into xterm on Windows paste shortcuts', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + const readText = vi.fn().mockResolvedValue('dir') + window.desktopHost = { + ...browserHost, + capabilities: { ...browserHost.capabilities, clipboard: true }, + clipboard: { + readText, + writeText: vi.fn(), + }, + } + + render() + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled()) + + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'v', ctrlKey: true }) + + await waitFor(() => { + expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledWith('dir') + }) + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'V', ctrlKey: true, shiftKey: true }) + fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', shiftKey: true }) + + await waitFor(() => { + expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledTimes(3) + }) + expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled() + }) + + it('does not intercept Ctrl+C as copy when the Windows terminal has no selection', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + const writeText = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + capabilities: { ...browserHost.capabilities, clipboard: true }, + clipboard: { + readText: vi.fn(), + writeText, + }, + } + + render() + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled()) + + const event = new window.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'c', + }) + screen.getByTestId('settings-terminal-frame').dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + expect(writeText).not.toHaveBeenCalled() + }) + it('can preserve and reattach a running terminal runtime across unmounts', async () => { terminalMocks.available = true diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index eeb20ec3..f15a8829 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState, type WheelEvent } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type WheelEvent } from 'react' import { Info } from 'lucide-react' import { useTranslation, type TranslationKey } from '../i18n' import { terminalApi } from '../api/terminal' @@ -312,6 +312,24 @@ export function TerminalSettings({ scroller.scrollBy({ top: event.deltaY, left: event.deltaX }) }, []) + const handleTerminalKeyDownCapture = useCallback((event: KeyboardEvent) => { + const terminal = runtime.terminal + if (!terminal) return + + if (isTerminalCopyShortcut(event, terminal)) { + event.preventDefault() + event.stopPropagation() + void copyTerminalSelection(terminal).catch(() => {}) + return + } + + if (isTerminalPasteShortcut(event)) { + event.preventDefault() + event.stopPropagation() + void pasteClipboardIntoTerminal(terminal).catch(() => {}) + } + }, [runtime]) + const savePreferences = async () => { setPreferencesError(null) setPreferencesSaved(false) @@ -523,6 +541,7 @@ export function TerminalSettings({ ) : (
@@ -537,6 +556,80 @@ export function TerminalSettings({ ) } +type TerminalKeyboardEvent = Pick, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'> +type ClipboardTerminal = { + focus(): void + getSelection(): string + hasSelection(): boolean + paste(data: string): void +} + +function isApplePlatform() { + if (typeof navigator === 'undefined') return false + return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) +} + +function isWindowsPlatform() { + if (typeof navigator === 'undefined') return false + return /Win/i.test(navigator.platform || navigator.userAgent) +} + +function normalizedKey(event: TerminalKeyboardEvent) { + return event.key.toLowerCase() +} + +function isTerminalCopyShortcut(event: TerminalKeyboardEvent, terminal: ClipboardTerminal) { + if (event.altKey || !terminal.hasSelection()) return false + + const key = normalizedKey(event) + if (isApplePlatform()) { + return event.metaKey && !event.ctrlKey && key === 'c' + } + + if (key === 'insert') { + return event.ctrlKey && !event.shiftKey && !event.metaKey + } + + if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'c') { + return true + } + + return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'c' +} + +function isTerminalPasteShortcut(event: TerminalKeyboardEvent) { + if (event.altKey) return false + + const key = normalizedKey(event) + if (isApplePlatform()) { + return event.metaKey && !event.ctrlKey && key === 'v' + } + + if (key === 'insert') { + return event.shiftKey && !event.ctrlKey && !event.metaKey + } + + if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'v') { + return true + } + + return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'v' +} + +async function copyTerminalSelection(terminal: ClipboardTerminal) { + const text = terminal.getSelection() + if (!text) return + await getDesktopHost().clipboard.writeText(text) + terminal.focus() +} + +async function pasteClipboardIntoTerminal(terminal: ClipboardTerminal) { + const text = await getDesktopHost().clipboard.readText() + if (!text) return + terminal.paste(text) + terminal.focus() +} + function TerminalHelpHint({ compact = false }: { compact?: boolean }) { const t = useTranslation() const tooltipId = useId()