v0.2.0 80c61e3

config.go RawHistoryFolder

1package config
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"regexp"
11	"strconv"
12	"strings"
13	"time"
14
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/ports"
17)
18
19const pollInterval = 2 * time.Second
20
21var (
22	processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23	percentagePattern  = regexp.MustCompile(`^[0-9]{1,3}%$`)
24)
25
26// Parse reads vev's flat action = value config format. Duplicate action keys
27// are accepted with a warning; the last value wins while first-seen action order
28// is preserved for binding conflict resolution.
29func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
30	cfg := domain.Defaults()
31	var warnings []domain.Warning
32	seenBindingKeys := make(map[string]bool)
33	seenFloatingKeys := make(map[string]bool)
34	seenCopyKeys := make(map[string]bool)
35	seenPaletteKeys := make(map[string]bool)
36	seenNavKeys := make(map[string]bool)
37	seenTabsKeys := make(map[string]bool)
38
39	scanner := bufio.NewScanner(r)
40	lineNo := 0
41	for scanner.Scan() {
42		lineNo++
43		line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
44		if line == "" || strings.HasPrefix(line, "#") {
45			continue
46		}
47		line = stripInlineComment(line)
48		if line == "" {
49			continue
50		}
51
52		key, value, ok := strings.Cut(line, "=")
53		if !ok {
54			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
55			continue
56		}
57		key = strings.TrimSpace(key)
58		value = strings.TrimSpace(value)
59		if key == "" {
60			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
61			continue
62		}
63
64		switch {
65		case key == "theme":
66			mode, ok := parseTheme(value)
67			if !ok {
68				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
69				continue
70			}
71			cfg.Theme = mode
72		case key == "theme.palette":
73			on, ok := parseOnOff(value)
74			if !ok {
75				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
76				continue
77			}
78			cfg.ThemePalette = on
79		case key == "theme.accent":
80			accent, warning := parseThemeAccent(value)
81			cfg.ThemeAccent = accent
82			if warning != "" {
83				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
84			}
85		case key == "bar.top-right":
86			command, ok := parseBarCommand(value)
87			if !ok {
88				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
89				continue
90			}
91			cfg.Bar.TopRight = command
92		case key == "bar.bottom-right":
93			command, ok := parseBarCommand(value)
94			if !ok {
95				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
96				continue
97			}
98			cfg.Bar.BottomRight = command
99		case key == "bar.interval":
100			interval, err := time.ParseDuration(value)
101			if err != nil {
102				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
103				continue
104			}
105			if interval < domain.MinBarInterval {
106				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
107				interval = domain.MinBarInterval
108			}
109			cfg.Bar.Interval = interval
110		case key == "palette.anchor":
111			warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
112			if strings.EqualFold(strings.TrimSpace(value), "auto") {
113				cfg.Palette = domain.PaletteConfig{}
114				continue
115			}
116			anchor, ok := domain.ParseAnchor(value)
117			if !ok {
118				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
119				continue
120			}
121			cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
122		case key == "nav.overflow-tabs":
123			warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
124			on, ok := parseOnOff(value)
125			if !ok {
126				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
127				continue
128			}
129			cfg.Nav.OverflowTabs = on
130		case key == "nav.overflow-sessions":
131			warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
132			on, ok := parseOnOff(value)
133			if !ok {
134				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
135				continue
136			}
137			cfg.Nav.OverflowSessions = on
138		case key == "tabs.terminal-title":
139			warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
140			on, ok := parseOnOff(value)
141			if !ok {
142				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
143				continue
144			}
145			cfg.Tabs.TerminalTitle = on
146		case key == "copy.word-separators":
147			warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
148			separators, ok := parseConfigString(value)
149			if !ok {
150				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
151				continue
152			}
153			cfg.Copy.WordSeparators = separators
154		case key == "floating.command":
155			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
156			command, ok := parseBarCommand(value)
157			if !ok {
158				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
159				continue
160			}
161			cfg.Floating.Command = command
162		case key == "floating.width":
163			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
164			width, ok := parsePercentage(value)
165			if !ok {
166				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
167				continue
168			}
169			cfg.Floating.Width = width
170		case key == "floating.height":
171			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
172			height, ok := parsePercentage(value)
173			if !ok {
174				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
175				continue
176			}
177			cfg.Floating.Height = height
178		case strings.HasPrefix(key, "code."):
179			codeKey := strings.TrimPrefix(key, "code.")
180			if codeKey == "" {
181				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
182				continue
183			}
184			if _, exists := cfg.Codes[codeKey]; exists {
185				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
186			}
187			cfg.Codes[codeKey] = value
188		case key == "snapshot.restore_processes":
189			var processWarnings []domain.Warning
190			cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
191			warnings = append(warnings, processWarnings...)
192			cfg.Snapshot.RestoreProcessesSet = true
193		default:
194			if seenBindingKeys[key] {
195				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
196				updateBindingEntry(cfg.BindingEntries, key, value)
197			} else {
198				seenBindingKeys[key] = true
199				cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
200			}
201		}
202	}
203	if err := scanner.Err(); err != nil {
204		return cfg, warnings, err
205	}
206	return cfg, warnings, nil
207}
208
209// Load reads path and parses it. Missing config files are treated as defaults.
210func Load(path string) (domain.Config, []domain.Warning, error) {
211	f, err := os.Open(path)
212	if errors.Is(err, os.ErrNotExist) {
213		return domain.Defaults(), nil, nil
214	}
215	if err != nil {
216		return domain.Defaults(), nil, err
217	}
218	cfg, warnings, parseErr := Parse(f)
219	if closeErr := f.Close(); parseErr == nil && closeErr != nil {
220		return cfg, warnings, closeErr
221	}
222	return cfg, warnings, parseErr
223}
224
225// Watch polls path for mtime/size changes every two seconds and calls onChange
226// with each successfully loaded config. Missing files are allowed and reload to
227// defaults when the watched state changes.
228func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
229	last, err := fileStamp(path)
230	lastErrMsg := ""
231	if err != nil {
232		lastErrMsg = err.Error()
233		onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
234	}
235	timer := clock.NewTimer(pollInterval)
236	defer timer.Stop()
237
238	for {
239		select {
240		case <-ctx.Done():
241			return ctx.Err()
242		case <-timer.C():
243			current, statErr := fileStamp(path)
244			if statErr != nil {
245				if statErr.Error() != lastErrMsg {
246					lastErrMsg = statErr.Error()
247					onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
248				}
249			} else if current != last || lastErrMsg != "" {
250				lastErrMsg = ""
251				last = current
252				cfg, warnings, err := Load(path)
253				if err != nil {
254					cfg = domain.Defaults()
255					warnings = append(warnings, domain.Warning{Msg: err.Error()})
256				}
257				onChange(cfg, warnings)
258			}
259			timer.Reset(pollInterval)
260		}
261	}
262}
263
264type stamp struct {
265	modTime time.Time
266	size    int64
267	exists  bool
268}
269
270func fileStamp(path string) (stamp, error) {
271	st, err := os.Stat(path)
272	if errors.Is(err, os.ErrNotExist) {
273		return stamp{}, nil
274	}
275	if err != nil {
276		return stamp{}, err
277	}
278	return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
279}
280
281func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
282	if seen[key] {
283		warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
284	}
285	seen[key] = true
286	return warnings
287}
288
289func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
290	for i := range entries {
291		if entries[i].Key == key {
292			entries[i].Value = value
293			return
294		}
295	}
296}
297
298func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
299	if strings.TrimSpace(value) == "" {
300		return nil, nil
301	}
302	parts := strings.Split(value, ",")
303	out := make([]string, 0, len(parts))
304	warnings := make([]domain.Warning, 0)
305	seen := make(map[string]struct{}, len(parts))
306	for _, part := range parts {
307		item := strings.TrimSpace(part)
308		if item == "" {
309			continue
310		}
311		if !processNamePattern.MatchString(item) {
312			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
313			continue
314		}
315		if _, ok := seen[item]; ok {
316			continue
317		}
318		seen[item] = struct{}{}
319		out = append(out, item)
320	}
321	return out, warnings
322}
323
324func parsePercentage(value string) (int, bool) {
325	if !percentagePattern.MatchString(value) {
326		return 0, false
327	}
328	percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
329	return percentage, err == nil && percentage >= 1 && percentage <= 100
330}
331
332func parseOnOff(value string) (bool, bool) {
333	switch strings.ToLower(strings.TrimSpace(value)) {
334	case "on":
335		return true, true
336	case "off":
337		return false, true
338	default:
339		return false, false
340	}
341}
342
343func parseBarCommand(value string) (string, bool) {
344	value = strings.TrimSpace(value)
345	if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
346		unquoted, err := strconv.Unquote(value)
347		if err != nil {
348			return value, false
349		}
350		return unquoted, true
351	}
352	return value, true
353}
354
355func parseConfigString(value string) (string, bool) {
356	value = strings.TrimSpace(value)
357	if value == "" {
358		return "", true
359	}
360	startsQuoted := strings.HasPrefix(value, `"`)
361	endsQuoted := strings.HasSuffix(value, `"`)
362	if startsQuoted != endsQuoted {
363		return "", false
364	}
365	if !startsQuoted {
366		return value, true
367	}
368	unquoted, err := strconv.Unquote(value)
369	if err != nil {
370		return "", false
371	}
372	return unquoted, true
373}
374
375func parseTheme(value string) (domain.ThemeMode, bool) {
376	switch strings.ToLower(strings.TrimSpace(value)) {
377	case "auto", "":
378		return domain.ThemeAuto, true
379	case "dark":
380		return domain.ThemeDark, true
381	case "light":
382		return domain.ThemeLight, true
383	default:
384		return domain.ThemeAuto, false
385	}
386}
387
388func stripInlineComment(line string) string {
389	inQuote := false
390	escaped := false
391	for i, r := range line {
392		if escaped {
393			escaped = false
394			continue
395		}
396		if inQuote && r == '\\' {
397			escaped = true
398			continue
399		}
400		if r == '"' {
401			inQuote = !inQuote
402			continue
403		}
404		if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
405			return strings.TrimSpace(line[:i])
406		}
407	}
408	return line
409}
410
411func isSpace(r rune) bool {
412	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
413}

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 12

Tags 11

↑↓ moveenter openesc close