v0.1.0 d9218d6

watch.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"sort"
9	"time"
10	"unsafe"
11
12	"github.com/bnema/neferwl/internal/ports"
13	"github.com/bnema/zerowrap"
14	"golang.org/x/sys/unix"
15)
16
17var directoryPollInterval = 2 * time.Second
18
19func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
20	f, err := os.Open(path)
21	if err != nil {
22		return ports.Config{}, nil, nil, err
23	}
24	defer f.Close()
25	return parse(f)
26}
27
28// loadRaw returns the effective keys of the file at startup; missing means none.
29func loadRaw(path string) map[string]string {
30	_, raw, _, err := loadFile(path)
31	if err != nil {
32		return map[string]string{}
33	}
34	return raw
35}
36
37// diff lists keys added, removed or changed, sorted.
38func diff(old, cur map[string]string) []string {
39	var keys []string
40	for k, v := range cur {
41		if o, ok := old[k]; !ok || o != v {
42			keys = append(keys, k)
43		}
44	}
45	for k := range old {
46		if _, ok := cur[k]; !ok {
47			keys = append(keys, k)
48		}
49	}
50	sort.Strings(keys)
51	return keys
52}
53
54// Watch observes the parent directory so atomic file replacements are detected.
55// If the directory is absent, it retries every two seconds until it appears.
56func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
57	dir, name := filepath.Dir(path), filepath.Base(path)
58	fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
59	if err != nil {
60		return err
61	}
62	defer unix.Close(fd)
63	const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
64	wd := -1
65	var pending time.Time
66	last := loadRaw(path)
67	buf := make([]byte, 4096)
68	for {
69		if ctx.Err() != nil {
70			return nil
71		}
72		if wd < 0 {
73			wd, err = unix.InotifyAddWatch(fd, dir, mask)
74			if errors.Is(err, unix.ENOENT) {
75				select {
76				case <-ctx.Done():
77					return nil
78				case <-time.After(directoryPollInterval):
79					continue
80				}
81			}
82			if err != nil {
83				return err
84			}
85		}
86		timeout := 200
87		if !pending.IsZero() {
88			remaining := time.Until(pending)
89			if remaining <= 0 {
90				pending = time.Time{}
91				cfg, raw, warnings, loadErr := loadFile(path)
92				switch {
93				case os.IsNotExist(loadErr):
94					log.Warn().Msg("config file removed; keeping current config")
95				case loadErr != nil:
96					log.Warn().Err(loadErr).Msg("config reload failed")
97				default:
98					for _, w := range warnings {
99						log.Warn().Int("line", w.Line).Msg(w.Msg)
100					}
101					changed := diff(last, raw)
102					last = raw
103					if len(changed) == 0 {
104						log.Debug().Msg("config unchanged")
105						continue
106					}
107					select {
108					case <-ctx.Done():
109						return nil
110					case out <- ports.ConfigChanged{Config: cfg}:
111					}
112					log.Info().Strs("changed", changed).Msg("config reloaded")
113				}
114				continue
115			}
116			if remaining < 200*time.Millisecond {
117				timeout = int(remaining.Milliseconds()) + 1
118			}
119		}
120		fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
121		_, err = unix.Poll(fds, timeout)
122		if err == unix.EINTR {
123			continue
124		}
125		if err != nil {
126			return err
127		}
128		if fds[0].Revents&unix.POLLIN == 0 {
129			continue
130		}
131		for {
132			n, readErr := unix.Read(fd, buf)
133			if readErr == unix.EAGAIN {
134				break
135			}
136			if readErr == unix.EINTR {
137				continue
138			}
139			if readErr != nil {
140				return readErr
141			}
142			if n == 0 {
143				break
144			}
145			for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
146				event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
147				size := unix.SizeofInotifyEvent + int(event.Len)
148				if offset+size > n {
149					break
150				}
151				if event.Mask&unix.IN_IGNORED != 0 {
152					wd = -1
153				}
154				if event.Len > 0 {
155					file := buf[offset+unix.SizeofInotifyEvent : offset+size]
156					for len(file) > 0 && file[len(file)-1] == 0 {
157						file = file[:len(file)-1]
158					}
159					if string(file) == name && event.Mask&mask != 0 {
160						pending = time.Now().Add(100 * time.Millisecond)
161					}
162				}
163				offset += size
164			}
165		}
166	}
167}

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 23

Tags 10

↑↓ moveenter openesc close