mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
This commit is contained in:
parent
3e6fe7d6bb
commit
8d26c2c154
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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<T>(command: string, args?: Record<string, unknown>): Promise<T>
|
||||
}
|
||||
clipboard: {
|
||||
readText(): Promise<string>
|
||||
writeText(text: string): Promise<void>
|
||||
}
|
||||
events: {
|
||||
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
||||
}
|
||||
|
||||
@ -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(<TerminalSettings />)
|
||||
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(<TerminalSettings />)
|
||||
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(<TerminalSettings />)
|
||||
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
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>) => {
|
||||
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({
|
||||
) : (
|
||||
<div
|
||||
data-testid="settings-terminal-frame"
|
||||
onKeyDownCapture={handleTerminalKeyDownCapture}
|
||||
onWheelCapture={handleTerminalWheelCapture}
|
||||
className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-sm)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]"
|
||||
>
|
||||
@ -537,6 +556,80 @@ export function TerminalSettings({
|
||||
)
|
||||
}
|
||||
|
||||
type TerminalKeyboardEvent = Pick<KeyboardEvent<HTMLElement>, '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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user