v0.13.0 976176a

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

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