diff --git a/src/components/codeBlock/codeBlock.spec.ts b/src/components/codeBlock/codeBlock.spec.ts new file mode 100644 index 0000000000000..ecc0bc0bb20ed --- /dev/null +++ b/src/components/codeBlock/codeBlock.spec.ts @@ -0,0 +1,131 @@ +import {describe, expect, it} from 'vitest'; + +import {cleanCodeSnippet} from './index'; + +describe('cleanCodeSnippet', () => { + describe('consecutive newlines', () => { + it('should reduce two consecutive newlines to a single newline', () => { + const input = 'line1\n\nline2\n\n\n line3'; + const result = cleanCodeSnippet(input, {}); + expect(result).toBe('line1\nline2\n\n line3'); + }); + + it('should handle input with single newlines', () => { + const input = 'line1\nline2\nline3'; + const result = cleanCodeSnippet(input, {}); + expect(result).toBe('line1\nline2\nline3'); + }); + }); + + describe('diff markers', () => { + it('should remove diff markers (+/-) from the beginning of lines by default', () => { + const input = '+added line\n- removed line\n normal line'; + const result = cleanCodeSnippet(input); + expect(result).toBe('added line\nremoved line\n normal line'); + }); + + it('should preserve diff markers when cleanDiffMarkers is set to false', () => { + const input = '+ added line\n- removed line'; + const result = cleanCodeSnippet(input, {cleanDiffMarkers: false}); + expect(result).toBe('+ added line\n- removed line'); + }); + + it('should remove diff markers based on real-life code example', () => { + const input = + '-Sentry.init({\n' + + "- dsn: '\n" + + '\n' + + "https://examplePublicKey@o0.ingest.sentry.io/0',\n" + + '- tracesSampleRate: 1.0,\n' + + '-\n' + + '- // uncomment the line below to enable Spotlight (https://spotlightjs.com)\n' + + '- // spotlight: import.meta.env.DEV,\n' + + '-});\n' + + '-\n' + + '-export const handle = sentryHandle();\n' + + '+export const handle = sequence(\n' + + '+ initCloudflareSentryHandle({\n' + + "+ dsn: '\n" + + '\n' + + "https://examplePublicKey@o0.ingest.sentry.io/0',\n" + + '+ tracesSampleRate: 1.0,\n' + + '+ }),\n' + + '+ sentryHandle()\n' + + '+);'; + + const result = cleanCodeSnippet(input); + expect(result).toBe( + 'Sentry.init({\n' + + " dsn: '\n" + + "https://examplePublicKey@o0.ingest.sentry.io/0',\n" + + ' tracesSampleRate: 1.0,\n' + + ' // uncomment the line below to enable Spotlight (https://spotlightjs.com)\n' + + ' // spotlight: import.meta.env.DEV,\n' + + '});\n' + + 'export const handle = sentryHandle();\n' + + 'export const handle = sequence(\n' + + ' initCloudflareSentryHandle({\n' + + " dsn: '\n" + + "https://examplePublicKey@o0.ingest.sentry.io/0',\n" + + ' tracesSampleRate: 1.0,\n' + + ' }),\n' + + ' sentryHandle()\n' + + ');' + ); + }); + }); + + describe('bash prompt', () => { + it('should remove bash prompt in bash/shell language', () => { + const input = '$ ls -la\nsome output'; + const result = cleanCodeSnippet(input, {language: 'bash'}); + expect(result).toBe('ls -la\nsome output'); + }); + + it('should remove bash prompt in shell language', () => { + const input = '$ git status\nsome output'; + const result = cleanCodeSnippet(input, {language: 'shell'}); + expect(result).toBe('git status\nsome output'); + }); + + it('should not remove bash prompt for non-bash/shell languages', () => { + const input = '$ some text'; + const result = cleanCodeSnippet(input, {language: 'python'}); + expect(result).toBe('$ some text'); + }); + + it('should handle bash prompt with multiple spaces', () => { + const input = '$ ls -la\nsome output'; + const result = cleanCodeSnippet(input, {language: 'bash'}); + expect(result).toBe('ls -la\nsome output'); + }); + }); + + describe('combination of options', () => { + it('should handle multiple cleaning operations together', () => { + const input = '+ $ ls -la\n\n- $ git status'; + const result = cleanCodeSnippet(input, {language: 'bash'}); + expect(result).toBe('ls -la\ngit status'); + }); + }); + + describe('edge cases', () => { + it('should handle empty input', () => { + const input = ''; + const result = cleanCodeSnippet(input, {}); + expect(result).toBe(''); + }); + + it('should handle input with only newlines', () => { + const input = '\n\n\n'; + const result = cleanCodeSnippet(input, {}); + expect(result).toBe('\n\n'); + }); + + it('should preserve leading whitespace not associated with diff markers', () => { + const input = ' normal line\n+ added line'; + const result = cleanCodeSnippet(input, {}); + expect(result).toBe(' normal line\nadded line'); + }); + }); +}); diff --git a/src/components/codeBlock/index.tsx b/src/components/codeBlock/index.tsx index cbc88eaf4c9db..e9755b1e23d15 100644 --- a/src/components/codeBlock/index.tsx +++ b/src/components/codeBlock/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useEffect, useRef, useState} from 'react'; +import {RefObject, useEffect, useRef, useState} from 'react'; import {Clipboard} from 'react-feather'; import styles from './code-blocks.module.scss'; @@ -25,23 +25,23 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) { setShowCopyButton(true); }, []); - async function copyCode() { + useCleanSnippetInClipboard(codeRef, {language}); + + async function copyCodeOnClick() { if (codeRef.current === null) { return; } - let code = codeRef.current.innerText.replace(/\n\n/g, '\n'); + const code = cleanCodeSnippet(codeRef.current.innerText, {language}); - // don't copy leading prompt for bash - if (language === 'bash' || language === 'shell') { - const match = code.match(/^\$\s*/); - if (match) { - code = code.substring(match[0].length); - } + try { + await navigator.clipboard.writeText(code); + setShowCopied(true); + setTimeout(() => setShowCopied(false), 1200); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to copy:', error); } - await navigator.clipboard.writeText(code); - setShowCopied(true); - setTimeout(() => setShowCopied(false), 1200); } return ( @@ -49,7 +49,7 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
{filename} {showCopyButton && ( - )} @@ -61,3 +61,85 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
); } + +interface CleanCopyOptions { + cleanBashPrompt?: boolean; + cleanDiffMarkers?: boolean; + language?: string; +} + +const REGEX = { + DIFF_MARKERS: /^[+\-](?:\s|(?=\S))/gm, // Matches diff markers (+ or -) at the beginning of lines, with or without spaces + BASH_PROMPT: /^\$\s*/, // Matches bash prompt ($ followed by a space) + CONSECUTIVE_NEWLINES: /\n\n/g, // Matches consecutive newlines +}; + +/** + * Cleans a code snippet by removing diff markers (+ or -) and bash prompts. + * + * @internal Only exported for testing + */ +export function cleanCodeSnippet(rawCodeSnippet: string, options?: CleanCopyOptions) { + const language = options?.language; + const cleanDiffMarkers = options?.cleanDiffMarkers ?? true; + const cleanBashPrompt = options?.cleanBashPrompt ?? true; + + let cleanedSnippet = rawCodeSnippet.replace(REGEX.CONSECUTIVE_NEWLINES, '\n'); + + if (cleanDiffMarkers) { + cleanedSnippet = cleanedSnippet.replace(REGEX.DIFF_MARKERS, ''); + } + + if (cleanBashPrompt && (language === 'bash' || language === 'shell')) { + // Split into lines, clean each line, then rejoin + cleanedSnippet = cleanedSnippet + .split('\n') + .map(line => { + const match = line.match(REGEX.BASH_PROMPT); + return match ? line.substring(match[0].length) : line; + }) + .filter(line => line.trim() !== '') // Remove empty lines + .join('\n'); + } + + return cleanedSnippet; +} + +/** + * A custom hook that handles cleaning text when manually copying code to clipboard + * + * @param codeRef - Reference to the code element + * @param options - Configuration options for cleaning + */ +export function useCleanSnippetInClipboard( + codeRef: RefObject, + options: CleanCopyOptions = {} +) { + const {cleanDiffMarkers = true, cleanBashPrompt = true, language = ''} = options; + + useEffect(() => { + const handleUserCopyEvent = (event: ClipboardEvent) => { + if (!codeRef.current || !event.clipboardData) return; + + const selection = window.getSelection()?.toString() || ''; + + if (selection) { + const cleanedSnippet = cleanCodeSnippet(selection, options); + + event.clipboardData.setData('text/plain', cleanedSnippet); + event.preventDefault(); + } + }; + + const codeElement = codeRef.current; + if (codeElement) { + codeElement.addEventListener('copy', handleUserCopyEvent as EventListener); + } + + return () => { + if (codeElement) { + codeElement.removeEventListener('copy', handleUserCopyEvent as EventListener); + } + }; + }, [codeRef, cleanDiffMarkers, language, cleanBashPrompt, options]); +} diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000000000..836de9fb7e92f --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,7 @@ +/// +import tsconfigPaths from 'vite-tsconfig-paths'; +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + plugins: [tsconfigPaths()], +});