fix: honor explicit omnibox selections over ghost input
Keep history search bound to user-typed text when ghost completion is visible, and make Ctrl+digit explicit selection navigation bypass typed-input URL prioritization. This ensures keyboard shortcuts and row activation always open the chosen history/favorite item.
- commit
- a5f78e3c7e3743b148f3ffd5b928fc704ae47141
- author
- brice · · 2026-02-17 17:46 UTC
- parent
- 39e6692
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +99 -20
M internal/ui/component/omnibox.go +47 -20
| @@ -579,15 +579,7 @@ func (o *Omnibox) initList() error { | |||
| 579 | 579 | } | |
| 580 | 580 | ||
| 581 | 581 | var targetURL string | |
| 582 | - | if mode == ViewModeHistory { | |
| 583 | - | if idx >= 0 && idx < len(suggestions) { | |
| 584 | - | targetURL = suggestions[idx].URL | |
| 585 | - | } | |
| 586 | - | } else { | |
| 587 | - | if idx >= 0 && idx < len(favorites) { | |
| 588 | - | targetURL = favorites[idx].URL | |
| 589 | - | } | |
| 590 | - | } | |
| 582 | + | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | |
| 591 | 583 | ||
| 592 | 584 | if targetURL != "" && o.onNavigate != nil { | |
| 593 | 585 | o.Hide(o.ctx) | |
| @@ -1245,6 +1237,8 @@ func (o *Omnibox) performSearch() { | |||
| 1245 | 1237 | o.mu.RLock() | |
| 1246 | 1238 | visible := o.visible | |
| 1247 | 1239 | mode := o.viewMode | |
| 1240 | + | realInput := o.realInput | |
| 1241 | + | hasGhost := o.hasGhostText | |
| 1248 | 1242 | o.mu.RUnlock() | |
| 1249 | 1243 | ||
| 1250 | 1244 | // Skip search if omnibox is hidden | |
| @@ -1252,7 +1246,7 @@ func (o *Omnibox) performSearch() { | |||
| 1252 | 1246 | return | |
| 1253 | 1247 | } | |
| 1254 | 1248 | ||
| 1255 | - | query := o.entry.GetText() | |
| 1249 | + | query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost) | |
| 1256 | 1250 | ||
| 1257 | 1251 | // Skip duplicate queries | |
| 1258 | 1252 | o.debounceMu.Lock() | |
| @@ -1291,6 +1285,13 @@ func (o *Omnibox) performSearch() { | |||
| 1291 | 1285 | o.searchHistory(query, o.effectiveMaxRows(), token) | |
| 1292 | 1286 | } | |
| 1293 | 1287 | ||
| 1288 | + | func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string { | |
| 1289 | + | if hasGhost && realInput != "" { | |
| 1290 | + | return realInput | |
| 1291 | + | } | |
| 1292 | + | return entryText | |
| 1293 | + | } | |
| 1294 | + | ||
| 1294 | 1295 | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1295 | 1296 | // query is the search text; limit caps the number of results. | |
| 1296 | 1297 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | |
| @@ -1876,7 +1877,28 @@ func (o *Omnibox) selectPrevious() { | |||
| 1876 | 1877 | // selectAndNavigate selects an index and navigates to it. | |
| 1877 | 1878 | func (o *Omnibox) selectAndNavigate(index int) { | |
| 1878 | 1879 | o.selectIndex(index) | |
| 1879 | - | o.navigateToSelected() | |
| 1880 | + | ||
| 1881 | + | o.mu.RLock() | |
| 1882 | + | mode := o.viewMode | |
| 1883 | + | bangMode := o.bangMode | |
| 1884 | + | suggestions := o.suggestions | |
| 1885 | + | favorites := o.favorites | |
| 1886 | + | o.mu.RUnlock() | |
| 1887 | + | ||
| 1888 | + | if bangMode { | |
| 1889 | + | o.navigateToSelected() | |
| 1890 | + | return | |
| 1891 | + | } | |
| 1892 | + | ||
| 1893 | + | targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites) | |
| 1894 | + | if targetURL == "" { | |
| 1895 | + | return | |
| 1896 | + | } | |
| 1897 | + | ||
| 1898 | + | o.Hide(o.ctx) | |
| 1899 | + | if o.onNavigate != nil { | |
| 1900 | + | o.onNavigate(targetURL) | |
| 1901 | + | } | |
| 1880 | 1902 | } | |
| 1881 | 1903 | ||
| 1882 | 1904 | // navigateToSelected navigates to the currently selected item or typed URL. | |
| @@ -1923,15 +1945,7 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1923 | 1945 | targetURL = o.buildURL(entryText) | |
| 1924 | 1946 | } else if idx >= 0 { | |
| 1925 | 1947 | // If user has selected a result, navigate to that result. | |
| 1926 | - | if mode == ViewModeHistory { | |
| 1927 | - | if idx < len(suggestions) { | |
| 1928 | - | targetURL = suggestions[idx].URL | |
| 1929 | - | } | |
| 1930 | - | } else { | |
| 1931 | - | if idx < len(favorites) { | |
| 1932 | - | targetURL = favorites[idx].URL | |
| 1933 | - | } | |
| 1934 | - | } | |
| 1948 | + | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | |
| 1935 | 1949 | } else { | |
| 1936 | 1950 | // No selection - use entry text as URL/search. | |
| 1937 | 1951 | targetURL = o.buildURL(entryText) | |
| @@ -1948,6 +1962,19 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1948 | 1962 | } | |
| 1949 | 1963 | } | |
| 1950 | 1964 | ||
| 1965 | + | func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string { | |
| 1966 | + | if mode == ViewModeHistory { | |
| 1967 | + | if idx >= 0 && idx < len(suggestions) { | |
| 1968 | + | return suggestions[idx].URL | |
| 1969 | + | } | |
| 1970 | + | return "" | |
| 1971 | + | } | |
| 1972 | + | if idx >= 0 && idx < len(favorites) { | |
| 1973 | + | return favorites[idx].URL | |
| 1974 | + | } | |
| 1975 | + | return "" | |
| 1976 | + | } | |
| 1977 | + | ||
| 1951 | 1978 | func shouldPreferTypedURLNavigation(entryText string) bool { | |
| 1952 | 1979 | entryText = strings.TrimSpace(entryText) | |
| 1953 | 1980 | if entryText == "" { | |
| @@ -579,15 +579,7 @@ func (o *Omnibox) initList() error { | |||||
| 579 | } | 579 | } | ||
| 580 | 580 | ||||
| 581 | var targetURL string | 581 | var targetURL string | ||
| 582 | - | if mode == ViewModeHistory { | 582 | + | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) |
| 583 | - | if idx >= 0 && idx < len(suggestions) { | |||
| 584 | - | targetURL = suggestions[idx].URL | |||
| 585 | - | } | |||
| 586 | - | } else { | |||
| 587 | - | if idx >= 0 && idx < len(favorites) { | |||
| 588 | - | targetURL = favorites[idx].URL | |||
| 589 | - | } | |||
| 590 | - | } | |||
| 591 | 583 | ||||
| 592 | if targetURL != "" && o.onNavigate != nil { | 584 | if targetURL != "" && o.onNavigate != nil { | ||
| 593 | o.Hide(o.ctx) | 585 | o.Hide(o.ctx) | ||
| @@ -1245,6 +1237,8 @@ func (o *Omnibox) performSearch() { | |||||
| 1245 | o.mu.RLock() | 1237 | o.mu.RLock() | ||
| 1246 | visible := o.visible | 1238 | visible := o.visible | ||
| 1247 | mode := o.viewMode | 1239 | mode := o.viewMode | ||
| 1240 | + | realInput := o.realInput | |||
| 1241 | + | hasGhost := o.hasGhostText | |||
| 1248 | o.mu.RUnlock() | 1242 | o.mu.RUnlock() | ||
| 1249 | 1243 | ||||
| 1250 | // Skip search if omnibox is hidden | 1244 | // Skip search if omnibox is hidden | ||
| @@ -1252,7 +1246,7 @@ func (o *Omnibox) performSearch() { | |||||
| 1252 | return | 1246 | return | ||
| 1253 | } | 1247 | } | ||
| 1254 | 1248 | ||||
| 1255 | - | query := o.entry.GetText() | 1249 | + | query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost) |
| 1256 | 1250 | ||||
| 1257 | // Skip duplicate queries | 1251 | // Skip duplicate queries | ||
| 1258 | o.debounceMu.Lock() | 1252 | o.debounceMu.Lock() | ||
| @@ -1291,6 +1285,13 @@ func (o *Omnibox) performSearch() { | |||||
| 1291 | o.searchHistory(query, o.effectiveMaxRows(), token) | 1285 | o.searchHistory(query, o.effectiveMaxRows(), token) | ||
| 1292 | } | 1286 | } | ||
| 1293 | 1287 | ||||
| 1288 | + | func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string { | |||
| 1289 | + | if hasGhost && realInput != "" { | |||
| 1290 | + | return realInput | |||
| 1291 | + | } | |||
| 1292 | + | return entryText | |||
| 1293 | + | } | |||
| 1294 | + | ||||
| 1294 | // searchHistory runs a fuzzy history search in a background goroutine. | 1295 | // searchHistory runs a fuzzy history search in a background goroutine. | ||
| 1295 | // query is the search text; limit caps the number of results. | 1296 | // query is the search text; limit caps the number of results. | ||
| 1296 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | 1297 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | ||
| @@ -1876,7 +1877,28 @@ func (o *Omnibox) selectPrevious() { | |||||
| 1876 | // selectAndNavigate selects an index and navigates to it. | 1877 | // selectAndNavigate selects an index and navigates to it. | ||
| 1877 | func (o *Omnibox) selectAndNavigate(index int) { | 1878 | func (o *Omnibox) selectAndNavigate(index int) { | ||
| 1878 | o.selectIndex(index) | 1879 | o.selectIndex(index) | ||
| 1879 | - | o.navigateToSelected() | 1880 | + | |
| 1881 | + | o.mu.RLock() | |||
| 1882 | + | mode := o.viewMode | |||
| 1883 | + | bangMode := o.bangMode | |||
| 1884 | + | suggestions := o.suggestions | |||
| 1885 | + | favorites := o.favorites | |||
| 1886 | + | o.mu.RUnlock() | |||
| 1887 | + | ||||
| 1888 | + | if bangMode { | |||
| 1889 | + | o.navigateToSelected() | |||
| 1890 | + | return | |||
| 1891 | + | } | |||
| 1892 | + | ||||
| 1893 | + | targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites) | |||
| 1894 | + | if targetURL == "" { | |||
| 1895 | + | return | |||
| 1896 | + | } | |||
| 1897 | + | ||||
| 1898 | + | o.Hide(o.ctx) | |||
| 1899 | + | if o.onNavigate != nil { | |||
| 1900 | + | o.onNavigate(targetURL) | |||
| 1901 | + | } | |||
| 1880 | } | 1902 | } | ||
| 1881 | 1903 | ||||
| 1882 | // navigateToSelected navigates to the currently selected item or typed URL. | 1904 | // navigateToSelected navigates to the currently selected item or typed URL. | ||
| @@ -1923,15 +1945,7 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1923 | targetURL = o.buildURL(entryText) | 1945 | targetURL = o.buildURL(entryText) | ||
| 1924 | } else if idx >= 0 { | 1946 | } else if idx >= 0 { | ||
| 1925 | // If user has selected a result, navigate to that result. | 1947 | // If user has selected a result, navigate to that result. | ||
| 1926 | - | if mode == ViewModeHistory { | 1948 | + | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) |
| 1927 | - | if idx < len(suggestions) { | |||
| 1928 | - | targetURL = suggestions[idx].URL | |||
| 1929 | - | } | |||
| 1930 | - | } else { | |||
| 1931 | - | if idx < len(favorites) { | |||
| 1932 | - | targetURL = favorites[idx].URL | |||
| 1933 | - | } | |||
| 1934 | - | } | |||
| 1935 | } else { | 1949 | } else { | ||
| 1936 | // No selection - use entry text as URL/search. | 1950 | // No selection - use entry text as URL/search. | ||
| 1937 | targetURL = o.buildURL(entryText) | 1951 | targetURL = o.buildURL(entryText) | ||
| @@ -1948,6 +1962,19 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1948 | } | 1962 | } | ||
| 1949 | } | 1963 | } | ||
| 1950 | 1964 | ||||
| 1965 | + | func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string { | |||
| 1966 | + | if mode == ViewModeHistory { | |||
| 1967 | + | if idx >= 0 && idx < len(suggestions) { | |||
| 1968 | + | return suggestions[idx].URL | |||
| 1969 | + | } | |||
| 1970 | + | return "" | |||
| 1971 | + | } | |||
| 1972 | + | if idx >= 0 && idx < len(favorites) { | |||
| 1973 | + | return favorites[idx].URL | |||
| 1974 | + | } | |||
| 1975 | + | return "" | |||
| 1976 | + | } | |||
| 1977 | + | ||||
| 1951 | func shouldPreferTypedURLNavigation(entryText string) bool { | 1978 | func shouldPreferTypedURLNavigation(entryText string) bool { | ||
| 1952 | entryText = strings.TrimSpace(entryText) | 1979 | entryText = strings.TrimSpace(entryText) | ||
| 1953 | if entryText == "" { | 1980 | if entryText == "" { | ||
M internal/ui/component/omnibox_navigation_state_test.go +52 -0
| @@ -88,3 +88,55 @@ func TestNextGhostSuppressionState(t *testing.T) { | |||
| 88 | 88 | }) | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | + | ||
| 92 | + | func TestEffectiveSearchQuery(t *testing.T) { | |
| 93 | + | tests := []struct { | |
| 94 | + | name string | |
| 95 | + | entryText string | |
| 96 | + | realInput string | |
| 97 | + | hasGhost bool | |
| 98 | + | want string | |
| 99 | + | }{ | |
| 100 | + | {name: "uses typed input when ghost visible", entryText: "dumber.bnema.dev", realInput: "dumb", hasGhost: true, want: "dumb"}, | |
| 101 | + | {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"}, | |
| 102 | + | {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"}, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | for _, tt := range tests { | |
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |
| 107 | + | if got := effectiveSearchQuery(tt.entryText, tt.realInput, tt.hasGhost); got != tt.want { | |
| 108 | + | t.Fatalf("effectiveSearchQuery(%q, %q, %v) = %q, want %q", tt.entryText, tt.realInput, tt.hasGhost, got, tt.want) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestResolveTargetURLForSelection(t *testing.T) { | |
| 115 | + | suggestions := []Suggestion{ | |
| 116 | + | {URL: "https://github.com/bnema/dumber"}, | |
| 117 | + | {URL: "https://github.com/bnema/dumber/pulls"}, | |
| 118 | + | } | |
| 119 | + | favorites := []Favorite{ | |
| 120 | + | {URL: "https://dumber.bnema.dev"}, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | mode ViewMode | |
| 126 | + | index int | |
| 127 | + | wantURL string | |
| 128 | + | }{ | |
| 129 | + | {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"}, | |
| 130 | + | {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"}, | |
| 131 | + | {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""}, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | for _, tt := range tests { | |
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |
| 136 | + | got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites) | |
| 137 | + | if got != tt.wantURL { | |
| 138 | + | t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL) | |
| 139 | + | } | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -88,3 +88,55 @@ func TestNextGhostSuppressionState(t *testing.T) { | |||||
| 88 | }) | 88 | }) | ||
| 89 | } | 89 | } | ||
| 90 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | func TestEffectiveSearchQuery(t *testing.T) { | |||
| 93 | + | tests := []struct { | |||
| 94 | + | name string | |||
| 95 | + | entryText string | |||
| 96 | + | realInput string | |||
| 97 | + | hasGhost bool | |||
| 98 | + | want string | |||
| 99 | + | }{ | |||
| 100 | + | {name: "uses typed input when ghost visible", entryText: "dumber.bnema.dev", realInput: "dumb", hasGhost: true, want: "dumb"}, | |||
| 101 | + | {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"}, | |||
| 102 | + | {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"}, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for _, tt := range tests { | |||
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 107 | + | if got := effectiveSearchQuery(tt.entryText, tt.realInput, tt.hasGhost); got != tt.want { | |||
| 108 | + | t.Fatalf("effectiveSearchQuery(%q, %q, %v) = %q, want %q", tt.entryText, tt.realInput, tt.hasGhost, got, tt.want) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestResolveTargetURLForSelection(t *testing.T) { | |||
| 115 | + | suggestions := []Suggestion{ | |||
| 116 | + | {URL: "https://github.com/bnema/dumber"}, | |||
| 117 | + | {URL: "https://github.com/bnema/dumber/pulls"}, | |||
| 118 | + | } | |||
| 119 | + | favorites := []Favorite{ | |||
| 120 | + | {URL: "https://dumber.bnema.dev"}, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | tests := []struct { | |||
| 124 | + | name string | |||
| 125 | + | mode ViewMode | |||
| 126 | + | index int | |||
| 127 | + | wantURL string | |||
| 128 | + | }{ | |||
| 129 | + | {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"}, | |||
| 130 | + | {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"}, | |||
| 131 | + | {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""}, | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | for _, tt := range tests { | |||
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | + | got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites) | |||
| 137 | + | if got != tt.wantURL { | |||
| 138 | + | t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL) | |||
| 139 | + | } | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | } | |||