detached 72fbbff

Merge pull request #174 from bnema/fix/omnibox-url-ip-ghost

fix omnibox URL routing, IP normalization, and ghost stability
commit
72fbbff59ed83af9630ffe103ac4e462fddaccd4
author
bnema · · 2026-02-17 18:07 UTC
committer
GitHub · · 2026-02-17 18:07 UTC
merge of
5960dd8 + 2dbb951 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +364 -29
M internal/domain/url/normalize.go +25 -0
@@ -3,6 +3,7 @@ package url
33
44 import (
55 "fmt"
6+ "net"
67 "net/url"
78 "os"
89 "path/filepath"
@@ -85,6 +86,9 @@ func Normalize(input string) string {
8586 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
8687 return "http://" + input
8788 }
89+ if looksLikeIPAddressWithOptionalPortOrPath(input) {
90+ return "http://" + input
91+ }
8892 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
8993 return "https://" + input
9094 }
@@ -92,6 +96,24 @@ func Normalize(input string) string {
9296 return input
9397 }
9498
99+func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
100+ if input == "" || strings.Contains(input, " ") {
101+ return false
102+ }
103+
104+ parsed, err := url.Parse("http://" + input)
105+ if err != nil {
106+ return false
107+ }
108+
109+ hostname := parsed.Hostname()
110+ if hostname == "" {
111+ return false
112+ }
113+
114+ return net.ParseIP(hostname) != nil
115+}
116+
95117 // LooksLikeURL checks if the input appears to be a URL (not a search query).
96118 // Returns true for strings like "github.com", "google.com/search", etc.
97119 // Also returns true for URLs with explicit schemes like "dumb://".
@@ -118,6 +140,9 @@ func LooksLikeURL(input string) bool {
118140 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119141 return true
120142 }
143+ if looksLikeIPAddressWithOptionalPortOrPath(input) {
144+ return true
145+ }
121146 return strings.Contains(input, ".") && !strings.Contains(input, " ")
122147 }
123148
@@ -3,6 +3,7 @@ package url
3 3
4 import (4 import (
5 "fmt"5 "fmt"
6+ "net"
6 "net/url"7 "net/url"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
@@ -85,6 +86,9 @@ func Normalize(input string) string {
85 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {86 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
86 return "http://" + input87 return "http://" + input
87 }88 }
89+ if looksLikeIPAddressWithOptionalPortOrPath(input) {
90+ return "http://" + input
91+ }
88 if strings.Contains(input, ".") && !strings.Contains(input, " ") {92 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
89 return "https://" + input93 return "https://" + input
90 }94 }
@@ -92,6 +96,24 @@ func Normalize(input string) string {
92 return input96 return input
93 }97 }
94 98
99+func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
100+ if input == "" || strings.Contains(input, " ") {
101+ return false
102+ }
103+
104+ parsed, err := url.Parse("http://" + input)
105+ if err != nil {
106+ return false
107+ }
108+
109+ hostname := parsed.Hostname()
110+ if hostname == "" {
111+ return false
112+ }
113+
114+ return net.ParseIP(hostname) != nil
115+}
116+
95 // LooksLikeURL checks if the input appears to be a URL (not a search query).117 // LooksLikeURL checks if the input appears to be a URL (not a search query).
96 // Returns true for strings like "github.com", "google.com/search", etc.118 // Returns true for strings like "github.com", "google.com/search", etc.
97 // Also returns true for URLs with explicit schemes like "dumb://".119 // Also returns true for URLs with explicit schemes like "dumb://".
@@ -118,6 +140,9 @@ func LooksLikeURL(input string) bool {
118 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {140 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119 return true141 return true
120 }142 }
143+ if looksLikeIPAddressWithOptionalPortOrPath(input) {
144+ return true
145+ }
121 return strings.Contains(input, ".") && !strings.Contains(input, " ")146 return strings.Contains(input, ".") && !strings.Contains(input, " ")
122 }147 }
123 148
M internal/domain/url/normalize_test.go +36 -0
@@ -390,6 +390,21 @@ func TestLooksLikeURL(t *testing.T) {
390390 input: "localhostevil.com",
391391 want: true,
392392 },
393+ {
394+ name: "ipv4 with port",
395+ input: "100.64.0.10:3000",
396+ want: true,
397+ },
398+ {
399+ name: "ipv4",
400+ input: "127.0.0.1",
401+ want: true,
402+ },
403+ {
404+ name: "ipv6 bracketed",
405+ input: "[::1]",
406+ want: true,
407+ },
393408 }
394409
395410 for _, tt := range tests {
@@ -402,6 +417,27 @@ func TestLooksLikeURL(t *testing.T) {
402417 }
403418 }
404419
420+func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
421+ tests := []struct {
422+ name string
423+ input string
424+ want string
425+ }{
426+ {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
427+ {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
428+ {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
429+ }
430+
431+ for _, tt := range tests {
432+ t.Run(tt.name, func(t *testing.T) {
433+ got := Normalize(tt.input)
434+ if got != tt.want {
435+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
436+ }
437+ })
438+ }
439+}
440+
405441 func TestIsExternalScheme(t *testing.T) {
406442 tests := []struct {
407443 name string
@@ -390,6 +390,21 @@ func TestLooksLikeURL(t *testing.T) {
390 input: "localhostevil.com",390 input: "localhostevil.com",
391 want: true,391 want: true,
392 },392 },
393+ {
394+ name: "ipv4 with port",
395+ input: "100.64.0.10:3000",
396+ want: true,
397+ },
398+ {
399+ name: "ipv4",
400+ input: "127.0.0.1",
401+ want: true,
402+ },
403+ {
404+ name: "ipv6 bracketed",
405+ input: "[::1]",
406+ want: true,
407+ },
393 }408 }
394 409
395 for _, tt := range tests {410 for _, tt := range tests {
@@ -402,6 +417,27 @@ func TestLooksLikeURL(t *testing.T) {
402 }417 }
403 }418 }
404 419
420+func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
421+ tests := []struct {
422+ name string
423+ input string
424+ want string
425+ }{
426+ {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
427+ {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
428+ {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
429+ }
430+
431+ for _, tt := range tests {
432+ t.Run(tt.name, func(t *testing.T) {
433+ got := Normalize(tt.input)
434+ if got != tt.want {
435+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
436+ }
437+ })
438+ }
439+}
440+
405 func TestIsExternalScheme(t *testing.T) {441 func TestIsExternalScheme(t *testing.T) {
406 tests := []struct {442 tests := []struct {
407 name string443 name string
M internal/ui/component/omnibox.go +126 -29
@@ -98,6 +98,7 @@ type Omnibox struct {
9898 ghostFullText string // Full text that ghost completes to
9999 hasGhostText bool // Quick check for Tab behavior
100100 isAcceptingGhost bool // Prevent re-trigger during acceptance
101+ suppressGhost bool // Temporarily disable ghost completion after deletion keys
101102
102103 // Dependencies
103104 historyUC *usecase.SearchHistoryUseCase
@@ -577,16 +578,7 @@ func (o *Omnibox) initList() error {
577578 return
578579 }
579580
580- var targetURL string
581- if mode == ViewModeHistory {
582- if idx >= 0 && idx < len(suggestions) {
583- targetURL = suggestions[idx].URL
584- }
585- } else {
586- if idx >= 0 && idx < len(favorites) {
587- targetURL = favorites[idx].URL
588- }
589- }
581+ targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites)
590582
591583 if targetURL != "" && o.onNavigate != nil {
592584 o.Hide(o.ctx)
@@ -681,6 +673,8 @@ func (o *Omnibox) hasUserNavigated() bool {
681673 // handleKeyPress processes keyboard events.
682674 // Returns true if the event was handled.
683675 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
676+ o.updateGhostSuppressionFromKey(keyval)
677+
684678 ctrl := state&gdk.ControlMaskValue != 0
685679
686680 switch keyval {
@@ -754,6 +748,25 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
754748 return false // Let entry handle the key
755749 }
756750
751+func nextGhostSuppressionState(keyval uint) bool {
752+ return isDeletionKey(keyval)
753+}
754+
755+func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) {
756+ o.mu.Lock()
757+ o.suppressGhost = nextGhostSuppressionState(keyval)
758+ o.mu.Unlock()
759+}
760+
761+func isDeletionKey(keyval uint) bool {
762+ switch keyval {
763+ case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
764+ return true
765+ default:
766+ return false
767+ }
768+}
769+
757770 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
758771 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
759772 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -813,6 +826,7 @@ func (o *Omnibox) onEntryChanged() {
813826
814827 // Reset navigation state when user types - space should type, not toggle favorite
815828 o.mu.Lock()
829+ previousInput := o.realInput
816830 o.hasNavigated = false
817831 // Update realInput with what user actually typed
818832 o.realInput = entryText
@@ -820,6 +834,11 @@ func (o *Omnibox) onEntryChanged() {
820834
821835 // Clear ghost text when user types (it will be recalculated after search)
822836 o.clearGhostText()
837+ // Recompute immediately from currently visible rows to avoid transient gaps
838+ // where suggestions are visible but ghost text is missing until async search returns.
839+ if shouldUpdateGhostImmediately(previousInput, entryText) {
840+ o.updateGhostFromSelectionWithInput(entryText)
841+ }
823842
824843 o.debounceMu.Lock()
825844 if o.debounceTimer != nil {
@@ -831,6 +850,10 @@ func (o *Omnibox) onEntryChanged() {
831850 o.debounceMu.Unlock()
832851 }
833852
853+func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
854+ return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
855+}
856+
834857 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
835858 o.mu.RLock()
836859 hasGhost := o.hasGhostText
@@ -1047,10 +1070,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
10471070 mode := o.viewMode
10481071 bangMode := o.bangMode
10491072 hasNavigated := o.hasNavigated
1073+ suppressGhost := o.suppressGhost
10501074 suggestions := o.suggestions
10511075 favorites := o.favorites
10521076 o.mu.RUnlock()
10531077
1078+ if suppressGhost {
1079+ o.clearGhostTextIfInput(entryText)
1080+ return
1081+ }
1082+
10541083 // No ghost text in bang mode
10551084 if bangMode {
10561085 o.clearGhostTextIfInput(entryText)
@@ -1096,6 +1125,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
10961125 return
10971126 }
10981127
1128+ leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1129+ if !ok {
1130+ o.clearGhostTextIfInput(entryText)
1131+ return
1132+ }
1133+
10991134 visibleURLs := make([]string, 0, len(suggestions))
11001135 if mode == ViewModeHistory {
11011136 for _, s := range suggestions {
@@ -1118,7 +1153,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11181153 o.mu.Unlock()
11191154
11201155 go func() {
1121- suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1156+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{
11221157 VisibleURLs: visibleURLs,
11231158 AllowBangs: false,
11241159 })
@@ -1129,15 +1164,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11291164 o.clearGhostTextIfInput(entryText)
11301165 return
11311166 }
1132- fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)
1167+ fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix)
11331168 if fullText == "" || suffix == "" {
11341169 o.clearGhostTextIfInput(entryText)
11351170 return
11361171 }
1137- o.setGhostText(entryText, suffix, fullText)
1172+ o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
11381173 }()
11391174 }
11401175
1176+func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1177+ trimmed := strings.TrimLeft(entryText, " \t")
1178+ leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1179+ if trimmed == "" {
1180+ return leadingWhitespace, "", false
1181+ }
1182+ return leadingWhitespace, trimmed, true
1183+}
1184+
11411185 // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
11421186 // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
11431187 func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
@@ -1193,6 +1237,8 @@ func (o *Omnibox) performSearch() {
11931237 o.mu.RLock()
11941238 visible := o.visible
11951239 mode := o.viewMode
1240+ realInput := o.realInput
1241+ hasGhost := o.hasGhostText
11961242 o.mu.RUnlock()
11971243
11981244 // Skip search if omnibox is hidden
@@ -1200,7 +1246,7 @@ func (o *Omnibox) performSearch() {
12001246 return
12011247 }
12021248
1203- query := o.entry.GetText()
1249+ query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
12041250
12051251 // Skip duplicate queries
12061252 o.debounceMu.Lock()
@@ -1239,6 +1285,13 @@ func (o *Omnibox) performSearch() {
12391285 o.searchHistory(query, o.effectiveMaxRows(), token)
12401286 }
12411287
1288+func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1289+ if hasGhost && realInput != "" {
1290+ return realInput
1291+ }
1292+ return entryText
1293+}
1294+
12421295 // searchHistory runs a fuzzy history search in a background goroutine.
12431296 // query is the search text; limit caps the number of results.
12441297 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
@@ -1824,7 +1877,28 @@ func (o *Omnibox) selectPrevious() {
18241877 // selectAndNavigate selects an index and navigates to it.
18251878 func (o *Omnibox) selectAndNavigate(index int) {
18261879 o.selectIndex(index)
1827- 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+ }
18281902 }
18291903
18301904 // navigateToSelected navigates to the currently selected item or typed URL.
@@ -1867,21 +1941,13 @@ func (o *Omnibox) navigateToSelected() {
18671941 }
18681942
18691943 var targetURL string
1870-
1871- // If user has selected a result, navigate to that result
1872- // Otherwise, treat entry text as URL/search
1873- if idx >= 0 {
1874- if mode == ViewModeHistory {
1875- if idx < len(suggestions) {
1876- targetURL = suggestions[idx].URL
1877- }
1878- } else {
1879- if idx < len(favorites) {
1880- targetURL = favorites[idx].URL
1881- }
1882- }
1944+ if shouldPreferTypedURLNavigation(entryText) {
1945+ targetURL = o.buildURL(entryText)
1946+ } else if idx >= 0 {
1947+ // If user has selected a result, navigate to that result.
1948+ targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
18831949 } else {
1884- // No selection - use entry text as URL/search
1950+ // No selection - use entry text as URL/search.
18851951 targetURL = o.buildURL(entryText)
18861952 }
18871953
@@ -1896,6 +1962,27 @@ func (o *Omnibox) navigateToSelected() {
18961962 }
18971963 }
18981964
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+
1978+func shouldPreferTypedURLNavigation(entryText string) bool {
1979+ entryText = strings.TrimSpace(entryText)
1980+ if entryText == "" {
1981+ return false
1982+ }
1983+ return url.LooksLikeURL(entryText)
1984+}
1985+
18991986 // toggleFavorite adds or removes the selected item from favorites.
19001987 // In History mode: adds the selected item to favorites
19011988 // In Favorites mode: removes the selected item from favorites
@@ -2142,6 +2229,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21422229 o.ghostFullText = ""
21432230 o.hasGhostText = false
21442231 o.isAcceptingGhost = false
2232+ o.suppressGhost = false
21452233 o.mu.Unlock()
21462234
21472235 // Set initial query
@@ -2174,6 +2262,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21742262
21752263 // Focus the entry
21762264 o.entry.GrabFocus()
2265+ o.resetSearchSessionState()
21772266
21782267 // Load initial data (may update size later if results found)
21792268 o.performSearch()
@@ -2212,13 +2301,21 @@ func (o *Omnibox) Hide(ctx context.Context) {
22122301 // Reset realInput
22132302 o.mu.Lock()
22142303 o.realInput = ""
2304+ o.suppressGhost = false
22152305 o.mu.Unlock()
2306+ o.resetSearchSessionState()
22162307
22172308 if o.onClose != nil {
22182309 o.onClose()
22192310 }
22202311 }
22212312
2313+func (o *Omnibox) resetSearchSessionState() {
2314+ o.debounceMu.Lock()
2315+ o.lastQuery = ""
2316+ o.debounceMu.Unlock()
2317+}
2318+
22222319 // Toggle shows if hidden, hides if visible.
22232320 func (o *Omnibox) Toggle(ctx context.Context) {
22242321 o.mu.RLock()
@@ -98,6 +98,7 @@ type Omnibox struct {
98 ghostFullText string // Full text that ghost completes to98 ghostFullText string // Full text that ghost completes to
99 hasGhostText bool // Quick check for Tab behavior99 hasGhostText bool // Quick check for Tab behavior
100 isAcceptingGhost bool // Prevent re-trigger during acceptance100 isAcceptingGhost bool // Prevent re-trigger during acceptance
101+ suppressGhost bool // Temporarily disable ghost completion after deletion keys
101 102
102 // Dependencies103 // Dependencies
103 historyUC *usecase.SearchHistoryUseCase104 historyUC *usecase.SearchHistoryUseCase
@@ -577,16 +578,7 @@ func (o *Omnibox) initList() error {
577 return578 return
578 }579 }
579 580
580- var targetURL string581+ targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites)
581- if mode == ViewModeHistory {
582- if idx >= 0 && idx < len(suggestions) {
583- targetURL = suggestions[idx].URL
584- }
585- } else {
586- if idx >= 0 && idx < len(favorites) {
587- targetURL = favorites[idx].URL
588- }
589- }
590 582
591 if targetURL != "" && o.onNavigate != nil {583 if targetURL != "" && o.onNavigate != nil {
592 o.Hide(o.ctx)584 o.Hide(o.ctx)
@@ -681,6 +673,8 @@ func (o *Omnibox) hasUserNavigated() bool {
681 // handleKeyPress processes keyboard events.673 // handleKeyPress processes keyboard events.
682 // Returns true if the event was handled.674 // Returns true if the event was handled.
683 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {675 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
676+ o.updateGhostSuppressionFromKey(keyval)
677+
684 ctrl := state&gdk.ControlMaskValue != 0678 ctrl := state&gdk.ControlMaskValue != 0
685 679
686 switch keyval {680 switch keyval {
@@ -754,6 +748,25 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
754 return false // Let entry handle the key748 return false // Let entry handle the key
755 }749 }
756 750
751+func nextGhostSuppressionState(keyval uint) bool {
752+ return isDeletionKey(keyval)
753+}
754+
755+func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) {
756+ o.mu.Lock()
757+ o.suppressGhost = nextGhostSuppressionState(keyval)
758+ o.mu.Unlock()
759+}
760+
761+func isDeletionKey(keyval uint) bool {
762+ switch keyval {
763+ case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
764+ return true
765+ default:
766+ return false
767+ }
768+}
769+
757 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.770 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
758 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).771 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
759 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {772 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -813,6 +826,7 @@ func (o *Omnibox) onEntryChanged() {
813 826
814 // Reset navigation state when user types - space should type, not toggle favorite827 // Reset navigation state when user types - space should type, not toggle favorite
815 o.mu.Lock()828 o.mu.Lock()
829+ previousInput := o.realInput
816 o.hasNavigated = false830 o.hasNavigated = false
817 // Update realInput with what user actually typed831 // Update realInput with what user actually typed
818 o.realInput = entryText832 o.realInput = entryText
@@ -820,6 +834,11 @@ func (o *Omnibox) onEntryChanged() {
820 834
821 // Clear ghost text when user types (it will be recalculated after search)835 // Clear ghost text when user types (it will be recalculated after search)
822 o.clearGhostText()836 o.clearGhostText()
837+ // Recompute immediately from currently visible rows to avoid transient gaps
838+ // where suggestions are visible but ghost text is missing until async search returns.
839+ if shouldUpdateGhostImmediately(previousInput, entryText) {
840+ o.updateGhostFromSelectionWithInput(entryText)
841+ }
823 842
824 o.debounceMu.Lock()843 o.debounceMu.Lock()
825 if o.debounceTimer != nil {844 if o.debounceTimer != nil {
@@ -831,6 +850,10 @@ func (o *Omnibox) onEntryChanged() {
831 o.debounceMu.Unlock()850 o.debounceMu.Unlock()
832 }851 }
833 852
853+func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
854+ return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
855+}
856+
834 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {857 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
835 o.mu.RLock()858 o.mu.RLock()
836 hasGhost := o.hasGhostText859 hasGhost := o.hasGhostText
@@ -1047,10 +1070,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1047 mode := o.viewMode1070 mode := o.viewMode
1048 bangMode := o.bangMode1071 bangMode := o.bangMode
1049 hasNavigated := o.hasNavigated1072 hasNavigated := o.hasNavigated
1073+ suppressGhost := o.suppressGhost
1050 suggestions := o.suggestions1074 suggestions := o.suggestions
1051 favorites := o.favorites1075 favorites := o.favorites
1052 o.mu.RUnlock()1076 o.mu.RUnlock()
1053 1077
1078+ if suppressGhost {
1079+ o.clearGhostTextIfInput(entryText)
1080+ return
1081+ }
1082+
1054 // No ghost text in bang mode1083 // No ghost text in bang mode
1055 if bangMode {1084 if bangMode {
1056 o.clearGhostTextIfInput(entryText)1085 o.clearGhostTextIfInput(entryText)
@@ -1096,6 +1125,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1096 return1125 return
1097 }1126 }
1098 1127
1128+ leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1129+ if !ok {
1130+ o.clearGhostTextIfInput(entryText)
1131+ return
1132+ }
1133+
1099 visibleURLs := make([]string, 0, len(suggestions))1134 visibleURLs := make([]string, 0, len(suggestions))
1100 if mode == ViewModeHistory {1135 if mode == ViewModeHistory {
1101 for _, s := range suggestions {1136 for _, s := range suggestions {
@@ -1118,7 +1153,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1118 o.mu.Unlock()1153 o.mu.Unlock()
1119 1154
1120 go func() {1155 go func() {
1121- suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{1156+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{
1122 VisibleURLs: visibleURLs,1157 VisibleURLs: visibleURLs,
1123 AllowBangs: false,1158 AllowBangs: false,
1124 })1159 })
@@ -1129,15 +1164,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1129 o.clearGhostTextIfInput(entryText)1164 o.clearGhostTextIfInput(entryText)
1130 return1165 return
1131 }1166 }
1132- fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)1167+ fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix)
1133 if fullText == "" || suffix == "" {1168 if fullText == "" || suffix == "" {
1134 o.clearGhostTextIfInput(entryText)1169 o.clearGhostTextIfInput(entryText)
1135 return1170 return
1136 }1171 }
1137- o.setGhostText(entryText, suffix, fullText)1172+ o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
1138 }()1173 }()
1139 }1174 }
1140 1175
1176+func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1177+ trimmed := strings.TrimLeft(entryText, " \t")
1178+ leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1179+ if trimmed == "" {
1180+ return leadingWhitespace, "", false
1181+ }
1182+ return leadingWhitespace, trimmed, true
1183+}
1184+
1141 // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input1185 // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1142 // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).1186 // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1143 func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {1187 func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
@@ -1193,6 +1237,8 @@ func (o *Omnibox) performSearch() {
1193 o.mu.RLock()1237 o.mu.RLock()
1194 visible := o.visible1238 visible := o.visible
1195 mode := o.viewMode1239 mode := o.viewMode
1240+ realInput := o.realInput
1241+ hasGhost := o.hasGhostText
1196 o.mu.RUnlock()1242 o.mu.RUnlock()
1197 1243
1198 // Skip search if omnibox is hidden1244 // Skip search if omnibox is hidden
@@ -1200,7 +1246,7 @@ func (o *Omnibox) performSearch() {
1200 return1246 return
1201 }1247 }
1202 1248
1203- query := o.entry.GetText()1249+ query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
1204 1250
1205 // Skip duplicate queries1251 // Skip duplicate queries
1206 o.debounceMu.Lock()1252 o.debounceMu.Lock()
@@ -1239,6 +1285,13 @@ func (o *Omnibox) performSearch() {
1239 o.searchHistory(query, o.effectiveMaxRows(), token)1285 o.searchHistory(query, o.effectiveMaxRows(), token)
1240 }1286 }
1241 1287
1288+func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1289+ if hasGhost && realInput != "" {
1290+ return realInput
1291+ }
1292+ return entryText
1293+}
1294+
1242 // searchHistory runs a fuzzy history search in a background goroutine.1295 // searchHistory runs a fuzzy history search in a background goroutine.
1243 // query is the search text; limit caps the number of results.1296 // query is the search text; limit caps the number of results.
1244 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {1297 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
@@ -1824,7 +1877,28 @@ func (o *Omnibox) selectPrevious() {
1824 // selectAndNavigate selects an index and navigates to it.1877 // selectAndNavigate selects an index and navigates to it.
1825 func (o *Omnibox) selectAndNavigate(index int) {1878 func (o *Omnibox) selectAndNavigate(index int) {
1826 o.selectIndex(index)1879 o.selectIndex(index)
1827- 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+ }
1828 }1902 }
1829 1903
1830 // navigateToSelected navigates to the currently selected item or typed URL.1904 // navigateToSelected navigates to the currently selected item or typed URL.
@@ -1867,21 +1941,13 @@ func (o *Omnibox) navigateToSelected() {
1867 }1941 }
1868 1942
1869 var targetURL string1943 var targetURL string
1870-1944+ if shouldPreferTypedURLNavigation(entryText) {
1871- // If user has selected a result, navigate to that result1945+ targetURL = o.buildURL(entryText)
1872- // Otherwise, treat entry text as URL/search1946+ } else if idx >= 0 {
1873- if idx >= 0 {1947+ // If user has selected a result, navigate to that result.
1874- if mode == ViewModeHistory {1948+ targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
1875- if idx < len(suggestions) {
1876- targetURL = suggestions[idx].URL
1877- }
1878- } else {
1879- if idx < len(favorites) {
1880- targetURL = favorites[idx].URL
1881- }
1882- }
1883 } else {1949 } else {
1884- // No selection - use entry text as URL/search1950+ // No selection - use entry text as URL/search.
1885 targetURL = o.buildURL(entryText)1951 targetURL = o.buildURL(entryText)
1886 }1952 }
1887 1953
@@ -1896,6 +1962,27 @@ func (o *Omnibox) navigateToSelected() {
1896 }1962 }
1897 }1963 }
1898 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+
1978+func shouldPreferTypedURLNavigation(entryText string) bool {
1979+ entryText = strings.TrimSpace(entryText)
1980+ if entryText == "" {
1981+ return false
1982+ }
1983+ return url.LooksLikeURL(entryText)
1984+}
1985+
1899 // toggleFavorite adds or removes the selected item from favorites.1986 // toggleFavorite adds or removes the selected item from favorites.
1900 // In History mode: adds the selected item to favorites1987 // In History mode: adds the selected item to favorites
1901 // In Favorites mode: removes the selected item from favorites1988 // In Favorites mode: removes the selected item from favorites
@@ -2142,6 +2229,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2142 o.ghostFullText = ""2229 o.ghostFullText = ""
2143 o.hasGhostText = false2230 o.hasGhostText = false
2144 o.isAcceptingGhost = false2231 o.isAcceptingGhost = false
2232+ o.suppressGhost = false
2145 o.mu.Unlock()2233 o.mu.Unlock()
2146 2234
2147 // Set initial query2235 // Set initial query
@@ -2174,6 +2262,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2174 2262
2175 // Focus the entry2263 // Focus the entry
2176 o.entry.GrabFocus()2264 o.entry.GrabFocus()
2265+ o.resetSearchSessionState()
2177 2266
2178 // Load initial data (may update size later if results found)2267 // Load initial data (may update size later if results found)
2179 o.performSearch()2268 o.performSearch()
@@ -2212,13 +2301,21 @@ func (o *Omnibox) Hide(ctx context.Context) {
2212 // Reset realInput2301 // Reset realInput
2213 o.mu.Lock()2302 o.mu.Lock()
2214 o.realInput = ""2303 o.realInput = ""
2304+ o.suppressGhost = false
2215 o.mu.Unlock()2305 o.mu.Unlock()
2306+ o.resetSearchSessionState()
2216 2307
2217 if o.onClose != nil {2308 if o.onClose != nil {
2218 o.onClose()2309 o.onClose()
2219 }2310 }
2220 }2311 }
2221 2312
2313+func (o *Omnibox) resetSearchSessionState() {
2314+ o.debounceMu.Lock()
2315+ o.lastQuery = ""
2316+ o.debounceMu.Unlock()
2317+}
2318+
2222 // Toggle shows if hidden, hides if visible.2319 // Toggle shows if hidden, hides if visible.
2223 func (o *Omnibox) Toggle(ctx context.Context) {2320 func (o *Omnibox) Toggle(ctx context.Context) {
2224 o.mu.RLock()2321 o.mu.RLock()
M internal/ui/component/omnibox_ghost_normalize_test.go +30 -0
@@ -33,3 +33,33 @@ func TestExtractHostForCompletion(t *testing.T) {
3333 t.Fatalf("expected normalized host, got %q", got)
3434 }
3535 }
36+
37+func TestGhostCompletionInput(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ entryText string
41+ wantPrefix string
42+ wantTrimmed string
43+ wantOK bool
44+ }{
45+ {name: "no leading whitespace", entryText: "goo", wantPrefix: "", wantTrimmed: "goo", wantOK: true},
46+ {name: "leading space", entryText: " goo", wantPrefix: " ", wantTrimmed: "goo", wantOK: true},
47+ {name: "leading tab", entryText: "\tgoo", wantPrefix: "\t", wantTrimmed: "goo", wantOK: true},
48+ {name: "only whitespace", entryText: " \t", wantPrefix: " \t", wantTrimmed: "", wantOK: false},
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ gotPrefix, gotTrimmed, gotOK := ghostCompletionInput(tt.entryText)
54+ if gotPrefix != tt.wantPrefix {
55+ t.Fatalf("ghostCompletionInput(%q) prefix = %q, want %q", tt.entryText, gotPrefix, tt.wantPrefix)
56+ }
57+ if gotTrimmed != tt.wantTrimmed {
58+ t.Fatalf("ghostCompletionInput(%q) trimmed = %q, want %q", tt.entryText, gotTrimmed, tt.wantTrimmed)
59+ }
60+ if gotOK != tt.wantOK {
61+ t.Fatalf("ghostCompletionInput(%q) ok = %v, want %v", tt.entryText, gotOK, tt.wantOK)
62+ }
63+ })
64+ }
65+}
@@ -33,3 +33,33 @@ func TestExtractHostForCompletion(t *testing.T) {
33 t.Fatalf("expected normalized host, got %q", got)33 t.Fatalf("expected normalized host, got %q", got)
34 }34 }
35 }35 }
36+
37+func TestGhostCompletionInput(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ entryText string
41+ wantPrefix string
42+ wantTrimmed string
43+ wantOK bool
44+ }{
45+ {name: "no leading whitespace", entryText: "goo", wantPrefix: "", wantTrimmed: "goo", wantOK: true},
46+ {name: "leading space", entryText: " goo", wantPrefix: " ", wantTrimmed: "goo", wantOK: true},
47+ {name: "leading tab", entryText: "\tgoo", wantPrefix: "\t", wantTrimmed: "goo", wantOK: true},
48+ {name: "only whitespace", entryText: " \t", wantPrefix: " \t", wantTrimmed: "", wantOK: false},
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ gotPrefix, gotTrimmed, gotOK := ghostCompletionInput(tt.entryText)
54+ if gotPrefix != tt.wantPrefix {
55+ t.Fatalf("ghostCompletionInput(%q) prefix = %q, want %q", tt.entryText, gotPrefix, tt.wantPrefix)
56+ }
57+ if gotTrimmed != tt.wantTrimmed {
58+ t.Fatalf("ghostCompletionInput(%q) trimmed = %q, want %q", tt.entryText, gotTrimmed, tt.wantTrimmed)
59+ }
60+ if gotOK != tt.wantOK {
61+ t.Fatalf("ghostCompletionInput(%q) ok = %v, want %v", tt.entryText, gotOK, tt.wantOK)
62+ }
63+ })
64+ }
65+}
A internal/ui/component/omnibox_navigation_state_test.go +147 -0
@@ -0,0 +1,147 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+func TestShouldPreferTypedURLNavigation(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want bool
14+ }{
15+ {name: "domain", input: "x.com", want: true},
16+ {name: "domain with path", input: "github.com/bnema", want: true},
17+ {name: "http url", input: "http://example.com", want: true},
18+ {name: "localhost", input: "localhost", want: true},
19+ {name: "ipv4", input: "127.0.0.1", want: true},
20+ {name: "ipv4 with port", input: "192.168.1.1:8080", want: true},
21+ {name: "ipv6 bracketed", input: "[::1]", want: true},
22+ {name: "http ipv6 bracketed", input: "http://[::1]", want: true},
23+ {name: "search query", input: "hello world", want: false},
24+ {name: "empty", input: "", want: false},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want {
30+ t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestResetSearchSessionState(t *testing.T) {
37+ o := &Omnibox{}
38+ o.lastQuery = "github"
39+
40+ o.resetSearchSessionState()
41+
42+ if o.lastQuery != "" {
43+ t.Fatalf("lastQuery should be reset, got %q", o.lastQuery)
44+ }
45+}
46+
47+func TestShouldUpdateGhostImmediately(t *testing.T) {
48+ tests := []struct {
49+ name string
50+ previousInput string
51+ entryText string
52+ want bool
53+ }{
54+ {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},
55+ {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},
56+ {name: "backspace", previousInput: "goog", entryText: "goo", want: false},
57+ {name: "clear input", previousInput: "goo", entryText: "", want: false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {
63+ t.Fatalf(
64+ "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
65+ tt.previousInput,
66+ tt.entryText,
67+ got,
68+ tt.want,
69+ )
70+ }
71+ })
72+ }
73+}
74+
75+func TestNextGhostSuppressionState(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ current bool
79+ key uint
80+ want bool
81+ }{
82+ {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
83+ {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
84+ {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
85+ {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ if got := nextGhostSuppressionState(tt.key); got != tt.want {
91+ t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
92+ }
93+ })
94+ }
95+}
96+
97+func TestEffectiveSearchQuery(t *testing.T) {
98+ tests := []struct {
99+ name string
100+ entryText string
101+ realInput string
102+ hasGhost bool
103+ want string
104+ }{
105+ {name: "uses typed input when ghost visible", entryText: "dumber.bnema.dev", realInput: "dumb", hasGhost: true, want: "dumb"},
106+ {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"},
107+ {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"},
108+ }
109+
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ if got := effectiveSearchQuery(tt.entryText, tt.realInput, tt.hasGhost); got != tt.want {
113+ t.Fatalf("effectiveSearchQuery(%q, %q, %v) = %q, want %q", tt.entryText, tt.realInput, tt.hasGhost, got, tt.want)
114+ }
115+ })
116+ }
117+}
118+
119+func TestResolveTargetURLForSelection(t *testing.T) {
120+ suggestions := []Suggestion{
121+ {URL: "https://github.com/bnema/dumber"},
122+ {URL: "https://github.com/bnema/dumber/pulls"},
123+ }
124+ favorites := []Favorite{
125+ {URL: "https://dumber.bnema.dev"},
126+ }
127+
128+ tests := []struct {
129+ name string
130+ mode ViewMode
131+ index int
132+ wantURL string
133+ }{
134+ {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"},
135+ {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"},
136+ {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites)
142+ if got != tt.wantURL {
143+ t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL)
144+ }
145+ })
146+ }
147+}
@@ -0,0 +1,147 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+func TestShouldPreferTypedURLNavigation(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want bool
14+ }{
15+ {name: "domain", input: "x.com", want: true},
16+ {name: "domain with path", input: "github.com/bnema", want: true},
17+ {name: "http url", input: "http://example.com", want: true},
18+ {name: "localhost", input: "localhost", want: true},
19+ {name: "ipv4", input: "127.0.0.1", want: true},
20+ {name: "ipv4 with port", input: "192.168.1.1:8080", want: true},
21+ {name: "ipv6 bracketed", input: "[::1]", want: true},
22+ {name: "http ipv6 bracketed", input: "http://[::1]", want: true},
23+ {name: "search query", input: "hello world", want: false},
24+ {name: "empty", input: "", want: false},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want {
30+ t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestResetSearchSessionState(t *testing.T) {
37+ o := &Omnibox{}
38+ o.lastQuery = "github"
39+
40+ o.resetSearchSessionState()
41+
42+ if o.lastQuery != "" {
43+ t.Fatalf("lastQuery should be reset, got %q", o.lastQuery)
44+ }
45+}
46+
47+func TestShouldUpdateGhostImmediately(t *testing.T) {
48+ tests := []struct {
49+ name string
50+ previousInput string
51+ entryText string
52+ want bool
53+ }{
54+ {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},
55+ {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},
56+ {name: "backspace", previousInput: "goog", entryText: "goo", want: false},
57+ {name: "clear input", previousInput: "goo", entryText: "", want: false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {
63+ t.Fatalf(
64+ "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
65+ tt.previousInput,
66+ tt.entryText,
67+ got,
68+ tt.want,
69+ )
70+ }
71+ })
72+ }
73+}
74+
75+func TestNextGhostSuppressionState(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ current bool
79+ key uint
80+ want bool
81+ }{
82+ {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
83+ {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
84+ {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
85+ {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ if got := nextGhostSuppressionState(tt.key); got != tt.want {
91+ t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
92+ }
93+ })
94+ }
95+}
96+
97+func TestEffectiveSearchQuery(t *testing.T) {
98+ tests := []struct {
99+ name string
100+ entryText string
101+ realInput string
102+ hasGhost bool
103+ want string
104+ }{
105+ {name: "uses typed input when ghost visible", entryText: "dumber.bnema.dev", realInput: "dumb", hasGhost: true, want: "dumb"},
106+ {name: "falls back to entry when no ghost", entryText: "dumb", realInput: "dumb", hasGhost: false, want: "dumb"},
107+ {name: "uses entry when real input unavailable", entryText: "dumb", realInput: "", hasGhost: true, want: "dumb"},
108+ }
109+
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ if got := effectiveSearchQuery(tt.entryText, tt.realInput, tt.hasGhost); got != tt.want {
113+ t.Fatalf("effectiveSearchQuery(%q, %q, %v) = %q, want %q", tt.entryText, tt.realInput, tt.hasGhost, got, tt.want)
114+ }
115+ })
116+ }
117+}
118+
119+func TestResolveTargetURLForSelection(t *testing.T) {
120+ suggestions := []Suggestion{
121+ {URL: "https://github.com/bnema/dumber"},
122+ {URL: "https://github.com/bnema/dumber/pulls"},
123+ }
124+ favorites := []Favorite{
125+ {URL: "https://dumber.bnema.dev"},
126+ }
127+
128+ tests := []struct {
129+ name string
130+ mode ViewMode
131+ index int
132+ wantURL string
133+ }{
134+ {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"},
135+ {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"},
136+ {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites)
142+ if got != tt.wantURL {
143+ t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL)
144+ }
145+ })
146+ }
147+}

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