From 498f464c4eaf6d76c1379d68e367666399d4aea0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:13:28 -0700 Subject: [PATCH] fix(right-sidebar): release the file search field on Escape Escape cleared the query but left focus trapped in the search input, and with an empty query it did nothing at all, so the field could not be exited from the keyboard. Blur the query input on Escape in both cases. --- .../useFileSearchPanel.escape-blur.test.tsx | 118 ++++++++++++++++++ .../right-sidebar/useFileSearchPanel.ts | 3 + 2 files changed, 121 insertions(+) create mode 100644 src/renderer/src/components/right-sidebar/useFileSearchPanel.escape-blur.test.tsx diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.escape-blur.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchPanel.escape-blur.test.tsx new file mode 100644 index 00000000000..edd47a1f39b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.escape-blur.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useFileSearchPanel } from './useFileSearchPanel' + +const mocks = vi.hoisted(() => ({ + clearFileSearch: vi.fn(), + updateFileSearchState: vi.fn(), + consumeFileSearchSeedRequest: vi.fn(), + toggleFileSearchCollapsedFile: vi.fn(), + openFile: vi.fn(), + setPendingEditorReveal: vi.fn(), + executeSearch: vi.fn(), + cancelPendingSearch: vi.fn(), + query: '' +})) + +const WORKTREE_ID = 'repo::/repo' + +function buildState(): Record { + return { + activeWorktreeId: WORKTREE_ID, + openFile: mocks.openFile, + setPendingEditorReveal: mocks.setPendingEditorReveal, + fileSearchStateByWorktree: { + [WORKTREE_ID]: { query: mocks.query } + }, + updateFileSearchState: mocks.updateFileSearchState, + consumeFileSearchSeedRequest: mocks.consumeFileSearchSeedRequest, + toggleFileSearchCollapsedFile: mocks.toggleFileSearchCollapsedFile, + clearFileSearch: mocks.clearFileSearch + } +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (s: Record) => unknown) => selector(buildState()), + { getState: () => buildState() } + ) +})) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => ({ id: WORKTREE_ID, path: '/repo' }) +})) + +vi.mock('./useFileSearchRunner', () => ({ + useFileSearchRunner: () => ({ + executeSearch: mocks.executeSearch, + cancelPendingSearch: mocks.cancelPendingSearch + }) +})) + +// Escape must always release the query field: with text it also clears, empty it +// still backs out. Asserted against real focus, not a spy, so a handler that +// "handles" Escape without blurring still fails. +function pressEscapeOnFocusedInput(query: string): { + activeAfter: Element | null + input: HTMLInputElement +} { + mocks.query = query + const { result } = renderHook(() => useFileSearchPanel('search')) + + const input = document.createElement('input') + document.body.appendChild(input) + ;(result.current.queryRowProps.inputRef as { current: HTMLInputElement | null }).current = input + input.focus() + expect(document.activeElement).toBe(input) + + act(() => { + result.current.queryRowProps.onKeyDown?.({ + key: 'Escape', + nativeEvent: { isComposing: false } + } as unknown as React.KeyboardEvent) + }) + + return { activeAfter: document.activeElement, input } +} + +describe('file search panel Escape handling', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + it('releases focus and clears when the query is non-empty', () => { + const { activeAfter, input } = pressEscapeOnFocusedInput('useCallback') + + expect(activeAfter).not.toBe(input) + expect(mocks.clearFileSearch).toHaveBeenCalledWith(WORKTREE_ID) + }) + + it('releases focus when the query is empty without clearing', () => { + const { activeAfter, input } = pressEscapeOnFocusedInput('') + + expect(activeAfter).not.toBe(input) + expect(mocks.clearFileSearch).not.toHaveBeenCalled() + }) + + it('leaves focus alone for other keys', () => { + mocks.query = 'useCallback' + const { result } = renderHook(() => useFileSearchPanel('search')) + + const input = document.createElement('input') + document.body.appendChild(input) + ;(result.current.queryRowProps.inputRef as { current: HTMLInputElement | null }).current = input + input.focus() + + act(() => { + result.current.queryRowProps.onKeyDown?.({ + key: 'Enter', + nativeEvent: { isComposing: false } + } as unknown as React.KeyboardEvent) + }) + + expect(document.activeElement).toBe(input) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index f0935b625a0..7166b825817 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -213,6 +213,9 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch if (fileSearchQuery) { handleClearSearch() } + // Why: Esc must back out of the field even with an empty query; other + // search surfaces close on Esc, and this panel has no close to fall back on. + inputRef.current?.blur() } if (e.key === 'Enter') { executeSearch(fileSearchQuery)