detached 6660b83

Merge pull request #185 from bnema/refactor/omnibox-core-unification

Refactor omnibox suggestion authority and history ranking
commit
6660b83b84b607800cfbbdee82673c5ff34f620c
author
bnema · · 2026-03-11 04:03 UTC
committer
GitHub · · 2026-03-11 04:03 UTC
merge of
690c646 + 46e2e0d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +1295 -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 +142 -3
@@ -3,9 +3,14 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "sort"
7+ "strings"
8+ "sync"
9+ "time"
610
711 "github.com/bnema/dumber/internal/domain/entity"
812 "github.com/bnema/dumber/internal/domain/repository"
13+ domainurl "github.com/bnema/dumber/internal/domain/url"
914 "github.com/bnema/dumber/internal/logging"
1015 )
1116
@@ -14,8 +19,15 @@ type ManageFavoritesUseCase struct {
1419 favoriteRepo repository.FavoriteRepository
1520 folderRepo repository.FolderRepository
1621 tagRepo repository.TagRepository
22+
23+ cacheMu sync.Mutex
24+ favoritesCache []*entity.Favorite
25+ favoriteURLsCache map[string]struct{}
26+ cacheTime time.Time
1727 }
1828
29+const favoritesCacheTTL = 2 * time.Second
30+
1931 // NewManageFavoritesUseCase creates a new favorites management use case.
2032 func NewManageFavoritesUseCase(
2133 favoriteRepo repository.FavoriteRepository,
@@ -73,6 +85,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
7385 }
7486
7587 log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
88+ uc.invalidateCache()
7689 return fav, nil
7790 }
7891
@@ -86,6 +99,7 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
8699 }
87100
88101 log.Info().Int64("id", int64(id)).Msg("favorite removed")
102+ uc.invalidateCache()
89103 return nil
90104 }
91105
@@ -116,6 +130,7 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori
116130 }
117131
118132 log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
133+ uc.invalidateCache()
119134 return nil
120135 }
121136
@@ -131,6 +146,7 @@ func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID
131146 }
132147
133148 log.Info().Int64("id", int64(id)).Msg("favorite moved")
149+ uc.invalidateCache()
134150 return nil
135151 }
136152
@@ -150,6 +166,7 @@ func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.Fav
150166 return fmt.Errorf("failed to set shortcut: %w", err)
151167 }
152168
169+ uc.invalidateCache()
153170 return nil
154171 }
155172
@@ -178,15 +195,74 @@ func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorit
178195 log := logging.FromContext(ctx)
179196 log.Debug().Msg("getting all favorites")
180197
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+
181206 favs, err := uc.favoriteRepo.GetAll(ctx)
182207 if err != nil {
183208 return nil, fmt.Errorf("failed to get favorites: %w", err)
184209 }
185210
211+ uc.cacheMu.Lock()
212+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
213+ cached := uc.favoritesCache
214+ uc.cacheMu.Unlock()
215+ return cached, nil
216+ }
217+ uc.favoritesCache = favs
218+ uc.favoriteURLsCache = buildFavoriteURLSet(favs)
219+ uc.cacheTime = time.Now()
220+ uc.cacheMu.Unlock()
221+
186222 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187223 return favs, nil
188224 }
189225
226+// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
227+func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
228+ favs, err := uc.GetAll(ctx)
229+ if err != nil {
230+ return nil, err
231+ }
232+
233+ queryLower := strings.ToLower(strings.TrimSpace(query))
234+ if queryLower == "" {
235+ return favs, nil
236+ }
237+
238+ filtered := make([]*entity.Favorite, 0, len(favs))
239+ for _, fav := range favs {
240+ if fav == nil {
241+ continue
242+ }
243+ titleLower := strings.ToLower(fav.Title)
244+ urlLower := strings.ToLower(fav.URL)
245+ if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
246+ continue
247+ }
248+ filtered = append(filtered, fav)
249+ }
250+
251+ sort.SliceStable(filtered, func(i, j int) bool {
252+ leftScore := omniboxFavoriteScore(filtered[i], queryLower)
253+ rightScore := omniboxFavoriteScore(filtered[j], queryLower)
254+ if leftScore != rightScore {
255+ return leftScore > rightScore
256+ }
257+ if filtered[i].Position != filtered[j].Position {
258+ return filtered[i].Position < filtered[j].Position
259+ }
260+ return filtered[i].URL < filtered[j].URL
261+ })
262+
263+ return filtered, nil
264+}
265+
190266 // IsFavorite checks if a URL is favorited.
191267 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192268 fav, err := uc.favoriteRepo.FindByURL(ctx, url)
@@ -196,6 +272,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196272 return fav != nil, nil
197273 }
198274
275+func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
276+ if fav == nil || queryLower == "" {
277+ return 0
278+ }
279+
280+ const (
281+ favoriteExactMatchScore = 600
282+ favoriteHostPrefixScore = 520
283+ favoriteTitlePrefixScore = 440
284+ favoriteTitleContainsScore = 320
285+ favoriteURLContainsScore = 280
286+ )
287+
288+ urlLower := strings.ToLower(fav.URL)
289+ strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
290+ if strippedURLLower == "" {
291+ strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
292+ }
293+ titleLower := strings.ToLower(fav.Title)
294+
295+ switch {
296+ case titleLower == queryLower || urlLower == queryLower:
297+ return favoriteExactMatchScore
298+ case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
299+ return favoriteHostPrefixScore
300+ case strings.HasPrefix(titleLower, queryLower):
301+ return favoriteTitlePrefixScore
302+ case strings.Contains(titleLower, queryLower):
303+ return favoriteTitleContainsScore
304+ case strings.Contains(urlLower, queryLower):
305+ return favoriteURLContainsScore
306+ default:
307+ return 0
308+ }
309+}
310+
199311 // ToggleResult indicates the result of a toggle operation.
200312 type ToggleResult struct {
201313 Added bool // true if favorite was added, false if removed
@@ -226,6 +338,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
226338 return nil, fmt.Errorf("failed to remove favorite: %w", err)
227339 }
228340 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
341+ uc.invalidateCache()
229342 return &ToggleResult{
230343 Added: false,
231344 URL: url,
@@ -240,6 +353,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
240353 return nil, fmt.Errorf("failed to add favorite: %w", err)
241354 }
242355 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
356+ uc.invalidateCache()
243357 return &ToggleResult{
244358 Added: true,
245359 URL: url,
@@ -253,18 +367,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st
253367 log := logging.FromContext(ctx)
254368 log.Debug().Msg("getting all favorite URLs")
255369
256- favs, err := uc.favoriteRepo.GetAll(ctx)
370+ uc.cacheMu.Lock()
371+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
372+ cached := uc.favoriteURLsCache
373+ uc.cacheMu.Unlock()
374+ return cached, nil
375+ }
376+ uc.cacheMu.Unlock()
377+
378+ favs, err := uc.GetAll(ctx)
257379 if err != nil {
258380 return nil, fmt.Errorf("failed to get favorites: %w", err)
259381 }
260382
383+ urls := buildFavoriteURLSet(favs)
384+
385+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
386+ return urls, nil
387+}
388+
389+func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
261390 urls := make(map[string]struct{}, len(favs))
262391 for _, fav := range favs {
392+ if fav == nil || fav.URL == "" {
393+ continue
394+ }
263395 urls[fav.URL] = struct{}{}
264396 }
397+ return urls
398+}
265399
266- log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267- return urls, nil
400+func (uc *ManageFavoritesUseCase) invalidateCache() {
401+ uc.cacheMu.Lock()
402+ defer uc.cacheMu.Unlock()
403+ uc.favoritesCache = nil
404+ uc.favoriteURLsCache = nil
405+ uc.cacheTime = time.Time{}
268406 }
269407
270408 // GetTree builds a complete hierarchical view of folders and favorites.
@@ -359,6 +497,7 @@ func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.Fo
359497 Msg("failed to move favorite from deleted folder")
360498 }
361499 }
500+ uc.invalidateCache()
362501
363502 // Get child folders
364503 children, err := uc.folderRepo.GetChildren(ctx, &id)
@@ -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.FavoriteRepository19 favoriteRepo repository.FavoriteRepository
15 folderRepo repository.FolderRepository20 folderRepo repository.FolderRepository
16 tagRepo repository.TagRepository21 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, nil89 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 nil103 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 nil134 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 nil150 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 nil170 return nil
154 }171 }
155 172
@@ -178,15 +195,74 @@ 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+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
213+ cached := uc.favoritesCache
214+ uc.cacheMu.Unlock()
215+ return cached, nil
216+ }
217+ uc.favoritesCache = favs
218+ uc.favoriteURLsCache = buildFavoriteURLSet(favs)
219+ uc.cacheTime = time.Now()
220+ uc.cacheMu.Unlock()
221+
186 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")222 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187 return favs, nil223 return favs, nil
188 }224 }
189 225
226+// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
227+func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
228+ favs, err := uc.GetAll(ctx)
229+ if err != nil {
230+ return nil, err
231+ }
232+
233+ queryLower := strings.ToLower(strings.TrimSpace(query))
234+ if queryLower == "" {
235+ return favs, nil
236+ }
237+
238+ filtered := make([]*entity.Favorite, 0, len(favs))
239+ for _, fav := range favs {
240+ if fav == nil {
241+ continue
242+ }
243+ titleLower := strings.ToLower(fav.Title)
244+ urlLower := strings.ToLower(fav.URL)
245+ if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
246+ continue
247+ }
248+ filtered = append(filtered, fav)
249+ }
250+
251+ sort.SliceStable(filtered, func(i, j int) bool {
252+ leftScore := omniboxFavoriteScore(filtered[i], queryLower)
253+ rightScore := omniboxFavoriteScore(filtered[j], queryLower)
254+ if leftScore != rightScore {
255+ return leftScore > rightScore
256+ }
257+ if filtered[i].Position != filtered[j].Position {
258+ return filtered[i].Position < filtered[j].Position
259+ }
260+ return filtered[i].URL < filtered[j].URL
261+ })
262+
263+ return filtered, nil
264+}
265+
190 // IsFavorite checks if a URL is favorited.266 // IsFavorite checks if a URL is favorited.
191 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {267 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192 fav, err := uc.favoriteRepo.FindByURL(ctx, url)268 fav, err := uc.favoriteRepo.FindByURL(ctx, url)
@@ -196,6 +272,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil272 return fav != nil, nil
197 }273 }
198 274
275+func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
276+ if fav == nil || queryLower == "" {
277+ return 0
278+ }
279+
280+ const (
281+ favoriteExactMatchScore = 600
282+ favoriteHostPrefixScore = 520
283+ favoriteTitlePrefixScore = 440
284+ favoriteTitleContainsScore = 320
285+ favoriteURLContainsScore = 280
286+ )
287+
288+ urlLower := strings.ToLower(fav.URL)
289+ strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
290+ if strippedURLLower == "" {
291+ strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
292+ }
293+ titleLower := strings.ToLower(fav.Title)
294+
295+ switch {
296+ case titleLower == queryLower || urlLower == queryLower:
297+ return favoriteExactMatchScore
298+ case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
299+ return favoriteHostPrefixScore
300+ case strings.HasPrefix(titleLower, queryLower):
301+ return favoriteTitlePrefixScore
302+ case strings.Contains(titleLower, queryLower):
303+ return favoriteTitleContainsScore
304+ case strings.Contains(urlLower, queryLower):
305+ return favoriteURLContainsScore
306+ default:
307+ return 0
308+ }
309+}
310+
199 // ToggleResult indicates the result of a toggle operation.311 // ToggleResult indicates the result of a toggle operation.
200 type ToggleResult struct {312 type ToggleResult struct {
201 Added bool // true if favorite was added, false if removed313 Added bool // true if favorite was added, false if removed
@@ -226,6 +338,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
226 return nil, fmt.Errorf("failed to remove favorite: %w", err)338 return nil, fmt.Errorf("failed to remove favorite: %w", err)
227 }339 }
228 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")340 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
341+ uc.invalidateCache()
229 return &ToggleResult{342 return &ToggleResult{
230 Added: false,343 Added: false,
231 URL: url,344 URL: url,
@@ -240,6 +353,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
240 return nil, fmt.Errorf("failed to add favorite: %w", err)353 return nil, fmt.Errorf("failed to add favorite: %w", err)
241 }354 }
242 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")355 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
356+ uc.invalidateCache()
243 return &ToggleResult{357 return &ToggleResult{
244 Added: true,358 Added: true,
245 URL: url,359 URL: url,
@@ -253,18 +367,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st
253 log := logging.FromContext(ctx)367 log := logging.FromContext(ctx)
254 log.Debug().Msg("getting all favorite URLs")368 log.Debug().Msg("getting all favorite URLs")
255 369
256- favs, err := uc.favoriteRepo.GetAll(ctx)370+ uc.cacheMu.Lock()
371+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
372+ cached := uc.favoriteURLsCache
373+ uc.cacheMu.Unlock()
374+ return cached, nil
375+ }
376+ uc.cacheMu.Unlock()
377+
378+ favs, err := uc.GetAll(ctx)
257 if err != nil {379 if err != nil {
258 return nil, fmt.Errorf("failed to get favorites: %w", err)380 return nil, fmt.Errorf("failed to get favorites: %w", err)
259 }381 }
260 382
383+ urls := buildFavoriteURLSet(favs)
384+
385+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
386+ return urls, nil
387+}
388+
389+func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
261 urls := make(map[string]struct{}, len(favs))390 urls := make(map[string]struct{}, len(favs))
262 for _, fav := range favs {391 for _, fav := range favs {
392+ if fav == nil || fav.URL == "" {
393+ continue
394+ }
263 urls[fav.URL] = struct{}{}395 urls[fav.URL] = struct{}{}
264 }396 }
397+ return urls
398+}
265 399
266- log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")400+func (uc *ManageFavoritesUseCase) invalidateCache() {
267- return urls, nil401+ uc.cacheMu.Lock()
402+ defer uc.cacheMu.Unlock()
403+ uc.favoritesCache = nil
404+ uc.favoriteURLsCache = nil
405+ uc.cacheTime = time.Time{}
268 }406 }
269 407
270 // GetTree builds a complete hierarchical view of folders and favorites.408 // GetTree builds a complete hierarchical view of folders and favorites.
@@ -359,6 +497,7 @@ func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.Fo
359 Msg("failed to move favorite from deleted folder")497 Msg("failed to move favorite from deleted folder")
360 }498 }
361 }499 }
500+ uc.invalidateCache()
362 501
363 // Get child folders502 // Get child folders
364 children, err := uc.folderRepo.GetChildren(ctx, &id)503 children, err := uc.folderRepo.GetChildren(ctx, &id)
A internal/application/usecase/manage_favorites_cache_test.go +48 -0
@@ -0,0 +1,48 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
16+ logger := logging.NewFromConfigValues("debug", "console")
17+ ctx := logging.WithContext(context.Background(), logger)
18+
19+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20+ folderRepo := repomocks.NewMockFolderRepository(t)
21+ tagRepo := repomocks.NewMockTagRepository(t)
22+
23+ fetchedFavorites := []*entity.Favorite{
24+ {ID: 1, URL: "https://stale.example"},
25+ }
26+ refreshedFavorites := []*entity.Favorite{
27+ {ID: 2, URL: "https://fresh.example"},
28+ }
29+
30+ uc := NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+ favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) {
32+ uc.cacheMu.Lock()
33+ uc.favoritesCache = refreshedFavorites
34+ uc.favoriteURLsCache = buildFavoriteURLSet(refreshedFavorites)
35+ uc.cacheTime = time.Now()
36+ uc.cacheMu.Unlock()
37+ }).Return(fetchedFavorites, nil).Once()
38+
39+ favorites, err := uc.GetAll(ctx)
40+ require.NoError(t, err)
41+ assert.Equal(t, refreshedFavorites, favorites)
42+
43+ uc.cacheMu.Lock()
44+ assert.Equal(t, refreshedFavorites, uc.favoritesCache)
45+ assert.Contains(t, uc.favoriteURLsCache, "https://fresh.example")
46+ assert.NotContains(t, uc.favoriteURLsCache, "https://stale.example")
47+ uc.cacheMu.Unlock()
48+}
@@ -0,0 +1,48 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
16+ logger := logging.NewFromConfigValues("debug", "console")
17+ ctx := logging.WithContext(context.Background(), logger)
18+
19+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20+ folderRepo := repomocks.NewMockFolderRepository(t)
21+ tagRepo := repomocks.NewMockTagRepository(t)
22+
23+ fetchedFavorites := []*entity.Favorite{
24+ {ID: 1, URL: "https://stale.example"},
25+ }
26+ refreshedFavorites := []*entity.Favorite{
27+ {ID: 2, URL: "https://fresh.example"},
28+ }
29+
30+ uc := NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+ favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) {
32+ uc.cacheMu.Lock()
33+ uc.favoritesCache = refreshedFavorites
34+ uc.favoriteURLsCache = buildFavoriteURLSet(refreshedFavorites)
35+ uc.cacheTime = time.Now()
36+ uc.cacheMu.Unlock()
37+ }).Return(fetchedFavorites, nil).Once()
38+
39+ favorites, err := uc.GetAll(ctx)
40+ require.NoError(t, err)
41+ assert.Equal(t, refreshedFavorites, favorites)
42+
43+ uc.cacheMu.Lock()
44+ assert.Equal(t, refreshedFavorites, uc.favoritesCache)
45+ assert.Contains(t, uc.favoriteURLsCache, "https://fresh.example")
46+ assert.NotContains(t, uc.favoriteURLsCache, "https://stale.example")
47+ uc.cacheMu.Unlock()
48+}
M internal/application/usecase/manage_favorites_test.go +144 -0
@@ -198,3 +198,147 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
198198 assert.Nil(t, result)
199199 assert.Contains(t, err.Error(), "failed to remove favorite")
200200 }
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+}
305+
306+func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testing.T) {
307+ ctx := testContext()
308+
309+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
310+ folderRepo := repomocks.NewMockFolderRepository(t)
311+ tagRepo := repomocks.NewMockTagRepository(t)
312+
313+ folderID := entity.FolderID(9)
314+ initialFolderID := folderID
315+ cachedFavorites := []*entity.Favorite{
316+ {ID: 1, URL: "https://example.com", FolderID: &initialFolderID},
317+ }
318+ updatedFavorites := []*entity.Favorite{
319+ {ID: 1, URL: "https://example.com", FolderID: nil},
320+ }
321+
322+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(cachedFavorites, nil).Once()
323+ folderRepo.EXPECT().FindByID(mock.Anything, folderID).Return(&entity.Folder{ID: folderID}, nil).Once()
324+ favoriteRepo.EXPECT().GetByFolder(mock.Anything, &folderID).Return(cachedFavorites, nil).Once()
325+ favoriteRepo.EXPECT().SetFolder(mock.Anything, entity.FavoriteID(1), (*entity.FolderID)(nil)).Return(nil).Once()
326+ folderRepo.EXPECT().GetChildren(mock.Anything, &folderID).Return([]*entity.Folder{}, nil).Once()
327+ folderRepo.EXPECT().Delete(mock.Anything, folderID).Return(nil).Once()
328+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
329+
330+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
331+
332+ favoritesBefore, err := uc.GetAll(ctx)
333+ require.NoError(t, err)
334+ require.Len(t, favoritesBefore, 1)
335+ require.NotNil(t, favoritesBefore[0].FolderID)
336+
337+ err = uc.DeleteFolder(ctx, folderID)
338+ require.NoError(t, err)
339+
340+ favoritesAfter, err := uc.GetAll(ctx)
341+ require.NoError(t, err)
342+ require.Len(t, favoritesAfter, 1)
343+ assert.Nil(t, favoritesAfter[0].FolderID)
344+}
@@ -198,3 +198,147 @@ 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+}
305+
306+func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testing.T) {
307+ ctx := testContext()
308+
309+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
310+ folderRepo := repomocks.NewMockFolderRepository(t)
311+ tagRepo := repomocks.NewMockTagRepository(t)
312+
313+ folderID := entity.FolderID(9)
314+ initialFolderID := folderID
315+ cachedFavorites := []*entity.Favorite{
316+ {ID: 1, URL: "https://example.com", FolderID: &initialFolderID},
317+ }
318+ updatedFavorites := []*entity.Favorite{
319+ {ID: 1, URL: "https://example.com", FolderID: nil},
320+ }
321+
322+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(cachedFavorites, nil).Once()
323+ folderRepo.EXPECT().FindByID(mock.Anything, folderID).Return(&entity.Folder{ID: folderID}, nil).Once()
324+ favoriteRepo.EXPECT().GetByFolder(mock.Anything, &folderID).Return(cachedFavorites, nil).Once()
325+ favoriteRepo.EXPECT().SetFolder(mock.Anything, entity.FavoriteID(1), (*entity.FolderID)(nil)).Return(nil).Once()
326+ folderRepo.EXPECT().GetChildren(mock.Anything, &folderID).Return([]*entity.Folder{}, nil).Once()
327+ folderRepo.EXPECT().Delete(mock.Anything, folderID).Return(nil).Once()
328+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
329+
330+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
331+
332+ favoritesBefore, err := uc.GetAll(ctx)
333+ require.NoError(t, err)
334+ require.Len(t, favoritesBefore, 1)
335+ require.NotNil(t, favoritesBefore[0].FolderID)
336+
337+ err = uc.DeleteFolder(ctx, folderID)
338+ require.NoError(t, err)
339+
340+ favoritesAfter, err := uc.GetAll(ctx)
341+ require.NoError(t, err)
342+ require.Len(t, favoritesAfter, 1)
343+ assert.Nil(t, favoritesAfter[0].FolderID)
344+}
M internal/application/usecase/navigate.go +10 -0
@@ -39,6 +39,10 @@ type historyVisitDeltaIncrementer interface {
3939 IncrementVisitCountBy(ctx context.Context, url string, delta int) error
4040 }
4141
42+type historyMetadataUpdater interface {
43+ UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
44+}
45+
4246 type paneHistoryState struct {
4347 lastRawURL string
4448 lastCanonicalURL string
@@ -337,6 +341,12 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, t
337341
338342 // Update existing entry's title
339343 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+ }
340350 if err := uc.historyRepo.Save(ctx, entry); err != nil {
341351 return fmt.Errorf("failed to update history title: %w", err)
342352 }
@@ -39,6 +39,10 @@ type historyVisitDeltaIncrementer interface {
39 IncrementVisitCountBy(ctx context.Context, url string, delta int) error39 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 string47 lastRawURL string
44 lastCanonicalURL string48 lastCanonicalURL string
@@ -337,6 +341,12 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, t
337 341
338 // Update existing entry's title342 // Update existing entry's title
339 entry.Title = title343 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
22
33 import (
44 "context"
5+ "path/filepath"
56 "testing"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
911 "github.com/stretchr/testify/mock"
1012 "github.com/stretchr/testify/require"
1113 )
@@ -58,3 +60,32 @@ func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
5860 uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
5961 uc.Close()
6062 }
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 +297 -42
@@ -5,6 +5,8 @@ import (
55 "database/sql"
66 "errors"
77 "fmt"
8+ "net/url"
9+ "sort"
810 "strings"
911 "sync/atomic"
1012 "time"
@@ -12,21 +14,44 @@ import (
1214
1315 "github.com/bnema/dumber/internal/domain/entity"
1416 "github.com/bnema/dumber/internal/domain/repository"
17+ domainurl "github.com/bnema/dumber/internal/domain/url"
1518 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
1619 "github.com/bnema/dumber/internal/logging"
1720 )
1821
1922 const logURLMaxLen = 60
2023
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+
2145 var expectedFTSFailureCount atomic.Uint64
2246
2347 type historyRepo struct {
48+ db *sql.DB
2449 queries *sqlc.Queries
2550 }
2651
2752 // NewHistoryRepository creates a new SQLite-backed history repository.
2853 func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
29- return &historyRepo{queries: sqlc.New(db)}
54+ return &historyRepo{db: db, queries: sqlc.New(db)}
3055 }
3156
3257 // 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
6085 return nil
6186 }
6287
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)
6590 if err != nil {
6691 if errors.Is(err, sql.ErrNoRows) {
6792 return nil, nil
@@ -71,7 +96,28 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
7196 return historyFromRow(row), nil
7297 }
7398
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+
74116 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
117+ if limit <= 0 {
118+ return []entity.HistoryMatch{}, nil
119+ }
120+
75121 words := sanitizeFTS5QueryTokens(query)
76122 if len(words) == 0 {
77123 return []entity.HistoryMatch{}, nil
@@ -106,40 +152,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
106152 titleRows = []sqlc.History{}
107153 }
108154
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- }
155+ ranked := rankHistoryMatches(query, words, urlRows, titleRows)
156+ if limit < len(ranked) {
157+ ranked = ranked[:limit]
140158 }
141-
142- return matches, nil
159+ return ranked, nil
143160 }
144161
145162 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {
@@ -154,6 +171,244 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
154171 }
155172 }
156173
174+func nullString(value string) sql.NullString {
175+ return sql.NullString{String: value, Valid: value != ""}
176+}
177+
178+type historySearchCandidate struct {
179+ entry *entity.HistoryEntry
180+ score float64
181+ urlMatch bool
182+ titleMatch bool
183+ domainBoost int64
184+ exactURLMatch bool
185+ prefixURL bool
186+ prefixTitle bool
187+}
188+
189+func rankHistoryMatches(
190+ query string,
191+ words []string,
192+ urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow,
193+ titleRows []sqlc.History,
194+) []entity.HistoryMatch {
195+ candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows))
196+
197+ for i := range urlRows {
198+ row := urlRows[i]
199+ entry := historyFromDomainBoostRow(row)
200+ candidate := ensureHistoryCandidate(candidates, entry)
201+ candidate.urlMatch = true
202+ candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost)
203+ }
204+
205+ for i := range titleRows {
206+ row := titleRows[i]
207+ entry := historyFromRow(row)
208+ candidate := ensureHistoryCandidate(candidates, entry)
209+ candidate.titleMatch = true
210+ }
211+
212+ queryLower := strings.ToLower(strings.TrimSpace(query))
213+ ranked := make([]rankedHistoryMatch, 0, len(candidates))
214+ for _, candidate := range candidates {
215+ candidate.score = computeHistoryCandidateScore(candidate, queryLower, words)
216+ ranked = append(ranked, rankedHistoryMatch{
217+ match: entity.HistoryMatch{
218+ Entry: candidate.entry,
219+ Score: candidate.score,
220+ },
221+ score: candidate.score,
222+ exactURLMatch: candidate.exactURLMatch,
223+ prefixURL: candidate.prefixURL,
224+ urlMatch: candidate.urlMatch,
225+ prefixTitle: candidate.prefixTitle,
226+ })
227+ }
228+
229+ sortRankedHistoryMatches(ranked)
230+
231+ matches := make([]entity.HistoryMatch, len(ranked))
232+ for i := range ranked {
233+ matches[i] = ranked[i].match
234+ }
235+ return matches
236+}
237+
238+type rankedHistoryMatch struct {
239+ match entity.HistoryMatch
240+ score float64
241+ exactURLMatch bool
242+ prefixURL bool
243+ urlMatch bool
244+ prefixTitle bool
245+}
246+
247+func sortRankedHistoryMatches(items []rankedHistoryMatch) {
248+ sort.Slice(items, func(i, j int) bool {
249+ li, ri := &items[i], &items[j]
250+ if li.score != ri.score {
251+ return li.score > ri.score
252+ }
253+ if li.exactURLMatch != ri.exactURLMatch {
254+ return li.exactURLMatch
255+ }
256+ if li.prefixURL != ri.prefixURL {
257+ return li.prefixURL
258+ }
259+ if li.urlMatch != ri.urlMatch {
260+ return li.urlMatch
261+ }
262+ if li.prefixTitle != ri.prefixTitle {
263+ return li.prefixTitle
264+ }
265+ if li.match.Entry.VisitCount != ri.match.Entry.VisitCount {
266+ return li.match.Entry.VisitCount > ri.match.Entry.VisitCount
267+ }
268+ if !li.match.Entry.LastVisited.Equal(ri.match.Entry.LastVisited) {
269+ return li.match.Entry.LastVisited.After(ri.match.Entry.LastVisited)
270+ }
271+ return li.match.Entry.URL < ri.match.Entry.URL
272+ })
273+}
274+
275+func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate {
276+ candidate, ok := candidates[entry.ID]
277+ if ok {
278+ return candidate
279+ }
280+ candidate = &historySearchCandidate{entry: entry}
281+ candidates[entry.ID] = candidate
282+ return candidate
283+}
284+
285+func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 {
286+ if candidate == nil || candidate.entry == nil {
287+ return 0
288+ }
289+
290+ urlLower := strings.ToLower(candidate.entry.URL)
291+ titleLower := strings.ToLower(candidate.entry.Title)
292+ hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL))
293+
294+ score := 0.0
295+ if candidate.urlMatch {
296+ score += historyURLMatchScore
297+ }
298+ if candidate.titleMatch {
299+ score += historyTitleMatchScore
300+ }
301+
302+ score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower)
303+ score += scoreHistoryWords(words, hostLower, urlLower, titleLower)
304+
305+ if looksLikeHostStyleQuery(queryLower) {
306+ pathDepth := historyPathDepth(candidate.entry.URL)
307+ score -= float64(pathDepth * historyPathDepthPenalty)
308+ if pathDepth == 0 {
309+ score += historyRootPathBonus
310+ }
311+ }
312+
313+ score += float64(candidate.domainBoost) * historyDomainBoostScore
314+ score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap)
315+ if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour {
316+ score += historyRecentVisitScore
317+ }
318+ return score
319+}
320+
321+func scoreQueryMatch(
322+ candidate *historySearchCandidate,
323+ queryLower, hostLower, urlLower, titleLower string,
324+) float64 {
325+ if queryLower == "" {
326+ return 0
327+ }
328+
329+ switch {
330+ case hostLower == queryLower:
331+ candidate.exactURLMatch = true
332+ return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
333+ case strings.TrimPrefix(hostLower, "www.") == queryLower:
334+ candidate.exactURLMatch = true
335+ return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
336+ case strings.HasPrefix(hostLower, queryLower):
337+ candidate.prefixURL = true
338+ return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
339+ case strings.HasPrefix(urlLower, queryLower):
340+ candidate.prefixURL = true
341+ return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
342+ case strings.Contains(urlLower, queryLower):
343+ return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
344+ default:
345+ return scoreTitleQueryMatch(candidate, queryLower, titleLower)
346+ }
347+}
348+
349+func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 {
350+ switch {
351+ case strings.HasPrefix(titleLower, queryLower):
352+ candidate.prefixTitle = true
353+ return historyTitlePrefixScore
354+ case strings.Contains(titleLower, queryLower):
355+ return historyTitleContainsScore
356+ default:
357+ return 0
358+ }
359+}
360+
361+func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 {
362+ score := 0.0
363+ for _, word := range words {
364+ wordLower := strings.ToLower(word)
365+ if wordLower == "" {
366+ continue
367+ }
368+ if strings.HasPrefix(hostLower, wordLower) {
369+ score += historyWordHostPrefixScore
370+ } else if strings.Contains(urlLower, wordLower) {
371+ score += historyWordURLContainsScore
372+ }
373+ if strings.HasPrefix(titleLower, wordLower) {
374+ score += historyWordTitlePrefixScore
375+ } else if strings.Contains(titleLower, wordLower) {
376+ score += historyWordTitleScore
377+ }
378+ }
379+ return score
380+}
381+
382+func looksLikeHostStyleQuery(query string) bool {
383+ return query != "" && !strings.ContainsAny(query, " /?#=&")
384+}
385+
386+func historyPathDepth(rawURL string) int {
387+ parsed, err := url.Parse(rawURL)
388+ if err != nil {
389+ return 0
390+ }
391+ trimmed := strings.Trim(parsed.Path, "/")
392+ if trimmed == "" {
393+ return 0
394+ }
395+ return len(strings.Split(trimmed, "/"))
396+}
397+
398+func maxInt64(left, right int64) int64 {
399+ if left > right {
400+ return left
401+ }
402+ return right
403+}
404+
405+func minFloat64(left, right float64) float64 {
406+ if left < right {
407+ return left
408+ }
409+ return right
410+}
411+
157412 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
158413 func sanitizeFTS5QueryTokens(query string) []string {
159414 normalized := normalizeFTSSeparators(query)
@@ -303,17 +558,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
303558 return entries, nil
304559 }
305560
306-func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
561+func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
307562 // Skip incrementing about:blank - it should always have visit_count = 1
308- if url == aboutBlankURL {
563+ if rawURL == aboutBlankURL {
309564 return nil
310565 }
311- return r.queries.IncrementVisitCount(ctx, url)
566+ return r.queries.IncrementVisitCount(ctx, rawURL)
312567 }
313568
314-func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
569+func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error {
315570 // Skip incrementing about:blank - it should always have visit_count = 1
316- if url == aboutBlankURL {
571+ if rawURL == aboutBlankURL {
317572 return nil
318573 }
319574 if delta <= 0 {
@@ -321,7 +576,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
321576 }
322577 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323578 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
324- Url: url,
579+ Url: rawURL,
325580 })
326581 }
327582
@@ -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 = 6022 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.Uint6445 var expectedFTSFailureCount atomic.Uint64
22 46
23 type historyRepo struct {47 type historyRepo struct {
48+ db *sql.DB
24 queries *sqlc.Queries49 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 nil85 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, nil92 return nil, nil
@@ -71,7 +96,28 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
71 return historyFromRow(row), nil96 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) {
117+ if limit <= 0 {
118+ return []entity.HistoryMatch{}, nil
119+ }
120+
75 words := sanitizeFTS5QueryTokens(query)121 words := sanitizeFTS5QueryTokens(query)
76 if len(words) == 0 {122 if len(words) == 0 {
77 return []entity.HistoryMatch{}, nil123 return []entity.HistoryMatch{}, nil
@@ -106,40 +152,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
106 titleRows = []sqlc.History{}152 titleRows = []sqlc.History{}
107 }153 }
108 154
109- // Merge results by interleaving URL and title matches for balanced results155+ ranked := rankHistoryMatches(query, words, urlRows, titleRows)
110- seen := make(map[int64]bool)156+ if limit < len(ranked) {
111- var matches []entity.HistoryMatch157+ 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 }158 }
141-159+ return ranked, nil
142- return matches, nil
143 }160 }
144 161
145 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {162 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {
@@ -154,6 +171,244 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
154 }171 }
155 }172 }
156 173
174+func nullString(value string) sql.NullString {
175+ return sql.NullString{String: value, Valid: value != ""}
176+}
177+
178+type historySearchCandidate struct {
179+ entry *entity.HistoryEntry
180+ score float64
181+ urlMatch bool
182+ titleMatch bool
183+ domainBoost int64
184+ exactURLMatch bool
185+ prefixURL bool
186+ prefixTitle bool
187+}
188+
189+func rankHistoryMatches(
190+ query string,
191+ words []string,
192+ urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow,
193+ titleRows []sqlc.History,
194+) []entity.HistoryMatch {
195+ candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows))
196+
197+ for i := range urlRows {
198+ row := urlRows[i]
199+ entry := historyFromDomainBoostRow(row)
200+ candidate := ensureHistoryCandidate(candidates, entry)
201+ candidate.urlMatch = true
202+ candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost)
203+ }
204+
205+ for i := range titleRows {
206+ row := titleRows[i]
207+ entry := historyFromRow(row)
208+ candidate := ensureHistoryCandidate(candidates, entry)
209+ candidate.titleMatch = true
210+ }
211+
212+ queryLower := strings.ToLower(strings.TrimSpace(query))
213+ ranked := make([]rankedHistoryMatch, 0, len(candidates))
214+ for _, candidate := range candidates {
215+ candidate.score = computeHistoryCandidateScore(candidate, queryLower, words)
216+ ranked = append(ranked, rankedHistoryMatch{
217+ match: entity.HistoryMatch{
218+ Entry: candidate.entry,
219+ Score: candidate.score,
220+ },
221+ score: candidate.score,
222+ exactURLMatch: candidate.exactURLMatch,
223+ prefixURL: candidate.prefixURL,
224+ urlMatch: candidate.urlMatch,
225+ prefixTitle: candidate.prefixTitle,
226+ })
227+ }
228+
229+ sortRankedHistoryMatches(ranked)
230+
231+ matches := make([]entity.HistoryMatch, len(ranked))
232+ for i := range ranked {
233+ matches[i] = ranked[i].match
234+ }
235+ return matches
236+}
237+
238+type rankedHistoryMatch struct {
239+ match entity.HistoryMatch
240+ score float64
241+ exactURLMatch bool
242+ prefixURL bool
243+ urlMatch bool
244+ prefixTitle bool
245+}
246+
247+func sortRankedHistoryMatches(items []rankedHistoryMatch) {
248+ sort.Slice(items, func(i, j int) bool {
249+ li, ri := &items[i], &items[j]
250+ if li.score != ri.score {
251+ return li.score > ri.score
252+ }
253+ if li.exactURLMatch != ri.exactURLMatch {
254+ return li.exactURLMatch
255+ }
256+ if li.prefixURL != ri.prefixURL {
257+ return li.prefixURL
258+ }
259+ if li.urlMatch != ri.urlMatch {
260+ return li.urlMatch
261+ }
262+ if li.prefixTitle != ri.prefixTitle {
263+ return li.prefixTitle
264+ }
265+ if li.match.Entry.VisitCount != ri.match.Entry.VisitCount {
266+ return li.match.Entry.VisitCount > ri.match.Entry.VisitCount
267+ }
268+ if !li.match.Entry.LastVisited.Equal(ri.match.Entry.LastVisited) {
269+ return li.match.Entry.LastVisited.After(ri.match.Entry.LastVisited)
270+ }
271+ return li.match.Entry.URL < ri.match.Entry.URL
272+ })
273+}
274+
275+func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate {
276+ candidate, ok := candidates[entry.ID]
277+ if ok {
278+ return candidate
279+ }
280+ candidate = &historySearchCandidate{entry: entry}
281+ candidates[entry.ID] = candidate
282+ return candidate
283+}
284+
285+func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 {
286+ if candidate == nil || candidate.entry == nil {
287+ return 0
288+ }
289+
290+ urlLower := strings.ToLower(candidate.entry.URL)
291+ titleLower := strings.ToLower(candidate.entry.Title)
292+ hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL))
293+
294+ score := 0.0
295+ if candidate.urlMatch {
296+ score += historyURLMatchScore
297+ }
298+ if candidate.titleMatch {
299+ score += historyTitleMatchScore
300+ }
301+
302+ score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower)
303+ score += scoreHistoryWords(words, hostLower, urlLower, titleLower)
304+
305+ if looksLikeHostStyleQuery(queryLower) {
306+ pathDepth := historyPathDepth(candidate.entry.URL)
307+ score -= float64(pathDepth * historyPathDepthPenalty)
308+ if pathDepth == 0 {
309+ score += historyRootPathBonus
310+ }
311+ }
312+
313+ score += float64(candidate.domainBoost) * historyDomainBoostScore
314+ score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap)
315+ if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour {
316+ score += historyRecentVisitScore
317+ }
318+ return score
319+}
320+
321+func scoreQueryMatch(
322+ candidate *historySearchCandidate,
323+ queryLower, hostLower, urlLower, titleLower string,
324+) float64 {
325+ if queryLower == "" {
326+ return 0
327+ }
328+
329+ switch {
330+ case hostLower == queryLower:
331+ candidate.exactURLMatch = true
332+ return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
333+ case strings.TrimPrefix(hostLower, "www.") == queryLower:
334+ candidate.exactURLMatch = true
335+ return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
336+ case strings.HasPrefix(hostLower, queryLower):
337+ candidate.prefixURL = true
338+ return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
339+ case strings.HasPrefix(urlLower, queryLower):
340+ candidate.prefixURL = true
341+ return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
342+ case strings.Contains(urlLower, queryLower):
343+ return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
344+ default:
345+ return scoreTitleQueryMatch(candidate, queryLower, titleLower)
346+ }
347+}
348+
349+func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 {
350+ switch {
351+ case strings.HasPrefix(titleLower, queryLower):
352+ candidate.prefixTitle = true
353+ return historyTitlePrefixScore
354+ case strings.Contains(titleLower, queryLower):
355+ return historyTitleContainsScore
356+ default:
357+ return 0
358+ }
359+}
360+
361+func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 {
362+ score := 0.0
363+ for _, word := range words {
364+ wordLower := strings.ToLower(word)
365+ if wordLower == "" {
366+ continue
367+ }
368+ if strings.HasPrefix(hostLower, wordLower) {
369+ score += historyWordHostPrefixScore
370+ } else if strings.Contains(urlLower, wordLower) {
371+ score += historyWordURLContainsScore
372+ }
373+ if strings.HasPrefix(titleLower, wordLower) {
374+ score += historyWordTitlePrefixScore
375+ } else if strings.Contains(titleLower, wordLower) {
376+ score += historyWordTitleScore
377+ }
378+ }
379+ return score
380+}
381+
382+func looksLikeHostStyleQuery(query string) bool {
383+ return query != "" && !strings.ContainsAny(query, " /?#=&")
384+}
385+
386+func historyPathDepth(rawURL string) int {
387+ parsed, err := url.Parse(rawURL)
388+ if err != nil {
389+ return 0
390+ }
391+ trimmed := strings.Trim(parsed.Path, "/")
392+ if trimmed == "" {
393+ return 0
394+ }
395+ return len(strings.Split(trimmed, "/"))
396+}
397+
398+func maxInt64(left, right int64) int64 {
399+ if left > right {
400+ return left
401+ }
402+ return right
403+}
404+
405+func minFloat64(left, right float64) float64 {
406+ if left < right {
407+ return left
408+ }
409+ return right
410+}
411+
157 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.412 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
158 func sanitizeFTS5QueryTokens(query string) []string {413 func sanitizeFTS5QueryTokens(query string) []string {
159 normalized := normalizeFTSSeparators(query)414 normalized := normalizeFTSSeparators(query)
@@ -303,17 +558,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
303 return entries, nil558 return entries, nil
304 }559 }
305 560
306-func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {561+func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
307 // Skip incrementing about:blank - it should always have visit_count = 1562 // Skip incrementing about:blank - it should always have visit_count = 1
308- if url == aboutBlankURL {563+ if rawURL == aboutBlankURL {
309 return nil564 return nil
310 }565 }
311- return r.queries.IncrementVisitCount(ctx, url)566+ return r.queries.IncrementVisitCount(ctx, rawURL)
312 }567 }
313 568
314-func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {569+func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error {
315 // Skip incrementing about:blank - it should always have visit_count = 1570 // Skip incrementing about:blank - it should always have visit_count = 1
316- if url == aboutBlankURL {571+ if rawURL == aboutBlankURL {
317 return nil572 return nil
318 }573 }
319 if delta <= 0 {574 if delta <= 0 {
@@ -321,7 +576,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
321 }576 }
322 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{577 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},578 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
324- Url: url,579+ Url: rawURL,
325 })580 })
326 }581 }
327 582
A internal/infrastructure/persistence/sqlite/history_repo_rank_test.go +98 -0
@@ -0,0 +1,98 @@
1+package sqlite
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestSortRankedHistoryMatches(t *testing.T) {
11+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
12+ older := now.Add(-24 * time.Hour)
13+
14+ items := []rankedHistoryMatch{
15+ {
16+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 1, URL: "z-url", VisitCount: 5, LastVisited: now}, Score: 10},
17+ score: 10,
18+ exactURLMatch: false,
19+ prefixURL: false,
20+ urlMatch: false,
21+ prefixTitle: false,
22+ },
23+ {
24+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 2, URL: "a-url", VisitCount: 5, LastVisited: now}, Score: 20},
25+ score: 20,
26+ exactURLMatch: false,
27+ prefixURL: false,
28+ urlMatch: false,
29+ prefixTitle: false,
30+ },
31+ {
32+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 3, URL: "b-url", VisitCount: 5, LastVisited: now}, Score: 10},
33+ score: 10,
34+ exactURLMatch: true,
35+ prefixURL: false,
36+ urlMatch: false,
37+ prefixTitle: false,
38+ },
39+ {
40+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 4, URL: "c-url", VisitCount: 5, LastVisited: now}, Score: 10},
41+ score: 10,
42+ exactURLMatch: false,
43+ prefixURL: true,
44+ urlMatch: false,
45+ prefixTitle: false,
46+ },
47+ {
48+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 5, URL: "d-url", VisitCount: 5, LastVisited: now}, Score: 10},
49+ score: 10,
50+ exactURLMatch: false,
51+ prefixURL: false,
52+ urlMatch: true,
53+ prefixTitle: false,
54+ },
55+ {
56+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 6, URL: "e-url", VisitCount: 5, LastVisited: now}, Score: 10},
57+ score: 10,
58+ exactURLMatch: false,
59+ prefixURL: false,
60+ urlMatch: false,
61+ prefixTitle: true,
62+ },
63+ {
64+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 7, URL: "f-url", VisitCount: 10, LastVisited: now}, Score: 10},
65+ score: 10,
66+ exactURLMatch: false,
67+ prefixURL: false,
68+ urlMatch: false,
69+ prefixTitle: false,
70+ },
71+ {
72+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 8, URL: "g-url", VisitCount: 5, LastVisited: now}, Score: 10},
73+ score: 10,
74+ exactURLMatch: false,
75+ prefixURL: false,
76+ urlMatch: false,
77+ prefixTitle: false,
78+ },
79+ {
80+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 9, URL: "h-url", VisitCount: 5, LastVisited: older}, Score: 10},
81+ score: 10,
82+ exactURLMatch: false,
83+ prefixURL: false,
84+ urlMatch: false,
85+ prefixTitle: false,
86+ },
87+ }
88+
89+ sortRankedHistoryMatches(items)
90+
91+ wantOrder := []int64{2, 3, 4, 5, 6, 7, 8, 1, 9}
92+ for i, want := range wantOrder {
93+ got := items[i].match.Entry.ID
94+ if got != want {
95+ t.Errorf("position %d: want ID %d, got ID %d", i, want, got)
96+ }
97+ }
98+}
@@ -0,0 +1,98 @@
1+package sqlite
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestSortRankedHistoryMatches(t *testing.T) {
11+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
12+ older := now.Add(-24 * time.Hour)
13+
14+ items := []rankedHistoryMatch{
15+ {
16+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 1, URL: "z-url", VisitCount: 5, LastVisited: now}, Score: 10},
17+ score: 10,
18+ exactURLMatch: false,
19+ prefixURL: false,
20+ urlMatch: false,
21+ prefixTitle: false,
22+ },
23+ {
24+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 2, URL: "a-url", VisitCount: 5, LastVisited: now}, Score: 20},
25+ score: 20,
26+ exactURLMatch: false,
27+ prefixURL: false,
28+ urlMatch: false,
29+ prefixTitle: false,
30+ },
31+ {
32+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 3, URL: "b-url", VisitCount: 5, LastVisited: now}, Score: 10},
33+ score: 10,
34+ exactURLMatch: true,
35+ prefixURL: false,
36+ urlMatch: false,
37+ prefixTitle: false,
38+ },
39+ {
40+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 4, URL: "c-url", VisitCount: 5, LastVisited: now}, Score: 10},
41+ score: 10,
42+ exactURLMatch: false,
43+ prefixURL: true,
44+ urlMatch: false,
45+ prefixTitle: false,
46+ },
47+ {
48+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 5, URL: "d-url", VisitCount: 5, LastVisited: now}, Score: 10},
49+ score: 10,
50+ exactURLMatch: false,
51+ prefixURL: false,
52+ urlMatch: true,
53+ prefixTitle: false,
54+ },
55+ {
56+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 6, URL: "e-url", VisitCount: 5, LastVisited: now}, Score: 10},
57+ score: 10,
58+ exactURLMatch: false,
59+ prefixURL: false,
60+ urlMatch: false,
61+ prefixTitle: true,
62+ },
63+ {
64+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 7, URL: "f-url", VisitCount: 10, LastVisited: now}, Score: 10},
65+ score: 10,
66+ exactURLMatch: false,
67+ prefixURL: false,
68+ urlMatch: false,
69+ prefixTitle: false,
70+ },
71+ {
72+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 8, URL: "g-url", VisitCount: 5, LastVisited: now}, Score: 10},
73+ score: 10,
74+ exactURLMatch: false,
75+ prefixURL: false,
76+ urlMatch: false,
77+ prefixTitle: false,
78+ },
79+ {
80+ match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 9, URL: "h-url", VisitCount: 5, LastVisited: older}, Score: 10},
81+ score: 10,
82+ exactURLMatch: false,
83+ prefixURL: false,
84+ urlMatch: false,
85+ prefixTitle: false,
86+ },
87+ }
88+
89+ sortRankedHistoryMatches(items)
90+
91+ wantOrder := []int64{2, 3, 4, 5, 6, 7, 8, 1, 9}
92+ for i, want := range wantOrder {
93+ got := items[i].match.Entry.ID
94+ if got != want {
95+ t.Errorf("position %d: want ID %d, got ID %d", i, want, got)
96+ }
97+ }
98+}
M internal/infrastructure/persistence/sqlite/history_repo_test.go +136 -0
@@ -627,6 +627,119 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
627627 require.NotEmpty(t, results, "slash-separated query should match history")
628628 }
629629
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+
630743 func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
631744 ctx := historyTestCtx()
632745 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -696,3 +809,26 @@ func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
696809 assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
697810 assert.Equal(t, int64(1), results[1].VisitCount)
698811 }
812+
813+func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) {
814+ ctx := historyTestCtx()
815+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
816+
817+ db, err := sqlite.NewConnection(ctx, dbPath)
818+ require.NoError(t, err)
819+ t.Cleanup(func() { _ = db.Close() })
820+
821+ repo := sqlite.NewHistoryRepository(db)
822+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
823+ URL: "https://github.com",
824+ Title: "GitHub",
825+ }))
826+
827+ results, err := repo.Search(ctx, "git", 0)
828+ require.NoError(t, err)
829+ assert.Empty(t, results)
830+
831+ results, err = repo.Search(ctx, "git", -1)
832+ require.NoError(t, err)
833+ assert.Empty(t, results)
834+}
@@ -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")
@@ -696,3 +809,26 @@ func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
696 assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")809 assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
697 assert.Equal(t, int64(1), results[1].VisitCount)810 assert.Equal(t, int64(1), results[1].VisitCount)
698 }811 }
812+
813+func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) {
814+ ctx := historyTestCtx()
815+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
816+
817+ db, err := sqlite.NewConnection(ctx, dbPath)
818+ require.NoError(t, err)
819+ t.Cleanup(func() { _ = db.Close() })
820+
821+ repo := sqlite.NewHistoryRepository(db)
822+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
823+ URL: "https://github.com",
824+ Title: "GitHub",
825+ }))
826+
827+ results, err := repo.Search(ctx, "git", 0)
828+ require.NoError(t, err)
829+ assert.Empty(t, results)
830+
831+ results, err = repo.Search(ctx, "git", -1)
832+ require.NoError(t, err)
833+ assert.Empty(t, results)
834+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +12 -0
@@ -123,6 +123,18 @@ func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*ent
123123 return r.repo.FindByURL(ctx, url)
124124 }
125125
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+
126138 func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
127139 if err := r.init(ctx); err != nil {
128140 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, err140 return nil, err
M internal/ui/app.go +0 -4
@@ -685,9 +685,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
685685 }
686686 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
687687
688- // Create autocomplete use case for inline suggestions
689- autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
690-
691688 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
692689 a.omniboxCfg = component.OmniboxConfig{
693690 HistoryUC: a.deps.HistoryUC,
@@ -695,7 +692,6 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
695692 FaviconAdapter: a.faviconAdapter,
696693 CopyURLUC: a.deps.CopyURLUC,
697694 ShortcutsUC: shortcutsUC,
698- AutocompleteUC: autocompleteUC,
699695 DefaultSearch: a.deps.Config.DefaultSearchEngine,
700696 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
701697 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 +192 -93
@@ -106,7 +106,6 @@ type Omnibox struct {
106106 faviconAdapter *adapter.FaviconAdapter
107107 copyURLUC *usecase.CopyURLUseCase
108108 shortcutsUC *usecase.SearchShortcutsUseCase
109- autocompleteUC *usecase.AutocompleteUseCase
110109 defaultSearch string
111110 initialBehavior string
112111 ctx context.Context
@@ -150,7 +149,6 @@ type OmniboxConfig struct {
150149 FaviconAdapter *adapter.FaviconAdapter
151150 CopyURLUC *usecase.CopyURLUseCase
152151 ShortcutsUC *usecase.SearchShortcutsUseCase
153- AutocompleteUC *usecase.AutocompleteUseCase
154152 DefaultSearch string
155153 InitialBehavior string
156154 UIScale float64 // UI scale for favicon sizing
@@ -178,7 +176,6 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
178176 faviconAdapter: cfg.FaviconAdapter,
179177 copyURLUC: cfg.CopyURLUC,
180178 shortcutsUC: cfg.ShortcutsUC,
181- autocompleteUC: cfg.AutocompleteUC,
182179 defaultSearch: cfg.DefaultSearch,
183180 initialBehavior: cfg.InitialBehavior,
184181 onToast: cfg.OnToast,
@@ -540,18 +537,20 @@ func (o *Omnibox) initList() error {
540537 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
541538
542539 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
540+ o.restoreEntryToRealInput()
543541 if rowPtr == 0 {
544542 o.mu.Lock()
545543 o.selectedIndex = -1
546544 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+ }
554552 }
553+ o.updateGhostFromSelection()
555554 }
556555 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
557556 o.listBox.ConnectRowSelected(&rowSelectedCb)
@@ -700,12 +699,10 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
700699
701700 case uint(gdk.KEY_Up):
702701 o.selectPrevious()
703- o.updateGhostFromSelection()
704702 return true
705703
706704 case uint(gdk.KEY_Down):
707705 o.selectNext()
708- o.updateGhostFromSelection()
709706 return true
710707
711708 case uint(gdk.KEY_Tab):
@@ -1060,7 +1057,48 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10601057
10611058 // updateGhostFromSelection updates ghost text based on the currently selected row.
10621059 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()
10641102 }
10651103
10661104 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
@@ -1091,22 +1129,13 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
10911129 return
10921130 }
10931131
1132+ targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites)
1133+
10941134 if entryText != "" {
1095- o.resolveGhostCompletion(entryText, mode, suggestions, favorites)
1135+ o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites)
10961136 return
10971137 }
10981138
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-
11101139 if targetURL == "" {
11111140 o.clearGhostTextIfInput(entryText)
11121141 return
@@ -1119,52 +1148,34 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
11191148 o.clearGhostTextIfInput(entryText)
11201149 }
11211150
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+) {
11281158 leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
11291159 if !ok {
11301160 o.clearGhostTextIfInput(entryText)
11311161 return
11321162 }
11331163
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-
11491164 o.mu.Lock()
11501165 o.ghostToken++
11511166 ghostToken := o.ghostToken
1152- searchToken := o.searchToken
11531167 o.mu.Unlock()
11541168
11551169 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) {
11611172 return
11621173 }
1163- if suggestion == nil {
1174+ if !found {
11641175 o.clearGhostTextIfInput(entryText)
11651176 return
11661177 }
1167- fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix)
1178+ fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
11681179 if fullText == "" || suffix == "" {
11691180 o.clearGhostTextIfInput(entryText)
11701181 return
@@ -1223,11 +1234,54 @@ func extractHostForCompletion(raw string) string {
12231234 return strings.TrimSpace(trimmed)
12241235 }
12251236
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 {
12271282 o.mu.RLock()
12281283 defer o.mu.RUnlock()
12291284 return o.visible &&
1230- o.searchToken == searchToken &&
12311285 o.ghostToken == ghostToken &&
12321286 o.realInput == query
12331287 }
@@ -1263,8 +1317,8 @@ func (o *Omnibox) performSearch() {
12631317 o.mu.Unlock()
12641318
12651319 if strings.HasPrefix(query, "!") {
1266- o.updateBangDetection(query)
1267- o.loadBangSuggestions(query)
1320+ o.updateBangDetection(query, token)
1321+ o.loadBangSuggestions(query, token)
12681322 return
12691323 }
12701324
@@ -1379,8 +1433,18 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
13791433 favCh := make(chan map[string]struct{}, 1)
13801434
13811435 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+ }
13841448 historyCh <- historyResult{results, err}
13851449 }()
13861450
@@ -1424,23 +1488,14 @@ func (o *Omnibox) loadFavorites(query string, token uint64) {
14241488 go func() {
14251489 ctx := o.ctx
14261490 log := logging.FromContext(ctx)
1427- results, err := o.favoritesUC.GetAll(ctx)
1491+ results, err := o.favoritesUC.FilterForOmnibox(ctx, query)
14281492 if err != nil {
14291493 log.Error().Err(err).Msg("failed to load favorites")
14301494 return
14311495 }
14321496
14331497 favorites := make([]Favorite, 0, len(results))
1434- queryLower := strings.ToLower(query)
14351498 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- }
14441499 favorites = append(favorites, Favorite{
14451500 ID: int64(r.ID),
14461501 URL: r.URL,
@@ -1453,7 +1508,7 @@ func (o *Omnibox) loadFavorites(query string, token uint64) {
14531508 }()
14541509 }
14551510
1456-func (o *Omnibox) loadBangSuggestions(query string) {
1511+func (o *Omnibox) loadBangSuggestions(query string, token uint64) {
14571512 if o.shortcutsUC == nil {
14581513 return
14591514 }
@@ -1462,21 +1517,21 @@ func (o *Omnibox) loadBangSuggestions(query string) {
14621517 for i, s := range output.Suggestions {
14631518 suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
14641519 }
1465- o.idleAddUpdateBangSuggestions(suggestions)
1520+ o.idleAddUpdateBangSuggestions(suggestions, query, token)
14661521 }
14671522
1468-func (o *Omnibox) updateBangDetection(query string) {
1523+func (o *Omnibox) updateBangDetection(query string, token uint64) {
14691524 if o.shortcutsUC == nil {
1470- o.clearDetectedBang()
1525+ o.clearDetectedBang(query, token)
14711526 return
14721527 }
14731528 output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
14741529 if output.Key == "" {
1475- o.clearDetectedBang()
1530+ o.clearDetectedBang(query, token)
14761531 return
14771532 }
14781533
1479- o.setDetectedBang(output.Key, output.Description)
1534+ o.setDetectedBang(output.Key, output.Description, query, token)
14801535 }
14811536
14821537 func (o *Omnibox) clearBangState() {
@@ -1485,10 +1540,14 @@ func (o *Omnibox) clearBangState() {
14851540 o.bangSuggestions = nil
14861541 o.mu.Unlock()
14871542
1488- o.clearDetectedBang()
1543+ o.mu.RLock()
1544+ query := o.realInput
1545+ token := o.searchToken
1546+ o.mu.RUnlock()
1547+ o.clearDetectedBang(query, token)
14891548 }
14901549
1491-func (o *Omnibox) setDetectedBang(key, description string) {
1550+func (o *Omnibox) setDetectedBang(key, description, query string, token uint64) {
14921551 o.mu.Lock()
14931552 o.detectedBang = key
14941553 o.mu.Unlock()
@@ -1498,6 +1557,9 @@ func (o *Omnibox) setDetectedBang(key, description string) {
14981557 }
14991558 label := description
15001559 var cb glib.SourceFunc = func(uintptr) bool {
1560+ if !o.isBangUpdateCurrent(query, token) {
1561+ return false
1562+ }
15011563 o.entry.AddCssClass("omnibox-entry-bang-active")
15021564 o.bangBadge.SetText(label)
15031565 o.bangBadge.SetVisible(true)
@@ -1506,7 +1568,7 @@ func (o *Omnibox) setDetectedBang(key, description string) {
15061568 glib.IdleAdd(&cb, 0)
15071569 }
15081570
1509-func (o *Omnibox) clearDetectedBang() {
1571+func (o *Omnibox) clearDetectedBang(query string, token uint64) {
15101572 o.mu.Lock()
15111573 o.detectedBang = ""
15121574 o.mu.Unlock()
@@ -1515,6 +1577,9 @@ func (o *Omnibox) clearDetectedBang() {
15151577 return
15161578 }
15171579 var cb glib.SourceFunc = func(uintptr) bool {
1580+ if !o.isBangUpdateCurrent(query, token) {
1581+ return false
1582+ }
15181583 o.entry.RemoveCssClass("omnibox-entry-bang-active")
15191584 o.bangBadge.SetVisible(false)
15201585 return false
@@ -1522,8 +1587,11 @@ func (o *Omnibox) clearDetectedBang() {
15221587 glib.IdleAdd(&cb, 0)
15231588 }
15241589
1525-func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
1590+func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion, query string, token uint64) {
15261591 var cb glib.SourceFunc = func(uintptr) bool {
1592+ if !o.isBangUpdateCurrent(query, token) {
1593+ return false
1594+ }
15271595 o.updateBangSuggestions(suggestions)
15281596 return false
15291597 }
@@ -1535,6 +1603,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
15351603 o.bangMode = true
15361604 o.bangSuggestions = suggestions
15371605 o.selectedIndex = -1
1606+ o.hasNavigated = false
15381607 o.mu.Unlock()
15391608
15401609 o.rebuildList()
@@ -1544,10 +1613,6 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
15441613 o.scrolledWin.SetVisible(rowCount > 0)
15451614 }
15461615 o.resizeAndCenter(rowCount)
1547-
1548- if rowCount > 0 {
1549- o.selectIndex(0)
1550- }
15511616 }
15521617
15531618 // updateSuggestions updates the list with history suggestions.
@@ -1569,9 +1634,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
15691634 }
15701635 o.resizeAndCenter(rowCount)
15711636
1572- // Select first item if available and update ghost text
15731637 if rowCount > 0 {
1574- o.selectIndex(0)
15751638 o.updateGhostFromSelectionWithInput(query)
15761639 } else {
15771640 o.clearGhostTextIfInput(query)
@@ -1597,9 +1660,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
15971660 }
15981661 o.resizeAndCenter(rowCount)
15991662
1600- // Select first item if available and update ghost text
16011663 if rowCount > 0 {
1602- o.selectIndex(0)
16031664 o.updateGhostFromSelectionWithInput(query)
16041665 } else {
16051666 o.clearGhostTextIfInput(query)
@@ -1713,6 +1774,7 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac
17131774 return nil
17141775 }
17151776 row.AddCssClass("omnibox-row")
1777+ o.attachRowHoverSelection(row, index)
17161778
17171779 const rowSpacing = 8
17181780 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
@@ -1783,6 +1845,33 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac
17831845 return row
17841846 }
17851847
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.mu.RLock()
1856+ realInput := o.realInput
1857+ hasGhostText := o.hasGhostText
1858+ hasNavigated := o.hasNavigated
1859+ o.mu.RUnlock()
1860+
1861+ if !shouldPromoteHoverSelection(realInput, hasGhostText, hasNavigated) {
1862+ return
1863+ }
1864+ o.selectIndex(index)
1865+ }
1866+ o.retainedCallbacks = append(o.retainedCallbacks, enterCb)
1867+ motionCtrl.ConnectEnter(&enterCb)
1868+ row.AddController(&motionCtrl.EventController)
1869+}
1870+
1871+func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bool) bool {
1872+ return realInput == "" && !hasGhostText && !hasNavigated
1873+}
1874+
17861875 // createSuggestionRow creates a ListBoxRow for a suggestion.
17871876 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
17881877 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
@@ -1809,13 +1898,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
18091898
18101899 // selectIndex selects a row by index.
18111900 func (o *Omnibox) selectIndex(index int) {
1901+ o.mu.Lock()
1902+ o.selectedIndex = index
1903+ o.mu.Unlock()
1904+
18121905 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
18131906 // which also acquires the mutex, causing deadlock
18141907 row := o.listBox.GetRowAtIndex(index)
18151908 if row != nil {
18161909 o.listBox.SelectRow(row)
1817- // selectedIndex is updated by rowSelectedCb
1910+ return
18181911 }
1912+
1913+ o.updateGhostFromSelection()
18191914 }
18201915
18211916 // selectNext moves selection down.
@@ -1938,16 +2033,14 @@ func (o *Omnibox) navigateToSelected() {
19382033 o.entry.SetPosition(-1)
19392034 return
19402035 }
2036+ return
19412037 }
19422038
19432039 var targetURL string
1944- if shouldPreferTypedURLNavigation(entryText) {
1945- targetURL = o.buildURL(entryText)
1946- } else if idx >= 0 {
2040+ if idx >= 0 {
19472041 // If user has selected a result, navigate to that result.
19482042 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
19492043 } else {
1950- // No selection - use entry text as URL/search.
19512044 targetURL = o.buildURL(entryText)
19522045 }
19532046
@@ -2410,6 +2503,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
24102503 return o.visible && o.searchToken == token
24112504 }
24122505
2506+func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2507+ o.mu.RLock()
2508+ defer o.mu.RUnlock()
2509+ return o.visible && o.searchToken == token && o.realInput == query
2510+}
2511+
24132512 // Widget returns the omnibox widget for embedding in an overlay.
24142513 func (o *Omnibox) Widget() *gtk.Widget {
24152514 if o.outerBox == nil {
@@ -106,7 +106,6 @@ type Omnibox struct {
106 faviconAdapter *adapter.FaviconAdapter106 faviconAdapter *adapter.FaviconAdapter
107 copyURLUC *usecase.CopyURLUseCase107 copyURLUC *usecase.CopyURLUseCase
108 shortcutsUC *usecase.SearchShortcutsUseCase108 shortcutsUC *usecase.SearchShortcutsUseCase
109- autocompleteUC *usecase.AutocompleteUseCase
110 defaultSearch string109 defaultSearch string
111 initialBehavior string110 initialBehavior string
112 ctx context.Context111 ctx context.Context
@@ -150,7 +149,6 @@ type OmniboxConfig struct {
150 FaviconAdapter *adapter.FaviconAdapter149 FaviconAdapter *adapter.FaviconAdapter
151 CopyURLUC *usecase.CopyURLUseCase150 CopyURLUC *usecase.CopyURLUseCase
152 ShortcutsUC *usecase.SearchShortcutsUseCase151 ShortcutsUC *usecase.SearchShortcutsUseCase
153- AutocompleteUC *usecase.AutocompleteUseCase
154 DefaultSearch string152 DefaultSearch string
155 InitialBehavior string153 InitialBehavior string
156 UIScale float64 // UI scale for favicon sizing154 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 = -1543 o.selectedIndex = -1
546 o.mu.Unlock()544 o.mu.Unlock()
547- return545+ } 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 true702 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 true706 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 return1129 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 return1136 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 return1141 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- return1154+ 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 return1161 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.ghostToken1166 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 return1172 return
1162 }1173 }
1163- if suggestion == nil {1174+ if !found {
1164 o.clearGhostTextIfInput(entryText)1175 o.clearGhostTextIfInput(entryText)
1165 return1176 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 return1181 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 == query1286 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 return1322 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 needed1436+ 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.ctx1489 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 return1494 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 return1513 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 return1526 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 return1531 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 = nil1540 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 = key1552 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 := description1558 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 return1577 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 false1585 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 false1596 return false
1529 }1597 }
@@ -1535,6 +1603,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1535 o.bangMode = true1603 o.bangMode = true
1536 o.bangSuggestions = suggestions1604 o.bangSuggestions = suggestions
1537 o.selectedIndex = -11605 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 nil1774 return nil
1714 }1775 }
1715 row.AddCssClass("omnibox-row")1776 row.AddCssClass("omnibox-row")
1777+ o.attachRowHoverSelection(row, index)
1716 1778
1717 const rowSpacing = 81779 const rowSpacing = 8
1718 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)1780 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
@@ -1783,6 +1845,33 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac
1783 return row1845 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.mu.RLock()
1856+ realInput := o.realInput
1857+ hasGhostText := o.hasGhostText
1858+ hasNavigated := o.hasNavigated
1859+ o.mu.RUnlock()
1860+
1861+ if !shouldPromoteHoverSelection(realInput, hasGhostText, hasNavigated) {
1862+ return
1863+ }
1864+ o.selectIndex(index)
1865+ }
1866+ o.retainedCallbacks = append(o.retainedCallbacks, enterCb)
1867+ motionCtrl.ConnectEnter(&enterCb)
1868+ row.AddController(&motionCtrl.EventController)
1869+}
1870+
1871+func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bool) bool {
1872+ return realInput == "" && !hasGhostText && !hasNavigated
1873+}
1874+
1786 // createSuggestionRow creates a ListBoxRow for a suggestion.1875 // createSuggestionRow creates a ListBoxRow for a suggestion.
1787 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {1876 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1788 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)1877 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
@@ -1809,13 +1898,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1809 1898
1810 // selectIndex selects a row by index.1899 // selectIndex selects a row by index.
1811 func (o *Omnibox) selectIndex(index int) {1900 func (o *Omnibox) selectIndex(index int) {
1901+ o.mu.Lock()
1902+ o.selectedIndex = index
1903+ o.mu.Unlock()
1904+
1812 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb1905 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
1813 // which also acquires the mutex, causing deadlock1906 // which also acquires the mutex, causing deadlock
1814 row := o.listBox.GetRowAtIndex(index)1907 row := o.listBox.GetRowAtIndex(index)
1815 if row != nil {1908 if row != nil {
1816 o.listBox.SelectRow(row)1909 o.listBox.SelectRow(row)
1817- // selectedIndex is updated by rowSelectedCb1910+ return
1818 }1911 }
1912+
1913+ o.updateGhostFromSelection()
1819 }1914 }
1820 1915
1821 // selectNext moves selection down.1916 // selectNext moves selection down.
@@ -1938,16 +2033,14 @@ func (o *Omnibox) navigateToSelected() {
1938 o.entry.SetPosition(-1)2033 o.entry.SetPosition(-1)
1939 return2034 return
1940 }2035 }
2036+ return
1941 }2037 }
1942 2038
1943 var targetURL string2039 var targetURL string
1944- if shouldPreferTypedURLNavigation(entryText) {2040+ if idx >= 0 {
1945- targetURL = o.buildURL(entryText)
1946- } else if idx >= 0 {
1947 // If user has selected a result, navigate to that result.2041 // If user has selected a result, navigate to that result.
1948 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)2042 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
1949 } else {2043 } else {
1950- // No selection - use entry text as URL/search.
1951 targetURL = o.buildURL(entryText)2044 targetURL = o.buildURL(entryText)
1952 }2045 }
1953 2046
@@ -2410,6 +2503,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2410 return o.visible && o.searchToken == token2503 return o.visible && o.searchToken == token
2411 }2504 }
2412 2505
2506+func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2507+ o.mu.RLock()
2508+ defer o.mu.RUnlock()
2509+ return o.visible && o.searchToken == token && o.realInput == query
2510+}
2511+
2413 // Widget returns the omnibox widget for embedding in an overlay.2512 // Widget returns the omnibox widget for embedding in an overlay.
2414 func (o *Omnibox) Widget() *gtk.Widget {2513 func (o *Omnibox) Widget() *gtk.Widget {
2415 if o.outerBox == nil {2514 if o.outerBox == nil {
M internal/ui/component/omnibox_async_guard_test.go +33 -7
@@ -23,20 +23,46 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
2323 func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
2424 o := &Omnibox{}
2525 o.visible = true
26- o.searchToken = 8
2726 o.ghostToken = 5
2827 o.realInput = "git"
2928
30- if !o.isGhostTokenCurrent(8, 5, "git") {
29+ if !o.isGhostTokenCurrent(5, "git") {
3130 t.Fatalf("expected ghost token to be current")
3231 }
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") {
3733 t.Fatalf("expected stale ghost token to be rejected")
3834 }
39- if o.isGhostTokenCurrent(8, 5, "github") {
35+ if o.isGhostTokenCurrent(5, "github") {
4036 t.Fatalf("expected query mismatch to be rejected")
4137 }
38+
39+ o.visible = false
40+ if o.isGhostTokenCurrent(5, "git") {
41+ t.Fatalf("expected hidden omnibox to reject matching ghost token")
42+ }
43+ if o.isGhostTokenCurrent(4, "github") {
44+ t.Fatalf("expected hidden omnibox to reject stale ghost token and query")
45+ }
46+}
47+
48+func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
49+ o := &Omnibox{}
50+ o.visible = true
51+ o.searchToken = 9
52+ o.realInput = "!gh"
53+
54+ if !o.isBangUpdateCurrent("!gh", 9) {
55+ t.Fatalf("expected bang update to be current")
56+ }
57+ if o.isBangUpdateCurrent("!g", 9) {
58+ t.Fatalf("expected bang query mismatch to be rejected")
59+ }
60+ if o.isBangUpdateCurrent("!gh", 8) {
61+ t.Fatalf("expected stale bang token to be rejected")
62+ }
63+
64+ o.visible = false
65+ if o.isBangUpdateCurrent("!gh", 9) {
66+ t.Fatalf("expected hidden omnibox to reject bang updates")
67+ }
4268 }
@@ -23,20 +23,46 @@ 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 = true25 o.visible = true
26- o.searchToken = 8
27 o.ghostToken = 526 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 }
38+
39+ o.visible = false
40+ if o.isGhostTokenCurrent(5, "git") {
41+ t.Fatalf("expected hidden omnibox to reject matching ghost token")
42+ }
43+ if o.isGhostTokenCurrent(4, "github") {
44+ t.Fatalf("expected hidden omnibox to reject stale ghost token and query")
45+ }
46+}
47+
48+func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
49+ o := &Omnibox{}
50+ o.visible = true
51+ o.searchToken = 9
52+ o.realInput = "!gh"
53+
54+ if !o.isBangUpdateCurrent("!gh", 9) {
55+ t.Fatalf("expected bang update to be current")
56+ }
57+ if o.isBangUpdateCurrent("!g", 9) {
58+ t.Fatalf("expected bang query mismatch to be rejected")
59+ }
60+ if o.isBangUpdateCurrent("!gh", 8) {
61+ t.Fatalf("expected stale bang token to be rejected")
62+ }
63+
64+ o.visible = false
65+ if o.isBangUpdateCurrent("!gh", 9) {
66+ t.Fatalf("expected hidden omnibox to reject bang updates")
67+ }
42 }68 }
M internal/ui/component/omnibox_navigation_state_test.go +152 -0
@@ -116,6 +116,16 @@ func TestEffectiveSearchQuery(t *testing.T) {
116116 }
117117 }
118118
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+
119129 func TestResolveTargetURLForSelection(t *testing.T) {
120130 suggestions := []Suggestion{
121131 {URL: "https://github.com/bnema/dumber"},
@@ -145,3 +155,145 @@ func TestResolveTargetURLForSelection(t *testing.T) {
145155 })
146156 }
147157 }
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+}
254+
255+func TestShouldPromoteHoverSelection(t *testing.T) {
256+ tests := []struct {
257+ name string
258+ realInput string
259+ hasGhostText bool
260+ hasNavigated bool
261+ want bool
262+ }{
263+ {
264+ name: "initial open allows hover selection",
265+ want: true,
266+ },
267+ {
268+ name: "typed input keeps hover from stealing authority",
269+ realInput: "git",
270+ want: false,
271+ },
272+ {
273+ name: "visible ghost keeps hover from stealing authority",
274+ hasGhostText: true,
275+ want: false,
276+ },
277+ {
278+ name: "keyboard navigation keeps hover from stealing authority",
279+ hasNavigated: true,
280+ want: false,
281+ },
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ got := shouldPromoteHoverSelection(tt.realInput, tt.hasGhostText, tt.hasNavigated)
287+ if got != tt.want {
288+ t.Fatalf(
289+ "shouldPromoteHoverSelection(%q, %v, %v) = %v, want %v",
290+ tt.realInput,
291+ tt.hasGhostText,
292+ tt.hasNavigated,
293+ got,
294+ tt.want,
295+ )
296+ }
297+ })
298+ }
299+}
@@ -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,145 @@ 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+}
254+
255+func TestShouldPromoteHoverSelection(t *testing.T) {
256+ tests := []struct {
257+ name string
258+ realInput string
259+ hasGhostText bool
260+ hasNavigated bool
261+ want bool
262+ }{
263+ {
264+ name: "initial open allows hover selection",
265+ want: true,
266+ },
267+ {
268+ name: "typed input keeps hover from stealing authority",
269+ realInput: "git",
270+ want: false,
271+ },
272+ {
273+ name: "visible ghost keeps hover from stealing authority",
274+ hasGhostText: true,
275+ want: false,
276+ },
277+ {
278+ name: "keyboard navigation keeps hover from stealing authority",
279+ hasNavigated: true,
280+ want: false,
281+ },
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ got := shouldPromoteHoverSelection(tt.realInput, tt.hasGhostText, tt.hasNavigated)
287+ if got != tt.want {
288+ t.Fatalf(
289+ "shouldPromoteHoverSelection(%q, %v, %v) = %v, want %v",
290+ tt.realInput,
291+ tt.hasGhostText,
292+ tt.hasNavigated,
293+ got,
294+ tt.want,
295+ )
296+ }
297+ })
298+ }
299+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close