v0.33.0 62a09f5

writer.go RawHistoryFolder

1package config
2
3import (
4	"bytes"
5	"fmt"
6	"os"
7	"path/filepath"
8	"regexp"
9	"sort"
10	"strings"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/pelletier/go-toml/v2"
14)
15
16// WriteConfigOrdered writes the configuration to disk with consistent ordering.
17// Uses atomic write (temp file + rename) to prevent race conditions with file watchers.
18func WriteConfigOrdered(cfg *Config, path string) error {
19	if cfg == nil {
20		return fmt.Errorf("config is nil")
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.
32func encodeConfigToTOML(cfg *Config) (string, error) {
33	var buf bytes.Buffer
34	enc := toml.NewEncoder(&buf)
35	enc.SetIndentTables(true)
36
37	if err := enc.Encode(cfg); err != nil {
38		return "", fmt.Errorf("failed to encode config: %w", err)
39	}
40
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.
46func 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	}()
64
65	if err := writeAndSync(tmpFile, content); err != nil {
66		return err
67	}
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
78	return nil
79}
80
81// writeAndSync writes content to file and syncs to disk.
82func 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.
103type 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.
109func sortTOMLSections(content string) string {
110	lines := strings.Split(content, "\n")
111	preamble, sections := parseTOMLSections(lines)
112
113	sort.Slice(sections, func(i, j int) bool {
114		return sections[i].header < sections[j].header
115	})
116
117	return buildTOMLOutput(preamble, sections)
118}
119
120// parseTOMLSections parses TOML lines into preamble (top-level keys) and sections.
121func parseTOMLSections(lines []string) (preamble []string, sections []tomlSection) {
122	sectionRegex := regexp.MustCompile(`^(\s*)\[([^\]]+)\]\s*$`)
123	var current *tomlSection
124
125	for _, line := range lines {
126		if match := sectionRegex.FindStringSubmatch(line); match != nil {
127			if current != nil {
128				sections = append(sections, *current)
129			}
130			current = &tomlSection{
131				header: match[2],
132				lines:  []string{line},
133			}
134		} else if current != nil {
135			current.lines = append(current.lines, line)
136		} else {
137			preamble = append(preamble, line)
138		}
139	}
140
141	if current != nil {
142		sections = append(sections, *current)
143	}
144
145	return preamble, sections
146}
147
148// buildTOMLOutput reconstructs TOML content from preamble and sorted sections.
149func buildTOMLOutput(preamble []string, sections []tomlSection) string {
150	var result strings.Builder
151
152	for _, line := range preamble {
153		result.WriteString(line)
154		result.WriteString("\n")
155	}
156
157	for i, sec := range sections {
158		if i > 0 || len(preamble) > 0 {
159			content := result.String()
160			if !strings.HasSuffix(content, "\n\n") && content != "" {
161				result.WriteString("\n")
162			}
163		}
164
165		for _, line := range sec.lines {
166			result.WriteString(line)
167			result.WriteString("\n")
168		}
169	}
170
171	output := strings.TrimRight(result.String(), "\n")
172	if output != "" {
173		output += "\n"
174	}
175
176	return output
177}

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