refactor: unify omnibox suggestion authority
- commit
- f7811f6a88e5bef4ac5e9ae8725e300c5638018d
- author
- brice · · 2026-03-10 11:30 UTC
- parent
- 690c646
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +986 -877
D internal/application/usecase/autocomplete.go +0 -362
| @@ -1,362 +0,0 @@ | |||
| 1 | - | package usecase | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "sync" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 9 | - | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | - | "github.com/bnema/dumber/internal/logging" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // AutocompleteUseCase handles inline autocomplete suggestions. | |
| 14 | - | type AutocompleteUseCase struct { | |
| 15 | - | historyUC *SearchHistoryUseCase | |
| 16 | - | favoritesUC *ManageFavoritesUseCase | |
| 17 | - | shortcutsUC *SearchShortcutsUseCase | |
| 18 | - | ||
| 19 | - | cacheMu sync.Mutex | |
| 20 | - | favoritesCache []*entity.Favorite | |
| 21 | - | favoritesCached time.Time | |
| 22 | - | historyCache []*entity.HistoryEntry | |
| 23 | - | historyCached time.Time | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | const autocompleteHistoryLimit = 200 | |
| 27 | - | const autocompleteCacheTTL = 2 * time.Second | |
| 28 | - | ||
| 29 | - | // NewAutocompleteUseCase creates a new autocomplete use case. | |
| 30 | - | func NewAutocompleteUseCase( | |
| 31 | - | historyUC *SearchHistoryUseCase, | |
| 32 | - | favoritesUC *ManageFavoritesUseCase, | |
| 33 | - | shortcutsUC *SearchShortcutsUseCase, | |
| 34 | - | ) *AutocompleteUseCase { | |
| 35 | - | return &AutocompleteUseCase{ | |
| 36 | - | historyUC: historyUC, | |
| 37 | - | favoritesUC: favoritesUC, | |
| 38 | - | shortcutsUC: shortcutsUC, | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // GetSuggestionInput contains parameters for getting an autocomplete suggestion. | |
| 43 | - | type GetSuggestionInput struct { | |
| 44 | - | Input string // Current user input | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // GetSuggestionOutput contains the best autocomplete suggestion. | |
| 48 | - | type GetSuggestionOutput struct { | |
| 49 | - | Suggestion *autocomplete.Suggestion | |
| 50 | - | Found bool | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // CompletionOptions controls how URL completions are resolved. | |
| 54 | - | type CompletionOptions struct { | |
| 55 | - | VisibleURLs []string | |
| 56 | - | AllowBangs bool | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // GetSuggestion returns the best autocomplete suggestion for the given input. | |
| 60 | - | // Priority: Favorites > History (by frecency) > Bang shortcuts. | |
| 61 | - | func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput { | |
| 62 | - | log := logging.FromContext(ctx) | |
| 63 | - | ||
| 64 | - | if input.Input == "" { | |
| 65 | - | return &GetSuggestionOutput{Found: false} | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Try favorites first (highest priority) | |
| 69 | - | if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil { | |
| 70 | - | log.Debug(). | |
| 71 | - | Str("input", input.Input). | |
| 72 | - | Str("match", suggestion.FullText). | |
| 73 | - | Msg("autocomplete: matched favorite") | |
| 74 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // Try history (second priority) | |
| 78 | - | if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil { | |
| 79 | - | log.Debug(). | |
| 80 | - | Str("input", input.Input). | |
| 81 | - | Str("match", suggestion.FullText). | |
| 82 | - | Msg("autocomplete: matched history") | |
| 83 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // Try bang shortcuts (lowest priority for URL-style completion) | |
| 87 | - | if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil { | |
| 88 | - | log.Debug(). | |
| 89 | - | Str("input", input.Input). | |
| 90 | - | Str("match", suggestion.FullText). | |
| 91 | - | Msg("autocomplete: matched bang shortcut") | |
| 92 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | return &GetSuggestionOutput{Found: false} | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // ResolveCompletion resolves the best completion using visible list order first, | |
| 99 | - | // then cached favorites/history as fallback. | |
| 100 | - | func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion { | |
| 101 | - | log := logging.FromContext(ctx) | |
| 102 | - | ||
| 103 | - | if input == "" { | |
| 104 | - | return nil | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | if len(opts.VisibleURLs) > 0 { | |
| 108 | - | if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil { | |
| 109 | - | log.Debug(). | |
| 110 | - | Str("input", input). | |
| 111 | - | Str("match", suggestion.FullText). | |
| 112 | - | Msg("autocomplete: matched visible list") | |
| 113 | - | return suggestion | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil { | |
| 118 | - | log.Debug(). | |
| 119 | - | Str("input", input). | |
| 120 | - | Str("match", suggestion.FullText). | |
| 121 | - | Msg("autocomplete: matched favorite fallback") | |
| 122 | - | return suggestion | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil { | |
| 126 | - | log.Debug(). | |
| 127 | - | Str("input", input). | |
| 128 | - | Str("match", suggestion.FullText). | |
| 129 | - | Msg("autocomplete: matched history fallback") | |
| 130 | - | return suggestion | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | if opts.AllowBangs { | |
| 134 | - | if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil { | |
| 135 | - | log.Debug(). | |
| 136 | - | Str("input", input). | |
| 137 | - | Str("match", suggestion.FullText). | |
| 138 | - | Msg("autocomplete: matched bang fallback") | |
| 139 | - | return suggestion | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | return nil | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // findFavoriteSuggestion finds the best matching favorite for the input. | |
| 147 | - | func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 148 | - | log := logging.FromContext(ctx) | |
| 149 | - | ||
| 150 | - | if uc.favoritesUC == nil { | |
| 151 | - | return nil | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | favorites, err := uc.getCachedFavorites(ctx) | |
| 155 | - | if err != nil { | |
| 156 | - | log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed") | |
| 157 | - | return nil | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, | |
| 161 | - | len(favorites), | |
| 162 | - | func(i int) string { | |
| 163 | - | if favorites[i] == nil { | |
| 164 | - | return "" | |
| 165 | - | } | |
| 166 | - | return favorites[i].URL | |
| 167 | - | }, | |
| 168 | - | func(i int) string { | |
| 169 | - | if favorites[i] == nil { | |
| 170 | - | return "" | |
| 171 | - | } | |
| 172 | - | return favorites[i].Title | |
| 173 | - | }, | |
| 174 | - | ) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // findHistorySuggestion finds the best matching history entry for the input. | |
| 178 | - | func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 179 | - | log := logging.FromContext(ctx) | |
| 180 | - | ||
| 181 | - | if uc.historyUC == nil { | |
| 182 | - | return nil | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | entries, err := uc.getCachedHistory(ctx) | |
| 186 | - | if err != nil { | |
| 187 | - | log.Debug().Err(err).Msg("autocomplete: history cache fetch failed") | |
| 188 | - | return nil | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, | |
| 192 | - | len(entries), | |
| 193 | - | func(i int) string { | |
| 194 | - | if entries[i] == nil { | |
| 195 | - | return "" | |
| 196 | - | } | |
| 197 | - | return entries[i].URL | |
| 198 | - | }, | |
| 199 | - | func(i int) string { | |
| 200 | - | if entries[i] == nil { | |
| 201 | - | return "" | |
| 202 | - | } | |
| 203 | - | return entries[i].Title | |
| 204 | - | }, | |
| 205 | - | ) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // findBangSuggestion finds the best matching bang shortcut for the input. | |
| 209 | - | func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 210 | - | _ = logging.FromContext(ctx) | |
| 211 | - | ||
| 212 | - | if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' { | |
| 213 | - | return nil | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Get filtered bang suggestions | |
| 217 | - | output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input}) | |
| 218 | - | if len(output.Suggestions) == 0 { | |
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | // Use the first (best) match | |
| 223 | - | bang := output.Suggestions[0] | |
| 224 | - | fullBang := "!" + bang.Key | |
| 225 | - | ||
| 226 | - | suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang) | |
| 227 | - | if ok { | |
| 228 | - | return &autocomplete.Suggestion{ | |
| 229 | - | FullText: fullBang, | |
| 230 | - | Suffix: suffix, | |
| 231 | - | Source: autocomplete.SourceBangShortcut, | |
| 232 | - | Title: bang.Description, | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | return nil | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | |
| 240 | - | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) | |
| 241 | - | if ok { | |
| 242 | - | return &autocomplete.Suggestion{ | |
| 243 | - | FullText: matchedURL, | |
| 244 | - | Suffix: suffix, | |
| 245 | - | Source: source, | |
| 246 | - | } | |
| 247 | - | } | |
| 248 | - | return nil | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func bestSuggestionFromURLs( | |
| 252 | - | input string, | |
| 253 | - | source autocomplete.SuggestionSource, | |
| 254 | - | count int, | |
| 255 | - | urlAt func(int) string, | |
| 256 | - | titleAt func(int) string, | |
| 257 | - | ) *autocomplete.Suggestion { | |
| 258 | - | if count <= 0 { | |
| 259 | - | return nil | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | urls := make([]string, 0, count) | |
| 263 | - | titles := make([]string, 0, count) | |
| 264 | - | for i := 0; i < count; i++ { | |
| 265 | - | url := urlAt(i) | |
| 266 | - | if url == "" { | |
| 267 | - | continue | |
| 268 | - | } | |
| 269 | - | urls = append(urls, url) | |
| 270 | - | titles = append(titles, titleAt(i)) | |
| 271 | - | } | |
| 272 | - | if len(urls) == 0 { | |
| 273 | - | return nil | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls) | |
| 277 | - | if !ok { | |
| 278 | - | return nil | |
| 279 | - | } | |
| 280 | - | title := "" | |
| 281 | - | if idx >= 0 && idx < len(titles) { | |
| 282 | - | title = titles[idx] | |
| 283 | - | } | |
| 284 | - | return &autocomplete.Suggestion{ | |
| 285 | - | FullText: matchedURL, | |
| 286 | - | Suffix: suffix, | |
| 287 | - | Source: source, | |
| 288 | - | Title: title, | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | |
| 293 | - | log := logging.FromContext(ctx) | |
| 294 | - | ||
| 295 | - | uc.cacheMu.Lock() | |
| 296 | - | if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil { | |
| 297 | - | favorites := uc.favoritesCache | |
| 298 | - | uc.cacheMu.Unlock() | |
| 299 | - | return favorites, nil | |
| 300 | - | } | |
| 301 | - | uc.cacheMu.Unlock() | |
| 302 | - | ||
| 303 | - | favorites, err := uc.favoritesUC.GetAll(ctx) | |
| 304 | - | if err != nil { | |
| 305 | - | return nil, err | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | uc.cacheMu.Lock() | |
| 309 | - | uc.favoritesCache = favorites | |
| 310 | - | uc.favoritesCached = time.Now() | |
| 311 | - | uc.cacheMu.Unlock() | |
| 312 | - | ||
| 313 | - | log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed") | |
| 314 | - | return favorites, nil | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 318 | - | log := logging.FromContext(ctx) | |
| 319 | - | ||
| 320 | - | uc.cacheMu.Lock() | |
| 321 | - | if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil { | |
| 322 | - | history := uc.historyCache | |
| 323 | - | uc.cacheMu.Unlock() | |
| 324 | - | return history, nil | |
| 325 | - | } | |
| 326 | - | uc.cacheMu.Unlock() | |
| 327 | - | ||
| 328 | - | entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0) | |
| 329 | - | if err != nil { | |
| 330 | - | return nil, err | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | uc.cacheMu.Lock() | |
| 334 | - | uc.historyCache = entries | |
| 335 | - | uc.historyCached = time.Now() | |
| 336 | - | uc.cacheMu.Unlock() | |
| 337 | - | ||
| 338 | - | log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed") | |
| 339 | - | return entries, nil | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL. | |
| 343 | - | // This is used when the user selects a row from the list to update ghost text. | |
| 344 | - | func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput { | |
| 345 | - | if input == "" || targetURL == "" { | |
| 346 | - | return &GetSuggestionOutput{Found: false} | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL) | |
| 350 | - | if !ok { | |
| 351 | - | return &GetSuggestionOutput{Found: false} | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | return &GetSuggestionOutput{ | |
| 355 | - | Suggestion: &autocomplete.Suggestion{ | |
| 356 | - | FullText: matchedURL, | |
| 357 | - | Suffix: suffix, | |
| 358 | - | Source: autocomplete.SourceHistory, // Default to history for selected rows | |
| 359 | - | }, | |
| 360 | - | Found: true, | |
| 361 | - | } | |
| 362 | - | } | |
| @@ -1,362 +0,0 @@ | |||||
| 1 | - | package usecase | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "sync" | |||
| 6 | - | "time" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/dumber/internal/domain/autocomplete" | |||
| 9 | - | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | - | "github.com/bnema/dumber/internal/logging" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // AutocompleteUseCase handles inline autocomplete suggestions. | |||
| 14 | - | type AutocompleteUseCase struct { | |||
| 15 | - | historyUC *SearchHistoryUseCase | |||
| 16 | - | favoritesUC *ManageFavoritesUseCase | |||
| 17 | - | shortcutsUC *SearchShortcutsUseCase | |||
| 18 | - | ||||
| 19 | - | cacheMu sync.Mutex | |||
| 20 | - | favoritesCache []*entity.Favorite | |||
| 21 | - | favoritesCached time.Time | |||
| 22 | - | historyCache []*entity.HistoryEntry | |||
| 23 | - | historyCached time.Time | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | const autocompleteHistoryLimit = 200 | |||
| 27 | - | const autocompleteCacheTTL = 2 * time.Second | |||
| 28 | - | ||||
| 29 | - | // NewAutocompleteUseCase creates a new autocomplete use case. | |||
| 30 | - | func NewAutocompleteUseCase( | |||
| 31 | - | historyUC *SearchHistoryUseCase, | |||
| 32 | - | favoritesUC *ManageFavoritesUseCase, | |||
| 33 | - | shortcutsUC *SearchShortcutsUseCase, | |||
| 34 | - | ) *AutocompleteUseCase { | |||
| 35 | - | return &AutocompleteUseCase{ | |||
| 36 | - | historyUC: historyUC, | |||
| 37 | - | favoritesUC: favoritesUC, | |||
| 38 | - | shortcutsUC: shortcutsUC, | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // GetSuggestionInput contains parameters for getting an autocomplete suggestion. | |||
| 43 | - | type GetSuggestionInput struct { | |||
| 44 | - | Input string // Current user input | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // GetSuggestionOutput contains the best autocomplete suggestion. | |||
| 48 | - | type GetSuggestionOutput struct { | |||
| 49 | - | Suggestion *autocomplete.Suggestion | |||
| 50 | - | Found bool | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // CompletionOptions controls how URL completions are resolved. | |||
| 54 | - | type CompletionOptions struct { | |||
| 55 | - | VisibleURLs []string | |||
| 56 | - | AllowBangs bool | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // GetSuggestion returns the best autocomplete suggestion for the given input. | |||
| 60 | - | // Priority: Favorites > History (by frecency) > Bang shortcuts. | |||
| 61 | - | func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput { | |||
| 62 | - | log := logging.FromContext(ctx) | |||
| 63 | - | ||||
| 64 | - | if input.Input == "" { | |||
| 65 | - | return &GetSuggestionOutput{Found: false} | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | // Try favorites first (highest priority) | |||
| 69 | - | if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil { | |||
| 70 | - | log.Debug(). | |||
| 71 | - | Str("input", input.Input). | |||
| 72 | - | Str("match", suggestion.FullText). | |||
| 73 | - | Msg("autocomplete: matched favorite") | |||
| 74 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // Try history (second priority) | |||
| 78 | - | if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil { | |||
| 79 | - | log.Debug(). | |||
| 80 | - | Str("input", input.Input). | |||
| 81 | - | Str("match", suggestion.FullText). | |||
| 82 | - | Msg("autocomplete: matched history") | |||
| 83 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Try bang shortcuts (lowest priority for URL-style completion) | |||
| 87 | - | if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil { | |||
| 88 | - | log.Debug(). | |||
| 89 | - | Str("input", input.Input). | |||
| 90 | - | Str("match", suggestion.FullText). | |||
| 91 | - | Msg("autocomplete: matched bang shortcut") | |||
| 92 | - | return &GetSuggestionOutput{Suggestion: suggestion, Found: true} | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | return &GetSuggestionOutput{Found: false} | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | // ResolveCompletion resolves the best completion using visible list order first, | |||
| 99 | - | // then cached favorites/history as fallback. | |||
| 100 | - | func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion { | |||
| 101 | - | log := logging.FromContext(ctx) | |||
| 102 | - | ||||
| 103 | - | if input == "" { | |||
| 104 | - | return nil | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | if len(opts.VisibleURLs) > 0 { | |||
| 108 | - | if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil { | |||
| 109 | - | log.Debug(). | |||
| 110 | - | Str("input", input). | |||
| 111 | - | Str("match", suggestion.FullText). | |||
| 112 | - | Msg("autocomplete: matched visible list") | |||
| 113 | - | return suggestion | |||
| 114 | - | } | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil { | |||
| 118 | - | log.Debug(). | |||
| 119 | - | Str("input", input). | |||
| 120 | - | Str("match", suggestion.FullText). | |||
| 121 | - | Msg("autocomplete: matched favorite fallback") | |||
| 122 | - | return suggestion | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil { | |||
| 126 | - | log.Debug(). | |||
| 127 | - | Str("input", input). | |||
| 128 | - | Str("match", suggestion.FullText). | |||
| 129 | - | Msg("autocomplete: matched history fallback") | |||
| 130 | - | return suggestion | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | if opts.AllowBangs { | |||
| 134 | - | if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil { | |||
| 135 | - | log.Debug(). | |||
| 136 | - | Str("input", input). | |||
| 137 | - | Str("match", suggestion.FullText). | |||
| 138 | - | Msg("autocomplete: matched bang fallback") | |||
| 139 | - | return suggestion | |||
| 140 | - | } | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | return nil | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // findFavoriteSuggestion finds the best matching favorite for the input. | |||
| 147 | - | func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |||
| 148 | - | log := logging.FromContext(ctx) | |||
| 149 | - | ||||
| 150 | - | if uc.favoritesUC == nil { | |||
| 151 | - | return nil | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | favorites, err := uc.getCachedFavorites(ctx) | |||
| 155 | - | if err != nil { | |||
| 156 | - | log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed") | |||
| 157 | - | return nil | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, | |||
| 161 | - | len(favorites), | |||
| 162 | - | func(i int) string { | |||
| 163 | - | if favorites[i] == nil { | |||
| 164 | - | return "" | |||
| 165 | - | } | |||
| 166 | - | return favorites[i].URL | |||
| 167 | - | }, | |||
| 168 | - | func(i int) string { | |||
| 169 | - | if favorites[i] == nil { | |||
| 170 | - | return "" | |||
| 171 | - | } | |||
| 172 | - | return favorites[i].Title | |||
| 173 | - | }, | |||
| 174 | - | ) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | // findHistorySuggestion finds the best matching history entry for the input. | |||
| 178 | - | func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |||
| 179 | - | log := logging.FromContext(ctx) | |||
| 180 | - | ||||
| 181 | - | if uc.historyUC == nil { | |||
| 182 | - | return nil | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | entries, err := uc.getCachedHistory(ctx) | |||
| 186 | - | if err != nil { | |||
| 187 | - | log.Debug().Err(err).Msg("autocomplete: history cache fetch failed") | |||
| 188 | - | return nil | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, | |||
| 192 | - | len(entries), | |||
| 193 | - | func(i int) string { | |||
| 194 | - | if entries[i] == nil { | |||
| 195 | - | return "" | |||
| 196 | - | } | |||
| 197 | - | return entries[i].URL | |||
| 198 | - | }, | |||
| 199 | - | func(i int) string { | |||
| 200 | - | if entries[i] == nil { | |||
| 201 | - | return "" | |||
| 202 | - | } | |||
| 203 | - | return entries[i].Title | |||
| 204 | - | }, | |||
| 205 | - | ) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // findBangSuggestion finds the best matching bang shortcut for the input. | |||
| 209 | - | func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |||
| 210 | - | _ = logging.FromContext(ctx) | |||
| 211 | - | ||||
| 212 | - | if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' { | |||
| 213 | - | return nil | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // Get filtered bang suggestions | |||
| 217 | - | output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input}) | |||
| 218 | - | if len(output.Suggestions) == 0 { | |||
| 219 | - | return nil | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | // Use the first (best) match | |||
| 223 | - | bang := output.Suggestions[0] | |||
| 224 | - | fullBang := "!" + bang.Key | |||
| 225 | - | ||||
| 226 | - | suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang) | |||
| 227 | - | if ok { | |||
| 228 | - | return &autocomplete.Suggestion{ | |||
| 229 | - | FullText: fullBang, | |||
| 230 | - | Suffix: suffix, | |||
| 231 | - | Source: autocomplete.SourceBangShortcut, | |||
| 232 | - | Title: bang.Description, | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | return nil | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | |||
| 240 | - | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) | |||
| 241 | - | if ok { | |||
| 242 | - | return &autocomplete.Suggestion{ | |||
| 243 | - | FullText: matchedURL, | |||
| 244 | - | Suffix: suffix, | |||
| 245 | - | Source: source, | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | return nil | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | func bestSuggestionFromURLs( | |||
| 252 | - | input string, | |||
| 253 | - | source autocomplete.SuggestionSource, | |||
| 254 | - | count int, | |||
| 255 | - | urlAt func(int) string, | |||
| 256 | - | titleAt func(int) string, | |||
| 257 | - | ) *autocomplete.Suggestion { | |||
| 258 | - | if count <= 0 { | |||
| 259 | - | return nil | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | urls := make([]string, 0, count) | |||
| 263 | - | titles := make([]string, 0, count) | |||
| 264 | - | for i := 0; i < count; i++ { | |||
| 265 | - | url := urlAt(i) | |||
| 266 | - | if url == "" { | |||
| 267 | - | continue | |||
| 268 | - | } | |||
| 269 | - | urls = append(urls, url) | |||
| 270 | - | titles = append(titles, titleAt(i)) | |||
| 271 | - | } | |||
| 272 | - | if len(urls) == 0 { | |||
| 273 | - | return nil | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls) | |||
| 277 | - | if !ok { | |||
| 278 | - | return nil | |||
| 279 | - | } | |||
| 280 | - | title := "" | |||
| 281 | - | if idx >= 0 && idx < len(titles) { | |||
| 282 | - | title = titles[idx] | |||
| 283 | - | } | |||
| 284 | - | return &autocomplete.Suggestion{ | |||
| 285 | - | FullText: matchedURL, | |||
| 286 | - | Suffix: suffix, | |||
| 287 | - | Source: source, | |||
| 288 | - | Title: title, | |||
| 289 | - | } | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | |||
| 293 | - | log := logging.FromContext(ctx) | |||
| 294 | - | ||||
| 295 | - | uc.cacheMu.Lock() | |||
| 296 | - | if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil { | |||
| 297 | - | favorites := uc.favoritesCache | |||
| 298 | - | uc.cacheMu.Unlock() | |||
| 299 | - | return favorites, nil | |||
| 300 | - | } | |||
| 301 | - | uc.cacheMu.Unlock() | |||
| 302 | - | ||||
| 303 | - | favorites, err := uc.favoritesUC.GetAll(ctx) | |||
| 304 | - | if err != nil { | |||
| 305 | - | return nil, err | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | uc.cacheMu.Lock() | |||
| 309 | - | uc.favoritesCache = favorites | |||
| 310 | - | uc.favoritesCached = time.Now() | |||
| 311 | - | uc.cacheMu.Unlock() | |||
| 312 | - | ||||
| 313 | - | log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed") | |||
| 314 | - | return favorites, nil | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | |||
| 318 | - | log := logging.FromContext(ctx) | |||
| 319 | - | ||||
| 320 | - | uc.cacheMu.Lock() | |||
| 321 | - | if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil { | |||
| 322 | - | history := uc.historyCache | |||
| 323 | - | uc.cacheMu.Unlock() | |||
| 324 | - | return history, nil | |||
| 325 | - | } | |||
| 326 | - | uc.cacheMu.Unlock() | |||
| 327 | - | ||||
| 328 | - | entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0) | |||
| 329 | - | if err != nil { | |||
| 330 | - | return nil, err | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | uc.cacheMu.Lock() | |||
| 334 | - | uc.historyCache = entries | |||
| 335 | - | uc.historyCached = time.Now() | |||
| 336 | - | uc.cacheMu.Unlock() | |||
| 337 | - | ||||
| 338 | - | log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed") | |||
| 339 | - | return entries, nil | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL. | |||
| 343 | - | // This is used when the user selects a row from the list to update ghost text. | |||
| 344 | - | func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput { | |||
| 345 | - | if input == "" || targetURL == "" { | |||
| 346 | - | return &GetSuggestionOutput{Found: false} | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL) | |||
| 350 | - | if !ok { | |||
| 351 | - | return &GetSuggestionOutput{Found: false} | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | return &GetSuggestionOutput{ | |||
| 355 | - | Suggestion: &autocomplete.Suggestion{ | |||
| 356 | - | FullText: matchedURL, | |||
| 357 | - | Suffix: suffix, | |||
| 358 | - | Source: autocomplete.SourceHistory, // Default to history for selected rows | |||
| 359 | - | }, | |||
| 360 | - | Found: true, | |||
| 361 | - | } | |||
| 362 | - | } | |||
D internal/application/usecase/autocomplete_test.go +0 -366
| @@ -1,366 +0,0 @@ | |||
| 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", output.Suggestion.FullText) | |
| 59 | - | assert.Equal(t, ".com", 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 | - | historyEntries := []*entity.HistoryEntry{ | |
| 75 | - | {URL: "https://news.ycombinator.com", Title: "Hacker News"}, | |
| 76 | - | } | |
| 77 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, 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().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, 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_ResolveCompletion_VisibleURLsOrder(t *testing.T) { | |
| 152 | - | ctx := testContext() | |
| 153 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 154 | - | ||
| 155 | - | urls := []string{ | |
| 156 | - | "https://bnema.dev", | |
| 157 | - | "https://bnema.io", | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | suggestion := uc.ResolveCompletion(ctx, "bnem", usecase.CompletionOptions{ | |
| 161 | - | VisibleURLs: urls, | |
| 162 | - | AllowBangs: false, | |
| 163 | - | }) | |
| 164 | - | ||
| 165 | - | require.NotNil(t, suggestion) | |
| 166 | - | assert.Equal(t, "bnema.dev", suggestion.FullText) | |
| 167 | - | assert.Equal(t, "a.dev", suggestion.Suffix) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) { | |
| 171 | - | ctx := testContext() | |
| 172 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 173 | - | ||
| 174 | - | urls := []string{ | |
| 175 | - | "https://www.google.com/url?q=https://dashboard.stripe.com", | |
| 176 | - | "https://google.com", | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{ | |
| 180 | - | VisibleURLs: urls, | |
| 181 | - | AllowBangs: false, | |
| 182 | - | }) | |
| 183 | - | ||
| 184 | - | require.NotNil(t, suggestion) | |
| 185 | - | assert.Equal(t, "google.com", suggestion.FullText) | |
| 186 | - | assert.Equal(t, "gle.com", suggestion.Suffix) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | |
| 190 | - | ctx := testContext() | |
| 191 | - | ||
| 192 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 193 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 194 | - | tagRepo := repomocks.NewMockTagRepository(t) | |
| 195 | - | ||
| 196 | - | favorites := []*entity.Favorite{ | |
| 197 | - | {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"}, | |
| 198 | - | } | |
| 199 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | |
| 200 | - | ||
| 201 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 202 | - | uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil) | |
| 203 | - | ||
| 204 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 205 | - | require.NotNil(t, output) | |
| 206 | - | assert.True(t, output.Found) | |
| 207 | - | ||
| 208 | - | output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 209 | - | require.NotNil(t, output) | |
| 210 | - | assert.True(t, output.Found) | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) { | |
| 214 | - | ctx := testContext() | |
| 215 | - | ||
| 216 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 217 | - | historyEntries := []*entity.HistoryEntry{ | |
| 218 | - | {URL: "https://news.ycombinator.com", Title: "Hacker News"}, | |
| 219 | - | } | |
| 220 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once() | |
| 221 | - | ||
| 222 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 223 | - | uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil) | |
| 224 | - | ||
| 225 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"}) | |
| 226 | - | require.NotNil(t, output) | |
| 227 | - | assert.True(t, output.Found) | |
| 228 | - | ||
| 229 | - | output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"}) | |
| 230 | - | require.NotNil(t, output) | |
| 231 | - | assert.True(t, output.Found) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) { | |
| 235 | - | ctx := testContext() | |
| 236 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 237 | - | ||
| 238 | - | tests := []struct { | |
| 239 | - | name string | |
| 240 | - | input string | |
| 241 | - | targetURL string | |
| 242 | - | }{ | |
| 243 | - | {name: "empty input", input: "", targetURL: "https://example.com"}, | |
| 244 | - | {name: "empty URL", input: "exa", targetURL: ""}, | |
| 245 | - | {name: "both empty", input: "", targetURL: ""}, | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | for _, tt := range tests { | |
| 249 | - | t.Run(tt.name, func(t *testing.T) { | |
| 250 | - | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |
| 251 | - | require.NotNil(t, output) | |
| 252 | - | assert.False(t, output.Found) | |
| 253 | - | }) | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) { | |
| 258 | - | ctx := testContext() | |
| 259 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 260 | - | ||
| 261 | - | tests := []struct { | |
| 262 | - | name string | |
| 263 | - | input string | |
| 264 | - | targetURL string | |
| 265 | - | wantSuffix string | |
| 266 | - | wantMatchedURL string | |
| 267 | - | }{ | |
| 268 | - | { | |
| 269 | - | name: "prefix match without protocol", | |
| 270 | - | input: "github", | |
| 271 | - | targetURL: "https://github.com/user/repo", | |
| 272 | - | wantSuffix: ".com/user/repo", | |
| 273 | - | wantMatchedURL: "github.com/user/repo", | |
| 274 | - | }, | |
| 275 | - | { | |
| 276 | - | name: "prefix match with protocol", | |
| 277 | - | input: "https://github", | |
| 278 | - | targetURL: "https://github.com/user/repo", | |
| 279 | - | wantSuffix: ".com/user/repo", | |
| 280 | - | wantMatchedURL: "https://github.com/user/repo", | |
| 281 | - | }, | |
| 282 | - | { | |
| 283 | - | name: "case insensitive match", | |
| 284 | - | input: "GITHUB", | |
| 285 | - | targetURL: "https://github.com", | |
| 286 | - | wantSuffix: ".com", | |
| 287 | - | wantMatchedURL: "github.com", | |
| 288 | - | }, | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | for _, tt := range tests { | |
| 292 | - | t.Run(tt.name, func(t *testing.T) { | |
| 293 | - | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |
| 294 | - | require.NotNil(t, output) | |
| 295 | - | assert.True(t, output.Found) | |
| 296 | - | require.NotNil(t, output.Suggestion) | |
| 297 | - | assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix) | |
| 298 | - | assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText) | |
| 299 | - | }) | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) { | |
| 304 | - | ctx := testContext() | |
| 305 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 306 | - | ||
| 307 | - | output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com") | |
| 308 | - | ||
| 309 | - | require.NotNil(t, output) | |
| 310 | - | assert.False(t, output.Found) | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) { | |
| 314 | - | ctx := testContext() | |
| 315 | - | ||
| 316 | - | // Both favorites and history have matches - favorites should win | |
| 317 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 318 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 319 | - | tagRepo := repomocks.NewMockTagRepository(t) | |
| 320 | - | favorites := []*entity.Favorite{ | |
| 321 | - | {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"}, | |
| 322 | - | } | |
| 323 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 324 | - | ||
| 325 | - | // History would also match, but should not be called because favorites matched | |
| 326 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 327 | - | // Note: no EXPECT on historyRepo - it shouldn't be called | |
| 328 | - | ||
| 329 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 330 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 331 | - | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 332 | - | ||
| 333 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |
| 334 | - | ||
| 335 | - | require.NotNil(t, output) | |
| 336 | - | assert.True(t, output.Found) | |
| 337 | - | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |
| 338 | - | assert.Equal(t, "Favorite Repo", output.Suggestion.Title) | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) { | |
| 342 | - | ctx := testContext() | |
| 343 | - | ||
| 344 | - | // Favorites returns empty | |
| 345 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 346 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 347 | - | tagRepo := repomocks.NewMockTagRepository(t) | |
| 348 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 349 | - | ||
| 350 | - | // History returns results but none are prefix matches | |
| 351 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 352 | - | historyEntries := []*entity.HistoryEntry{ | |
| 353 | - | // URL doesn't start with "git", so no prefix match | |
| 354 | - | {URL: "https://example.com/git", Title: "Example"}, | |
| 355 | - | } | |
| 356 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil) | |
| 357 | - | ||
| 358 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 359 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 360 | - | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |
| 361 | - | ||
| 362 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"}) | |
| 363 | - | ||
| 364 | - | require.NotNil(t, output) | |
| 365 | - | assert.False(t, output.Found) | |
| 366 | - | } | |
| @@ -1,366 +0,0 @@ | |||||
| 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", output.Suggestion.FullText) | |||
| 59 | - | assert.Equal(t, ".com", 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 | - | historyEntries := []*entity.HistoryEntry{ | |||
| 75 | - | {URL: "https://news.ycombinator.com", Title: "Hacker News"}, | |||
| 76 | - | } | |||
| 77 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, 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().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, 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_ResolveCompletion_VisibleURLsOrder(t *testing.T) { | |||
| 152 | - | ctx := testContext() | |||
| 153 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 154 | - | ||||
| 155 | - | urls := []string{ | |||
| 156 | - | "https://bnema.dev", | |||
| 157 | - | "https://bnema.io", | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | suggestion := uc.ResolveCompletion(ctx, "bnem", usecase.CompletionOptions{ | |||
| 161 | - | VisibleURLs: urls, | |||
| 162 | - | AllowBangs: false, | |||
| 163 | - | }) | |||
| 164 | - | ||||
| 165 | - | require.NotNil(t, suggestion) | |||
| 166 | - | assert.Equal(t, "bnema.dev", suggestion.FullText) | |||
| 167 | - | assert.Equal(t, "a.dev", suggestion.Suffix) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) { | |||
| 171 | - | ctx := testContext() | |||
| 172 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 173 | - | ||||
| 174 | - | urls := []string{ | |||
| 175 | - | "https://www.google.com/url?q=https://dashboard.stripe.com", | |||
| 176 | - | "https://google.com", | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{ | |||
| 180 | - | VisibleURLs: urls, | |||
| 181 | - | AllowBangs: false, | |||
| 182 | - | }) | |||
| 183 | - | ||||
| 184 | - | require.NotNil(t, suggestion) | |||
| 185 | - | assert.Equal(t, "google.com", suggestion.FullText) | |||
| 186 | - | assert.Equal(t, "gle.com", suggestion.Suffix) | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | |||
| 190 | - | ctx := testContext() | |||
| 191 | - | ||||
| 192 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 193 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 194 | - | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 195 | - | ||||
| 196 | - | favorites := []*entity.Favorite{ | |||
| 197 | - | {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"}, | |||
| 198 | - | } | |||
| 199 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | |||
| 200 | - | ||||
| 201 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 202 | - | uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil) | |||
| 203 | - | ||||
| 204 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |||
| 205 | - | require.NotNil(t, output) | |||
| 206 | - | assert.True(t, output.Found) | |||
| 207 | - | ||||
| 208 | - | output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |||
| 209 | - | require.NotNil(t, output) | |||
| 210 | - | assert.True(t, output.Found) | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) { | |||
| 214 | - | ctx := testContext() | |||
| 215 | - | ||||
| 216 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 217 | - | historyEntries := []*entity.HistoryEntry{ | |||
| 218 | - | {URL: "https://news.ycombinator.com", Title: "Hacker News"}, | |||
| 219 | - | } | |||
| 220 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once() | |||
| 221 | - | ||||
| 222 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 223 | - | uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil) | |||
| 224 | - | ||||
| 225 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"}) | |||
| 226 | - | require.NotNil(t, output) | |||
| 227 | - | assert.True(t, output.Found) | |||
| 228 | - | ||||
| 229 | - | output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"}) | |||
| 230 | - | require.NotNil(t, output) | |||
| 231 | - | assert.True(t, output.Found) | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) { | |||
| 235 | - | ctx := testContext() | |||
| 236 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 237 | - | ||||
| 238 | - | tests := []struct { | |||
| 239 | - | name string | |||
| 240 | - | input string | |||
| 241 | - | targetURL string | |||
| 242 | - | }{ | |||
| 243 | - | {name: "empty input", input: "", targetURL: "https://example.com"}, | |||
| 244 | - | {name: "empty URL", input: "exa", targetURL: ""}, | |||
| 245 | - | {name: "both empty", input: "", targetURL: ""}, | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | for _, tt := range tests { | |||
| 249 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 250 | - | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |||
| 251 | - | require.NotNil(t, output) | |||
| 252 | - | assert.False(t, output.Found) | |||
| 253 | - | }) | |||
| 254 | - | } | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) { | |||
| 258 | - | ctx := testContext() | |||
| 259 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 260 | - | ||||
| 261 | - | tests := []struct { | |||
| 262 | - | name string | |||
| 263 | - | input string | |||
| 264 | - | targetURL string | |||
| 265 | - | wantSuffix string | |||
| 266 | - | wantMatchedURL string | |||
| 267 | - | }{ | |||
| 268 | - | { | |||
| 269 | - | name: "prefix match without protocol", | |||
| 270 | - | input: "github", | |||
| 271 | - | targetURL: "https://github.com/user/repo", | |||
| 272 | - | wantSuffix: ".com/user/repo", | |||
| 273 | - | wantMatchedURL: "github.com/user/repo", | |||
| 274 | - | }, | |||
| 275 | - | { | |||
| 276 | - | name: "prefix match with protocol", | |||
| 277 | - | input: "https://github", | |||
| 278 | - | targetURL: "https://github.com/user/repo", | |||
| 279 | - | wantSuffix: ".com/user/repo", | |||
| 280 | - | wantMatchedURL: "https://github.com/user/repo", | |||
| 281 | - | }, | |||
| 282 | - | { | |||
| 283 | - | name: "case insensitive match", | |||
| 284 | - | input: "GITHUB", | |||
| 285 | - | targetURL: "https://github.com", | |||
| 286 | - | wantSuffix: ".com", | |||
| 287 | - | wantMatchedURL: "github.com", | |||
| 288 | - | }, | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | for _, tt := range tests { | |||
| 292 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 293 | - | output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL) | |||
| 294 | - | require.NotNil(t, output) | |||
| 295 | - | assert.True(t, output.Found) | |||
| 296 | - | require.NotNil(t, output.Suggestion) | |||
| 297 | - | assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix) | |||
| 298 | - | assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText) | |||
| 299 | - | }) | |||
| 300 | - | } | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) { | |||
| 304 | - | ctx := testContext() | |||
| 305 | - | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 306 | - | ||||
| 307 | - | output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com") | |||
| 308 | - | ||||
| 309 | - | require.NotNil(t, output) | |||
| 310 | - | assert.False(t, output.Found) | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) { | |||
| 314 | - | ctx := testContext() | |||
| 315 | - | ||||
| 316 | - | // Both favorites and history have matches - favorites should win | |||
| 317 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 318 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 319 | - | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 320 | - | favorites := []*entity.Favorite{ | |||
| 321 | - | {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"}, | |||
| 322 | - | } | |||
| 323 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |||
| 324 | - | ||||
| 325 | - | // History would also match, but should not be called because favorites matched | |||
| 326 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 327 | - | // Note: no EXPECT on historyRepo - it shouldn't be called | |||
| 328 | - | ||||
| 329 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 330 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 331 | - | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |||
| 332 | - | ||||
| 333 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"}) | |||
| 334 | - | ||||
| 335 | - | require.NotNil(t, output) | |||
| 336 | - | assert.True(t, output.Found) | |||
| 337 | - | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |||
| 338 | - | assert.Equal(t, "Favorite Repo", output.Suggestion.Title) | |||
| 339 | - | } | |||
| 340 | - | ||||
| 341 | - | func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) { | |||
| 342 | - | ctx := testContext() | |||
| 343 | - | ||||
| 344 | - | // Favorites returns empty | |||
| 345 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 346 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 347 | - | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 348 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |||
| 349 | - | ||||
| 350 | - | // History returns results but none are prefix matches | |||
| 351 | - | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 352 | - | historyEntries := []*entity.HistoryEntry{ | |||
| 353 | - | // URL doesn't start with "git", so no prefix match | |||
| 354 | - | {URL: "https://example.com/git", Title: "Example"}, | |||
| 355 | - | } | |||
| 356 | - | historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil) | |||
| 357 | - | ||||
| 358 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 359 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 360 | - | uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil) | |||
| 361 | - | ||||
| 362 | - | output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"}) | |||
| 363 | - | ||||
| 364 | - | require.NotNil(t, output) | |||
| 365 | - | assert.False(t, output.Found) | |||
| 366 | - | } | |||
M internal/application/usecase/manage_favorites.go +136 -3
| @@ -3,9 +3,14 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 6 | 10 | ||
| 7 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 12 | "github.com/bnema/dumber/internal/domain/repository" | |
| 13 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 15 | ) | |
| 11 | 16 | ||
| @@ -14,8 +19,15 @@ type ManageFavoritesUseCase struct { | |||
| 14 | 19 | favoriteRepo repository.FavoriteRepository | |
| 15 | 20 | folderRepo repository.FolderRepository | |
| 16 | 21 | tagRepo repository.TagRepository | |
| 22 | + | ||
| 23 | + | cacheMu sync.Mutex | |
| 24 | + | favoritesCache []*entity.Favorite | |
| 25 | + | favoriteURLsCache map[string]struct{} | |
| 26 | + | cacheTime time.Time | |
| 17 | 27 | } | |
| 18 | 28 | ||
| 29 | + | const favoritesCacheTTL = 2 * time.Second | |
| 30 | + | ||
| 19 | 31 | // NewManageFavoritesUseCase creates a new favorites management use case. | |
| 20 | 32 | func NewManageFavoritesUseCase( | |
| 21 | 33 | favoriteRepo repository.FavoriteRepository, | |
| @@ -73,6 +85,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||
| 73 | 85 | } | |
| 74 | 86 | ||
| 75 | 87 | log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added") | |
| 88 | + | uc.invalidateCache() | |
| 76 | 89 | return fav, nil | |
| 77 | 90 | } | |
| 78 | 91 | ||
| @@ -86,6 +99,7 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||
| 86 | 99 | } | |
| 87 | 100 | ||
| 88 | 101 | log.Info().Int64("id", int64(id)).Msg("favorite removed") | |
| 102 | + | uc.invalidateCache() | |
| 89 | 103 | return nil | |
| 90 | 104 | } | |
| 91 | 105 | ||
| @@ -116,6 +130,7 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori | |||
| 116 | 130 | } | |
| 117 | 131 | ||
| 118 | 132 | log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated") | |
| 133 | + | uc.invalidateCache() | |
| 119 | 134 | return nil | |
| 120 | 135 | } | |
| 121 | 136 | ||
| @@ -131,6 +146,7 @@ func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID | |||
| 131 | 146 | } | |
| 132 | 147 | ||
| 133 | 148 | log.Info().Int64("id", int64(id)).Msg("favorite moved") | |
| 149 | + | uc.invalidateCache() | |
| 134 | 150 | return nil | |
| 135 | 151 | } | |
| 136 | 152 | ||
| @@ -150,6 +166,7 @@ func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.Fav | |||
| 150 | 166 | return fmt.Errorf("failed to set shortcut: %w", err) | |
| 151 | 167 | } | |
| 152 | 168 | ||
| 169 | + | uc.invalidateCache() | |
| 153 | 170 | return nil | |
| 154 | 171 | } | |
| 155 | 172 | ||
| @@ -178,15 +195,69 @@ func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorit | |||
| 178 | 195 | log := logging.FromContext(ctx) | |
| 179 | 196 | log.Debug().Msg("getting all favorites") | |
| 180 | 197 | ||
| 198 | + | uc.cacheMu.Lock() | |
| 199 | + | if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil { | |
| 200 | + | cached := uc.favoritesCache | |
| 201 | + | uc.cacheMu.Unlock() | |
| 202 | + | return cached, nil | |
| 203 | + | } | |
| 204 | + | uc.cacheMu.Unlock() | |
| 205 | + | ||
| 181 | 206 | favs, err := uc.favoriteRepo.GetAll(ctx) | |
| 182 | 207 | if err != nil { | |
| 183 | 208 | return nil, fmt.Errorf("failed to get favorites: %w", err) | |
| 184 | 209 | } | |
| 185 | 210 | ||
| 211 | + | uc.cacheMu.Lock() | |
| 212 | + | uc.favoritesCache = favs | |
| 213 | + | uc.favoriteURLsCache = buildFavoriteURLSet(favs) | |
| 214 | + | uc.cacheTime = time.Now() | |
| 215 | + | uc.cacheMu.Unlock() | |
| 216 | + | ||
| 186 | 217 | log.Debug().Int("count", len(favs)).Msg("retrieved favorites") | |
| 187 | 218 | return favs, nil | |
| 188 | 219 | } | |
| 189 | 220 | ||
| 221 | + | // FilterForOmnibox returns favorites filtered and ranked for omnibox display. | |
| 222 | + | func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) { | |
| 223 | + | favs, err := uc.GetAll(ctx) | |
| 224 | + | if err != nil { | |
| 225 | + | return nil, err | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | queryLower := strings.ToLower(strings.TrimSpace(query)) | |
| 229 | + | if queryLower == "" { | |
| 230 | + | return favs, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | filtered := make([]*entity.Favorite, 0, len(favs)) | |
| 234 | + | for _, fav := range favs { | |
| 235 | + | if fav == nil { | |
| 236 | + | continue | |
| 237 | + | } | |
| 238 | + | titleLower := strings.ToLower(fav.Title) | |
| 239 | + | urlLower := strings.ToLower(fav.URL) | |
| 240 | + | if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) { | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | filtered = append(filtered, fav) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | sort.SliceStable(filtered, func(i, j int) bool { | |
| 247 | + | leftScore := omniboxFavoriteScore(filtered[i], queryLower) | |
| 248 | + | rightScore := omniboxFavoriteScore(filtered[j], queryLower) | |
| 249 | + | if leftScore != rightScore { | |
| 250 | + | return leftScore > rightScore | |
| 251 | + | } | |
| 252 | + | if filtered[i].Position != filtered[j].Position { | |
| 253 | + | return filtered[i].Position < filtered[j].Position | |
| 254 | + | } | |
| 255 | + | return filtered[i].URL < filtered[j].URL | |
| 256 | + | }) | |
| 257 | + | ||
| 258 | + | return filtered, nil | |
| 259 | + | } | |
| 260 | + | ||
| 190 | 261 | // IsFavorite checks if a URL is favorited. | |
| 191 | 262 | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | |
| 192 | 263 | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| @@ -196,6 +267,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||
| 196 | 267 | return fav != nil, nil | |
| 197 | 268 | } | |
| 198 | 269 | ||
| 270 | + | func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int { | |
| 271 | + | if fav == nil || queryLower == "" { | |
| 272 | + | return 0 | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | const ( | |
| 276 | + | favoriteExactMatchScore = 600 | |
| 277 | + | favoriteHostPrefixScore = 520 | |
| 278 | + | favoriteTitlePrefixScore = 440 | |
| 279 | + | favoriteTitleContainsScore = 320 | |
| 280 | + | favoriteURLContainsScore = 280 | |
| 281 | + | ) | |
| 282 | + | ||
| 283 | + | urlLower := strings.ToLower(fav.URL) | |
| 284 | + | strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www.")) | |
| 285 | + | if strippedURLLower == "" { | |
| 286 | + | strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://")) | |
| 287 | + | } | |
| 288 | + | titleLower := strings.ToLower(fav.Title) | |
| 289 | + | ||
| 290 | + | switch { | |
| 291 | + | case titleLower == queryLower || urlLower == queryLower: | |
| 292 | + | return favoriteExactMatchScore | |
| 293 | + | case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower): | |
| 294 | + | return favoriteHostPrefixScore | |
| 295 | + | case strings.HasPrefix(titleLower, queryLower): | |
| 296 | + | return favoriteTitlePrefixScore | |
| 297 | + | case strings.Contains(titleLower, queryLower): | |
| 298 | + | return favoriteTitleContainsScore | |
| 299 | + | case strings.Contains(urlLower, queryLower): | |
| 300 | + | return favoriteURLContainsScore | |
| 301 | + | default: | |
| 302 | + | return 0 | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 199 | 306 | // ToggleResult indicates the result of a toggle operation. | |
| 200 | 307 | type ToggleResult struct { | |
| 201 | 308 | Added bool // true if favorite was added, false if removed | |
| @@ -226,6 +333,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||
| 226 | 333 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 227 | 334 | } | |
| 228 | 335 | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 336 | + | uc.invalidateCache() | |
| 229 | 337 | return &ToggleResult{ | |
| 230 | 338 | Added: false, | |
| 231 | 339 | URL: url, | |
| @@ -240,6 +348,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||
| 240 | 348 | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 241 | 349 | } | |
| 242 | 350 | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 351 | + | uc.invalidateCache() | |
| 243 | 352 | return &ToggleResult{ | |
| 244 | 353 | Added: true, | |
| 245 | 354 | URL: url, | |
| @@ -253,18 +362,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st | |||
| 253 | 362 | log := logging.FromContext(ctx) | |
| 254 | 363 | log.Debug().Msg("getting all favorite URLs") | |
| 255 | 364 | ||
| 256 | - | favs, err := uc.favoriteRepo.GetAll(ctx) | |
| 365 | + | uc.cacheMu.Lock() | |
| 366 | + | if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil { | |
| 367 | + | cached := uc.favoriteURLsCache | |
| 368 | + | uc.cacheMu.Unlock() | |
| 369 | + | return cached, nil | |
| 370 | + | } | |
| 371 | + | uc.cacheMu.Unlock() | |
| 372 | + | ||
| 373 | + | favs, err := uc.GetAll(ctx) | |
| 257 | 374 | if err != nil { | |
| 258 | 375 | return nil, fmt.Errorf("failed to get favorites: %w", err) | |
| 259 | 376 | } | |
| 260 | 377 | ||
| 378 | + | urls := buildFavoriteURLSet(favs) | |
| 379 | + | ||
| 380 | + | log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") | |
| 381 | + | return urls, nil | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} { | |
| 261 | 385 | urls := make(map[string]struct{}, len(favs)) | |
| 262 | 386 | for _, fav := range favs { | |
| 387 | + | if fav == nil || fav.URL == "" { | |
| 388 | + | continue | |
| 389 | + | } | |
| 263 | 390 | urls[fav.URL] = struct{}{} | |
| 264 | 391 | } | |
| 392 | + | return urls | |
| 393 | + | } | |
| 265 | 394 | ||
| 266 | - | log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") | |
| 267 | - | return urls, nil | |
| 395 | + | func (uc *ManageFavoritesUseCase) invalidateCache() { | |
| 396 | + | uc.cacheMu.Lock() | |
| 397 | + | defer uc.cacheMu.Unlock() | |
| 398 | + | uc.favoritesCache = nil | |
| 399 | + | uc.favoriteURLsCache = nil | |
| 400 | + | uc.cacheTime = time.Time{} | |
| 268 | 401 | } | |
| 269 | 402 | ||
| 270 | 403 | // GetTree builds a complete hierarchical view of folders and favorites. | |
| @@ -3,9 +3,14 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 6 | 10 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/domain/repository" | 12 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 13 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 15 | ) | ||
| 11 | 16 | ||||
| @@ -14,8 +19,15 @@ type ManageFavoritesUseCase struct { | |||||
| 14 | favoriteRepo repository.FavoriteRepository | 19 | favoriteRepo repository.FavoriteRepository | ||
| 15 | folderRepo repository.FolderRepository | 20 | folderRepo repository.FolderRepository | ||
| 16 | tagRepo repository.TagRepository | 21 | tagRepo repository.TagRepository | ||
| 22 | + | ||||
| 23 | + | cacheMu sync.Mutex | |||
| 24 | + | favoritesCache []*entity.Favorite | |||
| 25 | + | favoriteURLsCache map[string]struct{} | |||
| 26 | + | cacheTime time.Time | |||
| 17 | } | 27 | } | ||
| 18 | 28 | ||||
| 29 | + | const favoritesCacheTTL = 2 * time.Second | |||
| 30 | + | ||||
| 19 | // NewManageFavoritesUseCase creates a new favorites management use case. | 31 | // NewManageFavoritesUseCase creates a new favorites management use case. | ||
| 20 | func NewManageFavoritesUseCase( | 32 | func NewManageFavoritesUseCase( | ||
| 21 | favoriteRepo repository.FavoriteRepository, | 33 | favoriteRepo repository.FavoriteRepository, | ||
| @@ -73,6 +85,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 73 | } | 85 | } | ||
| 74 | 86 | ||||
| 75 | log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added") | 87 | log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added") | ||
| 88 | + | uc.invalidateCache() | |||
| 76 | return fav, nil | 89 | return fav, nil | ||
| 77 | } | 90 | } | ||
| 78 | 91 | ||||
| @@ -86,6 +99,7 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||||
| 86 | } | 99 | } | ||
| 87 | 100 | ||||
| 88 | log.Info().Int64("id", int64(id)).Msg("favorite removed") | 101 | log.Info().Int64("id", int64(id)).Msg("favorite removed") | ||
| 102 | + | uc.invalidateCache() | |||
| 89 | return nil | 103 | return nil | ||
| 90 | } | 104 | } | ||
| 91 | 105 | ||||
| @@ -116,6 +130,7 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori | |||||
| 116 | } | 130 | } | ||
| 117 | 131 | ||||
| 118 | log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated") | 132 | log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated") | ||
| 133 | + | uc.invalidateCache() | |||
| 119 | return nil | 134 | return nil | ||
| 120 | } | 135 | } | ||
| 121 | 136 | ||||
| @@ -131,6 +146,7 @@ func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID | |||||
| 131 | } | 146 | } | ||
| 132 | 147 | ||||
| 133 | log.Info().Int64("id", int64(id)).Msg("favorite moved") | 148 | log.Info().Int64("id", int64(id)).Msg("favorite moved") | ||
| 149 | + | uc.invalidateCache() | |||
| 134 | return nil | 150 | return nil | ||
| 135 | } | 151 | } | ||
| 136 | 152 | ||||
| @@ -150,6 +166,7 @@ func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.Fav | |||||
| 150 | return fmt.Errorf("failed to set shortcut: %w", err) | 166 | return fmt.Errorf("failed to set shortcut: %w", err) | ||
| 151 | } | 167 | } | ||
| 152 | 168 | ||||
| 169 | + | uc.invalidateCache() | |||
| 153 | return nil | 170 | return nil | ||
| 154 | } | 171 | } | ||
| 155 | 172 | ||||
| @@ -178,15 +195,69 @@ func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorit | |||||
| 178 | log := logging.FromContext(ctx) | 195 | log := logging.FromContext(ctx) | ||
| 179 | log.Debug().Msg("getting all favorites") | 196 | log.Debug().Msg("getting all favorites") | ||
| 180 | 197 | ||||
| 198 | + | uc.cacheMu.Lock() | |||
| 199 | + | if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil { | |||
| 200 | + | cached := uc.favoritesCache | |||
| 201 | + | uc.cacheMu.Unlock() | |||
| 202 | + | return cached, nil | |||
| 203 | + | } | |||
| 204 | + | uc.cacheMu.Unlock() | |||
| 205 | + | ||||
| 181 | favs, err := uc.favoriteRepo.GetAll(ctx) | 206 | favs, err := uc.favoriteRepo.GetAll(ctx) | ||
| 182 | if err != nil { | 207 | if err != nil { | ||
| 183 | return nil, fmt.Errorf("failed to get favorites: %w", err) | 208 | return nil, fmt.Errorf("failed to get favorites: %w", err) | ||
| 184 | } | 209 | } | ||
| 185 | 210 | ||||
| 211 | + | uc.cacheMu.Lock() | |||
| 212 | + | uc.favoritesCache = favs | |||
| 213 | + | uc.favoriteURLsCache = buildFavoriteURLSet(favs) | |||
| 214 | + | uc.cacheTime = time.Now() | |||
| 215 | + | uc.cacheMu.Unlock() | |||
| 216 | + | ||||
| 186 | log.Debug().Int("count", len(favs)).Msg("retrieved favorites") | 217 | log.Debug().Int("count", len(favs)).Msg("retrieved favorites") | ||
| 187 | return favs, nil | 218 | return favs, nil | ||
| 188 | } | 219 | } | ||
| 189 | 220 | ||||
| 221 | + | // FilterForOmnibox returns favorites filtered and ranked for omnibox display. | |||
| 222 | + | func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) { | |||
| 223 | + | favs, err := uc.GetAll(ctx) | |||
| 224 | + | if err != nil { | |||
| 225 | + | return nil, err | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | queryLower := strings.ToLower(strings.TrimSpace(query)) | |||
| 229 | + | if queryLower == "" { | |||
| 230 | + | return favs, nil | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | filtered := make([]*entity.Favorite, 0, len(favs)) | |||
| 234 | + | for _, fav := range favs { | |||
| 235 | + | if fav == nil { | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | titleLower := strings.ToLower(fav.Title) | |||
| 239 | + | urlLower := strings.ToLower(fav.URL) | |||
| 240 | + | if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) { | |||
| 241 | + | continue | |||
| 242 | + | } | |||
| 243 | + | filtered = append(filtered, fav) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | sort.SliceStable(filtered, func(i, j int) bool { | |||
| 247 | + | leftScore := omniboxFavoriteScore(filtered[i], queryLower) | |||
| 248 | + | rightScore := omniboxFavoriteScore(filtered[j], queryLower) | |||
| 249 | + | if leftScore != rightScore { | |||
| 250 | + | return leftScore > rightScore | |||
| 251 | + | } | |||
| 252 | + | if filtered[i].Position != filtered[j].Position { | |||
| 253 | + | return filtered[i].Position < filtered[j].Position | |||
| 254 | + | } | |||
| 255 | + | return filtered[i].URL < filtered[j].URL | |||
| 256 | + | }) | |||
| 257 | + | ||||
| 258 | + | return filtered, nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 190 | // IsFavorite checks if a URL is favorited. | 261 | // IsFavorite checks if a URL is favorited. | ||
| 191 | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | 262 | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | ||
| 192 | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | 263 | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | ||
| @@ -196,6 +267,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||||
| 196 | return fav != nil, nil | 267 | return fav != nil, nil | ||
| 197 | } | 268 | } | ||
| 198 | 269 | ||||
| 270 | + | func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int { | |||
| 271 | + | if fav == nil || queryLower == "" { | |||
| 272 | + | return 0 | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | const ( | |||
| 276 | + | favoriteExactMatchScore = 600 | |||
| 277 | + | favoriteHostPrefixScore = 520 | |||
| 278 | + | favoriteTitlePrefixScore = 440 | |||
| 279 | + | favoriteTitleContainsScore = 320 | |||
| 280 | + | favoriteURLContainsScore = 280 | |||
| 281 | + | ) | |||
| 282 | + | ||||
| 283 | + | urlLower := strings.ToLower(fav.URL) | |||
| 284 | + | strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www.")) | |||
| 285 | + | if strippedURLLower == "" { | |||
| 286 | + | strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://")) | |||
| 287 | + | } | |||
| 288 | + | titleLower := strings.ToLower(fav.Title) | |||
| 289 | + | ||||
| 290 | + | switch { | |||
| 291 | + | case titleLower == queryLower || urlLower == queryLower: | |||
| 292 | + | return favoriteExactMatchScore | |||
| 293 | + | case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower): | |||
| 294 | + | return favoriteHostPrefixScore | |||
| 295 | + | case strings.HasPrefix(titleLower, queryLower): | |||
| 296 | + | return favoriteTitlePrefixScore | |||
| 297 | + | case strings.Contains(titleLower, queryLower): | |||
| 298 | + | return favoriteTitleContainsScore | |||
| 299 | + | case strings.Contains(urlLower, queryLower): | |||
| 300 | + | return favoriteURLContainsScore | |||
| 301 | + | default: | |||
| 302 | + | return 0 | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 199 | // ToggleResult indicates the result of a toggle operation. | 306 | // ToggleResult indicates the result of a toggle operation. | ||
| 200 | type ToggleResult struct { | 307 | type ToggleResult struct { | ||
| 201 | Added bool // true if favorite was added, false if removed | 308 | Added bool // true if favorite was added, false if removed | ||
| @@ -226,6 +333,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||||
| 226 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | 333 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | ||
| 227 | } | 334 | } | ||
| 228 | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | 335 | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | ||
| 336 | + | uc.invalidateCache() | |||
| 229 | return &ToggleResult{ | 337 | return &ToggleResult{ | ||
| 230 | Added: false, | 338 | Added: false, | ||
| 231 | URL: url, | 339 | URL: url, | ||
| @@ -240,6 +348,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||||
| 240 | return nil, fmt.Errorf("failed to add favorite: %w", err) | 348 | return nil, fmt.Errorf("failed to add favorite: %w", err) | ||
| 241 | } | 349 | } | ||
| 242 | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | 350 | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | ||
| 351 | + | uc.invalidateCache() | |||
| 243 | return &ToggleResult{ | 352 | return &ToggleResult{ | ||
| 244 | Added: true, | 353 | Added: true, | ||
| 245 | URL: url, | 354 | URL: url, | ||
| @@ -253,18 +362,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st | |||||
| 253 | log := logging.FromContext(ctx) | 362 | log := logging.FromContext(ctx) | ||
| 254 | log.Debug().Msg("getting all favorite URLs") | 363 | log.Debug().Msg("getting all favorite URLs") | ||
| 255 | 364 | ||||
| 256 | - | favs, err := uc.favoriteRepo.GetAll(ctx) | 365 | + | uc.cacheMu.Lock() |
| 366 | + | if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil { | |||
| 367 | + | cached := uc.favoriteURLsCache | |||
| 368 | + | uc.cacheMu.Unlock() | |||
| 369 | + | return cached, nil | |||
| 370 | + | } | |||
| 371 | + | uc.cacheMu.Unlock() | |||
| 372 | + | ||||
| 373 | + | favs, err := uc.GetAll(ctx) | |||
| 257 | if err != nil { | 374 | if err != nil { | ||
| 258 | return nil, fmt.Errorf("failed to get favorites: %w", err) | 375 | return nil, fmt.Errorf("failed to get favorites: %w", err) | ||
| 259 | } | 376 | } | ||
| 260 | 377 | ||||
| 378 | + | urls := buildFavoriteURLSet(favs) | |||
| 379 | + | ||||
| 380 | + | log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") | |||
| 381 | + | return urls, nil | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} { | |||
| 261 | urls := make(map[string]struct{}, len(favs)) | 385 | urls := make(map[string]struct{}, len(favs)) | ||
| 262 | for _, fav := range favs { | 386 | for _, fav := range favs { | ||
| 387 | + | if fav == nil || fav.URL == "" { | |||
| 388 | + | continue | |||
| 389 | + | } | |||
| 263 | urls[fav.URL] = struct{}{} | 390 | urls[fav.URL] = struct{}{} | ||
| 264 | } | 391 | } | ||
| 392 | + | return urls | |||
| 393 | + | } | |||
| 265 | 394 | ||||
| 266 | - | log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") | 395 | + | func (uc *ManageFavoritesUseCase) invalidateCache() { |
| 267 | - | return urls, nil | 396 | + | uc.cacheMu.Lock() |
| 397 | + | defer uc.cacheMu.Unlock() | |||
| 398 | + | uc.favoritesCache = nil | |||
| 399 | + | uc.favoriteURLsCache = nil | |||
| 400 | + | uc.cacheTime = time.Time{} | |||
| 268 | } | 401 | } | ||
| 269 | 402 | ||||
| 270 | // GetTree builds a complete hierarchical view of folders and favorites. | 403 | // GetTree builds a complete hierarchical view of folders and favorites. | ||
M internal/application/usecase/manage_favorites_test.go +104 -0
| @@ -198,3 +198,107 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||
| 198 | 198 | assert.Nil(t, result) | |
| 199 | 199 | assert.Contains(t, err.Error(), "failed to remove favorite") | |
| 200 | 200 | } | |
| 201 | + | ||
| 202 | + | func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) { | |
| 203 | + | ctx := testContext() | |
| 204 | + | ||
| 205 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 206 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 207 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 208 | + | ||
| 209 | + | favorites := []*entity.Favorite{ | |
| 210 | + | {ID: 1, URL: "https://example.com/landing", Title: "GitHub docs mirror", Position: 0}, | |
| 211 | + | {ID: 2, URL: "https://github.com/bnema/dumber", Title: "Project repo", Position: 1}, | |
| 212 | + | {ID: 3, URL: "https://gitlab.com/team/project", Title: "GitLab", Position: 2}, | |
| 213 | + | } | |
| 214 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 215 | + | ||
| 216 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 217 | + | ||
| 218 | + | results, err := uc.FilterForOmnibox(ctx, "git") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | require.Len(t, results, 3) | |
| 221 | + | assert.Equal(t, "https://github.com/bnema/dumber", results[0].URL) | |
| 222 | + | assert.Equal(t, "https://gitlab.com/team/project", results[1].URL) | |
| 223 | + | assert.Equal(t, "https://example.com/landing", results[2].URL) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(t *testing.T) { | |
| 227 | + | ctx := testContext() | |
| 228 | + | ||
| 229 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 230 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 231 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 232 | + | ||
| 233 | + | favorites := []*entity.Favorite{ | |
| 234 | + | {ID: 1, URL: "https://first.example"}, | |
| 235 | + | {ID: 2, URL: "https://second.example"}, | |
| 236 | + | } | |
| 237 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 238 | + | ||
| 239 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 240 | + | ||
| 241 | + | results, err := uc.FilterForOmnibox(ctx, "") | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | require.Len(t, results, 2) | |
| 244 | + | assert.Equal(t, "https://first.example", results[0].URL) | |
| 245 | + | assert.Equal(t, "https://second.example", results[1].URL) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) { | |
| 249 | + | ctx := testContext() | |
| 250 | + | ||
| 251 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 252 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 253 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 254 | + | ||
| 255 | + | favorites := []*entity.Favorite{ | |
| 256 | + | {ID: 1, URL: "https://example.com"}, | |
| 257 | + | } | |
| 258 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | |
| 259 | + | ||
| 260 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 261 | + | ||
| 262 | + | urls, err := uc.GetAllURLs(ctx) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | require.Contains(t, urls, "https://example.com") | |
| 265 | + | ||
| 266 | + | urls, err = uc.GetAllURLs(ctx) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | require.Contains(t, urls, "https://example.com") | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) { | |
| 272 | + | ctx := testContext() | |
| 273 | + | ||
| 274 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 275 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 276 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 277 | + | ||
| 278 | + | initialFavorites := []*entity.Favorite{ | |
| 279 | + | {ID: 1, URL: "https://cached.example"}, | |
| 280 | + | } | |
| 281 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(initialFavorites, nil).Once() | |
| 282 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://new.example").Return(nil, nil) | |
| 283 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |
| 284 | + | return f.URL == "https://new.example" | |
| 285 | + | })).Return(nil) | |
| 286 | + | updatedFavorites := []*entity.Favorite{ | |
| 287 | + | {ID: 1, URL: "https://cached.example"}, | |
| 288 | + | {ID: 2, URL: "https://new.example"}, | |
| 289 | + | } | |
| 290 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once() | |
| 291 | + | ||
| 292 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 293 | + | ||
| 294 | + | urls, err := uc.GetAllURLs(ctx) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | require.Contains(t, urls, "https://cached.example") | |
| 297 | + | ||
| 298 | + | _, err = uc.Toggle(ctx, "https://new.example", "New") | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | ||
| 301 | + | urls, err = uc.GetAllURLs(ctx) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | require.Contains(t, urls, "https://new.example") | |
| 304 | + | } | |
| @@ -198,3 +198,107 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||||
| 198 | assert.Nil(t, result) | 198 | assert.Nil(t, result) | ||
| 199 | assert.Contains(t, err.Error(), "failed to remove favorite") | 199 | assert.Contains(t, err.Error(), "failed to remove favorite") | ||
| 200 | } | 200 | } | ||
| 201 | + | ||||
| 202 | + | func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) { | |||
| 203 | + | ctx := testContext() | |||
| 204 | + | ||||
| 205 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 206 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 207 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 208 | + | ||||
| 209 | + | favorites := []*entity.Favorite{ | |||
| 210 | + | {ID: 1, URL: "https://example.com/landing", Title: "GitHub docs mirror", Position: 0}, | |||
| 211 | + | {ID: 2, URL: "https://github.com/bnema/dumber", Title: "Project repo", Position: 1}, | |||
| 212 | + | {ID: 3, URL: "https://gitlab.com/team/project", Title: "GitLab", Position: 2}, | |||
| 213 | + | } | |||
| 214 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |||
| 215 | + | ||||
| 216 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 217 | + | ||||
| 218 | + | results, err := uc.FilterForOmnibox(ctx, "git") | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | require.Len(t, results, 3) | |||
| 221 | + | assert.Equal(t, "https://github.com/bnema/dumber", results[0].URL) | |||
| 222 | + | assert.Equal(t, "https://gitlab.com/team/project", results[1].URL) | |||
| 223 | + | assert.Equal(t, "https://example.com/landing", results[2].URL) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(t *testing.T) { | |||
| 227 | + | ctx := testContext() | |||
| 228 | + | ||||
| 229 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 230 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 231 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 232 | + | ||||
| 233 | + | favorites := []*entity.Favorite{ | |||
| 234 | + | {ID: 1, URL: "https://first.example"}, | |||
| 235 | + | {ID: 2, URL: "https://second.example"}, | |||
| 236 | + | } | |||
| 237 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |||
| 238 | + | ||||
| 239 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 240 | + | ||||
| 241 | + | results, err := uc.FilterForOmnibox(ctx, "") | |||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | require.Len(t, results, 2) | |||
| 244 | + | assert.Equal(t, "https://first.example", results[0].URL) | |||
| 245 | + | assert.Equal(t, "https://second.example", results[1].URL) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) { | |||
| 249 | + | ctx := testContext() | |||
| 250 | + | ||||
| 251 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 252 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 253 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 254 | + | ||||
| 255 | + | favorites := []*entity.Favorite{ | |||
| 256 | + | {ID: 1, URL: "https://example.com"}, | |||
| 257 | + | } | |||
| 258 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | |||
| 259 | + | ||||
| 260 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 261 | + | ||||
| 262 | + | urls, err := uc.GetAllURLs(ctx) | |||
| 263 | + | require.NoError(t, err) | |||
| 264 | + | require.Contains(t, urls, "https://example.com") | |||
| 265 | + | ||||
| 266 | + | urls, err = uc.GetAllURLs(ctx) | |||
| 267 | + | require.NoError(t, err) | |||
| 268 | + | require.Contains(t, urls, "https://example.com") | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) { | |||
| 272 | + | ctx := testContext() | |||
| 273 | + | ||||
| 274 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 275 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 276 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 277 | + | ||||
| 278 | + | initialFavorites := []*entity.Favorite{ | |||
| 279 | + | {ID: 1, URL: "https://cached.example"}, | |||
| 280 | + | } | |||
| 281 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(initialFavorites, nil).Once() | |||
| 282 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://new.example").Return(nil, nil) | |||
| 283 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |||
| 284 | + | return f.URL == "https://new.example" | |||
| 285 | + | })).Return(nil) | |||
| 286 | + | updatedFavorites := []*entity.Favorite{ | |||
| 287 | + | {ID: 1, URL: "https://cached.example"}, | |||
| 288 | + | {ID: 2, URL: "https://new.example"}, | |||
| 289 | + | } | |||
| 290 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once() | |||
| 291 | + | ||||
| 292 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 293 | + | ||||
| 294 | + | urls, err := uc.GetAllURLs(ctx) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | require.Contains(t, urls, "https://cached.example") | |||
| 297 | + | ||||
| 298 | + | _, err = uc.Toggle(ctx, "https://new.example", "New") | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | ||||
| 301 | + | urls, err = uc.GetAllURLs(ctx) | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | require.Contains(t, urls, "https://new.example") | |||
| 304 | + | } | |||
M internal/application/usecase/navigate.go +10 -0
| @@ -39,6 +39,10 @@ type historyVisitDeltaIncrementer interface { | |||
| 39 | 39 | IncrementVisitCountBy(ctx context.Context, url string, delta int) error | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | type historyMetadataUpdater interface { | |
| 43 | + | UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error | |
| 44 | + | } | |
| 45 | + | ||
| 42 | 46 | type paneHistoryState struct { | |
| 43 | 47 | lastRawURL string | |
| 44 | 48 | lastCanonicalURL string | |
| @@ -337,6 +341,12 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, t | |||
| 337 | 341 | ||
| 338 | 342 | // Update existing entry's title | |
| 339 | 343 | entry.Title = title | |
| 344 | + | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | |
| 345 | + | if err := updater.UpdateMetadata(ctx, entry); err != nil { | |
| 346 | + | return fmt.Errorf("failed to update history metadata: %w", err) | |
| 347 | + | } | |
| 348 | + | return nil | |
| 349 | + | } | |
| 340 | 350 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 341 | 351 | return fmt.Errorf("failed to update history title: %w", err) | |
| 342 | 352 | } | |
| @@ -39,6 +39,10 @@ type historyVisitDeltaIncrementer interface { | |||||
| 39 | IncrementVisitCountBy(ctx context.Context, url string, delta int) error | 39 | IncrementVisitCountBy(ctx context.Context, url string, delta int) error | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | type historyMetadataUpdater interface { | |||
| 43 | + | UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error | |||
| 44 | + | } | |||
| 45 | + | ||||
| 42 | type paneHistoryState struct { | 46 | type paneHistoryState struct { | ||
| 43 | lastRawURL string | 47 | lastRawURL string | ||
| 44 | lastCanonicalURL string | 48 | lastCanonicalURL string | ||
| @@ -337,6 +341,12 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, t | |||||
| 337 | 341 | ||||
| 338 | // Update existing entry's title | 342 | // Update existing entry's title | ||
| 339 | entry.Title = title | 343 | entry.Title = title | ||
| 344 | + | if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok { | |||
| 345 | + | if err := updater.UpdateMetadata(ctx, entry); err != nil { | |||
| 346 | + | return fmt.Errorf("failed to update history metadata: %w", err) | |||
| 347 | + | } | |||
| 348 | + | return nil | |||
| 349 | + | } | |||
| 340 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | 350 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | ||
| 341 | return fmt.Errorf("failed to update history title: %w", err) | 351 | return fmt.Errorf("failed to update history title: %w", err) | ||
| 342 | } | 352 | } | ||
M internal/application/usecase/navigate_test.go +31 -0
| @@ -2,10 +2,12 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 9 | 11 | "github.com/stretchr/testify/mock" | |
| 10 | 12 | "github.com/stretchr/testify/require" | |
| 11 | 13 | ) | |
| @@ -58,3 +60,32 @@ func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) { | |||
| 58 | 60 | uc.RecordHistory(ctx, "pane-2", "https://example.com/article") | |
| 59 | 61 | uc.Close() | |
| 60 | 62 | } | |
| 63 | + | ||
| 64 | + | func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testing.T) { | |
| 65 | + | ctx := context.Background() | |
| 66 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 67 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 70 | + | ||
| 71 | + | repo := sqlite.NewHistoryRepository(db) | |
| 72 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 73 | + | URL: "https://example.com/article", | |
| 74 | + | Title: "Old", | |
| 75 | + | })) | |
| 76 | + | ||
| 77 | + | before, err := repo.FindByURL(ctx, "https://example.com/article") | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.NotNil(t, before) | |
| 80 | + | ||
| 81 | + | uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault) | |
| 82 | + | err = uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New") | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | ||
| 85 | + | after, err := repo.FindByURL(ctx, "https://example.com/article") | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | require.NotNil(t, after) | |
| 88 | + | require.Equal(t, "New", after.Title) | |
| 89 | + | require.Equal(t, before.VisitCount, after.VisitCount) | |
| 90 | + | uc.Close() | |
| 91 | + | } | |
| @@ -2,10 +2,12 @@ package usecase | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "path/filepath" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 9 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |||
| 9 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 10 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 13 | ) | ||
| @@ -58,3 +60,32 @@ func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) { | |||||
| 58 | uc.RecordHistory(ctx, "pane-2", "https://example.com/article") | 60 | uc.RecordHistory(ctx, "pane-2", "https://example.com/article") | ||
| 59 | uc.Close() | 61 | uc.Close() | ||
| 60 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testing.T) { | |||
| 65 | + | ctx := context.Background() | |||
| 66 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 67 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 70 | + | ||||
| 71 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 72 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 73 | + | URL: "https://example.com/article", | |||
| 74 | + | Title: "Old", | |||
| 75 | + | })) | |||
| 76 | + | ||||
| 77 | + | before, err := repo.FindByURL(ctx, "https://example.com/article") | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.NotNil(t, before) | |||
| 80 | + | ||||
| 81 | + | uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault) | |||
| 82 | + | err = uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New") | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | ||||
| 85 | + | after, err := repo.FindByURL(ctx, "https://example.com/article") | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | require.NotNil(t, after) | |||
| 88 | + | require.Equal(t, "New", after.Title) | |||
| 89 | + | require.Equal(t, before.VisitCount, after.VisitCount) | |||
| 90 | + | uc.Close() | |||
| 91 | + | } | |||
M internal/infrastructure/persistence/sqlite/history_repo.go +270 -42
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "database/sql" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "net/url" | |
| 9 | + | "sort" | |
| 8 | 10 | "strings" | |
| 9 | 11 | "sync/atomic" | |
| 10 | 12 | "time" | |
| @@ -12,21 +14,44 @@ import ( | |||
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 16 | "github.com/bnema/dumber/internal/domain/repository" | |
| 17 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 15 | 18 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |
| 16 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 17 | 20 | ) | |
| 18 | 21 | ||
| 19 | 22 | const logURLMaxLen = 60 | |
| 20 | 23 | ||
| 24 | + | const ( | |
| 25 | + | historyURLMatchScore = 300.0 | |
| 26 | + | historyTitleMatchScore = 120.0 | |
| 27 | + | historyExactHostScore = 220.0 | |
| 28 | + | historyTrimmedHostScore = 215.0 | |
| 29 | + | historyHostPrefixScore = 180.0 | |
| 30 | + | historyURLPrefixScore = 170.0 | |
| 31 | + | historyURLContainsScore = 80.0 | |
| 32 | + | historyTitlePrefixScore = 90.0 | |
| 33 | + | historyTitleContainsScore = 40.0 | |
| 34 | + | historyWordHostPrefixScore = 40.0 | |
| 35 | + | historyWordURLContainsScore = 15.0 | |
| 36 | + | historyWordTitlePrefixScore = 20.0 | |
| 37 | + | historyWordTitleScore = 8.0 | |
| 38 | + | historyPathDepthPenalty = 18 | |
| 39 | + | historyRootPathBonus = 20.0 | |
| 40 | + | historyDomainBoostScore = 35.0 | |
| 41 | + | historyVisitCountCap = 50.0 | |
| 42 | + | historyRecentVisitScore = 10.0 | |
| 43 | + | ) | |
| 44 | + | ||
| 21 | 45 | var expectedFTSFailureCount atomic.Uint64 | |
| 22 | 46 | ||
| 23 | 47 | type historyRepo struct { | |
| 48 | + | db *sql.DB | |
| 24 | 49 | queries *sqlc.Queries | |
| 25 | 50 | } | |
| 26 | 51 | ||
| 27 | 52 | // NewHistoryRepository creates a new SQLite-backed history repository. | |
| 28 | 53 | func NewHistoryRepository(db *sql.DB) repository.HistoryRepository { | |
| 29 | - | return &historyRepo{queries: sqlc.New(db)} | |
| 54 | + | return &historyRepo{db: db, queries: sqlc.New(db)} | |
| 30 | 55 | } | |
| 31 | 56 | ||
| 32 | 57 | // aboutBlankURL is the special URL for blank pages that should not accumulate visit counts. | |
| @@ -60,8 +85,8 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||
| 60 | 85 | return nil | |
| 61 | 86 | } | |
| 62 | 87 | ||
| 63 | - | func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | |
| 64 | - | row, err := r.queries.GetHistoryByURL(ctx, url) | |
| 88 | + | func (r *historyRepo) FindByURL(ctx context.Context, rawURL string) (*entity.HistoryEntry, error) { | |
| 89 | + | row, err := r.queries.GetHistoryByURL(ctx, rawURL) | |
| 65 | 90 | if err != nil { | |
| 66 | 91 | if errors.Is(err, sql.ErrNoRows) { | |
| 67 | 92 | return nil, nil | |
| @@ -71,6 +96,23 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||
| 71 | 96 | return historyFromRow(row), nil | |
| 72 | 97 | } | |
| 73 | 98 | ||
| 99 | + | func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error { | |
| 100 | + | if entry == nil || entry.URL == "" { | |
| 101 | + | return fmt.Errorf("history entry URL cannot be empty") | |
| 102 | + | } | |
| 103 | + | _, err := r.db.ExecContext( | |
| 104 | + | ctx, | |
| 105 | + | `UPDATE history | |
| 106 | + | SET title = COALESCE(?, title), | |
| 107 | + | favicon_url = COALESCE(?, favicon_url) | |
| 108 | + | WHERE url = ?`, | |
| 109 | + | nullString(entry.Title), | |
| 110 | + | nullString(entry.FaviconURL), | |
| 111 | + | entry.URL, | |
| 112 | + | ) | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | ||
| 74 | 116 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 75 | 117 | words := sanitizeFTS5QueryTokens(query) | |
| 76 | 118 | if len(words) == 0 { | |
| @@ -106,40 +148,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 106 | 148 | titleRows = []sqlc.History{} | |
| 107 | 149 | } | |
| 108 | 150 | ||
| 109 | - | // Merge results by interleaving URL and title matches for balanced results | |
| 110 | - | seen := make(map[int64]bool) | |
| 111 | - | var matches []entity.HistoryMatch | |
| 112 | - | ||
| 113 | - | i, j := 0, 0 | |
| 114 | - | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { | |
| 115 | - | // Take next URL match if available | |
| 116 | - | if i < len(urlRows) { | |
| 117 | - | row := urlRows[i] | |
| 118 | - | i++ | |
| 119 | - | if !seen[row.ID] { | |
| 120 | - | seen[row.ID] = true | |
| 121 | - | matches = append(matches, entity.HistoryMatch{ | |
| 122 | - | Entry: historyFromDomainBoostRow(row), | |
| 123 | - | Score: 1.0, | |
| 124 | - | }) | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // Take next title match if available | |
| 129 | - | if j < len(titleRows) && len(matches) < limit { | |
| 130 | - | row := titleRows[j] | |
| 131 | - | j++ | |
| 132 | - | if !seen[row.ID] { | |
| 133 | - | seen[row.ID] = true | |
| 134 | - | matches = append(matches, entity.HistoryMatch{ | |
| 135 | - | Entry: historyFromRow(row), | |
| 136 | - | Score: 1.0, | |
| 137 | - | }) | |
| 138 | - | } | |
| 139 | - | } | |
| 151 | + | ranked := rankHistoryMatches(query, words, urlRows, titleRows) | |
| 152 | + | if len(ranked) > limit { | |
| 153 | + | ranked = ranked[:limit] | |
| 140 | 154 | } | |
| 141 | - | ||
| 142 | - | return matches, nil | |
| 155 | + | return ranked, nil | |
| 143 | 156 | } | |
| 144 | 157 | ||
| 145 | 158 | func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry { | |
| @@ -154,6 +167,221 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) * | |||
| 154 | 167 | } | |
| 155 | 168 | } | |
| 156 | 169 | ||
| 170 | + | func nullString(value string) sql.NullString { | |
| 171 | + | return sql.NullString{String: value, Valid: value != ""} | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type historySearchCandidate struct { | |
| 175 | + | entry *entity.HistoryEntry | |
| 176 | + | score float64 | |
| 177 | + | urlMatch bool | |
| 178 | + | titleMatch bool | |
| 179 | + | domainBoost int64 | |
| 180 | + | exactURLMatch bool | |
| 181 | + | prefixURL bool | |
| 182 | + | prefixTitle bool | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func rankHistoryMatches( | |
| 186 | + | query string, | |
| 187 | + | words []string, | |
| 188 | + | urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow, | |
| 189 | + | titleRows []sqlc.History, | |
| 190 | + | ) []entity.HistoryMatch { | |
| 191 | + | candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows)) | |
| 192 | + | ||
| 193 | + | for i := range urlRows { | |
| 194 | + | row := urlRows[i] | |
| 195 | + | entry := historyFromDomainBoostRow(row) | |
| 196 | + | candidate := ensureHistoryCandidate(candidates, entry) | |
| 197 | + | candidate.urlMatch = true | |
| 198 | + | candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | for i := range titleRows { | |
| 202 | + | row := titleRows[i] | |
| 203 | + | entry := historyFromRow(row) | |
| 204 | + | candidate := ensureHistoryCandidate(candidates, entry) | |
| 205 | + | candidate.titleMatch = true | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | queryLower := strings.ToLower(strings.TrimSpace(query)) | |
| 209 | + | matches := make([]entity.HistoryMatch, 0, len(candidates)) | |
| 210 | + | for _, candidate := range candidates { | |
| 211 | + | candidate.score = computeHistoryCandidateScore(candidate, queryLower, words) | |
| 212 | + | matches = append(matches, entity.HistoryMatch{ | |
| 213 | + | Entry: candidate.entry, | |
| 214 | + | Score: candidate.score, | |
| 215 | + | }) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | sort.Slice(matches, func(i, j int) bool { | |
| 219 | + | left := candidates[matches[i].Entry.ID] | |
| 220 | + | right := candidates[matches[j].Entry.ID] | |
| 221 | + | if matches[i].Score != matches[j].Score { | |
| 222 | + | return matches[i].Score > matches[j].Score | |
| 223 | + | } | |
| 224 | + | if left.exactURLMatch != right.exactURLMatch { | |
| 225 | + | return left.exactURLMatch | |
| 226 | + | } | |
| 227 | + | if left.prefixURL != right.prefixURL { | |
| 228 | + | return left.prefixURL | |
| 229 | + | } | |
| 230 | + | if left.urlMatch != right.urlMatch { | |
| 231 | + | return left.urlMatch | |
| 232 | + | } | |
| 233 | + | if left.prefixTitle != right.prefixTitle { | |
| 234 | + | return left.prefixTitle | |
| 235 | + | } | |
| 236 | + | if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount { | |
| 237 | + | return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount | |
| 238 | + | } | |
| 239 | + | if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) { | |
| 240 | + | return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited) | |
| 241 | + | } | |
| 242 | + | return matches[i].Entry.URL < matches[j].Entry.URL | |
| 243 | + | }) | |
| 244 | + | ||
| 245 | + | return matches | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate { | |
| 249 | + | candidate, ok := candidates[entry.ID] | |
| 250 | + | if ok { | |
| 251 | + | return candidate | |
| 252 | + | } | |
| 253 | + | candidate = &historySearchCandidate{entry: entry} | |
| 254 | + | candidates[entry.ID] = candidate | |
| 255 | + | return candidate | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 { | |
| 259 | + | if candidate == nil || candidate.entry == nil { | |
| 260 | + | return 0 | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | urlLower := strings.ToLower(candidate.entry.URL) | |
| 264 | + | titleLower := strings.ToLower(candidate.entry.Title) | |
| 265 | + | hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL)) | |
| 266 | + | ||
| 267 | + | score := 0.0 | |
| 268 | + | if candidate.urlMatch { | |
| 269 | + | score += historyURLMatchScore | |
| 270 | + | } | |
| 271 | + | if candidate.titleMatch { | |
| 272 | + | score += historyTitleMatchScore | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower) | |
| 276 | + | score += scoreHistoryWords(words, hostLower, urlLower, titleLower) | |
| 277 | + | ||
| 278 | + | if looksLikeHostStyleQuery(queryLower) { | |
| 279 | + | pathDepth := historyPathDepth(candidate.entry.URL) | |
| 280 | + | score -= float64(pathDepth * historyPathDepthPenalty) | |
| 281 | + | if pathDepth == 0 { | |
| 282 | + | score += historyRootPathBonus | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | score += float64(candidate.domainBoost) * historyDomainBoostScore | |
| 287 | + | score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap) | |
| 288 | + | if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour { | |
| 289 | + | score += historyRecentVisitScore | |
| 290 | + | } | |
| 291 | + | return score | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func scoreQueryMatch( | |
| 295 | + | candidate *historySearchCandidate, | |
| 296 | + | queryLower, hostLower, urlLower, titleLower string, | |
| 297 | + | ) float64 { | |
| 298 | + | if queryLower == "" { | |
| 299 | + | return 0 | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | switch { | |
| 303 | + | case hostLower == queryLower: | |
| 304 | + | candidate.exactURLMatch = true | |
| 305 | + | return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 306 | + | case strings.TrimPrefix(hostLower, "www.") == queryLower: | |
| 307 | + | candidate.exactURLMatch = true | |
| 308 | + | return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 309 | + | case strings.HasPrefix(hostLower, queryLower): | |
| 310 | + | candidate.prefixURL = true | |
| 311 | + | return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 312 | + | case strings.HasPrefix(urlLower, queryLower): | |
| 313 | + | candidate.prefixURL = true | |
| 314 | + | return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 315 | + | case strings.Contains(urlLower, queryLower): | |
| 316 | + | return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 317 | + | default: | |
| 318 | + | return scoreTitleQueryMatch(candidate, queryLower, titleLower) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 { | |
| 323 | + | switch { | |
| 324 | + | case strings.HasPrefix(titleLower, queryLower): | |
| 325 | + | candidate.prefixTitle = true | |
| 326 | + | return historyTitlePrefixScore | |
| 327 | + | case strings.Contains(titleLower, queryLower): | |
| 328 | + | return historyTitleContainsScore | |
| 329 | + | default: | |
| 330 | + | return 0 | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 { | |
| 335 | + | score := 0.0 | |
| 336 | + | for _, word := range words { | |
| 337 | + | wordLower := strings.ToLower(word) | |
| 338 | + | if wordLower == "" { | |
| 339 | + | continue | |
| 340 | + | } | |
| 341 | + | if strings.HasPrefix(hostLower, wordLower) { | |
| 342 | + | score += historyWordHostPrefixScore | |
| 343 | + | } else if strings.Contains(urlLower, wordLower) { | |
| 344 | + | score += historyWordURLContainsScore | |
| 345 | + | } | |
| 346 | + | if strings.HasPrefix(titleLower, wordLower) { | |
| 347 | + | score += historyWordTitlePrefixScore | |
| 348 | + | } else if strings.Contains(titleLower, wordLower) { | |
| 349 | + | score += historyWordTitleScore | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | return score | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func looksLikeHostStyleQuery(query string) bool { | |
| 356 | + | return query != "" && !strings.ContainsAny(query, " /?#=&") | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func historyPathDepth(rawURL string) int { | |
| 360 | + | parsed, err := url.Parse(rawURL) | |
| 361 | + | if err != nil { | |
| 362 | + | return 0 | |
| 363 | + | } | |
| 364 | + | trimmed := strings.Trim(parsed.Path, "/") | |
| 365 | + | if trimmed == "" { | |
| 366 | + | return 0 | |
| 367 | + | } | |
| 368 | + | return len(strings.Split(trimmed, "/")) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func maxInt64(left, right int64) int64 { | |
| 372 | + | if left > right { | |
| 373 | + | return left | |
| 374 | + | } | |
| 375 | + | return right | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func minFloat64(left, right float64) float64 { | |
| 379 | + | if left < right { | |
| 380 | + | return left | |
| 381 | + | } | |
| 382 | + | return right | |
| 383 | + | } | |
| 384 | + | ||
| 157 | 385 | // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens. | |
| 158 | 386 | func sanitizeFTS5QueryTokens(query string) []string { | |
| 159 | 387 | normalized := normalizeFTSSeparators(query) | |
| @@ -303,17 +531,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||
| 303 | 531 | return entries, nil | |
| 304 | 532 | } | |
| 305 | 533 | ||
| 306 | - | func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error { | |
| 534 | + | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | |
| 307 | 535 | // Skip incrementing about:blank - it should always have visit_count = 1 | |
| 308 | - | if url == aboutBlankURL { | |
| 536 | + | if rawURL == aboutBlankURL { | |
| 309 | 537 | return nil | |
| 310 | 538 | } | |
| 311 | - | return r.queries.IncrementVisitCount(ctx, url) | |
| 539 | + | return r.queries.IncrementVisitCount(ctx, rawURL) | |
| 312 | 540 | } | |
| 313 | 541 | ||
| 314 | - | func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error { | |
| 542 | + | func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error { | |
| 315 | 543 | // Skip incrementing about:blank - it should always have visit_count = 1 | |
| 316 | - | if url == aboutBlankURL { | |
| 544 | + | if rawURL == aboutBlankURL { | |
| 317 | 545 | return nil | |
| 318 | 546 | } | |
| 319 | 547 | if delta <= 0 { | |
| @@ -321,7 +549,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del | |||
| 321 | 549 | } | |
| 322 | 550 | return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{ | |
| 323 | 551 | VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true}, | |
| 324 | - | Url: url, | |
| 552 | + | Url: rawURL, | |
| 325 | 553 | }) | |
| 326 | 554 | } | |
| 327 | 555 | ||
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "net/url" | |||
| 9 | + | "sort" | |||
| 8 | "strings" | 10 | "strings" | ||
| 9 | "sync/atomic" | 11 | "sync/atomic" | ||
| 10 | "time" | 12 | "time" | ||
| @@ -12,21 +14,44 @@ import ( | |||||
| 12 | 14 | ||||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 15 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | "github.com/bnema/dumber/internal/domain/repository" | 16 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 17 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 15 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | 18 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | ||
| 16 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 17 | ) | 20 | ) | ||
| 18 | 21 | ||||
| 19 | const logURLMaxLen = 60 | 22 | const logURLMaxLen = 60 | ||
| 20 | 23 | ||||
| 24 | + | const ( | |||
| 25 | + | historyURLMatchScore = 300.0 | |||
| 26 | + | historyTitleMatchScore = 120.0 | |||
| 27 | + | historyExactHostScore = 220.0 | |||
| 28 | + | historyTrimmedHostScore = 215.0 | |||
| 29 | + | historyHostPrefixScore = 180.0 | |||
| 30 | + | historyURLPrefixScore = 170.0 | |||
| 31 | + | historyURLContainsScore = 80.0 | |||
| 32 | + | historyTitlePrefixScore = 90.0 | |||
| 33 | + | historyTitleContainsScore = 40.0 | |||
| 34 | + | historyWordHostPrefixScore = 40.0 | |||
| 35 | + | historyWordURLContainsScore = 15.0 | |||
| 36 | + | historyWordTitlePrefixScore = 20.0 | |||
| 37 | + | historyWordTitleScore = 8.0 | |||
| 38 | + | historyPathDepthPenalty = 18 | |||
| 39 | + | historyRootPathBonus = 20.0 | |||
| 40 | + | historyDomainBoostScore = 35.0 | |||
| 41 | + | historyVisitCountCap = 50.0 | |||
| 42 | + | historyRecentVisitScore = 10.0 | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 21 | var expectedFTSFailureCount atomic.Uint64 | 45 | var expectedFTSFailureCount atomic.Uint64 | ||
| 22 | 46 | ||||
| 23 | type historyRepo struct { | 47 | type historyRepo struct { | ||
| 48 | + | db *sql.DB | |||
| 24 | queries *sqlc.Queries | 49 | queries *sqlc.Queries | ||
| 25 | } | 50 | } | ||
| 26 | 51 | ||||
| 27 | // NewHistoryRepository creates a new SQLite-backed history repository. | 52 | // NewHistoryRepository creates a new SQLite-backed history repository. | ||
| 28 | func NewHistoryRepository(db *sql.DB) repository.HistoryRepository { | 53 | func NewHistoryRepository(db *sql.DB) repository.HistoryRepository { | ||
| 29 | - | return &historyRepo{queries: sqlc.New(db)} | 54 | + | return &historyRepo{db: db, queries: sqlc.New(db)} |
| 30 | } | 55 | } | ||
| 31 | 56 | ||||
| 32 | // aboutBlankURL is the special URL for blank pages that should not accumulate visit counts. | 57 | // aboutBlankURL is the special URL for blank pages that should not accumulate visit counts. | ||
| @@ -60,8 +85,8 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||||
| 60 | return nil | 85 | return nil | ||
| 61 | } | 86 | } | ||
| 62 | 87 | ||||
| 63 | - | func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | 88 | + | func (r *historyRepo) FindByURL(ctx context.Context, rawURL string) (*entity.HistoryEntry, error) { |
| 64 | - | row, err := r.queries.GetHistoryByURL(ctx, url) | 89 | + | row, err := r.queries.GetHistoryByURL(ctx, rawURL) |
| 65 | if err != nil { | 90 | if err != nil { | ||
| 66 | if errors.Is(err, sql.ErrNoRows) { | 91 | if errors.Is(err, sql.ErrNoRows) { | ||
| 67 | return nil, nil | 92 | return nil, nil | ||
| @@ -71,6 +96,23 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||||
| 71 | return historyFromRow(row), nil | 96 | return historyFromRow(row), nil | ||
| 72 | } | 97 | } | ||
| 73 | 98 | ||||
| 99 | + | func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error { | |||
| 100 | + | if entry == nil || entry.URL == "" { | |||
| 101 | + | return fmt.Errorf("history entry URL cannot be empty") | |||
| 102 | + | } | |||
| 103 | + | _, err := r.db.ExecContext( | |||
| 104 | + | ctx, | |||
| 105 | + | `UPDATE history | |||
| 106 | + | SET title = COALESCE(?, title), | |||
| 107 | + | favicon_url = COALESCE(?, favicon_url) | |||
| 108 | + | WHERE url = ?`, | |||
| 109 | + | nullString(entry.Title), | |||
| 110 | + | nullString(entry.FaviconURL), | |||
| 111 | + | entry.URL, | |||
| 112 | + | ) | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | ||||
| 74 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 116 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 75 | words := sanitizeFTS5QueryTokens(query) | 117 | words := sanitizeFTS5QueryTokens(query) | ||
| 76 | if len(words) == 0 { | 118 | if len(words) == 0 { | ||
| @@ -106,40 +148,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 106 | titleRows = []sqlc.History{} | 148 | titleRows = []sqlc.History{} | ||
| 107 | } | 149 | } | ||
| 108 | 150 | ||||
| 109 | - | // Merge results by interleaving URL and title matches for balanced results | 151 | + | ranked := rankHistoryMatches(query, words, urlRows, titleRows) |
| 110 | - | seen := make(map[int64]bool) | 152 | + | if len(ranked) > limit { |
| 111 | - | var matches []entity.HistoryMatch | 153 | + | ranked = ranked[:limit] |
| 112 | - | ||||
| 113 | - | i, j := 0, 0 | |||
| 114 | - | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { | |||
| 115 | - | // Take next URL match if available | |||
| 116 | - | if i < len(urlRows) { | |||
| 117 | - | row := urlRows[i] | |||
| 118 | - | i++ | |||
| 119 | - | if !seen[row.ID] { | |||
| 120 | - | seen[row.ID] = true | |||
| 121 | - | matches = append(matches, entity.HistoryMatch{ | |||
| 122 | - | Entry: historyFromDomainBoostRow(row), | |||
| 123 | - | Score: 1.0, | |||
| 124 | - | }) | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // Take next title match if available | |||
| 129 | - | if j < len(titleRows) && len(matches) < limit { | |||
| 130 | - | row := titleRows[j] | |||
| 131 | - | j++ | |||
| 132 | - | if !seen[row.ID] { | |||
| 133 | - | seen[row.ID] = true | |||
| 134 | - | matches = append(matches, entity.HistoryMatch{ | |||
| 135 | - | Entry: historyFromRow(row), | |||
| 136 | - | Score: 1.0, | |||
| 137 | - | }) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | } | 154 | } | ||
| 141 | - | 155 | + | return ranked, nil | |
| 142 | - | return matches, nil | |||
| 143 | } | 156 | } | ||
| 144 | 157 | ||||
| 145 | func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry { | 158 | func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry { | ||
| @@ -154,6 +167,221 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) * | |||||
| 154 | } | 167 | } | ||
| 155 | } | 168 | } | ||
| 156 | 169 | ||||
| 170 | + | func nullString(value string) sql.NullString { | |||
| 171 | + | return sql.NullString{String: value, Valid: value != ""} | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | type historySearchCandidate struct { | |||
| 175 | + | entry *entity.HistoryEntry | |||
| 176 | + | score float64 | |||
| 177 | + | urlMatch bool | |||
| 178 | + | titleMatch bool | |||
| 179 | + | domainBoost int64 | |||
| 180 | + | exactURLMatch bool | |||
| 181 | + | prefixURL bool | |||
| 182 | + | prefixTitle bool | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func rankHistoryMatches( | |||
| 186 | + | query string, | |||
| 187 | + | words []string, | |||
| 188 | + | urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow, | |||
| 189 | + | titleRows []sqlc.History, | |||
| 190 | + | ) []entity.HistoryMatch { | |||
| 191 | + | candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows)) | |||
| 192 | + | ||||
| 193 | + | for i := range urlRows { | |||
| 194 | + | row := urlRows[i] | |||
| 195 | + | entry := historyFromDomainBoostRow(row) | |||
| 196 | + | candidate := ensureHistoryCandidate(candidates, entry) | |||
| 197 | + | candidate.urlMatch = true | |||
| 198 | + | candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | for i := range titleRows { | |||
| 202 | + | row := titleRows[i] | |||
| 203 | + | entry := historyFromRow(row) | |||
| 204 | + | candidate := ensureHistoryCandidate(candidates, entry) | |||
| 205 | + | candidate.titleMatch = true | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | queryLower := strings.ToLower(strings.TrimSpace(query)) | |||
| 209 | + | matches := make([]entity.HistoryMatch, 0, len(candidates)) | |||
| 210 | + | for _, candidate := range candidates { | |||
| 211 | + | candidate.score = computeHistoryCandidateScore(candidate, queryLower, words) | |||
| 212 | + | matches = append(matches, entity.HistoryMatch{ | |||
| 213 | + | Entry: candidate.entry, | |||
| 214 | + | Score: candidate.score, | |||
| 215 | + | }) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 219 | + | left := candidates[matches[i].Entry.ID] | |||
| 220 | + | right := candidates[matches[j].Entry.ID] | |||
| 221 | + | if matches[i].Score != matches[j].Score { | |||
| 222 | + | return matches[i].Score > matches[j].Score | |||
| 223 | + | } | |||
| 224 | + | if left.exactURLMatch != right.exactURLMatch { | |||
| 225 | + | return left.exactURLMatch | |||
| 226 | + | } | |||
| 227 | + | if left.prefixURL != right.prefixURL { | |||
| 228 | + | return left.prefixURL | |||
| 229 | + | } | |||
| 230 | + | if left.urlMatch != right.urlMatch { | |||
| 231 | + | return left.urlMatch | |||
| 232 | + | } | |||
| 233 | + | if left.prefixTitle != right.prefixTitle { | |||
| 234 | + | return left.prefixTitle | |||
| 235 | + | } | |||
| 236 | + | if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount { | |||
| 237 | + | return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount | |||
| 238 | + | } | |||
| 239 | + | if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) { | |||
| 240 | + | return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited) | |||
| 241 | + | } | |||
| 242 | + | return matches[i].Entry.URL < matches[j].Entry.URL | |||
| 243 | + | }) | |||
| 244 | + | ||||
| 245 | + | return matches | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate { | |||
| 249 | + | candidate, ok := candidates[entry.ID] | |||
| 250 | + | if ok { | |||
| 251 | + | return candidate | |||
| 252 | + | } | |||
| 253 | + | candidate = &historySearchCandidate{entry: entry} | |||
| 254 | + | candidates[entry.ID] = candidate | |||
| 255 | + | return candidate | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 { | |||
| 259 | + | if candidate == nil || candidate.entry == nil { | |||
| 260 | + | return 0 | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | urlLower := strings.ToLower(candidate.entry.URL) | |||
| 264 | + | titleLower := strings.ToLower(candidate.entry.Title) | |||
| 265 | + | hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL)) | |||
| 266 | + | ||||
| 267 | + | score := 0.0 | |||
| 268 | + | if candidate.urlMatch { | |||
| 269 | + | score += historyURLMatchScore | |||
| 270 | + | } | |||
| 271 | + | if candidate.titleMatch { | |||
| 272 | + | score += historyTitleMatchScore | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower) | |||
| 276 | + | score += scoreHistoryWords(words, hostLower, urlLower, titleLower) | |||
| 277 | + | ||||
| 278 | + | if looksLikeHostStyleQuery(queryLower) { | |||
| 279 | + | pathDepth := historyPathDepth(candidate.entry.URL) | |||
| 280 | + | score -= float64(pathDepth * historyPathDepthPenalty) | |||
| 281 | + | if pathDepth == 0 { | |||
| 282 | + | score += historyRootPathBonus | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | score += float64(candidate.domainBoost) * historyDomainBoostScore | |||
| 287 | + | score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap) | |||
| 288 | + | if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour { | |||
| 289 | + | score += historyRecentVisitScore | |||
| 290 | + | } | |||
| 291 | + | return score | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func scoreQueryMatch( | |||
| 295 | + | candidate *historySearchCandidate, | |||
| 296 | + | queryLower, hostLower, urlLower, titleLower string, | |||
| 297 | + | ) float64 { | |||
| 298 | + | if queryLower == "" { | |||
| 299 | + | return 0 | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | switch { | |||
| 303 | + | case hostLower == queryLower: | |||
| 304 | + | candidate.exactURLMatch = true | |||
| 305 | + | return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 306 | + | case strings.TrimPrefix(hostLower, "www.") == queryLower: | |||
| 307 | + | candidate.exactURLMatch = true | |||
| 308 | + | return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 309 | + | case strings.HasPrefix(hostLower, queryLower): | |||
| 310 | + | candidate.prefixURL = true | |||
| 311 | + | return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 312 | + | case strings.HasPrefix(urlLower, queryLower): | |||
| 313 | + | candidate.prefixURL = true | |||
| 314 | + | return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 315 | + | case strings.Contains(urlLower, queryLower): | |||
| 316 | + | return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 317 | + | default: | |||
| 318 | + | return scoreTitleQueryMatch(candidate, queryLower, titleLower) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 { | |||
| 323 | + | switch { | |||
| 324 | + | case strings.HasPrefix(titleLower, queryLower): | |||
| 325 | + | candidate.prefixTitle = true | |||
| 326 | + | return historyTitlePrefixScore | |||
| 327 | + | case strings.Contains(titleLower, queryLower): | |||
| 328 | + | return historyTitleContainsScore | |||
| 329 | + | default: | |||
| 330 | + | return 0 | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 { | |||
| 335 | + | score := 0.0 | |||
| 336 | + | for _, word := range words { | |||
| 337 | + | wordLower := strings.ToLower(word) | |||
| 338 | + | if wordLower == "" { | |||
| 339 | + | continue | |||
| 340 | + | } | |||
| 341 | + | if strings.HasPrefix(hostLower, wordLower) { | |||
| 342 | + | score += historyWordHostPrefixScore | |||
| 343 | + | } else if strings.Contains(urlLower, wordLower) { | |||
| 344 | + | score += historyWordURLContainsScore | |||
| 345 | + | } | |||
| 346 | + | if strings.HasPrefix(titleLower, wordLower) { | |||
| 347 | + | score += historyWordTitlePrefixScore | |||
| 348 | + | } else if strings.Contains(titleLower, wordLower) { | |||
| 349 | + | score += historyWordTitleScore | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | return score | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func looksLikeHostStyleQuery(query string) bool { | |||
| 356 | + | return query != "" && !strings.ContainsAny(query, " /?#=&") | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | func historyPathDepth(rawURL string) int { | |||
| 360 | + | parsed, err := url.Parse(rawURL) | |||
| 361 | + | if err != nil { | |||
| 362 | + | return 0 | |||
| 363 | + | } | |||
| 364 | + | trimmed := strings.Trim(parsed.Path, "/") | |||
| 365 | + | if trimmed == "" { | |||
| 366 | + | return 0 | |||
| 367 | + | } | |||
| 368 | + | return len(strings.Split(trimmed, "/")) | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func maxInt64(left, right int64) int64 { | |||
| 372 | + | if left > right { | |||
| 373 | + | return left | |||
| 374 | + | } | |||
| 375 | + | return right | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func minFloat64(left, right float64) float64 { | |||
| 379 | + | if left < right { | |||
| 380 | + | return left | |||
| 381 | + | } | |||
| 382 | + | return right | |||
| 383 | + | } | |||
| 384 | + | ||||
| 157 | // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens. | 385 | // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens. | ||
| 158 | func sanitizeFTS5QueryTokens(query string) []string { | 386 | func sanitizeFTS5QueryTokens(query string) []string { | ||
| 159 | normalized := normalizeFTSSeparators(query) | 387 | normalized := normalizeFTSSeparators(query) | ||
| @@ -303,17 +531,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||||
| 303 | return entries, nil | 531 | return entries, nil | ||
| 304 | } | 532 | } | ||
| 305 | 533 | ||||
| 306 | - | func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error { | 534 | + | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { |
| 307 | // Skip incrementing about:blank - it should always have visit_count = 1 | 535 | // Skip incrementing about:blank - it should always have visit_count = 1 | ||
| 308 | - | if url == aboutBlankURL { | 536 | + | if rawURL == aboutBlankURL { |
| 309 | return nil | 537 | return nil | ||
| 310 | } | 538 | } | ||
| 311 | - | return r.queries.IncrementVisitCount(ctx, url) | 539 | + | return r.queries.IncrementVisitCount(ctx, rawURL) |
| 312 | } | 540 | } | ||
| 313 | 541 | ||||
| 314 | - | func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error { | 542 | + | func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error { |
| 315 | // Skip incrementing about:blank - it should always have visit_count = 1 | 543 | // Skip incrementing about:blank - it should always have visit_count = 1 | ||
| 316 | - | if url == aboutBlankURL { | 544 | + | if rawURL == aboutBlankURL { |
| 317 | return nil | 545 | return nil | ||
| 318 | } | 546 | } | ||
| 319 | if delta <= 0 { | 547 | if delta <= 0 { | ||
| @@ -321,7 +549,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del | |||||
| 321 | } | 549 | } | ||
| 322 | return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{ | 550 | return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{ | ||
| 323 | VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true}, | 551 | VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true}, | ||
| 324 | - | Url: url, | 552 | + | Url: rawURL, |
| 325 | }) | 553 | }) | ||
| 326 | } | 554 | } | ||
| 327 | 555 | ||||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +113 -0
| @@ -627,6 +627,119 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |||
| 627 | 627 | require.NotEmpty(t, results, "slash-separated query should match history") | |
| 628 | 628 | } | |
| 629 | 629 | ||
| 630 | + | func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T) { | |
| 631 | + | ctx := historyTestCtx() | |
| 632 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 633 | + | ||
| 634 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 635 | + | require.NoError(t, err) | |
| 636 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 637 | + | ||
| 638 | + | repo := sqlite.NewHistoryRepository(db) | |
| 639 | + | ||
| 640 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 641 | + | URL: "https://github.com/bnema/dumber/issues", | |
| 642 | + | Title: "Project board", | |
| 643 | + | })) | |
| 644 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 645 | + | URL: "https://tracker.example.com/board", | |
| 646 | + | Title: "GitHub Issues Mirror", | |
| 647 | + | })) | |
| 648 | + | ||
| 649 | + | results, err := repo.Search(ctx, "github issues", 10) | |
| 650 | + | require.NoError(t, err) | |
| 651 | + | require.Len(t, results, 2) | |
| 652 | + | assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL) | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.T) { | |
| 656 | + | ctx := historyTestCtx() | |
| 657 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 658 | + | ||
| 659 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 660 | + | require.NoError(t, err) | |
| 661 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 662 | + | ||
| 663 | + | repo := sqlite.NewHistoryRepository(db) | |
| 664 | + | ||
| 665 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 666 | + | URL: "https://github.com", | |
| 667 | + | Title: "GitHub", | |
| 668 | + | })) | |
| 669 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 670 | + | URL: "https://example.com/landing", | |
| 671 | + | Title: "GitHub documentation mirror", | |
| 672 | + | })) | |
| 673 | + | ||
| 674 | + | results, err := repo.Search(ctx, "git", 10) | |
| 675 | + | require.NoError(t, err) | |
| 676 | + | require.Len(t, results, 2) | |
| 677 | + | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T) { | |
| 681 | + | ctx := historyTestCtx() | |
| 682 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 683 | + | ||
| 684 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 685 | + | require.NoError(t, err) | |
| 686 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 687 | + | ||
| 688 | + | repo := sqlite.NewHistoryRepository(db) | |
| 689 | + | ||
| 690 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 691 | + | URL: "https://github.com", | |
| 692 | + | Title: "GitHub", | |
| 693 | + | })) | |
| 694 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 695 | + | URL: "https://github.com/bnema/dumber/issues/123", | |
| 696 | + | Title: "Issue 123", | |
| 697 | + | })) | |
| 698 | + | ||
| 699 | + | results, err := repo.Search(ctx, "github", 10) | |
| 700 | + | require.NoError(t, err) | |
| 701 | + | require.Len(t, results, 2) | |
| 702 | + | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func TestHistoryRepository_UpdateMetadata_DoesNotIncrementVisitCount(t *testing.T) { | |
| 706 | + | ctx := historyTestCtx() | |
| 707 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 708 | + | ||
| 709 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 710 | + | require.NoError(t, err) | |
| 711 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 712 | + | ||
| 713 | + | repoIface := sqlite.NewHistoryRepository(db) | |
| 714 | + | repo, ok := repoIface.(interface { | |
| 715 | + | Save(context.Context, *entity.HistoryEntry) error | |
| 716 | + | FindByURL(context.Context, string) (*entity.HistoryEntry, error) | |
| 717 | + | UpdateMetadata(context.Context, *entity.HistoryEntry) error | |
| 718 | + | }) | |
| 719 | + | require.True(t, ok) | |
| 720 | + | ||
| 721 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 722 | + | URL: "https://github.com/bnema/dumber", | |
| 723 | + | Title: "Old title", | |
| 724 | + | })) | |
| 725 | + | ||
| 726 | + | before, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber") | |
| 727 | + | require.NoError(t, err) | |
| 728 | + | require.NotNil(t, before) | |
| 729 | + | require.Equal(t, int64(1), before.VisitCount) | |
| 730 | + | ||
| 731 | + | require.NoError(t, repo.UpdateMetadata(ctx, &entity.HistoryEntry{ | |
| 732 | + | URL: "https://github.com/bnema/dumber", | |
| 733 | + | Title: "New title", | |
| 734 | + | })) | |
| 735 | + | ||
| 736 | + | after, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber") | |
| 737 | + | require.NoError(t, err) | |
| 738 | + | require.NotNil(t, after) | |
| 739 | + | assert.Equal(t, int64(1), after.VisitCount) | |
| 740 | + | assert.Equal(t, "New title", after.Title) | |
| 741 | + | } | |
| 742 | + | ||
| 630 | 743 | func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) { | |
| 631 | 744 | ctx := historyTestCtx() | |
| 632 | 745 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| @@ -627,6 +627,119 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |||||
| 627 | require.NotEmpty(t, results, "slash-separated query should match history") | 627 | require.NotEmpty(t, results, "slash-separated query should match history") | ||
| 628 | } | 628 | } | ||
| 629 | 629 | ||||
| 630 | + | func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T) { | |||
| 631 | + | ctx := historyTestCtx() | |||
| 632 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 633 | + | ||||
| 634 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 635 | + | require.NoError(t, err) | |||
| 636 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 637 | + | ||||
| 638 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 639 | + | ||||
| 640 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 641 | + | URL: "https://github.com/bnema/dumber/issues", | |||
| 642 | + | Title: "Project board", | |||
| 643 | + | })) | |||
| 644 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 645 | + | URL: "https://tracker.example.com/board", | |||
| 646 | + | Title: "GitHub Issues Mirror", | |||
| 647 | + | })) | |||
| 648 | + | ||||
| 649 | + | results, err := repo.Search(ctx, "github issues", 10) | |||
| 650 | + | require.NoError(t, err) | |||
| 651 | + | require.Len(t, results, 2) | |||
| 652 | + | assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL) | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.T) { | |||
| 656 | + | ctx := historyTestCtx() | |||
| 657 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 658 | + | ||||
| 659 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 660 | + | require.NoError(t, err) | |||
| 661 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 662 | + | ||||
| 663 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 664 | + | ||||
| 665 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 666 | + | URL: "https://github.com", | |||
| 667 | + | Title: "GitHub", | |||
| 668 | + | })) | |||
| 669 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 670 | + | URL: "https://example.com/landing", | |||
| 671 | + | Title: "GitHub documentation mirror", | |||
| 672 | + | })) | |||
| 673 | + | ||||
| 674 | + | results, err := repo.Search(ctx, "git", 10) | |||
| 675 | + | require.NoError(t, err) | |||
| 676 | + | require.Len(t, results, 2) | |||
| 677 | + | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T) { | |||
| 681 | + | ctx := historyTestCtx() | |||
| 682 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 683 | + | ||||
| 684 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 685 | + | require.NoError(t, err) | |||
| 686 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 687 | + | ||||
| 688 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 689 | + | ||||
| 690 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 691 | + | URL: "https://github.com", | |||
| 692 | + | Title: "GitHub", | |||
| 693 | + | })) | |||
| 694 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 695 | + | URL: "https://github.com/bnema/dumber/issues/123", | |||
| 696 | + | Title: "Issue 123", | |||
| 697 | + | })) | |||
| 698 | + | ||||
| 699 | + | results, err := repo.Search(ctx, "github", 10) | |||
| 700 | + | require.NoError(t, err) | |||
| 701 | + | require.Len(t, results, 2) | |||
| 702 | + | assert.Equal(t, "https://github.com", results[0].Entry.URL) | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | func TestHistoryRepository_UpdateMetadata_DoesNotIncrementVisitCount(t *testing.T) { | |||
| 706 | + | ctx := historyTestCtx() | |||
| 707 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 708 | + | ||||
| 709 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 710 | + | require.NoError(t, err) | |||
| 711 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 712 | + | ||||
| 713 | + | repoIface := sqlite.NewHistoryRepository(db) | |||
| 714 | + | repo, ok := repoIface.(interface { | |||
| 715 | + | Save(context.Context, *entity.HistoryEntry) error | |||
| 716 | + | FindByURL(context.Context, string) (*entity.HistoryEntry, error) | |||
| 717 | + | UpdateMetadata(context.Context, *entity.HistoryEntry) error | |||
| 718 | + | }) | |||
| 719 | + | require.True(t, ok) | |||
| 720 | + | ||||
| 721 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 722 | + | URL: "https://github.com/bnema/dumber", | |||
| 723 | + | Title: "Old title", | |||
| 724 | + | })) | |||
| 725 | + | ||||
| 726 | + | before, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber") | |||
| 727 | + | require.NoError(t, err) | |||
| 728 | + | require.NotNil(t, before) | |||
| 729 | + | require.Equal(t, int64(1), before.VisitCount) | |||
| 730 | + | ||||
| 731 | + | require.NoError(t, repo.UpdateMetadata(ctx, &entity.HistoryEntry{ | |||
| 732 | + | URL: "https://github.com/bnema/dumber", | |||
| 733 | + | Title: "New title", | |||
| 734 | + | })) | |||
| 735 | + | ||||
| 736 | + | after, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber") | |||
| 737 | + | require.NoError(t, err) | |||
| 738 | + | require.NotNil(t, after) | |||
| 739 | + | assert.Equal(t, int64(1), after.VisitCount) | |||
| 740 | + | assert.Equal(t, "New title", after.Title) | |||
| 741 | + | } | |||
| 742 | + | ||||
| 630 | func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) { | 743 | func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) { | ||
| 631 | ctx := historyTestCtx() | 744 | ctx := historyTestCtx() | ||
| 632 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | 745 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | ||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +12 -0
| @@ -123,6 +123,18 @@ func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*ent | |||
| 123 | 123 | return r.repo.FindByURL(ctx, url) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error { | |
| 127 | + | if err := r.init(ctx); err != nil { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if updater, ok := r.repo.(interface { | |
| 131 | + | UpdateMetadata(context.Context, *entity.HistoryEntry) error | |
| 132 | + | }); ok { | |
| 133 | + | return updater.UpdateMetadata(ctx, entry) | |
| 134 | + | } | |
| 135 | + | return r.repo.Save(ctx, entry) | |
| 136 | + | } | |
| 137 | + | ||
| 126 | 138 | func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 127 | 139 | if err := r.init(ctx); err != nil { | |
| 128 | 140 | return nil, err | |
| @@ -123,6 +123,18 @@ func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*ent | |||||
| 123 | return r.repo.FindByURL(ctx, url) | 123 | return r.repo.FindByURL(ctx, url) | ||
| 124 | } | 124 | } | ||
| 125 | 125 | ||||
| 126 | + | func (r *LazyHistoryRepository) UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error { | |||
| 127 | + | if err := r.init(ctx); err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | if updater, ok := r.repo.(interface { | |||
| 131 | + | UpdateMetadata(context.Context, *entity.HistoryEntry) error | |||
| 132 | + | }); ok { | |||
| 133 | + | return updater.UpdateMetadata(ctx, entry) | |||
| 134 | + | } | |||
| 135 | + | return r.repo.Save(ctx, entry) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 126 | func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 138 | func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 127 | if err := r.init(ctx); err != nil { | 139 | if err := r.init(ctx); err != nil { | ||
| 128 | return nil, err | 140 | return nil, err | ||
M internal/ui/app.go +0 -4
| @@ -685,9 +685,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 685 | 685 | } | |
| 686 | 686 | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 687 | 687 | ||
| 688 | - | // Create autocomplete use case for inline suggestions | |
| 689 | - | autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC) | |
| 690 | - | ||
| 691 | 688 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | |
| 692 | 689 | a.omniboxCfg = component.OmniboxConfig{ | |
| 693 | 690 | HistoryUC: a.deps.HistoryUC, | |
| @@ -695,7 +692,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 695 | 692 | FaviconAdapter: a.faviconAdapter, | |
| 696 | 693 | CopyURLUC: a.deps.CopyURLUC, | |
| 697 | 694 | ShortcutsUC: shortcutsUC, | |
| 698 | - | AutocompleteUC: autocompleteUC, | |
| 699 | 695 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| 700 | 696 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 701 | 697 | UIScale: a.deps.Config.DefaultUIScale, | |
| @@ -685,9 +685,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 685 | } | 685 | } | ||
| 686 | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | 686 | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | ||
| 687 | 687 | ||||
| 688 | - | // Create autocomplete use case for inline suggestions | |||
| 689 | - | autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC) | |||
| 690 | - | ||||
| 691 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | 688 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | ||
| 692 | a.omniboxCfg = component.OmniboxConfig{ | 689 | a.omniboxCfg = component.OmniboxConfig{ | ||
| 693 | HistoryUC: a.deps.HistoryUC, | 690 | HistoryUC: a.deps.HistoryUC, | ||
| @@ -695,7 +692,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 695 | FaviconAdapter: a.faviconAdapter, | 692 | FaviconAdapter: a.faviconAdapter, | ||
| 696 | CopyURLUC: a.deps.CopyURLUC, | 693 | CopyURLUC: a.deps.CopyURLUC, | ||
| 697 | ShortcutsUC: shortcutsUC, | 694 | ShortcutsUC: shortcutsUC, | ||
| 698 | - | AutocompleteUC: autocompleteUC, | |||
| 699 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | 695 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | ||
| 700 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 696 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 701 | UIScale: a.deps.Config.DefaultUIScale, | 697 | UIScale: a.deps.Config.DefaultUIScale, | ||
M internal/ui/component/omnibox.go +179 -93
| @@ -106,7 +106,6 @@ type Omnibox struct { | |||
| 106 | 106 | faviconAdapter *adapter.FaviconAdapter | |
| 107 | 107 | copyURLUC *usecase.CopyURLUseCase | |
| 108 | 108 | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 109 | - | autocompleteUC *usecase.AutocompleteUseCase | |
| 110 | 109 | defaultSearch string | |
| 111 | 110 | initialBehavior string | |
| 112 | 111 | ctx context.Context | |
| @@ -150,7 +149,6 @@ type OmniboxConfig struct { | |||
| 150 | 149 | FaviconAdapter *adapter.FaviconAdapter | |
| 151 | 150 | CopyURLUC *usecase.CopyURLUseCase | |
| 152 | 151 | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 153 | - | AutocompleteUC *usecase.AutocompleteUseCase | |
| 154 | 152 | DefaultSearch string | |
| 155 | 153 | InitialBehavior string | |
| 156 | 154 | UIScale float64 // UI scale for favicon sizing | |
| @@ -178,7 +176,6 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 178 | 176 | faviconAdapter: cfg.FaviconAdapter, | |
| 179 | 177 | copyURLUC: cfg.CopyURLUC, | |
| 180 | 178 | shortcutsUC: cfg.ShortcutsUC, | |
| 181 | - | autocompleteUC: cfg.AutocompleteUC, | |
| 182 | 179 | defaultSearch: cfg.DefaultSearch, | |
| 183 | 180 | initialBehavior: cfg.InitialBehavior, | |
| 184 | 181 | onToast: cfg.OnToast, | |
| @@ -540,18 +537,20 @@ func (o *Omnibox) initList() error { | |||
| 540 | 537 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 541 | 538 | ||
| 542 | 539 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 540 | + | o.restoreEntryToRealInput() | |
| 543 | 541 | if rowPtr == 0 { | |
| 544 | 542 | o.mu.Lock() | |
| 545 | 543 | o.selectedIndex = -1 | |
| 546 | 544 | o.mu.Unlock() | |
| 547 | - | return | |
| 548 | - | } | |
| 549 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 550 | - | if row != nil { | |
| 551 | - | o.mu.Lock() | |
| 552 | - | o.selectedIndex = row.GetIndex() | |
| 553 | - | o.mu.Unlock() | |
| 545 | + | } else { | |
| 546 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 547 | + | if row != nil { | |
| 548 | + | o.mu.Lock() | |
| 549 | + | o.selectedIndex = row.GetIndex() | |
| 550 | + | o.mu.Unlock() | |
| 551 | + | } | |
| 554 | 552 | } | |
| 553 | + | o.updateGhostFromSelection() | |
| 555 | 554 | } | |
| 556 | 555 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | |
| 557 | 556 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| @@ -700,12 +699,10 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 700 | 699 | ||
| 701 | 700 | case uint(gdk.KEY_Up): | |
| 702 | 701 | o.selectPrevious() | |
| 703 | - | o.updateGhostFromSelection() | |
| 704 | 702 | return true | |
| 705 | 703 | ||
| 706 | 704 | case uint(gdk.KEY_Down): | |
| 707 | 705 | o.selectNext() | |
| 708 | - | o.updateGhostFromSelection() | |
| 709 | 706 | return true | |
| 710 | 707 | ||
| 711 | 708 | case uint(gdk.KEY_Tab): | |
| @@ -1060,7 +1057,48 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||
| 1060 | 1057 | ||
| 1061 | 1058 | // updateGhostFromSelection updates ghost text based on the currently selected row. | |
| 1062 | 1059 | func (o *Omnibox) updateGhostFromSelection() { | |
| 1063 | - | o.updateGhostFromSelectionWithInput(o.entry.GetText()) | |
| 1060 | + | o.mu.RLock() | |
| 1061 | + | realInput := o.realInput | |
| 1062 | + | hasGhost := o.hasGhostText | |
| 1063 | + | o.mu.RUnlock() | |
| 1064 | + | ||
| 1065 | + | entryText := "" | |
| 1066 | + | if o.entry != nil { | |
| 1067 | + | entryText = o.entry.GetText() | |
| 1068 | + | } | |
| 1069 | + | ||
| 1070 | + | query := effectiveSearchQuery(entryText, realInput, hasGhost) | |
| 1071 | + | o.restoreEntryToRealInput() | |
| 1072 | + | o.updateGhostFromSelectionWithInput(query) | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | func (o *Omnibox) restoreEntryToRealInput() { | |
| 1076 | + | if o.entry == nil { | |
| 1077 | + | return | |
| 1078 | + | } | |
| 1079 | + | ||
| 1080 | + | o.mu.RLock() | |
| 1081 | + | realInput := o.realInput | |
| 1082 | + | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 1083 | + | o.mu.RUnlock() | |
| 1084 | + | ||
| 1085 | + | o.mu.Lock() | |
| 1086 | + | o.isAcceptingGhost = true | |
| 1087 | + | o.ghostToken++ | |
| 1088 | + | o.ghostSuffix = "" | |
| 1089 | + | o.ghostFullText = "" | |
| 1090 | + | o.hasGhostText = false | |
| 1091 | + | o.mu.Unlock() | |
| 1092 | + | ||
| 1093 | + | if hadGhost || o.entry.GetText() != realInput { | |
| 1094 | + | o.entry.SetText(realInput) | |
| 1095 | + | } | |
| 1096 | + | o.entry.SelectRegion(-1, -1) | |
| 1097 | + | o.entry.SetPosition(-1) | |
| 1098 | + | ||
| 1099 | + | o.mu.Lock() | |
| 1100 | + | o.isAcceptingGhost = false | |
| 1101 | + | o.mu.Unlock() | |
| 1064 | 1102 | } | |
| 1065 | 1103 | ||
| 1066 | 1104 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | |
| @@ -1091,22 +1129,13 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1091 | 1129 | return | |
| 1092 | 1130 | } | |
| 1093 | 1131 | ||
| 1132 | + | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites) | |
| 1133 | + | ||
| 1094 | 1134 | if entryText != "" { | |
| 1095 | - | o.resolveGhostCompletion(entryText, mode, suggestions, favorites) | |
| 1135 | + | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1096 | 1136 | return | |
| 1097 | 1137 | } | |
| 1098 | 1138 | ||
| 1099 | - | var targetURL string | |
| 1100 | - | if mode == ViewModeHistory { | |
| 1101 | - | if idx >= 0 && idx < len(suggestions) { | |
| 1102 | - | targetURL = suggestions[idx].URL | |
| 1103 | - | } | |
| 1104 | - | } else { | |
| 1105 | - | if idx >= 0 && idx < len(favorites) { | |
| 1106 | - | targetURL = favorites[idx].URL | |
| 1107 | - | } | |
| 1108 | - | } | |
| 1109 | - | ||
| 1110 | 1139 | if targetURL == "" { | |
| 1111 | 1140 | o.clearGhostTextIfInput(entryText) | |
| 1112 | 1141 | return | |
| @@ -1119,52 +1148,34 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1119 | 1148 | o.clearGhostTextIfInput(entryText) | |
| 1120 | 1149 | } | |
| 1121 | 1150 | ||
| 1122 | - | func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) { | |
| 1123 | - | if o.autocompleteUC == nil { | |
| 1124 | - | o.clearGhostTextIfInput(entryText) | |
| 1125 | - | return | |
| 1126 | - | } | |
| 1127 | - | ||
| 1151 | + | func (o *Omnibox) resolveGhostCompletion( | |
| 1152 | + | entryText, selectedURL string, | |
| 1153 | + | hasExplicitSelection bool, | |
| 1154 | + | mode ViewMode, | |
| 1155 | + | suggestions []Suggestion, | |
| 1156 | + | favorites []Favorite, | |
| 1157 | + | ) { | |
| 1128 | 1158 | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |
| 1129 | 1159 | if !ok { | |
| 1130 | 1160 | o.clearGhostTextIfInput(entryText) | |
| 1131 | 1161 | return | |
| 1132 | 1162 | } | |
| 1133 | 1163 | ||
| 1134 | - | visibleURLs := make([]string, 0, len(suggestions)) | |
| 1135 | - | if mode == ViewModeHistory { | |
| 1136 | - | for _, s := range suggestions { | |
| 1137 | - | if s.URL != "" { | |
| 1138 | - | visibleURLs = append(visibleURLs, s.URL) | |
| 1139 | - | } | |
| 1140 | - | } | |
| 1141 | - | } else { | |
| 1142 | - | for _, f := range favorites { | |
| 1143 | - | if f.URL != "" { | |
| 1144 | - | visibleURLs = append(visibleURLs, f.URL) | |
| 1145 | - | } | |
| 1146 | - | } | |
| 1147 | - | } | |
| 1148 | - | ||
| 1149 | 1164 | o.mu.Lock() | |
| 1150 | 1165 | o.ghostToken++ | |
| 1151 | 1166 | ghostToken := o.ghostToken | |
| 1152 | - | searchToken := o.searchToken | |
| 1153 | 1167 | o.mu.Unlock() | |
| 1154 | 1168 | ||
| 1155 | 1169 | go func() { | |
| 1156 | - | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{ | |
| 1157 | - | VisibleURLs: visibleURLs, | |
| 1158 | - | AllowBangs: false, | |
| 1159 | - | }) | |
| 1160 | - | if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) { | |
| 1170 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1171 | + | if !o.isGhostTokenCurrent(ghostToken, entryText) { | |
| 1161 | 1172 | return | |
| 1162 | 1173 | } | |
| 1163 | - | if suggestion == nil { | |
| 1174 | + | if !found { | |
| 1164 | 1175 | o.clearGhostTextIfInput(entryText) | |
| 1165 | 1176 | return | |
| 1166 | 1177 | } | |
| 1167 | - | fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix) | |
| 1178 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | |
| 1168 | 1179 | if fullText == "" || suffix == "" { | |
| 1169 | 1180 | o.clearGhostTextIfInput(entryText) | |
| 1170 | 1181 | return | |
| @@ -1223,11 +1234,54 @@ func extractHostForCompletion(raw string) string { | |||
| 1223 | 1234 | return strings.TrimSpace(trimmed) | |
| 1224 | 1235 | } | |
| 1225 | 1236 | ||
| 1226 | - | func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool { | |
| 1237 | + | func visibleGhostSuggestion( | |
| 1238 | + | query, selectedURL string, | |
| 1239 | + | hasExplicitSelection bool, | |
| 1240 | + | mode ViewMode, | |
| 1241 | + | suggestions []Suggestion, | |
| 1242 | + | favorites []Favorite, | |
| 1243 | + | ) (fullText, suffix string, ok bool) { | |
| 1244 | + | if hasExplicitSelection { | |
| 1245 | + | suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL) | |
| 1246 | + | return fullText, suffix, ok | |
| 1247 | + | } | |
| 1248 | + | ||
| 1249 | + | visibleURLs := visibleURLsForMode(mode, suggestions, favorites) | |
| 1250 | + | suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs) | |
| 1251 | + | return fullText, suffix, ok | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string { | |
| 1255 | + | if mode == ViewModeHistory { | |
| 1256 | + | urls := make([]string, 0, len(suggestions)) | |
| 1257 | + | for _, s := range suggestions { | |
| 1258 | + | if s.URL != "" { | |
| 1259 | + | urls = append(urls, s.URL) | |
| 1260 | + | } | |
| 1261 | + | } | |
| 1262 | + | return urls | |
| 1263 | + | } | |
| 1264 | + | ||
| 1265 | + | urls := make([]string, 0, len(favorites)) | |
| 1266 | + | for _, f := range favorites { | |
| 1267 | + | if f.URL != "" { | |
| 1268 | + | urls = append(urls, f.URL) | |
| 1269 | + | } | |
| 1270 | + | } | |
| 1271 | + | return urls | |
| 1272 | + | } | |
| 1273 | + | ||
| 1274 | + | func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) { | |
| 1275 | + | if idx < 0 { | |
| 1276 | + | return "", false | |
| 1277 | + | } | |
| 1278 | + | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | |
| 1279 | + | } | |
| 1280 | + | ||
| 1281 | + | func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool { | |
| 1227 | 1282 | o.mu.RLock() | |
| 1228 | 1283 | defer o.mu.RUnlock() | |
| 1229 | 1284 | return o.visible && | |
| 1230 | - | o.searchToken == searchToken && | |
| 1231 | 1285 | o.ghostToken == ghostToken && | |
| 1232 | 1286 | o.realInput == query | |
| 1233 | 1287 | } | |
| @@ -1263,8 +1317,8 @@ func (o *Omnibox) performSearch() { | |||
| 1263 | 1317 | o.mu.Unlock() | |
| 1264 | 1318 | ||
| 1265 | 1319 | if strings.HasPrefix(query, "!") { | |
| 1266 | - | o.updateBangDetection(query) | |
| 1267 | - | o.loadBangSuggestions(query) | |
| 1320 | + | o.updateBangDetection(query, token) | |
| 1321 | + | o.loadBangSuggestions(query, token) | |
| 1268 | 1322 | return | |
| 1269 | 1323 | } | |
| 1270 | 1324 | ||
| @@ -1379,8 +1433,18 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||
| 1379 | 1433 | favCh := make(chan map[string]struct{}, 1) | |
| 1380 | 1434 | ||
| 1381 | 1435 | go func() { | |
| 1382 | - | // TODO: Implement GetMostVisited in use case if needed | |
| 1383 | - | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) | |
| 1436 | + | var ( | |
| 1437 | + | results []*entity.HistoryEntry | |
| 1438 | + | err error | |
| 1439 | + | ) | |
| 1440 | + | if o.initialBehavior == "most_visited" { | |
| 1441 | + | results, err = o.historyUC.GetMostVisited(ctx, 0) | |
| 1442 | + | if err == nil && len(results) > initialLimit { | |
| 1443 | + | results = results[:initialLimit] | |
| 1444 | + | } | |
| 1445 | + | } else { | |
| 1446 | + | results, err = o.historyUC.GetRecent(ctx, initialLimit, 0) | |
| 1447 | + | } | |
| 1384 | 1448 | historyCh <- historyResult{results, err} | |
| 1385 | 1449 | }() | |
| 1386 | 1450 | ||
| @@ -1424,23 +1488,14 @@ func (o *Omnibox) loadFavorites(query string, token uint64) { | |||
| 1424 | 1488 | go func() { | |
| 1425 | 1489 | ctx := o.ctx | |
| 1426 | 1490 | log := logging.FromContext(ctx) | |
| 1427 | - | results, err := o.favoritesUC.GetAll(ctx) | |
| 1491 | + | results, err := o.favoritesUC.FilterForOmnibox(ctx, query) | |
| 1428 | 1492 | if err != nil { | |
| 1429 | 1493 | log.Error().Err(err).Msg("failed to load favorites") | |
| 1430 | 1494 | return | |
| 1431 | 1495 | } | |
| 1432 | 1496 | ||
| 1433 | 1497 | favorites := make([]Favorite, 0, len(results)) | |
| 1434 | - | queryLower := strings.ToLower(query) | |
| 1435 | 1498 | for i, r := range results { | |
| 1436 | - | // Filter by query if provided | |
| 1437 | - | if query != "" { | |
| 1438 | - | titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower) | |
| 1439 | - | urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower) | |
| 1440 | - | if !titleMatch && !urlMatch { | |
| 1441 | - | continue | |
| 1442 | - | } | |
| 1443 | - | } | |
| 1444 | 1499 | favorites = append(favorites, Favorite{ | |
| 1445 | 1500 | ID: int64(r.ID), | |
| 1446 | 1501 | URL: r.URL, | |
| @@ -1453,7 +1508,7 @@ func (o *Omnibox) loadFavorites(query string, token uint64) { | |||
| 1453 | 1508 | }() | |
| 1454 | 1509 | } | |
| 1455 | 1510 | ||
| 1456 | - | func (o *Omnibox) loadBangSuggestions(query string) { | |
| 1511 | + | func (o *Omnibox) loadBangSuggestions(query string, token uint64) { | |
| 1457 | 1512 | if o.shortcutsUC == nil { | |
| 1458 | 1513 | return | |
| 1459 | 1514 | } | |
| @@ -1462,21 +1517,21 @@ func (o *Omnibox) loadBangSuggestions(query string) { | |||
| 1462 | 1517 | for i, s := range output.Suggestions { | |
| 1463 | 1518 | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | |
| 1464 | 1519 | } | |
| 1465 | - | o.idleAddUpdateBangSuggestions(suggestions) | |
| 1520 | + | o.idleAddUpdateBangSuggestions(suggestions, query, token) | |
| 1466 | 1521 | } | |
| 1467 | 1522 | ||
| 1468 | - | func (o *Omnibox) updateBangDetection(query string) { | |
| 1523 | + | func (o *Omnibox) updateBangDetection(query string, token uint64) { | |
| 1469 | 1524 | if o.shortcutsUC == nil { | |
| 1470 | - | o.clearDetectedBang() | |
| 1525 | + | o.clearDetectedBang(query, token) | |
| 1471 | 1526 | return | |
| 1472 | 1527 | } | |
| 1473 | 1528 | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | |
| 1474 | 1529 | if output.Key == "" { | |
| 1475 | - | o.clearDetectedBang() | |
| 1530 | + | o.clearDetectedBang(query, token) | |
| 1476 | 1531 | return | |
| 1477 | 1532 | } | |
| 1478 | 1533 | ||
| 1479 | - | o.setDetectedBang(output.Key, output.Description) | |
| 1534 | + | o.setDetectedBang(output.Key, output.Description, query, token) | |
| 1480 | 1535 | } | |
| 1481 | 1536 | ||
| 1482 | 1537 | func (o *Omnibox) clearBangState() { | |
| @@ -1485,10 +1540,14 @@ func (o *Omnibox) clearBangState() { | |||
| 1485 | 1540 | o.bangSuggestions = nil | |
| 1486 | 1541 | o.mu.Unlock() | |
| 1487 | 1542 | ||
| 1488 | - | o.clearDetectedBang() | |
| 1543 | + | o.mu.RLock() | |
| 1544 | + | query := o.realInput | |
| 1545 | + | token := o.searchToken | |
| 1546 | + | o.mu.RUnlock() | |
| 1547 | + | o.clearDetectedBang(query, token) | |
| 1489 | 1548 | } | |
| 1490 | 1549 | ||
| 1491 | - | func (o *Omnibox) setDetectedBang(key, description string) { | |
| 1550 | + | func (o *Omnibox) setDetectedBang(key, description, query string, token uint64) { | |
| 1492 | 1551 | o.mu.Lock() | |
| 1493 | 1552 | o.detectedBang = key | |
| 1494 | 1553 | o.mu.Unlock() | |
| @@ -1498,6 +1557,9 @@ func (o *Omnibox) setDetectedBang(key, description string) { | |||
| 1498 | 1557 | } | |
| 1499 | 1558 | label := description | |
| 1500 | 1559 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 1560 | + | if !o.isBangUpdateCurrent(query, token) { | |
| 1561 | + | return false | |
| 1562 | + | } | |
| 1501 | 1563 | o.entry.AddCssClass("omnibox-entry-bang-active") | |
| 1502 | 1564 | o.bangBadge.SetText(label) | |
| 1503 | 1565 | o.bangBadge.SetVisible(true) | |
| @@ -1506,7 +1568,7 @@ func (o *Omnibox) setDetectedBang(key, description string) { | |||
| 1506 | 1568 | glib.IdleAdd(&cb, 0) | |
| 1507 | 1569 | } | |
| 1508 | 1570 | ||
| 1509 | - | func (o *Omnibox) clearDetectedBang() { | |
| 1571 | + | func (o *Omnibox) clearDetectedBang(query string, token uint64) { | |
| 1510 | 1572 | o.mu.Lock() | |
| 1511 | 1573 | o.detectedBang = "" | |
| 1512 | 1574 | o.mu.Unlock() | |
| @@ -1515,6 +1577,9 @@ func (o *Omnibox) clearDetectedBang() { | |||
| 1515 | 1577 | return | |
| 1516 | 1578 | } | |
| 1517 | 1579 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 1580 | + | if !o.isBangUpdateCurrent(query, token) { | |
| 1581 | + | return false | |
| 1582 | + | } | |
| 1518 | 1583 | o.entry.RemoveCssClass("omnibox-entry-bang-active") | |
| 1519 | 1584 | o.bangBadge.SetVisible(false) | |
| 1520 | 1585 | return false | |
| @@ -1522,8 +1587,11 @@ func (o *Omnibox) clearDetectedBang() { | |||
| 1522 | 1587 | glib.IdleAdd(&cb, 0) | |
| 1523 | 1588 | } | |
| 1524 | 1589 | ||
| 1525 | - | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | |
| 1590 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion, query string, token uint64) { | |
| 1526 | 1591 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 1592 | + | if !o.isBangUpdateCurrent(query, token) { | |
| 1593 | + | return false | |
| 1594 | + | } | |
| 1527 | 1595 | o.updateBangSuggestions(suggestions) | |
| 1528 | 1596 | return false | |
| 1529 | 1597 | } | |
| @@ -1535,6 +1603,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 1535 | 1603 | o.bangMode = true | |
| 1536 | 1604 | o.bangSuggestions = suggestions | |
| 1537 | 1605 | o.selectedIndex = -1 | |
| 1606 | + | o.hasNavigated = false | |
| 1538 | 1607 | o.mu.Unlock() | |
| 1539 | 1608 | ||
| 1540 | 1609 | o.rebuildList() | |
| @@ -1544,10 +1613,6 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 1544 | 1613 | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1545 | 1614 | } | |
| 1546 | 1615 | o.resizeAndCenter(rowCount) | |
| 1547 | - | ||
| 1548 | - | if rowCount > 0 { | |
| 1549 | - | o.selectIndex(0) | |
| 1550 | - | } | |
| 1551 | 1616 | } | |
| 1552 | 1617 | ||
| 1553 | 1618 | // updateSuggestions updates the list with history suggestions. | |
| @@ -1569,9 +1634,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||
| 1569 | 1634 | } | |
| 1570 | 1635 | o.resizeAndCenter(rowCount) | |
| 1571 | 1636 | ||
| 1572 | - | // Select first item if available and update ghost text | |
| 1573 | 1637 | if rowCount > 0 { | |
| 1574 | - | o.selectIndex(0) | |
| 1575 | 1638 | o.updateGhostFromSelectionWithInput(query) | |
| 1576 | 1639 | } else { | |
| 1577 | 1640 | o.clearGhostTextIfInput(query) | |
| @@ -1597,9 +1660,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||
| 1597 | 1660 | } | |
| 1598 | 1661 | o.resizeAndCenter(rowCount) | |
| 1599 | 1662 | ||
| 1600 | - | // Select first item if available and update ghost text | |
| 1601 | 1663 | if rowCount > 0 { | |
| 1602 | - | o.selectIndex(0) | |
| 1603 | 1664 | o.updateGhostFromSelectionWithInput(query) | |
| 1604 | 1665 | } else { | |
| 1605 | 1666 | o.clearGhostTextIfInput(query) | |
| @@ -1713,6 +1774,7 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||
| 1713 | 1774 | return nil | |
| 1714 | 1775 | } | |
| 1715 | 1776 | row.AddCssClass("omnibox-row") | |
| 1777 | + | o.attachRowHoverSelection(row, index) | |
| 1716 | 1778 | ||
| 1717 | 1779 | const rowSpacing = 8 | |
| 1718 | 1780 | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |
| @@ -1783,6 +1845,20 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||
| 1783 | 1845 | return row | |
| 1784 | 1846 | } | |
| 1785 | 1847 | ||
| 1848 | + | func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) { | |
| 1849 | + | motionCtrl := gtk.NewEventControllerMotion() | |
| 1850 | + | if motionCtrl == nil { | |
| 1851 | + | return | |
| 1852 | + | } | |
| 1853 | + | ||
| 1854 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 1855 | + | o.selectIndex(index) | |
| 1856 | + | } | |
| 1857 | + | o.retainedCallbacks = append(o.retainedCallbacks, enterCb) | |
| 1858 | + | motionCtrl.ConnectEnter(&enterCb) | |
| 1859 | + | row.AddController(&motionCtrl.EventController) | |
| 1860 | + | } | |
| 1861 | + | ||
| 1786 | 1862 | // createSuggestionRow creates a ListBoxRow for a suggestion. | |
| 1787 | 1863 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | |
| 1788 | 1864 | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | |
| @@ -1809,13 +1885,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |||
| 1809 | 1885 | ||
| 1810 | 1886 | // selectIndex selects a row by index. | |
| 1811 | 1887 | func (o *Omnibox) selectIndex(index int) { | |
| 1888 | + | o.mu.Lock() | |
| 1889 | + | o.selectedIndex = index | |
| 1890 | + | o.mu.Unlock() | |
| 1891 | + | ||
| 1812 | 1892 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | |
| 1813 | 1893 | // which also acquires the mutex, causing deadlock | |
| 1814 | 1894 | row := o.listBox.GetRowAtIndex(index) | |
| 1815 | 1895 | if row != nil { | |
| 1816 | 1896 | o.listBox.SelectRow(row) | |
| 1817 | - | // selectedIndex is updated by rowSelectedCb | |
| 1897 | + | return | |
| 1818 | 1898 | } | |
| 1899 | + | ||
| 1900 | + | o.updateGhostFromSelection() | |
| 1819 | 1901 | } | |
| 1820 | 1902 | ||
| 1821 | 1903 | // selectNext moves selection down. | |
| @@ -1938,16 +2020,14 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1938 | 2020 | o.entry.SetPosition(-1) | |
| 1939 | 2021 | return | |
| 1940 | 2022 | } | |
| 2023 | + | return | |
| 1941 | 2024 | } | |
| 1942 | 2025 | ||
| 1943 | 2026 | var targetURL string | |
| 1944 | - | if shouldPreferTypedURLNavigation(entryText) { | |
| 1945 | - | targetURL = o.buildURL(entryText) | |
| 1946 | - | } else if idx >= 0 { | |
| 2027 | + | if idx >= 0 { | |
| 1947 | 2028 | // If user has selected a result, navigate to that result. | |
| 1948 | 2029 | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | |
| 1949 | 2030 | } else { | |
| 1950 | - | // No selection - use entry text as URL/search. | |
| 1951 | 2031 | targetURL = o.buildURL(entryText) | |
| 1952 | 2032 | } | |
| 1953 | 2033 | ||
| @@ -2410,6 +2490,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool { | |||
| 2410 | 2490 | return o.visible && o.searchToken == token | |
| 2411 | 2491 | } | |
| 2412 | 2492 | ||
| 2493 | + | func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool { | |
| 2494 | + | o.mu.RLock() | |
| 2495 | + | defer o.mu.RUnlock() | |
| 2496 | + | return o.visible && o.searchToken == token && o.realInput == query | |
| 2497 | + | } | |
| 2498 | + | ||
| 2413 | 2499 | // Widget returns the omnibox widget for embedding in an overlay. | |
| 2414 | 2500 | func (o *Omnibox) Widget() *gtk.Widget { | |
| 2415 | 2501 | if o.outerBox == nil { | |
| @@ -106,7 +106,6 @@ type Omnibox struct { | |||||
| 106 | faviconAdapter *adapter.FaviconAdapter | 106 | faviconAdapter *adapter.FaviconAdapter | ||
| 107 | copyURLUC *usecase.CopyURLUseCase | 107 | copyURLUC *usecase.CopyURLUseCase | ||
| 108 | shortcutsUC *usecase.SearchShortcutsUseCase | 108 | shortcutsUC *usecase.SearchShortcutsUseCase | ||
| 109 | - | autocompleteUC *usecase.AutocompleteUseCase | |||
| 110 | defaultSearch string | 109 | defaultSearch string | ||
| 111 | initialBehavior string | 110 | initialBehavior string | ||
| 112 | ctx context.Context | 111 | ctx context.Context | ||
| @@ -150,7 +149,6 @@ type OmniboxConfig struct { | |||||
| 150 | FaviconAdapter *adapter.FaviconAdapter | 149 | FaviconAdapter *adapter.FaviconAdapter | ||
| 151 | CopyURLUC *usecase.CopyURLUseCase | 150 | CopyURLUC *usecase.CopyURLUseCase | ||
| 152 | ShortcutsUC *usecase.SearchShortcutsUseCase | 151 | ShortcutsUC *usecase.SearchShortcutsUseCase | ||
| 153 | - | AutocompleteUC *usecase.AutocompleteUseCase | |||
| 154 | DefaultSearch string | 152 | DefaultSearch string | ||
| 155 | InitialBehavior string | 153 | InitialBehavior string | ||
| 156 | UIScale float64 // UI scale for favicon sizing | 154 | UIScale float64 // UI scale for favicon sizing | ||
| @@ -178,7 +176,6 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 178 | faviconAdapter: cfg.FaviconAdapter, | 176 | faviconAdapter: cfg.FaviconAdapter, | ||
| 179 | copyURLUC: cfg.CopyURLUC, | 177 | copyURLUC: cfg.CopyURLUC, | ||
| 180 | shortcutsUC: cfg.ShortcutsUC, | 178 | shortcutsUC: cfg.ShortcutsUC, | ||
| 181 | - | autocompleteUC: cfg.AutocompleteUC, | |||
| 182 | defaultSearch: cfg.DefaultSearch, | 179 | defaultSearch: cfg.DefaultSearch, | ||
| 183 | initialBehavior: cfg.InitialBehavior, | 180 | initialBehavior: cfg.InitialBehavior, | ||
| 184 | onToast: cfg.OnToast, | 181 | onToast: cfg.OnToast, | ||
| @@ -540,18 +537,20 @@ func (o *Omnibox) initList() error { | |||||
| 540 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 537 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 541 | 538 | ||||
| 542 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 539 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | ||
| 540 | + | o.restoreEntryToRealInput() | |||
| 543 | if rowPtr == 0 { | 541 | if rowPtr == 0 { | ||
| 544 | o.mu.Lock() | 542 | o.mu.Lock() | ||
| 545 | o.selectedIndex = -1 | 543 | o.selectedIndex = -1 | ||
| 546 | o.mu.Unlock() | 544 | o.mu.Unlock() | ||
| 547 | - | return | 545 | + | } else { |
| 548 | - | } | 546 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) |
| 549 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | 547 | + | if row != nil { |
| 550 | - | if row != nil { | 548 | + | o.mu.Lock() |
| 551 | - | o.mu.Lock() | 549 | + | o.selectedIndex = row.GetIndex() |
| 552 | - | o.selectedIndex = row.GetIndex() | 550 | + | o.mu.Unlock() |
| 553 | - | o.mu.Unlock() | 551 | + | } |
| 554 | } | 552 | } | ||
| 553 | + | o.updateGhostFromSelection() | |||
| 555 | } | 554 | } | ||
| 556 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | 555 | o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb) | ||
| 557 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 556 | o.listBox.ConnectRowSelected(&rowSelectedCb) | ||
| @@ -700,12 +699,10 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 700 | 699 | ||||
| 701 | case uint(gdk.KEY_Up): | 700 | case uint(gdk.KEY_Up): | ||
| 702 | o.selectPrevious() | 701 | o.selectPrevious() | ||
| 703 | - | o.updateGhostFromSelection() | |||
| 704 | return true | 702 | return true | ||
| 705 | 703 | ||||
| 706 | case uint(gdk.KEY_Down): | 704 | case uint(gdk.KEY_Down): | ||
| 707 | o.selectNext() | 705 | o.selectNext() | ||
| 708 | - | o.updateGhostFromSelection() | |||
| 709 | return true | 706 | return true | ||
| 710 | 707 | ||||
| 711 | case uint(gdk.KEY_Tab): | 708 | case uint(gdk.KEY_Tab): | ||
| @@ -1060,7 +1057,48 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||||
| 1060 | 1057 | ||||
| 1061 | // updateGhostFromSelection updates ghost text based on the currently selected row. | 1058 | // updateGhostFromSelection updates ghost text based on the currently selected row. | ||
| 1062 | func (o *Omnibox) updateGhostFromSelection() { | 1059 | func (o *Omnibox) updateGhostFromSelection() { | ||
| 1063 | - | o.updateGhostFromSelectionWithInput(o.entry.GetText()) | 1060 | + | o.mu.RLock() |
| 1061 | + | realInput := o.realInput | |||
| 1062 | + | hasGhost := o.hasGhostText | |||
| 1063 | + | o.mu.RUnlock() | |||
| 1064 | + | ||||
| 1065 | + | entryText := "" | |||
| 1066 | + | if o.entry != nil { | |||
| 1067 | + | entryText = o.entry.GetText() | |||
| 1068 | + | } | |||
| 1069 | + | ||||
| 1070 | + | query := effectiveSearchQuery(entryText, realInput, hasGhost) | |||
| 1071 | + | o.restoreEntryToRealInput() | |||
| 1072 | + | o.updateGhostFromSelectionWithInput(query) | |||
| 1073 | + | } | |||
| 1074 | + | ||||
| 1075 | + | func (o *Omnibox) restoreEntryToRealInput() { | |||
| 1076 | + | if o.entry == nil { | |||
| 1077 | + | return | |||
| 1078 | + | } | |||
| 1079 | + | ||||
| 1080 | + | o.mu.RLock() | |||
| 1081 | + | realInput := o.realInput | |||
| 1082 | + | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |||
| 1083 | + | o.mu.RUnlock() | |||
| 1084 | + | ||||
| 1085 | + | o.mu.Lock() | |||
| 1086 | + | o.isAcceptingGhost = true | |||
| 1087 | + | o.ghostToken++ | |||
| 1088 | + | o.ghostSuffix = "" | |||
| 1089 | + | o.ghostFullText = "" | |||
| 1090 | + | o.hasGhostText = false | |||
| 1091 | + | o.mu.Unlock() | |||
| 1092 | + | ||||
| 1093 | + | if hadGhost || o.entry.GetText() != realInput { | |||
| 1094 | + | o.entry.SetText(realInput) | |||
| 1095 | + | } | |||
| 1096 | + | o.entry.SelectRegion(-1, -1) | |||
| 1097 | + | o.entry.SetPosition(-1) | |||
| 1098 | + | ||||
| 1099 | + | o.mu.Lock() | |||
| 1100 | + | o.isAcceptingGhost = false | |||
| 1101 | + | o.mu.Unlock() | |||
| 1064 | } | 1102 | } | ||
| 1065 | 1103 | ||||
| 1066 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | 1104 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | ||
| @@ -1091,22 +1129,13 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1091 | return | 1129 | return | ||
| 1092 | } | 1130 | } | ||
| 1093 | 1131 | ||||
| 1132 | + | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites) | |||
| 1133 | + | ||||
| 1094 | if entryText != "" { | 1134 | if entryText != "" { | ||
| 1095 | - | o.resolveGhostCompletion(entryText, mode, suggestions, favorites) | 1135 | + | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites) |
| 1096 | return | 1136 | return | ||
| 1097 | } | 1137 | } | ||
| 1098 | 1138 | ||||
| 1099 | - | var targetURL string | |||
| 1100 | - | if mode == ViewModeHistory { | |||
| 1101 | - | if idx >= 0 && idx < len(suggestions) { | |||
| 1102 | - | targetURL = suggestions[idx].URL | |||
| 1103 | - | } | |||
| 1104 | - | } else { | |||
| 1105 | - | if idx >= 0 && idx < len(favorites) { | |||
| 1106 | - | targetURL = favorites[idx].URL | |||
| 1107 | - | } | |||
| 1108 | - | } | |||
| 1109 | - | ||||
| 1110 | if targetURL == "" { | 1139 | if targetURL == "" { | ||
| 1111 | o.clearGhostTextIfInput(entryText) | 1140 | o.clearGhostTextIfInput(entryText) | ||
| 1112 | return | 1141 | return | ||
| @@ -1119,52 +1148,34 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1119 | o.clearGhostTextIfInput(entryText) | 1148 | o.clearGhostTextIfInput(entryText) | ||
| 1120 | } | 1149 | } | ||
| 1121 | 1150 | ||||
| 1122 | - | func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) { | 1151 | + | func (o *Omnibox) resolveGhostCompletion( |
| 1123 | - | if o.autocompleteUC == nil { | 1152 | + | entryText, selectedURL string, |
| 1124 | - | o.clearGhostTextIfInput(entryText) | 1153 | + | hasExplicitSelection bool, |
| 1125 | - | return | 1154 | + | mode ViewMode, |
| 1126 | - | } | 1155 | + | suggestions []Suggestion, |
| 1127 | - | 1156 | + | favorites []Favorite, | |
| 1157 | + | ) { | |||
| 1128 | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | 1158 | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | ||
| 1129 | if !ok { | 1159 | if !ok { | ||
| 1130 | o.clearGhostTextIfInput(entryText) | 1160 | o.clearGhostTextIfInput(entryText) | ||
| 1131 | return | 1161 | return | ||
| 1132 | } | 1162 | } | ||
| 1133 | 1163 | ||||
| 1134 | - | visibleURLs := make([]string, 0, len(suggestions)) | |||
| 1135 | - | if mode == ViewModeHistory { | |||
| 1136 | - | for _, s := range suggestions { | |||
| 1137 | - | if s.URL != "" { | |||
| 1138 | - | visibleURLs = append(visibleURLs, s.URL) | |||
| 1139 | - | } | |||
| 1140 | - | } | |||
| 1141 | - | } else { | |||
| 1142 | - | for _, f := range favorites { | |||
| 1143 | - | if f.URL != "" { | |||
| 1144 | - | visibleURLs = append(visibleURLs, f.URL) | |||
| 1145 | - | } | |||
| 1146 | - | } | |||
| 1147 | - | } | |||
| 1148 | - | ||||
| 1149 | o.mu.Lock() | 1164 | o.mu.Lock() | ||
| 1150 | o.ghostToken++ | 1165 | o.ghostToken++ | ||
| 1151 | ghostToken := o.ghostToken | 1166 | ghostToken := o.ghostToken | ||
| 1152 | - | searchToken := o.searchToken | |||
| 1153 | o.mu.Unlock() | 1167 | o.mu.Unlock() | ||
| 1154 | 1168 | ||||
| 1155 | go func() { | 1169 | go func() { | ||
| 1156 | - | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{ | 1170 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) |
| 1157 | - | VisibleURLs: visibleURLs, | 1171 | + | if !o.isGhostTokenCurrent(ghostToken, entryText) { |
| 1158 | - | AllowBangs: false, | |||
| 1159 | - | }) | |||
| 1160 | - | if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) { | |||
| 1161 | return | 1172 | return | ||
| 1162 | } | 1173 | } | ||
| 1163 | - | if suggestion == nil { | 1174 | + | if !found { |
| 1164 | o.clearGhostTextIfInput(entryText) | 1175 | o.clearGhostTextIfInput(entryText) | ||
| 1165 | return | 1176 | return | ||
| 1166 | } | 1177 | } | ||
| 1167 | - | fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix) | 1178 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) |
| 1168 | if fullText == "" || suffix == "" { | 1179 | if fullText == "" || suffix == "" { | ||
| 1169 | o.clearGhostTextIfInput(entryText) | 1180 | o.clearGhostTextIfInput(entryText) | ||
| 1170 | return | 1181 | return | ||
| @@ -1223,11 +1234,54 @@ func extractHostForCompletion(raw string) string { | |||||
| 1223 | return strings.TrimSpace(trimmed) | 1234 | return strings.TrimSpace(trimmed) | ||
| 1224 | } | 1235 | } | ||
| 1225 | 1236 | ||||
| 1226 | - | func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool { | 1237 | + | func visibleGhostSuggestion( |
| 1238 | + | query, selectedURL string, | |||
| 1239 | + | hasExplicitSelection bool, | |||
| 1240 | + | mode ViewMode, | |||
| 1241 | + | suggestions []Suggestion, | |||
| 1242 | + | favorites []Favorite, | |||
| 1243 | + | ) (fullText, suffix string, ok bool) { | |||
| 1244 | + | if hasExplicitSelection { | |||
| 1245 | + | suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL) | |||
| 1246 | + | return fullText, suffix, ok | |||
| 1247 | + | } | |||
| 1248 | + | ||||
| 1249 | + | visibleURLs := visibleURLsForMode(mode, suggestions, favorites) | |||
| 1250 | + | suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs) | |||
| 1251 | + | return fullText, suffix, ok | |||
| 1252 | + | } | |||
| 1253 | + | ||||
| 1254 | + | func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string { | |||
| 1255 | + | if mode == ViewModeHistory { | |||
| 1256 | + | urls := make([]string, 0, len(suggestions)) | |||
| 1257 | + | for _, s := range suggestions { | |||
| 1258 | + | if s.URL != "" { | |||
| 1259 | + | urls = append(urls, s.URL) | |||
| 1260 | + | } | |||
| 1261 | + | } | |||
| 1262 | + | return urls | |||
| 1263 | + | } | |||
| 1264 | + | ||||
| 1265 | + | urls := make([]string, 0, len(favorites)) | |||
| 1266 | + | for _, f := range favorites { | |||
| 1267 | + | if f.URL != "" { | |||
| 1268 | + | urls = append(urls, f.URL) | |||
| 1269 | + | } | |||
| 1270 | + | } | |||
| 1271 | + | return urls | |||
| 1272 | + | } | |||
| 1273 | + | ||||
| 1274 | + | func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) { | |||
| 1275 | + | if idx < 0 { | |||
| 1276 | + | return "", false | |||
| 1277 | + | } | |||
| 1278 | + | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | |||
| 1279 | + | } | |||
| 1280 | + | ||||
| 1281 | + | func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool { | |||
| 1227 | o.mu.RLock() | 1282 | o.mu.RLock() | ||
| 1228 | defer o.mu.RUnlock() | 1283 | defer o.mu.RUnlock() | ||
| 1229 | return o.visible && | 1284 | return o.visible && | ||
| 1230 | - | o.searchToken == searchToken && | |||
| 1231 | o.ghostToken == ghostToken && | 1285 | o.ghostToken == ghostToken && | ||
| 1232 | o.realInput == query | 1286 | o.realInput == query | ||
| 1233 | } | 1287 | } | ||
| @@ -1263,8 +1317,8 @@ func (o *Omnibox) performSearch() { | |||||
| 1263 | o.mu.Unlock() | 1317 | o.mu.Unlock() | ||
| 1264 | 1318 | ||||
| 1265 | if strings.HasPrefix(query, "!") { | 1319 | if strings.HasPrefix(query, "!") { | ||
| 1266 | - | o.updateBangDetection(query) | 1320 | + | o.updateBangDetection(query, token) |
| 1267 | - | o.loadBangSuggestions(query) | 1321 | + | o.loadBangSuggestions(query, token) |
| 1268 | return | 1322 | return | ||
| 1269 | } | 1323 | } | ||
| 1270 | 1324 | ||||
| @@ -1379,8 +1433,18 @@ func (o *Omnibox) loadInitialHistory(token uint64) { | |||||
| 1379 | favCh := make(chan map[string]struct{}, 1) | 1433 | favCh := make(chan map[string]struct{}, 1) | ||
| 1380 | 1434 | ||||
| 1381 | go func() { | 1435 | go func() { | ||
| 1382 | - | // TODO: Implement GetMostVisited in use case if needed | 1436 | + | var ( |
| 1383 | - | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) | 1437 | + | results []*entity.HistoryEntry |
| 1438 | + | err error | |||
| 1439 | + | ) | |||
| 1440 | + | if o.initialBehavior == "most_visited" { | |||
| 1441 | + | results, err = o.historyUC.GetMostVisited(ctx, 0) | |||
| 1442 | + | if err == nil && len(results) > initialLimit { | |||
| 1443 | + | results = results[:initialLimit] | |||
| 1444 | + | } | |||
| 1445 | + | } else { | |||
| 1446 | + | results, err = o.historyUC.GetRecent(ctx, initialLimit, 0) | |||
| 1447 | + | } | |||
| 1384 | historyCh <- historyResult{results, err} | 1448 | historyCh <- historyResult{results, err} | ||
| 1385 | }() | 1449 | }() | ||
| 1386 | 1450 | ||||
| @@ -1424,23 +1488,14 @@ func (o *Omnibox) loadFavorites(query string, token uint64) { | |||||
| 1424 | go func() { | 1488 | go func() { | ||
| 1425 | ctx := o.ctx | 1489 | ctx := o.ctx | ||
| 1426 | log := logging.FromContext(ctx) | 1490 | log := logging.FromContext(ctx) | ||
| 1427 | - | results, err := o.favoritesUC.GetAll(ctx) | 1491 | + | results, err := o.favoritesUC.FilterForOmnibox(ctx, query) |
| 1428 | if err != nil { | 1492 | if err != nil { | ||
| 1429 | log.Error().Err(err).Msg("failed to load favorites") | 1493 | log.Error().Err(err).Msg("failed to load favorites") | ||
| 1430 | return | 1494 | return | ||
| 1431 | } | 1495 | } | ||
| 1432 | 1496 | ||||
| 1433 | favorites := make([]Favorite, 0, len(results)) | 1497 | favorites := make([]Favorite, 0, len(results)) | ||
| 1434 | - | queryLower := strings.ToLower(query) | |||
| 1435 | for i, r := range results { | 1498 | for i, r := range results { | ||
| 1436 | - | // Filter by query if provided | |||
| 1437 | - | if query != "" { | |||
| 1438 | - | titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower) | |||
| 1439 | - | urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower) | |||
| 1440 | - | if !titleMatch && !urlMatch { | |||
| 1441 | - | continue | |||
| 1442 | - | } | |||
| 1443 | - | } | |||
| 1444 | favorites = append(favorites, Favorite{ | 1499 | favorites = append(favorites, Favorite{ | ||
| 1445 | ID: int64(r.ID), | 1500 | ID: int64(r.ID), | ||
| 1446 | URL: r.URL, | 1501 | URL: r.URL, | ||
| @@ -1453,7 +1508,7 @@ func (o *Omnibox) loadFavorites(query string, token uint64) { | |||||
| 1453 | }() | 1508 | }() | ||
| 1454 | } | 1509 | } | ||
| 1455 | 1510 | ||||
| 1456 | - | func (o *Omnibox) loadBangSuggestions(query string) { | 1511 | + | func (o *Omnibox) loadBangSuggestions(query string, token uint64) { |
| 1457 | if o.shortcutsUC == nil { | 1512 | if o.shortcutsUC == nil { | ||
| 1458 | return | 1513 | return | ||
| 1459 | } | 1514 | } | ||
| @@ -1462,21 +1517,21 @@ func (o *Omnibox) loadBangSuggestions(query string) { | |||||
| 1462 | for i, s := range output.Suggestions { | 1517 | for i, s := range output.Suggestions { | ||
| 1463 | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | 1518 | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | ||
| 1464 | } | 1519 | } | ||
| 1465 | - | o.idleAddUpdateBangSuggestions(suggestions) | 1520 | + | o.idleAddUpdateBangSuggestions(suggestions, query, token) |
| 1466 | } | 1521 | } | ||
| 1467 | 1522 | ||||
| 1468 | - | func (o *Omnibox) updateBangDetection(query string) { | 1523 | + | func (o *Omnibox) updateBangDetection(query string, token uint64) { |
| 1469 | if o.shortcutsUC == nil { | 1524 | if o.shortcutsUC == nil { | ||
| 1470 | - | o.clearDetectedBang() | 1525 | + | o.clearDetectedBang(query, token) |
| 1471 | return | 1526 | return | ||
| 1472 | } | 1527 | } | ||
| 1473 | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | 1528 | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | ||
| 1474 | if output.Key == "" { | 1529 | if output.Key == "" { | ||
| 1475 | - | o.clearDetectedBang() | 1530 | + | o.clearDetectedBang(query, token) |
| 1476 | return | 1531 | return | ||
| 1477 | } | 1532 | } | ||
| 1478 | 1533 | ||||
| 1479 | - | o.setDetectedBang(output.Key, output.Description) | 1534 | + | o.setDetectedBang(output.Key, output.Description, query, token) |
| 1480 | } | 1535 | } | ||
| 1481 | 1536 | ||||
| 1482 | func (o *Omnibox) clearBangState() { | 1537 | func (o *Omnibox) clearBangState() { | ||
| @@ -1485,10 +1540,14 @@ func (o *Omnibox) clearBangState() { | |||||
| 1485 | o.bangSuggestions = nil | 1540 | o.bangSuggestions = nil | ||
| 1486 | o.mu.Unlock() | 1541 | o.mu.Unlock() | ||
| 1487 | 1542 | ||||
| 1488 | - | o.clearDetectedBang() | 1543 | + | o.mu.RLock() |
| 1544 | + | query := o.realInput | |||
| 1545 | + | token := o.searchToken | |||
| 1546 | + | o.mu.RUnlock() | |||
| 1547 | + | o.clearDetectedBang(query, token) | |||
| 1489 | } | 1548 | } | ||
| 1490 | 1549 | ||||
| 1491 | - | func (o *Omnibox) setDetectedBang(key, description string) { | 1550 | + | func (o *Omnibox) setDetectedBang(key, description, query string, token uint64) { |
| 1492 | o.mu.Lock() | 1551 | o.mu.Lock() | ||
| 1493 | o.detectedBang = key | 1552 | o.detectedBang = key | ||
| 1494 | o.mu.Unlock() | 1553 | o.mu.Unlock() | ||
| @@ -1498,6 +1557,9 @@ func (o *Omnibox) setDetectedBang(key, description string) { | |||||
| 1498 | } | 1557 | } | ||
| 1499 | label := description | 1558 | label := description | ||
| 1500 | var cb glib.SourceFunc = func(uintptr) bool { | 1559 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 1560 | + | if !o.isBangUpdateCurrent(query, token) { | |||
| 1561 | + | return false | |||
| 1562 | + | } | |||
| 1501 | o.entry.AddCssClass("omnibox-entry-bang-active") | 1563 | o.entry.AddCssClass("omnibox-entry-bang-active") | ||
| 1502 | o.bangBadge.SetText(label) | 1564 | o.bangBadge.SetText(label) | ||
| 1503 | o.bangBadge.SetVisible(true) | 1565 | o.bangBadge.SetVisible(true) | ||
| @@ -1506,7 +1568,7 @@ func (o *Omnibox) setDetectedBang(key, description string) { | |||||
| 1506 | glib.IdleAdd(&cb, 0) | 1568 | glib.IdleAdd(&cb, 0) | ||
| 1507 | } | 1569 | } | ||
| 1508 | 1570 | ||||
| 1509 | - | func (o *Omnibox) clearDetectedBang() { | 1571 | + | func (o *Omnibox) clearDetectedBang(query string, token uint64) { |
| 1510 | o.mu.Lock() | 1572 | o.mu.Lock() | ||
| 1511 | o.detectedBang = "" | 1573 | o.detectedBang = "" | ||
| 1512 | o.mu.Unlock() | 1574 | o.mu.Unlock() | ||
| @@ -1515,6 +1577,9 @@ func (o *Omnibox) clearDetectedBang() { | |||||
| 1515 | return | 1577 | return | ||
| 1516 | } | 1578 | } | ||
| 1517 | var cb glib.SourceFunc = func(uintptr) bool { | 1579 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 1580 | + | if !o.isBangUpdateCurrent(query, token) { | |||
| 1581 | + | return false | |||
| 1582 | + | } | |||
| 1518 | o.entry.RemoveCssClass("omnibox-entry-bang-active") | 1583 | o.entry.RemoveCssClass("omnibox-entry-bang-active") | ||
| 1519 | o.bangBadge.SetVisible(false) | 1584 | o.bangBadge.SetVisible(false) | ||
| 1520 | return false | 1585 | return false | ||
| @@ -1522,8 +1587,11 @@ func (o *Omnibox) clearDetectedBang() { | |||||
| 1522 | glib.IdleAdd(&cb, 0) | 1587 | glib.IdleAdd(&cb, 0) | ||
| 1523 | } | 1588 | } | ||
| 1524 | 1589 | ||||
| 1525 | - | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | 1590 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion, query string, token uint64) { |
| 1526 | var cb glib.SourceFunc = func(uintptr) bool { | 1591 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 1592 | + | if !o.isBangUpdateCurrent(query, token) { | |||
| 1593 | + | return false | |||
| 1594 | + | } | |||
| 1527 | o.updateBangSuggestions(suggestions) | 1595 | o.updateBangSuggestions(suggestions) | ||
| 1528 | return false | 1596 | return false | ||
| 1529 | } | 1597 | } | ||
| @@ -1535,6 +1603,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||||
| 1535 | o.bangMode = true | 1603 | o.bangMode = true | ||
| 1536 | o.bangSuggestions = suggestions | 1604 | o.bangSuggestions = suggestions | ||
| 1537 | o.selectedIndex = -1 | 1605 | o.selectedIndex = -1 | ||
| 1606 | + | o.hasNavigated = false | |||
| 1538 | o.mu.Unlock() | 1607 | o.mu.Unlock() | ||
| 1539 | 1608 | ||||
| 1540 | o.rebuildList() | 1609 | o.rebuildList() | ||
| @@ -1544,10 +1613,6 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||||
| 1544 | o.scrolledWin.SetVisible(rowCount > 0) | 1613 | o.scrolledWin.SetVisible(rowCount > 0) | ||
| 1545 | } | 1614 | } | ||
| 1546 | o.resizeAndCenter(rowCount) | 1615 | o.resizeAndCenter(rowCount) | ||
| 1547 | - | ||||
| 1548 | - | if rowCount > 0 { | |||
| 1549 | - | o.selectIndex(0) | |||
| 1550 | - | } | |||
| 1551 | } | 1616 | } | ||
| 1552 | 1617 | ||||
| 1553 | // updateSuggestions updates the list with history suggestions. | 1618 | // updateSuggestions updates the list with history suggestions. | ||
| @@ -1569,9 +1634,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||||
| 1569 | } | 1634 | } | ||
| 1570 | o.resizeAndCenter(rowCount) | 1635 | o.resizeAndCenter(rowCount) | ||
| 1571 | 1636 | ||||
| 1572 | - | // Select first item if available and update ghost text | |||
| 1573 | if rowCount > 0 { | 1637 | if rowCount > 0 { | ||
| 1574 | - | o.selectIndex(0) | |||
| 1575 | o.updateGhostFromSelectionWithInput(query) | 1638 | o.updateGhostFromSelectionWithInput(query) | ||
| 1576 | } else { | 1639 | } else { | ||
| 1577 | o.clearGhostTextIfInput(query) | 1640 | o.clearGhostTextIfInput(query) | ||
| @@ -1597,9 +1660,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||||
| 1597 | } | 1660 | } | ||
| 1598 | o.resizeAndCenter(rowCount) | 1661 | o.resizeAndCenter(rowCount) | ||
| 1599 | 1662 | ||||
| 1600 | - | // Select first item if available and update ghost text | |||
| 1601 | if rowCount > 0 { | 1663 | if rowCount > 0 { | ||
| 1602 | - | o.selectIndex(0) | |||
| 1603 | o.updateGhostFromSelectionWithInput(query) | 1664 | o.updateGhostFromSelectionWithInput(query) | ||
| 1604 | } else { | 1665 | } else { | ||
| 1605 | o.clearGhostTextIfInput(query) | 1666 | o.clearGhostTextIfInput(query) | ||
| @@ -1713,6 +1774,7 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||||
| 1713 | return nil | 1774 | return nil | ||
| 1714 | } | 1775 | } | ||
| 1715 | row.AddCssClass("omnibox-row") | 1776 | row.AddCssClass("omnibox-row") | ||
| 1777 | + | o.attachRowHoverSelection(row, index) | |||
| 1716 | 1778 | ||||
| 1717 | const rowSpacing = 8 | 1779 | const rowSpacing = 8 | ||
| 1718 | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | 1780 | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | ||
| @@ -1783,6 +1845,20 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||||
| 1783 | return row | 1845 | return row | ||
| 1784 | } | 1846 | } | ||
| 1785 | 1847 | ||||
| 1848 | + | func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) { | |||
| 1849 | + | motionCtrl := gtk.NewEventControllerMotion() | |||
| 1850 | + | if motionCtrl == nil { | |||
| 1851 | + | return | |||
| 1852 | + | } | |||
| 1853 | + | ||||
| 1854 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |||
| 1855 | + | o.selectIndex(index) | |||
| 1856 | + | } | |||
| 1857 | + | o.retainedCallbacks = append(o.retainedCallbacks, enterCb) | |||
| 1858 | + | motionCtrl.ConnectEnter(&enterCb) | |||
| 1859 | + | row.AddController(&motionCtrl.EventController) | |||
| 1860 | + | } | |||
| 1861 | + | ||||
| 1786 | // createSuggestionRow creates a ListBoxRow for a suggestion. | 1862 | // createSuggestionRow creates a ListBoxRow for a suggestion. | ||
| 1787 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | 1863 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | ||
| 1788 | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | 1864 | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | ||
| @@ -1809,13 +1885,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |||||
| 1809 | 1885 | ||||
| 1810 | // selectIndex selects a row by index. | 1886 | // selectIndex selects a row by index. | ||
| 1811 | func (o *Omnibox) selectIndex(index int) { | 1887 | func (o *Omnibox) selectIndex(index int) { | ||
| 1888 | + | o.mu.Lock() | |||
| 1889 | + | o.selectedIndex = index | |||
| 1890 | + | o.mu.Unlock() | |||
| 1891 | + | ||||
| 1812 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | 1892 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | ||
| 1813 | // which also acquires the mutex, causing deadlock | 1893 | // which also acquires the mutex, causing deadlock | ||
| 1814 | row := o.listBox.GetRowAtIndex(index) | 1894 | row := o.listBox.GetRowAtIndex(index) | ||
| 1815 | if row != nil { | 1895 | if row != nil { | ||
| 1816 | o.listBox.SelectRow(row) | 1896 | o.listBox.SelectRow(row) | ||
| 1817 | - | // selectedIndex is updated by rowSelectedCb | 1897 | + | return |
| 1818 | } | 1898 | } | ||
| 1899 | + | ||||
| 1900 | + | o.updateGhostFromSelection() | |||
| 1819 | } | 1901 | } | ||
| 1820 | 1902 | ||||
| 1821 | // selectNext moves selection down. | 1903 | // selectNext moves selection down. | ||
| @@ -1938,16 +2020,14 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1938 | o.entry.SetPosition(-1) | 2020 | o.entry.SetPosition(-1) | ||
| 1939 | return | 2021 | return | ||
| 1940 | } | 2022 | } | ||
| 2023 | + | return | |||
| 1941 | } | 2024 | } | ||
| 1942 | 2025 | ||||
| 1943 | var targetURL string | 2026 | var targetURL string | ||
| 1944 | - | if shouldPreferTypedURLNavigation(entryText) { | 2027 | + | if idx >= 0 { |
| 1945 | - | targetURL = o.buildURL(entryText) | |||
| 1946 | - | } else if idx >= 0 { | |||
| 1947 | // If user has selected a result, navigate to that result. | 2028 | // If user has selected a result, navigate to that result. | ||
| 1948 | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | 2029 | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | ||
| 1949 | } else { | 2030 | } else { | ||
| 1950 | - | // No selection - use entry text as URL/search. | |||
| 1951 | targetURL = o.buildURL(entryText) | 2031 | targetURL = o.buildURL(entryText) | ||
| 1952 | } | 2032 | } | ||
| 1953 | 2033 | ||||
| @@ -2410,6 +2490,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool { | |||||
| 2410 | return o.visible && o.searchToken == token | 2490 | return o.visible && o.searchToken == token | ||
| 2411 | } | 2491 | } | ||
| 2412 | 2492 | ||||
| 2493 | + | func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool { | |||
| 2494 | + | o.mu.RLock() | |||
| 2495 | + | defer o.mu.RUnlock() | |||
| 2496 | + | return o.visible && o.searchToken == token && o.realInput == query | |||
| 2497 | + | } | |||
| 2498 | + | ||||
| 2413 | // Widget returns the omnibox widget for embedding in an overlay. | 2499 | // Widget returns the omnibox widget for embedding in an overlay. | ||
| 2414 | func (o *Omnibox) Widget() *gtk.Widget { | 2500 | func (o *Omnibox) Widget() *gtk.Widget { | ||
| 2415 | if o.outerBox == nil { | 2501 | if o.outerBox == nil { | ||
M internal/ui/component/omnibox_async_guard_test.go +25 -7
| @@ -23,20 +23,38 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) { | |||
| 23 | 23 | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | |
| 24 | 24 | o := &Omnibox{} | |
| 25 | 25 | o.visible = true | |
| 26 | - | o.searchToken = 8 | |
| 27 | 26 | o.ghostToken = 5 | |
| 28 | 27 | o.realInput = "git" | |
| 29 | 28 | ||
| 30 | - | if !o.isGhostTokenCurrent(8, 5, "git") { | |
| 29 | + | if !o.isGhostTokenCurrent(5, "git") { | |
| 31 | 30 | t.Fatalf("expected ghost token to be current") | |
| 32 | 31 | } | |
| 33 | - | if o.isGhostTokenCurrent(7, 5, "git") { | |
| 34 | - | t.Fatalf("expected stale search token to be rejected") | |
| 35 | - | } | |
| 36 | - | if o.isGhostTokenCurrent(8, 4, "git") { | |
| 32 | + | if o.isGhostTokenCurrent(4, "git") { | |
| 37 | 33 | t.Fatalf("expected stale ghost token to be rejected") | |
| 38 | 34 | } | |
| 39 | - | if o.isGhostTokenCurrent(8, 5, "github") { | |
| 35 | + | if o.isGhostTokenCurrent(5, "github") { | |
| 40 | 36 | t.Fatalf("expected query mismatch to be rejected") | |
| 41 | 37 | } | |
| 42 | 38 | } | |
| 39 | + | ||
| 40 | + | func TestOmnibox_IsBangUpdateCurrent(t *testing.T) { | |
| 41 | + | o := &Omnibox{} | |
| 42 | + | o.visible = true | |
| 43 | + | o.searchToken = 9 | |
| 44 | + | o.realInput = "!gh" | |
| 45 | + | ||
| 46 | + | if !o.isBangUpdateCurrent("!gh", 9) { | |
| 47 | + | t.Fatalf("expected bang update to be current") | |
| 48 | + | } | |
| 49 | + | if o.isBangUpdateCurrent("!g", 9) { | |
| 50 | + | t.Fatalf("expected bang query mismatch to be rejected") | |
| 51 | + | } | |
| 52 | + | if o.isBangUpdateCurrent("!gh", 8) { | |
| 53 | + | t.Fatalf("expected stale bang token to be rejected") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | o.visible = false | |
| 57 | + | if o.isBangUpdateCurrent("!gh", 9) { | |
| 58 | + | t.Fatalf("expected hidden omnibox to reject bang updates") | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -23,20 +23,38 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) { | |||||
| 23 | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | 23 | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | ||
| 24 | o := &Omnibox{} | 24 | o := &Omnibox{} | ||
| 25 | o.visible = true | 25 | o.visible = true | ||
| 26 | - | o.searchToken = 8 | |||
| 27 | o.ghostToken = 5 | 26 | o.ghostToken = 5 | ||
| 28 | o.realInput = "git" | 27 | o.realInput = "git" | ||
| 29 | 28 | ||||
| 30 | - | if !o.isGhostTokenCurrent(8, 5, "git") { | 29 | + | if !o.isGhostTokenCurrent(5, "git") { |
| 31 | t.Fatalf("expected ghost token to be current") | 30 | t.Fatalf("expected ghost token to be current") | ||
| 32 | } | 31 | } | ||
| 33 | - | if o.isGhostTokenCurrent(7, 5, "git") { | 32 | + | if o.isGhostTokenCurrent(4, "git") { |
| 34 | - | t.Fatalf("expected stale search token to be rejected") | |||
| 35 | - | } | |||
| 36 | - | if o.isGhostTokenCurrent(8, 4, "git") { | |||
| 37 | t.Fatalf("expected stale ghost token to be rejected") | 33 | t.Fatalf("expected stale ghost token to be rejected") | ||
| 38 | } | 34 | } | ||
| 39 | - | if o.isGhostTokenCurrent(8, 5, "github") { | 35 | + | if o.isGhostTokenCurrent(5, "github") { |
| 40 | t.Fatalf("expected query mismatch to be rejected") | 36 | t.Fatalf("expected query mismatch to be rejected") | ||
| 41 | } | 37 | } | ||
| 42 | } | 38 | } | ||
| 39 | + | ||||
| 40 | + | func TestOmnibox_IsBangUpdateCurrent(t *testing.T) { | |||
| 41 | + | o := &Omnibox{} | |||
| 42 | + | o.visible = true | |||
| 43 | + | o.searchToken = 9 | |||
| 44 | + | o.realInput = "!gh" | |||
| 45 | + | ||||
| 46 | + | if !o.isBangUpdateCurrent("!gh", 9) { | |||
| 47 | + | t.Fatalf("expected bang update to be current") | |||
| 48 | + | } | |||
| 49 | + | if o.isBangUpdateCurrent("!g", 9) { | |||
| 50 | + | t.Fatalf("expected bang query mismatch to be rejected") | |||
| 51 | + | } | |||
| 52 | + | if o.isBangUpdateCurrent("!gh", 8) { | |||
| 53 | + | t.Fatalf("expected stale bang token to be rejected") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | o.visible = false | |||
| 57 | + | if o.isBangUpdateCurrent("!gh", 9) { | |||
| 58 | + | t.Fatalf("expected hidden omnibox to reject bang updates") | |||
| 59 | + | } | |||
| 60 | + | } | |||
M internal/ui/component/omnibox_navigation_state_test.go +106 -0
| @@ -116,6 +116,16 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | |
| 120 | + | entryText := "github.com/bnema/dumber" | |
| 121 | + | realInput := "upl" | |
| 122 | + | ||
| 123 | + | got := effectiveSearchQuery(entryText, realInput, true) | |
| 124 | + | if got != realInput { | |
| 125 | + | t.Fatalf("effectiveSearchQuery should prefer real input when ghost is visible, got %q want %q", got, realInput) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 119 | 129 | func TestResolveTargetURLForSelection(t *testing.T) { | |
| 120 | 130 | suggestions := []Suggestion{ | |
| 121 | 131 | {URL: "https://github.com/bnema/dumber"}, | |
| @@ -145,3 +155,99 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||
| 145 | 155 | }) | |
| 146 | 156 | } | |
| 147 | 157 | } | |
| 158 | + | ||
| 159 | + | func TestSelectedTargetURL(t *testing.T) { | |
| 160 | + | suggestions := []Suggestion{ | |
| 161 | + | {URL: "https://github.com/bnema/dumber"}, | |
| 162 | + | } | |
| 163 | + | favorites := []Favorite{ | |
| 164 | + | {URL: "https://dumber.bnema.dev"}, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | tests := []struct { | |
| 168 | + | name string | |
| 169 | + | mode ViewMode | |
| 170 | + | index int | |
| 171 | + | wantURL string | |
| 172 | + | wantBool bool | |
| 173 | + | }{ | |
| 174 | + | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false}, | |
| 175 | + | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true}, | |
| 176 | + | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true}, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | for _, tt := range tests { | |
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |
| 181 | + | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites) | |
| 182 | + | if gotURL != tt.wantURL || gotBool != tt.wantBool { | |
| 183 | + | t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestVisibleGhostSuggestion(t *testing.T) { | |
| 190 | + | suggestions := []Suggestion{ | |
| 191 | + | {URL: "https://github.com/bnema/dumber"}, | |
| 192 | + | {URL: "https://gitlab.com/team/project"}, | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | tests := []struct { | |
| 196 | + | name string | |
| 197 | + | input string | |
| 198 | + | selectedURL string | |
| 199 | + | hasExplicitSelection bool | |
| 200 | + | wantFull string | |
| 201 | + | wantSuffix string | |
| 202 | + | wantOK bool | |
| 203 | + | }{ | |
| 204 | + | { | |
| 205 | + | name: "top visible candidate drives ghost without selection", | |
| 206 | + | input: "git", | |
| 207 | + | wantFull: "github.com", | |
| 208 | + | wantSuffix: "hub.com", | |
| 209 | + | wantOK: true, | |
| 210 | + | }, | |
| 211 | + | { | |
| 212 | + | name: "explicit selection wins over top candidate", | |
| 213 | + | input: "git", | |
| 214 | + | selectedURL: "https://gitlab.com/team/project", | |
| 215 | + | hasExplicitSelection: true, | |
| 216 | + | wantFull: "gitlab.com/team/project", | |
| 217 | + | wantSuffix: "lab.com/team/project", | |
| 218 | + | wantOK: true, | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "no visible prefix match means no ghost", | |
| 222 | + | input: "xyz", | |
| 223 | + | wantFull: "", | |
| 224 | + | wantSuffix: "", | |
| 225 | + | wantOK: false, | |
| 226 | + | }, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | for _, tt := range tests { | |
| 230 | + | t.Run(tt.name, func(t *testing.T) { | |
| 231 | + | gotFull, gotSuffix, gotOK := visibleGhostSuggestion( | |
| 232 | + | tt.input, | |
| 233 | + | tt.selectedURL, | |
| 234 | + | tt.hasExplicitSelection, | |
| 235 | + | ViewModeHistory, | |
| 236 | + | suggestions, | |
| 237 | + | nil, | |
| 238 | + | ) | |
| 239 | + | if gotFull != tt.wantFull || gotSuffix != tt.wantSuffix || gotOK != tt.wantOK { | |
| 240 | + | t.Fatalf( | |
| 241 | + | "visibleGhostSuggestion(%q) = (%q, %q, %v), want (%q, %q, %v)", | |
| 242 | + | tt.input, | |
| 243 | + | gotFull, | |
| 244 | + | gotSuffix, | |
| 245 | + | gotOK, | |
| 246 | + | tt.wantFull, | |
| 247 | + | tt.wantSuffix, | |
| 248 | + | tt.wantOK, | |
| 249 | + | ) | |
| 250 | + | } | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | } | |
| @@ -116,6 +116,16 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||||
| 116 | } | 116 | } | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | + | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | |||
| 120 | + | entryText := "github.com/bnema/dumber" | |||
| 121 | + | realInput := "upl" | |||
| 122 | + | ||||
| 123 | + | got := effectiveSearchQuery(entryText, realInput, true) | |||
| 124 | + | if got != realInput { | |||
| 125 | + | t.Fatalf("effectiveSearchQuery should prefer real input when ghost is visible, got %q want %q", got, realInput) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 119 | func TestResolveTargetURLForSelection(t *testing.T) { | 129 | func TestResolveTargetURLForSelection(t *testing.T) { | ||
| 120 | suggestions := []Suggestion{ | 130 | suggestions := []Suggestion{ | ||
| 121 | {URL: "https://github.com/bnema/dumber"}, | 131 | {URL: "https://github.com/bnema/dumber"}, | ||
| @@ -145,3 +155,99 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||||
| 145 | }) | 155 | }) | ||
| 146 | } | 156 | } | ||
| 147 | } | 157 | } | ||
| 158 | + | ||||
| 159 | + | func TestSelectedTargetURL(t *testing.T) { | |||
| 160 | + | suggestions := []Suggestion{ | |||
| 161 | + | {URL: "https://github.com/bnema/dumber"}, | |||
| 162 | + | } | |||
| 163 | + | favorites := []Favorite{ | |||
| 164 | + | {URL: "https://dumber.bnema.dev"}, | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | tests := []struct { | |||
| 168 | + | name string | |||
| 169 | + | mode ViewMode | |||
| 170 | + | index int | |||
| 171 | + | wantURL string | |||
| 172 | + | wantBool bool | |||
| 173 | + | }{ | |||
| 174 | + | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false}, | |||
| 175 | + | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true}, | |||
| 176 | + | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true}, | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | for _, tt := range tests { | |||
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 181 | + | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites) | |||
| 182 | + | if gotURL != tt.wantURL || gotBool != tt.wantBool { | |||
| 183 | + | t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool) | |||
| 184 | + | } | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestVisibleGhostSuggestion(t *testing.T) { | |||
| 190 | + | suggestions := []Suggestion{ | |||
| 191 | + | {URL: "https://github.com/bnema/dumber"}, | |||
| 192 | + | {URL: "https://gitlab.com/team/project"}, | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | tests := []struct { | |||
| 196 | + | name string | |||
| 197 | + | input string | |||
| 198 | + | selectedURL string | |||
| 199 | + | hasExplicitSelection bool | |||
| 200 | + | wantFull string | |||
| 201 | + | wantSuffix string | |||
| 202 | + | wantOK bool | |||
| 203 | + | }{ | |||
| 204 | + | { | |||
| 205 | + | name: "top visible candidate drives ghost without selection", | |||
| 206 | + | input: "git", | |||
| 207 | + | wantFull: "github.com", | |||
| 208 | + | wantSuffix: "hub.com", | |||
| 209 | + | wantOK: true, | |||
| 210 | + | }, | |||
| 211 | + | { | |||
| 212 | + | name: "explicit selection wins over top candidate", | |||
| 213 | + | input: "git", | |||
| 214 | + | selectedURL: "https://gitlab.com/team/project", | |||
| 215 | + | hasExplicitSelection: true, | |||
| 216 | + | wantFull: "gitlab.com/team/project", | |||
| 217 | + | wantSuffix: "lab.com/team/project", | |||
| 218 | + | wantOK: true, | |||
| 219 | + | }, | |||
| 220 | + | { | |||
| 221 | + | name: "no visible prefix match means no ghost", | |||
| 222 | + | input: "xyz", | |||
| 223 | + | wantFull: "", | |||
| 224 | + | wantSuffix: "", | |||
| 225 | + | wantOK: false, | |||
| 226 | + | }, | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | for _, tt := range tests { | |||
| 230 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 231 | + | gotFull, gotSuffix, gotOK := visibleGhostSuggestion( | |||
| 232 | + | tt.input, | |||
| 233 | + | tt.selectedURL, | |||
| 234 | + | tt.hasExplicitSelection, | |||
| 235 | + | ViewModeHistory, | |||
| 236 | + | suggestions, | |||
| 237 | + | nil, | |||
| 238 | + | ) | |||
| 239 | + | if gotFull != tt.wantFull || gotSuffix != tt.wantSuffix || gotOK != tt.wantOK { | |||
| 240 | + | t.Fatalf( | |||
| 241 | + | "visibleGhostSuggestion(%q) = (%q, %q, %v), want (%q, %q, %v)", | |||
| 242 | + | tt.input, | |||
| 243 | + | gotFull, | |||
| 244 | + | gotSuffix, | |||
| 245 | + | gotOK, | |||
| 246 | + | tt.wantFull, | |||
| 247 | + | tt.wantSuffix, | |||
| 248 | + | tt.wantOK, | |||
| 249 | + | ) | |||
| 250 | + | } | |||
| 251 | + | }) | |||
| 252 | + | } | |||
| 253 | + | } | |||