v0.9.0 636c44c

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/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		updater := s.windowTitleUpdater
219		if s.webView != nil {
220			s.webView.RunOnMainThread(func() {
221				updater.SetTitle(windowTitle)
222			})
223		} else {
224			updater.SetTitle(windowTitle)
225		}
226	}
227
228	return nil
229}
230
231// GetRecentHistory returns recent browser history entries.
232func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
233	if limit <= 0 {
234		limit = 50 // Default limit
235	}
236
237	entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
238	if err != nil {
239		return nil, err
240	}
241
242	result := make([]HistoryEntry, len(entries))
243	for i, entry := range entries {
244		// Defensive cast to int32 to prevent overflow
245		vc := clampToInt32(entry.VisitCount.Int64)
246		result[i] = HistoryEntry{
247			ID:          entry.ID,
248			URL:         entry.Url,
249			Title:       entry.Title.String,
250			FaviconURL:  entry.FaviconUrl.String,
251			VisitCount:  vc,
252			LastVisited: entry.LastVisited.Time,
253			CreatedAt:   entry.CreatedAt.Time,
254		}
255	}
256
257	return result, nil
258}
259
260// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
261func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
262	if limit <= 0 {
263		limit = 20 // Default limit for pagination
264	}
265	if offset < 0 {
266		offset = 0
267	}
268
269	entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
270	if err != nil {
271		return nil, err
272	}
273
274	result := make([]HistoryEntry, len(entries))
275	for i, entry := range entries {
276		// Defensive cast to int32 to prevent overflow
277		vc := clampToInt32(entry.VisitCount.Int64)
278		result[i] = HistoryEntry{
279			ID:          entry.ID,
280			URL:         entry.Url,
281			Title:       entry.Title.String,
282			FaviconURL:  entry.FaviconUrl.String,
283			VisitCount:  vc,
284			LastVisited: entry.LastVisited.Time,
285			CreatedAt:   entry.CreatedAt.Time,
286		}
287	}
288
289	return result, nil
290}
291
292// SearchHistory searches browser history.
293func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
294	if query == "" {
295		return s.GetRecentHistory(ctx, limit)
296	}
297
298	if limit <= 0 {
299		limit = 50
300	}
301
302	urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
303	titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
304
305	entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
306	if err != nil {
307		return nil, err
308	}
309
310	result := make([]HistoryEntry, len(entries))
311	for i, entry := range entries {
312		vc := clampToInt32(entry.VisitCount.Int64)
313		result[i] = HistoryEntry{
314			ID:          entry.ID,
315			URL:         entry.Url,
316			Title:       entry.Title.String,
317			FaviconURL:  entry.FaviconUrl.String,
318			VisitCount:  vc,
319			LastVisited: entry.LastVisited.Time,
320			CreatedAt:   entry.CreatedAt.Time,
321		}
322	}
323
324	return result, nil
325}
326
327// DeleteHistoryEntry removes a specific history entry.
328func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
329	return s.dbQueries.DeleteHistory(ctx, id)
330}
331
332// ClearHistory removes all history entries.
333func (s *BrowserService) ClearHistory(ctx context.Context) error {
334	_ = ctx
335	// Note: ClearAllHistory method doesn't exist in current schema
336	// This would need to be implemented in the database layer
337	return fmt.Errorf("clear all history not implemented yet")
338}
339
340// GetHistoryStats returns statistics about browser history.
341func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
342	// Get recent entries for basic stats
343	recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
344	if err != nil {
345		return nil, err
346	}
347
348	// Calculate basic statistics
349	stats := map[string]interface{}{
350		"total_entries": len(recentEntries),
351		"recent_count":  len(recentEntries),
352	}
353
354	// Add more detailed stats if we have entries
355	if len(recentEntries) > 0 {
356		stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
357		stats["newest_entry"] = recentEntries[0].CreatedAt.Time
358	}
359
360	return stats, nil
361}
362
363// GetConfig returns the current browser configuration.
364func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
365	_ = ctx
366	return s.config, nil
367}
368
369// UpdateConfig updates the browser configuration.
370func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
371	_ = ctx
372	// In a real implementation, you'd want to validate and persist the config
373	s.config = newConfig
374	return nil
375}
376
377// GetSearchShortcuts returns available search shortcuts.
378func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
379	return s.config.SearchShortcuts, nil
380}
381
382// recordHistory adds or updates a history entry.
383func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
384	// Use AddOrUpdateHistory which handles both cases
385	titleNull := sql.NullString{Valid: false}
386	if title != "" {
387		titleNull = sql.NullString{String: title, Valid: true}
388	}
389
390	return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
391}
392
393// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
394var 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}
395
396// ZoomIn increases the zoom level to the next Firefox zoom level.
397func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
398	currentZoom, err := s.GetZoomLevel(ctx, url)
399	if err != nil {
400		return 1.0, err
401	}
402
403	newZoom := s.getNextZoomLevel(currentZoom, true)
404	err = s.SetZoomLevel(ctx, url, newZoom)
405	if err != nil {
406		return currentZoom, err
407	}
408
409	return newZoom, nil
410}
411
412// ZoomOut decreases the zoom level to the previous Firefox zoom level.
413func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
414	currentZoom, err := s.GetZoomLevel(ctx, url)
415	if err != nil {
416		return 1.0, err
417	}
418
419	newZoom := s.getNextZoomLevel(currentZoom, false)
420	err = s.SetZoomLevel(ctx, url, newZoom)
421	if err != nil {
422		return currentZoom, err
423	}
424
425	return newZoom, nil
426}
427
428// ResetZoom resets the zoom level to 1.0 for the current URL.
429func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
430	return s.setZoom(ctx, url, 1.0)
431}
432
433// GetZoomLevel retrieves the saved zoom level for a URL.
434func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
435	if url == "" {
436		return 1.0, nil
437	}
438
439	key := zoomKeyFromURL(url)
440	zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
441	if err != nil {
442		if errors.Is(err, sql.ErrNoRows) {
443			// Fallback to exact URL record if present (backward compatibility)
444			zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
445			if err2 == nil {
446				return zl, nil
447			}
448			// No zoom setting found, return default
449			return 1.0, nil
450		}
451		return 1.0, err
452	}
453
454	return zoomLevel, nil
455}
456
457// SetZoomLevel sets the zoom level for a URL.
458func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
459	if url == "" {
460		return fmt.Errorf("URL cannot be empty")
461	}
462
463	// Clamp zoom level to reasonable bounds
464	if zoomLevel < zoomMin {
465		zoomLevel = zoomMin
466	} else if zoomLevel > zoomMax {
467		zoomLevel = zoomMax
468	}
469
470	key := zoomKeyFromURL(url)
471	return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
472}
473
474// GetCurrentURL returns the current URL (this would be implemented by the frontend)
475func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
476	if s.webView == nil {
477		return "", nil
478	}
479	return s.webView.GetCurrentURL(), nil
480}
481
482// GetInitialURL returns the initially navigated URL for frontend synchronization
483func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
484	return s.initialURL, nil
485}
486
487// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
488func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
489	// This is primarily for logging/recording the copy action
490	// The actual clipboard operation is handled by the frontend
491	if url != "" {
492		// Could record this action in analytics if needed
493		fmt.Printf("URL copied to clipboard: %s\n", url)
494	}
495	return nil
496}
497
498// GoBack provides navigation back functionality
499func (s *BrowserService) GoBack(ctx context.Context) error {
500	if s.webView != nil {
501		return s.webView.GoBack()
502	}
503	fmt.Println("Navigation: Go back")
504	return nil
505}
506
507// GoForward provides navigation forward functionality
508func (s *BrowserService) GoForward(ctx context.Context) error {
509	if s.webView != nil {
510		return s.webView.GoForward()
511	}
512	fmt.Println("Navigation: Go forward")
513	return nil
514}
515
516// getNextZoomLevel returns the next Firefox zoom level in the specified direction
517const (
518	zoomMin            = 0.25
519	zoomMax            = 5.0
520	recentHistoryLimit = 1000
521)
522
523func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
524	// Find the closest current zoom level
525	closestIndex := 0
526	minDiff := 10.0 // Large initial value
527
528	for i, level := range firefoxZoomLevels {
529		diff := currentZoom - level
530		if diff < 0 {
531			diff = -diff // abs value
532		}
533		if diff < minDiff {
534			minDiff = diff
535			closestIndex = i
536		}
537	}
538
539	if zoomIn {
540		// Move to next higher zoom level
541		if closestIndex < len(firefoxZoomLevels)-1 {
542			return firefoxZoomLevels[closestIndex+1]
543		}
544		return firefoxZoomLevels[closestIndex] // Already at max
545	}
546	// Move to next lower zoom level
547	if closestIndex > 0 {
548		return firefoxZoomLevels[closestIndex-1]
549	}
550	return firefoxZoomLevels[closestIndex] // Already at min
551}
552
553// setZoom sets the zoom level to a specific value
554func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
555	err := s.SetZoomLevel(ctx, url, zoomLevel)
556	if err != nil {
557		return 1.0, err
558	}
559	return zoomLevel, nil
560}
561
562// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
563// Uses hostname if present; falls back to the full input URL string.
564func zoomKeyFromURL(raw string) string {
565	u, err := neturl.Parse(raw)
566	if err != nil || u.Host == "" {
567		return raw
568	}
569	return u.Host
570}
571
572// extractDomain extracts the domain from a URL for zoom level storage
573// Domain-based zoom allows settings to persist across different pages of the same site
574// extractDomain is no longer used; zoom is stored per URL for contract parity.
575
576// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
577func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
578
579// updateFavicon fetches and stores favicon URL for a given page URL
580func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) {
581	parsedURL, err := neturl.Parse(pageURL)
582	if err != nil {
583		return // Silently fail for invalid URLs
584	}
585
586	// Skip favicon update for localhost, file://, or special schemes
587	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
588		return
589	}
590	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
591		return
592	}
593
594	// Standard favicon location
595	faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
596
597	// Update in database
598	faviconNullString := sql.NullString{String: faviconURL, Valid: true}
599	if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
600		// Silently fail - favicon is not critical
601		return
602	}
603
604	// Also cache the favicon for dmenu use
605	if faviconCache, err := cache.NewFaviconCache(); err == nil {
606		faviconCache.CacheAsync(faviconURL)
607	}
608}

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