v0.15.0 9006957

config.go RawHistoryFolder

1// Package config provides configuration management for dumber with Viper integration.
2package config
3
4import (
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/pkg/gpu"
14	"github.com/fsnotify/fsnotify"
15	"github.com/spf13/viper"
16)
17
18// File permission constants
19const (
20	dirPerm  = 0755 // Standard directory permissions (rwxr-xr-x)
21	filePerm = 0644 // Standard file permissions (rw-r--r--)
22)
23
24// Config represents the complete configuration for dumber.
25type Config struct {
26	Database        DatabaseConfig            `mapstructure:"database" yaml:"database"`
27	History         HistoryConfig             `mapstructure:"history" yaml:"history"`
28	SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
29	// DefaultSearchEngine is the URL template for the default search engine (must contain %s placeholder)
30	DefaultSearchEngine string                  `mapstructure:"default_search_engine" yaml:"default_search_engine"`
31	Dmenu               DmenuConfig             `mapstructure:"dmenu" yaml:"dmenu"`
32	Logging             LoggingConfig           `mapstructure:"logging" yaml:"logging"`
33	Appearance          AppearanceConfig        `mapstructure:"appearance" yaml:"appearance"`
34	VideoAcceleration   VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
35	CodecPreferences    CodecConfig             `mapstructure:"codec_preferences" yaml:"codec_preferences"`
36	Debug               DebugConfig             `mapstructure:"debug" yaml:"debug"`
37	// RenderingMode controls GPU/CPU rendering selection for WebKit
38	RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
39	// UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
40	UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
41	// DefaultZoom sets the default zoom level for pages without saved zoom settings (1.0 = 100%, 1.2 = 120%)
42	DefaultZoom float64 `mapstructure:"default_zoom" yaml:"default_zoom"`
43	// Workspace defines workspace, pane, and tab handling behaviour.
44	Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
45	// ContentFiltering controls ad blocking and content filtering
46	ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering"`
47	// Omnibox controls the omnibox behavior (initial history display)
48	Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox"`
49}
50
51// RenderingMode selects GPU vs CPU rendering.
52type RenderingMode string
53
54const (
55	RenderingModeAuto RenderingMode = "auto"
56	RenderingModeGPU  RenderingMode = "gpu"
57	RenderingModeCPU  RenderingMode = "cpu"
58)
59
60// DatabaseConfig holds database-related configuration.
61type DatabaseConfig struct {
62	Path string `mapstructure:"path" yaml:"path"`
63}
64
65// HistoryConfig holds history-related configuration.
66type HistoryConfig struct {
67	MaxEntries          int `mapstructure:"max_entries" yaml:"max_entries"`
68	RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
69	CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
70}
71
72// SearchShortcut represents a search shortcut configuration.
73type SearchShortcut struct {
74	URL         string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
75	Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
76}
77
78// DmenuConfig holds dmenu/rofi integration configuration.
79type DmenuConfig struct {
80	MaxHistoryItems  int    `mapstructure:"max_history_items" yaml:"max_history_items"`
81	ShowVisitCount   bool   `mapstructure:"show_visit_count" yaml:"show_visit_count"`
82	ShowLastVisited  bool   `mapstructure:"show_last_visited" yaml:"show_last_visited"`
83	HistoryPrefix    string `mapstructure:"history_prefix" yaml:"history_prefix"`
84	ShortcutPrefix   string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
85	URLPrefix        string `mapstructure:"url_prefix" yaml:"url_prefix"`
86	DateFormat       string `mapstructure:"date_format" yaml:"date_format"`
87	SortByVisitCount bool   `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
88}
89
90// LoggingConfig holds logging configuration.
91type LoggingConfig struct {
92	Level      string `mapstructure:"level" yaml:"level"`
93	Format     string `mapstructure:"format" yaml:"format"`
94	Filename   string `mapstructure:"filename" yaml:"filename"`
95	MaxSize    int    `mapstructure:"max_size" yaml:"max_size"`
96	MaxBackups int    `mapstructure:"max_backups" yaml:"max_backups"`
97	MaxAge     int    `mapstructure:"max_age" yaml:"max_age"`
98	Compress   bool   `mapstructure:"compress" yaml:"compress"`
99
100	// File output configuration
101	LogDir        string `mapstructure:"log_dir" yaml:"log_dir"`
102	EnableFileLog bool   `mapstructure:"enable_file_log" yaml:"enable_file_log"`
103
104	// Capture settings
105	CaptureStdout  bool `mapstructure:"capture_stdout" yaml:"capture_stdout" json:"capture_stdout"`
106	CaptureStderr  bool `mapstructure:"capture_stderr" yaml:"capture_stderr" json:"capture_stderr"`
107	CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output" json:"capture_c_output"`
108	CaptureConsole bool `mapstructure:"capture_console" yaml:"capture_console" json:"capture_console"`
109
110	// Debug output
111	DebugFile     string `mapstructure:"debug_file" yaml:"debug_file"`
112	VerboseWebKit bool   `mapstructure:"verbose_webkit" yaml:"verbose_webkit"`
113}
114
115// AppearanceConfig holds UI/rendering preferences.
116type AppearanceConfig struct {
117	// Default fonts for pages that do not specify fonts.
118	SansFont      string `mapstructure:"sans_font" yaml:"sans_font"`
119	SerifFont     string `mapstructure:"serif_font" yaml:"serif_font"`
120	MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
121	// Default font size in CSS pixels (approx).
122	DefaultFontSize int          `mapstructure:"default_font_size" yaml:"default_font_size"`
123	LightPalette    ColorPalette `mapstructure:"light_palette" yaml:"light_palette"`
124	DarkPalette     ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette"`
125	// ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
126	ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme"`
127}
128
129// ColorPalette contains semantic color tokens for light/dark themes.
130type ColorPalette struct {
131	Background     string `mapstructure:"background" yaml:"background" json:"background"`
132	Surface        string `mapstructure:"surface" yaml:"surface" json:"surface"`
133	SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" json:"surface_variant"`
134	Text           string `mapstructure:"text" yaml:"text" json:"text"`
135	Muted          string `mapstructure:"muted" yaml:"muted" json:"muted"`
136	Accent         string `mapstructure:"accent" yaml:"accent" json:"accent"`
137	Border         string `mapstructure:"border" yaml:"border" json:"border"`
138}
139
140// VideoAccelerationConfig holds video hardware acceleration preferences.
141type VideoAccelerationConfig struct {
142	EnableVAAPI      bool   `mapstructure:"enable_vaapi" yaml:"enable_vaapi"`
143	AutoDetectGPU    bool   `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu"`
144	VAAPIDriverName  string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name"`
145	EnableAllDrivers bool   `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers"`
146	LegacyVAAPI      bool   `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
147}
148
149// CodecConfig holds video codec preferences and handling
150type CodecConfig struct {
151	// Codec preference order (e.g., "av1,h264,vp8")
152	PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs"`
153
154	// Force specific codec for platforms
155	ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1"`
156
157	// Block problematic codecs
158	BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9"`
159	BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8"`
160
161	// Hardware acceleration per codec
162	AV1HardwareOnly    bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only"`
163	DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware"`
164
165	// Buffer configuration for smooth playback
166	VideoBufferSizeMB  int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb"`
167	QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec"`
168
169	// Custom User-Agent for codec negotiation
170	CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent"`
171
172	// Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
173	AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution"`
174
175	// Site-specific codec control settings
176	DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`
177}
178
179// DebugConfig holds debug and troubleshooting options
180// ContentFilteringConfig holds content filtering and ad blocking preferences
181type ContentFilteringConfig struct {
182	// Enabled controls whether ad blocking is active (default: true)
183	Enabled bool `mapstructure:"enabled" yaml:"enabled"`
184	// Whitelist domains that should skip ad blocking (e.g. Twitch breaks with bot detection)
185	Whitelist []string `mapstructure:"whitelist" yaml:"whitelist"`
186	// FilterLists URLs of filter lists to use (EasyList, uBlock, etc.)
187	FilterLists []string `mapstructure:"filter_lists" yaml:"filter_lists"`
188}
189
190// OmniboxConfig holds omnibox behavior preferences
191type OmniboxConfig struct {
192	// InitialBehavior controls what to show when omnibox opens with empty input
193	// Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
194	InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior"`
195}
196
197type DebugConfig struct {
198	// Enable WebKit internal debug logging
199	EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"`
200
201	// WebKit debug categories (comma-separated)
202	// Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
203	WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories"`
204
205	// Enable content filtering debug logs
206	EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug"`
207
208	// Enable detailed WebView state logging
209	EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug"`
210
211	// Log WebKit crashes and errors to file
212	LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes"`
213
214	// Enable script injection debug logs
215	EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug"`
216
217	// Enable general debug mode (equivalent to DUMBER_DEBUG env var)
218	EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
219
220	// Enable workspace navigation and focus debug logs
221	EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
222
223	// Enable focus state machine debug logs
224	EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug"`
225
226	// Enable CSS reconciler debug logs
227	EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug"`
228
229	// Enable focus metrics tracking
230	EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics"`
231
232	// Enable detailed pane close instrumentation and tree snapshots
233	EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
234}
235
236// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
237type WorkspaceConfig struct {
238	// PaneMode defines modal pane behaviour and bindings.
239	PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
240	// TabMode defines modal tab behaviour and bindings (Alt+T).
241	TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" json:"tab_mode"`
242	// Tabs holds classic browser tab shortcuts.
243	Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"`
244	// TabBarPosition determines tab bar placement: "top" or "bottom".
245	TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" json:"tab_bar_position"`
246	// Popups configures default popup placement rules.
247	Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
248	// Styling configures workspace visual appearance.
249	Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" json:"styling"`
250}
251
252// PaneModeConfig defines modal behaviour for pane management.
253type PaneModeConfig struct {
254	ActivationShortcut  string              `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
255	TimeoutMilliseconds int                 `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
256	Actions             map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
257}
258
259// GetKeyBindings returns an inverted map for O(1) key→action lookup.
260// This is built from the action→keys structure in the config.
261func (p *PaneModeConfig) GetKeyBindings() map[string]string {
262	keyToAction := make(map[string]string)
263	for action, keys := range p.Actions {
264		for _, key := range keys {
265			keyToAction[key] = action
266		}
267	}
268	return keyToAction
269}
270
271// TabModeConfig defines modal behaviour for tab management (Zellij-style).
272type TabModeConfig struct {
273	ActivationShortcut  string              `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
274	TimeoutMilliseconds int                 `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
275	Actions             map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
276}
277
278// GetKeyBindings returns an inverted map for O(1) key→action lookup.
279// This is built from the action→keys structure in the config.
280func (t *TabModeConfig) GetKeyBindings() map[string]string {
281	keyToAction := make(map[string]string)
282	for action, keys := range t.Actions {
283		for _, key := range keys {
284			keyToAction[key] = action
285		}
286	}
287	return keyToAction
288}
289
290// TabKeyConfig defines Zellij-inspired tab shortcuts.
291type TabKeyConfig struct {
292	NewTab      string `mapstructure:"new_tab" yaml:"new_tab" json:"new_tab"`
293	CloseTab    string `mapstructure:"close_tab" yaml:"close_tab" json:"close_tab"`
294	NextTab     string `mapstructure:"next_tab" yaml:"next_tab" json:"next_tab"`
295	PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
296}
297
298// PopupBehavior defines how popup windows should be opened
299type PopupBehavior string
300
301const (
302	// PopupBehaviorSplit opens popups in a split pane (default)
303	PopupBehaviorSplit PopupBehavior = "split"
304	// PopupBehaviorStacked opens popups in a stacked pane
305	PopupBehaviorStacked PopupBehavior = "stacked"
306	// PopupBehaviorTabbed opens popups as a new tab
307	PopupBehaviorTabbed PopupBehavior = "tabbed"
308	// PopupBehaviorWindowed opens popups in a new workspace window
309	PopupBehaviorWindowed PopupBehavior = "windowed"
310)
311
312// PopupBehaviorConfig defines handling for popup windows.
313type PopupBehaviorConfig struct {
314	// Behavior determines how popups are opened (split/stacked/tabbed/windowed)
315	Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" json:"behavior"`
316
317	// Placement specifies direction for split behavior ("right", "left", "top", "bottom")
318	// Only used when Behavior is "split"
319	Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
320
321	// OpenInNewPane controls whether popups are opened in workspace or blocked
322	OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
323
324	// FollowPaneContext determines if popup placement follows parent pane context
325	FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
326
327	// BlankTargetBehavior determines how target="_blank" links are opened
328	// Accepted values: "split", "stacked" (default), "tabbed"
329	// This is separate from Behavior which controls JavaScript popups
330	BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
331
332	// EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
333	EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`
334
335	// OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
336	OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`
337}
338
339// WorkspaceStylingConfig defines visual styling for workspace panes.
340type WorkspaceStylingConfig struct {
341	// BorderWidth in pixels for active pane borders (overlay)
342	BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
343	// BorderColor for focused panes (CSS color value or theme variable)
344	BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"`
345
346	// PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
347	PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" json:"pane_mode_border_width"`
348	// PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
349	// Defaults to "#4A90E2" (blue) if not set
350	PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" json:"pane_mode_border_color"`
351
352	// TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
353	TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" json:"tab_mode_border_width"`
354	// TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
355	// Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
356	TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" json:"tab_mode_border_color"`
357
358	// TransitionDuration in milliseconds for border animations
359	TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
360	// UIScale is a multiplier for UI elements like title bars (1.0 = 100%, 1.2 = 120%)
361	UIScale float64 `mapstructure:"ui_scale" yaml:"ui_scale" json:"ui_scale"`
362}
363
364// Manager handles configuration loading, watching, and reloading.
365type Manager struct {
366	config    *Config
367	viper     *viper.Viper
368	mu        sync.RWMutex
369	callbacks []func(*Config)
370	watching  bool
371}
372
373// NewManager creates a new configuration manager.
374func NewManager() (*Manager, error) {
375	v := viper.New()
376
377	// Configure Viper - supports yaml, json, toml automatically
378	v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
379
380	// Add config paths
381	configDir, err := GetConfigDir()
382	if err != nil {
383		return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
384	}
385	v.AddConfigPath(configDir)
386	v.AddConfigPath(".") // Current directory for development
387
388	// Set up environment variable support
389	v.SetEnvPrefix("DUMB_BROWSER")
390	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
391	v.AutomaticEnv()
392
393	// Note: Most environment variables are handled automatically via AutomaticEnv()
394	// with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
395	// The explicit bindings below are only for special cases with different naming patterns.
396
397	// Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
398	if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
399		return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
400	}
401	if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
402		return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
403	}
404	if err := v.BindEnv("default_zoom", "DUMBER_DEFAULT_ZOOM"); err != nil {
405		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_ZOOM: %w", err)
406	}
407
408	// Video acceleration environment variable bindings
409	// These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
410	videoAccelEnvBindings := map[string]string{
411		"video_acceleration.enable_vaapi":       "DUMBER_VIDEO_ACCELERATION_ENABLE",
412		"video_acceleration.auto_detect_gpu":    "DUMBER_VIDEO_AUTO_DETECT",
413		"video_acceleration.vaapi_driver_name":  "LIBVA_DRIVER_NAME",
414		"video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
415		"video_acceleration.legacy_vaapi":       "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
416	}
417
418	for key, env := range videoAccelEnvBindings {
419		if err := v.BindEnv(key, env); err != nil {
420			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
421		}
422	}
423
424	// Codec preferences environment variable bindings
425	// These use DUMBER_* prefix for shorter, more convenient env var names
426	codecEnvBindings := map[string]string{
427		"codec_preferences.preferred_codecs":             "DUMBER_PREFERRED_CODECS",
428		"codec_preferences.force_av1":                    "DUMBER_FORCE_AV1",
429		"codec_preferences.block_vp9":                    "DUMBER_BLOCK_VP9",
430		"codec_preferences.block_vp8":                    "DUMBER_BLOCK_VP8",
431		"codec_preferences.av1_hardware_only":            "DUMBER_AV1_HW_ONLY",
432		"codec_preferences.disable_vp9_hardware":         "DUMBER_DISABLE_VP9_HW",
433		"codec_preferences.video_buffer_size_mb":         "DUMBER_VIDEO_BUFFER_MB",
434		"codec_preferences.queue_buffer_time_sec":        "DUMBER_QUEUE_BUFFER_SEC",
435		"codec_preferences.custom_user_agent":            "DUMBER_CUSTOM_UA",
436		"codec_preferences.av1_max_resolution":           "DUMBER_AV1_MAX_RES",
437		"codec_preferences.disable_twitch_codec_control": "DUMBER_DISABLE_TWITCH_CODEC",
438	}
439
440	for key, env := range codecEnvBindings {
441		if err := v.BindEnv(key, env); err != nil {
442			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
443		}
444	}
445
446	return &Manager{
447		viper:     v,
448		callbacks: make([]func(*Config), 0),
449	}, nil
450}
451
452// Load loads the configuration from file and environment variables.
453func (m *Manager) Load() error {
454	m.mu.Lock()
455	defer m.mu.Unlock()
456
457	// Ensure directories exist
458	if err := EnsureDirectories(); err != nil {
459		return fmt.Errorf("failed to ensure directories: %w", err)
460	}
461
462	// Set defaults
463	m.setDefaults()
464
465	// Read config file if it exists
466	if err := m.viper.ReadInConfig(); err != nil {
467		var configFileNotFoundError viper.ConfigFileNotFoundError
468		if errors.As(err, &configFileNotFoundError) {
469			// Config file not found, create default one
470			if err := m.createDefaultConfig(); err != nil {
471				configDir, _ := GetConfigDir()
472				return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err)
473			}
474			// Re-read the newly created config file
475			if err := m.viper.ReadInConfig(); err != nil {
476				return fmt.Errorf("failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", err)
477			}
478		} else {
479			configFile := m.viper.ConfigFileUsed()
480			if configFile == "" {
481				configDir, _ := GetConfigDir()
482				configFile = filepath.Join(configDir, "config.*")
483			}
484			return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML, JSON, or YAML) and permissions", configFile, err)
485		}
486	}
487
488	// Unmarshal into config struct
489	config := &Config{}
490	if err := m.viper.Unmarshal(config); err != nil {
491		configFile := m.viper.ConfigFileUsed()
492		return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err)
493	}
494
495	// Set database path if not specified
496	if config.Database.Path == "" {
497		dbPath, err := GetDatabaseFile()
498		if err != nil {
499			return fmt.Errorf("failed to get database path: %w", err)
500		}
501		config.Database.Path = dbPath
502	}
503
504	// Normalize/validate rendering mode
505	switch strings.ToLower(string(config.RenderingMode)) {
506	case "", string(RenderingModeAuto):
507		config.RenderingMode = RenderingModeAuto
508	case string(RenderingModeGPU):
509		config.RenderingMode = RenderingModeGPU
510	case string(RenderingModeCPU):
511		config.RenderingMode = RenderingModeCPU
512	default:
513		config.RenderingMode = RenderingModeAuto
514	}
515
516	// Auto-detect GPU if enabled and driver name is not set
517	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
518		gpuInfo := gpu.DetectGPU()
519		if gpuInfo.SupportsVAAPI() {
520			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
521		}
522	}
523
524	// Validate and configure codec preferences
525	config = m.validateAndConfigureCodecPreferences(config)
526
527	// Validate ColorScheme setting
528	m.validateColorScheme(config)
529
530	// Validate all config values
531	if err := validateConfig(config); err != nil {
532		return fmt.Errorf("configuration validation failed: %w", err)
533	}
534
535	m.config = config
536	return nil
537}
538
539// Get returns the current configuration (thread-safe).
540func (m *Manager) Get() *Config {
541	m.mu.RLock()
542	defer m.mu.RUnlock()
543
544	// Return a copy to prevent external modification
545	configCopy := *m.config
546	return &configCopy
547}
548
549// Watch starts watching the config file for changes and reloads automatically.
550func (m *Manager) Watch() error {
551	m.mu.Lock()
552	defer m.mu.Unlock()
553
554	if m.watching {
555		return nil // Already watching
556	}
557
558	m.viper.WatchConfig()
559	m.viper.OnConfigChange(func(_ fsnotify.Event) {
560		// Reload config
561		if err := m.reload(); err != nil {
562			fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
563			return
564		}
565
566		// Notify callbacks
567		m.mu.RLock()
568		config := m.config
569		callbacks := make([]func(*Config), len(m.callbacks))
570		copy(callbacks, m.callbacks)
571		m.mu.RUnlock()
572
573		for _, callback := range callbacks {
574			callback(config)
575		}
576	})
577
578	m.watching = true
579	return nil
580}
581
582// OnConfigChange registers a callback function to be called when config changes.
583func (m *Manager) OnConfigChange(callback func(*Config)) {
584	m.mu.Lock()
585	defer m.mu.Unlock()
586
587	m.callbacks = append(m.callbacks, callback)
588}
589
590// reload reloads the configuration (internal method, must be called with lock).
591func (m *Manager) reload() error {
592	if err := m.viper.ReadInConfig(); err != nil {
593		return err
594	}
595
596	config := &Config{}
597	if err := m.viper.Unmarshal(config); err != nil {
598		return err
599	}
600
601	// Set database path if not specified
602	if config.Database.Path == "" {
603		dbPath, err := GetDatabaseFile()
604		if err != nil {
605			return fmt.Errorf("failed to get database path: %w", err)
606		}
607		config.Database.Path = dbPath
608	}
609
610	// Normalize/validate rendering mode
611	switch strings.ToLower(string(config.RenderingMode)) {
612	case "", string(RenderingModeAuto):
613		config.RenderingMode = RenderingModeAuto
614	case string(RenderingModeGPU):
615		config.RenderingMode = RenderingModeGPU
616	case string(RenderingModeCPU):
617		config.RenderingMode = RenderingModeCPU
618	default:
619		config.RenderingMode = RenderingModeAuto
620	}
621
622	// Auto-detect GPU if enabled and driver name is not set
623	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
624		gpuInfo := gpu.DetectGPU()
625		if gpuInfo.SupportsVAAPI() {
626			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
627		}
628	}
629
630	// Validate and configure codec preferences
631	config = m.validateAndConfigureCodecPreferences(config)
632
633	// Validate ColorScheme setting
634	m.validateColorScheme(config)
635
636	// Validate all config values
637	if err := validateConfig(config); err != nil {
638		return fmt.Errorf("configuration validation failed: %w", err)
639	}
640
641	m.config = config
642	return nil
643}
644
645// setDefaults sets default configuration values in Viper.
646func (m *Manager) setDefaults() {
647	defaults := DefaultConfig()
648
649	// Note: Database.Path is set dynamically in Load(), no defaults needed
650
651	// Note: Browser config removed - we build our own browser
652
653	// History defaults
654	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
655	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
656	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
657
658	// Search shortcuts defaults
659	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
660	m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
661
662	// Dmenu defaults
663	m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
664	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
665	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
666	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
667	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
668	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
669	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
670	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
671
672	// Logging defaults
673	m.viper.SetDefault("logging.level", defaults.Logging.Level)
674	m.viper.SetDefault("logging.format", defaults.Logging.Format)
675	m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
676	m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
677	m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
678	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
679	m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
680	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
681	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
682	m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
683	m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
684	m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
685	m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
686	m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
687
688	// Debug defaults
689	m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
690	m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
691	m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
692	m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
693	m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
694	m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
695	m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
696	m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
697	m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
698	m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
699	m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
700	m.viper.SetDefault("debug.enable_pane_close_debug", defaults.Debug.EnablePaneCloseDebug)
701
702	// Appearance defaults
703	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
704	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
705	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
706	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
707	m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
708	m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
709	m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
710
711	// Video acceleration defaults
712	m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
713	m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
714	m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
715	m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
716	m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
717
718	// Codec preferences defaults
719	m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
720	m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
721	m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
722	m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
723	m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
724	m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
725	m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
726	m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
727	m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
728	m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
729	m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
730
731	// Rendering defaults
732	m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
733	m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
734	m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
735
736	// Workspace defaults
737	m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
738	m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
739	m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
740	m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
741	m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
742	m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
743	m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
744	m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
745	m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
746	m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
747	m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
748	m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
749	m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
750	m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
751	m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
752	// New popup behaviour defaults
753	m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
754	m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
755	m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
756	m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
757	m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
758	m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
759	m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
760	m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
761	m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
762	m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
763	m.viper.SetDefault("workspace.styling.ui_scale", defaults.Workspace.Styling.UIScale)
764
765	// Content filtering
766	m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
767	m.viper.SetDefault("content_filtering.whitelist", defaults.ContentFiltering.Whitelist)
768	m.viper.SetDefault("content_filtering.filter_lists", defaults.ContentFiltering.FilterLists)
769
770	// Omnibox defaults
771	m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
772}
773
774// createDefaultConfig creates a default configuration file.
775func (m *Manager) createDefaultConfig() error {
776	configFile, err := GetConfigFile()
777	if err != nil {
778		return err
779	}
780
781	// Create config directory if it doesn't exist
782	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
783		return err
784	}
785
786	// Use Viper's SafeWriteConfigAs to avoid duplicate keys
787	if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
788		return fmt.Errorf("failed to write config file: %w", err)
789	}
790
791	fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
792
793	// Generate JSON schema file only for JSON configs (used for IDE autocompletion)
794	if filepath.Ext(configFile) == ".json" {
795		if err := GenerateSchemaFile(); err != nil {
796			// Log error but don't fail config creation
797			fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
798		}
799	}
800
801	return nil
802}
803
804// GetConfigFile returns the path to the configuration file being used.
805func (m *Manager) GetConfigFile() string {
806	return m.viper.ConfigFileUsed()
807}
808
809// New returns a new default configuration instance.
810// This is a convenience function for getting default config without the full manager.
811func New() *Config {
812	return DefaultConfig()
813}
814
815// Global configuration manager instance
816var globalManager *Manager
817var globalManagerOnce sync.Once
818
819// Init initializes the global configuration manager.
820func Init() error {
821	var err error
822	globalManagerOnce.Do(func() {
823		globalManager, err = NewManager()
824		if err != nil {
825			return
826		}
827		err = globalManager.Load()
828	})
829	return err
830}
831
832// Get returns the global configuration.
833func Get() *Config {
834	if globalManager == nil {
835		// Return defaults if not initialized
836		return DefaultConfig()
837	}
838	return globalManager.Get()
839}
840
841// Watch starts watching the global configuration for changes.
842func Watch() error {
843	if globalManager == nil {
844		return fmt.Errorf("configuration not initialized")
845	}
846	return globalManager.Watch()
847}
848
849// OnConfigChange registers a callback for global configuration changes.
850func OnConfigChange(callback func(*Config)) {
851	if globalManager == nil {
852		return
853	}
854	globalManager.OnConfigChange(callback)
855}
856
857// validateAndConfigureCodecPreferences validates and auto-configures codec preferences based on GPU capabilities
858func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
859	// Validate codec preferences
860	if config.CodecPreferences.PreferredCodecs == "" {
861		config.CodecPreferences.PreferredCodecs = "av1,h264,vp8"
862		fmt.Printf("Config: Set default codec preferences: %s\n", config.CodecPreferences.PreferredCodecs)
863	}
864
865	// Auto-configure based on GPU capabilities
866	if config.VideoAcceleration.AutoDetectGPU {
867		gpuInfo := gpu.DetectGPU()
868
869		// Check AV1 hardware support
870		if config.CodecPreferences.AV1HardwareOnly && !gpuInfo.SupportsAV1Hardware() {
871			// Disable AV1 hardware-only if GPU doesn't support it
872			config.CodecPreferences.AV1HardwareOnly = false
873			fmt.Printf("Config: GPU doesn't support AV1 hardware, enabling software fallback\n")
874		}
875
876		// Get detailed AV1 capabilities
877		av1Caps := gpuInfo.GetAV1HardwareCapabilities()
878		if av1Caps["decode"] {
879			logging.Info("GPU supports AV1 hardware decode")
880
881			// If GPU supports AV1, prefer it over VP9
882			if !config.CodecPreferences.ForceAV1 &&
883				!strings.HasPrefix(config.CodecPreferences.PreferredCodecs, "av1") {
884				config.CodecPreferences.PreferredCodecs = "av1," + config.CodecPreferences.PreferredCodecs
885				fmt.Printf("Config: Auto-enabled AV1 preference due to hardware support\n")
886			}
887		} else {
888			// If no AV1 support, ensure VP9 hardware is disabled to prevent issues
889			if !config.CodecPreferences.DisableVP9Hardware {
890				config.CodecPreferences.DisableVP9Hardware = true
891				fmt.Printf("Config: Auto-disabled VP9 hardware acceleration (no AV1 fallback)\n")
892			}
893		}
894
895		// Log GPU-specific codec capabilities
896		logging.Info(fmt.Sprintf("%s GPU codec capabilities - AV1: decode=%t, encode=%t, 10bit=%t",
897			gpuInfo.Vendor, av1Caps["decode"], av1Caps["encode"], av1Caps["10bit"]))
898	}
899
900	return config
901}
902
903// validateColorScheme validates and normalizes the ColorScheme setting
904func (m *Manager) validateColorScheme(config *Config) {
905	switch config.Appearance.ColorScheme {
906	case "prefer-dark", "prefer-light", ThemeDefault, "":
907		// Valid values - no changes needed
908		// Empty string is treated the same as "default"
909		if config.Appearance.ColorScheme == "" {
910			config.Appearance.ColorScheme = ThemeDefault
911			logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)")
912		}
913	default:
914		// Invalid value - warn and reset to default
915		logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'",
916			config.Appearance.ColorScheme))
917		config.Appearance.ColorScheme = ThemeDefault
918	}
919}

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