From b5ae776c31e6970e5b028c504c9fda60f5aedfaa Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:26:21 -0700 Subject: [PATCH] fix(terminal): clear stale search highlights (#9519) Co-authored-by: kaynan --- .../src/components/TerminalSearch.test.tsx | 86 +++++++++++++++++++ .../src/components/TerminalSearch.tsx | 20 ++++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/TerminalSearch.test.tsx diff --git a/src/renderer/src/components/TerminalSearch.test.tsx b/src/renderer/src/components/TerminalSearch.test.tsx new file mode 100644 index 00000000000..f004bed4f9a --- /dev/null +++ b/src/renderer/src/components/TerminalSearch.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import type { SearchAddon } from '@xterm/addon-search' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TerminalSearch from './TerminalSearch' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +afterEach(cleanup) + +function createSearchAddon(): SearchAddon { + return { + findNext: vi.fn(() => true), + findPrevious: vi.fn(() => true), + clearDecorations: vi.fn() + } as unknown as SearchAddon +} + +function renderSearch(searchAddon: SearchAddon): ReturnType { + return render( + + ) +} + +describe('TerminalSearch cleanup', () => { + it('clears the current addon when the query is erased', async () => { + const addon = createSearchAddon() + const view = renderSearch(addon) + + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } }) + await waitFor(() => expect(addon.findNext).toHaveBeenCalled()) + vi.mocked(addon.clearDecorations).mockClear() + vi.mocked(addon.findNext).mockClear() + + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: '' } }) + + await waitFor(() => expect(addon.clearDecorations).toHaveBeenCalledTimes(1)) + expect(addon.findNext).toHaveBeenCalledWith('') + }) + + it('clears the previous addon when the search moves to another pane', async () => { + const previousAddon = createSearchAddon() + const nextAddon = createSearchAddon() + const view = renderSearch(previousAddon) + + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } }) + await waitFor(() => expect(previousAddon.findNext).toHaveBeenCalled()) + vi.mocked(previousAddon.clearDecorations).mockClear() + vi.mocked(previousAddon.findNext).mockClear() + + view.rerender( + + ) + + expect(previousAddon.clearDecorations).toHaveBeenCalledTimes(1) + expect(previousAddon.findNext).toHaveBeenCalledWith('') + }) + + it('clears the addon when the search portal unmounts', async () => { + const addon = createSearchAddon() + const view = renderSearch(addon) + + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } }) + await waitFor(() => expect(addon.findNext).toHaveBeenCalled()) + vi.mocked(addon.clearDecorations).mockClear() + vi.mocked(addon.findNext).mockClear() + + view.unmount() + + expect(addon.clearDecorations).toHaveBeenCalledTimes(1) + expect(addon.findNext).toHaveBeenCalledWith('') + }) +}) diff --git a/src/renderer/src/components/TerminalSearch.tsx b/src/renderer/src/components/TerminalSearch.tsx index f2f9ecf191b..ed8bf1b906a 100644 --- a/src/renderer/src/components/TerminalSearch.tsx +++ b/src/renderer/src/components/TerminalSearch.tsx @@ -14,6 +14,15 @@ type TerminalSearchProps = { searchStateRef: React.RefObject } +function clearTerminalSearch(searchAddon: SearchAddon | null): void { + if (!searchAddon) { + return + } + searchAddon.clearDecorations() + // Why: xterm keeps the active match selected after decorations are cleared. + searchAddon.findNext('') +} + export default function TerminalSearch({ isOpen, onClose, @@ -72,17 +81,24 @@ export default function TerminalSearch({ input?.focus() }, []) + useEffect( + () => () => { + clearTerminalSearch(searchAddon) + }, + [searchAddon] + ) + useEffect(() => { // Keep the ref in sync so the keyboard handler (Cmd+G / Cmd+Shift+G) // can read the current search state without lifting it to parent state. searchStateRef.current = { query: requestQuery ?? '', caseSensitive, regex } if (!isOpen) { - searchAddon?.clearDecorations() + clearTerminalSearch(searchAddon) return } if (!requestQuery) { - searchAddon?.clearDecorations() + clearTerminalSearch(searchAddon) return } if (searchAddon) {