detached 9938807

fix(copy): invalidate rejected pointer interactions

commit
99388074345d5aa157306fa61f8224dc5df43c60
author
brice · · 2026-07-15 08:06 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
b30c769
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +314 -26
M internal/usecase/daemon/copymode.go +8 -0
@@ -96,7 +96,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
9696 } else {
9797 rt.invalidateCopyPointerLocked(true)
9898 }
99+ pointerEpoch := rt.copyPointerEpoch
99100 rt.copyMu.Unlock()
101+ if d.beforeCopyModeRevalidate != nil {
102+ d.beforeCopyModeRevalidate()
103+ }
100104 valid := sess.activeTab() == tb
101105 if valid {
102106 tb.mu.Lock()
@@ -108,6 +112,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
108112 if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
109113 return false
110114 }
115+ if pointerEpoch != rt.copyPointerEpoch {
116+ rt.discardCopyCandidateLocked(mode)
117+ return false
118+ }
111119 if !valid {
112120 rt.clearCopyModeLocked()
113121 return false
@@ -96,7 +96,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
96 } else {96 } else {
97 rt.invalidateCopyPointerLocked(true)97 rt.invalidateCopyPointerLocked(true)
98 }98 }
99+ pointerEpoch := rt.copyPointerEpoch
99 rt.copyMu.Unlock()100 rt.copyMu.Unlock()
101+ if d.beforeCopyModeRevalidate != nil {
102+ d.beforeCopyModeRevalidate()
103+ }
100 valid := sess.activeTab() == tb104 valid := sess.activeTab() == tb
101 if valid {105 if valid {
102 tb.mu.Lock()106 tb.mu.Lock()
@@ -108,6 +112,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
108 if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {112 if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
109 return false113 return false
110 }114 }
115+ if pointerEpoch != rt.copyPointerEpoch {
116+ rt.discardCopyCandidateLocked(mode)
117+ return false
118+ }
111 if !valid {119 if !valid {
112 rt.clearCopyModeLocked()120 rt.clearCopyModeLocked()
113 return false121 return false
M internal/usecase/daemon/copymode_lifecycle_test.go +111 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "fmt"
45 "testing"
56 "time"
67
@@ -127,6 +128,116 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
127128 require.Nil(t, copyTargetPane(ac.overlays))
128129 }
129130
131+func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) {
132+ for _, tc := range []struct {
133+ name string
134+ invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane)
135+ }{
136+ {
137+ name: "release during publication",
138+ invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) {
139+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))
140+ },
141+ },
142+ {
143+ name: "pane close during publication",
144+ invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) {
145+ require.NoError(t, d.closePane(sess, tb, p.id, ac, false))
146+ },
147+ },
148+ } {
149+ t.Run(tc.name, func(t *testing.T) {
150+ d, sess, ac, _, releases := newManualTabSession(t, 2)
151+ defer func() {
152+ for _, release := range releases {
153+ release()
154+ }
155+ }()
156+ tb := sess.activeTab()
157+ p := tb.focusedPane()
158+
159+ // A real press is required: the motion publication must inherit this
160+ // pointer epoch, rather than publishing a fresh interaction.
161+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
162+ ac.overlays.copyMu.Lock()
163+ require.True(t, ac.overlays.copyPointer.valid)
164+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p}
165+ ac.overlays.copyMu.Unlock()
166+
167+ reached := make(chan struct{})
168+ resume := make(chan struct{})
169+ d.beforeCopyModeRevalidate = func() {
170+ close(reached)
171+ <-resume
172+ }
173+ done := make(chan struct{})
174+ go func() {
175+ d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
176+ close(done)
177+ }()
178+ <-reached
179+
180+ tc.invalidate(t, d, sess, ac, tb, p)
181+ close(resume)
182+ <-done
183+
184+ ac.overlays.copyMu.Lock()
185+ require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
186+ require.Nil(t, ac.overlays.copyCandidate)
187+ require.False(t, ac.overlays.copyPointer.valid)
188+ require.False(t, ac.overlays.copyClick.valid)
189+ ac.overlays.copyMu.Unlock()
190+ })
191+ }
192+}
193+
194+func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) {
195+ d, sess, ac, _, releases := newManualTabSession(t, 2)
196+ defer func() {
197+ for _, release := range releases {
198+ release()
199+ }
200+ }()
201+ tb := sess.activeTab()
202+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
203+ installTestFloating(tb, floating, true)
204+ tb.mu.Lock()
205+ _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
206+ tb.mu.Unlock()
207+ require.True(t, visible)
208+ press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
209+ motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3)
210+
211+ d.handleInput(sess, ac, press)
212+ ac.overlays.copyMu.Lock()
213+ require.True(t, ac.overlays.copyPointer.valid)
214+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating}
215+ ac.overlays.copyMu.Unlock()
216+
217+ reached := make(chan struct{})
218+ resume := make(chan struct{})
219+ d.beforeCopyModeRevalidate = func() {
220+ close(reached)
221+ <-resume
222+ }
223+ done := make(chan struct{})
224+ go func() {
225+ d.handleInput(sess, ac, motion)
226+ close(done)
227+ }()
228+ <-reached
229+ d.teardownFloating(tb, ac)
230+ close(resume)
231+ <-done
232+
233+ ac.overlays.copyMu.Lock()
234+ require.Nil(t, ac.overlays.copyMode)
235+ require.Nil(t, ac.overlays.copyCandidate)
236+ require.False(t, ac.overlays.copyPointer.valid)
237+ require.False(t, ac.overlays.copyClick.valid)
238+ ac.overlays.copyMu.Unlock()
239+}
240+
130241 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
131242 for _, tc := range []struct {
132243 name string
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "fmt"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -127,6 +128,116 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
127 require.Nil(t, copyTargetPane(ac.overlays))128 require.Nil(t, copyTargetPane(ac.overlays))
128 }129 }
129 130
131+func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) {
132+ for _, tc := range []struct {
133+ name string
134+ invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane)
135+ }{
136+ {
137+ name: "release during publication",
138+ invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) {
139+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))
140+ },
141+ },
142+ {
143+ name: "pane close during publication",
144+ invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) {
145+ require.NoError(t, d.closePane(sess, tb, p.id, ac, false))
146+ },
147+ },
148+ } {
149+ t.Run(tc.name, func(t *testing.T) {
150+ d, sess, ac, _, releases := newManualTabSession(t, 2)
151+ defer func() {
152+ for _, release := range releases {
153+ release()
154+ }
155+ }()
156+ tb := sess.activeTab()
157+ p := tb.focusedPane()
158+
159+ // A real press is required: the motion publication must inherit this
160+ // pointer epoch, rather than publishing a fresh interaction.
161+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
162+ ac.overlays.copyMu.Lock()
163+ require.True(t, ac.overlays.copyPointer.valid)
164+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p}
165+ ac.overlays.copyMu.Unlock()
166+
167+ reached := make(chan struct{})
168+ resume := make(chan struct{})
169+ d.beforeCopyModeRevalidate = func() {
170+ close(reached)
171+ <-resume
172+ }
173+ done := make(chan struct{})
174+ go func() {
175+ d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
176+ close(done)
177+ }()
178+ <-reached
179+
180+ tc.invalidate(t, d, sess, ac, tb, p)
181+ close(resume)
182+ <-done
183+
184+ ac.overlays.copyMu.Lock()
185+ require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
186+ require.Nil(t, ac.overlays.copyCandidate)
187+ require.False(t, ac.overlays.copyPointer.valid)
188+ require.False(t, ac.overlays.copyClick.valid)
189+ ac.overlays.copyMu.Unlock()
190+ })
191+ }
192+}
193+
194+func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) {
195+ d, sess, ac, _, releases := newManualTabSession(t, 2)
196+ defer func() {
197+ for _, release := range releases {
198+ release()
199+ }
200+ }()
201+ tb := sess.activeTab()
202+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
203+ installTestFloating(tb, floating, true)
204+ tb.mu.Lock()
205+ _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
206+ tb.mu.Unlock()
207+ require.True(t, visible)
208+ press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
209+ motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3)
210+
211+ d.handleInput(sess, ac, press)
212+ ac.overlays.copyMu.Lock()
213+ require.True(t, ac.overlays.copyPointer.valid)
214+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating}
215+ ac.overlays.copyMu.Unlock()
216+
217+ reached := make(chan struct{})
218+ resume := make(chan struct{})
219+ d.beforeCopyModeRevalidate = func() {
220+ close(reached)
221+ <-resume
222+ }
223+ done := make(chan struct{})
224+ go func() {
225+ d.handleInput(sess, ac, motion)
226+ close(done)
227+ }()
228+ <-reached
229+ d.teardownFloating(tb, ac)
230+ close(resume)
231+ <-done
232+
233+ ac.overlays.copyMu.Lock()
234+ require.Nil(t, ac.overlays.copyMode)
235+ require.Nil(t, ac.overlays.copyCandidate)
236+ require.False(t, ac.overlays.copyPointer.valid)
237+ require.False(t, ac.overlays.copyClick.valid)
238+ ac.overlays.copyMu.Unlock()
239+}
240+
130 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {241 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
131 for _, tc := range []struct {242 for _, tc := range []struct {
132 name string243 name string
M internal/usecase/daemon/daemon.go +24 -21
@@ -91,27 +91,30 @@ type Daemon struct {
9191 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
9292 // interval between JRS validation and its committed hand-off.
9393 beforeRecentSessionHandoff func()
94- ptys ports.PTYFactory
95- clock ports.Clock
96- log *slog.Logger
97- runtimeObserver ports.RuntimeObserver
98- baseEnv []string
99- shell string
100- shellArgs []string
101- shellOverride bool
102- persist *persist.Persister
103- persistEnabled bool
104- snaps ports.SnapshotStore
105- snapsEnabled bool
106- snapshotMarshal func(snapcodec.Session) ([]byte, error)
107- snapshotJobs chan *snapshotCapture
108- snapshotWorkerMu sync.Mutex
109- snapshotWorkerID uint64
110- snapshotWorkerCtx context.Context
111- snapshotWorkerCancel context.CancelFunc
112- snapshotWorkerDone chan struct{}
113- snapshotWorkerFlush chan struct{}
114- snapshotWorkerFinalWake chan struct{}
94+ // beforeCopyModeRevalidate is a deterministic test seam between staging a
95+ // copy-mode candidate and revalidating its pane membership.
96+ beforeCopyModeRevalidate func()
97+ ptys ports.PTYFactory
98+ clock ports.Clock
99+ log *slog.Logger
100+ runtimeObserver ports.RuntimeObserver
101+ baseEnv []string
102+ shell string
103+ shellArgs []string
104+ shellOverride bool
105+ persist *persist.Persister
106+ persistEnabled bool
107+ snaps ports.SnapshotStore
108+ snapsEnabled bool
109+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
110+ snapshotJobs chan *snapshotCapture
111+ snapshotWorkerMu sync.Mutex
112+ snapshotWorkerID uint64
113+ snapshotWorkerCtx context.Context
114+ snapshotWorkerCancel context.CancelFunc
115+ snapshotWorkerDone chan struct{}
116+ snapshotWorkerFlush chan struct{}
117+ snapshotWorkerFinalWake chan struct{}
115118 // snapshotFinalJobs coalesces terminal captures by session when the bounded
116119 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
117120 // sessions, each with only its newest terminal state while the worker blocks.
@@ -91,27 +91,30 @@ type Daemon struct {
91 // beforeRecentSessionHandoff is a deterministic test seam for the narrow91 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
92 // interval between JRS validation and its committed hand-off.92 // interval between JRS validation and its committed hand-off.
93 beforeRecentSessionHandoff func()93 beforeRecentSessionHandoff func()
94- ptys ports.PTYFactory94+ // beforeCopyModeRevalidate is a deterministic test seam between staging a
95- clock ports.Clock95+ // copy-mode candidate and revalidating its pane membership.
96- log *slog.Logger96+ beforeCopyModeRevalidate func()
97- runtimeObserver ports.RuntimeObserver97+ ptys ports.PTYFactory
98- baseEnv []string98+ clock ports.Clock
99- shell string99+ log *slog.Logger
100- shellArgs []string100+ runtimeObserver ports.RuntimeObserver
101- shellOverride bool101+ baseEnv []string
102- persist *persist.Persister102+ shell string
103- persistEnabled bool103+ shellArgs []string
104- snaps ports.SnapshotStore104+ shellOverride bool
105- snapsEnabled bool105+ persist *persist.Persister
106- snapshotMarshal func(snapcodec.Session) ([]byte, error)106+ persistEnabled bool
107- snapshotJobs chan *snapshotCapture107+ snaps ports.SnapshotStore
108- snapshotWorkerMu sync.Mutex108+ snapsEnabled bool
109- snapshotWorkerID uint64109+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
110- snapshotWorkerCtx context.Context110+ snapshotJobs chan *snapshotCapture
111- snapshotWorkerCancel context.CancelFunc111+ snapshotWorkerMu sync.Mutex
112- snapshotWorkerDone chan struct{}112+ snapshotWorkerID uint64
113- snapshotWorkerFlush chan struct{}113+ snapshotWorkerCtx context.Context
114- snapshotWorkerFinalWake chan struct{}114+ snapshotWorkerCancel context.CancelFunc
115+ snapshotWorkerDone chan struct{}
116+ snapshotWorkerFlush chan struct{}
117+ snapshotWorkerFinalWake chan struct{}
115 // snapshotFinalJobs coalesces terminal captures by session when the bounded118 // snapshotFinalJobs coalesces terminal captures by session when the bounded
116 // regular queue is full. It retains at most snapshotFinalQueueCapacity named119 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
117 // sessions, each with only its newest terminal state while the worker blocks.120 // sessions, each with only its newest terminal state while the worker blocks.
M internal/usecase/daemon/input.go +24 -5
@@ -41,10 +41,12 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
4141 }
4242 sess := ac.currentSession()
4343 if sess == nil {
44+ invalidateRejectedLeftPointer(rt, ev)
4445 return
4546 }
4647 tb := sess.activeTab()
4748 if tb == nil {
49+ invalidateRejectedLeftPointer(rt, ev)
4850 return
4951 }
5052
@@ -69,6 +71,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
6971 if floatingVisible {
7072 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
7173 tb.mu.Unlock()
74+ invalidateRejectedLeftPointer(rt, ev)
7275 return
7376 }
7477 tb.mu.Unlock()
@@ -85,6 +88,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
8588 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
8689 if !isMouseFocusPress(ev) {
8790 tb.mu.Unlock()
91+ invalidateRejectedLeftPointer(rt, ev)
8892 return
8993 }
9094 oldFocus := focusedID
@@ -111,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
111115 hoveredFocused = pl.ID == oldFocus
112116 tb.mu.Unlock()
113117 if p == nil {
118+ invalidateRejectedLeftPointer(rt, ev)
114119 return
115120 }
116121 if isMouseFocusPress(ev) && pl.ID != oldFocus {
@@ -125,17 +130,31 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
125130 } else {
126131 if multi {
127132 tb.mu.Unlock()
133+ invalidateRejectedLeftPointer(rt, ev)
128134 return
129135 }
130136 p = tb.focusedPane()
131137 tb.mu.Unlock()
132138 if p == nil {
139+ invalidateRejectedLeftPointer(rt, ev)
133140 return
134141 }
135142 }
136143 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
137144 }
138145
146+// invalidateRejectedLeftPointer clears a stale drag before returning from an
147+// event that cannot be routed to terminal content. It never runs while tab or
148+// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
149+func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
150+ if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
151+ return
152+ }
153+ rt.copyMu.Lock()
154+ rt.invalidateCopyPointerLocked(true)
155+ rt.copyMu.Unlock()
156+}
157+
139158 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
140159 // press time. Geometry is resolved after copyMu is released to preserve the
141160 // daemon's copyMu -> tab/pane lock prohibition.
@@ -153,10 +172,10 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
153172 // later floating/layout change must not turn an in-flight drag into a hit
154173 // on a different pane.
155174 if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document {
156- mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true)
175+ mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion)
157176 if ev.Type == mouse.Release && !ok {
158177 rt.copyMu.Lock()
159- rt.invalidateCopyPointerLocked(false)
178+ rt.invalidateCopyPointerLocked(true)
160179 rt.copyMu.Unlock()
161180 return
162181 }
@@ -181,7 +200,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
181200 if !ok {
182201 if ev.Type == mouse.Release || ev.Type == mouse.Press {
183202 rt.copyMu.Lock()
184- rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
203+ rt.invalidateCopyPointerLocked(true)
185204 rt.copyMu.Unlock()
186205 }
187206 return
@@ -206,7 +225,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
206225 }
207226 if ev.Type == mouse.Release || ev.Type == mouse.Press {
208227 rt.copyMu.Lock()
209- rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
228+ rt.invalidateCopyPointerLocked(true)
210229 rt.copyMu.Unlock()
211230 }
212231 return
@@ -303,7 +322,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
303322 d.invalidateRender(sess, ac, true, "input.go")
304323 case mouse.Release:
305324 rt.copyMu.Lock()
306- rt.invalidateCopyPointerLocked(false)
325+ rt.invalidateCopyPointerLocked(true)
307326 rt.copyMu.Unlock()
308327 }
309328 case mouse.WheelUp:
@@ -41,10 +41,12 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
41 }41 }
42 sess := ac.currentSession()42 sess := ac.currentSession()
43 if sess == nil {43 if sess == nil {
44+ invalidateRejectedLeftPointer(rt, ev)
44 return45 return
45 }46 }
46 tb := sess.activeTab()47 tb := sess.activeTab()
47 if tb == nil {48 if tb == nil {
49+ invalidateRejectedLeftPointer(rt, ev)
48 return50 return
49 }51 }
50 52
@@ -69,6 +71,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
69 if floatingVisible {71 if floatingVisible {
70 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {72 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
71 tb.mu.Unlock()73 tb.mu.Unlock()
74+ invalidateRejectedLeftPointer(rt, ev)
72 return75 return
73 }76 }
74 tb.mu.Unlock()77 tb.mu.Unlock()
@@ -85,6 +88,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {88 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
86 if !isMouseFocusPress(ev) {89 if !isMouseFocusPress(ev) {
87 tb.mu.Unlock()90 tb.mu.Unlock()
91+ invalidateRejectedLeftPointer(rt, ev)
88 return92 return
89 }93 }
90 oldFocus := focusedID94 oldFocus := focusedID
@@ -111,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
111 hoveredFocused = pl.ID == oldFocus115 hoveredFocused = pl.ID == oldFocus
112 tb.mu.Unlock()116 tb.mu.Unlock()
113 if p == nil {117 if p == nil {
118+ invalidateRejectedLeftPointer(rt, ev)
114 return119 return
115 }120 }
116 if isMouseFocusPress(ev) && pl.ID != oldFocus {121 if isMouseFocusPress(ev) && pl.ID != oldFocus {
@@ -125,17 +130,31 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
125 } else {130 } else {
126 if multi {131 if multi {
127 tb.mu.Unlock()132 tb.mu.Unlock()
133+ invalidateRejectedLeftPointer(rt, ev)
128 return134 return
129 }135 }
130 p = tb.focusedPane()136 p = tb.focusedPane()
131 tb.mu.Unlock()137 tb.mu.Unlock()
132 if p == nil {138 if p == nil {
139+ invalidateRejectedLeftPointer(rt, ev)
133 return140 return
134 }141 }
135 }142 }
136 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)143 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
137 }144 }
138 145
146+// invalidateRejectedLeftPointer clears a stale drag before returning from an
147+// event that cannot be routed to terminal content. It never runs while tab or
148+// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
149+func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
150+ if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
151+ return
152+ }
153+ rt.copyMu.Lock()
154+ rt.invalidateCopyPointerLocked(true)
155+ rt.copyMu.Unlock()
156+}
157+
139 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at158 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
140 // press time. Geometry is resolved after copyMu is released to preserve the159 // press time. Geometry is resolved after copyMu is released to preserve the
141 // daemon's copyMu -> tab/pane lock prohibition.160 // daemon's copyMu -> tab/pane lock prohibition.
@@ -153,10 +172,10 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
153 // later floating/layout change must not turn an in-flight drag into a hit172 // later floating/layout change must not turn an in-flight drag into a hit
154 // on a different pane.173 // on a different pane.
155 if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document {174 if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document {
156- mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true)175+ mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion)
157 if ev.Type == mouse.Release && !ok {176 if ev.Type == mouse.Release && !ok {
158 rt.copyMu.Lock()177 rt.copyMu.Lock()
159- rt.invalidateCopyPointerLocked(false)178+ rt.invalidateCopyPointerLocked(true)
160 rt.copyMu.Unlock()179 rt.copyMu.Unlock()
161 return180 return
162 }181 }
@@ -181,7 +200,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
181 if !ok {200 if !ok {
182 if ev.Type == mouse.Release || ev.Type == mouse.Press {201 if ev.Type == mouse.Release || ev.Type == mouse.Press {
183 rt.copyMu.Lock()202 rt.copyMu.Lock()
184- rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)203+ rt.invalidateCopyPointerLocked(true)
185 rt.copyMu.Unlock()204 rt.copyMu.Unlock()
186 }205 }
187 return206 return
@@ -206,7 +225,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
206 }225 }
207 if ev.Type == mouse.Release || ev.Type == mouse.Press {226 if ev.Type == mouse.Release || ev.Type == mouse.Press {
208 rt.copyMu.Lock()227 rt.copyMu.Lock()
209- rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)228+ rt.invalidateCopyPointerLocked(true)
210 rt.copyMu.Unlock()229 rt.copyMu.Unlock()
211 }230 }
212 return231 return
@@ -303,7 +322,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
303 d.invalidateRender(sess, ac, true, "input.go")322 d.invalidateRender(sess, ac, true, "input.go")
304 case mouse.Release:323 case mouse.Release:
305 rt.copyMu.Lock()324 rt.copyMu.Lock()
306- rt.invalidateCopyPointerLocked(false)325+ rt.invalidateCopyPointerLocked(true)
307 rt.copyMu.Unlock()326 rt.copyMu.Unlock()
308 }327 }
309 case mouse.WheelUp:328 case mouse.WheelUp:
M internal/usecase/daemon/input_test.go +136 -0
@@ -18,6 +18,7 @@ import (
1818 scopy "github.com/bnema/vev/internal/usecase/copy"
1919 "github.com/bnema/vev/internal/usecase/keys"
2020 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/mouse"
2122 "github.com/bnema/vev/internal/usecase/picker"
2223 "github.com/bnema/vev/pkg/vt"
2324 )
@@ -961,6 +962,23 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
961962 require.Equal(t, 2, hi)
962963 }
963964
965+func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
966+ p, release := newBlockingPTY(t)
967+ defer release()
968+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
969+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
970+ d.enterCopyMode(sess, ac)
971+ mustOutputData(t, sends)
972+
973+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m"))
974+ d.handleInput(sess, ac, []byte("y"))
975+
976+ for range 2 { // press repaint, then copy-mode exit repaint
977+ require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy")
978+ }
979+ require.False(t, ac.overlays.copyActive())
980+}
981+
964982 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
965983 p, _ := newBlockingPTY(t)
966984 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1057,6 +1075,30 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
10571075 // a Press on the status row left the previous drag state in place and a
10581076 // following Motion on a content row silently extended the old selection
10591077 // instead of being a no-op.
1078+func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) {
1079+ p, release := newBlockingPTY(t)
1080+ defer release()
1081+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1082+ d.enterCopyMode(sess, ac)
1083+ mustOutputData(t, sends)
1084+
1085+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1086+ ac.overlays.copyMu.Lock()
1087+ require.True(t, ac.overlays.copyPointer.valid)
1088+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
1089+ ac.overlays.copyMu.Unlock()
1090+
1091+ // Wire row 25 is below the focused pane content, so the release is not a
1092+ // valid endpoint and must not leave a drag that later motion can revive.
1093+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M"))
1094+ ac.overlays.copyMu.Lock()
1095+ require.False(t, ac.overlays.copyPointer.valid)
1096+ require.False(t, ac.overlays.copyClick.valid)
1097+ selection := ac.overlays.copyMode.Selection()
1098+ require.False(t, selection.Enabled)
1099+ ac.overlays.copyMu.Unlock()
1100+}
1101+
10601102 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10611103 p, _ := newBlockingPTY(t)
10621104 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1180,6 +1222,100 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
11801222 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
11811223 }
11821224
1225+func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1226+ for _, tc := range []struct {
1227+ name string
1228+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte
1229+ reject []byte
1230+ }{
1231+ {
1232+ name: "split divider",
1233+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1234+ tb := sess.activeTab()
1235+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1236+ tb.mu.Lock()
1237+ tb.size = domain.Size{Cols: 41, Rows: 10}
1238+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1239+ tb.panes["pane-2"] = p2
1240+ tb.mu.Unlock()
1241+ return []byte("\x1b[<0;21;2m")
1242+ },
1243+ },
1244+ {
1245+ name: "split title bar",
1246+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1247+ tb := sess.activeTab()
1248+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1249+ tb.mu.Lock()
1250+ tb.size = domain.Size{Cols: 41, Rows: 10}
1251+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1252+ tb.panes["pane-2"] = p2
1253+ tb.mu.Unlock()
1254+ return []byte("\x1b[<0;1;1m")
1255+ },
1256+ },
1257+ {
1258+ name: "floating exterior",
1259+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1260+ tb := sess.activeTab()
1261+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1262+ installTestFloating(tb, floating, true)
1263+ tb.mu.Lock()
1264+ _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1265+ tb.mu.Unlock()
1266+ require.True(t, visible)
1267+ *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
1268+ return []byte("\x1b[<0;1;1m")
1269+ },
1270+ },
1271+ } {
1272+ t.Run(tc.name, func(t *testing.T) {
1273+ p, release := newBlockingPTY(t)
1274+ defer release()
1275+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1276+ press := []byte("\x1b[<0;1;2M")
1277+ reject := tc.setup(t, d, sess, ac, &press)
1278+
1279+ // A content press is fresh state. The rejected release must clear it
1280+ // before a following motion has an opportunity to publish copy mode.
1281+ d.handleInput(sess, ac, press)
1282+ ac.overlays.copyMu.Lock()
1283+ require.True(t, ac.overlays.copyPointer.valid)
1284+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
1285+ ac.overlays.copyMu.Unlock()
1286+
1287+ d.handleInput(sess, ac, reject)
1288+ d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1289+
1290+ ac.overlays.copyMu.Lock()
1291+ require.False(t, ac.overlays.copyPointer.valid)
1292+ require.False(t, ac.overlays.copyClick.valid)
1293+ require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish")
1294+ ac.overlays.copyMu.Unlock()
1295+ })
1296+ }
1297+}
1298+
1299+func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) {
1300+ p, release := newBlockingPTY(t)
1301+ defer release()
1302+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1303+
1304+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1305+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press
1306+ ac.overlays.copyMu.Lock()
1307+ require.False(t, ac.overlays.copyPointer.valid)
1308+ ac.overlays.copyMu.Unlock()
1309+
1310+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1311+ ac.setSession(nil)
1312+ d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
1313+ ac.overlays.copyMu.Lock()
1314+ require.False(t, ac.overlays.copyPointer.valid)
1315+ require.False(t, ac.overlays.copyClick.valid)
1316+ ac.overlays.copyMu.Unlock()
1317+}
1318+
11831319 func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
11841320 cases := []struct {
11851321 name string
@@ -18,6 +18,7 @@ import (
18 scopy "github.com/bnema/vev/internal/usecase/copy"18 scopy "github.com/bnema/vev/internal/usecase/copy"
19 "github.com/bnema/vev/internal/usecase/keys"19 "github.com/bnema/vev/internal/usecase/keys"
20 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/mouse"
21 "github.com/bnema/vev/internal/usecase/picker"22 "github.com/bnema/vev/internal/usecase/picker"
22 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
23 )24 )
@@ -961,6 +962,23 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
961 require.Equal(t, 2, hi)962 require.Equal(t, 2, hi)
962 }963 }
963 964
965+func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
966+ p, release := newBlockingPTY(t)
967+ defer release()
968+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
969+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
970+ d.enterCopyMode(sess, ac)
971+ mustOutputData(t, sends)
972+
973+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m"))
974+ d.handleInput(sess, ac, []byte("y"))
975+
976+ for range 2 { // press repaint, then copy-mode exit repaint
977+ require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy")
978+ }
979+ require.False(t, ac.overlays.copyActive())
980+}
981+
964 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {982 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
965 p, _ := newBlockingPTY(t)983 p, _ := newBlockingPTY(t)
966 d, sess, ac, sends := newManualSessionWithPTYs(t, p)984 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1057,6 +1075,30 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
1057 // a Press on the status row left the previous drag state in place and a1075 // a Press on the status row left the previous drag state in place and a
1058 // following Motion on a content row silently extended the old selection1076 // following Motion on a content row silently extended the old selection
1059 // instead of being a no-op.1077 // instead of being a no-op.
1078+func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) {
1079+ p, release := newBlockingPTY(t)
1080+ defer release()
1081+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1082+ d.enterCopyMode(sess, ac)
1083+ mustOutputData(t, sends)
1084+
1085+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1086+ ac.overlays.copyMu.Lock()
1087+ require.True(t, ac.overlays.copyPointer.valid)
1088+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
1089+ ac.overlays.copyMu.Unlock()
1090+
1091+ // Wire row 25 is below the focused pane content, so the release is not a
1092+ // valid endpoint and must not leave a drag that later motion can revive.
1093+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M"))
1094+ ac.overlays.copyMu.Lock()
1095+ require.False(t, ac.overlays.copyPointer.valid)
1096+ require.False(t, ac.overlays.copyClick.valid)
1097+ selection := ac.overlays.copyMode.Selection()
1098+ require.False(t, selection.Enabled)
1099+ ac.overlays.copyMu.Unlock()
1100+}
1101+
1060 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {1102 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1061 p, _ := newBlockingPTY(t)1103 p, _ := newBlockingPTY(t)
1062 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1104 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1180,6 +1222,100 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
1180 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)1222 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1181 }1223 }
1182 1224
1225+func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1226+ for _, tc := range []struct {
1227+ name string
1228+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte
1229+ reject []byte
1230+ }{
1231+ {
1232+ name: "split divider",
1233+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1234+ tb := sess.activeTab()
1235+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1236+ tb.mu.Lock()
1237+ tb.size = domain.Size{Cols: 41, Rows: 10}
1238+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1239+ tb.panes["pane-2"] = p2
1240+ tb.mu.Unlock()
1241+ return []byte("\x1b[<0;21;2m")
1242+ },
1243+ },
1244+ {
1245+ name: "split title bar",
1246+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1247+ tb := sess.activeTab()
1248+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1249+ tb.mu.Lock()
1250+ tb.size = domain.Size{Cols: 41, Rows: 10}
1251+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1252+ tb.panes["pane-2"] = p2
1253+ tb.mu.Unlock()
1254+ return []byte("\x1b[<0;1;1m")
1255+ },
1256+ },
1257+ {
1258+ name: "floating exterior",
1259+ setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1260+ tb := sess.activeTab()
1261+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1262+ installTestFloating(tb, floating, true)
1263+ tb.mu.Lock()
1264+ _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1265+ tb.mu.Unlock()
1266+ require.True(t, visible)
1267+ *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
1268+ return []byte("\x1b[<0;1;1m")
1269+ },
1270+ },
1271+ } {
1272+ t.Run(tc.name, func(t *testing.T) {
1273+ p, release := newBlockingPTY(t)
1274+ defer release()
1275+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1276+ press := []byte("\x1b[<0;1;2M")
1277+ reject := tc.setup(t, d, sess, ac, &press)
1278+
1279+ // A content press is fresh state. The rejected release must clear it
1280+ // before a following motion has an opportunity to publish copy mode.
1281+ d.handleInput(sess, ac, press)
1282+ ac.overlays.copyMu.Lock()
1283+ require.True(t, ac.overlays.copyPointer.valid)
1284+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
1285+ ac.overlays.copyMu.Unlock()
1286+
1287+ d.handleInput(sess, ac, reject)
1288+ d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1289+
1290+ ac.overlays.copyMu.Lock()
1291+ require.False(t, ac.overlays.copyPointer.valid)
1292+ require.False(t, ac.overlays.copyClick.valid)
1293+ require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish")
1294+ ac.overlays.copyMu.Unlock()
1295+ })
1296+ }
1297+}
1298+
1299+func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) {
1300+ p, release := newBlockingPTY(t)
1301+ defer release()
1302+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1303+
1304+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1305+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press
1306+ ac.overlays.copyMu.Lock()
1307+ require.False(t, ac.overlays.copyPointer.valid)
1308+ ac.overlays.copyMu.Unlock()
1309+
1310+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1311+ ac.setSession(nil)
1312+ d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
1313+ ac.overlays.copyMu.Lock()
1314+ require.False(t, ac.overlays.copyPointer.valid)
1315+ require.False(t, ac.overlays.copyClick.valid)
1316+ ac.overlays.copyMu.Unlock()
1317+}
1318+
1183 func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {1319 func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1184 cases := []struct {1320 cases := []struct {
1185 name string1321 name string
M internal/usecase/daemon/overlay_runtime.go +11 -0
@@ -146,6 +146,17 @@ func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
146146 rt.copyClick = copyClickCandidate{}
147147 }
148148
149+func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
150+ if rt.copyCandidate != candidate {
151+ return
152+ }
153+ rt.copyCandidate = nil
154+ rt.copyDocument = nil
155+ rt.copyPane = nil
156+ rt.copySearch = nil
157+ rt.copySearchPending = nil
158+}
159+
149160 func (rt *overlayRuntime) clearCopyModeLocked() {
150161 rt.copyMode = nil
151162 rt.copyCandidate = nil
@@ -146,6 +146,17 @@ func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
146 rt.copyClick = copyClickCandidate{}146 rt.copyClick = copyClickCandidate{}
147 }147 }
148 148
149+func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
150+ if rt.copyCandidate != candidate {
151+ return
152+ }
153+ rt.copyCandidate = nil
154+ rt.copyDocument = nil
155+ rt.copyPane = nil
156+ rt.copySearch = nil
157+ rt.copySearchPending = nil
158+}
159+
149 func (rt *overlayRuntime) clearCopyModeLocked() {160 func (rt *overlayRuntime) clearCopyModeLocked() {
150 rt.copyMode = nil161 rt.copyMode = nil
151 rt.copyCandidate = nil162 rt.copyCandidate = nil

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