v0.10.0 0f4e9eb

handler.go RawHistoryFolder

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

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