detached f7811f6

refactor: unify omnibox suggestion authority

commit
f7811f6a88e5bef4ac5e9ae8725e300c5638018d
author
brice · · 2026-03-10 11:30 UTC
parent
690c646
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +986 -877
D internal/application/usecase/autocomplete.go +0 -362
@@ -1,362 +0,0 @@
1-package usecase
2-
3-import (
4- "context"
5- "sync"
6- "time"
7-
8- "github.com/bnema/dumber/internal/domain/autocomplete"
9- "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// AutocompleteUseCase handles inline autocomplete suggestions.
14-type AutocompleteUseCase struct {
15- historyUC *SearchHistoryUseCase
16- favoritesUC *ManageFavoritesUseCase
17- shortcutsUC *SearchShortcutsUseCase
18-
19- cacheMu sync.Mutex
20- favoritesCache []*entity.Favorite
21- favoritesCached time.Time
22- historyCache []*entity.HistoryEntry
23- historyCached time.Time
24-}
25-
26-const autocompleteHistoryLimit = 200
27-const autocompleteCacheTTL = 2 * time.Second
28-
29-// NewAutocompleteUseCase creates a new autocomplete use case.
30-func NewAutocompleteUseCase(
31- historyUC *SearchHistoryUseCase,
32- favoritesUC *ManageFavoritesUseCase,
33- shortcutsUC *SearchShortcutsUseCase,
34-) *AutocompleteUseCase {
35- return &AutocompleteUseCase{
36- historyUC: historyUC,
37- favoritesUC: favoritesUC,
38- shortcutsUC: shortcutsUC,
39- }
40-}
41-
42-// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
43-type GetSuggestionInput struct {
44- Input string // Current user input
45-}
46-
47-// GetSuggestionOutput contains the best autocomplete suggestion.
48-type GetSuggestionOutput struct {
49- Suggestion *autocomplete.Suggestion
50- Found bool
51-}
52-
53-// CompletionOptions controls how URL completions are resolved.
54-type CompletionOptions struct {
55- VisibleURLs []string
56- AllowBangs bool
57-}
58-
59-// GetSuggestion returns the best autocomplete suggestion for the given input.
60-// Priority: Favorites > History (by frecency) > Bang shortcuts.
61-func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
62- log := logging.FromContext(ctx)
63-
64- if input.Input == "" {
65- return &GetSuggestionOutput{Found: false}
66- }
67-
68- // Try favorites first (highest priority)
69- if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
70- log.Debug().
71- Str("input", input.Input).
72- Str("match", suggestion.FullText).
73- Msg("autocomplete: matched favorite")
74- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75- }
76-
77- // Try history (second priority)
78- if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
79- log.Debug().
80- Str("input", input.Input).
81- Str("match", suggestion.FullText).
82- Msg("autocomplete: matched history")
83- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
84- }
85-
86- // Try bang shortcuts (lowest priority for URL-style completion)
87- if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
88- log.Debug().
89- Str("input", input.Input).
90- Str("match", suggestion.FullText).
91- Msg("autocomplete: matched bang shortcut")
92- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
93- }
94-
95- return &GetSuggestionOutput{Found: false}
96-}
97-
98-// ResolveCompletion resolves the best completion using visible list order first,
99-// then cached favorites/history as fallback.
100-func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion {
101- log := logging.FromContext(ctx)
102-
103- if input == "" {
104- return nil
105- }
106-
107- if len(opts.VisibleURLs) > 0 {
108- if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil {
109- log.Debug().
110- Str("input", input).
111- Str("match", suggestion.FullText).
112- Msg("autocomplete: matched visible list")
113- return suggestion
114- }
115- }
116-
117- if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil {
118- log.Debug().
119- Str("input", input).
120- Str("match", suggestion.FullText).
121- Msg("autocomplete: matched favorite fallback")
122- return suggestion
123- }
124-
125- if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil {
126- log.Debug().
127- Str("input", input).
128- Str("match", suggestion.FullText).
129- Msg("autocomplete: matched history fallback")
130- return suggestion
131- }
132-
133- if opts.AllowBangs {
134- if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil {
135- log.Debug().
136- Str("input", input).
137- Str("match", suggestion.FullText).
138- Msg("autocomplete: matched bang fallback")
139- return suggestion
140- }
141- }
142-
143- return nil
144-}
145-
146-// findFavoriteSuggestion finds the best matching favorite for the input.
147-func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148- log := logging.FromContext(ctx)
149-
150- if uc.favoritesUC == nil {
151- return nil
152- }
153-
154- favorites, err := uc.getCachedFavorites(ctx)
155- if err != nil {
156- log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
157- return nil
158- }
159-
160- return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161- len(favorites),
162- func(i int) string {
163- if favorites[i] == nil {
164- return ""
165- }
166- return favorites[i].URL
167- },
168- func(i int) string {
169- if favorites[i] == nil {
170- return ""
171- }
172- return favorites[i].Title
173- },
174- )
175-}
176-
177-// findHistorySuggestion finds the best matching history entry for the input.
178-func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
179- log := logging.FromContext(ctx)
180-
181- if uc.historyUC == nil {
182- return nil
183- }
184-
185- entries, err := uc.getCachedHistory(ctx)
186- if err != nil {
187- log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
188- return nil
189- }
190-
191- return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
192- len(entries),
193- func(i int) string {
194- if entries[i] == nil {
195- return ""
196- }
197- return entries[i].URL
198- },
199- func(i int) string {
200- if entries[i] == nil {
201- return ""
202- }
203- return entries[i].Title
204- },
205- )
206-}
207-
208-// findBangSuggestion finds the best matching bang shortcut for the input.
209-func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
210- _ = logging.FromContext(ctx)
211-
212- if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
213- return nil
214- }
215-
216- // Get filtered bang suggestions
217- output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
218- if len(output.Suggestions) == 0 {
219- return nil
220- }
221-
222- // Use the first (best) match
223- bang := output.Suggestions[0]
224- fullBang := "!" + bang.Key
225-
226- suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
227- if ok {
228- return &autocomplete.Suggestion{
229- FullText: fullBang,
230- Suffix: suffix,
231- Source: autocomplete.SourceBangShortcut,
232- Title: bang.Description,
233- }
234- }
235-
236- return nil
237-}
238-
239-func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
240- suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
241- if ok {
242- return &autocomplete.Suggestion{
243- FullText: matchedURL,
244- Suffix: suffix,
245- Source: source,
246- }
247- }
248- return nil
249-}
250-
251-func bestSuggestionFromURLs(
252- input string,
253- source autocomplete.SuggestionSource,
254- count int,
255- urlAt func(int) string,
256- titleAt func(int) string,
257-) *autocomplete.Suggestion {
258- if count <= 0 {
259- return nil
260- }
261-
262- urls := make([]string, 0, count)
263- titles := make([]string, 0, count)
264- for i := 0; i < count; i++ {
265- url := urlAt(i)
266- if url == "" {
267- continue
268- }
269- urls = append(urls, url)
270- titles = append(titles, titleAt(i))
271- }
272- if len(urls) == 0 {
273- return nil
274- }
275-
276- suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277- if !ok {
278- return nil
279- }
280- title := ""
281- if idx >= 0 && idx < len(titles) {
282- title = titles[idx]
283- }
284- return &autocomplete.Suggestion{
285- FullText: matchedURL,
286- Suffix: suffix,
287- Source: source,
288- Title: title,
289- }
290-}
291-
292-func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
293- log := logging.FromContext(ctx)
294-
295- uc.cacheMu.Lock()
296- if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
297- favorites := uc.favoritesCache
298- uc.cacheMu.Unlock()
299- return favorites, nil
300- }
301- uc.cacheMu.Unlock()
302-
303- favorites, err := uc.favoritesUC.GetAll(ctx)
304- if err != nil {
305- return nil, err
306- }
307-
308- uc.cacheMu.Lock()
309- uc.favoritesCache = favorites
310- uc.favoritesCached = time.Now()
311- uc.cacheMu.Unlock()
312-
313- log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
314- return favorites, nil
315-}
316-
317-func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
318- log := logging.FromContext(ctx)
319-
320- uc.cacheMu.Lock()
321- if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
322- history := uc.historyCache
323- uc.cacheMu.Unlock()
324- return history, nil
325- }
326- uc.cacheMu.Unlock()
327-
328- entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
329- if err != nil {
330- return nil, err
331- }
332-
333- uc.cacheMu.Lock()
334- uc.historyCache = entries
335- uc.historyCached = time.Now()
336- uc.cacheMu.Unlock()
337-
338- log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
339- return entries, nil
340-}
341-
342-// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
343-// This is used when the user selects a row from the list to update ghost text.
344-func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
345- if input == "" || targetURL == "" {
346- return &GetSuggestionOutput{Found: false}
347- }
348-
349- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
350- if !ok {
351- return &GetSuggestionOutput{Found: false}
352- }
353-
354- return &GetSuggestionOutput{
355- Suggestion: &autocomplete.Suggestion{
356- FullText: matchedURL,
357- Suffix: suffix,
358- Source: autocomplete.SourceHistory, // Default to history for selected rows
359- },
360- Found: true,
361- }
362-}
@@ -1,362 +0,0 @@
1-package usecase
2-
3-import (
4- "context"
5- "sync"
6- "time"
7-
8- "github.com/bnema/dumber/internal/domain/autocomplete"
9- "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// AutocompleteUseCase handles inline autocomplete suggestions.
14-type AutocompleteUseCase struct {
15- historyUC *SearchHistoryUseCase
16- favoritesUC *ManageFavoritesUseCase
17- shortcutsUC *SearchShortcutsUseCase
18-
19- cacheMu sync.Mutex
20- favoritesCache []*entity.Favorite
21- favoritesCached time.Time
22- historyCache []*entity.HistoryEntry
23- historyCached time.Time
24-}
25-
26-const autocompleteHistoryLimit = 200
27-const autocompleteCacheTTL = 2 * time.Second
28-
29-// NewAutocompleteUseCase creates a new autocomplete use case.
30-func NewAutocompleteUseCase(
31- historyUC *SearchHistoryUseCase,
32- favoritesUC *ManageFavoritesUseCase,
33- shortcutsUC *SearchShortcutsUseCase,
34-) *AutocompleteUseCase {
35- return &AutocompleteUseCase{
36- historyUC: historyUC,
37- favoritesUC: favoritesUC,
38- shortcutsUC: shortcutsUC,
39- }
40-}
41-
42-// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
43-type GetSuggestionInput struct {
44- Input string // Current user input
45-}
46-
47-// GetSuggestionOutput contains the best autocomplete suggestion.
48-type GetSuggestionOutput struct {
49- Suggestion *autocomplete.Suggestion
50- Found bool
51-}
52-
53-// CompletionOptions controls how URL completions are resolved.
54-type CompletionOptions struct {
55- VisibleURLs []string
56- AllowBangs bool
57-}
58-
59-// GetSuggestion returns the best autocomplete suggestion for the given input.
60-// Priority: Favorites > History (by frecency) > Bang shortcuts.
61-func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
62- log := logging.FromContext(ctx)
63-
64- if input.Input == "" {
65- return &GetSuggestionOutput{Found: false}
66- }
67-
68- // Try favorites first (highest priority)
69- if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
70- log.Debug().
71- Str("input", input.Input).
72- Str("match", suggestion.FullText).
73- Msg("autocomplete: matched favorite")
74- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75- }
76-
77- // Try history (second priority)
78- if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
79- log.Debug().
80- Str("input", input.Input).
81- Str("match", suggestion.FullText).
82- Msg("autocomplete: matched history")
83- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
84- }
85-
86- // Try bang shortcuts (lowest priority for URL-style completion)
87- if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
88- log.Debug().
89- Str("input", input.Input).
90- Str("match", suggestion.FullText).
91- Msg("autocomplete: matched bang shortcut")
92- return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
93- }
94-
95- return &GetSuggestionOutput{Found: false}
96-}
97-
98-// ResolveCompletion resolves the best completion using visible list order first,
99-// then cached favorites/history as fallback.
100-func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion {
101- log := logging.FromContext(ctx)
102-
103- if input == "" {
104- return nil
105- }
106-
107- if len(opts.VisibleURLs) > 0 {
108- if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil {
109- log.Debug().
110- Str("input", input).
111- Str("match", suggestion.FullText).
112- Msg("autocomplete: matched visible list")
113- return suggestion
114- }
115- }
116-
117- if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil {
118- log.Debug().
119- Str("input", input).
120- Str("match", suggestion.FullText).
121- Msg("autocomplete: matched favorite fallback")
122- return suggestion
123- }
124-
125- if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil {
126- log.Debug().
127- Str("input", input).
128- Str("match", suggestion.FullText).
129- Msg("autocomplete: matched history fallback")
130- return suggestion
131- }
132-
133- if opts.AllowBangs {
134- if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil {
135- log.Debug().
136- Str("input", input).
137- Str("match", suggestion.FullText).
138- Msg("autocomplete: matched bang fallback")
139- return suggestion
140- }
141- }
142-
143- return nil
144-}
145-
146-// findFavoriteSuggestion finds the best matching favorite for the input.
147-func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148- log := logging.FromContext(ctx)
149-
150- if uc.favoritesUC == nil {
151- return nil
152- }
153-
154- favorites, err := uc.getCachedFavorites(ctx)
155- if err != nil {
156- log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
157- return nil
158- }
159-
160- return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161- len(favorites),
162- func(i int) string {
163- if favorites[i] == nil {
164- return ""
165- }
166- return favorites[i].URL
167- },
168- func(i int) string {
169- if favorites[i] == nil {
170- return ""
171- }
172- return favorites[i].Title
173- },
174- )
175-}
176-
177-// findHistorySuggestion finds the best matching history entry for the input.
178-func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
179- log := logging.FromContext(ctx)
180-
181- if uc.historyUC == nil {
182- return nil
183- }
184-
185- entries, err := uc.getCachedHistory(ctx)
186- if err != nil {
187- log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
188- return nil
189- }
190-
191- return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
192- len(entries),
193- func(i int) string {
194- if entries[i] == nil {
195- return ""
196- }
197- return entries[i].URL
198- },
199- func(i int) string {
200- if entries[i] == nil {
201- return ""
202- }
203- return entries[i].Title
204- },
205- )
206-}
207-
208-// findBangSuggestion finds the best matching bang shortcut for the input.
209-func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
210- _ = logging.FromContext(ctx)
211-
212- if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
213- return nil
214- }
215-
216- // Get filtered bang suggestions
217- output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
218- if len(output.Suggestions) == 0 {
219- return nil
220- }
221-
222- // Use the first (best) match
223- bang := output.Suggestions[0]
224- fullBang := "!" + bang.Key
225-
226- suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
227- if ok {
228- return &autocomplete.Suggestion{
229- FullText: fullBang,
230- Suffix: suffix,
231- Source: autocomplete.SourceBangShortcut,
232- Title: bang.Description,
233- }
234- }
235-
236- return nil
237-}
238-
239-func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
240- suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
241- if ok {
242- return &autocomplete.Suggestion{
243- FullText: matchedURL,
244- Suffix: suffix,
245- Source: source,
246- }
247- }
248- return nil
249-}
250-
251-func bestSuggestionFromURLs(
252- input string,
253- source autocomplete.SuggestionSource,
254- count int,
255- urlAt func(int) string,
256- titleAt func(int) string,
257-) *autocomplete.Suggestion {
258- if count <= 0 {
259- return nil
260- }
261-
262- urls := make([]string, 0, count)
263- titles := make([]string, 0, count)
264- for i := 0; i < count; i++ {
265- url := urlAt(i)
266- if url == "" {
267- continue
268- }
269- urls = append(urls, url)
270- titles = append(titles, titleAt(i))
271- }
272- if len(urls) == 0 {
273- return nil
274- }
275-
276- suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277- if !ok {
278- return nil
279- }
280- title := ""
281- if idx >= 0 && idx < len(titles) {
282- title = titles[idx]
283- }
284- return &autocomplete.Suggestion{
285- FullText: matchedURL,
286- Suffix: suffix,
287- Source: source,
288- Title: title,
289- }
290-}
291-
292-func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
293- log := logging.FromContext(ctx)
294-
295- uc.cacheMu.Lock()
296- if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
297- favorites := uc.favoritesCache
298- uc.cacheMu.Unlock()
299- return favorites, nil
300- }
301- uc.cacheMu.Unlock()
302-
303- favorites, err := uc.favoritesUC.GetAll(ctx)
304- if err != nil {
305- return nil, err
306- }
307-
308- uc.cacheMu.Lock()
309- uc.favoritesCache = favorites
310- uc.favoritesCached = time.Now()
311- uc.cacheMu.Unlock()
312-
313- log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
314- return favorites, nil
315-}
316-
317-func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
318- log := logging.FromContext(ctx)
319-
320- uc.cacheMu.Lock()
321- if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
322- history := uc.historyCache
323- uc.cacheMu.Unlock()
324- return history, nil
325- }
326- uc.cacheMu.Unlock()
327-
328- entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
329- if err != nil {
330- return nil, err
331- }
332-
333- uc.cacheMu.Lock()
334- uc.historyCache = entries
335- uc.historyCached = time.Now()
336- uc.cacheMu.Unlock()
337-
338- log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
339- return entries, nil
340-}
341-
342-// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
343-// This is used when the user selects a row from the list to update ghost text.
344-func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
345- if input == "" || targetURL == "" {
346- return &GetSuggestionOutput{Found: false}
347- }
348-
349- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
350- if !ok {
351- return &GetSuggestionOutput{Found: false}
352- }
353-
354- return &GetSuggestionOutput{
355- Suggestion: &autocomplete.Suggestion{
356- FullText: matchedURL,
357- Suffix: suffix,
358- Source: autocomplete.SourceHistory, // Default to history for selected rows
359- },
360- Found: true,
361- }
362-}
D internal/application/usecase/autocomplete_test.go +0 -366
@@ -1,366 +0,0 @@
1-package usecase_test
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/dumber/internal/application/usecase"
7- "github.com/bnema/dumber/internal/domain/autocomplete"
8- "github.com/bnema/dumber/internal/domain/entity"
9- repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/mock"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16- ctx := testContext()
17- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18-
19- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20-
21- require.NotNil(t, output)
22- assert.False(t, output.Found)
23- assert.Nil(t, output.Suggestion)
24-}
25-
26-func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27- ctx := testContext()
28- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29-
30- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31-
32- require.NotNil(t, output)
33- assert.False(t, output.Found)
34-}
35-
36-func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37- ctx := testContext()
38-
39- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40- folderRepo := repomocks.NewMockFolderRepository(t)
41- tagRepo := repomocks.NewMockTagRepository(t)
42-
43- favorites := []*entity.Favorite{
44- {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45- {ID: 2, URL: "https://google.com", Title: "Google"},
46- }
47- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48-
49- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50- uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51-
52- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53-
54- require.NotNil(t, output)
55- assert.True(t, output.Found)
56- assert.NotNil(t, output.Suggestion)
57- assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58- assert.Equal(t, "github.com", output.Suggestion.FullText)
59- assert.Equal(t, ".com", output.Suggestion.Suffix)
60- assert.Equal(t, "My Repo", output.Suggestion.Title)
61-}
62-
63-func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64- ctx := testContext()
65-
66- // Favorites returns empty
67- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68- folderRepo := repomocks.NewMockFolderRepository(t)
69- tagRepo := repomocks.NewMockTagRepository(t)
70- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71-
72- // History returns a match
73- historyRepo := repomocks.NewMockHistoryRepository(t)
74- historyEntries := []*entity.HistoryEntry{
75- {URL: "https://news.ycombinator.com", Title: "Hacker News"},
76- }
77- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
78-
79- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82-
83- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84-
85- require.NotNil(t, output)
86- assert.True(t, output.Found)
87- assert.NotNil(t, output.Suggestion)
88- assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89- assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90- assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91-}
92-
93-func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94- ctx := testContext()
95-
96- shortcuts := map[string]usecase.SearchShortcut{
97- "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98- "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99- }
100- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101- uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102-
103- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104-
105- require.NotNil(t, output)
106- assert.True(t, output.Found)
107- assert.NotNil(t, output.Suggestion)
108- assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109- assert.Equal(t, "!gh", output.Suggestion.FullText)
110- assert.Equal(t, "h", output.Suggestion.Suffix)
111- assert.Equal(t, "GitHub search", output.Suggestion.Title)
112-}
113-
114-func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115- ctx := testContext()
116-
117- shortcuts := map[string]usecase.SearchShortcut{
118- "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119- }
120- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121- uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122-
123- // Input without ! should not match bang shortcuts
124- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125-
126- require.NotNil(t, output)
127- assert.False(t, output.Found)
128-}
129-
130-func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131- ctx := testContext()
132-
133- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134- folderRepo := repomocks.NewMockFolderRepository(t)
135- tagRepo := repomocks.NewMockTagRepository(t)
136- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137-
138- historyRepo := repomocks.NewMockHistoryRepository(t)
139- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, nil)
140-
141- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144-
145- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146-
147- require.NotNil(t, output)
148- assert.False(t, output.Found)
149-}
150-
151-func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
152- ctx := testContext()
153- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154-
155- urls := []string{
156- "https://bnema.dev",
157- "https://bnema.io",
158- }
159-
160- suggestion := uc.ResolveCompletion(ctx, "bnem", usecase.CompletionOptions{
161- VisibleURLs: urls,
162- AllowBangs: false,
163- })
164-
165- require.NotNil(t, suggestion)
166- assert.Equal(t, "bnema.dev", suggestion.FullText)
167- assert.Equal(t, "a.dev", suggestion.Suffix)
168-}
169-
170-func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) {
171- ctx := testContext()
172- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
173-
174- urls := []string{
175- "https://www.google.com/url?q=https://dashboard.stripe.com",
176- "https://google.com",
177- }
178-
179- suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{
180- VisibleURLs: urls,
181- AllowBangs: false,
182- })
183-
184- require.NotNil(t, suggestion)
185- assert.Equal(t, "google.com", suggestion.FullText)
186- assert.Equal(t, "gle.com", suggestion.Suffix)
187-}
188-
189-func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
190- ctx := testContext()
191-
192- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
193- folderRepo := repomocks.NewMockFolderRepository(t)
194- tagRepo := repomocks.NewMockTagRepository(t)
195-
196- favorites := []*entity.Favorite{
197- {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"},
198- }
199- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
200-
201- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
202- uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
203-
204- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
205- require.NotNil(t, output)
206- assert.True(t, output.Found)
207-
208- output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
209- require.NotNil(t, output)
210- assert.True(t, output.Found)
211-}
212-
213-func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) {
214- ctx := testContext()
215-
216- historyRepo := repomocks.NewMockHistoryRepository(t)
217- historyEntries := []*entity.HistoryEntry{
218- {URL: "https://news.ycombinator.com", Title: "Hacker News"},
219- }
220- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once()
221-
222- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
223- uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil)
224-
225- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
226- require.NotNil(t, output)
227- assert.True(t, output.Found)
228-
229- output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
230- require.NotNil(t, output)
231- assert.True(t, output.Found)
232-}
233-
234-func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
235- ctx := testContext()
236- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
237-
238- tests := []struct {
239- name string
240- input string
241- targetURL string
242- }{
243- {name: "empty input", input: "", targetURL: "https://example.com"},
244- {name: "empty URL", input: "exa", targetURL: ""},
245- {name: "both empty", input: "", targetURL: ""},
246- }
247-
248- for _, tt := range tests {
249- t.Run(tt.name, func(t *testing.T) {
250- output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
251- require.NotNil(t, output)
252- assert.False(t, output.Found)
253- })
254- }
255-}
256-
257-func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
258- ctx := testContext()
259- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
260-
261- tests := []struct {
262- name string
263- input string
264- targetURL string
265- wantSuffix string
266- wantMatchedURL string
267- }{
268- {
269- name: "prefix match without protocol",
270- input: "github",
271- targetURL: "https://github.com/user/repo",
272- wantSuffix: ".com/user/repo",
273- wantMatchedURL: "github.com/user/repo",
274- },
275- {
276- name: "prefix match with protocol",
277- input: "https://github",
278- targetURL: "https://github.com/user/repo",
279- wantSuffix: ".com/user/repo",
280- wantMatchedURL: "https://github.com/user/repo",
281- },
282- {
283- name: "case insensitive match",
284- input: "GITHUB",
285- targetURL: "https://github.com",
286- wantSuffix: ".com",
287- wantMatchedURL: "github.com",
288- },
289- }
290-
291- for _, tt := range tests {
292- t.Run(tt.name, func(t *testing.T) {
293- output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
294- require.NotNil(t, output)
295- assert.True(t, output.Found)
296- require.NotNil(t, output.Suggestion)
297- assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
298- assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
299- })
300- }
301-}
302-
303-func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
304- ctx := testContext()
305- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
306-
307- output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
308-
309- require.NotNil(t, output)
310- assert.False(t, output.Found)
311-}
312-
313-func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
314- ctx := testContext()
315-
316- // Both favorites and history have matches - favorites should win
317- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
318- folderRepo := repomocks.NewMockFolderRepository(t)
319- tagRepo := repomocks.NewMockTagRepository(t)
320- favorites := []*entity.Favorite{
321- {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
322- }
323- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
324-
325- // History would also match, but should not be called because favorites matched
326- historyRepo := repomocks.NewMockHistoryRepository(t)
327- // Note: no EXPECT on historyRepo - it shouldn't be called
328-
329- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
330- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
331- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
332-
333- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
334-
335- require.NotNil(t, output)
336- assert.True(t, output.Found)
337- assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
338- assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
339-}
340-
341-func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
342- ctx := testContext()
343-
344- // Favorites returns empty
345- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
346- folderRepo := repomocks.NewMockFolderRepository(t)
347- tagRepo := repomocks.NewMockTagRepository(t)
348- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
349-
350- // History returns results but none are prefix matches
351- historyRepo := repomocks.NewMockHistoryRepository(t)
352- historyEntries := []*entity.HistoryEntry{
353- // URL doesn't start with "git", so no prefix match
354- {URL: "https://example.com/git", Title: "Example"},
355- }
356- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
357-
358- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
359- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
360- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
361-
362- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
363-
364- require.NotNil(t, output)
365- assert.False(t, output.Found)
366-}
@@ -1,366 +0,0 @@
1-package usecase_test
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/dumber/internal/application/usecase"
7- "github.com/bnema/dumber/internal/domain/autocomplete"
8- "github.com/bnema/dumber/internal/domain/entity"
9- repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/mock"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16- ctx := testContext()
17- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18-
19- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20-
21- require.NotNil(t, output)
22- assert.False(t, output.Found)
23- assert.Nil(t, output.Suggestion)
24-}
25-
26-func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27- ctx := testContext()
28- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29-
30- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31-
32- require.NotNil(t, output)
33- assert.False(t, output.Found)
34-}
35-
36-func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37- ctx := testContext()
38-
39- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40- folderRepo := repomocks.NewMockFolderRepository(t)
41- tagRepo := repomocks.NewMockTagRepository(t)
42-
43- favorites := []*entity.Favorite{
44- {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45- {ID: 2, URL: "https://google.com", Title: "Google"},
46- }
47- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48-
49- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50- uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51-
52- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53-
54- require.NotNil(t, output)
55- assert.True(t, output.Found)
56- assert.NotNil(t, output.Suggestion)
57- assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58- assert.Equal(t, "github.com", output.Suggestion.FullText)
59- assert.Equal(t, ".com", output.Suggestion.Suffix)
60- assert.Equal(t, "My Repo", output.Suggestion.Title)
61-}
62-
63-func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64- ctx := testContext()
65-
66- // Favorites returns empty
67- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68- folderRepo := repomocks.NewMockFolderRepository(t)
69- tagRepo := repomocks.NewMockTagRepository(t)
70- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71-
72- // History returns a match
73- historyRepo := repomocks.NewMockHistoryRepository(t)
74- historyEntries := []*entity.HistoryEntry{
75- {URL: "https://news.ycombinator.com", Title: "Hacker News"},
76- }
77- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
78-
79- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82-
83- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84-
85- require.NotNil(t, output)
86- assert.True(t, output.Found)
87- assert.NotNil(t, output.Suggestion)
88- assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89- assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90- assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91-}
92-
93-func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94- ctx := testContext()
95-
96- shortcuts := map[string]usecase.SearchShortcut{
97- "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98- "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99- }
100- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101- uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102-
103- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104-
105- require.NotNil(t, output)
106- assert.True(t, output.Found)
107- assert.NotNil(t, output.Suggestion)
108- assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109- assert.Equal(t, "!gh", output.Suggestion.FullText)
110- assert.Equal(t, "h", output.Suggestion.Suffix)
111- assert.Equal(t, "GitHub search", output.Suggestion.Title)
112-}
113-
114-func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115- ctx := testContext()
116-
117- shortcuts := map[string]usecase.SearchShortcut{
118- "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119- }
120- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121- uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122-
123- // Input without ! should not match bang shortcuts
124- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125-
126- require.NotNil(t, output)
127- assert.False(t, output.Found)
128-}
129-
130-func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131- ctx := testContext()
132-
133- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134- folderRepo := repomocks.NewMockFolderRepository(t)
135- tagRepo := repomocks.NewMockTagRepository(t)
136- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137-
138- historyRepo := repomocks.NewMockHistoryRepository(t)
139- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, nil)
140-
141- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144-
145- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146-
147- require.NotNil(t, output)
148- assert.False(t, output.Found)
149-}
150-
151-func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
152- ctx := testContext()
153- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154-
155- urls := []string{
156- "https://bnema.dev",
157- "https://bnema.io",
158- }
159-
160- suggestion := uc.ResolveCompletion(ctx, "bnem", usecase.CompletionOptions{
161- VisibleURLs: urls,
162- AllowBangs: false,
163- })
164-
165- require.NotNil(t, suggestion)
166- assert.Equal(t, "bnema.dev", suggestion.FullText)
167- assert.Equal(t, "a.dev", suggestion.Suffix)
168-}
169-
170-func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) {
171- ctx := testContext()
172- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
173-
174- urls := []string{
175- "https://www.google.com/url?q=https://dashboard.stripe.com",
176- "https://google.com",
177- }
178-
179- suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{
180- VisibleURLs: urls,
181- AllowBangs: false,
182- })
183-
184- require.NotNil(t, suggestion)
185- assert.Equal(t, "google.com", suggestion.FullText)
186- assert.Equal(t, "gle.com", suggestion.Suffix)
187-}
188-
189-func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
190- ctx := testContext()
191-
192- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
193- folderRepo := repomocks.NewMockFolderRepository(t)
194- tagRepo := repomocks.NewMockTagRepository(t)
195-
196- favorites := []*entity.Favorite{
197- {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"},
198- }
199- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
200-
201- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
202- uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
203-
204- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
205- require.NotNil(t, output)
206- assert.True(t, output.Found)
207-
208- output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
209- require.NotNil(t, output)
210- assert.True(t, output.Found)
211-}
212-
213-func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) {
214- ctx := testContext()
215-
216- historyRepo := repomocks.NewMockHistoryRepository(t)
217- historyEntries := []*entity.HistoryEntry{
218- {URL: "https://news.ycombinator.com", Title: "Hacker News"},
219- }
220- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once()
221-
222- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
223- uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil)
224-
225- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
226- require.NotNil(t, output)
227- assert.True(t, output.Found)
228-
229- output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
230- require.NotNil(t, output)
231- assert.True(t, output.Found)
232-}
233-
234-func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
235- ctx := testContext()
236- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
237-
238- tests := []struct {
239- name string
240- input string
241- targetURL string
242- }{
243- {name: "empty input", input: "", targetURL: "https://example.com"},
244- {name: "empty URL", input: "exa", targetURL: ""},
245- {name: "both empty", input: "", targetURL: ""},
246- }
247-
248- for _, tt := range tests {
249- t.Run(tt.name, func(t *testing.T) {
250- output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
251- require.NotNil(t, output)
252- assert.False(t, output.Found)
253- })
254- }
255-}
256-
257-func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
258- ctx := testContext()
259- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
260-
261- tests := []struct {
262- name string
263- input string
264- targetURL string
265- wantSuffix string
266- wantMatchedURL string
267- }{
268- {
269- name: "prefix match without protocol",
270- input: "github",
271- targetURL: "https://github.com/user/repo",
272- wantSuffix: ".com/user/repo",
273- wantMatchedURL: "github.com/user/repo",
274- },
275- {
276- name: "prefix match with protocol",
277- input: "https://github",
278- targetURL: "https://github.com/user/repo",
279- wantSuffix: ".com/user/repo",
280- wantMatchedURL: "https://github.com/user/repo",
281- },
282- {
283- name: "case insensitive match",
284- input: "GITHUB",
285- targetURL: "https://github.com",
286- wantSuffix: ".com",
287- wantMatchedURL: "github.com",
288- },
289- }
290-
291- for _, tt := range tests {
292- t.Run(tt.name, func(t *testing.T) {
293- output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
294- require.NotNil(t, output)
295- assert.True(t, output.Found)
296- require.NotNil(t, output.Suggestion)
297- assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
298- assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
299- })
300- }
301-}
302-
303-func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
304- ctx := testContext()
305- uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
306-
307- output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
308-
309- require.NotNil(t, output)
310- assert.False(t, output.Found)
311-}
312-
313-func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
314- ctx := testContext()
315-
316- // Both favorites and history have matches - favorites should win
317- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
318- folderRepo := repomocks.NewMockFolderRepository(t)
319- tagRepo := repomocks.NewMockTagRepository(t)
320- favorites := []*entity.Favorite{
321- {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
322- }
323- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
324-
325- // History would also match, but should not be called because favorites matched
326- historyRepo := repomocks.NewMockHistoryRepository(t)
327- // Note: no EXPECT on historyRepo - it shouldn't be called
328-
329- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
330- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
331- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
332-
333- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
334-
335- require.NotNil(t, output)
336- assert.True(t, output.Found)
337- assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
338- assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
339-}
340-
341-func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
342- ctx := testContext()
343-
344- // Favorites returns empty
345- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
346- folderRepo := repomocks.NewMockFolderRepository(t)
347- tagRepo := repomocks.NewMockTagRepository(t)
348- favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
349-
350- // History returns results but none are prefix matches
351- historyRepo := repomocks.NewMockHistoryRepository(t)
352- historyEntries := []*entity.HistoryEntry{
353- // URL doesn't start with "git", so no prefix match
354- {URL: "https://example.com/git", Title: "Example"},
355- }
356- historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
357-
358- favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
359- historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
360- uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
361-
362- output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
363-
364- require.NotNil(t, output)
365- assert.False(t, output.Found)
366-}
M internal/application/usecase/manage_favorites.go +136 -3
@@ -3,9 +3,14 @@ package usecase
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,69 @@ 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+ uc.favoritesCache = favs
213+ uc.favoriteURLsCache = buildFavoriteURLSet(favs)
214+ uc.cacheTime = time.Now()
215+ uc.cacheMu.Unlock()
216+
186217 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187218 return favs, nil
188219 }
189220
221+// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
222+func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
223+ favs, err := uc.GetAll(ctx)
224+ if err != nil {
225+ return nil, err
226+ }
227+
228+ queryLower := strings.ToLower(strings.TrimSpace(query))
229+ if queryLower == "" {
230+ return favs, nil
231+ }
232+
233+ filtered := make([]*entity.Favorite, 0, len(favs))
234+ for _, fav := range favs {
235+ if fav == nil {
236+ continue
237+ }
238+ titleLower := strings.ToLower(fav.Title)
239+ urlLower := strings.ToLower(fav.URL)
240+ if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
241+ continue
242+ }
243+ filtered = append(filtered, fav)
244+ }
245+
246+ sort.SliceStable(filtered, func(i, j int) bool {
247+ leftScore := omniboxFavoriteScore(filtered[i], queryLower)
248+ rightScore := omniboxFavoriteScore(filtered[j], queryLower)
249+ if leftScore != rightScore {
250+ return leftScore > rightScore
251+ }
252+ if filtered[i].Position != filtered[j].Position {
253+ return filtered[i].Position < filtered[j].Position
254+ }
255+ return filtered[i].URL < filtered[j].URL
256+ })
257+
258+ return filtered, nil
259+}
260+
190261 // IsFavorite checks if a URL is favorited.
191262 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192263 fav, err := uc.favoriteRepo.FindByURL(ctx, url)
@@ -196,6 +267,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196267 return fav != nil, nil
197268 }
198269
270+func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
271+ if fav == nil || queryLower == "" {
272+ return 0
273+ }
274+
275+ const (
276+ favoriteExactMatchScore = 600
277+ favoriteHostPrefixScore = 520
278+ favoriteTitlePrefixScore = 440
279+ favoriteTitleContainsScore = 320
280+ favoriteURLContainsScore = 280
281+ )
282+
283+ urlLower := strings.ToLower(fav.URL)
284+ strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
285+ if strippedURLLower == "" {
286+ strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
287+ }
288+ titleLower := strings.ToLower(fav.Title)
289+
290+ switch {
291+ case titleLower == queryLower || urlLower == queryLower:
292+ return favoriteExactMatchScore
293+ case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
294+ return favoriteHostPrefixScore
295+ case strings.HasPrefix(titleLower, queryLower):
296+ return favoriteTitlePrefixScore
297+ case strings.Contains(titleLower, queryLower):
298+ return favoriteTitleContainsScore
299+ case strings.Contains(urlLower, queryLower):
300+ return favoriteURLContainsScore
301+ default:
302+ return 0
303+ }
304+}
305+
199306 // ToggleResult indicates the result of a toggle operation.
200307 type ToggleResult struct {
201308 Added bool // true if favorite was added, false if removed
@@ -226,6 +333,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
226333 return nil, fmt.Errorf("failed to remove favorite: %w", err)
227334 }
228335 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
336+ uc.invalidateCache()
229337 return &ToggleResult{
230338 Added: false,
231339 URL: url,
@@ -240,6 +348,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
240348 return nil, fmt.Errorf("failed to add favorite: %w", err)
241349 }
242350 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
351+ uc.invalidateCache()
243352 return &ToggleResult{
244353 Added: true,
245354 URL: url,
@@ -253,18 +362,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st
253362 log := logging.FromContext(ctx)
254363 log.Debug().Msg("getting all favorite URLs")
255364
256- favs, err := uc.favoriteRepo.GetAll(ctx)
365+ uc.cacheMu.Lock()
366+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
367+ cached := uc.favoriteURLsCache
368+ uc.cacheMu.Unlock()
369+ return cached, nil
370+ }
371+ uc.cacheMu.Unlock()
372+
373+ favs, err := uc.GetAll(ctx)
257374 if err != nil {
258375 return nil, fmt.Errorf("failed to get favorites: %w", err)
259376 }
260377
378+ urls := buildFavoriteURLSet(favs)
379+
380+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
381+ return urls, nil
382+}
383+
384+func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
261385 urls := make(map[string]struct{}, len(favs))
262386 for _, fav := range favs {
387+ if fav == nil || fav.URL == "" {
388+ continue
389+ }
263390 urls[fav.URL] = struct{}{}
264391 }
392+ return urls
393+}
265394
266- log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267- return urls, nil
395+func (uc *ManageFavoritesUseCase) invalidateCache() {
396+ uc.cacheMu.Lock()
397+ defer uc.cacheMu.Unlock()
398+ uc.favoritesCache = nil
399+ uc.favoriteURLsCache = nil
400+ uc.cacheTime = time.Time{}
268401 }
269402
270403 // GetTree builds a complete hierarchical view of folders and favorites.
@@ -3,9 +3,14 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "sort"
7+ "strings"
8+ "sync"
9+ "time"
6 10
7 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/domain/repository"12 "github.com/bnema/dumber/internal/domain/repository"
13+ domainurl "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
10 )15 )
11 16
@@ -14,8 +19,15 @@ type ManageFavoritesUseCase struct {
14 favoriteRepo repository.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,69 @@ func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorit
178 log := logging.FromContext(ctx)195 log := logging.FromContext(ctx)
179 log.Debug().Msg("getting all favorites")196 log.Debug().Msg("getting all favorites")
180 197
198+ uc.cacheMu.Lock()
199+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
200+ cached := uc.favoritesCache
201+ uc.cacheMu.Unlock()
202+ return cached, nil
203+ }
204+ uc.cacheMu.Unlock()
205+
181 favs, err := uc.favoriteRepo.GetAll(ctx)206 favs, err := uc.favoriteRepo.GetAll(ctx)
182 if err != nil {207 if err != nil {
183 return nil, fmt.Errorf("failed to get favorites: %w", err)208 return nil, fmt.Errorf("failed to get favorites: %w", err)
184 }209 }
185 210
211+ uc.cacheMu.Lock()
212+ uc.favoritesCache = favs
213+ uc.favoriteURLsCache = buildFavoriteURLSet(favs)
214+ uc.cacheTime = time.Now()
215+ uc.cacheMu.Unlock()
216+
186 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")217 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187 return favs, nil218 return favs, nil
188 }219 }
189 220
221+// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
222+func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
223+ favs, err := uc.GetAll(ctx)
224+ if err != nil {
225+ return nil, err
226+ }
227+
228+ queryLower := strings.ToLower(strings.TrimSpace(query))
229+ if queryLower == "" {
230+ return favs, nil
231+ }
232+
233+ filtered := make([]*entity.Favorite, 0, len(favs))
234+ for _, fav := range favs {
235+ if fav == nil {
236+ continue
237+ }
238+ titleLower := strings.ToLower(fav.Title)
239+ urlLower := strings.ToLower(fav.URL)
240+ if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
241+ continue
242+ }
243+ filtered = append(filtered, fav)
244+ }
245+
246+ sort.SliceStable(filtered, func(i, j int) bool {
247+ leftScore := omniboxFavoriteScore(filtered[i], queryLower)
248+ rightScore := omniboxFavoriteScore(filtered[j], queryLower)
249+ if leftScore != rightScore {
250+ return leftScore > rightScore
251+ }
252+ if filtered[i].Position != filtered[j].Position {
253+ return filtered[i].Position < filtered[j].Position
254+ }
255+ return filtered[i].URL < filtered[j].URL
256+ })
257+
258+ return filtered, nil
259+}
260+
190 // IsFavorite checks if a URL is favorited.261 // IsFavorite checks if a URL is favorited.
191 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {262 func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192 fav, err := uc.favoriteRepo.FindByURL(ctx, url)263 fav, err := uc.favoriteRepo.FindByURL(ctx, url)
@@ -196,6 +267,42 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil267 return fav != nil, nil
197 }268 }
198 269
270+func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
271+ if fav == nil || queryLower == "" {
272+ return 0
273+ }
274+
275+ const (
276+ favoriteExactMatchScore = 600
277+ favoriteHostPrefixScore = 520
278+ favoriteTitlePrefixScore = 440
279+ favoriteTitleContainsScore = 320
280+ favoriteURLContainsScore = 280
281+ )
282+
283+ urlLower := strings.ToLower(fav.URL)
284+ strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
285+ if strippedURLLower == "" {
286+ strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
287+ }
288+ titleLower := strings.ToLower(fav.Title)
289+
290+ switch {
291+ case titleLower == queryLower || urlLower == queryLower:
292+ return favoriteExactMatchScore
293+ case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
294+ return favoriteHostPrefixScore
295+ case strings.HasPrefix(titleLower, queryLower):
296+ return favoriteTitlePrefixScore
297+ case strings.Contains(titleLower, queryLower):
298+ return favoriteTitleContainsScore
299+ case strings.Contains(urlLower, queryLower):
300+ return favoriteURLContainsScore
301+ default:
302+ return 0
303+ }
304+}
305+
199 // ToggleResult indicates the result of a toggle operation.306 // ToggleResult indicates the result of a toggle operation.
200 type ToggleResult struct {307 type ToggleResult struct {
201 Added bool // true if favorite was added, false if removed308 Added bool // true if favorite was added, false if removed
@@ -226,6 +333,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
226 return nil, fmt.Errorf("failed to remove favorite: %w", err)333 return nil, fmt.Errorf("failed to remove favorite: %w", err)
227 }334 }
228 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")335 log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
336+ uc.invalidateCache()
229 return &ToggleResult{337 return &ToggleResult{
230 Added: false,338 Added: false,
231 URL: url,339 URL: url,
@@ -240,6 +348,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
240 return nil, fmt.Errorf("failed to add favorite: %w", err)348 return nil, fmt.Errorf("failed to add favorite: %w", err)
241 }349 }
242 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")350 log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
351+ uc.invalidateCache()
243 return &ToggleResult{352 return &ToggleResult{
244 Added: true,353 Added: true,
245 URL: url,354 URL: url,
@@ -253,18 +362,42 @@ func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]st
253 log := logging.FromContext(ctx)362 log := logging.FromContext(ctx)
254 log.Debug().Msg("getting all favorite URLs")363 log.Debug().Msg("getting all favorite URLs")
255 364
256- favs, err := uc.favoriteRepo.GetAll(ctx)365+ uc.cacheMu.Lock()
366+ if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
367+ cached := uc.favoriteURLsCache
368+ uc.cacheMu.Unlock()
369+ return cached, nil
370+ }
371+ uc.cacheMu.Unlock()
372+
373+ favs, err := uc.GetAll(ctx)
257 if err != nil {374 if err != nil {
258 return nil, fmt.Errorf("failed to get favorites: %w", err)375 return nil, fmt.Errorf("failed to get favorites: %w", err)
259 }376 }
260 377
378+ urls := buildFavoriteURLSet(favs)
379+
380+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
381+ return urls, nil
382+}
383+
384+func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
261 urls := make(map[string]struct{}, len(favs))385 urls := make(map[string]struct{}, len(favs))
262 for _, fav := range favs {386 for _, fav := range favs {
387+ if fav == nil || fav.URL == "" {
388+ continue
389+ }
263 urls[fav.URL] = struct{}{}390 urls[fav.URL] = struct{}{}
264 }391 }
392+ return urls
393+}
265 394
266- log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")395+func (uc *ManageFavoritesUseCase) invalidateCache() {
267- return urls, nil396+ uc.cacheMu.Lock()
397+ defer uc.cacheMu.Unlock()
398+ uc.favoritesCache = nil
399+ uc.favoriteURLsCache = nil
400+ uc.cacheTime = time.Time{}
268 }401 }
269 402
270 // GetTree builds a complete hierarchical view of folders and favorites.403 // GetTree builds a complete hierarchical view of folders and favorites.
M internal/application/usecase/manage_favorites_test.go +104 -0
@@ -198,3 +198,107 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
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+}
@@ -198,3 +198,107 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
198 assert.Nil(t, result)198 assert.Nil(t, result)
199 assert.Contains(t, err.Error(), "failed to remove favorite")199 assert.Contains(t, err.Error(), "failed to remove favorite")
200 }200 }
201+
202+func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
203+ ctx := testContext()
204+
205+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
206+ folderRepo := repomocks.NewMockFolderRepository(t)
207+ tagRepo := repomocks.NewMockTagRepository(t)
208+
209+ favorites := []*entity.Favorite{
210+ {ID: 1, URL: "https://example.com/landing", Title: "GitHub docs mirror", Position: 0},
211+ {ID: 2, URL: "https://github.com/bnema/dumber", Title: "Project repo", Position: 1},
212+ {ID: 3, URL: "https://gitlab.com/team/project", Title: "GitLab", Position: 2},
213+ }
214+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
215+
216+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
217+
218+ results, err := uc.FilterForOmnibox(ctx, "git")
219+ require.NoError(t, err)
220+ require.Len(t, results, 3)
221+ assert.Equal(t, "https://github.com/bnema/dumber", results[0].URL)
222+ assert.Equal(t, "https://gitlab.com/team/project", results[1].URL)
223+ assert.Equal(t, "https://example.com/landing", results[2].URL)
224+}
225+
226+func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(t *testing.T) {
227+ ctx := testContext()
228+
229+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
230+ folderRepo := repomocks.NewMockFolderRepository(t)
231+ tagRepo := repomocks.NewMockTagRepository(t)
232+
233+ favorites := []*entity.Favorite{
234+ {ID: 1, URL: "https://first.example"},
235+ {ID: 2, URL: "https://second.example"},
236+ }
237+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
238+
239+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
240+
241+ results, err := uc.FilterForOmnibox(ctx, "")
242+ require.NoError(t, err)
243+ require.Len(t, results, 2)
244+ assert.Equal(t, "https://first.example", results[0].URL)
245+ assert.Equal(t, "https://second.example", results[1].URL)
246+}
247+
248+func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
249+ ctx := testContext()
250+
251+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
252+ folderRepo := repomocks.NewMockFolderRepository(t)
253+ tagRepo := repomocks.NewMockTagRepository(t)
254+
255+ favorites := []*entity.Favorite{
256+ {ID: 1, URL: "https://example.com"},
257+ }
258+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
259+
260+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
261+
262+ urls, err := uc.GetAllURLs(ctx)
263+ require.NoError(t, err)
264+ require.Contains(t, urls, "https://example.com")
265+
266+ urls, err = uc.GetAllURLs(ctx)
267+ require.NoError(t, err)
268+ require.Contains(t, urls, "https://example.com")
269+}
270+
271+func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
272+ ctx := testContext()
273+
274+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
275+ folderRepo := repomocks.NewMockFolderRepository(t)
276+ tagRepo := repomocks.NewMockTagRepository(t)
277+
278+ initialFavorites := []*entity.Favorite{
279+ {ID: 1, URL: "https://cached.example"},
280+ }
281+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(initialFavorites, nil).Once()
282+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://new.example").Return(nil, nil)
283+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
284+ return f.URL == "https://new.example"
285+ })).Return(nil)
286+ updatedFavorites := []*entity.Favorite{
287+ {ID: 1, URL: "https://cached.example"},
288+ {ID: 2, URL: "https://new.example"},
289+ }
290+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
291+
292+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
293+
294+ urls, err := uc.GetAllURLs(ctx)
295+ require.NoError(t, err)
296+ require.Contains(t, urls, "https://cached.example")
297+
298+ _, err = uc.Toggle(ctx, "https://new.example", "New")
299+ require.NoError(t, err)
300+
301+ urls, err = uc.GetAllURLs(ctx)
302+ require.NoError(t, err)
303+ require.Contains(t, urls, "https://new.example")
304+}
M internal/application/usecase/navigate.go +10 -0
@@ -39,6 +39,10 @@ type historyVisitDeltaIncrementer interface {
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 +270 -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,6 +96,23 @@ 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) {
75117 words := sanitizeFTS5QueryTokens(query)
76118 if len(words) == 0 {
@@ -106,40 +148,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
106148 titleRows = []sqlc.History{}
107149 }
108150
109- // Merge results by interleaving URL and title matches for balanced results
110- seen := make(map[int64]bool)
111- var matches []entity.HistoryMatch
112-
113- i, j := 0, 0
114- for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
115- // Take next URL match if available
116- if i < len(urlRows) {
117- row := urlRows[i]
118- i++
119- if !seen[row.ID] {
120- seen[row.ID] = true
121- matches = append(matches, entity.HistoryMatch{
122- Entry: historyFromDomainBoostRow(row),
123- Score: 1.0,
124- })
125- }
126- }
127-
128- // Take next title match if available
129- if j < len(titleRows) && len(matches) < limit {
130- row := titleRows[j]
131- j++
132- if !seen[row.ID] {
133- seen[row.ID] = true
134- matches = append(matches, entity.HistoryMatch{
135- Entry: historyFromRow(row),
136- Score: 1.0,
137- })
138- }
139- }
151+ ranked := rankHistoryMatches(query, words, urlRows, titleRows)
152+ if len(ranked) > limit {
153+ ranked = ranked[:limit]
140154 }
141-
142- return matches, nil
155+ return ranked, nil
143156 }
144157
145158 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {
@@ -154,6 +167,221 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
154167 }
155168 }
156169
170+func nullString(value string) sql.NullString {
171+ return sql.NullString{String: value, Valid: value != ""}
172+}
173+
174+type historySearchCandidate struct {
175+ entry *entity.HistoryEntry
176+ score float64
177+ urlMatch bool
178+ titleMatch bool
179+ domainBoost int64
180+ exactURLMatch bool
181+ prefixURL bool
182+ prefixTitle bool
183+}
184+
185+func rankHistoryMatches(
186+ query string,
187+ words []string,
188+ urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow,
189+ titleRows []sqlc.History,
190+) []entity.HistoryMatch {
191+ candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows))
192+
193+ for i := range urlRows {
194+ row := urlRows[i]
195+ entry := historyFromDomainBoostRow(row)
196+ candidate := ensureHistoryCandidate(candidates, entry)
197+ candidate.urlMatch = true
198+ candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost)
199+ }
200+
201+ for i := range titleRows {
202+ row := titleRows[i]
203+ entry := historyFromRow(row)
204+ candidate := ensureHistoryCandidate(candidates, entry)
205+ candidate.titleMatch = true
206+ }
207+
208+ queryLower := strings.ToLower(strings.TrimSpace(query))
209+ matches := make([]entity.HistoryMatch, 0, len(candidates))
210+ for _, candidate := range candidates {
211+ candidate.score = computeHistoryCandidateScore(candidate, queryLower, words)
212+ matches = append(matches, entity.HistoryMatch{
213+ Entry: candidate.entry,
214+ Score: candidate.score,
215+ })
216+ }
217+
218+ sort.Slice(matches, func(i, j int) bool {
219+ left := candidates[matches[i].Entry.ID]
220+ right := candidates[matches[j].Entry.ID]
221+ if matches[i].Score != matches[j].Score {
222+ return matches[i].Score > matches[j].Score
223+ }
224+ if left.exactURLMatch != right.exactURLMatch {
225+ return left.exactURLMatch
226+ }
227+ if left.prefixURL != right.prefixURL {
228+ return left.prefixURL
229+ }
230+ if left.urlMatch != right.urlMatch {
231+ return left.urlMatch
232+ }
233+ if left.prefixTitle != right.prefixTitle {
234+ return left.prefixTitle
235+ }
236+ if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount {
237+ return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount
238+ }
239+ if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) {
240+ return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited)
241+ }
242+ return matches[i].Entry.URL < matches[j].Entry.URL
243+ })
244+
245+ return matches
246+}
247+
248+func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate {
249+ candidate, ok := candidates[entry.ID]
250+ if ok {
251+ return candidate
252+ }
253+ candidate = &historySearchCandidate{entry: entry}
254+ candidates[entry.ID] = candidate
255+ return candidate
256+}
257+
258+func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 {
259+ if candidate == nil || candidate.entry == nil {
260+ return 0
261+ }
262+
263+ urlLower := strings.ToLower(candidate.entry.URL)
264+ titleLower := strings.ToLower(candidate.entry.Title)
265+ hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL))
266+
267+ score := 0.0
268+ if candidate.urlMatch {
269+ score += historyURLMatchScore
270+ }
271+ if candidate.titleMatch {
272+ score += historyTitleMatchScore
273+ }
274+
275+ score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower)
276+ score += scoreHistoryWords(words, hostLower, urlLower, titleLower)
277+
278+ if looksLikeHostStyleQuery(queryLower) {
279+ pathDepth := historyPathDepth(candidate.entry.URL)
280+ score -= float64(pathDepth * historyPathDepthPenalty)
281+ if pathDepth == 0 {
282+ score += historyRootPathBonus
283+ }
284+ }
285+
286+ score += float64(candidate.domainBoost) * historyDomainBoostScore
287+ score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap)
288+ if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour {
289+ score += historyRecentVisitScore
290+ }
291+ return score
292+}
293+
294+func scoreQueryMatch(
295+ candidate *historySearchCandidate,
296+ queryLower, hostLower, urlLower, titleLower string,
297+) float64 {
298+ if queryLower == "" {
299+ return 0
300+ }
301+
302+ switch {
303+ case hostLower == queryLower:
304+ candidate.exactURLMatch = true
305+ return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
306+ case strings.TrimPrefix(hostLower, "www.") == queryLower:
307+ candidate.exactURLMatch = true
308+ return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
309+ case strings.HasPrefix(hostLower, queryLower):
310+ candidate.prefixURL = true
311+ return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
312+ case strings.HasPrefix(urlLower, queryLower):
313+ candidate.prefixURL = true
314+ return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
315+ case strings.Contains(urlLower, queryLower):
316+ return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
317+ default:
318+ return scoreTitleQueryMatch(candidate, queryLower, titleLower)
319+ }
320+}
321+
322+func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 {
323+ switch {
324+ case strings.HasPrefix(titleLower, queryLower):
325+ candidate.prefixTitle = true
326+ return historyTitlePrefixScore
327+ case strings.Contains(titleLower, queryLower):
328+ return historyTitleContainsScore
329+ default:
330+ return 0
331+ }
332+}
333+
334+func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 {
335+ score := 0.0
336+ for _, word := range words {
337+ wordLower := strings.ToLower(word)
338+ if wordLower == "" {
339+ continue
340+ }
341+ if strings.HasPrefix(hostLower, wordLower) {
342+ score += historyWordHostPrefixScore
343+ } else if strings.Contains(urlLower, wordLower) {
344+ score += historyWordURLContainsScore
345+ }
346+ if strings.HasPrefix(titleLower, wordLower) {
347+ score += historyWordTitlePrefixScore
348+ } else if strings.Contains(titleLower, wordLower) {
349+ score += historyWordTitleScore
350+ }
351+ }
352+ return score
353+}
354+
355+func looksLikeHostStyleQuery(query string) bool {
356+ return query != "" && !strings.ContainsAny(query, " /?#=&")
357+}
358+
359+func historyPathDepth(rawURL string) int {
360+ parsed, err := url.Parse(rawURL)
361+ if err != nil {
362+ return 0
363+ }
364+ trimmed := strings.Trim(parsed.Path, "/")
365+ if trimmed == "" {
366+ return 0
367+ }
368+ return len(strings.Split(trimmed, "/"))
369+}
370+
371+func maxInt64(left, right int64) int64 {
372+ if left > right {
373+ return left
374+ }
375+ return right
376+}
377+
378+func minFloat64(left, right float64) float64 {
379+ if left < right {
380+ return left
381+ }
382+ return right
383+}
384+
157385 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
158386 func sanitizeFTS5QueryTokens(query string) []string {
159387 normalized := normalizeFTSSeparators(query)
@@ -303,17 +531,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
303531 return entries, nil
304532 }
305533
306-func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
534+func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
307535 // Skip incrementing about:blank - it should always have visit_count = 1
308- if url == aboutBlankURL {
536+ if rawURL == aboutBlankURL {
309537 return nil
310538 }
311- return r.queries.IncrementVisitCount(ctx, url)
539+ return r.queries.IncrementVisitCount(ctx, rawURL)
312540 }
313541
314-func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
542+func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error {
315543 // Skip incrementing about:blank - it should always have visit_count = 1
316- if url == aboutBlankURL {
544+ if rawURL == aboutBlankURL {
317545 return nil
318546 }
319547 if delta <= 0 {
@@ -321,7 +549,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
321549 }
322550 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323551 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
324- Url: url,
552+ Url: rawURL,
325553 })
326554 }
327555
@@ -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,6 +96,23 @@ 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) {
75 words := sanitizeFTS5QueryTokens(query)117 words := sanitizeFTS5QueryTokens(query)
76 if len(words) == 0 {118 if len(words) == 0 {
@@ -106,40 +148,11 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
106 titleRows = []sqlc.History{}148 titleRows = []sqlc.History{}
107 }149 }
108 150
109- // Merge results by interleaving URL and title matches for balanced results151+ ranked := rankHistoryMatches(query, words, urlRows, titleRows)
110- seen := make(map[int64]bool)152+ if len(ranked) > limit {
111- var matches []entity.HistoryMatch153+ ranked = ranked[:limit]
112-
113- i, j := 0, 0
114- for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
115- // Take next URL match if available
116- if i < len(urlRows) {
117- row := urlRows[i]
118- i++
119- if !seen[row.ID] {
120- seen[row.ID] = true
121- matches = append(matches, entity.HistoryMatch{
122- Entry: historyFromDomainBoostRow(row),
123- Score: 1.0,
124- })
125- }
126- }
127-
128- // Take next title match if available
129- if j < len(titleRows) && len(matches) < limit {
130- row := titleRows[j]
131- j++
132- if !seen[row.ID] {
133- seen[row.ID] = true
134- matches = append(matches, entity.HistoryMatch{
135- Entry: historyFromRow(row),
136- Score: 1.0,
137- })
138- }
139- }
140 }154 }
141-155+ return ranked, nil
142- return matches, nil
143 }156 }
144 157
145 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {158 func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {
@@ -154,6 +167,221 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
154 }167 }
155 }168 }
156 169
170+func nullString(value string) sql.NullString {
171+ return sql.NullString{String: value, Valid: value != ""}
172+}
173+
174+type historySearchCandidate struct {
175+ entry *entity.HistoryEntry
176+ score float64
177+ urlMatch bool
178+ titleMatch bool
179+ domainBoost int64
180+ exactURLMatch bool
181+ prefixURL bool
182+ prefixTitle bool
183+}
184+
185+func rankHistoryMatches(
186+ query string,
187+ words []string,
188+ urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow,
189+ titleRows []sqlc.History,
190+) []entity.HistoryMatch {
191+ candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows))
192+
193+ for i := range urlRows {
194+ row := urlRows[i]
195+ entry := historyFromDomainBoostRow(row)
196+ candidate := ensureHistoryCandidate(candidates, entry)
197+ candidate.urlMatch = true
198+ candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost)
199+ }
200+
201+ for i := range titleRows {
202+ row := titleRows[i]
203+ entry := historyFromRow(row)
204+ candidate := ensureHistoryCandidate(candidates, entry)
205+ candidate.titleMatch = true
206+ }
207+
208+ queryLower := strings.ToLower(strings.TrimSpace(query))
209+ matches := make([]entity.HistoryMatch, 0, len(candidates))
210+ for _, candidate := range candidates {
211+ candidate.score = computeHistoryCandidateScore(candidate, queryLower, words)
212+ matches = append(matches, entity.HistoryMatch{
213+ Entry: candidate.entry,
214+ Score: candidate.score,
215+ })
216+ }
217+
218+ sort.Slice(matches, func(i, j int) bool {
219+ left := candidates[matches[i].Entry.ID]
220+ right := candidates[matches[j].Entry.ID]
221+ if matches[i].Score != matches[j].Score {
222+ return matches[i].Score > matches[j].Score
223+ }
224+ if left.exactURLMatch != right.exactURLMatch {
225+ return left.exactURLMatch
226+ }
227+ if left.prefixURL != right.prefixURL {
228+ return left.prefixURL
229+ }
230+ if left.urlMatch != right.urlMatch {
231+ return left.urlMatch
232+ }
233+ if left.prefixTitle != right.prefixTitle {
234+ return left.prefixTitle
235+ }
236+ if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount {
237+ return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount
238+ }
239+ if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) {
240+ return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited)
241+ }
242+ return matches[i].Entry.URL < matches[j].Entry.URL
243+ })
244+
245+ return matches
246+}
247+
248+func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate {
249+ candidate, ok := candidates[entry.ID]
250+ if ok {
251+ return candidate
252+ }
253+ candidate = &historySearchCandidate{entry: entry}
254+ candidates[entry.ID] = candidate
255+ return candidate
256+}
257+
258+func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 {
259+ if candidate == nil || candidate.entry == nil {
260+ return 0
261+ }
262+
263+ urlLower := strings.ToLower(candidate.entry.URL)
264+ titleLower := strings.ToLower(candidate.entry.Title)
265+ hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL))
266+
267+ score := 0.0
268+ if candidate.urlMatch {
269+ score += historyURLMatchScore
270+ }
271+ if candidate.titleMatch {
272+ score += historyTitleMatchScore
273+ }
274+
275+ score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower)
276+ score += scoreHistoryWords(words, hostLower, urlLower, titleLower)
277+
278+ if looksLikeHostStyleQuery(queryLower) {
279+ pathDepth := historyPathDepth(candidate.entry.URL)
280+ score -= float64(pathDepth * historyPathDepthPenalty)
281+ if pathDepth == 0 {
282+ score += historyRootPathBonus
283+ }
284+ }
285+
286+ score += float64(candidate.domainBoost) * historyDomainBoostScore
287+ score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap)
288+ if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour {
289+ score += historyRecentVisitScore
290+ }
291+ return score
292+}
293+
294+func scoreQueryMatch(
295+ candidate *historySearchCandidate,
296+ queryLower, hostLower, urlLower, titleLower string,
297+) float64 {
298+ if queryLower == "" {
299+ return 0
300+ }
301+
302+ switch {
303+ case hostLower == queryLower:
304+ candidate.exactURLMatch = true
305+ return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
306+ case strings.TrimPrefix(hostLower, "www.") == queryLower:
307+ candidate.exactURLMatch = true
308+ return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
309+ case strings.HasPrefix(hostLower, queryLower):
310+ candidate.prefixURL = true
311+ return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
312+ case strings.HasPrefix(urlLower, queryLower):
313+ candidate.prefixURL = true
314+ return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
315+ case strings.Contains(urlLower, queryLower):
316+ return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
317+ default:
318+ return scoreTitleQueryMatch(candidate, queryLower, titleLower)
319+ }
320+}
321+
322+func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 {
323+ switch {
324+ case strings.HasPrefix(titleLower, queryLower):
325+ candidate.prefixTitle = true
326+ return historyTitlePrefixScore
327+ case strings.Contains(titleLower, queryLower):
328+ return historyTitleContainsScore
329+ default:
330+ return 0
331+ }
332+}
333+
334+func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 {
335+ score := 0.0
336+ for _, word := range words {
337+ wordLower := strings.ToLower(word)
338+ if wordLower == "" {
339+ continue
340+ }
341+ if strings.HasPrefix(hostLower, wordLower) {
342+ score += historyWordHostPrefixScore
343+ } else if strings.Contains(urlLower, wordLower) {
344+ score += historyWordURLContainsScore
345+ }
346+ if strings.HasPrefix(titleLower, wordLower) {
347+ score += historyWordTitlePrefixScore
348+ } else if strings.Contains(titleLower, wordLower) {
349+ score += historyWordTitleScore
350+ }
351+ }
352+ return score
353+}
354+
355+func looksLikeHostStyleQuery(query string) bool {
356+ return query != "" && !strings.ContainsAny(query, " /?#=&")
357+}
358+
359+func historyPathDepth(rawURL string) int {
360+ parsed, err := url.Parse(rawURL)
361+ if err != nil {
362+ return 0
363+ }
364+ trimmed := strings.Trim(parsed.Path, "/")
365+ if trimmed == "" {
366+ return 0
367+ }
368+ return len(strings.Split(trimmed, "/"))
369+}
370+
371+func maxInt64(left, right int64) int64 {
372+ if left > right {
373+ return left
374+ }
375+ return right
376+}
377+
378+func minFloat64(left, right float64) float64 {
379+ if left < right {
380+ return left
381+ }
382+ return right
383+}
384+
157 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.385 // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
158 func sanitizeFTS5QueryTokens(query string) []string {386 func sanitizeFTS5QueryTokens(query string) []string {
159 normalized := normalizeFTSSeparators(query)387 normalized := normalizeFTSSeparators(query)
@@ -303,17 +531,17 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
303 return entries, nil531 return entries, nil
304 }532 }
305 533
306-func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {534+func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
307 // Skip incrementing about:blank - it should always have visit_count = 1535 // Skip incrementing about:blank - it should always have visit_count = 1
308- if url == aboutBlankURL {536+ if rawURL == aboutBlankURL {
309 return nil537 return nil
310 }538 }
311- return r.queries.IncrementVisitCount(ctx, url)539+ return r.queries.IncrementVisitCount(ctx, rawURL)
312 }540 }
313 541
314-func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {542+func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error {
315 // Skip incrementing about:blank - it should always have visit_count = 1543 // Skip incrementing about:blank - it should always have visit_count = 1
316- if url == aboutBlankURL {544+ if rawURL == aboutBlankURL {
317 return nil545 return nil
318 }546 }
319 if delta <= 0 {547 if delta <= 0 {
@@ -321,7 +549,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
321 }549 }
322 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{550 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},551 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
324- Url: url,552+ Url: rawURL,
325 })553 })
326 }554 }
327 555
M internal/infrastructure/persistence/sqlite/history_repo_test.go +113 -0
@@ -627,6 +627,119 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
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")
@@ -627,6 +627,119 @@ func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
627 require.NotEmpty(t, results, "slash-separated query should match history")627 require.NotEmpty(t, results, "slash-separated query should match history")
628 }628 }
629 629
630+func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T) {
631+ ctx := historyTestCtx()
632+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
633+
634+ db, err := sqlite.NewConnection(ctx, dbPath)
635+ require.NoError(t, err)
636+ t.Cleanup(func() { _ = db.Close() })
637+
638+ repo := sqlite.NewHistoryRepository(db)
639+
640+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
641+ URL: "https://github.com/bnema/dumber/issues",
642+ Title: "Project board",
643+ }))
644+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
645+ URL: "https://tracker.example.com/board",
646+ Title: "GitHub Issues Mirror",
647+ }))
648+
649+ results, err := repo.Search(ctx, "github issues", 10)
650+ require.NoError(t, err)
651+ require.Len(t, results, 2)
652+ assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)
653+}
654+
655+func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.T) {
656+ ctx := historyTestCtx()
657+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
658+
659+ db, err := sqlite.NewConnection(ctx, dbPath)
660+ require.NoError(t, err)
661+ t.Cleanup(func() { _ = db.Close() })
662+
663+ repo := sqlite.NewHistoryRepository(db)
664+
665+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
666+ URL: "https://github.com",
667+ Title: "GitHub",
668+ }))
669+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
670+ URL: "https://example.com/landing",
671+ Title: "GitHub documentation mirror",
672+ }))
673+
674+ results, err := repo.Search(ctx, "git", 10)
675+ require.NoError(t, err)
676+ require.Len(t, results, 2)
677+ assert.Equal(t, "https://github.com", results[0].Entry.URL)
678+}
679+
680+func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T) {
681+ ctx := historyTestCtx()
682+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
683+
684+ db, err := sqlite.NewConnection(ctx, dbPath)
685+ require.NoError(t, err)
686+ t.Cleanup(func() { _ = db.Close() })
687+
688+ repo := sqlite.NewHistoryRepository(db)
689+
690+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
691+ URL: "https://github.com",
692+ Title: "GitHub",
693+ }))
694+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
695+ URL: "https://github.com/bnema/dumber/issues/123",
696+ Title: "Issue 123",
697+ }))
698+
699+ results, err := repo.Search(ctx, "github", 10)
700+ require.NoError(t, err)
701+ require.Len(t, results, 2)
702+ assert.Equal(t, "https://github.com", results[0].Entry.URL)
703+}
704+
705+func TestHistoryRepository_UpdateMetadata_DoesNotIncrementVisitCount(t *testing.T) {
706+ ctx := historyTestCtx()
707+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
708+
709+ db, err := sqlite.NewConnection(ctx, dbPath)
710+ require.NoError(t, err)
711+ t.Cleanup(func() { _ = db.Close() })
712+
713+ repoIface := sqlite.NewHistoryRepository(db)
714+ repo, ok := repoIface.(interface {
715+ Save(context.Context, *entity.HistoryEntry) error
716+ FindByURL(context.Context, string) (*entity.HistoryEntry, error)
717+ UpdateMetadata(context.Context, *entity.HistoryEntry) error
718+ })
719+ require.True(t, ok)
720+
721+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
722+ URL: "https://github.com/bnema/dumber",
723+ Title: "Old title",
724+ }))
725+
726+ before, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
727+ require.NoError(t, err)
728+ require.NotNil(t, before)
729+ require.Equal(t, int64(1), before.VisitCount)
730+
731+ require.NoError(t, repo.UpdateMetadata(ctx, &entity.HistoryEntry{
732+ URL: "https://github.com/bnema/dumber",
733+ Title: "New title",
734+ }))
735+
736+ after, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
737+ require.NoError(t, err)
738+ require.NotNil(t, after)
739+ assert.Equal(t, int64(1), after.VisitCount)
740+ assert.Equal(t, "New title", after.Title)
741+}
742+
630 func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {743 func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
631 ctx := historyTestCtx()744 ctx := historyTestCtx()
632 dbPath := filepath.Join(t.TempDir(), "dumber.db")745 dbPath := filepath.Join(t.TempDir(), "dumber.db")
M internal/infrastructure/persistence/sqlite/lazy_repos.go +12 -0
@@ -123,6 +123,18 @@ func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*ent
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 +179 -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,20 @@ 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.selectIndex(index)
1856+ }
1857+ o.retainedCallbacks = append(o.retainedCallbacks, enterCb)
1858+ motionCtrl.ConnectEnter(&enterCb)
1859+ row.AddController(&motionCtrl.EventController)
1860+}
1861+
17861862 // createSuggestionRow creates a ListBoxRow for a suggestion.
17871863 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
17881864 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
@@ -1809,13 +1885,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
18091885
18101886 // selectIndex selects a row by index.
18111887 func (o *Omnibox) selectIndex(index int) {
1888+ o.mu.Lock()
1889+ o.selectedIndex = index
1890+ o.mu.Unlock()
1891+
18121892 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
18131893 // which also acquires the mutex, causing deadlock
18141894 row := o.listBox.GetRowAtIndex(index)
18151895 if row != nil {
18161896 o.listBox.SelectRow(row)
1817- // selectedIndex is updated by rowSelectedCb
1897+ return
18181898 }
1899+
1900+ o.updateGhostFromSelection()
18191901 }
18201902
18211903 // selectNext moves selection down.
@@ -1938,16 +2020,14 @@ func (o *Omnibox) navigateToSelected() {
19382020 o.entry.SetPosition(-1)
19392021 return
19402022 }
2023+ return
19412024 }
19422025
19432026 var targetURL string
1944- if shouldPreferTypedURLNavigation(entryText) {
1945- targetURL = o.buildURL(entryText)
1946- } else if idx >= 0 {
2027+ if idx >= 0 {
19472028 // If user has selected a result, navigate to that result.
19482029 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
19492030 } else {
1950- // No selection - use entry text as URL/search.
19512031 targetURL = o.buildURL(entryText)
19522032 }
19532033
@@ -2410,6 +2490,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
24102490 return o.visible && o.searchToken == token
24112491 }
24122492
2493+func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2494+ o.mu.RLock()
2495+ defer o.mu.RUnlock()
2496+ return o.visible && o.searchToken == token && o.realInput == query
2497+}
2498+
24132499 // Widget returns the omnibox widget for embedding in an overlay.
24142500 func (o *Omnibox) Widget() *gtk.Widget {
24152501 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,20 @@ 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.selectIndex(index)
1856+ }
1857+ o.retainedCallbacks = append(o.retainedCallbacks, enterCb)
1858+ motionCtrl.ConnectEnter(&enterCb)
1859+ row.AddController(&motionCtrl.EventController)
1860+}
1861+
1786 // createSuggestionRow creates a ListBoxRow for a suggestion.1862 // createSuggestionRow creates a ListBoxRow for a suggestion.
1787 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {1863 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1788 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)1864 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
@@ -1809,13 +1885,19 @@ func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1809 1885
1810 // selectIndex selects a row by index.1886 // selectIndex selects a row by index.
1811 func (o *Omnibox) selectIndex(index int) {1887 func (o *Omnibox) selectIndex(index int) {
1888+ o.mu.Lock()
1889+ o.selectedIndex = index
1890+ o.mu.Unlock()
1891+
1812 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb1892 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
1813 // which also acquires the mutex, causing deadlock1893 // which also acquires the mutex, causing deadlock
1814 row := o.listBox.GetRowAtIndex(index)1894 row := o.listBox.GetRowAtIndex(index)
1815 if row != nil {1895 if row != nil {
1816 o.listBox.SelectRow(row)1896 o.listBox.SelectRow(row)
1817- // selectedIndex is updated by rowSelectedCb1897+ return
1818 }1898 }
1899+
1900+ o.updateGhostFromSelection()
1819 }1901 }
1820 1902
1821 // selectNext moves selection down.1903 // selectNext moves selection down.
@@ -1938,16 +2020,14 @@ func (o *Omnibox) navigateToSelected() {
1938 o.entry.SetPosition(-1)2020 o.entry.SetPosition(-1)
1939 return2021 return
1940 }2022 }
2023+ return
1941 }2024 }
1942 2025
1943 var targetURL string2026 var targetURL string
1944- if shouldPreferTypedURLNavigation(entryText) {2027+ if idx >= 0 {
1945- targetURL = o.buildURL(entryText)
1946- } else if idx >= 0 {
1947 // If user has selected a result, navigate to that result.2028 // If user has selected a result, navigate to that result.
1948 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)2029 targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
1949 } else {2030 } else {
1950- // No selection - use entry text as URL/search.
1951 targetURL = o.buildURL(entryText)2031 targetURL = o.buildURL(entryText)
1952 }2032 }
1953 2033
@@ -2410,6 +2490,12 @@ func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2410 return o.visible && o.searchToken == token2490 return o.visible && o.searchToken == token
2411 }2491 }
2412 2492
2493+func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2494+ o.mu.RLock()
2495+ defer o.mu.RUnlock()
2496+ return o.visible && o.searchToken == token && o.realInput == query
2497+}
2498+
2413 // Widget returns the omnibox widget for embedding in an overlay.2499 // Widget returns the omnibox widget for embedding in an overlay.
2414 func (o *Omnibox) Widget() *gtk.Widget {2500 func (o *Omnibox) Widget() *gtk.Widget {
2415 if o.outerBox == nil {2501 if o.outerBox == nil {
M internal/ui/component/omnibox_async_guard_test.go +25 -7
@@ -23,20 +23,38 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
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 }
4238 }
39+
40+func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
41+ o := &Omnibox{}
42+ o.visible = true
43+ o.searchToken = 9
44+ o.realInput = "!gh"
45+
46+ if !o.isBangUpdateCurrent("!gh", 9) {
47+ t.Fatalf("expected bang update to be current")
48+ }
49+ if o.isBangUpdateCurrent("!g", 9) {
50+ t.Fatalf("expected bang query mismatch to be rejected")
51+ }
52+ if o.isBangUpdateCurrent("!gh", 8) {
53+ t.Fatalf("expected stale bang token to be rejected")
54+ }
55+
56+ o.visible = false
57+ if o.isBangUpdateCurrent("!gh", 9) {
58+ t.Fatalf("expected hidden omnibox to reject bang updates")
59+ }
60+}
@@ -23,20 +23,38 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
23 func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {23 func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24 o := &Omnibox{}24 o := &Omnibox{}
25 o.visible = 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 }
42 }38 }
39+
40+func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
41+ o := &Omnibox{}
42+ o.visible = true
43+ o.searchToken = 9
44+ o.realInput = "!gh"
45+
46+ if !o.isBangUpdateCurrent("!gh", 9) {
47+ t.Fatalf("expected bang update to be current")
48+ }
49+ if o.isBangUpdateCurrent("!g", 9) {
50+ t.Fatalf("expected bang query mismatch to be rejected")
51+ }
52+ if o.isBangUpdateCurrent("!gh", 8) {
53+ t.Fatalf("expected stale bang token to be rejected")
54+ }
55+
56+ o.visible = false
57+ if o.isBangUpdateCurrent("!gh", 9) {
58+ t.Fatalf("expected hidden omnibox to reject bang updates")
59+ }
60+}
M internal/ui/component/omnibox_navigation_state_test.go +106 -0
@@ -116,6 +116,16 @@ func TestEffectiveSearchQuery(t *testing.T) {
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,99 @@ 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+}
@@ -116,6 +116,16 @@ func TestEffectiveSearchQuery(t *testing.T) {
116 }116 }
117 }117 }
118 118
119+func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {
120+ entryText := "github.com/bnema/dumber"
121+ realInput := "upl"
122+
123+ got := effectiveSearchQuery(entryText, realInput, true)
124+ if got != realInput {
125+ t.Fatalf("effectiveSearchQuery should prefer real input when ghost is visible, got %q want %q", got, realInput)
126+ }
127+}
128+
119 func TestResolveTargetURLForSelection(t *testing.T) {129 func TestResolveTargetURLForSelection(t *testing.T) {
120 suggestions := []Suggestion{130 suggestions := []Suggestion{
121 {URL: "https://github.com/bnema/dumber"},131 {URL: "https://github.com/bnema/dumber"},
@@ -145,3 +155,99 @@ func TestResolveTargetURLForSelection(t *testing.T) {
145 })155 })
146 }156 }
147 }157 }
158+
159+func TestSelectedTargetURL(t *testing.T) {
160+ suggestions := []Suggestion{
161+ {URL: "https://github.com/bnema/dumber"},
162+ }
163+ favorites := []Favorite{
164+ {URL: "https://dumber.bnema.dev"},
165+ }
166+
167+ tests := []struct {
168+ name string
169+ mode ViewMode
170+ index int
171+ wantURL string
172+ wantBool bool
173+ }{
174+ {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false},
175+ {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true},
176+ {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true},
177+ }
178+
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites)
182+ if gotURL != tt.wantURL || gotBool != tt.wantBool {
183+ t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool)
184+ }
185+ })
186+ }
187+}
188+
189+func TestVisibleGhostSuggestion(t *testing.T) {
190+ suggestions := []Suggestion{
191+ {URL: "https://github.com/bnema/dumber"},
192+ {URL: "https://gitlab.com/team/project"},
193+ }
194+
195+ tests := []struct {
196+ name string
197+ input string
198+ selectedURL string
199+ hasExplicitSelection bool
200+ wantFull string
201+ wantSuffix string
202+ wantOK bool
203+ }{
204+ {
205+ name: "top visible candidate drives ghost without selection",
206+ input: "git",
207+ wantFull: "github.com",
208+ wantSuffix: "hub.com",
209+ wantOK: true,
210+ },
211+ {
212+ name: "explicit selection wins over top candidate",
213+ input: "git",
214+ selectedURL: "https://gitlab.com/team/project",
215+ hasExplicitSelection: true,
216+ wantFull: "gitlab.com/team/project",
217+ wantSuffix: "lab.com/team/project",
218+ wantOK: true,
219+ },
220+ {
221+ name: "no visible prefix match means no ghost",
222+ input: "xyz",
223+ wantFull: "",
224+ wantSuffix: "",
225+ wantOK: false,
226+ },
227+ }
228+
229+ for _, tt := range tests {
230+ t.Run(tt.name, func(t *testing.T) {
231+ gotFull, gotSuffix, gotOK := visibleGhostSuggestion(
232+ tt.input,
233+ tt.selectedURL,
234+ tt.hasExplicitSelection,
235+ ViewModeHistory,
236+ suggestions,
237+ nil,
238+ )
239+ if gotFull != tt.wantFull || gotSuffix != tt.wantSuffix || gotOK != tt.wantOK {
240+ t.Fatalf(
241+ "visibleGhostSuggestion(%q) = (%q, %q, %v), want (%q, %q, %v)",
242+ tt.input,
243+ gotFull,
244+ gotSuffix,
245+ gotOK,
246+ tt.wantFull,
247+ tt.wantSuffix,
248+ tt.wantOK,
249+ )
250+ }
251+ })
252+ }
253+}

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