detached de4decf

fix(keys): preserve invalid split alt utf8 input

commit
de4decf704b65137395f76223b798160d3e2eb42
author
brice · · 2026-07-03 21:28 UTC
parent
3a2ff56
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +20 -3
M internal/usecase/keys/router.go +8 -3
@@ -71,7 +71,7 @@ func (r *Router) Route(data []byte) {
7171 r.pending = false
7272 r.pendingAlt = nil
7373 combined := append(append([]byte(nil), pendingAlt...), data...)
74- if consumed := r.routeAfterPendingESC(combined); consumed > len(pendingAlt) {
74+ if consumed := r.routeAfterPendingESC(combined, len(pendingAlt)); consumed > len(pendingAlt) {
7575 data = data[consumed-len(pendingAlt):]
7676 }
7777 }
@@ -122,7 +122,7 @@ func (r *Router) route(data []byte) {
122122 flush()
123123 }
124124
125-func (r *Router) routeAfterPendingESC(data []byte) int {
125+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
126126 next := data[0]
127127 if passThroughPrefix(next) {
128128 r.forward([]byte{ESC, next})
@@ -136,10 +136,15 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
136136 r.retainESC(data)
137137 return len(data)
138138 }
139- if _, size := utf8.DecodeRune(data); size > 1 {
139+ key, size := utf8.DecodeRune(data)
140+ if size > 1 {
140141 r.forward(append([]byte{ESC}, data[:size]...))
141142 return size
142143 }
144+ if key == utf8.RuneError && pendingAltLen > 0 {
145+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146+ return pendingAltLen
147+ }
143148 r.forward([]byte{ESC})
144149 return 0
145150 }
@@ -71,7 +71,7 @@ func (r *Router) Route(data []byte) {
71 r.pending = false71 r.pending = false
72 r.pendingAlt = nil72 r.pendingAlt = nil
73 combined := append(append([]byte(nil), pendingAlt...), data...)73 combined := append(append([]byte(nil), pendingAlt...), data...)
74- if consumed := r.routeAfterPendingESC(combined); consumed > len(pendingAlt) {74+ if consumed := r.routeAfterPendingESC(combined, len(pendingAlt)); consumed > len(pendingAlt) {
75 data = data[consumed-len(pendingAlt):]75 data = data[consumed-len(pendingAlt):]
76 }76 }
77 }77 }
@@ -122,7 +122,7 @@ func (r *Router) route(data []byte) {
122 flush()122 flush()
123 }123 }
124 124
125-func (r *Router) routeAfterPendingESC(data []byte) int {125+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
126 next := data[0]126 next := data[0]
127 if passThroughPrefix(next) {127 if passThroughPrefix(next) {
128 r.forward([]byte{ESC, next})128 r.forward([]byte{ESC, next})
@@ -136,10 +136,15 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
136 r.retainESC(data)136 r.retainESC(data)
137 return len(data)137 return len(data)
138 }138 }
139- if _, size := utf8.DecodeRune(data); size > 1 {139+ key, size := utf8.DecodeRune(data)
140+ if size > 1 {
140 r.forward(append([]byte{ESC}, data[:size]...))141 r.forward(append([]byte{ESC}, data[:size]...))
141 return size142 return size
142 }143 }
144+ if key == utf8.RuneError && pendingAltLen > 0 {
145+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146+ return pendingAltLen
147+ }
143 r.forward([]byte{ESC})148 r.forward([]byte{ESC})
144 return 0149 return 0
145 }150 }
M internal/usecase/keys/router_test.go +12 -0
@@ -119,6 +119,18 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
119119 require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
120120 }
121121
122+func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
123+ clk := &fakeClock{}
124+ h := &captureHandler{}
125+ r := NewRouter(clk, h)
126+
127+ r.Route([]byte{ESC, 0xc3})
128+ r.Route([]byte("X"))
129+
130+ require.Empty(t, h.actions)
131+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
132+}
133+
122134 func TestTopRowDigitIndexAcceptsQWERTYAndAZERTYVariants(t *testing.T) {
123135 cases := []struct {
124136 key rune
@@ -119,6 +119,18 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
119 require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)119 require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
120 }120 }
121 121
122+func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
123+ clk := &fakeClock{}
124+ h := &captureHandler{}
125+ r := NewRouter(clk, h)
126+
127+ r.Route([]byte{ESC, 0xc3})
128+ r.Route([]byte("X"))
129+
130+ require.Empty(t, h.actions)
131+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
132+}
133+
122 func TestTopRowDigitIndexAcceptsQWERTYAndAZERTYVariants(t *testing.T) {134 func TestTopRowDigitIndexAcceptsQWERTYAndAZERTYVariants(t *testing.T) {
123 cases := []struct {135 cases := []struct {
124 key rune136 key rune

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 12

Tags 11

↑↓ moveenter openesc close