detached 3462c76

fix(config): read the config without blocking and tolerate invalid UTF-8

Load and the watcher read through kvconf.ReadFile, so a FIFO in place of
the config fails instead of blocking startup or shutdown. An invalid
UTF-8 byte is replaced and reported as a warning at its line instead of
rejecting the whole file. The docs describe how a '#' inside a quoted
word stays in the value.

Tests cover invalid UTF-8, output names with spaces when parsing and
saving, quoted comments and a FIFO config.
commit
3462c76a87a216819fb81a2783151821ff186620
author
brice · · 2026-10-10 08:31 UTC
parent
6282a69
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
refactor/kvconf
7 files · +125 -27
M docs/config.md +1 -1
@@ -4,7 +4,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
44
55 ## Format
66
7-- One `key = value` per line; `#` starts a comment.
7+- One `key = value` per line. A line starting with `#` is a comment, and so is a `#` after a space, unless it is inside a quoted word (`"a # b"`).
88 - Lists are comma-separated; booleans are `on` / `off`.
99 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.
1010 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.
@@ -4,7 +4,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4 4
5 ## Format5 ## Format
6 6
7-- One `key = value` per line; `#` starts a comment.7+- One `key = value` per line. A line starting with `#` is a comment, and so is a `#` after a space, unless it is inside a quoted word (`"a # b"`).
8 - Lists are comma-separated; booleans are `on` / `off`.8 - Lists are comma-separated; booleans are `on` / `off`.
9 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.9 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.
10 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.10 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.
M internal/adapters/config/config.go +36 -11
@@ -2,9 +2,11 @@
22 package config
33
44 import (
5+ "bytes"
56 "errors"
67 "fmt"
78 "io"
9+ "io/fs"
810 "math"
911 "os"
1012 "path/filepath"
@@ -15,6 +17,7 @@ import (
1517 "strings"
1618 "time"
1719 "unicode"
20+ "unicode/utf8"
1821
1922 "github.com/bnema/kvconf"
2023 "github.com/bnema/neferwl/internal/ports"
@@ -222,7 +225,7 @@ func Defaults() ports.Config {
222225 // LoadDefault loads DefaultPath; a missing file means defaults.
223226 func LoadDefault() (ports.Config, []Warning, error) {
224227 c, w, err := Load(DefaultPath())
225- if os.IsNotExist(err) {
228+ if errors.Is(err, fs.ErrNotExist) {
226229 return Defaults(), nil, nil
227230 }
228231 return c, w, err
@@ -230,12 +233,20 @@ func LoadDefault() (ports.Config, []Warning, error) {
230233
231234 // Load reads a config file. The error is only for I/O; bad lines become warnings.
232235 func Load(path string) (ports.Config, []Warning, error) {
233- f, err := os.Open(path)
236+ c, _, w, err := loadFile(path)
234237 if err != nil {
235238 return Defaults(), nil, err
236239 }
237- defer f.Close()
238- return Parse(f)
240+ return c, w, nil
241+}
242+
243+// loadFile reads path without blocking on a FIFO or device, then parses it.
244+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
245+ data, err := kvconf.ReadFile(path, kvconf.Options{MaxBytes: -1})
246+ if err != nil {
247+ return ports.Config{}, nil, nil, err
248+ }
249+ return parseBytes(data)
239250 }
240251
241252 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
@@ -317,12 +328,17 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
317328
318329 // Parse reads `key = value` lines on top of the defaults.
319330 func Parse(r io.Reader) (ports.Config, []Warning, error) {
320- c, _, w, err := parse(r)
331+ data, err := io.ReadAll(r)
332+ if err != nil {
333+ return Defaults(), nil, err
334+ }
335+ c, _, w, err := parseBytes(data)
321336 return c, w, err
322337 }
323338
324-// parse also returns the effective key/value pairs, used to log what changed on reload.
325-func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
339+// parseBytes also returns the effective key/value pairs, used to log what
340+// changed on reload.
341+func parseBytes(data []byte) (ports.Config, map[string]string, []Warning, error) {
326342 c := Defaults()
327343 var warnings []Warning
328344 raw := map[string]string{}
@@ -333,12 +349,21 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333349 workspaces := map[string]int{}
334350 rules := map[string]*ruleEntry{}
335351 var ruleOrder []*ruleEntry
352+ // An invalid byte (a Latin-1 comment, say) costs a warning, not the
353+ // whole file: the rest still applies, like any other bad line.
354+ if !utf8.Valid(data) {
355+ line := 1
356+ for _, l := range bytes.Split(data, []byte("\n")) {
357+ if !utf8.Valid(l) {
358+ break
359+ }
360+ line++
361+ }
362+ data = bytes.ToValidUTF8(data, []byte("\uFFFD"))
363+ warnings = append(warnings, Warning{Line: line, Msg: "not valid UTF-8; invalid bytes replaced"})
364+ }
336365 // kvconf owns the line syntax; the values stay raw (neferwl has no
337366 // quoting) and repeated keys are reported below with their own wording.
338- data, err := io.ReadAll(r)
339- if err != nil {
340- return c, raw, nil, err
341- }
342367 entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
343368 if err != nil {
344369 return c, raw, nil, err
@@ -2,9 +2,11 @@
2 package config2 package config
3 3
4 import (4 import (
5+ "bytes"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
9+ "io/fs"
8 "math"10 "math"
9 "os"11 "os"
10 "path/filepath"12 "path/filepath"
@@ -15,6 +17,7 @@ import (
15 "strings"17 "strings"
16 "time"18 "time"
17 "unicode"19 "unicode"
20+ "unicode/utf8"
18 21
19 "github.com/bnema/kvconf"22 "github.com/bnema/kvconf"
20 "github.com/bnema/neferwl/internal/ports"23 "github.com/bnema/neferwl/internal/ports"
@@ -222,7 +225,7 @@ func Defaults() ports.Config {
222 // LoadDefault loads DefaultPath; a missing file means defaults.225 // LoadDefault loads DefaultPath; a missing file means defaults.
223 func LoadDefault() (ports.Config, []Warning, error) {226 func LoadDefault() (ports.Config, []Warning, error) {
224 c, w, err := Load(DefaultPath())227 c, w, err := Load(DefaultPath())
225- if os.IsNotExist(err) {228+ if errors.Is(err, fs.ErrNotExist) {
226 return Defaults(), nil, nil229 return Defaults(), nil, nil
227 }230 }
228 return c, w, err231 return c, w, err
@@ -230,12 +233,20 @@ func LoadDefault() (ports.Config, []Warning, error) {
230 233
231 // Load reads a config file. The error is only for I/O; bad lines become warnings.234 // Load reads a config file. The error is only for I/O; bad lines become warnings.
232 func Load(path string) (ports.Config, []Warning, error) {235 func Load(path string) (ports.Config, []Warning, error) {
233- f, err := os.Open(path)236+ c, _, w, err := loadFile(path)
234 if err != nil {237 if err != nil {
235 return Defaults(), nil, err238 return Defaults(), nil, err
236 }239 }
237- defer f.Close()240+ return c, w, nil
238- return Parse(f)241+}
242+
243+// loadFile reads path without blocking on a FIFO or device, then parses it.
244+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
245+ data, err := kvconf.ReadFile(path, kvconf.Options{MaxBytes: -1})
246+ if err != nil {
247+ return ports.Config{}, nil, nil, err
248+ }
249+ return parseBytes(data)
239 }250 }
240 251
241 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any252 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
@@ -317,12 +328,17 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
317 328
318 // Parse reads `key = value` lines on top of the defaults.329 // Parse reads `key = value` lines on top of the defaults.
319 func Parse(r io.Reader) (ports.Config, []Warning, error) {330 func Parse(r io.Reader) (ports.Config, []Warning, error) {
320- c, _, w, err := parse(r)331+ data, err := io.ReadAll(r)
332+ if err != nil {
333+ return Defaults(), nil, err
334+ }
335+ c, _, w, err := parseBytes(data)
321 return c, w, err336 return c, w, err
322 }337 }
323 338
324-// parse also returns the effective key/value pairs, used to log what changed on reload.339+// parseBytes also returns the effective key/value pairs, used to log what
325-func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {340+// changed on reload.
341+func parseBytes(data []byte) (ports.Config, map[string]string, []Warning, error) {
326 c := Defaults()342 c := Defaults()
327 var warnings []Warning343 var warnings []Warning
328 raw := map[string]string{}344 raw := map[string]string{}
@@ -333,12 +349,21 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333 workspaces := map[string]int{}349 workspaces := map[string]int{}
334 rules := map[string]*ruleEntry{}350 rules := map[string]*ruleEntry{}
335 var ruleOrder []*ruleEntry351 var ruleOrder []*ruleEntry
352+ // An invalid byte (a Latin-1 comment, say) costs a warning, not the
353+ // whole file: the rest still applies, like any other bad line.
354+ if !utf8.Valid(data) {
355+ line := 1
356+ for _, l := range bytes.Split(data, []byte("\n")) {
357+ if !utf8.Valid(l) {
358+ break
359+ }
360+ line++
361+ }
362+ data = bytes.ToValidUTF8(data, []byte("\uFFFD"))
363+ warnings = append(warnings, Warning{Line: line, Msg: "not valid UTF-8; invalid bytes replaced"})
364+ }
336 // kvconf owns the line syntax; the values stay raw (neferwl has no365 // kvconf owns the line syntax; the values stay raw (neferwl has no
337 // quoting) and repeated keys are reported below with their own wording.366 // quoting) and repeated keys are reported below with their own wording.
338- data, err := io.ReadAll(r)
339- if err != nil {
340- return c, raw, nil, err
341- }
342 entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})367 entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
343 if err != nil {368 if err != nil {
344 return c, raw, nil, err369 return c, raw, nil, err
M internal/adapters/config/config_test.go +69 -0
@@ -7,6 +7,7 @@ import (
77 "path/filepath"
88 "reflect"
99 "strings"
10+ "syscall"
1011 "testing"
1112 "time"
1213
@@ -831,3 +832,71 @@ func TestOutputModeLineKeepsAnchor(t *testing.T) {
831832 t.Fatalf("%+v", o)
832833 }
833834 }
835+
836+func TestFileSyntax(t *testing.T) {
837+ for _, tc := range []struct {
838+ name, in string
839+ check func(t *testing.T, c ports.Config)
840+ warnings []string
841+ }{
842+ {
843+ name: "invalid UTF-8 costs a warning, not the file",
844+ in: "# caf\xe9\nborder.width = 5\n",
845+ check: func(t *testing.T, c ports.Config) {
846+ if c.Border.Width != 5 {
847+ t.Fatalf("border.width = %d", c.Border.Width)
848+ }
849+ },
850+ warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
851+ },
852+ {
853+ name: "output name with spaces",
854+ in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
855+ check: func(t *testing.T, c ports.Config) {
856+ if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
857+ t.Fatalf("%+v", c.Outputs)
858+ }
859+ },
860+ },
861+ {
862+ // A '#' inside a quoted word is kept, as a shell would.
863+ name: "comment after a quoted word",
864+ in: "startup = notify-send \"a # b\" # note\n",
865+ check: func(t *testing.T, c ports.Config) {
866+ if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
867+ t.Fatalf("startup: %q", c.Startup)
868+ }
869+ },
870+ },
871+ } {
872+ t.Run(tc.name, func(t *testing.T) {
873+ c, w := parseString(t, tc.in)
874+ got := make([]string, 0, len(w))
875+ for _, x := range w {
876+ got = append(got, x.String())
877+ }
878+ if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
879+ t.Fatalf("warnings %q, want %q", got, tc.warnings)
880+ }
881+ tc.check(t, c)
882+ })
883+ }
884+}
885+
886+// A FIFO in place of the config fails at once instead of blocking startup.
887+func TestLoadRejectsFIFO(t *testing.T) {
888+ path := filepath.Join(t.TempDir(), "config")
889+ if err := syscall.Mkfifo(path, 0o600); err != nil {
890+ t.Skip(err)
891+ }
892+ done := make(chan error, 1)
893+ go func() { _, _, err := Load(path); done <- err }()
894+ select {
895+ case err := <-done:
896+ if err == nil {
897+ t.Fatal("FIFO loaded")
898+ }
899+ case <-time.After(5 * time.Second):
900+ t.Fatal("Load blocked on a FIFO")
901+ }
902+}
@@ -7,6 +7,7 @@ import (
7 "path/filepath"7 "path/filepath"
8 "reflect"8 "reflect"
9 "strings"9 "strings"
10+ "syscall"
10 "testing"11 "testing"
11 "time"12 "time"
12 13
@@ -831,3 +832,71 @@ func TestOutputModeLineKeepsAnchor(t *testing.T) {
831 t.Fatalf("%+v", o)832 t.Fatalf("%+v", o)
832 }833 }
833 }834 }
835+
836+func TestFileSyntax(t *testing.T) {
837+ for _, tc := range []struct {
838+ name, in string
839+ check func(t *testing.T, c ports.Config)
840+ warnings []string
841+ }{
842+ {
843+ name: "invalid UTF-8 costs a warning, not the file",
844+ in: "# caf\xe9\nborder.width = 5\n",
845+ check: func(t *testing.T, c ports.Config) {
846+ if c.Border.Width != 5 {
847+ t.Fatalf("border.width = %d", c.Border.Width)
848+ }
849+ },
850+ warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
851+ },
852+ {
853+ name: "output name with spaces",
854+ in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
855+ check: func(t *testing.T, c ports.Config) {
856+ if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
857+ t.Fatalf("%+v", c.Outputs)
858+ }
859+ },
860+ },
861+ {
862+ // A '#' inside a quoted word is kept, as a shell would.
863+ name: "comment after a quoted word",
864+ in: "startup = notify-send \"a # b\" # note\n",
865+ check: func(t *testing.T, c ports.Config) {
866+ if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
867+ t.Fatalf("startup: %q", c.Startup)
868+ }
869+ },
870+ },
871+ } {
872+ t.Run(tc.name, func(t *testing.T) {
873+ c, w := parseString(t, tc.in)
874+ got := make([]string, 0, len(w))
875+ for _, x := range w {
876+ got = append(got, x.String())
877+ }
878+ if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
879+ t.Fatalf("warnings %q, want %q", got, tc.warnings)
880+ }
881+ tc.check(t, c)
882+ })
883+ }
884+}
885+
886+// A FIFO in place of the config fails at once instead of blocking startup.
887+func TestLoadRejectsFIFO(t *testing.T) {
888+ path := filepath.Join(t.TempDir(), "config")
889+ if err := syscall.Mkfifo(path, 0o600); err != nil {
890+ t.Skip(err)
891+ }
892+ done := make(chan error, 1)
893+ go func() { _, _, err := Load(path); done <- err }()
894+ select {
895+ case err := <-done:
896+ if err == nil {
897+ t.Fatal("FIFO loaded")
898+ }
899+ case <-time.After(5 * time.Second):
900+ t.Fatal("Load blocked on a FIFO")
901+ }
902+}
M internal/adapters/config/rules_test.go +1 -1
@@ -116,7 +116,7 @@ func TestRuleReloadDiff(t *testing.T) {
116116
117117 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
118118 t.Helper()
119- c, raw, w, err := parse(strings.NewReader(s))
119+ c, raw, w, err := parseBytes([]byte(s))
120120 if err != nil {
121121 t.Fatal(err)
122122 }
@@ -116,7 +116,7 @@ func TestRuleReloadDiff(t *testing.T) {
116 116
117 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {117 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
118 t.Helper()118 t.Helper()
119- c, raw, w, err := parse(strings.NewReader(s))119+ c, raw, w, err := parseBytes([]byte(s))
120 if err != nil {120 if err != nil {
121 t.Fatal(err)121 t.Fatal(err)
122 }122 }
M internal/adapters/config/scalestore_test.go +14 -0
@@ -117,3 +117,17 @@ func TestFormatScaleRoundTrip(t *testing.T) {
117117 }
118118 }
119119 }
120+
121+// Output names may hold spaces (EDID model names).
122+func TestSaveOutputScaleSpacedName(t *testing.T) {
123+ path := filepath.Join(t.TempDir(), "config")
124+ if err := os.WriteFile(path, []byte("output.LG Electronics 27GR95UM.scale = 2\n"), 0o600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := (ScaleStore{Path: path}).SaveOutputScale("LG Electronics 27GR95UM", 1.5); err != nil {
128+ t.Fatal(err)
129+ }
130+ if got, _ := os.ReadFile(path); string(got) != "output.LG Electronics 27GR95UM.scale = 1.5\n" {
131+ t.Fatalf("%q", got)
132+ }
133+}
@@ -117,3 +117,17 @@ func TestFormatScaleRoundTrip(t *testing.T) {
117 }117 }
118 }118 }
119 }119 }
120+
121+// Output names may hold spaces (EDID model names).
122+func TestSaveOutputScaleSpacedName(t *testing.T) {
123+ path := filepath.Join(t.TempDir(), "config")
124+ if err := os.WriteFile(path, []byte("output.LG Electronics 27GR95UM.scale = 2\n"), 0o600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := (ScaleStore{Path: path}).SaveOutputScale("LG Electronics 27GR95UM", 1.5); err != nil {
128+ t.Fatal(err)
129+ }
130+ if got, _ := os.ReadFile(path); string(got) != "output.LG Electronics 27GR95UM.scale = 1.5\n" {
131+ t.Fatalf("%q", got)
132+ }
133+}
M internal/adapters/config/watch.go +3 -13
@@ -2,7 +2,8 @@ package config
22
33 import (
44 "context"
5- "os"
5+ "errors"
6+ "io/fs"
67 "time"
78
89 "github.com/bnema/kvconf"
@@ -14,15 +15,6 @@ import (
1415 // every write restarts it, so the file is never read half written.
1516 const reloadDebounce = 100 * time.Millisecond
1617
17-func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
18- f, err := os.Open(path)
19- if err != nil {
20- return ports.Config{}, nil, nil, err
21- }
22- defer f.Close()
23- return parse(f)
24-}
25-
2618 // loadRaw returns the effective keys of the file at startup; missing means none.
2719 func loadRaw(path string) map[string]string {
2820 _, raw, _, err := loadFile(path)
@@ -49,7 +41,6 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
4941 for {
5042 select {
5143 case <-ctx.Done():
52- cancel()
5344 return <-watchErr
5445 case err := <-watchErr:
5546 return err
@@ -57,7 +48,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
5748 }
5849 cfg, raw, warnings, err := loadFile(path)
5950 switch {
60- case os.IsNotExist(err):
51+ case errors.Is(err, fs.ErrNotExist):
6152 log.Warn().Msg("config file removed; keeping current config")
6253 continue
6354 case err != nil:
@@ -75,7 +66,6 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
7566 }
7667 select {
7768 case <-ctx.Done():
78- cancel()
7969 return <-watchErr
8070 case out <- ports.ConfigChanged{Config: cfg}:
8171 }
@@ -2,7 +2,8 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5- "os"5+ "errors"
6+ "io/fs"
6 "time"7 "time"
7 8
8 "github.com/bnema/kvconf"9 "github.com/bnema/kvconf"
@@ -14,15 +15,6 @@ import (
14 // every write restarts it, so the file is never read half written.15 // every write restarts it, so the file is never read half written.
15 const reloadDebounce = 100 * time.Millisecond16 const reloadDebounce = 100 * time.Millisecond
16 17
17-func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
18- f, err := os.Open(path)
19- if err != nil {
20- return ports.Config{}, nil, nil, err
21- }
22- defer f.Close()
23- return parse(f)
24-}
25-
26 // loadRaw returns the effective keys of the file at startup; missing means none.18 // loadRaw returns the effective keys of the file at startup; missing means none.
27 func loadRaw(path string) map[string]string {19 func loadRaw(path string) map[string]string {
28 _, raw, _, err := loadFile(path)20 _, raw, _, err := loadFile(path)
@@ -49,7 +41,6 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
49 for {41 for {
50 select {42 select {
51 case <-ctx.Done():43 case <-ctx.Done():
52- cancel()
53 return <-watchErr44 return <-watchErr
54 case err := <-watchErr:45 case err := <-watchErr:
55 return err46 return err
@@ -57,7 +48,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
57 }48 }
58 cfg, raw, warnings, err := loadFile(path)49 cfg, raw, warnings, err := loadFile(path)
59 switch {50 switch {
60- case os.IsNotExist(err):51+ case errors.Is(err, fs.ErrNotExist):
61 log.Warn().Msg("config file removed; keeping current config")52 log.Warn().Msg("config file removed; keeping current config")
62 continue53 continue
63 case err != nil:54 case err != nil:
@@ -75,7 +66,6 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
75 }66 }
76 select {67 select {
77 case <-ctx.Done():68 case <-ctx.Done():
78- cancel()
79 return <-watchErr69 return <-watchErr
80 case out <- ports.ConfigChanged{Config: cfg}:70 case out <- ports.ConfigChanged{Config: cfg}:
81 }71 }
M internal/adapters/config/watch_test.go +1 -1
@@ -220,7 +220,7 @@ func TestWatchSymlink(t *testing.T) {
220220 t.Fatal(err)
221221 }
222222 write(moved, "background = #000001\n")
223- time.Sleep(100 * time.Millisecond) // past the 20 ms retry
223+ time.Sleep(100 * time.Millisecond) // let the watch move back into the recreated directory
224224 until("#abcdef", func(body string) { write(moved, body) })
225225 // The old target no longer matters.
226226 write(target, "background = #123456\n")
@@ -220,7 +220,7 @@ func TestWatchSymlink(t *testing.T) {
220 t.Fatal(err)220 t.Fatal(err)
221 }221 }
222 write(moved, "background = #000001\n")222 write(moved, "background = #000001\n")
223- time.Sleep(100 * time.Millisecond) // past the 20 ms retry223+ time.Sleep(100 * time.Millisecond) // let the watch move back into the recreated directory
224 until("#abcdef", func(body string) { write(moved, body) })224 until("#abcdef", func(body string) { write(moved, body) })
225 // The old target no longer matters.225 // The old target no longer matters.
226 write(target, "background = #123456\n")226 write(target, "background = #123456\n")

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 23

Tags 10

↑↓ moveenter openesc close