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

@@ -53,9 +53,11 @@ export default class CodeEditor extends React.Component {
lineWrapping: this.props.enableLineWrapping ?? true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
@@ -227,6 +229,16 @@ export default class CodeEditor extends React.Component {
if (!isEqual(variables, this.variables)) {
this.addOverlay();
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
@@ -290,6 +302,11 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
// Update brunoVarInfo with latest variables
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -221,6 +221,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{!variable.secret && hasSensitiveUsage(variable.name) && (

View File

@@ -162,6 +162,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{typeof variable.value !== 'string' && (

View File

@@ -33,9 +33,11 @@ class MultiLineEditor extends Component {
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
@@ -125,9 +127,21 @@ class MultiLineEditor extends Component {
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
this.addOverlay(variables);
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}

View File

@@ -175,6 +175,7 @@ const QueryParams = ({ item, collection }) => {
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={handleRun}
collection={collection}
item={item}
variablesAutocomplete={true}
/>
</td>

View File

@@ -48,9 +48,11 @@ class SingleLineEditor extends Component {
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
scrollbarStyle: null,
tabindex: 0,
readOnly: this.props.readOnly,
@@ -146,9 +148,21 @@ class SingleLineEditor extends Component {
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
this.addOverlay(variables);
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}

View File

@@ -241,19 +241,25 @@ const GlobalStyle = createGlobalStyle`
.CodeMirror-brunoVarInfo {
color: ${(props) => props.theme.codemirror.variable.info.color};
background: ${(props) => props.theme.codemirror.variable.info.bg};
border-radius: 2px;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.border};
border-radius: 0.375rem;
box-shadow: ${(props) => props.theme.codemirror.variable.info.boxShadow};
box-sizing: border-box;
font-size: 13px;
line-height: 16px;
margin: 8px -8px;
max-width: 800px;
font-size: 0.875rem;
line-height: 1.25rem;
margin: 0;
min-width: 18.1875rem;
max-width: 18.1875rem;
opacity: 0;
overflow: hidden;
padding: 8px 8px;
overflow: visible;
padding: 0.5rem;
position: fixed;
transition: opacity 0.15s;
z-index: 50;
z-index: 10;
}
.CodeMirror-hints {
z-index: 50 !important;
}
.CodeMirror-brunoVarInfo :first-child {
@@ -268,6 +274,194 @@ const GlobalStyle = createGlobalStyle`
margin: 1em 0;
}
/* Header */
.CodeMirror-brunoVarInfo .var-info-header {
display: flex;
align-items: center;
margin-bottom: 0.375rem;
gap: 0.375rem;
}
.CodeMirror-brunoVarInfo .var-name {
font-size: 0.875rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
font-weight: 600;
}
/* Scope Badge */
.CodeMirror-brunoVarInfo .var-scope-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
background: #D977061A;
border-radius: 0.25rem;
font-size: 0.875rem;
color: #D97706;
letter-spacing: 0.03125rem;
}
/* Value Container */
.CodeMirror-brunoVarInfo .var-value-container {
position: relative;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.editorBorder};
border-radius: 0.375rem;
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
overflow-y: auto;
overflow-x: hidden;
min-width: 17.3125rem;
max-height: 13.1875rem;
}
/* Value Display (Read-only) */
.CodeMirror-brunoVarInfo .var-value-display {
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-word;
line-height: 1.25rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
min-height: 1.75rem;
max-width: 13.1875rem;
}
/* Value Editor (CodeMirror) */
.CodeMirror-brunoVarInfo .var-value-editor {
width: 100%;
min-width: 17.1875rem;
max-width: 17.1875rem;
max-height: 11.125rem;
position: relative;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror {
height: 100%;
min-height: 1.75rem;
max-height: 11.125rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.editorBorder};
border-radius: 0.375rem;
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
color: ${(props) => props.theme.codemirror.variable.info.color};
transition: border-color 0.15s;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-scroll {
min-height: 1.75rem;
max-height: 11.125rem;
overflow-y: auto !important;
overflow-x: hidden !important;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-focused {
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
border-color: ${(props) => props.theme.codemirror.variable.info.editorFocusBorder};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-lines {
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
max-width: 13.1875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror pre {
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
color: ${(props) => props.theme.codemirror.variable.info.color};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-line {
padding: 0;
max-width: 13.1875rem;
line-height: 1.25rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
color: ${(props) => props.theme.codemirror.variable.info.color};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-sizer {
margin-left: 0 !important;
margin-bottom: 0 !important;
max-width: 13.1875rem !important;
}
/* Editable value display (shows interpolated value, click to edit) */
.CodeMirror-brunoVarInfo .var-value-editable-display {
width: 17.1875rem;
max-width: 13.1875rem;
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
line-height: 1.25rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
min-height: 1.75rem;
cursor: text;
border-radius: 0.375rem;
}
/* Icons Container */
.CodeMirror-brunoVarInfo .var-icons {
position: absolute;
top: 0.375rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.CodeMirror-brunoVarInfo .secret-toggle-button,
.CodeMirror-brunoVarInfo .copy-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.125rem;
opacity: 1;
transition: opacity 0.2s;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
display: flex;
align-items: center;
justify-content: center;
}
.CodeMirror-brunoVarInfo .secret-toggle-button:hover,
.CodeMirror-brunoVarInfo .copy-button:hover {
opacity: 0.7;
}
.CodeMirror-brunoVarInfo .copy-success {
color: #22c55e !important;
}
/* Read-only Note */
.CodeMirror-brunoVarInfo .var-readonly-note {
font-size: 0.625rem;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
margin-top: 0.25rem;
}
.CodeMirror-hint-active {
background: #08f !important;
color: #fff !important;

View File

@@ -69,6 +69,7 @@ import { buildPersistedEnvVariables } from 'utils/environments';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab } from './index';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -1603,12 +1604,184 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
});
};
export const mergeAndPersistEnvironment =
({ persistentEnvVariables, collectionUid }) =>
(_dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
/**
* Update a variable value directly in the file without affecting draft state
* @param {string} pathname - File path
* @param {Object} variable - Variable object with uid, name, value, type, enabled
* @param {string} scopeType - Type of scope ('request', 'folder', 'collection')
* @param {string} collectionUid - Collection UID
* @param {string} itemUid - Item/Folder UID (for request/folder)
*/
const updateVariableInFile = (pathname, variable, scopeType, collectionUid, itemUid) => (dispatch) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-variable-in-file', pathname, variable, scopeType)
.then(() => {
// Update Redux state to reflect the change
if (scopeType === 'request') {
dispatch({
type: 'collections/updateRequestVarValue',
payload: { collectionUid, itemUid, variable }
});
} else if (scopeType === 'folder') {
dispatch({
type: 'collections/updateFolderVarValue',
payload: { collectionUid, folderUid: itemUid, variable }
});
} else if (scopeType === 'collection') {
dispatch({
type: 'collections/updateCollectionVarValue',
payload: { collectionUid, variable }
});
}
resolve();
})
.catch(reject);
});
};
/**
* Helper: Execute update action with toast notification
* @param {Function} action - The action to dispatch
* @param {string} successMessage - Success toast message
* @returns {Promise}
*/
const executeVariableUpdate = (dispatch, action, successMessage) => {
return dispatch(action)
.then(() => {
toast.success(successMessage);
});
};
/**
* Update a variable value in its detected scope (inline editing)
* @param {string} variableName - Name of the variable to update
* @param {string} newValue - New value for the variable
* @param {Object} scopeInfo - Scope information from getVariableScope()
* @param {string} collectionUid - Collection UID
*/
export const updateVariableInScope = (variableName, newValue, scopeInfo, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!scopeInfo || !variableName) {
return reject(new Error('Invalid scope information or variable name'));
}
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
try {
const { type, data } = scopeInfo;
// Handle read-only variables early
if (type === 'process.env') {
toast.error('Process environment variables cannot be edited');
return reject(new Error('Process environment variables are read-only'));
}
if (type === 'runtime') {
toast.error('Runtime variables are set by scripts and cannot be edited');
return reject(new Error('Runtime variables are read-only'));
}
// Validate collection for non-global scopes
if (type !== 'global' && !collection) {
return reject(new Error('Collection not found'));
}
let updatePromise;
let successMessage;
switch (type) {
case 'environment': {
const { environment, variable } = data;
const updatedVariables = variable
? environment.variables.map((v) => (v.name === variableName ? { ...v, value: newValue } : v))
: [...environment.variables, { uid: uuid(), name: variableName, value: newValue, type: 'text', enabled: true }];
updatePromise = saveEnvironment(updatedVariables, environment.uid, collectionUid);
successMessage = `Variable "${variableName}" ${variable ? 'updated' : 'created'}`;
break;
}
case 'collection': {
const { collection: scopeCollection, variable } = data;
const variableToSave = variable
? { ...variable, value: newValue }
: { uid: uuid(), name: variableName, value: newValue, type: 'text', enabled: true };
const collectionFilePath = path.join(scopeCollection.pathname, 'collection.bru');
updatePromise = updateVariableInFile(collectionFilePath, variableToSave, 'collection', collectionUid, null);
successMessage = `Variable "${variableName}" ${variable ? 'updated' : 'created'}`;
break;
}
case 'folder': {
const { folder, variable } = data;
const variableToSave = variable
? { ...variable, value: newValue }
: { uid: uuid(), name: variableName, value: newValue, type: 'text', enabled: true };
const folderFilePath = path.join(folder.pathname, 'folder.bru');
updatePromise = updateVariableInFile(folderFilePath, variableToSave, 'folder', collectionUid, folder.uid);
successMessage = `Variable "${variableName}" ${variable ? 'updated' : 'created'}`;
break;
}
case 'request': {
const { item, variable } = data;
const variableToSave = variable
? { ...variable, value: newValue }
: { uid: uuid(), name: variableName, value: newValue, type: 'text', enabled: true };
updatePromise = updateVariableInFile(item.pathname, variableToSave, 'request', collectionUid, item.uid);
successMessage = `Variable "${variableName}" ${variable ? 'updated' : 'created'}`;
break;
}
case 'global': {
const globalEnvironments = state.globalEnvironments?.globalEnvironments || [];
const activeGlobalEnvUid = state.globalEnvironments?.activeGlobalEnvironmentUid;
if (!activeGlobalEnvUid) {
return reject(new Error('No active global environment'));
}
const environment = globalEnvironments.find((env) => env.uid === activeGlobalEnvUid);
if (!environment) {
return reject(new Error('Global environment not found'));
}
const updatedVariables = environment.variables.map((v) =>
v.name === variableName ? { ...v, value: newValue } : v);
updatePromise = saveGlobalEnvironment({ variables: updatedVariables, environmentUid: activeGlobalEnvUid });
successMessage = `Variable "${variableName}" updated`;
break;
}
default:
return reject(new Error(`Unknown scope type: ${type}`));
}
executeVariableUpdate(dispatch, updatePromise, successMessage)
.then(resolve)
.catch(reject);
} catch (error) {
toast.error(`Failed to update variable: ${error.message}`);
reject(error);
}
});
};
export const mergeAndPersistEnvironment
= ({ persistentEnvVariables, collectionUid }) =>
(_dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));

View File

@@ -25,6 +25,19 @@ import path from 'utils/common/path';
import { getUniqueTagsFromItems } from 'utils/collections/index';
import * as exampleReducers from './exampleReducers';
// Helper: Update or create variable in variables array
const updateOrCreateVariable = (vars, variable) => {
const existingVar = vars.find((v) => v.name === variable.name);
if (existingVar) {
// Update existing variable - use the passed variable object to preserve UID
return vars.map((v) => (v.name === variable.name ? variable : v));
}
// Create new variable
return [...vars, variable];
};
// gRPC status code meanings
const grpcStatusCodes = {
0: 'OK',
@@ -3200,8 +3213,42 @@ export const collectionsSlice = createSlice({
deleteResponseExampleFormUrlEncodedParam: exampleReducers.deleteResponseExampleFormUrlEncodedParam,
addResponseExampleMultipartFormParam: exampleReducers.addResponseExampleMultipartFormParam,
updateResponseExampleMultipartFormParam: exampleReducers.updateResponseExampleMultipartFormParam,
deleteResponseExampleMultipartFormParam: exampleReducers.deleteResponseExampleMultipartFormParam
deleteResponseExampleMultipartFormParam: exampleReducers.deleteResponseExampleMultipartFormParam,
/* End Response Example Actions */
updateRequestVarValue: (state, action) => {
const { collectionUid, itemUid, variable } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (item) {
const vars = get(item, 'request.vars.req', []);
const updatedVars = updateOrCreateVariable(vars, variable);
set(item, 'request.vars.req', updatedVars);
}
},
updateFolderVarValue: (state, action) => {
const { collectionUid, folderUid, variable } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const folder = findItemInCollection(collection, folderUid);
if (folder) {
const vars = get(folder, 'root.request.vars.req', []);
const updatedVars = updateOrCreateVariable(vars, variable);
set(folder, 'root.request.vars.req', updatedVars);
}
},
updateCollectionVarValue: (state, action) => {
const { collectionUid, variable } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const vars = get(collection, 'root.request.vars.req', []);
const updatedVars = updateOrCreateVariable(vars, variable);
set(collection, 'root.request.vars.req', updatedVars);
}
}
});
@@ -3375,8 +3422,12 @@ export const {
deleteResponseExampleRequestHeader,
moveResponseExampleRequestHeader,
setResponseExampleRequestHeaders,
setResponseExampleParams
setResponseExampleParams,
/* Response Example Actions - End */
updateRequestVarValue,
updateFolderVarValue,
updateCollectionVarValue
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -289,9 +289,16 @@ const darkTheme = {
valid: 'rgb(11 178 126)',
invalid: '#f06f57',
info: {
color: '#ce9178',
bg: 'rgb(48,48,49)',
boxShadow: 'rgb(0 0 0 / 36%) 0px 2px 8px'
color: '#FFFFFF',
bg: '#343434',
boxShadow: 'rgb(0 0 0 / 36%) 0px 2px 8px',
editorBg: '#292929',
iconColor: '#989898',
editorBorder: '#3D3D3D',
editorFocusBorder: '#CCCCCC',
editableDisplayHoverBg: 'rgba(255,255,255,0.03)',
border: '#4F4F4F',
editorBorder: '#3D3D3D'
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.18)',

View File

@@ -290,9 +290,16 @@ const lightTheme = {
valid: '#047857',
invalid: 'rgb(185, 28, 28)',
info: {
color: 'rgb(52, 52, 52)',
bg: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.45)'
color: '#343434',
bg: '#FFFFFF',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.45)',
editorBg: '#F7F7F7',
iconColor: '#989898',
editorBorder: '#EFEFEF',
editorFocusBorder: '#989898',
editableDisplayHoverBg: 'rgba(0,0,0,0.02)',
border: '#EFEFEF',
editorBorder: '#EFEFEF'
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.10)',

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;
};

View File

@@ -286,6 +286,73 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
// Helper: Parse file content based on scope type
const parseFileByType = async (fileContent, scopeType) => {
switch (scopeType) {
case 'request':
return await parseRequestViaWorker(fileContent);
case 'folder':
return parseFolder(fileContent);
case 'collection':
return parseCollection(fileContent);
default:
throw new Error(`Invalid scope type: ${scopeType}`);
}
};
// Helper: Stringify data based on scope type
const stringifyByType = async (data, scopeType) => {
switch (scopeType) {
case 'request':
return await stringifyRequestViaWorker(data);
case 'folder':
return stringifyFolder(data);
case 'collection':
return stringifyCollection(data);
default:
throw new Error(`Invalid scope type: ${scopeType}`);
}
};
// Helper: Update or create variable in array
const updateOrCreateVariable = (variables, variable) => {
const existingVar = variables.find((v) => v.name === variable.name);
if (existingVar) {
// Update existing variable
return variables.map((v) => (v.name === variable.name ? variable : v));
}
// Create new variable
return [...variables, variable];
};
// update variable in request/folder/collection file
ipcMain.handle('renderer:update-variable-in-file', async (event, pathname, variable, scopeType) => {
try {
if (!fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} does not exist`);
}
// Read and parse the file
const fileContent = fs.readFileSync(pathname, 'utf8');
const parsedData = await parseFileByType(fileContent, scopeType);
// Update the specific variable or create it if it doesn't exist
const varsPath = 'request.vars.req';
const variables = _.get(parsedData, varsPath, []);
const updatedVariables = updateOrCreateVariable(variables, variable);
_.set(parsedData, varsPath, updatedVariables);
// Stringify and write back
const content = await stringifyByType(parsedData, scopeType);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
}
});
// create environment
ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => {
try {