diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
index 52614703f..a91fa706e 100644
--- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
+++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
@@ -12,31 +12,130 @@ let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
+const COPY_ICON_SVG_TEXT = `
+
+`;
- const renderVarInfo = (token, options, cm, pos) => {
- // Extract variable name and value based on token
- const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
+const CHECKMARK_ICON_SVG_TEXT = `
+
+`;
- if (variableValue === undefined) {
+const COPY_SUCCESS_COLOR = '#22c55e';
+
+export const COPY_SUCCESS_TIMEOUT = 1000;
+
+const getCopyButton = variableValue => {
+ const copyButton = document.createElement('button');
+
+ copyButton.className = 'copy-button';
+ copyButton.style.backgroundColor = 'transparent';
+ copyButton.style.border = 'none';
+ copyButton.style.color = 'inherit';
+ copyButton.style.cursor = 'pointer';
+ copyButton.style.padding = '2px';
+ copyButton.style.opacity = '0.7';
+ copyButton.style.transition = 'opacity 0.2s ease';
+ copyButton.style.display = 'flex';
+ copyButton.style.alignItems = 'center';
+ copyButton.style.justifyContent = 'center';
+
+ copyButton.innerHTML = COPY_ICON_SVG_TEXT;
+
+ let isCopied = false;
+
+ copyButton.addEventListener('mouseenter', () => {
+ if (isCopied) {
return;
}
- const into = document.createElement('div');
- const descriptionDiv = document.createElement('div');
- descriptionDiv.className = 'info-description';
+ copyButton.style.opacity = '1';
+ });
- if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
- descriptionDiv.appendChild(document.createTextNode('*****'));
- } else {
- descriptionDiv.appendChild(document.createTextNode(variableValue));
+ copyButton.addEventListener('mouseleave', () => {
+ if (isCopied) {
+ return;
}
- into.appendChild(descriptionDiv);
+ copyButton.style.opacity = '0.7';
+ });
- return into;
- };
+ copyButton.addEventListener('click', e => {
+ e.stopPropagation();
+
+ // Prevent clicking if showing success checkmark
+ if (isCopied) {
+ return;
+ }
+
+ navigator.clipboard
+ .writeText(variableValue)
+ .then(() => {
+ isCopied = true;
+ copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT;
+ copyButton.style.opacity = '1';
+ copyButton.style.color = COPY_SUCCESS_COLOR;
+ copyButton.style.cursor = 'default';
+ copyButton.classList.add('copy-success');
+
+ setTimeout(() => {
+ isCopied = false;
+ copyButton.innerHTML = COPY_ICON_SVG_TEXT;
+ copyButton.style.opacity = '0.7';
+ copyButton.style.color = 'inherit';
+ copyButton.style.cursor = 'pointer';
+ copyButton.classList.remove('copy-success');
+ }, COPY_SUCCESS_TIMEOUT);
+ })
+ .catch(err => {
+ console.error('Failed to copy to clipboard:', err.message);
+ });
+ });
+
+ return copyButton;
+};
+
+export const renderVarInfo = (token, options, cm, pos) => {
+ // Extract variable name and value based on token
+ const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
+
+ if (variableValue === undefined) {
+ return;
+ }
+
+ const into = document.createElement('div');
+
+ const contentDiv = document.createElement('div');
+ contentDiv.style.display = 'flex';
+ contentDiv.style.alignItems = 'center';
+ contentDiv.style.gap = '8px';
+ contentDiv.className = 'info-content';
+
+ const descriptionDiv = document.createElement('div');
+ descriptionDiv.className = 'info-description';
+ descriptionDiv.style.flex = '1';
+
+ if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
+ descriptionDiv.appendChild(document.createTextNode('*****'));
+ } else {
+ descriptionDiv.appendChild(document.createTextNode(variableValue));
+ }
+
+ const copyButton = getCopyButton(variableValue);
+
+ contentDiv.appendChild(descriptionDiv);
+ contentDiv.appendChild(copyButton);
+ into.appendChild(contentDiv);
+
+ return into;
+};
+
+if (!SERVER_RENDERED) {
+ CodeMirror = require('codemirror');
CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) {
if (old && old !== CodeMirror.Init) {
@@ -214,4 +313,4 @@ export const extractVariableInfo = (str, variables) => {
}
return { variableName, variableValue };
-};
\ No newline at end of file
+};
diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js
index 5002097c2..0a2a161dc 100644
--- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js
+++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js
@@ -1,5 +1,5 @@
import { interpolate } from '@usebruno/common';
-import { extractVariableInfo } from './brunoVarInfo';
+import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo';
// Mock the dependencies
jest.mock('@usebruno/common', () => ({
@@ -225,3 +225,120 @@ describe('extractVariableInfo', () => {
});
});
});
+
+describe('renderVarInfo', () => {
+ let clipboardText = '';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ // setup mock clipboard
+ clipboardText = '';
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: jest.fn(text => {
+ if (text === 'cause-clipboard-error') {
+ return Promise.reject(new Error('Clipboard error'));
+ }
+
+ clipboardText = text;
+
+ return Promise.resolve();
+ }),
+ },
+ configurable: true,
+ });
+
+ // mock console.error
+ console.error = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ function setupRender(variables) {
+ const result = renderVarInfo({ string: '{{apiKey}}' }, { variables });
+ const contentDiv = result.querySelector('.info-content');
+ const descriptionDiv = contentDiv.querySelector('.info-description');
+ const copyButton = contentDiv.querySelector('.copy-button');
+
+ return { result, contentDiv, descriptionDiv, copyButton };
+ }
+
+ describe('popup functionality', () => {
+ it('should create a popup', () => {
+ const { result } = setupRender({ apiKey: 'test-value' });
+
+ expect(result).toBeDefined();
+ });
+
+ it('should create a popup with the correct variable name and value', () => {
+ const { descriptionDiv } = setupRender({ apiKey: 'test-value' });
+
+ expect(descriptionDiv.textContent).toBe('test-value');
+ });
+
+ it('should correctly mask the variable value in the popup', () => {
+ const { descriptionDiv } = setupRender({
+ apiKey: 'test-value',
+ maskedEnvVariables: ['apiKey'],
+ });
+
+ expect(descriptionDiv.textContent).toBe('*****');
+ });
+ });
+
+ describe('copy button functionality', () => {
+ it('should create a copy button', () => {
+ const { copyButton } = setupRender({ apiKey: 'test-value' });
+
+ expect(copyButton).toBeDefined();
+ });
+
+ it('should copy the variable value to the clipboard', async () => {
+ const { copyButton } = setupRender({ apiKey: 'test-value' });
+
+ await copyButton.click();
+
+ expect(clipboardText).toBe('test-value');
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
+ });
+
+ it('should copy the variable value of masked variables to the clipboard', async () => {
+ const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] });
+
+ await copyButton.click();
+
+ expect(clipboardText).toBe('test-value');
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
+ });
+
+ it('should show a success checkmark when the variable value is copied', async () => {
+ const { copyButton } = setupRender({ apiKey: 'test-value' });
+
+ expect(copyButton.classList.contains('copy-success')).toBe(false);
+
+ await copyButton.click();
+
+ expect(copyButton.classList.contains('copy-success')).toBe(true);
+
+ jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT);
+
+ expect(copyButton.classList.contains('copy-success')).toBe(false);
+ });
+
+ it('should log to the console when the variable value is not copied', async () => {
+ const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' });
+
+ await copyButton.click();
+
+ // wait for .catch() microtask to run
+ await Promise.resolve();
+
+ expect(clipboardText).toBe('');
+ expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error');
+ });
+ });
+});