detached 738cf56

feat(handler): add frontend message handlers for favorites, navigation, and keyboard

Adds message handler package with specialized handlers for:
- FavoritesHandler: manages favorite CRUD operations
- QueryHandler: processes omnibox query submissions
- NavigateHandler: handles navigation requests
- KeyboardHandler: processes keyboard shortcut events
- PaletteHandler: manages command palette interactions
- ShortcutsHandler: manages shortcut configuration

Handlers bridge frontend messages to backend usecases via the MessageRouter.
commit
738cf56186a2dd4fab704edb6d971f7e9f91e736
author
brice · · 2025-12-11 22:29 UTC
committer
brice · · 2025-12-11 21:36 UTC
parent
5bdfa41
signature
unsigned
6 files · +537 -0
A internal/ui/handler/favorites_handler.go +124 -0
@@ -0,0 +1,124 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Favorite represents the payload the frontend expects.
15+type Favorite struct {
16+ ID int64 `json:"id"`
17+ URL string `json:"url"`
18+ Title string `json:"title"`
19+ FaviconURL string `json:"favicon_url,omitempty"`
20+ Position int `json:"position,omitempty"`
21+}
22+
23+// FavoritesHandler returns all favorites.
24+type FavoritesHandler struct {
25+ uc *usecase.ManageFavoritesUseCase
26+}
27+
28+// NewFavoritesHandler creates a favorites handler.
29+func NewFavoritesHandler(uc *usecase.ManageFavoritesUseCase) *FavoritesHandler {
30+ return &FavoritesHandler{uc: uc}
31+}
32+
33+// Handle returns the favorites list.
34+func (h *FavoritesHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
35+ log := logging.FromContext(ctx)
36+
37+ if h.uc == nil {
38+ log.Warn().Msg("favorites use case is nil; returning empty favorites")
39+ return []Favorite{}, nil
40+ }
41+
42+ favs, err := h.uc.GetAll(ctx)
43+ if err != nil {
44+ return nil, fmt.Errorf("get favorites: %w", err)
45+ }
46+
47+ return mapFavorites(favs), nil
48+}
49+
50+// ToggleFavoriteHandler toggles favorite status for a URL.
51+type ToggleFavoriteHandler struct {
52+ uc *usecase.ManageFavoritesUseCase
53+}
54+
55+// NewToggleFavoriteHandler creates a toggle favorite handler.
56+func NewToggleFavoriteHandler(uc *usecase.ManageFavoritesUseCase) *ToggleFavoriteHandler {
57+ return &ToggleFavoriteHandler{uc: uc}
58+}
59+
60+// Handle toggles a favorite and returns the updated list.
61+func (h *ToggleFavoriteHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
62+ log := logging.FromContext(ctx)
63+
64+ var req struct {
65+ URL string `json:"url"`
66+ Title string `json:"title"`
67+ FaviconURL string `json:"faviconURL"`
68+ }
69+ if err := json.Unmarshal(payload, &req); err != nil {
70+ return nil, fmt.Errorf("decode toggle favorite payload: %w", err)
71+ }
72+
73+ if h.uc == nil {
74+ log.Warn().Msg("favorites use case is nil; cannot toggle favorite")
75+ return []Favorite{}, nil
76+ }
77+
78+ existing, err := h.uc.GetByURL(ctx, req.URL)
79+ if err != nil {
80+ return nil, fmt.Errorf("check favorite: %w", err)
81+ }
82+
83+ if existing != nil {
84+ if err := h.uc.Remove(ctx, existing.ID); err != nil {
85+ return nil, fmt.Errorf("remove favorite: %w", err)
86+ }
87+ log.Info().Str("url", req.URL).Msg("favorite removed via toggle")
88+ } else {
89+ _, err := h.uc.Add(ctx, usecase.AddFavoriteInput{
90+ URL: req.URL,
91+ Title: req.Title,
92+ FaviconURL: req.FaviconURL,
93+ })
94+ if err != nil {
95+ return nil, fmt.Errorf("add favorite: %w", err)
96+ }
97+ log.Info().Str("url", req.URL).Msg("favorite added via toggle")
98+ }
99+
100+ // Return the refreshed list so the UI stays in sync.
101+ favs, err := h.uc.GetAll(ctx)
102+ if err != nil {
103+ return nil, fmt.Errorf("get favorites after toggle: %w", err)
104+ }
105+
106+ return mapFavorites(favs), nil
107+}
108+
109+func mapFavorites(favs []*entity.Favorite) []Favorite {
110+ result := make([]Favorite, 0, len(favs))
111+ for _, f := range favs {
112+ if f == nil {
113+ continue
114+ }
115+ result = append(result, Favorite{
116+ ID: int64(f.ID),
117+ URL: f.URL,
118+ Title: f.Title,
119+ FaviconURL: f.FaviconURL,
120+ Position: f.Position,
121+ })
122+ }
123+ return result
124+}
@@ -0,0 +1,124 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Favorite represents the payload the frontend expects.
15+type Favorite struct {
16+ ID int64 `json:"id"`
17+ URL string `json:"url"`
18+ Title string `json:"title"`
19+ FaviconURL string `json:"favicon_url,omitempty"`
20+ Position int `json:"position,omitempty"`
21+}
22+
23+// FavoritesHandler returns all favorites.
24+type FavoritesHandler struct {
25+ uc *usecase.ManageFavoritesUseCase
26+}
27+
28+// NewFavoritesHandler creates a favorites handler.
29+func NewFavoritesHandler(uc *usecase.ManageFavoritesUseCase) *FavoritesHandler {
30+ return &FavoritesHandler{uc: uc}
31+}
32+
33+// Handle returns the favorites list.
34+func (h *FavoritesHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
35+ log := logging.FromContext(ctx)
36+
37+ if h.uc == nil {
38+ log.Warn().Msg("favorites use case is nil; returning empty favorites")
39+ return []Favorite{}, nil
40+ }
41+
42+ favs, err := h.uc.GetAll(ctx)
43+ if err != nil {
44+ return nil, fmt.Errorf("get favorites: %w", err)
45+ }
46+
47+ return mapFavorites(favs), nil
48+}
49+
50+// ToggleFavoriteHandler toggles favorite status for a URL.
51+type ToggleFavoriteHandler struct {
52+ uc *usecase.ManageFavoritesUseCase
53+}
54+
55+// NewToggleFavoriteHandler creates a toggle favorite handler.
56+func NewToggleFavoriteHandler(uc *usecase.ManageFavoritesUseCase) *ToggleFavoriteHandler {
57+ return &ToggleFavoriteHandler{uc: uc}
58+}
59+
60+// Handle toggles a favorite and returns the updated list.
61+func (h *ToggleFavoriteHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
62+ log := logging.FromContext(ctx)
63+
64+ var req struct {
65+ URL string `json:"url"`
66+ Title string `json:"title"`
67+ FaviconURL string `json:"faviconURL"`
68+ }
69+ if err := json.Unmarshal(payload, &req); err != nil {
70+ return nil, fmt.Errorf("decode toggle favorite payload: %w", err)
71+ }
72+
73+ if h.uc == nil {
74+ log.Warn().Msg("favorites use case is nil; cannot toggle favorite")
75+ return []Favorite{}, nil
76+ }
77+
78+ existing, err := h.uc.GetByURL(ctx, req.URL)
79+ if err != nil {
80+ return nil, fmt.Errorf("check favorite: %w", err)
81+ }
82+
83+ if existing != nil {
84+ if err := h.uc.Remove(ctx, existing.ID); err != nil {
85+ return nil, fmt.Errorf("remove favorite: %w", err)
86+ }
87+ log.Info().Str("url", req.URL).Msg("favorite removed via toggle")
88+ } else {
89+ _, err := h.uc.Add(ctx, usecase.AddFavoriteInput{
90+ URL: req.URL,
91+ Title: req.Title,
92+ FaviconURL: req.FaviconURL,
93+ })
94+ if err != nil {
95+ return nil, fmt.Errorf("add favorite: %w", err)
96+ }
97+ log.Info().Str("url", req.URL).Msg("favorite added via toggle")
98+ }
99+
100+ // Return the refreshed list so the UI stays in sync.
101+ favs, err := h.uc.GetAll(ctx)
102+ if err != nil {
103+ return nil, fmt.Errorf("get favorites after toggle: %w", err)
104+ }
105+
106+ return mapFavorites(favs), nil
107+}
108+
109+func mapFavorites(favs []*entity.Favorite) []Favorite {
110+ result := make([]Favorite, 0, len(favs))
111+ for _, f := range favs {
112+ if f == nil {
113+ continue
114+ }
115+ result = append(result, Favorite{
116+ ID: int64(f.ID),
117+ URL: f.URL,
118+ Title: f.Title,
119+ FaviconURL: f.FaviconURL,
120+ Position: f.Position,
121+ })
122+ }
123+ return result
124+}
A internal/ui/handler/keyboard_handler.go +67 -0
@@ -0,0 +1,67 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// KeyboardBlockingHandler toggles keyboard and focus blocking in the page context.
14+type KeyboardBlockingHandler struct{}
15+
16+// NewKeyboardBlockingHandler creates a new handler.
17+func NewKeyboardBlockingHandler() *KeyboardBlockingHandler {
18+ return &KeyboardBlockingHandler{}
19+}
20+
21+// Handle enables or disables blocking by invoking page-world helpers.
22+func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23+ log := logging.FromContext(ctx)
24+
25+ var req struct {
26+ Action string `json:"action"`
27+ }
28+ if err := json.Unmarshal(payload, &req); err != nil {
29+ return nil, fmt.Errorf("decode keyboard payload: %w", err)
30+ }
31+
32+ action := strings.ToLower(strings.TrimSpace(req.Action))
33+ if action == "" {
34+ return nil, fmt.Errorf("keyboard_blocking action is required")
35+ }
36+
37+ wv := webkit.LookupWebView(webviewID)
38+ if wv == nil {
39+ return nil, fmt.Errorf("webview %d not found", webviewID)
40+ }
41+
42+ var js string
43+ switch action {
44+ case "enable":
45+ js = `if(window.__dumber_enableKeyboardBlocking){window.__dumber_enableKeyboardBlocking();}`
46+ case "disable":
47+ js = `if(window.__dumber_disableKeyboardBlocking){window.__dumber_disableKeyboardBlocking();}`
48+ default:
49+ return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
50+ }
51+
52+ if err := wv.RunJavaScript(ctx, js, ""); err != nil {
53+ log.Warn().
54+ Err(err).
55+ Uint64("webview_id", uint64(webviewID)).
56+ Str("action", action).
57+ Msg("failed to toggle keyboard blocking")
58+ return nil, err
59+ }
60+
61+ log.Debug().
62+ Str("action", action).
63+ Uint64("webview_id", uint64(webviewID)).
64+ Msg("keyboard blocking toggled")
65+
66+ return map[string]string{"status": "ok"}, nil
67+}
@@ -0,0 +1,67 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// KeyboardBlockingHandler toggles keyboard and focus blocking in the page context.
14+type KeyboardBlockingHandler struct{}
15+
16+// NewKeyboardBlockingHandler creates a new handler.
17+func NewKeyboardBlockingHandler() *KeyboardBlockingHandler {
18+ return &KeyboardBlockingHandler{}
19+}
20+
21+// Handle enables or disables blocking by invoking page-world helpers.
22+func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23+ log := logging.FromContext(ctx)
24+
25+ var req struct {
26+ Action string `json:"action"`
27+ }
28+ if err := json.Unmarshal(payload, &req); err != nil {
29+ return nil, fmt.Errorf("decode keyboard payload: %w", err)
30+ }
31+
32+ action := strings.ToLower(strings.TrimSpace(req.Action))
33+ if action == "" {
34+ return nil, fmt.Errorf("keyboard_blocking action is required")
35+ }
36+
37+ wv := webkit.LookupWebView(webviewID)
38+ if wv == nil {
39+ return nil, fmt.Errorf("webview %d not found", webviewID)
40+ }
41+
42+ var js string
43+ switch action {
44+ case "enable":
45+ js = `if(window.__dumber_enableKeyboardBlocking){window.__dumber_enableKeyboardBlocking();}`
46+ case "disable":
47+ js = `if(window.__dumber_disableKeyboardBlocking){window.__dumber_disableKeyboardBlocking();}`
48+ default:
49+ return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
50+ }
51+
52+ if err := wv.RunJavaScript(ctx, js, ""); err != nil {
53+ log.Warn().
54+ Err(err).
55+ Uint64("webview_id", uint64(webviewID)).
56+ Str("action", action).
57+ Msg("failed to toggle keyboard blocking")
58+ return nil, err
59+ }
60+
61+ log.Debug().
62+ Str("action", action).
63+ Uint64("webview_id", uint64(webviewID)).
64+ Msg("keyboard blocking toggled")
65+
66+ return map[string]string{"status": "ok"}, nil
67+}
A internal/ui/handler/navigate_handler.go +57 -0
@@ -0,0 +1,57 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// NavigateHandler processes navigation requests from the frontend.
14+type NavigateHandler struct{}
15+
16+// NewNavigateHandler creates a navigation handler.
17+func NewNavigateHandler() *NavigateHandler {
18+ return &NavigateHandler{}
19+}
20+
21+// Handle loads the requested URL in the target WebView.
22+func (h *NavigateHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23+ log := logging.FromContext(ctx)
24+
25+ var req struct {
26+ URL string `json:"url"`
27+ }
28+ if err := json.Unmarshal(payload, &req); err != nil {
29+ return nil, fmt.Errorf("decode navigate payload: %w", err)
30+ }
31+
32+ url := strings.TrimSpace(req.URL)
33+ if url == "" {
34+ return nil, fmt.Errorf("navigation URL is required")
35+ }
36+
37+ wv := webkit.LookupWebView(webviewID)
38+ if wv == nil {
39+ return nil, fmt.Errorf("webview %d not found", webviewID)
40+ }
41+
42+ if err := wv.LoadURI(ctx, url); err != nil {
43+ log.Warn().
44+ Err(err).
45+ Str("url", url).
46+ Uint64("webview_id", uint64(webviewID)).
47+ Msg("failed to load URL from frontend")
48+ return nil, err
49+ }
50+
51+ log.Info().
52+ Str("url", url).
53+ Uint64("webview_id", uint64(webviewID)).
54+ Msg("navigation triggered from frontend")
55+
56+ return map[string]string{"status": "ok"}, nil
57+}
@@ -0,0 +1,57 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// NavigateHandler processes navigation requests from the frontend.
14+type NavigateHandler struct{}
15+
16+// NewNavigateHandler creates a navigation handler.
17+func NewNavigateHandler() *NavigateHandler {
18+ return &NavigateHandler{}
19+}
20+
21+// Handle loads the requested URL in the target WebView.
22+func (h *NavigateHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23+ log := logging.FromContext(ctx)
24+
25+ var req struct {
26+ URL string `json:"url"`
27+ }
28+ if err := json.Unmarshal(payload, &req); err != nil {
29+ return nil, fmt.Errorf("decode navigate payload: %w", err)
30+ }
31+
32+ url := strings.TrimSpace(req.URL)
33+ if url == "" {
34+ return nil, fmt.Errorf("navigation URL is required")
35+ }
36+
37+ wv := webkit.LookupWebView(webviewID)
38+ if wv == nil {
39+ return nil, fmt.Errorf("webview %d not found", webviewID)
40+ }
41+
42+ if err := wv.LoadURI(ctx, url); err != nil {
43+ log.Warn().
44+ Err(err).
45+ Str("url", url).
46+ Uint64("webview_id", uint64(webviewID)).
47+ Msg("failed to load URL from frontend")
48+ return nil, err
49+ }
50+
51+ log.Info().
52+ Str("url", url).
53+ Uint64("webview_id", uint64(webviewID)).
54+ Msg("navigation triggered from frontend")
55+
56+ return map[string]string{"status": "ok"}, nil
57+}
A internal/ui/handler/palette_handler.go +59 -0
@@ -0,0 +1,59 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// PaletteHandler returns color palettes to the frontend.
14+type PaletteHandler struct {
15+ config *config.Config
16+}
17+
18+// NewPaletteHandler creates a palette handler.
19+func NewPaletteHandler(cfg *config.Config) *PaletteHandler {
20+ return &PaletteHandler{config: cfg}
21+}
22+
23+// PaletteResponse mirrors the frontend expectation.
24+type PaletteResponse struct {
25+ Light config.ColorPalette `json:"light"`
26+ Dark config.ColorPalette `json:"dark"`
27+ Current string `json:"current"`
28+}
29+
30+// Handle returns light/dark palettes and the current preference.
31+func (h *PaletteHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
32+ log := logging.FromContext(ctx)
33+
34+ if h.config == nil {
35+ log.Warn().Msg("config is nil; returning default palettes")
36+ defaultCfg := config.DefaultConfig()
37+ return PaletteResponse{
38+ Light: defaultCfg.Appearance.LightPalette,
39+ Dark: defaultCfg.Appearance.DarkPalette,
40+ Current: "light",
41+ }, nil
42+ }
43+
44+ current := strings.ToLower(h.config.Appearance.ColorScheme)
45+ switch current {
46+ case "prefer-dark", "dark":
47+ current = "dark"
48+ case "prefer-light", "light":
49+ current = "light"
50+ default:
51+ current = "system"
52+ }
53+
54+ return PaletteResponse{
55+ Light: h.config.Appearance.LightPalette,
56+ Dark: h.config.Appearance.DarkPalette,
57+ Current: current,
58+ }, nil
59+}
@@ -0,0 +1,59 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// PaletteHandler returns color palettes to the frontend.
14+type PaletteHandler struct {
15+ config *config.Config
16+}
17+
18+// NewPaletteHandler creates a palette handler.
19+func NewPaletteHandler(cfg *config.Config) *PaletteHandler {
20+ return &PaletteHandler{config: cfg}
21+}
22+
23+// PaletteResponse mirrors the frontend expectation.
24+type PaletteResponse struct {
25+ Light config.ColorPalette `json:"light"`
26+ Dark config.ColorPalette `json:"dark"`
27+ Current string `json:"current"`
28+}
29+
30+// Handle returns light/dark palettes and the current preference.
31+func (h *PaletteHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
32+ log := logging.FromContext(ctx)
33+
34+ if h.config == nil {
35+ log.Warn().Msg("config is nil; returning default palettes")
36+ defaultCfg := config.DefaultConfig()
37+ return PaletteResponse{
38+ Light: defaultCfg.Appearance.LightPalette,
39+ Dark: defaultCfg.Appearance.DarkPalette,
40+ Current: "light",
41+ }, nil
42+ }
43+
44+ current := strings.ToLower(h.config.Appearance.ColorScheme)
45+ switch current {
46+ case "prefer-dark", "dark":
47+ current = "dark"
48+ case "prefer-light", "light":
49+ current = "light"
50+ default:
51+ current = "system"
52+ }
53+
54+ return PaletteResponse{
55+ Light: h.config.Appearance.LightPalette,
56+ Dark: h.config.Appearance.DarkPalette,
57+ Current: current,
58+ }, nil
59+}
A internal/ui/handler/query_handler.go +192 -0
@@ -0,0 +1,192 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// Suggestion represents a minimal suggestion payload for the frontend.
17+type Suggestion struct {
18+ URL string `json:"url"`
19+ Title string `json:"title,omitempty"`
20+ FaviconURL string `json:"favicon_url,omitempty"`
21+ Favicon string `json:"favicon,omitempty"`
22+}
23+
24+// QueryHandler responds to omnibox search queries.
25+type QueryHandler struct {
26+ history *usecase.SearchHistoryUseCase
27+ config *config.Config
28+}
29+
30+// NewQueryHandler creates a new QueryHandler.
31+func NewQueryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *QueryHandler {
32+ return &QueryHandler{
33+ history: history,
34+ config: cfg,
35+ }
36+}
37+
38+// Handle executes a fuzzy history search and returns suggestions.
39+func (h *QueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
40+ log := logging.FromContext(ctx)
41+
42+ var req struct {
43+ Query string `json:"q"`
44+ Limit int `json:"limit"`
45+ }
46+ if err := json.Unmarshal(payload, &req); err != nil {
47+ return nil, fmt.Errorf("decode query payload: %w", err)
48+ }
49+
50+ limit := req.Limit
51+ if limit <= 0 {
52+ limit = 10
53+ if h.config != nil && h.config.Omnibox.InitialBehavior != "" {
54+ // Keep limit low by default; no per-behavior tuning yet.
55+ limit = 10
56+ }
57+ }
58+
59+ if h.history == nil {
60+ log.Warn().Msg("history use case is nil; returning empty suggestions")
61+ return []Suggestion{}, nil
62+ }
63+
64+ result, err := h.history.Search(ctx, usecase.SearchInput{
65+ Query: strings.TrimSpace(req.Query),
66+ Limit: limit,
67+ })
68+ if err != nil {
69+ return nil, fmt.Errorf("history search failed: %w", err)
70+ }
71+
72+ return toSuggestions(result.Matches), nil
73+}
74+
75+// InitialHistoryHandler returns initial history suggestions for an empty omnibox.
76+type InitialHistoryHandler struct {
77+ history *usecase.SearchHistoryUseCase
78+ config *config.Config
79+}
80+
81+// NewInitialHistoryHandler creates a new handler for initial history load.
82+func NewInitialHistoryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *InitialHistoryHandler {
83+ return &InitialHistoryHandler{
84+ history: history,
85+ config: cfg,
86+ }
87+}
88+
89+// Handle returns recent history entries as suggestions.
90+func (h *InitialHistoryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
91+ log := logging.FromContext(ctx)
92+
93+ var req struct {
94+ Limit int `json:"limit"`
95+ }
96+ if len(payload) > 0 {
97+ _ = json.Unmarshal(payload, &req)
98+ }
99+
100+ limit := req.Limit
101+ if limit <= 0 {
102+ limit = 10
103+ }
104+
105+ if h.history == nil {
106+ log.Warn().Msg("history use case is nil; returning empty initial history")
107+ return []Suggestion{}, nil
108+ }
109+
110+ entries, err := h.history.GetRecent(ctx, limit, 0)
111+ if err != nil {
112+ return nil, fmt.Errorf("get recent history: %w", err)
113+ }
114+
115+ suggestions := make([]Suggestion, 0, len(entries))
116+ for _, entry := range entries {
117+ if entry == nil {
118+ continue
119+ }
120+ suggestions = append(suggestions, Suggestion{
121+ URL: entry.URL,
122+ Title: entry.Title,
123+ FaviconURL: entry.FaviconURL,
124+ Favicon: entry.FaviconURL,
125+ })
126+ }
127+
128+ return suggestions, nil
129+}
130+
131+// PrefixQueryHandler returns an inline suggestion (ghost text) for a prefix query.
132+type PrefixQueryHandler struct {
133+ history *usecase.SearchHistoryUseCase
134+}
135+
136+// NewPrefixQueryHandler creates a new handler for prefix inline queries.
137+func NewPrefixQueryHandler(history *usecase.SearchHistoryUseCase) *PrefixQueryHandler {
138+ return &PrefixQueryHandler{history: history}
139+}
140+
141+// Handle returns the best matching URL for the given prefix (or null).
142+func (h *PrefixQueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
143+ log := logging.FromContext(ctx)
144+
145+ var req struct {
146+ Query string `json:"q"`
147+ }
148+ if err := json.Unmarshal(payload, &req); err != nil {
149+ return nil, fmt.Errorf("decode prefix query payload: %w", err)
150+ }
151+
152+ query := strings.TrimSpace(req.Query)
153+ if query == "" {
154+ return nil, nil
155+ }
156+
157+ if h.history == nil {
158+ log.Warn().Msg("history use case is nil; returning no inline suggestion")
159+ return nil, nil
160+ }
161+
162+ result, err := h.history.Search(ctx, usecase.SearchInput{
163+ Query: query,
164+ Limit: 1,
165+ })
166+ if err != nil {
167+ return nil, fmt.Errorf("inline history search failed: %w", err)
168+ }
169+
170+ if len(result.Matches) == 0 || result.Matches[0].Entry == nil {
171+ return nil, nil
172+ }
173+
174+ return result.Matches[0].Entry.URL, nil
175+}
176+
177+// toSuggestions converts history matches into UI suggestions.
178+func toSuggestions(matches []entity.HistoryMatch) []Suggestion {
179+ suggestions := make([]Suggestion, 0, len(matches))
180+ for _, match := range matches {
181+ if match.Entry == nil {
182+ continue
183+ }
184+ suggestions = append(suggestions, Suggestion{
185+ URL: match.Entry.URL,
186+ Title: match.Entry.Title,
187+ FaviconURL: match.Entry.FaviconURL,
188+ Favicon: match.Entry.FaviconURL,
189+ })
190+ }
191+ return suggestions
192+}
@@ -0,0 +1,192 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// Suggestion represents a minimal suggestion payload for the frontend.
17+type Suggestion struct {
18+ URL string `json:"url"`
19+ Title string `json:"title,omitempty"`
20+ FaviconURL string `json:"favicon_url,omitempty"`
21+ Favicon string `json:"favicon,omitempty"`
22+}
23+
24+// QueryHandler responds to omnibox search queries.
25+type QueryHandler struct {
26+ history *usecase.SearchHistoryUseCase
27+ config *config.Config
28+}
29+
30+// NewQueryHandler creates a new QueryHandler.
31+func NewQueryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *QueryHandler {
32+ return &QueryHandler{
33+ history: history,
34+ config: cfg,
35+ }
36+}
37+
38+// Handle executes a fuzzy history search and returns suggestions.
39+func (h *QueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
40+ log := logging.FromContext(ctx)
41+
42+ var req struct {
43+ Query string `json:"q"`
44+ Limit int `json:"limit"`
45+ }
46+ if err := json.Unmarshal(payload, &req); err != nil {
47+ return nil, fmt.Errorf("decode query payload: %w", err)
48+ }
49+
50+ limit := req.Limit
51+ if limit <= 0 {
52+ limit = 10
53+ if h.config != nil && h.config.Omnibox.InitialBehavior != "" {
54+ // Keep limit low by default; no per-behavior tuning yet.
55+ limit = 10
56+ }
57+ }
58+
59+ if h.history == nil {
60+ log.Warn().Msg("history use case is nil; returning empty suggestions")
61+ return []Suggestion{}, nil
62+ }
63+
64+ result, err := h.history.Search(ctx, usecase.SearchInput{
65+ Query: strings.TrimSpace(req.Query),
66+ Limit: limit,
67+ })
68+ if err != nil {
69+ return nil, fmt.Errorf("history search failed: %w", err)
70+ }
71+
72+ return toSuggestions(result.Matches), nil
73+}
74+
75+// InitialHistoryHandler returns initial history suggestions for an empty omnibox.
76+type InitialHistoryHandler struct {
77+ history *usecase.SearchHistoryUseCase
78+ config *config.Config
79+}
80+
81+// NewInitialHistoryHandler creates a new handler for initial history load.
82+func NewInitialHistoryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *InitialHistoryHandler {
83+ return &InitialHistoryHandler{
84+ history: history,
85+ config: cfg,
86+ }
87+}
88+
89+// Handle returns recent history entries as suggestions.
90+func (h *InitialHistoryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
91+ log := logging.FromContext(ctx)
92+
93+ var req struct {
94+ Limit int `json:"limit"`
95+ }
96+ if len(payload) > 0 {
97+ _ = json.Unmarshal(payload, &req)
98+ }
99+
100+ limit := req.Limit
101+ if limit <= 0 {
102+ limit = 10
103+ }
104+
105+ if h.history == nil {
106+ log.Warn().Msg("history use case is nil; returning empty initial history")
107+ return []Suggestion{}, nil
108+ }
109+
110+ entries, err := h.history.GetRecent(ctx, limit, 0)
111+ if err != nil {
112+ return nil, fmt.Errorf("get recent history: %w", err)
113+ }
114+
115+ suggestions := make([]Suggestion, 0, len(entries))
116+ for _, entry := range entries {
117+ if entry == nil {
118+ continue
119+ }
120+ suggestions = append(suggestions, Suggestion{
121+ URL: entry.URL,
122+ Title: entry.Title,
123+ FaviconURL: entry.FaviconURL,
124+ Favicon: entry.FaviconURL,
125+ })
126+ }
127+
128+ return suggestions, nil
129+}
130+
131+// PrefixQueryHandler returns an inline suggestion (ghost text) for a prefix query.
132+type PrefixQueryHandler struct {
133+ history *usecase.SearchHistoryUseCase
134+}
135+
136+// NewPrefixQueryHandler creates a new handler for prefix inline queries.
137+func NewPrefixQueryHandler(history *usecase.SearchHistoryUseCase) *PrefixQueryHandler {
138+ return &PrefixQueryHandler{history: history}
139+}
140+
141+// Handle returns the best matching URL for the given prefix (or null).
142+func (h *PrefixQueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
143+ log := logging.FromContext(ctx)
144+
145+ var req struct {
146+ Query string `json:"q"`
147+ }
148+ if err := json.Unmarshal(payload, &req); err != nil {
149+ return nil, fmt.Errorf("decode prefix query payload: %w", err)
150+ }
151+
152+ query := strings.TrimSpace(req.Query)
153+ if query == "" {
154+ return nil, nil
155+ }
156+
157+ if h.history == nil {
158+ log.Warn().Msg("history use case is nil; returning no inline suggestion")
159+ return nil, nil
160+ }
161+
162+ result, err := h.history.Search(ctx, usecase.SearchInput{
163+ Query: query,
164+ Limit: 1,
165+ })
166+ if err != nil {
167+ return nil, fmt.Errorf("inline history search failed: %w", err)
168+ }
169+
170+ if len(result.Matches) == 0 || result.Matches[0].Entry == nil {
171+ return nil, nil
172+ }
173+
174+ return result.Matches[0].Entry.URL, nil
175+}
176+
177+// toSuggestions converts history matches into UI suggestions.
178+func toSuggestions(matches []entity.HistoryMatch) []Suggestion {
179+ suggestions := make([]Suggestion, 0, len(matches))
180+ for _, match := range matches {
181+ if match.Entry == nil {
182+ continue
183+ }
184+ suggestions = append(suggestions, Suggestion{
185+ URL: match.Entry.URL,
186+ Title: match.Entry.Title,
187+ FaviconURL: match.Entry.FaviconURL,
188+ Favicon: match.Entry.FaviconURL,
189+ })
190+ }
191+ return suggestions
192+}
A internal/ui/handler/shortcuts_handler.go +38 -0
@@ -0,0 +1,38 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ShortcutsHandler returns configured search shortcuts.
13+type ShortcutsHandler struct {
14+ config *config.Config
15+}
16+
17+// NewShortcutsHandler creates a new shortcuts handler.
18+func NewShortcutsHandler(cfg *config.Config) *ShortcutsHandler {
19+ return &ShortcutsHandler{config: cfg}
20+}
21+
22+// Handle returns the configured search shortcuts map.
23+func (h *ShortcutsHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
24+ log := logging.FromContext(ctx)
25+
26+ if h.config == nil {
27+ log.Warn().Msg("config is nil; returning empty search shortcuts")
28+ return map[string]config.SearchShortcut{}, nil
29+ }
30+
31+ // Return a shallow copy to avoid accidental mutations.
32+ shortcuts := make(map[string]config.SearchShortcut, len(h.config.SearchShortcuts))
33+ for key, val := range h.config.SearchShortcuts {
34+ shortcuts[key] = val
35+ }
36+
37+ return shortcuts, nil
38+}
@@ -0,0 +1,38 @@
1+package handler
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ShortcutsHandler returns configured search shortcuts.
13+type ShortcutsHandler struct {
14+ config *config.Config
15+}
16+
17+// NewShortcutsHandler creates a new shortcuts handler.
18+func NewShortcutsHandler(cfg *config.Config) *ShortcutsHandler {
19+ return &ShortcutsHandler{config: cfg}
20+}
21+
22+// Handle returns the configured search shortcuts map.
23+func (h *ShortcutsHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
24+ log := logging.FromContext(ctx)
25+
26+ if h.config == nil {
27+ log.Warn().Msg("config is nil; returning empty search shortcuts")
28+ return map[string]config.SearchShortcut{}, nil
29+ }
30+
31+ // Return a shallow copy to avoid accidental mutations.
32+ shortcuts := make(map[string]config.SearchShortcut, len(h.config.SearchShortcuts))
33+ for key, val := range h.config.SearchShortcuts {
34+ shortcuts[key] = val
35+ }
36+
37+ return shortcuts, nil
38+}

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