v0.4.0 6203a22

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

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