v0.15.0 9006957

handler.go RawHistoryFolder

1package messaging
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"strconv"
9	"time"
10
11	"github.com/bnema/dumber/internal/app/constants"
12	"github.com/bnema/dumber/internal/app/control"
13	"github.com/bnema/dumber/internal/config"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/bnema/dumber/internal/services"
16	"github.com/bnema/dumber/pkg/webkit"
17)
18
19// Handler processes script messages from the WebView
20type WorkspaceObserver interface {
21	OnWorkspaceMessage(source *webkit.WebView, msg Message)
22}
23
24type Handler struct {
25	parserService        *services.ParserService
26	browserService       *services.BrowserService
27	webView              *webkit.WebView
28	navigationController *control.NavigationController
29	lastTheme            string
30	workspaceObserver    WorkspaceObserver
31}
32
33// Message represents a script message from the WebView
34type Message struct {
35	Type      string `json:"type"`
36	URL       string `json:"url"`
37	Q         string `json:"q"`
38	Limit     int    `json:"limit"`
39	Offset    int    `json:"offset"`
40	Value     string `json:"value"`
41	Event     string `json:"event"`
42	Action    string `json:"action"`
43	Direction string `json:"direction"`
44	// History operations
45	HistoryID string `json:"historyId"`
46	// Request tracking
47	RequestID string `json:"requestId"`
48	// Popup close tracking
49	WebViewID string `json:"webviewId"`
50	Reason    string `json:"reason"`
51	// Wails fetch bridge
52	ID      string          `json:"id"`
53	Payload json.RawMessage `json:"payload"`
54	// Favorites operations
55	Title      string `json:"title"`
56	FaviconURL string `json:"faviconURL"`
57}
58
59// NewHandler creates a new message handler
60func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
61	return &Handler{
62		parserService:  parserService,
63		browserService: browserService,
64	}
65}
66
67// SetNavigationController injects the navigation controller for unified navigation flow.
68func (h *Handler) SetNavigationController(controller *control.NavigationController) {
69	h.navigationController = controller
70}
71
72// SetWorkspaceObserver registers a workspace event observer.
73func (h *Handler) SetWorkspaceObserver(observer WorkspaceObserver) {
74	h.workspaceObserver = observer
75}
76
77// Handle processes incoming script messages
78func (h *Handler) Handle(payload string) {
79	msg, err := parseIncomingMessage(payload)
80	if err != nil {
81		logging.Error(fmt.Sprintf("[ERROR] Failed to unmarshal message: %v", err))
82		return
83	}
84
85	switch msg.Type {
86	case "navigate":
87		h.handleNavigation(msg)
88	case "query":
89		if h.webView == nil {
90			return
91		}
92		h.handleQuery(msg)
93	case "omnibox_initial_history":
94		if h.webView == nil {
95			return
96		}
97		h.handleOmniboxInitialHistory(msg)
98	case "wails":
99		h.handleWailsBridge(msg)
100	case "theme":
101		h.handleTheme(msg)
102	case "history_recent":
103		h.handleHistoryRecent(msg)
104	case "history_stats":
105		h.handleHistoryStats(msg)
106	case "history_search":
107		h.handleHistorySearch(msg)
108	case "history_delete":
109		h.handleHistoryDelete(msg)
110	case "workspace":
111		h.handleWorkspace(msg)
112	case "close-popup":
113		h.handleClosePopup(msg)
114	case "console-message":
115		h.handleConsoleMessage(msg)
116	case "request-webview-id":
117		h.handleWebViewIDRequest(msg)
118	case "get_search_shortcuts":
119		h.handleGetSearchShortcuts(msg)
120	case "get_color_palettes":
121		h.handleGetColorPalettes(msg)
122	case "get_favorites":
123		h.handleGetFavorites(msg)
124	case "toggle_favorite":
125		h.handleToggleFavorite(msg)
126	case "is_favorite":
127		h.handleIsFavorite(msg)
128	case "keyboard_blocking":
129		h.handleKeyboardBlocking(msg)
130	case "prefix_query":
131		h.handlePrefixQuery(msg)
132	}
133}
134
135func parseIncomingMessage(payload string) (Message, error) {
136	data := []byte(payload)
137	var msg Message
138	if err := json.Unmarshal(data, &msg); err == nil {
139		return msg, nil
140	} else {
141		normalized, normErr := normalizeWebViewIDPayload(data)
142		if normErr != nil {
143			return Message{}, err
144		}
145
146		if err := json.Unmarshal(normalized, &msg); err != nil {
147			return Message{}, err
148		}
149
150		return msg, nil
151	}
152}
153
154func normalizeWebViewIDPayload(data []byte) ([]byte, error) {
155	var raw map[string]json.RawMessage
156	if err := json.Unmarshal(data, &raw); err != nil {
157		return nil, err
158	}
159
160	rawID, ok := raw["webviewId"]
161	if !ok {
162		return nil, fmt.Errorf("webviewId missing in payload")
163	}
164
165	normalizedID, err := parseWebViewIDRaw(rawID)
166	if err != nil {
167		return nil, err
168	}
169
170	raw["webviewId"] = json.RawMessage(strconv.Quote(normalizedID))
171
172	return json.Marshal(raw)
173}
174
175func parseWebViewIDRaw(raw json.RawMessage) (string, error) {
176	trimmed := bytes.TrimSpace(raw)
177	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
178		return "", nil
179	}
180
181	if trimmed[0] == '"' {
182		var id string
183		if err := json.Unmarshal(trimmed, &id); err != nil {
184			return "", err
185		}
186		return id, nil
187	}
188
189	var number json.Number
190	if err := json.Unmarshal(trimmed, &number); err == nil {
191		return number.String(), nil
192	}
193
194	return "", fmt.Errorf("unsupported webviewId format: %s", string(trimmed))
195}
196
197// SetWebView sets the WebView reference (needed for script injection)
198func (h *Handler) SetWebView(webView *webkit.WebView) {
199	h.webView = webView
200}
201
202// handleNavigation processes navigation requests from the frontend
203func (h *Handler) handleNavigation(msg Message) {
204	if h.navigationController != nil {
205		if err := h.navigationController.NavigateToURL(msg.URL); err != nil {
206			logging.Error(fmt.Sprintf("[messaging] Navigation controller failed for input %q: %v", msg.URL, err))
207			h.legacyNavigate(msg)
208		}
209		return
210	}
211
212	h.legacyNavigate(msg)
213}
214
215func (h *Handler) legacyNavigate(msg Message) {
216	ctx := context.Background()
217	res, err := h.parserService.ParseInput(ctx, msg.URL)
218	if err != nil {
219		logging.Error(fmt.Sprintf("[messaging] Legacy navigation parse failed for %q: %v", msg.URL, err))
220		return
221	}
222
223	if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
224		logging.Warn(fmt.Sprintf("Warning: failed to navigate to %s: %v", res.URL, navErr))
225	}
226
227	if h.webView == nil {
228		return
229	}
230
231	if err := h.webView.LoadURL(res.URL); err != nil {
232		logging.Error(fmt.Sprintf("[messaging] Legacy LoadURL failed for %s: %v", res.URL, err))
233	}
234
235	if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
236		if err := h.webView.SetZoom(z); err != nil {
237			logging.Error(fmt.Sprintf("[messaging] Legacy SetZoom failed for %s: %v", res.URL, err))
238		}
239	}
240}
241
242// (legacy query handler removed; omnibox suggestions now fetched via dumb://api/omnibox/suggestions)
243// handleQuery computes omnibox suggestions natively and returns them to the GUI without fetch
244func (h *Handler) handleWorkspace(msg Message) {
245	if h.workspaceObserver == nil {
246		logging.Warn(fmt.Sprintf("[workspace] Received workspace event %q but no observer registered", msg.Event))
247		return
248	}
249	if h.webView == nil {
250		logging.Debug(fmt.Sprintf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event))
251		return
252	}
253	logging.Debug(fmt.Sprintf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s webviewId=%s", msg.Event, msg.Direction, msg.Action, msg.WebViewID))
254	h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
255}
256
257func (h *Handler) handleClosePopup(msg Message) {
258	logging.Debug(fmt.Sprintf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason))
259
260	if h.workspaceObserver == nil {
261		logging.Warn(fmt.Sprintf("[messaging] No workspace observer registered for close-popup request"))
262		return
263	}
264	if h.webView == nil {
265		logging.Debug(fmt.Sprintf("[messaging] No webview attached for close-popup request"))
266		return
267	}
268
269	// Forward close-popup request to workspace manager via observer
270	closeMsg := Message{
271		Type:      "workspace",
272		Event:     "close-popup",
273		WebViewID: msg.WebViewID,
274		Reason:    msg.Reason,
275	}
276
277	logging.Debug(fmt.Sprintf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason))
278	h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
279}
280
281func (h *Handler) handleQuery(msg Message) {
282	if h.webView == nil || h.browserService == nil {
283		return
284	}
285	ctx := context.Background()
286	q := msg.Q
287	if q == "" {
288		q = ""
289	}
290	limit := msg.Limit
291	if limit <= 0 {
292		limit = 10
293	}
294
295	// Build suggestions: shortcuts first, then history search
296	type suggestion struct {
297		URL     string `json:"url"`
298		Favicon string `json:"favicon,omitempty"`
299	}
300
301	results := make([]suggestion, 0, limit)
302	seen := make(map[string]struct{}, limit*2)
303
304	// Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
305
306	// History
307	if len(results) < limit {
308		remaining := limit - len(results)
309		if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err == nil {
310			for _, e := range entries {
311				// JSON roundtrip to map to get url field agnostic of struct tag
312				bb, _ := json.Marshal(e)
313				var m map[string]any
314				_ = json.Unmarshal(bb, &m)
315				var url string
316				if s, ok := m["url"].(string); ok {
317					url = s
318				} else if s, ok := m["URL"].(string); ok {
319					url = s
320				}
321				if url == "" {
322					continue
323				}
324				if _, ok := seen[url]; ok {
325					continue
326				}
327				// Use favicon_url from database (populated by FaviconService)
328				favicon := ""
329				if s, ok := m["favicon_url"].(string); ok && s != "" {
330					favicon = s
331				}
332				results = append(results, suggestion{URL: url, Favicon: favicon})
333				seen[url] = struct{}{}
334				if len(results) >= limit {
335					break
336				}
337			}
338		}
339	}
340
341	// Inject back to GUI
342	if b, err := json.Marshal(results); err == nil {
343		// Prefer unified page-world API; fallback to legacy global function
344		script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
345		if err := h.webView.InjectScript(script); err != nil {
346			logging.Error(fmt.Sprintf("[ERROR] Failed to inject omnibox suggestions: %v", err))
347		}
348	}
349}
350
351// handleOmniboxInitialHistory processes initial history display for empty omnibox
352func (h *Handler) handleOmniboxInitialHistory(msg Message) {
353	if h.webView == nil || h.browserService == nil {
354		return
355	}
356
357	ctx := context.Background()
358	limit := msg.Limit
359	if limit <= 0 {
360		limit = 10
361	}
362
363	// Get config to determine behavior
364	cfg, err := h.browserService.GetConfig(ctx)
365	if err != nil {
366		logging.Error(fmt.Sprintf("[ERROR] Failed to get config: %v", err))
367		return
368	}
369	behavior := cfg.Omnibox.InitialBehavior
370
371	type suggestion struct {
372		URL     string `json:"url"`
373		Favicon string `json:"favicon,omitempty"`
374	}
375
376	results := make([]suggestion, 0, limit)
377	seen := make(map[string]struct{}, limit*2)
378
379	// Fetch history based on config
380	var entries []interface{}
381
382	if behavior == "recent" {
383		historyEntries, histErr := h.browserService.GetRecentHistory(ctx, limit)
384		err = histErr
385		for _, e := range historyEntries {
386			entries = append(entries, e)
387		}
388	} else if behavior == "most_visited" {
389		historyEntries, histErr := h.browserService.GetMostVisited(ctx, limit)
390		err = histErr
391		for _, e := range historyEntries {
392			entries = append(entries, e)
393		}
394	} else if behavior == "none" {
395		// Return empty results
396		entries = []interface{}{}
397	}
398
399	if err != nil {
400		logging.Error(fmt.Sprintf("[ERROR] Failed to fetch initial history: %v", err))
401		return
402	}
403
404	// Build suggestions from entries with deduplication
405	for _, entry := range entries {
406		bb, _ := json.Marshal(entry)
407		var m map[string]any
408		_ = json.Unmarshal(bb, &m)
409
410		var url string
411		if s, ok := m["url"].(string); ok {
412			url = s
413		} else if s, ok := m["URL"].(string); ok {
414			url = s
415		}
416		if url == "" {
417			continue
418		}
419
420		// Skip duplicates
421		if _, ok := seen[url]; ok {
422			continue
423		}
424
425		favicon := ""
426		if s, ok := m["favicon_url"].(string); ok && s != "" {
427			favicon = s
428		}
429
430		results = append(results, suggestion{URL: url, Favicon: favicon})
431		seen[url] = struct{}{}
432		if len(results) >= limit {
433			break
434		}
435	}
436
437	// Inject back to GUI
438	if b, err := json.Marshal(results); err == nil {
439		script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
440		if err := h.webView.InjectScript(script); err != nil {
441			logging.Error(fmt.Sprintf("[ERROR] Failed to inject initial history: %v", err))
442		}
443	}
444}
445
446// handleWailsBridge processes Wails runtime bridge calls for homepage
447func (h *Handler) handleWailsBridge(msg Message) {
448	// Payload contains { methodID, methodName?, args }
449	var p struct {
450		MethodID   uint32          `json:"methodID"`
451		MethodName string          `json:"methodName"`
452		Args       json.RawMessage `json:"args"`
453	}
454	if err := json.Unmarshal(msg.Payload, &p); err != nil {
455		return
456	}
457
458	// Only implement the IDs we need
459	switch p.MethodID {
460	case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
461		var args []interface{}
462		_ = json.Unmarshal(p.Args, &args)
463		limit := 50
464		if len(args) > 0 {
465			if f, ok := args[0].(float64); ok {
466				limit = int(f)
467			}
468		}
469		ctx := context.Background()
470		entries, err := h.browserService.GetRecentHistory(ctx, limit)
471		if err != nil {
472			return
473		}
474		resp, _ := json.Marshal(entries)
475		if h.webView != nil {
476			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
477		}
478	case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
479		ctx := context.Background()
480		shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
481		if err != nil {
482			return
483		}
484		resp, _ := json.Marshal(shortcuts)
485		if h.webView != nil {
486			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
487		}
488	default:
489		// Return empty JSON to avoid breaking UI
490		if h.webView != nil {
491			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
492		}
493	}
494}
495
496// handleTheme processes theme-related messages
497func (h *Handler) handleTheme(msg Message) {
498	if msg.Value != "" && msg.Value != h.lastTheme {
499		logging.Debug(fmt.Sprintf("[theme] color-scheme changed: %s", msg.Value))
500		h.lastTheme = msg.Value
501	}
502}
503
504// handleHistoryRecent processes recent history requests
505func (h *Handler) handleHistoryRecent(msg Message) {
506	if h.browserService == nil || h.webView == nil {
507		return
508	}
509
510	ctx := context.Background()
511	limit := msg.Limit
512	if limit <= 0 {
513		limit = 20
514	}
515	offset := msg.Offset
516
517	var entries []interface{}
518	var err error
519
520	// Use paginated version if offset is provided
521	if offset > 0 {
522		histEntries, e := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
523		if e != nil {
524			err = e
525		} else {
526			entries = make([]interface{}, len(histEntries))
527			for i, entry := range histEntries {
528				entries[i] = entry
529			}
530		}
531	} else {
532		// Use original method for backward compatibility
533		histEntries, e := h.browserService.GetRecentHistory(ctx, limit)
534		if e != nil {
535			err = e
536		} else {
537			entries = make([]interface{}, len(histEntries))
538			for i, entry := range histEntries {
539				entries[i] = entry
540			}
541		}
542	}
543
544	if err != nil {
545		logging.Error(fmt.Sprintf("[messaging] Failed to get recent history: %v", err))
546		requestIdParam := ""
547		if msg.RequestID != "" {
548			requestIdParam = ", '" + msg.RequestID + "'"
549		}
550		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
551		return
552	}
553
554	b, _ := json.Marshal(entries)
555	requestIdParam := ""
556	if msg.RequestID != "" {
557		requestIdParam = ", '" + msg.RequestID + "'"
558	}
559	_ = h.webView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
560}
561
562// handleHistoryStats processes history stats requests
563func (h *Handler) handleHistoryStats(_ Message) {
564	if h.browserService == nil || h.webView == nil {
565		return
566	}
567
568	ctx := context.Background()
569	stats, err := h.browserService.GetHistoryStats(ctx)
570	if err != nil {
571		logging.Error(fmt.Sprintf("[messaging] Failed to get history stats: %v", err))
572		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load history stats')")
573		return
574	}
575
576	b, _ := json.Marshal(stats)
577	_ = h.webView.InjectScript("window.__dumber_history_stats && window.__dumber_history_stats(" + string(b) + ")")
578}
579
580// handleHistorySearch processes history search requests
581func (h *Handler) handleHistorySearch(msg Message) {
582	if h.browserService == nil || h.webView == nil {
583		return
584	}
585
586	ctx := context.Background()
587	query := msg.Q
588	limit := msg.Limit
589	if limit <= 0 {
590		limit = 5
591	}
592
593	entries, err := h.browserService.SearchHistory(ctx, query, limit)
594	if err != nil {
595		logging.Error(fmt.Sprintf("[messaging] Failed to search history: %v", err))
596		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to search history')")
597		return
598	}
599
600	b, _ := json.Marshal(entries)
601	_ = h.webView.InjectScript("window.__dumber_history_search && window.__dumber_history_search(" + string(b) + ")")
602}
603
604// handleHistoryDelete processes history deletion requests
605func (h *Handler) handleHistoryDelete(msg Message) {
606	if h.browserService == nil || h.webView == nil {
607		return
608	}
609
610	if msg.HistoryID == "" {
611		logging.Warn(fmt.Sprintf("[messaging] History delete: missing historyId"))
612		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Missing history ID')")
613		return
614	}
615
616	// Convert string ID to int64
617	id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
618	if err != nil {
619		logging.Error(fmt.Sprintf("[messaging] History delete: invalid ID format: %v", err))
620		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Invalid history ID format')")
621		return
622	}
623
624	ctx := context.Background()
625	err = h.browserService.DeleteHistoryEntry(ctx, id)
626	if err != nil {
627		logging.Error(fmt.Sprintf("[messaging] Failed to delete history entry: %v", err))
628		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to delete history entry')")
629		return
630	}
631
632	// Send success response with the deleted ID
633	successData := map[string]string{"deletedId": msg.HistoryID}
634	b, _ := json.Marshal(successData)
635	_ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
636}
637
638// handleConsoleMessage processes console-message from JavaScript
639func (h *Handler) handleConsoleMessage(msg Message) {
640	// Check if console capture is enabled
641	cfg := config.Get()
642	if !cfg.Logging.CaptureConsole {
643		return
644	}
645
646	// Parse the console message payload
647	var consolePayload struct {
648		Level   string `json:"level"`
649		Message string `json:"message"`
650		URL     string `json:"url"`
651	}
652
653	if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
654		logging.Error(fmt.Sprintf("[messaging] Failed to unmarshal console-message: %v", err))
655		return
656	}
657
658	// Send to logging system with [CONSOLE] tag
659	logging.CaptureWebKitLog(consolePayload.Message)
660}
661
662// handleWebViewIDRequest responds to JavaScript requests for the webview ID
663func (h *Handler) handleWebViewIDRequest(msg Message) {
664	if h.webView == nil {
665		logging.Warn(fmt.Sprintf("[messaging] Cannot provide webview ID - no webview available"))
666		return
667	}
668
669	// Check if WebView is destroyed before attempting any operations
670	if h.webView.IsDestroyed() {
671		logging.Warn(fmt.Sprintf("[messaging] Cannot provide webview ID - webview is destroyed"))
672		return
673	}
674
675	webViewID := h.webView.ID()
676	logging.Debug(fmt.Sprintf("[messaging] Sending webview ID %d to JavaScript", webViewID))
677
678	// Send the webview ID back to JavaScript via custom event
679	if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
680		"webviewId": webViewID,
681		"timestamp": time.Now().UnixMilli(),
682	}); err != nil {
683		logging.Error(fmt.Sprintf("[messaging] Failed to send webview ID: %v", err))
684	}
685}
686
687// handleGetSearchShortcuts sends search shortcuts configuration to JavaScript
688func (h *Handler) handleGetSearchShortcuts(msg Message) {
689	if h.webView == nil {
690		logging.Warn(fmt.Sprintf("[messaging] Cannot provide search shortcuts - no webview available"))
691		return
692	}
693
694	if h.webView.IsDestroyed() {
695		logging.Warn(fmt.Sprintf("[messaging] Cannot provide search shortcuts - webview is destroyed"))
696		return
697	}
698
699	// Get search shortcuts from browser service config
700	shortcuts, err := h.browserService.GetSearchShortcuts(context.Background())
701	if err != nil {
702		logging.Error(fmt.Sprintf("[messaging] Failed to get search shortcuts: %v", err))
703		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to get search shortcuts')")
704		return
705	}
706
707	// Marshal to JSON
708	b, err := json.Marshal(shortcuts)
709	if err != nil {
710		logging.Error(fmt.Sprintf("[messaging] Failed to marshal search shortcuts: %v", err))
711		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to load search shortcuts')")
712		return
713	}
714
715	// Inject the search shortcuts into the page
716	logging.Debug(fmt.Sprintf("[messaging] Sending search shortcuts to JavaScript"))
717	_ = h.webView.InjectScript("window.__dumber_search_shortcuts && window.__dumber_search_shortcuts(" + string(b) + ")")
718}
719
720// handleGetColorPalettes sends color palettes configuration to JavaScript
721func (h *Handler) handleGetColorPalettes(msg Message) {
722	if h.webView == nil {
723		logging.Warn(fmt.Sprintf("[messaging] Cannot provide color palettes - no webview available"))
724		return
725	}
726
727	if h.webView.IsDestroyed() {
728		logging.Warn(fmt.Sprintf("[messaging] Cannot provide color palettes - webview is destroyed"))
729		return
730	}
731
732	// Get color palettes from browser service config
733	palettes := h.browserService.GetColorPalettesForMessaging()
734
735	// Marshal to JSON
736	b, err := json.Marshal(palettes)
737	if err != nil {
738		logging.Error(fmt.Sprintf("[messaging] Failed to marshal color palettes: %v", err))
739		_ = h.webView.InjectScript("window.__dumber_color_palettes_error && window.__dumber_color_palettes_error('Failed to load color palettes')")
740		return
741	}
742
743	// Inject the color palettes into the page
744	logging.Debug(fmt.Sprintf("[messaging] Sending color palettes to JavaScript"))
745	_ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
746}
747
748// handleGetFavorites sends all favorites to JavaScript
749func (h *Handler) handleGetFavorites(msg Message) {
750	if h.webView == nil {
751		logging.Warn(fmt.Sprintf("[messaging] Cannot provide favorites - no webview available"))
752		return
753	}
754
755	if h.webView.IsDestroyed() {
756		logging.Warn(fmt.Sprintf("[messaging] Cannot provide favorites - webview is destroyed"))
757		return
758	}
759
760	// Get favorites from browser service
761	favorites, err := h.browserService.GetFavorites(context.Background())
762	if err != nil {
763		logging.Error(fmt.Sprintf("[messaging] Failed to get favorites: %v", err))
764		_ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to get favorites')")
765		return
766	}
767
768	// Marshal to JSON
769	b, err := json.Marshal(favorites)
770	if err != nil {
771		logging.Error(fmt.Sprintf("[messaging] Failed to marshal favorites: %v", err))
772		_ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to load favorites')")
773		return
774	}
775
776	// Inject the favorites into the page
777	logging.Debug(fmt.Sprintf("[messaging] Sending %d favorites to JavaScript", len(favorites)))
778	_ = h.webView.InjectScript("window.__dumber_favorites && window.__dumber_favorites(" + string(b) + ")")
779}
780
781// handleToggleFavorite adds or removes a URL from favorites
782func (h *Handler) handleToggleFavorite(msg Message) {
783	if h.webView == nil {
784		logging.Warn(fmt.Sprintf("[messaging] Cannot toggle favorite - no webview available"))
785		return
786	}
787
788	if h.webView.IsDestroyed() {
789		logging.Warn(fmt.Sprintf("[messaging] Cannot toggle favorite - webview is destroyed"))
790		return
791	}
792
793	if msg.URL == "" {
794		logging.Warn(fmt.Sprintf("[messaging] Cannot toggle favorite - URL is empty"))
795		return
796	}
797
798	// Toggle the favorite
799	added, err := h.browserService.ToggleFavorite(context.Background(), msg.URL, msg.Title, msg.FaviconURL)
800	if err != nil {
801		logging.Error(fmt.Sprintf("[messaging] Failed to toggle favorite for %s: %v", msg.URL, err))
802		_ = h.webView.InjectScript("window.__dumber_favorite_toggled_error && window.__dumber_favorite_toggled_error('Failed to toggle favorite')")
803		return
804	}
805
806	// Send result back to JavaScript
807	result := map[string]interface{}{
808		"url":   msg.URL,
809		"added": added,
810	}
811	b, err := json.Marshal(result)
812	if err != nil {
813		logging.Error(fmt.Sprintf("[messaging] Failed to marshal toggle result: %v", err))
814		return
815	}
816
817	logging.Debug(fmt.Sprintf("[messaging] Favorite toggled for %s (added: %v)", msg.URL, added))
818	_ = h.webView.InjectScript("window.__dumber_favorite_toggled && window.__dumber_favorite_toggled(" + string(b) + ")")
819}
820
821// handleIsFavorite checks if a URL is favorited
822func (h *Handler) handleIsFavorite(msg Message) {
823	if h.webView == nil {
824		logging.Warn(fmt.Sprintf("[messaging] Cannot check favorite - no webview available"))
825		return
826	}
827
828	if h.webView.IsDestroyed() {
829		logging.Warn(fmt.Sprintf("[messaging] Cannot check favorite - webview is destroyed"))
830		return
831	}
832
833	if msg.URL == "" {
834		logging.Warn(fmt.Sprintf("[messaging] Cannot check favorite - URL is empty"))
835		return
836	}
837
838	// Check if the URL is favorited
839	isFavorite, err := h.browserService.IsFavorite(context.Background(), msg.URL)
840	if err != nil {
841		logging.Error(fmt.Sprintf("[messaging] Failed to check if favorite for %s: %v", msg.URL, err))
842		return
843	}
844
845	// Send result back to JavaScript
846	result := map[string]interface{}{
847		"url":        msg.URL,
848		"isFavorite": isFavorite,
849	}
850	b, err := json.Marshal(result)
851	if err != nil {
852		logging.Error(fmt.Sprintf("[messaging] Failed to marshal is_favorite result: %v", err))
853		return
854	}
855
856	_ = h.webView.InjectScript("window.__dumber_is_favorite && window.__dumber_is_favorite(" + string(b) + ")")
857}
858
859// handleKeyboardBlocking enables or disables keyboard event blocking for omnibox
860func (h *Handler) handleKeyboardBlocking(msg Message) {
861	if h.webView == nil {
862		return
863	}
864
865	var script string
866	if msg.Action == "enable" {
867		script = "window.__dumber_enableKeyboardBlocking?.()"
868	} else {
869		script = "window.__dumber_disableKeyboardBlocking?.()"
870	}
871
872	if err := h.webView.InjectScript(script); err != nil {
873		logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err))
874	}
875}
876
877// handlePrefixQuery returns the best prefix-matching URL for inline suggestions
878func (h *Handler) handlePrefixQuery(msg Message) {
879	if h.webView == nil || h.browserService == nil {
880		return
881	}
882
883	query := msg.Q
884	if query == "" {
885		_ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
886		return
887	}
888
889	ctx := context.Background()
890	prefixURL := h.browserService.GetBestPrefixMatch(ctx, query)
891
892	if prefixURL != "" {
893		// Escape the URL for JavaScript string
894		escaped, err := json.Marshal(prefixURL)
895		if err != nil {
896			_ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
897			return
898		}
899		_ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")")
900	} else {
901		_ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
902	}
903}

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