Files
bruno/packages/bruno-app/src/components/SingleLineEditor/index.js
Chirag Chandrashekhar 8c9cad6d78 fix: cURL paste not updating request tab editors visually (#7610)
* fix: cURL paste not updating request tab editors visually

Remove the focus-based guard from PR#7098 that blocked editor value
updates when the editor had focus. This caused cURL paste to not
reflect in the request tab until switching tabs. Editors now always
accept incoming prop values and preserve cursor position. Also set
cursor to end of new URL after cURL paste using setTimeout.

* fix: added optional chaining

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 21:03:10 +05:30

324 lines
10 KiB
JavaScript

import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { setupLinkAware } from 'utils/codemirror/linkAware';
const CodeMirror = require('codemirror');
class SingleLineEditor extends Component {
constructor(props) {
super(props);
// Keep a cached version of the value, this cache will be updated when the
// editor is updated, which can later be used to protect the editor from
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.readOnly = props.readOnly || false;
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
const runHandler = () => {
if (this.props.onRun) {
this.props.onRun();
}
};
const saveHandler = () => {
if (this.props.onSave) {
this.props.onSave();
}
};
const noopHandler = () => { };
this.editor = CodeMirror(this.editorRef.current, {
placeholder: this.props.placeholder ?? '',
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
scrollbarStyle: null,
tabindex: 0,
readOnly: this.props.readOnly,
extraKeys: {
'Enter': runHandler,
'Alt-Enter': () => {
if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
} else if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-F': noopHandler,
'Ctrl-F': noopHandler,
// Tabbing disabled to make tabindex work
'Tab': false,
'Shift-Tab': false
}
});
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
// Setup AutoComplete Helper
const autoCompleteOptions = {
getAllVariables: getAllVariablesHandler,
getAnywordAutocompleteHints,
showHintsFor: this.props.showHintsFor || ['variables'],
showHintsOnClick: this.props.showHintsOnClick
};
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.editor.on('paste', this._onPaste);
this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
this.setState({ maskInput: this.props.isSecret });
// Add newline arrow markers if enabled
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
setupLinkAware(this.editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = this.editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
if (this.maskedEditor) {
this.maskedEditor.disable();
this.maskedEditor.destroy();
this.maskedEditor = null;
}
}
};
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
// Update newline markers after edit
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
};
_onPaste = (_, event) => this.props.onPaste?.(event);
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
// event loop.
this.ignoreChangeEvent = true;
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.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');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.placeholder !== prevProps.placeholder && this.editor) {
this.editor.setOption('placeholder', this.props.placeholder);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('paste', this._onPaste);
this._clearNewlineMarkers();
this.editor.getWrapperElement().remove();
this.editor = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
}
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true);
this.editor.setOption('mode', 'brunovariables');
};
/**
* Update markers to show arrows for newlines
*/
_updateNewlineMarkers = () => {
if (!this.editor) return;
// Clear existing markers
this._clearNewlineMarkers();
this.newlineMarkers = [];
const content = this.editor.getValue();
// Find all newlines and replace them with arrow widgets
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') {
const pos = this.editor.posFromIndex(i);
const nextPos = this.editor.posFromIndex(i + 1);
// Create a widget to display the arrow
const arrow = document.createElement('span');
arrow.className = 'newline-arrow';
arrow.textContent = '↲';
arrow.style.cssText = `
color: #888;
font-size: 8px;
margin: 0 2px;
vertical-align: middle;
display: inline-block;
`;
// Mark the newline character and replace it with the arrow widget
const marker = this.editor.markText(pos, nextPos, {
replacedWith: arrow,
handleMouseEvents: true
});
this.newlineMarkers.push(marker);
}
}
};
/**
* Clear all newline markers
*/
_clearNewlineMarkers = () => {
if (this.newlineMarkers) {
this.newlineMarkers.forEach((marker) => {
try {
marker.clear();
} catch (e) {
// Marker might already be cleared
}
});
this.newlineMarkers = [];
}
};
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
this._enableMaskedEditor(isVisible);
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button type="button" className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() {
return (
<div className={`flex flex-row items-center w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper
ref={this.editorRef}
className={`single-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`}
$isCompact={this.props.isCompact}
{...(this.props['data-testid'] ? { 'data-testid': this.props['data-testid'] } : {})}
/>
<div className="flex items-center">
{this.secretEye(this.props.isSecret)}
</div>
</div>
);
}
}
export default SingleLineEditor;