Skip to content

Commit ad6d6c0

Browse files
committed
add copy button
1 parent fc0e72c commit ad6d6c0

7 files changed

Lines changed: 831 additions & 9 deletions

File tree

packages/desktop/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,31 @@
1313
},
1414
"dependencies": {
1515
"@codingcode/core": "workspace:*",
16+
"@tanstack/react-virtual": "^3.12.6",
1617
"immer": "^10.1.1",
1718
"lucide-react": "^1.17.0",
1819
"prismjs": "^1.30.0",
1920
"react": "^19.1.0",
2021
"react-dom": "^19.1.0",
2122
"react-markdown": "^10.1.0",
2223
"react-resizable-panels": "^2.1.7",
23-
"@tanstack/react-virtual": "^3.12.6",
2424
"rehype-raw": "^7.0.0",
2525
"remark-gfm": "^4.0.1",
2626
"simple-git": "^3.27.0",
2727
"zustand": "^5.0.5"
2828
},
2929
"devDependencies": {
3030
"@tailwindcss/vite": "^4.1.8",
31+
"@testing-library/jest-dom": "^6.9.1",
32+
"@testing-library/react": "^16.3.2",
3133
"@types/node": "^22.0.0",
3234
"@types/react": "^19.1.6",
3335
"@types/react-dom": "^19.1.5",
3436
"@vitejs/plugin-react": "^4.4.1",
3537
"electron": "^35.5.0",
3638
"electron-builder": "^25.1.8",
3739
"electron-vite": "^3.1.0",
40+
"jsdom": "^29.1.1",
3841
"tailwindcss": "^4.1.8",
3942
"typescript": "^5.8.3"
4043
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useState, useCallback } from 'react';
2+
3+
export function useCopyToClipboard(resetDelay = 1500) {
4+
const [copiedId, setCopiedId] = useState<string | null>(null);
5+
6+
const copy = useCallback(
7+
async (text: string, id: string) => {
8+
await navigator.clipboard.writeText(text);
9+
setCopiedId(id);
10+
setTimeout(() => {
11+
setCopiedId((prev) => (prev === id ? null : prev));
12+
}, resetDelay);
13+
},
14+
[resetDelay]
15+
);
16+
17+
return { copiedId, copy };
18+
}

packages/desktop/src/shared/CodeBlock.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useRef } from 'react';
22
import Prism from 'prismjs';
3+
import { Copy, Check } from 'lucide-react';
34
import 'prismjs/components/prism-typescript';
45
import 'prismjs/components/prism-javascript';
56
import 'prismjs/components/prism-python';
@@ -14,6 +15,7 @@ import 'prismjs/components/prism-rust';
1415
import 'prismjs/components/prism-java';
1516
import 'prismjs/components/prism-c';
1617
import 'prismjs/components/prism-cpp';
18+
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';
1719

1820
interface CodeBlockProps {
1921
code: string;
@@ -22,6 +24,9 @@ interface CodeBlockProps {
2224

2325
export default function CodeBlock({ code, language }: CodeBlockProps) {
2426
const codeRef = useRef<HTMLElement>(null);
27+
const { copiedId, copy } = useCopyToClipboard();
28+
const codeKey = `code-${code.slice(0, 20)}`;
29+
const isCopied = copiedId === codeKey;
2530

2631
useEffect(() => {
2732
if (codeRef.current && language && Prism.languages[language]) {
@@ -34,10 +39,46 @@ export default function CodeBlock({ code, language }: CodeBlockProps) {
3439
return (
3540
<div className="relative group my-1 rounded overflow-hidden bg-[var(--bg-card)] border border-[var(--border-strong)]">
3641
{language && (
37-
<div className="px-3 py-1 text-xs text-[var(--text-muted)] bg-[var(--border-default)] border-b border-[var(--border-strong)] font-mono">
38-
{language}
42+
<div className="flex items-center justify-between px-3 py-1 text-xs text-[var(--text-muted)] bg-[var(--border-default)] border-b border-[var(--border-strong)] font-mono">
43+
<span>{language}</span>
44+
<button
45+
type="button"
46+
onClick={(e) => {
47+
e.stopPropagation();
48+
copy(code, codeKey);
49+
}}
50+
aria-label="复制代码"
51+
title="复制"
52+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-all opacity-0 group-hover:opacity-100 ${
53+
isCopied
54+
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
55+
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
56+
}`}
57+
>
58+
{isCopied ? <Check size={12} /> : <Copy size={12} />}
59+
{isCopied ? '已复制' : '复制'}
60+
</button>
3961
</div>
4062
)}
63+
{!language && (
64+
<button
65+
type="button"
66+
onClick={(e) => {
67+
e.stopPropagation();
68+
copy(code, codeKey);
69+
}}
70+
aria-label="复制代码"
71+
title="复制"
72+
className={`absolute top-1 right-1 z-10 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] transition-all opacity-0 group-hover:opacity-100 ${
73+
isCopied
74+
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
75+
: 'bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-active)]'
76+
}`}
77+
>
78+
{isCopied ? <Check size={12} /> : <Copy size={12} />}
79+
{isCopied ? '已复制' : '复制'}
80+
</button>
81+
)}
4182
<pre className="p-3 text-[13px] font-mono overflow-x-auto leading-relaxed whitespace-pre-wrap break-all m-0">
4283
<code ref={codeRef} className={`language-${lang}`}>
4384
{code}

packages/desktop/src/shared/MessageItem.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useState, useRef, useLayoutEffect, memo } from 'react';
2+
import { Copy, Check } from 'lucide-react';
23
import type { Item } from '@shared/types';
34
import ToolCallCard from './ToolCallCard';
45
import DiffBlock from './DiffBlock';
56
import ToolSummary from './ToolSummary';
67
import MarkdownRenderer from './MarkdownRenderer';
8+
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';
79

810
interface MessageItemProps {
911
item: Item;
@@ -34,10 +36,13 @@ const MessageItem = memo(function MessageItem({
3436
const rollbackBtnRef = useRef<HTMLButtonElement>(null);
3537
const rollbackMenuRef = useRef<HTMLDivElement>(null);
3638
const [menuFlip, setMenuFlip] = useState<{ vertical?: boolean; horizontal?: boolean }>({});
39+
const { copiedId, copy } = useCopyToClipboard();
3740

3841
const messageContent = item.type === 'message' ? item.content : null;
3942
const isAssistant = item.type === 'message' && item.role === 'assistant';
4043

44+
const isCopied = copiedId === `msg-${item.id}`;
45+
4146
// Dynamically flip menu if it would overflow the viewport
4247
useLayoutEffect(() => {
4348
if (!rollbackMenuOpen || !rollbackMenuRef.current || !rollbackBtnRef.current) return;
@@ -62,8 +67,8 @@ const MessageItem = memo(function MessageItem({
6267

6368
if (isUser) {
6469
return (
65-
<div className="flex justify-end mb-4 mt-4">
66-
<div className="relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words group">
70+
<div className="flex flex-col items-end mb-4 mt-4 group">
71+
<div className="relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words">
6772
{content}
6873
{hasRollback && (
6974
<div className="absolute -right-1 -bottom-1">
@@ -114,12 +119,31 @@ const MessageItem = memo(function MessageItem({
114119
</div>
115120
)}
116121
</div>
122+
<div className="mt-1.5 mr-1 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
123+
<button
124+
type="button"
125+
onClick={(e) => {
126+
e.stopPropagation();
127+
copy(content, `msg-${item.id}`);
128+
}}
129+
aria-label="复制消息"
130+
title="复制"
131+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
132+
isCopied
133+
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
134+
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
135+
}`}
136+
>
137+
{isCopied ? <Check size={12} /> : <Copy size={12} />}
138+
{isCopied ? '已复制' : '复制'}
139+
</button>
140+
</div>
117141
</div>
118142
);
119143
}
120144

121145
return (
122-
<div className="flex justify-start mb-1 pl-8">
146+
<div className="flex flex-col items-start mb-1 pl-8 group">
123147
<div className="max-w-[80%] text-[15px] text-[var(--text-primary)] leading-relaxed">
124148
{isAssistant && messageContent != null && (
125149
<MarkdownRenderer content={messageContent} />
@@ -128,6 +152,27 @@ const MessageItem = memo(function MessageItem({
128152
<span className="inline-block w-1.5 h-[1.1em] bg-[var(--accent-primary)] animate-pulse ml-0.5 align-middle" />
129153
)}
130154
</div>
155+
{isAssistant && !item.partial && messageContent != null && (
156+
<div className="max-w-[80%] mt-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
157+
<button
158+
type="button"
159+
onClick={(e) => {
160+
e.stopPropagation();
161+
copy(messageContent || '', `msg-${item.id}`);
162+
}}
163+
aria-label="复制消息"
164+
title="复制"
165+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
166+
isCopied
167+
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
168+
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
169+
}`}
170+
>
171+
{isCopied ? <Check size={12} /> : <Copy size={12} />}
172+
{isCopied ? '已复制' : '复制'}
173+
</button>
174+
</div>
175+
)}
131176
</div>
132177
);
133178
}

0 commit comments

Comments
 (0)