detached 8de4070

feat(search): add bang shortcut syntax for search engines

commit
8de40706bad5ef26c00258586efb9fdcfaea84e8
author
bnema · · 2025-12-18 22:07 UTC
parent
d6a17f6
signature
unsigned
8 files · +372 -27
M README.md +2 -2
@@ -19,7 +19,7 @@ A dumb browser that works like your favorite terminal multiplexer.
1919 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
2020 - **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).
2121 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
22-- **Search shortcuts**: Quick search via aliases (e.g., `g:golang` for Google).
22+- **Search shortcuts**: Quick search via bangs (e.g., `!g golang` for Google, `!gi cats` for Google Images).
2323 - **Customizable themes**: Light and dark palettes with semantic color tokens.
2424 - **Persistent storage**: SQLite for history, zoom levels, and settings.
2525 - **Live configuration**: Single config file with hot reload when possible.
@@ -207,7 +207,7 @@ sudo apt install va-driver-all
207207 - `dumber browse https://example.com`
208208 - `dumber browse example.com` # scheme auto‑added
209209 - `dumber browse dumb://home` # built-in homepage with stats
210- - `dumber browse g:golang` # Google search via shortcut
210+ - `dumber browse "!g golang"` # Google search via bang shortcut
211211 - Show version information:
212212 - `dumber version` # display version, commit, and build date
213213 - Launcher integration (dmenu‑style examples with favicon support):
@@ -19,7 +19,7 @@ A dumb browser that works like your favorite terminal multiplexer.
19 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.19 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
20 - **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).20 - **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).
21 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.21 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
22-- **Search shortcuts**: Quick search via aliases (e.g., `g:golang` for Google).22+- **Search shortcuts**: Quick search via bangs (e.g., `!g golang` for Google, `!gi cats` for Google Images).
23 - **Customizable themes**: Light and dark palettes with semantic color tokens.23 - **Customizable themes**: Light and dark palettes with semantic color tokens.
24 - **Persistent storage**: SQLite for history, zoom levels, and settings.24 - **Persistent storage**: SQLite for history, zoom levels, and settings.
25 - **Live configuration**: Single config file with hot reload when possible.25 - **Live configuration**: Single config file with hot reload when possible.
@@ -207,7 +207,7 @@ sudo apt install va-driver-all
207 - `dumber browse https://example.com`207 - `dumber browse https://example.com`
208 - `dumber browse example.com` # scheme auto‑added208 - `dumber browse example.com` # scheme auto‑added
209 - `dumber browse dumb://home` # built-in homepage with stats209 - `dumber browse dumb://home` # built-in homepage with stats
210- - `dumber browse g:golang` # Google search via shortcut210+ - `dumber browse "!g golang"` # Google search via bang shortcut
211 - Show version information:211 - Show version information:
212 - `dumber version` # display version, commit, and build date212 - `dumber version` # display version, commit, and build date
213 - Launcher integration (dmenu‑style examples with favicon support):213 - Launcher integration (dmenu‑style examples with favicon support):
M docs/CONFIG.md +12 -1
@@ -47,8 +47,15 @@ default_search_engine = "https://duckduckgo.com/?q=%s"
4747 # default_search_engine = "https://search.brave.com/search?q=%s"
4848 ```
4949
50-**Default shortcuts:**
50+**Default shortcuts (usage: `!shortcut query`):**
5151 ```toml
52+# Examples:
53+# !ddg golang → DuckDuckGo search for "golang"
54+# !g rust tutorial → Google search for "rust tutorial"
55+# !gi cats → Google Images search for "cats"
56+# !gh opencode → GitHub search for "opencode"
57+# !yt music video → YouTube search for "music video"
58+
5259 [search_shortcuts.ddg]
5360 url = "https://duckduckgo.com/?q=%s"
5461 description = "DuckDuckGo search"
@@ -57,6 +64,10 @@ description = "DuckDuckGo search"
5764 url = "https://google.com/search?q=%s"
5865 description = "Google Search"
5966
67+[search_shortcuts.gi]
68+url = "https://google.com/search?tbm=isch&q=%s"
69+description = "Google Images"
70+
6071 [search_shortcuts.gh]
6172 url = "https://github.com/search?q=%s"
6273 description = "GitHub"
@@ -47,8 +47,15 @@ default_search_engine = "https://duckduckgo.com/?q=%s"
47 # default_search_engine = "https://search.brave.com/search?q=%s"47 # default_search_engine = "https://search.brave.com/search?q=%s"
48 ```48 ```
49 49
50-**Default shortcuts:**50+**Default shortcuts (usage: `!shortcut query`):**
51 ```toml51 ```toml
52+# Examples:
53+# !ddg golang → DuckDuckGo search for "golang"
54+# !g rust tutorial → Google search for "rust tutorial"
55+# !gi cats → Google Images search for "cats"
56+# !gh opencode → GitHub search for "opencode"
57+# !yt music video → YouTube search for "music video"
58+
52 [search_shortcuts.ddg]59 [search_shortcuts.ddg]
53 url = "https://duckduckgo.com/?q=%s"60 url = "https://duckduckgo.com/?q=%s"
54 description = "DuckDuckGo search"61 description = "DuckDuckGo search"
@@ -57,6 +64,10 @@ description = "DuckDuckGo search"
57 url = "https://google.com/search?q=%s"64 url = "https://google.com/search?q=%s"
58 description = "Google Search"65 description = "Google Search"
59 66
67+[search_shortcuts.gi]
68+url = "https://google.com/search?tbm=isch&q=%s"
69+description = "Google Images"
70+
60 [search_shortcuts.gh]71 [search_shortcuts.gh]
61 url = "https://github.com/search?q=%s"72 url = "https://github.com/search?q=%s"
62 description = "GitHub"73 description = "GitHub"
M internal/cli/cmd/dmenu.go +9 -2
@@ -159,7 +159,8 @@ func runDmenuSelect() error {
159159 }
160160
161161 // parseSelection extracts the URL from a dmenu selection.
162-// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe.
162+// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe,
163+// or a bang shortcut like "!g query" for search.
163164 func parseSelection(selection string) string {
164165 selection = strings.TrimSpace(selection)
165166
@@ -173,7 +174,13 @@ func parseSelection(selection string) string {
173174 return ""
174175 }
175176
176- // Add https:// scheme if not present (display was stripped of scheme)
177+ // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
178+ app := GetApp()
179+ if app != nil {
180+ return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
181+ }
182+
183+ // Fallback: add https:// scheme if no app context
177184 if !strings.Contains(selection, "://") {
178185 selection = "https://" + selection
179186 }
@@ -159,7 +159,8 @@ func runDmenuSelect() error {
159 }159 }
160 160
161 // parseSelection extracts the URL from a dmenu selection.161 // parseSelection extracts the URL from a dmenu selection.
162-// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe.162+// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe,
163+// or a bang shortcut like "!g query" for search.
163 func parseSelection(selection string) string {164 func parseSelection(selection string) string {
164 selection = strings.TrimSpace(selection)165 selection = strings.TrimSpace(selection)
165 166
@@ -173,7 +174,13 @@ func parseSelection(selection string) string {
173 return ""174 return ""
174 }175 }
175 176
176- // Add https:// scheme if not present (display was stripped of scheme)177+ // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
178+ app := GetApp()
179+ if app != nil {
180+ return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
181+ }
182+
183+ // Fallback: add https:// scheme if no app context
177 if !strings.Contains(selection, "://") {184 if !strings.Contains(selection, "://") {
178 selection = "https://" + selection185 selection = "https://" + selection
179 }186 }
A internal/domain/url/search.go +72 -0
@@ -0,0 +1,72 @@
1+package url
2+
3+import "strings"
4+
5+// ParseBangShortcut extracts a bang shortcut from input.
6+// Input must start with "!" followed by shortcut key and a space.
7+// Returns (shortcutKey, query, found).
8+//
9+// Examples:
10+//
11+// "!g golang" → ("g", "golang", true)
12+// "!ddg test" → ("ddg", "test", true)
13+// "!gh repo name" → ("gh", "repo name", true)
14+// "!g" → ("", "", false) - no query
15+// "plain text" → ("", "", false)
16+// "test !g" → ("", "", false) - bang not at start
17+func ParseBangShortcut(input string) (shortcut, query string, found bool) {
18+ if !strings.HasPrefix(input, "!") {
19+ return "", "", false
20+ }
21+
22+ // Find the space separating shortcut from query
23+ spaceIdx := strings.Index(input, " ")
24+ if spaceIdx == -1 || spaceIdx == 1 {
25+ // No space found or empty shortcut ("! query")
26+ return "", "", false
27+ }
28+
29+ shortcut = input[1:spaceIdx]
30+ query = strings.TrimSpace(input[spaceIdx+1:])
31+
32+ if query == "" {
33+ return "", "", false
34+ }
35+
36+ return shortcut, query, true
37+}
38+
39+// BuildSearchURL constructs a URL from user input, handling bang shortcuts.
40+// It checks for bang shortcuts first, then URL-like input, then falls back to default search.
41+//
42+// Parameters:
43+// - input: user input (e.g., "!g golang", "example.com", "search query")
44+// - shortcutURLs: map of shortcut keys to URL templates (e.g., {"g": "https://google.com/search?q=%s"})
45+// - defaultSearch: default search engine URL template (e.g., "https://duckduckgo.com/?q=%s")
46+//
47+// Returns the resolved URL.
48+func BuildSearchURL(input string, shortcutURLs map[string]string, defaultSearch string) string {
49+ if input == "" {
50+ return ""
51+ }
52+
53+ // Check for bang shortcut (e.g., "!g query")
54+ if shortcutKey, query, found := ParseBangShortcut(input); found {
55+ if urlTemplate, ok := shortcutURLs[shortcutKey]; ok {
56+ return strings.Replace(urlTemplate, "%s", query, 1)
57+ }
58+ // Unknown bang falls through to default search with original input
59+ }
60+
61+ // Check if it looks like a URL
62+ if LooksLikeURL(input) {
63+ return Normalize(input)
64+ }
65+
66+ // Use default search
67+ if defaultSearch != "" {
68+ return strings.Replace(defaultSearch, "%s", input, 1)
69+ }
70+
71+ return input
72+}
@@ -0,0 +1,72 @@
1+package url
2+
3+import "strings"
4+
5+// ParseBangShortcut extracts a bang shortcut from input.
6+// Input must start with "!" followed by shortcut key and a space.
7+// Returns (shortcutKey, query, found).
8+//
9+// Examples:
10+//
11+// "!g golang" → ("g", "golang", true)
12+// "!ddg test" → ("ddg", "test", true)
13+// "!gh repo name" → ("gh", "repo name", true)
14+// "!g" → ("", "", false) - no query
15+// "plain text" → ("", "", false)
16+// "test !g" → ("", "", false) - bang not at start
17+func ParseBangShortcut(input string) (shortcut, query string, found bool) {
18+ if !strings.HasPrefix(input, "!") {
19+ return "", "", false
20+ }
21+
22+ // Find the space separating shortcut from query
23+ spaceIdx := strings.Index(input, " ")
24+ if spaceIdx == -1 || spaceIdx == 1 {
25+ // No space found or empty shortcut ("! query")
26+ return "", "", false
27+ }
28+
29+ shortcut = input[1:spaceIdx]
30+ query = strings.TrimSpace(input[spaceIdx+1:])
31+
32+ if query == "" {
33+ return "", "", false
34+ }
35+
36+ return shortcut, query, true
37+}
38+
39+// BuildSearchURL constructs a URL from user input, handling bang shortcuts.
40+// It checks for bang shortcuts first, then URL-like input, then falls back to default search.
41+//
42+// Parameters:
43+// - input: user input (e.g., "!g golang", "example.com", "search query")
44+// - shortcutURLs: map of shortcut keys to URL templates (e.g., {"g": "https://google.com/search?q=%s"})
45+// - defaultSearch: default search engine URL template (e.g., "https://duckduckgo.com/?q=%s")
46+//
47+// Returns the resolved URL.
48+func BuildSearchURL(input string, shortcutURLs map[string]string, defaultSearch string) string {
49+ if input == "" {
50+ return ""
51+ }
52+
53+ // Check for bang shortcut (e.g., "!g query")
54+ if shortcutKey, query, found := ParseBangShortcut(input); found {
55+ if urlTemplate, ok := shortcutURLs[shortcutKey]; ok {
56+ return strings.Replace(urlTemplate, "%s", query, 1)
57+ }
58+ // Unknown bang falls through to default search with original input
59+ }
60+
61+ // Check if it looks like a URL
62+ if LooksLikeURL(input) {
63+ return Normalize(input)
64+ }
65+
66+ // Use default search
67+ if defaultSearch != "" {
68+ return strings.Replace(defaultSearch, "%s", input, 1)
69+ }
70+
71+ return input
72+}
A internal/domain/url/search_test.go +255 -0
@@ -0,0 +1,255 @@
1+package url
2+
3+import "testing"
4+
5+// Test shortcuts for BuildSearchURL tests
6+var testShortcuts = map[string]string{
7+ "g": "https://google.com/search?q=%s",
8+ "ddg": "https://duckduckgo.com/?q=%s",
9+ "gh": "https://github.com/search?q=%s",
10+ "gi": "https://google.com/search?tbm=isch&q=%s",
11+}
12+
13+const testDefaultSearch = "https://duckduckgo.com/?q=%s"
14+
15+func TestBuildSearchURL(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ input string
19+ shortcuts map[string]string
20+ defaultSearch string
21+ want string
22+ }{
23+ {
24+ name: "bang shortcut google",
25+ input: "!g golang",
26+ shortcuts: testShortcuts,
27+ defaultSearch: testDefaultSearch,
28+ want: "https://google.com/search?q=golang",
29+ },
30+ {
31+ name: "bang shortcut duckduckgo multi-word",
32+ input: "!ddg rust async await",
33+ shortcuts: testShortcuts,
34+ defaultSearch: testDefaultSearch,
35+ want: "https://duckduckgo.com/?q=rust async await",
36+ },
37+ {
38+ name: "bang shortcut google images",
39+ input: "!gi cats",
40+ shortcuts: testShortcuts,
41+ defaultSearch: testDefaultSearch,
42+ want: "https://google.com/search?tbm=isch&q=cats",
43+ },
44+ {
45+ name: "unknown bang falls back to default search",
46+ input: "!unknown test query",
47+ shortcuts: testShortcuts,
48+ defaultSearch: testDefaultSearch,
49+ want: "https://duckduckgo.com/?q=!unknown test query",
50+ },
51+ {
52+ name: "url-like input gets normalized",
53+ input: "example.com",
54+ shortcuts: testShortcuts,
55+ defaultSearch: testDefaultSearch,
56+ want: "https://example.com",
57+ },
58+ {
59+ name: "url with path",
60+ input: "github.com/user/repo",
61+ shortcuts: testShortcuts,
62+ defaultSearch: testDefaultSearch,
63+ want: "https://github.com/user/repo",
64+ },
65+ {
66+ name: "url with scheme unchanged",
67+ input: "https://example.com",
68+ shortcuts: testShortcuts,
69+ defaultSearch: testDefaultSearch,
70+ want: "https://example.com",
71+ },
72+ {
73+ name: "plain search query uses default",
74+ input: "how to parse json",
75+ shortcuts: testShortcuts,
76+ defaultSearch: testDefaultSearch,
77+ want: "https://duckduckgo.com/?q=how to parse json",
78+ },
79+ {
80+ name: "empty input returns empty",
81+ input: "",
82+ shortcuts: testShortcuts,
83+ defaultSearch: testDefaultSearch,
84+ want: "",
85+ },
86+ {
87+ name: "no shortcuts uses default for bang",
88+ input: "!g test",
89+ shortcuts: nil,
90+ defaultSearch: testDefaultSearch,
91+ want: "https://duckduckgo.com/?q=!g test",
92+ },
93+ {
94+ name: "no default search returns input for plain query",
95+ input: "plain query",
96+ shortcuts: testShortcuts,
97+ defaultSearch: "",
98+ want: "plain query",
99+ },
100+ {
101+ name: "bang without query treated as search",
102+ input: "!g",
103+ shortcuts: testShortcuts,
104+ defaultSearch: testDefaultSearch,
105+ want: "https://duckduckgo.com/?q=!g",
106+ },
107+ }
108+
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ got := BuildSearchURL(tt.input, tt.shortcuts, tt.defaultSearch)
112+ if got != tt.want {
113+ t.Errorf("BuildSearchURL() = %q, want %q", got, tt.want)
114+ }
115+ })
116+ }
117+}
118+
119+func TestParseBangShortcut(t *testing.T) {
120+ tests := []struct {
121+ name string
122+ input string
123+ wantShortcut string
124+ wantQuery string
125+ wantFound bool
126+ }{
127+ {
128+ name: "simple google search",
129+ input: "!g golang",
130+ wantShortcut: "g",
131+ wantQuery: "golang",
132+ wantFound: true,
133+ },
134+ {
135+ name: "duckduckgo multi-word query",
136+ input: "!ddg rust async await",
137+ wantShortcut: "ddg",
138+ wantQuery: "rust async await",
139+ wantFound: true,
140+ },
141+ {
142+ name: "github search",
143+ input: "!gh opencode cli",
144+ wantShortcut: "gh",
145+ wantQuery: "opencode cli",
146+ wantFound: true,
147+ },
148+ {
149+ name: "google images",
150+ input: "!gi cats",
151+ wantShortcut: "gi",
152+ wantQuery: "cats",
153+ wantFound: true,
154+ },
155+ {
156+ name: "youtube search",
157+ input: "!yt music video",
158+ wantShortcut: "yt",
159+ wantQuery: "music video",
160+ wantFound: true,
161+ },
162+ {
163+ name: "no query after shortcut",
164+ input: "!g",
165+ wantShortcut: "",
166+ wantQuery: "",
167+ wantFound: false,
168+ },
169+ {
170+ name: "empty query after space",
171+ input: "!g ",
172+ wantShortcut: "",
173+ wantQuery: "",
174+ wantFound: false,
175+ },
176+ {
177+ name: "multiple spaces only",
178+ input: "!g ",
179+ wantShortcut: "",
180+ wantQuery: "",
181+ wantFound: false,
182+ },
183+ {
184+ name: "empty shortcut",
185+ input: "! query",
186+ wantShortcut: "",
187+ wantQuery: "",
188+ wantFound: false,
189+ },
190+ {
191+ name: "plain text no bang",
192+ input: "plain text",
193+ wantShortcut: "",
194+ wantQuery: "",
195+ wantFound: false,
196+ },
197+ {
198+ name: "bang at end not supported",
199+ input: "query !g",
200+ wantShortcut: "",
201+ wantQuery: "",
202+ wantFound: false,
203+ },
204+ {
205+ name: "url-like input",
206+ input: "example.com",
207+ wantShortcut: "",
208+ wantQuery: "",
209+ wantFound: false,
210+ },
211+ {
212+ name: "empty input",
213+ input: "",
214+ wantShortcut: "",
215+ wantQuery: "",
216+ wantFound: false,
217+ },
218+ {
219+ name: "just exclamation mark",
220+ input: "!",
221+ wantShortcut: "",
222+ wantQuery: "",
223+ wantFound: false,
224+ },
225+ {
226+ name: "query with extra spaces trimmed",
227+ input: "!g lots of spaces ",
228+ wantShortcut: "g",
229+ wantQuery: "lots of spaces",
230+ wantFound: true,
231+ },
232+ {
233+ name: "long shortcut key",
234+ input: "!stackoverflow how to parse json",
235+ wantShortcut: "stackoverflow",
236+ wantQuery: "how to parse json",
237+ wantFound: true,
238+ },
239+ }
240+
241+ for _, tt := range tests {
242+ t.Run(tt.name, func(t *testing.T) {
243+ gotShortcut, gotQuery, gotFound := ParseBangShortcut(tt.input)
244+ if gotShortcut != tt.wantShortcut {
245+ t.Errorf("ParseBangShortcut() shortcut = %q, want %q", gotShortcut, tt.wantShortcut)
246+ }
247+ if gotQuery != tt.wantQuery {
248+ t.Errorf("ParseBangShortcut() query = %q, want %q", gotQuery, tt.wantQuery)
249+ }
250+ if gotFound != tt.wantFound {
251+ t.Errorf("ParseBangShortcut() found = %v, want %v", gotFound, tt.wantFound)
252+ }
253+ })
254+ }
255+}
@@ -0,0 +1,255 @@
1+package url
2+
3+import "testing"
4+
5+// Test shortcuts for BuildSearchURL tests
6+var testShortcuts = map[string]string{
7+ "g": "https://google.com/search?q=%s",
8+ "ddg": "https://duckduckgo.com/?q=%s",
9+ "gh": "https://github.com/search?q=%s",
10+ "gi": "https://google.com/search?tbm=isch&q=%s",
11+}
12+
13+const testDefaultSearch = "https://duckduckgo.com/?q=%s"
14+
15+func TestBuildSearchURL(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ input string
19+ shortcuts map[string]string
20+ defaultSearch string
21+ want string
22+ }{
23+ {
24+ name: "bang shortcut google",
25+ input: "!g golang",
26+ shortcuts: testShortcuts,
27+ defaultSearch: testDefaultSearch,
28+ want: "https://google.com/search?q=golang",
29+ },
30+ {
31+ name: "bang shortcut duckduckgo multi-word",
32+ input: "!ddg rust async await",
33+ shortcuts: testShortcuts,
34+ defaultSearch: testDefaultSearch,
35+ want: "https://duckduckgo.com/?q=rust async await",
36+ },
37+ {
38+ name: "bang shortcut google images",
39+ input: "!gi cats",
40+ shortcuts: testShortcuts,
41+ defaultSearch: testDefaultSearch,
42+ want: "https://google.com/search?tbm=isch&q=cats",
43+ },
44+ {
45+ name: "unknown bang falls back to default search",
46+ input: "!unknown test query",
47+ shortcuts: testShortcuts,
48+ defaultSearch: testDefaultSearch,
49+ want: "https://duckduckgo.com/?q=!unknown test query",
50+ },
51+ {
52+ name: "url-like input gets normalized",
53+ input: "example.com",
54+ shortcuts: testShortcuts,
55+ defaultSearch: testDefaultSearch,
56+ want: "https://example.com",
57+ },
58+ {
59+ name: "url with path",
60+ input: "github.com/user/repo",
61+ shortcuts: testShortcuts,
62+ defaultSearch: testDefaultSearch,
63+ want: "https://github.com/user/repo",
64+ },
65+ {
66+ name: "url with scheme unchanged",
67+ input: "https://example.com",
68+ shortcuts: testShortcuts,
69+ defaultSearch: testDefaultSearch,
70+ want: "https://example.com",
71+ },
72+ {
73+ name: "plain search query uses default",
74+ input: "how to parse json",
75+ shortcuts: testShortcuts,
76+ defaultSearch: testDefaultSearch,
77+ want: "https://duckduckgo.com/?q=how to parse json",
78+ },
79+ {
80+ name: "empty input returns empty",
81+ input: "",
82+ shortcuts: testShortcuts,
83+ defaultSearch: testDefaultSearch,
84+ want: "",
85+ },
86+ {
87+ name: "no shortcuts uses default for bang",
88+ input: "!g test",
89+ shortcuts: nil,
90+ defaultSearch: testDefaultSearch,
91+ want: "https://duckduckgo.com/?q=!g test",
92+ },
93+ {
94+ name: "no default search returns input for plain query",
95+ input: "plain query",
96+ shortcuts: testShortcuts,
97+ defaultSearch: "",
98+ want: "plain query",
99+ },
100+ {
101+ name: "bang without query treated as search",
102+ input: "!g",
103+ shortcuts: testShortcuts,
104+ defaultSearch: testDefaultSearch,
105+ want: "https://duckduckgo.com/?q=!g",
106+ },
107+ }
108+
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ got := BuildSearchURL(tt.input, tt.shortcuts, tt.defaultSearch)
112+ if got != tt.want {
113+ t.Errorf("BuildSearchURL() = %q, want %q", got, tt.want)
114+ }
115+ })
116+ }
117+}
118+
119+func TestParseBangShortcut(t *testing.T) {
120+ tests := []struct {
121+ name string
122+ input string
123+ wantShortcut string
124+ wantQuery string
125+ wantFound bool
126+ }{
127+ {
128+ name: "simple google search",
129+ input: "!g golang",
130+ wantShortcut: "g",
131+ wantQuery: "golang",
132+ wantFound: true,
133+ },
134+ {
135+ name: "duckduckgo multi-word query",
136+ input: "!ddg rust async await",
137+ wantShortcut: "ddg",
138+ wantQuery: "rust async await",
139+ wantFound: true,
140+ },
141+ {
142+ name: "github search",
143+ input: "!gh opencode cli",
144+ wantShortcut: "gh",
145+ wantQuery: "opencode cli",
146+ wantFound: true,
147+ },
148+ {
149+ name: "google images",
150+ input: "!gi cats",
151+ wantShortcut: "gi",
152+ wantQuery: "cats",
153+ wantFound: true,
154+ },
155+ {
156+ name: "youtube search",
157+ input: "!yt music video",
158+ wantShortcut: "yt",
159+ wantQuery: "music video",
160+ wantFound: true,
161+ },
162+ {
163+ name: "no query after shortcut",
164+ input: "!g",
165+ wantShortcut: "",
166+ wantQuery: "",
167+ wantFound: false,
168+ },
169+ {
170+ name: "empty query after space",
171+ input: "!g ",
172+ wantShortcut: "",
173+ wantQuery: "",
174+ wantFound: false,
175+ },
176+ {
177+ name: "multiple spaces only",
178+ input: "!g ",
179+ wantShortcut: "",
180+ wantQuery: "",
181+ wantFound: false,
182+ },
183+ {
184+ name: "empty shortcut",
185+ input: "! query",
186+ wantShortcut: "",
187+ wantQuery: "",
188+ wantFound: false,
189+ },
190+ {
191+ name: "plain text no bang",
192+ input: "plain text",
193+ wantShortcut: "",
194+ wantQuery: "",
195+ wantFound: false,
196+ },
197+ {
198+ name: "bang at end not supported",
199+ input: "query !g",
200+ wantShortcut: "",
201+ wantQuery: "",
202+ wantFound: false,
203+ },
204+ {
205+ name: "url-like input",
206+ input: "example.com",
207+ wantShortcut: "",
208+ wantQuery: "",
209+ wantFound: false,
210+ },
211+ {
212+ name: "empty input",
213+ input: "",
214+ wantShortcut: "",
215+ wantQuery: "",
216+ wantFound: false,
217+ },
218+ {
219+ name: "just exclamation mark",
220+ input: "!",
221+ wantShortcut: "",
222+ wantQuery: "",
223+ wantFound: false,
224+ },
225+ {
226+ name: "query with extra spaces trimmed",
227+ input: "!g lots of spaces ",
228+ wantShortcut: "g",
229+ wantQuery: "lots of spaces",
230+ wantFound: true,
231+ },
232+ {
233+ name: "long shortcut key",
234+ input: "!stackoverflow how to parse json",
235+ wantShortcut: "stackoverflow",
236+ wantQuery: "how to parse json",
237+ wantFound: true,
238+ },
239+ }
240+
241+ for _, tt := range tests {
242+ t.Run(tt.name, func(t *testing.T) {
243+ gotShortcut, gotQuery, gotFound := ParseBangShortcut(tt.input)
244+ if gotShortcut != tt.wantShortcut {
245+ t.Errorf("ParseBangShortcut() shortcut = %q, want %q", gotShortcut, tt.wantShortcut)
246+ }
247+ if gotQuery != tt.wantQuery {
248+ t.Errorf("ParseBangShortcut() query = %q, want %q", gotQuery, tt.wantQuery)
249+ }
250+ if gotFound != tt.wantFound {
251+ t.Errorf("ParseBangShortcut() found = %v, want %v", gotFound, tt.wantFound)
252+ }
253+ })
254+ }
255+}
M internal/infrastructure/config/defaults.go +4 -0
@@ -205,6 +205,10 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut {
205205 URL: "https://www.google.com/search?q=%s",
206206 Description: "Google search",
207207 },
208+ "gi": {
209+ URL: "https://www.google.com/search?tbm=isch&q=%s",
210+ Description: "Google Images search",
211+ },
208212 "gh": {
209213 URL: "https://github.com/search?q=%s",
210214 Description: "GitHub search",
@@ -205,6 +205,10 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut {
205 URL: "https://www.google.com/search?q=%s",205 URL: "https://www.google.com/search?q=%s",
206 Description: "Google search",206 Description: "Google search",
207 },207 },
208+ "gi": {
209+ URL: "https://www.google.com/search?tbm=isch&q=%s",
210+ Description: "Google Images search",
211+ },
208 "gh": {212 "gh": {
209 URL: "https://github.com/search?q=%s",213 URL: "https://github.com/search?q=%s",
210 Description: "GitHub search",214 Description: "GitHub search",
M internal/infrastructure/config/schema.go +10 -0
@@ -80,6 +80,16 @@ type SearchShortcut struct {
8080 Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
8181 }
8282
83+// ShortcutURLs returns a simple map of shortcut keys to URL templates.
84+// This is useful for passing to url.BuildSearchURL.
85+func (c *Config) ShortcutURLs() map[string]string {
86+ result := make(map[string]string, len(c.SearchShortcuts))
87+ for key, shortcut := range c.SearchShortcuts {
88+ result[key] = shortcut.URL
89+ }
90+ return result
91+}
92+
8393 // DmenuConfig holds dmenu/rofi integration configuration.
8494 type DmenuConfig struct {
8595 MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
@@ -80,6 +80,16 @@ type SearchShortcut struct {
80 Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`80 Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
81 }81 }
82 82
83+// ShortcutURLs returns a simple map of shortcut keys to URL templates.
84+// This is useful for passing to url.BuildSearchURL.
85+func (c *Config) ShortcutURLs() map[string]string {
86+ result := make(map[string]string, len(c.SearchShortcuts))
87+ for key, shortcut := range c.SearchShortcuts {
88+ result[key] = shortcut.URL
89+ }
90+ return result
91+}
92+
83 // DmenuConfig holds dmenu/rofi integration configuration.93 // DmenuConfig holds dmenu/rofi integration configuration.
84 type DmenuConfig struct {94 type DmenuConfig struct {
85 MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`95 MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
M internal/ui/component/omnibox.go +8 -22
@@ -1122,30 +1122,16 @@ func (o *Omnibox) yankSelectedURL() {
11221122
11231123 // buildURL constructs a URL from input, handling search shortcuts.
11241124 func (o *Omnibox) buildURL(input string) string {
1125- if input == "" {
1126- return ""
1127- }
1128-
1129- // Check for search shortcut (e.g., "g:query")
1130- if colonIdx := strings.Index(input, ":"); colonIdx > 0 && colonIdx < 10 {
1131- prefix := input[:colonIdx]
1132- if shortcut, ok := o.shortcuts[prefix]; ok {
1133- query := strings.TrimSpace(input[colonIdx+1:])
1134- return strings.Replace(shortcut.URL, "%s", query, 1)
1135- }
1136- }
1137-
1138- // Check if it looks like a URL - use shared URL normalization
1139- if url.LooksLikeURL(input) {
1140- return url.Normalize(input)
1141- }
1125+ return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)
1126+}
11421127
1143- // Use default search
1144- if o.defaultSearch != "" {
1145- return strings.Replace(o.defaultSearch, "%s", input, 1)
1128+// shortcutURLs returns a map of shortcut keys to URL templates.
1129+func (o *Omnibox) shortcutURLs() map[string]string {
1130+ result := make(map[string]string, len(o.shortcuts))
1131+ for key, shortcut := range o.shortcuts {
1132+ result[key] = shortcut.URL
11461133 }
1147-
1148- return input
1134+ return result
11491135 }
11501136
11511137 // toggleViewMode switches between history and favorites.
@@ -1122,30 +1122,16 @@ func (o *Omnibox) yankSelectedURL() {
1122 1122
1123 // buildURL constructs a URL from input, handling search shortcuts.1123 // buildURL constructs a URL from input, handling search shortcuts.
1124 func (o *Omnibox) buildURL(input string) string {1124 func (o *Omnibox) buildURL(input string) string {
1125- if input == "" {1125+ return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)
1126- return ""1126+}
1127- }
1128-
1129- // Check for search shortcut (e.g., "g:query")
1130- if colonIdx := strings.Index(input, ":"); colonIdx > 0 && colonIdx < 10 {
1131- prefix := input[:colonIdx]
1132- if shortcut, ok := o.shortcuts[prefix]; ok {
1133- query := strings.TrimSpace(input[colonIdx+1:])
1134- return strings.Replace(shortcut.URL, "%s", query, 1)
1135- }
1136- }
1137-
1138- // Check if it looks like a URL - use shared URL normalization
1139- if url.LooksLikeURL(input) {
1140- return url.Normalize(input)
1141- }
1142 1127
1143- // Use default search1128+// shortcutURLs returns a map of shortcut keys to URL templates.
1144- if o.defaultSearch != "" {1129+func (o *Omnibox) shortcutURLs() map[string]string {
1145- return strings.Replace(o.defaultSearch, "%s", input, 1)1130+ result := make(map[string]string, len(o.shortcuts))
1131+ for key, shortcut := range o.shortcuts {
1132+ result[key] = shortcut.URL
1146 }1133 }
1147-1134+ return result
1148- return input
1149 }1135 }
1150 1136
1151 // toggleViewMode switches between history and favorites.1137 // 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