Skip to content

Commit 38ed82b

Browse files
authored
feat(code-block): Do not copy diff markers (#12900)
* feat(code-block): Do not copy diff markers * add tests
1 parent ab459a6 commit 38ed82b

File tree

3 files changed

+233
-13
lines changed

3 files changed

+233
-13
lines changed
+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {describe, expect, it} from 'vitest';
2+
3+
import {cleanCodeSnippet} from './index';
4+
5+
describe('cleanCodeSnippet', () => {
6+
describe('consecutive newlines', () => {
7+
it('should reduce two consecutive newlines to a single newline', () => {
8+
const input = 'line1\n\nline2\n\n\n line3';
9+
const result = cleanCodeSnippet(input, {});
10+
expect(result).toBe('line1\nline2\n\n line3');
11+
});
12+
13+
it('should handle input with single newlines', () => {
14+
const input = 'line1\nline2\nline3';
15+
const result = cleanCodeSnippet(input, {});
16+
expect(result).toBe('line1\nline2\nline3');
17+
});
18+
});
19+
20+
describe('diff markers', () => {
21+
it('should remove diff markers (+/-) from the beginning of lines by default', () => {
22+
const input = '+added line\n- removed line\n normal line';
23+
const result = cleanCodeSnippet(input);
24+
expect(result).toBe('added line\nremoved line\n normal line');
25+
});
26+
27+
it('should preserve diff markers when cleanDiffMarkers is set to false', () => {
28+
const input = '+ added line\n- removed line';
29+
const result = cleanCodeSnippet(input, {cleanDiffMarkers: false});
30+
expect(result).toBe('+ added line\n- removed line');
31+
});
32+
33+
it('should remove diff markers based on real-life code example', () => {
34+
const input =
35+
'-Sentry.init({\n' +
36+
"- dsn: '\n" +
37+
'\n' +
38+
"https://[email protected]/0',\n" +
39+
'- tracesSampleRate: 1.0,\n' +
40+
'-\n' +
41+
'- // uncomment the line below to enable Spotlight (https://spotlightjs.com)\n' +
42+
'- // spotlight: import.meta.env.DEV,\n' +
43+
'-});\n' +
44+
'-\n' +
45+
'-export const handle = sentryHandle();\n' +
46+
'+export const handle = sequence(\n' +
47+
'+ initCloudflareSentryHandle({\n' +
48+
"+ dsn: '\n" +
49+
'\n' +
50+
"https://[email protected]/0',\n" +
51+
'+ tracesSampleRate: 1.0,\n' +
52+
'+ }),\n' +
53+
'+ sentryHandle()\n' +
54+
'+);';
55+
56+
const result = cleanCodeSnippet(input);
57+
expect(result).toBe(
58+
'Sentry.init({\n' +
59+
" dsn: '\n" +
60+
"https://[email protected]/0',\n" +
61+
' tracesSampleRate: 1.0,\n' +
62+
' // uncomment the line below to enable Spotlight (https://spotlightjs.com)\n' +
63+
' // spotlight: import.meta.env.DEV,\n' +
64+
'});\n' +
65+
'export const handle = sentryHandle();\n' +
66+
'export const handle = sequence(\n' +
67+
' initCloudflareSentryHandle({\n' +
68+
" dsn: '\n" +
69+
"https://[email protected]/0',\n" +
70+
' tracesSampleRate: 1.0,\n' +
71+
' }),\n' +
72+
' sentryHandle()\n' +
73+
');'
74+
);
75+
});
76+
});
77+
78+
describe('bash prompt', () => {
79+
it('should remove bash prompt in bash/shell language', () => {
80+
const input = '$ ls -la\nsome output';
81+
const result = cleanCodeSnippet(input, {language: 'bash'});
82+
expect(result).toBe('ls -la\nsome output');
83+
});
84+
85+
it('should remove bash prompt in shell language', () => {
86+
const input = '$ git status\nsome output';
87+
const result = cleanCodeSnippet(input, {language: 'shell'});
88+
expect(result).toBe('git status\nsome output');
89+
});
90+
91+
it('should not remove bash prompt for non-bash/shell languages', () => {
92+
const input = '$ some text';
93+
const result = cleanCodeSnippet(input, {language: 'python'});
94+
expect(result).toBe('$ some text');
95+
});
96+
97+
it('should handle bash prompt with multiple spaces', () => {
98+
const input = '$ ls -la\nsome output';
99+
const result = cleanCodeSnippet(input, {language: 'bash'});
100+
expect(result).toBe('ls -la\nsome output');
101+
});
102+
});
103+
104+
describe('combination of options', () => {
105+
it('should handle multiple cleaning operations together', () => {
106+
const input = '+ $ ls -la\n\n- $ git status';
107+
const result = cleanCodeSnippet(input, {language: 'bash'});
108+
expect(result).toBe('ls -la\ngit status');
109+
});
110+
});
111+
112+
describe('edge cases', () => {
113+
it('should handle empty input', () => {
114+
const input = '';
115+
const result = cleanCodeSnippet(input, {});
116+
expect(result).toBe('');
117+
});
118+
119+
it('should handle input with only newlines', () => {
120+
const input = '\n\n\n';
121+
const result = cleanCodeSnippet(input, {});
122+
expect(result).toBe('\n\n');
123+
});
124+
125+
it('should preserve leading whitespace not associated with diff markers', () => {
126+
const input = ' normal line\n+ added line';
127+
const result = cleanCodeSnippet(input, {});
128+
expect(result).toBe(' normal line\nadded line');
129+
});
130+
});
131+
});

src/components/codeBlock/index.tsx

+95-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import {useEffect, useRef, useState} from 'react';
3+
import {RefObject, useEffect, useRef, useState} from 'react';
44
import {Clipboard} from 'react-feather';
55

66
import styles from './code-blocks.module.scss';
@@ -25,31 +25,31 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
2525
setShowCopyButton(true);
2626
}, []);
2727

28-
async function copyCode() {
28+
useCleanSnippetInClipboard(codeRef, {language});
29+
30+
async function copyCodeOnClick() {
2931
if (codeRef.current === null) {
3032
return;
3133
}
3234

33-
let code = codeRef.current.innerText.replace(/\n\n/g, '\n');
35+
const code = cleanCodeSnippet(codeRef.current.innerText, {language});
3436

35-
// don't copy leading prompt for bash
36-
if (language === 'bash' || language === 'shell') {
37-
const match = code.match(/^\$\s*/);
38-
if (match) {
39-
code = code.substring(match[0].length);
40-
}
37+
try {
38+
await navigator.clipboard.writeText(code);
39+
setShowCopied(true);
40+
setTimeout(() => setShowCopied(false), 1200);
41+
} catch (error) {
42+
// eslint-disable-next-line no-console
43+
console.error('Failed to copy:', error);
4144
}
42-
await navigator.clipboard.writeText(code);
43-
setShowCopied(true);
44-
setTimeout(() => setShowCopied(false), 1200);
4545
}
4646

4747
return (
4848
<div className={styles['code-block']}>
4949
<div className={styles['code-actions']}>
5050
<code className={styles.filename}>{filename}</code>
5151
{showCopyButton && (
52-
<button className={styles.copy} onClick={() => copyCode()}>
52+
<button className={styles.copy} onClick={copyCodeOnClick}>
5353
<Clipboard size={16} />
5454
</button>
5555
)}
@@ -61,3 +61,85 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
6161
</div>
6262
);
6363
}
64+
65+
interface CleanCopyOptions {
66+
cleanBashPrompt?: boolean;
67+
cleanDiffMarkers?: boolean;
68+
language?: string;
69+
}
70+
71+
const REGEX = {
72+
DIFF_MARKERS: /^[+\-](?:\s|(?=\S))/gm, // Matches diff markers (+ or -) at the beginning of lines, with or without spaces
73+
BASH_PROMPT: /^\$\s*/, // Matches bash prompt ($ followed by a space)
74+
CONSECUTIVE_NEWLINES: /\n\n/g, // Matches consecutive newlines
75+
};
76+
77+
/**
78+
* Cleans a code snippet by removing diff markers (+ or -) and bash prompts.
79+
*
80+
* @internal Only exported for testing
81+
*/
82+
export function cleanCodeSnippet(rawCodeSnippet: string, options?: CleanCopyOptions) {
83+
const language = options?.language;
84+
const cleanDiffMarkers = options?.cleanDiffMarkers ?? true;
85+
const cleanBashPrompt = options?.cleanBashPrompt ?? true;
86+
87+
let cleanedSnippet = rawCodeSnippet.replace(REGEX.CONSECUTIVE_NEWLINES, '\n');
88+
89+
if (cleanDiffMarkers) {
90+
cleanedSnippet = cleanedSnippet.replace(REGEX.DIFF_MARKERS, '');
91+
}
92+
93+
if (cleanBashPrompt && (language === 'bash' || language === 'shell')) {
94+
// Split into lines, clean each line, then rejoin
95+
cleanedSnippet = cleanedSnippet
96+
.split('\n')
97+
.map(line => {
98+
const match = line.match(REGEX.BASH_PROMPT);
99+
return match ? line.substring(match[0].length) : line;
100+
})
101+
.filter(line => line.trim() !== '') // Remove empty lines
102+
.join('\n');
103+
}
104+
105+
return cleanedSnippet;
106+
}
107+
108+
/**
109+
* A custom hook that handles cleaning text when manually copying code to clipboard
110+
*
111+
* @param codeRef - Reference to the code element
112+
* @param options - Configuration options for cleaning
113+
*/
114+
export function useCleanSnippetInClipboard(
115+
codeRef: RefObject<HTMLElement>,
116+
options: CleanCopyOptions = {}
117+
) {
118+
const {cleanDiffMarkers = true, cleanBashPrompt = true, language = ''} = options;
119+
120+
useEffect(() => {
121+
const handleUserCopyEvent = (event: ClipboardEvent) => {
122+
if (!codeRef.current || !event.clipboardData) return;
123+
124+
const selection = window.getSelection()?.toString() || '';
125+
126+
if (selection) {
127+
const cleanedSnippet = cleanCodeSnippet(selection, options);
128+
129+
event.clipboardData.setData('text/plain', cleanedSnippet);
130+
event.preventDefault();
131+
}
132+
};
133+
134+
const codeElement = codeRef.current;
135+
if (codeElement) {
136+
codeElement.addEventListener('copy', handleUserCopyEvent as EventListener);
137+
}
138+
139+
return () => {
140+
if (codeElement) {
141+
codeElement.removeEventListener('copy', handleUserCopyEvent as EventListener);
142+
}
143+
};
144+
}, [codeRef, cleanDiffMarkers, language, cleanBashPrompt, options]);
145+
}

vitest.config.mjs

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// <reference types="vitest" />
2+
import tsconfigPaths from 'vite-tsconfig-paths';
3+
import {defineConfig} from 'vitest/config';
4+
5+
export default defineConfig({
6+
plugins: [tsconfigPaths()],
7+
});

0 commit comments

Comments
 (0)