detached 41ad4a0

feat(config): reload the config file live, restarting only the modules that changed

commit
41ad4a0f26d3817d7368319e56448b9f85f7b4fa
author
brice · · 2026-10-05 14:53 UTC
parent
c4a6bed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +469 -22
M README.md +9 -0
@@ -38,6 +38,15 @@ zone = "center" # left | center | right
3838 exec = "clock.sh"
3939 ```
4040
41+## Live reload
42+
43+The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays.
44+
45+- Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes.
46+- Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started.
47+- `bar.output` needs a restart, and the bar says so.
48+- A module script that you edit on disk is picked up when the script restarts, not before.
49+
4150 ## Modules
4251
4352 A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame.
@@ -38,6 +38,15 @@ zone = "center" # left | center | right
38 exec = "clock.sh"38 exec = "clock.sh"
39 ```39 ```
40 40
41+## Live reload
42+
43+The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays.
44+
45+- Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes.
46+- Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started.
47+- `bar.output` needs a restart, and the bar says so.
48+- A module script that you edit on disk is picked up when the script restarts, not before.
49+
41 ## Modules50 ## Modules
42 51
43 A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame.52 A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame.
M cmd/neferbar/main.go +1 -0
@@ -53,6 +53,7 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri
5353 if err != nil {
5454 return err
5555 }
56+ b.WatchConfig(cfgPath)
5657 if memStats > 0 {
5758 go logMemStats(ctx, log, memStats)
5859 }
@@ -53,6 +53,7 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri
53 if err != nil {53 if err != nil {
54 return err54 return err
55 }55 }
56+ b.WatchConfig(cfgPath)
56 if memStats > 0 {57 if memStats > 0 {
57 go logMemStats(ctx, log, memStats)58 go logMemStats(ctx, log, memStats)
58 }59 }
M internal/bar/bar.go +115 -13
@@ -70,18 +70,22 @@ type Bar struct {
7070 frame gpu.Frame
7171 watched [gpu.SlotCount]int // watched eventfds, -1 when none
7272
73- mods []*module.Module
74- lay *layout.Layout
75- modCh chan struct{}
76- Stats Stats
77- fatal error
78- closed bool
73+ runners []runner
74+ mods []*module.Module // the modules of runners, in config order
75+ runCtx context.Context
76+ lay *layout.Layout
77+ modCh chan struct{}
78+ cfgPath string
79+ cfgCh chan config.Config
80+ Stats Stats
81+ fatal error
82+ closed bool
7983 }
8084
8185 // New builds a bar from a validated config.
8286 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.
8387 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
84- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1)}
88+ b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)}
8589 var err error
8690 if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {
8791 return nil, err
@@ -98,13 +102,103 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
98102 for i := range b.watched {
99103 b.watched[i] = -1
100104 }
101- zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
102- for _, m := range cfg.Module {
103- b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log))
104- }
105105 return b, nil
106106 }
107107
108+// WatchConfig makes Run reload the config file at path when it changes. Call
109+// it before Run.
110+func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
111+
112+// runner is one started module and how to stop it.
113+type runner struct {
114+ cfg config.Module
115+ m *module.Module
116+ cancel context.CancelFunc
117+}
118+
119+var zones = map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
120+
121+// syncModules makes the running modules match want. A module with the same
122+// name, zone and command keeps running; every other one is stopped, and a new
123+// or changed one is started.
124+func (b *Bar) syncModules(want []config.Module) {
125+ old := make(map[string]runner, len(b.runners))
126+ for _, r := range b.runners {
127+ old[r.cfg.Name] = r
128+ }
129+ next := make([]runner, 0, len(want))
130+ for _, w := range want {
131+ if r, ok := old[w.Name]; ok {
132+ delete(old, w.Name)
133+ if r.cfg == w {
134+ next = append(next, r)
135+ continue
136+ }
137+ r.cancel()
138+ }
139+ ctx, cancel := context.WithCancel(b.runCtx)
140+ m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
141+ go m.Run(ctx)
142+ next = append(next, runner{cfg: w, m: m, cancel: cancel})
143+ }
144+ for _, r := range old {
145+ r.cancel()
146+ }
147+ b.runners = next
148+ b.mods = b.mods[:0]
149+ for _, r := range next {
150+ b.mods = append(b.mods, r.m)
151+ }
152+ if b.lay != nil {
153+ b.lay.SetModules(b.mods)
154+ }
155+}
156+
157+// applyConfig switches to next. Nothing changes if it cannot be applied.
158+func (b *Bar) applyConfig(next config.Config) {
159+ old := b.cfg
160+ fg, err := config.ParseColor(next.Bar.Foreground)
161+ if err != nil {
162+ b.log.Warn("config not applied", "err", err)
163+ return
164+ }
165+ bg, err := config.ParseColor(next.Bar.Background)
166+ if err != nil {
167+ b.log.Warn("config not applied", "err", err)
168+ return
169+ }
170+ fontChanged := next.Bar.Font != old.Bar.Font
171+ reg, bold := b.regularPath, b.boldPath
172+ if fontChanged {
173+ if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil {
174+ b.log.Warn("config not applied", "err", err)
175+ return
176+ }
177+ if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil {
178+ bold = reg
179+ }
180+ }
181+ if next.Bar.Output != old.Bar.Output {
182+ b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
183+ next.Bar.Output = old.Bar.Output
184+ }
185+ b.cfg = next
186+ b.regularPath, b.boldPath = reg, bold
187+ b.syncModules(next.Module)
188+ if fg != b.fg || bg != b.bg {
189+ b.fg, b.bg = fg, bg
190+ if b.lay != nil {
191+ b.lay.SetColors(fg, bg)
192+ }
193+ }
194+ b.dirty = true
195+ if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
196+ b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
197+ b.reconcile()
198+ }
199+ b.log.Info("config reloaded")
200+}
201+
108202 // Run connects to the compositor and serves until ctx ends or the surface is
109203 // closed. It returns the first fatal error.
110204 func (b *Bar) Run(ctx context.Context) (err error) {
@@ -122,8 +216,14 @@ func (b *Bar) Run(ctx context.Context) (err error) {
122216 }
123217 runCtx, cancel := context.WithCancel(ctx)
124218 defer cancel()
125- for _, m := range b.mods {
126- go m.Run(runCtx)
219+ b.runCtx = runCtx
220+ b.syncModules(b.cfg.Module)
221+ if b.cfgPath != "" {
222+ go func() {
223+ if err := config.Watch(runCtx, b.cfgPath, b.cfg, b.cfgCh, b.log); err != nil {
224+ b.log.Warn("config reload is off", "err", err)
225+ }
226+ }()
127227 }
128228 for !b.closed {
129229 select {
@@ -135,6 +235,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
135235 }
136236 case <-b.modCh:
137237 b.Stats.ModuleWake++
238+ case next := <-b.cfgCh:
239+ b.applyConfig(next)
138240 }
139241 if b.fatal != nil {
140242 return b.fatal
@@ -70,18 +70,22 @@ type Bar struct {
70 frame gpu.Frame70 frame gpu.Frame
71 watched [gpu.SlotCount]int // watched eventfds, -1 when none71 watched [gpu.SlotCount]int // watched eventfds, -1 when none
72 72
73- mods []*module.Module73+ runners []runner
74- lay *layout.Layout74+ mods []*module.Module // the modules of runners, in config order
75- modCh chan struct{}75+ runCtx context.Context
76- Stats Stats76+ lay *layout.Layout
77- fatal error77+ modCh chan struct{}
78- closed bool78+ cfgPath string
79+ cfgCh chan config.Config
80+ Stats Stats
81+ fatal error
82+ closed bool
79 }83 }
80 84
81 // New builds a bar from a validated config.85 // New builds a bar from a validated config.
82 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.86 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.
83 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {87 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
84- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1)}88+ b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)}
85 var err error89 var err error
86 if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {90 if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {
87 return nil, err91 return nil, err
@@ -98,13 +102,103 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
98 for i := range b.watched {102 for i := range b.watched {
99 b.watched[i] = -1103 b.watched[i] = -1
100 }104 }
101- zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
102- for _, m := range cfg.Module {
103- b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log))
104- }
105 return b, nil105 return b, nil
106 }106 }
107 107
108+// WatchConfig makes Run reload the config file at path when it changes. Call
109+// it before Run.
110+func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
111+
112+// runner is one started module and how to stop it.
113+type runner struct {
114+ cfg config.Module
115+ m *module.Module
116+ cancel context.CancelFunc
117+}
118+
119+var zones = map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
120+
121+// syncModules makes the running modules match want. A module with the same
122+// name, zone and command keeps running; every other one is stopped, and a new
123+// or changed one is started.
124+func (b *Bar) syncModules(want []config.Module) {
125+ old := make(map[string]runner, len(b.runners))
126+ for _, r := range b.runners {
127+ old[r.cfg.Name] = r
128+ }
129+ next := make([]runner, 0, len(want))
130+ for _, w := range want {
131+ if r, ok := old[w.Name]; ok {
132+ delete(old, w.Name)
133+ if r.cfg == w {
134+ next = append(next, r)
135+ continue
136+ }
137+ r.cancel()
138+ }
139+ ctx, cancel := context.WithCancel(b.runCtx)
140+ m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
141+ go m.Run(ctx)
142+ next = append(next, runner{cfg: w, m: m, cancel: cancel})
143+ }
144+ for _, r := range old {
145+ r.cancel()
146+ }
147+ b.runners = next
148+ b.mods = b.mods[:0]
149+ for _, r := range next {
150+ b.mods = append(b.mods, r.m)
151+ }
152+ if b.lay != nil {
153+ b.lay.SetModules(b.mods)
154+ }
155+}
156+
157+// applyConfig switches to next. Nothing changes if it cannot be applied.
158+func (b *Bar) applyConfig(next config.Config) {
159+ old := b.cfg
160+ fg, err := config.ParseColor(next.Bar.Foreground)
161+ if err != nil {
162+ b.log.Warn("config not applied", "err", err)
163+ return
164+ }
165+ bg, err := config.ParseColor(next.Bar.Background)
166+ if err != nil {
167+ b.log.Warn("config not applied", "err", err)
168+ return
169+ }
170+ fontChanged := next.Bar.Font != old.Bar.Font
171+ reg, bold := b.regularPath, b.boldPath
172+ if fontChanged {
173+ if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil {
174+ b.log.Warn("config not applied", "err", err)
175+ return
176+ }
177+ if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil {
178+ bold = reg
179+ }
180+ }
181+ if next.Bar.Output != old.Bar.Output {
182+ b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
183+ next.Bar.Output = old.Bar.Output
184+ }
185+ b.cfg = next
186+ b.regularPath, b.boldPath = reg, bold
187+ b.syncModules(next.Module)
188+ if fg != b.fg || bg != b.bg {
189+ b.fg, b.bg = fg, bg
190+ if b.lay != nil {
191+ b.lay.SetColors(fg, bg)
192+ }
193+ }
194+ b.dirty = true
195+ if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
196+ b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
197+ b.reconcile()
198+ }
199+ b.log.Info("config reloaded")
200+}
201+
108 // Run connects to the compositor and serves until ctx ends or the surface is202 // Run connects to the compositor and serves until ctx ends or the surface is
109 // closed. It returns the first fatal error.203 // closed. It returns the first fatal error.
110 func (b *Bar) Run(ctx context.Context) (err error) {204 func (b *Bar) Run(ctx context.Context) (err error) {
@@ -122,8 +216,14 @@ func (b *Bar) Run(ctx context.Context) (err error) {
122 }216 }
123 runCtx, cancel := context.WithCancel(ctx)217 runCtx, cancel := context.WithCancel(ctx)
124 defer cancel()218 defer cancel()
125- for _, m := range b.mods {219+ b.runCtx = runCtx
126- go m.Run(runCtx)220+ b.syncModules(b.cfg.Module)
221+ if b.cfgPath != "" {
222+ go func() {
223+ if err := config.Watch(runCtx, b.cfgPath, b.cfg, b.cfgCh, b.log); err != nil {
224+ b.log.Warn("config reload is off", "err", err)
225+ }
226+ }()
127 }227 }
128 for !b.closed {228 for !b.closed {
129 select {229 select {
@@ -135,6 +235,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
135 }235 }
136 case <-b.modCh:236 case <-b.modCh:
137 b.Stats.ModuleWake++237 b.Stats.ModuleWake++
238+ case next := <-b.cfgCh:
239+ b.applyConfig(next)
138 }240 }
139 if b.fatal != nil {241 if b.fatal != nil {
140 return b.fatal242 return b.fatal
M internal/config/config.go +5 -0
@@ -87,6 +87,7 @@ func (c *Config) Validate() error {
8787 return err
8888 }
8989 }
90+ seen := map[string]bool{}
9091 for i := range c.Module {
9192 m := &c.Module[i]
9293 if m.Exec == "" {
@@ -95,6 +96,10 @@ func (c *Config) Validate() error {
9596 if m.Name == "" {
9697 m.Name = fmt.Sprintf("module%d", i+1)
9798 }
99+ if seen[m.Name] {
100+ return fmt.Errorf("config: two modules are named %q", m.Name)
101+ }
102+ seen[m.Name] = true
98103 switch m.Zone {
99104 case "left", "center", "right":
100105 default:
@@ -87,6 +87,7 @@ func (c *Config) Validate() error {
87 return err87 return err
88 }88 }
89 }89 }
90+ seen := map[string]bool{}
90 for i := range c.Module {91 for i := range c.Module {
91 m := &c.Module[i]92 m := &c.Module[i]
92 if m.Exec == "" {93 if m.Exec == "" {
@@ -95,6 +96,10 @@ func (c *Config) Validate() error {
95 if m.Name == "" {96 if m.Name == "" {
96 m.Name = fmt.Sprintf("module%d", i+1)97 m.Name = fmt.Sprintf("module%d", i+1)
97 }98 }
99+ if seen[m.Name] {
100+ return fmt.Errorf("config: two modules are named %q", m.Name)
101+ }
102+ seen[m.Name] = true
98 switch m.Zone {103 switch m.Zone {
99 case "left", "center", "right":104 case "left", "center", "right":
100 default:105 default:
A internal/config/watch.go +175 -0
@@ -0,0 +1,175 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "os"
8+ "path/filepath"
9+ "reflect"
10+ "slices"
11+ "time"
12+ "unsafe"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
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+}
48+
49+// 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 a
51+// 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.
57+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+ }
132+ 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
171+ }
172+ }
173+ }
174+ return nil
175+}
@@ -0,0 +1,175 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "os"
8+ "path/filepath"
9+ "reflect"
10+ "slices"
11+ "time"
12+ "unsafe"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
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+}
48+
49+// 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 a
51+// 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.
57+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+ }
132+ 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
171+ }
172+ }
173+ }
174+ return nil
175+}
A internal/config/watch_test.go +123 -0
@@ -0,0 +1,123 @@
1+package config
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+func startWatch(t *testing.T, path string, last Config) <-chan Config {
13+ t.Helper()
14+ out := make(chan Config, 4)
15+ ctx, cancel := context.WithCancel(context.Background())
16+ done := make(chan struct{})
17+ go func() {
18+ defer close(done)
19+ if err := Watch(ctx, path, last, out, slog.New(slog.DiscardHandler)); err != nil {
20+ t.Error(err)
21+ }
22+ }()
23+ t.Cleanup(func() { cancel(); <-done })
24+ time.Sleep(150 * time.Millisecond) // let the watch attach
25+ return out
26+}
27+
28+func recv(t *testing.T, ch <-chan Config) Config {
29+ t.Helper()
30+ select {
31+ case c := <-ch:
32+ return c
33+ case <-time.After(3 * time.Second):
34+ t.Fatal("no config reload")
35+ return Config{}
36+ }
37+}
38+
39+func none(t *testing.T, ch <-chan Config) {
40+ t.Helper()
41+ select {
42+ case c := <-ch:
43+ t.Fatalf("unexpected reload: %+v", c.Bar)
44+ case <-time.After(600 * time.Millisecond):
45+ }
46+}
47+
48+func TestWatchInPlaceAndAtomicSaves(t *testing.T) {
49+ path := write(t, "[bar]\nsize = 14\n")
50+ first, err := Load(path, true)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ ch := startWatch(t, path, first)
55+
56+ if err := os.WriteFile(path, []byte("[bar]\nsize = 18\n"), 0o600); err != nil {
57+ t.Fatal(err)
58+ }
59+ if got := recv(t, ch).Bar.Size; got != 18 {
60+ t.Fatalf("in-place save: size = %v", got)
61+ }
62+
63+ tmp := path + ".tmp" // what most editors do: write a sibling, rename over
64+ if err := os.WriteFile(tmp, []byte("[bar]\nsize = 20\n"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := os.Rename(tmp, path); err != nil {
68+ t.Fatal(err)
69+ }
70+ if got := recv(t, ch).Bar.Size; got != 20 {
71+ t.Fatalf("atomic save: size = %v", got)
72+ }
73+}
74+
75+func TestWatchKeepsOldConfigOnErrors(t *testing.T) {
76+ path := write(t, "[bar]\nsize = 14\n")
77+ first, _ := Load(path, true)
78+ ch := startWatch(t, path, first)
79+
80+ os.WriteFile(path, []byte("[bar\n"), 0o600) // syntax error
81+ none(t, ch)
82+ os.WriteFile(path, []byte("[bar]\nsize = 1\n"), 0o600) // out of range
83+ none(t, ch)
84+ os.Remove(path)
85+ none(t, ch)
86+
87+ os.WriteFile(path, []byte("[bar]\nsize = 16\n"), 0o600) // recovers
88+ if got := recv(t, ch).Bar.Size; got != 16 {
89+ t.Fatalf("after recovery: size = %v", got)
90+ }
91+}
92+
93+func TestWatchIgnoresUnchangedSave(t *testing.T) {
94+ path := write(t, "[bar]\nsize = 14\n")
95+ first, _ := Load(path, true)
96+ ch := startWatch(t, path, first)
97+ os.WriteFile(path, []byte("# a comment\n[bar]\nsize = 14\n"), 0o600)
98+ none(t, ch)
99+}
100+
101+func TestWatchFollowsSymlink(t *testing.T) {
102+ dir := t.TempDir()
103+ real := filepath.Join(dir, "dotfiles", "config.toml")
104+ os.MkdirAll(filepath.Dir(real), 0o700)
105+ os.WriteFile(real, []byte("[bar]\nsize = 14\n"), 0o600)
106+ link := filepath.Join(dir, "config.toml")
107+ if err := os.Symlink(real, link); err != nil {
108+ t.Fatal(err)
109+ }
110+ first, _ := Load(link, true)
111+ ch := startWatch(t, link, first)
112+ os.WriteFile(real, []byte("[bar]\nsize = 22\n"), 0o600)
113+ if got := recv(t, ch).Bar.Size; got != 22 {
114+ t.Fatalf("symlink target edit: size = %v", got)
115+ }
116+}
117+
118+func TestDuplicateModuleNames(t *testing.T) {
119+ _, err := Load(write(t, "[[module]]\nname=\"a\"\nzone=\"left\"\nexec=\"x\"\n[[module]]\nname=\"a\"\nzone=\"right\"\nexec=\"y\"\n"), true)
120+ if err == nil {
121+ t.Fatal("duplicate module names must fail")
122+ }
123+}
@@ -0,0 +1,123 @@
1+package config
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+func startWatch(t *testing.T, path string, last Config) <-chan Config {
13+ t.Helper()
14+ out := make(chan Config, 4)
15+ ctx, cancel := context.WithCancel(context.Background())
16+ done := make(chan struct{})
17+ go func() {
18+ defer close(done)
19+ if err := Watch(ctx, path, last, out, slog.New(slog.DiscardHandler)); err != nil {
20+ t.Error(err)
21+ }
22+ }()
23+ t.Cleanup(func() { cancel(); <-done })
24+ time.Sleep(150 * time.Millisecond) // let the watch attach
25+ return out
26+}
27+
28+func recv(t *testing.T, ch <-chan Config) Config {
29+ t.Helper()
30+ select {
31+ case c := <-ch:
32+ return c
33+ case <-time.After(3 * time.Second):
34+ t.Fatal("no config reload")
35+ return Config{}
36+ }
37+}
38+
39+func none(t *testing.T, ch <-chan Config) {
40+ t.Helper()
41+ select {
42+ case c := <-ch:
43+ t.Fatalf("unexpected reload: %+v", c.Bar)
44+ case <-time.After(600 * time.Millisecond):
45+ }
46+}
47+
48+func TestWatchInPlaceAndAtomicSaves(t *testing.T) {
49+ path := write(t, "[bar]\nsize = 14\n")
50+ first, err := Load(path, true)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ ch := startWatch(t, path, first)
55+
56+ if err := os.WriteFile(path, []byte("[bar]\nsize = 18\n"), 0o600); err != nil {
57+ t.Fatal(err)
58+ }
59+ if got := recv(t, ch).Bar.Size; got != 18 {
60+ t.Fatalf("in-place save: size = %v", got)
61+ }
62+
63+ tmp := path + ".tmp" // what most editors do: write a sibling, rename over
64+ if err := os.WriteFile(tmp, []byte("[bar]\nsize = 20\n"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := os.Rename(tmp, path); err != nil {
68+ t.Fatal(err)
69+ }
70+ if got := recv(t, ch).Bar.Size; got != 20 {
71+ t.Fatalf("atomic save: size = %v", got)
72+ }
73+}
74+
75+func TestWatchKeepsOldConfigOnErrors(t *testing.T) {
76+ path := write(t, "[bar]\nsize = 14\n")
77+ first, _ := Load(path, true)
78+ ch := startWatch(t, path, first)
79+
80+ os.WriteFile(path, []byte("[bar\n"), 0o600) // syntax error
81+ none(t, ch)
82+ os.WriteFile(path, []byte("[bar]\nsize = 1\n"), 0o600) // out of range
83+ none(t, ch)
84+ os.Remove(path)
85+ none(t, ch)
86+
87+ os.WriteFile(path, []byte("[bar]\nsize = 16\n"), 0o600) // recovers
88+ if got := recv(t, ch).Bar.Size; got != 16 {
89+ t.Fatalf("after recovery: size = %v", got)
90+ }
91+}
92+
93+func TestWatchIgnoresUnchangedSave(t *testing.T) {
94+ path := write(t, "[bar]\nsize = 14\n")
95+ first, _ := Load(path, true)
96+ ch := startWatch(t, path, first)
97+ os.WriteFile(path, []byte("# a comment\n[bar]\nsize = 14\n"), 0o600)
98+ none(t, ch)
99+}
100+
101+func TestWatchFollowsSymlink(t *testing.T) {
102+ dir := t.TempDir()
103+ real := filepath.Join(dir, "dotfiles", "config.toml")
104+ os.MkdirAll(filepath.Dir(real), 0o700)
105+ os.WriteFile(real, []byte("[bar]\nsize = 14\n"), 0o600)
106+ link := filepath.Join(dir, "config.toml")
107+ if err := os.Symlink(real, link); err != nil {
108+ t.Fatal(err)
109+ }
110+ first, _ := Load(link, true)
111+ ch := startWatch(t, link, first)
112+ os.WriteFile(real, []byte("[bar]\nsize = 22\n"), 0o600)
113+ if got := recv(t, ch).Bar.Size; got != 22 {
114+ t.Fatalf("symlink target edit: size = %v", got)
115+ }
116+}
117+
118+func TestDuplicateModuleNames(t *testing.T) {
119+ _, err := Load(write(t, "[[module]]\nname=\"a\"\nzone=\"left\"\nexec=\"x\"\n[[module]]\nname=\"a\"\nzone=\"right\"\nexec=\"y\"\n"), true)
120+ if err == nil {
121+ t.Fatal("duplicate module names must fail")
122+ }
123+}
M internal/layout/layout.go +41 -9
@@ -30,18 +30,50 @@ type Layout struct {
3030 // New builds a layout for a row of cols cells.
3131 func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {
3232 l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}
33+ l.SetModules(mods)
34+ return l
35+}
36+
37+func newSource(m *module.Module, cols int) *Source {
38+ s := &Source{
39+ M: m,
40+ screen: vt.NewScreen(cols, 1),
41+ frame: make([]byte, 0, module.MaxFrame),
42+ input: make([]byte, 0, module.MaxFrame+16),
43+ cells: make([]gpu.Cell, 0, cols),
44+ }
45+ s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
46+ return s
47+}
48+
49+// SetModules replaces the module list. A module that is already shown keeps
50+// its parsed text; new ones start empty. The order of mods is the order
51+// within each zone.
52+func (l *Layout) SetModules(mods []*module.Module) {
53+ old := make(map[*module.Module]*Source, len(l.sources))
54+ for _, s := range l.sources {
55+ old[s.M] = s
56+ }
57+ l.sources = make([]*Source, 0, len(mods))
3358 for _, m := range mods {
34- s := &Source{
35- M: m,
36- screen: vt.NewScreen(cols, 1),
37- frame: make([]byte, 0, module.MaxFrame),
38- input: make([]byte, 0, module.MaxFrame+16),
39- cells: make([]gpu.Cell, 0, cols),
59+ if s, ok := old[m]; ok {
60+ l.sources = append(l.sources, s)
61+ } else {
62+ l.sources = append(l.sources, newSource(m, l.cols))
63+ }
64+ }
65+}
66+
67+// SetColors changes the default colors and reparses every module's last frame.
68+func (l *Layout) SetColors(fg, bg [3]uint8) {
69+ l.fg, l.bg = fg, bg
70+ for _, s := range l.sources {
71+ if s.failed {
72+ s.cells = l.marker(s.cells[:0], s.M.Name)
73+ } else if len(s.frame) > 0 {
74+ s.parse(fg, bg)
4075 }
41- s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
42- l.sources = append(l.sources, s)
4376 }
44- return l
4577 }
4678
4779 // Resize changes the row width and reparses the last frame of every module.
@@ -30,18 +30,50 @@ type Layout struct {
30 // New builds a layout for a row of cols cells.30 // New builds a layout for a row of cols cells.
31 func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {31 func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {
32 l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}32 l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}
33+ l.SetModules(mods)
34+ return l
35+}
36+
37+func newSource(m *module.Module, cols int) *Source {
38+ s := &Source{
39+ M: m,
40+ screen: vt.NewScreen(cols, 1),
41+ frame: make([]byte, 0, module.MaxFrame),
42+ input: make([]byte, 0, module.MaxFrame+16),
43+ cells: make([]gpu.Cell, 0, cols),
44+ }
45+ s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
46+ return s
47+}
48+
49+// SetModules replaces the module list. A module that is already shown keeps
50+// its parsed text; new ones start empty. The order of mods is the order
51+// within each zone.
52+func (l *Layout) SetModules(mods []*module.Module) {
53+ old := make(map[*module.Module]*Source, len(l.sources))
54+ for _, s := range l.sources {
55+ old[s.M] = s
56+ }
57+ l.sources = make([]*Source, 0, len(mods))
33 for _, m := range mods {58 for _, m := range mods {
34- s := &Source{59+ if s, ok := old[m]; ok {
35- M: m,60+ l.sources = append(l.sources, s)
36- screen: vt.NewScreen(cols, 1),61+ } else {
37- frame: make([]byte, 0, module.MaxFrame),62+ l.sources = append(l.sources, newSource(m, l.cols))
38- input: make([]byte, 0, module.MaxFrame+16),63+ }
39- cells: make([]gpu.Cell, 0, cols),64+ }
65+}
66+
67+// SetColors changes the default colors and reparses every module's last frame.
68+func (l *Layout) SetColors(fg, bg [3]uint8) {
69+ l.fg, l.bg = fg, bg
70+ for _, s := range l.sources {
71+ if s.failed {
72+ s.cells = l.marker(s.cells[:0], s.M.Name)
73+ } else if len(s.frame) > 0 {
74+ s.parse(fg, bg)
40 }75 }
41- s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
42- l.sources = append(l.sources, s)
43 }76 }
44- return l
45 }77 }
46 78
47 // Resize changes the row width and reparses the last frame of every module.79 // Resize changes the row width and reparses the last frame of every module.

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