v0.8.0 0fa6785

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

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