mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 13:45:52 +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:
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user