fix: restore terminal paste and square workbar chrome
Terminal paste previously relied on the Renderer browser Clipboard API, which could be denied by Electron and leave the toolbar action unusable. Text clipboard access now goes through the trusted Main/Preload bridge, and the workbar tabs plus terminal toolbar use straight corners for a continuous docked appearance. Release note: 修复终端工具栏粘贴可能因剪贴板权限被拒绝而失败的问题,并将工作栏页签与终端工具栏调整为直角样式。
This commit is contained in:
@@ -242,6 +242,11 @@ describe('terminal IPC boundary', () => {
|
||||
snapshot.sessionId
|
||||
)
|
||||
|
||||
expect(
|
||||
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
|
||||
).toBe('Get-Location\r')
|
||||
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
|
||||
|
||||
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
||||
event,
|
||||
'## Copied response'
|
||||
@@ -249,6 +254,12 @@ describe('terminal IPC boundary', () => {
|
||||
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
|
||||
'## Copied response'
|
||||
)
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
|
||||
sender: { ...webContents, id: 99 },
|
||||
senderFrame: webContents.mainFrame
|
||||
})
|
||||
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
||||
event,
|
||||
@@ -319,6 +330,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
openPath: vi.fn(async () => ''),
|
||||
showItemInFolder: vi.fn(),
|
||||
openExternal: vi.fn(async () => undefined),
|
||||
readClipboardText: vi.fn(() => 'Get-Location\r'),
|
||||
writeClipboardText: vi.fn()
|
||||
}
|
||||
})
|
||||
@@ -1131,6 +1143,7 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
BrowserWindow: class {},
|
||||
clipboard: {
|
||||
readText: electronMocks.readClipboardText,
|
||||
writeText: electronMocks.writeClipboardText
|
||||
},
|
||||
dialog: {
|
||||
|
||||
@@ -3300,6 +3300,11 @@ export function registerIpcHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.clipboardReadText, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return clipboardTextSchema.parse(clipboard.readText())
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.clipboardWriteText, (event, input) => {
|
||||
assertTrustedSender(event, window)
|
||||
clipboard.writeText(clipboardTextSchema.parse(input))
|
||||
|
||||
@@ -255,6 +255,8 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
clipboard: {
|
||||
readText: () =>
|
||||
ipcRenderer.invoke(ipcChannels.clipboardReadText) as Promise<string>,
|
||||
writeText: async (text: string) => {
|
||||
await ipcRenderer.invoke(ipcChannels.clipboardWriteText, text)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('sandboxed preload', () => {
|
||||
expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u)
|
||||
})
|
||||
|
||||
it('exposes only explicit clipboard text writing', () => {
|
||||
it('exposes only explicit clipboard text reading and writing', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
'utf8'
|
||||
@@ -30,9 +30,11 @@ describe('sandboxed preload', () => {
|
||||
source.match(
|
||||
/clipboard: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}agent:/u
|
||||
)?.groups?.body ?? ''
|
||||
expect(clipboard).toContain('readText: ()')
|
||||
expect(clipboard).toContain('ipcChannels.clipboardReadText')
|
||||
expect(clipboard).toContain('writeText: async (text: string)')
|
||||
expect(clipboard).toContain('ipcChannels.clipboardWriteText')
|
||||
expect(clipboard).not.toMatch(/\b(?:read|image|html|rtf|bookmark)\b/iu)
|
||||
expect(clipboard).not.toMatch(/\b(?:image|html|rtf|bookmark)\b/iu)
|
||||
})
|
||||
|
||||
it('exposes only narrow browser-control methods', () => {
|
||||
|
||||
@@ -216,6 +216,7 @@ const api: DesktopApi = {
|
||||
onOpenSettings: vi.fn(() => () => {})
|
||||
},
|
||||
clipboard: {
|
||||
readText: vi.fn(async () => ''),
|
||||
writeText: vi.fn(async () => {})
|
||||
},
|
||||
feedback: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
TerminalEvent,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
TerminalPanel,
|
||||
resolveTerminalFontFamily,
|
||||
type TerminalAdapter,
|
||||
type TerminalClipboardAdapter,
|
||||
type TerminalEmulator,
|
||||
type TerminalFactory
|
||||
} from './TerminalPanel'
|
||||
@@ -19,6 +22,16 @@ vi.mock('@xterm/addon-search', () => ({
|
||||
}))
|
||||
|
||||
const sessionId = '00000000-0000-4000-8000-000000000201'
|
||||
const terminalStylesheet = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'renderer',
|
||||
'src',
|
||||
'terminal-panel.css'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function snapshot(
|
||||
overrides: Partial<TerminalSnapshot> = {}
|
||||
@@ -156,6 +169,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('TerminalPanel', () => {
|
||||
it('uses the Main-process clipboard bridge for copy and paste', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const clipboardAdapter: TerminalClipboardAdapter = {
|
||||
readText: vi.fn(async () => 'Get-Location\r'),
|
||||
writeText: vi.fn(async () => undefined)
|
||||
}
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
clipboardAdapter={clipboardAdapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('PowerShell 7')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制选中内容' }))
|
||||
await waitFor(() =>
|
||||
expect(clipboardAdapter.writeText).toHaveBeenCalledWith(
|
||||
'selected output'
|
||||
)
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '粘贴' }))
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.write).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
data: 'Get-Location\r'
|
||||
})
|
||||
)
|
||||
expect(clipboardAdapter.readText).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the terminal panel and toolbar controls square', () => {
|
||||
expect(terminalStylesheet).toMatch(
|
||||
/\.terminal-panel\s*\{[^}]*border-radius:\s*0;/u
|
||||
)
|
||||
expect(terminalStylesheet).toMatch(
|
||||
/\.terminal-panel__toolbar button\s*\{[^}]*border-radius:\s*0;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('creates only after the first valid fitted size and forwards input', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
|
||||
@@ -33,6 +33,8 @@ export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
|
||||
subscribe: DesktopApi['terminal']['onEvent']
|
||||
}
|
||||
|
||||
export type TerminalClipboardAdapter = DesktopApi['clipboard']
|
||||
|
||||
type Disposable = { dispose: () => void }
|
||||
|
||||
export type TerminalEmulator = {
|
||||
@@ -67,6 +69,7 @@ export type TerminalFactory = () => TerminalFactoryResult
|
||||
|
||||
export type TerminalPanelProps = {
|
||||
adapter: TerminalAdapter
|
||||
clipboardAdapter?: TerminalClipboardAdapter
|
||||
target: TerminalTarget
|
||||
sessionId?: TerminalSessionId
|
||||
title?: string
|
||||
@@ -149,6 +152,7 @@ function isValidSize(
|
||||
|
||||
export function TerminalPanel({
|
||||
adapter,
|
||||
clipboardAdapter,
|
||||
target,
|
||||
sessionId,
|
||||
title,
|
||||
@@ -484,7 +488,7 @@ export function TerminalPanel({
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
await (clipboardAdapter ?? window.goodbuddy.clipboard).writeText(value)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
failureMessage(
|
||||
@@ -501,7 +505,9 @@ export function TerminalPanel({
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await navigator.clipboard.readText()
|
||||
const data = await (
|
||||
clipboardAdapter ?? window.goodbuddy.clipboard
|
||||
).readText()
|
||||
if (data) {
|
||||
await adapter.write({ sessionId: current.sessionId, data })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
useState,
|
||||
type ReactNode
|
||||
@@ -29,6 +31,10 @@ const terminalOne: WorkbarTabInstance = {
|
||||
title: '终端 · 本机 1',
|
||||
targetRef: { type: 'local' }
|
||||
}
|
||||
const stylesheet = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function ControlledShell({
|
||||
initialInstances = DEFAULT_WORKBAR_INSTANCES,
|
||||
@@ -66,6 +72,15 @@ function ControlledShell({
|
||||
}
|
||||
|
||||
describe('WorkbarShell', () => {
|
||||
it('keeps tabs and their controls square', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.workbar-shell__tab-item\s*\{[^}]*border-radius:\s*0;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.workbar-shell__tab-close,\s*\n\.workbar-shell__add\s*\{[^}]*border-radius:\s*0;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
|
||||
render(<ControlledShell />)
|
||||
|
||||
|
||||
@@ -3858,7 +3858,7 @@ button > svg {
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-control) var(--radius-control) 0 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
transition:
|
||||
border-color var(--motion-fast) ease-out,
|
||||
@@ -3936,7 +3936,7 @@ button > svg {
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-panel--loading {
|
||||
@@ -47,6 +47,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:hover:not(:disabled),
|
||||
.terminal-panel__inline-form button:hover:not(:disabled),
|
||||
.terminal-panel__error button:hover:not(:disabled) {
|
||||
|
||||
@@ -1564,6 +1564,7 @@ export type DesktopApi = {
|
||||
onOpenSettings: (listener: () => void) => () => void
|
||||
}
|
||||
clipboard: {
|
||||
readText: () => Promise<string>
|
||||
writeText: (text: string) => Promise<void>
|
||||
}
|
||||
agent: {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ipcChannels = {
|
||||
appRendererPersistenceRequest: 'app:renderer-persistence-request',
|
||||
appRendererPersistenceComplete: 'app:renderer-persistence-complete',
|
||||
appClearLocalData: 'app:clear-local-data',
|
||||
clipboardReadText: 'clipboard:read-text',
|
||||
clipboardWriteText: 'clipboard:write-text',
|
||||
conversationNew: 'conversation:new',
|
||||
settingsOpen: 'settings:open',
|
||||
|
||||
Reference in New Issue
Block a user