fix: keep desktop copy actions reliable

Several desktop copy entry points still called navigator.clipboard directly, which can fail or report success inconsistently in Tauri and browser test contexts. Route those copy actions through the existing textarea execCommand fallback helper and cover the fallback paths for startup diagnostics, markdown copy controls, and workspace file paths.

Constraint: Clipboard API is not reliable in every desktop/browser context.

Rejected: Leave direct navigator.clipboard calls in isolated components | repeats the diagnostics copy failure mode and creates inconsistent copy behavior.

Confidence: high

Scope-risk: narrow

Directive: New desktop copy controls should use copyTextToClipboard or CopyButton instead of calling navigator.clipboard directly.

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- --run src/components/markdown/MarkdownRenderer.test.tsx src/components/layout/StartupErrorView.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/__tests__/diagnosticsSettings.test.tsx src/components/chat/MessageList.test.tsx

Tested: bun run check:desktop

Tested: bun run check:native

Tested: bun run check:coverage

Tested: bun run verify (passed=7 failed=0 skipped=3)

Not-tested: Live provider baseline; not required for this desktop-only clipboard fix.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 22:44:00 +08:00
parent e070c4a0d0
commit 5898e02e01
6 changed files with 163 additions and 27 deletions

View File

@ -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(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
try {
render(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
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,
})
}
})
})

View File

@ -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)
}

View File

@ -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(<MarkdownRenderer content={'<button data-copy-code="npm run verify">Copy</button>'} />)
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,
})
}
})
})

View File

@ -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) {

View File

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

View File

@ -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) {
<button
type="button"
role="menuitem"
onClick={() => copyWorkspacePath(fileContextMenu.path)}
onClick={() => void copyWorkspacePath(fileContextMenu.path)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">content_copy</span>