detached e930e7f

merge: live config reload

commit
e930e7f87ae3d2be42c639b9aad9925557cd427b
author
brice · · 2026-09-23 16:01 UTC
merge of
ea59d04 + a56a57d · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +305 -9
M README.md +1 -1
@@ -19,7 +19,7 @@ command = ["foot"]
1919 gaps = 8
2020 ```
2121
22-See `examples/config.toml` for all defaults.
22+See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.
2323
2424 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
2525
@@ -19,7 +19,7 @@ command = ["foot"]
19 gaps = 819 gaps = 8
20 ```20 ```
21 21
22-See `examples/config.toml` for all defaults.22+See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.
23 23
24 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.24 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
25 25
M cmd/nefertty/main.go +1 -1
@@ -163,7 +163,7 @@ func run() error {
163163 log := logging.For(ctx, "app")
164164 configLog := logging.For(ctx, "config")
165165 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
166+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
167167 // SIGINT and SIGTERM cancel the context and are clean exits.
168168 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
169169 return nil
@@ -163,7 +163,7 @@ func run() error {
163 log := logging.For(ctx, "app")163 log := logging.For(ctx, "app")
164 configLog := logging.For(ctx, "config")164 configLog := logging.For(ctx, "config")
165 configLog.Info().Str("path", path).Msg("loaded config")165 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {166+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
167 // SIGINT and SIGTERM cancel the context and are clean exits.167 // SIGINT and SIGTERM cancel the context and are clean exits.
168 if ctx.Err() != nil && errors.Is(err, context.Canceled) {168 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
169 return nil169 return nil
A internal/adapters/config/watch.go +121 -0
@@ -0,0 +1,121 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "time"
9+ "unsafe"
10+
11+ "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+ "golang.org/x/sys/unix"
14+)
15+
16+var directoryPollInterval = 2 * time.Second
17+
18+// Watch observes the parent directory so atomic file replacements are detected.
19+// If the directory is absent, it retries every two seconds until it appears.
20+func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
21+ dir, name := filepath.Dir(path), filepath.Base(path)
22+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
23+ if err != nil {
24+ return err
25+ }
26+ defer unix.Close(fd)
27+ const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
28+ wd := -1
29+ var pending time.Time
30+ buf := make([]byte, 4096)
31+ for {
32+ if ctx.Err() != nil {
33+ return nil
34+ }
35+ if wd < 0 {
36+ wd, err = unix.InotifyAddWatch(fd, dir, mask)
37+ if errors.Is(err, unix.ENOENT) {
38+ select {
39+ case <-ctx.Done():
40+ return nil
41+ case <-time.After(directoryPollInterval):
42+ continue
43+ }
44+ }
45+ if err != nil {
46+ return err
47+ }
48+ }
49+ timeout := 200
50+ if !pending.IsZero() {
51+ remaining := time.Until(pending)
52+ if remaining <= 0 {
53+ pending = time.Time{}
54+ cfg, loadErr := Load(path)
55+ switch {
56+ case os.IsNotExist(loadErr):
57+ log.Warn().Msg("config file removed; keeping current config")
58+ case loadErr != nil:
59+ log.Warn().Err(loadErr).Msg("config reload rejected")
60+ default:
61+ select {
62+ case <-ctx.Done():
63+ return nil
64+ case out <- ports.ConfigChanged{Config: cfg}:
65+ }
66+ log.Info().Msg("config reloaded")
67+ }
68+ continue
69+ }
70+ if remaining < 200*time.Millisecond {
71+ timeout = int(remaining.Milliseconds()) + 1
72+ }
73+ }
74+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
75+ _, err = unix.Poll(fds, timeout)
76+ if err == unix.EINTR {
77+ continue
78+ }
79+ if err != nil {
80+ return err
81+ }
82+ if fds[0].Revents&unix.POLLIN == 0 {
83+ continue
84+ }
85+ for {
86+ n, readErr := unix.Read(fd, buf)
87+ if readErr == unix.EAGAIN {
88+ break
89+ }
90+ if readErr == unix.EINTR {
91+ continue
92+ }
93+ if readErr != nil {
94+ return readErr
95+ }
96+ if n == 0 {
97+ break
98+ }
99+ for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
100+ event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
101+ size := unix.SizeofInotifyEvent + int(event.Len)
102+ if offset+size > n {
103+ break
104+ }
105+ if event.Mask&unix.IN_IGNORED != 0 {
106+ wd = -1
107+ }
108+ if event.Len > 0 {
109+ file := buf[offset+unix.SizeofInotifyEvent : offset+size]
110+ for len(file) > 0 && file[len(file)-1] == 0 {
111+ file = file[:len(file)-1]
112+ }
113+ if string(file) == name && event.Mask&mask != 0 {
114+ pending = time.Now().Add(100 * time.Millisecond)
115+ }
116+ }
117+ offset += size
118+ }
119+ }
120+ }
121+}
@@ -0,0 +1,121 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "time"
9+ "unsafe"
10+
11+ "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+ "golang.org/x/sys/unix"
14+)
15+
16+var directoryPollInterval = 2 * time.Second
17+
18+// Watch observes the parent directory so atomic file replacements are detected.
19+// If the directory is absent, it retries every two seconds until it appears.
20+func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
21+ dir, name := filepath.Dir(path), filepath.Base(path)
22+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
23+ if err != nil {
24+ return err
25+ }
26+ defer unix.Close(fd)
27+ const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
28+ wd := -1
29+ var pending time.Time
30+ buf := make([]byte, 4096)
31+ for {
32+ if ctx.Err() != nil {
33+ return nil
34+ }
35+ if wd < 0 {
36+ wd, err = unix.InotifyAddWatch(fd, dir, mask)
37+ if errors.Is(err, unix.ENOENT) {
38+ select {
39+ case <-ctx.Done():
40+ return nil
41+ case <-time.After(directoryPollInterval):
42+ continue
43+ }
44+ }
45+ if err != nil {
46+ return err
47+ }
48+ }
49+ timeout := 200
50+ if !pending.IsZero() {
51+ remaining := time.Until(pending)
52+ if remaining <= 0 {
53+ pending = time.Time{}
54+ cfg, loadErr := Load(path)
55+ switch {
56+ case os.IsNotExist(loadErr):
57+ log.Warn().Msg("config file removed; keeping current config")
58+ case loadErr != nil:
59+ log.Warn().Err(loadErr).Msg("config reload rejected")
60+ default:
61+ select {
62+ case <-ctx.Done():
63+ return nil
64+ case out <- ports.ConfigChanged{Config: cfg}:
65+ }
66+ log.Info().Msg("config reloaded")
67+ }
68+ continue
69+ }
70+ if remaining < 200*time.Millisecond {
71+ timeout = int(remaining.Milliseconds()) + 1
72+ }
73+ }
74+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
75+ _, err = unix.Poll(fds, timeout)
76+ if err == unix.EINTR {
77+ continue
78+ }
79+ if err != nil {
80+ return err
81+ }
82+ if fds[0].Revents&unix.POLLIN == 0 {
83+ continue
84+ }
85+ for {
86+ n, readErr := unix.Read(fd, buf)
87+ if readErr == unix.EAGAIN {
88+ break
89+ }
90+ if readErr == unix.EINTR {
91+ continue
92+ }
93+ if readErr != nil {
94+ return readErr
95+ }
96+ if n == 0 {
97+ break
98+ }
99+ for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
100+ event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
101+ size := unix.SizeofInotifyEvent + int(event.Len)
102+ if offset+size > n {
103+ break
104+ }
105+ if event.Mask&unix.IN_IGNORED != 0 {
106+ wd = -1
107+ }
108+ if event.Len > 0 {
109+ file := buf[offset+unix.SizeofInotifyEvent : offset+size]
110+ for len(file) > 0 && file[len(file)-1] == 0 {
111+ file = file[:len(file)-1]
112+ }
113+ if string(file) == name && event.Mask&mask != 0 {
114+ pending = time.Now().Add(100 * time.Millisecond)
115+ }
116+ }
117+ offset += size
118+ }
119+ }
120+ }
121+}
A internal/adapters/config/watch_test.go +97 -0
@@ -0,0 +1,97 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/logging"
11+ "github.com/bnema/nefertty/internal/ports"
12+)
13+
14+func TestWatch(t *testing.T) {
15+ for _, later := range []bool{false, true} {
16+ t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) {
17+ dir := filepath.Join(t.TempDir(), "config")
18+ if !later {
19+ if err := os.Mkdir(dir, 0700); err != nil {
20+ t.Fatal(err)
21+ }
22+ }
23+ path := filepath.Join(dir, "config.toml")
24+ ctx, cancel := context.WithCancel(context.Background())
25+ out := make(chan ports.ConfigChanged, 8)
26+ done := make(chan error, 1)
27+ old := directoryPollInterval
28+ directoryPollInterval = 20 * time.Millisecond
29+ defer func() { directoryPollInterval = old }()
30+ go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
31+ defer func() {
32+ cancel()
33+ if err := <-done; err != nil {
34+ t.Error(err)
35+ }
36+ }()
37+ if later {
38+ time.Sleep(40 * time.Millisecond)
39+ if err := os.Mkdir(dir, 0700); err != nil {
40+ t.Fatal(err)
41+ }
42+ time.Sleep(80 * time.Millisecond)
43+ } else {
44+ time.Sleep(40 * time.Millisecond)
45+ }
46+ write := func(file, body string) {
47+ t.Helper()
48+ if err := os.WriteFile(file, []byte(body), 0600); err != nil {
49+ t.Fatal(err)
50+ }
51+ }
52+ write(path, "[background]\ncolor='#000000'\n")
53+ receive := func(want string) {
54+ t.Helper()
55+ select {
56+ case c := <-out:
57+ if c.Config.Background.Color != want {
58+ t.Fatalf("color %s, want %s", c.Config.Background.Color, want)
59+ }
60+ case <-time.After(time.Second):
61+ t.Fatal("no config change")
62+ }
63+ }
64+ receive("#000000")
65+ tmp := filepath.Join(dir, "temp")
66+ write(tmp, "[background]\ncolor='#ff0000'\n")
67+ if err := os.Rename(tmp, path); err != nil {
68+ t.Fatal(err)
69+ }
70+ receive("#ff0000")
71+ for i := 0; i < 3; i++ {
72+ write(path, "[background]\ncolor='#00ff00'\n")
73+ time.Sleep(15 * time.Millisecond)
74+ }
75+ receive("#00ff00")
76+ select {
77+ case <-out:
78+ t.Fatal("duplicate reload")
79+ case <-time.After(250 * time.Millisecond):
80+ }
81+ write(path, "[background]\ncolor='bad'\n")
82+ select {
83+ case <-out:
84+ t.Fatal("invalid config sent")
85+ case <-time.After(500 * time.Millisecond):
86+ }
87+ if err := os.Remove(path); err != nil {
88+ t.Fatal(err)
89+ }
90+ select {
91+ case <-out:
92+ t.Fatal("removed config sent")
93+ case <-time.After(250 * time.Millisecond):
94+ }
95+ })
96+ }
97+}
@@ -0,0 +1,97 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/logging"
11+ "github.com/bnema/nefertty/internal/ports"
12+)
13+
14+func TestWatch(t *testing.T) {
15+ for _, later := range []bool{false, true} {
16+ t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) {
17+ dir := filepath.Join(t.TempDir(), "config")
18+ if !later {
19+ if err := os.Mkdir(dir, 0700); err != nil {
20+ t.Fatal(err)
21+ }
22+ }
23+ path := filepath.Join(dir, "config.toml")
24+ ctx, cancel := context.WithCancel(context.Background())
25+ out := make(chan ports.ConfigChanged, 8)
26+ done := make(chan error, 1)
27+ old := directoryPollInterval
28+ directoryPollInterval = 20 * time.Millisecond
29+ defer func() { directoryPollInterval = old }()
30+ go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
31+ defer func() {
32+ cancel()
33+ if err := <-done; err != nil {
34+ t.Error(err)
35+ }
36+ }()
37+ if later {
38+ time.Sleep(40 * time.Millisecond)
39+ if err := os.Mkdir(dir, 0700); err != nil {
40+ t.Fatal(err)
41+ }
42+ time.Sleep(80 * time.Millisecond)
43+ } else {
44+ time.Sleep(40 * time.Millisecond)
45+ }
46+ write := func(file, body string) {
47+ t.Helper()
48+ if err := os.WriteFile(file, []byte(body), 0600); err != nil {
49+ t.Fatal(err)
50+ }
51+ }
52+ write(path, "[background]\ncolor='#000000'\n")
53+ receive := func(want string) {
54+ t.Helper()
55+ select {
56+ case c := <-out:
57+ if c.Config.Background.Color != want {
58+ t.Fatalf("color %s, want %s", c.Config.Background.Color, want)
59+ }
60+ case <-time.After(time.Second):
61+ t.Fatal("no config change")
62+ }
63+ }
64+ receive("#000000")
65+ tmp := filepath.Join(dir, "temp")
66+ write(tmp, "[background]\ncolor='#ff0000'\n")
67+ if err := os.Rename(tmp, path); err != nil {
68+ t.Fatal(err)
69+ }
70+ receive("#ff0000")
71+ for i := 0; i < 3; i++ {
72+ write(path, "[background]\ncolor='#00ff00'\n")
73+ time.Sleep(15 * time.Millisecond)
74+ }
75+ receive("#00ff00")
76+ select {
77+ case <-out:
78+ t.Fatal("duplicate reload")
79+ case <-time.After(250 * time.Millisecond):
80+ }
81+ write(path, "[background]\ncolor='bad'\n")
82+ select {
83+ case <-out:
84+ t.Fatal("invalid config sent")
85+ case <-time.After(500 * time.Millisecond):
86+ }
87+ if err := os.Remove(path); err != nil {
88+ t.Fatal(err)
89+ }
90+ select {
91+ case <-out:
92+ t.Fatal("removed config sent")
93+ case <-time.After(250 * time.Millisecond):
94+ }
95+ })
96+ }
97+}
M internal/app/app.go +16 -5
@@ -11,6 +11,7 @@ import (
1111 "sync"
1212 "time"
1313
14+ "github.com/bnema/nefertty/internal/adapters/config"
1415 "github.com/bnema/nefertty/internal/adapters/headless"
1516 "github.com/bnema/nefertty/internal/adapters/headlessinput"
1617 "github.com/bnema/nefertty/internal/adapters/launcher"
@@ -25,6 +26,7 @@ import (
2526 type Options struct {
2627 Backend string
2728 Config ports.Config
29+ ConfigPath string
2830 Timeout time.Duration
2931 NoTerminal bool
3032 ScreenshotDir string
@@ -50,14 +52,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
5052 client := make(chan ports.ClientEvent, 32)
5153 input := make(chan ports.InputEvent, 32)
5254 output := make(chan ports.OutputEvent, 32)
53- config := make(chan ports.ConfigChanged, 8)
55+ configChanges := make(chan ports.ConfigChanged, 8)
5456 commands := make(chan ports.ClientCommand, 32)
5557 spawn := make(chan ports.SpawnRequest, 32)
5658 scenes := make(chan ports.Scene, 1)
5759 configErrors := make(chan error, 8)
5860 renderScenes := make(chan ports.Scene, 1)
5961 contents := make(chan ports.SurfaceContent, 64)
60- ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}
62+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}
6163 c, err := core.New(opts.Config, ch)
6264 if err != nil {
6365 return err
@@ -83,8 +85,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8385 inject(input)
8486 }
8587 var workers sync.WaitGroup
86- workers.Add(6)
87- done := make(chan error, 6)
88+ workers.Add(7)
89+ done := make(chan error, 7)
90+ path := opts.ConfigPath
91+ if path == "" {
92+ path = config.DefaultPath()
93+ }
94+ go func() {
95+ defer workers.Done()
96+ done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config"))
97+ }()
8898 script := make(chan string)
8999 go func() {
90100 defer workers.Done()
@@ -147,6 +157,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
147157
148158 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
149159 log := logging.For(ctx, "core")
160+ configLog := logging.For(ctx, "config")
150161 for {
151162 select {
152163 case <-ctx.Done():
@@ -172,7 +183,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
172183 }
173184 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
174185 case err := <-configErrors:
175- log.Debug().Err(err).Msg("config rejected")
186+ configLog.Warn().Err(err).Msg("config rejected")
176187 }
177188 }
178189 }
@@ -11,6 +11,7 @@ import (
11 "sync"11 "sync"
12 "time"12 "time"
13 13
14+ "github.com/bnema/nefertty/internal/adapters/config"
14 "github.com/bnema/nefertty/internal/adapters/headless"15 "github.com/bnema/nefertty/internal/adapters/headless"
15 "github.com/bnema/nefertty/internal/adapters/headlessinput"16 "github.com/bnema/nefertty/internal/adapters/headlessinput"
16 "github.com/bnema/nefertty/internal/adapters/launcher"17 "github.com/bnema/nefertty/internal/adapters/launcher"
@@ -25,6 +26,7 @@ import (
25 type Options struct {26 type Options struct {
26 Backend string27 Backend string
27 Config ports.Config28 Config ports.Config
29+ ConfigPath string
28 Timeout time.Duration30 Timeout time.Duration
29 NoTerminal bool31 NoTerminal bool
30 ScreenshotDir string32 ScreenshotDir string
@@ -50,14 +52,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
50 client := make(chan ports.ClientEvent, 32)52 client := make(chan ports.ClientEvent, 32)
51 input := make(chan ports.InputEvent, 32)53 input := make(chan ports.InputEvent, 32)
52 output := make(chan ports.OutputEvent, 32)54 output := make(chan ports.OutputEvent, 32)
53- config := make(chan ports.ConfigChanged, 8)55+ configChanges := make(chan ports.ConfigChanged, 8)
54 commands := make(chan ports.ClientCommand, 32)56 commands := make(chan ports.ClientCommand, 32)
55 spawn := make(chan ports.SpawnRequest, 32)57 spawn := make(chan ports.SpawnRequest, 32)
56 scenes := make(chan ports.Scene, 1)58 scenes := make(chan ports.Scene, 1)
57 configErrors := make(chan error, 8)59 configErrors := make(chan error, 8)
58 renderScenes := make(chan ports.Scene, 1)60 renderScenes := make(chan ports.Scene, 1)
59 contents := make(chan ports.SurfaceContent, 64)61 contents := make(chan ports.SurfaceContent, 64)
60- ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}62+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}
61 c, err := core.New(opts.Config, ch)63 c, err := core.New(opts.Config, ch)
62 if err != nil {64 if err != nil {
63 return err65 return err
@@ -83,8 +85,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
83 inject(input)85 inject(input)
84 }86 }
85 var workers sync.WaitGroup87 var workers sync.WaitGroup
86- workers.Add(6)88+ workers.Add(7)
87- done := make(chan error, 6)89+ done := make(chan error, 7)
90+ path := opts.ConfigPath
91+ if path == "" {
92+ path = config.DefaultPath()
93+ }
94+ go func() {
95+ defer workers.Done()
96+ done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config"))
97+ }()
88 script := make(chan string)98 script := make(chan string)
89 go func() {99 go func() {
90 defer workers.Done()100 defer workers.Done()
@@ -147,6 +157,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
147 157
148 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {158 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
149 log := logging.For(ctx, "core")159 log := logging.For(ctx, "core")
160+ configLog := logging.For(ctx, "config")
150 for {161 for {
151 select {162 select {
152 case <-ctx.Done():163 case <-ctx.Done():
@@ -172,7 +183,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
172 }183 }
173 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")184 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
174 case err := <-configErrors:185 case err := <-configErrors:
175- log.Debug().Err(err).Msg("config rejected")186+ configLog.Warn().Err(err).Msg("config rejected")
176 }187 }
177 }188 }
178 }189 }
M internal/app/app_test.go +69 -2
@@ -2,8 +2,7 @@ package app
22
33 import (
44 "context"
5- "github.com/bnema/nefertty/internal/adapters/config"
6- "github.com/bnema/nefertty/internal/ports"
5+ "image/color"
76 "image/png"
87 "os"
98 "os/exec"
@@ -11,8 +10,76 @@ import (
1110 "strings"
1211 "testing"
1312 "time"
13+
14+ "github.com/bnema/nefertty/internal/adapters/config"
15+ "github.com/bnema/nefertty/internal/ports"
1416 )
1517
18+func TestHeadlessConfigReload(t *testing.T) {
19+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
20+ dir := t.TempDir()
21+ path := filepath.Join(dir, "config.toml")
22+ if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil {
23+ t.Fatal(err)
24+ }
25+ cfg, err := config.Load(path)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ shots := t.TempDir()
30+ ctx, cancel := context.WithCancel(context.Background())
31+ done := make(chan error, 1)
32+ go func() {
33+ done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
34+ }()
35+ defer func() {
36+ cancel()
37+ select {
38+ case err := <-done:
39+ if err != nil {
40+ t.Errorf("Run: %v", err)
41+ }
42+ case <-time.After(3 * time.Second):
43+ t.Error("Run did not stop")
44+ }
45+ }()
46+ latest := filepath.Join(shots, "latest.png")
47+ waitColor := func(want color.RGBA, deadline time.Time) bool {
48+ for time.Now().Before(deadline) {
49+ f, err := os.Open(latest)
50+ if err == nil {
51+ img, decodeErr := png.Decode(f)
52+ f.Close()
53+ if decodeErr == nil {
54+ r, g, b, _ := img.At(1900, 10).RGBA()
55+ if uint8(r>>8) == want.R && uint8(g>>8) == want.G && uint8(b>>8) == want.B {
56+ return true
57+ }
58+ }
59+ }
60+ select {
61+ case err := <-done:
62+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
63+ t.Skipf("Vulkan unavailable: %v", err)
64+ }
65+ t.Fatalf("app exited early: %v", err)
66+ default:
67+ }
68+ time.Sleep(20 * time.Millisecond)
69+ }
70+ return false
71+ }
72+ if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {
73+ t.Fatal("first black frame missing")
74+ }
75+ if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil {
76+ t.Fatal(err)
77+ }
78+ if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {
79+ t.Fatal("red frame missing after reload")
80+ }
81+}
82+
1683 func TestQuitJoinsWorkers(t *testing.T) {
1784 done := make(chan error, 1)
1885 go func() {
@@ -2,8 +2,7 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5- "github.com/bnema/nefertty/internal/adapters/config"5+ "image/color"
6- "github.com/bnema/nefertty/internal/ports"
7 "image/png"6 "image/png"
8 "os"7 "os"
9 "os/exec"8 "os/exec"
@@ -11,8 +10,76 @@ import (
11 "strings"10 "strings"
12 "testing"11 "testing"
13 "time"12 "time"
13+
14+ "github.com/bnema/nefertty/internal/adapters/config"
15+ "github.com/bnema/nefertty/internal/ports"
14 )16 )
15 17
18+func TestHeadlessConfigReload(t *testing.T) {
19+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
20+ dir := t.TempDir()
21+ path := filepath.Join(dir, "config.toml")
22+ if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil {
23+ t.Fatal(err)
24+ }
25+ cfg, err := config.Load(path)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ shots := t.TempDir()
30+ ctx, cancel := context.WithCancel(context.Background())
31+ done := make(chan error, 1)
32+ go func() {
33+ done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
34+ }()
35+ defer func() {
36+ cancel()
37+ select {
38+ case err := <-done:
39+ if err != nil {
40+ t.Errorf("Run: %v", err)
41+ }
42+ case <-time.After(3 * time.Second):
43+ t.Error("Run did not stop")
44+ }
45+ }()
46+ latest := filepath.Join(shots, "latest.png")
47+ waitColor := func(want color.RGBA, deadline time.Time) bool {
48+ for time.Now().Before(deadline) {
49+ f, err := os.Open(latest)
50+ if err == nil {
51+ img, decodeErr := png.Decode(f)
52+ f.Close()
53+ if decodeErr == nil {
54+ r, g, b, _ := img.At(1900, 10).RGBA()
55+ if uint8(r>>8) == want.R && uint8(g>>8) == want.G && uint8(b>>8) == want.B {
56+ return true
57+ }
58+ }
59+ }
60+ select {
61+ case err := <-done:
62+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
63+ t.Skipf("Vulkan unavailable: %v", err)
64+ }
65+ t.Fatalf("app exited early: %v", err)
66+ default:
67+ }
68+ time.Sleep(20 * time.Millisecond)
69+ }
70+ return false
71+ }
72+ if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {
73+ t.Fatal("first black frame missing")
74+ }
75+ if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil {
76+ t.Fatal(err)
77+ }
78+ if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {
79+ t.Fatal("red frame missing after reload")
80+ }
81+}
82+
16 func TestQuitJoinsWorkers(t *testing.T) {83 func TestQuitJoinsWorkers(t *testing.T) {
17 done := make(chan error, 1)84 done := make(chan error, 1)
18 go func() {85 go func() {

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 25

Tags 10

↑↓ moveenter openesc close