mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
e070c4a0d0
commit
5898e02e01
@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user