v0.1.0 3b1b6ef

live.go RawHistoryFolder

1package kvconf
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io/fs"
8	"slices"
9	"sync/atomic"
10)
11
12// Load reads the config file at path into a new T: it starts from the
13// defaults (SetDefaults when *T implements Defaulter), then applies the file.
14// A missing file gives the defaults, unless opts.RequireFile is set.
15func Load[T any](path string, opts Options) (*T, []Warning, error) {
16	cfg, _, warnings, err := load[T](path, opts)
17	return cfg, warnings, err
18}
19
20func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) {
21	data, err := ReadFile(path, opts)
22	if err != nil {
23		if isNotExist(err) && !opts.RequireFile {
24			cfg := newDefault[T]()
25			if v, ok := any(cfg).(Validator); ok {
26				if err := v.Validate(); err != nil {
27					return nil, nil, nil, err
28				}
29			}
30			return cfg, nil, nil, nil
31		}
32		return nil, nil, nil, err
33	}
34	return decodeFile[T](path, data, opts)
35}
36
37func decodeFile[T any](path string, data []byte, opts Options) (*T, []Entry, []Warning, error) {
38	cfg := newDefault[T]()
39	entries, warnings, err := ParseBytes(data, opts)
40	if err != nil {
41		return nil, nil, warnings, fmt.Errorf("%s: %w", path, err)
42	}
43	more, err := Decode(entries, cfg, opts)
44	warnings = append(warnings, more...)
45	if err != nil {
46		return nil, nil, warnings, fmt.Errorf("%s: %w", path, err)
47	}
48	return cfg, entries, warnings, nil
49}
50
51func newDefault[T any]() *T {
52	cfg := new(T)
53	if d, ok := any(cfg).(Defaulter); ok {
54		d.SetDefaults()
55	}
56	return cfg
57}
58
59// Change describes a reload.
60type Change[T any] struct {
61	Old, New *T
62	// Keys lists, sorted, the keys added, removed or changed.
63	Keys     []string
64	Warnings []Warning
65}
66
67// LiveOptions controls a Live config.
68type LiveOptions[T any] struct {
69	Options
70	Watch WatchOptions
71	// OnChange is called from Run after each reload that changed a value.
72	OnChange func(Change[T])
73	// OnError is called from Run when a reload fails; the current config
74	// stays in place. A removed file reports an error wrapping
75	// fs.ErrNotExist.
76	OnError func(error)
77}
78
79// Live is a config that reloads itself when its file changes. Get is safe
80// for concurrent use; a config it returned is never modified, a reload
81// replaces it.
82type Live[T any] struct {
83	path    string
84	opts    LiveOptions[T]
85	cur     atomic.Pointer[T]
86	entries []Entry // owned by Run
87	running atomic.Bool
88}
89
90// Open loads the config file at path and returns it with the warnings of
91// the first load. Call Run to follow its changes.
92func Open[T any](path string, opts LiveOptions[T]) (*Live[T], []Warning, error) {
93	cfg, entries, warnings, err := load[T](path, opts.Options)
94	if err != nil {
95		return nil, warnings, err
96	}
97	l := &Live[T]{path: path, opts: opts, entries: entries}
98	l.cur.Store(cfg)
99	return l, warnings, nil
100}
101
102// Get returns the current config. Do not modify it.
103func (l *Live[T]) Get() *T { return l.cur.Load() }
104
105// Path returns the path of the config file.
106func (l *Live[T]) Path() string { return l.path }
107
108// Run watches the file and reloads it until ctx ends. A reload that fails,
109// or a removed file, keeps the current config. A save that changes no value
110// (a comment, spacing, the same file written again) reloads nothing. Run
111// returns nil when ctx ends, or the error of the watcher. Call it once.
112func (l *Live[T]) Run(ctx context.Context) error {
113	if !l.running.CompareAndSwap(false, true) {
114		return errors.New("kvconf: Live.Run called twice")
115	}
116	ctx, cancel := context.WithCancel(ctx)
117	defer cancel()
118	changed := make(chan struct{}, 1)
119	ready := make(chan struct{})
120	wopts := l.opts.Watch
121	userReady := wopts.Ready
122	wopts.Ready = ready
123	errc := make(chan error, 1)
124	go func() { errc <- Watch(ctx, l.path, wopts, changed) }()
125	select {
126	case <-ready:
127		// A save between Open and the watch would go unseen: check once.
128		l.reload()
129		if userReady != nil {
130			close(userReady)
131		}
132	case err := <-errc:
133		return err
134	}
135	for {
136		select {
137		case <-changed:
138			l.reload()
139		case err := <-errc:
140			return err
141		}
142	}
143}
144
145func (l *Live[T]) reload() {
146	data, err := ReadFile(l.path, l.opts.Options)
147	if isNotExist(err) {
148		// Keep the current config: a removed file is often about to be
149		// replaced, and an empty config is rarely what the user meant.
150		if l.entries != nil {
151			l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist))
152		}
153		return
154	}
155	if err != nil {
156		l.report(err)
157		return
158	}
159	cfg, entries, warnings, err := decodeFile[T](l.path, data, l.opts.Options)
160	if err != nil {
161		l.report(err)
162		return
163	}
164	if sameEntries(entries, l.entries) {
165		return
166	}
167	// Compare every assignment, not the last one: a repeated key whose
168	// last value is invalid decodes to an earlier one.
169	keys := changedKeys(l.entries, entries)
170	l.entries = entries
171	old := l.cur.Swap(cfg)
172	if l.opts.OnChange != nil {
173		l.opts.OnChange(Change[T]{Old: old, New: cfg, Keys: keys, Warnings: warnings})
174	}
175}
176
177// changedKeys lists, sorted, the keys whose assignments differ.
178func changedKeys(old, cur []Entry) []string {
179	group := func(es []Entry) map[string][]string {
180		m := make(map[string][]string, len(es))
181		for _, e := range es {
182			m[e.Key] = append(m[e.Key], e.Value)
183		}
184		return m
185	}
186	a, b := group(old), group(cur)
187	var keys []string
188	for k, v := range b {
189		if !slices.Equal(a[k], v) {
190			keys = append(keys, k)
191		}
192	}
193	for k := range a {
194		if _, ok := b[k]; !ok {
195			keys = append(keys, k)
196		}
197	}
198	slices.Sort(keys)
199	return keys
200}
201
202func (l *Live[T]) report(err error) {
203	if l.opts.OnError != nil && !errors.Is(err, context.Canceled) {
204		l.opts.OnError(err)
205	}
206}

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 1

Tags 3

↑↓ moveenter openesc close