Merge pull request #138 from bnema/fix/omnibox-ghost-text-alignment
fix(omnibox): align ghost text vertically with entry text
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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 10 | + | "unicode/utf8" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 11 | 14 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 15 | "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 16 | "github.com/bnema/dumber/internal/logging" | |
| @@ -16,14 +19,14 @@ import ( | |||
| 16 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | 20 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 18 | 21 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 19 | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 20 | 24 | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 21 | 25 | ) | |
| 22 | 26 | ||
| 23 | 27 | 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 | |
| 27 | 30 | ) | |
| 28 | 31 | ||
| 29 | 32 | // ViewMode distinguishes history search from favorites display. | |
| @@ -419,11 +422,14 @@ func (o *Omnibox) initEntry() error { | |||
| 419 | 422 | } | |
| 420 | 423 | o.ghostLabel.AddCssClass("omnibox-ghost") | |
| 421 | 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 | 427 | 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 | |
| 425 | 430 | ||
| 426 | 431 | o.entryOverlay.AddOverlay(&o.ghostLabel.Widget) | |
| 432 | + | o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds | |
| 427 | 433 | ||
| 428 | 434 | return nil | |
| 429 | 435 | } | |
| @@ -737,15 +743,8 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 737 | 743 | return | |
| 738 | 744 | } | |
| 739 | 745 | ||
| 746 | + | // Store ghost state optimistically - will be validated on main thread | |
| 740 | 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 | 748 | o.ghostSuffix = suffix | |
| 750 | 749 | o.ghostFullText = fullText | |
| 751 | 750 | o.hasGhostText = true | |
| @@ -756,6 +755,14 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 756 | 755 | 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 | 766 | // Get the entry's Pango context and create a layout to measure text width | |
| 760 | 767 | pangoCtx := o.entry.GetPangoContext() | |
| 761 | 768 | if pangoCtx == nil { | |
| @@ -770,15 +777,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 770 | 777 | var widthPx, heightPx int | |
| 771 | 778 | layout.GetPixelSize(&widthPx, &heightPx) | |
| 772 | 779 | ||
| 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 | |
| 780 | 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 | 813 | o.ghostLabel.SetMarginStart(marginStart) | |
| 814 | + | // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI | |
| 782 | 815 | o.ghostLabel.SetVisible(true) | |
| 783 | 816 | ||
| 784 | 817 | // Hide placeholder text so it doesn't show through ghost text | |
| @@ -846,16 +879,19 @@ func (o *Omnibox) acceptGhostCompletion() { | |||
| 846 | 879 | } | |
| 847 | 880 | ||
| 848 | 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 | 884 | if o.autocompleteUC == nil { | |
| 851 | 885 | return | |
| 852 | 886 | } | |
| 853 | 887 | ||
| 854 | 888 | o.mu.RLock() | |
| 855 | - | userInput := o.realInput | |
| 856 | 889 | bangMode := o.bangMode | |
| 857 | 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 | 895 | // Don't show ghost text in bang mode (it has its own completion) | |
| 860 | 896 | if bangMode || userInput == "" { | |
| 861 | 897 | o.clearGhostText() | |
| @@ -887,12 +923,7 @@ func (o *Omnibox) updateGhostFromURL(targetURL string) { | |||
| 887 | 923 | o.mu.RUnlock() | |
| 888 | 924 | ||
| 889 | 925 | // 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) | |
| 896 | 927 | ||
| 897 | 928 | // Try to compute proper suffix if input is a prefix of the URL | |
| 898 | 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 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) | |
| 1040 | 1071 | }() | |
| 1041 | 1072 | } | |
| 1042 | 1073 | ||
| @@ -1053,7 +1084,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1053 | 1084 | ||
| 1054 | 1085 | switch o.initialBehavior { | |
| 1055 | 1086 | case "none": | |
| 1056 | - | o.idleAddUpdateSuggestions(nil) | |
| 1087 | + | o.idleAddUpdateSuggestions(nil, "") | |
| 1057 | 1088 | return | |
| 1058 | 1089 | ||
| 1059 | 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 | 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 | 1274 | o.mu.Lock() | |
| 1243 | 1275 | o.bangMode = false | |
| 1244 | 1276 | o.bangSuggestions = nil | |
| @@ -1259,14 +1291,15 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||
| 1259 | 1291 | if rowCount > 0 { | |
| 1260 | 1292 | o.selectIndex(0) | |
| 1261 | 1293 | // Update ghost text from first suggestion | |
| 1262 | - | o.updateGhostFromSuggestion() | |
| 1294 | + | o.updateGhostFromSuggestion(query) | |
| 1263 | 1295 | } else { | |
| 1264 | 1296 | o.clearGhostText() | |
| 1265 | 1297 | } | |
| 1266 | 1298 | } | |
| 1267 | 1299 | ||
| 1268 | 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 | 1303 | o.mu.Lock() | |
| 1271 | 1304 | o.bangMode = false | |
| 1272 | 1305 | o.bangSuggestions = nil | |
| @@ -1287,7 +1320,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) { | |||
| 1287 | 1320 | if rowCount > 0 { | |
| 1288 | 1321 | o.selectIndex(0) | |
| 1289 | 1322 | // Update ghost text from first favorite | |
| 1290 | - | o.updateGhostFromSuggestion() | |
| 1323 | + | o.updateGhostFromSuggestion(query) | |
| 1291 | 1324 | } else { | |
| 1292 | 1325 | o.clearGhostText() | |
| 1293 | 1326 | } | |
| @@ -2009,9 +2042,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||
| 2009 | 2042 | } | |
| 2010 | 2043 | ||
| 2011 | 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 | 2047 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2014 | - | o.updateSuggestions(suggestions) | |
| 2048 | + | o.updateSuggestions(suggestions, query) | |
| 2015 | 2049 | return false // One-shot callback | |
| 2016 | 2050 | } | |
| 2017 | 2051 | glib.IdleAdd(&cb, 0) | |
| @@ -2033,9 +2067,10 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} { | |||
| 2033 | 2067 | } | |
| 2034 | 2068 | ||
| 2035 | 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 | 2072 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2038 | - | o.updateFavorites(favorites) | |
| 2073 | + | o.updateFavorites(favorites, query) | |
| 2039 | 2074 | return false // One-shot callback, return false to remove source | |
| 2040 | 2075 | } | |
| 2041 | 2076 | 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 = 50 | 28 | + | debounceDelayMs = 50 |
| 25 | - | endBoxSpacing = 6 | 29 | + | 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 clicks | 428 | + | 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 nil | 434 | return nil | ||
| 429 | } | 435 | } | ||
| @@ -737,15 +743,8 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 737 | return | 743 | 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 = suffix | 748 | o.ghostSuffix = suffix | ||
| 750 | o.ghostFullText = fullText | 749 | o.ghostFullText = fullText | ||
| 751 | o.hasGhostText = true | 750 | o.hasGhostText = true | ||
| @@ -756,6 +755,14 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 756 | return false | 755 | 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 width | 766 | // 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 int | 777 | var widthPx, heightPx int | ||
| 771 | layout.GetPixelSize(&widthPx, &heightPx) | 778 | layout.GetPixelSize(&widthPx, &heightPx) | ||
| 772 | 779 | ||||
| 773 | - | // Account for the search icon inside the entry | 780 | + | // 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 text | 817 | // 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 | return | 885 | return | ||
| 852 | } | 886 | } | ||
| 853 | 887 | ||||
| 854 | o.mu.RLock() | 888 | o.mu.RLock() | ||
| 855 | - | userInput := o.realInput | |||
| 856 | bangMode := o.bangMode | 889 | 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 display | 925 | // Strip protocol from URL for cleaner display | ||
| 890 | - | displayURL := targetURL | 926 | + | 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 URL | 928 | // 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 thread | 1069 | + | // 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 | return | 1088 | 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 = false | 1275 | o.bangMode = false | ||
| 1244 | o.bangSuggestions = nil | 1276 | 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 suggestion | 1293 | // 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 = false | 1304 | o.bangMode = false | ||
| 1272 | o.bangSuggestions = nil | 1305 | 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 favorite | 1322 | // 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 callback | 2049 | 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 source | 2074 | 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 { | |||
| 414 | 414 | .omnibox-ghost { | |
| 415 | 415 | font-size: 1em; | |
| 416 | 416 | 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 */ | |
| 420 | 418 | background-color: transparent; | |
| 421 | 419 | /* Allow clicks to pass through to entry below */ | |
| 422 | 420 | 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; | ||