diff --git a/packages/bruno-app/src/components/CodeEditor/CustomSearch/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/CustomSearch/StyledWrapper.js new file mode 100644 index 000000000..b50bb222b --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/CustomSearch/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/CodeEditor/CustomSearch/index.js b/packages/bruno-app/src/components/CodeEditor/CustomSearch/index.js new file mode 100644 index 000000000..18a648a62 --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/CustomSearch/index.js @@ -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 ( + +
+ 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(); + }} + /> + {matchCount > 0 ? `${matchIndex + 1} / ${matchCount}` : '0 results'} + + + + + + + + + + + + +
+
+ ); +}; + +export default CustomSearch; diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index 81569954b..bce574f3c 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index d5609395d..5ee05bc35 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -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; - }} - /> + > + this.setState({ searchBarVisible: false })} + /> +
{ this._node = node; }} + style={{ height: '100%', width: '100%' }} + /> + ); } @@ -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`; - } - }; } diff --git a/packages/bruno-app/src/hooks/useDebounce/index.js b/packages/bruno-app/src/hooks/useDebounce/index.js new file mode 100644 index 000000000..1b2e5cb9e --- /dev/null +++ b/packages/bruno-app/src/hooks/useDebounce/index.js @@ -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; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 1b574b405..505ddafd2 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -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: { diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 98e23bb0b..9a203aafc 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -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: { diff --git a/tests/request/collections/custom-search/bruno.json b/tests/request/collections/custom-search/bruno.json new file mode 100644 index 000000000..082d7a146 --- /dev/null +++ b/tests/request/collections/custom-search/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "custom-search", + "type": "collection", + "ignore": ["node_modules", ".git"] +} diff --git a/tests/request/collections/custom-search/package.json b/tests/request/collections/custom-search/package.json new file mode 100644 index 000000000..ef8ea6862 --- /dev/null +++ b/tests/request/collections/custom-search/package.json @@ -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" +} diff --git a/tests/request/collections/custom-search/search-request.bru b/tests/request/collections/custom-search/search-request.bru new file mode 100644 index 000000000..2285feda4 --- /dev/null +++ b/tests/request/collections/custom-search/search-request.bru @@ -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"); +} diff --git a/tests/request/tests/custom-search/custom-search.spec.ts b/tests/request/tests/custom-search/custom-search.spec.ts new file mode 100644 index 000000000..d9fdb72a0 --- /dev/null +++ b/tests/request/tests/custom-search/custom-search.spec.ts @@ -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(); + }); +}); diff --git a/tests/request/tests/custom-search/init-user-data/collection-security.json b/tests/request/tests/custom-search/init-user-data/collection-security.json new file mode 100644 index 000000000..cd07228fe --- /dev/null +++ b/tests/request/tests/custom-search/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/request/collections/custom-search", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/request/tests/custom-search/init-user-data/preferences.json b/tests/request/tests/custom-search/init-user-data/preferences.json new file mode 100644 index 000000000..8a42569a2 --- /dev/null +++ b/tests/request/tests/custom-search/init-user-data/preferences.json @@ -0,0 +1,5 @@ +{ + "maximized": true, + "lastOpenedCollections": ["{{projectRoot}}/tests/request/collections/custom-search"], + "preferences": {} +}