diff --git a/src/renderer/src/components/TerminalSearch.test.tsx b/src/renderer/src/components/TerminalSearch.test.tsx index f004bed4f9a..349898c11df 100644 --- a/src/renderer/src/components/TerminalSearch.test.tsx +++ b/src/renderer/src/components/TerminalSearch.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { act, 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' @@ -11,16 +11,33 @@ vi.mock('@/i18n/i18n', () => ({ afterEach(cleanup) -function createSearchAddon(): SearchAddon { - return { +type ResultsEvent = { resultIndex: number; resultCount: number } + +function createSearchAddon() { + let listener: ((payload: ResultsEvent) => void) | null = null + const dispose = vi.fn(() => { + listener = null + }) + const stub = { findNext: vi.fn(() => true), findPrevious: vi.fn(() => true), - clearDecorations: vi.fn() - } as unknown as SearchAddon + clearDecorations: vi.fn(), + onDidChangeResults: (handler: (payload: ResultsEvent) => void) => { + listener = handler + return { dispose } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The stub implements every addon member used by TerminalSearch. + const addon = stub as unknown as SearchAddon + return { + addon, + dispose, + emit: (payload: ResultsEvent) => act(() => listener?.(payload)) + } } -function renderSearch(searchAddon: SearchAddon): ReturnType { - return render( +function renderSearch(searchAddon: SearchAddon, query = ''): ReturnType { + const view = render( { searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }} /> ) + if (query) { + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: query } }) + } + return view } -describe('TerminalSearch cleanup', () => { - it('clears the current addon when the query is erased', async () => { - const addon = createSearchAddon() - const view = renderSearch(addon) +describe('TerminalSearch match-count indicator', () => { + it('renders 0/0 for an empty query', () => { + const { addon } = createSearchAddon() + expect(renderSearch(addon).getByText('0/0')).toBeTruthy() + }) - fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } }) + it('renders current/total after a results event and updates on navigation', () => { + const stub = createSearchAddon() + const view = renderSearch(stub.addon, 'foo') + stub.emit({ resultIndex: 2, resultCount: 12 }) + expect(view.getByText('3/12')).toBeTruthy() + stub.emit({ resultIndex: 3, resultCount: 12 }) + expect(view.getByText('4/12')).toBeTruthy() + }) + + it('renders No results for a non-empty query with zero matches', () => { + const stub = createSearchAddon() + const view = renderSearch(stub.addon, 'foo') + stub.emit({ resultIndex: -1, resultCount: 0 }) + expect(view.getByText('No results')).toBeTruthy() + }) + + it('renders count+ when the highlight threshold is exceeded', () => { + const stub = createSearchAddon() + const view = renderSearch(stub.addon, 'foo') + stub.emit({ resultIndex: -1, resultCount: 1000 }) + expect(view.getByText('1000+')).toBeTruthy() + }) + + it('disposes the results subscription on unmount', () => { + const stub = createSearchAddon() + renderSearch(stub.addon, 'foo').unmount() + expect(stub.dispose).toHaveBeenCalledTimes(1) + }) +}) + +describe('TerminalSearch cleanup', () => { + it('clears the current addon and count when the query is erased', async () => { + const stub = createSearchAddon() + const { addon } = stub + const view = renderSearch(addon, 'needle') + stub.emit({ resultIndex: 1, resultCount: 3 }) await waitFor(() => expect(addon.findNext).toHaveBeenCalled()) vi.mocked(addon.clearDecorations).mockClear() vi.mocked(addon.findNext).mockClear() @@ -44,36 +101,38 @@ describe('TerminalSearch cleanup', () => { await waitFor(() => expect(addon.clearDecorations).toHaveBeenCalledTimes(1)) expect(addon.findNext).toHaveBeenCalledWith('') + expect(view.getByText('0/0')).toBeTruthy() }) - 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() + it('clears and unsubscribes the previous addon when search moves to another pane', async () => { + const previous = createSearchAddon() + const next = createSearchAddon() + const view = renderSearch(previous.addon, 'needle') + previous.emit({ resultIndex: 1, resultCount: 3 }) + await waitFor(() => expect(previous.addon.findNext).toHaveBeenCalled()) + vi.mocked(previous.addon.clearDecorations).mockClear() + vi.mocked(previous.addon.findNext).mockClear() view.rerender( ) - expect(previousAddon.clearDecorations).toHaveBeenCalledTimes(1) - expect(previousAddon.findNext).toHaveBeenCalledWith('') + expect(previous.addon.clearDecorations).toHaveBeenCalledTimes(1) + expect(previous.addon.findNext).toHaveBeenCalledWith('') + expect(previous.dispose).toHaveBeenCalledTimes(1) + next.emit({ resultIndex: 0, resultCount: 7 }) + previous.emit({ resultIndex: 2, resultCount: 3 }) + expect(view.getByText('1/7')).toBeTruthy() }) 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' } }) + const { addon } = createSearchAddon() + const view = renderSearch(addon, 'needle') await waitFor(() => expect(addon.findNext).toHaveBeenCalled()) vi.mocked(addon.clearDecorations).mockClear() vi.mocked(addon.findNext).mockClear() @@ -83,4 +142,32 @@ describe('TerminalSearch cleanup', () => { expect(addon.clearDecorations).toHaveBeenCalledTimes(1) expect(addon.findNext).toHaveBeenCalledWith('') }) + + it('exposes the search input for refocusing and clears its ref on close', () => { + const { addon } = createSearchAddon() + const inputRef: { current: HTMLInputElement | null } = { current: null } + const searchStateRef = { current: { query: '', caseSensitive: false, regex: false } } + const view = render( + + ) + expect(inputRef.current).toBe(view.getByPlaceholderText('Search...')) + fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } }) + view.rerender( + + ) + expect(inputRef.current).toBeNull() + expect(addon.findNext).toHaveBeenLastCalledWith('') + }) }) diff --git a/src/renderer/src/components/TerminalSearch.tsx b/src/renderer/src/components/TerminalSearch.tsx index ed8bf1b906a..79051f5d84b 100644 --- a/src/renderer/src/components/TerminalSearch.tsx +++ b/src/renderer/src/components/TerminalSearch.tsx @@ -12,8 +12,12 @@ type TerminalSearchProps = { onClose: () => void searchAddon: SearchAddon | null searchStateRef: React.RefObject + inputRef?: React.RefObject } +// xterm uses index -1 when results exceed its highlight limit. +const EMPTY_RESULTS = { resultIndex: -1, resultCount: 0 } + function clearTerminalSearch(searchAddon: SearchAddon | null): void { if (!searchAddon) { return @@ -27,19 +31,16 @@ export default function TerminalSearch({ isOpen, onClose, searchAddon, - searchStateRef + searchStateRef, + inputRef }: TerminalSearchProps): React.JSX.Element | null { const [query, setQuery] = useState('') const [caseSensitive, setCaseSensitive] = useState(false) const [regex, setRegex] = useState(false) + const [results, setResults] = useState(EMPTY_RESULTS) const requestQuery = getFindRequestQuery(query) - // Why: the default xterm SearchAddon highlights blend into common - // terminal backgrounds (see orca#612). Providing explicit decoration - // colors gives all matches a visible yellow background and the - // current match a brighter orange, matching the contrast VS Code and - // iTerm2 use for terminal search. xterm requires #RRGGBB format for - // the background colors. + // xterm needs hex colors; explicit highlights stay visible over terminal themes (#612). const searchOptions = useCallback( (incremental: boolean = false) => ({ caseSensitive, @@ -77,27 +78,34 @@ export default function TerminalSearch({ } }, [searchAddon, requestQuery, searchOptions]) - const handleInputRef = useCallback((input: HTMLInputElement | null): void => { - input?.focus() - }, []) - - useEffect( - () => () => { - clearTerminalSearch(searchAddon) + const handleInputRef = useCallback( + (input: HTMLInputElement | null): void => { + if (inputRef) { + inputRef.current = input + } + input?.focus() + input?.select() }, - [searchAddon] + [inputRef] ) + // One addon subscription tracks both panel and keyboard navigation. 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) { - clearTerminalSearch(searchAddon) + if (!searchAddon) { return } - if (!requestQuery) { + const disposable = searchAddon.onDidChangeResults(setResults) + return () => { + disposable.dispose() + clearTerminalSearch(searchAddon) + } + }, [searchAddon]) + + useEffect(() => { + // Global match-navigation shortcuts read the same query as the panel. + searchStateRef.current = { query: requestQuery ?? '', caseSensitive, regex } + + if (!isOpen || !requestQuery) { clearTerminalSearch(searchAddon) return } @@ -129,11 +137,19 @@ export default function TerminalSearch({ return null } + const matchStatus = !requestQuery + ? '0/0' + : results.resultCount === 0 + ? translate('auto.components.TerminalSearch.10e039b591', 'No results') + : results.resultIndex === -1 + ? `${results.resultCount}+` + : `${results.resultIndex + 1}/${results.resultCount}` + return (
setQuery(e.target.value)} placeholder={translate('auto.components.TerminalSearch.e07012f26e', 'Search...')} - className="min-w-0 flex-1 border-none bg-transparent text-sm text-white outline-none placeholder:text-zinc-500" + className="min-w-0 flex-1 border-none bg-transparent text-sm text-popover-foreground outline-none placeholder:text-muted-foreground" /> -
+ + {matchStatus} + + +
-
+