detached d8fc808

fix(keys): cancel pending esc waiter

commit
d8fc8080cae16091f6401f1ef9e7dbd42ab7e76a
author
brice · · 2026-07-02 15:32 UTC
parent
0309f6e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +42 -11
M internal/usecase/daemon/daemon.go +2 -5
@@ -543,10 +543,7 @@ func windowSize(clientSize domain.Size) domain.Size {
543543 if !clientSize.Valid() {
544544 clientSize = defaultSize
545545 }
546- rows := clientSize.Rows - 1
547- if rows < 1 {
548- rows = 1
549- }
546+ rows := max(clientSize.Rows-1, 1)
550547 return domain.Size{Cols: clientSize.Cols, Rows: rows}
551548 }
552549
@@ -898,7 +895,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
898895 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
899896 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
900897 frame := renderer.NewFrame(width, screenRows+1)
901- for y := 0; y < screenRows; y++ {
898+ for y := range screenRows {
902899 copy(frame.Row(y), win.screen.Frame.Row(y))
903900 }
904901 drawStatus(frame.Row(screenRows), sess)
@@ -543,10 +543,7 @@ func windowSize(clientSize domain.Size) domain.Size {
543 if !clientSize.Valid() {543 if !clientSize.Valid() {
544 clientSize = defaultSize544 clientSize = defaultSize
545 }545 }
546- rows := clientSize.Rows - 1546+ rows := max(clientSize.Rows-1, 1)
547- if rows < 1 {
548- rows = 1
549- }
550 return domain.Size{Cols: clientSize.Cols, Rows: rows}547 return domain.Size{Cols: clientSize.Cols, Rows: rows}
551 }548 }
552 549
@@ -898,7 +895,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
898 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {895 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
899 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height896 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
900 frame := renderer.NewFrame(width, screenRows+1)897 frame := renderer.NewFrame(width, screenRows+1)
901- for y := 0; y < screenRows; y++ {898+ for y := range screenRows {
902 copy(frame.Row(y), win.screen.Frame.Row(y))899 copy(frame.Row(y), win.screen.Frame.Row(y))
903 }900 }
904 drawStatus(frame.Row(screenRows), sess)901 drawStatus(frame.Row(screenRows), sess)
M internal/usecase/keys/router.go +16 -6
@@ -51,9 +51,10 @@ type Router struct {
5151 delay time.Duration
5252 h Handler
5353
54- mu sync.Mutex
55- pending bool
56- timer ports.Timer
54+ mu sync.Mutex
55+ pending bool
56+ timer ports.Timer
57+ pendingDone chan struct{}
5758 }
5859
5960 func NewRouter(clock ports.Clock, h Handler) *Router {
@@ -142,15 +143,20 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
142143 func (r *Router) retainESC() {
143144 r.pending = true
144145 r.timer = r.clock.NewTimer(r.delay)
145- go func(timer ports.Timer) {
146- <-timer.C()
146+ r.pendingDone = make(chan struct{})
147+ go func(timer ports.Timer, done <-chan struct{}) {
148+ select {
149+ case <-timer.C():
150+ case <-done:
151+ return
152+ }
147153 r.mu.Lock()
148154 defer r.mu.Unlock()
149155 if r.pending && r.timer == timer {
150156 r.pending = false
151157 r.forward([]byte{ESC})
152158 }
153- }(r.timer)
159+ }(r.timer, r.pendingDone)
154160 }
155161
156162 func (r *Router) stopTimer() {
@@ -158,6 +164,10 @@ func (r *Router) stopTimer() {
158164 r.timer.Stop()
159165 r.timer = nil
160166 }
167+ if r.pendingDone != nil {
168+ close(r.pendingDone)
169+ r.pendingDone = nil
170+ }
161171 }
162172
163173 func (r *Router) forward(data []byte) {
@@ -51,9 +51,10 @@ type Router struct {
51 delay time.Duration51 delay time.Duration
52 h Handler52 h Handler
53 53
54- mu sync.Mutex54+ mu sync.Mutex
55- pending bool55+ pending bool
56- timer ports.Timer56+ timer ports.Timer
57+ pendingDone chan struct{}
57 }58 }
58 59
59 func NewRouter(clock ports.Clock, h Handler) *Router {60 func NewRouter(clock ports.Clock, h Handler) *Router {
@@ -142,15 +143,20 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
142 func (r *Router) retainESC() {143 func (r *Router) retainESC() {
143 r.pending = true144 r.pending = true
144 r.timer = r.clock.NewTimer(r.delay)145 r.timer = r.clock.NewTimer(r.delay)
145- go func(timer ports.Timer) {146+ r.pendingDone = make(chan struct{})
146- <-timer.C()147+ go func(timer ports.Timer, done <-chan struct{}) {
148+ select {
149+ case <-timer.C():
150+ case <-done:
151+ return
152+ }
147 r.mu.Lock()153 r.mu.Lock()
148 defer r.mu.Unlock()154 defer r.mu.Unlock()
149 if r.pending && r.timer == timer {155 if r.pending && r.timer == timer {
150 r.pending = false156 r.pending = false
151 r.forward([]byte{ESC})157 r.forward([]byte{ESC})
152 }158 }
153- }(r.timer)159+ }(r.timer, r.pendingDone)
154 }160 }
155 161
156 func (r *Router) stopTimer() {162 func (r *Router) stopTimer() {
@@ -158,6 +164,10 @@ func (r *Router) stopTimer() {
158 r.timer.Stop()164 r.timer.Stop()
159 r.timer = nil165 r.timer = nil
160 }166 }
167+ if r.pendingDone != nil {
168+ close(r.pendingDone)
169+ r.pendingDone = nil
170+ }
161 }171 }
162 172
163 func (r *Router) forward(data []byte) {173 func (r *Router) forward(data []byte) {
M internal/usecase/keys/router_test.go +24 -0
@@ -1,6 +1,8 @@
11 package keys
22
33 import (
4+ "runtime"
5+ "strings"
46 "testing"
57 "time"
68
@@ -119,6 +121,28 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
119121 require.Empty(t, h.forwards)
120122 }
121123
124+func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
125+ clk := &fakeClock{}
126+ h := &captureHandler{}
127+ r := NewRouter(clk, h)
128+
129+ before := retainESCWaiters()
130+ r.Route([]byte{ESC})
131+ require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
132+
133+ r.Route([]byte{'n'})
134+ require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
135+ require.True(t, clk.timers[0].stopped)
136+ require.Equal(t, []Action{ActionNextWindow}, h.actions)
137+ require.Empty(t, h.forwards)
138+}
139+
140+func retainESCWaiters() int {
141+ buf := make([]byte, 1<<20)
142+ n := runtime.Stack(buf, true)
143+ return strings.Count(string(buf[:n]), "github.com/bnema/vev/internal/usecase/keys.(*Router).retainESC.func1")
144+}
145+
122146 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
123147 clk := &fakeClock{}
124148 h := &captureHandler{notify: make(chan struct{}, 1)}
@@ -1,6 +1,8 @@
1 package keys1 package keys
2 2
3 import (3 import (
4+ "runtime"
5+ "strings"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
@@ -119,6 +121,28 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
119 require.Empty(t, h.forwards)121 require.Empty(t, h.forwards)
120 }122 }
121 123
124+func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
125+ clk := &fakeClock{}
126+ h := &captureHandler{}
127+ r := NewRouter(clk, h)
128+
129+ before := retainESCWaiters()
130+ r.Route([]byte{ESC})
131+ require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
132+
133+ r.Route([]byte{'n'})
134+ require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
135+ require.True(t, clk.timers[0].stopped)
136+ require.Equal(t, []Action{ActionNextWindow}, h.actions)
137+ require.Empty(t, h.forwards)
138+}
139+
140+func retainESCWaiters() int {
141+ buf := make([]byte, 1<<20)
142+ n := runtime.Stack(buf, true)
143+ return strings.Count(string(buf[:n]), "github.com/bnema/vev/internal/usecase/keys.(*Router).retainESC.func1")
144+}
145+
122 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {146 func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
123 clk := &fakeClock{}147 clk := &fakeClock{}
124 h := &captureHandler{notify: make(chan struct{}, 1)}148 h := &captureHandler{notify: make(chan struct{}, 1)}

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