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 | + | } | |||