mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 15:44:13 +00:00
Merge pull request #6069 from pooja-bruno/feat/add-edit-variable-in-place
feat: edit variable in place
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user