From dddb90208cc75b017e872a22b86ed54a4287efaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 25 Sep 2026 16:25:55 -0400 Subject: [PATCH 01/10] feat(search): Syntax highlight regex literals in the search bar Renders regex filter patterns with Prism's regex grammar, in the filter itself and in an overlay over the value input while editing. --- .../searchQueryBuilder/index.spec.tsx | 34 ++++++++++++ .../searchQueryBuilder/tokens/combobox.tsx | 34 +++++++++++- .../tokens/filter/filter.tsx | 29 ++++++++--- .../filter/highlightedRegexPattern.spec.tsx | 52 +++++++++++++++++++ .../tokens/filter/highlightedRegexPattern.tsx | 47 +++++++++++++++++ .../tokens/filter/valueCombobox.tsx | 4 ++ 6 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx create mode 100644 static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 9dcd04d4fb81..179123e3336d 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -8822,6 +8822,40 @@ describe('SearchQueryBuilder', () => { ); }); }); + + it('displays the pattern in the filter when the operator is matches regex', async () => { + render( + + ); + + expect( + within( + await screen.findByRole('row', {name: 'browser.name://fire.*fox//'}) + ).getByText('fire.*fox') + ).toBeInTheDocument(); + }); + + it('displays the pattern over the input when editing a regex value', async () => { + render( + + ); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: browser.name'}) + ); + + expect( + await screen.findByRole('combobox', {name: 'Edit filter value'}) + ).toHaveValue('fire.*fox'); + expect(screen.getByText('fire.*fox')).toHaveAttribute('aria-hidden', 'true'); + }); }); describe('regex pattern validation', () => { diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx index e36f73d92136..73656db2b3e8 100644 --- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import {createPortal} from 'react-dom'; import {usePopper} from 'react-popper'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {type AriaComboBoxProps} from '@react-aria/combobox'; import {type AriaListBoxOptions} from '@react-aria/listbox'; @@ -121,6 +122,11 @@ type SearchQueryBuilderComboboxProps; + /** + * Renders the input's value in an overlay drawn on top of the input, whose own + * text is hidden. Use to style the value beyond what an input can render. + */ + renderInputValue?: (value: string) => ReactNode; /** * Function to determine whether the menu should close when interacting with * other elements. @@ -421,6 +427,7 @@ export function SearchQueryBuilderCombobox< keepVisibleRef, 'data-test-id': dataTestId, ref, + renderInputValue, }: SearchQueryBuilderComboboxProps) { const {clearSearchQuery, dispatch} = useSearchQueryBuilderState(); const {disabled} = useSearchQueryBuilderConfig(); @@ -666,10 +673,13 @@ export function SearchQueryBuilderCombobox< const autosizeInput = useAutosizeInput({value: inputValue}); + const highlightedValue = inputValue ? renderInputValue?.(inputValue) : null; + return ( + {highlightedValue ? ( + {highlightedValue} + ) : null} {description ? ( ` background: transparent; border: none; box-shadow: none; @@ -767,6 +780,25 @@ const UnstyledInput = styled(Input)` border: none; box-shadow: none; } + + ${p => + p.hideValue && + css` + color: transparent; + caret-color: ${p.theme.tokens.content.primary}; + `} +`; + +const InputValueOverlay = styled('div')` + position: absolute; + inset: 0; + display: flex; + align-items: center; + white-space: pre; + pointer-events: none; + font-family: ${p => p.theme.font.family.sans}; + font-weight: ${p => p.theme.font.weight.sans.regular}; + font-size: ${p => p.theme.form.md.fontSize}; `; const StyledPositionWrapper = styled('div')<{visible?: boolean}>` diff --git a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx index bf12b3b9071d..f3029b81d337 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx @@ -22,6 +22,7 @@ import { import {AggregateKey} from 'sentry/components/searchQueryBuilder/tokens/filter/aggregateKey'; import {FilterKey} from 'sentry/components/searchQueryBuilder/tokens/filter/filterKey'; import {FilterOperator} from 'sentry/components/searchQueryBuilder/tokens/filter/filterOperator'; +import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; import {UnstyledButton} from 'sentry/components/searchQueryBuilder/tokens/filter/unstyledButton'; import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/tokens/filter/useFilterButtonProps'; import { @@ -38,7 +39,7 @@ import { type ParseResultToken, type TokenResult, } from 'sentry/components/searchSyntax/parser'; -import {getKeyName} from 'sentry/components/searchSyntax/utils'; +import {getKeyName, isRegexOperator} from 'sentry/components/searchSyntax/utils'; import {isQueryBuilderPanelChrome} from 'sentry/components/tokenizedInput/token/comboBoxLayout'; import {IconClose} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -97,14 +98,20 @@ function fitMiddleEllipsisToElement( return value; } - const previousText = element.textContent; const previousWidth = element.style.width; const fallback = ellipsizeFilterValue(value, fallbackMaxLength, multi); + // Detaching and reattaching the same nodes keeps the references React holds valid. + // Writing `element.textContent` instead would destroy any rendered child elements. + const children = Array.from(element.childNodes); + const measureNode = document.createTextNode(value); + try { + children.forEach(child => child.remove()); + // Expand to the full value first so content-sized ancestors can grow up to their // max-width when the window/search bar is no longer constraining them. - element.textContent = value; + element.append(measureNode); element.style.width = ''; if (element.clientWidth <= 0) { @@ -124,13 +131,13 @@ function fitMiddleEllipsisToElement( let low = 1; let high = value.length; - element.textContent = ELLIPSIS; + measureNode.data = ELLIPSIS; let best = element.scrollWidth <= availableWidth ? ELLIPSIS : ''; while (low <= high) { const mid = Math.floor((low + high) / 2); const candidate = ellipsizeFilterValue(value, mid, multi); - element.textContent = candidate; + measureNode.data = candidate; if (element.scrollWidth <= availableWidth) { best = candidate; low = mid + 1; @@ -141,7 +148,8 @@ function fitMiddleEllipsisToElement( return best; } finally { - element.textContent = previousText; + measureNode.remove(); + element.append(...children); element.style.width = previousWidth; } } @@ -150,10 +158,12 @@ function TruncatedFilterDisplayValue({ value, fallbackMaxLength, multi = false, + renderValue, }: { fallbackMaxLength: number; value: string; multi?: boolean; + renderValue?: (displayValue: string) => React.ReactNode; }) { const ref = useRef(null); const [displayValue, setDisplayValue] = useState(() => @@ -203,7 +213,7 @@ function TruncatedFilterDisplayValue({ return ( - {displayValue} + {renderValue ? renderValue(displayValue) : displayValue} ); } @@ -269,6 +279,11 @@ export function FilterValueText({token}: {token: TokenResult}) { + : undefined + } /> ); } diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx new file mode 100644 index 000000000000..bbbbbf8753fb --- /dev/null +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx @@ -0,0 +1,52 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {loadPrismLanguage} from '@sentry/scraps/code'; + +import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; + +jest.unmock('prismjs'); + +describe('HighlightedRegexPattern', () => { + beforeAll(async () => { + await loadPrismLanguage('regex', {}); + }); + + it('marks up anchors, character sets, and quantifiers when given a pattern', () => { + render(); + + expect(screen.getByText('^')).toHaveClass('anchor'); + expect(screen.getByText('GET /api/')).toBeInTheDocument(); + expect(screen.getByText('\\d')).toHaveClass('char-set'); + expect(screen.getByText('+')).toHaveClass('quantifier'); + expect(screen.getByText('$')).toHaveClass('anchor'); + }); + + it('marks up the brackets and range when given a character class', () => { + render(); + + expect(screen.getByText('[')).toHaveClass('char-class-punctuation'); + expect(screen.getByText('-')).toHaveClass('range-punctuation'); + expect(screen.getByText(']')).toHaveClass('char-class-punctuation'); + expect(screen.getByText('{2,3}')).toHaveClass('quantifier'); + }); + + it('marks up the group and keeps the text when given a half-typed pattern', () => { + render(); + + expect(screen.getByText('(')).toHaveClass('group'); + expect(screen.getByText('foo')).toBeInTheDocument(); + }); + + it('keeps the ellipsis when given a middle-truncated pattern', () => { + render(); + + expect(screen.getByText('abc…xyz')).toBeInTheDocument(); + }); + + it('renders no markup when given a pattern of only literals', () => { + render(); + + expect(screen.getByText('firefox')).toBeInTheDocument(); + expect(screen.queryByText('firefox', {selector: 'span'})).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx new file mode 100644 index 000000000000..bcd88775cac6 --- /dev/null +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; + +import {usePrismTokens} from 'sentry/utils/usePrismTokens'; + +/** + * Prism tokenizes partial patterns without throwing, which keeps highlighting stable + * while a pattern is typed and when the filter shows a middle-ellipsized fragment. + */ +export function HighlightedRegexPattern({pattern}: {pattern: string}) { + const [tokens] = usePrismTokens({code: pattern, language: 'regex'}); + + const isPlainText = tokens?.length === 1 && tokens[0]!.className === 'token'; + + if (!tokens?.length || isPlainText) { + return pattern; + } + + return ( + + {tokens.map((token, index) => ( + + {token.children} + + ))} + + ); +} + +const PatternTokens = styled('span')` + .token.group, + .token.quantifier, + .token.alternation, + .token.anchor, + .token.backreference, + .token.char-class-punctuation, + .token.char-class-negation, + .token.range-punctuation { + color: ${p => p.theme.tokens.syntax.operator}; + } + + .token.char-set, + .token.escape, + .token.special-escape, + .token.group-name { + color: ${p => p.theme.tokens.syntax.selector}; + } +`; diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index c1d44ead6585..ea0c722b4c0e 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -29,6 +29,7 @@ import { type CustomComboboxMenu, type CustomComboboxMenuProps, } from 'sentry/components/searchQueryBuilder/tokens/combobox'; +import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser'; import {SpecificDatePicker} from 'sentry/components/searchQueryBuilder/tokens/filter/specificDatePicker'; import {useFrozenSuggestionSectionItems} from 'sentry/components/searchQueryBuilder/tokens/filter/useFrozenSuggestionSectionItems'; @@ -1424,6 +1425,9 @@ export function SearchQueryBuilderValueCombobox({ inputValue={inputValue} filterValue={filterValue} placeholder={placeholder} + renderInputValue={ + isRegexValue ? value => : undefined + } token={token} inputLabel={t('Edit filter value')} keepVisibleRef={ref} From fd69c981060e572e76b3a38bb75b0de8f577ee07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 28 Sep 2026 08:08:53 -0400 Subject: [PATCH 02/10] ref(search): Give regex literals a base color distinct from metacharacters --- static/app/components/searchQueryBuilder/index.spec.tsx | 4 +++- .../tokens/filter/highlightedRegexPattern.spec.tsx | 4 ++-- .../tokens/filter/highlightedRegexPattern.tsx | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 179123e3336d..2d86a5bfabbf 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -8854,7 +8854,9 @@ describe('SearchQueryBuilder', () => { expect( await screen.findByRole('combobox', {name: 'Edit filter value'}) ).toHaveValue('fire.*fox'); - expect(screen.getByText('fire.*fox')).toHaveAttribute('aria-hidden', 'true'); + expect( + screen.getByText('fire.*fox', {selector: '[aria-hidden="true"] *'}) + ).toBeInTheDocument(); }); }); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx index bbbbbf8753fb..158a8ac0500c 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx @@ -43,10 +43,10 @@ describe('HighlightedRegexPattern', () => { expect(screen.getByText('abc…xyz')).toBeInTheDocument(); }); - it('renders no markup when given a pattern of only literals', () => { + it('renders no token markup when given a pattern of only literals', () => { render(); expect(screen.getByText('firefox')).toBeInTheDocument(); - expect(screen.queryByText('firefox', {selector: 'span'})).not.toBeInTheDocument(); + expect(screen.queryByText('firefox', {selector: '.token'})).not.toBeInTheDocument(); }); }); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx index bcd88775cac6..794ececde912 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -12,7 +12,7 @@ export function HighlightedRegexPattern({pattern}: {pattern: string}) { const isPlainText = tokens?.length === 1 && tokens[0]!.className === 'token'; if (!tokens?.length || isPlainText) { - return pattern; + return {pattern}; } return ( @@ -27,6 +27,8 @@ export function HighlightedRegexPattern({pattern}: {pattern: string}) { } const PatternTokens = styled('span')` + color: ${p => p.theme.tokens.content.primary}; + .token.group, .token.quantifier, .token.alternation, From 09bff06c984be433a90681a530434e08dc2651bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 28 Sep 2026 08:17:57 -0400 Subject: [PATCH 03/10] ref(search): Trim the regex highlighting down Collapses the plain-pattern branch, shares one render function between the two call sites, swaps the measure/restore juggling for replaceChildren, and drops selectors for constructs RE2 rejects. --- .../tokens/filter/filter.tsx | 15 +++------ .../filter/highlightedRegexPattern.spec.tsx | 17 +++++----- .../tokens/filter/highlightedRegexPattern.tsx | 32 ++++++++----------- .../tokens/filter/valueCombobox.tsx | 6 ++-- 4 files changed, 27 insertions(+), 43 deletions(-) diff --git a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx index f3029b81d337..65ffb2b4a71f 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx @@ -22,7 +22,7 @@ import { import {AggregateKey} from 'sentry/components/searchQueryBuilder/tokens/filter/aggregateKey'; import {FilterKey} from 'sentry/components/searchQueryBuilder/tokens/filter/filterKey'; import {FilterOperator} from 'sentry/components/searchQueryBuilder/tokens/filter/filterOperator'; -import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; +import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; import {UnstyledButton} from 'sentry/components/searchQueryBuilder/tokens/filter/unstyledButton'; import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/tokens/filter/useFilterButtonProps'; import { @@ -107,11 +107,9 @@ function fitMiddleEllipsisToElement( const measureNode = document.createTextNode(value); try { - children.forEach(child => child.remove()); - // Expand to the full value first so content-sized ancestors can grow up to their // max-width when the window/search bar is no longer constraining them. - element.append(measureNode); + element.replaceChildren(measureNode); element.style.width = ''; if (element.clientWidth <= 0) { @@ -148,8 +146,7 @@ function fitMiddleEllipsisToElement( return best; } finally { - measureNode.remove(); - element.append(...children); + element.replaceChildren(...children); element.style.width = previousWidth; } } @@ -279,11 +276,7 @@ export function FilterValueText({token}: {token: TokenResult}) { - : undefined - } + renderValue={isRegexOperator(token.operator) ? renderRegexPattern : undefined} /> ); } diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx index 158a8ac0500c..091cd3e92794 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx @@ -2,17 +2,17 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {loadPrismLanguage} from '@sentry/scraps/code'; -import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; +import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; jest.unmock('prismjs'); -describe('HighlightedRegexPattern', () => { +describe('renderRegexPattern', () => { beforeAll(async () => { await loadPrismLanguage('regex', {}); }); it('marks up anchors, character sets, and quantifiers when given a pattern', () => { - render(); + render(renderRegexPattern('^GET /api/\\d+$')); expect(screen.getByText('^')).toHaveClass('anchor'); expect(screen.getByText('GET /api/')).toBeInTheDocument(); @@ -22,7 +22,7 @@ describe('HighlightedRegexPattern', () => { }); it('marks up the brackets and range when given a character class', () => { - render(); + render(renderRegexPattern('[a-z]{2,3}')); expect(screen.getByText('[')).toHaveClass('char-class-punctuation'); expect(screen.getByText('-')).toHaveClass('range-punctuation'); @@ -31,22 +31,21 @@ describe('HighlightedRegexPattern', () => { }); it('marks up the group and keeps the text when given a half-typed pattern', () => { - render(); + render(renderRegexPattern('(foo')); expect(screen.getByText('(')).toHaveClass('group'); expect(screen.getByText('foo')).toBeInTheDocument(); }); it('keeps the ellipsis when given a middle-truncated pattern', () => { - render(); + render(renderRegexPattern('^abc…xyz$')); expect(screen.getByText('abc…xyz')).toBeInTheDocument(); }); - it('renders no token markup when given a pattern of only literals', () => { - render(); + it('renders the text when given a pattern of only literals', () => { + render(renderRegexPattern('firefox')); expect(screen.getByText('firefox')).toBeInTheDocument(); - expect(screen.queryByText('firefox', {selector: '.token'})).not.toBeInTheDocument(); }); }); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx index 794ececde912..12b13c074b6d 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -2,26 +2,22 @@ import styled from '@emotion/styled'; import {usePrismTokens} from 'sentry/utils/usePrismTokens'; -/** - * Prism tokenizes partial patterns without throwing, which keeps highlighting stable - * while a pattern is typed and when the filter shows a middle-ellipsized fragment. - */ -export function HighlightedRegexPattern({pattern}: {pattern: string}) { - const [tokens] = usePrismTokens({code: pattern, language: 'regex'}); - - const isPlainText = tokens?.length === 1 && tokens[0]!.className === 'token'; +export const renderRegexPattern = (pattern: string) => ( + +); - if (!tokens?.length || isPlainText) { - return {pattern}; - } +function HighlightedRegexPattern({pattern}: {pattern: string}) { + const [tokens] = usePrismTokens({code: pattern, language: 'regex'}); return ( - {tokens.map((token, index) => ( - - {token.children} - - ))} + {tokens?.length + ? tokens.map((token, index) => ( + + {token.children} + + )) + : pattern} ); } @@ -33,7 +29,6 @@ const PatternTokens = styled('span')` .token.quantifier, .token.alternation, .token.anchor, - .token.backreference, .token.char-class-punctuation, .token.char-class-negation, .token.range-punctuation { @@ -42,8 +37,7 @@ const PatternTokens = styled('span')` .token.char-set, .token.escape, - .token.special-escape, - .token.group-name { + .token.special-escape { color: ${p => p.theme.tokens.syntax.selector}; } `; diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index ea0c722b4c0e..5dfb179910f6 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -29,7 +29,7 @@ import { type CustomComboboxMenu, type CustomComboboxMenuProps, } from 'sentry/components/searchQueryBuilder/tokens/combobox'; -import {HighlightedRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; +import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern'; import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser'; import {SpecificDatePicker} from 'sentry/components/searchQueryBuilder/tokens/filter/specificDatePicker'; import {useFrozenSuggestionSectionItems} from 'sentry/components/searchQueryBuilder/tokens/filter/useFrozenSuggestionSectionItems'; @@ -1425,9 +1425,7 @@ export function SearchQueryBuilderValueCombobox({ inputValue={inputValue} filterValue={filterValue} placeholder={placeholder} - renderInputValue={ - isRegexValue ? value => : undefined - } + renderInputValue={isRegexValue ? renderRegexPattern : undefined} token={token} inputLabel={t('Edit filter value')} keepVisibleRef={ref} From 36fe41b220971965419687de1d1bd3b266a09805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 28 Sep 2026 08:32:58 -0400 Subject: [PATCH 04/10] fix(search): Keep the regex overlay in step with the input's scroll Flex shrinks the input below its autosized width, so a long pattern scrolls under the caret while the overlay kept showing the start of it. --- .../searchQueryBuilder/tokens/combobox.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx index 73656db2b3e8..e43f8bd7f656 100644 --- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx @@ -436,6 +436,7 @@ export function SearchQueryBuilderCombobox< const listBoxRef = useRef(null); const inputRef = useRef(null); const popoverRef = useRef(null); + const overlayRef = useRef(null); const descriptionRef = useRef(null); const askSeerButtonRef = useRef(null); const preventOverflowOptions = useMemo(() => ({boundary: document.body}), []); @@ -675,6 +676,21 @@ export function SearchQueryBuilderCombobox< const highlightedValue = inputValue ? renderInputValue?.(inputValue) : null; + // Flex can shrink the input below the width useAutosizeInput gives it, and the input + // then scrolls to keep the caret visible. The overlay has to follow or it shows a + // different slice of the value than the one being edited. + const syncOverlayScroll = useCallback(() => { + if (overlayRef.current && inputRef.current) { + overlayRef.current.scrollLeft = inputRef.current.scrollLeft; + } + }, []); + + useLayoutEffect(() => { + if (inputValue) { + syncOverlayScroll(); + } + }, [inputValue, syncOverlayScroll]); + return ( {highlightedValue ? ( - {highlightedValue} + + {highlightedValue} + ) : null} {description ? ( p.theme.font.family.sans}; font-weight: ${p => p.theme.font.weight.sans.regular}; From b2d73f442920667cbc5eb619a3ee9fa089535cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 28 Sep 2026 08:46:14 -0400 Subject: [PATCH 05/10] fix(search): Darken the regex character-set color for contrast tokens.syntax.selector sits at 3.88:1 on the tinted background an invalid filter gets, below the 4.5 AA bar for small text. colors.green600 is 4.82:1 there and 5.70:1 on the default background. --- .../tokens/filter/highlightedRegexPattern.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx index 12b13c074b6d..539482ceedc9 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -38,6 +38,6 @@ const PatternTokens = styled('span')` .token.char-set, .token.escape, .token.special-escape { - color: ${p => p.theme.tokens.syntax.selector}; + color: ${p => p.theme.colors.green600}; } `; From 4c7e7c12ca5f37b06f2bf0e057c7bd5ee223e7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 16:15:04 -0400 Subject: [PATCH 06/10] Webkit fixes --- .../searchQueryBuilder/tokens/combobox.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx index e43f8bd7f656..e1b7ac911e1b 100644 --- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx @@ -436,7 +436,7 @@ export function SearchQueryBuilderCombobox< const listBoxRef = useRef(null); const inputRef = useRef(null); const popoverRef = useRef(null); - const overlayRef = useRef(null); + const overlayContentRef = useRef(null); const descriptionRef = useRef(null); const askSeerButtonRef = useRef(null); const preventOverflowOptions = useMemo(() => ({boundary: document.body}), []); @@ -678,10 +678,12 @@ export function SearchQueryBuilderCombobox< // Flex can shrink the input below the width useAutosizeInput gives it, and the input // then scrolls to keep the caret visible. The overlay has to follow or it shows a - // different slice of the value than the one being edited. + // different slice of the value than the one being edited. It's translated rather than + // scrolled because WebKit lets the input scroll ~2px past the text for the caret, and + // the overlay's own scrollLeft would clamp short of that. const syncOverlayScroll = useCallback(() => { - if (overlayRef.current && inputRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft; + if (overlayContentRef.current && inputRef.current) { + overlayContentRef.current.style.transform = `translateX(${-inputRef.current.scrollLeft}px)`; } }, []); @@ -743,8 +745,8 @@ export function SearchQueryBuilderCombobox< data-test-id={dataTestId} /> {highlightedValue ? ( - - {highlightedValue} + + {highlightedValue} ) : null} {description ? ( @@ -782,6 +784,13 @@ export function SearchQueryBuilderCombobox< ); } +// WebKit doesn't kern across element boundaries, so the overlay's per-token spans would +// otherwise render wider than the input's single run of text. +const overlayTextMetrics = css` + font-kerning: none; + font-variant-ligatures: none; +`; + const UnstyledInput = styled(Input)<{hideValue?: boolean}>` background: transparent; border: none; @@ -805,10 +814,12 @@ const UnstyledInput = styled(Input)<{hideValue?: boolean}>` css` color: transparent; caret-color: ${p.theme.tokens.content.primary}; + ${overlayTextMetrics}; `} `; const InputValueOverlay = styled('div')` + ${overlayTextMetrics}; position: absolute; inset: 0; display: flex; From 782a59d45f0ef9e617fb10120e53003567782315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:14:28 -0400 Subject: [PATCH 07/10] fix(search): Replace regex overlay scroll sync with a shared grid cell The highlighted value and the input now share one grid cell at least as wide as the value's text, so the input never scrolls its own text out from under the highlighting. The chip row scrolls long regex values instead, and keeps the tail of an input wider than the row in view. --- .../searchQueryBuilder/tokens/combobox.tsx | 174 ++++++++---------- .../tokens/filter/valueCombobox.tsx | 10 +- 2 files changed, 87 insertions(+), 97 deletions(-) diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx index e1b7ac911e1b..5fb0b65c751e 100644 --- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx @@ -11,7 +11,6 @@ import { } from 'react'; import {createPortal} from 'react-dom'; import {usePopper} from 'react-popper'; -import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {type AriaComboBoxProps} from '@react-aria/combobox'; import {type AriaListBoxOptions} from '@react-aria/listbox'; @@ -436,7 +435,6 @@ export function SearchQueryBuilderCombobox< const listBoxRef = useRef(null); const inputRef = useRef(null); const popoverRef = useRef(null); - const overlayContentRef = useRef(null); const descriptionRef = useRef(null); const askSeerButtonRef = useRef(null); const preventOverflowOptions = useMemo(() => ({boundary: document.body}), []); @@ -674,81 +672,67 @@ export function SearchQueryBuilderCombobox< const autosizeInput = useAutosizeInput({value: inputValue}); - const highlightedValue = inputValue ? renderInputValue?.(inputValue) : null; + const InputComponent = renderInputValue ? HighlightedInput : UnstyledInput; + const input = ( + + )} + type="text" + placeholder={placeholder} + onClick={handleInputClick} + value={inputValue} + onChange={handleInputChange} + tabIndex={tabIndex} + onPaste={onPaste} + disabled={disabled} + onKeyDownCapture={e => { + if (isCtrlKeyPressed(e) && (e.key === 'Backspace' || e.key === 'Delete')) { + if (token.type === Token.FREE_TEXT) { + e.preventDefault(); + e.stopPropagation(); + state.close(); + dispatch({ + type: 'DELETE_TO_CURSOR', + token, + cursorPosition: e.currentTarget.selectionStart ?? 0, + inputValue, + direction: e.key === 'Backspace' ? 'before' : 'after', + }); + return; + } - // Flex can shrink the input below the width useAutosizeInput gives it, and the input - // then scrolls to keep the caret visible. The overlay has to follow or it shows a - // different slice of the value than the one being edited. It's translated rather than - // scrolled because WebKit lets the input scroll ~2px past the text for the caret, and - // the overlay's own scrollLeft would clamp short of that. - const syncOverlayScroll = useCallback(() => { - if (overlayContentRef.current && inputRef.current) { - overlayContentRef.current.style.transform = `translateX(${-inputRef.current.scrollLeft}px)`; - } - }, []); + if (!inputValue) { + e.preventDefault(); + e.stopPropagation(); + onSearchQueryClear?.(); + state.close(); + clearSearchQuery({reopenDropdown: true}); + return; + } + } - useLayoutEffect(() => { - if (inputValue) { - syncOverlayScroll(); - } - }, [inputValue, syncOverlayScroll]); + onKeyDownCapture?.(e, {state}); + }} + data-test-id={dataTestId} + /> + ); return ( - - )} - type="text" - placeholder={placeholder} - onScroll={syncOverlayScroll} - onClick={handleInputClick} - value={inputValue} - onChange={handleInputChange} - tabIndex={tabIndex} - onPaste={onPaste} - disabled={disabled} - onKeyDownCapture={e => { - if (isCtrlKeyPressed(e) && (e.key === 'Backspace' || e.key === 'Delete')) { - if (token.type === Token.FREE_TEXT) { - e.preventDefault(); - e.stopPropagation(); - state.close(); - dispatch({ - type: 'DELETE_TO_CURSOR', - token, - cursorPosition: e.currentTarget.selectionStart ?? 0, - inputValue, - direction: e.key === 'Backspace' ? 'before' : 'after', - }); - return; - } - - if (!inputValue) { - e.preventDefault(); - e.stopPropagation(); - onSearchQueryClear?.(); - state.close(); - clearSearchQuery({reopenDropdown: true}); - return; - } - } - - onKeyDownCapture?.(e, {state}); - }} - data-test-id={dataTestId} - /> - {highlightedValue ? ( - - {highlightedValue} - - ) : null} + {renderInputValue ? ( + + {input} + {renderInputValue(inputValue)} + + ) : ( + input + )} {description ? ( ` +const UnstyledInput = styled(Input)` background: transparent; border: none; box-shadow: none; @@ -808,28 +785,37 @@ const UnstyledInput = styled(Input)<{hideValue?: boolean}>` border: none; box-shadow: none; } +`; - ${p => - p.hideValue && - css` - color: transparent; - caret-color: ${p.theme.tokens.content.primary}; - ${overlayTextMetrics}; - `} +// The input and its highlighted value share one grid cell at least as wide as the value's +// text, so the input never scrolls its own text out from under the highlighting. +const HighlightStack = styled('div')` + display: grid; + grid-template-columns: minmax(max-content, 1fr); + flex-grow: 1; `; -const InputValueOverlay = styled('div')` - ${overlayTextMetrics}; - position: absolute; - inset: 0; - display: flex; - align-items: center; +// WebKit doesn't kern across the highlighted value's token spans, so neither side kerns. +const HighlightedInput = styled(UnstyledInput)` + grid-area: 1 / 1; + min-width: 100%; + color: transparent; + caret-color: ${p => p.theme.tokens.content.primary}; + font-kerning: none; + font-variant-ligatures: none; +`; + +const HighlightedValue = styled('div')` + grid-area: 1 / 1; + align-self: center; + padding-right: ${p => p.theme.space['2xs']}; white-space: pre; - overflow: hidden; pointer-events: none; font-family: ${p => p.theme.font.family.sans}; font-weight: ${p => p.theme.font.weight.sans.regular}; font-size: ${p => p.theme.form.md.fontSize}; + font-kerning: none; + font-variant-ligatures: none; `; const StyledPositionWrapper = styled('div')<{visible?: boolean}>` diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index 5dfb179910f6..f3e5debc8ed7 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -872,7 +872,10 @@ export function SearchQueryBuilderValueCombobox({ const inputRect = input.getBoundingClientRect(); if (inputRect.right > containerRect.right) { container.scrollLeft += inputRect.right - containerRect.right; - } else if (inputRect.left < containerRect.left) { + } else if ( + inputRect.left < containerRect.left && + inputRect.width <= containerRect.width + ) { container.scrollLeft -= containerRect.left - inputRect.left; } }, []); @@ -1466,6 +1469,7 @@ export function SearchQueryBuilderValueCombobox({ ? committedValues.filter(v => v.index < editingChip.index).length : chips.length; const chipRow = [...chips.slice(0, inputSlot), valueInput, ...chips.slice(inputSlot)]; + const rowScrolls = canSelectMultipleValues || isRegexValue; return ( @@ -1475,8 +1479,8 @@ export function SearchQueryBuilderValueCombobox({ gap="2xs" minWidth="0" height="100%" - overflowX={canSelectMultipleValues ? 'auto' : undefined} - overflowY={canSelectMultipleValues ? 'hidden' : undefined} + overflowX={rowScrolls ? 'auto' : undefined} + overflowY={rowScrolls ? 'hidden' : undefined} ref={ref} data-test-id="filter-value-editing" > From e5c2a47d651fc358e55f005c6b62a176ae8b8d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:32:56 -0400 Subject: [PATCH 08/10] fix(search): Keep a mid-pattern caret in place while typing a long regex When the regex input is wider than the chip row, only pin the row to the input's tail while the caret is at the end. Otherwise each keystroke yanked the row toward the tail, leaving the caret stuck at its left edge. --- .../searchQueryBuilder/tokens/filter/valueCombobox.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index f3e5debc8ed7..18253b0cb2ab 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -870,12 +870,14 @@ export function SearchQueryBuilderValueCombobox({ // a long neighbouring chip can't keep it off-screen. const containerRect = container.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); + // An input wider than the row can't be brought fully into view. + const isWiderThanRow = inputRect.width > containerRect.width; + if (isWiderThanRow && input.selectionStart !== input.value.length) { + return; + } if (inputRect.right > containerRect.right) { container.scrollLeft += inputRect.right - containerRect.right; - } else if ( - inputRect.left < containerRect.left && - inputRect.width <= containerRect.width - ) { + } else if (inputRect.left < containerRect.left && !isWiderThanRow) { container.scrollLeft -= containerRect.left - inputRect.left; } }, []); From f640db0e9d694749374ee44d3f7502155ceb53ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 18:34:58 -0400 Subject: [PATCH 09/10] fix(search): Color regex escapes with the content.success token --- .../tokens/filter/highlightedRegexPattern.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx index 539482ceedc9..d6f173ba4966 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -38,6 +38,6 @@ const PatternTokens = styled('span')` .token.char-set, .token.escape, .token.special-escape { - color: ${p => p.theme.colors.green600}; + color: ${p => p.theme.tokens.content.success}; } `; From ffa680bf8833ee3086fdb8d936792b887bc4a371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 20:25:36 -0400 Subject: [PATCH 10/10] fix(search): Soften the dark-mode green for regex escapes --- .../tokens/filter/highlightedRegexPattern.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx index d6f173ba4966..993c65b5356a 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx @@ -38,6 +38,7 @@ const PatternTokens = styled('span')` .token.char-set, .token.escape, .token.special-escape { - color: ${p => p.theme.tokens.content.success}; + color: ${p => + p.theme.type === 'dark' ? p.theme.colors.green600 : p.theme.tokens.content.success}; } `;