v0.3.2 a514042

watch.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"encoding/binary"
6	"errors"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11
12	"github.com/bnema/neferafk/internal/ports"
13	"golang.org/x/sys/unix"
14)
15
16func watchedFiles(path string) []string {
17	files := []string{filepath.Clean(path)}
18	for len(files) <= 40 {
19		cur := files[len(files)-1]
20		target, err := os.Readlink(cur)
21		if err != nil {
22			break
23		}
24		if !filepath.IsAbs(target) {
25			target = filepath.Join(filepath.Dir(cur), target)
26		}
27		target = filepath.Clean(target)
28		duplicate := false
29		for _, p := range files {
30			if p == target {
31				duplicate = true
32				break
33			}
34		}
35		if duplicate {
36			break
37		}
38		files = append(files, target)
39	}
40	return files
41}
42
43// watchDirectory finds the nearest existing ancestor. The missing component's
44// creation wakes inotify; the two-second retry also covers lost/overflow events.
45func watchDirectory(file string) (directory, name string, missing bool) {
46	directory, name = filepath.Dir(file), filepath.Base(file)
47	for {
48		st, err := os.Stat(directory)
49		if err == nil && st.IsDir() {
50			return
51		}
52		missing = true
53		parent := filepath.Dir(directory)
54		if parent == directory {
55			return
56		}
57		name, directory = filepath.Base(directory), parent
58	}
59}
60
61// Watch retains last-valid state across missing files/directories and follows
62// config symlink targets. It always emits the first valid candidate, including
63// a candidate equal to defaults: this closes an app Load -> Watch startup gap.
64// Ready closes after initial watch installation, before candidate delivery.
65// No invalid/deleted candidate emits a defaults reset. Errors never echo file
66// content or paths, which may themselves contain sensitive source metadata.
67func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {
68	fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
69	if err != nil {
70		return errors.New("initialize config watch")
71	}
72	defer unix.Close(fd)
73	const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF
74	names := map[int32]map[string]bool{}
75	retry := time.Time{}
76	rewatch := func() {
77		// Keep unchanged watches, avoiding IN_IGNORED from our own removals.
78		want := map[int32]map[string]bool{}
79		retry = time.Time{}
80		for _, file := range watchedFiles(path) {
81			dir, name, missing := watchDirectory(file)
82			wd, e := unix.InotifyAddWatch(fd, dir, mask)
83			if e != nil {
84				retry = time.Now().Add(2 * time.Second)
85				continue
86			}
87			if missing {
88				retry = time.Now().Add(2 * time.Second)
89			}
90			if want[int32(wd)] == nil {
91				want[int32(wd)] = map[string]bool{}
92			}
93			want[int32(wd)][name] = true
94		}
95		for wd := range names {
96			if want[wd] == nil {
97				_, _ = unix.InotifyRmWatch(fd, uint32(wd))
98			}
99		}
100		names = want
101	}
102	rewatch()
103	if ready != nil {
104		close(ready)
105	}
106	var last ports.Config
107	haveLast := false
108	publish := func() bool {
109		cfg, e := Load(path)
110		if e != nil {
111			if !os.IsNotExist(e) && failures != nil {
112				select {
113				case failures <- errors.New("config reload rejected"):
114				default:
115				}
116			}
117			return true
118		}
119		if haveLast && cfg == last {
120			return true
121		}
122		select {
123		case out <- ports.ConfigChanged{Config: cfg}:
124			last, haveLast = cfg, true
125			return true
126		case <-ctx.Done():
127			return false
128		}
129	}
130	if !publish() {
131		return nil
132	}
133	pending := time.Time{}
134	buf := make([]byte, 8192)
135	for ctx.Err() == nil {
136		now := time.Now()
137		if !retry.IsZero() && !now.Before(retry) {
138			rewatch()
139			pending = now
140		}
141		if !pending.IsZero() && !now.Before(pending) {
142			pending = time.Time{}
143			rewatch()
144			if !publish() {
145				return nil
146			}
147		}
148		timeout := 100 // cancellation bound, no periodic reads while watches are live
149		if !pending.IsZero() {
150			timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds())))
151		}
152		fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
153		_, e := unix.Poll(fds, timeout)
154		if errors.Is(e, unix.EINTR) {
155			continue
156		}
157		if e != nil {
158			return errors.New("poll config watch")
159		}
160		if fds[0].Revents&unix.POLLIN == 0 {
161			continue
162		}
163		n, e := unix.Read(fd, buf)
164		if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) {
165			continue
166		}
167		if e != nil {
168			return errors.New("read config watch")
169		}
170		for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
171			b := buf[offset:]
172			wd := int32(binary.NativeEndian.Uint32(b))
173			flags := binary.NativeEndian.Uint32(b[4:])
174			length := int(binary.NativeEndian.Uint32(b[12:]))
175			size := unix.SizeofInotifyEvent + length
176			if size > n-offset {
177				break
178			}
179			if flags&unix.IN_Q_OVERFLOW != 0 {
180				pending = time.Now().Add(100 * time.Millisecond)
181			}
182			if names[wd] != nil {
183				if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 {
184					if flags&unix.IN_IGNORED == 0 {
185						_, _ = unix.InotifyRmWatch(fd, uint32(wd))
186					}
187					delete(names, wd)
188					pending = time.Now().Add(100 * time.Millisecond)
189					retry = time.Now().Add(2 * time.Second)
190				}
191				name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00")
192				if names[wd][name] {
193					pending = time.Now().Add(100 * time.Millisecond)
194				}
195			}
196			offset += size
197		}
198	}
199	return nil
200}

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