v0.12.0 d035193

handler.go RawHistoryFolder

1package messaging
2
3import (
4	"context"
5	"encoding/json"
6	"log"
7	"strconv"
8	"strings"
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}
55
56// NewHandler creates a new message handler
57func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
58	return &Handler{
59		parserService:  parserService,
60		browserService: browserService,
61	}
62}
63
64// SetNavigationController injects the navigation controller for unified navigation flow.
65func (h *Handler) SetNavigationController(controller *control.NavigationController) {
66	h.navigationController = controller
67}
68
69// SetWorkspaceObserver registers a workspace event observer.
70func (h *Handler) SetWorkspaceObserver(observer WorkspaceObserver) {
71	h.workspaceObserver = observer
72}
73
74// Handle processes incoming script messages
75func (h *Handler) Handle(payload string) {
76	var msg Message
77	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
78		log.Printf("[ERROR] Failed to unmarshal message: %v", err)
79		return
80	}
81
82	switch msg.Type {
83	case "navigate":
84		h.handleNavigation(msg)
85	case "query":
86		log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit)
87		if h.webView == nil {
88			log.Printf("[WARN] Dropping query response: webview unavailable for suggestions")
89			return
90		}
91		h.handleQuery(msg)
92	case "wails":
93		h.handleWailsBridge(msg)
94	case "theme":
95		h.handleTheme(msg)
96	case "history_recent":
97		h.handleHistoryRecent(msg)
98	case "history_stats":
99		h.handleHistoryStats(msg)
100	case "history_search":
101		h.handleHistorySearch(msg)
102	case "history_delete":
103		h.handleHistoryDelete(msg)
104	case "workspace":
105		h.handleWorkspace(msg)
106	case "close-popup":
107		h.handleClosePopup(msg)
108	case "console-message":
109		h.handleConsoleMessage(msg)
110	case "request-webview-id":
111		h.handleWebViewIDRequest(msg)
112	case "get_search_shortcuts":
113		h.handleGetSearchShortcuts(msg)
114	case "get_color_palettes":
115		h.handleGetColorPalettes(msg)
116	}
117}
118
119// SetWebView sets the WebView reference (needed for script injection)
120func (h *Handler) SetWebView(webView *webkit.WebView) {
121	h.webView = webView
122}
123
124// handleNavigation processes navigation requests from the frontend
125func (h *Handler) handleNavigation(msg Message) {
126	if h.navigationController != nil {
127		if err := h.navigationController.NavigateToURL(msg.URL); err != nil {
128			log.Printf("[messaging] Navigation controller failed for input %q: %v", msg.URL, err)
129			h.legacyNavigate(msg)
130		}
131		return
132	}
133
134	h.legacyNavigate(msg)
135}
136
137func (h *Handler) legacyNavigate(msg Message) {
138	ctx := context.Background()
139	res, err := h.parserService.ParseInput(ctx, msg.URL)
140	if err != nil {
141		log.Printf("[messaging] Legacy navigation parse failed for %q: %v", msg.URL, err)
142		return
143	}
144
145	if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
146		log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
147	}
148
149	if h.webView == nil {
150		return
151	}
152
153	if err := h.webView.LoadURL(res.URL); err != nil {
154		log.Printf("[messaging] Legacy LoadURL failed for %s: %v", res.URL, err)
155	}
156
157	if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
158		if err := h.webView.SetZoom(z); err != nil {
159			log.Printf("[messaging] Legacy SetZoom failed for %s: %v", res.URL, err)
160		}
161	}
162}
163
164// (legacy query handler removed; omnibox suggestions now fetched via dumb://api/omnibox/suggestions)
165// handleQuery computes omnibox suggestions natively and returns them to the GUI without fetch
166func (h *Handler) handleWorkspace(msg Message) {
167	if h.workspaceObserver == nil {
168		log.Printf("[workspace] Received workspace event %q but no observer registered", msg.Event)
169		return
170	}
171	if h.webView == nil {
172		log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)
173		return
174	}
175	log.Printf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s webviewId=%s", msg.Event, msg.Direction, msg.Action, msg.WebViewID)
176	h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
177}
178
179func (h *Handler) handleClosePopup(msg Message) {
180	log.Printf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
181
182	if h.workspaceObserver == nil {
183		log.Printf("[messaging] No workspace observer registered for close-popup request")
184		return
185	}
186	if h.webView == nil {
187		log.Printf("[messaging] No webview attached for close-popup request")
188		return
189	}
190
191	// Forward close-popup request to workspace manager via observer
192	closeMsg := Message{
193		Type:      "workspace",
194		Event:     "close-popup",
195		WebViewID: msg.WebViewID,
196		Reason:    msg.Reason,
197	}
198
199	log.Printf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
200	h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
201}
202
203func (h *Handler) handleQuery(msg Message) {
204	if h.webView == nil {
205		log.Printf("[WARN] Skipping query handling: webview is nil")
206		return
207	}
208	log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)
209	if h.browserService == nil {
210		log.Printf("[ERROR] handleQuery: browserService is nil")
211		return
212	}
213	ctx := context.Background()
214	q := msg.Q
215	if q == "" {
216		q = ""
217	}
218	limit := msg.Limit
219	if limit <= 0 {
220		limit = 10
221	}
222
223	// Build suggestions: shortcuts first, then history search
224	type suggestion struct {
225		URL     string `json:"url"`
226		Favicon string `json:"favicon,omitempty"`
227	}
228
229	buildFavicon := func(raw string) string {
230		if raw == "" {
231			return ""
232		}
233		scheme := "https"
234		host := raw
235		if i := strings.Index(raw, "://"); i >= 0 {
236			if i > 0 {
237				scheme = raw[:i]
238			}
239			rest := raw[i+3:]
240			if j := strings.IndexByte(rest, '/'); j >= 0 {
241				host = rest[:j]
242			} else {
243				host = rest
244			}
245		} else {
246			if j := strings.IndexByte(raw, '/'); j >= 0 {
247				host = raw[:j]
248			}
249		}
250		if host == "" {
251			return ""
252		}
253		return scheme + "://" + host + "/favicon.ico"
254	}
255
256	results := make([]suggestion, 0, limit)
257	seen := make(map[string]struct{}, limit*2)
258
259	// Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
260
261	// History
262	if len(results) < limit {
263		remaining := limit - len(results)
264		log.Printf("[DEBUG] Searching history for '%s' with limit %d", q, remaining)
265		if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err != nil {
266			log.Printf("[ERROR] Failed to search history: %v", err)
267		} else {
268			log.Printf("[DEBUG] Found %d history entries", len(entries))
269			for i, e := range entries {
270				// JSON roundtrip to map to get url field agnostic of struct tag
271				bb, _ := json.Marshal(e)
272				var m map[string]any
273				_ = json.Unmarshal(bb, &m)
274				var url string
275				if s, ok := m["url"].(string); ok {
276					url = s
277				} else if s, ok := m["URL"].(string); ok {
278					url = s
279				}
280				log.Printf("[DEBUG] History entry %d: url=%s", i, url)
281				if url == "" {
282					continue
283				}
284				if _, ok := seen[url]; ok {
285					continue
286				}
287				// Use favicon_url from database if available, otherwise build it
288				favicon := ""
289				if s, ok := m["favicon_url"].(string); ok && s != "" {
290					favicon = s
291				} else {
292					favicon = buildFavicon(url)
293				}
294				results = append(results, suggestion{URL: url, Favicon: favicon})
295				seen[url] = struct{}{}
296				if len(results) >= limit {
297					break
298				}
299			}
300		}
301	}
302
303	// Inject back to GUI
304	log.Printf("[DEBUG] Final results count: %d", len(results))
305	if b, err := json.Marshal(results); err != nil {
306		log.Printf("[ERROR] Failed to marshal results: %v", err)
307	} else {
308		// Prefer unified page-world API; fallback to legacy global function
309		script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
310		log.Print("[DEBUG] Injecting omnibox suggestions")
311		if injErr := h.webView.InjectScript(script); injErr != nil {
312			log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
313		} else {
314			log.Printf("[DEBUG] Successfully injected omnibox suggestions")
315		}
316	}
317}
318
319// handleWailsBridge processes Wails runtime bridge calls for homepage
320func (h *Handler) handleWailsBridge(msg Message) {
321	// Payload contains { methodID, methodName?, args }
322	var p struct {
323		MethodID   uint32          `json:"methodID"`
324		MethodName string          `json:"methodName"`
325		Args       json.RawMessage `json:"args"`
326	}
327	if err := json.Unmarshal(msg.Payload, &p); err != nil {
328		return
329	}
330
331	// Only implement the IDs we need
332	switch p.MethodID {
333	case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
334		var args []interface{}
335		_ = json.Unmarshal(p.Args, &args)
336		limit := 50
337		if len(args) > 0 {
338			if f, ok := args[0].(float64); ok {
339				limit = int(f)
340			}
341		}
342		ctx := context.Background()
343		entries, err := h.browserService.GetRecentHistory(ctx, limit)
344		if err != nil {
345			return
346		}
347		resp, _ := json.Marshal(entries)
348		if h.webView != nil {
349			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
350		}
351	case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
352		ctx := context.Background()
353		shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
354		if err != nil {
355			return
356		}
357		resp, _ := json.Marshal(shortcuts)
358		if h.webView != nil {
359			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
360		}
361	default:
362		// Return empty JSON to avoid breaking UI
363		if h.webView != nil {
364			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
365		}
366	}
367}
368
369// handleTheme processes theme-related messages
370func (h *Handler) handleTheme(msg Message) {
371	if msg.Value != "" && msg.Value != h.lastTheme {
372		log.Printf("[theme] color-scheme changed: %s", msg.Value)
373		h.lastTheme = msg.Value
374	}
375}
376
377// handleHistoryRecent processes recent history requests
378func (h *Handler) handleHistoryRecent(msg Message) {
379	if h.browserService == nil || h.webView == nil {
380		return
381	}
382
383	ctx := context.Background()
384	limit := msg.Limit
385	if limit <= 0 {
386		limit = 20
387	}
388	offset := msg.Offset
389
390	var entries []interface{}
391	var err error
392
393	// Use paginated version if offset is provided
394	if offset > 0 {
395		histEntries, e := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
396		if e != nil {
397			err = e
398		} else {
399			entries = make([]interface{}, len(histEntries))
400			for i, entry := range histEntries {
401				entries[i] = entry
402			}
403		}
404	} else {
405		// Use original method for backward compatibility
406		histEntries, e := h.browserService.GetRecentHistory(ctx, limit)
407		if e != nil {
408			err = e
409		} else {
410			entries = make([]interface{}, len(histEntries))
411			for i, entry := range histEntries {
412				entries[i] = entry
413			}
414		}
415	}
416
417	if err != nil {
418		log.Printf("[messaging] Failed to get recent history: %v", err)
419		requestIdParam := ""
420		if msg.RequestID != "" {
421			requestIdParam = ", '" + msg.RequestID + "'"
422		}
423		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
424		return
425	}
426
427	b, _ := json.Marshal(entries)
428	requestIdParam := ""
429	if msg.RequestID != "" {
430		requestIdParam = ", '" + msg.RequestID + "'"
431	}
432	_ = h.webView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
433}
434
435// handleHistoryStats processes history stats requests
436func (h *Handler) handleHistoryStats(_ Message) {
437	if h.browserService == nil || h.webView == nil {
438		return
439	}
440
441	ctx := context.Background()
442	stats, err := h.browserService.GetHistoryStats(ctx)
443	if err != nil {
444		log.Printf("[messaging] Failed to get history stats: %v", err)
445		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load history stats')")
446		return
447	}
448
449	b, _ := json.Marshal(stats)
450	_ = h.webView.InjectScript("window.__dumber_history_stats && window.__dumber_history_stats(" + string(b) + ")")
451}
452
453// handleHistorySearch processes history search requests
454func (h *Handler) handleHistorySearch(msg Message) {
455	if h.browserService == nil || h.webView == nil {
456		return
457	}
458
459	ctx := context.Background()
460	query := msg.Q
461	limit := msg.Limit
462	if limit <= 0 {
463		limit = 5
464	}
465
466	entries, err := h.browserService.SearchHistory(ctx, query, limit)
467	if err != nil {
468		log.Printf("[messaging] Failed to search history: %v", err)
469		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to search history')")
470		return
471	}
472
473	b, _ := json.Marshal(entries)
474	_ = h.webView.InjectScript("window.__dumber_history_search && window.__dumber_history_search(" + string(b) + ")")
475}
476
477// handleHistoryDelete processes history deletion requests
478func (h *Handler) handleHistoryDelete(msg Message) {
479	if h.browserService == nil || h.webView == nil {
480		return
481	}
482
483	if msg.HistoryID == "" {
484		log.Printf("[messaging] History delete: missing historyId")
485		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Missing history ID')")
486		return
487	}
488
489	// Convert string ID to int64
490	id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
491	if err != nil {
492		log.Printf("[messaging] History delete: invalid ID format: %v", err)
493		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Invalid history ID format')")
494		return
495	}
496
497	ctx := context.Background()
498	err = h.browserService.DeleteHistoryEntry(ctx, id)
499	if err != nil {
500		log.Printf("[messaging] Failed to delete history entry: %v", err)
501		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to delete history entry')")
502		return
503	}
504
505	// Send success response with the deleted ID
506	successData := map[string]string{"deletedId": msg.HistoryID}
507	b, _ := json.Marshal(successData)
508	_ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
509}
510
511// handleConsoleMessage processes console-message from JavaScript
512func (h *Handler) handleConsoleMessage(msg Message) {
513	// Check if console capture is enabled
514	cfg := config.Get()
515	if !cfg.Logging.CaptureConsole {
516		return
517	}
518
519	// Parse the console message payload
520	var consolePayload struct {
521		Level   string `json:"level"`
522		Message string `json:"message"`
523		URL     string `json:"url"`
524	}
525
526	if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
527		log.Printf("[messaging] Failed to unmarshal console-message: %v", err)
528		return
529	}
530
531	// Send to logging system with [CONSOLE] tag
532	logging.CaptureWebKitLog(consolePayload.Message)
533}
534
535// handleWebViewIDRequest responds to JavaScript requests for the webview ID
536func (h *Handler) handleWebViewIDRequest(msg Message) {
537	if h.webView == nil {
538		log.Printf("[messaging] Cannot provide webview ID - no webview available")
539		return
540	}
541
542	// Check if WebView is destroyed before attempting any operations
543	if h.webView.IsDestroyed() {
544		log.Printf("[messaging] Cannot provide webview ID - webview is destroyed")
545		return
546	}
547
548	webViewID := h.webView.ID()
549	log.Printf("[messaging] Sending webview ID %d to JavaScript", webViewID)
550
551	// Send the webview ID back to JavaScript via custom event
552	if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
553		"webviewId": webViewID,
554		"timestamp": time.Now().UnixMilli(),
555	}); err != nil {
556		log.Printf("[messaging] Failed to send webview ID: %v", err)
557	}
558}
559
560// handleGetSearchShortcuts sends search shortcuts configuration to JavaScript
561func (h *Handler) handleGetSearchShortcuts(msg Message) {
562	if h.webView == nil {
563		log.Printf("[messaging] Cannot provide search shortcuts - no webview available")
564		return
565	}
566
567	if h.webView.IsDestroyed() {
568		log.Printf("[messaging] Cannot provide search shortcuts - webview is destroyed")
569		return
570	}
571
572	// Get search shortcuts from browser service config
573	shortcuts, err := h.browserService.GetSearchShortcuts(context.Background())
574	if err != nil {
575		log.Printf("[messaging] Failed to get search shortcuts: %v", err)
576		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to get search shortcuts')")
577		return
578	}
579
580	// Marshal to JSON
581	b, err := json.Marshal(shortcuts)
582	if err != nil {
583		log.Printf("[messaging] Failed to marshal search shortcuts: %v", err)
584		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to load search shortcuts')")
585		return
586	}
587
588	// Inject the search shortcuts into the page
589	log.Printf("[messaging] Sending search shortcuts to JavaScript")
590	_ = h.webView.InjectScript("window.__dumber_search_shortcuts && window.__dumber_search_shortcuts(" + string(b) + ")")
591}
592
593// handleGetColorPalettes sends color palettes configuration to JavaScript
594func (h *Handler) handleGetColorPalettes(msg Message) {
595	if h.webView == nil {
596		log.Printf("[messaging] Cannot provide color palettes - no webview available")
597		return
598	}
599
600	if h.webView.IsDestroyed() {
601		log.Printf("[messaging] Cannot provide color palettes - webview is destroyed")
602		return
603	}
604
605	// Get color palettes from browser service config
606	palettes := h.browserService.GetColorPalettesForMessaging()
607
608	// Marshal to JSON
609	b, err := json.Marshal(palettes)
610	if err != nil {
611		log.Printf("[messaging] Failed to marshal color palettes: %v", err)
612		_ = h.webView.InjectScript("window.__dumber_color_palettes_error && window.__dumber_color_palettes_error('Failed to load color palettes')")
613		return
614	}
615
616	// Inject the color palettes into the page
617	log.Printf("[messaging] Sending color palettes to JavaScript")
618	_ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
619}

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