| ... | @@ -1,10 +1,13 @@ | ... | @@ -1,10 +1,13 @@ |
| 1 | import { getSharedHighlighter } from "@pierre/diffs"; | 1 | import { getSharedHighlighter } from "@pierre/diffs"; |
| | 2 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 2 | import { | 3 | import { |
| 3 | Children, | 4 | Children, |
| 4 | isValidElement, | 5 | isValidElement, |
| | 6 | useCallback, |
| 5 | memo, | 7 | memo, |
| 6 | useEffect, | 8 | useEffect, |
| 7 | useMemo, | 9 | useMemo, |
| | 10 | useRef, |
| 8 | useState, | 11 | useState, |
| 9 | type ReactNode, | 12 | type ReactNode, |
| 10 | } from "react"; | 13 | } from "react"; |
| ... | @@ -159,6 +162,54 @@ interface ShikiCodeBlockProps { | ... | @@ -159,6 +162,54 @@ interface ShikiCodeBlockProps { |
| 159 | fallback: ReactNode; | 162 | fallback: ReactNode; |
| 160 | } | 163 | } |
| 161 | | 164 | |
| | 165 | function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { |
| | 166 | const [copied, setCopied] = useState(false); |
| | 167 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | 168 | const handleCopy = useCallback(() => { |
| | 169 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| | 170 | return; |
| | 171 | } |
| | 172 | void navigator.clipboard |
| | 173 | .writeText(code) |
| | 174 | .then(() => { |
| | 175 | if (copiedTimerRef.current != null) { |
| | 176 | clearTimeout(copiedTimerRef.current); |
| | 177 | } |
| | 178 | setCopied(true); |
| | 179 | copiedTimerRef.current = setTimeout(() => { |
| | 180 | setCopied(false); |
| | 181 | copiedTimerRef.current = null; |
| | 182 | }, 1200); |
| | 183 | }) |
| | 184 | .catch(() => undefined); |
| | 185 | }, [code]); |
| | 186 | |
| | 187 | useEffect( |
| | 188 | () => () => { |
| | 189 | if (copiedTimerRef.current != null) { |
| | 190 | clearTimeout(copiedTimerRef.current); |
| | 191 | copiedTimerRef.current = null; |
| | 192 | } |
| | 193 | }, |
| | 194 | [], |
| | 195 | ); |
| | 196 | |
| | 197 | return ( |
| | 198 | <div className="chat-markdown-codeblock"> |
| | 199 | <button |
| | 200 | type="button" |
| | 201 | className="chat-markdown-copy-button" |
| | 202 | onClick={handleCopy} |
| | 203 | title={copied ? "Copied" : "Copy code"} |
| | 204 | aria-label={copied ? "Copied" : "Copy code"} |
| | 205 | > |
| | 206 | {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} |
| | 207 | </button> |
| | 208 | {children} |
| | 209 | </div> |
| | 210 | ); |
| | 211 | } |
| | 212 | |
| 162 | function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { | 213 | function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { |
| 163 | const language = extractFenceLanguage(className); | 214 | const language = extractFenceLanguage(className); |
| 164 | const cacheKey = createHighlightCacheKey(code, language, themeName); | 215 | const cacheKey = createHighlightCacheKey(code, language, themeName); |
| ... | @@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 264 | } | 315 | } |
| 265 | | 316 | |
| 266 | return ( | 317 | return ( |
| 267 | <ShikiCodeBlock | 318 | <MarkdownCodeBlock code={codeBlock.code}> |
| 268 | className={codeBlock.className} | 319 | <ShikiCodeBlock |
| 269 | code={codeBlock.code} | 320 | className={codeBlock.className} |
| 270 | themeName={diffThemeName} | 321 | code={codeBlock.code} |
| 271 | isStreaming={isStreaming} | 322 | themeName={diffThemeName} |
| 272 | fallback={<pre {...props}>{children}</pre>} | 323 | isStreaming={isStreaming} |
| 273 | /> | 324 | fallback={<pre {...props}>{children}</pre>} |
| | 325 | /> |
| | 326 | </MarkdownCodeBlock> |
| 274 | ); | 327 | ); |
| 275 | }, | 328 | }, |
| 276 | }), | 329 | }), |