feat(terminal): search match count + Cmd+F focus parity (#9035)

* feat(terminal): show search match count and keep Cmd+F from closing search

Bring the terminal search bar to parity with the editor find bars:

- Show a live match indicator (0/0, current/total, "No results", or
  <count>+ past the highlight limit) driven by the xterm SearchAddon
  onDidChangeResults event.
- A repeat Cmd+F while the search is open now re-focuses and selects the
  query instead of toggling the panel closed; Esc remains the close path.

Adds unit coverage for the indicator states and the toggle decision.

* Use auto-generated localization key for TerminalSearch no-results (#9035)

Replace the hand-written "noResults" i18n key with the SHA1-based auto key
(auto.components.TerminalSearch.10e039b591) to match the repo's auto-keying
convention, and sync the key across all locale catalogs.

Addresses CodeRabbit review feedback.

* test(terminal): cover search dispatch after keyboard module split

* fix(terminal): refocus search from its input and verify real matches

* test(terminal): use portable echo commands for search proof

* refactor(terminal): keep search subscription and cleanup together

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Shahar Mor
2026-09-17 03:00:17 -07:00
committed by GitHub
co-authored by Neil
parent 96eb97aad6
commit de15227a1d
21 changed files with 357 additions and 72 deletions
@@ -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<typeof render> {
return render(
function renderSearch(searchAddon: SearchAddon, query = ''): ReturnType<typeof render> {
const view = render(
<TerminalSearch
isOpen
onClose={vi.fn()}
@@ -28,14 +45,54 @@ function renderSearch(searchAddon: SearchAddon): ReturnType<typeof 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(
<TerminalSearch
isOpen
onClose={vi.fn()}
searchAddon={nextAddon}
searchAddon={next.addon}
searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }}
/>
)
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(
<TerminalSearch
isOpen
onClose={vi.fn()}
searchAddon={addon}
searchStateRef={searchStateRef}
inputRef={inputRef}
/>
)
expect(inputRef.current).toBe(view.getByPlaceholderText('Search...'))
fireEvent.change(view.getByPlaceholderText('Search...'), { target: { value: 'needle' } })
view.rerender(
<TerminalSearch
isOpen={false}
onClose={vi.fn()}
searchAddon={addon}
searchStateRef={searchStateRef}
inputRef={inputRef}
/>
)
expect(inputRef.current).toBeNull()
expect(addon.findNext).toHaveBeenLastCalledWith('')
})
})
+56 -38
View File
@@ -12,8 +12,12 @@ type TerminalSearchProps = {
onClose: () => void
searchAddon: SearchAddon | null
searchStateRef: React.RefObject<SearchState>
inputRef?: React.RefObject<HTMLInputElement | null>
}
// 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 (
<div
data-terminal-search-root
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/95 px-2 py-1 shadow-lg backdrop-blur-sm"
style={{ width: 300 }}
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm"
style={{ width: 340, maxWidth: 'calc(100% - 16px)' }}
onKeyDown={handleKeyDown}
>
<input
@@ -142,17 +158,16 @@ export default function TerminalSearch({
value={query}
onChange={(e) => 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"
/>
<Button
type="button"
variant="ghost"
variant={caseSensitive ? 'secondary' : 'ghost'}
size="icon-xs"
aria-pressed={caseSensitive}
onClick={() => setCaseSensitive((v) => !v)}
className={`flex size-6 shrink-0 items-center justify-center rounded ${
caseSensitive ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
}`}
className="shrink-0"
title={translate('auto.components.TerminalSearch.90c61387d9', 'Case sensitive')}
>
<CaseSensitive size={14} />
@@ -160,25 +175,28 @@ export default function TerminalSearch({
<Button
type="button"
variant="ghost"
variant={regex ? 'secondary' : 'ghost'}
size="icon-xs"
aria-pressed={regex}
onClick={() => setRegex((v) => !v)}
className={`flex size-6 shrink-0 items-center justify-center rounded ${
regex ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
}`}
className="shrink-0"
title={translate('auto.components.TerminalSearch.42e466b9f1', 'Regex')}
>
<Regex size={14} />
</Button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<span className="shrink-0 whitespace-nowrap px-1 text-xs tabular-nums text-muted-foreground">
{matchStatus}
</span>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={findPrevious}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
className="shrink-0"
title={translate('auto.components.TerminalSearch.0f3066256e', 'Previous match')}
>
<ChevronUp size={14} />
@@ -189,20 +207,20 @@ export default function TerminalSearch({
variant="ghost"
size="icon-xs"
onClick={findNext}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
className="shrink-0"
title={translate('auto.components.TerminalSearch.7cb40c04eb', 'Next match')}
>
<ChevronDown size={14} />
</Button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<div className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
className="shrink-0"
title={translate('auto.components.TerminalSearch.db234b7519', 'Close')}
>
<X size={14} />
@@ -89,6 +89,7 @@ export function TerminalPaneSurface({
saveQuickCommand,
searchOpen,
searchStateRef,
searchInputRef,
sessionRestoredBannerPaneIds,
sessionStateSaveFailureOpen,
setAgentSessionContinuation,
@@ -211,6 +212,7 @@ export function TerminalPaneSurface({
onClose={() => setSearchOpen(false)}
searchAddon={activePane.searchAddon ?? null}
searchStateRef={searchStateRef}
inputRef={searchInputRef}
/>,
activePane.container
)}
@@ -87,6 +87,7 @@ describe('terminal keyboard effect registration stability', () => {
fallbackCwd: '',
expandedPaneIdRef: { current: null },
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
@@ -120,6 +120,7 @@ function createHarness(): {
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
@@ -101,6 +101,7 @@ function createHarness(options: { staleActivePane?: boolean } = {}): {
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
@@ -89,6 +89,7 @@ function createHarness(bindings?: Map<number, ShortcutBinding>): {
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
@@ -23,6 +23,8 @@ type ActionDispatchContext = {
persistLayoutSnapshot: () => void
toggleExpandPane: (paneId: number) => void
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
focusSearchInput: () => void
searchOpenRef: React.RefObject<boolean>
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
onSetTitle: (paneId: number) => void
@@ -51,6 +53,8 @@ export function dispatchTerminalShortcutAction(
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
searchOpenRef,
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
@@ -95,7 +99,11 @@ export function dispatchTerminalShortcutAction(
if (action.type === 'toggleSearch') {
event.preventDefault()
event.stopImmediatePropagation()
setSearchOpen((prev) => !prev)
if (searchOpenRef.current) {
focusSearchInput()
} else {
setSearchOpen(true)
}
return
}
if (action.type === 'clearActivePane') {
@@ -24,6 +24,7 @@ export type KeyboardHandlersDeps = {
persistLayoutSnapshot: () => void
toggleExpandPane: (paneId: number) => void
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
focusSearchInput: () => void
onSearchSelectedText: (text: string) => void
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
@@ -20,6 +20,7 @@ describe('terminal keyboard pane ownership', () => {
active = paneId === focused.id ? focused : first
})
const sendFocused = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the complete Enter path; unused runtime dependencies intentionally remain absent.
const handlers = createTerminalKeyboardEventHandlers({
isMac: false,
isWindows: false,
@@ -67,6 +68,7 @@ describe('terminal keyboard pane ownership', () => {
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
@@ -63,6 +63,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
@@ -178,6 +179,18 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
}
if (isEditableTarget(e.target)) {
if (
searchOpenRef.current &&
e.target instanceof HTMLElement &&
e.target.closest('[data-terminal-search-root]') &&
resolveShortcutEvent(e)?.type === 'toggleSearch'
) {
e.preventDefault()
e.stopImmediatePropagation()
if (!e.repeat) {
focusSearchInput()
}
}
return
}
@@ -275,6 +288,8 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
searchOpenRef,
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
@@ -30,6 +30,7 @@ export function useTerminalKeyboardShortcuts({
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
@@ -103,6 +104,7 @@ export function useTerminalKeyboardShortcuts({
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
@@ -167,6 +169,7 @@ export function useTerminalKeyboardShortcuts({
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch'
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
function createContext(searchOpen: boolean) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Search dispatch does not access the manager; an unexpected access must fail the test.
const manager = {} as PaneManager
const context: Parameters<typeof dispatchTerminalShortcutAction>[3] = {
tabId: 'tab-1',
worktreeId: 'folder-1',
fallbackCwd: '',
expandedPaneIdRef: { current: null },
setExpandedPane: vi.fn(),
restoreExpandedLayout: vi.fn(),
refreshPaneSizes: vi.fn(),
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
focusSearchInput: vi.fn(),
searchOpenRef: { current: searchOpen },
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
onSetTitle: vi.fn(),
onClearPaneTitle: vi.fn(),
paneTransportsRef: { current: new Map() },
paneCwdRef: { current: new Map() },
managerRef: { current: manager },
getKeyboardSplitTelemetrySource: () => 'keyboard',
armNativeOnlyShortcut: vi.fn()
}
return { manager, context }
}
describe('terminal find shortcut dispatch', () => {
it.each([true, false])('opens then refocuses without closing (isMac=%s)', (isMac) => {
const { manager, context } = createContext(false)
const event = new KeyboardEvent('keydown', {
key: 'f',
metaKey: isMac,
ctrlKey: !isMac,
cancelable: true
})
const action = resolveTerminalShortcutAction(event, isMac)
expect(action).toEqual({ type: 'toggleSearch' })
if (!action) {
throw new Error('Expected the terminal search shortcut')
}
dispatchTerminalShortcutAction(action, event, manager, context)
expect(context.setSearchOpen).toHaveBeenCalledWith(true)
expect(context.focusSearchInput).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
vi.mocked(context.setSearchOpen).mockClear()
context.searchOpenRef.current = true
dispatchTerminalShortcutAction(action, event, manager, context)
expect(context.focusSearchInput).toHaveBeenCalledTimes(1)
expect(context.setSearchOpen).not.toHaveBeenCalled()
})
it('ignores physical key repeat', () => {
const { manager, context } = createContext(true)
dispatchTerminalShortcutAction(
{ type: 'toggleSearch' },
new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, repeat: true }),
manager,
context
)
expect(context.focusSearchInput).not.toHaveBeenCalled()
expect(context.setSearchOpen).not.toHaveBeenCalled()
})
})
@@ -111,6 +111,11 @@ export function useTerminalPaneFoundation(
caseSensitive: false,
regex: false
})
const searchInputRef = useRef<HTMLInputElement | null>(null)
const focusSearchInput = useCallback((): void => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}, [])
const [pendingCloseConfirmation, setPendingCloseConfirmation] = useState<{
paneId: number
copyKind: CloseTerminalDialogCopyKind
@@ -200,6 +205,8 @@ export function useTerminalPaneFoundation(
setSearchOpen,
searchOpenRef,
searchStateRef,
searchInputRef,
focusSearchInput,
pendingCloseConfirmation,
setPendingCloseConfirmation,
quickCommandEditorOpen,
@@ -46,6 +46,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont
restoreExpandedLayout,
searchOpenRef,
searchStateRef,
focusSearchInput,
setExpandedPane,
setSearchOpen,
settings,
@@ -74,6 +75,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
focusSearchInput,
onSearchSelectedText: handleSearchSelectedText,
onRequestClosePane: handleRequestClosePane,
onClearPaneScrollback: clearPaneScrollback,
+2 -1
View File
@@ -2315,7 +2315,8 @@
"0f3066256e": "Previous match",
"42e466b9f1": "Regex",
"90c61387d9": "Case sensitive",
"e07012f26e": "Search..."
"e07012f26e": "Search...",
"10e039b591": "No results"
},
"UpdateCard": {
"68b235d264": "Restart to Update",
+2 -1
View File
@@ -1944,7 +1944,8 @@
"0f3066256e": "Coincidencia anterior",
"42e466b9f1": "Expresión regular",
"90c61387d9": "Distingue mayúsculas y minúsculas",
"e07012f26e": "Buscar..."
"e07012f26e": "Buscar...",
"10e039b591": "No results"
},
"UpdateCard": {
"68b235d264": "Reiniciar para actualizar",
+2 -1
View File
@@ -1944,7 +1944,8 @@
"0f3066256e": "前の一致",
"42e466b9f1": "正規表現",
"90c61387d9": "大文字と小文字を区別",
"e07012f26e": "検索…"
"e07012f26e": "検索…",
"10e039b591": "No results"
},
"UpdateCard": {
"68b235d264": "再起動してアップデートする",
+2 -1
View File
@@ -1949,7 +1949,8 @@
"0f3066256e": "이전 일치 항목",
"42e466b9f1": "정규식",
"90c61387d9": "대소문자 구분",
"e07012f26e": "검색..."
"e07012f26e": "검색...",
"10e039b591": "No results"
},
"UpdateCard": {
"68b235d264": "업데이트하려면 다시 시작하세요.",
+2 -1
View File
@@ -1947,7 +1947,8 @@
"0f3066256e": "上一个结果",
"42e466b9f1": "正则表达式",
"90c61387d9": "区分大小写",
"e07012f26e": "搜索..."
"e07012f26e": "搜索...",
"10e039b591": "No results"
},
"UpdateCard": {
"68b235d264": "重启即可更新",
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
focusActiveTerminalInput,
getTerminalContent,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
test('terminal search counts real matches and repeat find selects the query', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
for (let line = 0; line < 3; line++) {
await execInTerminal(orcaPage, ptyId, 'echo orca-search-proof')
}
await expect
.poll(
async () =>
(await getTerminalContent(orcaPage))
.split(/\r?\n/)
.filter((line) => line.trim() === 'orca-search-proof').length
)
.toBe(3)
await focusActiveTerminalInput(orcaPage)
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
await orcaPage.keyboard.press(`${modifier}+f`)
const search = orcaPage.locator('[data-terminal-search-root]')
const input = search.locator('input')
await expect(input).toBeFocused()
await search.getByTitle('Regex', { exact: true }).click()
const query = '^orca-search-proof$'
await input.fill(query)
await expect(search).toContainText(/[1-3]\/3/)
const initialCount = await search.innerText()
await input.press('Enter')
await expect.poll(() => search.innerText()).not.toBe(initialCount)
await orcaPage.screenshot({ path: testInfo.outputPath('search-results.png') })
await input.press('ArrowLeft')
await orcaPage.keyboard.press(`${modifier}+f`)
await expect(input).toBeFocused()
await expect
.poll(() =>
input.evaluate((element) => ({ start: element.selectionStart, end: element.selectionEnd }))
)
.toEqual({ start: 0, end: query.length })
await orcaPage.screenshot({ path: testInfo.outputPath('search-query-selected.png') })
await input.fill('no-such-search-result-314159')
await expect(search).toContainText('No results')
await input.press('Escape')
await expect(search).toBeHidden()
})