From 5898e02e01baa0e64068812d1edb065ded9c0c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 14 May 2026 22:44:00 +0800 Subject: [PATCH] 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. --- .../layout/StartupErrorView.test.tsx | 43 ++++++++--- .../components/layout/StartupErrorView.tsx | 5 +- .../markdown/MarkdownRenderer.test.tsx | 40 +++++++++- .../components/markdown/MarkdownRenderer.tsx | 19 +++-- .../workspace/WorkspacePanel.test.tsx | 76 +++++++++++++++++++ .../components/workspace/WorkspacePanel.tsx | 7 +- 6 files changed, 163 insertions(+), 27 deletions(-) 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) {