Feat/editor custom search (#5278)

* added custom search in editor

* UI improvements

* added yellow highlight for search

* added playwright tests

* memoizing matches and few other changes

* fixed issue with debounce

* refactoring and styling fixes

* lint fixes

* ensure ESC closes search bar even when focus is in editor

* move esc logic to editor

---------

Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
anusree-bruno
2025-10-08 11:05:17 +05:30
committed by GitHub
parent 9f47200e7b
commit a66e849cfb
13 changed files with 574 additions and 88 deletions

View File

@@ -0,0 +1,99 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-search-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
flex-wrap: nowrap;
padding: 0 2px;
min-height: 36px;
background: ${(props) => props.theme.sidebar.search.bg} !important;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.sidebar.search.bg} !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
width: auto;
min-width: 180px;
max-width: 320px;
}
.bruno-search-bar input {
min-width: 80px;
background: transparent;
color: inherit;
border: none;
outline: none;
padding: 1px 2px;
font-size: 13px;
margin: 0 1px;
height: 28px;
}
.searchbar-icon-btn {
background: none;
border: none;
padding: 0 1px;
margin: 0 1px;
cursor: pointer;
color: #aaa;
border-radius: 3px;
height: 18px;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.searchbar-result-count {
min-width: 28px;
text-align: center;
font-size: 11px;
color: #aaa;
margin: 0 8px 0 1px;
white-space: nowrap;
}
.bruno-search-bar.compact {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.codemirror.text || props.theme.text};
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border-radius: 4px;
padding: 1px 3px;
min-height: 22px;
display: flex;
align-items: center;
gap: 0;
}
.bruno-search-bar input {
background: transparent;
color: inherit;
border: none;
outline: none;
font-size: 13px;
padding: 1px 2px;
min-width: 80px;
}
.searchbar-icon-btn:focus {
outline: 1px solid ${(props) => props.theme.codemirror.border};
}
.bruno-search-bar, .bruno-search-bar input {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.searchbar-icon-btn.active {
color: #f39c12 !important;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import useDebounce from 'hooks/useDebounce';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CustomSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [matchIndex, setMatchIndex] = useState(0);
const [matchCount, setMatchCount] = useState(0);
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
if (!debouncedSearchText) {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
return;
}
try {
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
searchLineHighlight.current = null;
}
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
if (searchLineHighlight.current !== null && editor) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
searchMatches.current = [];
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
setTimeout(() => editor.focus(), 0);
}
}, [editor, onClose]);
const handleSearchTextChange = (text) => {
setSearchText(text);
setMatchIndex(0);
};
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};
if (!visible) return null;
return (
<StyledWrapper>
<div className="bruno-search-bar compact">
<input
autoFocus
type="text"
value={searchText}
onChange={(e) => handleSearchTextChange(e.target.value)}
placeholder="Search..."
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) handleNext();
if (e.key === 'Enter' && e.shiftKey) handlePrev();
if (e.key === 'Escape') handleSearchBarClose();
}}
/>
<span className="searchbar-result-count">{matchCount > 0 ? `${matchIndex + 1} / ${matchCount}` : '0 results'}</span>
<ToolHint text="Regex search" toolhintId="searchbar-regex-toolhint" place="top">
<button className={`searchbar-icon-btn ${regex ? 'active' : ''}`} onClick={handleToggleRegex}><IconRegex size={16} /></button>
</ToolHint>
<ToolHint text="Case sensitive" toolhintId="searchbar-case-toolhint" place="top">
<button className={`searchbar-icon-btn ${caseSensitive ? 'active' : ''}`} onClick={handleToggleCase}><IconLetterCase size={14} /></button>
</ToolHint>
<ToolHint text="Whole word" toolhintId="searchbar-wholeword-toolhint" place="top">
<button className={`searchbar-icon-btn ${wholeWord ? 'active' : ''}`} onClick={handleToggleWholeWord}><IconLetterW size={14} /></button>
</ToolHint>
<button className="searchbar-icon-btn" title="Previous" onClick={handlePrev}><IconArrowUp size={14} /></button>
<button className="searchbar-icon-btn" title="Next" onClick={handleNext}><IconArrowDown size={14} /></button>
<button className="searchbar-icon-btn" title="Close" onClick={handleSearchBarClose}><IconX size={14} /></button>
</div>
</StyledWrapper>
);
};
export default CustomSearch;

View File

@@ -109,6 +109,17 @@ const StyledWrapper = styled.div`
text-decoration:unset;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}
.cm-search-current {
background: rgba(255, 193, 7, 0.4);
}
`;
export default StyledWrapper;

View File

@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import CustomSearch from './CustomSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -37,6 +38,10 @@ export default class CodeEditor extends React.Component {
expr: true,
asi: true
};
this.state = {
searchBarVisible: false
};
}
componentDidMount() {
@@ -83,24 +88,14 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Ctrl-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -129,6 +124,11 @@ export default class CodeEditor extends React.Component {
} else {
this.editor.toggleComment();
}
},
'Esc': () => {
if (this.state.searchBarVisible) {
this.setState({ searchBarVisible: false });
}
}
},
foldOptions: {
@@ -254,11 +254,6 @@ export default class CodeEditor extends React.Component {
this.editor.off('scroll', this.onScroll);
this.editor = null;
}
this._unbindSearchHandler();
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
render() {
@@ -271,10 +266,18 @@ export default class CodeEditor extends React.Component {
aria-label="Code Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
/>
>
<CustomSearch
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}
/>
<div
className={`editor-container${this.state.searchBarVisible ? ' search-bar-visible' : ''}`}
ref={(node) => { this._node = node; }}
style={{ height: '100%', width: '100%' }}
/>
</StyledWrapper>
);
}
@@ -298,67 +301,4 @@ export default class CodeEditor extends React.Component {
}
}
};
_isSearchOpen = () => {
return document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
};
/**
* Bind handler to search input to count number of search results
*/
_bindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.addEventListener('input', this._countSearchResults);
}
};
/**
* Unbind handler to search input to count number of search results
*/
_unbindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.removeEventListener('input', this._countSearchResults);
}
};
/**
* Append search results count to search dialog
*/
_appendSearchResultsCount = () => {
const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
if (dialog) {
const searchResultsCount = document.createElement('span');
searchResultsCount.id = this.searchResultsCountElementId;
dialog.appendChild(searchResultsCount);
this._countSearchResults();
}
};
/**
* Count search results and update state
*/
_countSearchResults = () => {
let count = 0;
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput && searchInput.value.length > 0) {
// Escape special characters in search input to prevent RegExp crashes. Fixes #3051
const text = new RegExp(escapeRegExp(searchInput.value), 'gi');
const matches = this.editor.getValue().match(text);
count = matches ? matches.length : 0;
}
const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`);
if (searchResultsCountElement) {
searchResultsCountElement.innerText = `${count} results`;
}
};
}

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;

View File

@@ -277,7 +277,10 @@ const darkTheme = {
bg: 'rgb(48,48,49)',
boxShadow: 'rgb(0 0 0 / 36%) 0px 2px 8px'
}
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.18)',
searchMatch: '#FFD700',
searchMatchActive: '#FFFF00'
},
table: {

View File

@@ -278,7 +278,10 @@ const lightTheme = {
bg: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.45)'
}
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.10)',
searchMatch: '#B8860B',
searchMatchActive: '#DAA520'
},
table: {

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "custom-search",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -0,0 +1,9 @@
{
"name": "custom-search",
"version": "1.0.0",
"description": "A test collection for search functionality",
"main": "index.js",
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@@ -0,0 +1,46 @@
meta {
name: search-test-request
type: http
seq: 1
}
get {
url: https://httpbin.org/get
body: none
auth: inherit
}
script:pre-request {
const testVariable = "hello world";
const anotherVariable = "search test";
console.log("This is a test log message");
const searchableText = "find me";
const apiKey = "test-api-key-123";
const baseUrl = "https://api.example.com";
console.log("Pre-request script executed");
const uniquePreVar = "only in pre-request";
const commonVar = "common content";
const searchableText2 = "find me again";
const searchableText3 = "find me third time";
console.log("More searchableText instances");
}
script:post-response {
const responseData = "response content";
const searchableResponse = "find this too";
console.log("Response processed");
const statusCode = bru.getResponseStatus();
const responseTime = bru.getResponseTime();
console.log("Response status:", statusCode);
const uniquePostVar = "only in post-response";
const commonVar = "common content";
const searchableResponse2 = "find this too again";
const searchableResponse3 = "find this too third time";
console.log("More searchableResponse instances");
}

View File

@@ -0,0 +1,133 @@
import { test, expect } from '../../../../playwright';
test.describe('Custom Search Functionality in Scripts Tab', () => {
test('should open search box when Cmd+F or Ctrl+F is pressed in scripts tab', async ({ pageWithUserData: page }) => {
await page.getByTitle('custom-search').click();
await page.getByText('search-test-request').click();
await page.getByRole('tab', { name: 'Script' }).click();
await expect(page.getByText('Pre Request')).toBeVisible();
await expect(page.locator('.title.text-xs').filter({ hasText: 'Post Response' })).toBeVisible();
const preRequestEditor = page.locator('text=Pre Request').locator('..').locator('.CodeMirror').first();
const preTextarea = preRequestEditor.locator('textarea[tabindex="0"]');
await preTextarea.focus();
const preContent = await preRequestEditor.textContent();
console.log('Pre Request content loaded:', preContent?.substring(0, 100));
const postResponseEditor = page.locator('text=Post Response').locator('..').locator('.CodeMirror').first();
const postTextarea = postResponseEditor.locator('textarea[tabindex="0"]');
await postTextarea.focus();
const postContent = await postResponseEditor.textContent();
console.log('Post Response content loaded:', postContent?.substring(0, 100));
await preTextarea.focus();
await page.keyboard.press('Meta+f');
// Verify search box appears
await expect(page.locator('.bruno-search-bar input[placeholder="Search..."]')).toBeVisible();
// Test search functionality
const searchInput = page.locator('.bruno-search-bar input[placeholder="Search..."]');
await searchInput.fill('searchableText');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 4');
// Test search options
const regexButton = page.locator('.searchbar-icon-btn').filter({ hasText: '' }).first();
const caseSensitiveButton = page.locator('.searchbar-icon-btn').filter({ hasText: '' }).nth(1);
const wholeWordButton = page.locator('.searchbar-icon-btn').filter({ hasText: '' }).nth(2);
// Test regex search
await regexButton.click();
await searchInput.fill('test\\w+');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 1');
// Test case sensitive search
await regexButton.click();
await caseSensitiveButton.click();
await searchInput.fill('Test');
await expect(page.locator('.searchbar-result-count')).toContainText('0 results');
// Test whole word search
await caseSensitiveButton.click();
await wholeWordButton.click();
await searchInput.fill('hello');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 1');
// Test close search
const closeButton = page.locator('.searchbar-icon-btn').last();
await closeButton.click();
await expect(page.locator('.bruno-search-bar')).not.toBeVisible();
});
test('should handle search in different script editors independently', async ({ pageWithUserData: page }) => {
await page.getByTitle('custom-search').click();
await page.getByText('search-test-request').click();
await page.getByRole('tab', { name: 'Script' }).click();
await expect(page.getByText('Pre Request')).toBeVisible();
await expect(page.locator('.title.text-xs').filter({ hasText: 'Post Response' })).toBeVisible();
const preRequestEditor = page.locator('text=Pre Request').locator('..').locator('.CodeMirror').first();
const postResponseEditor = page.locator('text=Post Response').locator('..').locator('.CodeMirror').first();
const preTextarea = preRequestEditor.locator('textarea[tabindex="0"]');
await preTextarea.focus();
await page.keyboard.press('Meta+f');
const preSearchInput = page.locator('.bruno-search-bar input[placeholder="Search..."]');
await preSearchInput.fill('uniquePreVar');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 1');
await page.keyboard.press('Escape');
const postTextarea = postResponseEditor.locator('textarea[tabindex="0"]');
await postTextarea.focus();
await page.keyboard.press('Meta+f');
const postSearchInput = page.locator('.bruno-search-bar input[placeholder="Search..."]');
await postSearchInput.fill('uniquePostVar');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 1');
await page.keyboard.press('Escape');
});
test('should maintain search state when switching between editors', async ({ pageWithUserData: page }) => {
await page.getByTitle('custom-search').click();
await page.getByText('search-test-request').click();
await page.getByRole('tab', { name: 'Script' }).click();
await expect(page.getByText('Pre Request')).toBeVisible();
await expect(page.locator('.title.text-xs').filter({ hasText: 'Post Response' })).toBeVisible();
const preRequestEditor = page.locator('text=Pre Request').locator('..').locator('.CodeMirror').first();
const postResponseEditor = page.locator('text=Post Response').locator('..').locator('.CodeMirror').first();
// Open search in Pre Request editor
const preTextarea = preRequestEditor.locator('textarea[tabindex="0"]');
await preTextarea.focus();
await page.keyboard.press('Meta+f');
const searchInput = page.locator('.bruno-search-bar input[placeholder="Search..."]');
await searchInput.fill('commonVar');
await expect(page.locator('.searchbar-result-count')).toContainText('1 / 1');
// Switch to Post Response editor while search is open
const postTextarea = postResponseEditor.locator('textarea[tabindex="0"]');
await postTextarea.focus();
// Search should still be visible and functional
await expect(page.locator('.bruno-search-bar')).toBeVisible();
await expect(searchInput).toHaveValue('commonVar');
const closeButton = page.locator('.searchbar-icon-btn').last();
await closeButton.click();
await expect(page.locator('.bruno-search-bar')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/request/collections/custom-search",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,5 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/request/collections/custom-search"],
"preferences": {}
}