v0.33.1 9577fee

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

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