mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +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', () => {
|
describe('StartupErrorView', () => {
|
||||||
it('shows diagnostics and copies the full payload', async () => {
|
it('shows diagnostics and copies the full payload with the legacy fallback', async () => {
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
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', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
value: { writeText },
|
value: { writeText },
|
||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
render(
|
try {
|
||||||
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
|
render(
|
||||||
)
|
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
|
||||||
|
)
|
||||||
|
|
||||||
expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
|
expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
|
||||||
expect(screen.getByText('startup failed')).toBeInTheDocument()
|
expect(screen.getByText('startup failed')).toBeInTheDocument()
|
||||||
expect(screen.getByText('[stderr] boom')).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(
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
'startup failed\n\nRecent server logs:\n[stderr] boom',
|
'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 { useTranslation } from '../../i18n'
|
||||||
import { Button } from '../shared/Button'
|
import { Button } from '../shared/Button'
|
||||||
import { DoctorPanel } from '../doctor/DoctorPanel'
|
import { DoctorPanel } from '../doctor/DoctorPanel'
|
||||||
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
|
|
||||||
const LOG_MARKER = '\n\nRecent server logs:\n'
|
const LOG_MARKER = '\n\nRecent server logs:\n'
|
||||||
|
|
||||||
@ -35,7 +36,9 @@ export function StartupErrorView({ error }: StartupErrorViewProps) {
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
await navigator.clipboard?.writeText(diagnostics)
|
const ok = await copyTextToClipboard(diagnostics)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
window.setTimeout(() => setCopied(false), 1600)
|
window.setTimeout(() => setCopied(false), 1600)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
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'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
vi.mock('../chat/CodeViewer', () => ({
|
vi.mock('../chat/CodeViewer', () => ({
|
||||||
@ -126,4 +126,42 @@ describe('MarkdownRenderer', () => {
|
|||||||
expect(link).toHaveAttribute('target', '_blank')
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
|
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 { marked, type Tokens } from 'marked'
|
||||||
import { CodeViewer } from '../chat/CodeViewer'
|
import { CodeViewer } from '../chat/CodeViewer'
|
||||||
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
||||||
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
@ -201,16 +202,14 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
|||||||
const text = button.getAttribute('data-copy-code')
|
const text = button.getAttribute('data-copy-code')
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
try {
|
const copied = await copyTextToClipboard(text)
|
||||||
await navigator.clipboard.writeText(text)
|
if (!copied) return
|
||||||
const original = button.textContent
|
|
||||||
button.textContent = 'Copied'
|
const original = button.textContent
|
||||||
window.setTimeout(() => {
|
button.textContent = 'Copied'
|
||||||
button.textContent = original
|
window.setTimeout(() => {
|
||||||
}, 1500)
|
button.textContent = original
|
||||||
} catch {
|
}, 1500)
|
||||||
// Ignore clipboard errors
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (codeBlocks.length === 0) {
|
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 () => {
|
it('adds a line comment from a code preview to the chat context', async () => {
|
||||||
await setWorkspaceState((state) => ({
|
await setWorkspaceState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from '../../stores/workspacePanelStore'
|
} from '../../stores/workspacePanelStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||||
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||||
import {
|
import {
|
||||||
getFileExtension,
|
getFileExtension,
|
||||||
@ -1066,9 +1067,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
|||||||
setPreviewTabContextMenu(null)
|
setPreviewTabContextMenu(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyWorkspacePath = (path: string) => {
|
const copyWorkspacePath = async (path: string) => {
|
||||||
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
|
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
|
||||||
void navigator.clipboard?.writeText(resolvedPath)
|
await copyTextToClipboard(resolvedPath)
|
||||||
setFileContextMenu(null)
|
setFileContextMenu(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1460,7 +1461,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
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)]"
|
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>
|
<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