v0.28.0 3cb3770

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	mu             sync.RWMutex
23	callbacks      []func(*Config)
24	watching       bool
25	skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
26}
27
28// NewManager creates a new configuration manager.
29func NewManager() (*Manager, error) {
30	v := viper.New()
31
32	// Configure Viper for TOML as default format
33	v.SetConfigName("config") // Name without extension
34	v.SetConfigType("toml")   // TOML as default format
35
36	// Add config paths
37	configDir, err := GetConfigDir()
38	if err != nil {
39		return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
40	}
41	v.AddConfigPath(configDir)
42	v.AddConfigPath(".") // Current directory for development
43
44	// Set up environment variable support
45	v.SetEnvPrefix("DUMBER")
46	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
47	v.AutomaticEnv()
48
49	// Note: Most environment variables are handled automatically via AutomaticEnv()
50	// with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
51	// The explicit bindings below are only for special cases with different naming patterns.
52
53	// Explicit bindings for legacy or system env vars
54	if err := v.BindEnv("default_webpage_zoom", "DUMBER_DEFAULT_WEBPAGE_ZOOM"); err != nil {
55		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_WEBPAGE_ZOOM: %w", err)
56	}
57	if err := v.BindEnv("default_ui_scale", "DUMBER_DEFAULT_UI_SCALE"); err != nil {
58		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_UI_SCALE: %w", err)
59	}
60
61	// Logging environment variable bindings
62	if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
63		return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
64	}
65	if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
66		return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
67	}
68
69	return &Manager{
70		viper:     v,
71		callbacks: make([]func(*Config), 0),
72	}, nil
73}
74
75// Load loads the configuration from file and environment variables.
76func (m *Manager) Load() error {
77	m.mu.Lock()
78	defer m.mu.Unlock()
79
80	if err := EnsureDirectories(); err != nil {
81		return fmt.Errorf("failed to ensure directories: %w", err)
82	}
83
84	m.setDefaults()
85
86	if err := m.readConfigFile(); err != nil {
87		return err
88	}
89
90	if err := m.checkLegacyFormat(); err != nil {
91		return err
92	}
93
94	config, err := m.unmarshalConfig()
95	if err != nil {
96		return err
97	}
98	if err := ensureDatabasePath(config); err != nil {
99		return err
100	}
101	normalizeConfig(config)
102
103	if err := validateConfig(config); err != nil {
104		return fmt.Errorf("configuration validation failed: %w", err)
105	}
106
107	m.config = config
108	return nil
109}
110
111func (m *Manager) readConfigFile() error {
112	if err := m.viper.ReadInConfig(); err != nil {
113		var configFileNotFoundError viper.ConfigFileNotFoundError
114		if errors.As(err, &configFileNotFoundError) {
115			if createErr := m.createDefaultConfig(); createErr != nil {
116				configDir, _ := GetConfigDir()
117				return fmt.Errorf(
118					"failed to create default config at %s: %w\nTry creating the directory manually or check permissions",
119					configDir,
120					createErr,
121				)
122			}
123			if rereadErr := m.viper.ReadInConfig(); rereadErr != nil {
124				return fmt.Errorf(
125					"failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format",
126					rereadErr,
127				)
128			}
129		} else {
130			configFile := m.viper.ConfigFileUsed()
131			if configFile == "" {
132				configDir, _ := GetConfigDir()
133				configFile = filepath.Join(configDir, "config.toml")
134			}
135			return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
136		}
137	}
138
139	// Transform legacy action bindings before unmarshaling
140	m.transformLegacyActionBindings()
141
142	return nil
143}
144
145// transformLegacyActionBindings converts old-format action bindings to new format.
146// This is called after reading config but before unmarshaling.
147func (m *Manager) transformLegacyActionBindings() {
148	rawConfig := m.viper.AllSettings()
149	transformer := NewLegacyConfigTransformer()
150	transformer.TransformLegacyActions(rawConfig)
151
152	// Apply transformed config back to viper
153	for key, value := range rawConfig {
154		m.viper.Set(key, value)
155	}
156}
157
158// checkLegacyFormat detects old config format and returns an error directing user to migrate.
159func (m *Manager) checkLegacyFormat() error {
160	// Check if old sections exist by looking for known keys.
161	// IsSet works for legacy keys because they do not have defaults, while
162	// engine.type has a default and must be checked with InConfig.
163	hasOldSections := m.viper.IsSet("rendering.mode") ||
164		m.viper.IsSet("rendering.disable_dmabuf_renderer") ||
165		m.viper.IsSet("performance.profile") ||
166		m.viper.IsSet("privacy.cookie_policy") ||
167		m.viper.IsSet("runtime.prefix")
168
169	hasEngineSection := m.viper.InConfig("engine.type")
170
171	if hasOldSections && !hasEngineSection {
172		return fmt.Errorf(
173			"config format outdated: [rendering], [performance], [privacy] sections " +
174				"have moved to [engine]/[engine.webkit] — " +
175				"run \"dumber config migrate\" to update your config file",
176		)
177	}
178	return nil
179}
180
181func (m *Manager) unmarshalConfig() (*Config, error) {
182	config := &Config{}
183	if err := m.viper.Unmarshal(config); err != nil {
184		configFile := m.viper.ConfigFileUsed()
185		return nil, fmt.Errorf(
186			"failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
187			configFile,
188			err,
189		)
190	}
191	return config, nil
192}
193
194func ensureDatabasePath(config *Config) error {
195	if config.Database.Path != "" {
196		return nil
197	}
198	dbPath, err := GetDatabaseFile()
199	if err != nil {
200		return fmt.Errorf("failed to get database path: %w", err)
201	}
202	config.Database.Path = dbPath
203	return nil
204}
205
206func normalizeConfig(config *Config) {
207	normalizeAppearance(config)
208	normalizeMedia(config)
209	normalizeEngineConfig(config)
210}
211
212func normalizeEngineConfig(config *Config) {
213	// Normalize GSK renderer (engine.webkit)
214	switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
215	case "", string(GSKRendererAuto):
216		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
217	case string(GSKRendererOpenGL):
218		config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL
219	case string(GSKRendererVulkan):
220		config.Engine.WebKit.GSKRenderer = GSKRendererVulkan
221	case string(GSKRendererCairo):
222		config.Engine.WebKit.GSKRenderer = GSKRendererCairo
223	default:
224		config.Engine.WebKit.GSKRenderer = GSKRendererAuto
225	}
226
227	// Normalize cookie policy (engine).
228	// When no value (or an invalid value) is set, respect the ITP setting: if ITP is
229	// disabled the effective default is to block third-party cookies; if ITP is enabled
230	// all cookies are accepted by default.  An explicit "always" value always wins.
231	itpDefault := func() CookiePolicy {
232		if config.Engine.WebKit.ITPEnabled {
233			return CookiePolicyAlways
234		}
235		return CookiePolicyNoThirdParty
236	}
237	switch strings.ToLower(string(config.Engine.CookiePolicy)) {
238	case "":
239		config.Engine.CookiePolicy = itpDefault()
240	case string(CookiePolicyAlways):
241		config.Engine.CookiePolicy = CookiePolicyAlways
242	case string(CookiePolicyNoThirdParty):
243		config.Engine.CookiePolicy = CookiePolicyNoThirdParty
244	case string(CookiePolicyNever):
245		config.Engine.CookiePolicy = CookiePolicyNever
246	default:
247		config.Engine.CookiePolicy = itpDefault()
248	}
249
250	// Normalize performance profile (engine)
251	switch strings.ToLower(string(config.Engine.Profile)) {
252	case "", string(ProfileDefault):
253		config.Engine.Profile = ProfileDefault
254	case string(ProfileLite):
255		config.Engine.Profile = ProfileLite
256	case string(ProfileMax):
257		config.Engine.Profile = ProfileMax
258	case string(ProfileCustom):
259		config.Engine.Profile = ProfileCustom
260	default:
261		config.Engine.Profile = ProfileDefault
262	}
263
264	// Trim runtime prefix (engine.webkit)
265	config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix)
266}
267
268func normalizeAppearance(config *Config) {
269	switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
270	case strings.ToLower(ThemePreferDark):
271		config.Appearance.ColorScheme = ThemePreferDark
272	case strings.ToLower(ThemePreferLight):
273		config.Appearance.ColorScheme = ThemePreferLight
274	case strings.ToLower(ThemeDefault):
275		config.Appearance.ColorScheme = ThemeDefault
276	default:
277		config.Appearance.ColorScheme = ThemeDefault
278	}
279}
280
281func normalizeMedia(config *Config) {
282	switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
283	case "", string(HardwareDecodingAuto):
284		config.Media.HardwareDecodingMode = HardwareDecodingAuto
285	case string(HardwareDecodingForce):
286		config.Media.HardwareDecodingMode = HardwareDecodingForce
287	case string(HardwareDecodingDisable):
288		config.Media.HardwareDecodingMode = HardwareDecodingDisable
289	default:
290		config.Media.HardwareDecodingMode = HardwareDecodingAuto
291	}
292}
293
294// Get returns the current configuration (thread-safe).
295func (m *Manager) Get() *Config {
296	m.mu.RLock()
297	defer m.mu.RUnlock()
298
299	// Return a copy to prevent external modification
300	configCopy := *m.config
301	return &configCopy
302}
303
304// Save saves the provided configuration to disk with ordered sections.
305func (m *Manager) Save(cfg *Config) error {
306	m.mu.Lock()
307	defer m.mu.Unlock()
308
309	if cfg == nil {
310		return fmt.Errorf("config is nil")
311	}
312
313	// Validate before writing so UI gets immediate errors.
314	if err := validateConfig(cfg); err != nil {
315		return fmt.Errorf("configuration validation failed: %w", err)
316	}
317
318	// Write config with ordered sections (bypasses Viper's random map ordering)
319	configFile := m.viper.ConfigFileUsed()
320	if configFile == "" {
321		var err error
322		configFile, err = GetConfigFile()
323		if err != nil {
324			return fmt.Errorf("failed to determine config file path: %w", err)
325		}
326	}
327
328	// Set flag BEFORE writing to prevent fsnotify-triggered reload from
329	// overwriting our in-memory config with stale viper cache
330	m.skipNextReload = true
331
332	if err := WriteConfigOrdered(cfg, configFile); err != nil {
333		m.skipNextReload = false // Reset on error
334		return fmt.Errorf("failed to write config: %w", err)
335	}
336
337	// Update in-memory config immediately (don't wait for file watcher)
338	// This ensures subsequent Get() calls return the new values
339	m.config = cfg
340
341	// Also reload from disk to sync Viper's internal state (if not watching)
342	if !m.watching {
343		m.skipNextReload = false // Clear flag since we're doing manual reload
344		if err := m.reload(); err != nil {
345			return err
346		}
347	}
348
349	return nil
350}
351
352// GetConfigFile returns the path to the configuration file being used.
353func (m *Manager) GetConfigFile() string {
354	return m.viper.ConfigFileUsed()
355}
356
357// createDefaultConfig creates a default configuration file with ordered sections.
358func (m *Manager) createDefaultConfig() error {
359	configFile, err := GetConfigFile()
360	if err != nil {
361		return err
362	}
363
364	// Create config directory if it doesn't exist
365	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
366		return err
367	}
368
369	// Start with default config
370	cfg := DefaultConfig()
371
372	// Detect available fonts and override defaults
373	m.detectAndSetFontsOnConfig(cfg)
374
375	// Write config with ordered sections
376	if err := WriteConfigOrdered(cfg, configFile); err != nil {
377		return fmt.Errorf("failed to write config file: %w", err)
378	}
379
380	fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
381
382	return nil
383}
384
385// detectAndSetFontsOnConfig detects available system fonts and sets the best available
386// fonts from the fallback chains directly on the Config struct.
387func (*Manager) detectAndSetFontsOnConfig(cfg *Config) {
388	// Create context with logger for debugging first-run font detection.
389	logger := logging.NewFromEnv()
390	ctx := logging.WithContext(context.Background(), logger)
391
392	detector := fonts.NewDetector()
393
394	if !detector.IsAvailable(ctx) {
395		// fc-list not available, keep hardcoded defaults
396		return
397	}
398
399	cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
400	cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
401	cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
402}
403
404// setDefaults sets default configuration values in Viper.
405func (m *Manager) setDefaults() {
406	defaults := DefaultConfig()
407
408	// Note: Database.Path is set dynamically in Load(), no defaults needed
409
410	m.setHistoryDefaults(defaults)
411	m.setSearchDefaults(defaults)
412	m.setDmenuDefaults(defaults)
413	m.setLoggingDefaults(defaults)
414	m.setDebugDefaults(defaults)
415	m.setAppearanceDefaults(defaults)
416	m.setEngineDefaults(defaults)
417	m.setZoomAndScaleDefaults(defaults)
418	m.setWorkspaceDefaults(defaults)
419	m.setContentFilteringDefaults(defaults)
420	m.setClipboardDefaults(defaults)
421	m.setOmniboxDefaults(defaults)
422	m.setMediaDefaults(defaults)
423	m.setSessionDefaults(defaults)
424	m.setUpdateDefaults(defaults)
425	m.setDownloadsDefaults(defaults)
426}
427
428func (m *Manager) setHistoryDefaults(defaults *Config) {
429	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
430	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
431	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
432}
433
434func (m *Manager) setSearchDefaults(defaults *Config) {
435	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
436	m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
437}
438
439func (m *Manager) setDmenuDefaults(defaults *Config) {
440	m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
441	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
442	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
443	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
444	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
445	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
446	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
447	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
448}
449
450func (m *Manager) setLoggingDefaults(defaults *Config) {
451	m.viper.SetDefault("logging.level", defaults.Logging.Level)
452	m.viper.SetDefault("logging.format", defaults.Logging.Format)
453	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
454	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
455	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
456	m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
457	m.viper.SetDefault("logging.capture_gtk_logs", defaults.Logging.CaptureGTKLogs)
458}
459
460func (m *Manager) setDebugDefaults(defaults *Config) {
461	m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
462}
463
464func (m *Manager) setAppearanceDefaults(defaults *Config) {
465	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
466	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
467	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
468	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
469	m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
470	m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
471	m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
472}
473
474func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
475	m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
476	m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
477}
478
479func (m *Manager) setWorkspaceDefaults(defaults *Config) {
480	m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
481	m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
482	m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
483	m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
484	m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
485	m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
486	m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
487	m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
488	m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
489	m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
490	m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
491	m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
492	m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
493	m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
494	m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
495	m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
496	m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
497	m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
498	m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
499	m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
500	m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
501	m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
502	m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
503	m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
504	m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
505	m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
506	m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
507	m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
508	m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
509	m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
510	m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
511	m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
512	m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
513	m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
514	m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
515}
516
517func (m *Manager) setContentFilteringDefaults(defaults *Config) {
518	m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
519	m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
520}
521
522func (m *Manager) setClipboardDefaults(defaults *Config) {
523	m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
524}
525
526func (m *Manager) setOmniboxDefaults(defaults *Config) {
527	m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
528	m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
529}
530
531func (m *Manager) setMediaDefaults(defaults *Config) {
532	// GStreamer fields (force_vsync, gl_rendering_mode, gstreamer_debug_level)
533	// have moved to [engine.webkit] — only non-migrated media fields remain here.
534	m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
535	m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
536	m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
537}
538
539// setRuntimeDefaults removed — runtime.prefix moved to [engine.webkit].
540
541func (m *Manager) setSessionDefaults(defaults *Config) {
542	m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
543	m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
544	m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
545	m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
546	m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
547	m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
548	m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
549}
550
551func (m *Manager) setUpdateDefaults(defaults *Config) {
552	m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
553	m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
554	m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
555}
556
557// setPerformanceDefaults removed — all fields moved to [engine]/[engine.webkit].
558
559func (m *Manager) setDownloadsDefaults(defaults *Config) {
560	m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
561}
562
563func (m *Manager) setEngineDefaults(defaults *Config) {
564	e := defaults.Engine
565	m.viper.SetDefault("engine.type", e.Type)
566	m.viper.SetDefault("engine.profile", string(e.Profile))
567	m.viper.SetDefault("engine.pool_prewarm_count", e.PoolPrewarmCount)
568	m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
569	m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
570
571	wk := e.WebKit
572	m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
573	m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
574	m.viper.SetDefault("engine.webkit.skia_gpu_painting_threads", wk.SkiaGPUPaintingThreads)
575	m.viper.SetDefault("engine.webkit.skia_enable_cpu_rendering", wk.SkiaEnableCPURendering)
576	m.viper.SetDefault("engine.webkit.disable_dmabuf_renderer", wk.DisableDMABufRenderer)
577	m.viper.SetDefault("engine.webkit.force_compositing_mode", wk.ForceCompositingMode)
578	m.viper.SetDefault("engine.webkit.disable_compositing_mode", wk.DisableCompositingMode)
579	m.viper.SetDefault("engine.webkit.gsk_renderer", string(wk.GSKRenderer))
580	m.viper.SetDefault("engine.webkit.disable_mipmaps", wk.DisableMipmaps)
581	m.viper.SetDefault("engine.webkit.prefer_gl", wk.PreferGL)
582	m.viper.SetDefault("engine.webkit.draw_compositing_indicators", wk.DrawCompositingIndicators)
583	m.viper.SetDefault("engine.webkit.show_fps", wk.ShowFPS)
584	m.viper.SetDefault("engine.webkit.sample_memory", wk.SampleMemory)
585	m.viper.SetDefault("engine.webkit.debug_frames", wk.DebugFrames)
586	m.viper.SetDefault("engine.webkit.force_vsync", wk.ForceVSync)
587	m.viper.SetDefault("engine.webkit.gl_rendering_mode", string(wk.GLRenderingMode))
588	m.viper.SetDefault("engine.webkit.gstreamer_debug_level", wk.GStreamerDebugLevel)
589	m.viper.SetDefault("engine.webkit.prefix", wk.Prefix)
590	m.viper.SetDefault("engine.webkit.web_process_memory_limit_mb", wk.WebProcessMemoryLimitMB)
591	m.viper.SetDefault("engine.webkit.web_process_memory_poll_interval_sec", wk.WebProcessMemoryPollIntervalSec)
592	m.viper.SetDefault("engine.webkit.web_process_memory_conservative_threshold", wk.WebProcessMemoryConservativeThreshold)
593	m.viper.SetDefault("engine.webkit.web_process_memory_strict_threshold", wk.WebProcessMemoryStrictThreshold)
594	m.viper.SetDefault("engine.webkit.network_process_memory_limit_mb", wk.NetworkProcessMemoryLimitMB)
595	m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
596	m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
597	m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
598}
599
600// New returns a new default configuration instance.
601// This is a convenience function for getting default config without the full manager.
602func New() *Config {
603	return DefaultConfig()
604}
605
606// Global configuration manager instance
607var globalManager *Manager
608var globalManagerOnce sync.Once
609
610// Init initializes the global configuration manager.
611func Init() error {
612	var err error
613	globalManagerOnce.Do(func() {
614		globalManager, err = NewManager()
615		if err != nil {
616			return
617		}
618		err = globalManager.Load()
619	})
620	return err
621}
622
623// Get returns the global configuration.
624func Get() *Config {
625	if globalManager == nil {
626		// Return defaults if not initialized
627		return DefaultConfig()
628	}
629	return globalManager.Get()
630}
631
632// GetManager returns the global configuration manager.
633// This is useful for accessing watcher functionality.
634func GetManager() *Manager {
635	return globalManager
636}

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