v0.9.0 636c44c

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

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