feat(config): add ordered config file writer for deterministic output
- Add WriteConfigOrdered() that marshals config with go-toml v2 - Post-process TOML to sort all sections alphabetically - Replace Viper's WriteConfig() with ordered writer in Save/SaveKeybindings - Update changelog with upgrade notes recommending config regeneration
- commit
- f21fa3fc7a6df0a1757339d9653fcbf270176e88
- author
- brice · · 2026-01-24 08:03 UTC
- parent
- 1ae6f9b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +266 -59
M CHANGELOG.md +4 -0
| @@ -7,11 +7,15 @@ All notable changes to this project will be documented in this file. | |||
| 7 | 7 | ### Added | |
| 8 | 8 | - **Visual keybindings editor**: New Keybindings tab in `dumb://config` allows editing all keyboard shortcuts via a visual UI. Supports key capture, reset to defaults, and per-binding reset. Fixes #123. | |
| 9 | 9 | - **Keybindings hot reload**: Changed keybindings take effect immediately without restarting the browser. | |
| 10 | + | - **Ordered config file**: Config file sections are now written in alphabetical order for better readability and deterministic output. | |
| 10 | 11 | ||
| 11 | 12 | ### Changed | |
| 12 | 13 | - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122. | |
| 13 | 14 | - **BREAKING: Global shortcuts config structure**: `workspace.shortcuts` changed from individual string fields to unified `Actions` map with `ActionBinding` (keys + description). Existing custom shortcuts in config.toml will be replaced with defaults. Use the new Keybindings UI to reconfigure. | |
| 14 | 15 | ||
| 16 | + | ### Upgrade Notes | |
| 17 | + | > **Recommended**: Delete your `config.toml` and restart dumber to regenerate a fresh config with the new keybinding structure and alphabetically sorted sections. Your settings will reset to defaults, but you can reconfigure keybindings via the new visual editor at `dumb://config` → Keybindings tab. | |
| 18 | + | ||
| 15 | 19 | ### Fixed | |
| 16 | 20 | - **Session snapshot FK constraint**: Fixed SQLite foreign key constraint violation when snapshot service tried to save before session was persisted to database. Added ready flag and callback notification pattern. | |
| 17 | 21 | - **Stacked pane index out of bounds**: Fixed crash when domain model and UI got out of sync during stacked pane operations. Added bounds checking and proper rollback when UI updates fail. | |
| @@ -7,11 +7,15 @@ All notable changes to this project will be documented in this file. | |||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | - **Visual keybindings editor**: New Keybindings tab in `dumb://config` allows editing all keyboard shortcuts via a visual UI. Supports key capture, reset to defaults, and per-binding reset. Fixes #123. | 8 | - **Visual keybindings editor**: New Keybindings tab in `dumb://config` allows editing all keyboard shortcuts via a visual UI. Supports key capture, reset to defaults, and per-binding reset. Fixes #123. | ||
| 9 | - **Keybindings hot reload**: Changed keybindings take effect immediately without restarting the browser. | 9 | - **Keybindings hot reload**: Changed keybindings take effect immediately without restarting the browser. | ||
| 10 | + | - **Ordered config file**: Config file sections are now written in alphabetical order for better readability and deterministic output. | |||
| 10 | 11 | ||||
| 11 | ### Changed | 12 | ### Changed | ||
| 12 | - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122. | 13 | - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122. | ||
| 13 | - **BREAKING: Global shortcuts config structure**: `workspace.shortcuts` changed from individual string fields to unified `Actions` map with `ActionBinding` (keys + description). Existing custom shortcuts in config.toml will be replaced with defaults. Use the new Keybindings UI to reconfigure. | 14 | - **BREAKING: Global shortcuts config structure**: `workspace.shortcuts` changed from individual string fields to unified `Actions` map with `ActionBinding` (keys + description). Existing custom shortcuts in config.toml will be replaced with defaults. Use the new Keybindings UI to reconfigure. | ||
| 14 | 15 | ||||
| 16 | + | ### Upgrade Notes | |||
| 17 | + | > **Recommended**: Delete your `config.toml` and restart dumber to regenerate a fresh config with the new keybinding structure and alphabetically sorted sections. Your settings will reset to defaults, but you can reconfigure keybindings via the new visual editor at `dumb://config` → Keybindings tab. | |||
| 18 | + | ||||
| 15 | ### Fixed | 19 | ### Fixed | ||
| 16 | - **Session snapshot FK constraint**: Fixed SQLite foreign key constraint violation when snapshot service tried to save before session was persisted to database. Added ready flag and callback notification pattern. | 20 | - **Session snapshot FK constraint**: Fixed SQLite foreign key constraint violation when snapshot service tried to save before session was persisted to database. Added ready flag and callback notification pattern. | ||
| 17 | - **Stacked pane index out of bounds**: Fixed crash when domain model and UI got out of sync during stacked pane operations. Added bounds checking and proper rollback when UI updates fail. | 21 | - **Stacked pane index out of bounds**: Fixed crash when domain model and UI got out of sync during stacked pane operations. Added bounds checking and proper rollback when UI updates fail. | ||
M internal/infrastructure/config/loader.go +38 -59
| @@ -259,7 +259,7 @@ func (m *Manager) Get() *Config { | |||
| 259 | 259 | return &configCopy | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | - | // Save saves the provided configuration to disk and updates Viper. | |
| 262 | + | // Save saves the provided configuration to disk with ordered sections. | |
| 263 | 263 | func (m *Manager) Save(cfg *Config) error { | |
| 264 | 264 | m.mu.Lock() | |
| 265 | 265 | defer m.mu.Unlock() | |
| @@ -273,30 +273,21 @@ func (m *Manager) Save(cfg *Config) error { | |||
| 273 | 273 | return fmt.Errorf("configuration validation failed: %w", err) | |
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | - | // Update Viper with the new values. | |
| 277 | - | // Since we can't easily update Viper from a struct automatically while preserving | |
| 278 | - | // all settings, we update the main keys we care about. | |
| 279 | - | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | |
| 280 | - | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | |
| 281 | - | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | |
| 282 | - | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | |
| 283 | - | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | |
| 284 | - | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | |
| 285 | - | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | |
| 286 | - | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | |
| 287 | - | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |
| 288 | - | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |
| 289 | - | ||
| 290 | - | // Performance profile (requires browser restart to take effect) | |
| 291 | - | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |
| 292 | - | ||
| 293 | - | if err := m.viper.WriteConfig(); err != nil { | |
| 276 | + | // Write config with ordered sections (bypasses Viper's random map ordering) | |
| 277 | + | configFile := m.viper.ConfigFileUsed() | |
| 278 | + | if configFile == "" { | |
| 279 | + | var err error | |
| 280 | + | configFile, err = GetConfigFile() | |
| 281 | + | if err != nil { | |
| 282 | + | return fmt.Errorf("failed to determine config file path: %w", err) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { | |
| 294 | 287 | return fmt.Errorf("failed to write config: %w", err) | |
| 295 | 288 | } | |
| 296 | 289 | ||
| 297 | - | // We don't update m.config here manually because viper.OnConfigChange | |
| 298 | - | // will trigger reload() if Watch() is active. | |
| 299 | - | // If Watch() is not active, we should call reload() manually. | |
| 290 | + | // Reload config from disk to sync Viper's internal state | |
| 300 | 291 | if !m.watching { | |
| 301 | 292 | if err := m.reload(); err != nil { | |
| 302 | 293 | return err | |
| @@ -306,7 +297,7 @@ func (m *Manager) Save(cfg *Config) error { | |||
| 306 | 297 | return nil | |
| 307 | 298 | } | |
| 308 | 299 | ||
| 309 | - | // SaveKeybindings saves keybinding-related configuration to disk. | |
| 300 | + | // SaveKeybindings saves keybinding-related configuration to disk with ordered sections. | |
| 310 | 301 | func (m *Manager) SaveKeybindings(cfg *Config) error { | |
| 311 | 302 | m.mu.Lock() | |
| 312 | 303 | defer m.mu.Unlock() | |
| @@ -315,27 +306,17 @@ func (m *Manager) SaveKeybindings(cfg *Config) error { | |||
| 315 | 306 | return fmt.Errorf("config is nil") | |
| 316 | 307 | } | |
| 317 | 308 | ||
| 318 | - | // Update Viper with keybinding values | |
| 319 | - | // Pane mode | |
| 320 | - | m.viper.Set("workspace.pane_mode.actions", cfg.Workspace.PaneMode.Actions) | |
| 321 | - | m.viper.Set("workspace.pane_mode.activation_shortcut", cfg.Workspace.PaneMode.ActivationShortcut) | |
| 322 | - | ||
| 323 | - | // Tab mode | |
| 324 | - | m.viper.Set("workspace.tab_mode.actions", cfg.Workspace.TabMode.Actions) | |
| 325 | - | m.viper.Set("workspace.tab_mode.activation_shortcut", cfg.Workspace.TabMode.ActivationShortcut) | |
| 326 | - | ||
| 327 | - | // Resize mode | |
| 328 | - | m.viper.Set("workspace.resize_mode.actions", cfg.Workspace.ResizeMode.Actions) | |
| 329 | - | m.viper.Set("workspace.resize_mode.activation_shortcut", cfg.Workspace.ResizeMode.ActivationShortcut) | |
| 330 | - | ||
| 331 | - | // Session mode | |
| 332 | - | m.viper.Set("session.session_mode.actions", cfg.Session.SessionMode.Actions) | |
| 333 | - | m.viper.Set("session.session_mode.activation_shortcut", cfg.Session.SessionMode.ActivationShortcut) | |
| 334 | - | ||
| 335 | - | // Global shortcuts | |
| 336 | - | m.viper.Set("workspace.shortcuts.actions", cfg.Workspace.Shortcuts.Actions) | |
| 309 | + | // Write full config with ordered sections | |
| 310 | + | configFile := m.viper.ConfigFileUsed() | |
| 311 | + | if configFile == "" { | |
| 312 | + | var err error | |
| 313 | + | configFile, err = GetConfigFile() | |
| 314 | + | if err != nil { | |
| 315 | + | return fmt.Errorf("failed to determine config file path: %w", err) | |
| 316 | + | } | |
| 317 | + | } | |
| 337 | 318 | ||
| 338 | - | if err := m.viper.WriteConfig(); err != nil { | |
| 319 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { | |
| 339 | 320 | return fmt.Errorf("failed to write config: %w", err) | |
| 340 | 321 | } | |
| 341 | 322 | ||
| @@ -354,7 +335,7 @@ func (m *Manager) GetConfigFile() string { | |||
| 354 | 335 | return m.viper.ConfigFileUsed() | |
| 355 | 336 | } | |
| 356 | 337 | ||
| 357 | - | // createDefaultConfig creates a default configuration file. | |
| 338 | + | // createDefaultConfig creates a default configuration file with ordered sections. | |
| 358 | 339 | func (m *Manager) createDefaultConfig() error { | |
| 359 | 340 | configFile, err := GetConfigFile() | |
| 360 | 341 | if err != nil { | |
| @@ -366,12 +347,14 @@ func (m *Manager) createDefaultConfig() error { | |||
| 366 | 347 | return err | |
| 367 | 348 | } | |
| 368 | 349 | ||
| 369 | - | // Detect available fonts and override defaults before writing config. | |
| 370 | - | m.detectAndSetFonts() | |
| 350 | + | // Start with default config | |
| 351 | + | cfg := DefaultConfig() | |
| 352 | + | ||
| 353 | + | // Detect available fonts and override defaults | |
| 354 | + | m.detectAndSetFontsOnConfig(cfg) | |
| 371 | 355 | ||
| 372 | - | // Ensure TOML format and write config | |
| 373 | - | m.viper.SetConfigType("toml") | |
| 374 | - | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |
| 356 | + | // Write config with ordered sections | |
| 357 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { | |
| 375 | 358 | return fmt.Errorf("failed to write config file: %w", err) | |
| 376 | 359 | } | |
| 377 | 360 | ||
| @@ -380,9 +363,9 @@ func (m *Manager) createDefaultConfig() error { | |||
| 380 | 363 | return nil | |
| 381 | 364 | } | |
| 382 | 365 | ||
| 383 | - | // detectAndSetFonts detects available system fonts and sets the best available | |
| 384 | - | // fonts from the fallback chains. This runs only during first-run config creation. | |
| 385 | - | func (m *Manager) detectAndSetFonts() { | |
| 366 | + | // detectAndSetFontsOnConfig detects available system fonts and sets the best available | |
| 367 | + | // fonts from the fallback chains directly on the Config struct. | |
| 368 | + | func (m *Manager) detectAndSetFontsOnConfig(cfg *Config) { | |
| 386 | 369 | // Create context with logger for debugging first-run font detection. | |
| 387 | 370 | logger := logging.NewFromEnv() | |
| 388 | 371 | ctx := logging.WithContext(context.Background(), logger) | |
| @@ -394,13 +377,9 @@ func (m *Manager) detectAndSetFonts() { | |||
| 394 | 377 | return | |
| 395 | 378 | } | |
| 396 | 379 | ||
| 397 | - | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 398 | - | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 399 | - | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |
| 400 | - | ||
| 401 | - | m.viper.Set("appearance.sans_font", sansFont) | |
| 402 | - | m.viper.Set("appearance.serif_font", serifFont) | |
| 403 | - | m.viper.Set("appearance.monospace_font", monoFont) | |
| 380 | + | cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 381 | + | cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 382 | + | cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |
| 404 | 383 | } | |
| 405 | 384 | ||
| 406 | 385 | // setDefaults sets default configuration values in Viper. | |
| @@ -259,7 +259,7 @@ func (m *Manager) Get() *Config { | |||||
| 259 | return &configCopy | 259 | return &configCopy | ||
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | - | // Save saves the provided configuration to disk and updates Viper. | 262 | + | // Save saves the provided configuration to disk with ordered sections. |
| 263 | func (m *Manager) Save(cfg *Config) error { | 263 | func (m *Manager) Save(cfg *Config) error { | ||
| 264 | m.mu.Lock() | 264 | m.mu.Lock() | ||
| 265 | defer m.mu.Unlock() | 265 | defer m.mu.Unlock() | ||
| @@ -273,30 +273,21 @@ func (m *Manager) Save(cfg *Config) error { | |||||
| 273 | return fmt.Errorf("configuration validation failed: %w", err) | 273 | return fmt.Errorf("configuration validation failed: %w", err) | ||
| 274 | } | 274 | } | ||
| 275 | 275 | ||||
| 276 | - | // Update Viper with the new values. | 276 | + | // Write config with ordered sections (bypasses Viper's random map ordering) |
| 277 | - | // Since we can't easily update Viper from a struct automatically while preserving | 277 | + | configFile := m.viper.ConfigFileUsed() |
| 278 | - | // all settings, we update the main keys we care about. | 278 | + | if configFile == "" { |
| 279 | - | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | 279 | + | var err error |
| 280 | - | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | 280 | + | configFile, err = GetConfigFile() |
| 281 | - | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | 281 | + | if err != nil { |
| 282 | - | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | 282 | + | return fmt.Errorf("failed to determine config file path: %w", err) |
| 283 | - | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | 283 | + | } |
| 284 | - | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | 284 | + | } |
| 285 | - | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | 285 | + | |
| 286 | - | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | 286 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 287 | - | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |||
| 288 | - | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |||
| 289 | - | ||||
| 290 | - | // Performance profile (requires browser restart to take effect) | |||
| 291 | - | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |||
| 292 | - | ||||
| 293 | - | if err := m.viper.WriteConfig(); err != nil { | |||
| 294 | return fmt.Errorf("failed to write config: %w", err) | 287 | return fmt.Errorf("failed to write config: %w", err) | ||
| 295 | } | 288 | } | ||
| 296 | 289 | ||||
| 297 | - | // We don't update m.config here manually because viper.OnConfigChange | 290 | + | // Reload config from disk to sync Viper's internal state |
| 298 | - | // will trigger reload() if Watch() is active. | |||
| 299 | - | // If Watch() is not active, we should call reload() manually. | |||
| 300 | if !m.watching { | 291 | if !m.watching { | ||
| 301 | if err := m.reload(); err != nil { | 292 | if err := m.reload(); err != nil { | ||
| 302 | return err | 293 | return err | ||
| @@ -306,7 +297,7 @@ func (m *Manager) Save(cfg *Config) error { | |||||
| 306 | return nil | 297 | return nil | ||
| 307 | } | 298 | } | ||
| 308 | 299 | ||||
| 309 | - | // SaveKeybindings saves keybinding-related configuration to disk. | 300 | + | // SaveKeybindings saves keybinding-related configuration to disk with ordered sections. |
| 310 | func (m *Manager) SaveKeybindings(cfg *Config) error { | 301 | func (m *Manager) SaveKeybindings(cfg *Config) error { | ||
| 311 | m.mu.Lock() | 302 | m.mu.Lock() | ||
| 312 | defer m.mu.Unlock() | 303 | defer m.mu.Unlock() | ||
| @@ -315,27 +306,17 @@ func (m *Manager) SaveKeybindings(cfg *Config) error { | |||||
| 315 | return fmt.Errorf("config is nil") | 306 | return fmt.Errorf("config is nil") | ||
| 316 | } | 307 | } | ||
| 317 | 308 | ||||
| 318 | - | // Update Viper with keybinding values | 309 | + | // Write full config with ordered sections |
| 319 | - | // Pane mode | 310 | + | configFile := m.viper.ConfigFileUsed() |
| 320 | - | m.viper.Set("workspace.pane_mode.actions", cfg.Workspace.PaneMode.Actions) | 311 | + | if configFile == "" { |
| 321 | - | m.viper.Set("workspace.pane_mode.activation_shortcut", cfg.Workspace.PaneMode.ActivationShortcut) | 312 | + | var err error |
| 322 | - | 313 | + | configFile, err = GetConfigFile() | |
| 323 | - | // Tab mode | 314 | + | if err != nil { |
| 324 | - | m.viper.Set("workspace.tab_mode.actions", cfg.Workspace.TabMode.Actions) | 315 | + | return fmt.Errorf("failed to determine config file path: %w", err) |
| 325 | - | m.viper.Set("workspace.tab_mode.activation_shortcut", cfg.Workspace.TabMode.ActivationShortcut) | 316 | + | } |
| 326 | - | 317 | + | } | |
| 327 | - | // Resize mode | |||
| 328 | - | m.viper.Set("workspace.resize_mode.actions", cfg.Workspace.ResizeMode.Actions) | |||
| 329 | - | m.viper.Set("workspace.resize_mode.activation_shortcut", cfg.Workspace.ResizeMode.ActivationShortcut) | |||
| 330 | - | ||||
| 331 | - | // Session mode | |||
| 332 | - | m.viper.Set("session.session_mode.actions", cfg.Session.SessionMode.Actions) | |||
| 333 | - | m.viper.Set("session.session_mode.activation_shortcut", cfg.Session.SessionMode.ActivationShortcut) | |||
| 334 | - | ||||
| 335 | - | // Global shortcuts | |||
| 336 | - | m.viper.Set("workspace.shortcuts.actions", cfg.Workspace.Shortcuts.Actions) | |||
| 337 | 318 | ||||
| 338 | - | if err := m.viper.WriteConfig(); err != nil { | 319 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 339 | return fmt.Errorf("failed to write config: %w", err) | 320 | return fmt.Errorf("failed to write config: %w", err) | ||
| 340 | } | 321 | } | ||
| 341 | 322 | ||||
| @@ -354,7 +335,7 @@ func (m *Manager) GetConfigFile() string { | |||||
| 354 | return m.viper.ConfigFileUsed() | 335 | return m.viper.ConfigFileUsed() | ||
| 355 | } | 336 | } | ||
| 356 | 337 | ||||
| 357 | - | // createDefaultConfig creates a default configuration file. | 338 | + | // createDefaultConfig creates a default configuration file with ordered sections. |
| 358 | func (m *Manager) createDefaultConfig() error { | 339 | func (m *Manager) createDefaultConfig() error { | ||
| 359 | configFile, err := GetConfigFile() | 340 | configFile, err := GetConfigFile() | ||
| 360 | if err != nil { | 341 | if err != nil { | ||
| @@ -366,12 +347,14 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 366 | return err | 347 | return err | ||
| 367 | } | 348 | } | ||
| 368 | 349 | ||||
| 369 | - | // Detect available fonts and override defaults before writing config. | 350 | + | // Start with default config |
| 370 | - | m.detectAndSetFonts() | 351 | + | cfg := DefaultConfig() |
| 352 | + | ||||
| 353 | + | // Detect available fonts and override defaults | |||
| 354 | + | m.detectAndSetFontsOnConfig(cfg) | |||
| 371 | 355 | ||||
| 372 | - | // Ensure TOML format and write config | 356 | + | // Write config with ordered sections |
| 373 | - | m.viper.SetConfigType("toml") | 357 | + | if err := WriteConfigOrdered(cfg, configFile); err != nil { |
| 374 | - | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |||
| 375 | return fmt.Errorf("failed to write config file: %w", err) | 358 | return fmt.Errorf("failed to write config file: %w", err) | ||
| 376 | } | 359 | } | ||
| 377 | 360 | ||||
| @@ -380,9 +363,9 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 380 | return nil | 363 | return nil | ||
| 381 | } | 364 | } | ||
| 382 | 365 | ||||
| 383 | - | // detectAndSetFonts detects available system fonts and sets the best available | 366 | + | // detectAndSetFontsOnConfig detects available system fonts and sets the best available |
| 384 | - | // fonts from the fallback chains. This runs only during first-run config creation. | 367 | + | // fonts from the fallback chains directly on the Config struct. |
| 385 | - | func (m *Manager) detectAndSetFonts() { | 368 | + | func (m *Manager) detectAndSetFontsOnConfig(cfg *Config) { |
| 386 | // Create context with logger for debugging first-run font detection. | 369 | // Create context with logger for debugging first-run font detection. | ||
| 387 | logger := logging.NewFromEnv() | 370 | logger := logging.NewFromEnv() | ||
| 388 | ctx := logging.WithContext(context.Background(), logger) | 371 | ctx := logging.WithContext(context.Background(), logger) | ||
| @@ -394,13 +377,9 @@ func (m *Manager) detectAndSetFonts() { | |||||
| 394 | return | 377 | return | ||
| 395 | } | 378 | } | ||
| 396 | 379 | ||||
| 397 | - | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | 380 | + | cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) |
| 398 | - | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | 381 | + | cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) |
| 399 | - | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | 382 | + | cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) |
| 400 | - | ||||
| 401 | - | m.viper.Set("appearance.sans_font", sansFont) | |||
| 402 | - | m.viper.Set("appearance.serif_font", serifFont) | |||
| 403 | - | m.viper.Set("appearance.monospace_font", monoFont) | |||
| 404 | } | 383 | } | ||
| 405 | 384 | ||||
| 406 | // setDefaults sets default configuration values in Viper. | 385 | // setDefaults sets default configuration values in Viper. | ||
A internal/infrastructure/config/writer.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "regexp" | |
| 8 | + | "sort" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/pelletier/go-toml/v2" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // WriteConfigOrdered writes the configuration to disk with consistent ordering. | |
| 15 | + | // - Struct fields are written in definition order (go-toml v2 behavior) | |
| 16 | + | // - TOML sections are sorted alphabetically within each parent for deterministic output | |
| 17 | + | func WriteConfigOrdered(cfg *Config, path string) error { | |
| 18 | + | if cfg == nil { | |
| 19 | + | return fmt.Errorf("config is nil") | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | var buf bytes.Buffer | |
| 23 | + | enc := toml.NewEncoder(&buf) | |
| 24 | + | enc.SetIndentTables(true) | |
| 25 | + | ||
| 26 | + | if err := enc.Encode(cfg); err != nil { | |
| 27 | + | return fmt.Errorf("failed to encode config: %w", err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Post-process to sort TOML sections alphabetically | |
| 31 | + | sorted := sortTOMLSections(buf.String()) | |
| 32 | + | ||
| 33 | + | if err := os.WriteFile(path, []byte(sorted), filePerm); err != nil { | |
| 34 | + | return fmt.Errorf("failed to write config file: %w", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // sortTOMLSections sorts TOML content so sections are in alphabetical order. | |
| 41 | + | // This handles both top-level sections and indented nested sections. | |
| 42 | + | func sortTOMLSections(content string) string { | |
| 43 | + | lines := strings.Split(content, "\n") | |
| 44 | + | ||
| 45 | + | // Parse into sections | |
| 46 | + | type section struct { | |
| 47 | + | header string // e.g., "appearance" or "workspace.pane_mode.actions.cancel" | |
| 48 | + | lines []string // lines belonging to this section (including header) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | var sections []section | |
| 52 | + | var currentSection *section | |
| 53 | + | var preamble []string // lines before first section | |
| 54 | + | ||
| 55 | + | // Match section headers with optional leading whitespace (for indented sub-tables) | |
| 56 | + | sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`) | |
| 57 | + | ||
| 58 | + | for _, line := range lines { | |
| 59 | + | if match := sectionRegex.FindStringSubmatch(line); match != nil { | |
| 60 | + | // New section found | |
| 61 | + | if currentSection != nil { | |
| 62 | + | sections = append(sections, *currentSection) | |
| 63 | + | } | |
| 64 | + | currentSection = §ion{ | |
| 65 | + | header: match[2], // Just the section name, without brackets or indent | |
| 66 | + | lines: []string{line}, | |
| 67 | + | } | |
| 68 | + | } else if currentSection != nil { | |
| 69 | + | currentSection.lines = append(currentSection.lines, line) | |
| 70 | + | } else { | |
| 71 | + | // Before any section (top-level keys) | |
| 72 | + | preamble = append(preamble, line) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Don't forget the last section | |
| 77 | + | if currentSection != nil { | |
| 78 | + | sections = append(sections, *currentSection) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Sort sections alphabetically by header | |
| 82 | + | sort.Slice(sections, func(i, j int) bool { | |
| 83 | + | return sections[i].header < sections[j].header | |
| 84 | + | }) | |
| 85 | + | ||
| 86 | + | // Rebuild content | |
| 87 | + | var result strings.Builder | |
| 88 | + | ||
| 89 | + | // Write preamble (top-level keys) first | |
| 90 | + | for _, line := range preamble { | |
| 91 | + | result.WriteString(line) | |
| 92 | + | result.WriteString("\n") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Write sorted sections | |
| 96 | + | for i, sec := range sections { | |
| 97 | + | // Add blank line before section (except first if preamble is empty) | |
| 98 | + | if i > 0 || len(preamble) > 0 { | |
| 99 | + | // Check if previous content already ends with blank line | |
| 100 | + | content := result.String() | |
| 101 | + | if !strings.HasSuffix(content, "\n\n") && content != "" { | |
| 102 | + | result.WriteString("\n") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | for _, line := range sec.lines { | |
| 107 | + | result.WriteString(line) | |
| 108 | + | result.WriteString("\n") | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Trim trailing whitespace but ensure single newline at end | |
| 113 | + | output := strings.TrimRight(result.String(), "\n") | |
| 114 | + | if output != "" { | |
| 115 | + | output += "\n" | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | return output | |
| 119 | + | } | |
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "regexp" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/pelletier/go-toml/v2" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // WriteConfigOrdered writes the configuration to disk with consistent ordering. | |||
| 15 | + | // - Struct fields are written in definition order (go-toml v2 behavior) | |||
| 16 | + | // - TOML sections are sorted alphabetically within each parent for deterministic output | |||
| 17 | + | func WriteConfigOrdered(cfg *Config, path string) error { | |||
| 18 | + | if cfg == nil { | |||
| 19 | + | return fmt.Errorf("config is nil") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | var buf bytes.Buffer | |||
| 23 | + | enc := toml.NewEncoder(&buf) | |||
| 24 | + | enc.SetIndentTables(true) | |||
| 25 | + | ||||
| 26 | + | if err := enc.Encode(cfg); err != nil { | |||
| 27 | + | return fmt.Errorf("failed to encode config: %w", err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Post-process to sort TOML sections alphabetically | |||
| 31 | + | sorted := sortTOMLSections(buf.String()) | |||
| 32 | + | ||||
| 33 | + | if err := os.WriteFile(path, []byte(sorted), filePerm); err != nil { | |||
| 34 | + | return fmt.Errorf("failed to write config file: %w", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // sortTOMLSections sorts TOML content so sections are in alphabetical order. | |||
| 41 | + | // This handles both top-level sections and indented nested sections. | |||
| 42 | + | func sortTOMLSections(content string) string { | |||
| 43 | + | lines := strings.Split(content, "\n") | |||
| 44 | + | ||||
| 45 | + | // Parse into sections | |||
| 46 | + | type section struct { | |||
| 47 | + | header string // e.g., "appearance" or "workspace.pane_mode.actions.cancel" | |||
| 48 | + | lines []string // lines belonging to this section (including header) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | var sections []section | |||
| 52 | + | var currentSection *section | |||
| 53 | + | var preamble []string // lines before first section | |||
| 54 | + | ||||
| 55 | + | // Match section headers with optional leading whitespace (for indented sub-tables) | |||
| 56 | + | sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`) | |||
| 57 | + | ||||
| 58 | + | for _, line := range lines { | |||
| 59 | + | if match := sectionRegex.FindStringSubmatch(line); match != nil { | |||
| 60 | + | // New section found | |||
| 61 | + | if currentSection != nil { | |||
| 62 | + | sections = append(sections, *currentSection) | |||
| 63 | + | } | |||
| 64 | + | currentSection = §ion{ | |||
| 65 | + | header: match[2], // Just the section name, without brackets or indent | |||
| 66 | + | lines: []string{line}, | |||
| 67 | + | } | |||
| 68 | + | } else if currentSection != nil { | |||
| 69 | + | currentSection.lines = append(currentSection.lines, line) | |||
| 70 | + | } else { | |||
| 71 | + | // Before any section (top-level keys) | |||
| 72 | + | preamble = append(preamble, line) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Don't forget the last section | |||
| 77 | + | if currentSection != nil { | |||
| 78 | + | sections = append(sections, *currentSection) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Sort sections alphabetically by header | |||
| 82 | + | sort.Slice(sections, func(i, j int) bool { | |||
| 83 | + | return sections[i].header < sections[j].header | |||
| 84 | + | }) | |||
| 85 | + | ||||
| 86 | + | // Rebuild content | |||
| 87 | + | var result strings.Builder | |||
| 88 | + | ||||
| 89 | + | // Write preamble (top-level keys) first | |||
| 90 | + | for _, line := range preamble { | |||
| 91 | + | result.WriteString(line) | |||
| 92 | + | result.WriteString("\n") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Write sorted sections | |||
| 96 | + | for i, sec := range sections { | |||
| 97 | + | // Add blank line before section (except first if preamble is empty) | |||
| 98 | + | if i > 0 || len(preamble) > 0 { | |||
| 99 | + | // Check if previous content already ends with blank line | |||
| 100 | + | content := result.String() | |||
| 101 | + | if !strings.HasSuffix(content, "\n\n") && content != "" { | |||
| 102 | + | result.WriteString("\n") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | for _, line := range sec.lines { | |||
| 107 | + | result.WriteString(line) | |||
| 108 | + | result.WriteString("\n") | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Trim trailing whitespace but ensure single newline at end | |||
| 113 | + | output := strings.TrimRight(result.String(), "\n") | |||
| 114 | + | if output != "" { | |||
| 115 | + | output += "\n" | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | return output | |||
| 119 | + | } | |||
A internal/infrastructure/config/writer_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestWriteConfigOrdered(t *testing.T) { | |
| 11 | + | // Create a temp directory for the test | |
| 12 | + | tmpDir := t.TempDir() | |
| 13 | + | configPath := filepath.Join(tmpDir, "config.toml") | |
| 14 | + | ||
| 15 | + | // Get default config | |
| 16 | + | cfg := DefaultConfig() | |
| 17 | + | ||
| 18 | + | // Write it | |
| 19 | + | err := WriteConfigOrdered(cfg, configPath) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("WriteConfigOrdered failed: %v", err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Read back | |
| 25 | + | content, err := os.ReadFile(configPath) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatalf("Failed to read written config: %v", err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Verify sections are in alphabetical order | |
| 31 | + | lines := strings.Split(string(content), "\n") | |
| 32 | + | var sections []string | |
| 33 | + | for _, line := range lines { | |
| 34 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |
| 35 | + | sections = append(sections, line) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Check that sections are sorted | |
| 40 | + | for i := 1; i < len(sections); i++ { | |
| 41 | + | if sections[i-1] > sections[i] { | |
| 42 | + | t.Errorf("Sections not sorted: %s > %s", sections[i-1], sections[i]) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | t.Logf("Found %d sections, all sorted", len(sections)) | |
| 47 | + | ||
| 48 | + | // Print first 50 lines for debugging | |
| 49 | + | t.Logf("First 50 lines of output:\n%s", strings.Join(lines[:min(50, len(lines))], "\n")) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestSortTOMLSections(t *testing.T) { | |
| 53 | + | input := `default_search_engine = 'https://example.com' | |
| 54 | + | ||
| 55 | + | [workspace] | |
| 56 | + | new_pane_url = 'about:blank' | |
| 57 | + | ||
| 58 | + | [appearance] | |
| 59 | + | color_scheme = 'default' | |
| 60 | + | ||
| 61 | + | [workspace.pane_mode] | |
| 62 | + | timeout_ms = 3000 | |
| 63 | + | ||
| 64 | + | [appearance.dark_palette] | |
| 65 | + | background = '#000' | |
| 66 | + | ` | |
| 67 | + | ||
| 68 | + | result := sortTOMLSections(input) | |
| 69 | + | ||
| 70 | + | // Extract section order | |
| 71 | + | lines := strings.Split(result, "\n") | |
| 72 | + | var sections []string | |
| 73 | + | for _, line := range lines { | |
| 74 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |
| 75 | + | sections = append(sections, line) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Expected order: appearance, appearance.dark_palette, workspace, workspace.pane_mode | |
| 80 | + | expected := []string{ | |
| 81 | + | "[appearance]", | |
| 82 | + | "[appearance.dark_palette]", | |
| 83 | + | "[workspace]", | |
| 84 | + | "[workspace.pane_mode]", | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if len(sections) != len(expected) { | |
| 88 | + | t.Fatalf("Expected %d sections, got %d: %v", len(expected), len(sections), sections) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for i, exp := range expected { | |
| 92 | + | if sections[i] != exp { | |
| 93 | + | t.Errorf("Section %d: expected %s, got %s", i, exp, sections[i]) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | t.Logf("Sorted output:\n%s", result) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func min(a, b int) int { | |
| 101 | + | if a < b { | |
| 102 | + | return a | |
| 103 | + | } | |
| 104 | + | return b | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWriteConfigOrdered(t *testing.T) { | |||
| 11 | + | // Create a temp directory for the test | |||
| 12 | + | tmpDir := t.TempDir() | |||
| 13 | + | configPath := filepath.Join(tmpDir, "config.toml") | |||
| 14 | + | ||||
| 15 | + | // Get default config | |||
| 16 | + | cfg := DefaultConfig() | |||
| 17 | + | ||||
| 18 | + | // Write it | |||
| 19 | + | err := WriteConfigOrdered(cfg, configPath) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("WriteConfigOrdered failed: %v", err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Read back | |||
| 25 | + | content, err := os.ReadFile(configPath) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatalf("Failed to read written config: %v", err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Verify sections are in alphabetical order | |||
| 31 | + | lines := strings.Split(string(content), "\n") | |||
| 32 | + | var sections []string | |||
| 33 | + | for _, line := range lines { | |||
| 34 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |||
| 35 | + | sections = append(sections, line) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Check that sections are sorted | |||
| 40 | + | for i := 1; i < len(sections); i++ { | |||
| 41 | + | if sections[i-1] > sections[i] { | |||
| 42 | + | t.Errorf("Sections not sorted: %s > %s", sections[i-1], sections[i]) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | t.Logf("Found %d sections, all sorted", len(sections)) | |||
| 47 | + | ||||
| 48 | + | // Print first 50 lines for debugging | |||
| 49 | + | t.Logf("First 50 lines of output:\n%s", strings.Join(lines[:min(50, len(lines))], "\n")) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestSortTOMLSections(t *testing.T) { | |||
| 53 | + | input := `default_search_engine = 'https://example.com' | |||
| 54 | + | ||||
| 55 | + | [workspace] | |||
| 56 | + | new_pane_url = 'about:blank' | |||
| 57 | + | ||||
| 58 | + | [appearance] | |||
| 59 | + | color_scheme = 'default' | |||
| 60 | + | ||||
| 61 | + | [workspace.pane_mode] | |||
| 62 | + | timeout_ms = 3000 | |||
| 63 | + | ||||
| 64 | + | [appearance.dark_palette] | |||
| 65 | + | background = '#000' | |||
| 66 | + | ` | |||
| 67 | + | ||||
| 68 | + | result := sortTOMLSections(input) | |||
| 69 | + | ||||
| 70 | + | // Extract section order | |||
| 71 | + | lines := strings.Split(result, "\n") | |||
| 72 | + | var sections []string | |||
| 73 | + | for _, line := range lines { | |||
| 74 | + | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |||
| 75 | + | sections = append(sections, line) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Expected order: appearance, appearance.dark_palette, workspace, workspace.pane_mode | |||
| 80 | + | expected := []string{ | |||
| 81 | + | "[appearance]", | |||
| 82 | + | "[appearance.dark_palette]", | |||
| 83 | + | "[workspace]", | |||
| 84 | + | "[workspace.pane_mode]", | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if len(sections) != len(expected) { | |||
| 88 | + | t.Fatalf("Expected %d sections, got %d: %v", len(expected), len(sections), sections) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for i, exp := range expected { | |||
| 92 | + | if sections[i] != exp { | |||
| 93 | + | t.Errorf("Section %d: expected %s, got %s", i, exp, sections[i]) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | t.Logf("Sorted output:\n%s", result) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func min(a, b int) int { | |||
| 101 | + | if a < b { | |||
| 102 | + | return a | |||
| 103 | + | } | |||
| 104 | + | return b | |||
| 105 | + | } | |||