detached 6686c9a

Merge pull request #138 from bnema/fix/omnibox-ghost-text-alignment

fix(omnibox): align ghost text vertically with entry text
commit
6686c9ac0c1419595451b74251d5f3b975632bec
author
bnema · · 2026-01-24 04:10 UTC
committer
GitHub · · 2026-01-24 04:10 UTC
merge of
5195a15 + 603f4ba · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +628 -44
A internal/application/usecase/autocomplete_test.go +283 -0
@@ -0,0 +1,283 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/autocomplete"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16+ ctx := testContext()
17+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18+
19+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20+
21+ require.NotNil(t, output)
22+ assert.False(t, output.Found)
23+ assert.Nil(t, output.Suggestion)
24+}
25+
26+func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27+ ctx := testContext()
28+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29+
30+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31+
32+ require.NotNil(t, output)
33+ assert.False(t, output.Found)
34+}
35+
36+func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37+ ctx := testContext()
38+
39+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40+ folderRepo := repomocks.NewMockFolderRepository(t)
41+ tagRepo := repomocks.NewMockTagRepository(t)
42+
43+ favorites := []*entity.Favorite{
44+ {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45+ {ID: 2, URL: "https://google.com", Title: "Google"},
46+ }
47+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48+
49+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51+
52+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53+
54+ require.NotNil(t, output)
55+ assert.True(t, output.Found)
56+ assert.NotNil(t, output.Suggestion)
57+ 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)
60+ assert.Equal(t, "My Repo", output.Suggestion.Title)
61+}
62+
63+func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64+ ctx := testContext()
65+
66+ // Favorites returns empty
67+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68+ folderRepo := repomocks.NewMockFolderRepository(t)
69+ tagRepo := repomocks.NewMockTagRepository(t)
70+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71+
72+ // History returns a match
73+ historyRepo := repomocks.NewMockHistoryRepository(t)
74+ historyMatches := []entity.HistoryMatch{
75+ {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},
76+ }
77+ historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)
78+
79+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82+
83+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84+
85+ require.NotNil(t, output)
86+ assert.True(t, output.Found)
87+ assert.NotNil(t, output.Suggestion)
88+ assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89+ assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90+ assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91+}
92+
93+func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94+ ctx := testContext()
95+
96+ shortcuts := map[string]usecase.SearchShortcut{
97+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99+ }
100+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102+
103+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104+
105+ require.NotNil(t, output)
106+ assert.True(t, output.Found)
107+ assert.NotNil(t, output.Suggestion)
108+ assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109+ assert.Equal(t, "!gh", output.Suggestion.FullText)
110+ assert.Equal(t, "h", output.Suggestion.Suffix)
111+ assert.Equal(t, "GitHub search", output.Suggestion.Title)
112+}
113+
114+func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115+ ctx := testContext()
116+
117+ shortcuts := map[string]usecase.SearchShortcut{
118+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119+ }
120+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122+
123+ // Input without ! should not match bang shortcuts
124+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125+
126+ require.NotNil(t, output)
127+ assert.False(t, output.Found)
128+}
129+
130+func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131+ ctx := testContext()
132+
133+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134+ folderRepo := repomocks.NewMockFolderRepository(t)
135+ tagRepo := repomocks.NewMockTagRepository(t)
136+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137+
138+ historyRepo := repomocks.NewMockHistoryRepository(t)
139+ historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)
140+
141+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144+
145+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146+
147+ require.NotNil(t, output)
148+ assert.False(t, output.Found)
149+}
150+
151+func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152+ ctx := testContext()
153+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154+
155+ tests := []struct {
156+ name string
157+ input string
158+ targetURL string
159+ }{
160+ {name: "empty input", input: "", targetURL: "https://example.com"},
161+ {name: "empty URL", input: "exa", targetURL: ""},
162+ {name: "both empty", input: "", targetURL: ""},
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
168+ require.NotNil(t, output)
169+ assert.False(t, output.Found)
170+ })
171+ }
172+}
173+
174+func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
175+ ctx := testContext()
176+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
177+
178+ tests := []struct {
179+ name string
180+ input string
181+ targetURL string
182+ wantSuffix string
183+ wantMatchedURL string
184+ }{
185+ {
186+ name: "prefix match without protocol",
187+ input: "github",
188+ targetURL: "https://github.com/user/repo",
189+ wantSuffix: ".com/user/repo",
190+ wantMatchedURL: "github.com/user/repo",
191+ },
192+ {
193+ name: "prefix match with protocol",
194+ input: "https://github",
195+ targetURL: "https://github.com/user/repo",
196+ wantSuffix: ".com/user/repo",
197+ wantMatchedURL: "https://github.com/user/repo",
198+ },
199+ {
200+ name: "case insensitive match",
201+ input: "GITHUB",
202+ targetURL: "https://github.com",
203+ wantSuffix: ".com",
204+ wantMatchedURL: "github.com",
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+ require.NotNil(t, output.Suggestion)
214+ assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
215+ assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
216+ })
217+ }
218+}
219+
220+func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
221+ ctx := testContext()
222+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
223+
224+ output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
225+
226+ require.NotNil(t, output)
227+ assert.False(t, output.Found)
228+}
229+
230+func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
231+ ctx := testContext()
232+
233+ // Both favorites and history have matches - favorites should win
234+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
235+ folderRepo := repomocks.NewMockFolderRepository(t)
236+ tagRepo := repomocks.NewMockTagRepository(t)
237+ favorites := []*entity.Favorite{
238+ {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
239+ }
240+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
241+
242+ // History would also match, but should not be called because favorites matched
243+ historyRepo := repomocks.NewMockHistoryRepository(t)
244+ // Note: no EXPECT on historyRepo - it shouldn't be called
245+
246+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
247+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
248+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
249+
250+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
251+
252+ require.NotNil(t, output)
253+ assert.True(t, output.Found)
254+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
255+ assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
256+}
257+
258+func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
259+ ctx := testContext()
260+
261+ // Favorites returns empty
262+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
263+ folderRepo := repomocks.NewMockFolderRepository(t)
264+ tagRepo := repomocks.NewMockTagRepository(t)
265+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
266+
267+ // History returns results but none are prefix matches
268+ historyRepo := repomocks.NewMockHistoryRepository(t)
269+ historyMatches := []entity.HistoryMatch{
270+ // URL doesn't start with "git", so no prefix match
271+ {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},
272+ }
273+ historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)
274+
275+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
277+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
278+
279+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
280+
281+ require.NotNil(t, output)
282+ assert.False(t, output.Found)
283+}
@@ -0,0 +1,283 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/autocomplete"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16+ ctx := testContext()
17+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18+
19+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20+
21+ require.NotNil(t, output)
22+ assert.False(t, output.Found)
23+ assert.Nil(t, output.Suggestion)
24+}
25+
26+func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27+ ctx := testContext()
28+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29+
30+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31+
32+ require.NotNil(t, output)
33+ assert.False(t, output.Found)
34+}
35+
36+func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37+ ctx := testContext()
38+
39+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40+ folderRepo := repomocks.NewMockFolderRepository(t)
41+ tagRepo := repomocks.NewMockTagRepository(t)
42+
43+ favorites := []*entity.Favorite{
44+ {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45+ {ID: 2, URL: "https://google.com", Title: "Google"},
46+ }
47+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48+
49+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51+
52+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53+
54+ require.NotNil(t, output)
55+ assert.True(t, output.Found)
56+ assert.NotNil(t, output.Suggestion)
57+ 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)
60+ assert.Equal(t, "My Repo", output.Suggestion.Title)
61+}
62+
63+func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64+ ctx := testContext()
65+
66+ // Favorites returns empty
67+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68+ folderRepo := repomocks.NewMockFolderRepository(t)
69+ tagRepo := repomocks.NewMockTagRepository(t)
70+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71+
72+ // History returns a match
73+ historyRepo := repomocks.NewMockHistoryRepository(t)
74+ historyMatches := []entity.HistoryMatch{
75+ {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},
76+ }
77+ historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)
78+
79+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82+
83+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84+
85+ require.NotNil(t, output)
86+ assert.True(t, output.Found)
87+ assert.NotNil(t, output.Suggestion)
88+ assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89+ assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90+ assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91+}
92+
93+func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94+ ctx := testContext()
95+
96+ shortcuts := map[string]usecase.SearchShortcut{
97+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99+ }
100+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102+
103+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104+
105+ require.NotNil(t, output)
106+ assert.True(t, output.Found)
107+ assert.NotNil(t, output.Suggestion)
108+ assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109+ assert.Equal(t, "!gh", output.Suggestion.FullText)
110+ assert.Equal(t, "h", output.Suggestion.Suffix)
111+ assert.Equal(t, "GitHub search", output.Suggestion.Title)
112+}
113+
114+func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115+ ctx := testContext()
116+
117+ shortcuts := map[string]usecase.SearchShortcut{
118+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119+ }
120+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122+
123+ // Input without ! should not match bang shortcuts
124+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125+
126+ require.NotNil(t, output)
127+ assert.False(t, output.Found)
128+}
129+
130+func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131+ ctx := testContext()
132+
133+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134+ folderRepo := repomocks.NewMockFolderRepository(t)
135+ tagRepo := repomocks.NewMockTagRepository(t)
136+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137+
138+ historyRepo := repomocks.NewMockHistoryRepository(t)
139+ historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)
140+
141+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144+
145+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146+
147+ require.NotNil(t, output)
148+ assert.False(t, output.Found)
149+}
150+
151+func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152+ ctx := testContext()
153+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154+
155+ tests := []struct {
156+ name string
157+ input string
158+ targetURL string
159+ }{
160+ {name: "empty input", input: "", targetURL: "https://example.com"},
161+ {name: "empty URL", input: "exa", targetURL: ""},
162+ {name: "both empty", input: "", targetURL: ""},
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
168+ require.NotNil(t, output)
169+ assert.False(t, output.Found)
170+ })
171+ }
172+}
173+
174+func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
175+ ctx := testContext()
176+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
177+
178+ tests := []struct {
179+ name string
180+ input string
181+ targetURL string
182+ wantSuffix string
183+ wantMatchedURL string
184+ }{
185+ {
186+ name: "prefix match without protocol",
187+ input: "github",
188+ targetURL: "https://github.com/user/repo",
189+ wantSuffix: ".com/user/repo",
190+ wantMatchedURL: "github.com/user/repo",
191+ },
192+ {
193+ name: "prefix match with protocol",
194+ input: "https://github",
195+ targetURL: "https://github.com/user/repo",
196+ wantSuffix: ".com/user/repo",
197+ wantMatchedURL: "https://github.com/user/repo",
198+ },
199+ {
200+ name: "case insensitive match",
201+ input: "GITHUB",
202+ targetURL: "https://github.com",
203+ wantSuffix: ".com",
204+ wantMatchedURL: "github.com",
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+ require.NotNil(t, output.Suggestion)
214+ assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
215+ assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
216+ })
217+ }
218+}
219+
220+func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
221+ ctx := testContext()
222+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
223+
224+ output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
225+
226+ require.NotNil(t, output)
227+ assert.False(t, output.Found)
228+}
229+
230+func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
231+ ctx := testContext()
232+
233+ // Both favorites and history have matches - favorites should win
234+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
235+ folderRepo := repomocks.NewMockFolderRepository(t)
236+ tagRepo := repomocks.NewMockTagRepository(t)
237+ favorites := []*entity.Favorite{
238+ {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
239+ }
240+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
241+
242+ // History would also match, but should not be called because favorites matched
243+ historyRepo := repomocks.NewMockHistoryRepository(t)
244+ // Note: no EXPECT on historyRepo - it shouldn't be called
245+
246+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
247+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
248+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
249+
250+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
251+
252+ require.NotNil(t, output)
253+ assert.True(t, output.Found)
254+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
255+ assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
256+}
257+
258+func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
259+ ctx := testContext()
260+
261+ // Favorites returns empty
262+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
263+ folderRepo := repomocks.NewMockFolderRepository(t)
264+ tagRepo := repomocks.NewMockTagRepository(t)
265+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
266+
267+ // History returns results but none are prefix matches
268+ historyRepo := repomocks.NewMockHistoryRepository(t)
269+ historyMatches := []entity.HistoryMatch{
270+ // URL doesn't start with "git", so no prefix match
271+ {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},
272+ }
273+ historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)
274+
275+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
277+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
278+
279+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
280+
281+ require.NotNil(t, output)
282+ assert.False(t, output.Found)
283+}
A internal/domain/autocomplete/autocomplete_test.go +268 -0
@@ -0,0 +1,268 @@
1+package autocomplete
2+
3+import "testing"
4+
5+func TestComputeCompletionSuffix(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ fullText string
10+ wantSuffix string
11+ wantOK bool
12+ }{
13+ {
14+ name: "empty input",
15+ input: "",
16+ fullText: "example.com",
17+ wantSuffix: "",
18+ wantOK: false,
19+ },
20+ {
21+ name: "empty fullText",
22+ input: "exa",
23+ fullText: "",
24+ wantSuffix: "",
25+ wantOK: false,
26+ },
27+ {
28+ name: "both empty",
29+ input: "",
30+ fullText: "",
31+ wantSuffix: "",
32+ wantOK: false,
33+ },
34+ {
35+ name: "exact match returns false",
36+ input: "example.com",
37+ fullText: "example.com",
38+ wantSuffix: "",
39+ wantOK: false,
40+ },
41+ {
42+ name: "prefix match",
43+ input: "exa",
44+ fullText: "example.com",
45+ wantSuffix: "mple.com",
46+ wantOK: true,
47+ },
48+ {
49+ name: "case insensitive match",
50+ input: "EXA",
51+ fullText: "example.com",
52+ wantSuffix: "mple.com",
53+ wantOK: true,
54+ },
55+ {
56+ name: "case insensitive preserves original case in suffix",
57+ input: "git",
58+ fullText: "GitHub.com",
59+ wantSuffix: "Hub.com",
60+ wantOK: true,
61+ },
62+ {
63+ name: "no prefix match",
64+ input: "xyz",
65+ fullText: "example.com",
66+ wantSuffix: "",
67+ wantOK: false,
68+ },
69+ {
70+ name: "input longer than fullText",
71+ input: "example.com/path",
72+ fullText: "example.com",
73+ wantSuffix: "",
74+ wantOK: false,
75+ },
76+ {
77+ name: "single character prefix",
78+ input: "e",
79+ fullText: "example.com",
80+ wantSuffix: "xample.com",
81+ wantOK: true,
82+ },
83+ {
84+ name: "bang shortcut prefix",
85+ input: "!g",
86+ fullText: "!github",
87+ wantSuffix: "ithub",
88+ wantOK: true,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ suffix, ok := ComputeCompletionSuffix(tt.input, tt.fullText)
95+ if suffix != tt.wantSuffix {
96+ t.Errorf("ComputeCompletionSuffix(%q, %q) suffix = %q, want %q",
97+ tt.input, tt.fullText, suffix, tt.wantSuffix)
98+ }
99+ if ok != tt.wantOK {
100+ t.Errorf("ComputeCompletionSuffix(%q, %q) ok = %v, want %v",
101+ tt.input, tt.fullText, ok, tt.wantOK)
102+ }
103+ })
104+ }
105+}
106+
107+func TestStripProtocol(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ url string
111+ want string
112+ }{
113+ {
114+ name: "https protocol",
115+ url: "https://example.com",
116+ want: "example.com",
117+ },
118+ {
119+ name: "http protocol",
120+ url: "http://example.com",
121+ want: "example.com",
122+ },
123+ {
124+ name: "no protocol",
125+ url: "example.com",
126+ want: "example.com",
127+ },
128+ {
129+ name: "https with path",
130+ url: "https://example.com/path/to/page",
131+ want: "example.com/path/to/page",
132+ },
133+ {
134+ name: "http with query string",
135+ url: "http://example.com?query=value",
136+ want: "example.com?query=value",
137+ },
138+ {
139+ name: "file protocol unchanged",
140+ url: "file:///path/to/file",
141+ want: "file:///path/to/file",
142+ },
143+ {
144+ name: "empty string",
145+ url: "",
146+ want: "",
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got := StripProtocol(tt.url)
153+ if got != tt.want {
154+ t.Errorf("StripProtocol(%q) = %q, want %q", tt.url, got, tt.want)
155+ }
156+ })
157+ }
158+}
159+
160+func TestComputeURLCompletionSuffix(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ input string
164+ fullURL string
165+ wantSuffix string
166+ wantMatchedURL string
167+ wantOK bool
168+ }{
169+ {
170+ name: "empty input",
171+ input: "",
172+ fullURL: "https://example.com",
173+ wantSuffix: "",
174+ wantMatchedURL: "",
175+ wantOK: false,
176+ },
177+ {
178+ name: "direct match with protocol",
179+ input: "https://exa",
180+ fullURL: "https://example.com",
181+ wantSuffix: "mple.com",
182+ wantMatchedURL: "https://example.com",
183+ wantOK: true,
184+ },
185+ {
186+ name: "match without protocol in input",
187+ input: "exa",
188+ fullURL: "https://example.com",
189+ wantSuffix: "mple.com",
190+ wantMatchedURL: "example.com",
191+ wantOK: true,
192+ },
193+ {
194+ name: "match with www prefix in URL strips www",
195+ input: "exa",
196+ fullURL: "https://www.example.com",
197+ wantSuffix: "mple.com",
198+ wantMatchedURL: "example.com",
199+ wantOK: true,
200+ },
201+ {
202+ name: "match with www in input but not URL",
203+ input: "www.exa",
204+ fullURL: "https://example.com",
205+ wantSuffix: "mple.com",
206+ wantMatchedURL: "example.com",
207+ wantOK: true,
208+ },
209+ {
210+ name: "no match",
211+ input: "xyz",
212+ fullURL: "https://example.com",
213+ wantSuffix: "",
214+ wantMatchedURL: "",
215+ wantOK: false,
216+ },
217+ {
218+ name: "case insensitive URL match",
219+ input: "GITHUB",
220+ fullURL: "https://github.com/user/repo",
221+ wantSuffix: ".com/user/repo",
222+ wantMatchedURL: "github.com/user/repo",
223+ wantOK: true,
224+ },
225+ {
226+ name: "http protocol stripped",
227+ input: "news",
228+ fullURL: "http://news.ycombinator.com",
229+ wantSuffix: ".ycombinator.com",
230+ wantMatchedURL: "news.ycombinator.com",
231+ wantOK: true,
232+ },
233+ {
234+ name: "full URL match returns false",
235+ input: "example.com",
236+ fullURL: "https://example.com",
237+ wantSuffix: "",
238+ wantMatchedURL: "",
239+ wantOK: false,
240+ },
241+ {
242+ name: "URL with path",
243+ input: "github.com/user",
244+ fullURL: "https://github.com/user/repo",
245+ wantSuffix: "/repo",
246+ wantMatchedURL: "github.com/user/repo",
247+ wantOK: true,
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ suffix, matchedURL, ok := ComputeURLCompletionSuffix(tt.input, tt.fullURL)
254+ if suffix != tt.wantSuffix {
255+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) suffix = %q, want %q",
256+ tt.input, tt.fullURL, suffix, tt.wantSuffix)
257+ }
258+ if matchedURL != tt.wantMatchedURL {
259+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) matchedURL = %q, want %q",
260+ tt.input, tt.fullURL, matchedURL, tt.wantMatchedURL)
261+ }
262+ if ok != tt.wantOK {
263+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) ok = %v, want %v",
264+ tt.input, tt.fullURL, ok, tt.wantOK)
265+ }
266+ })
267+ }
268+}
@@ -0,0 +1,268 @@
1+package autocomplete
2+
3+import "testing"
4+
5+func TestComputeCompletionSuffix(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ fullText string
10+ wantSuffix string
11+ wantOK bool
12+ }{
13+ {
14+ name: "empty input",
15+ input: "",
16+ fullText: "example.com",
17+ wantSuffix: "",
18+ wantOK: false,
19+ },
20+ {
21+ name: "empty fullText",
22+ input: "exa",
23+ fullText: "",
24+ wantSuffix: "",
25+ wantOK: false,
26+ },
27+ {
28+ name: "both empty",
29+ input: "",
30+ fullText: "",
31+ wantSuffix: "",
32+ wantOK: false,
33+ },
34+ {
35+ name: "exact match returns false",
36+ input: "example.com",
37+ fullText: "example.com",
38+ wantSuffix: "",
39+ wantOK: false,
40+ },
41+ {
42+ name: "prefix match",
43+ input: "exa",
44+ fullText: "example.com",
45+ wantSuffix: "mple.com",
46+ wantOK: true,
47+ },
48+ {
49+ name: "case insensitive match",
50+ input: "EXA",
51+ fullText: "example.com",
52+ wantSuffix: "mple.com",
53+ wantOK: true,
54+ },
55+ {
56+ name: "case insensitive preserves original case in suffix",
57+ input: "git",
58+ fullText: "GitHub.com",
59+ wantSuffix: "Hub.com",
60+ wantOK: true,
61+ },
62+ {
63+ name: "no prefix match",
64+ input: "xyz",
65+ fullText: "example.com",
66+ wantSuffix: "",
67+ wantOK: false,
68+ },
69+ {
70+ name: "input longer than fullText",
71+ input: "example.com/path",
72+ fullText: "example.com",
73+ wantSuffix: "",
74+ wantOK: false,
75+ },
76+ {
77+ name: "single character prefix",
78+ input: "e",
79+ fullText: "example.com",
80+ wantSuffix: "xample.com",
81+ wantOK: true,
82+ },
83+ {
84+ name: "bang shortcut prefix",
85+ input: "!g",
86+ fullText: "!github",
87+ wantSuffix: "ithub",
88+ wantOK: true,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ suffix, ok := ComputeCompletionSuffix(tt.input, tt.fullText)
95+ if suffix != tt.wantSuffix {
96+ t.Errorf("ComputeCompletionSuffix(%q, %q) suffix = %q, want %q",
97+ tt.input, tt.fullText, suffix, tt.wantSuffix)
98+ }
99+ if ok != tt.wantOK {
100+ t.Errorf("ComputeCompletionSuffix(%q, %q) ok = %v, want %v",
101+ tt.input, tt.fullText, ok, tt.wantOK)
102+ }
103+ })
104+ }
105+}
106+
107+func TestStripProtocol(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ url string
111+ want string
112+ }{
113+ {
114+ name: "https protocol",
115+ url: "https://example.com",
116+ want: "example.com",
117+ },
118+ {
119+ name: "http protocol",
120+ url: "http://example.com",
121+ want: "example.com",
122+ },
123+ {
124+ name: "no protocol",
125+ url: "example.com",
126+ want: "example.com",
127+ },
128+ {
129+ name: "https with path",
130+ url: "https://example.com/path/to/page",
131+ want: "example.com/path/to/page",
132+ },
133+ {
134+ name: "http with query string",
135+ url: "http://example.com?query=value",
136+ want: "example.com?query=value",
137+ },
138+ {
139+ name: "file protocol unchanged",
140+ url: "file:///path/to/file",
141+ want: "file:///path/to/file",
142+ },
143+ {
144+ name: "empty string",
145+ url: "",
146+ want: "",
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got := StripProtocol(tt.url)
153+ if got != tt.want {
154+ t.Errorf("StripProtocol(%q) = %q, want %q", tt.url, got, tt.want)
155+ }
156+ })
157+ }
158+}
159+
160+func TestComputeURLCompletionSuffix(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ input string
164+ fullURL string
165+ wantSuffix string
166+ wantMatchedURL string
167+ wantOK bool
168+ }{
169+ {
170+ name: "empty input",
171+ input: "",
172+ fullURL: "https://example.com",
173+ wantSuffix: "",
174+ wantMatchedURL: "",
175+ wantOK: false,
176+ },
177+ {
178+ name: "direct match with protocol",
179+ input: "https://exa",
180+ fullURL: "https://example.com",
181+ wantSuffix: "mple.com",
182+ wantMatchedURL: "https://example.com",
183+ wantOK: true,
184+ },
185+ {
186+ name: "match without protocol in input",
187+ input: "exa",
188+ fullURL: "https://example.com",
189+ wantSuffix: "mple.com",
190+ wantMatchedURL: "example.com",
191+ wantOK: true,
192+ },
193+ {
194+ name: "match with www prefix in URL strips www",
195+ input: "exa",
196+ fullURL: "https://www.example.com",
197+ wantSuffix: "mple.com",
198+ wantMatchedURL: "example.com",
199+ wantOK: true,
200+ },
201+ {
202+ name: "match with www in input but not URL",
203+ input: "www.exa",
204+ fullURL: "https://example.com",
205+ wantSuffix: "mple.com",
206+ wantMatchedURL: "example.com",
207+ wantOK: true,
208+ },
209+ {
210+ name: "no match",
211+ input: "xyz",
212+ fullURL: "https://example.com",
213+ wantSuffix: "",
214+ wantMatchedURL: "",
215+ wantOK: false,
216+ },
217+ {
218+ name: "case insensitive URL match",
219+ input: "GITHUB",
220+ fullURL: "https://github.com/user/repo",
221+ wantSuffix: ".com/user/repo",
222+ wantMatchedURL: "github.com/user/repo",
223+ wantOK: true,
224+ },
225+ {
226+ name: "http protocol stripped",
227+ input: "news",
228+ fullURL: "http://news.ycombinator.com",
229+ wantSuffix: ".ycombinator.com",
230+ wantMatchedURL: "news.ycombinator.com",
231+ wantOK: true,
232+ },
233+ {
234+ name: "full URL match returns false",
235+ input: "example.com",
236+ fullURL: "https://example.com",
237+ wantSuffix: "",
238+ wantMatchedURL: "",
239+ wantOK: false,
240+ },
241+ {
242+ name: "URL with path",
243+ input: "github.com/user",
244+ fullURL: "https://github.com/user/repo",
245+ wantSuffix: "/repo",
246+ wantMatchedURL: "github.com/user/repo",
247+ wantOK: true,
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ suffix, matchedURL, ok := ComputeURLCompletionSuffix(tt.input, tt.fullURL)
254+ if suffix != tt.wantSuffix {
255+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) suffix = %q, want %q",
256+ tt.input, tt.fullURL, suffix, tt.wantSuffix)
257+ }
258+ if matchedURL != tt.wantMatchedURL {
259+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) matchedURL = %q, want %q",
260+ tt.input, tt.fullURL, matchedURL, tt.wantMatchedURL)
261+ }
262+ if ok != tt.wantOK {
263+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) ok = %v, want %v",
264+ tt.input, tt.fullURL, ok, tt.wantOK)
265+ }
266+ })
267+ }
268+}
M internal/ui/component/omnibox.go +76 -41
@@ -3,11 +3,14 @@ package component
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "strings"
78 "sync"
89 "time"
10+ "unicode/utf8"
911
1012 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/autocomplete"
1114 "github.com/bnema/dumber/internal/domain/entity"
1215 "github.com/bnema/dumber/internal/domain/url"
1316 "github.com/bnema/dumber/internal/logging"
@@ -16,14 +19,14 @@ import (
1619 "github.com/bnema/dumber/internal/ui/layout"
1720 "github.com/jwijenbergh/puregotk/v4/gdk"
1821 "github.com/jwijenbergh/puregotk/v4/glib"
22+ "github.com/jwijenbergh/puregotk/v4/graphene"
1923 "github.com/jwijenbergh/puregotk/v4/gtk"
2024 "github.com/jwijenbergh/puregotk/v4/pango"
2125 )
2226
2327 const (
24- debounceDelayMs = 50
25- endBoxSpacing = 6
26- ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale)
28+ debounceDelayMs = 50
29+ endBoxSpacing = 6
2730 )
2831
2932 // ViewMode distinguishes history search from favorites display.
@@ -419,11 +422,14 @@ func (o *Omnibox) initEntry() error {
419422 }
420423 o.ghostLabel.AddCssClass("omnibox-ghost")
421424 o.ghostLabel.SetHalign(gtk.AlignStartValue)
422- o.ghostLabel.SetValign(gtk.AlignCenterValue)
425+ o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
426+ o.ghostLabel.SetXalign(0)
423427 o.ghostLabel.SetVisible(false)
424- o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
428+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
429+ o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
425430
426431 o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
432+ o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
427433
428434 return nil
429435 }
@@ -737,15 +743,8 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
737743 return
738744 }
739745
746+ // Store ghost state optimistically - will be validated on main thread
740747 o.mu.Lock()
741- currentInput := o.realInput
742- // Check if input has changed since the autocomplete query was made
743- // Skip this check when originalInput is empty (row selection replaces full input)
744- if originalInput != "" && currentInput != originalInput {
745- o.mu.Unlock()
746- // Input changed, skip this stale result - a newer query will handle it
747- return
748- }
749748 o.ghostSuffix = suffix
750749 o.ghostFullText = fullText
751750 o.hasGhostText = true
@@ -756,6 +755,14 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
756755 return false
757756 }
758757
758+ // Check if input has changed since the autocomplete query was made
759+ // This check runs on GTK main thread where entry.GetText() is reliable
760+ // Skip this check when originalInput is empty (row selection replaces full input)
761+ if originalInput != "" && o.entry.GetText() != originalInput {
762+ // Input changed, skip this stale result - a newer query will handle it
763+ return false
764+ }
765+
759766 // Get the entry's Pango context and create a layout to measure text width
760767 pangoCtx := o.entry.GetPangoContext()
761768 if pangoCtx == nil {
@@ -770,15 +777,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
770777 var widthPx, heightPx int
771778 layout.GetPixelSize(&widthPx, &heightPx)
772779
773- // Account for the search icon inside the entry
774- // Ghost label already has same margin/padding as entry via CSS
775- // Scale the icon offset by UI scale factor
776- iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale)
777- marginStart := widthPx + iconOffset
778-
779- // Set the ghost label text (just the suffix) and position it
780+ // Set the ghost label text (just the suffix) and position it horizontally
780781 o.ghostLabel.SetText(suffix)
782+
783+ marginStart := widthPx
784+ delegate := o.entry.GetDelegate()
785+ if delegate != nil && o.entryOverlay != nil {
786+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
787+ if textWidget != nil {
788+ var textBounds graphene.Rect
789+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
790+ cursorPos := uint(utf8.RuneCountInString(originalInput))
791+ var strongRect, weakRect graphene.Rect
792+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
793+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
794+ marginStart = int(math.Round(cursorX))
795+ }
796+ }
797+ }
798+
799+ // Clamp horizontal position to entry bounds
800+ if marginStart < 0 {
801+ marginStart = 0
802+ }
803+ if o.entryOverlay != nil {
804+ var entryBounds graphene.Rect
805+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
806+ minStart := int(math.Round(float64(entryBounds.GetX())))
807+ if marginStart < minStart {
808+ marginStart = minStart
809+ }
810+ }
811+ }
812+
781813 o.ghostLabel.SetMarginStart(marginStart)
814+ // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
782815 o.ghostLabel.SetVisible(true)
783816
784817 // Hide placeholder text so it doesn't show through ghost text
@@ -846,16 +879,19 @@ func (o *Omnibox) acceptGhostCompletion() {
846879 }
847880
848881 // updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
849-func (o *Omnibox) updateGhostFromSuggestion() {
882+// The query parameter is the search query that was used, ensuring we don't use stale input.
883+func (o *Omnibox) updateGhostFromSuggestion(query string) {
850884 if o.autocompleteUC == nil {
851885 return
852886 }
853887
854888 o.mu.RLock()
855- userInput := o.realInput
856889 bangMode := o.bangMode
857890 o.mu.RUnlock()
858891
892+ // Use the query that triggered this search, not realInput which may have changed
893+ userInput := query
894+
859895 // Don't show ghost text in bang mode (it has its own completion)
860896 if bangMode || userInput == "" {
861897 o.clearGhostText()
@@ -887,12 +923,7 @@ func (o *Omnibox) updateGhostFromURL(targetURL string) {
887923 o.mu.RUnlock()
888924
889925 // Strip protocol from URL for cleaner display
890- displayURL := targetURL
891- if strings.HasPrefix(displayURL, "https://") {
892- displayURL = displayURL[8:]
893- } else if strings.HasPrefix(displayURL, "http://") {
894- displayURL = displayURL[7:]
895- }
926+ displayURL := autocomplete.StripProtocol(targetURL)
896927
897928 // Try to compute proper suffix if input is a prefix of the URL
898929 if o.autocompleteUC != nil && userInput != "" {
@@ -1035,8 +1066,8 @@ func (o *Omnibox) performSearch() {
10351066 })
10361067 }
10371068
1038- // Marshal back to GTK main thread
1039- o.idleAddUpdateSuggestions(suggestions)
1069+ // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1070+ o.idleAddUpdateSuggestions(suggestions, query)
10401071 }()
10411072 }
10421073
@@ -1053,7 +1084,7 @@ func (o *Omnibox) loadInitialHistory() {
10531084
10541085 switch o.initialBehavior {
10551086 case "none":
1056- o.idleAddUpdateSuggestions(nil)
1087+ o.idleAddUpdateSuggestions(nil, "")
10571088 return
10581089
10591090 case "most_visited", "recent", "":
@@ -1098,7 +1129,7 @@ func (o *Omnibox) loadInitialHistory() {
10981129 }
10991130 }
11001131
1101- o.idleAddUpdateSuggestions(suggestions)
1132+ o.idleAddUpdateSuggestions(suggestions, "")
11021133 }()
11031134 }
11041135
@@ -1136,7 +1167,7 @@ func (o *Omnibox) loadFavorites(query string) {
11361167 })
11371168 }
11381169
1139- o.idleAddUpdateFavorites(favorites)
1170+ o.idleAddUpdateFavorites(favorites, query)
11401171 }()
11411172 }
11421173
@@ -1238,7 +1269,8 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
12381269 }
12391270
12401271 // updateSuggestions updates the list with history suggestions.
1241-func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
1272+// The query parameter is the search query that triggered this update.
1273+func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
12421274 o.mu.Lock()
12431275 o.bangMode = false
12441276 o.bangSuggestions = nil
@@ -1259,14 +1291,15 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
12591291 if rowCount > 0 {
12601292 o.selectIndex(0)
12611293 // Update ghost text from first suggestion
1262- o.updateGhostFromSuggestion()
1294+ o.updateGhostFromSuggestion(query)
12631295 } else {
12641296 o.clearGhostText()
12651297 }
12661298 }
12671299
12681300 // updateFavorites updates the list with favorites.
1269-func (o *Omnibox) updateFavorites(favorites []Favorite) {
1301+// The query parameter is the search query that triggered this update.
1302+func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
12701303 o.mu.Lock()
12711304 o.bangMode = false
12721305 o.bangSuggestions = nil
@@ -1287,7 +1320,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
12871320 if rowCount > 0 {
12881321 o.selectIndex(0)
12891322 // Update ghost text from first favorite
1290- o.updateGhostFromSuggestion()
1323+ o.updateGhostFromSuggestion(query)
12911324 } else {
12921325 o.clearGhostText()
12931326 }
@@ -2009,9 +2042,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
20092042 }
20102043
20112044 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2012-func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
2045+// The query parameter is passed through to ensure ghost text uses the correct input.
2046+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
20132047 var cb glib.SourceFunc = func(data uintptr) bool {
2014- o.updateSuggestions(suggestions)
2048+ o.updateSuggestions(suggestions, query)
20152049 return false // One-shot callback
20162050 }
20172051 glib.IdleAdd(&cb, 0)
@@ -2033,9 +2067,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
20332067 }
20342068
20352069 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2036-func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
2070+// The query parameter is passed through to ensure ghost text uses the correct input.
2071+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
20372072 var cb glib.SourceFunc = func(data uintptr) bool {
2038- o.updateFavorites(favorites)
2073+ o.updateFavorites(favorites, query)
20392074 return false // One-shot callback, return false to remove source
20402075 }
20412076 glib.IdleAdd(&cb, 0)
@@ -3,11 +3,14 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
10+ "unicode/utf8"
9 11
10 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/autocomplete"
11 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/url"15 "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
@@ -16,14 +19,14 @@ import (
16 "github.com/bnema/dumber/internal/ui/layout"19 "github.com/bnema/dumber/internal/ui/layout"
17 "github.com/jwijenbergh/puregotk/v4/gdk"20 "github.com/jwijenbergh/puregotk/v4/gdk"
18 "github.com/jwijenbergh/puregotk/v4/glib"21 "github.com/jwijenbergh/puregotk/v4/glib"
22+ "github.com/jwijenbergh/puregotk/v4/graphene"
19 "github.com/jwijenbergh/puregotk/v4/gtk"23 "github.com/jwijenbergh/puregotk/v4/gtk"
20 "github.com/jwijenbergh/puregotk/v4/pango"24 "github.com/jwijenbergh/puregotk/v4/pango"
21 )25 )
22 26
23 const (27 const (
24- debounceDelayMs = 5028+ debounceDelayMs = 50
25- endBoxSpacing = 629+ endBoxSpacing = 6
26- ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale)
27 )30 )
28 31
29 // ViewMode distinguishes history search from favorites display.32 // ViewMode distinguishes history search from favorites display.
@@ -419,11 +422,14 @@ func (o *Omnibox) initEntry() error {
419 }422 }
420 o.ghostLabel.AddCssClass("omnibox-ghost")423 o.ghostLabel.AddCssClass("omnibox-ghost")
421 o.ghostLabel.SetHalign(gtk.AlignStartValue)424 o.ghostLabel.SetHalign(gtk.AlignStartValue)
422- o.ghostLabel.SetValign(gtk.AlignCenterValue)425+ o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
426+ o.ghostLabel.SetXalign(0)
423 o.ghostLabel.SetVisible(false)427 o.ghostLabel.SetVisible(false)
424- o.ghostLabel.SetCanTarget(false) // Don't intercept clicks428+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
429+ o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
425 430
426 o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)431 o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
432+ o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
427 433
428 return nil434 return nil
429 }435 }
@@ -737,15 +743,8 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
737 return743 return
738 }744 }
739 745
746+ // Store ghost state optimistically - will be validated on main thread
740 o.mu.Lock()747 o.mu.Lock()
741- currentInput := o.realInput
742- // Check if input has changed since the autocomplete query was made
743- // Skip this check when originalInput is empty (row selection replaces full input)
744- if originalInput != "" && currentInput != originalInput {
745- o.mu.Unlock()
746- // Input changed, skip this stale result - a newer query will handle it
747- return
748- }
749 o.ghostSuffix = suffix748 o.ghostSuffix = suffix
750 o.ghostFullText = fullText749 o.ghostFullText = fullText
751 o.hasGhostText = true750 o.hasGhostText = true
@@ -756,6 +755,14 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
756 return false755 return false
757 }756 }
758 757
758+ // Check if input has changed since the autocomplete query was made
759+ // This check runs on GTK main thread where entry.GetText() is reliable
760+ // Skip this check when originalInput is empty (row selection replaces full input)
761+ if originalInput != "" && o.entry.GetText() != originalInput {
762+ // Input changed, skip this stale result - a newer query will handle it
763+ return false
764+ }
765+
759 // Get the entry's Pango context and create a layout to measure text width766 // Get the entry's Pango context and create a layout to measure text width
760 pangoCtx := o.entry.GetPangoContext()767 pangoCtx := o.entry.GetPangoContext()
761 if pangoCtx == nil {768 if pangoCtx == nil {
@@ -770,15 +777,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
770 var widthPx, heightPx int777 var widthPx, heightPx int
771 layout.GetPixelSize(&widthPx, &heightPx)778 layout.GetPixelSize(&widthPx, &heightPx)
772 779
773- // Account for the search icon inside the entry780+ // Set the ghost label text (just the suffix) and position it horizontally
774- // Ghost label already has same margin/padding as entry via CSS
775- // Scale the icon offset by UI scale factor
776- iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale)
777- marginStart := widthPx + iconOffset
778-
779- // Set the ghost label text (just the suffix) and position it
780 o.ghostLabel.SetText(suffix)781 o.ghostLabel.SetText(suffix)
782+
783+ marginStart := widthPx
784+ delegate := o.entry.GetDelegate()
785+ if delegate != nil && o.entryOverlay != nil {
786+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
787+ if textWidget != nil {
788+ var textBounds graphene.Rect
789+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
790+ cursorPos := uint(utf8.RuneCountInString(originalInput))
791+ var strongRect, weakRect graphene.Rect
792+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
793+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
794+ marginStart = int(math.Round(cursorX))
795+ }
796+ }
797+ }
798+
799+ // Clamp horizontal position to entry bounds
800+ if marginStart < 0 {
801+ marginStart = 0
802+ }
803+ if o.entryOverlay != nil {
804+ var entryBounds graphene.Rect
805+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
806+ minStart := int(math.Round(float64(entryBounds.GetX())))
807+ if marginStart < minStart {
808+ marginStart = minStart
809+ }
810+ }
811+ }
812+
781 o.ghostLabel.SetMarginStart(marginStart)813 o.ghostLabel.SetMarginStart(marginStart)
814+ // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
782 o.ghostLabel.SetVisible(true)815 o.ghostLabel.SetVisible(true)
783 816
784 // Hide placeholder text so it doesn't show through ghost text817 // Hide placeholder text so it doesn't show through ghost text
@@ -846,16 +879,19 @@ func (o *Omnibox) acceptGhostCompletion() {
846 }879 }
847 880
848 // updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.881 // updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
849-func (o *Omnibox) updateGhostFromSuggestion() {882+// The query parameter is the search query that was used, ensuring we don't use stale input.
883+func (o *Omnibox) updateGhostFromSuggestion(query string) {
850 if o.autocompleteUC == nil {884 if o.autocompleteUC == nil {
851 return885 return
852 }886 }
853 887
854 o.mu.RLock()888 o.mu.RLock()
855- userInput := o.realInput
856 bangMode := o.bangMode889 bangMode := o.bangMode
857 o.mu.RUnlock()890 o.mu.RUnlock()
858 891
892+ // Use the query that triggered this search, not realInput which may have changed
893+ userInput := query
894+
859 // Don't show ghost text in bang mode (it has its own completion)895 // Don't show ghost text in bang mode (it has its own completion)
860 if bangMode || userInput == "" {896 if bangMode || userInput == "" {
861 o.clearGhostText()897 o.clearGhostText()
@@ -887,12 +923,7 @@ func (o *Omnibox) updateGhostFromURL(targetURL string) {
887 o.mu.RUnlock()923 o.mu.RUnlock()
888 924
889 // Strip protocol from URL for cleaner display925 // Strip protocol from URL for cleaner display
890- displayURL := targetURL926+ displayURL := autocomplete.StripProtocol(targetURL)
891- if strings.HasPrefix(displayURL, "https://") {
892- displayURL = displayURL[8:]
893- } else if strings.HasPrefix(displayURL, "http://") {
894- displayURL = displayURL[7:]
895- }
896 927
897 // Try to compute proper suffix if input is a prefix of the URL928 // Try to compute proper suffix if input is a prefix of the URL
898 if o.autocompleteUC != nil && userInput != "" {929 if o.autocompleteUC != nil && userInput != "" {
@@ -1035,8 +1066,8 @@ func (o *Omnibox) performSearch() {
1035 })1066 })
1036 }1067 }
1037 1068
1038- // Marshal back to GTK main thread1069+ // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1039- o.idleAddUpdateSuggestions(suggestions)1070+ o.idleAddUpdateSuggestions(suggestions, query)
1040 }()1071 }()
1041 }1072 }
1042 1073
@@ -1053,7 +1084,7 @@ func (o *Omnibox) loadInitialHistory() {
1053 1084
1054 switch o.initialBehavior {1085 switch o.initialBehavior {
1055 case "none":1086 case "none":
1056- o.idleAddUpdateSuggestions(nil)1087+ o.idleAddUpdateSuggestions(nil, "")
1057 return1088 return
1058 1089
1059 case "most_visited", "recent", "":1090 case "most_visited", "recent", "":
@@ -1098,7 +1129,7 @@ func (o *Omnibox) loadInitialHistory() {
1098 }1129 }
1099 }1130 }
1100 1131
1101- o.idleAddUpdateSuggestions(suggestions)1132+ o.idleAddUpdateSuggestions(suggestions, "")
1102 }()1133 }()
1103 }1134 }
1104 1135
@@ -1136,7 +1167,7 @@ func (o *Omnibox) loadFavorites(query string) {
1136 })1167 })
1137 }1168 }
1138 1169
1139- o.idleAddUpdateFavorites(favorites)1170+ o.idleAddUpdateFavorites(favorites, query)
1140 }()1171 }()
1141 }1172 }
1142 1173
@@ -1238,7 +1269,8 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1238 }1269 }
1239 1270
1240 // updateSuggestions updates the list with history suggestions.1271 // updateSuggestions updates the list with history suggestions.
1241-func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {1272+// The query parameter is the search query that triggered this update.
1273+func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1242 o.mu.Lock()1274 o.mu.Lock()
1243 o.bangMode = false1275 o.bangMode = false
1244 o.bangSuggestions = nil1276 o.bangSuggestions = nil
@@ -1259,14 +1291,15 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
1259 if rowCount > 0 {1291 if rowCount > 0 {
1260 o.selectIndex(0)1292 o.selectIndex(0)
1261 // Update ghost text from first suggestion1293 // Update ghost text from first suggestion
1262- o.updateGhostFromSuggestion()1294+ o.updateGhostFromSuggestion(query)
1263 } else {1295 } else {
1264 o.clearGhostText()1296 o.clearGhostText()
1265 }1297 }
1266 }1298 }
1267 1299
1268 // updateFavorites updates the list with favorites.1300 // updateFavorites updates the list with favorites.
1269-func (o *Omnibox) updateFavorites(favorites []Favorite) {1301+// The query parameter is the search query that triggered this update.
1302+func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1270 o.mu.Lock()1303 o.mu.Lock()
1271 o.bangMode = false1304 o.bangMode = false
1272 o.bangSuggestions = nil1305 o.bangSuggestions = nil
@@ -1287,7 +1320,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
1287 if rowCount > 0 {1320 if rowCount > 0 {
1288 o.selectIndex(0)1321 o.selectIndex(0)
1289 // Update ghost text from first favorite1322 // Update ghost text from first favorite
1290- o.updateGhostFromSuggestion()1323+ o.updateGhostFromSuggestion(query)
1291 } else {1324 } else {
1292 o.clearGhostText()1325 o.clearGhostText()
1293 }1326 }
@@ -2009,9 +2042,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2009 }2042 }
2010 2043
2011 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.2044 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2012-func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {2045+// The query parameter is passed through to ensure ghost text uses the correct input.
2046+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2013 var cb glib.SourceFunc = func(data uintptr) bool {2047 var cb glib.SourceFunc = func(data uintptr) bool {
2014- o.updateSuggestions(suggestions)2048+ o.updateSuggestions(suggestions, query)
2015 return false // One-shot callback2049 return false // One-shot callback
2016 }2050 }
2017 glib.IdleAdd(&cb, 0)2051 glib.IdleAdd(&cb, 0)
@@ -2033,9 +2067,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2033 }2067 }
2034 2068
2035 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.2069 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2036-func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {2070+// The query parameter is passed through to ensure ghost text uses the correct input.
2071+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2037 var cb glib.SourceFunc = func(data uintptr) bool {2072 var cb glib.SourceFunc = func(data uintptr) bool {
2038- o.updateFavorites(favorites)2073+ o.updateFavorites(favorites, query)
2039 return false // One-shot callback, return false to remove source2074 return false // One-shot callback, return false to remove source
2040 }2075 }
2041 glib.IdleAdd(&cb, 0)2076 glib.IdleAdd(&cb, 0)
M internal/ui/theme/css.go +1 -3
@@ -414,9 +414,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
414414 .omnibox-ghost {
415415 font-size: 1em;
416416 color: var(--muted);
417- padding: 0.625em 0.75em;
418- margin: 0.5em 0.75em;
419- /* Match the entry padding/margin exactly for alignment */
417+ margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */
420418 background-color: transparent;
421419 /* Allow clicks to pass through to entry below */
422420 pointer-events: none;
@@ -414,9 +414,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
414 .omnibox-ghost {414 .omnibox-ghost {
415 font-size: 1em;415 font-size: 1em;
416 color: var(--muted);416 color: var(--muted);
417- padding: 0.625em 0.75em;417+ margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */
418- margin: 0.5em 0.75em;
419- /* Match the entry padding/margin exactly for alignment */
420 background-color: transparent;418 background-color: transparent;
421 /* Allow clicks to pass through to entry below */419 /* Allow clicks to pass through to entry below */
422 pointer-events: none;420 pointer-events: none;

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