mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -2315,7 +2315,8 @@
|
||||
"0f3066256e": "Previous match",
|
||||
"42e466b9f1": "Regex",
|
||||
"90c61387d9": "Case sensitive",
|
||||
"e07012f26e": "Search..."
|
||||
"e07012f26e": "Search...",
|
||||
"10e039b591": "No results"
|
||||
},
|
||||
"UpdateCard": {
|
||||
"68b235d264": "Restart to Update",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1944,7 +1944,8 @@
|
||||
"0f3066256e": "前の一致",
|
||||
"42e466b9f1": "正規表現",
|
||||
"90c61387d9": "大文字と小文字を区別",
|
||||
"e07012f26e": "検索…"
|
||||
"e07012f26e": "検索…",
|
||||
"10e039b591": "No results"
|
||||
},
|
||||
"UpdateCard": {
|
||||
"68b235d264": "再起動してアップデートする",
|
||||
|
||||
@@ -1949,7 +1949,8 @@
|
||||
"0f3066256e": "이전 일치 항목",
|
||||
"42e466b9f1": "정규식",
|
||||
"90c61387d9": "대소문자 구분",
|
||||
"e07012f26e": "검색..."
|
||||
"e07012f26e": "검색...",
|
||||
"10e039b591": "No results"
|
||||
},
|
||||
"UpdateCard": {
|
||||
"68b235d264": "업데이트하려면 다시 시작하세요.",
|
||||
|
||||
@@ -1947,7 +1947,8 @@
|
||||
"0f3066256e": "上一个结果",
|
||||
"42e466b9f1": "正则表达式",
|
||||
"90c61387d9": "区分大小写",
|
||||
"e07012f26e": "搜索..."
|
||||
"e07012f26e": "搜索...",
|
||||
"10e039b591": "No results"
|
||||
},
|
||||
"UpdateCard": {
|
||||
"68b235d264": "重启即可更新",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user