detached 59a422a

fix: constrain omnibox ghost completion to domain for noisy URLs

commit
59a422a34efd997fea32881a276915ef346002cf
author
brice · · 2026-02-07 05:50 UTC
parent
bf5b0f6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +83 -1
M internal/ui/component/omnibox.go +48 -1
@@ -3,6 +3,7 @@ package component
33 import (
44 "context"
55 "fmt"
6+ stdurl "net/url"
67 "strings"
78 "sync"
89 "time"
@@ -1066,10 +1067,56 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
10661067 o.clearGhostTextIfInput(entryText)
10671068 return
10681069 }
1069- o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)
1070+ fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)
1071+ if fullText == "" || suffix == "" {
1072+ o.clearGhostTextIfInput(entryText)
1073+ return
1074+ }
1075+ o.setGhostText(entryText, suffix, fullText)
10701076 }()
10711077 }
10721078
1079+// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1080+// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1081+func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1082+ if queryText == "" || fullText == "" {
1083+ return "", ""
1084+ }
1085+
1086+ // If input already contains path/query-ish delimiters, keep original completion behavior.
1087+ if strings.ContainsAny(queryText, "/?#=& ") {
1088+ return fullText, fallbackSuffix
1089+ }
1090+
1091+ hostOnly := extractHostForCompletion(fullText)
1092+ if hostOnly != "" {
1093+ for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1094+ if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1095+ return candidate, completionSuffix
1096+ }
1097+ }
1098+ }
1099+
1100+ return fullText, fallbackSuffix
1101+}
1102+
1103+func extractHostForCompletion(raw string) string {
1104+ if raw == "" {
1105+ return ""
1106+ }
1107+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1108+ return strings.ToLower(parsed.Hostname())
1109+ }
1110+ trimmed := autocomplete.StripProtocol(raw)
1111+ trimmed = strings.ToLower(trimmed)
1112+ for _, sep := range []string{"/", "?", "#"} {
1113+ if idx := strings.Index(trimmed, sep); idx >= 0 {
1114+ trimmed = trimmed[:idx]
1115+ }
1116+ }
1117+ return strings.TrimSpace(trimmed)
1118+}
1119+
10731120 func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {
10741121 o.mu.RLock()
10751122 defer o.mu.RUnlock()
@@ -3,6 +3,7 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ stdurl "net/url"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -1066,10 +1067,56 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1066 o.clearGhostTextIfInput(entryText)1067 o.clearGhostTextIfInput(entryText)
1067 return1068 return
1068 }1069 }
1069- o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)1070+ fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)
1071+ if fullText == "" || suffix == "" {
1072+ o.clearGhostTextIfInput(entryText)
1073+ return
1074+ }
1075+ o.setGhostText(entryText, suffix, fullText)
1070 }()1076 }()
1071 }1077 }
1072 1078
1079+// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1080+// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1081+func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1082+ if queryText == "" || fullText == "" {
1083+ return "", ""
1084+ }
1085+
1086+ // If input already contains path/query-ish delimiters, keep original completion behavior.
1087+ if strings.ContainsAny(queryText, "/?#=& ") {
1088+ return fullText, fallbackSuffix
1089+ }
1090+
1091+ hostOnly := extractHostForCompletion(fullText)
1092+ if hostOnly != "" {
1093+ for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1094+ if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1095+ return candidate, completionSuffix
1096+ }
1097+ }
1098+ }
1099+
1100+ return fullText, fallbackSuffix
1101+}
1102+
1103+func extractHostForCompletion(raw string) string {
1104+ if raw == "" {
1105+ return ""
1106+ }
1107+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1108+ return strings.ToLower(parsed.Hostname())
1109+ }
1110+ trimmed := autocomplete.StripProtocol(raw)
1111+ trimmed = strings.ToLower(trimmed)
1112+ for _, sep := range []string{"/", "?", "#"} {
1113+ if idx := strings.Index(trimmed, sep); idx >= 0 {
1114+ trimmed = trimmed[:idx]
1115+ }
1116+ }
1117+ return strings.TrimSpace(trimmed)
1118+}
1119+
1073 func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {1120 func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {
1074 o.mu.RLock()1121 o.mu.RLock()
1075 defer o.mu.RUnlock()1122 defer o.mu.RUnlock()
A internal/ui/component/omnibox_ghost_normalize_test.go +35 -0
@@ -0,0 +1,35 @@
1+package component
2+
3+import "testing"
4+
5+func TestNormalizeGhostSuggestion_PrefersHostOnlyForRedirectURL(t *testing.T) {
6+ full, suffix := normalizeGhostSuggestion(
7+ "google",
8+ "google.com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
9+ ".com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
10+ )
11+ if full != "google.com" {
12+ t.Fatalf("expected host-only completion, got %q", full)
13+ }
14+ if suffix != ".com" {
15+ t.Fatalf("expected .com suffix, got %q", suffix)
16+ }
17+}
18+
19+func TestNormalizeGhostSuggestion_KeepsFallbackWhenInputLooksLikePath(t *testing.T) {
20+ fallback := "/url?q=https://x"
21+ full, suffix := normalizeGhostSuggestion("google.com/u", "google.com/url?q=https://x", fallback)
22+ if full != "google.com/url?q=https://x" {
23+ t.Fatalf("expected full URL fallback, got %q", full)
24+ }
25+ if suffix != fallback {
26+ t.Fatalf("expected fallback suffix, got %q", suffix)
27+ }
28+}
29+
30+func TestExtractHostForCompletion(t *testing.T) {
31+ got := extractHostForCompletion("https://WWW.Google.com/url?q=abc")
32+ if got != "www.google.com" {
33+ t.Fatalf("expected normalized host, got %q", got)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package component
2+
3+import "testing"
4+
5+func TestNormalizeGhostSuggestion_PrefersHostOnlyForRedirectURL(t *testing.T) {
6+ full, suffix := normalizeGhostSuggestion(
7+ "google",
8+ "google.com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
9+ ".com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
10+ )
11+ if full != "google.com" {
12+ t.Fatalf("expected host-only completion, got %q", full)
13+ }
14+ if suffix != ".com" {
15+ t.Fatalf("expected .com suffix, got %q", suffix)
16+ }
17+}
18+
19+func TestNormalizeGhostSuggestion_KeepsFallbackWhenInputLooksLikePath(t *testing.T) {
20+ fallback := "/url?q=https://x"
21+ full, suffix := normalizeGhostSuggestion("google.com/u", "google.com/url?q=https://x", fallback)
22+ if full != "google.com/url?q=https://x" {
23+ t.Fatalf("expected full URL fallback, got %q", full)
24+ }
25+ if suffix != fallback {
26+ t.Fatalf("expected fallback suffix, got %q", suffix)
27+ }
28+}
29+
30+func TestExtractHostForCompletion(t *testing.T) {
31+ got := extractHostForCompletion("https://WWW.Google.com/url?q=abc")
32+ if got != "www.google.com" {
33+ t.Fatalf("expected normalized host, got %q", got)
34+ }
35+}

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