detached 3b55bde

Merge pull request #55 from bnema/feature/omnibox-bangs

feat(omnibox): bang shortcuts UI
commit
3b55bde328c52ec505a53dc9e9a2ea59d93db107
author
bnema · · 2025-12-28 15:00 UTC
committer
GitHub · · 2025-12-28 15:00 UTC
merge of
56bd04d + 81ee824 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +661 -34
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.
89 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
910 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
1011 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.
8 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.9 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
9 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).10 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
10 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.11 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
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+}
A internal/application/usecase/search_shortcuts_test.go +175 -0
@@ -0,0 +1,175 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestFilterBangs(t *testing.T) {
9+ shortcuts := map[string]SearchShortcut{
10+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"},
11+ "g": {URL: "https://google.com/search?q=%s", Description: "Google search"},
12+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
13+ "n": {URL: "https://news.ycombinator.com/", Description: ""},
14+ }
15+ uc := NewSearchShortcutsUseCase(shortcuts)
16+ ctx := context.Background()
17+
18+ cases := []struct {
19+ name string
20+ query string
21+ wantKeys []string
22+ wantDescr map[string]string
23+ }{
24+ {
25+ name: "bang only returns all sorted",
26+ query: "!",
27+ wantKeys: []string{"ddg", "g", "gh", "n"},
28+ },
29+ {
30+ name: "filters by prefix",
31+ query: "!g",
32+ wantKeys: []string{"g", "gh"},
33+ },
34+ {
35+ name: "filters by prefix case-insensitive",
36+ query: "!DD",
37+ wantKeys: []string{"ddg"},
38+ },
39+ {
40+ name: "stops prefix at space",
41+ query: "!g query",
42+ wantKeys: []string{"g", "gh"},
43+ },
44+ {
45+ name: "falls back to url when description empty",
46+ query: "!n",
47+ wantKeys: []string{"n"},
48+ wantDescr: map[string]string{
49+ "n": "https://news.ycombinator.com/",
50+ },
51+ },
52+ }
53+
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query})
57+ got := output.Suggestions
58+ if len(got) != len(tc.wantKeys) {
59+ t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys))
60+ }
61+ for i, wantKey := range tc.wantKeys {
62+ if got[i].Key != wantKey {
63+ t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey)
64+ }
65+ if tc.wantDescr != nil {
66+ if wantD, ok := tc.wantDescr[wantKey]; ok {
67+ if got[i].Description != wantD {
68+ t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD)
69+ }
70+ }
71+ }
72+ }
73+ })
74+ }
75+}
76+
77+func TestDetectBangKey(t *testing.T) {
78+ shortcuts := map[string]SearchShortcut{
79+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
80+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""},
81+ }
82+ uc := NewSearchShortcutsUseCase(shortcuts)
83+ ctx := context.Background()
84+
85+ cases := []struct {
86+ name string
87+ query string
88+ wantKey string
89+ wantDescr string
90+ }{
91+ {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""},
92+ {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""},
93+ {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""},
94+ {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""},
95+ {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"},
96+ {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
97+ {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
98+ }
99+
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query})
103+ if output.Key != tc.wantKey {
104+ t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey)
105+ }
106+ if output.Description != tc.wantDescr {
107+ t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr)
108+ }
109+ })
110+ }
111+}
112+
113+func TestBuildNavigationText(t *testing.T) {
114+ shortcuts := map[string]SearchShortcut{
115+ "gh": {URL: "https://github.com/search?q=%s"},
116+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
117+ }
118+ uc := NewSearchShortcutsUseCase(shortcuts)
119+ ctx := context.Background()
120+
121+ cases := []struct {
122+ name string
123+ entryText string
124+ want string
125+ wantValid bool
126+ }{
127+ {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false},
128+ {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false},
129+ {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false},
130+ {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true},
131+ {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true},
132+ }
133+
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText})
137+ if output.Text != tc.want {
138+ t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want)
139+ }
140+ if output.Valid != tc.wantValid {
141+ t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid)
142+ }
143+ })
144+ }
145+}
146+
147+func TestGetShortcut(t *testing.T) {
148+ shortcuts := map[string]SearchShortcut{
149+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
150+ }
151+ uc := NewSearchShortcutsUseCase(shortcuts)
152+
153+ cases := []struct {
154+ name string
155+ key string
156+ wantOK bool
157+ wantURL string
158+ }{
159+ {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"},
160+ {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"},
161+ {name: "not found", key: "unknown", wantOK: false, wantURL: ""},
162+ }
163+
164+ for _, tc := range cases {
165+ t.Run(tc.name, func(t *testing.T) {
166+ shortcut, ok := uc.GetShortcut(tc.key)
167+ if ok != tc.wantOK {
168+ t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK)
169+ }
170+ if shortcut.URL != tc.wantURL {
171+ t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL)
172+ }
173+ })
174+ }
175+}
@@ -0,0 +1,175 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestFilterBangs(t *testing.T) {
9+ shortcuts := map[string]SearchShortcut{
10+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"},
11+ "g": {URL: "https://google.com/search?q=%s", Description: "Google search"},
12+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
13+ "n": {URL: "https://news.ycombinator.com/", Description: ""},
14+ }
15+ uc := NewSearchShortcutsUseCase(shortcuts)
16+ ctx := context.Background()
17+
18+ cases := []struct {
19+ name string
20+ query string
21+ wantKeys []string
22+ wantDescr map[string]string
23+ }{
24+ {
25+ name: "bang only returns all sorted",
26+ query: "!",
27+ wantKeys: []string{"ddg", "g", "gh", "n"},
28+ },
29+ {
30+ name: "filters by prefix",
31+ query: "!g",
32+ wantKeys: []string{"g", "gh"},
33+ },
34+ {
35+ name: "filters by prefix case-insensitive",
36+ query: "!DD",
37+ wantKeys: []string{"ddg"},
38+ },
39+ {
40+ name: "stops prefix at space",
41+ query: "!g query",
42+ wantKeys: []string{"g", "gh"},
43+ },
44+ {
45+ name: "falls back to url when description empty",
46+ query: "!n",
47+ wantKeys: []string{"n"},
48+ wantDescr: map[string]string{
49+ "n": "https://news.ycombinator.com/",
50+ },
51+ },
52+ }
53+
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query})
57+ got := output.Suggestions
58+ if len(got) != len(tc.wantKeys) {
59+ t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys))
60+ }
61+ for i, wantKey := range tc.wantKeys {
62+ if got[i].Key != wantKey {
63+ t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey)
64+ }
65+ if tc.wantDescr != nil {
66+ if wantD, ok := tc.wantDescr[wantKey]; ok {
67+ if got[i].Description != wantD {
68+ t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD)
69+ }
70+ }
71+ }
72+ }
73+ })
74+ }
75+}
76+
77+func TestDetectBangKey(t *testing.T) {
78+ shortcuts := map[string]SearchShortcut{
79+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
80+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""},
81+ }
82+ uc := NewSearchShortcutsUseCase(shortcuts)
83+ ctx := context.Background()
84+
85+ cases := []struct {
86+ name string
87+ query string
88+ wantKey string
89+ wantDescr string
90+ }{
91+ {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""},
92+ {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""},
93+ {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""},
94+ {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""},
95+ {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"},
96+ {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
97+ {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
98+ }
99+
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query})
103+ if output.Key != tc.wantKey {
104+ t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey)
105+ }
106+ if output.Description != tc.wantDescr {
107+ t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr)
108+ }
109+ })
110+ }
111+}
112+
113+func TestBuildNavigationText(t *testing.T) {
114+ shortcuts := map[string]SearchShortcut{
115+ "gh": {URL: "https://github.com/search?q=%s"},
116+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
117+ }
118+ uc := NewSearchShortcutsUseCase(shortcuts)
119+ ctx := context.Background()
120+
121+ cases := []struct {
122+ name string
123+ entryText string
124+ want string
125+ wantValid bool
126+ }{
127+ {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false},
128+ {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false},
129+ {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false},
130+ {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true},
131+ {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true},
132+ }
133+
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText})
137+ if output.Text != tc.want {
138+ t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want)
139+ }
140+ if output.Valid != tc.wantValid {
141+ t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid)
142+ }
143+ })
144+ }
145+}
146+
147+func TestGetShortcut(t *testing.T) {
148+ shortcuts := map[string]SearchShortcut{
149+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
150+ }
151+ uc := NewSearchShortcutsUseCase(shortcuts)
152+
153+ cases := []struct {
154+ name string
155+ key string
156+ wantOK bool
157+ wantURL string
158+ }{
159+ {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"},
160+ {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"},
161+ {name: "not found", key: "unknown", wantOK: false, wantURL: ""},
162+ }
163+
164+ for _, tc := range cases {
165+ t.Run(tc.name, func(t *testing.T) {
166+ shortcut, ok := uc.GetShortcut(tc.key)
167+ if ok != tc.wantOK {
168+ t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK)
169+ }
170+ if shortcut.URL != tc.wantURL {
171+ t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL)
172+ }
173+ })
174+ }
175+}
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 +225 -33
@@ -10,7 +10,6 @@ import (
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/bnema/dumber/internal/domain/url"
13- "github.com/bnema/dumber/internal/infrastructure/config"
1413 "github.com/bnema/dumber/internal/logging"
1514 "github.com/bnema/dumber/internal/ui/adapter"
1615 "github.com/bnema/dumber/internal/ui/input"
@@ -22,6 +21,7 @@ import (
2221
2322 const (
2423 debounceDelayMs = 150
24+ endBoxSpacing = 6
2525 )
2626
2727 // ViewMode distinguishes history search from favorites display.
@@ -40,6 +40,13 @@ type Suggestion struct {
4040 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
4141 }
4242
43+// BangSuggestion represents a configured bang shortcut.
44+// A bang is invoked by typing "!<key>".
45+type BangSuggestion struct {
46+ Key string
47+ Description string
48+}
49+
4350 // Favorite represents a bookmarked URL.
4451 type Favorite struct {
4552 ID int64
@@ -58,6 +65,7 @@ type Omnibox struct {
5865 historyBtn *gtk.Button
5966 favoritesBtn *gtk.Button
6067 zoomLabel *gtk.Label
68+ bangBadge *gtk.Label
6169 entry *gtk.SearchEntry
6270 scrolledWin *gtk.ScrolledWindow
6371 listBox *gtk.ListBox
@@ -66,20 +74,23 @@ type Omnibox struct {
6674 parentOverlay layout.OverlayWidget
6775
6876 // State
69- mu sync.RWMutex
70- visible bool
71- viewMode ViewMode
72- selectedIndex int
73- suggestions []Suggestion
74- favorites []Favorite
75- hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
77+ mu sync.RWMutex
78+ visible bool
79+ viewMode ViewMode
80+ selectedIndex int
81+ suggestions []Suggestion
82+ favorites []Favorite
83+ bangSuggestions []BangSuggestion
84+ bangMode bool
85+ detectedBang string
86+ hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
7687
7788 // Dependencies
7889 historyUC *usecase.SearchHistoryUseCase
7990 favoritesUC *usecase.ManageFavoritesUseCase
8091 faviconAdapter *adapter.FaviconAdapter
8192 copyURLUC *usecase.CopyURLUseCase
82- shortcuts map[string]config.SearchShortcut
93+ shortcutsUC *usecase.SearchShortcutsUseCase
8394 defaultSearch string
8495 initialBehavior string
8596 ctx context.Context
@@ -115,7 +126,7 @@ type OmniboxConfig struct {
115126 FavoritesUC *usecase.ManageFavoritesUseCase
116127 FaviconAdapter *adapter.FaviconAdapter
117128 CopyURLUC *usecase.CopyURLUseCase
118- Shortcuts map[string]config.SearchShortcut
129+ ShortcutsUC *usecase.SearchShortcutsUseCase
119130 DefaultSearch string
120131 InitialBehavior string
121132 UIScale float64 // UI scale for favicon sizing
@@ -140,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
140151 favoritesUC: cfg.FavoritesUC,
141152 faviconAdapter: cfg.FaviconAdapter,
142153 copyURLUC: cfg.CopyURLUC,
143- shortcuts: cfg.Shortcuts,
154+ shortcutsUC: cfg.ShortcutsUC,
144155 defaultSearch: cfg.DefaultSearch,
145156 initialBehavior: cfg.InitialBehavior,
146157 onToast: cfg.OnToast,
@@ -351,12 +362,16 @@ func (o *Omnibox) initHeader() error {
351362 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
352363 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
353364
365+ o.bangBadge = gtk.NewLabel(nil)
366+ if o.bangBadge != nil {
367+ o.bangBadge.AddCssClass("omnibox-bang-badge")
368+ o.bangBadge.SetVisible(false)
369+ }
370+
354371 o.zoomLabel = gtk.NewLabel(nil)
355372 if o.zoomLabel != nil {
356373 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
357374 o.zoomLabel.SetVisible(false)
358- o.zoomLabel.SetHalign(gtk.AlignEndValue)
359- o.zoomLabel.SetHexpand(true)
360375 }
361376 return nil
362377 }
@@ -369,7 +384,7 @@ func (o *Omnibox) initEntry() error {
369384 o.entry.AddCssClass("omnibox-entry")
370385 o.entry.SetHexpand(true)
371386
372- placeholder := "Search history or enter URL..."
387+ placeholder := "Search history or enter URL… (! lists bangs)"
373388 o.entry.SetPlaceholderText(&placeholder)
374389 return nil
375390 }
@@ -413,8 +428,18 @@ func (o *Omnibox) initList() error {
413428 func (o *Omnibox) assembleWidgets() {
414429 o.headerBox.Append(&o.historyBtn.Widget)
415430 o.headerBox.Append(&o.favoritesBtn.Widget)
416- if o.zoomLabel != nil {
417- o.headerBox.Append(&o.zoomLabel.Widget)
431+
432+ endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
433+ if endBox != nil {
434+ endBox.SetHexpand(true)
435+ endBox.SetHalign(gtk.AlignEndValue)
436+ if o.bangBadge != nil {
437+ endBox.Append(&o.bangBadge.Widget)
438+ }
439+ if o.zoomLabel != nil {
440+ endBox.Append(&o.zoomLabel.Widget)
441+ }
442+ o.headerBox.Append(&endBox.Widget)
418443 }
419444
420445 o.scrolledWin.SetChild(&o.listBox.Widget)
@@ -578,6 +603,14 @@ func (o *Omnibox) performSearch() {
578603 o.lastQuery = query
579604 o.debounceMu.Unlock()
580605
606+ if strings.HasPrefix(query, "!") {
607+ o.updateBangDetection(query)
608+ o.loadBangSuggestions(query)
609+ return
610+ }
611+
612+ o.clearBangState()
613+
581614 if mode == ViewModeFavorites {
582615 o.loadFavorites(query)
583616 return
@@ -745,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) {
745778 }()
746779 }
747780
781+func (o *Omnibox) loadBangSuggestions(query string) {
782+ if o.shortcutsUC == nil {
783+ return
784+ }
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}
789+ }
790+ o.idleAddUpdateBangSuggestions(suggestions)
791+}
792+
793+func (o *Omnibox) updateBangDetection(query string) {
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 == "" {
800+ o.clearDetectedBang()
801+ return
802+ }
803+
804+ o.setDetectedBang(output.Key, output.Description)
805+}
806+
807+func (o *Omnibox) clearBangState() {
808+ o.mu.Lock()
809+ o.bangMode = false
810+ o.bangSuggestions = nil
811+ o.mu.Unlock()
812+
813+ o.clearDetectedBang()
814+}
815+
816+func (o *Omnibox) setDetectedBang(key, description string) {
817+ o.mu.Lock()
818+ o.detectedBang = key
819+ o.mu.Unlock()
820+
821+ if o.bangBadge == nil {
822+ return
823+ }
824+ label := description
825+ var cb glib.SourceFunc = func(uintptr) bool {
826+ o.entry.AddCssClass("omnibox-entry-bang-active")
827+ o.bangBadge.SetText(label)
828+ o.bangBadge.SetVisible(true)
829+ return false
830+ }
831+ glib.IdleAdd(&cb, 0)
832+}
833+
834+func (o *Omnibox) clearDetectedBang() {
835+ o.mu.Lock()
836+ o.detectedBang = ""
837+ o.mu.Unlock()
838+
839+ if o.bangBadge == nil {
840+ return
841+ }
842+ var cb glib.SourceFunc = func(uintptr) bool {
843+ o.entry.RemoveCssClass("omnibox-entry-bang-active")
844+ o.bangBadge.SetVisible(false)
845+ return false
846+ }
847+ glib.IdleAdd(&cb, 0)
848+}
849+
850+func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
851+ var cb glib.SourceFunc = func(uintptr) bool {
852+ o.updateBangSuggestions(suggestions)
853+ return false
854+ }
855+ glib.IdleAdd(&cb, 0)
856+}
857+
858+func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
859+ o.mu.Lock()
860+ o.bangMode = true
861+ o.bangSuggestions = suggestions
862+ o.selectedIndex = -1
863+ o.mu.Unlock()
864+
865+ o.rebuildList()
866+
867+ rowCount := len(suggestions)
868+ if o.scrolledWin != nil {
869+ o.scrolledWin.SetVisible(rowCount > 0)
870+ }
871+ o.resizeAndCenter(rowCount)
872+
873+ if rowCount > 0 {
874+ o.selectIndex(0)
875+ }
876+}
877+
748878 // updateSuggestions updates the list with history suggestions.
749879 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
750880 o.mu.Lock()
881+ o.bangMode = false
882+ o.bangSuggestions = nil
751883 o.suggestions = suggestions
752884 o.selectedIndex = -1
753885 o.mu.Unlock()
@@ -770,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
770902 // updateFavorites updates the list with favorites.
771903 func (o *Omnibox) updateFavorites(favorites []Favorite) {
772904 o.mu.Lock()
905+ o.bangMode = false
906+ o.bangSuggestions = nil
773907 o.favorites = favorites
774908 o.selectedIndex = -1
775909 o.mu.Unlock()
@@ -798,9 +932,18 @@ func (o *Omnibox) rebuildList() {
798932 mode := o.viewMode
799933 suggestions := o.suggestions
800934 favorites := o.favorites
935+ bangMode := o.bangMode
936+ bangSuggestions := o.bangSuggestions
801937 o.mu.RUnlock()
802938
803- if mode == ViewModeHistory {
939+ if bangMode {
940+ for i, b := range bangSuggestions {
941+ row := o.createBangRow(b, i)
942+ if row != nil {
943+ o.listBox.Append(&row.Widget)
944+ }
945+ }
946+ } else if mode == ViewModeHistory {
804947 for i, s := range suggestions {
805948 row := o.createSuggestionRow(s, i)
806949 if row != nil {
@@ -831,7 +974,9 @@ func (o *Omnibox) rebuildList() {
831974 // Re-trigger resize with accurate measurements
832975 o.mu.RLock()
833976 var count int
834- if o.viewMode == ViewModeHistory {
977+ if o.bangMode {
978+ count = len(o.bangSuggestions)
979+ } else if o.viewMode == ViewModeHistory {
835980 count = len(o.suggestions)
836981 } else {
837982 count = len(o.favorites)
@@ -872,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
8721017 }
8731018
8741019 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1020+// Uses rawURL for both favicon fetching and display.
8751021 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1022+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1023+}
1024+
1025+// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1026+// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1027+func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
8761028 row := gtk.NewListBoxRow()
8771029 if row == nil {
8781030 return nil
@@ -887,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
8871039 hbox.SetHexpand(true)
8881040
8891041 // Favicon image (vertically centered)
890- if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {
1042+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
8911043 favicon.SetValign(gtk.AlignCenterValue)
8921044 hbox.Append(&favicon.Widget)
8931045 }
@@ -903,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
9031055 // Title label (or URL if no title)
9041056 displayTitle := title
9051057 if displayTitle == "" {
906- displayTitle = rawURL
1058+ displayTitle = displayURL
9071059 }
9081060 titleLabel := gtk.NewLabel(nil)
9091061 if titleLabel != nil {
@@ -915,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
9151067 }
9161068
9171069 // URL label (only if title exists and differs from URL)
918- if title != "" && title != rawURL {
1070+ if title != "" && title != displayURL {
9191071 urlLabel := gtk.NewLabel(nil)
9201072 if urlLabel != nil {
921- urlLabel.SetText(rawURL)
1073+ urlLabel.SetText(displayURL)
9221074 urlLabel.AddCssClass("omnibox-suggestion-url")
9231075 urlLabel.SetHalign(gtk.AlignStartValue)
9241076 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
@@ -962,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
9621114 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
9631115 }
9641116
1117+func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1118+ // Pass description as URL param (displayed as secondary label) and empty
1119+ // faviconURL to skip async favicon fetching - bang rows use static icon only
1120+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1121+ if row != nil {
1122+ row.AddCssClass("omnibox-row-bang")
1123+ }
1124+ return row
1125+}
1126+
9651127 // selectIndex selects a row by index.
9661128 func (o *Omnibox) selectIndex(index int) {
9671129 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -978,8 +1140,11 @@ func (o *Omnibox) selectNext() {
9781140 o.mu.Lock()
9791141 current := o.selectedIndex
9801142 mode := o.viewMode
1143+ bangMode := o.bangMode
9811144 var maxIndex int
982- if mode == ViewModeHistory {
1145+ if bangMode {
1146+ maxIndex = len(o.bangSuggestions) - 1
1147+ } else if mode == ViewModeHistory {
9831148 maxIndex = len(o.suggestions) - 1
9841149 } else {
9851150 maxIndex = len(o.favorites) - 1
@@ -1003,8 +1168,11 @@ func (o *Omnibox) selectPrevious() {
10031168 o.mu.Lock()
10041169 current := o.selectedIndex
10051170 mode := o.viewMode
1171+ bangMode := o.bangMode
10061172 var maxIndex int
1007- if mode == ViewModeHistory {
1173+ if bangMode {
1174+ maxIndex = len(o.bangSuggestions) - 1
1175+ } else if mode == ViewModeHistory {
10081176 maxIndex = len(o.suggestions) - 1
10091177 } else {
10101178 maxIndex = len(o.favorites) - 1
@@ -1037,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() {
10371205 idx := o.selectedIndex
10381206 suggestions := o.suggestions
10391207 favorites := o.favorites
1208+ bangMode := o.bangMode
1209+ bangSuggestions := o.bangSuggestions
10401210 o.mu.RUnlock()
10411211
10421212 entryText := o.entry.GetText()
1213+
1214+ if bangMode {
1215+ // If user typed a full bang query, navigate using the bang URL.
1216+ if o.shortcutsUC != nil {
1217+ navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1218+ if navOutput.Valid {
1219+ targetURL := o.buildURL(navOutput.Text)
1220+ if targetURL == "" {
1221+ return
1222+ }
1223+ o.Hide(o.ctx)
1224+ if o.onNavigate != nil {
1225+ o.onNavigate(targetURL)
1226+ }
1227+ return
1228+ }
1229+ }
1230+
1231+ // Otherwise, Enter autocompletes the selected bang.
1232+ if idx >= 0 && idx < len(bangSuggestions) {
1233+ o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1234+ o.entry.SetPosition(-1)
1235+ return
1236+ }
1237+ }
1238+
10431239 var targetURL string
10441240
10451241 // Check if user typed a URL-like string (contains . and no spaces)
@@ -1214,16 +1410,11 @@ func (o *Omnibox) yankSelectedURL() {
12141410
12151411 // buildURL constructs a URL from text, handling search shortcuts.
12161412 func (o *Omnibox) buildURL(text string) string {
1217- return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)
1218-}
1219-
1220-// shortcutURLs returns a map of shortcut keys to URL templates.
1221-func (o *Omnibox) shortcutURLs() map[string]string {
1222- result := make(map[string]string, len(o.shortcuts))
1223- for key, shortcut := range o.shortcuts {
1224- result[key] = shortcut.URL
1413+ var shortcutURLs map[string]string
1414+ if o.shortcutsUC != nil {
1415+ shortcutURLs = o.shortcutsUC.ShortcutURLs()
12251416 }
1226- return result
1417+ return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
12271418 }
12281419
12291420 // toggleViewMode switches between history and favorites.
@@ -1330,6 +1521,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
13301521 o.outerBox.SetVisible(false)
13311522
13321523 // Clear state
1524+ o.clearBangState()
13331525 o.entry.SetText("")
13341526 o.listBox.RemoveAll()
13351527
@@ -10,7 +10,6 @@ import (
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/url"12 "github.com/bnema/dumber/internal/domain/url"
13- "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/dumber/internal/ui/adapter"14 "github.com/bnema/dumber/internal/ui/adapter"
16 "github.com/bnema/dumber/internal/ui/input"15 "github.com/bnema/dumber/internal/ui/input"
@@ -22,6 +21,7 @@ import (
22 21
23 const (22 const (
24 debounceDelayMs = 15023 debounceDelayMs = 150
24+ endBoxSpacing = 6
25 )25 )
26 26
27 // ViewMode distinguishes history search from favorites display.27 // ViewMode distinguishes history search from favorites display.
@@ -40,6 +40,13 @@ type Suggestion struct {
40 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite40 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
41 }41 }
42 42
43+// BangSuggestion represents a configured bang shortcut.
44+// A bang is invoked by typing "!<key>".
45+type BangSuggestion struct {
46+ Key string
47+ Description string
48+}
49+
43 // Favorite represents a bookmarked URL.50 // Favorite represents a bookmarked URL.
44 type Favorite struct {51 type Favorite struct {
45 ID int6452 ID int64
@@ -58,6 +65,7 @@ type Omnibox struct {
58 historyBtn *gtk.Button65 historyBtn *gtk.Button
59 favoritesBtn *gtk.Button66 favoritesBtn *gtk.Button
60 zoomLabel *gtk.Label67 zoomLabel *gtk.Label
68+ bangBadge *gtk.Label
61 entry *gtk.SearchEntry69 entry *gtk.SearchEntry
62 scrolledWin *gtk.ScrolledWindow70 scrolledWin *gtk.ScrolledWindow
63 listBox *gtk.ListBox71 listBox *gtk.ListBox
@@ -66,20 +74,23 @@ type Omnibox struct {
66 parentOverlay layout.OverlayWidget74 parentOverlay layout.OverlayWidget
67 75
68 // State76 // State
69- mu sync.RWMutex77+ mu sync.RWMutex
70- visible bool78+ visible bool
71- viewMode ViewMode79+ viewMode ViewMode
72- selectedIndex int80+ selectedIndex int
73- suggestions []Suggestion81+ suggestions []Suggestion
74- favorites []Favorite82+ favorites []Favorite
75- hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)83+ bangSuggestions []BangSuggestion
84+ bangMode bool
85+ detectedBang string
86+ hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
76 87
77 // Dependencies88 // Dependencies
78 historyUC *usecase.SearchHistoryUseCase89 historyUC *usecase.SearchHistoryUseCase
79 favoritesUC *usecase.ManageFavoritesUseCase90 favoritesUC *usecase.ManageFavoritesUseCase
80 faviconAdapter *adapter.FaviconAdapter91 faviconAdapter *adapter.FaviconAdapter
81 copyURLUC *usecase.CopyURLUseCase92 copyURLUC *usecase.CopyURLUseCase
82- shortcuts map[string]config.SearchShortcut93+ shortcutsUC *usecase.SearchShortcutsUseCase
83 defaultSearch string94 defaultSearch string
84 initialBehavior string95 initialBehavior string
85 ctx context.Context96 ctx context.Context
@@ -115,7 +126,7 @@ type OmniboxConfig struct {
115 FavoritesUC *usecase.ManageFavoritesUseCase126 FavoritesUC *usecase.ManageFavoritesUseCase
116 FaviconAdapter *adapter.FaviconAdapter127 FaviconAdapter *adapter.FaviconAdapter
117 CopyURLUC *usecase.CopyURLUseCase128 CopyURLUC *usecase.CopyURLUseCase
118- Shortcuts map[string]config.SearchShortcut129+ ShortcutsUC *usecase.SearchShortcutsUseCase
119 DefaultSearch string130 DefaultSearch string
120 InitialBehavior string131 InitialBehavior string
121 UIScale float64 // UI scale for favicon sizing132 UIScale float64 // UI scale for favicon sizing
@@ -140,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
140 favoritesUC: cfg.FavoritesUC,151 favoritesUC: cfg.FavoritesUC,
141 faviconAdapter: cfg.FaviconAdapter,152 faviconAdapter: cfg.FaviconAdapter,
142 copyURLUC: cfg.CopyURLUC,153 copyURLUC: cfg.CopyURLUC,
143- shortcuts: cfg.Shortcuts,154+ shortcutsUC: cfg.ShortcutsUC,
144 defaultSearch: cfg.DefaultSearch,155 defaultSearch: cfg.DefaultSearch,
145 initialBehavior: cfg.InitialBehavior,156 initialBehavior: cfg.InitialBehavior,
146 onToast: cfg.OnToast,157 onToast: cfg.OnToast,
@@ -351,12 +362,16 @@ func (o *Omnibox) initHeader() error {
351 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)362 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
352 o.favoritesBtn.ConnectClicked(&favoritesClickCb)363 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
353 364
365+ o.bangBadge = gtk.NewLabel(nil)
366+ if o.bangBadge != nil {
367+ o.bangBadge.AddCssClass("omnibox-bang-badge")
368+ o.bangBadge.SetVisible(false)
369+ }
370+
354 o.zoomLabel = gtk.NewLabel(nil)371 o.zoomLabel = gtk.NewLabel(nil)
355 if o.zoomLabel != nil {372 if o.zoomLabel != nil {
356 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")373 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
357 o.zoomLabel.SetVisible(false)374 o.zoomLabel.SetVisible(false)
358- o.zoomLabel.SetHalign(gtk.AlignEndValue)
359- o.zoomLabel.SetHexpand(true)
360 }375 }
361 return nil376 return nil
362 }377 }
@@ -369,7 +384,7 @@ func (o *Omnibox) initEntry() error {
369 o.entry.AddCssClass("omnibox-entry")384 o.entry.AddCssClass("omnibox-entry")
370 o.entry.SetHexpand(true)385 o.entry.SetHexpand(true)
371 386
372- placeholder := "Search history or enter URL..."387+ placeholder := "Search history or enter URL… (! lists bangs)"
373 o.entry.SetPlaceholderText(&placeholder)388 o.entry.SetPlaceholderText(&placeholder)
374 return nil389 return nil
375 }390 }
@@ -413,8 +428,18 @@ func (o *Omnibox) initList() error {
413 func (o *Omnibox) assembleWidgets() {428 func (o *Omnibox) assembleWidgets() {
414 o.headerBox.Append(&o.historyBtn.Widget)429 o.headerBox.Append(&o.historyBtn.Widget)
415 o.headerBox.Append(&o.favoritesBtn.Widget)430 o.headerBox.Append(&o.favoritesBtn.Widget)
416- if o.zoomLabel != nil {431+
417- o.headerBox.Append(&o.zoomLabel.Widget)432+ endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
433+ if endBox != nil {
434+ endBox.SetHexpand(true)
435+ endBox.SetHalign(gtk.AlignEndValue)
436+ if o.bangBadge != nil {
437+ endBox.Append(&o.bangBadge.Widget)
438+ }
439+ if o.zoomLabel != nil {
440+ endBox.Append(&o.zoomLabel.Widget)
441+ }
442+ o.headerBox.Append(&endBox.Widget)
418 }443 }
419 444
420 o.scrolledWin.SetChild(&o.listBox.Widget)445 o.scrolledWin.SetChild(&o.listBox.Widget)
@@ -578,6 +603,14 @@ func (o *Omnibox) performSearch() {
578 o.lastQuery = query603 o.lastQuery = query
579 o.debounceMu.Unlock()604 o.debounceMu.Unlock()
580 605
606+ if strings.HasPrefix(query, "!") {
607+ o.updateBangDetection(query)
608+ o.loadBangSuggestions(query)
609+ return
610+ }
611+
612+ o.clearBangState()
613+
581 if mode == ViewModeFavorites {614 if mode == ViewModeFavorites {
582 o.loadFavorites(query)615 o.loadFavorites(query)
583 return616 return
@@ -745,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) {
745 }()778 }()
746 }779 }
747 780
781+func (o *Omnibox) loadBangSuggestions(query string) {
782+ if o.shortcutsUC == nil {
783+ return
784+ }
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}
789+ }
790+ o.idleAddUpdateBangSuggestions(suggestions)
791+}
792+
793+func (o *Omnibox) updateBangDetection(query string) {
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 == "" {
800+ o.clearDetectedBang()
801+ return
802+ }
803+
804+ o.setDetectedBang(output.Key, output.Description)
805+}
806+
807+func (o *Omnibox) clearBangState() {
808+ o.mu.Lock()
809+ o.bangMode = false
810+ o.bangSuggestions = nil
811+ o.mu.Unlock()
812+
813+ o.clearDetectedBang()
814+}
815+
816+func (o *Omnibox) setDetectedBang(key, description string) {
817+ o.mu.Lock()
818+ o.detectedBang = key
819+ o.mu.Unlock()
820+
821+ if o.bangBadge == nil {
822+ return
823+ }
824+ label := description
825+ var cb glib.SourceFunc = func(uintptr) bool {
826+ o.entry.AddCssClass("omnibox-entry-bang-active")
827+ o.bangBadge.SetText(label)
828+ o.bangBadge.SetVisible(true)
829+ return false
830+ }
831+ glib.IdleAdd(&cb, 0)
832+}
833+
834+func (o *Omnibox) clearDetectedBang() {
835+ o.mu.Lock()
836+ o.detectedBang = ""
837+ o.mu.Unlock()
838+
839+ if o.bangBadge == nil {
840+ return
841+ }
842+ var cb glib.SourceFunc = func(uintptr) bool {
843+ o.entry.RemoveCssClass("omnibox-entry-bang-active")
844+ o.bangBadge.SetVisible(false)
845+ return false
846+ }
847+ glib.IdleAdd(&cb, 0)
848+}
849+
850+func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
851+ var cb glib.SourceFunc = func(uintptr) bool {
852+ o.updateBangSuggestions(suggestions)
853+ return false
854+ }
855+ glib.IdleAdd(&cb, 0)
856+}
857+
858+func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
859+ o.mu.Lock()
860+ o.bangMode = true
861+ o.bangSuggestions = suggestions
862+ o.selectedIndex = -1
863+ o.mu.Unlock()
864+
865+ o.rebuildList()
866+
867+ rowCount := len(suggestions)
868+ if o.scrolledWin != nil {
869+ o.scrolledWin.SetVisible(rowCount > 0)
870+ }
871+ o.resizeAndCenter(rowCount)
872+
873+ if rowCount > 0 {
874+ o.selectIndex(0)
875+ }
876+}
877+
748 // updateSuggestions updates the list with history suggestions.878 // updateSuggestions updates the list with history suggestions.
749 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {879 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
750 o.mu.Lock()880 o.mu.Lock()
881+ o.bangMode = false
882+ o.bangSuggestions = nil
751 o.suggestions = suggestions883 o.suggestions = suggestions
752 o.selectedIndex = -1884 o.selectedIndex = -1
753 o.mu.Unlock()885 o.mu.Unlock()
@@ -770,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
770 // updateFavorites updates the list with favorites.902 // updateFavorites updates the list with favorites.
771 func (o *Omnibox) updateFavorites(favorites []Favorite) {903 func (o *Omnibox) updateFavorites(favorites []Favorite) {
772 o.mu.Lock()904 o.mu.Lock()
905+ o.bangMode = false
906+ o.bangSuggestions = nil
773 o.favorites = favorites907 o.favorites = favorites
774 o.selectedIndex = -1908 o.selectedIndex = -1
775 o.mu.Unlock()909 o.mu.Unlock()
@@ -798,9 +932,18 @@ func (o *Omnibox) rebuildList() {
798 mode := o.viewMode932 mode := o.viewMode
799 suggestions := o.suggestions933 suggestions := o.suggestions
800 favorites := o.favorites934 favorites := o.favorites
935+ bangMode := o.bangMode
936+ bangSuggestions := o.bangSuggestions
801 o.mu.RUnlock()937 o.mu.RUnlock()
802 938
803- if mode == ViewModeHistory {939+ if bangMode {
940+ for i, b := range bangSuggestions {
941+ row := o.createBangRow(b, i)
942+ if row != nil {
943+ o.listBox.Append(&row.Widget)
944+ }
945+ }
946+ } else if mode == ViewModeHistory {
804 for i, s := range suggestions {947 for i, s := range suggestions {
805 row := o.createSuggestionRow(s, i)948 row := o.createSuggestionRow(s, i)
806 if row != nil {949 if row != nil {
@@ -831,7 +974,9 @@ func (o *Omnibox) rebuildList() {
831 // Re-trigger resize with accurate measurements974 // Re-trigger resize with accurate measurements
832 o.mu.RLock()975 o.mu.RLock()
833 var count int976 var count int
834- if o.viewMode == ViewModeHistory {977+ if o.bangMode {
978+ count = len(o.bangSuggestions)
979+ } else if o.viewMode == ViewModeHistory {
835 count = len(o.suggestions)980 count = len(o.suggestions)
836 } else {981 } else {
837 count = len(o.favorites)982 count = len(o.favorites)
@@ -872,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
872 }1017 }
873 1018
874 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.1019 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1020+// Uses rawURL for both favicon fetching and display.
875 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {1021 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1022+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1023+}
1024+
1025+// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1026+// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1027+func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
876 row := gtk.NewListBoxRow()1028 row := gtk.NewListBoxRow()
877 if row == nil {1029 if row == nil {
878 return nil1030 return nil
@@ -887,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
887 hbox.SetHexpand(true)1039 hbox.SetHexpand(true)
888 1040
889 // Favicon image (vertically centered)1041 // Favicon image (vertically centered)
890- if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {1042+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
891 favicon.SetValign(gtk.AlignCenterValue)1043 favicon.SetValign(gtk.AlignCenterValue)
892 hbox.Append(&favicon.Widget)1044 hbox.Append(&favicon.Widget)
893 }1045 }
@@ -903,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
903 // Title label (or URL if no title)1055 // Title label (or URL if no title)
904 displayTitle := title1056 displayTitle := title
905 if displayTitle == "" {1057 if displayTitle == "" {
906- displayTitle = rawURL1058+ displayTitle = displayURL
907 }1059 }
908 titleLabel := gtk.NewLabel(nil)1060 titleLabel := gtk.NewLabel(nil)
909 if titleLabel != nil {1061 if titleLabel != nil {
@@ -915,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
915 }1067 }
916 1068
917 // URL label (only if title exists and differs from URL)1069 // URL label (only if title exists and differs from URL)
918- if title != "" && title != rawURL {1070+ if title != "" && title != displayURL {
919 urlLabel := gtk.NewLabel(nil)1071 urlLabel := gtk.NewLabel(nil)
920 if urlLabel != nil {1072 if urlLabel != nil {
921- urlLabel.SetText(rawURL)1073+ urlLabel.SetText(displayURL)
922 urlLabel.AddCssClass("omnibox-suggestion-url")1074 urlLabel.AddCssClass("omnibox-suggestion-url")
923 urlLabel.SetHalign(gtk.AlignStartValue)1075 urlLabel.SetHalign(gtk.AlignStartValue)
924 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END1076 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
@@ -962,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
962 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)1114 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
963 }1115 }
964 1116
1117+func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1118+ // Pass description as URL param (displayed as secondary label) and empty
1119+ // faviconURL to skip async favicon fetching - bang rows use static icon only
1120+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1121+ if row != nil {
1122+ row.AddCssClass("omnibox-row-bang")
1123+ }
1124+ return row
1125+}
1126+
965 // selectIndex selects a row by index.1127 // selectIndex selects a row by index.
966 func (o *Omnibox) selectIndex(index int) {1128 func (o *Omnibox) selectIndex(index int) {
967 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb1129 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -978,8 +1140,11 @@ func (o *Omnibox) selectNext() {
978 o.mu.Lock()1140 o.mu.Lock()
979 current := o.selectedIndex1141 current := o.selectedIndex
980 mode := o.viewMode1142 mode := o.viewMode
1143+ bangMode := o.bangMode
981 var maxIndex int1144 var maxIndex int
982- if mode == ViewModeHistory {1145+ if bangMode {
1146+ maxIndex = len(o.bangSuggestions) - 1
1147+ } else if mode == ViewModeHistory {
983 maxIndex = len(o.suggestions) - 11148 maxIndex = len(o.suggestions) - 1
984 } else {1149 } else {
985 maxIndex = len(o.favorites) - 11150 maxIndex = len(o.favorites) - 1
@@ -1003,8 +1168,11 @@ func (o *Omnibox) selectPrevious() {
1003 o.mu.Lock()1168 o.mu.Lock()
1004 current := o.selectedIndex1169 current := o.selectedIndex
1005 mode := o.viewMode1170 mode := o.viewMode
1171+ bangMode := o.bangMode
1006 var maxIndex int1172 var maxIndex int
1007- if mode == ViewModeHistory {1173+ if bangMode {
1174+ maxIndex = len(o.bangSuggestions) - 1
1175+ } else if mode == ViewModeHistory {
1008 maxIndex = len(o.suggestions) - 11176 maxIndex = len(o.suggestions) - 1
1009 } else {1177 } else {
1010 maxIndex = len(o.favorites) - 11178 maxIndex = len(o.favorites) - 1
@@ -1037,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() {
1037 idx := o.selectedIndex1205 idx := o.selectedIndex
1038 suggestions := o.suggestions1206 suggestions := o.suggestions
1039 favorites := o.favorites1207 favorites := o.favorites
1208+ bangMode := o.bangMode
1209+ bangSuggestions := o.bangSuggestions
1040 o.mu.RUnlock()1210 o.mu.RUnlock()
1041 1211
1042 entryText := o.entry.GetText()1212 entryText := o.entry.GetText()
1213+
1214+ if bangMode {
1215+ // If user typed a full bang query, navigate using the bang URL.
1216+ if o.shortcutsUC != nil {
1217+ navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1218+ if navOutput.Valid {
1219+ targetURL := o.buildURL(navOutput.Text)
1220+ if targetURL == "" {
1221+ return
1222+ }
1223+ o.Hide(o.ctx)
1224+ if o.onNavigate != nil {
1225+ o.onNavigate(targetURL)
1226+ }
1227+ return
1228+ }
1229+ }
1230+
1231+ // Otherwise, Enter autocompletes the selected bang.
1232+ if idx >= 0 && idx < len(bangSuggestions) {
1233+ o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1234+ o.entry.SetPosition(-1)
1235+ return
1236+ }
1237+ }
1238+
1043 var targetURL string1239 var targetURL string
1044 1240
1045 // Check if user typed a URL-like string (contains . and no spaces)1241 // Check if user typed a URL-like string (contains . and no spaces)
@@ -1214,16 +1410,11 @@ func (o *Omnibox) yankSelectedURL() {
1214 1410
1215 // buildURL constructs a URL from text, handling search shortcuts.1411 // buildURL constructs a URL from text, handling search shortcuts.
1216 func (o *Omnibox) buildURL(text string) string {1412 func (o *Omnibox) buildURL(text string) string {
1217- return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)1413+ var shortcutURLs map[string]string
1218-}1414+ if o.shortcutsUC != nil {
1219-1415+ shortcutURLs = o.shortcutsUC.ShortcutURLs()
1220-// shortcutURLs returns a map of shortcut keys to URL templates.
1221-func (o *Omnibox) shortcutURLs() map[string]string {
1222- result := make(map[string]string, len(o.shortcuts))
1223- for key, shortcut := range o.shortcuts {
1224- result[key] = shortcut.URL
1225 }1416 }
1226- return result1417+ return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
1227 }1418 }
1228 1419
1229 // toggleViewMode switches between history and favorites.1420 // toggleViewMode switches between history and favorites.
@@ -1330,6 +1521,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
1330 o.outerBox.SetVisible(false)1521 o.outerBox.SetVisible(false)
1331 1522
1332 // Clear state1523 // Clear state
1524+ o.clearBangState()
1333 o.entry.SetText("")1525 o.entry.SetText("")
1334 o.listBox.RemoveAll()1526 o.listBox.RemoveAll()
1335 1527
M internal/ui/theme/css.go +23 -0
@@ -372,6 +372,16 @@ entry.omnibox-entry > *:focus-visible {
372372 color: var(--accent);
373373 }
374374
375+/* Bang indicator badge in omnibox header */
376+.omnibox-bang-badge {
377+ background-color: alpha(var(--accent), 0.18);
378+ color: var(--accent);
379+ border-radius: 0.25em;
380+ padding: 0.125em 0.5em;
381+ font-size: 0.6875em;
382+ font-weight: 600;
383+}
384+
375385 /* Zoom indicator in omnibox header */
376386 .omnibox-zoom-indicator {
377387 background-color: alpha(var(--accent), 0.2);
@@ -382,6 +392,19 @@ entry.omnibox-entry > *:focus-visible {
382392 font-weight: 600;
383393 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
384394 }
395+
396+entry.omnibox-entry.omnibox-entry-bang-active,
397+entry.omnibox-entry.omnibox-entry-bang-active:focus,
398+entry.omnibox-entry.omnibox-entry-bang-active:focus-within,
399+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
400+ border-color: var(--accent);
401+ background-color: shade(var(--bg), 1.05);
402+}
403+
404+.omnibox-row.omnibox-row-bang .omnibox-suggestion-title {
405+ color: var(--accent);
406+ font-weight: 600;
407+}
385408 `
386409 }
387410
@@ -372,6 +372,16 @@ entry.omnibox-entry > *:focus-visible {
372 color: var(--accent);372 color: var(--accent);
373 }373 }
374 374
375+/* Bang indicator badge in omnibox header */
376+.omnibox-bang-badge {
377+ background-color: alpha(var(--accent), 0.18);
378+ color: var(--accent);
379+ border-radius: 0.25em;
380+ padding: 0.125em 0.5em;
381+ font-size: 0.6875em;
382+ font-weight: 600;
383+}
384+
375 /* Zoom indicator in omnibox header */385 /* Zoom indicator in omnibox header */
376 .omnibox-zoom-indicator {386 .omnibox-zoom-indicator {
377 background-color: alpha(var(--accent), 0.2);387 background-color: alpha(var(--accent), 0.2);
@@ -382,6 +392,19 @@ entry.omnibox-entry > *:focus-visible {
382 font-weight: 600;392 font-weight: 600;
383 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */393 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
384 }394 }
395+
396+entry.omnibox-entry.omnibox-entry-bang-active,
397+entry.omnibox-entry.omnibox-entry-bang-active:focus,
398+entry.omnibox-entry.omnibox-entry-bang-active:focus-within,
399+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
400+ border-color: var(--accent);
401+ background-color: shade(var(--bg), 1.05);
402+}
403+
404+.omnibox-row.omnibox-row-bang .omnibox-suggestion-title {
405+ color: var(--accent);
406+ font-weight: 600;
407+}
385 `408 `
386 }409 }
387 410

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