Skip to content

Commit 5b55fd6

Browse files
Olasunkanmi OyinlolaOlasunkanmi Oyinlola
authored andcommitted
feat(ui): Enhance code block copy functionality and UI
- Replaces download functionality with a copy to clipboard feature for code blocks. - Implements a more robust copy mechanism with fallback for wider browser compatibility. - Adds visual feedback (Copied! message) on successful copy. - Introduces a wrapper for code blocks with language label and copy button. - Updates styles for improved user experience during copy operation.
1 parent a10de28 commit 5b55fd6

File tree

4 files changed

+194
-58
lines changed

4 files changed

+194
-58
lines changed

webviewUi/src/components/botMessage.tsx

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import DOMPurify from "dompurify";
33
import React from "react";
44
import { BotIcon } from "./botIcon";
55
import { DownloadIcon } from "./downloadIcon";
6-
import { downloadAsMarkdown } from "../utils/downloadMarkdown";
76
import { IParseURL, parseUrl } from "../utils/parseUrl";
87
import UrlCardList from "./urlCardList";
98

@@ -20,16 +19,78 @@ export const BotMessage: React.FC<CodeBlockProps> = ({ language, content }) => {
2019
parsedUrls = parseUrl(sanitizedContent);
2120
}
2221

23-
const handleDownload = () => {
24-
console.log("Download button clicked");
25-
console.log("Content length:", sanitizedContent.length);
26-
console.log("Content preview:", sanitizedContent.substring(0, 100));
22+
const handleCopyMarkdown = async () => {
23+
try {
24+
// Use the original content directly since it's already in markdown format
25+
const markdownContent = content || sanitizedContent;
2726

28-
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
29-
const filename = `codebuddy-response-${timestamp}`;
30-
console.log("Filename:", filename);
27+
// If content is HTML, we need to convert it back to markdown
28+
// But if it's already markdown, use it as-is
29+
let contentToCopy = markdownContent;
3130

32-
downloadAsMarkdown(sanitizedContent, filename);
31+
// Check if the content contains HTML tags (indicating it's been processed)
32+
if (markdownContent.includes("<") && markdownContent.includes(">")) {
33+
// Create a temporary div to extract text content while preserving line breaks
34+
const tempDiv = document.createElement("div");
35+
tempDiv.innerHTML = markdownContent;
36+
37+
// Get the text content and preserve markdown-like structure
38+
contentToCopy = tempDiv.textContent || tempDiv.innerText || "";
39+
40+
// Clean up the text to maintain markdown formatting
41+
contentToCopy = contentToCopy
42+
.replace(/\n\s*\n\s*\n/g, "\n\n") // Remove excessive line breaks
43+
.replace(/(^\s+)|(\s+$)/g, "") // Trim whitespace
44+
.trim();
45+
} else {
46+
// Content is already in markdown format, use it directly
47+
contentToCopy = markdownContent.trim();
48+
}
49+
50+
await navigator.clipboard.writeText(contentToCopy);
51+
console.log("Markdown content copied to clipboard successfully");
52+
} catch (error) {
53+
console.error("Failed to copy markdown to clipboard:", error);
54+
55+
// Fallback method
56+
try {
57+
const markdownContent = content || sanitizedContent;
58+
let contentToCopy = markdownContent;
59+
60+
if (markdownContent.includes("<") && markdownContent.includes(">")) {
61+
const tempDiv = document.createElement("div");
62+
tempDiv.innerHTML = markdownContent;
63+
contentToCopy = tempDiv.textContent || tempDiv.innerText || "";
64+
contentToCopy = contentToCopy
65+
.replace(/\n\s*\n\s*\n/g, "\n\n")
66+
.replace(/(^\s+)|(\s+$)/g, "")
67+
.trim();
68+
} else {
69+
contentToCopy = markdownContent.trim();
70+
}
71+
72+
// Create a temporary textarea for fallback copy
73+
const textarea = document.createElement("textarea");
74+
textarea.value = contentToCopy;
75+
textarea.style.position = "fixed";
76+
textarea.style.opacity = "0";
77+
document.body.appendChild(textarea);
78+
textarea.select();
79+
80+
// Use the modern approach with fallback
81+
try {
82+
document.execCommand("copy");
83+
} catch (execError) {
84+
console.error("execCommand failed:", execError);
85+
}
86+
87+
document.body.removeChild(textarea);
88+
89+
console.log("Markdown content copied to clipboard using fallback method");
90+
} catch (fallbackError) {
91+
console.error("Both clipboard methods failed:", fallbackError);
92+
}
93+
}
3394
};
3495

3596
return (
@@ -45,7 +106,7 @@ export const BotMessage: React.FC<CodeBlockProps> = ({ language, content }) => {
45106
<div className="code-header">
46107
<span className="language-label">{language}</span>
47108
<div className="header-buttons">
48-
<DownloadIcon onClick={handleDownload} />
109+
<DownloadIcon onClick={handleCopyMarkdown} />
49110
</div>
50111
</div>
51112
{parsedUrls.length > 0 ? (
Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,73 @@
11
import React, { useState } from "react";
22

33
interface DownloadIconProps {
4-
onClick: () => void;
4+
onClick: () => void | Promise<void>;
55
className?: string;
66
title?: string;
77
}
88

9-
export const DownloadIcon: React.FC<DownloadIconProps> = ({
10-
onClick,
11-
className = "",
12-
title = "Download as Markdown",
13-
}) => {
14-
const [isDownloading, setIsDownloading] = useState(false);
9+
export const DownloadIcon: React.FC<DownloadIconProps> = ({ onClick, className = "", title = "Copy as Markdown" }) => {
10+
const [isProcessing, setIsProcessing] = useState(false);
11+
const [showSuccess, setShowSuccess] = useState(false);
1512

1613
const handleClick = async () => {
17-
setIsDownloading(true);
14+
setIsProcessing(true);
15+
setShowSuccess(false);
16+
1817
try {
1918
await onClick();
19+
setShowSuccess(true);
20+
setTimeout(() => setShowSuccess(false), 2000);
21+
} catch (error) {
22+
console.error("Copy operation failed:", error);
2023
} finally {
21-
setTimeout(() => setIsDownloading(false), 1000);
24+
setIsProcessing(false);
2225
}
2326
};
2427

28+
const getButtonText = () => {
29+
if (showSuccess) return "Copied!";
30+
if (isProcessing) return "...";
31+
return "MD";
32+
};
33+
2534
return (
2635
<button
2736
onClick={handleClick}
28-
className={`download-button ${className} ${isDownloading ? "downloading" : ""}`}
37+
className={`download-button ${className} ${isProcessing ? "processing" : ""} ${showSuccess ? "success" : ""}`}
2938
title={title}
3039
aria-label={title}
31-
disabled={isDownloading}
40+
disabled={isProcessing}
3241
>
33-
<svg
34-
width="16"
35-
height="16"
36-
viewBox="0 0 24 24"
37-
fill="none"
38-
stroke="currentColor"
39-
strokeWidth="2"
40-
strokeLinecap="round"
41-
strokeLinejoin="round"
42-
>
43-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
44-
<polyline points="7,10 12,15 17,10" />
45-
<line x1="12" y1="15" x2="12" y2="3" />
46-
</svg>
47-
<span className="download-text">{isDownloading ? "..." : "MD"}</span>
42+
{showSuccess ? (
43+
<svg
44+
width="16"
45+
height="16"
46+
viewBox="0 0 24 24"
47+
fill="none"
48+
stroke="currentColor"
49+
strokeWidth="2"
50+
strokeLinecap="round"
51+
strokeLinejoin="round"
52+
>
53+
<polyline points="20,6 9,17 4,12" />
54+
</svg>
55+
) : (
56+
<svg
57+
width="16"
58+
height="16"
59+
viewBox="0 0 24 24"
60+
fill="none"
61+
stroke="currentColor"
62+
strokeWidth="2"
63+
strokeLinecap="round"
64+
strokeLinejoin="round"
65+
>
66+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
67+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
68+
</svg>
69+
)}
70+
<span className="download-text">{getButtonText()}</span>
4871
</button>
4972
);
5073
};

webviewUi/src/index.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,41 @@ hr {
578578
color: white;
579579
}
580580

581+
/* Individual code block wrapper and header styles */
582+
.code-block-wrapper {
583+
background-color: var(--vscode-editor-background);
584+
border: 1px solid var(--vscode-panel-border);
585+
border-radius: 6px;
586+
margin: 16px 0;
587+
font-family: "JetBrains Mono", SF Mono, "Geist Mono", "Fira Code", "Fira Mono", "Menlo", "Consolas", "DejaVu Sans Mono", monospace;
588+
}
589+
590+
.individual-code-header {
591+
background-color: var(--vscode-editor-background);
592+
border-bottom: 1px solid var(--vscode-panel-border);
593+
border-top-left-radius: 6px;
594+
border-top-right-radius: 6px;
595+
}
596+
597+
.individual-code-header .copy-button {
598+
margin-left: 0;
599+
font-size: 11px;
600+
padding: 3px 6px;
601+
border-color: #7aa2f7;
602+
}
603+
604+
.individual-code-header .copy-button:hover {
605+
background: #7aa2f7;
606+
border-color: #7aa2f7;
607+
color: white;
608+
}
609+
610+
.code-block-wrapper pre {
611+
margin: 0;
612+
border-radius: 0 0 6px 6px;
613+
background-color: var(--vscode-editor-background);
614+
}
615+
581616
.download-button {
582617
border: 1px solid #28a745;
583618
background: transparent;

webviewUi/src/utils/highlightCode.ts

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,14 @@ export const highlightCodeBlocks = (hljsApi: HLJSApi, messages: any) => {
1212
if (!hljsApi || messages?.length <= 0) return;
1313
document.querySelectorAll("pre code:not(.hljs-done)").forEach((block) => {
1414
let language = null;
15-
const languageClass = Array.from(block.classList).find((className) =>
16-
className.startsWith("language-"),
17-
);
15+
const languageClass = Array.from(block.classList).find((className) => className.startsWith("language-"));
1816
if (languageClass) {
1917
language = languageClass.substring("language-".length);
2018
}
2119

2220
try {
2321
const decodedCode = decodeHtml(block.textContent ?? "");
24-
const detectedLanguage =
25-
language ?? hljsApi.highlightAuto(decodedCode).language;
22+
const detectedLanguage = language ?? hljsApi.highlightAuto(decodedCode).language;
2623
if (detectedLanguage != undefined) {
2724
const highlightedCode = hljsApi.highlight(decodedCode, {
2825
language: detectedLanguage,
@@ -48,23 +45,43 @@ export const highlightCodeBlocks = (hljsApi: HLJSApi, messages: any) => {
4845
}
4946
});
5047

51-
// Find the closest code-block parent and add the button to the header buttons container
52-
const codeBlockParent = block.closest(".code-block");
53-
if (codeBlockParent) {
54-
const headerButtons =
55-
codeBlockParent.querySelector(".header-buttons");
56-
if (headerButtons) {
57-
headerButtons.appendChild(copyButton);
58-
} else {
59-
const codeHeader = codeBlockParent.querySelector(".code-header");
60-
if (codeHeader) {
61-
codeHeader.appendChild(copyButton);
62-
} else {
63-
codeBlockParent.appendChild(copyButton);
64-
}
65-
}
66-
} else {
67-
block.parentNode?.insertBefore(copyButton, block);
48+
// Create a wrapper for the code block with its own copy button
49+
const preElement = block.closest("pre");
50+
if (preElement && !preElement.querySelector(".code-block-wrapper")) {
51+
// Create a wrapper div for this specific code block
52+
const wrapper = document.createElement("div");
53+
wrapper.classList.add("code-block-wrapper");
54+
wrapper.style.position = "relative";
55+
wrapper.style.marginBottom = "1rem";
56+
57+
// Create a header for this code block
58+
const codeHeader = document.createElement("div");
59+
codeHeader.classList.add("individual-code-header");
60+
codeHeader.style.display = "flex";
61+
codeHeader.style.justifyContent = "space-between";
62+
codeHeader.style.alignItems = "center";
63+
codeHeader.style.padding = "0.5rem 1rem";
64+
codeHeader.style.backgroundColor = "var(--vscode-editor-background)";
65+
codeHeader.style.borderBottom = "1px solid var(--vscode-panel-border)";
66+
codeHeader.style.fontSize = "0.875rem";
67+
68+
// Add language label
69+
const languageLabel = document.createElement("span");
70+
languageLabel.textContent = detectedLanguage || "code";
71+
languageLabel.style.color = "var(--vscode-editor-foreground)";
72+
languageLabel.style.opacity = "0.8";
73+
74+
// Add copy button to the header
75+
copyButton.style.position = "static";
76+
copyButton.style.margin = "0";
77+
78+
codeHeader.appendChild(languageLabel);
79+
codeHeader.appendChild(copyButton);
80+
81+
// Wrap the pre element
82+
preElement.parentNode?.insertBefore(wrapper, preElement);
83+
wrapper.appendChild(codeHeader);
84+
wrapper.appendChild(preElement);
6885
}
6986
}
7087
} catch (error) {

0 commit comments

Comments
 (0)