diff --git a/desktop/src/components/layout/StartupErrorView.test.tsx b/desktop/src/components/layout/StartupErrorView.test.tsx index 757a63c2..007e4bde 100644 --- a/desktop/src/components/layout/StartupErrorView.test.tsx +++ b/desktop/src/components/layout/StartupErrorView.test.tsx @@ -19,28 +19,47 @@ describe('splitStartupError', () => { }) describe('StartupErrorView', () => { - it('shows diagnostics and copies the full payload', async () => { - const writeText = vi.fn().mockResolvedValue(undefined) + it('shows diagnostics and copies the full payload with the legacy fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + const writeText = vi.fn().mockRejectedValue(new Error('clipboard blocked')) Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, }) - render( - , - ) + try { + render( + , + ) - expect(screen.getByText('本地服务启动失败')).toBeInTheDocument() - expect(screen.getByText('startup failed')).toBeInTheDocument() - expect(screen.getByText('[stderr] boom')).toBeInTheDocument() + expect(screen.getByText('本地服务启动失败')).toBeInTheDocument() + expect(screen.getByText('startup failed')).toBeInTheDocument() + expect(screen.getByText('[stderr] boom')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' })) + fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' })) - await waitFor(() => { + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) expect(writeText).toHaveBeenCalledWith( 'startup failed\n\nRecent server logs:\n[stderr] boom', ) - }) - expect(screen.getByText('已复制')).toBeInTheDocument() + expect(screen.getByText('已复制')).toBeInTheDocument() + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } }) }) diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx index 88a820ea..134cf4ee 100644 --- a/desktop/src/components/layout/StartupErrorView.tsx +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react' import { useTranslation } from '../../i18n' import { Button } from '../shared/Button' import { DoctorPanel } from '../doctor/DoctorPanel' +import { copyTextToClipboard } from '../chat/clipboard' const LOG_MARKER = '\n\nRecent server logs:\n' @@ -35,7 +36,9 @@ export function StartupErrorView({ error }: StartupErrorViewProps) { const [copied, setCopied] = useState(false) const handleCopy = async () => { - await navigator.clipboard?.writeText(diagnostics) + const ok = await copyTextToClipboard(diagnostics) + if (!ok) return + setCopied(true) window.setTimeout(() => setCopied(false), 1600) } diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index b47bd5b8..4692a7ff 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('../chat/CodeViewer', () => ({ @@ -126,4 +126,42 @@ describe('MarkdownRenderer', () => { expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) }) + + it('copies enhanced markdown button text with the legacy clipboard fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), + }, + }) + const writeText = vi.mocked(navigator.clipboard.writeText) + + try { + render(Copy'} />) + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + expect(writeText).toHaveBeenCalledWith('npm run verify') + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument() + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) }) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index f710de05..eac69a50 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -3,6 +3,7 @@ import DOMPurify from 'dompurify' import { marked, type Tokens } from 'marked' import { CodeViewer } from '../chat/CodeViewer' import { MermaidRenderer } from '../chat/MermaidRenderer' +import { copyTextToClipboard } from '../chat/clipboard' type Props = { content: string @@ -201,16 +202,14 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr const text = button.getAttribute('data-copy-code') if (!text) return - try { - await navigator.clipboard.writeText(text) - const original = button.textContent - button.textContent = 'Copied' - window.setTimeout(() => { - button.textContent = original - }, 1500) - } catch { - // Ignore clipboard errors - } + const copied = await copyTextToClipboard(text) + if (!copied) return + + const original = button.textContent + button.textContent = 'Copied' + window.setTimeout(() => { + button.textContent = original + }, 1500) }, []) if (codeBlocks.length === 0) { diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index a0873feb..5063b41b 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -1194,6 +1194,82 @@ describe('WorkspacePanel', () => { ]) }) + it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), + }, + }) + const writeText = vi.mocked(navigator.clipboard.writeText) + + try { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-copy-file': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-copy-file': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-copy-file': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], + }, + }, + }, + })) + + const view = await renderPanel('session-copy-file') + + await act(() => { + fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { + clientX: 260, + clientY: 80, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Copy path' })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + expect(writeText).toHaveBeenCalledWith('/repo/src/App.tsx') + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + it('adds a line comment from a code preview to the chat context', async () => { await setWorkspaceState((state) => ({ ...state, diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index ada3f69e..020e3bdd 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -16,6 +16,7 @@ import { } from '../../stores/workspacePanelStore' import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +import { copyTextToClipboard } from '../chat/clipboard' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { getFileExtension, @@ -1066,9 +1067,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { setPreviewTabContextMenu(null) } - const copyWorkspacePath = (path: string) => { + const copyWorkspacePath = async (path: string) => { const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path) - void navigator.clipboard?.writeText(resolvedPath) + await copyTextToClipboard(resolvedPath) setFileContextMenu(null) } @@ -1460,7 +1461,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {