v0.12.1 1453d21

handler.go RawHistoryFolder

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

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