v0.2.0 759cc92

config.go RawHistoryFolder

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

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