detached f21fa3f

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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.
77 ### Added
88 - **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.
99 - **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.
1011
1112 ### Changed
1213 - **Simplified keybinding syntax**: Keybindings now use symbol syntax (`[`, `]`, `{`, `}`) instead of verbose GTK names (`bracketleft`, `braceleft`). Fixes #122.
1314 - **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.
1415
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+
1519 ### Fixed
1620 - **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.
1721 - **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 ### Added7 ### 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 ### Changed12 ### 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 ### Fixed19 ### 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 {
259259 return &configCopy
260260 }
261261
262-// Save saves the provided configuration to disk and updates Viper.
262+// Save saves the provided configuration to disk with ordered sections.
263263 func (m *Manager) Save(cfg *Config) error {
264264 m.mu.Lock()
265265 defer m.mu.Unlock()
@@ -273,30 +273,21 @@ func (m *Manager) Save(cfg *Config) error {
273273 return fmt.Errorf("configuration validation failed: %w", err)
274274 }
275275
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 {
294287 return fmt.Errorf("failed to write config: %w", err)
295288 }
296289
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
300291 if !m.watching {
301292 if err := m.reload(); err != nil {
302293 return err
@@ -306,7 +297,7 @@ func (m *Manager) Save(cfg *Config) error {
306297 return nil
307298 }
308299
309-// SaveKeybindings saves keybinding-related configuration to disk.
300+// SaveKeybindings saves keybinding-related configuration to disk with ordered sections.
310301 func (m *Manager) SaveKeybindings(cfg *Config) error {
311302 m.mu.Lock()
312303 defer m.mu.Unlock()
@@ -315,27 +306,17 @@ func (m *Manager) SaveKeybindings(cfg *Config) error {
315306 return fmt.Errorf("config is nil")
316307 }
317308
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+ }
337318
338- if err := m.viper.WriteConfig(); err != nil {
319+ if err := WriteConfigOrdered(cfg, configFile); err != nil {
339320 return fmt.Errorf("failed to write config: %w", err)
340321 }
341322
@@ -354,7 +335,7 @@ func (m *Manager) GetConfigFile() string {
354335 return m.viper.ConfigFileUsed()
355336 }
356337
357-// createDefaultConfig creates a default configuration file.
338+// createDefaultConfig creates a default configuration file with ordered sections.
358339 func (m *Manager) createDefaultConfig() error {
359340 configFile, err := GetConfigFile()
360341 if err != nil {
@@ -366,12 +347,14 @@ func (m *Manager) createDefaultConfig() error {
366347 return err
367348 }
368349
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)
371355
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 {
375358 return fmt.Errorf("failed to write config file: %w", err)
376359 }
377360
@@ -380,9 +363,9 @@ func (m *Manager) createDefaultConfig() error {
380363 return nil
381364 }
382365
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) {
386369 // Create context with logger for debugging first-run font detection.
387370 logger := logging.NewFromEnv()
388371 ctx := logging.WithContext(context.Background(), logger)
@@ -394,13 +377,9 @@ func (m *Manager) detectAndSetFonts() {
394377 return
395378 }
396379
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())
404383 }
405384
406385 // setDefaults sets default configuration values in Viper.
@@ -259,7 +259,7 @@ func (m *Manager) Get() *Config {
259 return &configCopy259 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 preserving277+ 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.OnConfigChange290+ // 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 err293 return err
@@ -306,7 +297,7 @@ func (m *Manager) Save(cfg *Config) error {
306 return nil297 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 values309+ // Write full config with ordered sections
319- // Pane mode310+ 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 mode314+ 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 err347 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 config356+ // 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 nil363 return nil
381 }364 }
382 365
383-// detectAndSetFonts detects available system fonts and sets the best available366+// 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 return377 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 = &section{
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 = &section{
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+}

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