refactor(omnibox): extract bang shortcuts logic to SearchShortcutsUseCase
- commit
- 0dc071d72b8dee9e3362d8188100e9e12ed58a65
- author
- brice · · 2025-12-28 14:45 UTC
- parent
- f1f0461
- signature
- unsigned
4 files · +330 -121
A internal/application/usecase/search_shortcuts.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/url" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SearchShortcut represents a search shortcut configuration. | |
| 13 | + | // This is a domain-level type to avoid depending on infrastructure config. | |
| 14 | + | type SearchShortcut struct { | |
| 15 | + | URL string | |
| 16 | + | Description string | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // BangSuggestion represents a configured bang shortcut for display. | |
| 20 | + | type BangSuggestion struct { | |
| 21 | + | Key string | |
| 22 | + | Description string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // SearchShortcutsUseCase handles bang shortcut filtering and resolution. | |
| 26 | + | type SearchShortcutsUseCase struct { | |
| 27 | + | shortcuts map[string]SearchShortcut | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // NewSearchShortcutsUseCase creates a new search shortcuts use case. | |
| 31 | + | func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase { | |
| 32 | + | return &SearchShortcutsUseCase{ | |
| 33 | + | shortcuts: shortcuts, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // FilterBangsInput contains parameters for filtering bang suggestions. | |
| 38 | + | type FilterBangsInput struct { | |
| 39 | + | Query string // e.g., "!" or "!g" or "!g query" | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // FilterBangsOutput contains filtered bang suggestions. | |
| 43 | + | type FilterBangsOutput struct { | |
| 44 | + | Suggestions []BangSuggestion | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // FilterBangs returns bang shortcuts matching the query prefix. | |
| 48 | + | // Query should start with "!". The prefix is extracted before any space. | |
| 49 | + | func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput { | |
| 50 | + | log := logging.FromContext(ctx) | |
| 51 | + | ||
| 52 | + | suggestions := uc.buildBangSuggestions(input.Query) | |
| 53 | + | ||
| 54 | + | log.Debug(). | |
| 55 | + | Str("query", input.Query). | |
| 56 | + | Int("matches", len(suggestions)). | |
| 57 | + | Msg("filtered bang suggestions") | |
| 58 | + | ||
| 59 | + | return &FilterBangsOutput{Suggestions: suggestions} | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // DetectBangKeyInput contains parameters for detecting a completed bang key. | |
| 63 | + | type DetectBangKeyInput struct { | |
| 64 | + | Query string // e.g., "!gh query" | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // DetectBangKeyOutput contains the detected bang key if found. | |
| 68 | + | type DetectBangKeyOutput struct { | |
| 69 | + | Key string // The matched key (empty if not found) | |
| 70 | + | Description string // Description of the matched shortcut | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // DetectBangKey checks if the query contains a valid, completed bang key. | |
| 74 | + | // A completed bang key requires "!<key> " with a space after the key. | |
| 75 | + | func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 77 | + | ||
| 78 | + | key := uc.detectBangKey(input.Query) | |
| 79 | + | if key == "" { | |
| 80 | + | return &DetectBangKeyOutput{} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | shortcut, ok := uc.shortcuts[key] | |
| 84 | + | if !ok { | |
| 85 | + | return &DetectBangKeyOutput{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | description := shortcut.Description | |
| 89 | + | if description == "" { | |
| 90 | + | description = shortcut.URL | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | log.Debug(). | |
| 94 | + | Str("query", input.Query). | |
| 95 | + | Str("detected_key", key). | |
| 96 | + | Msg("detected bang key") | |
| 97 | + | ||
| 98 | + | return &DetectBangKeyOutput{ | |
| 99 | + | Key: key, | |
| 100 | + | Description: description, | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // BuildNavigationTextInput contains parameters for building navigation text. | |
| 105 | + | type BuildNavigationTextInput struct { | |
| 106 | + | EntryText string // e.g., "!GH dumber" | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // BuildNavigationTextOutput contains the normalized navigation text. | |
| 110 | + | type BuildNavigationTextOutput struct { | |
| 111 | + | Text string // Normalized text (e.g., "!gh dumber") or empty if invalid | |
| 112 | + | Valid bool // Whether the entry text represents a valid bang navigation | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // BuildNavigationText normalizes a bang query for navigation. | |
| 116 | + | // Returns the normalized text with the canonical key case. | |
| 117 | + | func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput { | |
| 118 | + | log := logging.FromContext(ctx) | |
| 119 | + | ||
| 120 | + | text := uc.buildBangNavigationText(input.EntryText) | |
| 121 | + | if text == "" { | |
| 122 | + | return &BuildNavigationTextOutput{} | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | log.Debug(). | |
| 126 | + | Str("entry_text", input.EntryText). | |
| 127 | + | Str("navigation_text", text). | |
| 128 | + | Msg("built bang navigation text") | |
| 129 | + | ||
| 130 | + | return &BuildNavigationTextOutput{ | |
| 131 | + | Text: text, | |
| 132 | + | Valid: true, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetShortcut returns a shortcut by key (case-insensitive). | |
| 137 | + | func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) { | |
| 138 | + | normalizedKey, ok := uc.normalizeBangKey(key) | |
| 139 | + | if !ok { | |
| 140 | + | return SearchShortcut{}, false | |
| 141 | + | } | |
| 142 | + | shortcut, ok := uc.shortcuts[normalizedKey] | |
| 143 | + | return shortcut, ok | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // ShortcutURLs returns a map of shortcut keys to URL templates. | |
| 147 | + | // This is useful for passing to url.BuildSearchURL. | |
| 148 | + | func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string { | |
| 149 | + | result := make(map[string]string, len(uc.shortcuts)) | |
| 150 | + | for key, shortcut := range uc.shortcuts { | |
| 151 | + | result[key] = shortcut.URL | |
| 152 | + | } | |
| 153 | + | return result | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // buildBangSuggestions filters and sorts shortcuts matching the query prefix. | |
| 157 | + | func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion { | |
| 158 | + | prefix := strings.TrimPrefix(query, "!") | |
| 159 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |
| 160 | + | prefix = prefix[:idx] | |
| 161 | + | } | |
| 162 | + | prefix = strings.ToLower(prefix) | |
| 163 | + | ||
| 164 | + | suggestions := make([]BangSuggestion, 0, len(uc.shortcuts)) | |
| 165 | + | for key, shortcut := range uc.shortcuts { | |
| 166 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | description := shortcut.Description | |
| 170 | + | if description == "" { | |
| 171 | + | description = shortcut.URL | |
| 172 | + | } | |
| 173 | + | suggestions = append(suggestions, BangSuggestion{ | |
| 174 | + | Key: key, | |
| 175 | + | Description: description, | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | sort.Slice(suggestions, func(i, j int) bool { | |
| 180 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |
| 181 | + | }) | |
| 182 | + | ||
| 183 | + | return suggestions | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // detectBangKey returns the matched shortcut key if the query has a valid bang prefix. | |
| 187 | + | func (uc *SearchShortcutsUseCase) detectBangKey(query string) string { | |
| 188 | + | spaceIdx := strings.Index(query, " ") | |
| 189 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |
| 190 | + | return "" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | candidate := query[1:spaceIdx] | |
| 194 | + | for key := range uc.shortcuts { | |
| 195 | + | if strings.EqualFold(key, candidate) { | |
| 196 | + | return key | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return "" | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup). | |
| 204 | + | func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) { | |
| 205 | + | for key := range uc.shortcuts { | |
| 206 | + | if strings.EqualFold(key, shortcutKey) { | |
| 207 | + | return key, true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return "", false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // buildBangNavigationText normalizes a bang query for navigation. | |
| 214 | + | func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string { | |
| 215 | + | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |
| 216 | + | if !found || query == "" { | |
| 217 | + | return "" | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | resolvedKey, ok := uc.normalizeBangKey(shortcutKey) | |
| 221 | + | if !ok { | |
| 222 | + | return "" | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return "!" + resolvedKey + " " + query | |
| 226 | + | } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/url" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SearchShortcut represents a search shortcut configuration. | |||
| 13 | + | // This is a domain-level type to avoid depending on infrastructure config. | |||
| 14 | + | type SearchShortcut struct { | |||
| 15 | + | URL string | |||
| 16 | + | Description string | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // BangSuggestion represents a configured bang shortcut for display. | |||
| 20 | + | type BangSuggestion struct { | |||
| 21 | + | Key string | |||
| 22 | + | Description string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // SearchShortcutsUseCase handles bang shortcut filtering and resolution. | |||
| 26 | + | type SearchShortcutsUseCase struct { | |||
| 27 | + | shortcuts map[string]SearchShortcut | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // NewSearchShortcutsUseCase creates a new search shortcuts use case. | |||
| 31 | + | func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase { | |||
| 32 | + | return &SearchShortcutsUseCase{ | |||
| 33 | + | shortcuts: shortcuts, | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // FilterBangsInput contains parameters for filtering bang suggestions. | |||
| 38 | + | type FilterBangsInput struct { | |||
| 39 | + | Query string // e.g., "!" or "!g" or "!g query" | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // FilterBangsOutput contains filtered bang suggestions. | |||
| 43 | + | type FilterBangsOutput struct { | |||
| 44 | + | Suggestions []BangSuggestion | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // FilterBangs returns bang shortcuts matching the query prefix. | |||
| 48 | + | // Query should start with "!". The prefix is extracted before any space. | |||
| 49 | + | func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput { | |||
| 50 | + | log := logging.FromContext(ctx) | |||
| 51 | + | ||||
| 52 | + | suggestions := uc.buildBangSuggestions(input.Query) | |||
| 53 | + | ||||
| 54 | + | log.Debug(). | |||
| 55 | + | Str("query", input.Query). | |||
| 56 | + | Int("matches", len(suggestions)). | |||
| 57 | + | Msg("filtered bang suggestions") | |||
| 58 | + | ||||
| 59 | + | return &FilterBangsOutput{Suggestions: suggestions} | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // DetectBangKeyInput contains parameters for detecting a completed bang key. | |||
| 63 | + | type DetectBangKeyInput struct { | |||
| 64 | + | Query string // e.g., "!gh query" | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // DetectBangKeyOutput contains the detected bang key if found. | |||
| 68 | + | type DetectBangKeyOutput struct { | |||
| 69 | + | Key string // The matched key (empty if not found) | |||
| 70 | + | Description string // Description of the matched shortcut | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // DetectBangKey checks if the query contains a valid, completed bang key. | |||
| 74 | + | // A completed bang key requires "!<key> " with a space after the key. | |||
| 75 | + | func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput { | |||
| 76 | + | log := logging.FromContext(ctx) | |||
| 77 | + | ||||
| 78 | + | key := uc.detectBangKey(input.Query) | |||
| 79 | + | if key == "" { | |||
| 80 | + | return &DetectBangKeyOutput{} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | shortcut, ok := uc.shortcuts[key] | |||
| 84 | + | if !ok { | |||
| 85 | + | return &DetectBangKeyOutput{} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | description := shortcut.Description | |||
| 89 | + | if description == "" { | |||
| 90 | + | description = shortcut.URL | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | log.Debug(). | |||
| 94 | + | Str("query", input.Query). | |||
| 95 | + | Str("detected_key", key). | |||
| 96 | + | Msg("detected bang key") | |||
| 97 | + | ||||
| 98 | + | return &DetectBangKeyOutput{ | |||
| 99 | + | Key: key, | |||
| 100 | + | Description: description, | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // BuildNavigationTextInput contains parameters for building navigation text. | |||
| 105 | + | type BuildNavigationTextInput struct { | |||
| 106 | + | EntryText string // e.g., "!GH dumber" | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // BuildNavigationTextOutput contains the normalized navigation text. | |||
| 110 | + | type BuildNavigationTextOutput struct { | |||
| 111 | + | Text string // Normalized text (e.g., "!gh dumber") or empty if invalid | |||
| 112 | + | Valid bool // Whether the entry text represents a valid bang navigation | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // BuildNavigationText normalizes a bang query for navigation. | |||
| 116 | + | // Returns the normalized text with the canonical key case. | |||
| 117 | + | func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput { | |||
| 118 | + | log := logging.FromContext(ctx) | |||
| 119 | + | ||||
| 120 | + | text := uc.buildBangNavigationText(input.EntryText) | |||
| 121 | + | if text == "" { | |||
| 122 | + | return &BuildNavigationTextOutput{} | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | log.Debug(). | |||
| 126 | + | Str("entry_text", input.EntryText). | |||
| 127 | + | Str("navigation_text", text). | |||
| 128 | + | Msg("built bang navigation text") | |||
| 129 | + | ||||
| 130 | + | return &BuildNavigationTextOutput{ | |||
| 131 | + | Text: text, | |||
| 132 | + | Valid: true, | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // GetShortcut returns a shortcut by key (case-insensitive). | |||
| 137 | + | func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) { | |||
| 138 | + | normalizedKey, ok := uc.normalizeBangKey(key) | |||
| 139 | + | if !ok { | |||
| 140 | + | return SearchShortcut{}, false | |||
| 141 | + | } | |||
| 142 | + | shortcut, ok := uc.shortcuts[normalizedKey] | |||
| 143 | + | return shortcut, ok | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // ShortcutURLs returns a map of shortcut keys to URL templates. | |||
| 147 | + | // This is useful for passing to url.BuildSearchURL. | |||
| 148 | + | func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string { | |||
| 149 | + | result := make(map[string]string, len(uc.shortcuts)) | |||
| 150 | + | for key, shortcut := range uc.shortcuts { | |||
| 151 | + | result[key] = shortcut.URL | |||
| 152 | + | } | |||
| 153 | + | return result | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // buildBangSuggestions filters and sorts shortcuts matching the query prefix. | |||
| 157 | + | func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion { | |||
| 158 | + | prefix := strings.TrimPrefix(query, "!") | |||
| 159 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |||
| 160 | + | prefix = prefix[:idx] | |||
| 161 | + | } | |||
| 162 | + | prefix = strings.ToLower(prefix) | |||
| 163 | + | ||||
| 164 | + | suggestions := make([]BangSuggestion, 0, len(uc.shortcuts)) | |||
| 165 | + | for key, shortcut := range uc.shortcuts { | |||
| 166 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |||
| 167 | + | continue | |||
| 168 | + | } | |||
| 169 | + | description := shortcut.Description | |||
| 170 | + | if description == "" { | |||
| 171 | + | description = shortcut.URL | |||
| 172 | + | } | |||
| 173 | + | suggestions = append(suggestions, BangSuggestion{ | |||
| 174 | + | Key: key, | |||
| 175 | + | Description: description, | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | sort.Slice(suggestions, func(i, j int) bool { | |||
| 180 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |||
| 181 | + | }) | |||
| 182 | + | ||||
| 183 | + | return suggestions | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // detectBangKey returns the matched shortcut key if the query has a valid bang prefix. | |||
| 187 | + | func (uc *SearchShortcutsUseCase) detectBangKey(query string) string { | |||
| 188 | + | spaceIdx := strings.Index(query, " ") | |||
| 189 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |||
| 190 | + | return "" | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | candidate := query[1:spaceIdx] | |||
| 194 | + | for key := range uc.shortcuts { | |||
| 195 | + | if strings.EqualFold(key, candidate) { | |||
| 196 | + | return key | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | return "" | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup). | |||
| 204 | + | func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) { | |||
| 205 | + | for key := range uc.shortcuts { | |||
| 206 | + | if strings.EqualFold(key, shortcutKey) { | |||
| 207 | + | return key, true | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return "", false | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // buildBangNavigationText normalizes a bang query for navigation. | |||
| 214 | + | func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string { | |||
| 215 | + | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |||
| 216 | + | if !found || query == "" { | |||
| 217 | + | return "" | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | resolvedKey, ok := uc.normalizeBangKey(shortcutKey) | |||
| 221 | + | if !ok { | |||
| 222 | + | return "" | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | return "!" + resolvedKey + " " + query | |||
| 226 | + | } | |||
R internal/ui/component/omnibox_bang_test.go → internal/application/usecase/search_shortcuts_test.go +60 -18 renamed, 54% similar
| @@ -1,18 +1,19 @@ | |||
| 1 | - | package component | |
| 1 | + | package usecase | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | 6 | ) | |
| 8 | 7 | ||
| 9 | - | func TestBuildBangSuggestions(t *testing.T) { | |
| 10 | - | shortcuts := map[string]config.SearchShortcut{ | |
| 8 | + | func TestFilterBangs(t *testing.T) { | |
| 9 | + | shortcuts := map[string]SearchShortcut{ | |
| 11 | 10 | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |
| 12 | 11 | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |
| 13 | 12 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 14 | 13 | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |
| 15 | 14 | } | |
| 15 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 16 | + | ctx := context.Background() | |
| 16 | 17 | ||
| 17 | 18 | cases := []struct { | |
| 18 | 19 | name string | |
| @@ -52,7 +53,8 @@ func TestBuildBangSuggestions(t *testing.T) { | |||
| 52 | 53 | ||
| 53 | 54 | for _, tc := range cases { | |
| 54 | 55 | t.Run(tc.name, func(t *testing.T) { | |
| 55 | - | got := buildBangSuggestions(shortcuts, tc.query) | |
| 56 | + | output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query}) | |
| 57 | + | got := output.Suggestions | |
| 56 | 58 | if len(got) != len(tc.wantKeys) { | |
| 57 | 59 | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |
| 58 | 60 | } | |
| @@ -73,10 +75,12 @@ func TestBuildBangSuggestions(t *testing.T) { | |||
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | func TestDetectBangKey(t *testing.T) { | |
| 76 | - | shortcuts := map[string]config.SearchShortcut{ | |
| 78 | + | shortcuts := map[string]SearchShortcut{ | |
| 77 | 79 | "gh": {URL: "https://github.com/search?q=%s"}, | |
| 78 | 80 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | |
| 79 | 81 | } | |
| 82 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 83 | + | ctx := context.Background() | |
| 80 | 84 | ||
| 81 | 85 | cases := []struct { | |
| 82 | 86 | name string | |
| @@ -93,35 +97,73 @@ func TestDetectBangKey(t *testing.T) { | |||
| 93 | 97 | ||
| 94 | 98 | for _, tc := range cases { | |
| 95 | 99 | t.Run(tc.name, func(t *testing.T) { | |
| 96 | - | if got := detectBangKey(shortcuts, tc.query); got != tc.want { | |
| 97 | - | t.Fatalf("detectBangKey(%q)=%q want=%q", tc.query, got, tc.want) | |
| 100 | + | output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query}) | |
| 101 | + | if output.Key != tc.want { | |
| 102 | + | t.Fatalf("DetectBangKey(%q)=%q want=%q", tc.query, output.Key, tc.want) | |
| 98 | 103 | } | |
| 99 | 104 | }) | |
| 100 | 105 | } | |
| 101 | 106 | } | |
| 102 | 107 | ||
| 103 | - | func TestBuildBangNavigationText(t *testing.T) { | |
| 104 | - | shortcuts := map[string]config.SearchShortcut{ | |
| 108 | + | func TestBuildNavigationText(t *testing.T) { | |
| 109 | + | shortcuts := map[string]SearchShortcut{ | |
| 105 | 110 | "gh": {URL: "https://github.com/search?q=%s"}, | |
| 106 | 111 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | |
| 107 | 112 | } | |
| 113 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 114 | + | ctx := context.Background() | |
| 108 | 115 | ||
| 109 | 116 | cases := []struct { | |
| 110 | 117 | name string | |
| 111 | 118 | entryText string | |
| 112 | 119 | want string | |
| 120 | + | wantValid bool | |
| 121 | + | }{ | |
| 122 | + | {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false}, | |
| 123 | + | {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false}, | |
| 124 | + | {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false}, | |
| 125 | + | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true}, | |
| 126 | + | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true}, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | for _, tc := range cases { | |
| 130 | + | t.Run(tc.name, func(t *testing.T) { | |
| 131 | + | output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText}) | |
| 132 | + | if output.Text != tc.want { | |
| 133 | + | t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want) | |
| 134 | + | } | |
| 135 | + | if output.Valid != tc.wantValid { | |
| 136 | + | t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid) | |
| 137 | + | } | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestGetShortcut(t *testing.T) { | |
| 143 | + | shortcuts := map[string]SearchShortcut{ | |
| 144 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |
| 145 | + | } | |
| 146 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 147 | + | ||
| 148 | + | cases := []struct { | |
| 149 | + | name string | |
| 150 | + | key string | |
| 151 | + | wantOK bool | |
| 152 | + | wantURL string | |
| 113 | 153 | }{ | |
| 114 | - | {name: "not a bang shortcut", entryText: "example.com", want: ""}, | |
| 115 | - | {name: "bang key without query", entryText: "!gh ", want: ""}, | |
| 116 | - | {name: "unknown bang key", entryText: "!nope test", want: ""}, | |
| 117 | - | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber"}, | |
| 118 | - | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query"}, | |
| 154 | + | {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 155 | + | {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 156 | + | {name: "not found", key: "unknown", wantOK: false, wantURL: ""}, | |
| 119 | 157 | } | |
| 120 | 158 | ||
| 121 | 159 | for _, tc := range cases { | |
| 122 | 160 | t.Run(tc.name, func(t *testing.T) { | |
| 123 | - | if got := buildBangNavigationText(shortcuts, tc.entryText); got != tc.want { | |
| 124 | - | t.Fatalf("buildBangNavigationText(%q)=%q want=%q", tc.entryText, got, tc.want) | |
| 161 | + | shortcut, ok := uc.GetShortcut(tc.key) | |
| 162 | + | if ok != tc.wantOK { | |
| 163 | + | t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK) | |
| 164 | + | } | |
| 165 | + | if shortcut.URL != tc.wantURL { | |
| 166 | + | t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL) | |
| 125 | 167 | } | |
| 126 | 168 | }) | |
| 127 | 169 | } | |
| @@ -1,18 +1,19 @@ | |||||
| 1 | - | package component | 1 | + | package usecase |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | - | ||||
| 6 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | ) | 6 | ) | ||
| 8 | 7 | ||||
| 9 | - | func TestBuildBangSuggestions(t *testing.T) { | 8 | + | func TestFilterBangs(t *testing.T) { |
| 10 | - | shortcuts := map[string]config.SearchShortcut{ | 9 | + | shortcuts := map[string]SearchShortcut{ |
| 11 | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | 10 | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | ||
| 12 | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | 11 | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | ||
| 13 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | 12 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | ||
| 14 | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | 13 | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | ||
| 15 | } | 14 | } | ||
| 15 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 16 | + | ctx := context.Background() | |||
| 16 | 17 | ||||
| 17 | cases := []struct { | 18 | cases := []struct { | ||
| 18 | name string | 19 | name string | ||
| @@ -52,7 +53,8 @@ func TestBuildBangSuggestions(t *testing.T) { | |||||
| 52 | 53 | ||||
| 53 | for _, tc := range cases { | 54 | for _, tc := range cases { | ||
| 54 | t.Run(tc.name, func(t *testing.T) { | 55 | t.Run(tc.name, func(t *testing.T) { | ||
| 55 | - | got := buildBangSuggestions(shortcuts, tc.query) | 56 | + | output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query}) |
| 57 | + | got := output.Suggestions | |||
| 56 | if len(got) != len(tc.wantKeys) { | 58 | if len(got) != len(tc.wantKeys) { | ||
| 57 | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | 59 | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | ||
| 58 | } | 60 | } | ||
| @@ -73,10 +75,12 @@ func TestBuildBangSuggestions(t *testing.T) { | |||||
| 73 | } | 75 | } | ||
| 74 | 76 | ||||
| 75 | func TestDetectBangKey(t *testing.T) { | 77 | func TestDetectBangKey(t *testing.T) { | ||
| 76 | - | shortcuts := map[string]config.SearchShortcut{ | 78 | + | shortcuts := map[string]SearchShortcut{ |
| 77 | "gh": {URL: "https://github.com/search?q=%s"}, | 79 | "gh": {URL: "https://github.com/search?q=%s"}, | ||
| 78 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | 80 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | ||
| 79 | } | 81 | } | ||
| 82 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 83 | + | ctx := context.Background() | |||
| 80 | 84 | ||||
| 81 | cases := []struct { | 85 | cases := []struct { | ||
| 82 | name string | 86 | name string | ||
| @@ -93,35 +97,73 @@ func TestDetectBangKey(t *testing.T) { | |||||
| 93 | 97 | ||||
| 94 | for _, tc := range cases { | 98 | for _, tc := range cases { | ||
| 95 | t.Run(tc.name, func(t *testing.T) { | 99 | t.Run(tc.name, func(t *testing.T) { | ||
| 96 | - | if got := detectBangKey(shortcuts, tc.query); got != tc.want { | 100 | + | output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query}) |
| 97 | - | t.Fatalf("detectBangKey(%q)=%q want=%q", tc.query, got, tc.want) | 101 | + | if output.Key != tc.want { |
| 102 | + | t.Fatalf("DetectBangKey(%q)=%q want=%q", tc.query, output.Key, tc.want) | |||
| 98 | } | 103 | } | ||
| 99 | }) | 104 | }) | ||
| 100 | } | 105 | } | ||
| 101 | } | 106 | } | ||
| 102 | 107 | ||||
| 103 | - | func TestBuildBangNavigationText(t *testing.T) { | 108 | + | func TestBuildNavigationText(t *testing.T) { |
| 104 | - | shortcuts := map[string]config.SearchShortcut{ | 109 | + | shortcuts := map[string]SearchShortcut{ |
| 105 | "gh": {URL: "https://github.com/search?q=%s"}, | 110 | "gh": {URL: "https://github.com/search?q=%s"}, | ||
| 106 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | 111 | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | ||
| 107 | } | 112 | } | ||
| 113 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 114 | + | ctx := context.Background() | |||
| 108 | 115 | ||||
| 109 | cases := []struct { | 116 | cases := []struct { | ||
| 110 | name string | 117 | name string | ||
| 111 | entryText string | 118 | entryText string | ||
| 112 | want string | 119 | want string | ||
| 120 | + | wantValid bool | |||
| 121 | + | }{ | |||
| 122 | + | {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false}, | |||
| 123 | + | {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false}, | |||
| 124 | + | {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false}, | |||
| 125 | + | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true}, | |||
| 126 | + | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true}, | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | for _, tc := range cases { | |||
| 130 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 131 | + | output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText}) | |||
| 132 | + | if output.Text != tc.want { | |||
| 133 | + | t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want) | |||
| 134 | + | } | |||
| 135 | + | if output.Valid != tc.wantValid { | |||
| 136 | + | t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid) | |||
| 137 | + | } | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestGetShortcut(t *testing.T) { | |||
| 143 | + | shortcuts := map[string]SearchShortcut{ | |||
| 144 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |||
| 145 | + | } | |||
| 146 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 147 | + | ||||
| 148 | + | cases := []struct { | |||
| 149 | + | name string | |||
| 150 | + | key string | |||
| 151 | + | wantOK bool | |||
| 152 | + | wantURL string | |||
| 113 | }{ | 153 | }{ | ||
| 114 | - | {name: "not a bang shortcut", entryText: "example.com", want: ""}, | 154 | + | {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"}, |
| 115 | - | {name: "bang key without query", entryText: "!gh ", want: ""}, | 155 | + | {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"}, |
| 116 | - | {name: "unknown bang key", entryText: "!nope test", want: ""}, | 156 | + | {name: "not found", key: "unknown", wantOK: false, wantURL: ""}, |
| 117 | - | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber"}, | |||
| 118 | - | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query"}, | |||
| 119 | } | 157 | } | ||
| 120 | 158 | ||||
| 121 | for _, tc := range cases { | 159 | for _, tc := range cases { | ||
| 122 | t.Run(tc.name, func(t *testing.T) { | 160 | t.Run(tc.name, func(t *testing.T) { | ||
| 123 | - | if got := buildBangNavigationText(shortcuts, tc.entryText); got != tc.want { | 161 | + | shortcut, ok := uc.GetShortcut(tc.key) |
| 124 | - | t.Fatalf("buildBangNavigationText(%q)=%q want=%q", tc.entryText, got, tc.want) | 162 | + | if ok != tc.wantOK { |
| 163 | + | t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK) | |||
| 164 | + | } | |||
| 165 | + | if shortcut.URL != tc.wantURL { | |||
| 166 | + | t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL) | |||
| 125 | } | 167 | } | ||
| 126 | }) | 168 | }) | ||
| 127 | } | 169 | } | ||
M internal/ui/app.go +11 -1
| @@ -389,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 389 | 389 | ||
| 390 | 390 | log := logging.FromContext(ctx) | |
| 391 | 391 | ||
| 392 | + | // Convert config shortcuts to use case type | |
| 393 | + | shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts)) | |
| 394 | + | for key, shortcut := range a.deps.Config.SearchShortcuts { | |
| 395 | + | shortcuts[key] = usecase.SearchShortcut{ | |
| 396 | + | URL: shortcut.URL, | |
| 397 | + | Description: shortcut.Description, | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 401 | + | ||
| 392 | 402 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | |
| 393 | 403 | a.omniboxCfg = component.OmniboxConfig{ | |
| 394 | 404 | HistoryUC: a.deps.HistoryUC, | |
| 395 | 405 | FavoritesUC: a.deps.FavoritesUC, | |
| 396 | 406 | FaviconAdapter: a.faviconAdapter, | |
| 397 | 407 | CopyURLUC: a.deps.CopyURLUC, | |
| 398 | - | Shortcuts: a.deps.Config.SearchShortcuts, | |
| 408 | + | ShortcutsUC: shortcutsUC, | |
| 399 | 409 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| 400 | 410 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 401 | 411 | UIScale: a.deps.Config.DefaultUIScale, | |
| @@ -389,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 389 | 389 | ||||
| 390 | log := logging.FromContext(ctx) | 390 | log := logging.FromContext(ctx) | ||
| 391 | 391 | ||||
| 392 | + | // Convert config shortcuts to use case type | |||
| 393 | + | shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts)) | |||
| 394 | + | for key, shortcut := range a.deps.Config.SearchShortcuts { | |||
| 395 | + | shortcuts[key] = usecase.SearchShortcut{ | |||
| 396 | + | URL: shortcut.URL, | |||
| 397 | + | Description: shortcut.Description, | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |||
| 401 | + | ||||
| 392 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | 402 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | ||
| 393 | a.omniboxCfg = component.OmniboxConfig{ | 403 | a.omniboxCfg = component.OmniboxConfig{ | ||
| 394 | HistoryUC: a.deps.HistoryUC, | 404 | HistoryUC: a.deps.HistoryUC, | ||
| 395 | FavoritesUC: a.deps.FavoritesUC, | 405 | FavoritesUC: a.deps.FavoritesUC, | ||
| 396 | FaviconAdapter: a.faviconAdapter, | 406 | FaviconAdapter: a.faviconAdapter, | ||
| 397 | CopyURLUC: a.deps.CopyURLUC, | 407 | CopyURLUC: a.deps.CopyURLUC, | ||
| 398 | - | Shortcuts: a.deps.Config.SearchShortcuts, | 408 | + | ShortcutsUC: shortcutsUC, |
| 399 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | 409 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | ||
| 400 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 410 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 401 | UIScale: a.deps.Config.DefaultUIScale, | 411 | UIScale: a.deps.Config.DefaultUIScale, | ||
M internal/ui/component/omnibox.go +33 -102
| @@ -3,7 +3,6 @@ package component | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "sort" | |
| 7 | 6 | "strings" | |
| 8 | 7 | "sync" | |
| 9 | 8 | "time" | |
| @@ -11,7 +10,6 @@ import ( | |||
| 11 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 12 | "github.com/bnema/dumber/internal/domain/url" | |
| 14 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 16 | 14 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 17 | 15 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -92,7 +90,7 @@ type Omnibox struct { | |||
| 92 | 90 | favoritesUC *usecase.ManageFavoritesUseCase | |
| 93 | 91 | faviconAdapter *adapter.FaviconAdapter | |
| 94 | 92 | copyURLUC *usecase.CopyURLUseCase | |
| 95 | - | shortcuts map[string]config.SearchShortcut | |
| 93 | + | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 96 | 94 | defaultSearch string | |
| 97 | 95 | initialBehavior string | |
| 98 | 96 | ctx context.Context | |
| @@ -128,7 +126,7 @@ type OmniboxConfig struct { | |||
| 128 | 126 | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 129 | 127 | FaviconAdapter *adapter.FaviconAdapter | |
| 130 | 128 | CopyURLUC *usecase.CopyURLUseCase | |
| 131 | - | Shortcuts map[string]config.SearchShortcut | |
| 129 | + | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 132 | 130 | DefaultSearch string | |
| 133 | 131 | InitialBehavior string | |
| 134 | 132 | UIScale float64 // UI scale for favicon sizing | |
| @@ -153,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 153 | 151 | favoritesUC: cfg.FavoritesUC, | |
| 154 | 152 | faviconAdapter: cfg.FaviconAdapter, | |
| 155 | 153 | copyURLUC: cfg.CopyURLUC, | |
| 156 | - | shortcuts: cfg.Shortcuts, | |
| 154 | + | shortcutsUC: cfg.ShortcutsUC, | |
| 157 | 155 | defaultSearch: cfg.DefaultSearch, | |
| 158 | 156 | initialBehavior: cfg.InitialBehavior, | |
| 159 | 157 | onToast: cfg.OnToast, | |
| @@ -780,87 +778,30 @@ func (o *Omnibox) loadFavorites(query string) { | |||
| 780 | 778 | }() | |
| 781 | 779 | } | |
| 782 | 780 | ||
| 783 | - | func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query string) []BangSuggestion { | |
| 784 | - | prefix := strings.TrimPrefix(query, "!") | |
| 785 | - | if idx := strings.Index(prefix, " "); idx >= 0 { | |
| 786 | - | prefix = prefix[:idx] | |
| 787 | - | } | |
| 788 | - | prefix = strings.ToLower(prefix) | |
| 789 | - | ||
| 790 | - | suggestions := make([]BangSuggestion, 0, len(shortcuts)) | |
| 791 | - | for key, shortcut := range shortcuts { | |
| 792 | - | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |
| 793 | - | continue | |
| 794 | - | } | |
| 795 | - | description := shortcut.Description | |
| 796 | - | if description == "" { | |
| 797 | - | description = shortcut.URL | |
| 798 | - | } | |
| 799 | - | suggestions = append(suggestions, BangSuggestion{ | |
| 800 | - | Key: key, | |
| 801 | - | Description: description, | |
| 802 | - | }) | |
| 803 | - | } | |
| 804 | - | ||
| 805 | - | sort.Slice(suggestions, func(i, j int) bool { | |
| 806 | - | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |
| 807 | - | }) | |
| 808 | - | ||
| 809 | - | return suggestions | |
| 810 | - | } | |
| 811 | - | ||
| 812 | - | func detectBangKey(shortcuts map[string]config.SearchShortcut, query string) string { | |
| 813 | - | spaceIdx := strings.Index(query, " ") | |
| 814 | - | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |
| 815 | - | return "" | |
| 816 | - | } | |
| 817 | - | ||
| 818 | - | candidate := query[1:spaceIdx] | |
| 819 | - | for key := range shortcuts { | |
| 820 | - | if strings.EqualFold(key, candidate) { | |
| 821 | - | return key | |
| 822 | - | } | |
| 823 | - | } | |
| 824 | - | ||
| 825 | - | return "" | |
| 826 | - | } | |
| 827 | - | ||
| 828 | - | func normalizeBangKey(shortcuts map[string]config.SearchShortcut, shortcutKey string) (string, bool) { | |
| 829 | - | for key := range shortcuts { | |
| 830 | - | if strings.EqualFold(key, shortcutKey) { | |
| 831 | - | return key, true | |
| 832 | - | } | |
| 833 | - | } | |
| 834 | - | return "", false | |
| 835 | - | } | |
| 836 | - | ||
| 837 | - | func buildBangNavigationText(shortcuts map[string]config.SearchShortcut, entryText string) string { | |
| 838 | - | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |
| 839 | - | if !found || query == "" { | |
| 840 | - | return "" | |
| 781 | + | func (o *Omnibox) loadBangSuggestions(query string) { | |
| 782 | + | if o.shortcutsUC == nil { | |
| 783 | + | return | |
| 841 | 784 | } | |
| 842 | - | ||
| 843 | - | resolvedKey, ok := normalizeBangKey(shortcuts, shortcutKey) | |
| 844 | - | if !ok { | |
| 845 | - | return "" | |
| 785 | + | output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query}) | |
| 786 | + | suggestions := make([]BangSuggestion, len(output.Suggestions)) | |
| 787 | + | for i, s := range output.Suggestions { | |
| 788 | + | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | |
| 846 | 789 | } | |
| 847 | - | ||
| 848 | - | return "!" + resolvedKey + " " + query | |
| 849 | - | } | |
| 850 | - | ||
| 851 | - | func (o *Omnibox) loadBangSuggestions(query string) { | |
| 852 | - | suggestions := buildBangSuggestions(o.shortcuts, query) | |
| 853 | 790 | o.idleAddUpdateBangSuggestions(suggestions) | |
| 854 | 791 | } | |
| 855 | 792 | ||
| 856 | 793 | func (o *Omnibox) updateBangDetection(query string) { | |
| 857 | - | key := detectBangKey(o.shortcuts, query) | |
| 858 | - | if key == "" { | |
| 794 | + | if o.shortcutsUC == nil { | |
| 795 | + | o.clearDetectedBang() | |
| 796 | + | return | |
| 797 | + | } | |
| 798 | + | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | |
| 799 | + | if output.Key == "" { | |
| 859 | 800 | o.clearDetectedBang() | |
| 860 | 801 | return | |
| 861 | 802 | } | |
| 862 | 803 | ||
| 863 | - | o.setDetectedBang(key) | |
| 804 | + | o.setDetectedBang(output.Key, output.Description) | |
| 864 | 805 | } | |
| 865 | 806 | ||
| 866 | 807 | func (o *Omnibox) clearBangState() { | |
| @@ -872,7 +813,7 @@ func (o *Omnibox) clearBangState() { | |||
| 872 | 813 | o.clearDetectedBang() | |
| 873 | 814 | } | |
| 874 | 815 | ||
| 875 | - | func (o *Omnibox) setDetectedBang(key string) { | |
| 816 | + | func (o *Omnibox) setDetectedBang(key, description string) { | |
| 876 | 817 | o.mu.Lock() | |
| 877 | 818 | o.detectedBang = key | |
| 878 | 819 | o.mu.Unlock() | |
| @@ -880,14 +821,6 @@ func (o *Omnibox) setDetectedBang(key string) { | |||
| 880 | 821 | if o.bangBadge == nil { | |
| 881 | 822 | return | |
| 882 | 823 | } | |
| 883 | - | shortcut, ok := o.shortcuts[key] | |
| 884 | - | if !ok { | |
| 885 | - | return | |
| 886 | - | } | |
| 887 | - | description := shortcut.Description | |
| 888 | - | if description == "" { | |
| 889 | - | description = shortcut.URL | |
| 890 | - | } | |
| 891 | 824 | label := description | |
| 892 | 825 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 893 | 826 | o.entry.AddCssClass("omnibox-entry-bang-active") | |
| @@ -1271,16 +1204,19 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1271 | 1204 | ||
| 1272 | 1205 | if bangMode { | |
| 1273 | 1206 | // If user typed a full bang query, navigate using the bang URL. | |
| 1274 | - | if navText := buildBangNavigationText(o.shortcuts, entryText); navText != "" { | |
| 1275 | - | targetURL := o.buildURL(navText) | |
| 1276 | - | if targetURL == "" { | |
| 1207 | + | if o.shortcutsUC != nil { | |
| 1208 | + | navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText}) | |
| 1209 | + | if navOutput.Valid { | |
| 1210 | + | targetURL := o.buildURL(navOutput.Text) | |
| 1211 | + | if targetURL == "" { | |
| 1212 | + | return | |
| 1213 | + | } | |
| 1214 | + | o.Hide(o.ctx) | |
| 1215 | + | if o.onNavigate != nil { | |
| 1216 | + | o.onNavigate(targetURL) | |
| 1217 | + | } | |
| 1277 | 1218 | return | |
| 1278 | 1219 | } | |
| 1279 | - | o.Hide(o.ctx) | |
| 1280 | - | if o.onNavigate != nil { | |
| 1281 | - | o.onNavigate(targetURL) | |
| 1282 | - | } | |
| 1283 | - | return | |
| 1284 | 1220 | } | |
| 1285 | 1221 | ||
| 1286 | 1222 | // Otherwise, Enter autocompletes the selected bang. | |
| @@ -1465,16 +1401,11 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1465 | 1401 | ||
| 1466 | 1402 | // buildURL constructs a URL from text, handling search shortcuts. | |
| 1467 | 1403 | func (o *Omnibox) buildURL(text string) string { | |
| 1468 | - | return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch) | |
| 1469 | - | } | |
| 1470 | - | ||
| 1471 | - | // shortcutURLs returns a map of shortcut keys to URL templates. | |
| 1472 | - | func (o *Omnibox) shortcutURLs() map[string]string { | |
| 1473 | - | result := make(map[string]string, len(o.shortcuts)) | |
| 1474 | - | for key, shortcut := range o.shortcuts { | |
| 1475 | - | result[key] = shortcut.URL | |
| 1404 | + | var shortcutURLs map[string]string | |
| 1405 | + | if o.shortcutsUC != nil { | |
| 1406 | + | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 1476 | 1407 | } | |
| 1477 | - | return result | |
| 1408 | + | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |
| 1478 | 1409 | } | |
| 1479 | 1410 | ||
| 1480 | 1411 | // toggleViewMode switches between history and favorites. | |
| @@ -3,7 +3,6 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "sort" | |||
| 7 | "strings" | 6 | "strings" | ||
| 8 | "sync" | 7 | "sync" | ||
| 9 | "time" | 8 | "time" | ||
| @@ -11,7 +10,6 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 12 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 13 | "github.com/bnema/dumber/internal/domain/url" | 12 | "github.com/bnema/dumber/internal/domain/url" | ||
| 14 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 15 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 16 | "github.com/bnema/dumber/internal/ui/adapter" | 14 | "github.com/bnema/dumber/internal/ui/adapter" | ||
| 17 | "github.com/bnema/dumber/internal/ui/input" | 15 | "github.com/bnema/dumber/internal/ui/input" | ||
| @@ -92,7 +90,7 @@ type Omnibox struct { | |||||
| 92 | favoritesUC *usecase.ManageFavoritesUseCase | 90 | favoritesUC *usecase.ManageFavoritesUseCase | ||
| 93 | faviconAdapter *adapter.FaviconAdapter | 91 | faviconAdapter *adapter.FaviconAdapter | ||
| 94 | copyURLUC *usecase.CopyURLUseCase | 92 | copyURLUC *usecase.CopyURLUseCase | ||
| 95 | - | shortcuts map[string]config.SearchShortcut | 93 | + | shortcutsUC *usecase.SearchShortcutsUseCase |
| 96 | defaultSearch string | 94 | defaultSearch string | ||
| 97 | initialBehavior string | 95 | initialBehavior string | ||
| 98 | ctx context.Context | 96 | ctx context.Context | ||
| @@ -128,7 +126,7 @@ type OmniboxConfig struct { | |||||
| 128 | FavoritesUC *usecase.ManageFavoritesUseCase | 126 | FavoritesUC *usecase.ManageFavoritesUseCase | ||
| 129 | FaviconAdapter *adapter.FaviconAdapter | 127 | FaviconAdapter *adapter.FaviconAdapter | ||
| 130 | CopyURLUC *usecase.CopyURLUseCase | 128 | CopyURLUC *usecase.CopyURLUseCase | ||
| 131 | - | Shortcuts map[string]config.SearchShortcut | 129 | + | ShortcutsUC *usecase.SearchShortcutsUseCase |
| 132 | DefaultSearch string | 130 | DefaultSearch string | ||
| 133 | InitialBehavior string | 131 | InitialBehavior string | ||
| 134 | UIScale float64 // UI scale for favicon sizing | 132 | UIScale float64 // UI scale for favicon sizing | ||
| @@ -153,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 153 | favoritesUC: cfg.FavoritesUC, | 151 | favoritesUC: cfg.FavoritesUC, | ||
| 154 | faviconAdapter: cfg.FaviconAdapter, | 152 | faviconAdapter: cfg.FaviconAdapter, | ||
| 155 | copyURLUC: cfg.CopyURLUC, | 153 | copyURLUC: cfg.CopyURLUC, | ||
| 156 | - | shortcuts: cfg.Shortcuts, | 154 | + | shortcutsUC: cfg.ShortcutsUC, |
| 157 | defaultSearch: cfg.DefaultSearch, | 155 | defaultSearch: cfg.DefaultSearch, | ||
| 158 | initialBehavior: cfg.InitialBehavior, | 156 | initialBehavior: cfg.InitialBehavior, | ||
| 159 | onToast: cfg.OnToast, | 157 | onToast: cfg.OnToast, | ||
| @@ -780,87 +778,30 @@ func (o *Omnibox) loadFavorites(query string) { | |||||
| 780 | }() | 778 | }() | ||
| 781 | } | 779 | } | ||
| 782 | 780 | ||||
| 783 | - | func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query string) []BangSuggestion { | 781 | + | func (o *Omnibox) loadBangSuggestions(query string) { |
| 784 | - | prefix := strings.TrimPrefix(query, "!") | 782 | + | if o.shortcutsUC == nil { |
| 785 | - | if idx := strings.Index(prefix, " "); idx >= 0 { | 783 | + | return |
| 786 | - | prefix = prefix[:idx] | |||
| 787 | - | } | |||
| 788 | - | prefix = strings.ToLower(prefix) | |||
| 789 | - | ||||
| 790 | - | suggestions := make([]BangSuggestion, 0, len(shortcuts)) | |||
| 791 | - | for key, shortcut := range shortcuts { | |||
| 792 | - | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |||
| 793 | - | continue | |||
| 794 | - | } | |||
| 795 | - | description := shortcut.Description | |||
| 796 | - | if description == "" { | |||
| 797 | - | description = shortcut.URL | |||
| 798 | - | } | |||
| 799 | - | suggestions = append(suggestions, BangSuggestion{ | |||
| 800 | - | Key: key, | |||
| 801 | - | Description: description, | |||
| 802 | - | }) | |||
| 803 | - | } | |||
| 804 | - | ||||
| 805 | - | sort.Slice(suggestions, func(i, j int) bool { | |||
| 806 | - | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |||
| 807 | - | }) | |||
| 808 | - | ||||
| 809 | - | return suggestions | |||
| 810 | - | } | |||
| 811 | - | ||||
| 812 | - | func detectBangKey(shortcuts map[string]config.SearchShortcut, query string) string { | |||
| 813 | - | spaceIdx := strings.Index(query, " ") | |||
| 814 | - | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |||
| 815 | - | return "" | |||
| 816 | - | } | |||
| 817 | - | ||||
| 818 | - | candidate := query[1:spaceIdx] | |||
| 819 | - | for key := range shortcuts { | |||
| 820 | - | if strings.EqualFold(key, candidate) { | |||
| 821 | - | return key | |||
| 822 | - | } | |||
| 823 | - | } | |||
| 824 | - | ||||
| 825 | - | return "" | |||
| 826 | - | } | |||
| 827 | - | ||||
| 828 | - | func normalizeBangKey(shortcuts map[string]config.SearchShortcut, shortcutKey string) (string, bool) { | |||
| 829 | - | for key := range shortcuts { | |||
| 830 | - | if strings.EqualFold(key, shortcutKey) { | |||
| 831 | - | return key, true | |||
| 832 | - | } | |||
| 833 | - | } | |||
| 834 | - | return "", false | |||
| 835 | - | } | |||
| 836 | - | ||||
| 837 | - | func buildBangNavigationText(shortcuts map[string]config.SearchShortcut, entryText string) string { | |||
| 838 | - | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |||
| 839 | - | if !found || query == "" { | |||
| 840 | - | return "" | |||
| 841 | } | 784 | } | ||
| 842 | - | 785 | + | output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query}) | |
| 843 | - | resolvedKey, ok := normalizeBangKey(shortcuts, shortcutKey) | 786 | + | suggestions := make([]BangSuggestion, len(output.Suggestions)) |
| 844 | - | if !ok { | 787 | + | for i, s := range output.Suggestions { |
| 845 | - | return "" | 788 | + | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} |
| 846 | } | 789 | } | ||
| 847 | - | ||||
| 848 | - | return "!" + resolvedKey + " " + query | |||
| 849 | - | } | |||
| 850 | - | ||||
| 851 | - | func (o *Omnibox) loadBangSuggestions(query string) { | |||
| 852 | - | suggestions := buildBangSuggestions(o.shortcuts, query) | |||
| 853 | o.idleAddUpdateBangSuggestions(suggestions) | 790 | o.idleAddUpdateBangSuggestions(suggestions) | ||
| 854 | } | 791 | } | ||
| 855 | 792 | ||||
| 856 | func (o *Omnibox) updateBangDetection(query string) { | 793 | func (o *Omnibox) updateBangDetection(query string) { | ||
| 857 | - | key := detectBangKey(o.shortcuts, query) | 794 | + | if o.shortcutsUC == nil { |
| 858 | - | if key == "" { | 795 | + | o.clearDetectedBang() |
| 796 | + | return | |||
| 797 | + | } | |||
| 798 | + | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | |||
| 799 | + | if output.Key == "" { | |||
| 859 | o.clearDetectedBang() | 800 | o.clearDetectedBang() | ||
| 860 | return | 801 | return | ||
| 861 | } | 802 | } | ||
| 862 | 803 | ||||
| 863 | - | o.setDetectedBang(key) | 804 | + | o.setDetectedBang(output.Key, output.Description) |
| 864 | } | 805 | } | ||
| 865 | 806 | ||||
| 866 | func (o *Omnibox) clearBangState() { | 807 | func (o *Omnibox) clearBangState() { | ||
| @@ -872,7 +813,7 @@ func (o *Omnibox) clearBangState() { | |||||
| 872 | o.clearDetectedBang() | 813 | o.clearDetectedBang() | ||
| 873 | } | 814 | } | ||
| 874 | 815 | ||||
| 875 | - | func (o *Omnibox) setDetectedBang(key string) { | 816 | + | func (o *Omnibox) setDetectedBang(key, description string) { |
| 876 | o.mu.Lock() | 817 | o.mu.Lock() | ||
| 877 | o.detectedBang = key | 818 | o.detectedBang = key | ||
| 878 | o.mu.Unlock() | 819 | o.mu.Unlock() | ||
| @@ -880,14 +821,6 @@ func (o *Omnibox) setDetectedBang(key string) { | |||||
| 880 | if o.bangBadge == nil { | 821 | if o.bangBadge == nil { | ||
| 881 | return | 822 | return | ||
| 882 | } | 823 | } | ||
| 883 | - | shortcut, ok := o.shortcuts[key] | |||
| 884 | - | if !ok { | |||
| 885 | - | return | |||
| 886 | - | } | |||
| 887 | - | description := shortcut.Description | |||
| 888 | - | if description == "" { | |||
| 889 | - | description = shortcut.URL | |||
| 890 | - | } | |||
| 891 | label := description | 824 | label := description | ||
| 892 | var cb glib.SourceFunc = func(uintptr) bool { | 825 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 893 | o.entry.AddCssClass("omnibox-entry-bang-active") | 826 | o.entry.AddCssClass("omnibox-entry-bang-active") | ||
| @@ -1271,16 +1204,19 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1271 | 1204 | ||||
| 1272 | if bangMode { | 1205 | if bangMode { | ||
| 1273 | // If user typed a full bang query, navigate using the bang URL. | 1206 | // If user typed a full bang query, navigate using the bang URL. | ||
| 1274 | - | if navText := buildBangNavigationText(o.shortcuts, entryText); navText != "" { | 1207 | + | if o.shortcutsUC != nil { |
| 1275 | - | targetURL := o.buildURL(navText) | 1208 | + | navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText}) |
| 1276 | - | if targetURL == "" { | 1209 | + | if navOutput.Valid { |
| 1210 | + | targetURL := o.buildURL(navOutput.Text) | |||
| 1211 | + | if targetURL == "" { | |||
| 1212 | + | return | |||
| 1213 | + | } | |||
| 1214 | + | o.Hide(o.ctx) | |||
| 1215 | + | if o.onNavigate != nil { | |||
| 1216 | + | o.onNavigate(targetURL) | |||
| 1217 | + | } | |||
| 1277 | return | 1218 | return | ||
| 1278 | } | 1219 | } | ||
| 1279 | - | o.Hide(o.ctx) | |||
| 1280 | - | if o.onNavigate != nil { | |||
| 1281 | - | o.onNavigate(targetURL) | |||
| 1282 | - | } | |||
| 1283 | - | return | |||
| 1284 | } | 1220 | } | ||
| 1285 | 1221 | ||||
| 1286 | // Otherwise, Enter autocompletes the selected bang. | 1222 | // Otherwise, Enter autocompletes the selected bang. | ||
| @@ -1465,16 +1401,11 @@ func (o *Omnibox) yankSelectedURL() { | |||||
| 1465 | 1401 | ||||
| 1466 | // buildURL constructs a URL from text, handling search shortcuts. | 1402 | // buildURL constructs a URL from text, handling search shortcuts. | ||
| 1467 | func (o *Omnibox) buildURL(text string) string { | 1403 | func (o *Omnibox) buildURL(text string) string { | ||
| 1468 | - | return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch) | 1404 | + | var shortcutURLs map[string]string |
| 1469 | - | } | 1405 | + | if o.shortcutsUC != nil { |
| 1470 | - | 1406 | + | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 1471 | - | // shortcutURLs returns a map of shortcut keys to URL templates. | |||
| 1472 | - | func (o *Omnibox) shortcutURLs() map[string]string { | |||
| 1473 | - | result := make(map[string]string, len(o.shortcuts)) | |||
| 1474 | - | for key, shortcut := range o.shortcuts { | |||
| 1475 | - | result[key] = shortcut.URL | |||
| 1476 | } | 1407 | } | ||
| 1477 | - | return result | 1408 | + | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) |
| 1478 | } | 1409 | } | ||
| 1479 | 1410 | ||||
| 1480 | // toggleViewMode switches between history and favorites. | 1411 | // toggleViewMode switches between history and favorites. | ||