v0.14.1 0da30ba

handler.go RawHistoryFolder

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

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