Skip to content

Commit

Permalink
feat(code-block): Do not copy diff markers (#12900)
Browse files Browse the repository at this point in the history
* feat(code-block): Do not copy diff markers

* add tests
  • Loading branch information
s1gr1d authored Mar 4, 2025
1 parent ab459a6 commit 38ed82b
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 13 deletions.
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()],
});

0 comments on commit 38ed82b

Please sign in to comment.