detached 2a105ed

fix(config): address PR review feedback

commit
2a105ed2a2fa1bf6de4ae2d19dcd54bda4a28819
author
brice · · 2026-07-05 17:37 UTC
parent
fead047
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +96 -21
M internal/adapters/config/config.go +1 -0
@@ -128,6 +128,7 @@ func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(do
128128 last = current
129129 cfg, warnings, err := Load(path)
130130 if err != nil {
131+ cfg = domain.Defaults()
131132 warnings = append(warnings, domain.Warning{Msg: err.Error()})
132133 }
133134 onChange(cfg, warnings)
@@ -128,6 +128,7 @@ func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(do
128 last = current128 last = current
129 cfg, warnings, err := Load(path)129 cfg, warnings, err := Load(path)
130 if err != nil {130 if err != nil {
131+ cfg = domain.Defaults()
131 warnings = append(warnings, domain.Warning{Msg: err.Error()})132 warnings = append(warnings, domain.Warning{Msg: err.Error()})
132 }133 }
133 onChange(cfg, warnings)134 onChange(cfg, warnings)
M internal/adapters/config/config_test.go +41 -0
@@ -194,6 +194,47 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
194194 }
195195 }
196196
197+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
198+ t.Parallel()
199+
200+ path := filepath.Join(t.TempDir(), "config")
201+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
202+ t.Fatal(err)
203+ }
204+
205+ clk := newFakeClock(time.Unix(0, 0))
206+ ctx, cancel := context.WithCancel(context.Background())
207+ defer cancel()
208+
209+ type change struct {
210+ cfg domain.Config
211+ warnings []domain.Warning
212+ }
213+ changes := make(chan change, 2)
214+ done := make(chan error, 1)
215+ go func() {
216+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
217+ changes <- change{cfg: cfg, warnings: warnings}
218+ })
219+ }()
220+
221+ clk.waitForTimers(t, 1)
222+ tooLong := strings.Repeat("x", 70*1024)
223+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
224+ t.Fatal(err)
225+ }
226+ clk.advance(2 * time.Second)
227+
228+ got := <-changes
229+ require.Equal(t, domain.Defaults(), got.cfg)
230+ require.NotEmpty(t, got.warnings)
231+
232+ cancel()
233+ if err := <-done; !errors.Is(err, context.Canceled) {
234+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
235+ }
236+}
237+
197238 func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
198239 t.Parallel()
199240
@@ -194,6 +194,47 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
194 }194 }
195 }195 }
196 196
197+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
198+ t.Parallel()
199+
200+ path := filepath.Join(t.TempDir(), "config")
201+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
202+ t.Fatal(err)
203+ }
204+
205+ clk := newFakeClock(time.Unix(0, 0))
206+ ctx, cancel := context.WithCancel(context.Background())
207+ defer cancel()
208+
209+ type change struct {
210+ cfg domain.Config
211+ warnings []domain.Warning
212+ }
213+ changes := make(chan change, 2)
214+ done := make(chan error, 1)
215+ go func() {
216+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
217+ changes <- change{cfg: cfg, warnings: warnings}
218+ })
219+ }()
220+
221+ clk.waitForTimers(t, 1)
222+ tooLong := strings.Repeat("x", 70*1024)
223+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
224+ t.Fatal(err)
225+ }
226+ clk.advance(2 * time.Second)
227+
228+ got := <-changes
229+ require.Equal(t, domain.Defaults(), got.cfg)
230+ require.NotEmpty(t, got.warnings)
231+
232+ cancel()
233+ if err := <-done; !errors.Is(err, context.Canceled) {
234+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
235+ }
236+}
237+
197 func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {238 func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
198 t.Parallel()239 t.Parallel()
199 240
M internal/usecase/theme/theme.go +4 -3
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
910 )
1011
1112 const maxPending = 64
@@ -140,7 +141,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
140141 onBytes(data[byteStart:i])
141142 }
142143 onScheme(light)
143- i += len("\x1b[?997;1n") - 1
144+ i += len(vt.ColorSchemeReportDark) - 1
144145 byteStart = i + 1
145146 continue
146147 } else if possible {
@@ -231,8 +232,8 @@ var schemeCSIPrefixes = []struct {
231232 seq []byte
232233 light bool
233234 }{
234- {seq: []byte("\x1b[?997;1n"), light: false},
235- {seq: []byte("\x1b[?997;2n"), light: true},
235+ {seq: []byte(vt.ColorSchemeReportDark), light: false},
236+ {seq: []byte(vt.ColorSchemeReportLight), light: true},
236237 }
237238
238239 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
9 )10 )
10 11
11 const maxPending = 6412 const maxPending = 64
@@ -140,7 +141,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
140 onBytes(data[byteStart:i])141 onBytes(data[byteStart:i])
141 }142 }
142 onScheme(light)143 onScheme(light)
143- i += len("\x1b[?997;1n") - 1144+ i += len(vt.ColorSchemeReportDark) - 1
144 byteStart = i + 1145 byteStart = i + 1
145 continue146 continue
146 } else if possible {147 } else if possible {
@@ -231,8 +232,8 @@ var schemeCSIPrefixes = []struct {
231 seq []byte232 seq []byte
232 light bool233 light bool
233 }{234 }{
234- {seq: []byte("\x1b[?997;1n"), light: false},235+ {seq: []byte(vt.ColorSchemeReportDark), light: false},
235- {seq: []byte("\x1b[?997;2n"), light: true},236+ {seq: []byte(vt.ColorSchemeReportLight), light: true},
236 }237 }
237 238
238 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {239 func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
M internal/usecase/theme/theme_test.go +36 -10
@@ -2,6 +2,7 @@ package theme
22
33 import (
44 "bytes"
5+ "reflect"
56 "testing"
67
78 "github.com/bnema/vev/pkg/renderer"
@@ -83,18 +84,43 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
8384 }
8485
8586 func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
86- var s Scanner
87- var schemes []bool
88- var out bytes.Buffer
87+ tests := []struct {
88+ name string
89+ chunks []string
90+ wantOut string
91+ wantSchemes []bool
92+ }{
93+ {
94+ name: "split dark then complete light",
95+ chunks: []string{"a\x1b[?997", ";1nb\x1b[?997;2nc"},
96+ wantOut: "abc",
97+ wantSchemes: []bool{false, true},
98+ },
99+ {
100+ name: "split light",
101+ chunks: []string{"x\x1b[?997;", "2ny"},
102+ wantOut: "xy",
103+ wantSchemes: []bool{true},
104+ },
105+ }
89106
90- s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
91- s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ var s Scanner
110+ var schemes []bool
111+ var out bytes.Buffer
92112
93- if out.String() != "abc" {
94- t.Fatalf("passthrough=%q want abc", out.String())
95- }
96- if len(schemes) != 2 || schemes[0] != false || schemes[1] != true {
97- t.Fatalf("schemes=%v want [false true]", schemes)
113+ for _, chunk := range tt.chunks {
114+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
115+ }
116+
117+ if out.String() != tt.wantOut {
118+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
119+ }
120+ if !reflect.DeepEqual(schemes, tt.wantSchemes) {
121+ t.Fatalf("schemes=%v want %v", schemes, tt.wantSchemes)
122+ }
123+ })
98124 }
99125 }
100126
@@ -2,6 +2,7 @@ package theme
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "reflect"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
@@ -83,18 +84,43 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
83 }84 }
84 85
85 func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {86 func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
86- var s Scanner87+ tests := []struct {
87- var schemes []bool88+ name string
88- var out bytes.Buffer89+ chunks []string
90+ wantOut string
91+ wantSchemes []bool
92+ }{
93+ {
94+ name: "split dark then complete light",
95+ chunks: []string{"a\x1b[?997", ";1nb\x1b[?997;2nc"},
96+ wantOut: "abc",
97+ wantSchemes: []bool{false, true},
98+ },
99+ {
100+ name: "split light",
101+ chunks: []string{"x\x1b[?997;", "2ny"},
102+ wantOut: "xy",
103+ wantSchemes: []bool{true},
104+ },
105+ }
89 106
90- s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })107+ for _, tt := range tests {
91- s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })108+ t.Run(tt.name, func(t *testing.T) {
109+ var s Scanner
110+ var schemes []bool
111+ var out bytes.Buffer
92 112
93- if out.String() != "abc" {113+ for _, chunk := range tt.chunks {
94- t.Fatalf("passthrough=%q want abc", out.String())114+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
95- }115+ }
96- if len(schemes) != 2 || schemes[0] != false || schemes[1] != true {116+
97- t.Fatalf("schemes=%v want [false true]", schemes)117+ if out.String() != tt.wantOut {
118+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
119+ }
120+ if !reflect.DeepEqual(schemes, tt.wantSchemes) {
121+ t.Fatalf("schemes=%v want %v", schemes, tt.wantSchemes)
122+ }
123+ })
98 }124 }
99 }125 }
100 126
M pkg/vt/query_test.go +3 -3
@@ -25,8 +25,8 @@ func TestScreenQueryResponses(t *testing.T) {
2525 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
2626 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
2727 {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},
28- {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"},
29- {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"},
28+ {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},
29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
3030 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
3131 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
3232 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
@@ -190,7 +190,7 @@ func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
190190 t.Fatalf("unchanged response = %q, want silence", got.String())
191191 }
192192 s.SetColorScheme(false)
193- if got.String() != "\x1b[?997;1n" {
193+ if got.String() != ColorSchemeReportDark {
194194 t.Fatalf("changed response = %q, want dark notification", got.String())
195195 }
196196 got.Reset()
@@ -25,8 +25,8 @@ func TestScreenQueryResponses(t *testing.T) {
25 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},25 {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
26 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},26 {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
27 {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},27 {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},
28- {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"},28+ {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},
29- {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"},29+ {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
30 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},30 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
31 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},31 {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
32 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},32 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
@@ -190,7 +190,7 @@ func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
190 t.Fatalf("unchanged response = %q, want silence", got.String())190 t.Fatalf("unchanged response = %q, want silence", got.String())
191 }191 }
192 s.SetColorScheme(false)192 s.SetColorScheme(false)
193- if got.String() != "\x1b[?997;1n" {193+ if got.String() != ColorSchemeReportDark {
194 t.Fatalf("changed response = %q, want dark notification", got.String())194 t.Fatalf("changed response = %q, want dark notification", got.String())
195 }195 }
196 got.Reset()196 got.Reset()
M pkg/vt/screen.go +11 -5
@@ -31,6 +31,13 @@ type screenState struct {
3131 // so keep this value in sync if that payload cap changes.
3232 const maxEscapeBufferLen = 128 * 1024
3333
34+const (
35+ // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
36+ ColorSchemeReportDark = "\x1b[?997;1n"
37+ // ColorSchemeReportLight is the DEC 2031 light-scheme report.
38+ ColorSchemeReportLight = "\x1b[?997;2n"
39+)
40+
3441 const (
3542 progressStateClear = iota
3643 progressStateNormal
@@ -509,9 +516,9 @@ func (s *Screen) colorSchemeReport() []byte {
509516 return nil
510517 }
511518 if s.colorSchemeLight {
512- return []byte("\x1b[?997;2n")
519+ return []byte(ColorSchemeReportLight)
513520 }
514- return []byte("\x1b[?997;1n")
521+ return []byte(ColorSchemeReportDark)
515522 }
516523
517524 func (s *Screen) scrollDownRegion(top, bottom, n int) {
@@ -754,11 +761,10 @@ func (s *Screen) handleProgress(rest string) {
754761 // clipboard query (data == "?") is always ignored — vev never answers
755762 // clipboard queries. A payload with no second ";" is malformed and ignored.
756763 func (s *Screen) handleOSC52(rest string) {
757- idx := strings.IndexByte(rest, ';')
758- if idx < 0 {
764+ _, data, ok := strings.Cut(rest, ";")
765+ if !ok {
759766 return
760767 }
761- data := rest[idx+1:]
762768 if data == "?" {
763769 return
764770 }
@@ -31,6 +31,13 @@ type screenState struct {
31 // so keep this value in sync if that payload cap changes.31 // so keep this value in sync if that payload cap changes.
32 const maxEscapeBufferLen = 128 * 102432 const maxEscapeBufferLen = 128 * 1024
33 33
34+const (
35+ // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
36+ ColorSchemeReportDark = "\x1b[?997;1n"
37+ // ColorSchemeReportLight is the DEC 2031 light-scheme report.
38+ ColorSchemeReportLight = "\x1b[?997;2n"
39+)
40+
34 const (41 const (
35 progressStateClear = iota42 progressStateClear = iota
36 progressStateNormal43 progressStateNormal
@@ -509,9 +516,9 @@ func (s *Screen) colorSchemeReport() []byte {
509 return nil516 return nil
510 }517 }
511 if s.colorSchemeLight {518 if s.colorSchemeLight {
512- return []byte("\x1b[?997;2n")519+ return []byte(ColorSchemeReportLight)
513 }520 }
514- return []byte("\x1b[?997;1n")521+ return []byte(ColorSchemeReportDark)
515 }522 }
516 523
517 func (s *Screen) scrollDownRegion(top, bottom, n int) {524 func (s *Screen) scrollDownRegion(top, bottom, n int) {
@@ -754,11 +761,10 @@ func (s *Screen) handleProgress(rest string) {
754 // clipboard query (data == "?") is always ignored — vev never answers761 // clipboard query (data == "?") is always ignored — vev never answers
755 // clipboard queries. A payload with no second ";" is malformed and ignored.762 // clipboard queries. A payload with no second ";" is malformed and ignored.
756 func (s *Screen) handleOSC52(rest string) {763 func (s *Screen) handleOSC52(rest string) {
757- idx := strings.IndexByte(rest, ';')764+ _, data, ok := strings.Cut(rest, ";")
758- if idx < 0 {765+ if !ok {
759 return766 return
760 }767 }
761- data := rest[idx+1:]
762 if data == "?" {768 if data == "?" {
763 return769 return
764 }770 }

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 2

Tags 13

↑↓ moveenter openesc close