mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +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', () => {
|
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, 'https://example.com')).toBe(true)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false)
|
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, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
|
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
|
||||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
|
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.appGetVersion]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
|
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
|
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
|
||||||
|
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
|
||||||
|
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
|
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
|
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
|
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ export const ELECTRON_IPC_CHANNELS = {
|
|||||||
appGetVersion: 'desktop:app:get-version',
|
appGetVersion: 'desktop:app:get-version',
|
||||||
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
|
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
|
||||||
commandInvoke: 'desktop:command:invoke',
|
commandInvoke: 'desktop:command:invoke',
|
||||||
|
clipboardReadText: 'desktop:clipboard:read-text',
|
||||||
|
clipboardWriteText: 'desktop:clipboard:write-text',
|
||||||
shellOpen: 'desktop:shell:open',
|
shellOpen: 'desktop:shell:open',
|
||||||
shellOpenPath: 'desktop:shell:open-path',
|
shellOpenPath: 'desktop:shell:open-path',
|
||||||
traceOpenWindow: 'desktop:trace:open-window',
|
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 { autoUpdater } from 'electron-updater'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
|
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.appGetVersion, () => app.getVersion())
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
|
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
|
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.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
|
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
|
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { buildTraceWindowUrl } from '../traceLaunch'
|
|||||||
|
|
||||||
const browserCapabilities: DesktopHostCapabilities = {
|
const browserCapabilities: DesktopHostCapabilities = {
|
||||||
appMode: false,
|
appMode: false,
|
||||||
|
clipboard: false,
|
||||||
dialogs: false,
|
dialogs: false,
|
||||||
notifications: false,
|
notifications: false,
|
||||||
previewWebview: false,
|
previewWebview: false,
|
||||||
@ -54,6 +55,21 @@ export const browserHost: DesktopHost = {
|
|||||||
unsupported('Native commands')
|
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: {
|
events: {
|
||||||
async listen() {
|
async listen() {
|
||||||
return noopUnlisten
|
return noopUnlisten
|
||||||
|
|||||||
@ -9,6 +9,7 @@ describe('desktop host contract', () => {
|
|||||||
expect(browserHost.isDesktop).toBe(false)
|
expect(browserHost.isDesktop).toBe(false)
|
||||||
expect(browserHost.capabilities).toEqual({
|
expect(browserHost.capabilities).toEqual({
|
||||||
appMode: false,
|
appMode: false,
|
||||||
|
clipboard: false,
|
||||||
dialogs: false,
|
dialogs: false,
|
||||||
notifications: false,
|
notifications: false,
|
||||||
previewWebview: 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 () => {
|
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
|
||||||
const invoke = vi.fn()
|
const invoke = vi.fn()
|
||||||
const host = createElectronHost({
|
const host = createElectronHost({
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
appMode: true,
|
appMode: true,
|
||||||
|
clipboard: true,
|
||||||
dialogs: true,
|
dialogs: true,
|
||||||
notifications: true,
|
notifications: true,
|
||||||
previewWebview: true,
|
previewWebview: true,
|
||||||
@ -81,6 +82,10 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
commands: {
|
commands: {
|
||||||
invoke: (command, args) => invoke(ELECTRON_IPC_CHANNELS.commandInvoke, { command, args }),
|
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: {
|
events: {
|
||||||
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
|
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type DesktopHostKind = 'browser' | 'electron'
|
|||||||
|
|
||||||
export type DesktopHostCapability =
|
export type DesktopHostCapability =
|
||||||
| 'appMode'
|
| 'appMode'
|
||||||
|
| 'clipboard'
|
||||||
| 'dialogs'
|
| 'dialogs'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'previewWebview'
|
| 'previewWebview'
|
||||||
@ -153,6 +154,10 @@ export type DesktopHost = {
|
|||||||
commands: {
|
commands: {
|
||||||
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>
|
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>
|
||||||
}
|
}
|
||||||
|
clipboard: {
|
||||||
|
readText(): Promise<string>
|
||||||
|
writeText(text: string): Promise<void>
|
||||||
|
}
|
||||||
events: {
|
events: {
|
||||||
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,10 @@ const terminalMocks = vi.hoisted(() => {
|
|||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
writeln: vi.fn(),
|
writeln: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
|
focus: vi.fn(),
|
||||||
|
getSelection: vi.fn(),
|
||||||
|
hasSelection: vi.fn(),
|
||||||
|
paste: vi.fn(),
|
||||||
}
|
}
|
||||||
const fitInstance = {
|
const fitInstance = {
|
||||||
fit: vi.fn(),
|
fit: vi.fn(),
|
||||||
@ -86,6 +90,12 @@ describe('TerminalSettings', () => {
|
|||||||
terminalMocks.terminalInstance.write.mockClear()
|
terminalMocks.terminalInstance.write.mockClear()
|
||||||
terminalMocks.terminalInstance.writeln.mockClear()
|
terminalMocks.terminalInstance.writeln.mockClear()
|
||||||
terminalMocks.terminalInstance.clear.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.fitInstance.fit.mockClear()
|
||||||
terminalMocks.onOutput.mockResolvedValue(vi.fn())
|
terminalMocks.onOutput.mockResolvedValue(vi.fn())
|
||||||
terminalMocks.onExit.mockResolvedValue(vi.fn())
|
terminalMocks.onExit.mockResolvedValue(vi.fn())
|
||||||
@ -215,6 +225,96 @@ describe('TerminalSettings', () => {
|
|||||||
expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n')
|
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 () => {
|
it('can preserve and reattach a running terminal runtime across unmounts', async () => {
|
||||||
terminalMocks.available = true
|
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 { Info } from 'lucide-react'
|
||||||
import { useTranslation, type TranslationKey } from '../i18n'
|
import { useTranslation, type TranslationKey } from '../i18n'
|
||||||
import { terminalApi } from '../api/terminal'
|
import { terminalApi } from '../api/terminal'
|
||||||
@ -312,6 +312,24 @@ export function TerminalSettings({
|
|||||||
scroller.scrollBy({ top: event.deltaY, left: event.deltaX })
|
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 () => {
|
const savePreferences = async () => {
|
||||||
setPreferencesError(null)
|
setPreferencesError(null)
|
||||||
setPreferencesSaved(false)
|
setPreferencesSaved(false)
|
||||||
@ -523,6 +541,7 @@ export function TerminalSettings({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
data-testid="settings-terminal-frame"
|
data-testid="settings-terminal-frame"
|
||||||
|
onKeyDownCapture={handleTerminalKeyDownCapture}
|
||||||
onWheelCapture={handleTerminalWheelCapture}
|
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)]"
|
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 }) {
|
function TerminalHelpHint({ compact = false }: { compact?: boolean }) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const tooltipId = useId()
|
const tooltipId = useId()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user