Merge pull request #6069 from pooja-bruno/feat/add-edit-variable-in-place

feat: edit variable in place
This commit is contained in:
Pooja
2025-11-17 16:13:09 +05:30
committed by GitHub
parent 27a7b623c7
commit 4631eda281
16 changed files with 1605 additions and 118 deletions

View File

@@ -7,20 +7,26 @@
*/
import { interpolate } from '@usebruno/common';
import { getVariableScope, isVariableSecret, getAllVariables } from 'utils/collections';
import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions';
import store from 'providers/ReduxStore';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
const COPY_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
const CHECKMARK_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
`;
@@ -29,43 +35,100 @@ const COPY_SUCCESS_COLOR = '#22c55e';
export const COPY_SUCCESS_TIMEOUT = 1000;
const getCopyButton = (variableValue) => {
// Editor height constraints
const EDITOR_MIN_HEIGHT = 1.75;
const EDITOR_MAX_HEIGHT = 11.125;
/**
* Calculate editor height based on content, clamped between min and max
* @param {number} contentHeight - The actual content height from CodeMirror
* @returns {number} The clamped height value
*/
const calculateEditorHeight = (contentHeight) => {
const contentHeightRem = contentHeight / 16;
return Math.min(Math.max(contentHeightRem, EDITOR_MIN_HEIGHT), EDITOR_MAX_HEIGHT);
};
const EYE_ICON_SVG = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
const EYE_OFF_ICON_SVG = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
const getScopeLabel = (scopeType) => {
const labels = {
'global': 'Global',
'environment': 'Environment',
'collection': 'Collection',
'folder': 'Folder',
'request': 'Request',
'runtime': 'Runtime',
'process.env': 'Process Env',
'undefined': 'Undefined'
};
return labels[scopeType] || scopeType;
};
// Get the masked display text based on the value length
const getMaskedDisplay = (value) => {
const contentLength = (value || '').length;
return contentLength > 0 ? '*'.repeat(contentLength) : '';
};
// Update the value display based on the secret and masked state
const updateValueDisplay = (valueDisplay, value, isSecret, isMasked, isRevealed) => {
if ((isSecret || isMasked) && !isRevealed) {
valueDisplay.textContent = getMaskedDisplay(value);
} else {
valueDisplay.textContent = value || '';
}
};
// Check if the raw value contains references to secret variables
const containsSecretVariableReferences = (rawValue, collection, item) => {
if (!rawValue || typeof rawValue !== 'string') {
return false;
}
// Match all variable references like {{varName}}
const variableReferencePattern = /\{\{([^}]+)\}\}/g;
const matches = rawValue.matchAll(variableReferencePattern);
for (const match of matches) {
const referencedVarName = match[1].trim();
// Get scope info for the referenced variable
const referencedScopeInfo = getVariableScope(referencedVarName, collection, item);
// Check if the referenced variable is a secret
if (referencedScopeInfo && isVariableSecret(referencedScopeInfo)) {
return true;
}
}
return false;
};
const getCopyButton = (variableValue, onCopyCallback) => {
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;
copyButton.type = 'button';
let isCopied = false;
copyButton.addEventListener('mouseenter', () => {
if (isCopied) {
return;
}
copyButton.style.opacity = '1';
});
copyButton.addEventListener('mouseleave', () => {
if (isCopied) {
return;
}
copyButton.style.opacity = '0.7';
});
copyButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
// Prevent clicking if showing success checkmark
if (isCopied) {
@@ -77,7 +140,6 @@ const getCopyButton = (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');
@@ -85,11 +147,15 @@ const getCopyButton = (variableValue) => {
setTimeout(() => {
isCopied = false;
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
copyButton.style.opacity = '0.7';
copyButton.style.color = 'inherit';
copyButton.style.color = '#989898';
copyButton.style.cursor = 'pointer';
copyButton.classList.remove('copy-success');
}, COPY_SUCCESS_TIMEOUT);
// Call callback if provided
if (onCopyCallback) {
onCopyCallback();
}
})
.catch((err) => {
console.error('Failed to copy to clipboard:', err.message);
@@ -99,37 +165,336 @@ const getCopyButton = (variableValue) => {
return copyButton;
};
export const renderVarInfo = (token, options, cm, pos) => {
export const renderVarInfo = (token, options) => {
// Extract variable name and value based on token
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
if (variableValue === undefined) {
// Don't show popover if we can't extract a variable name or if it's empty/whitespace
if (!variableName || !variableName.trim()) {
return;
}
const into = document.createElement('div');
const collection = options.collection;
const item = options.item;
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('*****'));
// Check if this is a process.env variable (starts with "process.env.")
let scopeInfo;
if (variableName.startsWith('process.env.')) {
scopeInfo = {
type: 'process.env',
value: variableValue || '',
data: null
};
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
// Detect variable scope
scopeInfo = getVariableScope(variableName, collection, item);
// If variable doesn't exist in any scope, default to creating it at request level
if (!scopeInfo) {
if (item) {
// Create as request variable if we have an item context
scopeInfo = {
type: 'request',
value: '', // Empty value for new variable
data: { item, variable: null } // variable is null since it doesn't exist yet
};
} else {
// If no item context, show as undefined
scopeInfo = {
type: 'undefined',
value: '',
data: null
};
}
}
}
const copyButton = getCopyButton(variableValue);
// Check if variable is read-only (process.env, runtime, and undefined variables cannot be edited)
const isReadOnly = scopeInfo.type === 'process.env' || scopeInfo.type === 'runtime' || scopeInfo.type === 'undefined';
contentDiv.appendChild(descriptionDiv);
contentDiv.appendChild(copyButton);
into.appendChild(contentDiv);
// Get raw value from scope
const rawValue = scopeInfo?.value || '';
// Check if variable should be masked:
const isSecret = scopeInfo.type !== 'undefined' ? isVariableSecret(scopeInfo) : false;
const hasSecretReferences = containsSecretVariableReferences(rawValue, collection, item);
const shouldMaskValue = isSecret || hasSecretReferences;
const isMasked = options?.variables?.maskedEnvVariables?.includes(variableName);
const into = document.createElement('div');
into.className = 'bruno-var-info-container';
// Header: Variable name + Scope badge
const header = document.createElement('div');
header.className = 'var-info-header';
const varName = document.createElement('span');
varName.className = 'var-name';
varName.textContent = variableName;
const scopeBadge = document.createElement('span');
scopeBadge.className = 'var-scope-badge';
// Show scope label with indication if it's a new variable
const scopeLabel = scopeInfo ? getScopeLabel(scopeInfo.type) : 'Unknown';
const isNewVariable = scopeInfo && scopeInfo.data && scopeInfo.data.variable === null;
scopeBadge.textContent = isNewVariable ? `${scopeLabel}` : scopeLabel;
header.appendChild(varName);
header.appendChild(scopeBadge);
into.appendChild(header);
// Value container with icons
const valueContainer = document.createElement('div');
valueContainer.className = 'var-value-container';
// Create editable value display/editor (if editable)
if (!isReadOnly && scopeInfo) {
// Handle secret/masked variables state
let isRevealed = false;
// Create display element (shows interpolated value by default)
const valueDisplay = document.createElement('div');
valueDisplay.className = 'var-value-editable-display';
// Mask the displayed value if it contains secrets or references to secrets
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, false);
// Create container for CodeMirror (hidden by default)
const editorContainer = document.createElement('div');
editorContainer.className = 'var-value-editor';
editorContainer.style.display = 'none'; // Hidden initially
// Detect current theme from DOM
const isDarkTheme = document.documentElement.classList.contains('dark');
const cmTheme = isDarkTheme ? 'monokai' : 'default';
// Get all variables for syntax highlighting (but prevent recursive tooltips)
const allVariables = collection ? getAllVariables(collection, item) : {};
// Create CodeMirror instance
const cmEditor = CodeMirror(editorContainer, {
value: rawValue, // Use raw value (e.g., {{echo-host}} not resolved value)
mode: 'brunovariables',
theme: cmTheme,
lineWrapping: true,
lineNumbers: false,
brunoVarInfo: false, // Disable tooltips within the editor to prevent recursion
scrollbarStyle: null,
viewportMargin: Infinity
});
// Setup variable mode for syntax highlighting
defineCodeMirrorBrunoVariablesMode(allVariables, 'text/plain', false, true);
cmEditor.setOption('mode', 'brunovariables');
// Setup autocomplete
const getAllVariablesHandler = () => allVariables;
const autoCompleteOptions = {
getAllVariables: getAllVariablesHandler,
showHintsFor: ['variables']
};
const autoCompleteCleanup = setupAutoComplete(cmEditor, autoCompleteOptions);
// Handle secret/masked variables
let maskedEditor = null;
if (shouldMaskValue || isMasked) {
maskedEditor = new MaskedEditor(cmEditor);
maskedEditor.enable();
}
// Store original value for comparison and track editing state
let originalValue = rawValue;
let isEditing = false;
// Dynamically adjust editor height as content changes
cmEditor.on('change', () => {
if (isEditing) {
// Use requestAnimationFrame for smoother updates after DOM changes
requestAnimationFrame(() => {
cmEditor.refresh();
// Get height from the actual rendered sizer element (more accurate)
const sizer = cmEditor.getWrapperElement().querySelector('.CodeMirror-sizer');
const contentHeight = sizer ? sizer.clientHeight : cmEditor.getScrollInfo().height;
const newHeight = calculateEditorHeight(contentHeight);
editorContainer.style.height = `${newHeight}rem`;
});
}
});
// Icons container (top-right)
const iconsContainer = document.createElement('div');
iconsContainer.className = 'var-icons';
// Eye toggle button (show if the displayed value is masked)
if (shouldMaskValue || isMasked) {
const toggleButton = document.createElement('button');
toggleButton.className = 'secret-toggle-button';
toggleButton.innerHTML = EYE_ICON_SVG;
toggleButton.type = 'button';
toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
isRevealed = !isRevealed;
// Update icon
toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG;
// Update display mode
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
// Update editor mode
if (maskedEditor) {
isRevealed ? maskedEditor.disable() : maskedEditor.enable();
}
// Refocus the editor if it's currently in edit mode
if (isEditing) {
setTimeout(() => {
cmEditor.focus();
}, 0);
}
});
iconsContainer.appendChild(toggleButton);
}
// Copy button (copy actual value, not masked)
const copyButton = getCopyButton(variableValue || '', () => {
// Refocus the editor if it's currently in edit mode
if (isEditing) {
setTimeout(() => {
cmEditor.focus();
}, 0);
}
});
iconsContainer.appendChild(copyButton);
valueContainer.appendChild(valueDisplay);
valueContainer.appendChild(editorContainer);
valueContainer.appendChild(iconsContainer);
// Click on display to enter edit mode
valueDisplay.addEventListener('click', () => {
if (isEditing) return;
isEditing = true;
valueDisplay.style.display = 'none';
editorContainer.style.display = 'block';
// Focus the editor and ensure proper sizing
setTimeout(() => {
cmEditor.refresh();
cmEditor.focus();
// Set cursor to end of content
const lineCount = cmEditor.lineCount();
const lastLine = cmEditor.getLine(lineCount - 1);
cmEditor.setCursor(lineCount - 1, lastLine ? lastLine.length : 0);
// Adjust height based on content
const contentHeight = cmEditor.getScrollInfo().height;
editorContainer.style.height = `${calculateEditorHeight(contentHeight)}rem`;
}, 0);
});
// Save on blur and return to display mode
cmEditor.on('blur', () => {
const newValue = cmEditor.getValue();
// Switch back to display mode
editorContainer.style.display = 'none';
editorContainer.style.height = `${EDITOR_MIN_HEIGHT}rem`; // Reset to minimum height
valueDisplay.style.display = 'block';
isEditing = false;
if (newValue !== originalValue) {
// Dispatch Redux action to update variable
const dispatch = store.dispatch;
dispatch(updateVariableInScope(variableName, newValue, scopeInfo, collection.uid))
.then(() => {
originalValue = newValue;
// Re-interpolate the new value to show the resolved value in display
const interpolatedValue = interpolate(newValue, allVariables);
// Check if the NEW value contains secret references
const newHasSecretRefs = containsSecretVariableReferences(newValue, collection, item);
const newShouldMask = isSecret || newHasSecretRefs;
updateValueDisplay(valueDisplay, interpolatedValue, newShouldMask, isMasked, isRevealed);
})
.catch((err) => {
console.error('Failed to update variable:', err);
// Revert on error
cmEditor.setValue(originalValue);
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
});
}
});
// Store references for cleanup
valueContainer._cmEditor = cmEditor;
valueContainer._maskedEditor = maskedEditor;
valueContainer._autoCompleteCleanup = autoCompleteCleanup;
} else {
// Read-only display (for runtime, process.env, undefined variables)
let isRevealed = false;
const valueDisplay = document.createElement('div');
valueDisplay.className = 'var-value-display';
// For read-only variables, still check if they reference secrets
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, false);
// Icons container
const iconsContainer = document.createElement('div');
iconsContainer.className = 'var-icons';
// Eye toggle button (for read-only variables that reference secrets or are masked)
if (shouldMaskValue || isMasked) {
const toggleButton = document.createElement('button');
toggleButton.className = 'secret-toggle-button';
toggleButton.innerHTML = EYE_ICON_SVG;
toggleButton.type = 'button';
toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
isRevealed = !isRevealed;
toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG;
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
});
iconsContainer.appendChild(toggleButton);
}
// Copy button (always copy actual value, not masked)
const copyButton = getCopyButton(variableValue || '');
iconsContainer.appendChild(copyButton);
valueContainer.appendChild(valueDisplay);
valueContainer.appendChild(iconsContainer);
// Read-only note
if (scopeInfo?.type === 'process.env') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'read-only';
into.appendChild(readOnlyNote);
} else if (scopeInfo?.type === 'runtime') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'Set by scripts (read-only)';
into.appendChild(readOnlyNote);
} else if (scopeInfo?.type === 'undefined') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'No active environment';
into.appendChild(readOnlyNote);
}
}
into.appendChild(valueContainer);
return into;
};
@@ -137,6 +502,9 @@ export const renderVarInfo = (token, options, cm, pos) => {
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
// Global state to track active popup
let activePopup = null;
CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) {
if (old && old !== CodeMirror.Init) {
const oldOnMouseOver = cm.state.brunoVarInfo.onMouseOver;
@@ -167,10 +535,12 @@ if (!SERVER_RENDERED) {
const state = cm.state.brunoVarInfo;
const target = e.target || e.srcElement;
if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined) {
// Prevent new tooltips if one is already active
if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined || activePopup !== null) {
return;
}
if (!target.classList.contains('cm-variable-valid')) {
// Show popover for both valid and invalid variables
if (!target.classList.contains('cm-variable-valid') && !target.classList.contains('cm-variable-invalid')) {
return;
}
@@ -212,7 +582,7 @@ if (!SERVER_RENDERED) {
const options = state.options;
const token = cm.getTokenAt(pos, true);
if (token) {
const brunoVarInfo = renderVarInfo(token, options, cm, pos);
const brunoVarInfo = renderVarInfo(token, options);
if (brunoVarInfo) {
showPopup(cm, box, brunoVarInfo);
}
@@ -220,11 +590,20 @@ if (!SERVER_RENDERED) {
}
function showPopup(cm, box, brunoVarInfo) {
// If there's already an active popup, remove it first
if (activePopup && activePopup.parentNode) {
activePopup.parentNode.removeChild(activePopup);
activePopup = null;
}
const popup = document.createElement('div');
popup.className = 'CodeMirror-brunoVarInfo';
popup.appendChild(brunoVarInfo);
document.body.appendChild(popup);
// Track this popup as the active one
activePopup = popup;
const popupBox = popup.getBoundingClientRect();
const popupStyle = popup.currentStyle || window.getComputedStyle(popup);
const popupWidth =
@@ -232,28 +611,38 @@ if (!SERVER_RENDERED) {
const popupHeight =
popupBox.bottom - popupBox.top + parseFloat(popupStyle.marginTop) + parseFloat(popupStyle.marginBottom);
let topPos = box.bottom;
if (popupHeight > window.innerHeight - box.bottom - 15 && box.top > window.innerHeight - box.bottom) {
topPos = box.top - popupHeight;
const GAP_REM = 0.5;
const EDGE_MARGIN_REM = 0.9375;
// Position below the trigger by default with gap
let topPos = box.bottom + (GAP_REM * 16);
// Check if there's enough space below; if not, position above
if (popupHeight > window.innerHeight - box.bottom - (EDGE_MARGIN_REM * 16) && box.top > window.innerHeight - box.bottom) {
topPos = box.top - popupHeight - (GAP_REM * 16);
}
// Ensure it doesn't go off the top of the screen
if (topPos < 0) {
topPos = box.bottom;
topPos = box.bottom + (GAP_REM * 16);
}
// make popup appear on top of cursor
if (topPos > 70) {
topPos = topPos - 70;
// Horizontal positioning - align to left of trigger
let leftPos = box.left;
// Ensure it doesn't go off the right edge
if (leftPos + popupWidth > window.innerWidth - (EDGE_MARGIN_REM * 16)) {
leftPos = window.innerWidth - popupWidth - (EDGE_MARGIN_REM * 16);
}
let leftPos = Math.max(0, window.innerWidth - popupWidth - 15);
if (leftPos > box.left) {
leftPos = box.left;
// Ensure it doesn't go off the left edge
if (leftPos < 0) {
leftPos = 0;
}
popup.style.opacity = 1;
popup.style.top = topPos + 'px';
popup.style.left = leftPos + 'px';
popup.style.top = `${topPos / 16}rem`;
popup.style.left = `${leftPos / 16}rem`;
let popupTimeout;
@@ -263,13 +652,41 @@ if (!SERVER_RENDERED) {
const onMouseOut = function () {
clearTimeout(popupTimeout);
popupTimeout = setTimeout(hidePopup, 200);
popupTimeout = setTimeout(hidePopup, 500);
};
const hidePopup = function () {
CodeMirror.off(popup, 'mouseover', onMouseOverPopup);
CodeMirror.off(popup, 'mouseout', onMouseOut);
CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut);
CodeMirror.off(cm, 'change', onEditorChange);
// Cleanup CodeMirror and MaskedEditor instances
const valueContainer = popup.querySelector('.var-value-container');
if (valueContainer) {
// Cleanup autocomplete
if (valueContainer._autoCompleteCleanup) {
valueContainer._autoCompleteCleanup();
valueContainer._autoCompleteCleanup = null;
}
// Cleanup MaskedEditor
if (valueContainer._maskedEditor) {
valueContainer._maskedEditor.destroy();
valueContainer._maskedEditor = null;
}
// Cleanup CodeMirror
if (valueContainer._cmEditor) {
valueContainer._cmEditor.getWrapperElement().remove();
valueContainer._cmEditor = null;
}
}
// Clear the active popup reference
if (activePopup === popup) {
activePopup = null;
}
if (popup.style.opacity) {
popup.style.opacity = 0;
@@ -283,9 +700,15 @@ if (!SERVER_RENDERED) {
}
};
// Hide popup when user types in the main editor
const onEditorChange = function () {
hidePopup();
};
CodeMirror.on(popup, 'mouseover', onMouseOverPopup);
CodeMirror.on(popup, 'mouseout', onMouseOut);
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
CodeMirror.on(cm, 'change', onEditorChange);
}
}
@@ -302,10 +725,22 @@ export const extractVariableInfo = (str, variables) => {
if (DOUBLE_BRACE_PATTERN.test(str)) {
variableName = str.replace('{{', '').replace('}}', '').trim();
// Don't return empty variable names
if (!variableName) {
return { variableName: undefined, variableValue: undefined };
}
variableValue = interpolate(get(variables, variableName), variables);
} else if (str.startsWith('/:')) {
variableName = str.replace('/:', '').trim();
// Don't return empty variable names
if (!variableName) {
return { variableName: undefined, variableValue: undefined };
}
variableValue = variables?.pathParams?.[variableName];
} else if (str.startsWith('{{') && str.endsWith('}}')) {
// Handle cases like {{}} or {{ }} (empty or whitespace only)
// These don't match the pattern but look like variables
return { variableName: undefined, variableValue: undefined };
} else {
// direct variable reference (e.g., for numeric values in JSON mode or plain variable names)
variableName = str;

View File

@@ -6,6 +6,51 @@ jest.mock('@usebruno/common', () => ({
interpolate: jest.fn()
}));
jest.mock('providers/ReduxStore', () => ({
default: {
dispatch: jest.fn(),
getState: jest.fn()
}
}));
jest.mock('providers/ReduxStore/slices/collections/actions', () => ({
updateVariableInScope: jest.fn()
}));
jest.mock('utils/collections', () => ({
getVariableScope: jest.fn(),
isVariableSecret: jest.fn(),
getAllVariables: jest.fn(),
findEnvironmentInCollection: jest.fn()
}));
jest.mock('utils/common/codemirror', () => ({
defineCodeMirrorBrunoVariablesMode: jest.fn()
}));
jest.mock('utils/common/masked-editor', () => ({
MaskedEditor: jest.fn()
}));
jest.mock('utils/codemirror/autocomplete', () => ({
setupAutoComplete: jest.fn(() => jest.fn())
}));
// Mock CodeMirror
global.CodeMirror = jest.fn((element, options) => {
const mockEditor = {
getValue: jest.fn(() => options.value || ''),
setValue: jest.fn(),
on: jest.fn(),
off: jest.fn(),
refresh: jest.fn(),
focus: jest.fn(),
options: options || {},
getWrapperElement: jest.fn(() => element)
};
return mockEditor;
});
describe('extractVariableInfo', () => {
let mockVariables;
@@ -93,6 +138,24 @@ describe('extractVariableInfo', () => {
variableValue: undefined
});
});
it('should return undefined for empty double brace variables', () => {
const result = extractVariableInfo('{{}}', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
it('should return undefined for whitespace-only double brace variables', () => {
const result = extractVariableInfo('{{ }}', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
});
describe('path parameter format (/:variableName)', () => {
@@ -136,6 +199,24 @@ describe('extractVariableInfo', () => {
variableValue: undefined
});
});
it('should return undefined for empty path parameters', () => {
const result = extractVariableInfo('/:', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
it('should return undefined for whitespace-only path parameters', () => {
const result = extractVariableInfo('/: ', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
});
describe('direct variable format', () => {
@@ -258,13 +339,15 @@ describe('renderVarInfo', () => {
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');
function setupRender(variables, collection = null, item = null) {
const result = renderVarInfo({ string: '{{apiKey}}' }, { variables, collection, item });
if (!result) return { result: null, containerDiv: null, valueDisplay: null, copyButton: null };
return { result, contentDiv, descriptionDiv, copyButton };
const containerDiv = result;
const valueDisplay = containerDiv.querySelector('.var-value-editable-display') || containerDiv.querySelector('.var-value-display');
const copyButton = containerDiv.querySelector('.copy-button');
return { result, containerDiv, valueDisplay, copyButton };
}
describe('popup functionality', () => {
@@ -275,18 +358,18 @@ describe('renderVarInfo', () => {
});
it('should create a popup with the correct variable name and value', () => {
const { descriptionDiv } = setupRender({ apiKey: 'test-value' });
const { valueDisplay } = setupRender({ apiKey: 'test-value' });
expect(descriptionDiv.textContent).toBe('test-value');
expect(valueDisplay.textContent).toBe('test-value');
});
it('should correctly mask the variable value in the popup', () => {
const { descriptionDiv } = setupRender({
const { valueDisplay } = setupRender({
apiKey: 'test-value',
maskedEnvVariables: ['apiKey']
});
expect(descriptionDiv.textContent).toBe('*****');
expect(valueDisplay.textContent).toBe('**********');
});
});
@@ -297,19 +380,19 @@ describe('renderVarInfo', () => {
expect(copyButton).toBeDefined();
});
it('should copy the variable value to the clipboard', async () => {
it('should copy the variable value to the clipboard', () => {
const { copyButton } = setupRender({ apiKey: 'test-value' });
await copyButton.click();
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 () => {
it('should copy the variable value of masked variables to the clipboard', () => {
const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] });
await copyButton.click();
copyButton.click();
expect(clipboardText).toBe('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
@@ -332,10 +415,10 @@ describe('renderVarInfo', () => {
it('should log to the console when the variable value is not copied', async () => {
const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' });
await copyButton.click();
copyButton.click();
// wait for .catch() microtask to run
await Promise.resolve();
await jest.runAllTimersAsync();
expect(clipboardText).toBe('');
expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error');

View File

@@ -1472,3 +1472,108 @@ export const getInitialExampleName = (item) => {
counter++;
}
};
// Get the scope and raw value of a variable by checking all scopes in priority order
export const getVariableScope = (variableName, collection, item) => {
if (!variableName || !collection) {
return null;
}
// 1. Check Request Variables (highest priority)
if (item && item.request && item.request.vars && item.request.vars.req) {
const requestVar = item.request.vars.req.find((v) => v.name === variableName && v.enabled);
if (requestVar) {
return {
type: 'request',
value: requestVar.value,
data: { item, variable: requestVar }
};
}
}
// 2. Check Folder Variables
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
for (let i = requestTreePath.length - 1; i >= 0; i--) {
const pathItem = requestTreePath[i];
if (pathItem.type === 'folder') {
const folderVars = get(pathItem, 'root.request.vars.req', []);
const folderVar = folderVars.find((v) => v.name === variableName && v.enabled);
if (folderVar) {
return {
type: 'folder',
value: folderVar.value,
data: { folder: pathItem, variable: folderVar }
};
}
}
}
// 3. Check Environment Variables
if (collection.activeEnvironmentUid) {
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (environment && environment.variables) {
const envVar = environment.variables.find((v) => v.name === variableName && v.enabled);
if (envVar) {
return {
type: 'environment',
value: envVar.value,
data: { environment, variable: envVar }
};
}
}
}
// 4. Check Collection Variables
const collectionVars = get(collection, 'root.request.vars.req', []);
const collectionVar = collectionVars.find((v) => v.name === variableName && v.enabled);
if (collectionVar) {
return {
type: 'collection',
value: collectionVar.value,
data: { collection, variable: collectionVar }
};
}
// 5. Check Global Environment Variables
const { globalEnvironmentVariables = {} } = collection;
if (globalEnvironmentVariables && globalEnvironmentVariables[variableName]) {
return {
type: 'global',
value: globalEnvironmentVariables[variableName],
data: { variableName, value: globalEnvironmentVariables[variableName] }
};
}
// 6. Check Runtime Variables (set during request execution via scripts)
const { runtimeVariables = {} } = collection;
if (runtimeVariables && runtimeVariables[variableName]) {
return {
type: 'runtime',
value: runtimeVariables[variableName],
data: { variableName, value: runtimeVariables[variableName], readonly: true }
};
}
// Process.env variables are not checked here
return null;
};
// Check if a variable is marked as secret
export const isVariableSecret = (scopeInfo) => {
if (!scopeInfo) {
return false;
}
// Only environment variables can be marked as secret
if (scopeInfo.type === 'environment') {
return !!scopeInfo.data.variable?.secret;
}
// Global variables are not checked here
if (scopeInfo.type === 'global') {
return false;
}
return false;
};