detached 606e951

fix(shortcuts): align backend JSON and placeholder handling\n\n- Add json tags to SearchShortcut (url, description)\n- Frontend normalizes field case and supports %s/{query} templates\n- Parser URL-encodes query and supports %s in templates

commit
606e9515edceb5f72a4384c6c5670d57e1a311b4
author
bamen · · 2025-09-09 16:17 UTC
committer
bamen · · 2025-09-09 19:30 UTC
parent
c0744da
signature
unsigned
3 files · +45 -28
M frontend/src/services/app.ts +11 -1
@@ -63,7 +63,17 @@ export class AppService {
6363 async loadShortcuts(): Promise<void> {
6464 try {
6565 const cfg = await fetch('/api/config').then(r => r.json());
66- this.shortcuts = cfg?.search_shortcuts || {};
66+ const raw = cfg?.search_shortcuts || {};
67+ // Normalize field casing from backend (supports URL/Description and url/description)
68+ const normalized: Record<string, { description: string; url: string }> = {};
69+ for (const [key, value] of Object.entries(raw)) {
70+ const v: any = value as any;
71+ normalized[key] = {
72+ description: v.description ?? v.Description ?? '',
73+ url: v.url ?? v.URL ?? ''
74+ };
75+ }
76+ this.shortcuts = normalized;
6777 } catch (error) {
6878 console.error('Failed to load shortcuts:', error);
6979 // Mock shortcuts for development/fallback
@@ -63,7 +63,17 @@ export class AppService {
63 async loadShortcuts(): Promise<void> {63 async loadShortcuts(): Promise<void> {
64 try {64 try {
65 const cfg = await fetch('/api/config').then(r => r.json());65 const cfg = await fetch('/api/config').then(r => r.json());
66- this.shortcuts = cfg?.search_shortcuts || {};66+ const raw = cfg?.search_shortcuts || {};
67+ // Normalize field casing from backend (supports URL/Description and url/description)
68+ const normalized: Record<string, { description: string; url: string }> = {};
69+ for (const [key, value] of Object.entries(raw)) {
70+ const v: any = value as any;
71+ normalized[key] = {
72+ description: v.description ?? v.Description ?? '',
73+ url: v.url ?? v.URL ?? ''
74+ };
75+ }
76+ this.shortcuts = normalized;
67 } catch (error) {77 } catch (error) {
68 console.error('Failed to load shortcuts:', error);78 console.error('Failed to load shortcuts:', error);
69 // Mock shortcuts for development/fallback79 // Mock shortcuts for development/fallback
M internal/config/config.go +2 -2
@@ -52,8 +52,8 @@ type HistoryConfig struct {
5252
5353 // SearchShortcut represents a search shortcut configuration.
5454 type SearchShortcut struct {
55- URL string `mapstructure:"url" yaml:"url"`
56- Description string `mapstructure:"description" yaml:"description"`
55+ URL string `mapstructure:"url" yaml:"url" json:"url"`
56+ Description string `mapstructure:"description" yaml:"description" json:"description"`
5757 }
5858
5959 // DmenuConfig holds dmenu/rofi integration configuration.
@@ -52,8 +52,8 @@ type HistoryConfig struct {
52 52
53 // SearchShortcut represents a search shortcut configuration.53 // SearchShortcut represents a search shortcut configuration.
54 type SearchShortcut struct {54 type SearchShortcut struct {
55- URL string `mapstructure:"url" yaml:"url"`55+ URL string `mapstructure:"url" yaml:"url" json:"url"`
56- Description string `mapstructure:"description" yaml:"description"`56+ Description string `mapstructure:"description" yaml:"description" json:"description"`
57 }57 }
58 58
59 // DmenuConfig holds dmenu/rofi integration configuration.59 // DmenuConfig holds dmenu/rofi integration configuration.
M internal/parser/parser.go +32 -25
@@ -1,11 +1,12 @@
11 package parser
22
33 import (
4- "fmt"
5- "strings"
6- "time"
4+ "fmt"
5+ "strings"
6+ "time"
7+ neturl "net/url"
78
8- "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/config"
910 )
1011
1112 // ParseInput parses user input and returns a ParseResult with URL and metadata.
@@ -156,10 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime
156157
157158 // processShortcut processes a search shortcut and builds the final URL.
158159 func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
159- urlTemplate := shortcut.URL
160+ urlTemplate := shortcut.URL
160161
161- // Replace placeholder with query
162- finalURL := strings.ReplaceAll(urlTemplate, "{query}", query)
162+ // Replace placeholder with query
163+ q := neturl.QueryEscape(query)
164+ finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
165+ finalURL = strings.ReplaceAll(finalURL, "%s", q)
163166
164167 // Validate the resulting URL
165168 validator := NewURLValidator()
@@ -172,24 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear
172175
173176 // buildSearchURL builds a search URL using the default search engine.
174177 func (p *Parser) buildSearchURL(searchEngine, query string) string {
175- // If no specific search engine, use Google as default
176- if searchEngine == "" {
177- defaultShortcut, exists := p.config.SearchShortcuts["g"]
178- if exists {
179- url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query)
180- return url
181- }
182- // Fallback to Google if no "g" shortcut configured
183- return fmt.Sprintf("https://www.google.com/search?q=%s", query)
184- }
185-
186- // Use specified search engine
187- shortcut, exists := p.config.SearchShortcuts[searchEngine]
188- if !exists {
189- return fmt.Sprintf("https://www.google.com/search?q=%s", query)
190- }
191-
192- return strings.ReplaceAll(shortcut.URL, "{query}", query)
178+ q := neturl.QueryEscape(query)
179+ // If no specific search engine, use Google as default
180+ if searchEngine == "" {
181+ defaultShortcut, exists := p.config.SearchShortcuts["g"]
182+ if exists {
183+ url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
184+ url = strings.ReplaceAll(url, "%s", q)
185+ return url
186+ }
187+ // Fallback to Google if no "g" shortcut configured
188+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
189+ }
190+
191+ // Use specified search engine
192+ shortcut, exists := p.config.SearchShortcuts[searchEngine]
193+ if !exists {
194+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
195+ }
196+
197+ u := strings.ReplaceAll(shortcut.URL, "{query}", q)
198+ u = strings.ReplaceAll(u, "%s", q)
199+ return u
193200 }
194201
195202 // FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
@@ -1,11 +1,12 @@
1 package parser1 package parser
2 2
3 import (3 import (
4- "fmt"4+ "fmt"
5- "strings"5+ "strings"
6- "time"6+ "time"
7+ neturl "net/url"
7 8
8- "github.com/bnema/dumber/internal/config"9+ "github.com/bnema/dumber/internal/config"
9 )10 )
10 11
11 // ParseInput parses user input and returns a ParseResult with URL and metadata.12 // ParseInput parses user input and returns a ParseResult with URL and metadata.
@@ -156,10 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime
156 157
157 // processShortcut processes a search shortcut and builds the final URL.158 // processShortcut processes a search shortcut and builds the final URL.
158 func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {159 func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
159- urlTemplate := shortcut.URL160+ urlTemplate := shortcut.URL
160 161
161- // Replace placeholder with query162+ // Replace placeholder with query
162- finalURL := strings.ReplaceAll(urlTemplate, "{query}", query)163+ q := neturl.QueryEscape(query)
164+ finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
165+ finalURL = strings.ReplaceAll(finalURL, "%s", q)
163 166
164 // Validate the resulting URL167 // Validate the resulting URL
165 validator := NewURLValidator()168 validator := NewURLValidator()
@@ -172,24 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear
172 175
173 // buildSearchURL builds a search URL using the default search engine.176 // buildSearchURL builds a search URL using the default search engine.
174 func (p *Parser) buildSearchURL(searchEngine, query string) string {177 func (p *Parser) buildSearchURL(searchEngine, query string) string {
175- // If no specific search engine, use Google as default178+ q := neturl.QueryEscape(query)
176- if searchEngine == "" {179+ // If no specific search engine, use Google as default
177- defaultShortcut, exists := p.config.SearchShortcuts["g"]180+ if searchEngine == "" {
178- if exists {181+ defaultShortcut, exists := p.config.SearchShortcuts["g"]
179- url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query)182+ if exists {
180- return url183+ url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
181- }184+ url = strings.ReplaceAll(url, "%s", q)
182- // Fallback to Google if no "g" shortcut configured185+ return url
183- return fmt.Sprintf("https://www.google.com/search?q=%s", query)186+ }
184- }187+ // Fallback to Google if no "g" shortcut configured
185-188+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
186- // Use specified search engine189+ }
187- shortcut, exists := p.config.SearchShortcuts[searchEngine]190+
188- if !exists {191+ // Use specified search engine
189- return fmt.Sprintf("https://www.google.com/search?q=%s", query)192+ shortcut, exists := p.config.SearchShortcuts[searchEngine]
190- }193+ if !exists {
191-194+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
192- return strings.ReplaceAll(shortcut.URL, "{query}", query)195+ }
196+
197+ u := strings.ReplaceAll(shortcut.URL, "{query}", q)
198+ u = strings.ReplaceAll(u, "%s", q)
199+ return u
193 }200 }
194 201
195 // FuzzySearchHistory performs fuzzy search on history and returns ranked matches.202 // FuzzySearchHistory performs fuzzy search on history and returns ranked matches.

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