detached 61db896

fix(config): prevent race condition with atomic write and skipNextReload flag

commit
61db896402f5d05325d66951cc9b4a43b919dc4f
author
brice · · 2026-01-24 14:18 UTC
parent
1960daf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +149 -59
M internal/infrastructure/config/loader.go +17 -6
@@ -17,11 +17,12 @@ import (
1717
1818 // Manager handles configuration loading, watching, and reloading.
1919 type Manager struct {
20- config *Config
21- viper *viper.Viper
22- mu sync.RWMutex
23- callbacks []func(*Config)
24- watching bool
20+ config *Config
21+ viper *viper.Viper
22+ mu sync.RWMutex
23+ callbacks []func(*Config)
24+ watching bool
25+ skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
2526 }
2627
2728 // NewManager creates a new configuration manager.
@@ -283,12 +284,22 @@ func (m *Manager) Save(cfg *Config) error {
283284 }
284285 }
285286
287+ // Set flag BEFORE writing to prevent fsnotify-triggered reload from
288+ // overwriting our in-memory config with stale viper cache
289+ m.skipNextReload = true
290+
286291 if err := WriteConfigOrdered(cfg, configFile); err != nil {
292+ m.skipNextReload = false // Reset on error
287293 return fmt.Errorf("failed to write config: %w", err)
288294 }
289295
290- // Reload config from disk to sync Viper's internal state
296+ // Update in-memory config immediately (don't wait for file watcher)
297+ // This ensures subsequent Get() calls return the new values
298+ m.config = cfg
299+
300+ // Also reload from disk to sync Viper's internal state (if not watching)
291301 if !m.watching {
302+ m.skipNextReload = false // Clear flag since we're doing manual reload
292303 if err := m.reload(); err != nil {
293304 return err
294305 }
@@ -17,11 +17,12 @@ import (
17 17
18 // Manager handles configuration loading, watching, and reloading.18 // Manager handles configuration loading, watching, and reloading.
19 type Manager struct {19 type Manager struct {
20- config *Config20+ config *Config
21- viper *viper.Viper21+ viper *viper.Viper
22- mu sync.RWMutex22+ mu sync.RWMutex
23- callbacks []func(*Config)23+ callbacks []func(*Config)
24- watching bool24+ watching bool
25+ skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
25 }26 }
26 27
27 // NewManager creates a new configuration manager.28 // NewManager creates a new configuration manager.
@@ -283,12 +284,22 @@ func (m *Manager) Save(cfg *Config) error {
283 }284 }
284 }285 }
285 286
287+ // Set flag BEFORE writing to prevent fsnotify-triggered reload from
288+ // overwriting our in-memory config with stale viper cache
289+ m.skipNextReload = true
290+
286 if err := WriteConfigOrdered(cfg, configFile); err != nil {291 if err := WriteConfigOrdered(cfg, configFile); err != nil {
292+ m.skipNextReload = false // Reset on error
287 return fmt.Errorf("failed to write config: %w", err)293 return fmt.Errorf("failed to write config: %w", err)
288 }294 }
289 295
290- // Reload config from disk to sync Viper's internal state296+ // Update in-memory config immediately (don't wait for file watcher)
297+ // This ensures subsequent Get() calls return the new values
298+ m.config = cfg
299+
300+ // Also reload from disk to sync Viper's internal state (if not watching)
291 if !m.watching {301 if !m.watching {
302+ m.skipNextReload = false // Clear flag since we're doing manual reload
292 if err := m.reload(); err != nil {303 if err := m.reload(); err != nil {
293 return err304 return err
294 }305 }
M internal/infrastructure/config/watcher.go +35 -14
@@ -2,8 +2,8 @@ package config
22
33 import (
44 "fmt"
5- "os"
65
6+ "github.com/bnema/dumber/internal/logging"
77 "github.com/fsnotify/fsnotify"
88 )
99
@@ -17,29 +17,50 @@ func (m *Manager) Watch() error {
1717 }
1818
1919 m.viper.WatchConfig()
20- m.viper.OnConfigChange(func(_ fsnotify.Event) {
21- // Reload config
22- if err := m.reload(); err != nil {
23- fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
20+ m.viper.OnConfigChange(func(e fsnotify.Event) {
21+ log := logging.NewFromEnv()
22+ log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected")
23+
24+ // Acquire write lock before reload (reload modifies m.config)
25+ m.mu.Lock()
26+
27+ // Skip reload if this was triggered by our own Save() - the in-memory
28+ // config is already correct and viper may have stale cached data
29+ if m.skipNextReload {
30+ log.Debug().Msg("skipping reload (triggered by own Save)")
31+ m.skipNextReload = false
32+ // Still need to sync viper's internal state with the file we just wrote
33+ _ = m.viper.ReadInConfig()
34+ m.notifyCallbacksLocked()
2435 return
2536 }
2637
27- // Notify callbacks
28- m.mu.RLock()
29- config := m.config
30- callbacks := make([]func(*Config), len(m.callbacks))
31- copy(callbacks, m.callbacks)
32- m.mu.RUnlock()
33-
34- for _, callback := range callbacks {
35- callback(config)
38+ log.Debug().Msg("reloading config from external change")
39+ if err := m.reload(); err != nil {
40+ m.mu.Unlock()
41+ log.Warn().Err(err).Msg("failed to reload config")
42+ return
3643 }
44+ m.notifyCallbacksLocked()
3745 })
3846
3947 m.watching = true
4048 return nil
4149 }
4250
51+// notifyCallbacksLocked copies callbacks and config, releases lock, then notifies.
52+// Must be called with m.mu held for write. Releases the lock before calling callbacks.
53+func (m *Manager) notifyCallbacksLocked() {
54+ config := m.config
55+ callbacks := make([]func(*Config), len(m.callbacks))
56+ copy(callbacks, m.callbacks)
57+ m.mu.Unlock()
58+
59+ for _, callback := range callbacks {
60+ callback(config)
61+ }
62+}
63+
4364 // OnConfigChange registers a callback function to be called when config changes.
4465 func (m *Manager) OnConfigChange(callback func(*Config)) {
4566 m.mu.Lock()
@@ -2,8 +2,8 @@ package config
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6 5
6+ "github.com/bnema/dumber/internal/logging"
7 "github.com/fsnotify/fsnotify"7 "github.com/fsnotify/fsnotify"
8 )8 )
9 9
@@ -17,29 +17,50 @@ func (m *Manager) Watch() error {
17 }17 }
18 18
19 m.viper.WatchConfig()19 m.viper.WatchConfig()
20- m.viper.OnConfigChange(func(_ fsnotify.Event) {20+ m.viper.OnConfigChange(func(e fsnotify.Event) {
21- // Reload config21+ log := logging.NewFromEnv()
22- if err := m.reload(); err != nil {22+ log.Debug().Str("op", e.Op.String()).Str("file", e.Name).Msg("fsnotify config change detected")
23- fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)23+
24+ // Acquire write lock before reload (reload modifies m.config)
25+ m.mu.Lock()
26+
27+ // Skip reload if this was triggered by our own Save() - the in-memory
28+ // config is already correct and viper may have stale cached data
29+ if m.skipNextReload {
30+ log.Debug().Msg("skipping reload (triggered by own Save)")
31+ m.skipNextReload = false
32+ // Still need to sync viper's internal state with the file we just wrote
33+ _ = m.viper.ReadInConfig()
34+ m.notifyCallbacksLocked()
24 return35 return
25 }36 }
26 37
27- // Notify callbacks38+ log.Debug().Msg("reloading config from external change")
28- m.mu.RLock()39+ if err := m.reload(); err != nil {
29- config := m.config40+ m.mu.Unlock()
30- callbacks := make([]func(*Config), len(m.callbacks))41+ log.Warn().Err(err).Msg("failed to reload config")
31- copy(callbacks, m.callbacks)42+ return
32- m.mu.RUnlock()
33-
34- for _, callback := range callbacks {
35- callback(config)
36 }43 }
44+ m.notifyCallbacksLocked()
37 })45 })
38 46
39 m.watching = true47 m.watching = true
40 return nil48 return nil
41 }49 }
42 50
51+// notifyCallbacksLocked copies callbacks and config, releases lock, then notifies.
52+// Must be called with m.mu held for write. Releases the lock before calling callbacks.
53+func (m *Manager) notifyCallbacksLocked() {
54+ config := m.config
55+ callbacks := make([]func(*Config), len(m.callbacks))
56+ copy(callbacks, m.callbacks)
57+ m.mu.Unlock()
58+
59+ for _, callback := range callbacks {
60+ callback(config)
61+ }
62+}
63+
43 // OnConfigChange registers a callback function to be called when config changes.64 // OnConfigChange registers a callback function to be called when config changes.
44 func (m *Manager) OnConfigChange(callback func(*Config)) {65 func (m *Manager) OnConfigChange(callback func(*Config)) {
45 m.mu.Lock()66 m.mu.Lock()
M internal/infrastructure/config/writer.go +97 -39
@@ -4,99 +4,158 @@ import (
44 "bytes"
55 "fmt"
66 "os"
7+ "path/filepath"
78 "regexp"
89 "sort"
910 "strings"
1011
12+ "github.com/bnema/dumber/internal/logging"
1113 "github.com/pelletier/go-toml/v2"
1214 )
1315
1416 // 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+// Uses atomic write (temp file + rename) to prevent race conditions with file watchers.
1718 func WriteConfigOrdered(cfg *Config, path string) error {
1819 if cfg == nil {
1920 return fmt.Errorf("config is nil")
2021 }
2122
23+ content, err := encodeConfigToTOML(cfg)
24+ if err != nil {
25+ return err
26+ }
27+
28+ return atomicWriteFile(path, content, filePerm)
29+}
30+
31+// encodeConfigToTOML encodes the config struct to sorted TOML content.
32+func encodeConfigToTOML(cfg *Config) (string, error) {
2233 var buf bytes.Buffer
2334 enc := toml.NewEncoder(&buf)
2435 enc.SetIndentTables(true)
2536
2637 if err := enc.Encode(cfg); err != nil {
27- return fmt.Errorf("failed to encode config: %w", err)
38+ return "", fmt.Errorf("failed to encode config: %w", err)
2839 }
2940
30- // Post-process to sort TOML sections alphabetically
31- sorted := sortTOMLSections(buf.String())
41+ return sortTOMLSections(buf.String()), nil
42+}
43+
44+// atomicWriteFile writes content to a file atomically using temp file + rename.
45+// This ensures file watchers only see complete content, not partial writes.
46+func atomicWriteFile(path, content string, perm os.FileMode) error {
47+ dir := filepath.Dir(path)
48+ tmpFile, err := os.CreateTemp(dir, ".config-*.tmp")
49+ if err != nil {
50+ return fmt.Errorf("failed to create temp file: %w", err)
51+ }
52+ tmpPath := tmpFile.Name()
53+
54+ // Cleanup on error
55+ success := false
56+ defer func() {
57+ if !success {
58+ if removeErr := os.Remove(tmpPath); removeErr != nil {
59+ log := logging.NewFromEnv()
60+ log.Warn().Err(removeErr).Str("path", tmpPath).Msg("failed to cleanup temp config file")
61+ }
62+ }
63+ }()
3264
33- if err := os.WriteFile(path, []byte(sorted), filePerm); err != nil {
34- return fmt.Errorf("failed to write config file: %w", err)
65+ if err := writeAndSync(tmpFile, content); err != nil {
66+ return err
3567 }
3668
69+ if err := os.Chmod(tmpPath, perm); err != nil {
70+ return fmt.Errorf("failed to set file permissions: %w", err)
71+ }
72+
73+ if err := os.Rename(tmpPath, path); err != nil {
74+ return fmt.Errorf("failed to rename temp file: %w", err)
75+ }
76+
77+ success = true
3778 return nil
3879 }
3980
40-// sortTOMLSections sorts TOML content so sections are in alphabetical order.
41-// This handles both top-level sections and indented nested sections.
81+// writeAndSync writes content to file and syncs to disk.
82+func writeAndSync(f *os.File, content string) (err error) {
83+ // Ensure file is closed, capturing close error if no prior error
84+ defer func() {
85+ closeErr := f.Close()
86+ if err == nil && closeErr != nil {
87+ err = fmt.Errorf("failed to close file: %w", closeErr)
88+ }
89+ }()
90+
91+ if _, err = f.WriteString(content); err != nil {
92+ return fmt.Errorf("failed to write file: %w", err)
93+ }
94+
95+ if err = f.Sync(); err != nil {
96+ return fmt.Errorf("failed to sync file: %w", err)
97+ }
98+
99+ return nil
100+}
101+
102+// tomlSection represents a parsed TOML section with its header and content lines.
103+type tomlSection struct {
104+ header string // Section name without brackets (e.g., "workspace.shortcuts")
105+ lines []string // All lines belonging to this section, including the header
106+}
107+
108+// sortTOMLSections sorts TOML content so sections appear in alphabetical order.
42109 func sortTOMLSections(content string) string {
43110 lines := strings.Split(content, "\n")
111+ preamble, sections := parseTOMLSections(lines)
44112
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- }
113+ sort.Slice(sections, func(i, j int) bool {
114+ return sections[i].header < sections[j].header
115+ })
50116
51- var sections []section
52- var currentSection *section
53- var preamble []string // lines before first section
117+ return buildTOMLOutput(preamble, sections)
118+}
54119
55- // Match section headers with optional leading whitespace (for indented sub-tables)
120+// parseTOMLSections parses TOML lines into preamble (top-level keys) and sections.
121+func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) {
56122 sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)
123+ var current *tomlSection
57124
58125 for _, line := range lines {
59126 if match := sectionRegex.FindStringSubmatch(line); match != nil {
60- // New section found
61- if currentSection != nil {
62- sections = append(sections, *currentSection)
127+ if current != nil {
128+ sections = append(sections, *current)
63129 }
64- currentSection = &section{
65- header: match[2], // Just the section name, without brackets or indent
130+ current = &tomlSection{
131+ header: match[2],
66132 lines: []string{line},
67133 }
68- } else if currentSection != nil {
69- currentSection.lines = append(currentSection.lines, line)
134+ } else if current != nil {
135+ current.lines = append(current.lines, line)
70136 } else {
71- // Before any section (top-level keys)
72137 preamble = append(preamble, line)
73138 }
74139 }
75140
76- // Don't forget the last section
77- if currentSection != nil {
78- sections = append(sections, *currentSection)
141+ if current != nil {
142+ sections = append(sections, *current)
79143 }
80144
81- // Sort sections alphabetically by header
82- sort.Slice(sections, func(i, j int) bool {
83- return sections[i].header < sections[j].header
84- })
145+ return preamble, sections
146+}
85147
86- // Rebuild content
148+// buildTOMLOutput reconstructs TOML content from preamble and sorted sections.
149+func buildTOMLOutput(preamble []string, sections []tomlSection) string {
87150 var result strings.Builder
88151
89- // Write preamble (top-level keys) first
90152 for _, line := range preamble {
91153 result.WriteString(line)
92154 result.WriteString("\n")
93155 }
94156
95- // Write sorted sections
96157 for i, sec := range sections {
97- // Add blank line before section (except first if preamble is empty)
98158 if i > 0 || len(preamble) > 0 {
99- // Check if previous content already ends with blank line
100159 content := result.String()
101160 if !strings.HasSuffix(content, "\n\n") && content != "" {
102161 result.WriteString("\n")
@@ -109,7 +168,6 @@ func sortTOMLSections(content string) string {
109168 }
110169 }
111170
112- // Trim trailing whitespace but ensure single newline at end
113171 output := strings.TrimRight(result.String(), "\n")
114172 if output != "" {
115173 output += "\n"
@@ -4,99 +4,158 @@ import (
4 "bytes"4 "bytes"
5 "fmt"5 "fmt"
6 "os"6 "os"
7+ "path/filepath"
7 "regexp"8 "regexp"
8 "sort"9 "sort"
9 "strings"10 "strings"
10 11
12+ "github.com/bnema/dumber/internal/logging"
11 "github.com/pelletier/go-toml/v2"13 "github.com/pelletier/go-toml/v2"
12 )14 )
13 15
14 // WriteConfigOrdered writes the configuration to disk with consistent ordering.16 // WriteConfigOrdered writes the configuration to disk with consistent ordering.
15-// - Struct fields are written in definition order (go-toml v2 behavior)17+// Uses atomic write (temp file + rename) to prevent race conditions with file watchers.
16-// - TOML sections are sorted alphabetically within each parent for deterministic output
17 func WriteConfigOrdered(cfg *Config, path string) error {18 func WriteConfigOrdered(cfg *Config, path string) error {
18 if cfg == nil {19 if cfg == nil {
19 return fmt.Errorf("config is nil")20 return fmt.Errorf("config is nil")
20 }21 }
21 22
23+ content, err := encodeConfigToTOML(cfg)
24+ if err != nil {
25+ return err
26+ }
27+
28+ return atomicWriteFile(path, content, filePerm)
29+}
30+
31+// encodeConfigToTOML encodes the config struct to sorted TOML content.
32+func encodeConfigToTOML(cfg *Config) (string, error) {
22 var buf bytes.Buffer33 var buf bytes.Buffer
23 enc := toml.NewEncoder(&buf)34 enc := toml.NewEncoder(&buf)
24 enc.SetIndentTables(true)35 enc.SetIndentTables(true)
25 36
26 if err := enc.Encode(cfg); err != nil {37 if err := enc.Encode(cfg); err != nil {
27- return fmt.Errorf("failed to encode config: %w", err)38+ return "", fmt.Errorf("failed to encode config: %w", err)
28 }39 }
29 40
30- // Post-process to sort TOML sections alphabetically41+ return sortTOMLSections(buf.String()), nil
31- sorted := sortTOMLSections(buf.String())42+}
43+
44+// atomicWriteFile writes content to a file atomically using temp file + rename.
45+// This ensures file watchers only see complete content, not partial writes.
46+func atomicWriteFile(path, content string, perm os.FileMode) error {
47+ dir := filepath.Dir(path)
48+ tmpFile, err := os.CreateTemp(dir, ".config-*.tmp")
49+ if err != nil {
50+ return fmt.Errorf("failed to create temp file: %w", err)
51+ }
52+ tmpPath := tmpFile.Name()
53+
54+ // Cleanup on error
55+ success := false
56+ defer func() {
57+ if !success {
58+ if removeErr := os.Remove(tmpPath); removeErr != nil {
59+ log := logging.NewFromEnv()
60+ log.Warn().Err(removeErr).Str("path", tmpPath).Msg("failed to cleanup temp config file")
61+ }
62+ }
63+ }()
32 64
33- if err := os.WriteFile(path, []byte(sorted), filePerm); err != nil {65+ if err := writeAndSync(tmpFile, content); err != nil {
34- return fmt.Errorf("failed to write config file: %w", err)66+ return err
35 }67 }
36 68
69+ if err := os.Chmod(tmpPath, perm); err != nil {
70+ return fmt.Errorf("failed to set file permissions: %w", err)
71+ }
72+
73+ if err := os.Rename(tmpPath, path); err != nil {
74+ return fmt.Errorf("failed to rename temp file: %w", err)
75+ }
76+
77+ success = true
37 return nil78 return nil
38 }79 }
39 80
40-// sortTOMLSections sorts TOML content so sections are in alphabetical order.81+// writeAndSync writes content to file and syncs to disk.
41-// This handles both top-level sections and indented nested sections.82+func writeAndSync(f *os.File, content string) (err error) {
83+ // Ensure file is closed, capturing close error if no prior error
84+ defer func() {
85+ closeErr := f.Close()
86+ if err == nil && closeErr != nil {
87+ err = fmt.Errorf("failed to close file: %w", closeErr)
88+ }
89+ }()
90+
91+ if _, err = f.WriteString(content); err != nil {
92+ return fmt.Errorf("failed to write file: %w", err)
93+ }
94+
95+ if err = f.Sync(); err != nil {
96+ return fmt.Errorf("failed to sync file: %w", err)
97+ }
98+
99+ return nil
100+}
101+
102+// tomlSection represents a parsed TOML section with its header and content lines.
103+type tomlSection struct {
104+ header string // Section name without brackets (e.g., "workspace.shortcuts")
105+ lines []string // All lines belonging to this section, including the header
106+}
107+
108+// sortTOMLSections sorts TOML content so sections appear in alphabetical order.
42 func sortTOMLSections(content string) string {109 func sortTOMLSections(content string) string {
43 lines := strings.Split(content, "\n")110 lines := strings.Split(content, "\n")
111+ preamble, sections := parseTOMLSections(lines)
44 112
45- // Parse into sections113+ sort.Slice(sections, func(i, j int) bool {
46- type section struct {114+ return sections[i].header < sections[j].header
47- header string // e.g., "appearance" or "workspace.pane_mode.actions.cancel"115+ })
48- lines []string // lines belonging to this section (including header)
49- }
50 116
51- var sections []section117+ return buildTOMLOutput(preamble, sections)
52- var currentSection *section118+}
53- var preamble []string // lines before first section
54 119
55- // Match section headers with optional leading whitespace (for indented sub-tables)120+// parseTOMLSections parses TOML lines into preamble (top-level keys) and sections.
121+func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) {
56 sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)122 sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)
123+ var current *tomlSection
57 124
58 for _, line := range lines {125 for _, line := range lines {
59 if match := sectionRegex.FindStringSubmatch(line); match != nil {126 if match := sectionRegex.FindStringSubmatch(line); match != nil {
60- // New section found127+ if current != nil {
61- if currentSection != nil {128+ sections = append(sections, *current)
62- sections = append(sections, *currentSection)
63 }129 }
64- currentSection = &section{130+ current = &tomlSection{
65- header: match[2], // Just the section name, without brackets or indent131+ header: match[2],
66 lines: []string{line},132 lines: []string{line},
67 }133 }
68- } else if currentSection != nil {134+ } else if current != nil {
69- currentSection.lines = append(currentSection.lines, line)135+ current.lines = append(current.lines, line)
70 } else {136 } else {
71- // Before any section (top-level keys)
72 preamble = append(preamble, line)137 preamble = append(preamble, line)
73 }138 }
74 }139 }
75 140
76- // Don't forget the last section141+ if current != nil {
77- if currentSection != nil {142+ sections = append(sections, *current)
78- sections = append(sections, *currentSection)
79 }143 }
80 144
81- // Sort sections alphabetically by header145+ return preamble, sections
82- sort.Slice(sections, func(i, j int) bool {146+}
83- return sections[i].header < sections[j].header
84- })
85 147
86- // Rebuild content148+// buildTOMLOutput reconstructs TOML content from preamble and sorted sections.
149+func buildTOMLOutput(preamble []string, sections []tomlSection) string {
87 var result strings.Builder150 var result strings.Builder
88 151
89- // Write preamble (top-level keys) first
90 for _, line := range preamble {152 for _, line := range preamble {
91 result.WriteString(line)153 result.WriteString(line)
92 result.WriteString("\n")154 result.WriteString("\n")
93 }155 }
94 156
95- // Write sorted sections
96 for i, sec := range sections {157 for i, sec := range sections {
97- // Add blank line before section (except first if preamble is empty)
98 if i > 0 || len(preamble) > 0 {158 if i > 0 || len(preamble) > 0 {
99- // Check if previous content already ends with blank line
100 content := result.String()159 content := result.String()
101 if !strings.HasSuffix(content, "\n\n") && content != "" {160 if !strings.HasSuffix(content, "\n\n") && content != "" {
102 result.WriteString("\n")161 result.WriteString("\n")
@@ -109,7 +168,6 @@ func sortTOMLSections(content string) string {
109 }168 }
110 }169 }
111 170
112- // Trim trailing whitespace but ensure single newline at end
113 output := strings.TrimRight(result.String(), "\n")171 output := strings.TrimRight(result.String(), "\n")
114 if output != "" {172 if output != "" {
115 output += "\n"173 output += "\n"

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