detached 74f955e

feat(bar): restart modules when their script folder changes and add bar.position to put the bar at the top or bottom

commit
74f955ebe33e13a8883360596d80bb9b515b35df
author
brice · · 2026-10-05 19:04 UTC
parent
2f98a9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/reload-and-position
8 files · +272 -8
M README.md +3 -0
@@ -46,11 +46,14 @@ exec = "~/.config/neferbar/clock.sh"
4646
4747 Add one `[[module]]` block per script. Save the file and the bar updates immediately. If you make a typo, the bar keeps the old config and prints the error.
4848
49+The bar also watches the folder of each script. Edit a script, or a helper it sources from the same folder, and the modules in that folder restart by themselves. Hidden files (`.name`) and backups (`name~`) are ignored, so an editor's swap file does nothing.
50+
4951 | Setting | Default | Meaning |
5052 |---|---|---|
5153 | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). |
5254 | `bar.size` | `14` | Text size in logical pixels. |
5355 | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. |
56+| `bar.position` | `top` | `top` or `bottom`: the screen edge the bar sits on. Applies live. |
5457 | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. |
5558 | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). |
5659 | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. |
@@ -46,11 +46,14 @@ exec = "~/.config/neferbar/clock.sh"
46 46
47 Add one `[[module]]` block per script. Save the file and the bar updates immediately. If you make a typo, the bar keeps the old config and prints the error.47 Add one `[[module]]` block per script. Save the file and the bar updates immediately. If you make a typo, the bar keeps the old config and prints the error.
48 48
49+The bar also watches the folder of each script. Edit a script, or a helper it sources from the same folder, and the modules in that folder restart by themselves. Hidden files (`.name`) and backups (`name~`) are ignored, so an editor's swap file does nothing.
50+
49 | Setting | Default | Meaning |51 | Setting | Default | Meaning |
50 |---|---|---|52 |---|---|---|
51 | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). |53 | `bar.font` | `JetBrainsMono Nerd Font Mono` | A font family known to fontconfig (`fc-list`). |
52 | `bar.size` | `14` | Text size in logical pixels. |54 | `bar.size` | `14` | Text size in logical pixels. |
53 | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. |55 | `bar.scale` | `1.0` | Extra zoom on top of the monitor scale. |
56+| `bar.position` | `top` | `top` or `bottom`: the screen edge the bar sits on. Applies live. |
54 | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. |57 | `bar.output` | any | A monitor name such as `HDMI-A-1`. Needs a restart. |
55 | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). |58 | `bar.theme` | `auto` | `auto` reads the colors of the terminal you use. Or the path of a theme file (see Colors). |
56 | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. |59 | `bar.accent` | `4` | Which of the theme's 16 colors (0-15) the highlights use. 4 is the ANSI blue; try 2 (green), 6 (cyan) or 5 (magenta) to match your theme. |
M internal/bar/bar.go +87 -3
@@ -84,6 +84,11 @@ type Bar struct {
8484 cfgPath string
8585 cfgCh chan config.Config
8686
87+ // scriptCh carries the directory of a script that changed on disk.
88+ scriptCh chan string
89+ scriptCancel context.CancelFunc
90+ stale map[string]bool // directories whose modules restart at the next syncModules
91+
8792 theme theme.Theme
8893 themeCh chan struct{}
8994 themeCancel context.CancelFunc
@@ -121,7 +126,7 @@ type Bar struct {
121126 // empty uses $WAYLAND_DISPLAY.
122127 func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) {
123128 b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
124- themeCh: make(chan struct{}, 1)}
129+ themeCh: make(chan struct{}, 1), scriptCh: make(chan string, 16)}
125130 var err error
126131 if err = b.loadTheme(); err != nil {
127132 return nil, err
@@ -199,6 +204,58 @@ func (b *Bar) watchTheme() {
199204 }
200205 }
201206
207+// watchScripts watches the directory of every module's script, so editing a
208+// script, or a helper it sources from the same directory, restarts the modules
209+// that live there. It runs again whenever the set of modules changes.
210+func (b *Bar) watchScripts() {
211+ if b.scriptCancel != nil {
212+ b.scriptCancel()
213+ }
214+ if b.runCtx == nil {
215+ return // Run has not started; it calls syncModules, which calls this
216+ }
217+ ctx, cancel := context.WithCancel(b.runCtx)
218+ b.scriptCancel = cancel
219+ seen := map[string]bool{}
220+ for _, r := range b.runners {
221+ dir := module.ScriptDir(r.cfg.Exec)
222+ if dir == "" || seen[dir] {
223+ continue
224+ }
225+ seen[dir] = true
226+ go func() {
227+ changed := make(chan struct{}, 1)
228+ go func() {
229+ if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil {
230+ b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)
231+ }
232+ }()
233+ for {
234+ select {
235+ case <-ctx.Done():
236+ return
237+ case <-changed:
238+ select {
239+ case b.scriptCh <- dir:
240+ case <-ctx.Done():
241+ return
242+ }
243+ }
244+ }
245+ }()
246+ }
247+}
248+
249+// restartScripts restarts the modules whose script is in dir.
250+func (b *Bar) restartScripts(dir string) {
251+ b.log.Info("script changed; restarting its modules", "dir", dir)
252+ if b.stale == nil {
253+ b.stale = map[string]bool{}
254+ }
255+ b.stale[dir] = true
256+ b.syncModules(b.cfg.Module)
257+}
258+
202259 // reloadTheme re-reads the theme after one of its files changed.
203260 func (b *Bar) reloadTheme() {
204261 base := filepath.Dir(b.cfgPath)
@@ -245,7 +302,7 @@ func (b *Bar) syncModules(want []config.Module) {
245302 for _, w := range want {
246303 if r, ok := old[w.Name]; ok {
247304 delete(old, w.Name)
248- if r.cfg == w && slices.Equal(r.env, b.env) {
305+ if r.cfg == w && slices.Equal(r.env, b.env) && !b.stale[module.ScriptDir(w.Exec)] {
249306 next = append(next, r)
250307 continue
251308 }
@@ -261,6 +318,8 @@ func (b *Bar) syncModules(want []config.Module) {
261318 r.cancel()
262319 }
263320 b.runners = next
321+ clear(b.stale)
322+ b.watchScripts()
264323 b.mods = b.mods[:0]
265324 for _, r := range next {
266325 b.mods = append(b.mods, r.m)
@@ -293,6 +352,21 @@ func (b *Bar) applyConfig(next config.Config) {
293352 }
294353 b.syncModules(next.Module)
295354 b.dirty = true
355+ if next.Bar.Position != old.Bar.Position && b.surf != nil {
356+ // The edge is part of how a layer surface is created: make a new one.
357+ b.log.Info("moving the bar", "to", next.Bar.Position)
358+ b.dropRenderer()
359+ _, h, _ := b.surf.Size()
360+ if err = b.surf.Close(); err != nil {
361+ b.fail(err)
362+ return
363+ }
364+ if err = b.createSurface(h); err != nil {
365+ b.fail(err)
366+ return
367+ }
368+ b.Stats.Recreated++
369+ }
296370 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
297371 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
298372 b.reconcile()
@@ -341,6 +415,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
341415 b.applyConfig(next)
342416 case <-b.themeCh:
343417 b.reloadTheme()
418+ case dir := <-b.scriptCh:
419+ b.restartScripts(dir)
344420 case <-b.settleC:
345421 b.settleC, b.settling = nil, false
346422 b.reconcile()
@@ -399,12 +475,20 @@ func connectError(display string, err error) error {
399475 // estimateHeight is the first request, before the real cell height is known.
400476 func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }
401477
478+// edge is the layer-shell anchor for a bar.position value.
479+func edge(position string) neferclient.Anchor {
480+ if position == "bottom" {
481+ return neferclient.AnchorBottom
482+ }
483+ return neferclient.AnchorTop
484+}
485+
402486 func (b *Bar) createSurface(h int32) error {
403487 surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{
404488 Output: b.cfg.Bar.Output,
405489 Namespace: "neferbar",
406490 Level: neferclient.LayerTop,
407- Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight,
491+ Anchors: edge(b.cfg.Bar.Position) | neferclient.AnchorLeft | neferclient.AnchorRight,
408492 ExclusiveZone: h,
409493 Height: h,
410494 InputRects: []neferclient.Rect{}, // read only: click-through
@@ -84,6 +84,11 @@ type Bar struct {
84 cfgPath string84 cfgPath string
85 cfgCh chan config.Config85 cfgCh chan config.Config
86 86
87+ // scriptCh carries the directory of a script that changed on disk.
88+ scriptCh chan string
89+ scriptCancel context.CancelFunc
90+ stale map[string]bool // directories whose modules restart at the next syncModules
91+
87 theme theme.Theme92 theme theme.Theme
88 themeCh chan struct{}93 themeCh chan struct{}
89 themeCancel context.CancelFunc94 themeCancel context.CancelFunc
@@ -121,7 +126,7 @@ type Bar struct {
121 // empty uses $WAYLAND_DISPLAY.126 // empty uses $WAYLAND_DISPLAY.
122 func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) {127 func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) {
123 b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),128 b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
124- themeCh: make(chan struct{}, 1)}129+ themeCh: make(chan struct{}, 1), scriptCh: make(chan string, 16)}
125 var err error130 var err error
126 if err = b.loadTheme(); err != nil {131 if err = b.loadTheme(); err != nil {
127 return nil, err132 return nil, err
@@ -199,6 +204,58 @@ func (b *Bar) watchTheme() {
199 }204 }
200 }205 }
201 206
207+// watchScripts watches the directory of every module's script, so editing a
208+// script, or a helper it sources from the same directory, restarts the modules
209+// that live there. It runs again whenever the set of modules changes.
210+func (b *Bar) watchScripts() {
211+ if b.scriptCancel != nil {
212+ b.scriptCancel()
213+ }
214+ if b.runCtx == nil {
215+ return // Run has not started; it calls syncModules, which calls this
216+ }
217+ ctx, cancel := context.WithCancel(b.runCtx)
218+ b.scriptCancel = cancel
219+ seen := map[string]bool{}
220+ for _, r := range b.runners {
221+ dir := module.ScriptDir(r.cfg.Exec)
222+ if dir == "" || seen[dir] {
223+ continue
224+ }
225+ seen[dir] = true
226+ go func() {
227+ changed := make(chan struct{}, 1)
228+ go func() {
229+ if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil {
230+ b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)
231+ }
232+ }()
233+ for {
234+ select {
235+ case <-ctx.Done():
236+ return
237+ case <-changed:
238+ select {
239+ case b.scriptCh <- dir:
240+ case <-ctx.Done():
241+ return
242+ }
243+ }
244+ }
245+ }()
246+ }
247+}
248+
249+// restartScripts restarts the modules whose script is in dir.
250+func (b *Bar) restartScripts(dir string) {
251+ b.log.Info("script changed; restarting its modules", "dir", dir)
252+ if b.stale == nil {
253+ b.stale = map[string]bool{}
254+ }
255+ b.stale[dir] = true
256+ b.syncModules(b.cfg.Module)
257+}
258+
202 // reloadTheme re-reads the theme after one of its files changed.259 // reloadTheme re-reads the theme after one of its files changed.
203 func (b *Bar) reloadTheme() {260 func (b *Bar) reloadTheme() {
204 base := filepath.Dir(b.cfgPath)261 base := filepath.Dir(b.cfgPath)
@@ -245,7 +302,7 @@ func (b *Bar) syncModules(want []config.Module) {
245 for _, w := range want {302 for _, w := range want {
246 if r, ok := old[w.Name]; ok {303 if r, ok := old[w.Name]; ok {
247 delete(old, w.Name)304 delete(old, w.Name)
248- if r.cfg == w && slices.Equal(r.env, b.env) {305+ if r.cfg == w && slices.Equal(r.env, b.env) && !b.stale[module.ScriptDir(w.Exec)] {
249 next = append(next, r)306 next = append(next, r)
250 continue307 continue
251 }308 }
@@ -261,6 +318,8 @@ func (b *Bar) syncModules(want []config.Module) {
261 r.cancel()318 r.cancel()
262 }319 }
263 b.runners = next320 b.runners = next
321+ clear(b.stale)
322+ b.watchScripts()
264 b.mods = b.mods[:0]323 b.mods = b.mods[:0]
265 for _, r := range next {324 for _, r := range next {
266 b.mods = append(b.mods, r.m)325 b.mods = append(b.mods, r.m)
@@ -293,6 +352,21 @@ func (b *Bar) applyConfig(next config.Config) {
293 }352 }
294 b.syncModules(next.Module)353 b.syncModules(next.Module)
295 b.dirty = true354 b.dirty = true
355+ if next.Bar.Position != old.Bar.Position && b.surf != nil {
356+ // The edge is part of how a layer surface is created: make a new one.
357+ b.log.Info("moving the bar", "to", next.Bar.Position)
358+ b.dropRenderer()
359+ _, h, _ := b.surf.Size()
360+ if err = b.surf.Close(); err != nil {
361+ b.fail(err)
362+ return
363+ }
364+ if err = b.createSurface(h); err != nil {
365+ b.fail(err)
366+ return
367+ }
368+ b.Stats.Recreated++
369+ }
296 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {370 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
297 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed371 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
298 b.reconcile()372 b.reconcile()
@@ -341,6 +415,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
341 b.applyConfig(next)415 b.applyConfig(next)
342 case <-b.themeCh:416 case <-b.themeCh:
343 b.reloadTheme()417 b.reloadTheme()
418+ case dir := <-b.scriptCh:
419+ b.restartScripts(dir)
344 case <-b.settleC:420 case <-b.settleC:
345 b.settleC, b.settling = nil, false421 b.settleC, b.settling = nil, false
346 b.reconcile()422 b.reconcile()
@@ -399,12 +475,20 @@ func connectError(display string, err error) error {
399 // estimateHeight is the first request, before the real cell height is known.475 // estimateHeight is the first request, before the real cell height is known.
400 func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }476 func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }
401 477
478+// edge is the layer-shell anchor for a bar.position value.
479+func edge(position string) neferclient.Anchor {
480+ if position == "bottom" {
481+ return neferclient.AnchorBottom
482+ }
483+ return neferclient.AnchorTop
484+}
485+
402 func (b *Bar) createSurface(h int32) error {486 func (b *Bar) createSurface(h int32) error {
403 surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{487 surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{
404 Output: b.cfg.Bar.Output,488 Output: b.cfg.Bar.Output,
405 Namespace: "neferbar",489 Namespace: "neferbar",
406 Level: neferclient.LayerTop,490 Level: neferclient.LayerTop,
407- Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight,491+ Anchors: edge(b.cfg.Bar.Position) | neferclient.AnchorLeft | neferclient.AnchorRight,
408 ExclusiveZone: h,492 ExclusiveZone: h,
409 Height: h,493 Height: h,
410 InputRects: []neferclient.Rect{}, // read only: click-through494 InputRects: []neferclient.Rect{}, // read only: click-through
M internal/config/config.go +6 -1
@@ -17,6 +17,8 @@ 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+ // Position is the screen edge the bar sits on: "top" or "bottom".
21+ Position string `toml:"position"`
2022 // Theme is "auto" (the colors of the terminal in use) or the path of a
2123 // terminal theme or config file: kitty, foot, ghostty or alacritty.
2224 Theme string `toml:"theme"`
@@ -45,7 +47,7 @@ type Config struct {
4547 func Default() Config {
4648 return Config{Bar: Bar{
4749 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
48- Theme: "auto", Accent: 4,
50+ Theme: "auto", Accent: 4, Position: "top",
4951 }}
5052 }
5153
@@ -96,6 +98,9 @@ func (c *Config) Validate() error {
9698 return err
9799 }
98100 }
101+ if b.Position != "top" && b.Position != "bottom" {
102+ return fmt.Errorf(`config: bar.position %q must be "top" or "bottom"`, b.Position)
103+ }
99104 if b.Accent < 0 || b.Accent > 15 {
100105 return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent)
101106 }
@@ -17,6 +17,8 @@ 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+ // Position is the screen edge the bar sits on: "top" or "bottom".
21+ Position string `toml:"position"`
20 // Theme is "auto" (the colors of the terminal in use) or the path of a22 // 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.23 // terminal theme or config file: kitty, foot, ghostty or alacritty.
22 Theme string `toml:"theme"`24 Theme string `toml:"theme"`
@@ -45,7 +47,7 @@ type Config struct {
45 func Default() Config {47 func Default() Config {
46 return Config{Bar: Bar{48 return Config{Bar: Bar{
47 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,49 Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
48- Theme: "auto", Accent: 4,50+ Theme: "auto", Accent: 4, Position: "top",
49 }}51 }}
50 }52 }
51 53
@@ -96,6 +98,9 @@ func (c *Config) Validate() error {
96 return err98 return err
97 }99 }
98 }100 }
101+ if b.Position != "top" && b.Position != "bottom" {
102+ return fmt.Errorf(`config: bar.position %q must be "top" or "bottom"`, b.Position)
103+ }
99 if b.Accent < 0 || b.Accent > 15 {104 if b.Accent < 0 || b.Accent > 15 {
100 return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent)105 return fmt.Errorf("config: bar.accent %d must be a color number from 0 to 15", b.Accent)
101 }106 }
M internal/config/config_test.go +14 -0
@@ -37,6 +37,7 @@ func TestLoadErrors(t *testing.T) {
3737 "bad size": "[bar]\nsize = 1\n",
3838 "bad scale": "[bar]\nscale = 20\n",
3939 "bad color": "[bar]\nbackground = \"red\"\n",
40+ "position": "[bar]\nposition = \"middle\"\n",
4041 "accent low": "[bar]\naccent = -1\n",
4142 "accent high": "[bar]\naccent = 16\n",
4243 "syntax error": "[bar\n",
@@ -69,3 +70,16 @@ func TestAccentDefaultsToBlue(t *testing.T) {
6970 t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err)
7071 }
7172 }
73+
74+func TestPositionDefaultsToTop(t *testing.T) {
75+ for config, want := range map[string]string{
76+ "[bar]\nsize = 14\n": "top",
77+ "[bar]\nposition = \"top\"\n": "top",
78+ "[bar]\nposition = \"bottom\"\n": "bottom",
79+ } {
80+ c, err := Load(write(t, config), true)
81+ if err != nil || c.Bar.Position != want {
82+ t.Errorf("%q: position = %q, err %v; want %q", config, c.Bar.Position, err, want)
83+ }
84+ }
85+}
@@ -37,6 +37,7 @@ func TestLoadErrors(t *testing.T) {
37 "bad size": "[bar]\nsize = 1\n",37 "bad size": "[bar]\nsize = 1\n",
38 "bad scale": "[bar]\nscale = 20\n",38 "bad scale": "[bar]\nscale = 20\n",
39 "bad color": "[bar]\nbackground = \"red\"\n",39 "bad color": "[bar]\nbackground = \"red\"\n",
40+ "position": "[bar]\nposition = \"middle\"\n",
40 "accent low": "[bar]\naccent = -1\n",41 "accent low": "[bar]\naccent = -1\n",
41 "accent high": "[bar]\naccent = 16\n",42 "accent high": "[bar]\naccent = 16\n",
42 "syntax error": "[bar\n",43 "syntax error": "[bar\n",
@@ -69,3 +70,16 @@ func TestAccentDefaultsToBlue(t *testing.T) {
69 t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err)70 t.Fatalf("accent = %d, err %v; want 2", c.Bar.Accent, err)
70 }71 }
71 }72 }
73+
74+func TestPositionDefaultsToTop(t *testing.T) {
75+ for config, want := range map[string]string{
76+ "[bar]\nsize = 14\n": "top",
77+ "[bar]\nposition = \"top\"\n": "top",
78+ "[bar]\nposition = \"bottom\"\n": "bottom",
79+ } {
80+ c, err := Load(write(t, config), true)
81+ if err != nil || c.Bar.Position != want {
82+ t.Errorf("%q: position = %q, err %v; want %q", config, c.Bar.Position, err, want)
83+ }
84+ }
85+}
M internal/fswatch/fswatch.go +27 -4
@@ -1,4 +1,5 @@
1-// Package fswatch reports changes to one file with inotify.
1+// Package fswatch reports changes to one file, or to the files of one
2+// directory, with inotify.
23 //
34 // It watches the parent directory, so a file that is replaced by a rename (the
45 // way editors and NeferWL write) is noticed, and it follows symlinks, so a
@@ -54,6 +55,23 @@ func files(path string) []string {
5455 // full, the pending signal already says "something changed", which is all a
5556 // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
5657 func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
58+ return watch(ctx, path, false, debounce, out)
59+}
60+
61+// WatchDir is Watch for every file directly inside dir. Hidden files (a name
62+// starting with a dot, such as an editor's swap file) and backup files (a name
63+// ending in "~") are ignored. Subdirectories are not watched.
64+func WatchDir(ctx context.Context, dir string, debounce time.Duration, out chan<- struct{}) error {
65+ return watch(ctx, dir, true, debounce, out)
66+}
67+
68+// visible reports whether a change to a file called name in a watched
69+// directory is worth reporting.
70+func visible(name string) bool {
71+ return name != "" && name[0] != '.' && name[len(name)-1] != '~'
72+}
73+
74+func watch(ctx context.Context, path string, dirMode bool, debounce time.Duration, out chan<- struct{}) error {
5775 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
5876 if err != nil {
5977 return err
@@ -78,6 +96,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
7896 }
7997 clear(names)
8098 watched = files(path)
99+ if dirMode {
100+ watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself
101+ }
81102 for _, file := range watched {
82103 wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83104 if errors.Is(err, unix.ENOENT) {
@@ -90,7 +111,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
90111 if names[int32(wd)] == nil {
91112 names[int32(wd)] = map[string]bool{}
92113 }
93- names[int32(wd)][filepath.Base(file)] = true
114+ if !dirMode {
115+ names[int32(wd)][filepath.Base(file)] = true
116+ }
94117 }
95118 }
96119
@@ -147,9 +170,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
147170 for len(name) > 0 && name[len(name)-1] == 0 {
148171 name = name[:len(name)-1]
149172 }
150- if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {
173+ if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) {
151174 pending = time.Now().Add(debounce)
152- if !slices.Equal(watched, files(path)) {
175+ if !dirMode && !slices.Equal(watched, files(path)) {
153176 rewatch = true // a symlink now points elsewhere
154177 }
155178 }
@@ -1,4 +1,5 @@
1-// Package fswatch reports changes to one file with inotify.1+// Package fswatch reports changes to one file, or to the files of one
2+// directory, with inotify.
2 //3 //
3 // It watches the parent directory, so a file that is replaced by a rename (the4 // 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 a5 // way editors and NeferWL write) is noticed, and it follows symlinks, so a
@@ -54,6 +55,23 @@ func files(path string) []string {
54 // full, the pending signal already says "something changed", which is all a55 // 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 // 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 func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
58+ return watch(ctx, path, false, debounce, out)
59+}
60+
61+// WatchDir is Watch for every file directly inside dir. Hidden files (a name
62+// starting with a dot, such as an editor's swap file) and backup files (a name
63+// ending in "~") are ignored. Subdirectories are not watched.
64+func WatchDir(ctx context.Context, dir string, debounce time.Duration, out chan<- struct{}) error {
65+ return watch(ctx, dir, true, debounce, out)
66+}
67+
68+// visible reports whether a change to a file called name in a watched
69+// directory is worth reporting.
70+func visible(name string) bool {
71+ return name != "" && name[0] != '.' && name[len(name)-1] != '~'
72+}
73+
74+func watch(ctx context.Context, path string, dirMode bool, debounce time.Duration, out chan<- struct{}) error {
57 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)75 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
58 if err != nil {76 if err != nil {
59 return err77 return err
@@ -78,6 +96,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
78 }96 }
79 clear(names)97 clear(names)
80 watched = files(path)98 watched = files(path)
99+ if dirMode {
100+ watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself
101+ }
81 for _, file := range watched {102 for _, file := range watched {
82 wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)103 wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83 if errors.Is(err, unix.ENOENT) {104 if errors.Is(err, unix.ENOENT) {
@@ -90,7 +111,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
90 if names[int32(wd)] == nil {111 if names[int32(wd)] == nil {
91 names[int32(wd)] = map[string]bool{}112 names[int32(wd)] = map[string]bool{}
92 }113 }
93- names[int32(wd)][filepath.Base(file)] = true114+ if !dirMode {
115+ names[int32(wd)][filepath.Base(file)] = true
116+ }
94 }117 }
95 }118 }
96 119
@@ -147,9 +170,9 @@ func Watch(ctx context.Context, path string, debounce time.Duration, out chan<-
147 for len(name) > 0 && name[len(name)-1] == 0 {170 for len(name) > 0 && name[len(name)-1] == 0 {
148 name = name[:len(name)-1]171 name = name[:len(name)-1]
149 }172 }
150- if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {173+ if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) {
151 pending = time.Now().Add(debounce)174 pending = time.Now().Add(debounce)
152- if !slices.Equal(watched, files(path)) {175+ if !dirMode && !slices.Equal(watched, files(path)) {
153 rewatch = true // a symlink now points elsewhere176 rewatch = true // a symlink now points elsewhere
154 }177 }
155 }178 }
A internal/fswatch/fswatch_test.go +88 -0
@@ -0,0 +1,88 @@
1+package fswatch
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+)
10+
11+func waitSignal(ch <-chan struct{}, d time.Duration) bool {
12+ select {
13+ case <-ch:
14+ return true
15+ case <-time.After(d):
16+ return false
17+ }
18+}
19+
20+// start runs WatchDir and returns once the watch is surely in place.
21+func start(t *testing.T, dir string) <-chan struct{} {
22+ t.Helper()
23+ ctx, cancel := context.WithCancel(context.Background())
24+ t.Cleanup(cancel)
25+ ch := make(chan struct{}, 1)
26+ go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }()
27+ time.Sleep(150 * time.Millisecond)
28+ return ch
29+}
30+
31+func TestWatchDirReportsAnyFileOfTheDirectory(t *testing.T) {
32+ dir := t.TempDir()
33+ ch := start(t, dir)
34+ if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("x"), 0o644); err != nil {
35+ t.Fatal(err)
36+ }
37+ if !waitSignal(ch, 2*time.Second) {
38+ t.Fatal("a new file in the directory was not reported")
39+ }
40+ // An edit of an existing file, and a replacement by rename, are reported too.
41+ if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("y"), 0o644); err != nil {
42+ t.Fatal(err)
43+ }
44+ if !waitSignal(ch, 2*time.Second) {
45+ t.Fatal("an edit was not reported")
46+ }
47+ tmp := filepath.Join(dir, "lib.sh.new")
48+ _ = os.WriteFile(tmp, []byte("z"), 0o644)
49+ waitSignal(ch, 2*time.Second) // the .new file itself
50+ if err := os.Rename(tmp, filepath.Join(dir, "lib.sh")); err != nil {
51+ t.Fatal(err)
52+ }
53+ if !waitSignal(ch, 2*time.Second) {
54+ t.Fatal("a rename over a file was not reported")
55+ }
56+}
57+
58+func TestWatchDirIgnoresHiddenAndBackupFiles(t *testing.T) {
59+ dir := t.TempDir()
60+ ch := start(t, dir)
61+ for _, name := range []string{".left.sh.swp", "left.sh~"} {
62+ if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
63+ t.Fatal(err)
64+ }
65+ }
66+ if waitSignal(ch, 400*time.Millisecond) {
67+ t.Fatal("a swap or backup file was reported")
68+ }
69+}
70+
71+func TestWatchDirWaitsForAMissingDirectory(t *testing.T) {
72+ dir := filepath.Join(t.TempDir(), "later")
73+ ctx, cancel := context.WithCancel(context.Background())
74+ t.Cleanup(cancel)
75+ ch := make(chan struct{}, 1)
76+ go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }()
77+ time.Sleep(100 * time.Millisecond)
78+ if err := os.Mkdir(dir, 0o755); err != nil {
79+ t.Fatal(err)
80+ }
81+ time.Sleep(2500 * time.Millisecond) // the missing directory is looked for every 2 s
82+ if err := os.WriteFile(filepath.Join(dir, "a.sh"), []byte("x"), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
85+ if !waitSignal(ch, 2*time.Second) {
86+ t.Fatal("a directory that appeared later was not watched")
87+ }
88+}
@@ -0,0 +1,88 @@
1+package fswatch
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+)
10+
11+func waitSignal(ch <-chan struct{}, d time.Duration) bool {
12+ select {
13+ case <-ch:
14+ return true
15+ case <-time.After(d):
16+ return false
17+ }
18+}
19+
20+// start runs WatchDir and returns once the watch is surely in place.
21+func start(t *testing.T, dir string) <-chan struct{} {
22+ t.Helper()
23+ ctx, cancel := context.WithCancel(context.Background())
24+ t.Cleanup(cancel)
25+ ch := make(chan struct{}, 1)
26+ go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }()
27+ time.Sleep(150 * time.Millisecond)
28+ return ch
29+}
30+
31+func TestWatchDirReportsAnyFileOfTheDirectory(t *testing.T) {
32+ dir := t.TempDir()
33+ ch := start(t, dir)
34+ if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("x"), 0o644); err != nil {
35+ t.Fatal(err)
36+ }
37+ if !waitSignal(ch, 2*time.Second) {
38+ t.Fatal("a new file in the directory was not reported")
39+ }
40+ // An edit of an existing file, and a replacement by rename, are reported too.
41+ if err := os.WriteFile(filepath.Join(dir, "lib.sh"), []byte("y"), 0o644); err != nil {
42+ t.Fatal(err)
43+ }
44+ if !waitSignal(ch, 2*time.Second) {
45+ t.Fatal("an edit was not reported")
46+ }
47+ tmp := filepath.Join(dir, "lib.sh.new")
48+ _ = os.WriteFile(tmp, []byte("z"), 0o644)
49+ waitSignal(ch, 2*time.Second) // the .new file itself
50+ if err := os.Rename(tmp, filepath.Join(dir, "lib.sh")); err != nil {
51+ t.Fatal(err)
52+ }
53+ if !waitSignal(ch, 2*time.Second) {
54+ t.Fatal("a rename over a file was not reported")
55+ }
56+}
57+
58+func TestWatchDirIgnoresHiddenAndBackupFiles(t *testing.T) {
59+ dir := t.TempDir()
60+ ch := start(t, dir)
61+ for _, name := range []string{".left.sh.swp", "left.sh~"} {
62+ if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
63+ t.Fatal(err)
64+ }
65+ }
66+ if waitSignal(ch, 400*time.Millisecond) {
67+ t.Fatal("a swap or backup file was reported")
68+ }
69+}
70+
71+func TestWatchDirWaitsForAMissingDirectory(t *testing.T) {
72+ dir := filepath.Join(t.TempDir(), "later")
73+ ctx, cancel := context.WithCancel(context.Background())
74+ t.Cleanup(cancel)
75+ ch := make(chan struct{}, 1)
76+ go func() { _ = WatchDir(ctx, dir, 30*time.Millisecond, ch) }()
77+ time.Sleep(100 * time.Millisecond)
78+ if err := os.Mkdir(dir, 0o755); err != nil {
79+ t.Fatal(err)
80+ }
81+ time.Sleep(2500 * time.Millisecond) // the missing directory is looked for every 2 s
82+ if err := os.WriteFile(filepath.Join(dir, "a.sh"), []byte("x"), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
85+ if !waitSignal(ch, 2*time.Second) {
86+ t.Fatal("a directory that appeared later was not watched")
87+ }
88+}
M internal/module/module.go +27 -0
@@ -12,6 +12,8 @@ import (
1212 "log/slog"
1313 "os"
1414 "os/exec"
15+ "path/filepath"
16+ "strings"
1517 "sync"
1618 "syscall"
1719 "time"
@@ -219,3 +221,28 @@ func (w *logWriter) Write(p []byte) (int, error) {
219221
220222 // PublishForTest injects a frame as if the script had printed it.
221223 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224+
225+// ScriptDir is the directory of the program a module's command starts, or ""
226+// when it has none to watch: a bare name such as "date" is found through $PATH,
227+// and a command that cannot be split into words is left alone. A leading "~/"
228+// is the home directory, as the shell reads it, and a relative path is taken
229+// from the current directory.
230+func ScriptDir(command string) string {
231+ fields := strings.Fields(command)
232+ if len(fields) == 0 || !strings.Contains(fields[0], "/") {
233+ return ""
234+ }
235+ path := fields[0]
236+ if rest, ok := strings.CutPrefix(path, "~/"); ok {
237+ home, err := os.UserHomeDir()
238+ if err != nil {
239+ return ""
240+ }
241+ path = filepath.Join(home, rest)
242+ }
243+ abs, err := filepath.Abs(path)
244+ if err != nil {
245+ return ""
246+ }
247+ return filepath.Dir(abs)
248+}
@@ -12,6 +12,8 @@ import (
12 "log/slog"12 "log/slog"
13 "os"13 "os"
14 "os/exec"14 "os/exec"
15+ "path/filepath"
16+ "strings"
15 "sync"17 "sync"
16 "syscall"18 "syscall"
17 "time"19 "time"
@@ -219,3 +221,28 @@ func (w *logWriter) Write(p []byte) (int, error) {
219 221
220 // PublishForTest injects a frame as if the script had printed it.222 // PublishForTest injects a frame as if the script had printed it.
221 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }223 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224+
225+// ScriptDir is the directory of the program a module's command starts, or ""
226+// when it has none to watch: a bare name such as "date" is found through $PATH,
227+// and a command that cannot be split into words is left alone. A leading "~/"
228+// is the home directory, as the shell reads it, and a relative path is taken
229+// from the current directory.
230+func ScriptDir(command string) string {
231+ fields := strings.Fields(command)
232+ if len(fields) == 0 || !strings.Contains(fields[0], "/") {
233+ return ""
234+ }
235+ path := fields[0]
236+ if rest, ok := strings.CutPrefix(path, "~/"); ok {
237+ home, err := os.UserHomeDir()
238+ if err != nil {
239+ return ""
240+ }
241+ path = filepath.Join(home, rest)
242+ }
243+ abs, err := filepath.Abs(path)
244+ if err != nil {
245+ return ""
246+ }
247+ return filepath.Dir(abs)
248+}
M internal/module/module_test.go +20 -0
@@ -111,3 +111,23 @@ func TestStopEndsTheScriptsChildren(t *testing.T) {
111111 t.Fatal("a child of the script is still running after Wait returned")
112112 }
113113 }
114+
115+func TestScriptDir(t *testing.T) {
116+ home, _ := os.UserHomeDir()
117+ cwd, _ := os.Getwd()
118+ for command, want := range map[string]string{
119+ "/opt/bar/left.sh": "/opt/bar",
120+ "/opt/bar/left.sh 60 30": "/opt/bar",
121+ "~/.config/neferbar/a.sh": filepath.Join(home, ".config/neferbar"),
122+ "examples/modules/clock.sh": filepath.Join(cwd, "examples/modules"),
123+ "./local.sh": cwd,
124+ "date": "",
125+ "sh -c 'echo hi'": "",
126+ "": "",
127+ " ": "",
128+ } {
129+ if got := ScriptDir(command); got != want {
130+ t.Errorf("ScriptDir(%q) = %q, want %q", command, got, want)
131+ }
132+ }
133+}
@@ -111,3 +111,23 @@ func TestStopEndsTheScriptsChildren(t *testing.T) {
111 t.Fatal("a child of the script is still running after Wait returned")111 t.Fatal("a child of the script is still running after Wait returned")
112 }112 }
113 }113 }
114+
115+func TestScriptDir(t *testing.T) {
116+ home, _ := os.UserHomeDir()
117+ cwd, _ := os.Getwd()
118+ for command, want := range map[string]string{
119+ "/opt/bar/left.sh": "/opt/bar",
120+ "/opt/bar/left.sh 60 30": "/opt/bar",
121+ "~/.config/neferbar/a.sh": filepath.Join(home, ".config/neferbar"),
122+ "examples/modules/clock.sh": filepath.Join(cwd, "examples/modules"),
123+ "./local.sh": cwd,
124+ "date": "",
125+ "sh -c 'echo hi'": "",
126+ "": "",
127+ " ": "",
128+ } {
129+ if got := ScriptDir(command); got != want {
130+ t.Errorf("ScriptDir(%q) = %q, want %q", command, got, want)
131+ }
132+ }
133+}

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