detached f1f0461

test(omnibox): cover bang detection and navigation

commit
f1f04613360e2e4249830db87837a22e774b3670
author
brice · · 2025-12-28 14:31 UTC
parent
a934acc
signature
unsigned
2 files · +99 -20
M internal/ui/component/omnibox.go +44 -20
@@ -809,27 +809,58 @@ func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query stri
809809 return suggestions
810810 }
811811
812+func detectBangKey(shortcuts map[string]config.SearchShortcut, query string) string {
813+ spaceIdx := strings.Index(query, " ")
814+ if !strings.HasPrefix(query, "!") || spaceIdx <= 1 {
815+ return ""
816+ }
817+
818+ candidate := query[1:spaceIdx]
819+ for key := range shortcuts {
820+ if strings.EqualFold(key, candidate) {
821+ return key
822+ }
823+ }
824+
825+ return ""
826+}
827+
828+func normalizeBangKey(shortcuts map[string]config.SearchShortcut, shortcutKey string) (string, bool) {
829+ for key := range shortcuts {
830+ if strings.EqualFold(key, shortcutKey) {
831+ return key, true
832+ }
833+ }
834+ return "", false
835+}
836+
837+func buildBangNavigationText(shortcuts map[string]config.SearchShortcut, entryText string) string {
838+ shortcutKey, query, found := url.ParseBangShortcut(entryText)
839+ if !found || query == "" {
840+ return ""
841+ }
842+
843+ resolvedKey, ok := normalizeBangKey(shortcuts, shortcutKey)
844+ if !ok {
845+ return ""
846+ }
847+
848+ return "!" + resolvedKey + " " + query
849+}
850+
812851 func (o *Omnibox) loadBangSuggestions(query string) {
813852 suggestions := buildBangSuggestions(o.shortcuts, query)
814853 o.idleAddUpdateBangSuggestions(suggestions)
815854 }
816855
817856 func (o *Omnibox) updateBangDetection(query string) {
818- spaceIdx := strings.Index(query, " ")
819- if !strings.HasPrefix(query, "!") || spaceIdx <= 1 {
857+ key := detectBangKey(o.shortcuts, query)
858+ if key == "" {
820859 o.clearDetectedBang()
821860 return
822861 }
823862
824- candidate := query[1:spaceIdx]
825- for key := range o.shortcuts {
826- if strings.EqualFold(key, candidate) {
827- o.setDetectedBang(key)
828- return
829- }
830- }
831-
832- o.clearDetectedBang()
863+ o.setDetectedBang(key)
833864 }
834865
835866 func (o *Omnibox) clearBangState() {
@@ -1240,15 +1271,8 @@ func (o *Omnibox) navigateToSelected() {
12401271
12411272 if bangMode {
12421273 // If user typed a full bang query, navigate using the bang URL.
1243- if shortcutKey, query, found := url.ParseBangShortcut(entryText); found {
1244- resolvedKey := shortcutKey
1245- for key := range o.shortcuts {
1246- if strings.EqualFold(key, shortcutKey) {
1247- resolvedKey = key
1248- break
1249- }
1250- }
1251- targetURL := o.buildURL("!" + resolvedKey + " " + query)
1274+ if navText := buildBangNavigationText(o.shortcuts, entryText); navText != "" {
1275+ targetURL := o.buildURL(navText)
12521276 if targetURL == "" {
12531277 return
12541278 }
@@ -809,27 +809,58 @@ func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query stri
809 return suggestions809 return suggestions
810 }810 }
811 811
812+func detectBangKey(shortcuts map[string]config.SearchShortcut, query string) string {
813+ spaceIdx := strings.Index(query, " ")
814+ if !strings.HasPrefix(query, "!") || spaceIdx <= 1 {
815+ return ""
816+ }
817+
818+ candidate := query[1:spaceIdx]
819+ for key := range shortcuts {
820+ if strings.EqualFold(key, candidate) {
821+ return key
822+ }
823+ }
824+
825+ return ""
826+}
827+
828+func normalizeBangKey(shortcuts map[string]config.SearchShortcut, shortcutKey string) (string, bool) {
829+ for key := range shortcuts {
830+ if strings.EqualFold(key, shortcutKey) {
831+ return key, true
832+ }
833+ }
834+ return "", false
835+}
836+
837+func buildBangNavigationText(shortcuts map[string]config.SearchShortcut, entryText string) string {
838+ shortcutKey, query, found := url.ParseBangShortcut(entryText)
839+ if !found || query == "" {
840+ return ""
841+ }
842+
843+ resolvedKey, ok := normalizeBangKey(shortcuts, shortcutKey)
844+ if !ok {
845+ return ""
846+ }
847+
848+ return "!" + resolvedKey + " " + query
849+}
850+
812 func (o *Omnibox) loadBangSuggestions(query string) {851 func (o *Omnibox) loadBangSuggestions(query string) {
813 suggestions := buildBangSuggestions(o.shortcuts, query)852 suggestions := buildBangSuggestions(o.shortcuts, query)
814 o.idleAddUpdateBangSuggestions(suggestions)853 o.idleAddUpdateBangSuggestions(suggestions)
815 }854 }
816 855
817 func (o *Omnibox) updateBangDetection(query string) {856 func (o *Omnibox) updateBangDetection(query string) {
818- spaceIdx := strings.Index(query, " ")857+ key := detectBangKey(o.shortcuts, query)
819- if !strings.HasPrefix(query, "!") || spaceIdx <= 1 {858+ if key == "" {
820 o.clearDetectedBang()859 o.clearDetectedBang()
821 return860 return
822 }861 }
823 862
824- candidate := query[1:spaceIdx]863+ o.setDetectedBang(key)
825- for key := range o.shortcuts {
826- if strings.EqualFold(key, candidate) {
827- o.setDetectedBang(key)
828- return
829- }
830- }
831-
832- o.clearDetectedBang()
833 }864 }
834 865
835 func (o *Omnibox) clearBangState() {866 func (o *Omnibox) clearBangState() {
@@ -1240,15 +1271,8 @@ func (o *Omnibox) navigateToSelected() {
1240 1271
1241 if bangMode {1272 if bangMode {
1242 // If user typed a full bang query, navigate using the bang URL.1273 // If user typed a full bang query, navigate using the bang URL.
1243- if shortcutKey, query, found := url.ParseBangShortcut(entryText); found {1274+ if navText := buildBangNavigationText(o.shortcuts, entryText); navText != "" {
1244- resolvedKey := shortcutKey1275+ targetURL := o.buildURL(navText)
1245- for key := range o.shortcuts {
1246- if strings.EqualFold(key, shortcutKey) {
1247- resolvedKey = key
1248- break
1249- }
1250- }
1251- targetURL := o.buildURL("!" + resolvedKey + " " + query)
1252 if targetURL == "" {1276 if targetURL == "" {
1253 return1277 return
1254 }1278 }
M internal/ui/component/omnibox_bang_test.go +55 -0
@@ -71,3 +71,58 @@ func TestBuildBangSuggestions(t *testing.T) {
7171 })
7272 }
7373 }
74+
75+func TestDetectBangKey(t *testing.T) {
76+ shortcuts := map[string]config.SearchShortcut{
77+ "gh": {URL: "https://github.com/search?q=%s"},
78+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
79+ }
80+
81+ cases := []struct {
82+ name string
83+ query string
84+ want string
85+ }{
86+ {name: "no bang prefix", query: "gh test", want: ""},
87+ {name: "bang only has no space", query: "!gh", want: ""},
88+ {name: "space at position 1", query: "! test", want: ""},
89+ {name: "unknown bang", query: "!nope test", want: ""},
90+ {name: "case-insensitive match", query: "!GH test", want: "gh"},
91+ {name: "valid bang key", query: "!ddg test", want: "ddg"},
92+ }
93+
94+ for _, tc := range cases {
95+ t.Run(tc.name, func(t *testing.T) {
96+ if got := detectBangKey(shortcuts, tc.query); got != tc.want {
97+ t.Fatalf("detectBangKey(%q)=%q want=%q", tc.query, got, tc.want)
98+ }
99+ })
100+ }
101+}
102+
103+func TestBuildBangNavigationText(t *testing.T) {
104+ shortcuts := map[string]config.SearchShortcut{
105+ "gh": {URL: "https://github.com/search?q=%s"},
106+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
107+ }
108+
109+ cases := []struct {
110+ name string
111+ entryText string
112+ want string
113+ }{
114+ {name: "not a bang shortcut", entryText: "example.com", want: ""},
115+ {name: "bang key without query", entryText: "!gh ", want: ""},
116+ {name: "unknown bang key", entryText: "!nope test", want: ""},
117+ {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber"},
118+ {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query"},
119+ }
120+
121+ for _, tc := range cases {
122+ t.Run(tc.name, func(t *testing.T) {
123+ if got := buildBangNavigationText(shortcuts, tc.entryText); got != tc.want {
124+ t.Fatalf("buildBangNavigationText(%q)=%q want=%q", tc.entryText, got, tc.want)
125+ }
126+ })
127+ }
128+}
@@ -71,3 +71,58 @@ func TestBuildBangSuggestions(t *testing.T) {
71 })71 })
72 }72 }
73 }73 }
74+
75+func TestDetectBangKey(t *testing.T) {
76+ shortcuts := map[string]config.SearchShortcut{
77+ "gh": {URL: "https://github.com/search?q=%s"},
78+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
79+ }
80+
81+ cases := []struct {
82+ name string
83+ query string
84+ want string
85+ }{
86+ {name: "no bang prefix", query: "gh test", want: ""},
87+ {name: "bang only has no space", query: "!gh", want: ""},
88+ {name: "space at position 1", query: "! test", want: ""},
89+ {name: "unknown bang", query: "!nope test", want: ""},
90+ {name: "case-insensitive match", query: "!GH test", want: "gh"},
91+ {name: "valid bang key", query: "!ddg test", want: "ddg"},
92+ }
93+
94+ for _, tc := range cases {
95+ t.Run(tc.name, func(t *testing.T) {
96+ if got := detectBangKey(shortcuts, tc.query); got != tc.want {
97+ t.Fatalf("detectBangKey(%q)=%q want=%q", tc.query, got, tc.want)
98+ }
99+ })
100+ }
101+}
102+
103+func TestBuildBangNavigationText(t *testing.T) {
104+ shortcuts := map[string]config.SearchShortcut{
105+ "gh": {URL: "https://github.com/search?q=%s"},
106+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
107+ }
108+
109+ cases := []struct {
110+ name string
111+ entryText string
112+ want string
113+ }{
114+ {name: "not a bang shortcut", entryText: "example.com", want: ""},
115+ {name: "bang key without query", entryText: "!gh ", want: ""},
116+ {name: "unknown bang key", entryText: "!nope test", want: ""},
117+ {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber"},
118+ {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query"},
119+ }
120+
121+ for _, tc := range cases {
122+ t.Run(tc.name, func(t *testing.T) {
123+ if got := buildBangNavigationText(shortcuts, tc.entryText); got != tc.want {
124+ t.Fatalf("buildBangNavigationText(%q)=%q want=%q", tc.entryText, got, tc.want)
125+ }
126+ })
127+ }
128+}

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