v0.30.0 eb3068f

loader.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/infrastructure/fonts"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/spf13/viper"
16)
17
18// Manager handles configuration loading, watching, and reloading.
19type Manager struct {
20	config         *Config
21	viper          *viper.Viper
22	automaticEnv   automaticEnvConfig
23	mu             sync.RWMutex
24	callbacks      []func(*Config)
25	watching       bool
26	skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
27}
28
29type automaticEnvConfig struct {
30	enabled     bool
31	prefix      string
32	keyReplacer *strings.Replacer
33}
34
35// NewManager creates a new configuration manager.
36func NewManager() (*Manager, error) {
37	v := viper.New()
38	m := &Manager{
39		viper:     v,
40		callbacks: make([]func(*Config), 0),
41	}
42
43	// Configure Viper for TOML as default format
44	v.SetConfigName("config") // Name without extension
45	v.SetConfigType("toml")   // TOML as default format
46
47	// Add config paths
48	configDir, err := GetConfigDir()
49	if err != nil {
50		return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
51	}
52	v.AddConfigPath(configDir)
53	v.AddConfigPath(".") // Current directory for development
54
55	// Set up environment variable support
56	m.configureAutomaticEnv()
57
58	// Note: Most environment variables are handled automatically via AutomaticEnv()
59	// with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
60	// The explicit bindings below are only for special cases with different naming patterns.
61
62	// Explicit bindings for legacy or system env vars
63	if err := v.BindEnv("default_webpage_zoom", "DUMBER_DEFAULT_WEBPAGE_ZOOM"); err != nil {
64		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_WEBPAGE_ZOOM: %w", err)
65	}
66	if err := v.BindEnv("default_ui_scale", "DUMBER_DEFAULT_UI_SCALE"); err != nil {
67		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_UI_SCALE: %w", err)
68	}
69
70	// Logging environment variable bindings
71	if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
72		return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
73	}
74	if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
75		return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
76	}
77	if err := bindBrowsingContextEnvAliases(v); err != nil {
78		return nil, err
79	}
80
81	return m, nil
82}
83
84// Load loads the configuration from file and environment variables.
85func (m *Manager) Load() error {
86	m.mu.Lock()
87	defer m.mu.Unlock()
88
89	if err := EnsureDirectories(); err != nil {
90		return fmt.Errorf("failed to ensure directories: %w", err)
91	}
92
93	m.setDefaults()
94
95	if err := m.readConfigFile(); err != nil {
96		return err
97	}
98
99	if err := m.checkLegacyFormat(); err != nil {
100		return err
101	}
102
103	config, err := m.unmarshalConfig()
104	if err != nil {
105		return err
106	}
107	if err := ensureDatabasePath(config); err != nil {
108		return err
109	}
110	normalizeConfig(config)
111
112	if err := validateConfig(config); err != nil {
113		return fmt.Errorf("configuration validation failed: %w", err)
114	}
115
116	m.config = config
117	return nil
118}
119
120func (m *Manager) readConfigFile() error {
121	if err := m.viper.ReadInConfig(); err != nil {
122		var configFileNotFoundError viper.ConfigFileNotFoundError
123		if errors.As(err, &configFileNotFoundError) {
124			if createErr := m.createDefaultConfig(); createErr != nil {
125				configDir, _ := GetConfigDir()
126				return fmt.Errorf(
127					"failed to create default config at %s: %w\nTry creating the directory manually or check permissions",
128					configDir,
129					createErr,
130				)
131			}
132			if rereadErr := m.viper.ReadInConfig(); rereadErr != nil {
133				return fmt.Errorf(
134					"failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format",
135					rereadErr,
136				)
137			}
138		} else {
139			configFile := m.viper.ConfigFileUsed()
140			if configFile == "" {
141				configDir, _ := GetConfigDir()
142				configFile = filepath.Join(configDir, "config.toml")
143			}
144			return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
145		}
146	}
147
148	// Transform legacy action bindings and engine config before unmarshaling.
149	m.transformLegacyConfig()
150
151	return nil
152}
153
154// transformLegacyConfig converts old-format action bindings and engine config to the new format.
155// This is called after reading config but before unmarshaling.
156func (m *Manager) transformLegacyConfig() {
157	legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158
159	rawConfig := m.viper.AllSettings()
160	transformer := NewLegacyConfigTransformer()
161	transformer.TransformLegacyActions(rawConfig)
162	transformer.TransformLegacyEngineConfigWithExplicitAdaptive(
163		rawConfig,
164		m.hasExplicitAdaptiveWindowlessFrameRateSetting(),
165		m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"),
166	)
167
168	// AllSettings includes defaults, so workspace.browsing_contexts may already be
169	// present even when the user only configured legacy workspace.popups. Always
170	// merge legacy popup values field-by-field into the canonical section while
171	// preserving explicit canonical file/env overrides.
172	if legacyPopupsInConfig {
173		m.mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig)
174	} else {
175		transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176	}
177
178	// Merge newly added default actions into in-memory config so existing persisted
179	// action maps keep working before the user writes a migrated config to disk.
180	configMigrator.mergeMissingDefaultActions(rawConfig)
181
182	// Apply transformed config back to viper
183	for key, value := range rawConfig {
184		m.viper.Set(key, value)
185	}
186}
187
188func (m *Manager) configureAutomaticEnv() {
189	if m == nil || m.viper == nil {
190		return
191	}
192	replacer := strings.NewReplacer(".", "_")
193	m.viper.SetEnvPrefix("DUMBER")
194	m.viper.SetEnvKeyReplacer(replacer)
195	m.viper.AutomaticEnv()
196	m.automaticEnv = automaticEnvConfig{
197		enabled:     true,
198		prefix:      "DUMBER",
199		keyReplacer: replacer,
200	}
201}
202
203func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool {
204	if m == nil || m.viper == nil {
205		return false
206	}
207	if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") {
208		return true
209	}
210	if !m.automaticEnv.enabled {
211		return false
212	}
213	envKey := "engine.cef.adaptive_windowless_frame_rate"
214	if m.automaticEnv.keyReplacer != nil {
215		envKey = m.automaticEnv.keyReplacer.Replace(envKey)
216	}
217	envKey = strings.ToUpper(envKey)
218	if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" {
219		envKey = prefix + "_" + envKey
220	}
221	value, ok := os.LookupEnv(envKey)
222	return ok && value != ""
223}
224
225func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {
226	workspace, ok := rawConfig["workspace"].(map[string]any)
227	if !ok {
228		return
229	}
230
231	legacyPopups, ok := workspace["popups"].(map[string]any)
232	if !ok {
233		return
234	}
235
236	canonical, ok := workspace["browsing_contexts"].(map[string]any)
237	if !ok || canonical == nil {
238		canonical = make(map[string]any, len(legacyPopups))
239		workspace["browsing_contexts"] = canonical
240	}
241
242	for key, value := range legacyPopups {
243		if m.hasCanonicalBrowsingContextsOverride(key) {
244			continue
245		}
246		canonical[key] = value
247	}
248
249	delete(workspace, "popups")
250}
251
252func (m *Manager) hasCanonicalBrowsingContextsOverride(field string) bool {
253	if m.viper.InConfig("workspace.browsing_contexts." + field) {
254		return true
255	}
256	return m.hasCanonicalBrowsingContextsEnvOverride(field)
257}
258
259func (*Manager) hasCanonicalBrowsingContextsEnvOverride(field string) bool {
260	suffix := strings.ToUpper(strings.ReplaceAll(field, ".", "_"))
261	for _, envKey := range []string{
262		"DUMBER_WORKSPACE_BROWSING_CONTEXTS_" + suffix,
263		"DUMBER_WORKSPACE_POPUPS_" + suffix,
264	} {
265		if _, ok := os.LookupEnv(envKey); ok {
266			return true
267		}
268	}
269	return false
270}
271
272func bindBrowsingContextEnvAliases(v *viper.Viper) error {
273	bindings := map[string][]string{
274		"workspace.browsing_contexts.behavior": {
275			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_BEHAVIOR",
276			"DUMBER_WORKSPACE_POPUPS_BEHAVIOR",
277		},
278		"workspace.browsing_contexts.placement": {
279			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_PLACEMENT",
280			"DUMBER_WORKSPACE_POPUPS_PLACEMENT",
281		},
282		"workspace.browsing_contexts.open_in_new_pane": {
283			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_OPEN_IN_NEW_PANE",
284			"DUMBER_WORKSPACE_POPUPS_OPEN_IN_NEW_PANE",
285		},
286		"workspace.browsing_contexts.follow_pane_context": {
287			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_FOLLOW_PANE_CONTEXT",
288			"DUMBER_WORKSPACE_POPUPS_FOLLOW_PANE_CONTEXT",
289		},
290		"workspace.browsing_contexts.blank_target_behavior": {
291			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_BLANK_TARGET_BEHAVIOR",
292			"DUMBER_WORKSPACE_POPUPS_BLANK_TARGET_BEHAVIOR",
293		},
294		"workspace.browsing_contexts.enable_smart_detection": {
295			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_ENABLE_SMART_DETECTION",
296			"DUMBER_WORKSPACE_POPUPS_ENABLE_SMART_DETECTION",
297		},
298		"workspace.browsing_contexts.oauth_auto_close": {
299			"DUMBER_WORKSPACE_BROWSING_CONTEXTS_OAUTH_AUTO_CLOSE",
300			"DUMBER_WORKSPACE_POPUPS_OAUTH_AUTO_CLOSE",
301		},
302	}
303	for key, envs := range bindings {
304		args := append([]string{key}, envs...)
305		if err := v.BindEnv(args...); err != nil {
306			return fmt.Errorf("failed to bind browsing-context env aliases for %s: %w", key, err)
307		}
308	}
309	return nil
310}
311
312// checkLegacyFormat detects old config format and returns an error directing user to migrate.
313func (m *Manager) checkLegacyFormat() error {
314	// IsSet works for legacy keys because they do not have defaults, while
315	// engine.type has a default and must be checked with InConfig.
316	hasOldSections := false
317	for _, alias := range legacyEngineAliases {
318		if m.viper.IsSet(alias.legacyKey()) {
319			hasOldSections = true
320			break
321		}
322	}
323
324	hasEngineSection := m.hasEngineSectionInConfig()
325
326	if hasOldSections && hasEngineSection {
327		return fmt.Errorf(
328			"mixed old/new config: [engine] coexists with legacy engine keys in %s; "+
329				"remove legacy keys or migrate from a purely legacy config",
330			legacyEngineInputSectionsMessage(),
331		)
332	}
333	if hasOldSections {
334		return fmt.Errorf(
335			"config format outdated: legacy engine keys in %s have moved to "+
336				"[engine]/[engine.webkit] — run \"dumber config migrate\" "+
337				"to update your config file",
338			legacyEngineInputSectionsMessage(),
339		)
340	}
341	return nil
342}
343
344func (m *Manager) hasEngineSectionInConfig() bool {
345	if configFile := m.viper.ConfigFileUsed(); configFile != "" {
346		raw, err := readRawTOML(configFile)
347		if err == nil {
348			_, exists := raw["engine"]
349			return exists
350		}
351	}
352	return m.viper.InConfig("engine.type")
353}
354
355func (m *Manager) unmarshalConfig() (*Config, error) {
356	config := &Config{}
357	if err := m.viper.Unmarshal(config); err != nil {
358		configFile := m.viper.ConfigFileUsed()
359		return nil, fmt.Errorf(
360			"failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
361			configFile,
362			err,
363		)
364	}
365	return config, nil
366}
367
368func ensureDatabasePath(config *Config) error {
369	if config.Database.Path != "" {
370		return nil
371	}
372	dbPath, err := GetDatabaseFile()
373	if err != nil {
374		return fmt.Errorf("failed to get database path: %w", err)
375	}
376	config.Database.Path = dbPath
377	return nil
378}
379
380func normalizeConfig(config *Config) {
381	normalizeAppearance(config)
382	normalizeMedia(config)
383	normalizeEngineConfig(config)
384	normalizeBrowsingContexts(config)
385}
386
387// normalizeBrowsingContexts ensures the deprecated Popups field mirrors BrowsingContexts
388// for runtime compatibility. Existing code can keep reading config.Workspace.Popups.
389func normalizeBrowsingContexts(config *Config) {
390	config.Workspace.Popups = config.Workspace.BrowsingContexts
391}
392
393func normalizeEngineConfig(config *Config) {
394	// Normalize GSK renderer (engine.webkit)
395	switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
396	case "", string(GSKRendererAuto):
397		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
398	case string(GSKRendererOpenGL):
399		config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL
400	case string(GSKRendererVulkan):
401		config.Engine.WebKit.GSKRenderer = GSKRendererVulkan
402	case string(GSKRendererCairo):
403		config.Engine.WebKit.GSKRenderer = GSKRendererCairo
404	default:
405		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
406	}
407
408	// Normalize cookie policy (engine).
409	// When no value (or an invalid value) is set, respect the ITP setting: if ITP is
410	// disabled the effective default is to block third-party cookies; if ITP is enabled
411	// all cookies are accepted by default.  An explicit "always" value always wins.
412	itpDefault := func() CookiePolicy {
413		if config.Engine.WebKit.ITPEnabled {
414			return CookiePolicyAlways
415		}
416		return CookiePolicyNoThirdParty
417	}
418	switch strings.ToLower(string(config.Engine.CookiePolicy)) {
419	case "":
420		config.Engine.CookiePolicy = itpDefault()
421	case string(CookiePolicyAlways):
422		config.Engine.CookiePolicy = CookiePolicyAlways
423	case string(CookiePolicyNoThirdParty):
424		config.Engine.CookiePolicy = CookiePolicyNoThirdParty
425	case string(CookiePolicyNever):
426		config.Engine.CookiePolicy = CookiePolicyNever
427	default:
428		config.Engine.CookiePolicy = itpDefault()
429	}
430
431	// Normalize performance profile (engine)
432	switch strings.ToLower(string(config.Engine.Profile)) {
433	case "", string(ProfileDefault):
434		config.Engine.Profile = ProfileDefault
435	case string(ProfileLite):
436		config.Engine.Profile = ProfileLite
437	case string(ProfileBalanced):
438		config.Engine.Profile = ProfileBalanced
439	case string(ProfileMax):
440		config.Engine.Profile = ProfileMax
441	case string(ProfileCustom):
442		config.Engine.Profile = ProfileCustom
443	default:
444		config.Engine.Profile = ProfileDefault
445	}
446
447	// Trim runtime prefix (engine.webkit)
448	config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix)
449}
450
451func normalizeAppearance(config *Config) {
452	config.Appearance.ExternalTheme.Provider = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Provider))
453	config.Appearance.ExternalTheme.Format = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Format))
454	config.Appearance.ExternalTheme.Path = strings.TrimSpace(config.Appearance.ExternalTheme.Path)
455	if config.Appearance.ExternalTheme.Provider == "" {
456		config.Appearance.ExternalTheme.Provider = defaultExternalThemeProvider
457	}
458	if config.Appearance.ExternalTheme.Format == "" {
459		config.Appearance.ExternalTheme.Format = defaultExternalThemeFormat
460	}
461	if config.Appearance.ExternalTheme.Enabled &&
462		config.Appearance.ExternalTheme.Provider == defaultExternalThemeProvider &&
463		isSupportedExternalThemeFormat(config.Appearance.ExternalTheme.Format) &&
464		config.Appearance.ExternalTheme.Path == "" {
465		config.Appearance.ExternalTheme.Path = getDefaultExternalThemePathForFormat(config.Appearance.ExternalTheme.Format)
466	}
467
468	switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
469	case strings.ToLower(ThemePreferDark):
470		config.Appearance.ColorScheme = ThemePreferDark
471	case strings.ToLower(ThemePreferLight):
472		config.Appearance.ColorScheme = ThemePreferLight
473	case strings.ToLower(ThemeDefault):
474		config.Appearance.ColorScheme = ThemeDefault
475	default:
476		config.Appearance.ColorScheme = ThemeDefault
477	}
478}
479
480func normalizeMedia(config *Config) {
481	switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
482	case "", string(HardwareDecodingAuto):
483		config.Media.HardwareDecodingMode = HardwareDecodingAuto
484	case string(HardwareDecodingForce):
485		config.Media.HardwareDecodingMode = HardwareDecodingForce
486	case string(HardwareDecodingDisable):
487		config.Media.HardwareDecodingMode = HardwareDecodingDisable
488	default:
489		config.Media.HardwareDecodingMode = HardwareDecodingAuto
490	}
491}
492
493// Get returns the current configuration (thread-safe).
494func (m *Manager) Get() *Config {
495	m.mu.RLock()
496	defer m.mu.RUnlock()
497
498	// Return a copy to prevent external modification
499	configCopy := *m.config
500	return &configCopy
501}
502
503// Save saves the provided configuration to disk with ordered sections.
504func (m *Manager) Save(cfg *Config) error {
505	m.mu.Lock()
506	defer m.mu.Unlock()
507
508	if cfg == nil {
509		return fmt.Errorf("config is nil")
510	}
511
512	// Normalize a copy before writing/publishing so deprecated runtime aliases
513	// stay in sync even when the watcher skips reload after Save().
514	cfgToSave := *cfg
515	normalizeConfig(&cfgToSave)
516
517	// Validate before writing so UI gets immediate errors.
518	if err := validateConfig(&cfgToSave); err != nil {
519		return fmt.Errorf("configuration validation failed: %w", err)
520	}
521
522	// Write config with ordered sections (bypasses Viper's random map ordering)
523	configFile := m.viper.ConfigFileUsed()
524	if configFile == "" {
525		var err error
526		configFile, err = GetConfigFile()
527		if err != nil {
528			return fmt.Errorf("failed to determine config file path: %w", err)
529		}
530	}
531
532	// Set flag BEFORE writing to prevent fsnotify-triggered reload from
533	// overwriting our in-memory config with stale viper cache
534	m.skipNextReload = true
535
536	if err := WriteConfigOrdered(&cfgToSave, configFile); err != nil {
537		m.skipNextReload = false // Reset on error
538		return fmt.Errorf("failed to write config: %w", err)
539	}
540
541	// Update in-memory config immediately (don't wait for file watcher).
542	// This ensures subsequent Get() calls return normalized values even when
543	// the watcher intentionally skips reload for our own Save().
544	m.config = &cfgToSave
545
546	// Also reload from disk to sync Viper's internal state (if not watching)
547	if !m.watching {
548		m.skipNextReload = false // Clear flag since we're doing manual reload
549		if err := m.reload(); err != nil {
550			return err
551		}
552	}
553
554	return nil
555}
556
557// GetConfigFile returns the path to the configuration file being used.
558func (m *Manager) GetConfigFile() string {
559	return m.viper.ConfigFileUsed()
560}
561
562// createDefaultConfig creates a default configuration file with ordered sections.
563func (m *Manager) createDefaultConfig() error {
564	configFile, err := GetConfigFile()
565	if err != nil {
566		return err
567	}
568
569	// Create config directory if it doesn't exist
570	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
571		return err
572	}
573
574	// Start with default config
575	cfg := DefaultConfig()
576
577	// Detect available fonts and override defaults
578	m.detectAndSetFontsOnConfig(cfg)
579
580	// Write config with ordered sections
581	if err := WriteConfigOrdered(cfg, configFile); err != nil {
582		return fmt.Errorf("failed to write config file: %w", err)
583	}
584
585	fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
586
587	return nil
588}
589
590// detectAndSetFontsOnConfig detects available system fonts and sets the best available
591// fonts from the fallback chains directly on the Config struct.
592func (*Manager) detectAndSetFontsOnConfig(cfg *Config) {
593	// Create context with logger for debugging first-run font detection.
594	logger := logging.NewFromEnv()
595	ctx := logging.WithContext(context.Background(), logger)
596
597	detector := fonts.NewDetector()
598
599	if !detector.IsAvailable(ctx) {
600		// fc-list not available, keep hardcoded defaults
601		return
602	}
603
604	cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
605	cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
606	cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
607}
608
609// setDefaults sets default configuration values in Viper.
610func (m *Manager) setDefaults() {
611	defaults := DefaultConfig()
612
613	// Note: Database.Path is set dynamically in Load(), no defaults needed
614
615	m.setHistoryDefaults(defaults)
616	m.setSearchDefaults(defaults)
617	m.setDmenuDefaults(defaults)
618	m.setLoggingDefaults(defaults)
619	m.setDebugDefaults(defaults)
620	m.setAppearanceDefaults(defaults)
621	m.setEngineDefaults(defaults)
622	m.setZoomAndScaleDefaults(defaults)
623	m.setWorkspaceDefaults(defaults)
624	m.setContentFilteringDefaults(defaults)
625	m.setClipboardDefaults(defaults)
626	m.setOmniboxDefaults(defaults)
627	m.setMediaDefaults(defaults)
628	m.setSessionDefaults(defaults)
629	m.setUpdateDefaults(defaults)
630	m.setDownloadsDefaults(defaults)
631}
632
633func (m *Manager) setHistoryDefaults(defaults *Config) {
634	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
635	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
636	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
637}
638
639func (m *Manager) setSearchDefaults(defaults *Config) {
640	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
641	m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
642}
643
644func (m *Manager) setDmenuDefaults(defaults *Config) {
645	m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
646	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
647	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
648	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
649	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
650	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
651	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
652	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
653}
654
655func (m *Manager) setLoggingDefaults(defaults *Config) {
656	m.viper.SetDefault("logging.level", defaults.Logging.Level)
657	m.viper.SetDefault("logging.format", defaults.Logging.Format)
658	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
659	m.viper.SetDefault("logging.max_files", defaults.Logging.MaxFiles)
660	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
661	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
662	m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
663	m.viper.SetDefault("logging.capture_gtk_logs", defaults.Logging.CaptureGTKLogs)
664}
665
666func (m *Manager) setDebugDefaults(defaults *Config) {
667	m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
668}
669
670func (m *Manager) setAppearanceDefaults(defaults *Config) {
671	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
672	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
673	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
674	m.viper.SetDefault("appearance.gtk_font", defaults.Appearance.GtkFont)
675	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
676	m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
677	m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
678	m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
679	m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)
680	m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)
681	m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)
682}
683
684func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
685	m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
686	m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
687	m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
688}
689
690func (m *Manager) setWorkspaceDefaults(defaults *Config) {
691	m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
692	m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
693	m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
694	m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
695	m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
696	m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
697	m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
698	m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
699	m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
700	m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
701	m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
702	m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
703	m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
704	m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
705	m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
706	m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
707	m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
708	m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
709	m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
710	m.viper.SetDefault("workspace.browsing_contexts.behavior", string(defaults.Workspace.BrowsingContexts.Behavior))
711	m.viper.SetDefault("workspace.browsing_contexts.placement", defaults.Workspace.BrowsingContexts.Placement)
712	m.viper.SetDefault("workspace.browsing_contexts.open_in_new_pane", defaults.Workspace.BrowsingContexts.OpenInNewPane)
713	m.viper.SetDefault("workspace.browsing_contexts.follow_pane_context", defaults.Workspace.BrowsingContexts.FollowPaneContext)
714	m.viper.SetDefault("workspace.browsing_contexts.blank_target_behavior", defaults.Workspace.BrowsingContexts.BlankTargetBehavior)
715	m.viper.SetDefault("workspace.browsing_contexts.enable_smart_detection", defaults.Workspace.BrowsingContexts.EnableSmartDetection)
716	m.viper.SetDefault("workspace.browsing_contexts.oauth_auto_close", defaults.Workspace.BrowsingContexts.OAuthAutoClose)
717	m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
718	m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
719	m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
720	m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
721	m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
722	m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
723	m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
724	m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
725	m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
726}
727
728func (m *Manager) setContentFilteringDefaults(defaults *Config) {
729	m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
730	m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
731}
732
733func (m *Manager) setClipboardDefaults(defaults *Config) {
734	m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
735}
736
737func (m *Manager) setOmniboxDefaults(defaults *Config) {
738	m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
739	m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)
740	m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
741}
742
743func (m *Manager) setMediaDefaults(defaults *Config) {
744	// GStreamer fields (force_vsync, gl_rendering_mode, gstreamer_debug_level)
745	// have moved to [engine.webkit] — only non-migrated media fields remain here.
746	m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
747	m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
748	m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
749}
750
751// setRuntimeDefaults removed — runtime.prefix moved to [engine.webkit].
752
753func (m *Manager) setSessionDefaults(defaults *Config) {
754	m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
755	m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
756	m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
757	m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
758	m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
759	m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
760	m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
761}
762
763func (m *Manager) setUpdateDefaults(defaults *Config) {
764	m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
765	m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
766	m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
767}
768
769// setPerformanceDefaults removed — all fields moved to [engine]/[engine.webkit].
770
771func (m *Manager) setDownloadsDefaults(defaults *Config) {
772	m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
773}
774
775func (m *Manager) setEngineDefaults(defaults *Config) {
776	e := defaults.Engine
777	m.viper.SetDefault("engine.type", e.Type)
778	m.viper.SetDefault("engine.profile", string(e.Profile))
779	m.viper.SetDefault("engine.pool_prewarm_count", e.PoolPrewarmCount)
780	m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
781	m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
782
783	ce := e.CEF
784	m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
785	m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
786	m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
787	m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
788	m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
789	m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
790	m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
791	m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
792	m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
793	m.setCEFInputDefaults(ce.Input)
794
795	wk := e.WebKit
796	m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
797	m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
798	m.viper.SetDefault("engine.webkit.skia_gpu_painting_threads", wk.SkiaGPUPaintingThreads)
799	m.viper.SetDefault("engine.webkit.skia_enable_cpu_rendering", wk.SkiaEnableCPURendering)
800	m.viper.SetDefault("engine.webkit.disable_dmabuf_renderer", wk.DisableDMABufRenderer)
801	m.viper.SetDefault("engine.webkit.force_compositing_mode", wk.ForceCompositingMode)
802	m.viper.SetDefault("engine.webkit.disable_compositing_mode", wk.DisableCompositingMode)
803	m.viper.SetDefault("engine.webkit.gsk_renderer", string(wk.GSKRenderer))
804	m.viper.SetDefault("engine.webkit.disable_mipmaps", wk.DisableMipmaps)
805	m.viper.SetDefault("engine.webkit.prefer_gl", wk.PreferGL)
806	m.viper.SetDefault("engine.webkit.draw_compositing_indicators", wk.DrawCompositingIndicators)
807	m.viper.SetDefault("engine.webkit.show_fps", wk.ShowFPS)
808	m.viper.SetDefault("engine.webkit.sample_memory", wk.SampleMemory)
809	m.viper.SetDefault("engine.webkit.debug_frames", wk.DebugFrames)
810	m.viper.SetDefault("engine.webkit.force_vsync", wk.ForceVSync)
811	m.viper.SetDefault("engine.webkit.gl_rendering_mode", string(wk.GLRenderingMode))
812	m.viper.SetDefault("engine.webkit.gstreamer_debug_level", wk.GStreamerDebugLevel)
813	m.viper.SetDefault("engine.webkit.prefix", wk.Prefix)
814	m.viper.SetDefault("engine.webkit.web_process_memory_limit_mb", wk.WebProcessMemoryLimitMB)
815	m.viper.SetDefault("engine.webkit.web_process_memory_poll_interval_sec", wk.WebProcessMemoryPollIntervalSec)
816	m.viper.SetDefault("engine.webkit.web_process_memory_conservative_threshold", wk.WebProcessMemoryConservativeThreshold)
817	m.viper.SetDefault("engine.webkit.web_process_memory_strict_threshold", wk.WebProcessMemoryStrictThreshold)
818	m.viper.SetDefault("engine.webkit.network_process_memory_limit_mb", wk.NetworkProcessMemoryLimitMB)
819	m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
820	m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
821	m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
822}
823
824func (m *Manager) setCEFInputDefaults(input CEFInputConfig) {
825	m.viper.SetDefault("engine.cef.input.scroll_wheel_multiplier", input.ScrollWheelMultiplier)
826	m.viper.SetDefault("engine.cef.input.scroll_precise_multiplier", input.ScrollPreciseMultiplier)
827	m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier)
828	m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier)
829	m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta)
830	m.viper.SetDefault("engine.cef.input.touchpad_navigation_enabled", input.TouchpadNavigationEnabled)
831	m.viper.SetDefault("engine.cef.input.touchpad_navigation_min_delta", input.TouchpadNavigationMinDelta)
832	m.viper.SetDefault("engine.cef.input.touchpad_navigation_max_vertical_ratio", input.TouchpadNavigationMaxVerticalRatio)
833}
834
835// New returns a new default configuration instance.
836// This is a convenience function for getting default config without the full manager.
837func New() *Config {
838	return DefaultConfig()
839}
840
841// Global configuration manager instance
842var globalManager *Manager
843var globalManagerOnce sync.Once
844
845// Init initializes the global configuration manager.
846func Init() error {
847	var err error
848	globalManagerOnce.Do(func() {
849		globalManager, err = NewManager()
850		if err != nil {
851			return
852		}
853		err = globalManager.Load()
854	})
855	return err
856}
857
858// Get returns the global configuration.
859func Get() *Config {
860	if globalManager == nil {
861		// Return defaults if not initialized
862		return DefaultConfig()
863	}
864	return globalManager.Get()
865}
866
867// GetManager returns the global configuration manager.
868// This is useful for accessing watcher functionality.
869func GetManager() *Manager {
870	return globalManager
871}

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