From b1e3101857c338872cd94d8cfebd23a242aee035 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 3 Sep 2026 17:12:27 +0800 Subject: [PATCH] fix: restore terminal paste and square workbar chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 修复终端工具栏粘贴可能因剪贴板权限被拒绝而失败的问题,并将工作栏页签与终端工具栏调整为直角样式。 --- UI-DESIGN.md | 2 + .../assistant-workbar/terminal-tabs-prd.md | 5 +- src/main/ipc.test.ts | 13 +++++ src/main/ipc.ts | 5 ++ src/preload/index.ts | 2 + src/preload/preload-sandbox.test.ts | 6 +- src/renderer/src/App.test.tsx | 1 + src/renderer/src/TerminalPanel.test.tsx | 57 +++++++++++++++++++ src/renderer/src/TerminalPanel.tsx | 10 +++- src/renderer/src/WorkbarShell.test.tsx | 15 +++++ src/renderer/src/styles.css | 4 +- src/renderer/src/terminal-panel.css | 6 +- src/shared/contracts.ts | 1 + src/shared/ipc-channels.ts | 1 + 14 files changed, 120 insertions(+), 8 deletions(-) diff --git a/UI-DESIGN.md b/UI-DESIGN.md index b07afdff..4945c96f 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -336,6 +336,8 @@ - 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少 两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或 `focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。 +- 工作栏 Tab、Tab 关闭按钮、“+”入口、终端面板外框及终端顶部工具栏按钮统一使用直角, + 形成连续、紧凑的停靠式工具界面,不沿用普通卡片和表单控件的圆角。 - 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层 目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。 - 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目; diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index 25951615..ca2b78fc 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -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。 - 终端重命名。 - 显式关闭、退出后重新打开和远程中断后重新连接。 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index dd866192..fa0fa230 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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: { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 11e039a6..b8c8abd7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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)) diff --git a/src/preload/index.ts b/src/preload/index.ts index 8034ddc7..81cfc5b8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -255,6 +255,8 @@ const desktopApi: DesktopApi = { } }, clipboard: { + readText: () => + ipcRenderer.invoke(ipcChannels.clipboardReadText) as Promise, writeText: async (text: string) => { await ipcRenderer.invoke(ipcChannels.clipboardWriteText, text) } diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 23d2f1b9..60e7ec12 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -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: \{(?[\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', () => { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index d5fa1798..532e29c5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -216,6 +216,7 @@ const api: DesktopApi = { onOpenSettings: vi.fn(() => () => {}) }, clipboard: { + readText: vi.fn(async () => ''), writeText: vi.fn(async () => {}) }, feedback: { diff --git a/src/renderer/src/TerminalPanel.test.tsx b/src/renderer/src/TerminalPanel.test.tsx index 977699ec..ef77ea77 100644 --- a/src/renderer/src/TerminalPanel.test.tsx +++ b/src/renderer/src/TerminalPanel.test.tsx @@ -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 = {} @@ -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( + + ) + 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() diff --git a/src/renderer/src/TerminalPanel.tsx b/src/renderer/src/TerminalPanel.tsx index 5d78fb63..63b43982 100644 --- a/src/renderer/src/TerminalPanel.tsx +++ b/src/renderer/src/TerminalPanel.tsx @@ -33,6 +33,8 @@ export type TerminalAdapter = Omit & { 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 }) } diff --git a/src/renderer/src/WorkbarShell.test.tsx b/src/renderer/src/WorkbarShell.test.tsx index c0719dfe..53d062eb 100644 --- a/src/renderer/src/WorkbarShell.test.tsx +++ b/src/renderer/src/WorkbarShell.test.tsx @@ -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() diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index f9e1a6e8..9ee1cf95 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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; diff --git a/src/renderer/src/terminal-panel.css b/src/renderer/src/terminal-panel.css index ac25db50..e47cb344 100644 --- a/src/renderer/src/terminal-panel.css +++ b/src/renderer/src/terminal-panel.css @@ -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) { diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 06c2733f..84556929 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1564,6 +1564,7 @@ export type DesktopApi = { onOpenSettings: (listener: () => void) => () => void } clipboard: { + readText: () => Promise writeText: (text: string) => Promise } agent: { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index f03a10e0..54d095d2 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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',