v0.15.0 9006957

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	"fmt"
9	"math"
10	neturl "net/url"
11	"sync"
12	"time"
13
14	"github.com/bnema/dumber/internal/cache"
15	"github.com/bnema/dumber/internal/config"
16	"github.com/bnema/dumber/internal/db"
17	"github.com/bnema/dumber/internal/logging"
18	"github.com/bnema/dumber/internal/parser"
19	"github.com/bnema/dumber/pkg/webkit"
20)
21
22func clampToInt32(i int64) int32 {
23	if i > math.MaxInt32 {
24		i = math.MaxInt32
25	}
26	if i < math.MinInt32 {
27		i = math.MinInt32
28	}
29	return int32(i) //nolint:gosec // value is clamped to int32 bounds
30}
31
32// WindowTitleUpdater interface allows the service to update the window title
33// Decoupled from any specific GUI framework.
34type WindowTitleUpdater interface {
35	SetTitle(title string)
36}
37
38// historyUpdate represents a pending history write operation
39type historyUpdate struct {
40	url   string
41	title sql.NullString
42}
43
44// BrowserService handles browser-related operations for the built-in browser.
45type BrowserService struct {
46	config             *config.Config
47	dbQueries          db.DatabaseQuerier
48	windowTitleUpdater WindowTitleUpdater
49	webView            *webkit.WebView
50	initialURL         string
51	guiBundle          string
52	zoomCache          *cache.ZoomCache            // In-memory cache for zoom levels
53	certCache          *cache.CertValidationsCache // In-memory cache for certificate validations
54	favoritesCache     *cache.FavoritesCache       // In-memory cache for favorites
55	fuzzyCache         *cache.CacheManager         // Async fuzzy search cache for dmenu
56	historyQueue       chan historyUpdate          // Queue for batched history writes
57	historyFlushDone   chan bool                   // Signal when history flush is complete (buffered)
58	flushOnce          sync.Once                   // Ensures FlushHistoryQueue only runs once
59
60	// Fuzzy cache refresh tracking
61	historySinceRefresh int       // Count of history entries added since last cache refresh
62	lastCacheRefresh    time.Time // Timestamp of last fuzzy cache refresh
63}
64
65// ServiceName returns the service name for frontend binding
66func (s *BrowserService) ServiceName() string {
67	return "BrowserService"
68}
69
70// NavigationResult represents the result of a navigation operation.
71type NavigationResult struct {
72	URL           string        `json:"url"`
73	Title         string        `json:"title"`
74	Success       bool          `json:"success"`
75	Error         string        `json:"error,omitempty"`
76	LoadTime      time.Duration `json:"load_time"`
77	RedirectChain []string      `json:"redirect_chain,omitempty"`
78}
79
80// HistoryEntry represents a simplified history entry for frontend.
81type HistoryEntry struct {
82	ID          int64     `json:"id"`
83	URL         string    `json:"url"`
84	Title       string    `json:"title"`
85	FaviconURL  string    `json:"favicon_url"`
86	VisitCount  int32     `json:"visit_count"`
87	LastVisited time.Time `json:"last_visited"`
88	CreatedAt   time.Time `json:"created_at"`
89}
90
91// NewBrowserService creates a new BrowserService instance.
92func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
93	s := &BrowserService{
94		config:              cfg,
95		dbQueries:           queries,
96		windowTitleUpdater:  nil,
97		webView:             nil,
98		initialURL:          "",
99		guiBundle:           "",
100		zoomCache:           cache.NewZoomCache(queries),
101		certCache:           cache.NewCertValidationsCache(queries),
102		favoritesCache:      cache.NewFavoritesCache(queries),
103		fuzzyCache:          cache.NewCacheManager(queries, nil), // nil = use defaults
104		historyQueue:        make(chan historyUpdate, 100),       // Buffer 100 history updates
105		historyFlushDone:    make(chan bool, 1),                  // Buffered to prevent blocking
106		historySinceRefresh: 0,
107		lastCacheRefresh:    time.Now(), // Initialize to now
108	}
109
110	// Start background batch processor for history writes
111	go s.processHistoryQueue()
112
113	return s
114}
115
116// SetWindowTitleUpdater sets the window title updater interface
117func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
118	s.windowTitleUpdater = updater
119}
120
121// AttachWebView connects a native WebKit WebView to this service for integration.
122func (s *BrowserService) AttachWebView(view *webkit.WebView) {
123	s.webView = view
124	// Favicon handling is now managed by FaviconService
125}
126
127// LoadGUIBundle loads the unified GUI bundle from assets
128func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
129	logging.Debug(fmt.Sprintf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js"))
130	bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
131	if err != nil {
132		logging.Error(fmt.Sprintf("[browser] ERROR: Failed to load GUI bundle: %v", err))
133		return fmt.Errorf("failed to load GUI bundle: %w", err)
134	}
135	s.guiBundle = string(bundleBytes)
136	logging.Info(fmt.Sprintf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes)))
137	return nil
138}
139
140// GetGUIBundle returns the loaded GUI bundle string
141func (s *BrowserService) GetGUIBundle() string {
142	return s.guiBundle
143}
144
145// InjectToastSystem injects the GUI bundle and initializes the toast system
146func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
147	_ = ctx
148	if s.webView == nil {
149		return fmt.Errorf("webview not attached")
150	}
151	if s.guiBundle == "" {
152		return fmt.Errorf("GUI bundle not loaded")
153	}
154
155	// Set theme if provided
156	themeScript := ""
157	if theme != "" {
158		themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
159	}
160
161	// Inject the unified GUI bundle and initialize toast system after DOM ready
162	toastScript := s.guiBundle + ";" + themeScript +
163		"(function(){" +
164		"function initToast(){" +
165		"if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
166		"window.__dumber_gui.initializeToast().then(function(){" +
167		"console.log('✅ Toast system initialized via GUI bundle');" +
168		"}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
169		"}else{console.error('❌ GUI bundle not properly loaded');}" +
170		"}" +
171		"if(document.readyState==='loading'){" +
172		"document.addEventListener('DOMContentLoaded',initToast);" +
173		"}else{initToast();}" +
174		"})();"
175
176	return s.webView.InjectScript(toastScript)
177}
178
179// Navigate handles navigation to a URL and records it in history.
180func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
181	startTime := time.Now()
182
183	if url == "" {
184		return &NavigationResult{
185			Success: false,
186			Error:   "URL cannot be empty",
187		}, nil
188	}
189
190	// Store the initial URL for frontend synchronization
191	if s.initialURL == "" {
192		s.initialURL = url
193	}
194
195	// Record the navigation in history
196	err := s.recordHistory(ctx, url, "")
197	if err != nil {
198		// Don't fail navigation if history recording fails
199		fmt.Printf("Failed to record history: %v\n", err)
200	}
201
202	// Favicon detection is now handled automatically by WebKit's native favicon database
203
204	return &NavigationResult{
205		URL:      url,
206		Success:  true,
207		LoadTime: time.Since(startTime),
208	}, nil
209}
210
211// UpdatePageTitle updates the title of the current page in history and the window title.
212func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
213	if url == "" || title == "" {
214		return fmt.Errorf("URL and title cannot be empty")
215	}
216
217	normalizedURL := parser.NormalizeHistoryURL(url)
218
219	// Use AddOrUpdateHistory to update the title
220	titleNull := sql.NullString{String: title, Valid: true}
221	err := s.dbQueries.AddOrUpdateHistory(ctx, normalizedURL, titleNull)
222	if err != nil {
223		return err
224	}
225
226	// Update the window title if we have a title updater
227	if s.windowTitleUpdater != nil && title != "" {
228		windowTitle := fmt.Sprintf("Dumber - %s", title)
229		updater := s.windowTitleUpdater
230		if s.webView != nil {
231			s.webView.RunOnMainThread(func() {
232				updater.SetTitle(windowTitle)
233			})
234		} else {
235			updater.SetTitle(windowTitle)
236		}
237	}
238
239	return nil
240}
241
242// GetRecentHistory returns recent browser history entries.
243func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
244	if limit <= 0 {
245		limit = 50 // Default limit
246	}
247
248	entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
249	if err != nil {
250		return nil, err
251	}
252
253	result := make([]HistoryEntry, len(entries))
254	for i, entry := range entries {
255		// Defensive cast to int32 to prevent overflow
256		vc := clampToInt32(entry.VisitCount.Int64)
257		result[i] = HistoryEntry{
258			ID:          entry.ID,
259			URL:         entry.Url,
260			Title:       entry.Title.String,
261			FaviconURL:  entry.FaviconUrl.String,
262			VisitCount:  vc,
263			LastVisited: entry.LastVisited.Time,
264			CreatedAt:   entry.CreatedAt.Time,
265		}
266	}
267
268	return result, nil
269}
270
271// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
272func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
273	if limit <= 0 {
274		limit = 20 // Default limit for pagination
275	}
276	if offset < 0 {
277		offset = 0
278	}
279
280	entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
281	if err != nil {
282		return nil, err
283	}
284
285	result := make([]HistoryEntry, len(entries))
286	for i, entry := range entries {
287		// Defensive cast to int32 to prevent overflow
288		vc := clampToInt32(entry.VisitCount.Int64)
289		result[i] = HistoryEntry{
290			ID:          entry.ID,
291			URL:         entry.Url,
292			Title:       entry.Title.String,
293			FaviconURL:  entry.FaviconUrl.String,
294			VisitCount:  vc,
295			LastVisited: entry.LastVisited.Time,
296			CreatedAt:   entry.CreatedAt.Time,
297		}
298	}
299
300	return result, nil
301}
302
303// GetMostVisited returns the most visited browser history entries.
304func (s *BrowserService) GetMostVisited(ctx context.Context, limit int) ([]HistoryEntry, error) {
305	if limit <= 0 {
306		limit = 10 // Default limit
307	}
308
309	entries, err := s.dbQueries.GetMostVisited(ctx, int64(limit))
310	if err != nil {
311		return nil, err
312	}
313
314	result := make([]HistoryEntry, len(entries))
315	for i, entry := range entries {
316		// Defensive cast to int32 to prevent overflow
317		vc := clampToInt32(entry.VisitCount.Int64)
318		result[i] = HistoryEntry{
319			ID:          entry.ID,
320			URL:         entry.Url,
321			Title:       entry.Title.String,
322			FaviconURL:  entry.FaviconUrl.String,
323			VisitCount:  vc,
324			LastVisited: entry.LastVisited.Time,
325			CreatedAt:   entry.CreatedAt.Time,
326		}
327	}
328
329	return result, nil
330}
331
332// SearchHistory searches browser history.
333func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
334	if query == "" {
335		return s.GetRecentHistory(ctx, limit)
336	}
337
338	if limit <= 0 {
339		limit = 50
340	}
341
342	urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
343	titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
344
345	entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
346	if err != nil {
347		return nil, err
348	}
349
350	result := make([]HistoryEntry, len(entries))
351	for i, entry := range entries {
352		vc := clampToInt32(entry.VisitCount.Int64)
353		// For omnibox display, convert favicon URI to file path when available
354		faviconURL := entry.FaviconUrl.String
355		result[i] = HistoryEntry{
356			ID:          entry.ID,
357			URL:         entry.Url,
358			Title:       entry.Title.String,
359			FaviconURL:  faviconURL,
360			VisitCount:  vc,
361			LastVisited: entry.LastVisited.Time,
362			CreatedAt:   entry.CreatedAt.Time,
363		}
364	}
365
366	return result, nil
367}
368
369// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
370// Used for fish-style inline autosuggestions in the omnibox.
371func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string {
372	if s.fuzzyCache == nil || prefix == "" {
373		return ""
374	}
375	return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix)
376}
377
378// DeleteHistoryEntry removes a specific history entry.
379func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
380	return s.dbQueries.DeleteHistory(ctx, id)
381}
382
383// ClearHistory removes all history entries.
384func (s *BrowserService) ClearHistory(ctx context.Context) error {
385	_ = ctx
386	// Note: ClearAllHistory method doesn't exist in current schema
387	// This would need to be implemented in the database layer
388	return fmt.Errorf("clear all history not implemented yet")
389}
390
391// GetHistoryStats returns statistics about browser history.
392func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
393	// Get recent entries for basic stats
394	recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
395	if err != nil {
396		return nil, err
397	}
398
399	// Calculate basic statistics
400	stats := map[string]interface{}{
401		"total_entries": len(recentEntries),
402		"recent_count":  len(recentEntries),
403	}
404
405	// Add more detailed stats if we have entries
406	if len(recentEntries) > 0 {
407		stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
408		stats["newest_entry"] = recentEntries[0].CreatedAt.Time
409	}
410
411	return stats, nil
412}
413
414// GetConfig returns the current browser configuration.
415func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
416	_ = ctx
417	return s.config, nil
418}
419
420// UpdateConfig updates the browser configuration.
421func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
422	_ = ctx
423	// In a real implementation, you'd want to validate and persist the config
424	s.config = newConfig
425	return nil
426}
427
428// GetSearchShortcuts returns available search shortcuts.
429func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
430	return s.config.SearchShortcuts, nil
431}
432
433// recordHistory adds or updates a history entry.
434// Now queues the update for batched processing instead of immediate DB write.
435func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
436	normalizedURL := parser.NormalizeHistoryURL(url)
437
438	titleNull := sql.NullString{Valid: false}
439	if title != "" {
440		titleNull = sql.NullString{String: title, Valid: true}
441	}
442
443	// Queue the history update (non-blocking with buffer)
444	select {
445	case s.historyQueue <- historyUpdate{url: normalizedURL, title: titleNull}:
446		// Successfully queued
447		return nil
448	default:
449		// Queue full, write directly (fallback)
450		logging.Warn(fmt.Sprintf("Warning: history queue full, writing directly"))
451		return s.dbQueries.AddOrUpdateHistory(ctx, normalizedURL, titleNull)
452	}
453}
454
455// processHistoryQueue processes batched history writes in the background.
456// Flushes every 5 seconds or when a batch reaches 50 items.
457func (s *BrowserService) processHistoryQueue() {
458	ticker := time.NewTicker(5 * time.Second)
459	defer ticker.Stop()
460
461	batch := make([]historyUpdate, 0, 50)
462	const maxBatchSize = 50
463
464	flush := func() {
465		if len(batch) == 0 {
466			return
467		}
468
469		ctx := context.Background()
470		// Process all updates in the batch
471		// Note: We can't use a transaction with sqlc-generated code easily,
472		// but we can still batch the operations to reduce overhead
473		for _, update := range batch {
474			if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil {
475				logging.Warn(fmt.Sprintf("Warning: failed to write history for %s: %v", update.url, err))
476			}
477		}
478
479		logging.Debug(fmt.Sprintf("Flushed %d history updates to database", len(batch)))
480
481		// Track history entries for fuzzy cache refresh
482		batchSize := len(batch)
483		batch = batch[:0] // Clear batch
484
485		// Smart fuzzy cache refresh: rebuild after N entries AND M minutes
486		const minEntries = 10               // Minimum new entries before refresh
487		const minInterval = 5 * time.Minute // Minimum time between refreshes
488
489		s.historySinceRefresh += batchSize
490		timeSinceRefresh := time.Since(s.lastCacheRefresh)
491
492		if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval {
493			logging.Debug(fmt.Sprintf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)",
494				s.historySinceRefresh, timeSinceRefresh))
495
496			// Async refresh in background (non-blocking)
497			s.fuzzyCache.InvalidateAndRefresh(ctx)
498
499			// Reset counters
500			s.historySinceRefresh = 0
501			s.lastCacheRefresh = time.Now()
502		}
503	}
504
505	for {
506		select {
507		case update, ok := <-s.historyQueue:
508			if !ok {
509				// Channel closed, flush remaining and exit
510				flush()
511				s.historyFlushDone <- true // Send signal (non-blocking due to buffer)
512				return
513			}
514			batch = append(batch, update)
515			if len(batch) >= maxBatchSize {
516				flush()
517			}
518
519		case <-ticker.C:
520			flush()
521		}
522	}
523}
524
525// FlushHistoryQueue flushes any pending history writes and stops the processor.
526// Call this during graceful shutdown. Safe to call multiple times.
527// Respects context cancellation with a timeout to prevent hanging.
528func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error {
529	var flushErr error
530	s.flushOnce.Do(func() {
531		close(s.historyQueue)
532
533		// Wait for completion with context cancellation support
534		select {
535		case <-s.historyFlushDone:
536			logging.Info(fmt.Sprintf("History queue flushed and closed"))
537		case <-ctx.Done():
538			flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err())
539			logging.Warn(fmt.Sprintf("Warning: %v", flushErr))
540		}
541	})
542	return flushErr
543}
544
545// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
546var 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}
547
548// ZoomIn increases the zoom level to the next Firefox zoom level.
549func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
550	currentZoom, err := s.GetZoomLevel(ctx, url)
551	if err != nil {
552		return 1.0, err
553	}
554
555	newZoom := s.getNextZoomLevel(currentZoom, true)
556	err = s.SetZoomLevel(ctx, url, newZoom)
557	if err != nil {
558		return currentZoom, err
559	}
560
561	return newZoom, nil
562}
563
564// ZoomOut decreases the zoom level to the previous Firefox zoom level.
565func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
566	currentZoom, err := s.GetZoomLevel(ctx, url)
567	if err != nil {
568		return 1.0, err
569	}
570
571	newZoom := s.getNextZoomLevel(currentZoom, false)
572	err = s.SetZoomLevel(ctx, url, newZoom)
573	if err != nil {
574		return currentZoom, err
575	}
576
577	return newZoom, nil
578}
579
580// ResetZoom resets the zoom level to 1.0 for the current URL.
581func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
582	return s.setZoom(ctx, url, 1.0)
583}
584
585// LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup.
586// This eliminates database reads during page transitions.
587func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error {
588	if err := s.zoomCache.Load(ctx); err != nil {
589		return fmt.Errorf("failed to load zoom levels: %w", err)
590	}
591
592	loadedCount := len(s.zoomCache.List())
593	logging.Info(fmt.Sprintf("Loaded %d zoom levels into cache", loadedCount))
594	return nil
595}
596
597// LoadCertCacheFromDB loads all certificate validations from the database into the cache.
598// This is typically called once at startup for fast RAM-first access.
599func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error {
600	if err := s.certCache.Load(ctx); err != nil {
601		return fmt.Errorf("failed to load certificate validations: %w", err)
602	}
603
604	loadedCount := len(s.certCache.List())
605	logging.Info(fmt.Sprintf("Loaded %d certificate validations into cache", loadedCount))
606	return nil
607}
608
609// LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history.
610// This ensures dmenu has instant access to pre-built search structures.
611func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
612	// GetCache automatically loads from binary file if valid, or builds from DB
613	_, err := s.fuzzyCache.GetCache(ctx)
614	if err != nil {
615		return fmt.Errorf("failed to load fuzzy cache: %w", err)
616	}
617
618	stats := s.fuzzyCache.Stats()
619	logging.Info(fmt.Sprintf("Loaded fuzzy cache: %d entries, %d trigrams",
620		stats.EntryCount, stats.TrigramCount))
621	return nil
622}
623
624// LoadFavoritesCacheFromDB loads all favorites from the database into the cache.
625// This is typically called once at startup for fast RAM-first access.
626func (s *BrowserService) LoadFavoritesCacheFromDB(ctx context.Context) error {
627	if err := s.favoritesCache.Load(ctx); err != nil {
628		return fmt.Errorf("failed to load favorites: %w", err)
629	}
630
631	loadedCount := len(s.favoritesCache.List())
632	logging.Info(fmt.Sprintf("Loaded %d favorites into cache", loadedCount))
633	return nil
634}
635
636// FavoriteEntry represents a favorite for the frontend (similar to HistoryEntry).
637type FavoriteEntry struct {
638	ID         int64  `json:"id"`
639	URL        string `json:"url"`
640	Title      string `json:"title"`
641	FaviconURL string `json:"favicon_url"`
642	Position   int64  `json:"position"`
643}
644
645// GetFavorites returns all favorites ordered by position.
646func (s *BrowserService) GetFavorites(ctx context.Context) ([]FavoriteEntry, error) {
647	favorites := s.favoritesCache.GetAll()
648
649	result := make([]FavoriteEntry, len(favorites))
650	for i, fav := range favorites {
651		result[i] = FavoriteEntry{
652			ID:         fav.ID,
653			URL:        fav.Url,
654			Title:      fav.Title.String,
655			FaviconURL: fav.FaviconUrl.String,
656			Position:   fav.Position,
657		}
658	}
659
660	return result, nil
661}
662
663// IsFavorite checks if a URL is in the favorites list.
664func (s *BrowserService) IsFavorite(ctx context.Context, url string) (bool, error) {
665	if url == "" {
666		return false, nil
667	}
668
669	_, exists := s.favoritesCache.Get(url)
670	return exists, nil
671}
672
673// ToggleFavorite adds or removes a URL from favorites.
674// Returns true if the URL was added, false if it was removed.
675func (s *BrowserService) ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error) {
676	if url == "" {
677		return false, fmt.Errorf("URL cannot be empty")
678	}
679
680	// Check if already favorited
681	_, exists := s.favoritesCache.Get(url)
682
683	if exists {
684		// Remove from favorites
685		if err := s.favoritesCache.Delete(url); err != nil {
686			return false, fmt.Errorf("failed to remove favorite: %w", err)
687		}
688		logging.Info(fmt.Sprintf("Removed favorite: %s", url))
689		return false, nil
690	}
691
692	// Add to favorites
693	favorite := db.Favorite{
694		Url:        url,
695		Title:      sql.NullString{String: title, Valid: title != ""},
696		FaviconUrl: sql.NullString{String: faviconURL, Valid: faviconURL != ""},
697	}
698
699	if err := s.favoritesCache.Set(url, favorite); err != nil {
700		return false, fmt.Errorf("failed to add favorite: %w", err)
701	}
702
703	logging.Info(fmt.Sprintf("Added favorite: %s", url))
704	return true, nil
705}
706
707// FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
708// This is typically called during shutdown to prevent data loss.
709func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
710	// Flush zoom cache
711	if err := s.zoomCache.Flush(); err != nil {
712		return fmt.Errorf("failed to flush zoom cache: %w", err)
713	}
714
715	// Flush certificate validation cache
716	if err := s.certCache.Flush(); err != nil {
717		return fmt.Errorf("failed to flush cert cache: %w", err)
718	}
719
720	// Flush favorites cache
721	if err := s.favoritesCache.Flush(); err != nil {
722		return fmt.Errorf("failed to flush favorites cache: %w", err)
723	}
724
725	// Save fuzzy cache to binary file for instant dmenu access
726	s.fuzzyCache.OnApplicationExit(ctx)
727
728	// Flush history queue (existing mechanism)
729	if err := s.FlushHistoryQueue(ctx); err != nil {
730		return fmt.Errorf("failed to flush history queue: %w", err)
731	}
732
733	logging.Info(fmt.Sprintf("All caches flushed successfully"))
734	return nil
735}
736
737// GetZoomLevel retrieves the saved zoom level for a URL.
738// Now reads from in-memory cache instead of database for instant access.
739func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
740	if url == "" {
741		return s.config.DefaultZoom, nil
742	}
743
744	key := zoomKeyFromURL(url)
745
746	// Check cache first (fast RAM lookup)
747	if zoom, ok := s.zoomCache.Get(key); ok {
748		return zoom, nil
749	}
750
751	// Fallback to exact URL record for backward compatibility (cache miss)
752	if key != url {
753		if zoom, ok := s.zoomCache.Get(url); ok {
754			return zoom, nil
755		}
756	}
757
758	// No zoom setting found, return configured default
759	return s.config.DefaultZoom, nil
760}
761
762// SetZoomLevel sets the zoom level for a URL.
763// Updates cache immediately (instant) and persists to DB asynchronously.
764func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
765	if url == "" {
766		return fmt.Errorf("URL cannot be empty")
767	}
768
769	// Clamp zoom level to reasonable bounds
770	if zoomLevel < zoomMin {
771		zoomLevel = zoomMin
772	} else if zoomLevel > zoomMax {
773		zoomLevel = zoomMax
774	}
775
776	key := zoomKeyFromURL(url)
777
778	// Update cache immediately (fast, synchronous) and persist async
779	return s.zoomCache.Set(key, zoomLevel)
780}
781
782// GetCurrentURL returns the current URL (this would be implemented by the frontend)
783func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
784	if s.webView == nil {
785		return "", nil
786	}
787	return s.webView.GetCurrentURL(), nil
788}
789
790// GetInitialURL returns the initially navigated URL for frontend synchronization
791func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
792	return s.initialURL, nil
793}
794
795// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
796func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
797	// This is primarily for logging/recording the copy action
798	// The actual clipboard operation is handled by the frontend
799	if url != "" {
800		// Could record this action in analytics if needed
801		fmt.Printf("URL copied to clipboard: %s\n", url)
802	}
803	return nil
804}
805
806// GoBack provides navigation back functionality
807func (s *BrowserService) GoBack(ctx context.Context) error {
808	if s.webView != nil {
809		return s.webView.GoBack()
810	}
811	fmt.Println("Navigation: Go back")
812	return nil
813}
814
815// GoForward provides navigation forward functionality
816func (s *BrowserService) GoForward(ctx context.Context) error {
817	if s.webView != nil {
818		return s.webView.GoForward()
819	}
820	fmt.Println("Navigation: Go forward")
821	return nil
822}
823
824// getNextZoomLevel returns the next Firefox zoom level in the specified direction
825const (
826	zoomMin            = 0.25
827	zoomMax            = 5.0
828	recentHistoryLimit = 1000
829)
830
831func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
832	// Find the closest current zoom level
833	closestIndex := 0
834	minDiff := 10.0 // Large initial value
835
836	for i, level := range firefoxZoomLevels {
837		diff := currentZoom - level
838		if diff < 0 {
839			diff = -diff // abs value
840		}
841		if diff < minDiff {
842			minDiff = diff
843			closestIndex = i
844		}
845	}
846
847	if zoomIn {
848		// Move to next higher zoom level
849		if closestIndex < len(firefoxZoomLevels)-1 {
850			return firefoxZoomLevels[closestIndex+1]
851		}
852		return firefoxZoomLevels[closestIndex] // Already at max
853	}
854	// Move to next lower zoom level
855	if closestIndex > 0 {
856		return firefoxZoomLevels[closestIndex-1]
857	}
858	return firefoxZoomLevels[closestIndex] // Already at min
859}
860
861// setZoom sets the zoom level to a specific value
862func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
863	err := s.SetZoomLevel(ctx, url, zoomLevel)
864	if err != nil {
865		return 1.0, err
866	}
867	return zoomLevel, nil
868}
869
870// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
871// Uses hostname if present; falls back to the full input URL string.
872func zoomKeyFromURL(raw string) string {
873	u, err := neturl.Parse(raw)
874	if err != nil || u.Host == "" {
875		return raw
876	}
877	return u.Host
878}
879
880// extractDomain extracts the domain from a URL for zoom level storage
881// Domain-based zoom allows settings to persist across different pages of the same site
882// extractDomain is no longer used; zoom is stored per URL for contract parity.
883
884// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
885func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
886
887// ColorPalettesResponse holds light and dark palettes for JSON marshaling
888type ColorPalettesResponse struct {
889	Light config.ColorPalette `json:"light"`
890	Dark  config.ColorPalette `json:"dark"`
891}
892
893// GetColorPalettesForMessaging returns the color palettes from config
894func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
895	if s.config == nil {
896		return ColorPalettesResponse{}
897	}
898	return ColorPalettesResponse{
899		Light: s.config.Appearance.LightPalette,
900		Dark:  s.config.Appearance.DarkPalette,
901	}
902}

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