detached 32e241e

Merge pull request 'refactor(watch): replace internal/fswatch with kvconf' (#5) from refactor/kvconf into main

Reviewed-on: https://git.bnema.dev/bnema/neferbar/pulls/5
commit
32e241eec84f852851ef1f822c983d85016c1d87
author
bnema · · 2026-10-10 07:35 UTC
merge of
da0887c + 52395af · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
6 files · +8 -278
M go.mod +1 -0
@@ -5,6 +5,7 @@ go 1.27
55 require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/bnema/go-wayland-bindings v0.1.0
8+ github.com/bnema/kvconf v0.1.2
89 github.com/bnema/neferclient v0.4.0
910 github.com/bnema/nefergui v0.7.0
1011 github.com/bnema/purego-vulkan v0.6.0
@@ -5,6 +5,7 @@ go 1.27
5 require (5 require (
6 github.com/BurntSushi/toml v1.6.06 github.com/BurntSushi/toml v1.6.0
7 github.com/bnema/go-wayland-bindings v0.1.07 github.com/bnema/go-wayland-bindings v0.1.0
8+ github.com/bnema/kvconf v0.1.2
8 github.com/bnema/neferclient v0.4.09 github.com/bnema/neferclient v0.4.0
9 github.com/bnema/nefergui v0.7.010 github.com/bnema/nefergui v0.7.0
10 github.com/bnema/purego-vulkan v0.6.011 github.com/bnema/purego-vulkan v0.6.0
M go.sum +2 -0 generated file: collapsed
M internal/bar/bar.go +3 -3
@@ -22,8 +22,8 @@ import (
2222
2323 "github.com/bnema/neferclient"
2424
25+ "github.com/bnema/kvconf"
2526 "github.com/bnema/neferbar/internal/config"
26- "github.com/bnema/neferbar/internal/fswatch"
2727 "github.com/bnema/neferbar/internal/glyph"
2828 "github.com/bnema/neferbar/internal/gpu"
2929 "github.com/bnema/neferbar/internal/layout"
@@ -212,7 +212,7 @@ func (b *Bar) watchTheme() {
212212 b.themeCancel = cancel
213213 for _, f := range b.theme.Files {
214214 go func() {
215- if err := fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh); err != nil {
215+ if err := kvconf.Watch(ctx, f, kvconf.WatchOptions{Debounce: 150 * time.Millisecond}, b.themeCh); err != nil {
216216 b.log.Warn("cannot watch a theme file; edits to it need a restart", "file", f, "err", err)
217217 }
218218 }()
@@ -241,7 +241,7 @@ func (b *Bar) watchScripts() {
241241 go func() {
242242 changed := make(chan struct{}, 1)
243243 go func() {
244- if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil {
244+ if err := kvconf.Watch(ctx, dir, kvconf.WatchOptions{Debounce: 150 * time.Millisecond, Dir: true}, changed); err != nil {
245245 b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)
246246 }
247247 }()
@@ -22,8 +22,8 @@ import (
22 22
23 "github.com/bnema/neferclient"23 "github.com/bnema/neferclient"
24 24
25+ "github.com/bnema/kvconf"
25 "github.com/bnema/neferbar/internal/config"26 "github.com/bnema/neferbar/internal/config"
26- "github.com/bnema/neferbar/internal/fswatch"
27 "github.com/bnema/neferbar/internal/glyph"27 "github.com/bnema/neferbar/internal/glyph"
28 "github.com/bnema/neferbar/internal/gpu"28 "github.com/bnema/neferbar/internal/gpu"
29 "github.com/bnema/neferbar/internal/layout"29 "github.com/bnema/neferbar/internal/layout"
@@ -212,7 +212,7 @@ func (b *Bar) watchTheme() {
212 b.themeCancel = cancel212 b.themeCancel = cancel
213 for _, f := range b.theme.Files {213 for _, f := range b.theme.Files {
214 go func() {214 go func() {
215- if err := fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh); err != nil {215+ if err := kvconf.Watch(ctx, f, kvconf.WatchOptions{Debounce: 150 * time.Millisecond}, b.themeCh); err != nil {
216 b.log.Warn("cannot watch a theme file; edits to it need a restart", "file", f, "err", err)216 b.log.Warn("cannot watch a theme file; edits to it need a restart", "file", f, "err", err)
217 }217 }
218 }()218 }()
@@ -241,7 +241,7 @@ func (b *Bar) watchScripts() {
241 go func() {241 go func() {
242 changed := make(chan struct{}, 1)242 changed := make(chan struct{}, 1)
243 go func() {243 go func() {
244- if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil {244+ if err := kvconf.Watch(ctx, dir, kvconf.WatchOptions{Debounce: 150 * time.Millisecond, Dir: true}, changed); err != nil {
245 b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)245 b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)
246 }246 }
247 }()247 }()
M internal/config/watch.go +2 -2
@@ -8,7 +8,7 @@ import (
88 "reflect"
99 "time"
1010
11- "github.com/bnema/neferbar/internal/fswatch"
11+ "github.com/bnema/kvconf"
1212 )
1313
1414 // debounce waits for a save to finish before the file is read.
@@ -24,7 +24,7 @@ func Watch(ctx context.Context, path string, last Config, out chan<- Config, log
2424 wctx, cancel := context.WithCancel(ctx)
2525 defer cancel()
2626 errc := make(chan error, 1)
27- go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }()
27+ go func() { errc <- kvconf.Watch(wctx, path, kvconf.WatchOptions{Debounce: debounce}, changed) }()
2828
2929 for {
3030 select {
@@ -8,7 +8,7 @@ import (
8 "reflect"8 "reflect"
9 "time"9 "time"
10 10
11- "github.com/bnema/neferbar/internal/fswatch"11+ "github.com/bnema/kvconf"
12 )12 )
13 13
14 // debounce waits for a save to finish before the file is read.14 // debounce waits for a save to finish before the file is read.
@@ -24,7 +24,7 @@ func Watch(ctx context.Context, path string, last Config, out chan<- Config, log
24 wctx, cancel := context.WithCancel(ctx)24 wctx, cancel := context.WithCancel(ctx)
25 defer cancel()25 defer cancel()
26 errc := make(chan error, 1)26 errc := make(chan error, 1)
27- go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }()27+ go func() { errc <- kvconf.Watch(wctx, path, kvconf.WatchOptions{Debounce: debounce}, changed) }()
28 28
29 for {29 for {
30 select {30 select {
D internal/fswatch/fswatch.go +0 -185
@@ -1,185 +0,0 @@
1-// Package fswatch reports changes to one file, or to the files of one
2-// directory, with inotify.
3-//
4-// It watches the parent directory, so a file that is replaced by a rename (the
5-// way editors and NeferWL write) is noticed, and it follows symlinks, so a
6-// dotfile-managed file is noticed at its target.
7-package fswatch
8-
9-import (
10- "context"
11- "errors"
12- "os"
13- "path/filepath"
14- "slices"
15- "time"
16- "unsafe"
17-
18- "golang.org/x/sys/unix"
19-)
20-
21-const (
22- // retryEvery is how often a missing directory is looked for again.
23- retryEvery = 2 * time.Second
24- // pollEvery bounds how long a wait lasts, so ctx is noticed.
25- pollEvery = 200 // ms
26- // mask is every way a file can be written, replaced or removed. IN_MODIFY
27- // restarts the debounce on each write, so a truncating in-place save is
28- // never read half written.
29- mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
30-)
31-
32-// files lists path and every symlink target it goes through.
33-func files(path string) []string {
34- out := []string{filepath.Clean(path)}
35- for len(out) <= 40 { // the kernel's symlink limit
36- cur := out[len(out)-1]
37- target, err := os.Readlink(cur)
38- if err != nil {
39- break
40- }
41- if !filepath.IsAbs(target) {
42- target = filepath.Join(filepath.Dir(cur), target)
43- }
44- target = filepath.Clean(target)
45- if slices.Contains(out, target) {
46- break
47- }
48- out = append(out, target)
49- }
50- return out
51-}
52-
53-// Watch sends on out after path changed and stayed quiet for debounce. A
54-// debounce of 0 reports each change at once. The send never blocks: if out is
55-// full, the pending signal already says "something changed", which is all a
56-// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
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 {
75- fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
76- if err != nil {
77- return err
78- }
79- defer unix.Close(fd)
80-
81- names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
82- var watched []string
83- var retry, pending time.Time
84- rewatch := true
85- buf := make([]byte, 4096)
86- fds := make([]unix.PollFd, 1)
87-
88- for ctx.Err() == nil {
89- if !retry.IsZero() && !time.Now().Before(retry) {
90- rewatch = true
91- }
92- if rewatch {
93- rewatch, retry = false, time.Time{}
94- for wd := range names {
95- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
96- }
97- clear(names)
98- watched = files(path)
99- if dirMode {
100- watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself
101- }
102- for _, file := range watched {
103- wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
104- if errors.Is(err, unix.ENOENT) {
105- retry = time.Now().Add(retryEvery)
106- continue
107- }
108- if err != nil {
109- return err
110- }
111- if names[int32(wd)] == nil {
112- names[int32(wd)] = map[string]bool{}
113- }
114- if !dirMode {
115- names[int32(wd)][filepath.Base(file)] = true
116- }
117- }
118- }
119-
120- timeout := pollEvery
121- if !retry.IsZero() {
122- timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
123- }
124- if !pending.IsZero() {
125- remaining := time.Until(pending)
126- if remaining <= 0 {
127- pending = time.Time{}
128- select {
129- case out <- struct{}{}:
130- default:
131- }
132- continue
133- }
134- timeout = min(timeout, int(remaining.Milliseconds())+1)
135- }
136-
137- fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
138- if _, err = unix.Poll(fds, timeout); err != nil {
139- if err == unix.EINTR {
140- continue
141- }
142- return err
143- }
144- if fds[0].Revents&unix.POLLIN == 0 {
145- continue
146- }
147- for {
148- n, err := unix.Read(fd, buf)
149- if err == unix.EAGAIN || n == 0 {
150- break
151- }
152- if err == unix.EINTR {
153- continue
154- }
155- if err != nil {
156- return err
157- }
158- for off := 0; off+unix.SizeofInotifyEvent <= n; {
159- ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
160- size := unix.SizeofInotifyEvent + int(ev.Len)
161- if off+size > n {
162- break
163- }
164- // One of our watches vanished (its directory was removed).
165- if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
166- rewatch = true
167- }
168- if ev.Len > 0 {
169- name := buf[off+unix.SizeofInotifyEvent : off+size]
170- for len(name) > 0 && name[len(name)-1] == 0 {
171- name = name[:len(name)-1]
172- }
173- if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) {
174- pending = time.Now().Add(debounce)
175- if !dirMode && !slices.Equal(watched, files(path)) {
176- rewatch = true // a symlink now points elsewhere
177- }
178- }
179- }
180- off += size
181- }
182- }
183- }
184- return nil
185-}
@@ -1,185 +0,0 @@
1-// Package fswatch reports changes to one file, or to the files of one
2-// directory, with inotify.
3-//
4-// It watches the parent directory, so a file that is replaced by a rename (the
5-// way editors and NeferWL write) is noticed, and it follows symlinks, so a
6-// dotfile-managed file is noticed at its target.
7-package fswatch
8-
9-import (
10- "context"
11- "errors"
12- "os"
13- "path/filepath"
14- "slices"
15- "time"
16- "unsafe"
17-
18- "golang.org/x/sys/unix"
19-)
20-
21-const (
22- // retryEvery is how often a missing directory is looked for again.
23- retryEvery = 2 * time.Second
24- // pollEvery bounds how long a wait lasts, so ctx is noticed.
25- pollEvery = 200 // ms
26- // mask is every way a file can be written, replaced or removed. IN_MODIFY
27- // restarts the debounce on each write, so a truncating in-place save is
28- // never read half written.
29- mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
30-)
31-
32-// files lists path and every symlink target it goes through.
33-func files(path string) []string {
34- out := []string{filepath.Clean(path)}
35- for len(out) <= 40 { // the kernel's symlink limit
36- cur := out[len(out)-1]
37- target, err := os.Readlink(cur)
38- if err != nil {
39- break
40- }
41- if !filepath.IsAbs(target) {
42- target = filepath.Join(filepath.Dir(cur), target)
43- }
44- target = filepath.Clean(target)
45- if slices.Contains(out, target) {
46- break
47- }
48- out = append(out, target)
49- }
50- return out
51-}
52-
53-// Watch sends on out after path changed and stayed quiet for debounce. A
54-// debounce of 0 reports each change at once. The send never blocks: if out is
55-// full, the pending signal already says "something changed", which is all a
56-// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
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 {
75- fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
76- if err != nil {
77- return err
78- }
79- defer unix.Close(fd)
80-
81- names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
82- var watched []string
83- var retry, pending time.Time
84- rewatch := true
85- buf := make([]byte, 4096)
86- fds := make([]unix.PollFd, 1)
87-
88- for ctx.Err() == nil {
89- if !retry.IsZero() && !time.Now().Before(retry) {
90- rewatch = true
91- }
92- if rewatch {
93- rewatch, retry = false, time.Time{}
94- for wd := range names {
95- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
96- }
97- clear(names)
98- watched = files(path)
99- if dirMode {
100- watched = []string{filepath.Join(path, "x")} // its Dir is the directory itself
101- }
102- for _, file := range watched {
103- wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
104- if errors.Is(err, unix.ENOENT) {
105- retry = time.Now().Add(retryEvery)
106- continue
107- }
108- if err != nil {
109- return err
110- }
111- if names[int32(wd)] == nil {
112- names[int32(wd)] = map[string]bool{}
113- }
114- if !dirMode {
115- names[int32(wd)][filepath.Base(file)] = true
116- }
117- }
118- }
119-
120- timeout := pollEvery
121- if !retry.IsZero() {
122- timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
123- }
124- if !pending.IsZero() {
125- remaining := time.Until(pending)
126- if remaining <= 0 {
127- pending = time.Time{}
128- select {
129- case out <- struct{}{}:
130- default:
131- }
132- continue
133- }
134- timeout = min(timeout, int(remaining.Milliseconds())+1)
135- }
136-
137- fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
138- if _, err = unix.Poll(fds, timeout); err != nil {
139- if err == unix.EINTR {
140- continue
141- }
142- return err
143- }
144- if fds[0].Revents&unix.POLLIN == 0 {
145- continue
146- }
147- for {
148- n, err := unix.Read(fd, buf)
149- if err == unix.EAGAIN || n == 0 {
150- break
151- }
152- if err == unix.EINTR {
153- continue
154- }
155- if err != nil {
156- return err
157- }
158- for off := 0; off+unix.SizeofInotifyEvent <= n; {
159- ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
160- size := unix.SizeofInotifyEvent + int(ev.Len)
161- if off+size > n {
162- break
163- }
164- // One of our watches vanished (its directory was removed).
165- if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
166- rewatch = true
167- }
168- if ev.Len > 0 {
169- name := buf[off+unix.SizeofInotifyEvent : off+size]
170- for len(name) > 0 && name[len(name)-1] == 0 {
171- name = name[:len(name)-1]
172- }
173- if ev.Mask&mask != 0 && (names[ev.Wd][string(name)] || (dirMode && names[ev.Wd] != nil && visible(string(name)))) {
174- pending = time.Now().Add(debounce)
175- if !dirMode && !slices.Equal(watched, files(path)) {
176- rewatch = true // a symlink now points elsewhere
177- }
178- }
179- }
180- off += size
181- }
182- }
183- }
184- return nil
185-}
D internal/fswatch/fswatch_test.go +0 -88
@@ -1,88 +0,0 @@
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-}
@@ -1,88 +0,0 @@
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-}

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