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

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