Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(code-block): Do not copy diff markers #12900

Merged
merged 2 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/components/codeBlock/codeBlock.spec.ts
Original file line number Diff line number Diff line change
@@ -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://[email protected]/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://[email protected]/0',\n" +
'+ tracesSampleRate: 1.0,\n' +
'+ }),\n' +
'+ sentryHandle()\n' +
'+);';

const result = cleanCodeSnippet(input);
expect(result).toBe(
'Sentry.init({\n' +
" dsn: '\n" +
"https://[email protected]/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://[email protected]/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');
});
});
});
108 changes: 95 additions & 13 deletions src/components/codeBlock/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,31 +25,31 @@ 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 (
<div className={styles['code-block']}>
<div className={styles['code-actions']}>
<code className={styles.filename}>{filename}</code>
{showCopyButton && (
<button className={styles.copy} onClick={() => copyCode()}>
<button className={styles.copy} onClick={copyCodeOnClick}>
<Clipboard size={16} />
</button>
)}
Expand All @@ -61,3 +61,85 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
</div>
);
}

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<HTMLElement>,
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]);
}
7 changes: 7 additions & 0 deletions vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// <reference types="vitest" />
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';

export default defineConfig({
plugins: [tsconfigPaths()],
});
Loading