v0.2.1 7e79979

config.go RawHistoryFolder

1// Package config reads nefercap's persistent settings.
2package config
3
4import (
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"path/filepath"
10	"slices"
11	"strings"
12	"syscall"
13)
14
15const (
16	maxBytes = 4096
17	maxPath  = 4096
18)
19
20// ScreenshotOutput says where an interactive screenshot goes.
21type ScreenshotOutput string
22
23const (
24	OutputFile          ScreenshotOutput = "file"
25	OutputFileClipboard ScreenshotOutput = "file+clipboard"
26	OutputClipboard     ScreenshotOutput = "clipboard"
27)
28
29// Settings holds the persistent configuration. G toggles Grid for the current
30// selection without changing the saved setting. The directories are absolute,
31// or empty for the XDG default.
32type Settings struct {
33	Grid          bool
34	ScreenshotDir string
35	VideoDir      string
36	Output        ScreenshotOutput
37}
38
39const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir"
40
41// Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
42// An absent file or setting enables the full monitor grid. Invalid settings
43// fail explicitly rather than silently changing the requested appearance.
44func Load() (Settings, error) {
45	settings := Settings{Grid: true, Output: OutputFile}
46	root := os.Getenv("XDG_CONFIG_HOME")
47	if !filepath.IsAbs(root) {
48		home, err := os.UserHomeDir()
49		if err != nil {
50			return settings, nil
51		}
52		root = filepath.Join(home, ".config")
53	}
54	path := filepath.Join(root, "nefercap", "config")
55	// Nonblocking open also protects against a FIFO substituted before Stat.
56	file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
57	if errors.Is(err, os.ErrNotExist) {
58		return settings, nil
59	}
60	if err != nil {
61		return Settings{}, fmt.Errorf("open config: %w", err)
62	}
63	defer file.Close()
64	info, err := file.Stat()
65	if err != nil {
66		return Settings{}, fmt.Errorf("stat config: %w", err)
67	}
68	if !info.Mode().IsRegular() {
69		return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
70	}
71	data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
72	if err != nil {
73		return Settings{}, fmt.Errorf("read config %s: %w", path, err)
74	}
75	if len(data) > maxBytes {
76		return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
77	}
78	var seen [4]bool
79	for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
80		line := strings.TrimSpace(raw)
81		if line == "" || strings.HasPrefix(line, "#") {
82			continue
83		}
84		key, value, ok := strings.Cut(line, "=")
85		key, value = strings.TrimSpace(key), strings.TrimSpace(value)
86		slot := -1
87		if ok {
88			slot = slices.Index(keys[:], key)
89		}
90		if slot < 0 || seen[slot] {
91			return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys)
92		}
93		seen[slot] = true
94		if err := apply(&settings, key, value); err != nil {
95			return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err)
96		}
97	}
98	return settings, nil
99}
100
101var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"}
102
103func apply(s *Settings, key, value string) (err error) {
104	switch key {
105	case "selector.grid":
106		switch value {
107		case "on":
108			s.Grid = true
109		case "off":
110			s.Grid = false
111		default:
112			return errors.New("selector.grid must be on or off")
113		}
114	case "screenshot.output":
115		switch out := ScreenshotOutput(value); out {
116		case OutputFile, OutputFileClipboard, OutputClipboard:
117			s.Output = out
118		default:
119			return errors.New("screenshot.output must be file, file+clipboard or clipboard")
120		}
121	case "screenshot.dir":
122		s.ScreenshotDir, err = dir(key, value)
123	case "video.dir":
124		s.VideoDir, err = dir(key, value)
125	}
126	return err
127}
128
129// dir accepts an absolute path or one starting with ~/ (the home directory).
130func dir(key, value string) (string, error) {
131	if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
132		return "", fmt.Errorf("%s must be a non-empty path", key)
133	}
134	if strings.HasPrefix(value, "~/") {
135		home, err := os.UserHomeDir()
136		if err != nil || !filepath.IsAbs(home) {
137			return "", fmt.Errorf("%s: no absolute home directory for ~", key)
138		}
139		value = filepath.Join(home, value[1:])
140	}
141	if !filepath.IsAbs(value) {
142		return "", fmt.Errorf("%s must be an absolute path or start with ~/", key)
143	}
144	return filepath.Clean(value), nil
145}

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 2

Tags 9

↑↓ moveenter openesc close