detached 39e6692

fix: improve omnibox URL intent and ghost completion stability

Prioritize typed URL-like input on Enter and normalize bare IP targets to HTTP so local/tailnet services open reliably. Stabilize ghost completion by handling leading whitespace, resetting stale query state between sessions, and suppressing ghost rewrites during deletion to keep backspace behavior predictable.
commit
39e6692e93c1f15758ea05cd376378d926748e8b
author
brice · · 2026-02-17 17:37 UTC
parent
5960dd8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +246 -8
M internal/domain/url/normalize.go +22 -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://".
@@ -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://".
M internal/domain/url/normalize_test.go +26 -0
@@ -390,6 +390,11 @@ 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+ },
393398 }
394399
395400 for _, tt := range tests {
@@ -402,6 +407,27 @@ func TestLooksLikeURL(t *testing.T) {
402407 }
403408 }
404409
410+func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
411+ tests := []struct {
412+ name string
413+ input string
414+ want string
415+ }{
416+ {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
417+ {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
418+ {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
419+ }
420+
421+ for _, tt := range tests {
422+ t.Run(tt.name, func(t *testing.T) {
423+ got := Normalize(tt.input)
424+ if got != tt.want {
425+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
426+ }
427+ })
428+ }
429+}
430+
405431 func TestIsExternalScheme(t *testing.T) {
406432 tests := []struct {
407433 name string
@@ -390,6 +390,11 @@ 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+ },
393 }398 }
394 399
395 for _, tt := range tests {400 for _, tt := range tests {
@@ -402,6 +407,27 @@ func TestLooksLikeURL(t *testing.T) {
402 }407 }
403 }408 }
404 409
410+func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
411+ tests := []struct {
412+ name string
413+ input string
414+ want string
415+ }{
416+ {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
417+ {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
418+ {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
419+ }
420+
421+ for _, tt := range tests {
422+ t.Run(tt.name, func(t *testing.T) {
423+ got := Normalize(tt.input)
424+ if got != tt.want {
425+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
426+ }
427+ })
428+ }
429+}
430+
405 func TestIsExternalScheme(t *testing.T) {431 func TestIsExternalScheme(t *testing.T) {
406 tests := []struct {432 tests := []struct {
407 name string433 name string
M internal/ui/component/omnibox.go +78 -8
@@ -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
@@ -681,6 +682,10 @@ func (o *Omnibox) hasUserNavigated() bool {
681682 // handleKeyPress processes keyboard events.
682683 // Returns true if the event was handled.
683684 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
685+ o.mu.Lock()
686+ o.suppressGhost = nextGhostSuppressionState(o.suppressGhost, keyval)
687+ o.mu.Unlock()
688+
684689 ctrl := state&gdk.ControlMaskValue != 0
685690
686691 switch keyval {
@@ -754,6 +759,22 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
754759 return false // Let entry handle the key
755760 }
756761
762+func nextGhostSuppressionState(_ bool, keyval uint) bool {
763+ if isDeletionKey(keyval) {
764+ return true
765+ }
766+ return false
767+}
768+
769+func isDeletionKey(keyval uint) bool {
770+ switch keyval {
771+ case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
772+ return true
773+ default:
774+ return false
775+ }
776+}
777+
757778 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
758779 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
759780 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -813,6 +834,7 @@ func (o *Omnibox) onEntryChanged() {
813834
814835 // Reset navigation state when user types - space should type, not toggle favorite
815836 o.mu.Lock()
837+ previousInput := o.realInput
816838 o.hasNavigated = false
817839 // Update realInput with what user actually typed
818840 o.realInput = entryText
@@ -820,6 +842,11 @@ func (o *Omnibox) onEntryChanged() {
820842
821843 // Clear ghost text when user types (it will be recalculated after search)
822844 o.clearGhostText()
845+ // Recompute immediately from currently visible rows to avoid transient gaps
846+ // where suggestions are visible but ghost text is missing until async search returns.
847+ if shouldUpdateGhostImmediately(previousInput, entryText) {
848+ o.updateGhostFromSelectionWithInput(entryText)
849+ }
823850
824851 o.debounceMu.Lock()
825852 if o.debounceTimer != nil {
@@ -831,6 +858,10 @@ func (o *Omnibox) onEntryChanged() {
831858 o.debounceMu.Unlock()
832859 }
833860
861+func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
862+ return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
863+}
864+
834865 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
835866 o.mu.RLock()
836867 hasGhost := o.hasGhostText
@@ -1047,10 +1078,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
10471078 mode := o.viewMode
10481079 bangMode := o.bangMode
10491080 hasNavigated := o.hasNavigated
1081+ suppressGhost := o.suppressGhost
10501082 suggestions := o.suggestions
10511083 favorites := o.favorites
10521084 o.mu.RUnlock()
10531085
1086+ if suppressGhost {
1087+ o.clearGhostTextIfInput(entryText)
1088+ return
1089+ }
1090+
10541091 // No ghost text in bang mode
10551092 if bangMode {
10561093 o.clearGhostTextIfInput(entryText)
@@ -1096,6 +1133,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
10961133 return
10971134 }
10981135
1136+ leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1137+ if !ok {
1138+ o.clearGhostTextIfInput(entryText)
1139+ return
1140+ }
1141+
10991142 visibleURLs := make([]string, 0, len(suggestions))
11001143 if mode == ViewModeHistory {
11011144 for _, s := range suggestions {
@@ -1118,7 +1161,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11181161 o.mu.Unlock()
11191162
11201163 go func() {
1121- suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1164+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{
11221165 VisibleURLs: visibleURLs,
11231166 AllowBangs: false,
11241167 })
@@ -1129,15 +1172,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
11291172 o.clearGhostTextIfInput(entryText)
11301173 return
11311174 }
1132- fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)
1175+ fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix)
11331176 if fullText == "" || suffix == "" {
11341177 o.clearGhostTextIfInput(entryText)
11351178 return
11361179 }
1137- o.setGhostText(entryText, suffix, fullText)
1180+ o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
11381181 }()
11391182 }
11401183
1184+func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1185+ trimmed := strings.TrimLeft(entryText, " \t")
1186+ leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1187+ if trimmed == "" {
1188+ return leadingWhitespace, "", false
1189+ }
1190+ return leadingWhitespace, trimmed, true
1191+}
1192+
11411193 // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
11421194 // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
11431195 func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
@@ -1867,10 +1919,10 @@ func (o *Omnibox) navigateToSelected() {
18671919 }
18681920
18691921 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 {
1922+ if shouldPreferTypedURLNavigation(entryText) {
1923+ targetURL = o.buildURL(entryText)
1924+ } else if idx >= 0 {
1925+ // If user has selected a result, navigate to that result.
18741926 if mode == ViewModeHistory {
18751927 if idx < len(suggestions) {
18761928 targetURL = suggestions[idx].URL
@@ -1881,7 +1933,7 @@ func (o *Omnibox) navigateToSelected() {
18811933 }
18821934 }
18831935 } else {
1884- // No selection - use entry text as URL/search
1936+ // No selection - use entry text as URL/search.
18851937 targetURL = o.buildURL(entryText)
18861938 }
18871939
@@ -1896,6 +1948,14 @@ func (o *Omnibox) navigateToSelected() {
18961948 }
18971949 }
18981950
1951+func shouldPreferTypedURLNavigation(entryText string) bool {
1952+ entryText = strings.TrimSpace(entryText)
1953+ if entryText == "" {
1954+ return false
1955+ }
1956+ return url.LooksLikeURL(entryText)
1957+}
1958+
18991959 // toggleFavorite adds or removes the selected item from favorites.
19001960 // In History mode: adds the selected item to favorites
19011961 // In Favorites mode: removes the selected item from favorites
@@ -2142,6 +2202,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21422202 o.ghostFullText = ""
21432203 o.hasGhostText = false
21442204 o.isAcceptingGhost = false
2205+ o.suppressGhost = false
21452206 o.mu.Unlock()
21462207
21472208 // Set initial query
@@ -2174,6 +2235,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21742235
21752236 // Focus the entry
21762237 o.entry.GrabFocus()
2238+ o.resetSearchSessionState()
21772239
21782240 // Load initial data (may update size later if results found)
21792241 o.performSearch()
@@ -2212,13 +2274,21 @@ func (o *Omnibox) Hide(ctx context.Context) {
22122274 // Reset realInput
22132275 o.mu.Lock()
22142276 o.realInput = ""
2277+ o.suppressGhost = false
22152278 o.mu.Unlock()
2279+ o.resetSearchSessionState()
22162280
22172281 if o.onClose != nil {
22182282 o.onClose()
22192283 }
22202284 }
22212285
2286+func (o *Omnibox) resetSearchSessionState() {
2287+ o.debounceMu.Lock()
2288+ o.lastQuery = ""
2289+ o.debounceMu.Unlock()
2290+}
2291+
22222292 // Toggle shows if hidden, hides if visible.
22232293 func (o *Omnibox) Toggle(ctx context.Context) {
22242294 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
@@ -681,6 +682,10 @@ func (o *Omnibox) hasUserNavigated() bool {
681 // handleKeyPress processes keyboard events.682 // handleKeyPress processes keyboard events.
682 // Returns true if the event was handled.683 // Returns true if the event was handled.
683 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {684 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
685+ o.mu.Lock()
686+ o.suppressGhost = nextGhostSuppressionState(o.suppressGhost, keyval)
687+ o.mu.Unlock()
688+
684 ctrl := state&gdk.ControlMaskValue != 0689 ctrl := state&gdk.ControlMaskValue != 0
685 690
686 switch keyval {691 switch keyval {
@@ -754,6 +759,22 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
754 return false // Let entry handle the key759 return false // Let entry handle the key
755 }760 }
756 761
762+func nextGhostSuppressionState(_ bool, keyval uint) bool {
763+ if isDeletionKey(keyval) {
764+ return true
765+ }
766+ return false
767+}
768+
769+func isDeletionKey(keyval uint) bool {
770+ switch keyval {
771+ case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
772+ return true
773+ default:
774+ return false
775+ }
776+}
777+
757 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.778 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
758 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).779 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
759 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {780 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -813,6 +834,7 @@ func (o *Omnibox) onEntryChanged() {
813 834
814 // Reset navigation state when user types - space should type, not toggle favorite835 // Reset navigation state when user types - space should type, not toggle favorite
815 o.mu.Lock()836 o.mu.Lock()
837+ previousInput := o.realInput
816 o.hasNavigated = false838 o.hasNavigated = false
817 // Update realInput with what user actually typed839 // Update realInput with what user actually typed
818 o.realInput = entryText840 o.realInput = entryText
@@ -820,6 +842,11 @@ func (o *Omnibox) onEntryChanged() {
820 842
821 // Clear ghost text when user types (it will be recalculated after search)843 // Clear ghost text when user types (it will be recalculated after search)
822 o.clearGhostText()844 o.clearGhostText()
845+ // Recompute immediately from currently visible rows to avoid transient gaps
846+ // where suggestions are visible but ghost text is missing until async search returns.
847+ if shouldUpdateGhostImmediately(previousInput, entryText) {
848+ o.updateGhostFromSelectionWithInput(entryText)
849+ }
823 850
824 o.debounceMu.Lock()851 o.debounceMu.Lock()
825 if o.debounceTimer != nil {852 if o.debounceTimer != nil {
@@ -831,6 +858,10 @@ func (o *Omnibox) onEntryChanged() {
831 o.debounceMu.Unlock()858 o.debounceMu.Unlock()
832 }859 }
833 860
861+func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
862+ return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
863+}
864+
834 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {865 func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
835 o.mu.RLock()866 o.mu.RLock()
836 hasGhost := o.hasGhostText867 hasGhost := o.hasGhostText
@@ -1047,10 +1078,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1047 mode := o.viewMode1078 mode := o.viewMode
1048 bangMode := o.bangMode1079 bangMode := o.bangMode
1049 hasNavigated := o.hasNavigated1080 hasNavigated := o.hasNavigated
1081+ suppressGhost := o.suppressGhost
1050 suggestions := o.suggestions1082 suggestions := o.suggestions
1051 favorites := o.favorites1083 favorites := o.favorites
1052 o.mu.RUnlock()1084 o.mu.RUnlock()
1053 1085
1086+ if suppressGhost {
1087+ o.clearGhostTextIfInput(entryText)
1088+ return
1089+ }
1090+
1054 // No ghost text in bang mode1091 // No ghost text in bang mode
1055 if bangMode {1092 if bangMode {
1056 o.clearGhostTextIfInput(entryText)1093 o.clearGhostTextIfInput(entryText)
@@ -1096,6 +1133,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1096 return1133 return
1097 }1134 }
1098 1135
1136+ leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1137+ if !ok {
1138+ o.clearGhostTextIfInput(entryText)
1139+ return
1140+ }
1141+
1099 visibleURLs := make([]string, 0, len(suggestions))1142 visibleURLs := make([]string, 0, len(suggestions))
1100 if mode == ViewModeHistory {1143 if mode == ViewModeHistory {
1101 for _, s := range suggestions {1144 for _, s := range suggestions {
@@ -1118,7 +1161,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1118 o.mu.Unlock()1161 o.mu.Unlock()
1119 1162
1120 go func() {1163 go func() {
1121- suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{1164+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{
1122 VisibleURLs: visibleURLs,1165 VisibleURLs: visibleURLs,
1123 AllowBangs: false,1166 AllowBangs: false,
1124 })1167 })
@@ -1129,15 +1172,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1129 o.clearGhostTextIfInput(entryText)1172 o.clearGhostTextIfInput(entryText)
1130 return1173 return
1131 }1174 }
1132- fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)1175+ fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix)
1133 if fullText == "" || suffix == "" {1176 if fullText == "" || suffix == "" {
1134 o.clearGhostTextIfInput(entryText)1177 o.clearGhostTextIfInput(entryText)
1135 return1178 return
1136 }1179 }
1137- o.setGhostText(entryText, suffix, fullText)1180+ o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
1138 }()1181 }()
1139 }1182 }
1140 1183
1184+func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1185+ trimmed := strings.TrimLeft(entryText, " \t")
1186+ leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1187+ if trimmed == "" {
1188+ return leadingWhitespace, "", false
1189+ }
1190+ return leadingWhitespace, trimmed, true
1191+}
1192+
1141 // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input1193 // 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).1194 // 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) {1195 func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
@@ -1867,10 +1919,10 @@ func (o *Omnibox) navigateToSelected() {
1867 }1919 }
1868 1920
1869 var targetURL string1921 var targetURL string
1870-1922+ if shouldPreferTypedURLNavigation(entryText) {
1871- // If user has selected a result, navigate to that result1923+ targetURL = o.buildURL(entryText)
1872- // Otherwise, treat entry text as URL/search1924+ } else if idx >= 0 {
1873- if idx >= 0 {1925+ // If user has selected a result, navigate to that result.
1874 if mode == ViewModeHistory {1926 if mode == ViewModeHistory {
1875 if idx < len(suggestions) {1927 if idx < len(suggestions) {
1876 targetURL = suggestions[idx].URL1928 targetURL = suggestions[idx].URL
@@ -1881,7 +1933,7 @@ func (o *Omnibox) navigateToSelected() {
1881 }1933 }
1882 }1934 }
1883 } else {1935 } else {
1884- // No selection - use entry text as URL/search1936+ // No selection - use entry text as URL/search.
1885 targetURL = o.buildURL(entryText)1937 targetURL = o.buildURL(entryText)
1886 }1938 }
1887 1939
@@ -1896,6 +1948,14 @@ func (o *Omnibox) navigateToSelected() {
1896 }1948 }
1897 }1949 }
1898 1950
1951+func shouldPreferTypedURLNavigation(entryText string) bool {
1952+ entryText = strings.TrimSpace(entryText)
1953+ if entryText == "" {
1954+ return false
1955+ }
1956+ return url.LooksLikeURL(entryText)
1957+}
1958+
1899 // toggleFavorite adds or removes the selected item from favorites.1959 // toggleFavorite adds or removes the selected item from favorites.
1900 // In History mode: adds the selected item to favorites1960 // In History mode: adds the selected item to favorites
1901 // In Favorites mode: removes the selected item from favorites1961 // In Favorites mode: removes the selected item from favorites
@@ -2142,6 +2202,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2142 o.ghostFullText = ""2202 o.ghostFullText = ""
2143 o.hasGhostText = false2203 o.hasGhostText = false
2144 o.isAcceptingGhost = false2204 o.isAcceptingGhost = false
2205+ o.suppressGhost = false
2145 o.mu.Unlock()2206 o.mu.Unlock()
2146 2207
2147 // Set initial query2208 // Set initial query
@@ -2174,6 +2235,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2174 2235
2175 // Focus the entry2236 // Focus the entry
2176 o.entry.GrabFocus()2237 o.entry.GrabFocus()
2238+ o.resetSearchSessionState()
2177 2239
2178 // Load initial data (may update size later if results found)2240 // Load initial data (may update size later if results found)
2179 o.performSearch()2241 o.performSearch()
@@ -2212,13 +2274,21 @@ func (o *Omnibox) Hide(ctx context.Context) {
2212 // Reset realInput2274 // Reset realInput
2213 o.mu.Lock()2275 o.mu.Lock()
2214 o.realInput = ""2276 o.realInput = ""
2277+ o.suppressGhost = false
2215 o.mu.Unlock()2278 o.mu.Unlock()
2279+ o.resetSearchSessionState()
2216 2280
2217 if o.onClose != nil {2281 if o.onClose != nil {
2218 o.onClose()2282 o.onClose()
2219 }2283 }
2220 }2284 }
2221 2285
2286+func (o *Omnibox) resetSearchSessionState() {
2287+ o.debounceMu.Lock()
2288+ o.lastQuery = ""
2289+ o.debounceMu.Unlock()
2290+}
2291+
2222 // Toggle shows if hidden, hides if visible.2292 // Toggle shows if hidden, hides if visible.
2223 func (o *Omnibox) Toggle(ctx context.Context) {2293 func (o *Omnibox) Toggle(ctx context.Context) {
2224 o.mu.RLock()2294 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 +90 -0
@@ -0,0 +1,90 @@
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: "search query", input: "hello world", want: false},
19+ {name: "empty", input: "", want: false},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want {
25+ t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestResetSearchSessionState(t *testing.T) {
32+ o := &Omnibox{}
33+ o.lastQuery = "github"
34+
35+ o.resetSearchSessionState()
36+
37+ if o.lastQuery != "" {
38+ t.Fatalf("lastQuery should be reset, got %q", o.lastQuery)
39+ }
40+}
41+
42+func TestShouldUpdateGhostImmediately(t *testing.T) {
43+ tests := []struct {
44+ name string
45+ previousInput string
46+ entryText string
47+ want bool
48+ }{
49+ {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},
50+ {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},
51+ {name: "backspace", previousInput: "goog", entryText: "goo", want: false},
52+ {name: "clear input", previousInput: "goo", entryText: "", want: false},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {
58+ t.Fatalf(
59+ "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
60+ tt.previousInput,
61+ tt.entryText,
62+ got,
63+ tt.want,
64+ )
65+ }
66+ })
67+ }
68+}
69+
70+func TestNextGhostSuppressionState(t *testing.T) {
71+ tests := []struct {
72+ name string
73+ current bool
74+ key uint
75+ want bool
76+ }{
77+ {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
78+ {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
79+ {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
80+ {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ if got := nextGhostSuppressionState(tt.current, tt.key); got != tt.want {
86+ t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
87+ }
88+ })
89+ }
90+}
@@ -0,0 +1,90 @@
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: "search query", input: "hello world", want: false},
19+ {name: "empty", input: "", want: false},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want {
25+ t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestResetSearchSessionState(t *testing.T) {
32+ o := &Omnibox{}
33+ o.lastQuery = "github"
34+
35+ o.resetSearchSessionState()
36+
37+ if o.lastQuery != "" {
38+ t.Fatalf("lastQuery should be reset, got %q", o.lastQuery)
39+ }
40+}
41+
42+func TestShouldUpdateGhostImmediately(t *testing.T) {
43+ tests := []struct {
44+ name string
45+ previousInput string
46+ entryText string
47+ want bool
48+ }{
49+ {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},
50+ {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},
51+ {name: "backspace", previousInput: "goog", entryText: "goo", want: false},
52+ {name: "clear input", previousInput: "goo", entryText: "", want: false},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {
58+ t.Fatalf(
59+ "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
60+ tt.previousInput,
61+ tt.entryText,
62+ got,
63+ tt.want,
64+ )
65+ }
66+ })
67+ }
68+}
69+
70+func TestNextGhostSuppressionState(t *testing.T) {
71+ tests := []struct {
72+ name string
73+ current bool
74+ key uint
75+ want bool
76+ }{
77+ {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
78+ {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
79+ {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
80+ {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ if got := nextGhostSuppressionState(tt.current, tt.key); got != tt.want {
86+ t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
87+ }
88+ })
89+ }
90+}

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