v0.31.0 bf0756b

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

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