v0.5.0 f906adb

browser_service.go RawHistoryFolder

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

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