detached c93c03d

refactor: rewrite omnibox inline ghost suggestion state and ranking

commit
c93c03d862127eb4d880a09575b428f8480c7717
author
brice · · 2026-02-07 06:12 UTC
parent
aae7da7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +424 -46
M internal/application/usecase/autocomplete.go +74 -41
@@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input
157157 return nil
158158 }
159159
160- for _, fav := range favorites {
161- if fav == nil || fav.URL == "" {
162- continue
163- }
164- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)
165- if ok {
166- return &autocomplete.Suggestion{
167- FullText: matchedURL,
168- Suffix: suffix,
169- Source: autocomplete.SourceFavorite,
170- Title: fav.Title,
160+ return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161+ len(favorites),
162+ func(i int) string {
163+ if favorites[i] == nil {
164+ return ""
171165 }
172- }
173- }
174-
175- return nil
166+ return favorites[i].URL
167+ },
168+ func(i int) string {
169+ if favorites[i] == nil {
170+ return ""
171+ }
172+ return favorites[i].Title
173+ },
174+ )
176175 }
177176
178177 // findHistorySuggestion finds the best matching history entry for the input.
@@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
189188 return nil
190189 }
191190
192- for _, entry := range entries {
193- if entry == nil || entry.URL == "" {
194- continue
195- }
196- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL)
197- if ok {
198- return &autocomplete.Suggestion{
199- FullText: matchedURL,
200- Suffix: suffix,
201- Source: autocomplete.SourceHistory,
202- Title: entry.Title,
191+ return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
192+ len(entries),
193+ func(i int) string {
194+ if entries[i] == nil {
195+ return ""
203196 }
204- }
205- }
206-
207- return nil
197+ return entries[i].URL
198+ },
199+ func(i int) string {
200+ if entries[i] == nil {
201+ return ""
202+ }
203+ return entries[i].Title
204+ },
205+ )
208206 }
209207
210208 // findBangSuggestion finds the best matching bang shortcut for the input.
@@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
239237 }
240238
241239 func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
242- for _, u := range urls {
243- if u == "" {
244- continue
240+ suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
241+ if ok {
242+ return &autocomplete.Suggestion{
243+ FullText: matchedURL,
244+ Suffix: suffix,
245+ Source: source,
245246 }
246- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u)
247- if ok {
248- return &autocomplete.Suggestion{
249- FullText: matchedURL,
250- Suffix: suffix,
251- Source: source,
252- }
247+ }
248+ return nil
249+}
250+
251+func bestSuggestionFromURLs(
252+ input string,
253+ source autocomplete.SuggestionSource,
254+ count int,
255+ urlAt func(int) string,
256+ titleAt func(int) string,
257+) *autocomplete.Suggestion {
258+ if count <= 0 {
259+ return nil
260+ }
261+
262+ urls := make([]string, 0, count)
263+ titles := make([]string, 0, count)
264+ for i := 0; i < count; i++ {
265+ url := urlAt(i)
266+ if url == "" {
267+ continue
253268 }
269+ urls = append(urls, url)
270+ titles = append(titles, titleAt(i))
271+ }
272+ if len(urls) == 0 {
273+ return nil
254274 }
255275
256- return nil
276+ suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277+ if !ok {
278+ return nil
279+ }
280+ title := ""
281+ if idx >= 0 && idx < len(titles) {
282+ title = titles[idx]
283+ }
284+ return &autocomplete.Suggestion{
285+ FullText: matchedURL,
286+ Suffix: suffix,
287+ Source: source,
288+ Title: title,
289+ }
257290 }
258291
259292 func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
@@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input
157 return nil157 return nil
158 }158 }
159 159
160- for _, fav := range favorites {160+ return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161- if fav == nil || fav.URL == "" {161+ len(favorites),
162- continue162+ func(i int) string {
163- }163+ if favorites[i] == nil {
164- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)164+ return ""
165- if ok {
166- return &autocomplete.Suggestion{
167- FullText: matchedURL,
168- Suffix: suffix,
169- Source: autocomplete.SourceFavorite,
170- Title: fav.Title,
171 }165 }
172- }166+ return favorites[i].URL
173- }167+ },
174-168+ func(i int) string {
175- return nil169+ if favorites[i] == nil {
170+ return ""
171+ }
172+ return favorites[i].Title
173+ },
174+ )
176 }175 }
177 176
178 // findHistorySuggestion finds the best matching history entry for the input.177 // findHistorySuggestion finds the best matching history entry for the input.
@@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
189 return nil188 return nil
190 }189 }
191 190
192- for _, entry := range entries {191+ return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
193- if entry == nil || entry.URL == "" {192+ len(entries),
194- continue193+ func(i int) string {
195- }194+ if entries[i] == nil {
196- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL)195+ return ""
197- if ok {
198- return &autocomplete.Suggestion{
199- FullText: matchedURL,
200- Suffix: suffix,
201- Source: autocomplete.SourceHistory,
202- Title: entry.Title,
203 }196 }
204- }197+ return entries[i].URL
205- }198+ },
206-199+ func(i int) string {
207- return nil200+ if entries[i] == nil {
201+ return ""
202+ }
203+ return entries[i].Title
204+ },
205+ )
208 }206 }
209 207
210 // findBangSuggestion finds the best matching bang shortcut for the input.208 // findBangSuggestion finds the best matching bang shortcut for the input.
@@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
239 }237 }
240 238
241 func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {239 func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
242- for _, u := range urls {240+ suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
243- if u == "" {241+ if ok {
244- continue242+ return &autocomplete.Suggestion{
243+ FullText: matchedURL,
244+ Suffix: suffix,
245+ Source: source,
245 }246 }
246- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u)247+ }
247- if ok {248+ return nil
248- return &autocomplete.Suggestion{249+}
249- FullText: matchedURL,250+
250- Suffix: suffix,251+func bestSuggestionFromURLs(
251- Source: source,252+ input string,
252- }253+ source autocomplete.SuggestionSource,
254+ count int,
255+ urlAt func(int) string,
256+ titleAt func(int) string,
257+) *autocomplete.Suggestion {
258+ if count <= 0 {
259+ return nil
260+ }
261+
262+ urls := make([]string, 0, count)
263+ titles := make([]string, 0, count)
264+ for i := 0; i < count; i++ {
265+ url := urlAt(i)
266+ if url == "" {
267+ continue
253 }268 }
269+ urls = append(urls, url)
270+ titles = append(titles, titleAt(i))
271+ }
272+ if len(urls) == 0 {
273+ return nil
254 }274 }
255 275
256- return nil276+ suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277+ if !ok {
278+ return nil
279+ }
280+ title := ""
281+ if idx >= 0 && idx < len(titles) {
282+ title = titles[idx]
283+ }
284+ return &autocomplete.Suggestion{
285+ FullText: matchedURL,
286+ Suffix: suffix,
287+ Source: source,
288+ Title: title,
289+ }
257 }290 }
258 291
259 func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {292 func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
M internal/application/usecase/autocomplete_test.go +21 -2
@@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
5555 assert.True(t, output.Found)
5656 assert.NotNil(t, output.Suggestion)
5757 assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58- assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText)
59- assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix)
58+ assert.Equal(t, "github.com", output.Suggestion.FullText)
59+ assert.Equal(t, ".com", output.Suggestion.Suffix)
6060 assert.Equal(t, "My Repo", output.Suggestion.Title)
6161 }
6262
@@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
167167 assert.Equal(t, "a.dev", suggestion.Suffix)
168168 }
169169
170+func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) {
171+ ctx := testContext()
172+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
173+
174+ urls := []string{
175+ "https://www.google.com/url?q=https://dashboard.stripe.com",
176+ "https://google.com",
177+ }
178+
179+ suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{
180+ VisibleURLs: urls,
181+ AllowBangs: false,
182+ })
183+
184+ require.NotNil(t, suggestion)
185+ assert.Equal(t, "google.com", suggestion.FullText)
186+ assert.Equal(t, "gle.com", suggestion.Suffix)
187+}
188+
170189 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
171190 ctx := testContext()
172191
@@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
55 assert.True(t, output.Found)55 assert.True(t, output.Found)
56 assert.NotNil(t, output.Suggestion)56 assert.NotNil(t, output.Suggestion)
57 assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)57 assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58- assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText)58+ assert.Equal(t, "github.com", output.Suggestion.FullText)
59- assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix)59+ assert.Equal(t, ".com", output.Suggestion.Suffix)
60 assert.Equal(t, "My Repo", output.Suggestion.Title)60 assert.Equal(t, "My Repo", output.Suggestion.Title)
61 }61 }
62 62
@@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
167 assert.Equal(t, "a.dev", suggestion.Suffix)167 assert.Equal(t, "a.dev", suggestion.Suffix)
168 }168 }
169 169
170+func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) {
171+ ctx := testContext()
172+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
173+
174+ urls := []string{
175+ "https://www.google.com/url?q=https://dashboard.stripe.com",
176+ "https://google.com",
177+ }
178+
179+ suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{
180+ VisibleURLs: urls,
181+ AllowBangs: false,
182+ })
183+
184+ require.NotNil(t, suggestion)
185+ assert.Equal(t, "google.com", suggestion.FullText)
186+ assert.Equal(t, "gle.com", suggestion.Suffix)
187+}
188+
170 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {189 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
171 ctx := testContext()190 ctx := testContext()
172 191
M internal/domain/autocomplete/autocomplete.go +123 -1
@@ -1,7 +1,11 @@
11 // Package autocomplete provides domain types and logic for URL autocompletion.
22 package autocomplete
33
4-import "strings"
4+import (
5+ stdurl "net/url"
6+ "strings"
7+ "unicode/utf8"
8+)
59
610 // SuggestionSource indicates the origin of an autocomplete suggestion.
711 type SuggestionSource int
@@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin
7882
7983 return "", "", false
8084 }
85+
86+// BestURLCompletion returns the best completion candidate from a list of URLs.
87+// For host-like queries (for example "goo"), it prefers short host completions
88+// over deep redirect paths.
89+func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) {
90+ suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls)
91+ return suffix, matchedURL, ok
92+}
93+
94+// BestURLCompletionWithIndex returns the best completion candidate and its source URL index.
95+func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) {
96+ bestScore := completionScore{
97+ valid: false,
98+ }
99+ bestSuffix := ""
100+ bestMatched := ""
101+ bestIndex := -1
102+
103+ for idx, rawURL := range urls {
104+ if rawURL == "" {
105+ continue
106+ }
107+
108+ candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL)
109+ if !candidateOK {
110+ continue
111+ }
112+
113+ score := rankCompletion(candidateText, candidateSuffix, idx)
114+ if !bestScore.valid || score.less(bestScore) {
115+ bestScore = score
116+ bestSuffix = candidateSuffix
117+ bestMatched = candidateText
118+ bestIndex = idx
119+ }
120+ }
121+
122+ if !bestScore.valid {
123+ return "", "", -1, false
124+ }
125+ return bestSuffix, bestMatched, bestIndex, true
126+}
127+
128+func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) {
129+ if looksLikeHostInput(input) {
130+ if host := hostForCompletion(rawURL); host != "" {
131+ for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} {
132+ if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK {
133+ return candidate, completionSuffix, true
134+ }
135+ }
136+ }
137+ }
138+
139+ completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL)
140+ if !completionOK {
141+ return "", "", false
142+ }
143+ return completionMatched, completionSuffix, true
144+}
145+
146+func looksLikeHostInput(input string) bool {
147+ if input == "" || strings.HasPrefix(input, "!") {
148+ return false
149+ }
150+ return !strings.ContainsAny(input, "/?#=& ")
151+}
152+
153+func hostForCompletion(raw string) string {
154+ if raw == "" {
155+ return ""
156+ }
157+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
158+ return strings.ToLower(parsed.Hostname())
159+ }
160+
161+ trimmed := strings.ToLower(StripProtocol(raw))
162+ for _, sep := range []string{"/", "?", "#"} {
163+ if idx := strings.Index(trimmed, sep); idx >= 0 {
164+ trimmed = trimmed[:idx]
165+ }
166+ }
167+ return strings.TrimSpace(trimmed)
168+}
169+
170+type completionScore struct {
171+ valid bool
172+ hasPathOrArgs bool
173+ suffixLen int
174+ totalLen int
175+ index int
176+}
177+
178+func rankCompletion(matchedURL, suffix string, index int) completionScore {
179+ return completionScore{
180+ valid: true,
181+ hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"),
182+ suffixLen: utf8.RuneCountInString(suffix),
183+ totalLen: utf8.RuneCountInString(matchedURL),
184+ index: index,
185+ }
186+}
187+
188+func (s completionScore) less(other completionScore) bool {
189+ if s.hasPathOrArgs != other.hasPathOrArgs {
190+ return !s.hasPathOrArgs
191+ }
192+ if s.index != other.index {
193+ return s.index < other.index
194+ }
195+ if s.suffixLen != other.suffixLen {
196+ return s.suffixLen < other.suffixLen
197+ }
198+ if s.totalLen != other.totalLen {
199+ return s.totalLen < other.totalLen
200+ }
201+ return false
202+}
@@ -1,7 +1,11 @@
1 // Package autocomplete provides domain types and logic for URL autocompletion.1 // Package autocomplete provides domain types and logic for URL autocompletion.
2 package autocomplete2 package autocomplete
3 3
4-import "strings"4+import (
5+ stdurl "net/url"
6+ "strings"
7+ "unicode/utf8"
8+)
5 9
6 // SuggestionSource indicates the origin of an autocomplete suggestion.10 // SuggestionSource indicates the origin of an autocomplete suggestion.
7 type SuggestionSource int11 type SuggestionSource int
@@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin
78 82
79 return "", "", false83 return "", "", false
80 }84 }
85+
86+// BestURLCompletion returns the best completion candidate from a list of URLs.
87+// For host-like queries (for example "goo"), it prefers short host completions
88+// over deep redirect paths.
89+func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) {
90+ suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls)
91+ return suffix, matchedURL, ok
92+}
93+
94+// BestURLCompletionWithIndex returns the best completion candidate and its source URL index.
95+func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) {
96+ bestScore := completionScore{
97+ valid: false,
98+ }
99+ bestSuffix := ""
100+ bestMatched := ""
101+ bestIndex := -1
102+
103+ for idx, rawURL := range urls {
104+ if rawURL == "" {
105+ continue
106+ }
107+
108+ candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL)
109+ if !candidateOK {
110+ continue
111+ }
112+
113+ score := rankCompletion(candidateText, candidateSuffix, idx)
114+ if !bestScore.valid || score.less(bestScore) {
115+ bestScore = score
116+ bestSuffix = candidateSuffix
117+ bestMatched = candidateText
118+ bestIndex = idx
119+ }
120+ }
121+
122+ if !bestScore.valid {
123+ return "", "", -1, false
124+ }
125+ return bestSuffix, bestMatched, bestIndex, true
126+}
127+
128+func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) {
129+ if looksLikeHostInput(input) {
130+ if host := hostForCompletion(rawURL); host != "" {
131+ for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} {
132+ if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK {
133+ return candidate, completionSuffix, true
134+ }
135+ }
136+ }
137+ }
138+
139+ completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL)
140+ if !completionOK {
141+ return "", "", false
142+ }
143+ return completionMatched, completionSuffix, true
144+}
145+
146+func looksLikeHostInput(input string) bool {
147+ if input == "" || strings.HasPrefix(input, "!") {
148+ return false
149+ }
150+ return !strings.ContainsAny(input, "/?#=& ")
151+}
152+
153+func hostForCompletion(raw string) string {
154+ if raw == "" {
155+ return ""
156+ }
157+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
158+ return strings.ToLower(parsed.Hostname())
159+ }
160+
161+ trimmed := strings.ToLower(StripProtocol(raw))
162+ for _, sep := range []string{"/", "?", "#"} {
163+ if idx := strings.Index(trimmed, sep); idx >= 0 {
164+ trimmed = trimmed[:idx]
165+ }
166+ }
167+ return strings.TrimSpace(trimmed)
168+}
169+
170+type completionScore struct {
171+ valid bool
172+ hasPathOrArgs bool
173+ suffixLen int
174+ totalLen int
175+ index int
176+}
177+
178+func rankCompletion(matchedURL, suffix string, index int) completionScore {
179+ return completionScore{
180+ valid: true,
181+ hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"),
182+ suffixLen: utf8.RuneCountInString(suffix),
183+ totalLen: utf8.RuneCountInString(matchedURL),
184+ index: index,
185+ }
186+}
187+
188+func (s completionScore) less(other completionScore) bool {
189+ if s.hasPathOrArgs != other.hasPathOrArgs {
190+ return !s.hasPathOrArgs
191+ }
192+ if s.index != other.index {
193+ return s.index < other.index
194+ }
195+ if s.suffixLen != other.suffixLen {
196+ return s.suffixLen < other.suffixLen
197+ }
198+ if s.totalLen != other.totalLen {
199+ return s.totalLen < other.totalLen
200+ }
201+ return false
202+}
M internal/domain/autocomplete/autocomplete_test.go +53 -0
@@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) {
266266 })
267267 }
268268 }
269+
270+func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) {
271+ urls := []string{
272+ "https://www.google.com/url?q=https://dashboard.stripe.com/auth",
273+ "https://google.com",
274+ }
275+
276+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
277+ if !ok {
278+ t.Fatalf("expected completion")
279+ }
280+ if matchedURL != "google.com" {
281+ t.Fatalf("expected host completion, got %q", matchedURL)
282+ }
283+ if suffix != "gle.com" {
284+ t.Fatalf("expected host suffix, got %q", suffix)
285+ }
286+}
287+
288+func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) {
289+ urls := []string{
290+ "https://google.com/url?q=https://example.com",
291+ }
292+
293+ suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls)
294+ if !ok {
295+ t.Fatalf("expected completion")
296+ }
297+ if matchedURL != "google.com/url?q=https://example.com" {
298+ t.Fatalf("unexpected matched URL: %q", matchedURL)
299+ }
300+ if suffix != "rl?q=https://example.com" {
301+ t.Fatalf("unexpected suffix: %q", suffix)
302+ }
303+}
304+
305+func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) {
306+ urls := []string{
307+ "https://example.org",
308+ "https://google.com/maps",
309+ }
310+
311+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
312+ if !ok {
313+ t.Fatalf("expected completion from second URL")
314+ }
315+ if matchedURL != "google.com" {
316+ t.Fatalf("expected host completion from second URL, got %q", matchedURL)
317+ }
318+ if suffix != "gle.com" {
319+ t.Fatalf("unexpected suffix: %q", suffix)
320+ }
321+}
@@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) {
266 })266 })
267 }267 }
268 }268 }
269+
270+func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) {
271+ urls := []string{
272+ "https://www.google.com/url?q=https://dashboard.stripe.com/auth",
273+ "https://google.com",
274+ }
275+
276+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
277+ if !ok {
278+ t.Fatalf("expected completion")
279+ }
280+ if matchedURL != "google.com" {
281+ t.Fatalf("expected host completion, got %q", matchedURL)
282+ }
283+ if suffix != "gle.com" {
284+ t.Fatalf("expected host suffix, got %q", suffix)
285+ }
286+}
287+
288+func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) {
289+ urls := []string{
290+ "https://google.com/url?q=https://example.com",
291+ }
292+
293+ suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls)
294+ if !ok {
295+ t.Fatalf("expected completion")
296+ }
297+ if matchedURL != "google.com/url?q=https://example.com" {
298+ t.Fatalf("unexpected matched URL: %q", matchedURL)
299+ }
300+ if suffix != "rl?q=https://example.com" {
301+ t.Fatalf("unexpected suffix: %q", suffix)
302+ }
303+}
304+
305+func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) {
306+ urls := []string{
307+ "https://example.org",
308+ "https://google.com/maps",
309+ }
310+
311+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
312+ if !ok {
313+ t.Fatalf("expected completion from second URL")
314+ }
315+ if matchedURL != "google.com" {
316+ t.Fatalf("expected host completion from second URL, got %q", matchedURL)
317+ }
318+ if suffix != "gle.com" {
319+ t.Fatalf("unexpected suffix: %q", suffix)
320+ }
321+}
M internal/ui/component/omnibox.go +42 -2
@@ -798,6 +798,10 @@ func (o *Omnibox) onEntryChanged() {
798798 return
799799 }
800800
801+ if o.isGhostProgrammaticEcho(entryText) {
802+ return
803+ }
804+
801805 // Reset navigation state when user types - space should type, not toggle favorite
802806 o.mu.Lock()
803807 o.hasNavigated = false
@@ -823,6 +827,35 @@ func (o *Omnibox) onEntryChanged() {
823827 o.debounceMu.Unlock()
824828 }
825829
830+func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
831+ o.mu.RLock()
832+ hasGhost := o.hasGhostText
833+ realInput := o.realInput
834+ ghostFullText := o.ghostFullText
835+ o.mu.RUnlock()
836+
837+ if !hasGhost || ghostFullText == "" || entryText != ghostFullText {
838+ return false
839+ }
840+
841+ selectionStart := 0
842+ selectionEnd := 0
843+ hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd)
844+ return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection)
845+}
846+
847+func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool {
848+ if !hasSelection {
849+ return false
850+ }
851+ prefixRunes := utf8.RuneCountInString(realInput)
852+ fullRunes := utf8.RuneCountInString(ghostFullText)
853+ if prefixRunes < 0 || fullRunes <= prefixRunes {
854+ return false
855+ }
856+ return selectionStart == prefixRunes && selectionEnd == fullRunes
857+}
858+
826859 // setGhostText applies inline completion using entry selection.
827860 // It writes full text and selects [len(originalInput):end] as the ghost suffix.
828861 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
@@ -922,11 +955,11 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
922955 // acceptGhostCompletion accepts the ghost text and fills the input.
923956 func (o *Omnibox) acceptGhostCompletion() {
924957 o.mu.Lock()
925- if !o.hasGhostText || o.ghostFullText == "" {
958+ fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
959+ if !accepted {
926960 o.mu.Unlock()
927961 return
928962 }
929- fullText := o.ghostFullText
930963 o.isAcceptingGhost = true
931964 o.realInput = fullText
932965 o.ghostToken++
@@ -948,6 +981,13 @@ func (o *Omnibox) acceptGhostCompletion() {
948981 o.mu.Unlock()
949982 }
950983
984+func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
985+ if !hasGhost || ghostFullText == "" {
986+ return "", false
987+ }
988+ return ghostFullText, true
989+}
990+
951991 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
952992 // When a row is selected via arrow keys, show the URL as ghost text even if
953993 // the user's input isn't a prefix - this shows what Tab would fill in.
@@ -798,6 +798,10 @@ func (o *Omnibox) onEntryChanged() {
798 return798 return
799 }799 }
800 800
801+ if o.isGhostProgrammaticEcho(entryText) {
802+ return
803+ }
804+
801 // Reset navigation state when user types - space should type, not toggle favorite805 // Reset navigation state when user types - space should type, not toggle favorite
802 o.mu.Lock()806 o.mu.Lock()
803 o.hasNavigated = false807 o.hasNavigated = false
@@ -823,6 +827,35 @@ func (o *Omnibox) onEntryChanged() {
823 o.debounceMu.Unlock()827 o.debounceMu.Unlock()
824 }828 }
825 829
830+func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
831+ o.mu.RLock()
832+ hasGhost := o.hasGhostText
833+ realInput := o.realInput
834+ ghostFullText := o.ghostFullText
835+ o.mu.RUnlock()
836+
837+ if !hasGhost || ghostFullText == "" || entryText != ghostFullText {
838+ return false
839+ }
840+
841+ selectionStart := 0
842+ selectionEnd := 0
843+ hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd)
844+ return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection)
845+}
846+
847+func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool {
848+ if !hasSelection {
849+ return false
850+ }
851+ prefixRunes := utf8.RuneCountInString(realInput)
852+ fullRunes := utf8.RuneCountInString(ghostFullText)
853+ if prefixRunes < 0 || fullRunes <= prefixRunes {
854+ return false
855+ }
856+ return selectionStart == prefixRunes && selectionEnd == fullRunes
857+}
858+
826 // setGhostText applies inline completion using entry selection.859 // setGhostText applies inline completion using entry selection.
827 // It writes full text and selects [len(originalInput):end] as the ghost suffix.860 // It writes full text and selects [len(originalInput):end] as the ghost suffix.
828 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {861 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
@@ -922,11 +955,11 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
922 // acceptGhostCompletion accepts the ghost text and fills the input.955 // acceptGhostCompletion accepts the ghost text and fills the input.
923 func (o *Omnibox) acceptGhostCompletion() {956 func (o *Omnibox) acceptGhostCompletion() {
924 o.mu.Lock()957 o.mu.Lock()
925- if !o.hasGhostText || o.ghostFullText == "" {958+ fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
959+ if !accepted {
926 o.mu.Unlock()960 o.mu.Unlock()
927 return961 return
928 }962 }
929- fullText := o.ghostFullText
930 o.isAcceptingGhost = true963 o.isAcceptingGhost = true
931 o.realInput = fullText964 o.realInput = fullText
932 o.ghostToken++965 o.ghostToken++
@@ -948,6 +981,13 @@ func (o *Omnibox) acceptGhostCompletion() {
948 o.mu.Unlock()981 o.mu.Unlock()
949 }982 }
950 983
984+func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
985+ if !hasGhost || ghostFullText == "" {
986+ return "", false
987+ }
988+ return ghostFullText, true
989+}
990+
951 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).991 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
952 // When a row is selected via arrow keys, show the URL as ghost text even if992 // When a row is selected via arrow keys, show the URL as ghost text even if
953 // the user's input isn't a prefix - this shows what Tab would fill in.993 // the user's input isn't a prefix - this shows what Tab would fill in.
A internal/ui/component/omnibox_ghost_state_test.go +111 -0
@@ -0,0 +1,111 @@
1+package component
2+
3+import "testing"
4+
5+func TestInlineGhostSelection(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ realInput string
9+ ghostFullText string
10+ selectionStart int
11+ selectionEnd int
12+ hasSelection bool
13+ want bool
14+ }{
15+ {
16+ name: "selected suffix matches typed prefix",
17+ realInput: "goo",
18+ ghostFullText: "google.com",
19+ selectionStart: 3,
20+ selectionEnd: 10,
21+ hasSelection: true,
22+ want: true,
23+ },
24+ {
25+ name: "no selection means no ghost suffix",
26+ realInput: "goo",
27+ ghostFullText: "google.com",
28+ selectionStart: 10,
29+ selectionEnd: 10,
30+ hasSelection: false,
31+ want: false,
32+ },
33+ {
34+ name: "collapsed selection should not be treated as ghost",
35+ realInput: "goo",
36+ ghostFullText: "google.com",
37+ selectionStart: 10,
38+ selectionEnd: 10,
39+ hasSelection: true,
40+ want: false,
41+ },
42+ {
43+ name: "wrong selection range should not be treated as ghost",
44+ realInput: "goo",
45+ ghostFullText: "google.com",
46+ selectionStart: 1,
47+ selectionEnd: 10,
48+ hasSelection: true,
49+ want: false,
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ got := isInlineGhostSelection(
56+ tt.realInput,
57+ tt.ghostFullText,
58+ tt.selectionStart,
59+ tt.selectionEnd,
60+ tt.hasSelection,
61+ )
62+ if got != tt.want {
63+ t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want)
64+ }
65+ })
66+ }
67+}
68+
69+func TestAcceptedGhostInput(t *testing.T) {
70+ tests := []struct {
71+ name string
72+ hasGhost bool
73+ ghostFullText string
74+ wantText string
75+ wantAccepted bool
76+ }{
77+ {
78+ name: "accepts explicit ghost",
79+ hasGhost: true,
80+ ghostFullText: "google.com",
81+ wantText: "google.com",
82+ wantAccepted: true,
83+ },
84+ {
85+ name: "rejects without ghost",
86+ hasGhost: false,
87+ ghostFullText: "google.com",
88+ wantText: "",
89+ wantAccepted: false,
90+ },
91+ {
92+ name: "rejects empty ghost text",
93+ hasGhost: true,
94+ ghostFullText: "",
95+ wantText: "",
96+ wantAccepted: false,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText)
103+ if gotText != tt.wantText {
104+ t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText)
105+ }
106+ if gotAccepted != tt.wantAccepted {
107+ t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted)
108+ }
109+ })
110+ }
111+}
@@ -0,0 +1,111 @@
1+package component
2+
3+import "testing"
4+
5+func TestInlineGhostSelection(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ realInput string
9+ ghostFullText string
10+ selectionStart int
11+ selectionEnd int
12+ hasSelection bool
13+ want bool
14+ }{
15+ {
16+ name: "selected suffix matches typed prefix",
17+ realInput: "goo",
18+ ghostFullText: "google.com",
19+ selectionStart: 3,
20+ selectionEnd: 10,
21+ hasSelection: true,
22+ want: true,
23+ },
24+ {
25+ name: "no selection means no ghost suffix",
26+ realInput: "goo",
27+ ghostFullText: "google.com",
28+ selectionStart: 10,
29+ selectionEnd: 10,
30+ hasSelection: false,
31+ want: false,
32+ },
33+ {
34+ name: "collapsed selection should not be treated as ghost",
35+ realInput: "goo",
36+ ghostFullText: "google.com",
37+ selectionStart: 10,
38+ selectionEnd: 10,
39+ hasSelection: true,
40+ want: false,
41+ },
42+ {
43+ name: "wrong selection range should not be treated as ghost",
44+ realInput: "goo",
45+ ghostFullText: "google.com",
46+ selectionStart: 1,
47+ selectionEnd: 10,
48+ hasSelection: true,
49+ want: false,
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ got := isInlineGhostSelection(
56+ tt.realInput,
57+ tt.ghostFullText,
58+ tt.selectionStart,
59+ tt.selectionEnd,
60+ tt.hasSelection,
61+ )
62+ if got != tt.want {
63+ t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want)
64+ }
65+ })
66+ }
67+}
68+
69+func TestAcceptedGhostInput(t *testing.T) {
70+ tests := []struct {
71+ name string
72+ hasGhost bool
73+ ghostFullText string
74+ wantText string
75+ wantAccepted bool
76+ }{
77+ {
78+ name: "accepts explicit ghost",
79+ hasGhost: true,
80+ ghostFullText: "google.com",
81+ wantText: "google.com",
82+ wantAccepted: true,
83+ },
84+ {
85+ name: "rejects without ghost",
86+ hasGhost: false,
87+ ghostFullText: "google.com",
88+ wantText: "",
89+ wantAccepted: false,
90+ },
91+ {
92+ name: "rejects empty ghost text",
93+ hasGhost: true,
94+ ghostFullText: "",
95+ wantText: "",
96+ wantAccepted: false,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText)
103+ if gotText != tt.wantText {
104+ t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText)
105+ }
106+ if gotAccepted != tt.wantAccepted {
107+ t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted)
108+ }
109+ })
110+ }
111+}

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