detached 589da7e

refactor(daemon): extract overlay byte routing

commit
589da7e1c0c85fcec92e401ddae88b0937bd809a
author
brice · · 2026-07-03 09:19 UTC
parent
b3d7152
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +185 -77
A internal/usecase/daemon/overlay.go +99 -0
@@ -0,0 +1,99 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+)
7+
8+type overlayEvents struct {
9+ rune func(rune)
10+ backspace func()
11+ enter func()
12+ cancel func()
13+ up func()
14+ down func()
15+}
16+
17+func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
18+ if pending != nil && len(*pending) > 0 {
19+ combined := make([]byte, 0, len(*pending)+len(data))
20+ combined = append(combined, (*pending)...)
21+ combined = append(combined, data...)
22+ data = combined
23+ *pending = nil
24+ }
25+
26+ for i := 0; i < len(data); {
27+ switch data[i] {
28+ case 0x1b:
29+ consumed, routed := routeOverlayEscape(data[i:], ev)
30+ if routed {
31+ i += consumed
32+ continue
33+ }
34+ if isOverlayEscapePrefix(data[i:]) {
35+ if pending != nil {
36+ *pending = append((*pending)[:0], data[i:]...)
37+ }
38+ return
39+ }
40+ call(ev.cancel)
41+ i++
42+ case 0x03:
43+ call(ev.cancel)
44+ i++
45+ case 0x0e:
46+ call(ev.down)
47+ i++
48+ case 0x10:
49+ call(ev.up)
50+ i++
51+ case '\r', '\n':
52+ call(ev.enter)
53+ i++
54+ case 0x7f, 0x08:
55+ call(ev.backspace)
56+ i++
57+ default:
58+ r, size := utf8.DecodeRune(data[i:])
59+ if r == utf8.RuneError {
60+ if !utf8.FullRune(data[i:]) {
61+ if pending != nil {
62+ *pending = append((*pending)[:0], data[i:]...)
63+ }
64+ return
65+ }
66+ i++
67+ continue
68+ }
69+ if !unicode.IsControl(r) && ev.rune != nil {
70+ ev.rune(r)
71+ }
72+ i += size
73+ }
74+ }
75+}
76+
77+func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
78+ if len(data) >= 3 && data[1] == '[' {
79+ switch data[2] {
80+ case 'A':
81+ call(ev.up)
82+ return 3, true
83+ case 'B':
84+ call(ev.down)
85+ return 3, true
86+ }
87+ }
88+ return 0, false
89+}
90+
91+func isOverlayEscapePrefix(data []byte) bool {
92+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
93+}
94+
95+func call(fn func()) {
96+ if fn != nil {
97+ fn()
98+ }
99+}
@@ -0,0 +1,99 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+)
7+
8+type overlayEvents struct {
9+ rune func(rune)
10+ backspace func()
11+ enter func()
12+ cancel func()
13+ up func()
14+ down func()
15+}
16+
17+func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
18+ if pending != nil && len(*pending) > 0 {
19+ combined := make([]byte, 0, len(*pending)+len(data))
20+ combined = append(combined, (*pending)...)
21+ combined = append(combined, data...)
22+ data = combined
23+ *pending = nil
24+ }
25+
26+ for i := 0; i < len(data); {
27+ switch data[i] {
28+ case 0x1b:
29+ consumed, routed := routeOverlayEscape(data[i:], ev)
30+ if routed {
31+ i += consumed
32+ continue
33+ }
34+ if isOverlayEscapePrefix(data[i:]) {
35+ if pending != nil {
36+ *pending = append((*pending)[:0], data[i:]...)
37+ }
38+ return
39+ }
40+ call(ev.cancel)
41+ i++
42+ case 0x03:
43+ call(ev.cancel)
44+ i++
45+ case 0x0e:
46+ call(ev.down)
47+ i++
48+ case 0x10:
49+ call(ev.up)
50+ i++
51+ case '\r', '\n':
52+ call(ev.enter)
53+ i++
54+ case 0x7f, 0x08:
55+ call(ev.backspace)
56+ i++
57+ default:
58+ r, size := utf8.DecodeRune(data[i:])
59+ if r == utf8.RuneError {
60+ if !utf8.FullRune(data[i:]) {
61+ if pending != nil {
62+ *pending = append((*pending)[:0], data[i:]...)
63+ }
64+ return
65+ }
66+ i++
67+ continue
68+ }
69+ if !unicode.IsControl(r) && ev.rune != nil {
70+ ev.rune(r)
71+ }
72+ i += size
73+ }
74+ }
75+}
76+
77+func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
78+ if len(data) >= 3 && data[1] == '[' {
79+ switch data[2] {
80+ case 'A':
81+ call(ev.up)
82+ return 3, true
83+ case 'B':
84+ call(ev.down)
85+ return 3, true
86+ }
87+ }
88+ return 0, false
89+}
90+
91+func isOverlayEscapePrefix(data []byte) bool {
92+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
93+}
94+
95+func call(fn func()) {
96+ if fn != nil {
97+ fn()
98+ }
99+}
A internal/usecase/daemon/overlay_test.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {
10+ var pending []byte
11+ var got []rune
12+ ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
13+
14+ routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)
15+ require.Equal(t, []rune{'w'}, got)
16+ require.Equal(t, []byte{0xc3}, pending)
17+
18+ routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)
19+ require.Equal(t, []rune{'w', 'ø', 'k'}, got)
20+ require.Empty(t, pending)
21+}
22+
23+func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
24+ var pending []byte
25+ var up, down int
26+ ev := overlayEvents{
27+ up: func() { up++ },
28+ down: func() { down++ },
29+ }
30+
31+ routeOverlayBytes([]byte("\x1b["), &pending, ev)
32+ require.Equal(t, []byte("\x1b["), pending)
33+ routeOverlayBytes([]byte("B"), &pending, ev)
34+ require.Equal(t, 1, down)
35+ require.Empty(t, pending)
36+
37+ routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)
38+ require.Equal(t, 2, up)
39+ require.Equal(t, 2, down)
40+}
41+
42+func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
43+ var pending []byte
44+ var backspace, enter, cancel int
45+ ev := overlayEvents{
46+ backspace: func() { backspace++ },
47+ enter: func() { enter++ },
48+ cancel: func() { cancel++ },
49+ }
50+
51+ routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
52+
53+ require.Equal(t, 2, backspace)
54+ require.Equal(t, 2, enter)
55+ require.Equal(t, 2, cancel)
56+ require.Empty(t, pending)
57+}
58+
59+func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
60+ var pending []byte
61+ var got []rune
62+ ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
63+
64+ routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
65+
66+ require.Equal(t, []rune{'a', 'b'}, got)
67+ require.Empty(t, pending)
68+}
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {
10+ var pending []byte
11+ var got []rune
12+ ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
13+
14+ routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)
15+ require.Equal(t, []rune{'w'}, got)
16+ require.Equal(t, []byte{0xc3}, pending)
17+
18+ routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)
19+ require.Equal(t, []rune{'w', 'ø', 'k'}, got)
20+ require.Empty(t, pending)
21+}
22+
23+func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
24+ var pending []byte
25+ var up, down int
26+ ev := overlayEvents{
27+ up: func() { up++ },
28+ down: func() { down++ },
29+ }
30+
31+ routeOverlayBytes([]byte("\x1b["), &pending, ev)
32+ require.Equal(t, []byte("\x1b["), pending)
33+ routeOverlayBytes([]byte("B"), &pending, ev)
34+ require.Equal(t, 1, down)
35+ require.Empty(t, pending)
36+
37+ routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)
38+ require.Equal(t, 2, up)
39+ require.Equal(t, 2, down)
40+}
41+
42+func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
43+ var pending []byte
44+ var backspace, enter, cancel int
45+ ev := overlayEvents{
46+ backspace: func() { backspace++ },
47+ enter: func() { enter++ },
48+ cancel: func() { cancel++ },
49+ }
50+
51+ routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
52+
53+ require.Equal(t, 2, backspace)
54+ require.Equal(t, 2, enter)
55+ require.Equal(t, 2, cancel)
56+ require.Empty(t, pending)
57+}
58+
59+func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
60+ var pending []byte
61+ var got []rune
62+ ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
63+
64+ routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
65+
66+ require.Equal(t, []rune{'a', 'b'}, got)
67+ require.Empty(t, pending)
68+}
M internal/usecase/daemon/palette.go +18 -77
@@ -1,9 +1,6 @@
11 package daemon
22
33 import (
4- "unicode"
5- "unicode/utf8"
6-
74 "github.com/bnema/vev/internal/domain"
85 "github.com/bnema/vev/internal/usecase/command"
96 "github.com/bnema/vev/internal/usecase/palette"
@@ -41,76 +38,38 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
4138 ac.paletteMu.Unlock()
4239 return
4340 }
44- if len(ac.palettePending) > 0 {
45- combined := make([]byte, 0, len(ac.palettePending)+len(data))
46- combined = append(combined, ac.palettePending...)
47- combined = append(combined, data...)
48- data = combined
49- ac.palettePending = nil
50- }
51-
5241 changed := false
5342 exit := false
5443 run := false
5544 var cmd command.Command
5645 var ok bool
5746
58- for i := 0; i < len(data); {
59- switch data[i] {
60- case 0x1b:
61- consumed, routed := routePaletteEscape(ac.palette, data[i:])
62- if routed {
63- i += consumed
64- changed = true
65- continue
66- }
67- if isPaletteEscapePrefix(data[i:]) {
68- ac.palettePending = append(ac.palettePending[:0], data[i:]...)
69- i = len(data)
70- continue
71- }
72- exit = true
73- i++
74- case 0x03:
75- exit = true
76- i++
77- case 0x0e:
78- ac.palette.Down()
47+ routeOverlayBytes(data, &ac.palettePending, overlayEvents{
48+ rune: func(r rune) {
49+ ac.palette.Insert(r)
7950 changed = true
80- i++
81- case 0x10:
82- ac.palette.Up()
51+ },
52+ backspace: func() {
53+ ac.palette.Backspace()
8354 changed = true
84- i++
85- case '\r', '\n':
55+ },
56+ enter: func() {
8657 cmd, ok = ac.palette.Selected()
8758 if ok {
8859 run = true
8960 exit = true
9061 }
91- i++
92- case 0x7f, 0x08:
93- ac.palette.Backspace()
62+ },
63+ cancel: func() { exit = true },
64+ up: func() {
65+ ac.palette.Up()
9466 changed = true
95- i++
96- default:
97- r, size := utf8.DecodeRune(data[i:])
98- if r == utf8.RuneError {
99- if !utf8.FullRune(data[i:]) {
100- ac.palettePending = append(ac.palettePending[:0], data[i:]...)
101- i = len(data)
102- continue
103- }
104- i++
105- continue
106- }
107- if !unicode.IsControl(r) {
108- ac.palette.Insert(r)
109- changed = true
110- }
111- i += size
112- }
113- }
67+ },
68+ down: func() {
69+ ac.palette.Down()
70+ changed = true
71+ },
72+ })
11473 if exit {
11574 ac.palette = nil
11675 ac.palettePending = nil
@@ -128,24 +87,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
12887 }
12988 }
13089
131-func routePaletteEscape(m *palette.Model, data []byte) (int, bool) {
132- if len(data) >= 3 && data[1] == '[' {
133- switch data[2] {
134- case 'A':
135- m.Up()
136- return 3, true
137- case 'B':
138- m.Down()
139- return 3, true
140- }
141- }
142- return 0, false
143-}
144-
145-func isPaletteEscapePrefix(data []byte) bool {
146- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
147-}
148-
14990 type paletteExec struct {
15091 d *Daemon
15192 sess *session
@@ -1,9 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "unicode"
5- "unicode/utf8"
6-
7 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/usecase/command"5 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/bnema/vev/internal/usecase/palette"6 "github.com/bnema/vev/internal/usecase/palette"
@@ -41,76 +38,38 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
41 ac.paletteMu.Unlock()38 ac.paletteMu.Unlock()
42 return39 return
43 }40 }
44- if len(ac.palettePending) > 0 {
45- combined := make([]byte, 0, len(ac.palettePending)+len(data))
46- combined = append(combined, ac.palettePending...)
47- combined = append(combined, data...)
48- data = combined
49- ac.palettePending = nil
50- }
51-
52 changed := false41 changed := false
53 exit := false42 exit := false
54 run := false43 run := false
55 var cmd command.Command44 var cmd command.Command
56 var ok bool45 var ok bool
57 46
58- for i := 0; i < len(data); {47+ routeOverlayBytes(data, &ac.palettePending, overlayEvents{
59- switch data[i] {48+ rune: func(r rune) {
60- case 0x1b:49+ ac.palette.Insert(r)
61- consumed, routed := routePaletteEscape(ac.palette, data[i:])
62- if routed {
63- i += consumed
64- changed = true
65- continue
66- }
67- if isPaletteEscapePrefix(data[i:]) {
68- ac.palettePending = append(ac.palettePending[:0], data[i:]...)
69- i = len(data)
70- continue
71- }
72- exit = true
73- i++
74- case 0x03:
75- exit = true
76- i++
77- case 0x0e:
78- ac.palette.Down()
79 changed = true50 changed = true
80- i++51+ },
81- case 0x10:52+ backspace: func() {
82- ac.palette.Up()53+ ac.palette.Backspace()
83 changed = true54 changed = true
84- i++55+ },
85- case '\r', '\n':56+ enter: func() {
86 cmd, ok = ac.palette.Selected()57 cmd, ok = ac.palette.Selected()
87 if ok {58 if ok {
88 run = true59 run = true
89 exit = true60 exit = true
90 }61 }
91- i++62+ },
92- case 0x7f, 0x08:63+ cancel: func() { exit = true },
93- ac.palette.Backspace()64+ up: func() {
65+ ac.palette.Up()
94 changed = true66 changed = true
95- i++67+ },
96- default:68+ down: func() {
97- r, size := utf8.DecodeRune(data[i:])69+ ac.palette.Down()
98- if r == utf8.RuneError {70+ changed = true
99- if !utf8.FullRune(data[i:]) {71+ },
100- ac.palettePending = append(ac.palettePending[:0], data[i:]...)72+ })
101- i = len(data)
102- continue
103- }
104- i++
105- continue
106- }
107- if !unicode.IsControl(r) {
108- ac.palette.Insert(r)
109- changed = true
110- }
111- i += size
112- }
113- }
114 if exit {73 if exit {
115 ac.palette = nil74 ac.palette = nil
116 ac.palettePending = nil75 ac.palettePending = nil
@@ -128,24 +87,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
128 }87 }
129 }88 }
130 89
131-func routePaletteEscape(m *palette.Model, data []byte) (int, bool) {
132- if len(data) >= 3 && data[1] == '[' {
133- switch data[2] {
134- case 'A':
135- m.Up()
136- return 3, true
137- case 'B':
138- m.Down()
139- return 3, true
140- }
141- }
142- return 0, false
143-}
144-
145-func isPaletteEscapePrefix(data []byte) bool {
146- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
147-}
148-
149 type paletteExec struct {90 type paletteExec struct {
150 d *Daemon91 d *Daemon
151 sess *session92 sess *session

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