v0.5.0 f906adb

config.go RawHistoryFolder

1// Package config provides configuration management for dumber with Viper integration.
2package config
3
4import (
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/bnema/dumber/pkg/gpu"
15	"github.com/fsnotify/fsnotify"
16	"github.com/spf13/viper"
17)
18
19// File permission constants
20const (
21	dirPerm  = 0755 // Standard directory permissions (rwxr-xr-x)
22	filePerm = 0644 // Standard file permissions (rw-r--r--)
23)
24
25// Config represents the complete configuration for dumber.
26type Config struct {
27	Database        DatabaseConfig            `mapstructure:"database" yaml:"database"`
28	History         HistoryConfig             `mapstructure:"history" yaml:"history"`
29	SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
30	Dmenu           DmenuConfig               `mapstructure:"dmenu" yaml:"dmenu"`
31	Logging         LoggingConfig             `mapstructure:"logging" yaml:"logging"`
32	Appearance        AppearanceConfig        `mapstructure:"appearance" yaml:"appearance"`
33	VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
34	// RenderingMode controls GPU/CPU rendering selection for WebKit
35	RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
36}
37
38// RenderingMode selects GPU vs CPU rendering.
39type RenderingMode string
40
41const (
42	RenderingModeAuto RenderingMode = "auto"
43	RenderingModeGPU  RenderingMode = "gpu"
44	RenderingModeCPU  RenderingMode = "cpu"
45)
46
47// DatabaseConfig holds database-related configuration.
48type DatabaseConfig struct {
49	Path           string        `mapstructure:"path" yaml:"path"`
50	MaxConnections int           `mapstructure:"max_connections" yaml:"max_connections"`
51	MaxIdleTime    time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
52	QueryTimeout   time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
53}
54
55// HistoryConfig holds history-related configuration.
56type HistoryConfig struct {
57	MaxEntries          int `mapstructure:"max_entries" yaml:"max_entries"`
58	RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
59	CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
60}
61
62// SearchShortcut represents a search shortcut configuration.
63type SearchShortcut struct {
64	URL         string `mapstructure:"url" yaml:"url" json:"url"`
65	Description string `mapstructure:"description" yaml:"description" json:"description"`
66}
67
68// DmenuConfig holds dmenu/rofi integration configuration.
69type DmenuConfig struct {
70	MaxHistoryItems  int    `mapstructure:"max_history_items" yaml:"max_history_items"`
71	ShowVisitCount   bool   `mapstructure:"show_visit_count" yaml:"show_visit_count"`
72	ShowLastVisited  bool   `mapstructure:"show_last_visited" yaml:"show_last_visited"`
73	HistoryPrefix    string `mapstructure:"history_prefix" yaml:"history_prefix"`
74	ShortcutPrefix   string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
75	URLPrefix        string `mapstructure:"url_prefix" yaml:"url_prefix"`
76	DateFormat       string `mapstructure:"date_format" yaml:"date_format"`
77	SortByVisitCount bool   `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
78}
79
80// LoggingConfig holds logging configuration.
81type LoggingConfig struct {
82	Level         string `mapstructure:"level" yaml:"level"`
83	Format        string `mapstructure:"format" yaml:"format"`
84	Filename      string `mapstructure:"filename" yaml:"filename"`
85	MaxSize       int    `mapstructure:"max_size" yaml:"max_size"`
86	MaxBackups    int    `mapstructure:"max_backups" yaml:"max_backups"`
87	MaxAge        int    `mapstructure:"max_age" yaml:"max_age"`
88	Compress      bool   `mapstructure:"compress" yaml:"compress"`
89
90	// File output configuration
91	LogDir        string `mapstructure:"log_dir" yaml:"log_dir"`
92	EnableFileLog bool   `mapstructure:"enable_file_log" yaml:"enable_file_log"`
93
94	// Capture settings
95	CaptureStdout  bool `mapstructure:"capture_stdout" yaml:"capture_stdout"`
96	CaptureStderr  bool `mapstructure:"capture_stderr" yaml:"capture_stderr"`
97	CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"`
98
99	// Debug output
100	DebugFile     string `mapstructure:"debug_file" yaml:"debug_file"`
101	VerboseWebKit bool   `mapstructure:"verbose_webkit" yaml:"verbose_webkit"`
102}
103
104// AppearanceConfig holds UI/rendering preferences.
105type AppearanceConfig struct {
106	// Default fonts for pages that do not specify fonts.
107	SansFont      string `mapstructure:"sans_font" yaml:"sans_font"`
108	SerifFont     string `mapstructure:"serif_font" yaml:"serif_font"`
109	MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
110	// Default font size in CSS pixels (approx).
111	DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
112}
113
114// VideoAccelerationConfig holds video hardware acceleration preferences.
115type VideoAccelerationConfig struct {
116	EnableVAAPI      bool   `mapstructure:"enable_vaapi" yaml:"enable_vaapi"`
117	AutoDetectGPU    bool   `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu"`
118	VAAPIDriverName  string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name"`
119	EnableAllDrivers bool   `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers"`
120	LegacyVAAPI      bool   `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
121}
122
123// Manager handles configuration loading, watching, and reloading.
124type Manager struct {
125	config    *Config
126	viper     *viper.Viper
127	mu        sync.RWMutex
128	callbacks []func(*Config)
129	watching  bool
130}
131
132// NewManager creates a new configuration manager.
133func NewManager() (*Manager, error) {
134	v := viper.New()
135
136	// Configure Viper - supports yaml, json, toml automatically
137	v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
138
139	// Add config paths
140	configDir, err := GetConfigDir()
141	if err != nil {
142		return nil, fmt.Errorf("failed to get config directory: %w", err)
143	}
144	v.AddConfigPath(configDir)
145	v.AddConfigPath(".") // Current directory for development
146
147	// Set up environment variable support
148	v.SetEnvPrefix("DUMB_BROWSER")
149	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
150	v.AutomaticEnv()
151
152	// Bind specific environment variables
153	bindings := map[string]string{
154		"database.path":             "DATABASE_PATH",
155		"database.max_connections":  "DATABASE_MAX_CONNECTIONS",
156		"database.max_idle_time":    "DATABASE_MAX_IDLE_TIME",
157		"database.query_timeout":    "DATABASE_QUERY_TIMEOUT",
158		"browser.command":           "BROWSER_COMMAND",
159		"browser.timeout":           "BROWSER_TIMEOUT",
160		"browser.detach_process":    "BROWSER_DETACH_PROCESS",
161		"history.max_entries":       "HISTORY_MAX_ENTRIES",
162		"history.retention_period":  "HISTORY_RETENTION_PERIOD",
163		"history.cleanup_interval":  "HISTORY_CLEANUP_INTERVAL",
164		"dmenu.max_history_items":   "DMENU_MAX_HISTORY_ITEMS",
165		"dmenu.show_visit_count":    "DMENU_SHOW_VISIT_COUNT",
166		"dmenu.show_last_visited":   "DMENU_SHOW_LAST_VISITED",
167		"dmenu.history_prefix":      "DMENU_HISTORY_PREFIX",
168		"dmenu.shortcut_prefix":     "DMENU_SHORTCUT_PREFIX",
169		"dmenu.url_prefix":          "DMENU_URL_PREFIX",
170		"dmenu.date_format":         "DMENU_DATE_FORMAT",
171		"dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
172		"logging.level":             "LOGGING_LEVEL",
173		"logging.format":            "LOGGING_FORMAT",
174		"logging.filename":          "LOGGING_FILENAME",
175		"logging.max_size":          "LOGGING_MAX_SIZE",
176		"logging.max_backups":       "LOGGING_MAX_BACKUPS",
177		"logging.max_age":           "LOGGING_MAX_AGE",
178		"logging.compress":          "LOGGING_COMPRESS",
179	}
180
181	for key, env := range bindings {
182		if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
183			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
184		}
185	}
186
187	// Explicit binding for rendering mode via dedicated env var
188	if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
189		return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
190	}
191
192	// Video acceleration environment variable bindings
193	videoAccelEnvBindings := map[string]string{
194		"video_acceleration.enable_vaapi":       "DUMBER_VIDEO_ACCELERATION_ENABLE",
195		"video_acceleration.auto_detect_gpu":    "DUMBER_VIDEO_AUTO_DETECT",
196		"video_acceleration.vaapi_driver_name":  "LIBVA_DRIVER_NAME",
197		"video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS", 
198		"video_acceleration.legacy_vaapi":       "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
199	}
200
201	for key, env := range videoAccelEnvBindings {
202		if err := v.BindEnv(key, env); err != nil {
203			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
204		}
205	}
206
207	return &Manager{
208		viper:     v,
209		callbacks: make([]func(*Config), 0),
210	}, nil
211}
212
213// Load loads the configuration from file and environment variables.
214func (m *Manager) Load() error {
215	m.mu.Lock()
216	defer m.mu.Unlock()
217
218	// Ensure directories exist
219	if err := EnsureDirectories(); err != nil {
220		return fmt.Errorf("failed to ensure directories: %w", err)
221	}
222
223	// Set defaults
224	m.setDefaults()
225
226	// Read config file if it exists
227	if err := m.viper.ReadInConfig(); err != nil {
228		var configFileNotFoundError viper.ConfigFileNotFoundError
229		if errors.As(err, &configFileNotFoundError) {
230			// Config file not found, create default one
231			if err := m.createDefaultConfig(); err != nil {
232				return fmt.Errorf("failed to create default config: %w", err)
233			}
234		} else {
235			return fmt.Errorf("failed to read config file: %w", err)
236		}
237	}
238
239	// Unmarshal into config struct
240	config := &Config{}
241	if err := m.viper.Unmarshal(config); err != nil {
242		return fmt.Errorf("failed to unmarshal config: %w", err)
243	}
244
245	// Set database path if not specified
246	if config.Database.Path == "" {
247		dbPath, err := GetDatabaseFile()
248		if err != nil {
249			return fmt.Errorf("failed to get database path: %w", err)
250		}
251		config.Database.Path = dbPath
252	}
253
254	// Normalize/validate rendering mode
255	switch strings.ToLower(string(config.RenderingMode)) {
256	case "", string(RenderingModeAuto):
257		config.RenderingMode = RenderingModeAuto
258	case string(RenderingModeGPU):
259		config.RenderingMode = RenderingModeGPU
260	case string(RenderingModeCPU):
261		config.RenderingMode = RenderingModeCPU
262	default:
263		config.RenderingMode = RenderingModeAuto
264	}
265
266	// Auto-detect GPU if enabled and driver name is not set
267	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
268		gpuInfo := gpu.DetectGPU()
269		if gpuInfo.SupportsVAAPI() {
270			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
271		}
272	}
273
274	m.config = config
275	return nil
276}
277
278// Get returns the current configuration (thread-safe).
279func (m *Manager) Get() *Config {
280	m.mu.RLock()
281	defer m.mu.RUnlock()
282
283	// Return a copy to prevent external modification
284	configCopy := *m.config
285	return &configCopy
286}
287
288// Watch starts watching the config file for changes and reloads automatically.
289func (m *Manager) Watch() error {
290	m.mu.Lock()
291	defer m.mu.Unlock()
292
293	if m.watching {
294		return nil // Already watching
295	}
296
297	m.viper.WatchConfig()
298	m.viper.OnConfigChange(func(_ fsnotify.Event) {
299		// Reload config
300		if err := m.reload(); err != nil {
301			fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
302			return
303		}
304
305		// Notify callbacks
306		m.mu.RLock()
307		config := m.config
308		callbacks := make([]func(*Config), len(m.callbacks))
309		copy(callbacks, m.callbacks)
310		m.mu.RUnlock()
311
312		for _, callback := range callbacks {
313			callback(config)
314		}
315	})
316
317	m.watching = true
318	return nil
319}
320
321// OnConfigChange registers a callback function to be called when config changes.
322func (m *Manager) OnConfigChange(callback func(*Config)) {
323	m.mu.Lock()
324	defer m.mu.Unlock()
325
326	m.callbacks = append(m.callbacks, callback)
327}
328
329// reload reloads the configuration (internal method, must be called with lock).
330func (m *Manager) reload() error {
331	if err := m.viper.ReadInConfig(); err != nil {
332		return err
333	}
334
335	config := &Config{}
336	if err := m.viper.Unmarshal(config); err != nil {
337		return err
338	}
339
340	// Set database path if not specified
341	if config.Database.Path == "" {
342		dbPath, err := GetDatabaseFile()
343		if err != nil {
344			return fmt.Errorf("failed to get database path: %w", err)
345		}
346		config.Database.Path = dbPath
347	}
348
349	// Normalize/validate rendering mode
350	switch strings.ToLower(string(config.RenderingMode)) {
351	case "", string(RenderingModeAuto):
352		config.RenderingMode = RenderingModeAuto
353	case string(RenderingModeGPU):
354		config.RenderingMode = RenderingModeGPU
355	case string(RenderingModeCPU):
356		config.RenderingMode = RenderingModeCPU
357	default:
358		config.RenderingMode = RenderingModeAuto
359	}
360
361	// Auto-detect GPU if enabled and driver name is not set
362	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
363		gpuInfo := gpu.DetectGPU()
364		if gpuInfo.SupportsVAAPI() {
365			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
366		}
367	}
368
369	m.config = config
370	return nil
371}
372
373// setDefaults sets default configuration values in Viper.
374func (m *Manager) setDefaults() {
375	defaults := DefaultConfig()
376
377	// Database defaults
378	m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
379	m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
380	m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
381
382	// Note: Browser config removed - we build our own browser
383
384	// History defaults
385	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
386	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
387	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
388
389	// Search shortcuts defaults
390	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
391
392	// Dmenu defaults
393	m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
394	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
395	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
396	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
397	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
398	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
399	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
400	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
401
402	// Logging defaults
403	m.viper.SetDefault("logging.level", defaults.Logging.Level)
404	m.viper.SetDefault("logging.format", defaults.Logging.Format)
405	m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
406	m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
407	m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
408	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
409	m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
410	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
411	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
412	m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
413	m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
414	m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
415	m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
416	m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
417
418	// Appearance defaults
419	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
420	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
421	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
422	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
423
424	// Video acceleration defaults
425	m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
426	m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
427	m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
428	m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
429	m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
430
431	// Rendering defaults
432	m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
433}
434
435// createDefaultConfig creates a default configuration file.
436func (m *Manager) createDefaultConfig() error {
437	configFile, err := GetConfigFile()
438	if err != nil {
439		return err
440	}
441
442	// Create config directory if it doesn't exist
443	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
444		return err
445	}
446
447	// Get the default configuration
448	defaultConfig := DefaultConfig()
449
450	// Marshal to JSON with proper indentation
451	configData, err := json.MarshalIndent(defaultConfig, "", "  ")
452	if err != nil {
453		return fmt.Errorf("failed to marshal default config: %w", err)
454	}
455
456	// Write JSON config file
457	if err := os.WriteFile(configFile, configData, filePerm); err != nil {
458		return fmt.Errorf("failed to write config file: %w", err)
459	}
460
461	fmt.Printf("Created default configuration file: %s\n", configFile)
462	return nil
463}
464
465// GetConfigFile returns the path to the configuration file being used.
466func (m *Manager) GetConfigFile() string {
467	return m.viper.ConfigFileUsed()
468}
469
470// Global configuration manager instance
471var globalManager *Manager
472var globalManagerOnce sync.Once
473
474// Init initializes the global configuration manager.
475func Init() error {
476	var err error
477	globalManagerOnce.Do(func() {
478		globalManager, err = NewManager()
479		if err != nil {
480			return
481		}
482		err = globalManager.Load()
483	})
484	return err
485}
486
487// Get returns the global configuration.
488func Get() *Config {
489	if globalManager == nil {
490		// Return defaults if not initialized
491		return DefaultConfig()
492	}
493	return globalManager.Get()
494}
495
496// Watch starts watching the global configuration for changes.
497func Watch() error {
498	if globalManager == nil {
499		return fmt.Errorf("configuration not initialized")
500	}
501	return globalManager.Watch()
502}
503
504// OnConfigChange registers a callback for global configuration changes.
505func OnConfigChange(callback func(*Config)) {
506	if globalManager == nil {
507		return
508	}
509	globalManager.OnConfigChange(callback)
510}

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