v0.12.1 1453d21

browser.go RawHistoryFolder

1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5	"context"
6	"database/sql"
7	"embed"
8	"errors"
9	"fmt"
10	"log"
11	"math"
12	neturl "net/url"
13	"time"
14
15	"github.com/bnema/dumber/internal/config"
16	"github.com/bnema/dumber/internal/db"
17	"github.com/bnema/dumber/pkg/webkit"
18)
19
20func clampToInt32(i int64) int32 {
21	if i > math.MaxInt32 {
22		i = math.MaxInt32
23	}
24	if i < math.MinInt32 {
25		i = math.MinInt32
26	}
27	return int32(i) //nolint:gosec // value is clamped to int32 bounds
28}
29
30// WindowTitleUpdater interface allows the service to update the window title
31// Decoupled from any specific GUI framework.
32type WindowTitleUpdater interface {
33	SetTitle(title string)
34}
35
36// BrowserService handles browser-related operations for the built-in browser.
37type BrowserService struct {
38	config             *config.Config
39	dbQueries          db.DatabaseQuerier
40	windowTitleUpdater WindowTitleUpdater
41	webView            *webkit.WebView
42	initialURL         string
43	guiBundle          string
44}
45
46// ServiceName returns the service name for frontend binding
47func (s *BrowserService) ServiceName() string {
48	return "BrowserService"
49}
50
51// NavigationResult represents the result of a navigation operation.
52type NavigationResult struct {
53	URL           string        `json:"url"`
54	Title         string        `json:"title"`
55	Success       bool          `json:"success"`
56	Error         string        `json:"error,omitempty"`
57	LoadTime      time.Duration `json:"load_time"`
58	RedirectChain []string      `json:"redirect_chain,omitempty"`
59}
60
61// HistoryEntry represents a simplified history entry for frontend.
62type HistoryEntry struct {
63	ID          int64     `json:"id"`
64	URL         string    `json:"url"`
65	Title       string    `json:"title"`
66	FaviconURL  string    `json:"favicon_url"`
67	VisitCount  int32     `json:"visit_count"`
68	LastVisited time.Time `json:"last_visited"`
69	CreatedAt   time.Time `json:"created_at"`
70}
71
72// NewBrowserService creates a new BrowserService instance.
73func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
74	return &BrowserService{
75		config:             cfg,
76		dbQueries:          queries,
77		windowTitleUpdater: nil,
78		webView:            nil,
79		initialURL:         "",
80		guiBundle:          "",
81	}
82}
83
84// SetWindowTitleUpdater sets the window title updater interface
85func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
86	s.windowTitleUpdater = updater
87}
88
89// AttachWebView connects a native WebKit WebView to this service for integration.
90func (s *BrowserService) AttachWebView(view *webkit.WebView) {
91	s.webView = view
92	// Favicon handling is now managed by FaviconService
93}
94
95// LoadGUIBundle loads the unified GUI bundle from assets
96func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
97	log.Printf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js")
98	bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
99	if err != nil {
100		log.Printf("[browser] ERROR: Failed to load GUI bundle: %v", err)
101		return fmt.Errorf("failed to load GUI bundle: %w", err)
102	}
103	s.guiBundle = string(bundleBytes)
104	log.Printf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes))
105	return nil
106}
107
108// GetGUIBundle returns the loaded GUI bundle string
109func (s *BrowserService) GetGUIBundle() string {
110	return s.guiBundle
111}
112
113// InjectToastSystem injects the GUI bundle and initializes the toast system
114func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
115	_ = ctx
116	if s.webView == nil {
117		return fmt.Errorf("webview not attached")
118	}
119	if s.guiBundle == "" {
120		return fmt.Errorf("GUI bundle not loaded")
121	}
122
123	// Set theme if provided
124	themeScript := ""
125	if theme != "" {
126		themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
127	}
128
129	// Inject the unified GUI bundle and initialize toast system after DOM ready
130	toastScript := s.guiBundle + ";" + themeScript +
131		"(function(){" +
132		"function initToast(){" +
133		"if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
134		"window.__dumber_gui.initializeToast().then(function(){" +
135		"console.log('✅ Toast system initialized via GUI bundle');" +
136		"}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
137		"}else{console.error('❌ GUI bundle not properly loaded');}" +
138		"}" +
139		"if(document.readyState==='loading'){" +
140		"document.addEventListener('DOMContentLoaded',initToast);" +
141		"}else{initToast();}" +
142		"})();"
143
144	return s.webView.InjectScript(toastScript)
145}
146
147// Navigate handles navigation to a URL and records it in history.
148func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
149	startTime := time.Now()
150
151	if url == "" {
152		return &NavigationResult{
153			Success: false,
154			Error:   "URL cannot be empty",
155		}, nil
156	}
157
158	// Store the initial URL for frontend synchronization
159	if s.initialURL == "" {
160		s.initialURL = url
161	}
162
163	// Record the navigation in history
164	err := s.recordHistory(ctx, url, "")
165	if err != nil {
166		// Don't fail navigation if history recording fails
167		fmt.Printf("Failed to record history: %v\n", err)
168	}
169
170	// Favicon detection is now handled automatically by WebKit's native favicon database
171
172	return &NavigationResult{
173		URL:      url,
174		Success:  true,
175		LoadTime: time.Since(startTime),
176	}, nil
177}
178
179// UpdatePageTitle updates the title of the current page in history and the window title.
180func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
181	if url == "" || title == "" {
182		return fmt.Errorf("URL and title cannot be empty")
183	}
184
185	// Use AddOrUpdateHistory to update the title
186	titleNull := sql.NullString{String: title, Valid: true}
187	err := s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
188	if err != nil {
189		return err
190	}
191
192	// Update the window title if we have a title updater
193	if s.windowTitleUpdater != nil && title != "" {
194		windowTitle := fmt.Sprintf("Dumber - %s", title)
195		updater := s.windowTitleUpdater
196		if s.webView != nil {
197			s.webView.RunOnMainThread(func() {
198				updater.SetTitle(windowTitle)
199			})
200		} else {
201			updater.SetTitle(windowTitle)
202		}
203	}
204
205	return nil
206}
207
208// GetRecentHistory returns recent browser history entries.
209func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
210	if limit <= 0 {
211		limit = 50 // Default limit
212	}
213
214	entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
215	if err != nil {
216		return nil, err
217	}
218
219	result := make([]HistoryEntry, len(entries))
220	for i, entry := range entries {
221		// Defensive cast to int32 to prevent overflow
222		vc := clampToInt32(entry.VisitCount.Int64)
223		result[i] = HistoryEntry{
224			ID:          entry.ID,
225			URL:         entry.Url,
226			Title:       entry.Title.String,
227			FaviconURL:  entry.FaviconUrl.String,
228			VisitCount:  vc,
229			LastVisited: entry.LastVisited.Time,
230			CreatedAt:   entry.CreatedAt.Time,
231		}
232	}
233
234	return result, nil
235}
236
237// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
238func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
239	if limit <= 0 {
240		limit = 20 // Default limit for pagination
241	}
242	if offset < 0 {
243		offset = 0
244	}
245
246	entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
247	if err != nil {
248		return nil, err
249	}
250
251	result := make([]HistoryEntry, len(entries))
252	for i, entry := range entries {
253		// Defensive cast to int32 to prevent overflow
254		vc := clampToInt32(entry.VisitCount.Int64)
255		result[i] = HistoryEntry{
256			ID:          entry.ID,
257			URL:         entry.Url,
258			Title:       entry.Title.String,
259			FaviconURL:  entry.FaviconUrl.String,
260			VisitCount:  vc,
261			LastVisited: entry.LastVisited.Time,
262			CreatedAt:   entry.CreatedAt.Time,
263		}
264	}
265
266	return result, nil
267}
268
269// SearchHistory searches browser history.
270func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
271	if query == "" {
272		return s.GetRecentHistory(ctx, limit)
273	}
274
275	if limit <= 0 {
276		limit = 50
277	}
278
279	urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
280	titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
281
282	entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
283	if err != nil {
284		return nil, err
285	}
286
287	result := make([]HistoryEntry, len(entries))
288	for i, entry := range entries {
289		vc := clampToInt32(entry.VisitCount.Int64)
290		// For omnibox display, convert favicon URI to file path when available
291		faviconURL := entry.FaviconUrl.String
292		result[i] = HistoryEntry{
293			ID:          entry.ID,
294			URL:         entry.Url,
295			Title:       entry.Title.String,
296			FaviconURL:  faviconURL,
297			VisitCount:  vc,
298			LastVisited: entry.LastVisited.Time,
299			CreatedAt:   entry.CreatedAt.Time,
300		}
301	}
302
303	return result, nil
304}
305
306// DeleteHistoryEntry removes a specific history entry.
307func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
308	return s.dbQueries.DeleteHistory(ctx, id)
309}
310
311// ClearHistory removes all history entries.
312func (s *BrowserService) ClearHistory(ctx context.Context) error {
313	_ = ctx
314	// Note: ClearAllHistory method doesn't exist in current schema
315	// This would need to be implemented in the database layer
316	return fmt.Errorf("clear all history not implemented yet")
317}
318
319// GetHistoryStats returns statistics about browser history.
320func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
321	// Get recent entries for basic stats
322	recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
323	if err != nil {
324		return nil, err
325	}
326
327	// Calculate basic statistics
328	stats := map[string]interface{}{
329		"total_entries": len(recentEntries),
330		"recent_count":  len(recentEntries),
331	}
332
333	// Add more detailed stats if we have entries
334	if len(recentEntries) > 0 {
335		stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
336		stats["newest_entry"] = recentEntries[0].CreatedAt.Time
337	}
338
339	return stats, nil
340}
341
342// GetConfig returns the current browser configuration.
343func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
344	_ = ctx
345	return s.config, nil
346}
347
348// UpdateConfig updates the browser configuration.
349func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
350	_ = ctx
351	// In a real implementation, you'd want to validate and persist the config
352	s.config = newConfig
353	return nil
354}
355
356// GetSearchShortcuts returns available search shortcuts.
357func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
358	return s.config.SearchShortcuts, nil
359}
360
361// recordHistory adds or updates a history entry.
362func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
363	// Use AddOrUpdateHistory which handles both cases
364	titleNull := sql.NullString{Valid: false}
365	if title != "" {
366		titleNull = sql.NullString{String: title, Valid: true}
367	}
368
369	return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
370}
371
372// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
373var firefoxZoomLevels = []float64{0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, 4.0, 5.0}
374
375// ZoomIn increases the zoom level to the next Firefox zoom level.
376func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
377	currentZoom, err := s.GetZoomLevel(ctx, url)
378	if err != nil {
379		return 1.0, err
380	}
381
382	newZoom := s.getNextZoomLevel(currentZoom, true)
383	err = s.SetZoomLevel(ctx, url, newZoom)
384	if err != nil {
385		return currentZoom, err
386	}
387
388	return newZoom, nil
389}
390
391// ZoomOut decreases the zoom level to the previous Firefox zoom level.
392func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
393	currentZoom, err := s.GetZoomLevel(ctx, url)
394	if err != nil {
395		return 1.0, err
396	}
397
398	newZoom := s.getNextZoomLevel(currentZoom, false)
399	err = s.SetZoomLevel(ctx, url, newZoom)
400	if err != nil {
401		return currentZoom, err
402	}
403
404	return newZoom, nil
405}
406
407// ResetZoom resets the zoom level to 1.0 for the current URL.
408func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
409	return s.setZoom(ctx, url, 1.0)
410}
411
412// GetZoomLevel retrieves the saved zoom level for a URL.
413func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
414	if url == "" {
415		return s.config.DefaultZoom, nil
416	}
417
418	key := zoomKeyFromURL(url)
419	zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
420	if err != nil {
421		if errors.Is(err, sql.ErrNoRows) {
422			// Fallback to exact URL record if present (backward compatibility)
423			zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
424			if err2 == nil {
425				return zl, nil
426			}
427			// No zoom setting found, return configured default
428			return s.config.DefaultZoom, nil
429		}
430		return s.config.DefaultZoom, err
431	}
432
433	return zoomLevel, nil
434}
435
436// SetZoomLevel sets the zoom level for a URL.
437func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
438	if url == "" {
439		return fmt.Errorf("URL cannot be empty")
440	}
441
442	// Clamp zoom level to reasonable bounds
443	if zoomLevel < zoomMin {
444		zoomLevel = zoomMin
445	} else if zoomLevel > zoomMax {
446		zoomLevel = zoomMax
447	}
448
449	key := zoomKeyFromURL(url)
450	return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
451}
452
453// GetCurrentURL returns the current URL (this would be implemented by the frontend)
454func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
455	if s.webView == nil {
456		return "", nil
457	}
458	return s.webView.GetCurrentURL(), nil
459}
460
461// GetInitialURL returns the initially navigated URL for frontend synchronization
462func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
463	return s.initialURL, nil
464}
465
466// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
467func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
468	// This is primarily for logging/recording the copy action
469	// The actual clipboard operation is handled by the frontend
470	if url != "" {
471		// Could record this action in analytics if needed
472		fmt.Printf("URL copied to clipboard: %s\n", url)
473	}
474	return nil
475}
476
477// GoBack provides navigation back functionality
478func (s *BrowserService) GoBack(ctx context.Context) error {
479	if s.webView != nil {
480		return s.webView.GoBack()
481	}
482	fmt.Println("Navigation: Go back")
483	return nil
484}
485
486// GoForward provides navigation forward functionality
487func (s *BrowserService) GoForward(ctx context.Context) error {
488	if s.webView != nil {
489		return s.webView.GoForward()
490	}
491	fmt.Println("Navigation: Go forward")
492	return nil
493}
494
495// getNextZoomLevel returns the next Firefox zoom level in the specified direction
496const (
497	zoomMin            = 0.25
498	zoomMax            = 5.0
499	recentHistoryLimit = 1000
500)
501
502func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
503	// Find the closest current zoom level
504	closestIndex := 0
505	minDiff := 10.0 // Large initial value
506
507	for i, level := range firefoxZoomLevels {
508		diff := currentZoom - level
509		if diff < 0 {
510			diff = -diff // abs value
511		}
512		if diff < minDiff {
513			minDiff = diff
514			closestIndex = i
515		}
516	}
517
518	if zoomIn {
519		// Move to next higher zoom level
520		if closestIndex < len(firefoxZoomLevels)-1 {
521			return firefoxZoomLevels[closestIndex+1]
522		}
523		return firefoxZoomLevels[closestIndex] // Already at max
524	}
525	// Move to next lower zoom level
526	if closestIndex > 0 {
527		return firefoxZoomLevels[closestIndex-1]
528	}
529	return firefoxZoomLevels[closestIndex] // Already at min
530}
531
532// setZoom sets the zoom level to a specific value
533func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
534	err := s.SetZoomLevel(ctx, url, zoomLevel)
535	if err != nil {
536		return 1.0, err
537	}
538	return zoomLevel, nil
539}
540
541// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
542// Uses hostname if present; falls back to the full input URL string.
543func zoomKeyFromURL(raw string) string {
544	u, err := neturl.Parse(raw)
545	if err != nil || u.Host == "" {
546		return raw
547	}
548	return u.Host
549}
550
551// extractDomain extracts the domain from a URL for zoom level storage
552// Domain-based zoom allows settings to persist across different pages of the same site
553// extractDomain is no longer used; zoom is stored per URL for contract parity.
554
555// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
556func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
557
558// ColorPalettesResponse holds light and dark palettes for JSON marshaling
559type ColorPalettesResponse struct {
560	Light config.ColorPalette `json:"light"`
561	Dark  config.ColorPalette `json:"dark"`
562}
563
564// GetColorPalettesForMessaging returns the color palettes from config
565func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
566	if s.config == nil {
567		return ColorPalettesResponse{}
568	}
569	return ColorPalettesResponse{
570		Light: s.config.Appearance.LightPalette,
571		Dark:  s.config.Appearance.DarkPalette,
572	}
573}

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