From a0466641621bd46fe3a12f83e7cafd52342a7dc8 Mon Sep 17 00:00:00 2001 From: maasa Date: Sat, 11 Jul 2026 18:52:37 +0900 Subject: [PATCH 1/2] feat(client): add a search box to the revision selector Add a sticky filter input at the top of the branch/commit dropdown so revisions can be narrowed down by typing, similar to GitHub's branch picker. Matching is case-insensitive across special option labels, commit hashes and messages, and branch names. Pressing Enter selects the first enabled match, and an empty state is shown when nothing matches. Also cap the dropdown height with the floating-ui size middleware so the list (and the new search box) always stays inside the viewport instead of overflowing off-screen. Implements suggestion 2 in #432. Co-Authored-By: Claude Fable 5 --- .../components/RevisionSelector.test.tsx | 80 ++++++++++- src/client/components/RevisionSelector.tsx | 136 ++++++++++++------ 2 files changed, 170 insertions(+), 46 deletions(-) diff --git a/src/client/components/RevisionSelector.test.tsx b/src/client/components/RevisionSelector.test.tsx index d10feee8..93859d8a 100644 --- a/src/client/components/RevisionSelector.test.tsx +++ b/src/client/components/RevisionSelector.test.tsx @@ -9,6 +9,11 @@ vi.mock('lucide-react', () => ({ ChevronDown ), + Search: ({ size, className }: { size: number; className: string }) => ( +
+ Search +
+ ), })); describe('RevisionSelector', () => { @@ -35,7 +40,9 @@ describe('RevisionSelector', () => { fireEvent.click(screen.getByRole('button', { name: /Base:/ })); - const workingOption = screen.getByRole('button', { name: 'Working Directory' }); + const workingOption = screen.getByRole('button', { + name: 'Working Directory', + }); expect(workingOption).toBeDisabled(); }); @@ -59,6 +66,77 @@ describe('RevisionSelector', () => { expect(commitButton).toHaveClass('border-l-diff-selected-border'); }); + it('filters options by the search query', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Base:/ })); + + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'staging' } }); + + expect(screen.getByRole('button', { name: 'Staging Area' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Working Directory' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'main' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Commit A/ })).not.toBeInTheDocument(); + }); + + it('filters commits by hash and message', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Base:/ })); + + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'commit a' } }); + + expect(screen.getByRole('button', { name: /abc1234/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'main' })).not.toBeInTheDocument(); + }); + + it('shows an empty state when nothing matches the query', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Base:/ })); + + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'does-not-exist' } }); + + expect(screen.getByText('No matching branches or commits')).toBeInTheDocument(); + }); + + it('selects the first enabled match when pressing Enter in the search box', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Base:/ })); + + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'commit a' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith('abc1234'); + }); + + it('skips disabled options when selecting with Enter', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Base:/ })); + + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'working' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(onChange).not.toHaveBeenCalled(); + }); + it('hides reserved quick preset values from the special options list', () => { render( (null); const { refs, floatingStyles, context } = useFloating({ open: isOpen, - onOpenChange: setIsOpen, - middleware: [offset(4), flip(), shift({ padding: 8 })], + onOpenChange: (open) => { + setIsOpen(open); + if (!open) setQuery(''); + }, + middleware: [ + offset(4), + flip(), + shift({ padding: 8 }), + // Cap the dropdown height to the space actually available so the sticky + // search box is never pushed out of the viewport. + size({ + padding: 8, + apply({ availableHeight, elements }) { + elements.floating.style.maxHeight = `${Math.min(400, availableHeight)}px`; + }, + }), + ], whileElementsMounted: autoUpdate, }); @@ -92,6 +110,43 @@ export function RevisionSelector({ const handleSelect = (newValue: string) => { onChange(newValue); setIsOpen(false); + setQuery(''); + }; + + const normalizedQuery = query.trim().toLowerCase(); + const matchesQuery = (...texts: string[]) => + normalizedQuery.length === 0 || + texts.some((text) => text.toLowerCase().includes(normalizedQuery)); + + const filteredSpecialOptions = visibleSpecialOptions.filter((opt) => + matchesQuery(opt.label, opt.value), + ); + const filteredCommits = isWorkingStagedMode + ? [] + : options.commits.filter((commit) => + matchesQuery(commit.shortHash, commit.hash, commit.message), + ); + const filteredBranches = isWorkingStagedMode + ? [] + : options.branches.filter((branch) => matchesQuery(branch.name)); + + const hasNoMatches = + filteredSpecialOptions.length === 0 && + filteredCommits.length === 0 && + filteredBranches.length === 0; + + // Select the first enabled match when pressing Enter in the search box + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + + const firstMatch = + filteredSpecialOptions.find((opt) => !isDisabled(opt.value))?.value ?? + filteredCommits.find((commit) => !isDisabled(commit.shortHash))?.shortHash ?? + filteredBranches.find((branch) => !isDisabled(branch.name))?.name; + if (firstMatch !== undefined) { + handleSelect(firstMatch); + } }; // Check if a value is disabled @@ -122,35 +177,6 @@ export function RevisionSelector({ return shortHash === resolvedValue || hash === resolvedValue; }; - // Calculate initial focus index for the current value - const getInitialFocusIndex = (): number => { - let index = 0; - - // Check special options - const specialIndex = visibleSpecialOptions.findIndex((opt) => opt.value === value); - if (specialIndex !== -1) { - return specialIndex; - } - index += visibleSpecialOptions.length; - - // Check commits (only if not in working/staged mode) - if (!isWorkingStagedMode) { - const commitIndex = options.commits.findIndex((c) => c.shortHash === value); - if (commitIndex !== -1) { - return index + commitIndex; - } - index += options.commits.length; - - // Check branches - const branchIndex = options.branches.findIndex((b) => b.name === value); - if (branchIndex !== -1) { - return index + branchIndex; - } - } - - return 0; // Default to first item - }; - return ( <> - - {mainBranch && mainUncommittedPreset && ( - <> + + + {isFiltering ? ( + <> + {filteredPresets.length > 0 && ( +
+
+ Quick Diffs +
+ {filteredPresets.map((item) => ( + + ))} +
+ )} + + {filteredCommits.length > 0 && ( +
+
+ Commits +
+ {filteredCommits.map((commit) => ( + + ))} +
+ )} + + {filteredBranches.length > 0 && ( +
+
+ Branches +
+ {filteredBranches.map((branch) => ( + + ))} +
+ )} + + {hasNoMatches && ( +
+ No matching branches or commits +
+ )} + + ) : ( + <> +
+
+ Quick Diffs +
+ {presetItems + .filter((item) => item.key !== 'previous') + .map((item) => ( + + ))} +
+ +
- - )} - {originDefaultBranch && originUncommittedPreset && ( - - )} -
- -
- -
+ -
-
- - -
- -
+
+ +
+ + )} )}