detached a5f78e3

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
579579 }
580580
581581 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)
591583
592584 if targetURL != "" && o.onNavigate != nil {
593585 o.Hide(o.ctx)
@@ -1245,6 +1237,8 @@ func (o *Omnibox) performSearch() {
12451237 o.mu.RLock()
12461238 visible := o.visible
12471239 mode := o.viewMode
1240+ realInput := o.realInput
1241+ hasGhost := o.hasGhostText
12481242 o.mu.RUnlock()
12491243
12501244 // Skip search if omnibox is hidden
@@ -1252,7 +1246,7 @@ func (o *Omnibox) performSearch() {
12521246 return
12531247 }
12541248
1255- query := o.entry.GetText()
1249+ query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
12561250
12571251 // Skip duplicate queries
12581252 o.debounceMu.Lock()
@@ -1291,6 +1285,13 @@ func (o *Omnibox) performSearch() {
12911285 o.searchHistory(query, o.effectiveMaxRows(), token)
12921286 }
12931287
1288+func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1289+ if hasGhost && realInput != "" {
1290+ return realInput
1291+ }
1292+ return entryText
1293+}
1294+
12941295 // searchHistory runs a fuzzy history search in a background goroutine.
12951296 // query is the search text; limit caps the number of results.
12961297 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
@@ -1876,7 +1877,28 @@ func (o *Omnibox) selectPrevious() {
18761877 // selectAndNavigate selects an index and navigates to it.
18771878 func (o *Omnibox) selectAndNavigate(index int) {
18781879 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+ }
18801902 }
18811903
18821904 // navigateToSelected navigates to the currently selected item or typed URL.
@@ -1923,15 +1945,7 @@ func (o *Omnibox) navigateToSelected() {
19231945 targetURL = o.buildURL(entryText)
19241946 } else if idx >= 0 {
19251947 // 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)
19351949 } else {
19361950 // No selection - use entry text as URL/search.
19371951 targetURL = o.buildURL(entryText)
@@ -1948,6 +1962,19 @@ func (o *Omnibox) navigateToSelected() {
19481962 }
19491963 }
19501964
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+
19511978 func shouldPreferTypedURLNavigation(entryText string) bool {
19521979 entryText = strings.TrimSpace(entryText)
19531980 if entryText == "" {
@@ -579,15 +579,7 @@ func (o *Omnibox) initList() error {
579 }579 }
580 580
581 var targetURL string581 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.visible1238 visible := o.visible
1247 mode := o.viewMode1239 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 hidden1244 // Skip search if omnibox is hidden
@@ -1252,7 +1246,7 @@ func (o *Omnibox) performSearch() {
1252 return1246 return
1253 }1247 }
1254 1248
1255- query := o.entry.GetText()1249+ query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
1256 1250
1257 // Skip duplicate queries1251 // 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) {
8888 })
8989 }
9090 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close