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/domain/terminalcap"
17	"github.com/bnema/vev/internal/ports"
18)
19
20const pollInterval = 2 * time.Second
21
22var (
23	processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
24	percentagePattern  = regexp.MustCompile(`^[0-9]{1,3}%$`)
25)
26
27// Parse reads vev's flat action = value config format. Duplicate action keys
28// are accepted with a warning; the last value wins while first-seen action order
29// is preserved for binding conflict resolution.
30func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
31	cfg := domain.Defaults()
32	var warnings []domain.Warning
33	seenBindingKeys := make(map[string]bool)
34	seenFloatingKeys := make(map[string]bool)
35	seenCopyKeys := make(map[string]bool)
36	seenPaletteKeys := make(map[string]bool)
37	seenNavKeys := make(map[string]bool)
38	seenTabsKeys := make(map[string]bool)
39	seenEphemeralKeys := make(map[string]bool)
40	seenKeyboardKeys := make(map[string]bool)
41	seenTerminalKeys := make(map[string]bool)
42	seenScrollbackKeys := make(map[string]bool)
43	seenWebKeys := make(map[string]bool)
44
45	scanner := bufio.NewScanner(r)
46	lineNo := 0
47	for scanner.Scan() {
48		lineNo++
49		line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
50		if line == "" || strings.HasPrefix(line, "#") {
51			continue
52		}
53		line = stripInlineComment(line)
54		if line == "" {
55			continue
56		}
57
58		key, value, ok := strings.Cut(line, "=")
59		if !ok {
60			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
61			continue
62		}
63		key = strings.TrimSpace(key)
64		value = strings.TrimSpace(value)
65		if key == "" {
66			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
67			continue
68		}
69
70		switch {
71		case key == "web.listen" || key == "web.origin":
72			warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
73			// Keep raw values for fail-closed validation at gateway startup.
74			if key == "web.listen" {
75				cfg.WebListen = value
76			} else {
77				cfg.WebOrigin = value
78			}
79		case key == "theme":
80			mode, ok := parseTheme(value)
81			if !ok {
82				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
83				continue
84			}
85			cfg.Theme = mode
86		case key == "theme.palette":
87			on, ok := parseOnOff(value)
88			if !ok {
89				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
90				continue
91			}
92			cfg.ThemePalette = on
93		case key == "theme.accent":
94			accent, warning := parseThemeAccent(value)
95			cfg.ThemeAccent = accent
96			if warning != "" {
97				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
98			}
99		case key == "bar.top-right":
100			command, ok := parseBarCommand(value)
101			if !ok {
102				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
103				continue
104			}
105			cfg.Bar.TopRight = command
106		case key == "bar.bottom-right":
107			command, ok := parseBarCommand(value)
108			if !ok {
109				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
110				continue
111			}
112			cfg.Bar.BottomRight = command
113		case key == "bar.interval":
114			interval, err := time.ParseDuration(value)
115			if err != nil {
116				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
117				continue
118			}
119			if interval < domain.MinBarInterval {
120				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
121				interval = domain.MinBarInterval
122			}
123			cfg.Bar.Interval = interval
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 == "terminal.colors":
177			warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178			mode, auto, err := terminalcap.ParseColorMode(value)
179			if err != nil {
180				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181				continue
182			}
183			cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
184		case key == "copy.word-separators":
185			warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
186			separators, ok := parseConfigString(value)
187			if !ok {
188				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
189				continue
190			}
191			cfg.Copy.WordSeparators = separators
192		case key == "copy.reduce-motion":
193			warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
194			on, ok := parseOnOff(value)
195			if !ok {
196				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)})
197				continue
198			}
199			cfg.Copy.ReduceMotion = on
200		case key == "floating.command":
201			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
202			command, ok := parseBarCommand(value)
203			if !ok {
204				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
205				continue
206			}
207			cfg.Floating.Command = command
208		case key == "floating.width":
209			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
210			width, ok := parsePercentage(value)
211			if !ok {
212				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
213				continue
214			}
215			cfg.Floating.Width = width
216		case key == "floating.height":
217			warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
218			height, ok := parsePercentage(value)
219			if !ok {
220				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
221				continue
222			}
223			cfg.Floating.Height = height
224		case strings.HasPrefix(key, "code."):
225			codeKey := strings.TrimPrefix(key, "code.")
226			if codeKey == "" {
227				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
228				continue
229			}
230			if _, exists := cfg.Codes[codeKey]; exists {
231				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
232			}
233			cfg.Codes[codeKey] = value
234		case key == "scrollback.megabytes":
235			warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
236			n, err := strconv.ParseUint(value, 10, 64)
237			if err != nil || n > domain.MaxScrollbackMegabytes {
238				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
239				continue
240			}
241			cfg.Scrollback.Megabytes = n
242		case key == "scrollback.lines":
243			warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
244			n, err := strconv.ParseUint(value, 10, 64)
245			if err != nil || n > domain.MaxScrollbackLines {
246				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
247				continue
248			}
249			cfg.Scrollback.Lines = int(n)
250		case key == "snapshot.restore_processes":
251			var processWarnings []domain.Warning
252			cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
253			warnings = append(warnings, processWarnings...)
254			cfg.Snapshot.RestoreProcessesSet = true
255		default:
256			if seenBindingKeys[key] {
257				warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
258				updateBindingEntry(cfg.BindingEntries, key, value)
259			} else {
260				seenBindingKeys[key] = true
261				cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
262			}
263		}
264	}
265	if err := scanner.Err(); err != nil {
266		return cfg, warnings, err
267	}
268	return cfg, warnings, nil
269}
270
271// Load reads path and parses it. Missing config files are treated as defaults.
272func Load(path string) (domain.Config, []domain.Warning, error) {
273	f, err := os.Open(path)
274	if errors.Is(err, os.ErrNotExist) {
275		return domain.Defaults(), nil, nil
276	}
277	if err != nil {
278		return domain.Defaults(), nil, err
279	}
280	cfg, warnings, parseErr := Parse(f)
281	if closeErr := f.Close(); parseErr == nil && closeErr != nil {
282		return cfg, warnings, closeErr
283	}
284	return cfg, warnings, parseErr
285}
286
287// Watch polls path for mtime/size changes every two seconds and calls onChange
288// with each successfully loaded config. Missing files are allowed and reload to
289// defaults when the watched state changes.
290func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
291	last, err := fileStamp(path)
292	lastErrMsg := ""
293	if err != nil {
294		lastErrMsg = err.Error()
295		onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
296	}
297	timer := clock.NewTimer(pollInterval)
298	defer timer.Stop()
299
300	for {
301		select {
302		case <-ctx.Done():
303			return ctx.Err()
304		case <-timer.C():
305			current, statErr := fileStamp(path)
306			if statErr != nil {
307				if statErr.Error() != lastErrMsg {
308					lastErrMsg = statErr.Error()
309					onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
310				}
311			} else if current != last || lastErrMsg != "" {
312				lastErrMsg = ""
313				last = current
314				cfg, warnings, err := Load(path)
315				if err != nil {
316					cfg = domain.Defaults()
317					warnings = append(warnings, domain.Warning{Msg: err.Error()})
318				}
319				onChange(cfg, warnings)
320			}
321			timer.Reset(pollInterval)
322		}
323	}
324}
325
326type stamp struct {
327	modTime time.Time
328	size    int64
329	exists  bool
330}
331
332func fileStamp(path string) (stamp, error) {
333	st, err := os.Stat(path)
334	if errors.Is(err, os.ErrNotExist) {
335		return stamp{}, nil
336	}
337	if err != nil {
338		return stamp{}, err
339	}
340	return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
341}
342
343func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
344	if seen[key] {
345		warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
346	}
347	seen[key] = true
348	return warnings
349}
350
351func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
352	for i := range entries {
353		if entries[i].Key == key {
354			entries[i].Value = value
355			return
356		}
357	}
358}
359
360func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
361	if strings.TrimSpace(value) == "" {
362		return nil, nil
363	}
364	parts := strings.Split(value, ",")
365	out := make([]string, 0, len(parts))
366	warnings := make([]domain.Warning, 0)
367	seen := make(map[string]struct{}, len(parts))
368	for _, part := range parts {
369		item := strings.TrimSpace(part)
370		if item == "" {
371			continue
372		}
373		if !processNamePattern.MatchString(item) {
374			warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
375			continue
376		}
377		if _, ok := seen[item]; ok {
378			continue
379		}
380		seen[item] = struct{}{}
381		out = append(out, item)
382	}
383	return out, warnings
384}
385
386func parsePercentage(value string) (int, bool) {
387	if !percentagePattern.MatchString(value) {
388		return 0, false
389	}
390	percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
391	return percentage, err == nil && percentage >= 1 && percentage <= 100
392}
393
394func parseOnOff(value string) (bool, bool) {
395	switch strings.ToLower(strings.TrimSpace(value)) {
396	case "on":
397		return true, true
398	case "off":
399		return false, true
400	default:
401		return false, false
402	}
403}
404
405func parseBarCommand(value string) (string, bool) {
406	value = strings.TrimSpace(value)
407	if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
408		unquoted, err := strconv.Unquote(value)
409		if err != nil {
410			return value, false
411		}
412		return unquoted, true
413	}
414	return value, true
415}
416
417func parseConfigString(value string) (string, bool) {
418	value = strings.TrimSpace(value)
419	if value == "" {
420		return "", true
421	}
422	startsQuoted := strings.HasPrefix(value, `"`)
423	endsQuoted := strings.HasSuffix(value, `"`)
424	if startsQuoted != endsQuoted {
425		return "", false
426	}
427	if !startsQuoted {
428		return value, true
429	}
430	unquoted, err := strconv.Unquote(value)
431	if err != nil {
432		return "", false
433	}
434	return unquoted, true
435}
436
437func parseTheme(value string) (domain.ThemeMode, bool) {
438	switch strings.ToLower(strings.TrimSpace(value)) {
439	case "auto", "":
440		return domain.ThemeAuto, true
441	case "dark":
442		return domain.ThemeDark, true
443	case "light":
444		return domain.ThemeLight, true
445	default:
446		return domain.ThemeAuto, false
447	}
448}
449
450func stripInlineComment(line string) string {
451	inQuote := false
452	escaped := false
453	for i, r := range line {
454		if escaped {
455			escaped = false
456			continue
457		}
458		if inQuote && r == '\\' {
459			escaped = true
460			continue
461		}
462		if r == '"' {
463			inQuote = !inQuote
464			continue
465		}
466		if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
467			return strings.TrimSpace(line[:i])
468		}
469	}
470	return line
471}
472
473func isSpace(r rune) bool {
474	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
475}

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