v0.14.1 0da30ba

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

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