Skip to content

Commit

Permalink
Add code syntax highlighting and copy code (#520)
Browse files Browse the repository at this point in the history
  • Loading branch information
logancyang authored Aug 21, 2024
1 parent d8acc24 commit 0191f66
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 117 deletions.
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# .husky/pre-commit
prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown
git update-index --again

# Add linting
npm run lint
23 changes: 8 additions & 15 deletions src/components/ChatComponents/ChatSingleMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { BotIcon, CheckIcon, CopyClipboardIcon, UserIcon } from '@/components/Icons';
import ReactMarkdown from '@/components/Markdown/MemoizedReactMarkdown';
import { USER_SENDER } from '@/constants';
import { ChatMessage } from '@/sharedState';
import React, { useState } from 'react';

import { BotIcon, CheckIcon, CopyClipboardIcon, UserIcon } from "@/components/Icons";
import MemoizedReactMarkdown from "@/components/Markdown/MemoizedReactMarkdown";
import { USER_SENDER } from "@/constants";
import { ChatMessage } from "@/sharedState";
import React, { useState } from "react";

interface ChatSingleMessageProps {
message: ChatMessage;
Expand All @@ -27,22 +26,16 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({ message }) => {
};

return (
<div className='message-container'>
<div
className={`message ${
message.sender === USER_SENDER ? "user-message" : "bot-message"
}`}
>
<div className="message-container">
<div className={`message ${message.sender === USER_SENDER ? "user-message" : "bot-message"}`}>
<div className="message-icon">
{message.sender === USER_SENDER ? <UserIcon /> : <BotIcon />}
</div>
<div className="message-content">
{message.sender === USER_SENDER ? (
<span>{message.message}</span>
) : (
<ReactMarkdown
transformLinkUri={null}
>{message.message}</ReactMarkdown>
<MemoizedReactMarkdown transformLinkUri={null}>{message.message}</MemoizedReactMarkdown>
)}
</div>
</div>
Expand Down
117 changes: 37 additions & 80 deletions src/components/Markdown/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import PropTypes from 'prop-types';
import React, { FC, memo, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';

import { IconCheck, IconClipboard } from "@tabler/icons-react";
import { useTranslation } from "next-i18next";
import PropTypes from "prop-types";
import React, { FC, memo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";

interface CodeProps {
language?: string;
Expand All @@ -16,43 +15,43 @@ interface languageMap {
}

export const programmingLanguages: languageMap = {
javascript: '.js',
python: '.py',
java: '.java',
c: '.c',
cpp: '.cpp',
'c++': '.cpp',
'c#': '.cs',
ruby: '.rb',
php: '.php',
swift: '.swift',
'objective-c': '.m',
kotlin: '.kt',
typescript: '.ts',
go: '.go',
perl: '.pl',
rust: '.rs',
scala: '.scala',
haskell: '.hs',
lua: '.lua',
shell: '.sh',
sql: '.sql',
html: '.html',
css: '.css',
javascript: ".js",
python: ".py",
java: ".java",
c: ".c",
cpp: ".cpp",
"c++": ".cpp",
"c#": ".cs",
ruby: ".rb",
php: ".php",
swift: ".swift",
"objective-c": ".m",
kotlin: ".kt",
typescript: ".ts",
go: ".go",
perl: ".pl",
rust: ".rs",
scala: ".scala",
haskell: ".hs",
lua: ".lua",
shell: ".sh",
sql: ".sql",
html: ".html",
css: ".css",
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
};

export const generateRandomString = (length: number, lowercase = false) => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXY3456789'; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = '';
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return lowercase ? result.toLowerCase() : result;
};

export const CodeBlock: FC<CodeProps> = memo(({ language = 'text', value }) => {
const { t } = useTranslation('markdown');
export const CodeBlock: FC<CodeProps> = memo(({ language = "text", value }) => {
const { t } = useTranslation("markdown");
const [isCopied, setIsCopied] = useState<boolean>(false);

const copyToClipboard = () => {
Expand All @@ -68,64 +67,22 @@ export const CodeBlock: FC<CodeProps> = memo(({ language = 'text', value }) => {
}, 2000);
});
};
const downloadAsFile = () => {
const fileExtension = programmingLanguages[language] || '.file';
const suggestedFileName = `file-${generateRandomString(
3,
true,
)}${fileExtension}`;
const fileName = window.prompt(
t('Enter file name') || '',
suggestedFileName,
);

if (!fileName) {
// user pressed cancel on prompt
return;
}

const blob = new Blob([value], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = fileName;
link.href = url;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="codeblock relative font-sans text-[16px]">
<div className="flex items-center justify-between py-1.5 px-4">
<span className="text-xs lowercase text-white">{language}</span>

<div className="flex items-center">
<button
className="flex gap-1.5 items-center rounded bg-none p-1 text-xs text-white"
onClick={copyToClipboard}
>
{isCopied ? (
<IconCheck size={18} />
) : (
<IconClipboard size={18} />
)}
{isCopied ? t('Copied!') : t('Copy code')}
</button>
<button
className="flex items-center rounded bg-none p-1 text-xs text-white"
onClick={downloadAsFile}
>
<IconDownload size={18} />
{isCopied ? <IconCheck size={18} /> : <IconClipboard size={18} />}
{isCopied ? t("Copied!") : t("Copy code")}
</button>
</div>
</div>

<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{ margin: 0 }}
>
<SyntaxHighlighter language={language} style={oneDark} customStyle={{ margin: 0 }}>
{value}
</SyntaxHighlighter>
</div>
Expand All @@ -135,6 +92,6 @@ CodeBlock.propTypes = {
language: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
};
CodeBlock.displayName = 'CodeBlock';
CodeBlock.displayName = "CodeBlock";

export default CodeBlock;
24 changes: 21 additions & 3 deletions src/components/Markdown/MemoizedReactMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { FC, memo } from 'react';
import ReactMarkdown, { Options } from 'react-markdown';
import React, { FC, memo } from "react";
import ReactMarkdown, { Options } from "react-markdown";
import CodeBlock from "./CodeBlock"; // Adjust the import path as necessary

const MemoizedReactMarkdown: FC<Options> = memo((props) => (
<ReactMarkdown
{...props}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<CodeBlock language={match[1]} value={String(children).replace(/\n$/, "")} />
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
/>
));

const MemoizedReactMarkdown: FC<Options> = memo(ReactMarkdown);
export default MemoizedReactMarkdown;
20 changes: 8 additions & 12 deletions src/proxyServer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ChatModelDisplayNames } from "@/constants";
import { CopilotSettings } from "@/settings/SettingsPage";
import cors from "@koa/cors";
import Koa from "koa";
import proxy from "koa-proxies";
import { CopilotSettings } from "@/settings/SettingsPage";
import { ChatModelDisplayNames } from "@/constants";

// There should only be 1 running proxy server at a time so keep it in upper scope
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let server: any;

export class ProxyServer {
Expand All @@ -22,10 +23,7 @@ export class ProxyServer {
getProxyURL(currentModel: string): string {
if (currentModel === ChatModelDisplayNames.CLAUDE) {
return "https://api.anthropic.com/";
} else if (
this.settings.useOpenAILocalProxy &&
this.settings.openAIProxyBaseUrl
) {
} else if (this.settings.useOpenAILocalProxy && this.settings.openAIProxyBaseUrl) {
return this.settings.openAIProxyBaseUrl;
}

Expand All @@ -51,12 +49,12 @@ export class ProxyServer {
changeOrigin: true,
logs: false,
rewrite: rewritePaths ? (path) => path : undefined,
}),
})
);

// Create the server and attach error handling for "EADDRINUSE"
if (server?.listening) {
return
return;
}
server = app.listen(this.port);
server.on("error", (err: NodeJS.ErrnoException) => {
Expand All @@ -71,7 +69,7 @@ export class ProxyServer {
this.runningUrl = proxyBaseUrl;
if (this.debug) {
console.log(
`Proxy server running on http://localhost:${this.port}. Proxy to ${proxyBaseUrl}`,
`Proxy server running on http://localhost:${this.port}. Proxy to ${proxyBaseUrl}`
);
}
});
Expand All @@ -81,9 +79,7 @@ export class ProxyServer {
let waitForClose: Promise<boolean> | boolean = false;
if (server) {
if (this.debug) {
console.log(
`Attempting to stop proxy server proxying to ${this.runningUrl}...`,
);
console.log(`Attempting to stop proxy server proxying to ${this.runningUrl}...`);
}
waitForClose = new Promise((resolve) => {
server.on("close", () => {
Expand Down
16 changes: 9 additions & 7 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ If your plugin does not need CSS, delete this file.
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden; /* Fix overflow and can't scroll up */
overflow: hidden; /* Fix overflow and can't scroll up */
}

.bottom-container {
Expand Down Expand Up @@ -211,12 +211,14 @@ If your plugin does not need CSS, delete this file.
word-break: break-word;
}

.message-content pre, .message-content p {
.message-content pre,
.message-content p {
margin: 0;
padding: 0;
}

.message-content ol, .message-content ul {
.message-content ol,
.message-content ul {
list-style: none;
padding-left: 20px;
margin: 0;
Expand All @@ -238,13 +240,13 @@ If your plugin does not need CSS, delete this file.
}

.message-content li:has(> p) {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}

.message-content li:has(> p)::before {
align-self: flex-start;
margin-top: 0; /* Adjust this value to align the pseudo-element vertically */
align-self: flex-start;
margin-top: 0; /* Adjust this value to align the pseudo-element vertically */
}

.copy-message-button {
Expand Down

0 comments on commit 0191f66

Please sign in to comment.