main ece7682

config.go RawHistoryFolder

1package config
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"regexp"
11	"strconv"
12	"strings"
13	"time"
14
15	"github.com/bnema/kvconf"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/domain/terminalcap"
18)
19
20var (
21	processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
22	percentagePattern  = regexp.MustCompile(`^[0-9]{1,3}%$`)
23)
24
25// Parse reads vev's flat action = value config format. Duplicate action keys
26// are accepted with a warning; the last value wins while first-seen action order
27// is preserved for binding conflict resolution.
28func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
29	cfg := domain.Defaults()
30	var warnings []domain.Warning
31	seenBindingKeys := make(map[string]bool)
32	seenFloatingKeys := make(map[string]bool)
33	seenCopyKeys := make(map[string]bool)
34	seenPaletteKeys := make(map[string]bool)
35	seenNavKeys := make(map[string]bool)
36	seenTabsKeys := make(map[string]bool)
37	seenEphemeralKeys := make(map[string]bool)
38	seenKeyboardKeys := make(map[string]bool)
39	seenEchoKeys := make(map[string]bool)
40	seenTerminalKeys := make(map[string]bool)
41	seenScrollbackKeys := make(map[string]bool)
42	seenWebKeys := make(map[string]bool)
43
44	entries, syntax, err := kvconf.Parse(r, parseOptions)
45	if err != nil {
46		return domain.Defaults(), nil, err
47	}
48	pending := syntaxWarnings(syntax)
49
50	for _, entry := range entries {
51		// Interleave line-syntax warnings with domain warnings in file order.
52		for len(pending) > 0 && pending[0].Line < entry.Line {
53			warnings = append(warnings, pending[0])
54			pending = pending[1:]
55		}
56		lineNo, key, value := entry.Line, entry.Key, entry.Value
57		// "key = # note" has an empty value: kvconf only strips a '#' that
58		// follows other text.
59		if strings.HasPrefix(value, "#") {
60			value = ""
61		}
62
63		switch {
64		case key == "web.listen" || key == "web.origin":
65			warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
66			// Keep raw values for fail-closed validation at gateway startup.
67			if key == "web.listen" {
68				cfg.WebListen = value
69			} else {
70				cfg.WebOrigin = value
71			}
72		case key == "theme":
73			mode, ok := parseTheme(value)
74			if !ok {
75				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
76				continue
77			}
78			cfg.Theme = mode
79		case key == "theme.palette":
80			on, ok := parseOnOff(value)
81			if !ok {
82				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
83				continue
84			}
85			cfg.ThemePalette = on
86		case key == "theme.accent":
87			accent, warning := parseThemeAccent(value)
88			cfg.ThemeAccent = accent
89			if warning != "" {
90				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
91			}
92		case key == "bar.top-right":
93			command, ok := parseBarCommand(value)
94			if !ok {
95				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
96				continue
97			}
98			cfg.Bar.TopRight = command
99		case key == "bar.bottom-right":
100			command, ok := parseBarCommand(value)
101			if !ok {
102				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
103				continue
104			}
105			cfg.Bar.BottomRight = command
106		case key == "bar.interval":
107			interval, err := time.ParseDuration(value)
108			if err != nil {
109				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
110				continue
111			}
112			if interval < domain.MinBarInterval {
113				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
114				interval = domain.MinBarInterval
115			}
116			cfg.Bar.Interval = interval
117		case key == "bar.background":
118			on, ok := parseOnOff(value)
119			if !ok {
120				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.background %q", value)})
121				continue
122			}
123			cfg.Bar.Transparent = !on
124		case key == "palette.anchor":
125			warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
126			if strings.EqualFold(strings.TrimSpace(value), "auto") {
127				cfg.Palette = domain.PaletteConfig{}
128				continue
129			}
130			anchor, ok := domain.ParseAnchor(value)
131			if !ok {
132				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
133				continue
134			}
135			cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
136		case key == "nav.overflow-tabs":
137			warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
138			on, ok := parseOnOff(value)
139			if !ok {
140				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
141				continue
142			}
143			cfg.Nav.OverflowTabs = on
144		case key == "nav.overflow-sessions":
145			warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
146			on, ok := parseOnOff(value)
147			if !ok {
148				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
149				continue
150			}
151			cfg.Nav.OverflowSessions = on
152		case key == "tabs.terminal-title":
153			warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
154			on, ok := parseOnOff(value)
155			if !ok {
156				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
157				continue
158			}
159			cfg.Tabs.TerminalTitle = on
160		case key == "ephemeral.close-on-exit":
161			warnings = warnDuplicateKey(warnings, seenEphemeralKeys, key, lineNo)
162			on, ok := parseOnOff(value)
163			if !ok {
164				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid ephemeral.close-on-exit %q", value)})
165				continue
166			}
167			cfg.Ephemeral.CloseOnExit = on
168		case key == "keyboard.kitty-protocol":
169			warnings = warnDuplicateKey(warnings, seenKeyboardKeys, key, lineNo)
170			on, ok := parseOnOff(value)
171			if !ok {
172				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid keyboard.kitty-protocol %q", value)})
173				continue
174			}
175			cfg.Keyboard.KittyProtocol = on
176		case key == "echo.predict":
177			warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo)
178			mode, ok := parseEchoPredict(value)
179			if !ok {
180				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)})
181				continue
182			}
183			cfg.Echo.Predict = mode
184		case key == "terminal.colors":
185			warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
186			mode, auto, err := terminalcap.ParseColorMode(value)
187			if err != nil {
188				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
189				continue
190			}
191			cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
192		case key == "copy.word-separators":
193			warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
194			separators, ok := parseConfigString(value)
195			if !ok {
196				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
197				continue
198			}
199			cfg.Copy.WordSeparators = separators
200		case key == "copy.reduce-motion":
201			warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
202			on, ok := parseOnOff(value)
203			if !ok {
204				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)})
205				continue
206			}
207			cfg.Copy.ReduceMotion = on
208		case key == "floating.command":
209			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
210			command, ok := parseBarCommand(value)
211			if !ok {
212				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
213				continue
214			}
215			cfg.Floating.Command = command
216		case key == "floating.width":
217			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
218			width, ok := parsePercentage(value)
219			if !ok {
220				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
221				continue
222			}
223			cfg.Floating.Width = width
224		case key == "floating.height":
225			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
226			height, ok := parsePercentage(value)
227			if !ok {
228				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
229				continue
230			}
231			cfg.Floating.Height = height
232		case strings.HasPrefix(key, "code."):
233			codeKey := strings.TrimPrefix(key, "code.")
234			if codeKey == "" {
235				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
236				continue
237			}
238			if _, exists := cfg.Codes[codeKey]; exists {
239				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
240			}
241			cfg.Codes[codeKey] = value
242		case key == "scrollback.megabytes":
243			warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
244			n, err := strconv.ParseUint(value, 10, 64)
245			if err != nil || n > domain.MaxScrollbackMegabytes {
246				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
247				continue
248			}
249			cfg.Scrollback.Megabytes = n
250		case key == "scrollback.lines":
251			warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
252			n, err := strconv.ParseUint(value, 10, 64)
253			if err != nil || n > domain.MaxScrollbackLines {
254				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
255				continue
256			}
257			cfg.Scrollback.Lines = int(n)
258		case key == "snapshot.restore_processes":
259			var processWarnings []domain.Warning
260			cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
261			warnings = append(warnings, processWarnings...)
262			cfg.Snapshot.RestoreProcessesSet = true
263		default:
264			if seenBindingKeys[key] {
265				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
266				updateBindingEntry(cfg.BindingEntries, key, value)
267			} else {
268				seenBindingKeys[key] = true
269				cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
270			}
271		}
272	}
273	warnings = append(warnings, pending...)
274	return cfg, warnings, nil
275}
276
277// parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes
278// per key and reports its own duplicate warnings.
279var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true}
280
281// syntaxWarnings maps kvconf line-syntax warnings to domain warnings.
282func syntaxWarnings(in []kvconf.Warning) []domain.Warning {
283	out := make([]domain.Warning, 0, len(in))
284	for _, w := range in {
285		msg := w.Msg
286		if msg == "expected key = value" {
287			msg = "missing '='"
288		}
289		out = append(out, domain.Warning{Line: w.Line, Msg: msg})
290	}
291	return out
292}
293
294// Load reads path and parses it. Missing config files are treated as defaults.
295func Load(path string) (domain.Config, []domain.Warning, error) {
296	data, err := kvconf.ReadFile(path, parseOptions)
297	if errors.Is(err, os.ErrNotExist) {
298		return domain.Defaults(), nil, nil
299	}
300	if err != nil {
301		return domain.Defaults(), nil, err
302	}
303	return Parse(bytes.NewReader(data))
304}
305
306// Watch follows path with native file notifications (kvconf.Watch) and calls
307// onChange with the config reloaded after each change. Missing files are
308// allowed and reload to defaults. A load failure reports defaults plus a
309// warning; the same failure is not reported twice in a row. Watch returns
310// ctx.Err() when ctx ends.
311func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error {
312	return watch(ctx, path, kvconf.WatchOptions{}, onChange)
313}
314
315// fallbackPoll is the polling interval used when native watching fails.
316const fallbackPoll = 2 * time.Second
317
318// kvWatch is kvconf.Watch; tests replace it to make native watching fail.
319var kvWatch = kvconf.Watch
320
321func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error {
322	lastErrMsg := ""
323	reportErr := func(err error) {
324		if msg := err.Error(); msg != lastErrMsg {
325			lastErrMsg = msg
326			onChange(domain.Defaults(), []domain.Warning{{Msg: msg}})
327		}
328	}
329	if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) {
330		reportErr(err)
331	}
332
333	changed := make(chan struct{}, 1)
334	watchErr := make(chan error, 1)
335	go func() { watchErr <- kvWatch(ctx, path, opts, changed) }()
336
337	for {
338		select {
339		case <-ctx.Done():
340			<-watchErr
341			return ctx.Err()
342		case err := <-watchErr:
343			if ctx.Err() != nil {
344				return ctx.Err()
345			}
346			if err == nil {
347				err = errors.New("config watcher stopped")
348			}
349			if opts.Poll > 0 {
350				return err
351			}
352			// The OS ran out of watches (inotify limits): poll like before
353			// rather than lose live reload until the daemon restarts. The
354			// warning rides on the current config, which must not reset.
355			opts.Poll = fallbackPoll
356			go func() { watchErr <- kvWatch(ctx, path, opts, changed) }()
357			if cfg, warnings, err2 := Load(path); err2 == nil {
358				lastErrMsg = ""
359				onChange(cfg, append(warnings, domain.Warning{Msg: fmt.Sprintf("config watcher: %v; polling every %s", err, fallbackPoll)}))
360			}
361		case <-changed:
362			cfg, warnings, err := Load(path)
363			if err != nil {
364				reportErr(err)
365				continue
366			}
367			lastErrMsg = ""
368			onChange(cfg, warnings)
369		}
370	}
371}
372
373func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
374	switch strings.ToLower(value) {
375	case "adaptive":
376		return domain.EchoPredictAdaptive, true
377	case "always":
378		return domain.EchoPredictAlways, true
379	case "never":
380		return domain.EchoPredictNever, true
381	}
382	return 0, false
383}
384
385func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
386	if seen[key] {
387		warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
388	}
389	seen[key] = true
390	return warnings
391}
392
393func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
394	for i := range entries {
395		if entries[i].Key == key {
396			entries[i].Value = value
397			return
398		}
399	}
400}
401
402func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
403	if strings.TrimSpace(value) == "" {
404		return nil, nil
405	}
406	parts := strings.Split(value, ",")
407	out := make([]string, 0, len(parts))
408	warnings := make([]domain.Warning, 0)
409	seen := make(map[string]struct{}, len(parts))
410	for _, part := range parts {
411		item := strings.TrimSpace(part)
412		if item == "" {
413			continue
414		}
415		if !processNamePattern.MatchString(item) {
416			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
417			continue
418		}
419		if _, ok := seen[item]; ok {
420			continue
421		}
422		seen[item] = struct{}{}
423		out = append(out, item)
424	}
425	return out, warnings
426}
427
428func parsePercentage(value string) (int, bool) {
429	if !percentagePattern.MatchString(value) {
430		return 0, false
431	}
432	percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
433	return percentage, err == nil && percentage >= 1 && percentage <= 100
434}
435
436func parseOnOff(value string) (bool, bool) {
437	switch strings.ToLower(strings.TrimSpace(value)) {
438	case "on":
439		return true, true
440	case "off":
441		return false, true
442	default:
443		return false, false
444	}
445}
446
447func parseBarCommand(value string) (string, bool) {
448	value = strings.TrimSpace(value)
449	if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
450		unquoted, err := strconv.Unquote(value)
451		if err != nil {
452			return value, false
453		}
454		return unquoted, true
455	}
456	return value, true
457}
458
459func parseConfigString(value string) (string, bool) {
460	value = strings.TrimSpace(value)
461	if value == "" {
462		return "", true
463	}
464	startsQuoted := strings.HasPrefix(value, `"`)
465	endsQuoted := strings.HasSuffix(value, `"`)
466	if startsQuoted != endsQuoted {
467		return "", false
468	}
469	if !startsQuoted {
470		return value, true
471	}
472	unquoted, err := strconv.Unquote(value)
473	if err != nil {
474		return "", false
475	}
476	return unquoted, true
477}
478
479func parseTheme(value string) (domain.ThemeMode, bool) {
480	switch strings.ToLower(strings.TrimSpace(value)) {
481	case "auto", "":
482		return domain.ThemeAuto, true
483	case "dark":
484		return domain.ThemeDark, true
485	case "light":
486		return domain.ThemeLight, true
487	default:
488		return domain.ThemeAuto, false
489	}
490}

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