detached 5c63f5a

refactor(config): migrate to TOML with validation and optimized action bindings

commit
5c63f5a8812401200e3e7a983342e7c984b5656d
author
bnema · · 2025-11-15 17:01 UTC
parent
037f923
signature
unsigned
6 files · +279 -183
M README.md +23 -28
@@ -173,44 +173,40 @@ Dumber features a Zellij-inspired pane management system that allows you to spli
173173 4. **Close**: Press `X` in pane mode to close the current pane
174174
175175 ### Configuration
176-The pane system is fully configurable via `config.json`:
177-```json
178-"workspace": {
179- "enable_zellij_controls": true,
180- "pane_mode": {
181- "activation_shortcut": "ctrl+p",
182- "timeout_ms": 3000,
183- "action_bindings": {
184- "arrowright": "split-right",
185- "r": "split-right",
186- "x": "close-pane",
187- // ... full key mappings
188- }
189- },
190- "popups": {
191- "placement": "right",
192- "open_in_new_pane": true
193- },
194- "styling": {
195- "border_width": 2,
196- "border_color": "@theme_selected_bg_color",
197- "transition_duration": 120
198- }
199-}
176+The pane system is fully configurable via `config.toml`:
177+```toml
178+[workspace.pane_mode]
179+activation_shortcut = "ctrl+p"
180+timeout_ms = 3000
181+
182+[workspace.pane_mode.actions]
183+split-right = ["arrowright", "r"]
184+split-left = ["arrowleft", "l"]
185+close-pane = ["x"]
186+# ... full action mappings
187+
188+[workspace.popups]
189+placement = "right"
190+open_in_new_pane = true
191+
192+[workspace.styling]
193+border_width = 2
194+border_color = "@theme_selected_bg_color"
195+transition_duration = 120
200196 ```
201197
202198 ## Configuration
203199
204200 Dumber follows the XDG Base Directory spec:
205-- Config: `~/.config/dumber/config.json`
201+- Config: `~/.config/dumber/config.toml` (also supports `.json` and `.yaml`)
206202 - Data: `~/.local/share/dumber`
207203 - State: `~/.local/state/dumber` (default DB lives here)
208204
209-A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible.
205+A default `config.toml` is created on first run. Config changes are watched and applied at runtime when possible.
210206
211207 ### How Configuration Works
212208
213-- **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too)
209+- **Config file**: `~/.config/dumber/config.toml` (also supports `.json` and `.yaml`)
214210 - **Format**: JSON/YAML/TOML - auto-detected
215211 - **JSON Schema**: Auto-generated for IDE autocompletion and validation
216212 - **Live reload**: Changes are automatically applied without restarting
@@ -227,7 +223,6 @@ A default `config.json` is created on first run. Config changes are watched and
227223 "g": { "url": "https://google.com/search?q=%s", "description": "Google" }
228224 },
229225 "workspace": {
230- "enable_zellij_controls": true,
231226 "popups": { "behavior": "split" }
232227 }
233228 }
@@ -173,44 +173,40 @@ Dumber features a Zellij-inspired pane management system that allows you to spli
173 4. **Close**: Press `X` in pane mode to close the current pane173 4. **Close**: Press `X` in pane mode to close the current pane
174 174
175 ### Configuration175 ### Configuration
176-The pane system is fully configurable via `config.json`:176+The pane system is fully configurable via `config.toml`:
177-```json177+```toml
178-"workspace": {178+[workspace.pane_mode]
179- "enable_zellij_controls": true,179+activation_shortcut = "ctrl+p"
180- "pane_mode": {180+timeout_ms = 3000
181- "activation_shortcut": "ctrl+p",181+
182- "timeout_ms": 3000,182+[workspace.pane_mode.actions]
183- "action_bindings": {183+split-right = ["arrowright", "r"]
184- "arrowright": "split-right",184+split-left = ["arrowleft", "l"]
185- "r": "split-right",185+close-pane = ["x"]
186- "x": "close-pane",186+# ... full action mappings
187- // ... full key mappings187+
188- }188+[workspace.popups]
189- },189+placement = "right"
190- "popups": {190+open_in_new_pane = true
191- "placement": "right",191+
192- "open_in_new_pane": true192+[workspace.styling]
193- },193+border_width = 2
194- "styling": {194+border_color = "@theme_selected_bg_color"
195- "border_width": 2,195+transition_duration = 120
196- "border_color": "@theme_selected_bg_color",
197- "transition_duration": 120
198- }
199-}
200 ```196 ```
201 197
202 ## Configuration198 ## Configuration
203 199
204 Dumber follows the XDG Base Directory spec:200 Dumber follows the XDG Base Directory spec:
205-- Config: `~/.config/dumber/config.json`201+- Config: `~/.config/dumber/config.toml` (also supports `.json` and `.yaml`)
206 - Data: `~/.local/share/dumber`202 - Data: `~/.local/share/dumber`
207 - State: `~/.local/state/dumber` (default DB lives here)203 - State: `~/.local/state/dumber` (default DB lives here)
208 204
209-A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible.205+A default `config.toml` is created on first run. Config changes are watched and applied at runtime when possible.
210 206
211 ### How Configuration Works207 ### How Configuration Works
212 208
213-- **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too)209+- **Config file**: `~/.config/dumber/config.toml` (also supports `.json` and `.yaml`)
214 - **Format**: JSON/YAML/TOML - auto-detected210 - **Format**: JSON/YAML/TOML - auto-detected
215 - **JSON Schema**: Auto-generated for IDE autocompletion and validation211 - **JSON Schema**: Auto-generated for IDE autocompletion and validation
216 - **Live reload**: Changes are automatically applied without restarting212 - **Live reload**: Changes are automatically applied without restarting
@@ -227,7 +223,6 @@ A default `config.json` is created on first run. Config changes are watched and
227 "g": { "url": "https://google.com/search?q=%s", "description": "Google" }223 "g": { "url": "https://google.com/search?q=%s", "description": "Google" }
228 },224 },
229 "workspace": {225 "workspace": {
230- "enable_zellij_controls": true,
231 "popups": { "behavior": "split" }226 "popups": { "behavior": "split" }
232 }227 }
233 }228 }
M docs/CONFIG.md +61 -95
@@ -1,23 +1,19 @@
11 # Configuration Reference
22
3-**Primary format**: JSON
4-**Location**: `~/.config/dumber/config.json`
5-**Also supports**: YAML (`.yaml`) and TOML (`.toml`)
6-**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated)
3+**Primary format**: TOML (recommended)
4+**Location**: `~/.config/dumber/config.toml`
5+**Also supports**: JSON (`.json`) and YAML (`.yaml`)
6+**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated for JSON format)
77
8-## IDE Integration
8+## Why TOML?
99
10-For autocompletion and validation in VS Code or other editors, add this to the top of your `config.json`:
10+TOML is the recommended format for dumber configuration because:
11+- **Clear structure**: Explicit sections make complex configs easy to read and modify
12+- **No ambiguity**: No whitespace sensitivity or type coercion issues
13+- **Comments supported**: Add documentation directly in your config
14+- **Industry standard**: Used by Cargo, Hugo, and many modern tools
1115
12-```json
13-{
14- "$schema": "./config.schema.json",
15- "default_zoom": 1.2,
16- ...
17-}
18-```
19-
20-The schema is automatically generated when you first create a config file.
16+You can still use JSON or YAML if you prefer - dumber automatically detects the format.
2117
2218 ## Database
2319
@@ -40,23 +36,18 @@ The schema is automatically generated when you first create a config file.
4036 | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs |
4137
4238 **Default shortcuts:**
43-```json
44-{
45- "search_shortcuts": {
46- "g": {
47- "url": "https://google.com/search?q=%s",
48- "description": "Google Search"
49- },
50- "gh": {
51- "url": "https://github.com/search?q=%s",
52- "description": "GitHub"
53- },
54- "yt": {
55- "url": "https://youtube.com/results?search_query=%s",
56- "description": "YouTube"
57- }
58- }
59-}
39+```toml
40+[search_shortcuts.g]
41+url = "https://google.com/search?q=%s"
42+description = "Google Search"
43+
44+[search_shortcuts.gh]
45+url = "https://github.com/search?q=%s"
46+description = "GitHub"
47+
48+[search_shortcuts.yt]
49+url = "https://youtube.com/results?search_query=%s"
50+description = "YouTube"
6051 ```
6152
6253 ## Dmenu / Launcher
@@ -105,37 +96,27 @@ The schema is automatically generated when you first create a config file.
10596 ### Color Palettes
10697
10798 **Light palette:**
108-```json
109-{
110- "appearance": {
111- "light_palette": {
112- "background": "#f8f8f8",
113- "surface": "#f2f2f2",
114- "surface_variant": "#ececec",
115- "text": "#1a1a1a",
116- "muted": "#6e6e6e",
117- "accent": "#404040",
118- "border": "#d2d2d2"
119- }
120- }
121-}
99+```toml
100+[appearance.light_palette]
101+background = "#f8f8f8"
102+surface = "#f2f2f2"
103+surface_variant = "#ececec"
104+text = "#1a1a1a"
105+muted = "#6e6e6e"
106+accent = "#404040"
107+border = "#d2d2d2"
122108 ```
123109
124110 **Dark palette:**
125-```json
126-{
127- "appearance": {
128- "dark_palette": {
129- "background": "#0e0e0e",
130- "surface": "#1a1a1a",
131- "surface_variant": "#141414",
132- "text": "#e4e4e4",
133- "muted": "#848484",
134- "accent": "#a8a8a8",
135- "border": "#363636"
136- }
137- }
138-}
111+```toml
112+[appearance.dark_palette]
113+background = "#0e0e0e"
114+surface = "#1a1a1a"
115+surface_variant = "#141414"
116+text = "#e4e4e4"
117+muted = "#848484"
118+accent = "#a8a8a8"
119+border = "#363636"
139120 ```
140121
141122 ## Video Acceleration
@@ -191,41 +172,28 @@ The schema is automatically generated when you first create a config file.
191172
192173 ## Workspace Configuration
193174
194-| Key | Type | Default | Description |
195-|-----|------|---------|-------------|
196-| `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings |
197-
198175 ### Pane Mode
199176
200177 | Key | Type | Default | Description |
201178 |-----|------|---------|-------------|
202179 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |
203180 | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) |
204-| `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings |
205-
206-**Default pane mode bindings:**
207-```json
208-{
209- "workspace": {
210- "pane_mode": {
211- "action_bindings": {
212- "arrowright": "split-right",
213- "arrowleft": "split-left",
214- "arrowup": "split-up",
215- "arrowdown": "split-down",
216- "r": "split-right",
217- "l": "split-left",
218- "u": "split-up",
219- "d": "split-down",
220- "x": "close-pane",
221- "enter": "confirm",
222- "escape": "cancel"
223- }
224- }
225- }
226-}
181+| `workspace.pane_mode.actions` | map | See below | Action→keys mappings |
182+
183+**Default pane mode actions:**
184+```toml
185+[workspace.pane_mode.actions]
186+split-right = ["arrowright", "r"]
187+split-left = ["arrowleft", "l"]
188+split-up = ["arrowup", "u"]
189+split-down = ["arrowdown", "d"]
190+close-pane = ["x"]
191+confirm = ["enter"]
192+cancel = ["escape"]
227193 ```
228194
195+> **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
196+
229197 ### Tab Shortcuts
230198
231199 | Key | Type | Default | Description |
@@ -267,14 +235,12 @@ The schema is automatically generated when you first create a config file.
267235 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |
268236
269237 **Default whitelist:**
270-```json
271-{
272- "content_filtering_whitelist": [
273- "twitch.tv",
274- "passport.twitch.tv",
275- "gql.twitch.tv"
276- ]
277-}
238+```toml
239+content_filtering_whitelist = [
240+ "twitch.tv",
241+ "passport.twitch.tv",
242+ "gql.twitch.tv"
243+]
278244 ```
279245
280246 ## Environment Variables
@@ -1,23 +1,19 @@
1 # Configuration Reference1 # Configuration Reference
2 2
3-**Primary format**: JSON3+**Primary format**: TOML (recommended)
4-**Location**: `~/.config/dumber/config.json`4+**Location**: `~/.config/dumber/config.toml`
5-**Also supports**: YAML (`.yaml`) and TOML (`.toml`)5+**Also supports**: JSON (`.json`) and YAML (`.yaml`)
6-**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated)6+**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated for JSON format)
7 7
8-## IDE Integration8+## Why TOML?
9 9
10-For autocompletion and validation in VS Code or other editors, add this to the top of your `config.json`:10+TOML is the recommended format for dumber configuration because:
11+- **Clear structure**: Explicit sections make complex configs easy to read and modify
12+- **No ambiguity**: No whitespace sensitivity or type coercion issues
13+- **Comments supported**: Add documentation directly in your config
14+- **Industry standard**: Used by Cargo, Hugo, and many modern tools
11 15
12-```json16+You can still use JSON or YAML if you prefer - dumber automatically detects the format.
13-{
14- "$schema": "./config.schema.json",
15- "default_zoom": 1.2,
16- ...
17-}
18-```
19-
20-The schema is automatically generated when you first create a config file.
21 17
22 ## Database18 ## Database
23 19
@@ -40,23 +36,18 @@ The schema is automatically generated when you first create a config file.
40 | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs |36 | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs |
41 37
42 **Default shortcuts:**38 **Default shortcuts:**
43-```json39+```toml
44-{40+[search_shortcuts.g]
45- "search_shortcuts": {41+url = "https://google.com/search?q=%s"
46- "g": {42+description = "Google Search"
47- "url": "https://google.com/search?q=%s",43+
48- "description": "Google Search"44+[search_shortcuts.gh]
49- },45+url = "https://github.com/search?q=%s"
50- "gh": {46+description = "GitHub"
51- "url": "https://github.com/search?q=%s",47+
52- "description": "GitHub"48+[search_shortcuts.yt]
53- },49+url = "https://youtube.com/results?search_query=%s"
54- "yt": {50+description = "YouTube"
55- "url": "https://youtube.com/results?search_query=%s",
56- "description": "YouTube"
57- }
58- }
59-}
60 ```51 ```
61 52
62 ## Dmenu / Launcher53 ## Dmenu / Launcher
@@ -105,37 +96,27 @@ The schema is automatically generated when you first create a config file.
105 ### Color Palettes96 ### Color Palettes
106 97
107 **Light palette:**98 **Light palette:**
108-```json99+```toml
109-{100+[appearance.light_palette]
110- "appearance": {101+background = "#f8f8f8"
111- "light_palette": {102+surface = "#f2f2f2"
112- "background": "#f8f8f8",103+surface_variant = "#ececec"
113- "surface": "#f2f2f2",104+text = "#1a1a1a"
114- "surface_variant": "#ececec",105+muted = "#6e6e6e"
115- "text": "#1a1a1a",106+accent = "#404040"
116- "muted": "#6e6e6e",107+border = "#d2d2d2"
117- "accent": "#404040",
118- "border": "#d2d2d2"
119- }
120- }
121-}
122 ```108 ```
123 109
124 **Dark palette:**110 **Dark palette:**
125-```json111+```toml
126-{112+[appearance.dark_palette]
127- "appearance": {113+background = "#0e0e0e"
128- "dark_palette": {114+surface = "#1a1a1a"
129- "background": "#0e0e0e",115+surface_variant = "#141414"
130- "surface": "#1a1a1a",116+text = "#e4e4e4"
131- "surface_variant": "#141414",117+muted = "#848484"
132- "text": "#e4e4e4",118+accent = "#a8a8a8"
133- "muted": "#848484",119+border = "#363636"
134- "accent": "#a8a8a8",
135- "border": "#363636"
136- }
137- }
138-}
139 ```120 ```
140 121
141 ## Video Acceleration122 ## Video Acceleration
@@ -191,41 +172,28 @@ The schema is automatically generated when you first create a config file.
191 172
192 ## Workspace Configuration173 ## Workspace Configuration
193 174
194-| Key | Type | Default | Description |
195-|-----|------|---------|-------------|
196-| `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings |
197-
198 ### Pane Mode175 ### Pane Mode
199 176
200 | Key | Type | Default | Description |177 | Key | Type | Default | Description |
201 |-----|------|---------|-------------|178 |-----|------|---------|-------------|
202 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |179 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |
203 | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) |180 | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) |
204-| `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings |181+| `workspace.pane_mode.actions` | map | See below | Action→keys mappings |
205-182+
206-**Default pane mode bindings:**183+**Default pane mode actions:**
207-```json184+```toml
208-{185+[workspace.pane_mode.actions]
209- "workspace": {186+split-right = ["arrowright", "r"]
210- "pane_mode": {187+split-left = ["arrowleft", "l"]
211- "action_bindings": {188+split-up = ["arrowup", "u"]
212- "arrowright": "split-right",189+split-down = ["arrowdown", "d"]
213- "arrowleft": "split-left",190+close-pane = ["x"]
214- "arrowup": "split-up",191+confirm = ["enter"]
215- "arrowdown": "split-down",192+cancel = ["escape"]
216- "r": "split-right",
217- "l": "split-left",
218- "u": "split-up",
219- "d": "split-down",
220- "x": "close-pane",
221- "enter": "confirm",
222- "escape": "cancel"
223- }
224- }
225- }
226-}
227 ```193 ```
228 194
195+> **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
196+
229 ### Tab Shortcuts197 ### Tab Shortcuts
230 198
231 | Key | Type | Default | Description |199 | Key | Type | Default | Description |
@@ -267,14 +235,12 @@ The schema is automatically generated when you first create a config file.
267 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |235 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |
268 236
269 **Default whitelist:**237 **Default whitelist:**
270-```json238+```toml
271-{239+content_filtering_whitelist = [
272- "content_filtering_whitelist": [240+ "twitch.tv",
273- "twitch.tv",241+ "passport.twitch.tv",
274- "passport.twitch.tv",242+ "gql.twitch.tv"
275- "gql.twitch.tv"243+]
276- ]
277-}
278 ```244 ```
279 245
280 ## Environment Variables246 ## Environment Variables
M internal/config/config.go +51 -46
@@ -214,8 +214,6 @@ type DebugConfig struct {
214214
215215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
216216 type WorkspaceConfig struct {
217- // EnableZellijControls toggles Zellij-inspired keybindings.
218- EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
219217 // PaneMode defines modal pane behaviour and bindings.
220218 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
221219 // Tabs holds classic browser tab shortcuts.
@@ -228,9 +226,21 @@ type WorkspaceConfig struct {
228226
229227 // PaneModeConfig defines modal behaviour for pane management.
230228 type PaneModeConfig struct {
231- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
232- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
233- ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`
229+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
230+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
231+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
232+}
233+
234+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
235+// This is built from the action→keys structure in the config.
236+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
237+ keyToAction := make(map[string]string)
238+ for action, keys := range p.Actions {
239+ for _, key := range keys {
240+ keyToAction[key] = action
241+ }
242+ }
243+ return keyToAction
234244 }
235245
236246 // TabKeyConfig defines Zellij-inspired tab shortcuts.
@@ -321,7 +331,7 @@ func NewManager() (*Manager, error) {
321331 // Add config paths
322332 configDir, err := GetConfigDir()
323333 if err != nil {
324- return nil, fmt.Errorf("failed to get config directory: %w", err)
334+ return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
325335 }
326336 v.AddConfigPath(configDir)
327337 v.AddConfigPath(".") // Current directory for development
@@ -331,36 +341,11 @@ func NewManager() (*Manager, error) {
331341 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
332342 v.AutomaticEnv()
333343
334- // Bind specific environment variables
335- bindings := map[string]string{
336- "database.path": "DATABASE_PATH",
337- "history.max_entries": "HISTORY_MAX_ENTRIES",
338- "history.retention_period": "HISTORY_RETENTION_PERIOD",
339- "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
340- "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
341- "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
342- "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
343- "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
344- "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
345- "dmenu.url_prefix": "DMENU_URL_PREFIX",
346- "dmenu.date_format": "DMENU_DATE_FORMAT",
347- "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
348- "logging.level": "LOGGING_LEVEL",
349- "logging.format": "LOGGING_FORMAT",
350- "logging.filename": "LOGGING_FILENAME",
351- "logging.max_size": "LOGGING_MAX_SIZE",
352- "logging.max_backups": "LOGGING_MAX_BACKUPS",
353- "logging.max_age": "LOGGING_MAX_AGE",
354- "logging.compress": "LOGGING_COMPRESS",
355- }
356-
357- for key, env := range bindings {
358- if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
359- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
360- }
361- }
344+ // Note: Most environment variables are handled automatically via AutomaticEnv()
345+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
346+ // The explicit bindings below are only for special cases with different naming patterns.
362347
363- // Explicit binding for rendering mode via dedicated env var
348+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
364349 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
365350 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
366351 }
@@ -372,6 +357,7 @@ func NewManager() (*Manager, error) {
372357 }
373358
374359 // Video acceleration environment variable bindings
360+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
375361 videoAccelEnvBindings := map[string]string{
376362 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
377363 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
@@ -387,6 +373,7 @@ func NewManager() (*Manager, error) {
387373 }
388374
389375 // Codec preferences environment variable bindings
376+ // These use DUMBER_* prefix for shorter, more convenient env var names
390377 codecEnvBindings := map[string]string{
391378 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
392379 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
@@ -432,21 +419,28 @@ func (m *Manager) Load() error {
432419 if errors.As(err, &configFileNotFoundError) {
433420 // Config file not found, create default one
434421 if err := m.createDefaultConfig(); err != nil {
435- return fmt.Errorf("failed to create default config: %w", err)
422+ configDir, _ := GetConfigDir()
423+ return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err)
436424 }
437425 // Re-read the newly created config file
438426 if err := m.viper.ReadInConfig(); err != nil {
439- return fmt.Errorf("failed to read newly created config: %w", err)
427+ 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)
440428 }
441429 } else {
442- return fmt.Errorf("failed to read config file: %w", err)
430+ configFile := m.viper.ConfigFileUsed()
431+ if configFile == "" {
432+ configDir, _ := GetConfigDir()
433+ configFile = filepath.Join(configDir, "config.*")
434+ }
435+ 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)
443436 }
444437 }
445438
446439 // Unmarshal into config struct
447440 config := &Config{}
448441 if err := m.viper.Unmarshal(config); err != nil {
449- return fmt.Errorf("failed to unmarshal config: %w", err)
442+ configFile := m.viper.ConfigFileUsed()
443+ return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err)
450444 }
451445
452446 // Set database path if not specified
@@ -484,6 +478,11 @@ func (m *Manager) Load() error {
484478 // Validate ColorScheme setting
485479 m.validateColorScheme(config)
486480
481+ // Validate all config values
482+ if err := validateConfig(config); err != nil {
483+ return fmt.Errorf("configuration validation failed: %w", err)
484+ }
485+
487486 m.config = config
488487 return nil
489488 }
@@ -585,6 +584,11 @@ func (m *Manager) reload() error {
585584 // Validate ColorScheme setting
586585 m.validateColorScheme(config)
587586
587+ // Validate all config values
588+ if err := validateConfig(config); err != nil {
589+ return fmt.Errorf("configuration validation failed: %w", err)
590+ }
591+
588592 m.config = config
589593 return nil
590594 }
@@ -680,10 +684,9 @@ func (m *Manager) setDefaults() {
680684 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
681685
682686 // Workspace defaults
683- m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
684687 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
685688 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
686- m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings)
689+ m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
687690 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
688691 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
689692 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
@@ -723,12 +726,14 @@ func (m *Manager) createDefaultConfig() error {
723726 return fmt.Errorf("failed to write config file: %w", err)
724727 }
725728
726- fmt.Printf("Created default configuration file: %s\n", configFile)
729+ fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
727730
728- // Generate JSON schema file
729- if err := GenerateSchemaFile(); err != nil {
730- // Log error but don't fail config creation
731- fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
731+ // Generate JSON schema file only for JSON configs (used for IDE autocompletion)
732+ if filepath.Ext(configFile) == ".json" {
733+ if err := GenerateSchemaFile(); err != nil {
734+ // Log error but don't fail config creation
735+ fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
736+ }
732737 }
733738
734739 return nil
@@ -214,8 +214,6 @@ type DebugConfig struct {
214 214
215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
216 type WorkspaceConfig struct {216 type WorkspaceConfig struct {
217- // EnableZellijControls toggles Zellij-inspired keybindings.
218- EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
219 // PaneMode defines modal pane behaviour and bindings.217 // PaneMode defines modal pane behaviour and bindings.
220 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`218 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
221 // Tabs holds classic browser tab shortcuts.219 // Tabs holds classic browser tab shortcuts.
@@ -228,9 +226,21 @@ type WorkspaceConfig struct {
228 226
229 // PaneModeConfig defines modal behaviour for pane management.227 // PaneModeConfig defines modal behaviour for pane management.
230 type PaneModeConfig struct {228 type PaneModeConfig struct {
231- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`229+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
232- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`230+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
233- ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`231+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
232+}
233+
234+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
235+// This is built from the action→keys structure in the config.
236+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
237+ keyToAction := make(map[string]string)
238+ for action, keys := range p.Actions {
239+ for _, key := range keys {
240+ keyToAction[key] = action
241+ }
242+ }
243+ return keyToAction
234 }244 }
235 245
236 // TabKeyConfig defines Zellij-inspired tab shortcuts.246 // TabKeyConfig defines Zellij-inspired tab shortcuts.
@@ -321,7 +331,7 @@ func NewManager() (*Manager, error) {
321 // Add config paths331 // Add config paths
322 configDir, err := GetConfigDir()332 configDir, err := GetConfigDir()
323 if err != nil {333 if err != nil {
324- return nil, fmt.Errorf("failed to get config directory: %w", err)334+ return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
325 }335 }
326 v.AddConfigPath(configDir)336 v.AddConfigPath(configDir)
327 v.AddConfigPath(".") // Current directory for development337 v.AddConfigPath(".") // Current directory for development
@@ -331,36 +341,11 @@ func NewManager() (*Manager, error) {
331 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))341 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
332 v.AutomaticEnv()342 v.AutomaticEnv()
333 343
334- // Bind specific environment variables344+ // Note: Most environment variables are handled automatically via AutomaticEnv()
335- bindings := map[string]string{345+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
336- "database.path": "DATABASE_PATH",346+ // The explicit bindings below are only for special cases with different naming patterns.
337- "history.max_entries": "HISTORY_MAX_ENTRIES",
338- "history.retention_period": "HISTORY_RETENTION_PERIOD",
339- "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
340- "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
341- "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
342- "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
343- "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
344- "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
345- "dmenu.url_prefix": "DMENU_URL_PREFIX",
346- "dmenu.date_format": "DMENU_DATE_FORMAT",
347- "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
348- "logging.level": "LOGGING_LEVEL",
349- "logging.format": "LOGGING_FORMAT",
350- "logging.filename": "LOGGING_FILENAME",
351- "logging.max_size": "LOGGING_MAX_SIZE",
352- "logging.max_backups": "LOGGING_MAX_BACKUPS",
353- "logging.max_age": "LOGGING_MAX_AGE",
354- "logging.compress": "LOGGING_COMPRESS",
355- }
356-
357- for key, env := range bindings {
358- if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
359- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
360- }
361- }
362 347
363- // Explicit binding for rendering mode via dedicated env var348+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
364 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {349 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
365 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)350 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
366 }351 }
@@ -372,6 +357,7 @@ func NewManager() (*Manager, error) {
372 }357 }
373 358
374 // Video acceleration environment variable bindings359 // Video acceleration environment variable bindings
360+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
375 videoAccelEnvBindings := map[string]string{361 videoAccelEnvBindings := map[string]string{
376 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",362 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
377 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",363 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
@@ -387,6 +373,7 @@ func NewManager() (*Manager, error) {
387 }373 }
388 374
389 // Codec preferences environment variable bindings375 // Codec preferences environment variable bindings
376+ // These use DUMBER_* prefix for shorter, more convenient env var names
390 codecEnvBindings := map[string]string{377 codecEnvBindings := map[string]string{
391 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",378 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
392 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",379 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
@@ -432,21 +419,28 @@ func (m *Manager) Load() error {
432 if errors.As(err, &configFileNotFoundError) {419 if errors.As(err, &configFileNotFoundError) {
433 // Config file not found, create default one420 // Config file not found, create default one
434 if err := m.createDefaultConfig(); err != nil {421 if err := m.createDefaultConfig(); err != nil {
435- return fmt.Errorf("failed to create default config: %w", err)422+ configDir, _ := GetConfigDir()
423+ return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err)
436 }424 }
437 // Re-read the newly created config file425 // Re-read the newly created config file
438 if err := m.viper.ReadInConfig(); err != nil {426 if err := m.viper.ReadInConfig(); err != nil {
439- return fmt.Errorf("failed to read newly created config: %w", err)427+ 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)
440 }428 }
441 } else {429 } else {
442- return fmt.Errorf("failed to read config file: %w", err)430+ configFile := m.viper.ConfigFileUsed()
431+ if configFile == "" {
432+ configDir, _ := GetConfigDir()
433+ configFile = filepath.Join(configDir, "config.*")
434+ }
435+ 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)
443 }436 }
444 }437 }
445 438
446 // Unmarshal into config struct439 // Unmarshal into config struct
447 config := &Config{}440 config := &Config{}
448 if err := m.viper.Unmarshal(config); err != nil {441 if err := m.viper.Unmarshal(config); err != nil {
449- return fmt.Errorf("failed to unmarshal config: %w", err)442+ configFile := m.viper.ConfigFileUsed()
443+ return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err)
450 }444 }
451 445
452 // Set database path if not specified446 // Set database path if not specified
@@ -484,6 +478,11 @@ func (m *Manager) Load() error {
484 // Validate ColorScheme setting478 // Validate ColorScheme setting
485 m.validateColorScheme(config)479 m.validateColorScheme(config)
486 480
481+ // Validate all config values
482+ if err := validateConfig(config); err != nil {
483+ return fmt.Errorf("configuration validation failed: %w", err)
484+ }
485+
487 m.config = config486 m.config = config
488 return nil487 return nil
489 }488 }
@@ -585,6 +584,11 @@ func (m *Manager) reload() error {
585 // Validate ColorScheme setting584 // Validate ColorScheme setting
586 m.validateColorScheme(config)585 m.validateColorScheme(config)
587 586
587+ // Validate all config values
588+ if err := validateConfig(config); err != nil {
589+ return fmt.Errorf("configuration validation failed: %w", err)
590+ }
591+
588 m.config = config592 m.config = config
589 return nil593 return nil
590 }594 }
@@ -680,10 +684,9 @@ func (m *Manager) setDefaults() {
680 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)684 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
681 685
682 // Workspace defaults686 // Workspace defaults
683- m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
684 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)687 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
685 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)688 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
686- m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings)689+ m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
687 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)690 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
688 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)691 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
689 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)692 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
@@ -723,12 +726,14 @@ func (m *Manager) createDefaultConfig() error {
723 return fmt.Errorf("failed to write config file: %w", err)726 return fmt.Errorf("failed to write config file: %w", err)
724 }727 }
725 728
726- fmt.Printf("Created default configuration file: %s\n", configFile)729+ fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
727 730
728- // Generate JSON schema file731+ // Generate JSON schema file only for JSON configs (used for IDE autocompletion)
729- if err := GenerateSchemaFile(); err != nil {732+ if filepath.Ext(configFile) == ".json" {
730- // Log error but don't fail config creation733+ if err := GenerateSchemaFile(); err != nil {
731- fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)734+ // Log error but don't fail config creation
735+ fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
736+ }
732 }737 }
733 738
734 return nil739 return nil
M internal/config/defaults.go +8 -13
@@ -145,22 +145,17 @@ func DefaultConfig() *Config {
145145 UseDomZoom: false,
146146 DefaultZoom: 1.2, // 120% default zoom for better readability
147147 Workspace: WorkspaceConfig{
148- EnableZellijControls: true,
149148 PaneMode: PaneModeConfig{
150149 ActivationShortcut: defaultPaneActivationShortcut,
151150 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
152- ActionBindings: map[string]string{
153- "arrowright": "split-right",
154- "arrowleft": "split-left",
155- "arrowup": "split-up",
156- "arrowdown": "split-down",
157- "r": "split-right",
158- "l": "split-left",
159- "u": "split-up",
160- "d": "split-down",
161- "x": "close-pane",
162- "enter": "confirm",
163- "escape": "cancel",
151+ Actions: map[string][]string{
152+ "split-right": {"arrowright", "r"},
153+ "split-left": {"arrowleft", "l"},
154+ "split-up": {"arrowup", "u"},
155+ "split-down": {"arrowdown", "d"},
156+ "close-pane": {"x"},
157+ "confirm": {"enter"},
158+ "cancel": {"escape"},
164159 },
165160 },
166161 Tabs: TabKeyConfig{
@@ -145,22 +145,17 @@ func DefaultConfig() *Config {
145 UseDomZoom: false,145 UseDomZoom: false,
146 DefaultZoom: 1.2, // 120% default zoom for better readability146 DefaultZoom: 1.2, // 120% default zoom for better readability
147 Workspace: WorkspaceConfig{147 Workspace: WorkspaceConfig{
148- EnableZellijControls: true,
149 PaneMode: PaneModeConfig{148 PaneMode: PaneModeConfig{
150 ActivationShortcut: defaultPaneActivationShortcut,149 ActivationShortcut: defaultPaneActivationShortcut,
151 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,150 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
152- ActionBindings: map[string]string{151+ Actions: map[string][]string{
153- "arrowright": "split-right",152+ "split-right": {"arrowright", "r"},
154- "arrowleft": "split-left",153+ "split-left": {"arrowleft", "l"},
155- "arrowup": "split-up",154+ "split-up": {"arrowup", "u"},
156- "arrowdown": "split-down",155+ "split-down": {"arrowdown", "d"},
157- "r": "split-right",156+ "close-pane": {"x"},
158- "l": "split-left",157+ "confirm": {"enter"},
159- "u": "split-up",158+ "cancel": {"escape"},
160- "d": "split-down",
161- "x": "close-pane",
162- "enter": "confirm",
163- "escape": "cancel",
164 },159 },
165 },160 },
166 Tabs: TabKeyConfig{161 Tabs: TabKeyConfig{
A internal/config/validation.go +135 -0
@@ -0,0 +1,135 @@
1+// Package config provides validation utilities for configuration values.
2+package config
3+
4+import (
5+ "fmt"
6+ "strings"
7+)
8+
9+// validateConfig performs comprehensive validation of configuration values
10+func validateConfig(config *Config) error {
11+ var validationErrors []string
12+
13+ // Validate numeric ranges
14+ if config.History.MaxEntries < 0 {
15+ validationErrors = append(validationErrors, "history.max_entries must be non-negative")
16+ }
17+ if config.History.RetentionPeriodDays < 0 {
18+ validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
19+ }
20+ if config.History.CleanupIntervalDays < 0 {
21+ validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
22+ }
23+
24+ if config.Dmenu.MaxHistoryItems < 0 {
25+ validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative")
26+ }
27+
28+ if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
29+ validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
30+ }
31+
32+ if config.DefaultZoom < 0.1 || config.DefaultZoom > 5.0 {
33+ validationErrors = append(validationErrors, "default_zoom must be between 0.1 and 5.0")
34+ }
35+
36+ // Validate popup behavior
37+ switch config.Workspace.Popups.Behavior {
38+ case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
39+ // Valid
40+ default:
41+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior))
42+ }
43+
44+ // Validate popup placement for split behavior
45+ if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
46+ switch config.Workspace.Popups.Placement {
47+ case "right", "left", "top", "bottom":
48+ // Valid
49+ default:
50+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement))
51+ }
52+ }
53+
54+ // Validate blank target behavior
55+ switch config.Workspace.Popups.BlankTargetBehavior {
56+ case "pane", "tab":
57+ // Valid
58+ default:
59+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: pane, tab (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
60+ }
61+
62+ // Validate workspace styling values
63+ if config.Workspace.Styling.BorderWidth < 0 {
64+ validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
65+ }
66+ if config.Workspace.Styling.InactiveBorderWidth < 0 {
67+ validationErrors = append(validationErrors, "workspace.styling.inactive_border_width must be non-negative")
68+ }
69+ if config.Workspace.Styling.TransitionDuration < 0 {
70+ validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
71+ }
72+ if config.Workspace.Styling.BorderRadius < 0 {
73+ validationErrors = append(validationErrors, "workspace.styling.border_radius must be non-negative")
74+ }
75+
76+ // Validate pane mode timeout
77+ if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
78+ validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
79+ }
80+
81+ // Validate pane mode actions
82+ if len(config.Workspace.PaneMode.Actions) == 0 {
83+ validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
84+ }
85+
86+ // Check for duplicate keys and empty action key lists
87+ seenKeys := make(map[string]string)
88+ for action, keys := range config.Workspace.PaneMode.Actions {
89+ if len(keys) == 0 {
90+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
91+ }
92+ for _, key := range keys {
93+ if existingAction, exists := seenKeys[key]; exists {
94+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in actions '%s' and '%s'", key, existingAction, action))
95+ }
96+ seenKeys[key] = action
97+ }
98+ }
99+
100+ // Validate codec buffer sizes
101+ if config.CodecPreferences.VideoBufferSizeMB < 0 {
102+ validationErrors = append(validationErrors, "codec_preferences.video_buffer_size_mb must be non-negative")
103+ }
104+ if config.CodecPreferences.QueueBufferTimeSec < 0 {
105+ validationErrors = append(validationErrors, "codec_preferences.queue_buffer_time_sec must be non-negative")
106+ }
107+
108+ // Validate AV1 max resolution
109+ if config.CodecPreferences.AV1MaxResolution != "" {
110+ validResolutions := map[string]bool{
111+ "720p": true, "1080p": true, "1440p": true, "4k": true, "unlimited": true,
112+ }
113+ if !validResolutions[config.CodecPreferences.AV1MaxResolution] {
114+ validationErrors = append(validationErrors, fmt.Sprintf("codec_preferences.av1_max_resolution must be one of: 720p, 1080p, 1440p, 4k, unlimited (got: %s)", config.CodecPreferences.AV1MaxResolution))
115+ }
116+ }
117+
118+ // Validate logging values
119+ if config.Logging.MaxSize < 0 {
120+ validationErrors = append(validationErrors, "logging.max_size must be non-negative")
121+ }
122+ if config.Logging.MaxBackups < 0 {
123+ validationErrors = append(validationErrors, "logging.max_backups must be non-negative")
124+ }
125+ if config.Logging.MaxAge < 0 {
126+ validationErrors = append(validationErrors, "logging.max_age must be non-negative")
127+ }
128+
129+ // If there are validation errors, return them
130+ if len(validationErrors) > 0 {
131+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
132+ }
133+
134+ return nil
135+}
@@ -0,0 +1,135 @@
1+// Package config provides validation utilities for configuration values.
2+package config
3+
4+import (
5+ "fmt"
6+ "strings"
7+)
8+
9+// validateConfig performs comprehensive validation of configuration values
10+func validateConfig(config *Config) error {
11+ var validationErrors []string
12+
13+ // Validate numeric ranges
14+ if config.History.MaxEntries < 0 {
15+ validationErrors = append(validationErrors, "history.max_entries must be non-negative")
16+ }
17+ if config.History.RetentionPeriodDays < 0 {
18+ validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
19+ }
20+ if config.History.CleanupIntervalDays < 0 {
21+ validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
22+ }
23+
24+ if config.Dmenu.MaxHistoryItems < 0 {
25+ validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative")
26+ }
27+
28+ if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
29+ validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
30+ }
31+
32+ if config.DefaultZoom < 0.1 || config.DefaultZoom > 5.0 {
33+ validationErrors = append(validationErrors, "default_zoom must be between 0.1 and 5.0")
34+ }
35+
36+ // Validate popup behavior
37+ switch config.Workspace.Popups.Behavior {
38+ case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
39+ // Valid
40+ default:
41+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior))
42+ }
43+
44+ // Validate popup placement for split behavior
45+ if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
46+ switch config.Workspace.Popups.Placement {
47+ case "right", "left", "top", "bottom":
48+ // Valid
49+ default:
50+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement))
51+ }
52+ }
53+
54+ // Validate blank target behavior
55+ switch config.Workspace.Popups.BlankTargetBehavior {
56+ case "pane", "tab":
57+ // Valid
58+ default:
59+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: pane, tab (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
60+ }
61+
62+ // Validate workspace styling values
63+ if config.Workspace.Styling.BorderWidth < 0 {
64+ validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
65+ }
66+ if config.Workspace.Styling.InactiveBorderWidth < 0 {
67+ validationErrors = append(validationErrors, "workspace.styling.inactive_border_width must be non-negative")
68+ }
69+ if config.Workspace.Styling.TransitionDuration < 0 {
70+ validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
71+ }
72+ if config.Workspace.Styling.BorderRadius < 0 {
73+ validationErrors = append(validationErrors, "workspace.styling.border_radius must be non-negative")
74+ }
75+
76+ // Validate pane mode timeout
77+ if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
78+ validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
79+ }
80+
81+ // Validate pane mode actions
82+ if len(config.Workspace.PaneMode.Actions) == 0 {
83+ validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
84+ }
85+
86+ // Check for duplicate keys and empty action key lists
87+ seenKeys := make(map[string]string)
88+ for action, keys := range config.Workspace.PaneMode.Actions {
89+ if len(keys) == 0 {
90+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
91+ }
92+ for _, key := range keys {
93+ if existingAction, exists := seenKeys[key]; exists {
94+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in actions '%s' and '%s'", key, existingAction, action))
95+ }
96+ seenKeys[key] = action
97+ }
98+ }
99+
100+ // Validate codec buffer sizes
101+ if config.CodecPreferences.VideoBufferSizeMB < 0 {
102+ validationErrors = append(validationErrors, "codec_preferences.video_buffer_size_mb must be non-negative")
103+ }
104+ if config.CodecPreferences.QueueBufferTimeSec < 0 {
105+ validationErrors = append(validationErrors, "codec_preferences.queue_buffer_time_sec must be non-negative")
106+ }
107+
108+ // Validate AV1 max resolution
109+ if config.CodecPreferences.AV1MaxResolution != "" {
110+ validResolutions := map[string]bool{
111+ "720p": true, "1080p": true, "1440p": true, "4k": true, "unlimited": true,
112+ }
113+ if !validResolutions[config.CodecPreferences.AV1MaxResolution] {
114+ validationErrors = append(validationErrors, fmt.Sprintf("codec_preferences.av1_max_resolution must be one of: 720p, 1080p, 1440p, 4k, unlimited (got: %s)", config.CodecPreferences.AV1MaxResolution))
115+ }
116+ }
117+
118+ // Validate logging values
119+ if config.Logging.MaxSize < 0 {
120+ validationErrors = append(validationErrors, "logging.max_size must be non-negative")
121+ }
122+ if config.Logging.MaxBackups < 0 {
123+ validationErrors = append(validationErrors, "logging.max_backups must be non-negative")
124+ }
125+ if config.Logging.MaxAge < 0 {
126+ validationErrors = append(validationErrors, "logging.max_age must be non-negative")
127+ }
128+
129+ // If there are validation errors, return them
130+ if len(validationErrors) > 0 {
131+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
132+ }
133+
134+ return nil
135+}
M internal/config/xdg.go +1 -1
@@ -114,7 +114,7 @@ func GetConfigFile() (string, error) {
114114 if err != nil {
115115 return "", err
116116 }
117- return filepath.Join(configDir, "config.json"), nil
117+ return filepath.Join(configDir, "config.toml"), nil
118118 }
119119
120120 // GetDatabaseFile returns the path to the database file in the data directory.
@@ -114,7 +114,7 @@ func GetConfigFile() (string, error) {
114 if err != nil {114 if err != nil {
115 return "", err115 return "", err
116 }116 }
117- return filepath.Join(configDir, "config.json"), nil117+ return filepath.Join(configDir, "config.toml"), nil
118 }118 }
119 119
120 // GetDatabaseFile returns the path to the database file in the data directory.120 // GetDatabaseFile returns the path to the database file in the data directory.

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