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',