detached 397c5b9

refactor(config): share one inotify file watcher between config and theme reload

commit
397c5b945c4a541f543947ff3b54b0f6816d7ecf
author
brice · · 2026-10-05 15:42 UTC
parent
b5cca9e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +205 -155
M internal/config/config.go +11 -2
@@ -17,7 +17,10 @@ type Bar struct {
1717 Scale float64 `toml:"scale"`
1818 // Output is the wl_output name; empty lets the compositor choose.
1919 Output string `toml:"output"`
20- // Background and Foreground are #rrggbb.
20+ // Theme is "auto" (the colors of the terminal in use) or the path of a
21+ // terminal theme or config file: kitty, foot, ghostty or alacritty.
22+ Theme string `toml:"theme"`
23+ // Background and Foreground, as #rrggbb, override the theme's.
2124 Background string `toml:"background"`
2225 Foreground string `toml:"foreground"`
2326 }
@@ -39,7 +42,7 @@ type Config struct {
3942 func Default() Config {
4043 return Config{Bar: Bar{
4144 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
42- Background: "#1e1e2e", Foreground: "#cdd6f4",
45+ Theme: "auto",
4346 }}
4447 }
4548
@@ -83,10 +86,16 @@ func (c *Config) Validate() error {
8386 return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale)
8487 }
8588 for _, col := range []string{b.Background, b.Foreground} {
89+ if col == "" {
90+ continue // taken from the theme
91+ }
8692 if _, err := ParseColor(col); err != nil {
8793 return err
8894 }
8995 }
96+ if b.Theme == "" {
97+ return fmt.Errorf("config: bar.theme is empty; use \"auto\" or the path of a theme file")
98+ }
9099 seen := map[string]bool{}
91100 for i := range c.Module {
92101 m := &c.Module[i]
@@ -17,7 +17,10 @@ type Bar struct {
17 Scale float64 `toml:"scale"`17 Scale float64 `toml:"scale"`
18 // Output is the wl_output name; empty lets the compositor choose.18 // Output is the wl_output name; empty lets the compositor choose.
19 Output string `toml:"output"`19 Output string `toml:"output"`
20- // Background and Foreground are #rrggbb.20+ // Theme is "auto" (the colors of the terminal in use) or the path of a
21+ // terminal theme or config file: kitty, foot, ghostty or alacritty.
22+ Theme string `toml:"theme"`
23+ // Background and Foreground, as #rrggbb, override the theme's.
21 Background string `toml:"background"`24 Background string `toml:"background"`
22 Foreground string `toml:"foreground"`25 Foreground string `toml:"foreground"`
23 }26 }
@@ -39,7 +42,7 @@ type Config struct {
39 func Default() Config {42 func Default() Config {
40 return Config{Bar: Bar{43 return Config{Bar: Bar{
41 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,44 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
42- Background: "#1e1e2e", Foreground: "#cdd6f4",45+ Theme: "auto",
43 }}46 }}
44 }47 }
45 48
@@ -83,10 +86,16 @@ func (c *Config) Validate() error {
83 return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale)86 return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale)
84 }87 }
85 for _, col := range []string{b.Background, b.Foreground} {88 for _, col := range []string{b.Background, b.Foreground} {
89+ if col == "" {
90+ continue // taken from the theme
91+ }
86 if _, err := ParseColor(col); err != nil {92 if _, err := ParseColor(col); err != nil {
87 return err93 return err
88 }94 }
89 }95 }
96+ if b.Theme == "" {
97+ return fmt.Errorf("config: bar.theme is empty; use \"auto\" or the path of a theme file")
98+ }
90 seen := map[string]bool{}99 seen := map[string]bool{}
91 for i := range c.Module {100 for i := range c.Module {
92 m := &c.Module[i]101 m := &c.Module[i]
M internal/config/watch.go +32 -153
@@ -5,171 +5,50 @@ import (
55 "errors"
66 "log/slog"
77 "os"
8- "path/filepath"
98 "reflect"
10- "slices"
119 "time"
12- "unsafe"
1310
14- "golang.org/x/sys/unix"
11+ "git.bnema.dev/bnema/neferbar/internal/fswatch"
1512 )
1613
17-const (
18- debounce = 100 * time.Millisecond
19- // retryEvery is how often a missing directory is looked for again.
20- retryEvery = 2 * time.Second
21- // inotify mask: every way a file can be written, replaced or removed.
22- // IN_MODIFY restarts the debounce on each write, so a truncating in-place
23- // save is never read half written.
24- watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
25-)
26-
27-// watchedFiles lists the config path and every symlink target it goes through
28-// (dotfile managers), so retargeting a link is noticed.
29-func watchedFiles(path string) []string {
30- files := []string{filepath.Clean(path)}
31- for len(files) <= 40 { // the kernel's symlink limit
32- cur := files[len(files)-1]
33- target, err := os.Readlink(cur)
34- if err != nil {
35- break
36- }
37- if !filepath.IsAbs(target) {
38- target = filepath.Join(filepath.Dir(cur), target)
39- }
40- target = filepath.Clean(target)
41- if slices.Contains(files, target) {
42- break
43- }
44- files = append(files, target)
45- }
46- return files
47-}
14+// debounce waits for a save to finish before the file is read.
15+const debounce = 100 * time.Millisecond
4816
4917 // Watch reloads the file at path whenever it changes and sends each valid,
5018 // different Config to out. last is the Config the caller already runs, so a
5119 // save that changes nothing sends nothing. A broken file or a deleted file is
52-// logged and the caller keeps its current configuration.
53-//
54-// It watches the parent directory, so atomic replacements (editors that
55-// write a temp file and rename it) are detected. If the directory is missing
56-// it retries every two seconds. It blocks until ctx ends.
20+// logged and the caller keeps its current configuration. It blocks until ctx
21+// ends.
5722 func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error {
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-
64- names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
65- var files []string
66- var retry, pending time.Time
67- rewatch := true
68- buf := make([]byte, 4096)
69- fds := make([]unix.PollFd, 1)
70-
71- for ctx.Err() == nil {
72- if !retry.IsZero() && !time.Now().Before(retry) {
73- rewatch = true
74- }
75- if rewatch {
76- rewatch, retry = false, time.Time{}
77- for wd := range names {
78- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
79- }
80- clear(names)
81- files = watchedFiles(path)
82- for _, file := range files {
83- wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), watchMask)
84- if errors.Is(err, unix.ENOENT) {
85- retry = time.Now().Add(retryEvery)
86- continue
87- }
88- if err != nil {
89- return err
90- }
91- if names[int32(wd)] == nil {
92- names[int32(wd)] = map[string]bool{}
93- }
94- names[int32(wd)][filepath.Base(file)] = true
95- }
96- }
97-
98- timeout := 200 // ms: also the cadence at which ctx is checked
99- if !retry.IsZero() {
100- timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
101- }
102- if !pending.IsZero() {
103- remaining := time.Until(pending)
104- if remaining <= 0 {
105- pending = time.Time{}
106- next, err := Load(path, true)
107- switch {
108- case errors.Is(err, os.ErrNotExist):
109- log.Warn("config file removed; keeping the current config", "path", path)
110- case err != nil:
111- log.Warn("config reload failed; keeping the current config", "err", err)
112- case reflect.DeepEqual(next, last):
113- log.Debug("config unchanged")
114- default:
115- last = next
116- select {
117- case <-ctx.Done():
118- return nil
119- case out <- next:
120- }
121- }
122- continue
123- }
124- timeout = min(timeout, int(remaining.Milliseconds())+1)
125- }
126-
127- fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
128- if _, err = unix.Poll(fds, timeout); err != nil {
129- if err == unix.EINTR {
130- continue
131- }
23+ changed := make(chan struct{}, 1)
24+ wctx, cancel := context.WithCancel(ctx)
25+ defer cancel()
26+ errc := make(chan error, 1)
27+ go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }()
28+
29+ for {
30+ select {
31+ case <-ctx.Done():
32+ return nil
33+ case err := <-errc:
13234 return err
133- }
134- if fds[0].Revents&unix.POLLIN == 0 {
135- continue
136- }
137- for {
138- n, err := unix.Read(fd, buf)
139- if err == unix.EAGAIN || n == 0 {
140- break
141- }
142- if err == unix.EINTR {
143- continue
144- }
145- if err != nil {
146- return err
147- }
148- for off := 0; off+unix.SizeofInotifyEvent <= n; {
149- ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
150- size := unix.SizeofInotifyEvent + int(ev.Len)
151- if off+size > n {
152- break
153- }
154- // One of our watches vanished (its directory was removed).
155- if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
156- rewatch = true
157- }
158- if ev.Len > 0 {
159- name := buf[off+unix.SizeofInotifyEvent : off+size]
160- for len(name) > 0 && name[len(name)-1] == 0 {
161- name = name[:len(name)-1]
162- }
163- if names[ev.Wd][string(name)] && ev.Mask&watchMask != 0 {
164- pending = time.Now().Add(debounce)
165- if !slices.Equal(files, watchedFiles(path)) {
166- rewatch = true // a symlink now points elsewhere
167- }
168- }
169- }
170- off += size
35+ case <-changed:
36+ }
37+ next, err := Load(path, true)
38+ switch {
39+ case errors.Is(err, os.ErrNotExist):
40+ log.Warn("config file removed; keeping the current config", "path", path)
41+ case err != nil:
42+ log.Warn("config reload failed; keeping the current config", "err", err)
43+ case reflect.DeepEqual(next, last):
44+ log.Debug("config unchanged")
45+ default:
46+ last = next
47+ select {
48+ case <-ctx.Done():
49+ return nil
50+ case out <- next:
17151 }
17252 }
17353 }
174- return nil
17554 }
@@ -5,171 +5,50 @@ import (
5 "errors"5 "errors"
6 "log/slog"6 "log/slog"
7 "os"7 "os"
8- "path/filepath"
9 "reflect"8 "reflect"
10- "slices"
11 "time"9 "time"
12- "unsafe"
13 10
14- "golang.org/x/sys/unix"11+ "git.bnema.dev/bnema/neferbar/internal/fswatch"
15 )12 )
16 13
17-const (14+// debounce waits for a save to finish before the file is read.
18- debounce = 100 * time.Millisecond15+const debounce = 100 * time.Millisecond
19- // retryEvery is how often a missing directory is looked for again.
20- retryEvery = 2 * time.Second
21- // inotify mask: every way a file can be written, replaced or removed.
22- // IN_MODIFY restarts the debounce on each write, so a truncating in-place
23- // save is never read half written.
24- watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
25-)
26-
27-// watchedFiles lists the config path and every symlink target it goes through
28-// (dotfile managers), so retargeting a link is noticed.
29-func watchedFiles(path string) []string {
30- files := []string{filepath.Clean(path)}
31- for len(files) <= 40 { // the kernel's symlink limit
32- cur := files[len(files)-1]
33- target, err := os.Readlink(cur)
34- if err != nil {
35- break
36- }
37- if !filepath.IsAbs(target) {
38- target = filepath.Join(filepath.Dir(cur), target)
39- }
40- target = filepath.Clean(target)
41- if slices.Contains(files, target) {
42- break
43- }
44- files = append(files, target)
45- }
46- return files
47-}
48 16
49 // Watch reloads the file at path whenever it changes and sends each valid,17 // Watch reloads the file at path whenever it changes and sends each valid,
50 // different Config to out. last is the Config the caller already runs, so a18 // different Config to out. last is the Config the caller already runs, so a
51 // save that changes nothing sends nothing. A broken file or a deleted file is19 // save that changes nothing sends nothing. A broken file or a deleted file is
52-// logged and the caller keeps its current configuration.20+// logged and the caller keeps its current configuration. It blocks until ctx
53-//21+// ends.
54-// It watches the parent directory, so atomic replacements (editors that
55-// write a temp file and rename it) are detected. If the directory is missing
56-// it retries every two seconds. It blocks until ctx ends.
57 func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error {22 func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error {
58- fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)23+ changed := make(chan struct{}, 1)
59- if err != nil {24+ wctx, cancel := context.WithCancel(ctx)
60- return err25+ defer cancel()
61- }26+ errc := make(chan error, 1)
62- defer unix.Close(fd)27+ go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }()
63-28+
64- names := map[int32]map[string]bool{} // watch -> file names that matter in its directory29+ for {
65- var files []string30+ select {
66- var retry, pending time.Time31+ case <-ctx.Done():
67- rewatch := true32+ return nil
68- buf := make([]byte, 4096)33+ case err := <-errc:
69- fds := make([]unix.PollFd, 1)
70-
71- for ctx.Err() == nil {
72- if !retry.IsZero() && !time.Now().Before(retry) {
73- rewatch = true
74- }
75- if rewatch {
76- rewatch, retry = false, time.Time{}
77- for wd := range names {
78- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
79- }
80- clear(names)
81- files = watchedFiles(path)
82- for _, file := range files {
83- wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), watchMask)
84- if errors.Is(err, unix.ENOENT) {
85- retry = time.Now().Add(retryEvery)
86- continue
87- }
88- if err != nil {
89- return err
90- }
91- if names[int32(wd)] == nil {
92- names[int32(wd)] = map[string]bool{}
93- }
94- names[int32(wd)][filepath.Base(file)] = true
95- }
96- }
97-
98- timeout := 200 // ms: also the cadence at which ctx is checked
99- if !retry.IsZero() {
100- timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
101- }
102- if !pending.IsZero() {
103- remaining := time.Until(pending)
104- if remaining <= 0 {
105- pending = time.Time{}
106- next, err := Load(path, true)
107- switch {
108- case errors.Is(err, os.ErrNotExist):
109- log.Warn("config file removed; keeping the current config", "path", path)
110- case err != nil:
111- log.Warn("config reload failed; keeping the current config", "err", err)
112- case reflect.DeepEqual(next, last):
113- log.Debug("config unchanged")
114- default:
115- last = next
116- select {
117- case <-ctx.Done():
118- return nil
119- case out <- next:
120- }
121- }
122- continue
123- }
124- timeout = min(timeout, int(remaining.Milliseconds())+1)
125- }
126-
127- fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
128- if _, err = unix.Poll(fds, timeout); err != nil {
129- if err == unix.EINTR {
130- continue
131- }
132 return err34 return err
133- }35+ case <-changed:
134- if fds[0].Revents&unix.POLLIN == 0 {36+ }
135- continue37+ next, err := Load(path, true)
136- }38+ switch {
137- for {39+ case errors.Is(err, os.ErrNotExist):
138- n, err := unix.Read(fd, buf)40+ log.Warn("config file removed; keeping the current config", "path", path)
139- if err == unix.EAGAIN || n == 0 {41+ case err != nil:
140- break42+ log.Warn("config reload failed; keeping the current config", "err", err)
141- }43+ case reflect.DeepEqual(next, last):
142- if err == unix.EINTR {44+ log.Debug("config unchanged")
143- continue45+ default:
144- }46+ last = next
145- if err != nil {47+ select {
146- return err48+ case <-ctx.Done():
147- }49+ return nil
148- for off := 0; off+unix.SizeofInotifyEvent <= n; {50+ case out <- next:
149- ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
150- size := unix.SizeofInotifyEvent + int(ev.Len)
151- if off+size > n {
152- break
153- }
154- // One of our watches vanished (its directory was removed).
155- if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
156- rewatch = true
157- }
158- if ev.Len > 0 {
159- name := buf[off+unix.SizeofInotifyEvent : off+size]
160- for len(name) > 0 && name[len(name)-1] == 0 {
161- name = name[:len(name)-1]
162- }
163- if names[ev.Wd][string(name)] && ev.Mask&watchMask != 0 {
164- pending = time.Now().Add(debounce)
165- if !slices.Equal(files, watchedFiles(path)) {
166- rewatch = true // a symlink now points elsewhere
167- }
168- }
169- }
170- off += size
171 }51 }
172 }52 }
173 }53 }
174- return nil
175 }54 }
A internal/fswatch/fswatch.go +162 -0
@@ -0,0 +1,162 @@
1+// Package fswatch reports changes to one file with inotify.
2+//
3+// It watches the parent directory, so a file that is replaced by a rename (the
4+// way editors and NeferWL write) is noticed, and it follows symlinks, so a
5+// dotfile-managed file is noticed at its target.
6+package fswatch
7+
8+import (
9+ "context"
10+ "errors"
11+ "os"
12+ "path/filepath"
13+ "slices"
14+ "time"
15+ "unsafe"
16+
17+ "golang.org/x/sys/unix"
18+)
19+
20+const (
21+ // retryEvery is how often a missing directory is looked for again.
22+ retryEvery = 2 * time.Second
23+ // pollEvery bounds how long a wait lasts, so ctx is noticed.
24+ pollEvery = 200 // ms
25+ // mask is every way a file can be written, replaced or removed. IN_MODIFY
26+ // restarts the debounce on each write, so a truncating in-place save is
27+ // never read half written.
28+ mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
29+)
30+
31+// files lists path and every symlink target it goes through.
32+func files(path string) []string {
33+ out := []string{filepath.Clean(path)}
34+ for len(out) <= 40 { // the kernel's symlink limit
35+ cur := out[len(out)-1]
36+ target, err := os.Readlink(cur)
37+ if err != nil {
38+ break
39+ }
40+ if !filepath.IsAbs(target) {
41+ target = filepath.Join(filepath.Dir(cur), target)
42+ }
43+ target = filepath.Clean(target)
44+ if slices.Contains(out, target) {
45+ break
46+ }
47+ out = append(out, target)
48+ }
49+ return out
50+}
51+
52+// Watch sends on out after path changed and stayed quiet for debounce. A
53+// debounce of 0 reports each change at once. The send never blocks: if out is
54+// full, the pending signal already says "something changed", which is all a
55+// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
56+func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
57+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
58+ if err != nil {
59+ return err
60+ }
61+ defer unix.Close(fd)
62+
63+ names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
64+ var watched []string
65+ var retry, pending time.Time
66+ rewatch := true
67+ buf := make([]byte, 4096)
68+ fds := make([]unix.PollFd, 1)
69+
70+ for ctx.Err() == nil {
71+ if !retry.IsZero() && !time.Now().Before(retry) {
72+ rewatch = true
73+ }
74+ if rewatch {
75+ rewatch, retry = false, time.Time{}
76+ for wd := range names {
77+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
78+ }
79+ clear(names)
80+ watched = files(path)
81+ for _, file := range watched {
82+ wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83+ if errors.Is(err, unix.ENOENT) {
84+ retry = time.Now().Add(retryEvery)
85+ continue
86+ }
87+ if err != nil {
88+ return err
89+ }
90+ if names[int32(wd)] == nil {
91+ names[int32(wd)] = map[string]bool{}
92+ }
93+ names[int32(wd)][filepath.Base(file)] = true
94+ }
95+ }
96+
97+ timeout := pollEvery
98+ if !retry.IsZero() {
99+ timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
100+ }
101+ if !pending.IsZero() {
102+ remaining := time.Until(pending)
103+ if remaining <= 0 {
104+ pending = time.Time{}
105+ select {
106+ case out <- struct{}{}:
107+ default:
108+ }
109+ continue
110+ }
111+ timeout = min(timeout, int(remaining.Milliseconds())+1)
112+ }
113+
114+ fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
115+ if _, err = unix.Poll(fds, timeout); err != nil {
116+ if err == unix.EINTR {
117+ continue
118+ }
119+ return err
120+ }
121+ if fds[0].Revents&unix.POLLIN == 0 {
122+ continue
123+ }
124+ for {
125+ n, err := unix.Read(fd, buf)
126+ if err == unix.EAGAIN || n == 0 {
127+ break
128+ }
129+ if err == unix.EINTR {
130+ continue
131+ }
132+ if err != nil {
133+ return err
134+ }
135+ for off := 0; off+unix.SizeofInotifyEvent <= n; {
136+ ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
137+ size := unix.SizeofInotifyEvent + int(ev.Len)
138+ if off+size > n {
139+ break
140+ }
141+ // One of our watches vanished (its directory was removed).
142+ if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
143+ rewatch = true
144+ }
145+ if ev.Len > 0 {
146+ name := buf[off+unix.SizeofInotifyEvent : off+size]
147+ for len(name) > 0 && name[len(name)-1] == 0 {
148+ name = name[:len(name)-1]
149+ }
150+ if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {
151+ pending = time.Now().Add(debounce)
152+ if !slices.Equal(watched, files(path)) {
153+ rewatch = true // a symlink now points elsewhere
154+ }
155+ }
156+ }
157+ off += size
158+ }
159+ }
160+ }
161+ return nil
162+}
@@ -0,0 +1,162 @@
1+// Package fswatch reports changes to one file with inotify.
2+//
3+// It watches the parent directory, so a file that is replaced by a rename (the
4+// way editors and NeferWL write) is noticed, and it follows symlinks, so a
5+// dotfile-managed file is noticed at its target.
6+package fswatch
7+
8+import (
9+ "context"
10+ "errors"
11+ "os"
12+ "path/filepath"
13+ "slices"
14+ "time"
15+ "unsafe"
16+
17+ "golang.org/x/sys/unix"
18+)
19+
20+const (
21+ // retryEvery is how often a missing directory is looked for again.
22+ retryEvery = 2 * time.Second
23+ // pollEvery bounds how long a wait lasts, so ctx is noticed.
24+ pollEvery = 200 // ms
25+ // mask is every way a file can be written, replaced or removed. IN_MODIFY
26+ // restarts the debounce on each write, so a truncating in-place save is
27+ // never read half written.
28+ mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
29+)
30+
31+// files lists path and every symlink target it goes through.
32+func files(path string) []string {
33+ out := []string{filepath.Clean(path)}
34+ for len(out) <= 40 { // the kernel's symlink limit
35+ cur := out[len(out)-1]
36+ target, err := os.Readlink(cur)
37+ if err != nil {
38+ break
39+ }
40+ if !filepath.IsAbs(target) {
41+ target = filepath.Join(filepath.Dir(cur), target)
42+ }
43+ target = filepath.Clean(target)
44+ if slices.Contains(out, target) {
45+ break
46+ }
47+ out = append(out, target)
48+ }
49+ return out
50+}
51+
52+// Watch sends on out after path changed and stayed quiet for debounce. A
53+// debounce of 0 reports each change at once. The send never blocks: if out is
54+// full, the pending signal already says "something changed", which is all a
55+// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
56+func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
57+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
58+ if err != nil {
59+ return err
60+ }
61+ defer unix.Close(fd)
62+
63+ names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
64+ var watched []string
65+ var retry, pending time.Time
66+ rewatch := true
67+ buf := make([]byte, 4096)
68+ fds := make([]unix.PollFd, 1)
69+
70+ for ctx.Err() == nil {
71+ if !retry.IsZero() && !time.Now().Before(retry) {
72+ rewatch = true
73+ }
74+ if rewatch {
75+ rewatch, retry = false, time.Time{}
76+ for wd := range names {
77+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
78+ }
79+ clear(names)
80+ watched = files(path)
81+ for _, file := range watched {
82+ wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83+ if errors.Is(err, unix.ENOENT) {
84+ retry = time.Now().Add(retryEvery)
85+ continue
86+ }
87+ if err != nil {
88+ return err
89+ }
90+ if names[int32(wd)] == nil {
91+ names[int32(wd)] = map[string]bool{}
92+ }
93+ names[int32(wd)][filepath.Base(file)] = true
94+ }
95+ }
96+
97+ timeout := pollEvery
98+ if !retry.IsZero() {
99+ timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
100+ }
101+ if !pending.IsZero() {
102+ remaining := time.Until(pending)
103+ if remaining <= 0 {
104+ pending = time.Time{}
105+ select {
106+ case out <- struct{}{}:
107+ default:
108+ }
109+ continue
110+ }
111+ timeout = min(timeout, int(remaining.Milliseconds())+1)
112+ }
113+
114+ fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
115+ if _, err = unix.Poll(fds, timeout); err != nil {
116+ if err == unix.EINTR {
117+ continue
118+ }
119+ return err
120+ }
121+ if fds[0].Revents&unix.POLLIN == 0 {
122+ continue
123+ }
124+ for {
125+ n, err := unix.Read(fd, buf)
126+ if err == unix.EAGAIN || n == 0 {
127+ break
128+ }
129+ if err == unix.EINTR {
130+ continue
131+ }
132+ if err != nil {
133+ return err
134+ }
135+ for off := 0; off+unix.SizeofInotifyEvent <= n; {
136+ ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
137+ size := unix.SizeofInotifyEvent + int(ev.Len)
138+ if off+size > n {
139+ break
140+ }
141+ // One of our watches vanished (its directory was removed).
142+ if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
143+ rewatch = true
144+ }
145+ if ev.Len > 0 {
146+ name := buf[off+unix.SizeofInotifyEvent : off+size]
147+ for len(name) > 0 && name[len(name)-1] == 0 {
148+ name = name[:len(name)-1]
149+ }
150+ if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {
151+ pending = time.Now().Add(debounce)
152+ if !slices.Equal(watched, files(path)) {
153+ rewatch = true // a symlink now points elsewhere
154+ }
155+ }
156+ }
157+ off += size
158+ }
159+ }
160+ }
161+ return nil
162+}

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 5

Tags 4

↑↓ moveenter openesc close