mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
merge: integrate reliable desktop copy fallback
Bring the detached worktree clipboard fix onto local main while preserving main's existing desktop responsiveness work. Constraint: Local main and the detached worktree diverged from e070c4a0, so this requires a merge commit instead of a fast-forward. Confidence: high Scope-risk: narrow Directive: Keep desktop copy controls on the shared fallback helper when adding new copy surfaces. Tested: bun run verify (passed=7 failed=0 skipped=3) in /Users/nanmi/.codex/worktrees/ec25/claude-code-haha before merge Not-tested: Post-merge full gate on local main; merge applies the verified six-file desktop patch onto a clean local main.
This commit is contained in:
commit
c39a8331c7
@ -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