diff --git a/src/client/components/DiffQuickMenu.test.ts b/src/client/components/DiffQuickMenu.test.ts index 89e1505f..fdc27cd9 100644 --- a/src/client/components/DiffQuickMenu.test.ts +++ b/src/client/components/DiffQuickMenu.test.ts @@ -78,7 +78,9 @@ describe('DiffQuickMenu', () => { fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); fireEvent.click(screen.getByRole('button', { name: 'Pick Commit...' })); - const commitButtons = await screen.findAllByRole('button', { name: /1f23cd1/ }); + const commitButtons = await screen.findAllByRole('button', { + name: /1f23cd1/, + }); const highlightedCommitButton = commitButtons.find((button) => button.className.includes('border-l-diff-selected-border'), ); @@ -104,7 +106,9 @@ describe('DiffQuickMenu', () => { screen.getByRole('button', { name: 'main...Uncommitted (merge-base)' }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'origin/main...Uncommitted (merge-base)' }), + screen.getByRole('button', { + name: 'origin/main...Uncommitted (merge-base)', + }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'HEAD' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'HEAD...Staging' })).not.toBeInTheDocument(); @@ -163,7 +167,11 @@ describe('DiffQuickMenu', () => { ); fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); - fireEvent.click(screen.getByRole('button', { name: 'origin/main...Uncommitted (merge-base)' })); + fireEvent.click( + screen.getByRole('button', { + name: 'origin/main...Uncommitted (merge-base)', + }), + ); expect(onSelectDiff).toHaveBeenCalledWith({ baseCommitish: 'origin/main', @@ -172,11 +180,130 @@ describe('DiffQuickMenu', () => { }); }); + it('filters the menu down to matching commits when typing in the search box', () => { + render( + createElement(DiffQuickMenu, { + options, + selection: { baseCommitish: 'HEAD', targetCommitish: '.' }, + onSelectDiff: vi.fn(), + onOpenAdvanced: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); + fireEvent.change(screen.getByPlaceholderText('Filter branches and commits...'), { + target: { value: 'release' }, + }); + + expect(screen.getByRole('button', { name: /1f23cd1/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'HEAD' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pick Commit...' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Detailed...' })).not.toBeInTheDocument(); + }); + + it('selects commit^...commit when a filtered commit is clicked', () => { + const onSelectDiff = vi.fn(); + render( + createElement(DiffQuickMenu, { + options, + selection: { baseCommitish: 'HEAD', targetCommitish: '.' }, + onSelectDiff, + onOpenAdvanced: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); + fireEvent.change(screen.getByPlaceholderText('Filter branches and commits...'), { + target: { value: 'release' }, + }); + fireEvent.click(screen.getByRole('button', { name: /1f23cd1/ })); + + expect(onSelectDiff).toHaveBeenCalledWith({ + baseCommitish: '1f23cd1^', + targetCommitish: '1f23cd1', + }); + }); + + it('selects branch...Uncommitted (merge-base) when a filtered branch is clicked', () => { + const onSelectDiff = vi.fn(); + render( + createElement(DiffQuickMenu, { + options, + selection: { baseCommitish: 'HEAD', targetCommitish: '.' }, + onSelectDiff, + onOpenAdvanced: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); + fireEvent.change(screen.getByPlaceholderText('Filter branches and commits...'), { + target: { value: 'main' }, + }); + + const branchesSection = screen.getByText('Branches').parentElement; + expect(branchesSection).not.toBeNull(); + fireEvent.click( + within(branchesSection!).getByRole('button', { + name: /main \(current\)/, + }), + ); + + expect(onSelectDiff).toHaveBeenCalledWith({ + baseCommitish: 'main', + targetCommitish: '.', + baseMode: 'merge-base', + }); + }); + + it('selects the first match when pressing Enter in the search box', () => { + const onSelectDiff = vi.fn(); + render( + createElement(DiffQuickMenu, { + options, + selection: { baseCommitish: 'HEAD', targetCommitish: '.' }, + onSelectDiff, + onOpenAdvanced: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); + const searchInput = screen.getByPlaceholderText('Filter branches and commits...'); + fireEvent.change(searchInput, { target: { value: 'v3.1.5' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(onSelectDiff).toHaveBeenCalledWith({ + baseCommitish: '1f23cd1^', + targetCommitish: '1f23cd1', + }); + }); + + it('shows an empty state when nothing matches the search query', () => { + render( + createElement(DiffQuickMenu, { + options, + selection: { baseCommitish: 'HEAD', targetCommitish: '.' }, + onSelectDiff: vi.fn(), + onOpenAdvanced: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: /Revision menu:/ })); + fireEvent.change(screen.getByPlaceholderText('Filter branches and commits...'), { + target: { value: 'does-not-exist' }, + }); + + expect(screen.getByText('No matching branches or commits')).toBeInTheDocument(); + }); + it('shows merge-base in the current label', () => { render( createElement(DiffQuickMenu, { options, - selection: { baseCommitish: 'origin/main', targetCommitish: '.', baseMode: 'merge-base' }, + selection: { + baseCommitish: 'origin/main', + targetCommitish: '.', + baseMode: 'merge-base', + }, onSelectDiff: vi.fn(), onOpenAdvanced: vi.fn(), }), diff --git a/src/client/components/DiffQuickMenu.tsx b/src/client/components/DiffQuickMenu.tsx index 08f324af..6d97ebe9 100644 --- a/src/client/components/DiffQuickMenu.tsx +++ b/src/client/components/DiffQuickMenu.tsx @@ -12,8 +12,8 @@ import { FloatingPortal, safePolygon, } from '@floating-ui/react'; -import { ChevronDown, ChevronLeft, GitBranch } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronLeft, GitBranch, Search } from 'lucide-react'; +import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'; import { type CommitInfo, type DiffSelection, type RevisionsResponse } from '../../types/diff'; import { @@ -113,6 +113,7 @@ export function DiffQuickMenu({ }: DiffQuickMenuProps) { const [isOpen, setIsOpen] = useState(false); const [isCommitMenuOpen, setIsCommitMenuOpen] = useState(false); + const [query, setQuery] = useState(''); const isCompact = compact; const { refs, floatingStyles, context } = useFloating({ @@ -120,6 +121,7 @@ export function DiffQuickMenu({ onOpenChange: (open) => { if (!open && isCommitMenuOpen) return; setIsOpen(open); + if (!open) setQuery(''); }, placement: 'bottom-end', middleware: [offset(6), flip(), shift({ padding: 8 })], @@ -221,14 +223,101 @@ export function DiffQuickMenu({ onSelectDiff(nextSelection); setIsCommitMenuOpen(false); setIsOpen(false); + setQuery(''); }; const handleOpenAdvanced = () => { setIsCommitMenuOpen(false); setIsOpen(false); + setQuery(''); onOpenAdvanced(); }; + const presetItems = useMemo(() => { + const items: Array<{ + key: string; + label: string; + selection: DiffSelection; + }> = [ + { key: 'head', label: 'HEAD', selection: headPreset }, + { + key: 'head-uncommitted', + label: 'HEAD...Uncommitted (merge-base)', + selection: headUncommittedPreset, + }, + ]; + if (mainBranch && mainUncommittedPreset) { + items.push({ + key: 'main-uncommitted', + label: `${mainBranch.name}...Uncommitted (merge-base)`, + selection: mainUncommittedPreset, + }); + } + if (originDefaultBranch && originUncommittedPreset) { + items.push({ + key: 'origin-uncommitted', + label: `${originDefaultBranch}...Uncommitted (merge-base)`, + selection: originUncommittedPreset, + }); + } + items.push({ + key: 'previous', + label: 'Previous commit', + selection: previousCommitPreset, + }); + return items; + }, [ + headPreset, + headUncommittedPreset, + mainBranch, + mainUncommittedPreset, + originDefaultBranch, + originUncommittedPreset, + previousCommitPreset, + ]); + + const normalizedQuery = query.trim().toLowerCase(); + const isFiltering = normalizedQuery.length > 0; + const matchesQuery = (...texts: string[]) => + texts.some((text) => text.toLowerCase().includes(normalizedQuery)); + + const filteredPresets = isFiltering + ? presetItems.filter((item) => matchesQuery(item.label)) + : presetItems; + const filteredCommits = isFiltering + ? options.commits.filter((commit) => + matchesQuery(commit.shortHash, commit.hash, commit.message), + ) + : []; + const filteredBranches = isFiltering + ? options.branches.filter((branch) => matchesQuery(branch.name)) + : []; + + const hasNoMatches = + isFiltering && + filteredPresets.length === 0 && + filteredCommits.length === 0 && + filteredBranches.length === 0; + + const branchSelection = (branchName: string) => + createDiffSelection(branchName, '.', 'merge-base'); + + // Select the first match when pressing Enter in the search box + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || !isFiltering) return; + event.preventDefault(); + + const firstMatch = + filteredPresets[0]?.selection ?? + (filteredCommits[0] + ? createDiffSelection(`${filteredCommits[0].shortHash}^`, filteredCommits[0].shortHash) + : undefined) ?? + (filteredBranches[0] ? branchSelection(filteredBranches[0].name) : undefined); + if (firstMatch) { + handleSelect(firstMatch); + } + }; + const getItemClasses = (highlighted: boolean, disabled: boolean) => { const highlightClasses = highlighted ? 'bg-diff-selected-bg border-l-4 border-l-diff-selected-border font-semibold pl-2' @@ -294,79 +383,163 @@ export function DiffQuickMenu({
-
-
- Quick Diffs + {/* Search box */} +
+
+ + setQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Filter branches and commits..." + aria-label="Filter branches and commits" + className="w-full bg-transparent text-xs text-github-text-primary placeholder:text-github-text-muted focus:outline-none" + />
- - - {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 && ( - - )} -
- -
- -
+
-
-
- -
-
- -
+
+ +
+ + )}
)} 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 ( <>