Merge pull request #151 from bnema/refactor/omnibox-autocomplete
fix: omnibox stability and auto-open timing
12 files · +625 -161
M internal/application/usecase/autocomplete.go +154 -11
| @@ -2,8 +2,11 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| @@ -12,8 +15,17 @@ type AutocompleteUseCase struct { | |||
| 12 | 15 | historyUC *SearchHistoryUseCase | |
| 13 | 16 | favoritesUC *ManageFavoritesUseCase | |
| 14 | 17 | 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 | 29 | // NewAutocompleteUseCase creates a new autocomplete use case. | |
| 18 | 30 | func NewAutocompleteUseCase( | |
| 19 | 31 | historyUC *SearchHistoryUseCase, | |
| @@ -38,6 +50,12 @@ type GetSuggestionOutput struct { | |||
| 38 | 50 | 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 | 59 | // GetSuggestion returns the best autocomplete suggestion for the given input. | |
| 42 | 60 | // Priority: Favorites > History (by frecency) > Bang shortcuts. | |
| 43 | 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 | 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 | 146 | // findFavoriteSuggestion finds the best matching favorite for the input. | |
| 81 | 147 | func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 148 | + | log := logging.FromContext(ctx) | |
| 149 | + | ||
| 82 | 150 | if uc.favoritesUC == nil { | |
| 83 | 151 | return nil | |
| 84 | 152 | } | |
| 85 | 153 | ||
| 86 | - | favorites, err := uc.favoritesUC.GetAll(ctx) | |
| 154 | + | favorites, err := uc.getCachedFavorites(ctx) | |
| 87 | 155 | if err != nil { | |
| 156 | + | log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed") | |
| 88 | 157 | return nil | |
| 89 | 158 | } | |
| 90 | 159 | ||
| 91 | 160 | for _, fav := range favorites { | |
| 161 | + | if fav == nil || fav.URL == "" { | |
| 162 | + | continue | |
| 163 | + | } | |
| 92 | 164 | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | |
| 93 | 165 | if ok { | |
| 94 | 166 | return &autocomplete.Suggestion{ | |
| @@ -105,28 +177,29 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input | |||
| 105 | 177 | ||
| 106 | 178 | // findHistorySuggestion finds the best matching history entry for the input. | |
| 107 | 179 | func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 180 | + | log := logging.FromContext(ctx) | |
| 181 | + | ||
| 108 | 182 | if uc.historyUC == nil { | |
| 109 | 183 | return nil | |
| 110 | 184 | } | |
| 111 | 185 | ||
| 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") | |
| 118 | 189 | return nil | |
| 119 | 190 | } | |
| 120 | 191 | ||
| 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) | |
| 124 | 197 | if ok { | |
| 125 | 198 | return &autocomplete.Suggestion{ | |
| 126 | 199 | FullText: matchedURL, | |
| 127 | 200 | Suffix: suffix, | |
| 128 | 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 | 210 | // findBangSuggestion finds the best matching bang shortcut for the input. | |
| 138 | 211 | func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion { | |
| 212 | + | _ = logging.FromContext(ctx) | |
| 213 | + | ||
| 139 | 214 | if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' { | |
| 140 | 215 | return nil | |
| 141 | 216 | } | |
| @@ -163,6 +238,74 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str | |||
| 163 | 238 | 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 | 309 | // GetSuggestionForURL returns an autocomplete suggestion specifically for a URL. | |
| 167 | 310 | // This is used when the user selects a row from the list to update ghost text. | |
| 168 | 311 | 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 *SearchHistoryUseCase | 15 | historyUC *SearchHistoryUseCase | ||
| 13 | favoritesUC *ManageFavoritesUseCase | 16 | favoritesUC *ManageFavoritesUseCase | ||
| 14 | shortcutsUC *SearchShortcutsUseCase | 17 | 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 bool | 50 | 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 nil | 151 | 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 nil | 157 | 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 nil | 183 | return nil | ||
| 110 | } | 184 | } | ||
| 111 | 185 | ||||
| 112 | - | // Search history for matches | 186 | + | 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 nil | 189 | return nil | ||
| 119 | } | 190 | } | ||
| 120 | 191 | ||||
| 121 | - | // Find the first result that is a prefix match | 192 | + | 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 nil | 215 | return nil | ||
| 141 | } | 216 | } | ||
| @@ -163,6 +238,74 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str | |||||
| 163 | return nil | 238 | 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) { | |||
| 71 | 71 | ||
| 72 | 72 | // History returns a match | |
| 73 | 73 | 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"}, | |
| 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 | 79 | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 80 | 80 | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| @@ -136,7 +136,7 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) { | |||
| 136 | 136 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 137 | 137 | ||
| 138 | 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 | 141 | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 142 | 142 | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| @@ -148,6 +148,70 @@ func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) { | |||
| 148 | 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 | 215 | func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) { | |
| 152 | 216 | ctx := testContext() | |
| 153 | 217 | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| @@ -266,11 +330,11 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) { | |||
| 266 | 330 | ||
| 267 | 331 | // History returns results but none are prefix matches | |
| 268 | 332 | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 269 | - | historyMatches := []entity.HistoryMatch{ | |
| 333 | + | historyEntries := []*entity.HistoryEntry{ | |
| 270 | 334 | // 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 | 339 | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 276 | 340 | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| @@ -71,10 +71,10 @@ func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) { | |||||
| 71 | 71 | ||||
| 72 | // History returns a match | 72 | // 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 matches | 331 | // 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 match | 334 | // 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 { | |||
| 25 | 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 | 31 | func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error { | |
| 29 | 32 | log := logging.FromContext(ctx) | |
| 30 | 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 | 36 | Url: entry.URL, | |
| 34 | 37 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | |
| 35 | 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 | 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 | 70 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 51 | 71 | log := logging.FromContext(ctx) | |
| 52 | 72 | ||
| 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) | |
| 57 | 80 | ||
| 58 | 81 | // Split query into words and add prefix matching to each | |
| 59 | 82 | // This enables multi-word searches like "github issues" -> "github* issues*" | |
| @@ -148,7 +171,7 @@ func sanitizeFTS5Word(word string) string { | |||
| 148 | 171 | var result strings.Builder | |
| 149 | 172 | for _, r := range word { | |
| 150 | 173 | switch r { | |
| 151 | - | case '"', '*', '(', ')', ':', '^', '-': | |
| 174 | + | case '"', '*', '(', ')', ':', '^', '-', '/': | |
| 152 | 175 | // Skip FTS5 special characters | |
| 153 | 176 | default: | |
| 154 | 177 | result.WriteRune(r) | |
| @@ -236,6 +259,10 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||
| 236 | 259 | } | |
| 237 | 260 | ||
| 238 | 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 | 266 | return r.queries.IncrementVisitCount(ctx, url) | |
| 240 | 267 | } | |
| 241 | 268 | ||
| @@ -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 separators | 73 | + | // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators |
| 54 | - | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | 74 | + | // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens |
| 55 | - | // This allows searching "gordon.bne" to match URLs containing both terms | 75 | + | // 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 each | 81 | // 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.Builder | 171 | 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 characters | 175 | // 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) { | |||
| 546 | 546 | `example"test`, // quotes | |
| 547 | 547 | "example^", // caret | |
| 548 | 548 | "example-test", // hyphen | |
| 549 | + | "example/", // slash | |
| 549 | 550 | } | |
| 550 | 551 | ||
| 551 | 552 | for _, q := range specialQueries { | |
| @@ -555,3 +556,93 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||
| 555 | 556 | _ = 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 | + | } | |
| @@ -546,6 +546,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||||
| 546 | `example"test`, // quotes | 546 | `example"test`, // quotes | ||
| 547 | "example^", // caret | 547 | "example^", // caret | ||
| 548 | "example-test", // hyphen | 548 | "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 | _ = results | 556 | _ = 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; | |||
| 97 | 97 | -- name: GetAllMostVisited :many | |
| 98 | 98 | SELECT * FROM history | |
| 99 | 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 > ?; | |
| @@ -97,3 +97,6 @@ ORDER BY last_visited DESC; | |||||
| 97 | -- name: GetAllMostVisited :many | 97 | -- name: GetAllMostVisited :many | ||
| 98 | SELECT * FROM history | 98 | 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 ( | |||
| 10 | 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 | 28 | const DeleteAllHistory = `-- name: DeleteAllHistory :exec | |
| 14 | 29 | DELETE FROM history | |
| 15 | 30 | ` | |
| @@ -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 :exec | 28 | const DeleteAllHistory = `-- name: DeleteAllHistory :exec | ||
| 14 | DELETE FROM history | 29 | DELETE FROM history | ||
| 15 | ` | 30 | ` | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | type Querier interface { | |
| 13 | 13 | AddToWhitelist(ctx context.Context, domain string) error | |
| 14 | 14 | AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error | |
| 15 | + | CapVisitCount(ctx context.Context, arg CapVisitCountParams) error | |
| 15 | 16 | CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) | |
| 16 | 17 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | |
| 17 | 18 | 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) error | 13 | AddToWhitelist(ctx context.Context, domain string) error | ||
| 14 | AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error | 14 | 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. | |||
| 1698 | 1698 | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 1699 | 1699 | // Set find bar config for this workspace view | |
| 1700 | 1700 | wsView.SetFindBarConfig(a.findBarCfg) | |
| 1701 | + | // Set auto-open omnibox on new pane | |
| 1702 | + | wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane) | |
| 1701 | 1703 | ||
| 1702 | 1704 | wsView.SetOnPaneFocused(func(paneID entity.PaneID) { | |
| 1703 | 1705 | 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 view | 1699 | // 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) | |||
| 90 | 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 | 96 | parentWidth = cfg.FallbackWidth | |
| 95 | 97 | } | |
| 96 | - | if parentHeight <= 0 { | |
| 98 | + | if parentHeight < 100 { | |
| 97 | 99 | parentHeight = cfg.FallbackHeight | |
| 98 | 100 | } | |
| 99 | 101 | ||
| @@ -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.FallbackWidth | 96 | parentWidth = cfg.FallbackWidth | ||
| 95 | } | 97 | } | ||
| 96 | - | if parentHeight <= 0 { | 98 | + | if parentHeight < 100 { |
| 97 | parentHeight = cfg.FallbackHeight | 99 | parentHeight = cfg.FallbackHeight | ||
| 98 | } | 100 | } | ||
| 99 | 101 | ||||
M internal/ui/component/omnibox.go +214 -123
| @@ -25,8 +25,11 @@ import ( | |||
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | 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 | |
| 30 | 33 | ) | |
| 31 | 34 | ||
| 32 | 35 | // ViewMode distinguishes history search from favorites display. | |
| @@ -304,24 +307,21 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 304 | 307 | return | |
| 305 | 308 | } | |
| 306 | 309 | ||
| 307 | - | // Calculate width using shared helper | |
| 308 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 309 | - | ||
| 310 | 310 | // Cap at max results | |
| 311 | 311 | if rowCount > OmniboxListDefaults.MaxVisibleRows { | |
| 312 | 312 | rowCount = OmniboxListDefaults.MaxVisibleRows | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | 315 | // Schedule measurement after GTK has laid out widgets | |
| 316 | - | // This ensures we get accurate heights from actual rendered content | |
| 317 | 316 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 317 | + | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 318 | 318 | o.measureAndResize(width, rowCount) | |
| 319 | - | return false // One-shot | |
| 319 | + | return false | |
| 320 | 320 | } | |
| 321 | 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 | 325 | // Uses measured widget heights when available, falls back to estimates. | |
| 326 | 326 | func (o *Omnibox) measureAndResize(width, rowCount int) { | |
| 327 | 327 | if o.outerBox == nil || o.mainBox == nil { | |
| @@ -330,6 +330,9 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 330 | 330 | ||
| 331 | 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 | 336 | var rowHeight int | |
| 334 | 337 | ||
| 335 | 338 | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | |
| @@ -488,7 +491,7 @@ func (o *Omnibox) initEntry() error { | |||
| 488 | 491 | o.entry.AddCssClass("omnibox-entry") | |
| 489 | 492 | o.entry.SetHexpand(true) | |
| 490 | 493 | ||
| 491 | - | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 494 | + | placeholder := defaultOmniboxPlaceholder | |
| 492 | 495 | o.entry.SetPlaceholderText(&placeholder) | |
| 493 | 496 | ||
| 494 | 497 | // Create overlay for ghost text | |
| @@ -834,77 +837,128 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 834 | 837 | o.hasGhostText = true | |
| 835 | 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 | 844 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 838 | 845 | if o.ghostLabel == nil || o.entry == nil { | |
| 839 | 846 | return false | |
| 840 | 847 | } | |
| 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) | |
| 847 | 853 | return false | |
| 848 | 854 | } | |
| 849 | 855 | ||
| 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 { | |
| 853 | 858 | 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 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)) | |
| 879 | 935 | } | |
| 880 | 936 | } | |
| 881 | 937 | } | |
| 938 | + | } | |
| 882 | 939 | ||
| 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 | |
| 894 | 949 | } | |
| 895 | 950 | } | |
| 951 | + | } | |
| 896 | 952 | ||
| 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 | + | } | |
| 904 | 955 | ||
| 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) | |
| 908 | 962 | } | |
| 909 | 963 | ||
| 910 | 964 | // clearGhostText hides the ghost completion text. | |
| @@ -926,7 +980,7 @@ func (o *Omnibox) clearGhostText() { | |||
| 926 | 980 | } | |
| 927 | 981 | // Restore placeholder text | |
| 928 | 982 | if o.entry != nil { | |
| 929 | - | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 983 | + | placeholder := defaultOmniboxPlaceholder | |
| 930 | 984 | o.entry.SetPlaceholderText(&placeholder) | |
| 931 | 985 | } | |
| 932 | 986 | return false | |
| @@ -934,6 +988,36 @@ func (o *Omnibox) clearGhostText() { | |||
| 934 | 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 | 1021 | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 938 | 1022 | func (o *Omnibox) acceptGhostCompletion() { | |
| 939 | 1023 | o.mu.Lock() | |
| @@ -962,75 +1046,40 @@ func (o *Omnibox) acceptGhostCompletion() { | |||
| 962 | 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 | 1049 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| 997 | 1050 | // When a row is selected via arrow keys, show the URL as ghost text even if | |
| 998 | 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 | 1054 | if targetURL == "" { | |
| 1001 | - | o.clearGhostText() | |
| 1002 | - | return | |
| 1055 | + | return false | |
| 1003 | 1056 | } | |
| 1004 | 1057 | ||
| 1005 | - | o.mu.RLock() | |
| 1006 | - | userInput := o.realInput | |
| 1007 | - | o.mu.RUnlock() | |
| 1008 | - | ||
| 1009 | 1058 | // Strip protocol from URL for cleaner display | |
| 1010 | 1059 | displayURL := autocomplete.StripProtocol(targetURL) | |
| 1011 | 1060 | ||
| 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 | |
| 1019 | 1065 | } | |
| 1020 | 1066 | ||
| 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 | |
| 1026 | 1071 | } | |
| 1027 | 1072 | ||
| 1028 | - | // Only show full URL as ghost text when input is empty (initial state) | |
| 1029 | - | o.setGhostText("", displayURL, displayURL) | |
| 1073 | + | return false | |
| 1030 | 1074 | } | |
| 1031 | 1075 | ||
| 1032 | 1076 | // updateGhostFromSelection updates ghost text based on the currently selected row. | |
| 1033 | 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 | 1083 | o.mu.RLock() | |
| 1035 | 1084 | idx := o.selectedIndex | |
| 1036 | 1085 | mode := o.viewMode | |
| @@ -1041,6 +1090,12 @@ func (o *Omnibox) updateGhostFromSelection() { | |||
| 1041 | 1090 | ||
| 1042 | 1091 | // No ghost text in bang mode | |
| 1043 | 1092 | if bangMode { | |
| 1093 | + | o.clearGhostTextIfInput(entryText) | |
| 1094 | + | return | |
| 1095 | + | } | |
| 1096 | + | ||
| 1097 | + | if entryText != "" { | |
| 1098 | + | o.resolveGhostCompletion(entryText, mode, suggestions, favorites) | |
| 1044 | 1099 | return | |
| 1045 | 1100 | } | |
| 1046 | 1101 | ||
| @@ -1056,11 +1111,49 @@ func (o *Omnibox) updateGhostFromSelection() { | |||
| 1056 | 1111 | } | |
| 1057 | 1112 | ||
| 1058 | 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 | 1128 | 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 | 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 | 1473 | // Select first item if available and update ghost text | |
| 1381 | 1474 | if rowCount > 0 { | |
| 1382 | 1475 | o.selectIndex(0) | |
| 1383 | - | // Update ghost text from first suggestion | |
| 1384 | - | o.updateGhostFromSuggestion(query) | |
| 1476 | + | o.updateGhostFromSelectionWithInput(query) | |
| 1385 | 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 | 1501 | // Select first item if available and update ghost text | |
| 1410 | 1502 | if rowCount > 0 { | |
| 1411 | 1503 | o.selectIndex(0) | |
| 1412 | - | // Update ghost text from first favorite | |
| 1413 | - | o.updateGhostFromSuggestion(query) | |
| 1504 | + | o.updateGhostFromSelectionWithInput(query) | |
| 1414 | 1505 | } else { | |
| 1415 | - | o.clearGhostText() | |
| 1506 | + | o.clearGhostTextIfInput(query) | |
| 1416 | 1507 | } | |
| 1417 | 1508 | } | |
| 1418 | 1509 | ||
| @@ -25,8 +25,11 @@ import ( | |||||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | const ( | 27 | const ( | ||
| 28 | - | debounceDelayMs = 50 | 28 | + | debounceDelayMs = 50 |
| 29 | - | endBoxSpacing = 6 | 29 | + | 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 | return | 307 | return | ||
| 305 | } | 308 | } | ||
| 306 | 309 | ||||
| 307 | - | // Calculate width using shared helper | |||
| 308 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |||
| 309 | - | ||||
| 310 | // Cap at max results | 310 | // Cap at max results | ||
| 311 | if rowCount > OmniboxListDefaults.MaxVisibleRows { | 311 | if rowCount > OmniboxListDefaults.MaxVisibleRows { | ||
| 312 | rowCount = OmniboxListDefaults.MaxVisibleRows | 312 | rowCount = OmniboxListDefaults.MaxVisibleRows | ||
| 313 | } | 313 | } | ||
| 314 | 314 | ||||
| 315 | // Schedule measurement after GTK has laid out widgets | 315 | // 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-shot | 319 | + | 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 int | 336 | 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 text | 497 | // Create overlay for ghost text | ||
| @@ -834,77 +837,128 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 834 | o.hasGhostText = true | 837 | 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 false | 846 | return false | ||
| 840 | } | 847 | } | ||
| 841 | - | 848 | + | if o.shouldSkipGhostUpdate(originalInput) { | |
| 842 | - | // Check if input has changed since the autocomplete query was made | 849 | + | return false |
| 843 | - | // This check runs on GTK main thread where entry.GetText() is reliable | 850 | + | } |
| 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 false | 853 | return false | ||
| 848 | } | 854 | } | ||
| 849 | 855 | ||||
| 850 | - | // Get the entry's Pango context and create a layout to measure text width | 856 | + | marginStart, ok := o.computeGhostMarginStart(originalInput) |
| 851 | - | pangoCtx := o.entry.GetPangoContext() | 857 | + | if !ok { |
| 852 | - | if pangoCtx == nil { | |||
| 853 | return false | 858 | 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 width | 873 | + | 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 pixels | 877 | + | overlayWidth = o.entryOverlay.GetAllocatedWidth() |
| 861 | - | var widthPx, heightPx int | 878 | + | } |
| 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 horizontally | 881 | + | return true |
| 865 | - | o.ghostLabel.SetText(suffix) | 882 | + | } |
| 866 | - | 883 | + | ||
| 867 | - | marginStart := widthPx | 884 | + | // 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.Rect | 889 | + | 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.Rect | 892 | + | 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 bounds | 940 | + | if marginStart < 0 { |
| 884 | - | if marginStart < 0 { | 941 | + | marginStart = 0 |
| 885 | - | marginStart = 0 | 942 | + | } |
| 886 | - | } | 943 | + | if o.entryOverlay != nil { |
| 887 | - | if o.entryOverlay != nil { | 944 | + | var entryBounds graphene.Rect |
| 888 | - | var entryBounds graphene.Rect | 945 | + | 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 UI | 954 | + | } |
| 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 false | 956 | + | 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 text | 981 | // 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 false | 986 | 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 if | 1050 | // 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 display | 1058 | // 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 URL | 1061 | + | // 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 text | 1067 | + | // Try to compute proper suffix if input is a prefix of the URL |
| 1022 | - | // This prevents confusing ghost text when scrolling through history | 1068 | + | 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.selectedIndex | 1084 | idx := o.selectedIndex | ||
| 1036 | mode := o.viewMode | 1085 | mode := o.viewMode | ||
| @@ -1041,6 +1090,12 @@ func (o *Omnibox) updateGhostFromSelection() { | |||||
| 1041 | 1090 | ||||
| 1042 | // No ghost text in bang mode | 1091 | // 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 | return | 1099 | 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 | return | 1128 | 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 text | 1473 | // 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 suggestion | 1476 | + | 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 text | 1501 | // 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 favorite | 1504 | + | 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 ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 13 | "github.com/bnema/dumber/internal/ui/layout" | |
| 14 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 14 | 15 | "github.com/rs/zerolog" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -55,6 +56,9 @@ type WorkspaceView struct { | |||
| 55 | 56 | // Prevents hover focus from overriding keyboard-initiated focus changes | |
| 56 | 57 | hoverSuppressedUntil time.Time | |
| 57 | 58 | ||
| 59 | + | // Auto-open omnibox on new pane creation | |
| 60 | + | autoOpenOnNewPane bool | |
| 61 | + | ||
| 58 | 62 | mu sync.RWMutex | |
| 59 | 63 | } | |
| 60 | 64 | ||
| @@ -213,15 +217,8 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||
| 213 | 217 | wv.logger.Debug(). | |
| 214 | 218 | Str("from_pane", string(currentActiveID)). | |
| 215 | 219 | Str("to_pane", string(paneID)). | |
| 216 | - | Bool("omnibox_visible", wv.omnibox != nil). | |
| 217 | - | Str("omnibox_pane", string(wv.omniboxPaneID)). | |
| 218 | 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 | 222 | // Destroy find bar if active pane is changing | |
| 226 | 223 | if currentActiveID != paneID && wv.findBar != nil { | |
| 227 | 224 | wv.hideFindBarInternal() | |
| @@ -554,6 +551,36 @@ func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) { | |||
| 554 | 551 | 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 | 584 | // ShowOmnibox creates and shows the omnibox in the active pane. | |
| 558 | 585 | func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |
| 559 | 586 | 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 changes | 56 | // Prevents hover focus from overriding keyboard-initiated focus changes | ||
| 56 | hoverSuppressedUntil time.Time | 57 | hoverSuppressedUntil time.Time | ||
| 57 | 58 | ||||
| 59 | + | // Auto-open omnibox on new pane creation | |||
| 60 | + | autoOpenOnNewPane bool | |||
| 61 | + | ||||
| 58 | mu sync.RWMutex | 62 | 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 changing | 222 | // 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 = cfg | 551 | 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 | |||
| 145 | 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 { | |
| 149 | - | splitCtx.wsView.ShowOmnibox(ctx, "") | |
| 148 | + | if splitCtx.wsView != nil { | |
| 149 | + | splitCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | 152 | // Notify state change for session snapshots | |
| @@ -1377,9 +1377,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1377 | 1377 | log.Warn().Err(err).Msg("failed to set active pane") | |
| 1378 | 1378 | } | |
| 1379 | 1379 | ||
| 1380 | - | if config.Get().Omnibox.AutoOpenOnNewPane { | |
| 1381 | - | stackCtx.wsView.ShowOmnibox(ctx, "") | |
| 1382 | - | } | |
| 1380 | + | stackCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 1383 | 1381 | ||
| 1384 | 1382 | // Set up title bar click and close callbacks | |
| 1385 | 1383 | 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 snapshots | 152 | // 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 callbacks | 1382 | // Set up title bar click and close callbacks | ||
| 1385 | tr := stackCtx.wsView.TreeRenderer() | 1383 | tr := stackCtx.wsView.TreeRenderer() | ||