detached c1836ab

Merge pull request #151 from bnema/refactor/omnibox-autocomplete

fix: omnibox stability and auto-open timing
commit
c1836ab3e1f57a120954f593df4a0e5711479417
author
bnema · · 2026-01-26 05:58 UTC
committer
GitHub · · 2026-01-26 05:58 UTC
merge of
654b259 + 3cd9d2e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +625 -161
M internal/application/usecase/autocomplete.go +154 -11
@@ -2,8 +2,11 @@ package usecase
22
33 import (
44 "context"
5+ "sync"
6+ "time"
57
68 "github.com/bnema/dumber/internal/domain/autocomplete"
9+ "github.com/bnema/dumber/internal/domain/entity"
710 "github.com/bnema/dumber/internal/logging"
811 )
912
@@ -12,8 +15,17 @@ type AutocompleteUseCase struct {
1215 historyUC *SearchHistoryUseCase
1316 favoritesUC *ManageFavoritesUseCase
1417 shortcutsUC *SearchShortcutsUseCase
18+
19+ cacheMu sync.Mutex
20+ favoritesCache []*entity.Favorite
21+ favoritesCached time.Time
22+ historyCache []*entity.HistoryEntry
23+ historyCached time.Time
1524 }
1625
26+const autocompleteHistoryLimit = 200
27+const autocompleteCacheTTL = 2 * time.Second
28+
1729 // NewAutocompleteUseCase creates a new autocomplete use case.
1830 func NewAutocompleteUseCase(
1931 historyUC *SearchHistoryUseCase,
@@ -38,6 +50,12 @@ type GetSuggestionOutput struct {
3850 Found bool
3951 }
4052
53+// CompletionOptions controls how URL completions are resolved.
54+type CompletionOptions struct {
55+ VisibleURLs []string
56+ AllowBangs bool
57+}
58+
4159 // GetSuggestion returns the best autocomplete suggestion for the given input.
4260 // Priority: Favorites > History (by frecency) > Bang shortcuts.
4361 func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
@@ -77,18 +95,72 @@ func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSugge
7795 return &GetSuggestionOutput{Found: false}
7896 }
7997
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+
80146 // findFavoriteSuggestion finds the best matching favorite for the input.
81147 func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148+ log := logging.FromContext(ctx)
149+
82150 if uc.favoritesUC == nil {
83151 return nil
84152 }
85153
86- favorites, err := uc.favoritesUC.GetAll(ctx)
154+ favorites, err := uc.getCachedFavorites(ctx)
87155 if err != nil {
156+ log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
88157 return nil
89158 }
90159
91160 for _, fav := range favorites {
161+ if fav == nil || fav.URL == "" {
162+ continue
163+ }
92164 suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)
93165 if ok {
94166 return &autocomplete.Suggestion{
@@ -105,28 +177,29 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input
105177
106178 // findHistorySuggestion finds the best matching history entry for the input.
107179 func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
180+ log := logging.FromContext(ctx)
181+
108182 if uc.historyUC == nil {
109183 return nil
110184 }
111185
112- // Search history for matches
113- output, err := uc.historyUC.Search(ctx, SearchInput{
114- Query: input,
115- Limit: 10, // Get top 10 to find best prefix match
116- })
117- if err != nil || output == nil {
186+ entries, err := uc.getCachedHistory(ctx)
187+ if err != nil {
188+ log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
118189 return nil
119190 }
120191
121- // Find the first result that is a prefix match
122- for _, match := range output.Matches {
123- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, match.Entry.URL)
192+ for _, entry := range entries {
193+ if entry == nil || entry.URL == "" {
194+ continue
195+ }
196+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL)
124197 if ok {
125198 return &autocomplete.Suggestion{
126199 FullText: matchedURL,
127200 Suffix: suffix,
128201 Source: autocomplete.SourceHistory,
129- Title: match.Entry.Title,
202+ Title: entry.Title,
130203 }
131204 }
132205 }
@@ -136,6 +209,8 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
136209
137210 // findBangSuggestion finds the best matching bang shortcut for the input.
138211 func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
212+ _ = logging.FromContext(ctx)
213+
139214 if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
140215 return nil
141216 }
@@ -163,6 +238,74 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
163238 return nil
164239 }
165240
241+func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
242+ for _, u := range urls {
243+ if u == "" {
244+ continue
245+ }
246+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u)
247+ if ok {
248+ return &autocomplete.Suggestion{
249+ FullText: matchedURL,
250+ Suffix: suffix,
251+ Source: source,
252+ }
253+ }
254+ }
255+
256+ return nil
257+}
258+
259+func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
260+ log := logging.FromContext(ctx)
261+
262+ uc.cacheMu.Lock()
263+ if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
264+ favorites := uc.favoritesCache
265+ uc.cacheMu.Unlock()
266+ return favorites, nil
267+ }
268+ uc.cacheMu.Unlock()
269+
270+ favorites, err := uc.favoritesUC.GetAll(ctx)
271+ if err != nil {
272+ return nil, err
273+ }
274+
275+ uc.cacheMu.Lock()
276+ uc.favoritesCache = favorites
277+ uc.favoritesCached = time.Now()
278+ uc.cacheMu.Unlock()
279+
280+ log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
281+ return favorites, nil
282+}
283+
284+func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
285+ log := logging.FromContext(ctx)
286+
287+ uc.cacheMu.Lock()
288+ if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
289+ history := uc.historyCache
290+ uc.cacheMu.Unlock()
291+ return history, nil
292+ }
293+ uc.cacheMu.Unlock()
294+
295+ entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
296+ if err != nil {
297+ return nil, err
298+ }
299+
300+ uc.cacheMu.Lock()
301+ uc.historyCache = entries
302+ uc.historyCached = time.Now()
303+ uc.cacheMu.Unlock()
304+
305+ log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
306+ return entries, nil
307+}
308+
166309 // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
167310 // This is used when the user selects a row from the list to update ghost text.
168311 func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
@@ -2,8 +2,11 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
6+ "time"
5 7
6 "github.com/bnema/dumber/internal/domain/autocomplete"8 "github.com/bnema/dumber/internal/domain/autocomplete"
9+ "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
8 )11 )
9 12
@@ -12,8 +15,17 @@ type AutocompleteUseCase struct {
12 historyUC *SearchHistoryUseCase15 historyUC *SearchHistoryUseCase
13 favoritesUC *ManageFavoritesUseCase16 favoritesUC *ManageFavoritesUseCase
14 shortcutsUC *SearchShortcutsUseCase17 shortcutsUC *SearchShortcutsUseCase
18+
19+ cacheMu sync.Mutex
20+ favoritesCache []*entity.Favorite
21+ favoritesCached time.Time
22+ historyCache []*entity.HistoryEntry
23+ historyCached time.Time
15 }24 }
16 25
26+const autocompleteHistoryLimit = 200
27+const autocompleteCacheTTL = 2 * time.Second
28+
17 // NewAutocompleteUseCase creates a new autocomplete use case.29 // NewAutocompleteUseCase creates a new autocomplete use case.
18 func NewAutocompleteUseCase(30 func NewAutocompleteUseCase(
19 historyUC *SearchHistoryUseCase,31 historyUC *SearchHistoryUseCase,
@@ -38,6 +50,12 @@ type GetSuggestionOutput struct {
38 Found bool50 Found bool
39 }51 }
40 52
53+// CompletionOptions controls how URL completions are resolved.
54+type CompletionOptions struct {
55+ VisibleURLs []string
56+ AllowBangs bool
57+}
58+
41 // GetSuggestion returns the best autocomplete suggestion for the given input.59 // GetSuggestion returns the best autocomplete suggestion for the given input.
42 // Priority: Favorites > History (by frecency) > Bang shortcuts.60 // Priority: Favorites > History (by frecency) > Bang shortcuts.
43 func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {61 func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
@@ -77,18 +95,72 @@ func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSugge
77 return &GetSuggestionOutput{Found: false}95 return &GetSuggestionOutput{Found: false}
78 }96 }
79 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+
80 // findFavoriteSuggestion finds the best matching favorite for the input.146 // findFavoriteSuggestion finds the best matching favorite for the input.
81 func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {147 func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148+ log := logging.FromContext(ctx)
149+
82 if uc.favoritesUC == nil {150 if uc.favoritesUC == nil {
83 return nil151 return nil
84 }152 }
85 153
86- favorites, err := uc.favoritesUC.GetAll(ctx)154+ favorites, err := uc.getCachedFavorites(ctx)
87 if err != nil {155 if err != nil {
156+ log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
88 return nil157 return nil
89 }158 }
90 159
91 for _, fav := range favorites {160 for _, fav := range favorites {
161+ if fav == nil || fav.URL == "" {
162+ continue
163+ }
92 suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)164 suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)
93 if ok {165 if ok {
94 return &autocomplete.Suggestion{166 return &autocomplete.Suggestion{
@@ -105,28 +177,29 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input
105 177
106 // findHistorySuggestion finds the best matching history entry for the input.178 // findHistorySuggestion finds the best matching history entry for the input.
107 func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {179 func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
180+ log := logging.FromContext(ctx)
181+
108 if uc.historyUC == nil {182 if uc.historyUC == nil {
109 return nil183 return nil
110 }184 }
111 185
112- // Search history for matches186+ entries, err := uc.getCachedHistory(ctx)
113- output, err := uc.historyUC.Search(ctx, SearchInput{187+ if err != nil {
114- Query: input,188+ log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
115- Limit: 10, // Get top 10 to find best prefix match
116- })
117- if err != nil || output == nil {
118 return nil189 return nil
119 }190 }
120 191
121- // Find the first result that is a prefix match192+ for _, entry := range entries {
122- for _, match := range output.Matches {193+ if entry == nil || entry.URL == "" {
123- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, match.Entry.URL)194+ continue
195+ }
196+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL)
124 if ok {197 if ok {
125 return &autocomplete.Suggestion{198 return &autocomplete.Suggestion{
126 FullText: matchedURL,199 FullText: matchedURL,
127 Suffix: suffix,200 Suffix: suffix,
128 Source: autocomplete.SourceHistory,201 Source: autocomplete.SourceHistory,
129- Title: match.Entry.Title,202+ Title: entry.Title,
130 }203 }
131 }204 }
132 }205 }
@@ -136,6 +209,8 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
136 209
137 // findBangSuggestion finds the best matching bang shortcut for the input.210 // findBangSuggestion finds the best matching bang shortcut for the input.
138 func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {211 func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
212+ _ = logging.FromContext(ctx)
213+
139 if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {214 if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
140 return nil215 return nil
141 }216 }
@@ -163,6 +238,74 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
163 return nil238 return nil
164 }239 }
165 240
241+func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
242+ for _, u := range urls {
243+ if u == "" {
244+ continue
245+ }
246+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u)
247+ if ok {
248+ return &autocomplete.Suggestion{
249+ FullText: matchedURL,
250+ Suffix: suffix,
251+ Source: source,
252+ }
253+ }
254+ }
255+
256+ return nil
257+}
258+
259+func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
260+ log := logging.FromContext(ctx)
261+
262+ uc.cacheMu.Lock()
263+ if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
264+ favorites := uc.favoritesCache
265+ uc.cacheMu.Unlock()
266+ return favorites, nil
267+ }
268+ uc.cacheMu.Unlock()
269+
270+ favorites, err := uc.favoritesUC.GetAll(ctx)
271+ if err != nil {
272+ return nil, err
273+ }
274+
275+ uc.cacheMu.Lock()
276+ uc.favoritesCache = favorites
277+ uc.favoritesCached = time.Now()
278+ uc.cacheMu.Unlock()
279+
280+ log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
281+ return favorites, nil
282+}
283+
284+func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
285+ log := logging.FromContext(ctx)
286+
287+ uc.cacheMu.Lock()
288+ if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
289+ history := uc.historyCache
290+ uc.cacheMu.Unlock()
291+ return history, nil
292+ }
293+ uc.cacheMu.Unlock()
294+
295+ entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
296+ if err != nil {
297+ return nil, err
298+ }
299+
300+ uc.cacheMu.Lock()
301+ uc.historyCache = entries
302+ uc.historyCached = time.Now()
303+ uc.cacheMu.Unlock()
304+
305+ log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
306+ return entries, nil
307+}
308+
166 // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.309 // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
167 // This is used when the user selects a row from the list to update ghost text.310 // This is used when the user selects a row from the list to update ghost text.
168 func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {311 func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
M internal/application/usecase/autocomplete_test.go +71 -7
@@ -71,10 +71,10 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
7171
7272 // History returns a match
7373 historyRepo := repomocks.NewMockHistoryRepository(t)
74- historyMatches := []entity.HistoryMatch{
75- {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},
74+ historyEntries := []*entity.HistoryEntry{
75+ {URL: "https://news.ycombinator.com", Title: "Hacker News"},
7676 }
77- historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)
77+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
7878
7979 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
8080 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -136,7 +136,7 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
136136 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137137
138138 historyRepo := repomocks.NewMockHistoryRepository(t)
139- historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)
139+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, nil)
140140
141141 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142142 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -148,6 +148,70 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
148148 assert.False(t, output.Found)
149149 }
150150
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_GetSuggestion_FavoritesCached(t *testing.T) {
171+ ctx := testContext()
172+
173+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
174+ folderRepo := repomocks.NewMockFolderRepository(t)
175+ tagRepo := repomocks.NewMockTagRepository(t)
176+
177+ favorites := []*entity.Favorite{
178+ {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"},
179+ }
180+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
181+
182+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
183+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
184+
185+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
186+ require.NotNil(t, output)
187+ assert.True(t, output.Found)
188+
189+ output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
190+ require.NotNil(t, output)
191+ assert.True(t, output.Found)
192+}
193+
194+func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) {
195+ ctx := testContext()
196+
197+ historyRepo := repomocks.NewMockHistoryRepository(t)
198+ historyEntries := []*entity.HistoryEntry{
199+ {URL: "https://news.ycombinator.com", Title: "Hacker News"},
200+ }
201+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once()
202+
203+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
204+ uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil)
205+
206+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
207+ require.NotNil(t, output)
208+ assert.True(t, output.Found)
209+
210+ output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+}
214+
151215 func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152216 ctx := testContext()
153217 uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
@@ -266,11 +330,11 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
266330
267331 // History returns results but none are prefix matches
268332 historyRepo := repomocks.NewMockHistoryRepository(t)
269- historyMatches := []entity.HistoryMatch{
333+ historyEntries := []*entity.HistoryEntry{
270334 // URL doesn't start with "git", so no prefix match
271- {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},
335+ {URL: "https://example.com/git", Title: "Example"},
272336 }
273- historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)
337+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
274338
275339 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276340 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -71,10 +71,10 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
71 71
72 // History returns a match72 // History returns a match
73 historyRepo := repomocks.NewMockHistoryRepository(t)73 historyRepo := repomocks.NewMockHistoryRepository(t)
74- historyMatches := []entity.HistoryMatch{74+ historyEntries := []*entity.HistoryEntry{
75- {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},75+ {URL: "https://news.ycombinator.com", Title: "Hacker News"},
76 }76 }
77- historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)77+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
78 78
79 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)79 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)80 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -136,7 +136,7 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
136 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)136 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137 137
138 historyRepo := repomocks.NewMockHistoryRepository(t)138 historyRepo := repomocks.NewMockHistoryRepository(t)
139- historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)139+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return([]*entity.HistoryEntry{}, nil)
140 140
141 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)141 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)142 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -148,6 +148,70 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
148 assert.False(t, output.Found)148 assert.False(t, output.Found)
149 }149 }
150 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_GetSuggestion_FavoritesCached(t *testing.T) {
171+ ctx := testContext()
172+
173+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
174+ folderRepo := repomocks.NewMockFolderRepository(t)
175+ tagRepo := repomocks.NewMockTagRepository(t)
176+
177+ favorites := []*entity.Favorite{
178+ {ID: 1, URL: "https://github.com/user/repo", Title: "Repo"},
179+ }
180+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
181+
182+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
183+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
184+
185+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
186+ require.NotNil(t, output)
187+ assert.True(t, output.Found)
188+
189+ output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
190+ require.NotNil(t, output)
191+ assert.True(t, output.Found)
192+}
193+
194+func TestAutocompleteUseCase_GetSuggestion_HistoryCached(t *testing.T) {
195+ ctx := testContext()
196+
197+ historyRepo := repomocks.NewMockHistoryRepository(t)
198+ historyEntries := []*entity.HistoryEntry{
199+ {URL: "https://news.ycombinator.com", Title: "Hacker News"},
200+ }
201+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil).Once()
202+
203+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
204+ uc := usecase.NewAutocompleteUseCase(historyUC, nil, nil)
205+
206+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
207+ require.NotNil(t, output)
208+ assert.True(t, output.Found)
209+
210+ output = uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+}
214+
151 func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {215 func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152 ctx := testContext()216 ctx := testContext()
153 uc := usecase.NewAutocompleteUseCase(nil, nil, nil)217 uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
@@ -266,11 +330,11 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
266 330
267 // History returns results but none are prefix matches331 // History returns results but none are prefix matches
268 historyRepo := repomocks.NewMockHistoryRepository(t)332 historyRepo := repomocks.NewMockHistoryRepository(t)
269- historyMatches := []entity.HistoryMatch{333+ historyEntries := []*entity.HistoryEntry{
270 // URL doesn't start with "git", so no prefix match334 // URL doesn't start with "git", so no prefix match
271- {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},335+ {URL: "https://example.com/git", Title: "Example"},
272 }336 }
273- historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)337+ historyRepo.EXPECT().GetRecent(mock.Anything, mock.Anything, 0).Return(historyEntries, nil)
274 338
275 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)339 favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)340 historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
M internal/infrastructure/persistence/sqlite/history_repo.go +33 -6
@@ -25,15 +25,35 @@ func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
2525 return &historyRepo{queries: sqlc.New(db)}
2626 }
2727
28+// aboutBlankURL is the special URL for blank pages that should not accumulate visit counts.
29+const aboutBlankURL = "about:blank"
30+
2831 func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {
2932 log := logging.FromContext(ctx)
3033 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
3134
32- return r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
35+ err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
3336 Url: entry.URL,
3437 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
3538 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
3639 })
40+ if err != nil {
41+ return err
42+ }
43+
44+ // Cap about:blank visit count to 1 so it exists but never dominates suggestions
45+ if entry.URL == aboutBlankURL {
46+ capErr := r.queries.CapVisitCount(ctx, sqlc.CapVisitCountParams{
47+ VisitCount: sql.NullInt64{Int64: 1, Valid: true},
48+ Url: aboutBlankURL,
49+ VisitCount_2: sql.NullInt64{Int64: 1, Valid: true},
50+ })
51+ if capErr != nil {
52+ log.Debug().Err(capErr).Msg("failed to cap about:blank visit count")
53+ }
54+ }
55+
56+ return nil
3757 }
3858
3959 func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
@@ -50,10 +70,13 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
5070 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
5171 log := logging.FromContext(ctx)
5272
53- // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators
54- // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens
55- // This allows searching "gordon.bne" to match URLs containing both terms
56- normalizedQuery := strings.ReplaceAll(query, ".", " ")
73+ // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators
74+ // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens
75+ // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms
76+ normalizedQuery := strings.NewReplacer(
77+ ".", " ",
78+ "/", " ",
79+ ).Replace(query)
5780
5881 // Split query into words and add prefix matching to each
5982 // This enables multi-word searches like "github issues" -> "github* issues*"
@@ -148,7 +171,7 @@ func sanitizeFTS5Word(word string) string {
148171 var result strings.Builder
149172 for _, r := range word {
150173 switch r {
151- case '"', '*', '(', ')', ':', '^', '-':
174+ case '"', '*', '(', ')', ':', '^', '-', '/':
152175 // Skip FTS5 special characters
153176 default:
154177 result.WriteRune(r)
@@ -236,6 +259,10 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
236259 }
237260
238261 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
262+ // Skip incrementing about:blank - it should always have visit_count = 1
263+ if url == aboutBlankURL {
264+ return nil
265+ }
239266 return r.queries.IncrementVisitCount(ctx, url)
240267 }
241268
@@ -25,15 +25,35 @@ func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
25 return &historyRepo{queries: sqlc.New(db)}25 return &historyRepo{queries: sqlc.New(db)}
26 }26 }
27 27
28+// aboutBlankURL is the special URL for blank pages that should not accumulate visit counts.
29+const aboutBlankURL = "about:blank"
30+
28 func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {31 func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {
29 log := logging.FromContext(ctx)32 log := logging.FromContext(ctx)
30 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")33 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
31 34
32- return r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{35+ err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
33 Url: entry.URL,36 Url: entry.URL,
34 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},37 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
35 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},38 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
36 })39 })
40+ if err != nil {
41+ return err
42+ }
43+
44+ // Cap about:blank visit count to 1 so it exists but never dominates suggestions
45+ if entry.URL == aboutBlankURL {
46+ capErr := r.queries.CapVisitCount(ctx, sqlc.CapVisitCountParams{
47+ VisitCount: sql.NullInt64{Int64: 1, Valid: true},
48+ Url: aboutBlankURL,
49+ VisitCount_2: sql.NullInt64{Int64: 1, Valid: true},
50+ })
51+ if capErr != nil {
52+ log.Debug().Err(capErr).Msg("failed to cap about:blank visit count")
53+ }
54+ }
55+
56+ return nil
37 }57 }
38 58
39 func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {59 func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
@@ -50,10 +70,13 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
50 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {70 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
51 log := logging.FromContext(ctx)71 log := logging.FromContext(ctx)
52 72
53- // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators73+ // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators
54- // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens74+ // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens
55- // This allows searching "gordon.bne" to match URLs containing both terms75+ // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms
56- normalizedQuery := strings.ReplaceAll(query, ".", " ")76+ normalizedQuery := strings.NewReplacer(
77+ ".", " ",
78+ "/", " ",
79+ ).Replace(query)
57 80
58 // Split query into words and add prefix matching to each81 // Split query into words and add prefix matching to each
59 // This enables multi-word searches like "github issues" -> "github* issues*"82 // This enables multi-word searches like "github issues" -> "github* issues*"
@@ -148,7 +171,7 @@ func sanitizeFTS5Word(word string) string {
148 var result strings.Builder171 var result strings.Builder
149 for _, r := range word {172 for _, r := range word {
150 switch r {173 switch r {
151- case '"', '*', '(', ')', ':', '^', '-':174+ case '"', '*', '(', ')', ':', '^', '-', '/':
152 // Skip FTS5 special characters175 // Skip FTS5 special characters
153 default:176 default:
154 result.WriteRune(r)177 result.WriteRune(r)
@@ -236,6 +259,10 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
236 }259 }
237 260
238 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {261 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
262+ // Skip incrementing about:blank - it should always have visit_count = 1
263+ if url == aboutBlankURL {
264+ return nil
265+ }
239 return r.queries.IncrementVisitCount(ctx, url)266 return r.queries.IncrementVisitCount(ctx, url)
240 }267 }
241 268
M internal/infrastructure/persistence/sqlite/history_repo_test.go +91 -0
@@ -546,6 +546,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
546546 `example"test`, // quotes
547547 "example^", // caret
548548 "example-test", // hyphen
549+ "example/", // slash
549550 }
550551
551552 for _, q := range specialQueries {
@@ -555,3 +556,93 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
555556 _ = results
556557 }
557558 }
559+
560+func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561+ ctx := historyTestCtx()
562+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
563+
564+ db, err := sqlite.NewConnection(ctx, dbPath)
565+ require.NoError(t, err)
566+ t.Cleanup(func() { _ = db.Close() })
567+
568+ repo := sqlite.NewHistoryRepository(db)
569+
570+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
571+ URL: "https://github.com/bnema/dumber",
572+ Title: "Dumber",
573+ }))
574+
575+ results, err := repo.Search(ctx, "github.com/bnema", 10)
576+ require.NoError(t, err)
577+ require.NotEmpty(t, results, "slash-separated query should match history")
578+}
579+
580+func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
581+ ctx := historyTestCtx()
582+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
583+
584+ db, err := sqlite.NewConnection(ctx, dbPath)
585+ require.NoError(t, err)
586+ t.Cleanup(func() { _ = db.Close() })
587+
588+ repo := sqlite.NewHistoryRepository(db)
589+
590+ // Save about:blank multiple times - visit count should stay at 1
591+ for i := 0; i < 5; i++ {
592+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
593+ URL: "about:blank",
594+ Title: "New Tab",
595+ }))
596+ }
597+
598+ // Verify about:blank has visit_count = 1
599+ found, err := repo.FindByURL(ctx, "about:blank")
600+ require.NoError(t, err)
601+ require.NotNil(t, found)
602+ assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
603+
604+ // Try incrementing visit count directly - should be ignored
605+ require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
606+
607+ found2, err := repo.FindByURL(ctx, "about:blank")
608+ require.NoError(t, err)
609+ assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
610+}
611+
612+func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
613+ ctx := historyTestCtx()
614+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
615+
616+ db, err := sqlite.NewConnection(ctx, dbPath)
617+ require.NoError(t, err)
618+ t.Cleanup(func() { _ = db.Close() })
619+
620+ repo := sqlite.NewHistoryRepository(db)
621+
622+ // Save about:blank many times
623+ for i := 0; i < 10; i++ {
624+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
625+ URL: "about:blank",
626+ Title: "New Tab",
627+ }))
628+ }
629+
630+ // Save a regular entry with multiple visits
631+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
632+ URL: "https://example.com",
633+ Title: "Example",
634+ }))
635+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
636+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
637+
638+ // Get most visited - example.com should be first (3 visits vs 1)
639+ results, err := repo.GetAllMostVisited(ctx)
640+ require.NoError(t, err)
641+ require.Len(t, results, 2)
642+
643+ assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
644+ assert.Equal(t, int64(3), results[0].VisitCount)
645+
646+ assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
647+ assert.Equal(t, int64(1), results[1].VisitCount)
648+}
@@ -546,6 +546,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
546 `example"test`, // quotes546 `example"test`, // quotes
547 "example^", // caret547 "example^", // caret
548 "example-test", // hyphen548 "example-test", // hyphen
549+ "example/", // slash
549 }550 }
550 551
551 for _, q := range specialQueries {552 for _, q := range specialQueries {
@@ -555,3 +556,93 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
555 _ = results556 _ = results
556 }557 }
557 }558 }
559+
560+func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561+ ctx := historyTestCtx()
562+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
563+
564+ db, err := sqlite.NewConnection(ctx, dbPath)
565+ require.NoError(t, err)
566+ t.Cleanup(func() { _ = db.Close() })
567+
568+ repo := sqlite.NewHistoryRepository(db)
569+
570+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
571+ URL: "https://github.com/bnema/dumber",
572+ Title: "Dumber",
573+ }))
574+
575+ results, err := repo.Search(ctx, "github.com/bnema", 10)
576+ require.NoError(t, err)
577+ require.NotEmpty(t, results, "slash-separated query should match history")
578+}
579+
580+func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
581+ ctx := historyTestCtx()
582+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
583+
584+ db, err := sqlite.NewConnection(ctx, dbPath)
585+ require.NoError(t, err)
586+ t.Cleanup(func() { _ = db.Close() })
587+
588+ repo := sqlite.NewHistoryRepository(db)
589+
590+ // Save about:blank multiple times - visit count should stay at 1
591+ for i := 0; i < 5; i++ {
592+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
593+ URL: "about:blank",
594+ Title: "New Tab",
595+ }))
596+ }
597+
598+ // Verify about:blank has visit_count = 1
599+ found, err := repo.FindByURL(ctx, "about:blank")
600+ require.NoError(t, err)
601+ require.NotNil(t, found)
602+ assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
603+
604+ // Try incrementing visit count directly - should be ignored
605+ require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
606+
607+ found2, err := repo.FindByURL(ctx, "about:blank")
608+ require.NoError(t, err)
609+ assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
610+}
611+
612+func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
613+ ctx := historyTestCtx()
614+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
615+
616+ db, err := sqlite.NewConnection(ctx, dbPath)
617+ require.NoError(t, err)
618+ t.Cleanup(func() { _ = db.Close() })
619+
620+ repo := sqlite.NewHistoryRepository(db)
621+
622+ // Save about:blank many times
623+ for i := 0; i < 10; i++ {
624+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
625+ URL: "about:blank",
626+ Title: "New Tab",
627+ }))
628+ }
629+
630+ // Save a regular entry with multiple visits
631+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
632+ URL: "https://example.com",
633+ Title: "Example",
634+ }))
635+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
636+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
637+
638+ // Get most visited - example.com should be first (3 visits vs 1)
639+ results, err := repo.GetAllMostVisited(ctx)
640+ require.NoError(t, err)
641+ require.Len(t, results, 2)
642+
643+ assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
644+ assert.Equal(t, int64(3), results[0].VisitCount)
645+
646+ assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
647+ assert.Equal(t, int64(1), results[1].VisitCount)
648+}
M internal/infrastructure/persistence/sqlite/queries/history.sql +3 -0
@@ -97,3 +97,6 @@ ORDER BY last_visited DESC;
9797 -- name: GetAllMostVisited :many
9898 SELECT * FROM history
9999 ORDER BY visit_count DESC, last_visited DESC;
100+
101+-- name: CapVisitCount :exec
102+UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?;
@@ -97,3 +97,6 @@ ORDER BY last_visited DESC;
97 -- name: GetAllMostVisited :many97 -- name: GetAllMostVisited :many
98 SELECT * FROM history98 SELECT * FROM history
99 ORDER BY visit_count DESC, last_visited DESC;99 ORDER BY visit_count DESC, last_visited DESC;
100+
101+-- name: CapVisitCount :exec
102+UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +15 -0
@@ -10,6 +10,21 @@ import (
1010 "database/sql"
1111 )
1212
13+const CapVisitCount = `-- name: CapVisitCount :exec
14+UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?
15+`
16+
17+type CapVisitCountParams struct {
18+ VisitCount sql.NullInt64 `json:"visit_count"`
19+ Url string `json:"url"`
20+ VisitCount_2 sql.NullInt64 `json:"visit_count_2"`
21+}
22+
23+func (q *Queries) CapVisitCount(ctx context.Context, arg CapVisitCountParams) error {
24+ _, err := q.db.ExecContext(ctx, CapVisitCount, arg.VisitCount, arg.Url, arg.VisitCount_2)
25+ return err
26+}
27+
1328 const DeleteAllHistory = `-- name: DeleteAllHistory :exec
1429 DELETE FROM history
1530 `
@@ -10,6 +10,21 @@ import (
10 "database/sql"10 "database/sql"
11 )11 )
12 12
13+const CapVisitCount = `-- name: CapVisitCount :exec
14+UPDATE history SET visit_count = ? WHERE url = ? AND visit_count > ?
15+`
16+
17+type CapVisitCountParams struct {
18+ VisitCount sql.NullInt64 `json:"visit_count"`
19+ Url string `json:"url"`
20+ VisitCount_2 sql.NullInt64 `json:"visit_count_2"`
21+}
22+
23+func (q *Queries) CapVisitCount(ctx context.Context, arg CapVisitCountParams) error {
24+ _, err := q.db.ExecContext(ctx, CapVisitCount, arg.VisitCount, arg.Url, arg.VisitCount_2)
25+ return err
26+}
27+
13 const DeleteAllHistory = `-- name: DeleteAllHistory :exec28 const DeleteAllHistory = `-- name: DeleteAllHistory :exec
14 DELETE FROM history29 DELETE FROM history
15 `30 `
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
@@ -12,6 +12,7 @@ import (
1212 type Querier interface {
1313 AddToWhitelist(ctx context.Context, domain string) error
1414 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
15+ CapVisitCount(ctx context.Context, arg CapVisitCountParams) error
1516 CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)
1617 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
1718 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
@@ -12,6 +12,7 @@ import (
12 type Querier interface {12 type Querier interface {
13 AddToWhitelist(ctx context.Context, domain string) error13 AddToWhitelist(ctx context.Context, domain string) error
14 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error14 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
15+ CapVisitCount(ctx context.Context, arg CapVisitCountParams) error
15 CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)16 CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)
16 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)17 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
17 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)18 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
M internal/ui/app.go +2 -0
@@ -1698,6 +1698,8 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
16981698 wsView.SetOmniboxConfig(a.omniboxCfg)
16991699 // Set find bar config for this workspace view
17001700 wsView.SetFindBarConfig(a.findBarCfg)
1701+ // Set auto-open omnibox on new pane
1702+ wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
17011703
17021704 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
17031705 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
@@ -1698,6 +1698,8 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1698 wsView.SetOmniboxConfig(a.omniboxCfg)1698 wsView.SetOmniboxConfig(a.omniboxCfg)
1699 // Set find bar config for this workspace view1699 // Set find bar config for this workspace view
1700 wsView.SetFindBarConfig(a.findBarCfg)1700 wsView.SetFindBarConfig(a.findBarCfg)
1701+ // Set auto-open omnibox on new pane
1702+ wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
1701 1703
1702 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {1704 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1703 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {1705 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
M internal/ui/component/modal_sizing.go +4 -2
@@ -90,10 +90,12 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
9090 parentHeight = parent.GetAllocatedHeight()
9191 }
9292
93- if parentWidth <= 0 {
93+ // Use fallback if parent not allocated or too small to be useful
94+ // A width < 100 is too small for any meaningful modal
95+ if parentWidth < 100 {
9496 parentWidth = cfg.FallbackWidth
9597 }
96- if parentHeight <= 0 {
98+ if parentHeight < 100 {
9799 parentHeight = cfg.FallbackHeight
98100 }
99101
@@ -90,10 +90,12 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
90 parentHeight = parent.GetAllocatedHeight()90 parentHeight = parent.GetAllocatedHeight()
91 }91 }
92 92
93- if parentWidth <= 0 {93+ // Use fallback if parent not allocated or too small to be useful
94+ // A width < 100 is too small for any meaningful modal
95+ if parentWidth < 100 {
94 parentWidth = cfg.FallbackWidth96 parentWidth = cfg.FallbackWidth
95 }97 }
96- if parentHeight <= 0 {98+ if parentHeight < 100 {
97 parentHeight = cfg.FallbackHeight99 parentHeight = cfg.FallbackHeight
98 }100 }
99 101
M internal/ui/component/omnibox.go +214 -123
@@ -25,8 +25,11 @@ import (
2525 )
2626
2727 const (
28- debounceDelayMs = 50
29- endBoxSpacing = 6
28+ debounceDelayMs = 50
29+ endBoxSpacing = 6
30+ defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31+ ghostPositionRetryMs = 16
32+ ghostPositionMaxAttempts = 5
3033 )
3134
3235 // ViewMode distinguishes history search from favorites display.
@@ -304,24 +307,21 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
304307 return
305308 }
306309
307- // Calculate width using shared helper
308- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
309-
310310 // Cap at max results
311311 if rowCount > OmniboxListDefaults.MaxVisibleRows {
312312 rowCount = OmniboxListDefaults.MaxVisibleRows
313313 }
314314
315315 // Schedule measurement after GTK has laid out widgets
316- // This ensures we get accurate heights from actual rendered content
317316 var cb glib.SourceFunc = func(uintptr) bool {
317+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
318318 o.measureAndResize(width, rowCount)
319- return false // One-shot
319+ return false
320320 }
321321 glib.IdleAdd(&cb, 0)
322322 }
323323
324-// measureAndResize calculates and sets the omnibox height based on row count.
324+// measureAndResize calculates and sets the omnibox size based on content.
325325 // Uses measured widget heights when available, falls back to estimates.
326326 func (o *Omnibox) measureAndResize(width, rowCount int) {
327327 if o.outerBox == nil || o.mainBox == nil {
@@ -330,6 +330,9 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
330330
331331 log := logging.FromContext(o.ctx)
332332
333+ // Apply width to mainBox - this allows the omnibox to adapt when parent resizes
334+ o.mainBox.SetSizeRequest(width, -1)
335+
333336 var rowHeight int
334337
335338 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
@@ -488,7 +491,7 @@ func (o *Omnibox) initEntry() error {
488491 o.entry.AddCssClass("omnibox-entry")
489492 o.entry.SetHexpand(true)
490493
491- placeholder := "Search history or enter URL… (! lists bangs)"
494+ placeholder := defaultOmniboxPlaceholder
492495 o.entry.SetPlaceholderText(&placeholder)
493496
494497 // Create overlay for ghost text
@@ -834,77 +837,128 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
834837 o.hasGhostText = true
835838 o.mu.Unlock()
836839
840+ o.scheduleGhostPosition(originalInput, suffix, 0)
841+}
842+
843+func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
837844 var cb glib.SourceFunc = func(uintptr) bool {
838845 if o.ghostLabel == nil || o.entry == nil {
839846 return false
840847 }
841-
842- // Check if input has changed since the autocomplete query was made
843- // This check runs on GTK main thread where entry.GetText() is reliable
844- // Skip this check when originalInput is empty (row selection replaces full input)
845- if originalInput != "" && o.entry.GetText() != originalInput {
846- // Input changed, skip this stale result - a newer query will handle it
848+ if o.shouldSkipGhostUpdate(originalInput) {
849+ return false
850+ }
851+ if !o.ghostPositionReady(attempt) {
852+ o.retryGhostPosition(originalInput, suffix, attempt+1)
847853 return false
848854 }
849855
850- // Get the entry's Pango context and create a layout to measure text width
851- pangoCtx := o.entry.GetPangoContext()
852- if pangoCtx == nil {
856+ marginStart, ok := o.computeGhostMarginStart(originalInput)
857+ if !ok {
853858 return false
854859 }
860+ o.applyGhostPosition(suffix, marginStart)
861+ return false
862+ }
863+ glib.IdleAdd(&cb, 0)
864+}
865+
866+func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
867+ if originalInput == "" {
868+ return false
869+ }
870+ return o.entry.GetText() != originalInput
871+}
855872
856- // Create layout to measure the input text width
857- layout := pango.NewLayout(pangoCtx)
858- layout.SetText(originalInput, len(originalInput))
859-
860- // Get the width in pixels
861- var widthPx, heightPx int
862- layout.GetPixelSize(&widthPx, &heightPx)
863-
864- // Set the ghost label text (just the suffix) and position it horizontally
865- o.ghostLabel.SetText(suffix)
866-
867- marginStart := widthPx
868- delegate := o.entry.GetDelegate()
869- if delegate != nil && o.entryOverlay != nil {
870- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
871- if textWidget != nil {
872- var textBounds graphene.Rect
873- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
874- cursorPos := uint(utf8.RuneCountInString(originalInput))
875- var strongRect, weakRect graphene.Rect
876- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
877- cursorX := float64(textBounds.GetX() + strongRect.GetX())
878- marginStart = int(math.Round(cursorX))
873+func (o *Omnibox) ghostPositionReady(attempt int) bool {
874+ entryWidth := o.entry.GetAllocatedWidth()
875+ overlayWidth := 0
876+ if o.entryOverlay != nil {
877+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
878+ }
879+
880+ if entryWidth > 0 && overlayWidth > 0 {
881+ return true
882+ }
883+
884+ // Max attempts reached but widgets still not allocated - give up
885+ if attempt >= ghostPositionMaxAttempts {
886+ log := logging.FromContext(o.ctx)
887+ log.Debug().
888+ Int("attempt", attempt).
889+ Int("entryWidth", entryWidth).
890+ Int("overlayWidth", overlayWidth).
891+ Msg("ghost position timeout, clearing ghost text")
892+ o.clearGhostText()
893+ return false
894+ }
895+
896+ return false
897+}
898+
899+func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {
900+ var retryCb glib.SourceFunc = func(uintptr) bool {
901+ o.scheduleGhostPosition(originalInput, suffix, attempt)
902+ return false
903+ }
904+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
905+}
906+
907+func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
908+ pangoCtx := o.entry.GetPangoContext()
909+ if pangoCtx == nil {
910+ return 0, false
911+ }
912+
913+ textLayout := pango.NewLayout(pangoCtx)
914+ defer textLayout.Unref() // Prevent memory leak
915+ textLayout.SetText(originalInput, len(originalInput))
916+
917+ var widthPx, heightPx int
918+ textLayout.GetPixelSize(&widthPx, &heightPx)
919+
920+ marginStart := widthPx
921+ delegate := o.entry.GetDelegate()
922+ if delegate != nil && o.entryOverlay != nil {
923+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
924+ if textWidget != nil {
925+ var textBounds graphene.Rect
926+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
927+ textStart := float64(textBounds.GetX())
928+ cursorPos := uint(utf8.RuneCountInString(originalInput))
929+ var strongRect, weakRect graphene.Rect
930+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
931+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
932+ marginStart = int(math.Round(cursorX))
933+ if cursorX < textStart {
934+ marginStart = int(math.Round(textStart))
879935 }
880936 }
881937 }
938+ }
882939
883- // Clamp horizontal position to entry bounds
884- if marginStart < 0 {
885- marginStart = 0
886- }
887- if o.entryOverlay != nil {
888- var entryBounds graphene.Rect
889- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
890- minStart := int(math.Round(float64(entryBounds.GetX())))
891- if marginStart < minStart {
892- marginStart = minStart
893- }
940+ if marginStart < 0 {
941+ marginStart = 0
942+ }
943+ if o.entryOverlay != nil {
944+ var entryBounds graphene.Rect
945+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
946+ minStart := int(math.Round(float64(entryBounds.GetX())))
947+ if marginStart < minStart {
948+ marginStart = minStart
894949 }
895950 }
951+ }
896952
897- o.ghostLabel.SetMarginStart(marginStart)
898- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
899- o.ghostLabel.SetVisible(true)
900-
901- // Hide placeholder text so it doesn't show through ghost text
902- emptyPlaceholder := ""
903- o.entry.SetPlaceholderText(&emptyPlaceholder)
953+ return marginStart, true
954+}
904955
905- return false
906- }
907- glib.IdleAdd(&cb, 0)
956+func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
957+ o.ghostLabel.SetText(suffix)
958+ o.ghostLabel.SetMarginStart(marginStart)
959+ o.ghostLabel.SetVisible(true)
960+ emptyPlaceholder := ""
961+ o.entry.SetPlaceholderText(&emptyPlaceholder)
908962 }
909963
910964 // clearGhostText hides the ghost completion text.
@@ -926,7 +980,7 @@ func (o *Omnibox) clearGhostText() {
926980 }
927981 // Restore placeholder text
928982 if o.entry != nil {
929- placeholder := "Search history or enter URL… (! lists bangs)"
983+ placeholder := defaultOmniboxPlaceholder
930984 o.entry.SetPlaceholderText(&placeholder)
931985 }
932986 return false
@@ -934,6 +988,36 @@ func (o *Omnibox) clearGhostText() {
934988 glib.IdleAdd(&cb, 0)
935989 }
936990
991+// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
992+// This prevents stale background results from wiping newer ghost text.
993+func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
994+ if o.entry == nil {
995+ return
996+ }
997+
998+ var cb glib.SourceFunc = func(uintptr) bool {
999+ if o.entry == nil {
1000+ return false
1001+ }
1002+ if o.entry.GetText() != expectedInput {
1003+ return false
1004+ }
1005+ o.mu.Lock()
1006+ o.ghostSuffix = ""
1007+ o.ghostFullText = ""
1008+ o.hasGhostText = false
1009+ o.mu.Unlock()
1010+ if o.ghostLabel != nil {
1011+ o.ghostLabel.SetVisible(false)
1012+ o.ghostLabel.SetText("")
1013+ }
1014+ placeholder := defaultOmniboxPlaceholder
1015+ o.entry.SetPlaceholderText(&placeholder)
1016+ return false
1017+ }
1018+ glib.IdleAdd(&cb, 0)
1019+}
1020+
9371021 // acceptGhostCompletion accepts the ghost text and fills the input.
9381022 func (o *Omnibox) acceptGhostCompletion() {
9391023 o.mu.Lock()
@@ -962,75 +1046,40 @@ func (o *Omnibox) acceptGhostCompletion() {
9621046 o.mu.Unlock()
9631047 }
9641048
965-// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966-// The query parameter is the search query that was used, ensuring we don't use stale input.
967-func (o *Omnibox) updateGhostFromSuggestion(query string) {
968- if o.autocompleteUC == nil {
969- return
970- }
971-
972- o.mu.RLock()
973- bangMode := o.bangMode
974- o.mu.RUnlock()
975-
976- // Use the query that triggered this search, not realInput which may have changed
977- userInput := query
978-
979- // Don't show ghost text in bang mode (it has its own completion)
980- if bangMode || userInput == "" {
981- o.clearGhostText()
982- return
983- }
984-
985- go func() {
986- output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987- if !output.Found || output.Suggestion == nil {
988- o.clearGhostText()
989- return
990- }
991- // Pass the original input so setGhostText can verify it's still current
992- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993- }()
994-}
995-
9961049 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
9971050 // When a row is selected via arrow keys, show the URL as ghost text even if
9981051 // the user's input isn't a prefix - this shows what Tab would fill in.
999-func (o *Omnibox) updateGhostFromURL(targetURL string) {
1052+
1053+func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10001054 if targetURL == "" {
1001- o.clearGhostText()
1002- return
1055+ return false
10031056 }
10041057
1005- o.mu.RLock()
1006- userInput := o.realInput
1007- o.mu.RUnlock()
1008-
10091058 // Strip protocol from URL for cleaner display
10101059 displayURL := autocomplete.StripProtocol(targetURL)
10111060
1012- // Try to compute proper suffix if input is a prefix of the URL
1013- if o.autocompleteUC != nil && userInput != "" {
1014- output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
1015- if output.Found && output.Suggestion != nil {
1016- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017- return
1018- }
1061+ // Only show full URL as ghost text when input is empty (initial state)
1062+ if userInput == "" {
1063+ o.setGhostText("", displayURL, displayURL)
1064+ return true
10191065 }
10201066
1021- // If user has typed something but it's not a prefix match, don't show ghost text
1022- // This prevents confusing ghost text when scrolling through history
1023- if userInput != "" {
1024- o.clearGhostText()
1025- return
1067+ // Try to compute proper suffix if input is a prefix of the URL
1068+ if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1069+ o.setGhostText(userInput, suffix, matchedURL)
1070+ return true
10261071 }
10271072
1028- // Only show full URL as ghost text when input is empty (initial state)
1029- o.setGhostText("", displayURL, displayURL)
1073+ return false
10301074 }
10311075
10321076 // updateGhostFromSelection updates ghost text based on the currently selected row.
10331077 func (o *Omnibox) updateGhostFromSelection() {
1078+ o.updateGhostFromSelectionWithInput(o.entry.GetText())
1079+}
1080+
1081+// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1082+func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
10341083 o.mu.RLock()
10351084 idx := o.selectedIndex
10361085 mode := o.viewMode
@@ -1041,6 +1090,12 @@ func (o *Omnibox) updateGhostFromSelection() {
10411090
10421091 // No ghost text in bang mode
10431092 if bangMode {
1093+ o.clearGhostTextIfInput(entryText)
1094+ return
1095+ }
1096+
1097+ if entryText != "" {
1098+ o.resolveGhostCompletion(entryText, mode, suggestions, favorites)
10441099 return
10451100 }
10461101
@@ -1056,11 +1111,49 @@ func (o *Omnibox) updateGhostFromSelection() {
10561111 }
10571112
10581113 if targetURL == "" {
1059- o.clearGhostText()
1114+ o.clearGhostTextIfInput(entryText)
1115+ return
1116+ }
1117+
1118+ if o.updateGhostFromURL(entryText, targetURL) {
1119+ return
1120+ }
1121+
1122+ o.clearGhostTextIfInput(entryText)
1123+}
1124+
1125+func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) {
1126+ if o.autocompleteUC == nil {
1127+ o.clearGhostTextIfInput(entryText)
10601128 return
10611129 }
10621130
1063- o.updateGhostFromURL(targetURL)
1131+ visibleURLs := make([]string, 0, len(suggestions))
1132+ if mode == ViewModeHistory {
1133+ for _, s := range suggestions {
1134+ if s.URL != "" {
1135+ visibleURLs = append(visibleURLs, s.URL)
1136+ }
1137+ }
1138+ } else {
1139+ for _, f := range favorites {
1140+ if f.URL != "" {
1141+ visibleURLs = append(visibleURLs, f.URL)
1142+ }
1143+ }
1144+ }
1145+
1146+ go func() {
1147+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1148+ VisibleURLs: visibleURLs,
1149+ AllowBangs: false,
1150+ })
1151+ if suggestion == nil {
1152+ o.clearGhostTextIfInput(entryText)
1153+ return
1154+ }
1155+ o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)
1156+ }()
10641157 }
10651158
10661159 // performSearch executes the search based on current view mode and query.
@@ -1380,10 +1473,9 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
13801473 // Select first item if available and update ghost text
13811474 if rowCount > 0 {
13821475 o.selectIndex(0)
1383- // Update ghost text from first suggestion
1384- o.updateGhostFromSuggestion(query)
1476+ o.updateGhostFromSelectionWithInput(query)
13851477 } else {
1386- o.clearGhostText()
1478+ o.clearGhostTextIfInput(query)
13871479 }
13881480 }
13891481
@@ -1409,10 +1501,9 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
14091501 // Select first item if available and update ghost text
14101502 if rowCount > 0 {
14111503 o.selectIndex(0)
1412- // Update ghost text from first favorite
1413- o.updateGhostFromSuggestion(query)
1504+ o.updateGhostFromSelectionWithInput(query)
14141505 } else {
1415- o.clearGhostText()
1506+ o.clearGhostTextIfInput(query)
14161507 }
14171508 }
14181509
@@ -25,8 +25,11 @@ import (
25 )25 )
26 26
27 const (27 const (
28- debounceDelayMs = 5028+ debounceDelayMs = 50
29- endBoxSpacing = 629+ endBoxSpacing = 6
30+ defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31+ ghostPositionRetryMs = 16
32+ ghostPositionMaxAttempts = 5
30 )33 )
31 34
32 // ViewMode distinguishes history search from favorites display.35 // ViewMode distinguishes history search from favorites display.
@@ -304,24 +307,21 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
304 return307 return
305 }308 }
306 309
307- // Calculate width using shared helper
308- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
309-
310 // Cap at max results310 // Cap at max results
311 if rowCount > OmniboxListDefaults.MaxVisibleRows {311 if rowCount > OmniboxListDefaults.MaxVisibleRows {
312 rowCount = OmniboxListDefaults.MaxVisibleRows312 rowCount = OmniboxListDefaults.MaxVisibleRows
313 }313 }
314 314
315 // Schedule measurement after GTK has laid out widgets315 // Schedule measurement after GTK has laid out widgets
316- // This ensures we get accurate heights from actual rendered content
317 var cb glib.SourceFunc = func(uintptr) bool {316 var cb glib.SourceFunc = func(uintptr) bool {
317+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
318 o.measureAndResize(width, rowCount)318 o.measureAndResize(width, rowCount)
319- return false // One-shot319+ return false
320 }320 }
321 glib.IdleAdd(&cb, 0)321 glib.IdleAdd(&cb, 0)
322 }322 }
323 323
324-// measureAndResize calculates and sets the omnibox height based on row count.324+// measureAndResize calculates and sets the omnibox size based on content.
325 // Uses measured widget heights when available, falls back to estimates.325 // Uses measured widget heights when available, falls back to estimates.
326 func (o *Omnibox) measureAndResize(width, rowCount int) {326 func (o *Omnibox) measureAndResize(width, rowCount int) {
327 if o.outerBox == nil || o.mainBox == nil {327 if o.outerBox == nil || o.mainBox == nil {
@@ -330,6 +330,9 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
330 330
331 log := logging.FromContext(o.ctx)331 log := logging.FromContext(o.ctx)
332 332
333+ // Apply width to mainBox - this allows the omnibox to adapt when parent resizes
334+ o.mainBox.SetSizeRequest(width, -1)
335+
333 var rowHeight int336 var rowHeight int
334 337
335 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {338 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
@@ -488,7 +491,7 @@ func (o *Omnibox) initEntry() error {
488 o.entry.AddCssClass("omnibox-entry")491 o.entry.AddCssClass("omnibox-entry")
489 o.entry.SetHexpand(true)492 o.entry.SetHexpand(true)
490 493
491- placeholder := "Search history or enter URL… (! lists bangs)"494+ placeholder := defaultOmniboxPlaceholder
492 o.entry.SetPlaceholderText(&placeholder)495 o.entry.SetPlaceholderText(&placeholder)
493 496
494 // Create overlay for ghost text497 // Create overlay for ghost text
@@ -834,77 +837,128 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
834 o.hasGhostText = true837 o.hasGhostText = true
835 o.mu.Unlock()838 o.mu.Unlock()
836 839
840+ o.scheduleGhostPosition(originalInput, suffix, 0)
841+}
842+
843+func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
837 var cb glib.SourceFunc = func(uintptr) bool {844 var cb glib.SourceFunc = func(uintptr) bool {
838 if o.ghostLabel == nil || o.entry == nil {845 if o.ghostLabel == nil || o.entry == nil {
839 return false846 return false
840 }847 }
841-848+ if o.shouldSkipGhostUpdate(originalInput) {
842- // Check if input has changed since the autocomplete query was made849+ return false
843- // This check runs on GTK main thread where entry.GetText() is reliable850+ }
844- // Skip this check when originalInput is empty (row selection replaces full input)851+ if !o.ghostPositionReady(attempt) {
845- if originalInput != "" && o.entry.GetText() != originalInput {852+ o.retryGhostPosition(originalInput, suffix, attempt+1)
846- // Input changed, skip this stale result - a newer query will handle it
847 return false853 return false
848 }854 }
849 855
850- // Get the entry's Pango context and create a layout to measure text width856+ marginStart, ok := o.computeGhostMarginStart(originalInput)
851- pangoCtx := o.entry.GetPangoContext()857+ if !ok {
852- if pangoCtx == nil {
853 return false858 return false
854 }859 }
860+ o.applyGhostPosition(suffix, marginStart)
861+ return false
862+ }
863+ glib.IdleAdd(&cb, 0)
864+}
865+
866+func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
867+ if originalInput == "" {
868+ return false
869+ }
870+ return o.entry.GetText() != originalInput
871+}
855 872
856- // Create layout to measure the input text width873+func (o *Omnibox) ghostPositionReady(attempt int) bool {
857- layout := pango.NewLayout(pangoCtx)874+ entryWidth := o.entry.GetAllocatedWidth()
858- layout.SetText(originalInput, len(originalInput))875+ overlayWidth := 0
859-876+ if o.entryOverlay != nil {
860- // Get the width in pixels877+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
861- var widthPx, heightPx int878+ }
862- layout.GetPixelSize(&widthPx, &heightPx)879+
863-880+ if entryWidth > 0 && overlayWidth > 0 {
864- // Set the ghost label text (just the suffix) and position it horizontally881+ return true
865- o.ghostLabel.SetText(suffix)882+ }
866-883+
867- marginStart := widthPx884+ // Max attempts reached but widgets still not allocated - give up
868- delegate := o.entry.GetDelegate()885+ if attempt >= ghostPositionMaxAttempts {
869- if delegate != nil && o.entryOverlay != nil {886+ log := logging.FromContext(o.ctx)
870- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())887+ log.Debug().
871- if textWidget != nil {888+ Int("attempt", attempt).
872- var textBounds graphene.Rect889+ Int("entryWidth", entryWidth).
873- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {890+ Int("overlayWidth", overlayWidth).
874- cursorPos := uint(utf8.RuneCountInString(originalInput))891+ Msg("ghost position timeout, clearing ghost text")
875- var strongRect, weakRect graphene.Rect892+ o.clearGhostText()
876- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)893+ return false
877- cursorX := float64(textBounds.GetX() + strongRect.GetX())894+ }
878- marginStart = int(math.Round(cursorX))895+
896+ return false
897+}
898+
899+func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {
900+ var retryCb glib.SourceFunc = func(uintptr) bool {
901+ o.scheduleGhostPosition(originalInput, suffix, attempt)
902+ return false
903+ }
904+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
905+}
906+
907+func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
908+ pangoCtx := o.entry.GetPangoContext()
909+ if pangoCtx == nil {
910+ return 0, false
911+ }
912+
913+ textLayout := pango.NewLayout(pangoCtx)
914+ defer textLayout.Unref() // Prevent memory leak
915+ textLayout.SetText(originalInput, len(originalInput))
916+
917+ var widthPx, heightPx int
918+ textLayout.GetPixelSize(&widthPx, &heightPx)
919+
920+ marginStart := widthPx
921+ delegate := o.entry.GetDelegate()
922+ if delegate != nil && o.entryOverlay != nil {
923+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
924+ if textWidget != nil {
925+ var textBounds graphene.Rect
926+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
927+ textStart := float64(textBounds.GetX())
928+ cursorPos := uint(utf8.RuneCountInString(originalInput))
929+ var strongRect, weakRect graphene.Rect
930+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
931+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
932+ marginStart = int(math.Round(cursorX))
933+ if cursorX < textStart {
934+ marginStart = int(math.Round(textStart))
879 }935 }
880 }936 }
881 }937 }
938+ }
882 939
883- // Clamp horizontal position to entry bounds940+ if marginStart < 0 {
884- if marginStart < 0 {941+ marginStart = 0
885- marginStart = 0942+ }
886- }943+ if o.entryOverlay != nil {
887- if o.entryOverlay != nil {944+ var entryBounds graphene.Rect
888- var entryBounds graphene.Rect945+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
889- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {946+ minStart := int(math.Round(float64(entryBounds.GetX())))
890- minStart := int(math.Round(float64(entryBounds.GetX())))947+ if marginStart < minStart {
891- if marginStart < minStart {948+ marginStart = minStart
892- marginStart = minStart
893- }
894 }949 }
895 }950 }
951+ }
896 952
897- o.ghostLabel.SetMarginStart(marginStart)953+ return marginStart, true
898- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI954+}
899- o.ghostLabel.SetVisible(true)
900-
901- // Hide placeholder text so it doesn't show through ghost text
902- emptyPlaceholder := ""
903- o.entry.SetPlaceholderText(&emptyPlaceholder)
904 955
905- return false956+func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
906- }957+ o.ghostLabel.SetText(suffix)
907- glib.IdleAdd(&cb, 0)958+ o.ghostLabel.SetMarginStart(marginStart)
959+ o.ghostLabel.SetVisible(true)
960+ emptyPlaceholder := ""
961+ o.entry.SetPlaceholderText(&emptyPlaceholder)
908 }962 }
909 963
910 // clearGhostText hides the ghost completion text.964 // clearGhostText hides the ghost completion text.
@@ -926,7 +980,7 @@ func (o *Omnibox) clearGhostText() {
926 }980 }
927 // Restore placeholder text981 // Restore placeholder text
928 if o.entry != nil {982 if o.entry != nil {
929- placeholder := "Search history or enter URL… (! lists bangs)"983+ placeholder := defaultOmniboxPlaceholder
930 o.entry.SetPlaceholderText(&placeholder)984 o.entry.SetPlaceholderText(&placeholder)
931 }985 }
932 return false986 return false
@@ -934,6 +988,36 @@ func (o *Omnibox) clearGhostText() {
934 glib.IdleAdd(&cb, 0)988 glib.IdleAdd(&cb, 0)
935 }989 }
936 990
991+// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
992+// This prevents stale background results from wiping newer ghost text.
993+func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
994+ if o.entry == nil {
995+ return
996+ }
997+
998+ var cb glib.SourceFunc = func(uintptr) bool {
999+ if o.entry == nil {
1000+ return false
1001+ }
1002+ if o.entry.GetText() != expectedInput {
1003+ return false
1004+ }
1005+ o.mu.Lock()
1006+ o.ghostSuffix = ""
1007+ o.ghostFullText = ""
1008+ o.hasGhostText = false
1009+ o.mu.Unlock()
1010+ if o.ghostLabel != nil {
1011+ o.ghostLabel.SetVisible(false)
1012+ o.ghostLabel.SetText("")
1013+ }
1014+ placeholder := defaultOmniboxPlaceholder
1015+ o.entry.SetPlaceholderText(&placeholder)
1016+ return false
1017+ }
1018+ glib.IdleAdd(&cb, 0)
1019+}
1020+
937 // acceptGhostCompletion accepts the ghost text and fills the input.1021 // acceptGhostCompletion accepts the ghost text and fills the input.
938 func (o *Omnibox) acceptGhostCompletion() {1022 func (o *Omnibox) acceptGhostCompletion() {
939 o.mu.Lock()1023 o.mu.Lock()
@@ -962,75 +1046,40 @@ func (o *Omnibox) acceptGhostCompletion() {
962 o.mu.Unlock()1046 o.mu.Unlock()
963 }1047 }
964 1048
965-// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966-// The query parameter is the search query that was used, ensuring we don't use stale input.
967-func (o *Omnibox) updateGhostFromSuggestion(query string) {
968- if o.autocompleteUC == nil {
969- return
970- }
971-
972- o.mu.RLock()
973- bangMode := o.bangMode
974- o.mu.RUnlock()
975-
976- // Use the query that triggered this search, not realInput which may have changed
977- userInput := query
978-
979- // Don't show ghost text in bang mode (it has its own completion)
980- if bangMode || userInput == "" {
981- o.clearGhostText()
982- return
983- }
984-
985- go func() {
986- output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987- if !output.Found || output.Suggestion == nil {
988- o.clearGhostText()
989- return
990- }
991- // Pass the original input so setGhostText can verify it's still current
992- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993- }()
994-}
995-
996 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).1049 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
997 // When a row is selected via arrow keys, show the URL as ghost text even if1050 // When a row is selected via arrow keys, show the URL as ghost text even if
998 // the user's input isn't a prefix - this shows what Tab would fill in.1051 // the user's input isn't a prefix - this shows what Tab would fill in.
999-func (o *Omnibox) updateGhostFromURL(targetURL string) {1052+
1053+func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1000 if targetURL == "" {1054 if targetURL == "" {
1001- o.clearGhostText()1055+ return false
1002- return
1003 }1056 }
1004 1057
1005- o.mu.RLock()
1006- userInput := o.realInput
1007- o.mu.RUnlock()
1008-
1009 // Strip protocol from URL for cleaner display1058 // Strip protocol from URL for cleaner display
1010 displayURL := autocomplete.StripProtocol(targetURL)1059 displayURL := autocomplete.StripProtocol(targetURL)
1011 1060
1012- // Try to compute proper suffix if input is a prefix of the URL1061+ // Only show full URL as ghost text when input is empty (initial state)
1013- if o.autocompleteUC != nil && userInput != "" {1062+ if userInput == "" {
1014- output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)1063+ o.setGhostText("", displayURL, displayURL)
1015- if output.Found && output.Suggestion != nil {1064+ return true
1016- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017- return
1018- }
1019 }1065 }
1020 1066
1021- // If user has typed something but it's not a prefix match, don't show ghost text1067+ // Try to compute proper suffix if input is a prefix of the URL
1022- // This prevents confusing ghost text when scrolling through history1068+ if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1023- if userInput != "" {1069+ o.setGhostText(userInput, suffix, matchedURL)
1024- o.clearGhostText()1070+ return true
1025- return
1026 }1071 }
1027 1072
1028- // Only show full URL as ghost text when input is empty (initial state)1073+ return false
1029- o.setGhostText("", displayURL, displayURL)
1030 }1074 }
1031 1075
1032 // updateGhostFromSelection updates ghost text based on the currently selected row.1076 // updateGhostFromSelection updates ghost text based on the currently selected row.
1033 func (o *Omnibox) updateGhostFromSelection() {1077 func (o *Omnibox) updateGhostFromSelection() {
1078+ o.updateGhostFromSelectionWithInput(o.entry.GetText())
1079+}
1080+
1081+// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1082+func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1034 o.mu.RLock()1083 o.mu.RLock()
1035 idx := o.selectedIndex1084 idx := o.selectedIndex
1036 mode := o.viewMode1085 mode := o.viewMode
@@ -1041,6 +1090,12 @@ func (o *Omnibox) updateGhostFromSelection() {
1041 1090
1042 // No ghost text in bang mode1091 // No ghost text in bang mode
1043 if bangMode {1092 if bangMode {
1093+ o.clearGhostTextIfInput(entryText)
1094+ return
1095+ }
1096+
1097+ if entryText != "" {
1098+ o.resolveGhostCompletion(entryText, mode, suggestions, favorites)
1044 return1099 return
1045 }1100 }
1046 1101
@@ -1056,11 +1111,49 @@ func (o *Omnibox) updateGhostFromSelection() {
1056 }1111 }
1057 1112
1058 if targetURL == "" {1113 if targetURL == "" {
1059- o.clearGhostText()1114+ o.clearGhostTextIfInput(entryText)
1115+ return
1116+ }
1117+
1118+ if o.updateGhostFromURL(entryText, targetURL) {
1119+ return
1120+ }
1121+
1122+ o.clearGhostTextIfInput(entryText)
1123+}
1124+
1125+func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) {
1126+ if o.autocompleteUC == nil {
1127+ o.clearGhostTextIfInput(entryText)
1060 return1128 return
1061 }1129 }
1062 1130
1063- o.updateGhostFromURL(targetURL)1131+ visibleURLs := make([]string, 0, len(suggestions))
1132+ if mode == ViewModeHistory {
1133+ for _, s := range suggestions {
1134+ if s.URL != "" {
1135+ visibleURLs = append(visibleURLs, s.URL)
1136+ }
1137+ }
1138+ } else {
1139+ for _, f := range favorites {
1140+ if f.URL != "" {
1141+ visibleURLs = append(visibleURLs, f.URL)
1142+ }
1143+ }
1144+ }
1145+
1146+ go func() {
1147+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1148+ VisibleURLs: visibleURLs,
1149+ AllowBangs: false,
1150+ })
1151+ if suggestion == nil {
1152+ o.clearGhostTextIfInput(entryText)
1153+ return
1154+ }
1155+ o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)
1156+ }()
1064 }1157 }
1065 1158
1066 // performSearch executes the search based on current view mode and query.1159 // performSearch executes the search based on current view mode and query.
@@ -1380,10 +1473,9 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1380 // Select first item if available and update ghost text1473 // Select first item if available and update ghost text
1381 if rowCount > 0 {1474 if rowCount > 0 {
1382 o.selectIndex(0)1475 o.selectIndex(0)
1383- // Update ghost text from first suggestion1476+ o.updateGhostFromSelectionWithInput(query)
1384- o.updateGhostFromSuggestion(query)
1385 } else {1477 } else {
1386- o.clearGhostText()1478+ o.clearGhostTextIfInput(query)
1387 }1479 }
1388 }1480 }
1389 1481
@@ -1409,10 +1501,9 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1409 // Select first item if available and update ghost text1501 // Select first item if available and update ghost text
1410 if rowCount > 0 {1502 if rowCount > 0 {
1411 o.selectIndex(0)1503 o.selectIndex(0)
1412- // Update ghost text from first favorite1504+ o.updateGhostFromSelectionWithInput(query)
1413- o.updateGhostFromSuggestion(query)
1414 } else {1505 } else {
1415- o.clearGhostText()1506+ o.clearGhostTextIfInput(query)
1416 }1507 }
1417 }1508 }
1418 1509
M internal/ui/component/workspace_view.go +34 -7
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
1415 "github.com/rs/zerolog"
1516 )
1617
@@ -55,6 +56,9 @@ type WorkspaceView struct {
5556 // Prevents hover focus from overriding keyboard-initiated focus changes
5657 hoverSuppressedUntil time.Time
5758
59+ // Auto-open omnibox on new pane creation
60+ autoOpenOnNewPane bool
61+
5862 mu sync.RWMutex
5963 }
6064
@@ -213,15 +217,8 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
213217 wv.logger.Debug().
214218 Str("from_pane", string(currentActiveID)).
215219 Str("to_pane", string(paneID)).
216- Bool("omnibox_visible", wv.omnibox != nil).
217- Str("omnibox_pane", string(wv.omniboxPaneID)).
218220 Msg("active pane changing")
219221
220- // Destroy omnibox if active pane is changing
221- if currentActiveID != paneID && wv.omnibox != nil {
222- wv.logger.Debug().Msg("destroying omnibox due to pane change")
223- wv.hideOmniboxInternal()
224- }
225222 // Destroy find bar if active pane is changing
226223 if currentActiveID != paneID && wv.findBar != nil {
227224 wv.hideFindBarInternal()
@@ -554,6 +551,36 @@ func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
554551 wv.findBarCfg = cfg
555552 }
556553
554+// SetAutoOpenOnNewPane configures whether to show omnibox when a new pane is created.
555+func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
556+ wv.mu.Lock()
557+ defer wv.mu.Unlock()
558+ wv.autoOpenOnNewPane = enabled
559+}
560+
561+// NotifyNewPaneCreated handles new pane creation events.
562+// Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
563+func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
564+ // Suppress hover focus so mouse position doesn't steal focus from new pane
565+ wv.SuppressHover(KeyboardFocusSuppressDuration)
566+ wv.CancelAllPendingHovers()
567+
568+ wv.mu.RLock()
569+ autoOpen := wv.autoOpenOnNewPane
570+ wv.mu.RUnlock()
571+
572+ if !autoOpen {
573+ return
574+ }
575+
576+ // Defer omnibox show until after GTK layout allocation completes
577+ cb := glib.SourceFunc(func(uintptr) bool {
578+ wv.ShowOmnibox(ctx, "")
579+ return false
580+ })
581+ glib.IdleAdd(&cb, 0)
582+}
583+
557584 // ShowOmnibox creates and shows the omnibox in the active pane.
558585 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
559586 wv.mu.Lock()
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/layout"13 "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
14 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
15 )16 )
16 17
@@ -55,6 +56,9 @@ type WorkspaceView struct {
55 // Prevents hover focus from overriding keyboard-initiated focus changes56 // Prevents hover focus from overriding keyboard-initiated focus changes
56 hoverSuppressedUntil time.Time57 hoverSuppressedUntil time.Time
57 58
59+ // Auto-open omnibox on new pane creation
60+ autoOpenOnNewPane bool
61+
58 mu sync.RWMutex62 mu sync.RWMutex
59 }63 }
60 64
@@ -213,15 +217,8 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
213 wv.logger.Debug().217 wv.logger.Debug().
214 Str("from_pane", string(currentActiveID)).218 Str("from_pane", string(currentActiveID)).
215 Str("to_pane", string(paneID)).219 Str("to_pane", string(paneID)).
216- Bool("omnibox_visible", wv.omnibox != nil).
217- Str("omnibox_pane", string(wv.omniboxPaneID)).
218 Msg("active pane changing")220 Msg("active pane changing")
219 221
220- // Destroy omnibox if active pane is changing
221- if currentActiveID != paneID && wv.omnibox != nil {
222- wv.logger.Debug().Msg("destroying omnibox due to pane change")
223- wv.hideOmniboxInternal()
224- }
225 // Destroy find bar if active pane is changing222 // Destroy find bar if active pane is changing
226 if currentActiveID != paneID && wv.findBar != nil {223 if currentActiveID != paneID && wv.findBar != nil {
227 wv.hideFindBarInternal()224 wv.hideFindBarInternal()
@@ -554,6 +551,36 @@ func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
554 wv.findBarCfg = cfg551 wv.findBarCfg = cfg
555 }552 }
556 553
554+// SetAutoOpenOnNewPane configures whether to show omnibox when a new pane is created.
555+func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
556+ wv.mu.Lock()
557+ defer wv.mu.Unlock()
558+ wv.autoOpenOnNewPane = enabled
559+}
560+
561+// NotifyNewPaneCreated handles new pane creation events.
562+// Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
563+func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
564+ // Suppress hover focus so mouse position doesn't steal focus from new pane
565+ wv.SuppressHover(KeyboardFocusSuppressDuration)
566+ wv.CancelAllPendingHovers()
567+
568+ wv.mu.RLock()
569+ autoOpen := wv.autoOpenOnNewPane
570+ wv.mu.RUnlock()
571+
572+ if !autoOpen {
573+ return
574+ }
575+
576+ // Defer omnibox show until after GTK layout allocation completes
577+ cb := glib.SourceFunc(func(uintptr) bool {
578+ wv.ShowOmnibox(ctx, "")
579+ return false
580+ })
581+ glib.IdleAdd(&cb, 0)
582+}
583+
557 // ShowOmnibox creates and shows the omnibox in the active pane.584 // ShowOmnibox creates and shows the omnibox in the active pane.
558 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {585 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
559 wv.mu.Lock()586 wv.mu.Lock()
M internal/ui/coordinator/workspace.go +3 -5
@@ -145,8 +145,8 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
145145 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
146146 }
147147
148- if splitCtx.wsView != nil && config.Get().Omnibox.AutoOpenOnNewPane {
149- splitCtx.wsView.ShowOmnibox(ctx, "")
148+ if splitCtx.wsView != nil {
149+ splitCtx.wsView.NotifyNewPaneCreated(ctx)
150150 }
151151
152152 // Notify state change for session snapshots
@@ -1377,9 +1377,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
13771377 log.Warn().Err(err).Msg("failed to set active pane")
13781378 }
13791379
1380- if config.Get().Omnibox.AutoOpenOnNewPane {
1381- stackCtx.wsView.ShowOmnibox(ctx, "")
1382- }
1380+ stackCtx.wsView.NotifyNewPaneCreated(ctx)
13831381
13841382 // Set up title bar click and close callbacks
13851383 tr := stackCtx.wsView.TreeRenderer()
@@ -145,8 +145,8 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
145 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)145 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
146 }146 }
147 147
148- if splitCtx.wsView != nil && config.Get().Omnibox.AutoOpenOnNewPane {148+ if splitCtx.wsView != nil {
149- splitCtx.wsView.ShowOmnibox(ctx, "")149+ splitCtx.wsView.NotifyNewPaneCreated(ctx)
150 }150 }
151 151
152 // Notify state change for session snapshots152 // Notify state change for session snapshots
@@ -1377,9 +1377,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1377 log.Warn().Err(err).Msg("failed to set active pane")1377 log.Warn().Err(err).Msg("failed to set active pane")
1378 }1378 }
1379 1379
1380- if config.Get().Omnibox.AutoOpenOnNewPane {1380+ stackCtx.wsView.NotifyNewPaneCreated(ctx)
1381- stackCtx.wsView.ShowOmnibox(ctx, "")
1382- }
1383 1381
1384 // Set up title bar click and close callbacks1382 // Set up title bar click and close callbacks
1385 tr := stackCtx.wsView.TreeRenderer()1383 tr := stackCtx.wsView.TreeRenderer()

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