fix(terminal): clear stale search highlights (#9519)

Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>
This commit is contained in:
Kaynan Sampaio de Camargo
2026-07-24 00:26:21 -07:00
committed by GitHub
co-authored by kaynan
parent a90ec540f2
commit b5ae776c31
2 changed files with 104 additions and 2 deletions
@@ -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<typeof render> {
return render(
<TerminalSearch
isOpen
onClose={vi.fn()}
searchAddon={searchAddon}
searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }}
/>
)
}
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(
<TerminalSearch
isOpen
onClose={vi.fn()}
searchAddon={nextAddon}
searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }}
/>
)
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('')
})
})
+18 -2
View File
@@ -14,6 +14,15 @@ type TerminalSearchProps = {
searchStateRef: React.RefObject<SearchState>
}
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) {