From 4631eda28126348d4aeae5a01e6e607ed041a2e9 Mon Sep 17 00:00:00 2001 From: Pooja Date: Mon, 17 Nov 2025 16:13:09 +0530 Subject: [PATCH] Merge pull request #6069 from pooja-bruno/feat/add-edit-variable-in-place feat: edit variable in place --- .../src/components/CodeEditor/index.js | 23 +- .../EnvironmentVariables/index.js | 1 + .../EnvironmentVariables/index.js | 1 + .../src/components/MultiLineEditor/index.js | 22 +- .../RequestPane/QueryParams/index.js | 1 + .../src/components/SingleLineEditor/index.js | 22 +- packages/bruno-app/src/globalStyles.js | 210 ++++++- .../ReduxStore/slices/collections/actions.js | 185 +++++- .../ReduxStore/slices/collections/index.js | 55 +- packages/bruno-app/src/themes/dark.js | 13 +- packages/bruno-app/src/themes/light.js | 13 +- .../src/utils/codemirror/brunoVarInfo.js | 573 +++++++++++++++--- .../src/utils/codemirror/brunoVarInfo.spec.js | 115 +++- .../bruno-app/src/utils/collections/index.js | 105 ++++ packages/bruno-electron/src/ipc/collection.js | 67 ++ .../variable-tooltip/variable-tooltip.spec.ts | 317 ++++++++++ 16 files changed, 1605 insertions(+), 118 deletions(-) create mode 100644 tests/variable-tooltip/variable-tooltip.spec.ts diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 73fe15330..003e5e739 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -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'); }; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index 17faa570c..20d627765 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -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} /> {!variable.secret && hasSensitiveUsage(variable.name) && ( diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index 8365e383d..e428b67e0 100644 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -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} /> {typeof variable.value !== 'string' && ( diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index 19f2c3921..af3a77d19 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -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'); } diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 4a1e37f4c..abda85bfb 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -175,6 +175,7 @@ const QueryParams = ({ item, collection }) => { onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')} onRun={handleRun} collection={collection} + item={item} variablesAutocomplete={true} /> diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index b8b5a2f85..5fbd71789 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -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'); } diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js index 7d820c41f..0a3a6374c 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -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; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 9e58f15f8..079422487 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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')); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 07e3792a8..f759d6897 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -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; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 7c5af6554..c1afa43e1 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -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)', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 05b40766e..01f61b9eb 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -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)', diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index 8f87c6824..64ab5b96d 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -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 = ` - + `; const CHECKMARK_ICON_SVG_TEXT = ` - + `; @@ -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 = ` + + + + +`; + +const EYE_OFF_ICON_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; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index f425d91a5..42e565cf7 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -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'); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 160b05c81..5e826000a 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -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; +}; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index d38a02f03..c666d8292 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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 { diff --git a/tests/variable-tooltip/variable-tooltip.spec.ts b/tests/variable-tooltip/variable-tooltip.spec.ts new file mode 100644 index 000000000..6b9643a9c --- /dev/null +++ b/tests/variable-tooltip/variable-tooltip.spec.ts @@ -0,0 +1,317 @@ +import { test, expect } from '../../playwright'; +import { createCollection, closeAllCollections } from '../utils/page'; + +test.describe('Variable Tooltip', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should test tooltip functionality with environment variables', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-test'; + + await test.step('Create collection and add environment variables', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-collection'), { + openWithSandboxMode: 'safe' + }); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + + // Open environment settings + await page.locator('[data-testid="environment-selector-trigger"]').click(); + await expect(page.locator('[data-testid="env-tab-collection"]')).toHaveClass(/active/); + + // Create environment + await page.locator('button[id="create-env"]').click(); + await page.locator('input[name="name"]').fill('Test Env'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Add apiKey variable + await page.locator('button[data-testid="add-variable"]').click(); + await page.locator('input[name="0.name"]').fill('apiKey'); + await page.locator('tr').filter({ has: page.locator('input[name="0.name"]') }).locator('.CodeMirror').click(); + await page.keyboard.type('test-key-123'); + + // Add secretToken variable + await page.locator('button[data-testid="add-variable"]').click(); + await page.locator('input[name="1.name"]').fill('secretToken'); + await page.locator('tr').filter({ has: page.locator('input[name="1.name"]') }).locator('.CodeMirror').click(); + await page.keyboard.type('secret-xyz'); + await page.locator('input[name="1.secret"]').check(); + + // Save and close + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByText('×').click(); + }); + + await test.step('Create request and test tooltip', async () => { + // Create request + const collectionContainer = page.locator('.collection-name').filter({ hasText: collectionName }); + await collectionContainer.locator('.collection-actions').hover(); + await collectionContainer.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + + await page.getByPlaceholder('Request Name').fill('Test Request'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('https://api.example.com?key={{apiKey}}'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Open request + await page.locator('.collection-item-name').filter({ hasText: 'Test Request' }).click(); + }); + + await test.step('Test basic tooltip', async () => { + const urlEditor = page.locator('#request-url .CodeMirror'); + const apiKeyVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'apiKey' }).first(); + + await apiKeyVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + await expect(tooltip.locator('.var-name')).toContainText('apiKey'); + await expect(tooltip.locator('.var-scope-badge')).toContainText('Environment'); + await expect(tooltip.locator('.var-value-editable-display')).toContainText('test-key-123'); + await expect(tooltip.locator('.copy-button')).toBeVisible(); + }); + + await test.step('Test secret variable with toggle', async () => { + // Move mouse away to dismiss any active tooltip + await page.mouse.move(0, 0); + + // Add header with secret + await page.getByRole('tab', { name: 'Headers' }).click(); + await page.locator('button.btn-action').filter({ hasText: 'Add Header' }).click(); + + const headerNameEditor = page.locator('table tbody tr').first().locator('td').first().locator('.CodeMirror'); + await headerNameEditor.click(); + await page.keyboard.type('Authorization'); + + const headerValueEditor = page.locator('table tbody tr').first().locator('td').nth(1).locator('.CodeMirror'); + await headerValueEditor.click(); + await page.keyboard.type('Bearer {{secretToken}}'); + await page.keyboard.press('Control+s'); + + // Test tooltip with secret + const secretVar = headerValueEditor.locator('.cm-variable-valid').filter({ hasText: 'secretToken' }).first(); + await secretVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + // Verify masked + const valueDisplay = tooltip.locator('.var-value-editable-display'); + const maskedText = await valueDisplay.textContent(); + // Check that value is masked (contains bullet points and not the actual value) + expect(maskedText).not.toContain('secret-xyz'); + expect(maskedText?.length).toBeGreaterThan(0); + + // Test toggle + const toggleButton = tooltip.locator('.secret-toggle-button'); + await expect(toggleButton).toBeVisible(); + await toggleButton.click(); + await expect(valueDisplay).toContainText('secret-xyz'); + + // Toggle back + await toggleButton.click(); + const remaskedText = await valueDisplay.textContent(); + expect(remaskedText).not.toContain('secret-xyz'); + expect(remaskedText?.length).toBeGreaterThan(0); + }); + }); + + test('should test tooltip with variable references', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-reference-test'; + + await test.step('Create collection with interdependent variables', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-ref-collection'), { + openWithSandboxMode: 'safe' + }); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + + // Open environment settings + await page.locator('[data-testid="environment-selector-trigger"]').click(); + await expect(page.locator('[data-testid="env-tab-collection"]')).toHaveClass(/active/); + + // Create environment + await page.locator('button[id="create-env"]').click(); + await page.locator('input[name="name"]').fill('Ref Test Env'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Add host variable + await page.locator('button[data-testid="add-variable"]').click(); + await page.locator('input[name="0.name"]').fill('host'); + await page.locator('tr').filter({ has: page.locator('input[name="0.name"]') }).locator('.CodeMirror').click(); + await page.keyboard.type('api.example.com'); + + // Add endpoint that references host + await page.locator('button[data-testid="add-variable"]').click(); + await page.locator('input[name="1.name"]').fill('endpoint'); + await page.locator('tr').filter({ has: page.locator('input[name="1.name"]') }).locator('.CodeMirror').click(); + await page.keyboard.type('https://{{host}}/users'); + + // Save and close + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByText('×').click(); + }); + + await test.step('Create request with variable references', async () => { + const collectionContainer = page.locator('.collection-name').filter({ hasText: collectionName }); + await collectionContainer.locator('.collection-actions').hover(); + await collectionContainer.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + + await page.getByPlaceholder('Request Name').fill('Ref Test Request'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('{{endpoint}}'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.locator('.collection-item-name').filter({ hasText: 'Ref Test Request' }).click(); + }); + + await test.step('Test variable referencing other variables', async () => { + const urlEditor = page.locator('#request-url .CodeMirror'); + const endpointVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'endpoint' }).first(); + + await endpointVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + await expect(tooltip.locator('.var-name')).toContainText('endpoint'); + + // Should show resolved value + await expect(tooltip.locator('.var-value-editable-display')).toContainText('https://api.example.com/users'); + + // Should have copy button + await expect(tooltip.locator('.copy-button')).toBeVisible(); + }); + + await test.step('Test editing variable with references', async () => { + // Move mouse away to dismiss any active tooltip + await page.mouse.move(0, 0); + + // URL editor is always visible at the top + const urlEditor = page.locator('#request-url .CodeMirror'); + const endpointVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'endpoint' }).first(); + + await endpointVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + // Click on value to edit + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + // Should show editor with raw value (not resolved) + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + // Verify it shows the raw value with variable references + // focus on the editor + const editorContent = await editor.locator('.CodeMirror-line').textContent(); + expect(editorContent).toContain('{{host}}'); + + // Edit the value + await page.keyboard.press('End'); + await page.keyboard.type('/posts'); + + // Click outside to save + await page.locator('body').click(); + + // Move mouse away and back to get fresh tooltip + await page.mouse.move(0, 0); + + // Hover again to verify the change + await endpointVar.hover(); + + const newTooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(newTooltip).toBeVisible(); + + // Should show updated resolved value + await expect(newTooltip.locator('.var-value-editable-display')).toContainText('https://api.example.com/users/posts'); + }); + + await test.step('Test copy button', async () => { + // Move mouse away to dismiss any active tooltip + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const endpointVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'endpoint' }).first(); + + await endpointVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const copyButton = tooltip.locator('.copy-button'); + await expect(copyButton).toBeVisible(); + + // Click copy button + await copyButton.click(); + + // Should show success state (checkmark) + await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); + + // Wait for it to revert back to copy icon + await expect(copyButton.locator('svg rect')).toBeVisible(); + }); + }); + + test('should handle runtime and process.env variables', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-readonly-test'; + + await test.step('Create collection and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-readonly-collection'), { + openWithSandboxMode: 'safe' + }); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + + // Create environment + await page.locator('[data-testid="environment-selector-trigger"]').click(); + await page.locator('button[id="create-env"]').click(); + await page.locator('input[name="name"]').fill('Readonly Env'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByText('×').click(); + + // Create request + const collectionContainer = page.locator('.collection-name').filter({ hasText: collectionName }); + await collectionContainer.locator('.collection-actions').hover(); + await collectionContainer.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + + await page.getByPlaceholder('Request Name').fill('Readonly Test'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('https://example.com'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.locator('.collection-item-name').filter({ hasText: 'Readonly Test' }).click(); + }); + + await test.step('Test process.env variable tooltip', async () => { + // Move mouse away to dismiss any active tooltip + await page.mouse.move(0, 0); + + // Add a process.env variable in URL (URL editor is always visible at the top) + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.press('End'); + await page.keyboard.type('?env={{process.env.HOME}}'); + await page.keyboard.press('Control+s'); + + // Hover over process.env variable + const processEnvVar = urlEditor.locator('.cm-variable-valid, .cm-variable-invalid').filter({ hasText: 'process.env.HOME' }).first(); + await processEnvVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + await expect(tooltip.locator('.var-name')).toContainText('process.env.HOME'); + await expect(tooltip.locator('.var-scope-badge')).toContainText('Process Env'); + + // Should show read-only note + await expect(tooltip.locator('.var-readonly-note')).toContainText('read-only'); + + // Should have copy button but not be editable + await expect(tooltip.locator('.copy-button')).toBeVisible(); + await expect(tooltip.locator('.var-value-editor')).not.toBeVisible(); + }); + }); +});