detached 0dc071d

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
22
33 import (
4+ "context"
45 "testing"
5-
6- "github.com/bnema/dumber/internal/infrastructure/config"
76 )
87
9-func TestBuildBangSuggestions(t *testing.T) {
10- shortcuts := map[string]config.SearchShortcut{
8+func TestFilterBangs(t *testing.T) {
9+ shortcuts := map[string]SearchShortcut{
1110 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"},
1211 "g": {URL: "https://google.com/search?q=%s", Description: "Google search"},
1312 "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
1413 "n": {URL: "https://news.ycombinator.com/", Description: ""},
1514 }
15+ uc := NewSearchShortcutsUseCase(shortcuts)
16+ ctx := context.Background()
1617
1718 cases := []struct {
1819 name string
@@ -52,7 +53,8 @@ func TestBuildBangSuggestions(t *testing.T) {
5253
5354 for _, tc := range cases {
5455 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
5658 if len(got) != len(tc.wantKeys) {
5759 t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys))
5860 }
@@ -73,10 +75,12 @@ func TestBuildBangSuggestions(t *testing.T) {
7375 }
7476
7577 func TestDetectBangKey(t *testing.T) {
76- shortcuts := map[string]config.SearchShortcut{
78+ shortcuts := map[string]SearchShortcut{
7779 "gh": {URL: "https://github.com/search?q=%s"},
7880 "ddg": {URL: "https://duckduckgo.com/?q=%s"},
7981 }
82+ uc := NewSearchShortcutsUseCase(shortcuts)
83+ ctx := context.Background()
8084
8185 cases := []struct {
8286 name string
@@ -93,35 +97,73 @@ func TestDetectBangKey(t *testing.T) {
9397
9498 for _, tc := range cases {
9599 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)
98103 }
99104 })
100105 }
101106 }
102107
103-func TestBuildBangNavigationText(t *testing.T) {
104- shortcuts := map[string]config.SearchShortcut{
108+func TestBuildNavigationText(t *testing.T) {
109+ shortcuts := map[string]SearchShortcut{
105110 "gh": {URL: "https://github.com/search?q=%s"},
106111 "ddg": {URL: "https://duckduckgo.com/?q=%s"},
107112 }
113+ uc := NewSearchShortcutsUseCase(shortcuts)
114+ ctx := context.Background()
108115
109116 cases := []struct {
110117 name string
111118 entryText string
112119 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
113153 }{
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: ""},
119157 }
120158
121159 for _, tc := range cases {
122160 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)
125167 }
126168 })
127169 }
@@ -1,18 +1,19 @@
1-package component1+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 string19 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 string86 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 string117 name string
111 entryText string118 entryText string
112 want string119 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) {
389389
390390 log := logging.FromContext(ctx)
391391
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+
392402 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
393403 a.omniboxCfg = component.OmniboxConfig{
394404 HistoryUC: a.deps.HistoryUC,
395405 FavoritesUC: a.deps.FavoritesUC,
396406 FaviconAdapter: a.faviconAdapter,
397407 CopyURLUC: a.deps.CopyURLUC,
398- Shortcuts: a.deps.Config.SearchShortcuts,
408+ ShortcutsUC: shortcutsUC,
399409 DefaultSearch: a.deps.Config.DefaultSearchEngine,
400410 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
401411 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
33 import (
44 "context"
55 "fmt"
6- "sort"
76 "strings"
87 "sync"
98 "time"
@@ -11,7 +10,6 @@ import (
1110 "github.com/bnema/dumber/internal/application/usecase"
1211 "github.com/bnema/dumber/internal/domain/entity"
1312 "github.com/bnema/dumber/internal/domain/url"
14- "github.com/bnema/dumber/internal/infrastructure/config"
1513 "github.com/bnema/dumber/internal/logging"
1614 "github.com/bnema/dumber/internal/ui/adapter"
1715 "github.com/bnema/dumber/internal/ui/input"
@@ -92,7 +90,7 @@ type Omnibox struct {
9290 favoritesUC *usecase.ManageFavoritesUseCase
9391 faviconAdapter *adapter.FaviconAdapter
9492 copyURLUC *usecase.CopyURLUseCase
95- shortcuts map[string]config.SearchShortcut
93+ shortcutsUC *usecase.SearchShortcutsUseCase
9694 defaultSearch string
9795 initialBehavior string
9896 ctx context.Context
@@ -128,7 +126,7 @@ type OmniboxConfig struct {
128126 FavoritesUC *usecase.ManageFavoritesUseCase
129127 FaviconAdapter *adapter.FaviconAdapter
130128 CopyURLUC *usecase.CopyURLUseCase
131- Shortcuts map[string]config.SearchShortcut
129+ ShortcutsUC *usecase.SearchShortcutsUseCase
132130 DefaultSearch string
133131 InitialBehavior string
134132 UIScale float64 // UI scale for favicon sizing
@@ -153,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
153151 favoritesUC: cfg.FavoritesUC,
154152 faviconAdapter: cfg.FaviconAdapter,
155153 copyURLUC: cfg.CopyURLUC,
156- shortcuts: cfg.Shortcuts,
154+ shortcutsUC: cfg.ShortcutsUC,
157155 defaultSearch: cfg.DefaultSearch,
158156 initialBehavior: cfg.InitialBehavior,
159157 onToast: cfg.OnToast,
@@ -780,87 +778,30 @@ func (o *Omnibox) loadFavorites(query string) {
780778 }()
781779 }
782780
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
841784 }
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}
846789 }
847-
848- return "!" + resolvedKey + " " + query
849-}
850-
851-func (o *Omnibox) loadBangSuggestions(query string) {
852- suggestions := buildBangSuggestions(o.shortcuts, query)
853790 o.idleAddUpdateBangSuggestions(suggestions)
854791 }
855792
856793 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 == "" {
859800 o.clearDetectedBang()
860801 return
861802 }
862803
863- o.setDetectedBang(key)
804+ o.setDetectedBang(output.Key, output.Description)
864805 }
865806
866807 func (o *Omnibox) clearBangState() {
@@ -872,7 +813,7 @@ func (o *Omnibox) clearBangState() {
872813 o.clearDetectedBang()
873814 }
874815
875-func (o *Omnibox) setDetectedBang(key string) {
816+func (o *Omnibox) setDetectedBang(key, description string) {
876817 o.mu.Lock()
877818 o.detectedBang = key
878819 o.mu.Unlock()
@@ -880,14 +821,6 @@ func (o *Omnibox) setDetectedBang(key string) {
880821 if o.bangBadge == nil {
881822 return
882823 }
883- shortcut, ok := o.shortcuts[key]
884- if !ok {
885- return
886- }
887- description := shortcut.Description
888- if description == "" {
889- description = shortcut.URL
890- }
891824 label := description
892825 var cb glib.SourceFunc = func(uintptr) bool {
893826 o.entry.AddCssClass("omnibox-entry-bang-active")
@@ -1271,16 +1204,19 @@ func (o *Omnibox) navigateToSelected() {
12711204
12721205 if bangMode {
12731206 // 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+ }
12771218 return
12781219 }
1279- o.Hide(o.ctx)
1280- if o.onNavigate != nil {
1281- o.onNavigate(targetURL)
1282- }
1283- return
12841220 }
12851221
12861222 // Otherwise, Enter autocompletes the selected bang.
@@ -1465,16 +1401,11 @@ func (o *Omnibox) yankSelectedURL() {
14651401
14661402 // buildURL constructs a URL from text, handling search shortcuts.
14671403 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()
14761407 }
1477- return result
1408+ return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
14781409 }
14791410
14801411 // 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.ManageFavoritesUseCase90 favoritesUC *usecase.ManageFavoritesUseCase
93 faviconAdapter *adapter.FaviconAdapter91 faviconAdapter *adapter.FaviconAdapter
94 copyURLUC *usecase.CopyURLUseCase92 copyURLUC *usecase.CopyURLUseCase
95- shortcuts map[string]config.SearchShortcut93+ shortcutsUC *usecase.SearchShortcutsUseCase
96 defaultSearch string94 defaultSearch string
97 initialBehavior string95 initialBehavior string
98 ctx context.Context96 ctx context.Context
@@ -128,7 +126,7 @@ type OmniboxConfig struct {
128 FavoritesUC *usecase.ManageFavoritesUseCase126 FavoritesUC *usecase.ManageFavoritesUseCase
129 FaviconAdapter *adapter.FaviconAdapter127 FaviconAdapter *adapter.FaviconAdapter
130 CopyURLUC *usecase.CopyURLUseCase128 CopyURLUC *usecase.CopyURLUseCase
131- Shortcuts map[string]config.SearchShortcut129+ ShortcutsUC *usecase.SearchShortcutsUseCase
132 DefaultSearch string130 DefaultSearch string
133 InitialBehavior string131 InitialBehavior string
134 UIScale float64 // UI scale for favicon sizing132 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 return801 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 = key818 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 return822 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 := description824 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 return1218 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 result1408+ 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.

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close