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

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