v0.8.0 0fa6785

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/pkg/webkit"
14	"github.com/bnema/dumber/services"
15)
16
17// Handler processes script messages from the WebView
18type Handler struct {
19	parserService  *services.ParserService
20	browserService *services.BrowserService
21	webView        *webkit.WebView
22	lastTheme      string
23}
24
25// Message represents a script message from the WebView
26type Message struct {
27	Type   string `json:"type"`
28	URL    string `json:"url"`
29	Q      string `json:"q"`
30	Limit  int    `json:"limit"`
31	Offset int    `json:"offset"`
32	Value  string `json:"value"`
33	// History operations
34	HistoryID string `json:"historyId"`
35	// Request tracking
36	RequestID string `json:"requestId"`
37	// Wails fetch bridge
38	ID      string          `json:"id"`
39	Payload json.RawMessage `json:"payload"`
40}
41
42// NewHandler creates a new message handler
43func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
44	return &Handler{
45		parserService:  parserService,
46		browserService: browserService,
47	}
48}
49
50// Handle processes incoming script messages
51func (h *Handler) Handle(payload string) {
52	log.Printf("[DEBUG] Received message: %s", payload)
53	var msg Message
54	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
55		log.Printf("[ERROR] Failed to unmarshal message: %v", err)
56		return
57	}
58	log.Printf("[DEBUG] Parsed message type: %s", msg.Type)
59
60	switch msg.Type {
61	case "navigate":
62		h.handleNavigation(msg)
63	case "query":
64		log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit)
65		h.handleQuery(msg)
66	case "wails":
67		h.handleWailsBridge(msg)
68	case "theme":
69		h.handleTheme(msg)
70	case "history_recent":
71		h.handleHistoryRecent(msg)
72	case "history_stats":
73		h.handleHistoryStats(msg)
74	case "history_search":
75		h.handleHistorySearch(msg)
76	case "history_delete":
77		h.handleHistoryDelete(msg)
78	}
79}
80
81// SetWebView sets the WebView reference (needed for script injection)
82func (h *Handler) SetWebView(webView *webkit.WebView) {
83	h.webView = webView
84}
85
86// handleNavigation processes navigation requests from the frontend
87func (h *Handler) handleNavigation(msg Message) {
88	ctx := context.Background()
89	res, err := h.parserService.ParseInput(ctx, msg.URL)
90	if err == nil {
91		if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
92			log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
93		}
94		if h.webView != nil {
95			_ = h.webView.LoadURL(res.URL)
96			if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
97				_ = h.webView.SetZoom(z)
98			}
99		}
100	}
101}
102
103// (legacy query handler removed; omnibox suggestions now fetched via dumb://api/omnibox/suggestions)
104// handleQuery computes omnibox suggestions natively and returns them to the GUI without fetch
105func (h *Handler) handleQuery(msg Message) {
106	log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)
107	if h.webView == nil {
108		log.Printf("[ERROR] handleQuery: webView is nil")
109		return
110	}
111	if h.browserService == nil {
112		log.Printf("[ERROR] handleQuery: browserService is nil")
113		return
114	}
115	ctx := context.Background()
116	q := msg.Q
117	if q == "" {
118		q = ""
119	}
120	limit := msg.Limit
121	if limit <= 0 {
122		limit = 10
123	}
124
125	// Build suggestions: shortcuts first, then history search
126	type suggestion struct {
127		URL     string `json:"url"`
128		Favicon string `json:"favicon,omitempty"`
129	}
130
131	buildFavicon := func(raw string) string {
132		if raw == "" {
133			return ""
134		}
135		scheme := "https"
136		host := raw
137		if i := strings.Index(raw, "://"); i >= 0 {
138			if i > 0 {
139				scheme = raw[:i]
140			}
141			rest := raw[i+3:]
142			if j := strings.IndexByte(rest, '/'); j >= 0 {
143				host = rest[:j]
144			} else {
145				host = rest
146			}
147		} else {
148			if j := strings.IndexByte(raw, '/'); j >= 0 {
149				host = raw[:j]
150			}
151		}
152		if host == "" {
153			return ""
154		}
155		return scheme + "://" + host + "/favicon.ico"
156	}
157
158	expandShortcut := func(tpl string, query string) string {
159		esc := neturl.QueryEscape(query)
160		u := strings.ReplaceAll(tpl, "{query}", esc)
161		u = strings.ReplaceAll(u, "%s", esc)
162		return u
163	}
164
165	results := make([]suggestion, 0, limit)
166	seen := make(map[string]struct{}, limit*2)
167
168	// Shortcuts
169	if shortcuts, err := h.browserService.GetSearchShortcuts(ctx); err != nil {
170		log.Printf("[ERROR] Failed to get search shortcuts: %v", err)
171	} else if len(shortcuts) > 0 {
172		log.Printf("[DEBUG] Found %d search shortcuts", len(shortcuts))
173		keys := make([]string, 0, len(shortcuts))
174		for k := range shortcuts {
175			keys = append(keys, k)
176		}
177		sort.Strings(keys)
178		for _, k := range keys {
179			sc := shortcuts[k]
180			url := expandShortcut(sc.URL, q)
181			log.Printf("[DEBUG] Shortcut %s -> %s", k, url)
182			if url == "" {
183				continue
184			}
185			if _, ok := seen[url]; ok {
186				continue
187			}
188			results = append(results, suggestion{URL: url, Favicon: buildFavicon(url)})
189			seen[url] = struct{}{}
190			if len(results) >= limit {
191				break
192			}
193		}
194	} else {
195		log.Printf("[DEBUG] No search shortcuts found")
196	}
197
198	// History
199	if len(results) < limit {
200		remaining := limit - len(results)
201		log.Printf("[DEBUG] Searching history for '%s' with limit %d", q, remaining)
202		if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err != nil {
203			log.Printf("[ERROR] Failed to search history: %v", err)
204		} else {
205			log.Printf("[DEBUG] Found %d history entries", len(entries))
206			for i, e := range entries {
207				// JSON roundtrip to map to get url field agnostic of struct tag
208				bb, _ := json.Marshal(e)
209				var m map[string]any
210				_ = json.Unmarshal(bb, &m)
211				var url string
212				if s, ok := m["url"].(string); ok {
213					url = s
214				} else if s, ok := m["URL"].(string); ok {
215					url = s
216				}
217				log.Printf("[DEBUG] History entry %d: url=%s", i, url)
218				if url == "" {
219					continue
220				}
221				if _, ok := seen[url]; ok {
222					continue
223				}
224				results = append(results, suggestion{URL: url, Favicon: buildFavicon(url)})
225				seen[url] = struct{}{}
226				if len(results) >= limit {
227					break
228				}
229			}
230		}
231	}
232
233	// Inject back to GUI
234	log.Printf("[DEBUG] Final results count: %d", len(results))
235	if b, err := json.Marshal(results); err != nil {
236		log.Printf("[ERROR] Failed to marshal results: %v", err)
237	} else {
238		// Prefer unified page-world API; fallback to legacy global function
239		script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
240		log.Printf("[DEBUG] Injecting omnibox suggestions: %s", script)
241		if injErr := h.webView.InjectScript(script); injErr != nil {
242			log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
243		} else {
244			log.Printf("[DEBUG] Successfully injected omnibox suggestions")
245		}
246	}
247}
248
249// handleWailsBridge processes Wails runtime bridge calls for homepage
250func (h *Handler) handleWailsBridge(msg Message) {
251	// Payload contains { methodID, methodName?, args }
252	var p struct {
253		MethodID   uint32          `json:"methodID"`
254		MethodName string          `json:"methodName"`
255		Args       json.RawMessage `json:"args"`
256	}
257	if err := json.Unmarshal(msg.Payload, &p); err != nil {
258		return
259	}
260
261	// Only implement the IDs we need
262	switch p.MethodID {
263	case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
264		var args []interface{}
265		_ = json.Unmarshal(p.Args, &args)
266		limit := 50
267		if len(args) > 0 {
268			if f, ok := args[0].(float64); ok {
269				limit = int(f)
270			}
271		}
272		ctx := context.Background()
273		entries, err := h.browserService.GetRecentHistory(ctx, limit)
274		if err != nil {
275			return
276		}
277		resp, _ := json.Marshal(entries)
278		if h.webView != nil {
279			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
280		}
281	case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
282		ctx := context.Background()
283		shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
284		if err != nil {
285			return
286		}
287		resp, _ := json.Marshal(shortcuts)
288		if h.webView != nil {
289			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
290		}
291	default:
292		// Return empty JSON to avoid breaking UI
293		if h.webView != nil {
294			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
295		}
296	}
297}
298
299// handleTheme processes theme-related messages
300func (h *Handler) handleTheme(msg Message) {
301	if msg.Value != "" && msg.Value != h.lastTheme {
302		log.Printf("[theme] color-scheme changed: %s", msg.Value)
303		h.lastTheme = msg.Value
304	}
305}
306
307// handleHistoryRecent processes recent history requests
308func (h *Handler) handleHistoryRecent(msg Message) {
309	if h.browserService == nil || h.webView == nil {
310		return
311	}
312
313	ctx := context.Background()
314	limit := msg.Limit
315	if limit <= 0 {
316		limit = 20
317	}
318	offset := msg.Offset
319
320	var entries []interface{}
321	var err error
322
323	// Use paginated version if offset is provided
324	if offset > 0 {
325		histEntries, e := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
326		if e != nil {
327			err = e
328		} else {
329			entries = make([]interface{}, len(histEntries))
330			for i, entry := range histEntries {
331				entries[i] = entry
332			}
333		}
334	} else {
335		// Use original method for backward compatibility
336		histEntries, e := h.browserService.GetRecentHistory(ctx, limit)
337		if e != nil {
338			err = e
339		} else {
340			entries = make([]interface{}, len(histEntries))
341			for i, entry := range histEntries {
342				entries[i] = entry
343			}
344		}
345	}
346
347	if err != nil {
348		log.Printf("[messaging] Failed to get recent history: %v", err)
349		requestIdParam := ""
350		if msg.RequestID != "" {
351			requestIdParam = ", '" + msg.RequestID + "'"
352		}
353		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
354		return
355	}
356
357	b, _ := json.Marshal(entries)
358	requestIdParam := ""
359	if msg.RequestID != "" {
360		requestIdParam = ", '" + msg.RequestID + "'"
361	}
362	_ = h.webView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
363}
364
365// handleHistoryStats processes history stats requests
366func (h *Handler) handleHistoryStats(msg Message) {
367	if h.browserService == nil || h.webView == nil {
368		return
369	}
370
371	ctx := context.Background()
372	stats, err := h.browserService.GetHistoryStats(ctx)
373	if err != nil {
374		log.Printf("[messaging] Failed to get history stats: %v", err)
375		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load history stats')")
376		return
377	}
378
379	b, _ := json.Marshal(stats)
380	_ = h.webView.InjectScript("window.__dumber_history_stats && window.__dumber_history_stats(" + string(b) + ")")
381}
382
383// handleHistorySearch processes history search requests
384func (h *Handler) handleHistorySearch(msg Message) {
385	if h.browserService == nil || h.webView == nil {
386		return
387	}
388
389	ctx := context.Background()
390	query := msg.Q
391	limit := msg.Limit
392	if limit <= 0 {
393		limit = 5
394	}
395
396	entries, err := h.browserService.SearchHistory(ctx, query, limit)
397	if err != nil {
398		log.Printf("[messaging] Failed to search history: %v", err)
399		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to search history')")
400		return
401	}
402
403	b, _ := json.Marshal(entries)
404	_ = h.webView.InjectScript("window.__dumber_history_search && window.__dumber_history_search(" + string(b) + ")")
405}
406
407// handleHistoryDelete processes history deletion requests
408func (h *Handler) handleHistoryDelete(msg Message) {
409	if h.browserService == nil || h.webView == nil {
410		return
411	}
412
413	if msg.HistoryID == "" {
414		log.Printf("[messaging] History delete: missing historyId")
415		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Missing history ID')")
416		return
417	}
418
419	// Convert string ID to int64
420	id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
421	if err != nil {
422		log.Printf("[messaging] History delete: invalid ID format: %v", err)
423		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Invalid history ID format')")
424		return
425	}
426
427	ctx := context.Background()
428	err = h.browserService.DeleteHistoryEntry(ctx, id)
429	if err != nil {
430		log.Printf("[messaging] Failed to delete history entry: %v", err)
431		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to delete history entry')")
432		return
433	}
434
435	// Send success response with the deleted ID
436	successData := map[string]string{"deletedId": msg.HistoryID}
437	b, _ := json.Marshal(successData)
438	_ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
439}

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