Merge pull request #174 from bnema/fix/omnibox-url-ip-ghost
fix omnibox URL routing, IP normalization, and ghost stability
5 files · +364 -29
M internal/domain/url/normalize.go +25 -0
| @@ -3,6 +3,7 @@ package url | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | + | "net" | |
| 6 | 7 | "net/url" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -85,6 +86,9 @@ func Normalize(input string) string { | |||
| 85 | 86 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 86 | 87 | return "http://" + input | |
| 87 | 88 | } | |
| 89 | + | if looksLikeIPAddressWithOptionalPortOrPath(input) { | |
| 90 | + | return "http://" + input | |
| 91 | + | } | |
| 88 | 92 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | |
| 89 | 93 | return "https://" + input | |
| 90 | 94 | } | |
| @@ -92,6 +96,24 @@ func Normalize(input string) string { | |||
| 92 | 96 | 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 | 117 | // LooksLikeURL checks if the input appears to be a URL (not a search query). | |
| 96 | 118 | // Returns true for strings like "github.com", "google.com/search", etc. | |
| 97 | 119 | // Also returns true for URLs with explicit schemes like "dumb://". | |
| @@ -118,6 +140,9 @@ func LooksLikeURL(input string) bool { | |||
| 118 | 140 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 119 | 141 | return true | |
| 120 | 142 | } | |
| 143 | + | if looksLikeIPAddressWithOptionalPortOrPath(input) { | |
| 144 | + | return true | |
| 145 | + | } | |
| 121 | 146 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | |
| 122 | 147 | } | |
| 123 | 148 | ||
| @@ -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://" + input | 87 | 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://" + input | 93 | return "https://" + input | ||
| 90 | } | 94 | } | ||
| @@ -92,6 +96,24 @@ func Normalize(input string) string { | |||||
| 92 | return input | 96 | 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 true | 141 | 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) { | |||
| 390 | 390 | input: "localhostevil.com", | |
| 391 | 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 | 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 | 441 | func TestIsExternalScheme(t *testing.T) { | |
| 406 | 442 | tests := []struct { | |
| 407 | 443 | 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 string | 443 | name string | ||
M internal/ui/component/omnibox.go +126 -29
| @@ -98,6 +98,7 @@ type Omnibox struct { | |||
| 98 | 98 | ghostFullText string // Full text that ghost completes to | |
| 99 | 99 | hasGhostText bool // Quick check for Tab behavior | |
| 100 | 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | |
| 101 | + | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |
| 101 | 102 | ||
| 102 | 103 | // Dependencies | |
| 103 | 104 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -577,16 +578,7 @@ func (o *Omnibox) initList() error { | |||
| 577 | 578 | return | |
| 578 | 579 | } | |
| 579 | 580 | ||
| 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) | |
| 590 | 582 | ||
| 591 | 583 | if targetURL != "" && o.onNavigate != nil { | |
| 592 | 584 | o.Hide(o.ctx) | |
| @@ -681,6 +673,8 @@ func (o *Omnibox) hasUserNavigated() bool { | |||
| 681 | 673 | // handleKeyPress processes keyboard events. | |
| 682 | 674 | // Returns true if the event was handled. | |
| 683 | 675 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 676 | + | o.updateGhostSuppressionFromKey(keyval) | |
| 677 | + | ||
| 684 | 678 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 685 | 679 | ||
| 686 | 680 | switch keyval { | |
| @@ -754,6 +748,25 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 754 | 748 | 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 | 770 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | |
| 758 | 771 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | |
| 759 | 772 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | |
| @@ -813,6 +826,7 @@ func (o *Omnibox) onEntryChanged() { | |||
| 813 | 826 | ||
| 814 | 827 | // Reset navigation state when user types - space should type, not toggle favorite | |
| 815 | 828 | o.mu.Lock() | |
| 829 | + | previousInput := o.realInput | |
| 816 | 830 | o.hasNavigated = false | |
| 817 | 831 | // Update realInput with what user actually typed | |
| 818 | 832 | o.realInput = entryText | |
| @@ -820,6 +834,11 @@ func (o *Omnibox) onEntryChanged() { | |||
| 820 | 834 | ||
| 821 | 835 | // Clear ghost text when user types (it will be recalculated after search) | |
| 822 | 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 | 843 | o.debounceMu.Lock() | |
| 825 | 844 | if o.debounceTimer != nil { | |
| @@ -831,6 +850,10 @@ func (o *Omnibox) onEntryChanged() { | |||
| 831 | 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 | 857 | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 835 | 858 | o.mu.RLock() | |
| 836 | 859 | hasGhost := o.hasGhostText | |
| @@ -1047,10 +1070,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1047 | 1070 | mode := o.viewMode | |
| 1048 | 1071 | bangMode := o.bangMode | |
| 1049 | 1072 | hasNavigated := o.hasNavigated | |
| 1073 | + | suppressGhost := o.suppressGhost | |
| 1050 | 1074 | suggestions := o.suggestions | |
| 1051 | 1075 | favorites := o.favorites | |
| 1052 | 1076 | o.mu.RUnlock() | |
| 1053 | 1077 | ||
| 1078 | + | if suppressGhost { | |
| 1079 | + | o.clearGhostTextIfInput(entryText) | |
| 1080 | + | return | |
| 1081 | + | } | |
| 1082 | + | ||
| 1054 | 1083 | // No ghost text in bang mode | |
| 1055 | 1084 | if bangMode { | |
| 1056 | 1085 | o.clearGhostTextIfInput(entryText) | |
| @@ -1096,6 +1125,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1096 | 1125 | return | |
| 1097 | 1126 | } | |
| 1098 | 1127 | ||
| 1128 | + | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |
| 1129 | + | if !ok { | |
| 1130 | + | o.clearGhostTextIfInput(entryText) | |
| 1131 | + | return | |
| 1132 | + | } | |
| 1133 | + | ||
| 1099 | 1134 | visibleURLs := make([]string, 0, len(suggestions)) | |
| 1100 | 1135 | if mode == ViewModeHistory { | |
| 1101 | 1136 | for _, s := range suggestions { | |
| @@ -1118,7 +1153,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1118 | 1153 | o.mu.Unlock() | |
| 1119 | 1154 | ||
| 1120 | 1155 | go func() { | |
| 1121 | - | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | |
| 1156 | + | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{ | |
| 1122 | 1157 | VisibleURLs: visibleURLs, | |
| 1123 | 1158 | AllowBangs: false, | |
| 1124 | 1159 | }) | |
| @@ -1129,15 +1164,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1129 | 1164 | o.clearGhostTextIfInput(entryText) | |
| 1130 | 1165 | return | |
| 1131 | 1166 | } | |
| 1132 | - | fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix) | |
| 1167 | + | fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix) | |
| 1133 | 1168 | if fullText == "" || suffix == "" { | |
| 1134 | 1169 | o.clearGhostTextIfInput(entryText) | |
| 1135 | 1170 | 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 | 1185 | // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input | |
| 1142 | 1186 | // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL). | |
| 1143 | 1187 | func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) { | |
| @@ -1193,6 +1237,8 @@ func (o *Omnibox) performSearch() { | |||
| 1193 | 1237 | o.mu.RLock() | |
| 1194 | 1238 | visible := o.visible | |
| 1195 | 1239 | mode := o.viewMode | |
| 1240 | + | realInput := o.realInput | |
| 1241 | + | hasGhost := o.hasGhostText | |
| 1196 | 1242 | o.mu.RUnlock() | |
| 1197 | 1243 | ||
| 1198 | 1244 | // Skip search if omnibox is hidden | |
| @@ -1200,7 +1246,7 @@ func (o *Omnibox) performSearch() { | |||
| 1200 | 1246 | return | |
| 1201 | 1247 | } | |
| 1202 | 1248 | ||
| 1203 | - | query := o.entry.GetText() | |
| 1249 | + | query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost) | |
| 1204 | 1250 | ||
| 1205 | 1251 | // Skip duplicate queries | |
| 1206 | 1252 | o.debounceMu.Lock() | |
| @@ -1239,6 +1285,13 @@ func (o *Omnibox) performSearch() { | |||
| 1239 | 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 | 1295 | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1243 | 1296 | // query is the search text; limit caps the number of results. | |
| 1244 | 1297 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | |
| @@ -1824,7 +1877,28 @@ func (o *Omnibox) selectPrevious() { | |||
| 1824 | 1877 | // selectAndNavigate selects an index and navigates to it. | |
| 1825 | 1878 | func (o *Omnibox) selectAndNavigate(index int) { | |
| 1826 | 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 | 1904 | // navigateToSelected navigates to the currently selected item or typed URL. | |
| @@ -1867,21 +1941,13 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1867 | 1941 | } | |
| 1868 | 1942 | ||
| 1869 | 1943 | 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) | |
| 1883 | 1949 | } else { | |
| 1884 | - | // No selection - use entry text as URL/search | |
| 1950 | + | // No selection - use entry text as URL/search. | |
| 1885 | 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 | 1986 | // toggleFavorite adds or removes the selected item from favorites. | |
| 1900 | 1987 | // In History mode: adds the selected item to favorites | |
| 1901 | 1988 | // In Favorites mode: removes the selected item from favorites | |
| @@ -2142,6 +2229,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2142 | 2229 | o.ghostFullText = "" | |
| 2143 | 2230 | o.hasGhostText = false | |
| 2144 | 2231 | o.isAcceptingGhost = false | |
| 2232 | + | o.suppressGhost = false | |
| 2145 | 2233 | o.mu.Unlock() | |
| 2146 | 2234 | ||
| 2147 | 2235 | // Set initial query | |
| @@ -2174,6 +2262,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2174 | 2262 | ||
| 2175 | 2263 | // Focus the entry | |
| 2176 | 2264 | o.entry.GrabFocus() | |
| 2265 | + | o.resetSearchSessionState() | |
| 2177 | 2266 | ||
| 2178 | 2267 | // Load initial data (may update size later if results found) | |
| 2179 | 2268 | o.performSearch() | |
| @@ -2212,13 +2301,21 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 2212 | 2301 | // Reset realInput | |
| 2213 | 2302 | o.mu.Lock() | |
| 2214 | 2303 | o.realInput = "" | |
| 2304 | + | o.suppressGhost = false | |
| 2215 | 2305 | o.mu.Unlock() | |
| 2306 | + | o.resetSearchSessionState() | |
| 2216 | 2307 | ||
| 2217 | 2308 | if o.onClose != nil { | |
| 2218 | 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 | 2319 | // Toggle shows if hidden, hides if visible. | |
| 2223 | 2320 | func (o *Omnibox) Toggle(ctx context.Context) { | |
| 2224 | 2321 | o.mu.RLock() | |
| @@ -98,6 +98,7 @@ type Omnibox struct { | |||||
| 98 | ghostFullText string // Full text that ghost completes to | 98 | ghostFullText string // Full text that ghost completes to | ||
| 99 | hasGhostText bool // Quick check for Tab behavior | 99 | hasGhostText bool // Quick check for Tab behavior | ||
| 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | ||
| 101 | + | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |||
| 101 | 102 | ||||
| 102 | // Dependencies | 103 | // Dependencies | ||
| 103 | historyUC *usecase.SearchHistoryUseCase | 104 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -577,16 +578,7 @@ func (o *Omnibox) initList() error { | |||||
| 577 | return | 578 | return | ||
| 578 | } | 579 | } | ||
| 579 | 580 | ||||
| 580 | - | var targetURL string | 581 | + | 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 != 0 | 678 | 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 key | 748 | 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 favorite | 827 | // 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 = false | 830 | o.hasNavigated = false | ||
| 817 | // Update realInput with what user actually typed | 831 | // Update realInput with what user actually typed | ||
| 818 | o.realInput = entryText | 832 | 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.hasGhostText | 859 | hasGhost := o.hasGhostText | ||
| @@ -1047,10 +1070,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1047 | mode := o.viewMode | 1070 | mode := o.viewMode | ||
| 1048 | bangMode := o.bangMode | 1071 | bangMode := o.bangMode | ||
| 1049 | hasNavigated := o.hasNavigated | 1072 | hasNavigated := o.hasNavigated | ||
| 1073 | + | suppressGhost := o.suppressGhost | |||
| 1050 | suggestions := o.suggestions | 1074 | suggestions := o.suggestions | ||
| 1051 | favorites := o.favorites | 1075 | 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 mode | 1083 | // 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 | return | 1125 | 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 | return | 1165 | 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 | return | 1170 | 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 input | 1185 | // 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.visible | 1238 | visible := o.visible | ||
| 1195 | mode := o.viewMode | 1239 | 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 hidden | 1244 | // Skip search if omnibox is hidden | ||
| @@ -1200,7 +1246,7 @@ func (o *Omnibox) performSearch() { | |||||
| 1200 | return | 1246 | return | ||
| 1201 | } | 1247 | } | ||
| 1202 | 1248 | ||||
| 1203 | - | query := o.entry.GetText() | 1249 | + | query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost) |
| 1204 | 1250 | ||||
| 1205 | // Skip duplicate queries | 1251 | // 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 string | 1943 | var targetURL string | ||
| 1870 | - | 1944 | + | if shouldPreferTypedURLNavigation(entryText) { | |
| 1871 | - | // If user has selected a result, navigate to that result | 1945 | + | targetURL = o.buildURL(entryText) |
| 1872 | - | // Otherwise, treat entry text as URL/search | 1946 | + | } 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/search | 1950 | + | // 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 favorites | 1987 | // In History mode: adds the selected item to favorites | ||
| 1901 | // In Favorites mode: removes the selected item from favorites | 1988 | // 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 = false | 2230 | o.hasGhostText = false | ||
| 2144 | o.isAcceptingGhost = false | 2231 | o.isAcceptingGhost = false | ||
| 2232 | + | o.suppressGhost = false | |||
| 2145 | o.mu.Unlock() | 2233 | o.mu.Unlock() | ||
| 2146 | 2234 | ||||
| 2147 | // Set initial query | 2235 | // Set initial query | ||
| @@ -2174,6 +2262,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2174 | 2262 | ||||
| 2175 | // Focus the entry | 2263 | // 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 realInput | 2301 | // 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) { | |||
| 33 | 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 | + | } | |
| @@ -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 | + | } | |||