detached 1f016aa

fix(copy): preserve mouse routing across overlays

commit
1f016aac4f7aa69e37052a8cf6fdd7b82344eb22
author
brice · · 2026-07-15 10:15 UTC
committer
brice · · 2026-07-15 10:19 UTC
parent
7ab375d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +105 -25
M internal/usecase/daemon/copy_mouse_input.go +25 -25
@@ -13,15 +13,21 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev
1313 if !rt.copyActive() {
1414 return false
1515 }
16+ if rt.copySearchActive() {
17+ // Search owns ordinary mouse input, but a release completes an
18+ // in-flight drag. Route it through the active mapper so its endpoint is
19+ // clamped against the press-owned geometry before clearing the pointer.
20+ if ev.Button == mouse.Left && ev.Type == mouse.Release {
21+ d.handleActiveCopyMouse(sess, ac, tb, ev)
22+ }
23+ return true
24+ }
1625 if ev.Button == mouse.Left && ev.Type == mouse.Release {
1726 rt.copyMu.Lock()
1827 rt.invalidateCopyPointerLocked(false)
1928 rt.copyMu.Unlock()
2029 return true
2130 }
22- if rt.copySearchActive() {
23- return true
24- }
2531 switch ev.Button {
2632 case mouse.WheelUp:
2733 d.copyWheel(sess, ac, -3)
@@ -251,7 +257,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
251257 // later floating/layout change must not turn an in-flight drag into a hit
252258 // on a different pane.
253259 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
254- mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
260+ mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion || ev.Type == mouse.Release)
255261 if ev.Type == mouse.Release && !ok {
256262 invalidateSnapshotCopyPointer(rt, snapshot)
257263 return
@@ -263,15 +269,24 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
263269 }
264270 tb.mu.Lock()
265271 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
266- if !ok && ev.Type == mouse.Press {
267- // Preserve the established press-on-another-pane behavior: copy mode
268- // exits rather than retargeting its immutable document.
269- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {
270- focusPlacementLocked(tb, hit.pane.id)
271- d.applyLayoutLocked(tb)
272+ otherPane := false
273+ if ev.Type == mouse.Press {
274+ // A new press is allowed to change focus, but never the document owned
275+ // by the active copy mode. Do all tab work first; exitCopyMode takes
276+ // copyMu only after tb.mu is released.
277+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
278+ otherPane = true
279+ if tb.tree != nil {
280+ focusPlacementLocked(tb, hit.pane.id)
281+ d.applyLayoutLocked(tb)
282+ }
272283 }
273284 }
274285 tb.mu.Unlock()
286+ if otherPane {
287+ d.exitCopyMode(ac)
288+ return
289+ }
275290 if !ok {
276291 if ev.Type == mouse.Release || ev.Type == mouse.Press {
277292 invalidateSnapshotCopyPointer(rt, snapshot)
@@ -281,21 +296,6 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
281296 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
282297 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
283298 if !mappedOK {
284- if ev.Type == mouse.Press {
285- otherPane := false
286- tb.mu.Lock()
287- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
288- otherPane = true
289- if tb.tree != nil {
290- focusPlacementLocked(tb, hit.pane.id)
291- d.applyLayoutLocked(tb)
292- }
293- }
294- tb.mu.Unlock()
295- if otherPane {
296- d.exitCopyMode(ac)
297- }
298- }
299299 if ev.Type == mouse.Release || ev.Type == mouse.Press {
300300 invalidateSnapshotCopyPointer(rt, snapshot)
301301 }
@@ -13,15 +13,21 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev
13 if !rt.copyActive() {13 if !rt.copyActive() {
14 return false14 return false
15 }15 }
16+ if rt.copySearchActive() {
17+ // Search owns ordinary mouse input, but a release completes an
18+ // in-flight drag. Route it through the active mapper so its endpoint is
19+ // clamped against the press-owned geometry before clearing the pointer.
20+ if ev.Button == mouse.Left && ev.Type == mouse.Release {
21+ d.handleActiveCopyMouse(sess, ac, tb, ev)
22+ }
23+ return true
24+ }
16 if ev.Button == mouse.Left && ev.Type == mouse.Release {25 if ev.Button == mouse.Left && ev.Type == mouse.Release {
17 rt.copyMu.Lock()26 rt.copyMu.Lock()
18 rt.invalidateCopyPointerLocked(false)27 rt.invalidateCopyPointerLocked(false)
19 rt.copyMu.Unlock()28 rt.copyMu.Unlock()
20 return true29 return true
21 }30 }
22- if rt.copySearchActive() {
23- return true
24- }
25 switch ev.Button {31 switch ev.Button {
26 case mouse.WheelUp:32 case mouse.WheelUp:
27 d.copyWheel(sess, ac, -3)33 d.copyWheel(sess, ac, -3)
@@ -251,7 +257,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
251 // later floating/layout change must not turn an in-flight drag into a hit257 // later floating/layout change must not turn an in-flight drag into a hit
252 // on a different pane.258 // on a different pane.
253 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {259 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
254- mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)260+ mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion || ev.Type == mouse.Release)
255 if ev.Type == mouse.Release && !ok {261 if ev.Type == mouse.Release && !ok {
256 invalidateSnapshotCopyPointer(rt, snapshot)262 invalidateSnapshotCopyPointer(rt, snapshot)
257 return263 return
@@ -263,15 +269,24 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
263 }269 }
264 tb.mu.Lock()270 tb.mu.Lock()
265 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)271 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
266- if !ok && ev.Type == mouse.Press {272+ otherPane := false
267- // Preserve the established press-on-another-pane behavior: copy mode273+ if ev.Type == mouse.Press {
268- // exits rather than retargeting its immutable document.274+ // A new press is allowed to change focus, but never the document owned
269- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {275+ // by the active copy mode. Do all tab work first; exitCopyMode takes
270- focusPlacementLocked(tb, hit.pane.id)276+ // copyMu only after tb.mu is released.
271- d.applyLayoutLocked(tb)277+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
278+ otherPane = true
279+ if tb.tree != nil {
280+ focusPlacementLocked(tb, hit.pane.id)
281+ d.applyLayoutLocked(tb)
282+ }
272 }283 }
273 }284 }
274 tb.mu.Unlock()285 tb.mu.Unlock()
286+ if otherPane {
287+ d.exitCopyMode(ac)
288+ return
289+ }
275 if !ok {290 if !ok {
276 if ev.Type == mouse.Release || ev.Type == mouse.Press {291 if ev.Type == mouse.Release || ev.Type == mouse.Press {
277 invalidateSnapshotCopyPointer(rt, snapshot)292 invalidateSnapshotCopyPointer(rt, snapshot)
@@ -281,21 +296,6 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
281 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release296 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
282 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)297 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
283 if !mappedOK {298 if !mappedOK {
284- if ev.Type == mouse.Press {
285- otherPane := false
286- tb.mu.Lock()
287- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
288- otherPane = true
289- if tb.tree != nil {
290- focusPlacementLocked(tb, hit.pane.id)
291- d.applyLayoutLocked(tb)
292- }
293- }
294- tb.mu.Unlock()
295- if otherPane {
296- d.exitCopyMode(ac)
297- }
298- }
299 if ev.Type == mouse.Release || ev.Type == mouse.Press {299 if ev.Type == mouse.Release || ev.Type == mouse.Press {
300 invalidateSnapshotCopyPointer(rt, snapshot)300 invalidateSnapshotCopyPointer(rt, snapshot)
301 }301 }
M internal/usecase/daemon/copymode.go +8 -0
@@ -193,6 +193,14 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
193193 rt.copyPointer = pointer
194194 }
195195 case mouse.Release:
196+ pointer := rt.copyPointer
197+ if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging {
198+ if pointer.wordDrag {
199+ changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
200+ } else {
201+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
202+ }
203+ }
196204 rt.invalidateCopyPointerLocked(false)
197205 }
198206 rt.copyMu.Unlock()
@@ -193,6 +193,14 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
193 rt.copyPointer = pointer193 rt.copyPointer = pointer
194 }194 }
195 case mouse.Release:195 case mouse.Release:
196+ pointer := rt.copyPointer
197+ if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging {
198+ if pointer.wordDrag {
199+ changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
200+ } else {
201+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
202+ }
203+ }
196 rt.invalidateCopyPointerLocked(false)204 rt.invalidateCopyPointerLocked(false)
197 }205 }
198 rt.copyMu.Unlock()206 rt.copyMu.Unlock()
M internal/usecase/daemon/input_test.go +72 -0
@@ -1473,6 +1473,78 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
14731473 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
14741474 }
14751475
1476+func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) {
1477+ p1, release := newBlockingPTY(t)
1478+ defer release()
1479+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1480+ tb := sess.activeTab()
1481+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1482+ tb.mu.Lock()
1483+ tb.size = domain.Size{Cols: 41, Rows: 10}
1484+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1485+ tb.panes["pane-2"] = p2
1486+ tb.mu.Unlock()
1487+
1488+ d.enterCopyMode(sess, ac)
1489+ require.True(t, ac.overlays.copyActive())
1490+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1491+ ac.overlays.copyMu.Lock()
1492+ require.True(t, ac.overlays.copyPointer.valid)
1493+ require.True(t, ac.overlays.copyClick.valid)
1494+ ac.overlays.copyMu.Unlock()
1495+
1496+ // A press in the right split must focus it, then leave the old pane's copy
1497+ // snapshot behind. The following press is therefore a fresh p2 candidate.
1498+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1499+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1500+ require.False(t, ac.overlays.copyActive())
1501+ ac.overlays.copyMu.Lock()
1502+ require.False(t, ac.overlays.copyPointer.valid)
1503+ require.False(t, ac.overlays.copyClick.valid)
1504+ ac.overlays.copyMu.Unlock()
1505+
1506+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1507+ ac.overlays.copyMu.Lock()
1508+ pointer := ac.overlays.copyPointer
1509+ ac.overlays.copyMu.Unlock()
1510+ require.True(t, pointer.valid)
1511+ require.Same(t, p2, pointer.pane)
1512+}
1513+
1514+func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1515+ for _, tc := range []struct {
1516+ name string
1517+ release string
1518+ wantCol int
1519+ }{
1520+ {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7},
1521+ {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79},
1522+ } {
1523+ t.Run(tc.name, func(t *testing.T) {
1524+ d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1525+
1526+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1527+ copyMouseInput(d, sess, ac, "/")
1528+ require.True(t, ac.overlays.copySearchActive())
1529+
1530+ // Search owns ordinary mouse input, but the release still completes
1531+ // the active drag against its press-owned geometry.
1532+ copyMouseInput(d, sess, ac, tc.release)
1533+ copyMouseInput(d, sess, ac, "\x03")
1534+
1535+ ac.overlays.copyMu.Lock()
1536+ selection := ac.overlays.copyMode.Selection()
1537+ pointerValid := ac.overlays.copyPointer.valid
1538+ ac.overlays.copyMu.Unlock()
1539+ require.True(t, selection.Enabled)
1540+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1541+ require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active)
1542+ require.False(t, pointerValid)
1543+ require.False(t, ac.overlays.copySearchActive())
1544+ })
1545+ }
1546+}
1547+
14761548 // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
14771549 // deterministic. Each path captures a copy-input snapshot, yields while that
14781550 // snapshot is outside copyMu, then starts a new pointer. The old event must
@@ -1473,6 +1473,78 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1473 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)1473 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
1474 }1474 }
1475 1475
1476+func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) {
1477+ p1, release := newBlockingPTY(t)
1478+ defer release()
1479+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1480+ tb := sess.activeTab()
1481+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1482+ tb.mu.Lock()
1483+ tb.size = domain.Size{Cols: 41, Rows: 10}
1484+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1485+ tb.panes["pane-2"] = p2
1486+ tb.mu.Unlock()
1487+
1488+ d.enterCopyMode(sess, ac)
1489+ require.True(t, ac.overlays.copyActive())
1490+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1491+ ac.overlays.copyMu.Lock()
1492+ require.True(t, ac.overlays.copyPointer.valid)
1493+ require.True(t, ac.overlays.copyClick.valid)
1494+ ac.overlays.copyMu.Unlock()
1495+
1496+ // A press in the right split must focus it, then leave the old pane's copy
1497+ // snapshot behind. The following press is therefore a fresh p2 candidate.
1498+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1499+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1500+ require.False(t, ac.overlays.copyActive())
1501+ ac.overlays.copyMu.Lock()
1502+ require.False(t, ac.overlays.copyPointer.valid)
1503+ require.False(t, ac.overlays.copyClick.valid)
1504+ ac.overlays.copyMu.Unlock()
1505+
1506+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1507+ ac.overlays.copyMu.Lock()
1508+ pointer := ac.overlays.copyPointer
1509+ ac.overlays.copyMu.Unlock()
1510+ require.True(t, pointer.valid)
1511+ require.Same(t, p2, pointer.pane)
1512+}
1513+
1514+func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1515+ for _, tc := range []struct {
1516+ name string
1517+ release string
1518+ wantCol int
1519+ }{
1520+ {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7},
1521+ {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79},
1522+ } {
1523+ t.Run(tc.name, func(t *testing.T) {
1524+ d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1525+
1526+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1527+ copyMouseInput(d, sess, ac, "/")
1528+ require.True(t, ac.overlays.copySearchActive())
1529+
1530+ // Search owns ordinary mouse input, but the release still completes
1531+ // the active drag against its press-owned geometry.
1532+ copyMouseInput(d, sess, ac, tc.release)
1533+ copyMouseInput(d, sess, ac, "\x03")
1534+
1535+ ac.overlays.copyMu.Lock()
1536+ selection := ac.overlays.copyMode.Selection()
1537+ pointerValid := ac.overlays.copyPointer.valid
1538+ ac.overlays.copyMu.Unlock()
1539+ require.True(t, selection.Enabled)
1540+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1541+ require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active)
1542+ require.False(t, pointerValid)
1543+ require.False(t, ac.overlays.copySearchActive())
1544+ })
1545+ }
1546+}
1547+
1476 // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race1548 // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
1477 // deterministic. Each path captures a copy-input snapshot, yields while that1549 // deterministic. Each path captures a copy-input snapshot, yields while that
1478 // snapshot is outside copyMu, then starts a new pointer. The old event must1550 // snapshot is outside copyMu, then starts a new pointer. The old event must

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