v0.29.0 21b3150

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	// Check if old sections exist by looking for known keys.
311	// IsSet works for legacy keys because they do not have defaults, while
312	// engine.type has a default and must be checked with InConfig.
313	hasOldSections := m.viper.IsSet("rendering.mode") ||
314		m.viper.IsSet("rendering.disable_dmabuf_renderer") ||
315		m.viper.IsSet("performance.profile") ||
316		m.viper.IsSet("privacy.cookie_policy") ||
317		m.viper.IsSet("runtime.prefix")
318
319	hasEngineSection := m.viper.InConfig("engine.type")
320
321	if hasOldSections && !hasEngineSection {
322		return fmt.Errorf(
323			"config format outdated: [rendering], [performance], [privacy] sections " +
324				"have moved to [engine]/[engine.webkit] — " +
325				"run \"dumber config migrate\" to update your config file",
326		)
327	}
328	return nil
329}
330
331func (m *Manager) unmarshalConfig() (*Config, error) {
332	config := &Config{}
333	if err := m.viper.Unmarshal(config); err != nil {
334		configFile := m.viper.ConfigFileUsed()
335		return nil, fmt.Errorf(
336			"failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
337			configFile,
338			err,
339		)
340	}
341	return config, nil
342}
343
344func ensureDatabasePath(config *Config) error {
345	if config.Database.Path != "" {
346		return nil
347	}
348	dbPath, err := GetDatabaseFile()
349	if err != nil {
350		return fmt.Errorf("failed to get database path: %w", err)
351	}
352	config.Database.Path = dbPath
353	return nil
354}
355
356func normalizeConfig(config *Config) {
357	normalizeAppearance(config)
358	normalizeMedia(config)
359	normalizeEngineConfig(config)
360	normalizeBrowsingContexts(config)
361}
362
363// normalizeBrowsingContexts ensures the deprecated Popups field mirrors BrowsingContexts
364// for runtime compatibility. Existing code can keep reading config.Workspace.Popups.
365func normalizeBrowsingContexts(config *Config) {
366	config.Workspace.Popups = config.Workspace.BrowsingContexts
367}
368
369func normalizeEngineConfig(config *Config) {
370	// Normalize GSK renderer (engine.webkit)
371	switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
372	case "", string(GSKRendererAuto):
373		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
374	case string(GSKRendererOpenGL):
375		config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL
376	case string(GSKRendererVulkan):
377		config.Engine.WebKit.GSKRenderer = GSKRendererVulkan
378	case string(GSKRendererCairo):
379		config.Engine.WebKit.GSKRenderer = GSKRendererCairo
380	default:
381		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
382	}
383
384	// Normalize cookie policy (engine).
385	// When no value (or an invalid value) is set, respect the ITP setting: if ITP is
386	// disabled the effective default is to block third-party cookies; if ITP is enabled
387	// all cookies are accepted by default.  An explicit "always" value always wins.
388	itpDefault := func() CookiePolicy {
389		if config.Engine.WebKit.ITPEnabled {
390			return CookiePolicyAlways
391		}
392		return CookiePolicyNoThirdParty
393	}
394	switch strings.ToLower(string(config.Engine.CookiePolicy)) {
395	case "":
396		config.Engine.CookiePolicy = itpDefault()
397	case string(CookiePolicyAlways):
398		config.Engine.CookiePolicy = CookiePolicyAlways
399	case string(CookiePolicyNoThirdParty):
400		config.Engine.CookiePolicy = CookiePolicyNoThirdParty
401	case string(CookiePolicyNever):
402		config.Engine.CookiePolicy = CookiePolicyNever
403	default:
404		config.Engine.CookiePolicy = itpDefault()
405	}
406
407	// Normalize performance profile (engine)
408	switch strings.ToLower(string(config.Engine.Profile)) {
409	case "", string(ProfileDefault):
410		config.Engine.Profile = ProfileDefault
411	case string(ProfileLite):
412		config.Engine.Profile = ProfileLite
413	case string(ProfileBalanced):
414		config.Engine.Profile = ProfileBalanced
415	case string(ProfileMax):
416		config.Engine.Profile = ProfileMax
417	case string(ProfileCustom):
418		config.Engine.Profile = ProfileCustom
419	default:
420		config.Engine.Profile = ProfileDefault
421	}
422
423	// Trim runtime prefix (engine.webkit)
424	config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix)
425}
426
427func normalizeAppearance(config *Config) {
428	switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
429	case strings.ToLower(ThemePreferDark):
430		config.Appearance.ColorScheme = ThemePreferDark
431	case strings.ToLower(ThemePreferLight):
432		config.Appearance.ColorScheme = ThemePreferLight
433	case strings.ToLower(ThemeDefault):
434		config.Appearance.ColorScheme = ThemeDefault
435	default:
436		config.Appearance.ColorScheme = ThemeDefault
437	}
438}
439
440func normalizeMedia(config *Config) {
441	switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
442	case "", string(HardwareDecodingAuto):
443		config.Media.HardwareDecodingMode = HardwareDecodingAuto
444	case string(HardwareDecodingForce):
445		config.Media.HardwareDecodingMode = HardwareDecodingForce
446	case string(HardwareDecodingDisable):
447		config.Media.HardwareDecodingMode = HardwareDecodingDisable
448	default:
449		config.Media.HardwareDecodingMode = HardwareDecodingAuto
450	}
451}
452
453// Get returns the current configuration (thread-safe).
454func (m *Manager) Get() *Config {
455	m.mu.RLock()
456	defer m.mu.RUnlock()
457
458	// Return a copy to prevent external modification
459	configCopy := *m.config
460	return &configCopy
461}
462
463// Save saves the provided configuration to disk with ordered sections.
464func (m *Manager) Save(cfg *Config) error {
465	m.mu.Lock()
466	defer m.mu.Unlock()
467
468	if cfg == nil {
469		return fmt.Errorf("config is nil")
470	}
471
472	// Normalize a copy before writing/publishing so deprecated runtime aliases
473	// stay in sync even when the watcher skips reload after Save().
474	cfgToSave := *cfg
475	normalizeConfig(&cfgToSave)
476
477	// Validate before writing so UI gets immediate errors.
478	if err := validateConfig(&cfgToSave); err != nil {
479		return fmt.Errorf("configuration validation failed: %w", err)
480	}
481
482	// Write config with ordered sections (bypasses Viper's random map ordering)
483	configFile := m.viper.ConfigFileUsed()
484	if configFile == "" {
485		var err error
486		configFile, err = GetConfigFile()
487		if err != nil {
488			return fmt.Errorf("failed to determine config file path: %w", err)
489		}
490	}
491
492	// Set flag BEFORE writing to prevent fsnotify-triggered reload from
493	// overwriting our in-memory config with stale viper cache
494	m.skipNextReload = true
495
496	if err := WriteConfigOrdered(&cfgToSave, configFile); err != nil {
497		m.skipNextReload = false // Reset on error
498		return fmt.Errorf("failed to write config: %w", err)
499	}
500
501	// Update in-memory config immediately (don't wait for file watcher).
502	// This ensures subsequent Get() calls return normalized values even when
503	// the watcher intentionally skips reload for our own Save().
504	m.config = &cfgToSave
505
506	// Also reload from disk to sync Viper's internal state (if not watching)
507	if !m.watching {
508		m.skipNextReload = false // Clear flag since we're doing manual reload
509		if err := m.reload(); err != nil {
510			return err
511		}
512	}
513
514	return nil
515}
516
517// GetConfigFile returns the path to the configuration file being used.
518func (m *Manager) GetConfigFile() string {
519	return m.viper.ConfigFileUsed()
520}
521
522// createDefaultConfig creates a default configuration file with ordered sections.
523func (m *Manager) createDefaultConfig() error {
524	configFile, err := GetConfigFile()
525	if err != nil {
526		return err
527	}
528
529	// Create config directory if it doesn't exist
530	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
531		return err
532	}
533
534	// Start with default config
535	cfg := DefaultConfig()
536
537	// Detect available fonts and override defaults
538	m.detectAndSetFontsOnConfig(cfg)
539
540	// Write config with ordered sections
541	if err := WriteConfigOrdered(cfg, configFile); err != nil {
542		return fmt.Errorf("failed to write config file: %w", err)
543	}
544
545	fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
546
547	return nil
548}
549
550// detectAndSetFontsOnConfig detects available system fonts and sets the best available
551// fonts from the fallback chains directly on the Config struct.
552func (*Manager) detectAndSetFontsOnConfig(cfg *Config) {
553	// Create context with logger for debugging first-run font detection.
554	logger := logging.NewFromEnv()
555	ctx := logging.WithContext(context.Background(), logger)
556
557	detector := fonts.NewDetector()
558
559	if !detector.IsAvailable(ctx) {
560		// fc-list not available, keep hardcoded defaults
561		return
562	}
563
564	cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
565	cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
566	cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
567}
568
569// setDefaults sets default configuration values in Viper.
570func (m *Manager) setDefaults() {
571	defaults := DefaultConfig()
572
573	// Note: Database.Path is set dynamically in Load(), no defaults needed
574
575	m.setHistoryDefaults(defaults)
576	m.setSearchDefaults(defaults)
577	m.setDmenuDefaults(defaults)
578	m.setLoggingDefaults(defaults)
579	m.setDebugDefaults(defaults)
580	m.setAppearanceDefaults(defaults)
581	m.setEngineDefaults(defaults)
582	m.setZoomAndScaleDefaults(defaults)
583	m.setWorkspaceDefaults(defaults)
584	m.setContentFilteringDefaults(defaults)
585	m.setClipboardDefaults(defaults)
586	m.setOmniboxDefaults(defaults)
587	m.setMediaDefaults(defaults)
588	m.setSessionDefaults(defaults)
589	m.setUpdateDefaults(defaults)
590	m.setDownloadsDefaults(defaults)
591}
592
593func (m *Manager) setHistoryDefaults(defaults *Config) {
594	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
595	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
596	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
597}
598
599func (m *Manager) setSearchDefaults(defaults *Config) {
600	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
601	m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
602}
603
604func (m *Manager) setDmenuDefaults(defaults *Config) {
605	m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
606	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
607	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
608	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
609	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
610	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
611	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
612	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
613}
614
615func (m *Manager) setLoggingDefaults(defaults *Config) {
616	m.viper.SetDefault("logging.level", defaults.Logging.Level)
617	m.viper.SetDefault("logging.format", defaults.Logging.Format)
618	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
619	m.viper.SetDefault("logging.max_files", defaults.Logging.MaxFiles)
620	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
621	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
622	m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
623	m.viper.SetDefault("logging.capture_gtk_logs", defaults.Logging.CaptureGTKLogs)
624}
625
626func (m *Manager) setDebugDefaults(defaults *Config) {
627	m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
628}
629
630func (m *Manager) setAppearanceDefaults(defaults *Config) {
631	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
632	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
633	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
634	m.viper.SetDefault("appearance.gtk_font", defaults.Appearance.GtkFont)
635	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
636	m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
637	m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
638	m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
639}
640
641func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
642	m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
643	m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
644}
645
646func (m *Manager) setWorkspaceDefaults(defaults *Config) {
647	m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
648	m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
649	m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
650	m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
651	m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
652	m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
653	m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
654	m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
655	m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
656	m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
657	m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
658	m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
659	m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
660	m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
661	m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
662	m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
663	m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
664	m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
665	m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
666	m.viper.SetDefault("workspace.browsing_contexts.behavior", string(defaults.Workspace.BrowsingContexts.Behavior))
667	m.viper.SetDefault("workspace.browsing_contexts.placement", defaults.Workspace.BrowsingContexts.Placement)
668	m.viper.SetDefault("workspace.browsing_contexts.open_in_new_pane", defaults.Workspace.BrowsingContexts.OpenInNewPane)
669	m.viper.SetDefault("workspace.browsing_contexts.follow_pane_context", defaults.Workspace.BrowsingContexts.FollowPaneContext)
670	m.viper.SetDefault("workspace.browsing_contexts.blank_target_behavior", defaults.Workspace.BrowsingContexts.BlankTargetBehavior)
671	m.viper.SetDefault("workspace.browsing_contexts.enable_smart_detection", defaults.Workspace.BrowsingContexts.EnableSmartDetection)
672	m.viper.SetDefault("workspace.browsing_contexts.oauth_auto_close", defaults.Workspace.BrowsingContexts.OAuthAutoClose)
673	m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
674	m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
675	m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
676	m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
677	m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
678	m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
679	m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
680	m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
681	m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
682}
683
684func (m *Manager) setContentFilteringDefaults(defaults *Config) {
685	m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
686	m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
687}
688
689func (m *Manager) setClipboardDefaults(defaults *Config) {
690	m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
691}
692
693func (m *Manager) setOmniboxDefaults(defaults *Config) {
694	m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
695	m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)
696	m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
697}
698
699func (m *Manager) setMediaDefaults(defaults *Config) {
700	// GStreamer fields (force_vsync, gl_rendering_mode, gstreamer_debug_level)
701	// have moved to [engine.webkit] — only non-migrated media fields remain here.
702	m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
703	m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
704	m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
705}
706
707// setRuntimeDefaults removed — runtime.prefix moved to [engine.webkit].
708
709func (m *Manager) setSessionDefaults(defaults *Config) {
710	m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
711	m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
712	m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
713	m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
714	m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
715	m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
716	m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
717}
718
719func (m *Manager) setUpdateDefaults(defaults *Config) {
720	m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
721	m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
722	m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
723}
724
725// setPerformanceDefaults removed — all fields moved to [engine]/[engine.webkit].
726
727func (m *Manager) setDownloadsDefaults(defaults *Config) {
728	m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
729}
730
731func (m *Manager) setEngineDefaults(defaults *Config) {
732	e := defaults.Engine
733	m.viper.SetDefault("engine.type", e.Type)
734	m.viper.SetDefault("engine.profile", string(e.Profile))
735	m.viper.SetDefault("engine.pool_prewarm_count", e.PoolPrewarmCount)
736	m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
737	m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
738
739	ce := e.CEF
740	m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
741	m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
742	m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
743	m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
744	m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
745	m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
746	m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
747	m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
748	m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
749
750	wk := e.WebKit
751	m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
752	m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
753	m.viper.SetDefault("engine.webkit.skia_gpu_painting_threads", wk.SkiaGPUPaintingThreads)
754	m.viper.SetDefault("engine.webkit.skia_enable_cpu_rendering", wk.SkiaEnableCPURendering)
755	m.viper.SetDefault("engine.webkit.disable_dmabuf_renderer", wk.DisableDMABufRenderer)
756	m.viper.SetDefault("engine.webkit.force_compositing_mode", wk.ForceCompositingMode)
757	m.viper.SetDefault("engine.webkit.disable_compositing_mode", wk.DisableCompositingMode)
758	m.viper.SetDefault("engine.webkit.gsk_renderer", string(wk.GSKRenderer))
759	m.viper.SetDefault("engine.webkit.disable_mipmaps", wk.DisableMipmaps)
760	m.viper.SetDefault("engine.webkit.prefer_gl", wk.PreferGL)
761	m.viper.SetDefault("engine.webkit.draw_compositing_indicators", wk.DrawCompositingIndicators)
762	m.viper.SetDefault("engine.webkit.show_fps", wk.ShowFPS)
763	m.viper.SetDefault("engine.webkit.sample_memory", wk.SampleMemory)
764	m.viper.SetDefault("engine.webkit.debug_frames", wk.DebugFrames)
765	m.viper.SetDefault("engine.webkit.force_vsync", wk.ForceVSync)
766	m.viper.SetDefault("engine.webkit.gl_rendering_mode", string(wk.GLRenderingMode))
767	m.viper.SetDefault("engine.webkit.gstreamer_debug_level", wk.GStreamerDebugLevel)
768	m.viper.SetDefault("engine.webkit.prefix", wk.Prefix)
769	m.viper.SetDefault("engine.webkit.web_process_memory_limit_mb", wk.WebProcessMemoryLimitMB)
770	m.viper.SetDefault("engine.webkit.web_process_memory_poll_interval_sec", wk.WebProcessMemoryPollIntervalSec)
771	m.viper.SetDefault("engine.webkit.web_process_memory_conservative_threshold", wk.WebProcessMemoryConservativeThreshold)
772	m.viper.SetDefault("engine.webkit.web_process_memory_strict_threshold", wk.WebProcessMemoryStrictThreshold)
773	m.viper.SetDefault("engine.webkit.network_process_memory_limit_mb", wk.NetworkProcessMemoryLimitMB)
774	m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
775	m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
776	m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
777}
778
779// New returns a new default configuration instance.
780// This is a convenience function for getting default config without the full manager.
781func New() *Config {
782	return DefaultConfig()
783}
784
785// Global configuration manager instance
786var globalManager *Manager
787var globalManagerOnce sync.Once
788
789// Init initializes the global configuration manager.
790func Init() error {
791	var err error
792	globalManagerOnce.Do(func() {
793		globalManager, err = NewManager()
794		if err != nil {
795			return
796		}
797		err = globalManager.Load()
798	})
799	return err
800}
801
802// Get returns the global configuration.
803func Get() *Config {
804	if globalManager == nil {
805		// Return defaults if not initialized
806		return DefaultConfig()
807	}
808	return globalManager.Get()
809}
810
811// GetManager returns the global configuration manager.
812// This is useful for accessing watcher functionality.
813func GetManager() *Manager {
814	return globalManager
815}

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