v0.1 bf7c821

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

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