mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 23:54:24 +00:00
935 lines
32 KiB
JavaScript
935 lines
32 KiB
JavaScript
/**
|
|
* Copyright (c) 2017, Facebook, Inc.
|
|
* All rights reserved.
|
|
*
|
|
* This source code is licensed under the BSD-style license found in the
|
|
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
|
|
*/
|
|
|
|
import { interpolate, mockDataFunctions, timeBasedDynamicVars } 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';
|
|
import { variableNameRegex } from 'utils/common/regex';
|
|
|
|
let CodeMirror;
|
|
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
|
const { get } = require('lodash');
|
|
|
|
const COPY_ICON_SVG_TEXT = `
|
|
<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="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>
|
|
`;
|
|
|
|
const COPY_SUCCESS_COLOR = '#22c55e';
|
|
|
|
export const COPY_SUCCESS_TIMEOUT = 1000;
|
|
|
|
// 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',
|
|
'dynamic': 'Dynamic',
|
|
'oauth2': 'OAuth2',
|
|
'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.innerHTML = COPY_ICON_SVG_TEXT;
|
|
copyButton.type = 'button';
|
|
|
|
let isCopied = false;
|
|
|
|
copyButton.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
// Prevent clicking if showing success checkmark
|
|
if (isCopied) {
|
|
return;
|
|
}
|
|
|
|
navigator.clipboard
|
|
.writeText(variableValue)
|
|
.then(() => {
|
|
isCopied = true;
|
|
copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT;
|
|
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.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);
|
|
});
|
|
});
|
|
|
|
return copyButton;
|
|
};
|
|
|
|
export const renderVarInfo = (token, options) => {
|
|
// Extract variable name and value based on token
|
|
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
|
|
|
|
// Don't show popover if we can't extract a variable name or if it's empty/whitespace
|
|
if (!variableName || !variableName.trim()) {
|
|
return;
|
|
}
|
|
|
|
const collection = options.collection;
|
|
const item = options.item;
|
|
|
|
// Check if this is a dynamic/faker variable (starts with "$")
|
|
let scopeInfo;
|
|
if (variableName.startsWith('$oauth2.')) {
|
|
// OAuth2 token variable - look up in variables object
|
|
const oauth2Value = get(options.variables, variableName);
|
|
scopeInfo = {
|
|
type: 'oauth2',
|
|
value: oauth2Value !== undefined ? oauth2Value : '',
|
|
data: null,
|
|
isValidOAuth2Variable: oauth2Value !== undefined
|
|
};
|
|
} else if (variableName.startsWith('$')) {
|
|
const fakerKeyword = variableName.substring(1); // Remove the $ prefix
|
|
const fakerFunction = mockDataFunctions[fakerKeyword];
|
|
const isTimeBased = timeBasedDynamicVars.has(fakerKeyword);
|
|
scopeInfo = {
|
|
type: 'dynamic',
|
|
value: '',
|
|
data: null,
|
|
isValidDynamicVariable: !!fakerFunction,
|
|
isTimeBased
|
|
};
|
|
} else if (variableName.startsWith('process.env.')) {
|
|
// Check if this is a process.env variable (starts with "process.env.")
|
|
scopeInfo = {
|
|
type: 'process.env',
|
|
value: variableValue || '',
|
|
data: null
|
|
};
|
|
} else {
|
|
// Detect variable scope
|
|
scopeInfo = getVariableScope(variableName, collection, item);
|
|
|
|
// If variable doesn't exist in any scope, determine scope based on context
|
|
if (!scopeInfo) {
|
|
if (item) {
|
|
// Determine if item is a folder or request
|
|
const isFolder = item.type === 'folder';
|
|
|
|
if (isFolder) {
|
|
// We're in folder settings - create as folder variable
|
|
scopeInfo = {
|
|
type: 'folder',
|
|
value: '', // Empty value for new variable
|
|
data: { folder: item, variable: null } // variable is null since it doesn't exist yet
|
|
};
|
|
} else {
|
|
// We're in a request - create as request variable
|
|
scopeInfo = {
|
|
type: 'request',
|
|
value: '', // Empty value for new variable
|
|
data: { item, variable: null } // variable is null since it doesn't exist yet
|
|
};
|
|
}
|
|
} else if (collection) {
|
|
// No item context but we have collection - create as collection variable
|
|
scopeInfo = {
|
|
type: 'collection',
|
|
value: '',
|
|
data: { collection, variable: null }
|
|
};
|
|
} else {
|
|
// No context at all, show as undefined
|
|
scopeInfo = {
|
|
type: 'undefined',
|
|
value: '',
|
|
data: null
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if a runtime variable exists with the same name (even if scope is detected as collection/folder/environment)
|
|
const hasRuntimeVariable = collection && collection.runtimeVariables && collection.runtimeVariables[variableName];
|
|
// Check if variable is read-only (process.env, runtime, dynamic/faker, oauth2, and undefined variables cannot be edited)
|
|
const isReadOnly = scopeInfo.type === 'process.env' || scopeInfo.type === 'runtime' || scopeInfo.type === 'dynamic' || scopeInfo.type === 'oauth2' || scopeInfo.type === 'undefined' || hasRuntimeVariable;
|
|
|
|
// 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';
|
|
|
|
// Check if a runtime variable exists - if so, show Runtime scope (even if detected as collection/folder/environment)
|
|
const displayScopeType = hasRuntimeVariable ? 'runtime' : (scopeInfo ? scopeInfo.type : 'Unknown');
|
|
// Show scope label with indication if it's a new variable
|
|
const scopeLabel = getScopeLabel(displayScopeType);
|
|
const isNewVariable = scopeInfo && scopeInfo.data && scopeInfo.data.variable === null;
|
|
scopeBadge.textContent = isNewVariable ? `${scopeLabel}` : scopeLabel;
|
|
|
|
header.appendChild(varName);
|
|
header.appendChild(scopeBadge);
|
|
into.appendChild(header);
|
|
|
|
// Check if variable name is valid
|
|
const isValidVariableName = scopeInfo.type === 'process.env' || scopeInfo.type === 'dynamic' || scopeInfo.type === 'oauth2' || variableNameRegex.test(variableName);
|
|
|
|
// Show warning if variable name is invalid
|
|
if (!isValidVariableName) {
|
|
const warningNote = document.createElement('div');
|
|
warningNote.className = 'var-warning-note';
|
|
warningNote.textContent = 'Invalid variable name! Variables must only contain alpha-numeric characters, "-", "_", "."';
|
|
into.appendChild(warningNote);
|
|
|
|
// Don't show value or any other content for invalid variable names
|
|
return into;
|
|
}
|
|
|
|
// Show warning for invalid dynamic variable (starts with $ but not a valid dynamic function)
|
|
if (scopeInfo.type === 'dynamic' && !scopeInfo.isValidDynamicVariable) {
|
|
const warningNote = document.createElement('div');
|
|
warningNote.className = 'var-warning-note';
|
|
warningNote.textContent = `Unknown dynamic variable "${variableName}". Check the variable name.`;
|
|
into.appendChild(warningNote);
|
|
return into;
|
|
}
|
|
|
|
// For valid dynamic variables, show appropriate read-only note based on type
|
|
if (scopeInfo.type === 'dynamic' && scopeInfo.isValidDynamicVariable) {
|
|
const readOnlyNote = document.createElement('div');
|
|
readOnlyNote.className = 'var-readonly-note';
|
|
readOnlyNote.textContent = scopeInfo.isTimeBased
|
|
? 'Generates current timestamp on each request'
|
|
: 'Generates random value on each request';
|
|
into.appendChild(readOnlyNote);
|
|
return into;
|
|
}
|
|
|
|
// Show warning for invalid OAuth2 variable (token not found)
|
|
if (scopeInfo.type === 'oauth2' && !scopeInfo.isValidOAuth2Variable) {
|
|
const warningNote = document.createElement('div');
|
|
warningNote.className = 'var-warning-note';
|
|
warningNote.textContent = `OAuth2 token not found. Make sure you have fetched the token with the correct Token ID.`;
|
|
into.appendChild(warningNote);
|
|
return into;
|
|
}
|
|
|
|
// 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: typeof rawValue === 'string' ? rawValue : String(rawValue), // Use raw value (e.g., {{echo-host}} not resolved value) (ensure it's always a string for CodeMirror) #usebruno/bruno/#6265
|
|
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;
|
|
|
|
cmEditor.setOption('extraKeys', {
|
|
'Enter': (cm) => {
|
|
// Enter: save and blur
|
|
cm.getInputField().blur();
|
|
},
|
|
'Shift-Enter': (cm) => {
|
|
// Shift+Enter: insert new line
|
|
cm.replaceSelection('\n', 'end');
|
|
}
|
|
});
|
|
|
|
// 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' || hasRuntimeVariable) {
|
|
const readOnlyNote = document.createElement('div');
|
|
readOnlyNote.className = 'var-readonly-note';
|
|
readOnlyNote.textContent = 'Set by scripts (read-only)';
|
|
into.appendChild(readOnlyNote);
|
|
} else if (scopeInfo.type === 'oauth2') {
|
|
const readOnlyNote = document.createElement('div');
|
|
readOnlyNote.className = 'var-readonly-note';
|
|
readOnlyNote.textContent = '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;
|
|
};
|
|
|
|
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;
|
|
CodeMirror.off(cm.getWrapperElement(), 'mouseover', oldOnMouseOver);
|
|
clearTimeout(cm.state.brunoVarInfo.hoverTimeout);
|
|
delete cm.state.brunoVarInfo;
|
|
}
|
|
|
|
if (options) {
|
|
const state = (cm.state.brunoVarInfo = createState(options));
|
|
state.onMouseOver = onMouseOver.bind(null, cm);
|
|
CodeMirror.on(cm.getWrapperElement(), 'mouseover', state.onMouseOver);
|
|
}
|
|
});
|
|
|
|
function createState(options) {
|
|
return {
|
|
options: options instanceof Function ? { render: options } : options === true ? {} : options
|
|
};
|
|
}
|
|
|
|
function getHoverTime(cm) {
|
|
const options = cm.state.brunoVarInfo.options;
|
|
return (options && options.hoverTime) || 50;
|
|
}
|
|
|
|
function onMouseOver(cm, e) {
|
|
const state = cm.state.brunoVarInfo;
|
|
const target = e.target || e.srcElement;
|
|
|
|
// Prevent new tooltips if one is already active
|
|
if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined) {
|
|
return;
|
|
}
|
|
// Show popover for both valid and invalid variables
|
|
if (!target.classList.contains('cm-variable-valid') && !target.classList.contains('cm-variable-invalid')) {
|
|
return;
|
|
}
|
|
|
|
const box = target.getBoundingClientRect();
|
|
|
|
const onMouseMove = function () {
|
|
clearTimeout(state.hoverTimeout);
|
|
state.hoverTimeout = setTimeout(onHover, hoverTime);
|
|
};
|
|
|
|
const onMouseOut = function () {
|
|
CodeMirror.off(document, 'mousemove', onMouseMove);
|
|
CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut);
|
|
clearTimeout(state.hoverTimeout);
|
|
state.hoverTimeout = undefined;
|
|
};
|
|
|
|
const onHover = function () {
|
|
CodeMirror.off(document, 'mousemove', onMouseMove);
|
|
CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut);
|
|
state.hoverTimeout = undefined;
|
|
onMouseHover(cm, box);
|
|
};
|
|
|
|
const hoverTime = getHoverTime(cm);
|
|
state.hoverTimeout = setTimeout(onHover, hoverTime);
|
|
|
|
CodeMirror.on(document, 'mousemove', onMouseMove);
|
|
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
|
|
}
|
|
|
|
function onMouseHover(cm, box) {
|
|
const pos = cm.coordsChar({
|
|
left: (box.left + box.right) / 2,
|
|
top: (box.top + box.bottom) / 2
|
|
});
|
|
|
|
const state = cm.state.brunoVarInfo;
|
|
const options = state.options;
|
|
|
|
// Get the full line text where the hover happened
|
|
const line = cm.getLine(pos.line);
|
|
if (!line) return;
|
|
|
|
// If the line doesn't even contain both braces, no need to run loops
|
|
if (!line.includes('{{') || !line.includes('}}')) {
|
|
return;
|
|
}
|
|
|
|
// lastIndexOf searches backward from the cursor indexOf searches forward
|
|
if (line.lastIndexOf('{{', pos.ch) === -1 || line.indexOf('}}', pos.ch) === -1) {
|
|
return;
|
|
}
|
|
let start = pos.ch;
|
|
let end = pos.ch;
|
|
|
|
// ---------- Find opening '{{' to the LEFT ----------
|
|
while (start > 0) {
|
|
const leftTwo = line.substring(start - 2, start);
|
|
|
|
// If we find opening braces, stop
|
|
if (leftTwo === '{{') {
|
|
start -= 2;
|
|
break;
|
|
}
|
|
|
|
// If we cross a closing braces before finding '{{', we're not inside a variable
|
|
if (leftTwo === '}}') {
|
|
return;
|
|
}
|
|
|
|
start--;
|
|
}
|
|
|
|
// If we reached the start of the line and didn't match '{{', return
|
|
if (start < 0 || line.substring(start, start + 2) !== '{{') {
|
|
return;
|
|
}
|
|
|
|
// ---------- Find closing '}}' to the RIGHT ----------
|
|
while (end < line.length) {
|
|
const rightTwo = line.substring(end, end + 2);
|
|
|
|
// If we find closing braces, stop
|
|
if (rightTwo === '}}') {
|
|
end += 2;
|
|
break;
|
|
}
|
|
|
|
// If we hit another '{{' before a '}}', then this isn't a valid enclosing pair
|
|
if (rightTwo === '{{') {
|
|
return;
|
|
}
|
|
|
|
end++;
|
|
}
|
|
// If we reached end-of-line without finding '}}', return
|
|
if (end > line.length || line.substring(end - 2, end) !== '}}') {
|
|
return;
|
|
}
|
|
|
|
const fullVariableString = line.substring(start, end);
|
|
|
|
// Basic validation to ensure it's a non-empty variable
|
|
if (!fullVariableString.startsWith('{{') || !fullVariableString.endsWith('}}')) {
|
|
return;
|
|
}
|
|
|
|
// Prevent tooltips for empty variables like {{ }}
|
|
const inner = fullVariableString.slice(2, -2).trim();
|
|
if (!inner) return;
|
|
|
|
// Build a token object compatible with renderVarInfo
|
|
const token = {
|
|
string: fullVariableString,
|
|
start: start,
|
|
end: end
|
|
};
|
|
|
|
const brunoVarInfo = renderVarInfo(token, options);
|
|
if (brunoVarInfo) {
|
|
showPopup(cm, box, brunoVarInfo);
|
|
}
|
|
}
|
|
|
|
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
|
|
= popupBox.right - popupBox.left + parseFloat(popupStyle.marginLeft) + parseFloat(popupStyle.marginRight);
|
|
const popupHeight
|
|
= popupBox.bottom - popupBox.top + parseFloat(popupStyle.marginTop) + parseFloat(popupStyle.marginBottom);
|
|
|
|
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 + (GAP_REM * 16);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Ensure it doesn't go off the left edge
|
|
if (leftPos < 0) {
|
|
leftPos = 0;
|
|
}
|
|
|
|
popup.style.opacity = 1;
|
|
popup.style.top = `${topPos / 16}rem`;
|
|
popup.style.left = `${leftPos / 16}rem`;
|
|
|
|
let popupTimeout;
|
|
|
|
const onMouseOverPopup = function () {
|
|
clearTimeout(popupTimeout);
|
|
};
|
|
|
|
const onMouseOut = function () {
|
|
clearTimeout(popupTimeout);
|
|
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;
|
|
setTimeout(function () {
|
|
if (popup.parentNode) {
|
|
popup.parentNode.removeChild(popup);
|
|
}
|
|
}, 600);
|
|
} else if (popup.parentNode) {
|
|
popup.parentNode.removeChild(popup);
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
export const extractVariableInfo = (str, variables) => {
|
|
let variableName;
|
|
let variableValue;
|
|
|
|
if (!str || !str.length || typeof str !== 'string') {
|
|
return { variableName, variableValue };
|
|
}
|
|
|
|
// Regex to match double brace variable syntax: {{variableName}}
|
|
const DOUBLE_BRACE_PATTERN = /\{\{([^}]+)\}\}/;
|
|
|
|
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;
|
|
variableValue = interpolate(get(variables, variableName), variables);
|
|
}
|
|
|
|
return { variableName, variableValue };
|
|
};
|