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:
mesalogo
2026-09-03 17:12:27 +08:00
parent 7369c13bcf
commit b1e3101857
14 changed files with 120 additions and 8 deletions
+2
View File
@@ -336,6 +336,8 @@
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
- 工作栏 Tab、Tab 关闭按钮、“+”入口、终端面板外框及终端顶部工具栏按钮统一使用直角,
形成连续、紧凑的停靠式工具界面,不沿用普通卡片和表单控件的圆角。
- 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层
目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。
- 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目;
@@ -147,6 +147,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
工作目录和 Shell。用户可以重命名终端。
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
- Tab、关闭按钮、“+”入口、终端面板外框和终端顶部工具栏按钮使用直角,保持停靠式工具界面
的连续边界,不使用卡片式圆角。
- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现;
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
@@ -237,7 +239,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 真实 TTY 输入与 ANSI 输出。
- 中文输入法、组合字符和常规键盘快捷键。
- 复制、粘贴、全选、搜索、清屏和滚动。
- 复制、粘贴、全选、搜索、清屏和滚动。复制与粘贴通过桌面 Main 进程访问系统文本剪贴板,
不依赖 Renderer 的浏览器剪贴板权限。
- 工作栏宽度或窗口尺寸变化后的 PTY resize。
- 终端重命名。
- 显式关闭、退出后重新打开和远程中断后重新连接。
+13
View File
@@ -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: {
+5
View File
@@ -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))
+2
View File
@@ -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)
}
+4 -2
View File
@@ -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', () => {
+1
View File
@@ -216,6 +216,7 @@ const api: DesktopApi = {
onOpenSettings: vi.fn(() => () => {})
},
clipboard: {
readText: vi.fn(async () => ''),
writeText: vi.fn(async () => {})
},
feedback: {
+57
View File
@@ -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()
+8 -2
View File
@@ -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 })
}
+15
View File
@@ -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 />)
+2 -2
View File
@@ -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;
+5 -1
View File
@@ -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) {
+1
View File
@@ -1564,6 +1564,7 @@ export type DesktopApi = {
onOpenSettings: (listener: () => void) => () => void
}
clipboard: {
readText: () => Promise<string>
writeText: (text: string) => Promise<void>
}
agent: {
+1
View File
@@ -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',