detached d9ee20f

fix(copy): ignore stale pointer resets

commit
d9ee20f74218e35c0f971ce9479350149e41c675
author
brice · · 2026-07-15 08:49 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
b26b9f7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +103 -15
M internal/usecase/daemon/input.go +19 -15
@@ -311,6 +311,19 @@ func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
311311 }, true
312312 }
313313
314+// invalidateSnapshotCopyPointer clears only the pointer captured with the
315+// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
316+// be erased by an older rejected event.
317+func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
318+ rt.copyMu.Lock()
319+ defer rt.copyMu.Unlock()
320+ if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
321+ rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
322+ return
323+ }
324+ rt.invalidateCopyPointerLocked()
325+}
326+
314327 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
315328 // press time. Geometry is resolved after copyMu is released to preserve the
316329 // daemon's copyMu -> tab/pane lock prohibition.
@@ -320,19 +333,17 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
320333 if !active {
321334 return
322335 }
336+ if d.beforeCopyMouseMap != nil {
337+ d.beforeCopyMouseMap()
338+ }
323339 cfg := d.currentFloatingConfig()
324340 // Once pressed, use the committed rectangle captured by that press. A
325341 // later floating/layout change must not turn an in-flight drag into a hit
326342 // on a different pane.
327343 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
328- if d.beforeCopyMouseMap != nil {
329- d.beforeCopyMouseMap()
330- }
331344 mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
332345 if ev.Type == mouse.Release && !ok {
333- rt.copyMu.Lock()
334- rt.invalidateCopyPointerLocked()
335- rt.copyMu.Unlock()
346+ invalidateSnapshotCopyPointer(rt, snapshot)
336347 return
337348 }
338349 if ok {
@@ -353,16 +364,11 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
353364 tb.mu.Unlock()
354365 if !ok {
355366 if ev.Type == mouse.Release || ev.Type == mouse.Press {
356- rt.copyMu.Lock()
357- rt.invalidateCopyPointerLocked()
358- rt.copyMu.Unlock()
367+ invalidateSnapshotCopyPointer(rt, snapshot)
359368 }
360369 return
361370 }
362371 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
363- if d.beforeCopyMouseMap != nil {
364- d.beforeCopyMouseMap()
365- }
366372 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
367373 if !mappedOK {
368374 if ev.Type == mouse.Press {
@@ -381,9 +387,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
381387 }
382388 }
383389 if ev.Type == mouse.Release || ev.Type == mouse.Press {
384- rt.copyMu.Lock()
385- rt.invalidateCopyPointerLocked()
386- rt.copyMu.Unlock()
390+ invalidateSnapshotCopyPointer(rt, snapshot)
387391 }
388392 return
389393 }
@@ -311,6 +311,19 @@ func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
311 }, true311 }, true
312 }312 }
313 313
314+// invalidateSnapshotCopyPointer clears only the pointer captured with the
315+// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
316+// be erased by an older rejected event.
317+func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
318+ rt.copyMu.Lock()
319+ defer rt.copyMu.Unlock()
320+ if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
321+ rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
322+ return
323+ }
324+ rt.invalidateCopyPointerLocked()
325+}
326+
314 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at327 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
315 // press time. Geometry is resolved after copyMu is released to preserve the328 // press time. Geometry is resolved after copyMu is released to preserve the
316 // daemon's copyMu -> tab/pane lock prohibition.329 // daemon's copyMu -> tab/pane lock prohibition.
@@ -320,19 +333,17 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
320 if !active {333 if !active {
321 return334 return
322 }335 }
336+ if d.beforeCopyMouseMap != nil {
337+ d.beforeCopyMouseMap()
338+ }
323 cfg := d.currentFloatingConfig()339 cfg := d.currentFloatingConfig()
324 // Once pressed, use the committed rectangle captured by that press. A340 // Once pressed, use the committed rectangle captured by that press. A
325 // later floating/layout change must not turn an in-flight drag into a hit341 // later floating/layout change must not turn an in-flight drag into a hit
326 // on a different pane.342 // on a different pane.
327 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {343 if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
328- if d.beforeCopyMouseMap != nil {
329- d.beforeCopyMouseMap()
330- }
331 mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)344 mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
332 if ev.Type == mouse.Release && !ok {345 if ev.Type == mouse.Release && !ok {
333- rt.copyMu.Lock()346+ invalidateSnapshotCopyPointer(rt, snapshot)
334- rt.invalidateCopyPointerLocked()
335- rt.copyMu.Unlock()
336 return347 return
337 }348 }
338 if ok {349 if ok {
@@ -353,16 +364,11 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
353 tb.mu.Unlock()364 tb.mu.Unlock()
354 if !ok {365 if !ok {
355 if ev.Type == mouse.Release || ev.Type == mouse.Press {366 if ev.Type == mouse.Release || ev.Type == mouse.Press {
356- rt.copyMu.Lock()367+ invalidateSnapshotCopyPointer(rt, snapshot)
357- rt.invalidateCopyPointerLocked()
358- rt.copyMu.Unlock()
359 }368 }
360 return369 return
361 }370 }
362 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release371 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
363- if d.beforeCopyMouseMap != nil {
364- d.beforeCopyMouseMap()
365- }
366 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)372 mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
367 if !mappedOK {373 if !mappedOK {
368 if ev.Type == mouse.Press {374 if ev.Type == mouse.Press {
@@ -381,9 +387,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
381 }387 }
382 }388 }
383 if ev.Type == mouse.Release || ev.Type == mouse.Press {389 if ev.Type == mouse.Release || ev.Type == mouse.Press {
384- rt.copyMu.Lock()390+ invalidateSnapshotCopyPointer(rt, snapshot)
385- rt.invalidateCopyPointerLocked()
386- rt.copyMu.Unlock()
387 }391 }
388 return392 return
389 }393 }
M internal/usecase/daemon/input_test.go +84 -0
@@ -1473,6 +1473,90 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
14731473 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
14741474 }
14751475
1476+// TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
1477+// deterministic. Each path captures a copy-input snapshot, yields while that
1478+// snapshot is outside copyMu, then starts a new pointer. The old event must
1479+// never invalidate the new interaction.
1480+func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
1481+ for _, tc := range []struct {
1482+ name string
1483+ prepare func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event
1484+ }{
1485+ {
1486+ name: "press-owned release rejects mapping",
1487+ prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
1488+ t.Helper()
1489+ p := sess.activeTab().focusedPane()
1490+ tb := sess.activeTab()
1491+ tb.mu.Lock()
1492+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
1493+ tb.mu.Unlock()
1494+ require.True(t, ok)
1495+ ac.overlays.copyMu.Lock()
1496+ ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: ac.overlays.copyDocument, geometry: geometry})
1497+ ac.overlays.copyMu.Unlock()
1498+ return mouse.Event{Button: mouse.Left, Type: mouse.Release, Col: geometry.content.X - 1, Row: geometry.content.Y}
1499+ },
1500+ },
1501+ {
1502+ name: "current geometry disappears",
1503+ prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1504+ t.Helper()
1505+ tb := sess.activeTab()
1506+ tb.mu.Lock()
1507+ tb.tree = nil
1508+ tb.mu.Unlock()
1509+ return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 2}
1510+ },
1511+ },
1512+ {
1513+ name: "fresh press rejects document mapping",
1514+ prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1515+ t.Helper()
1516+ tb := sess.activeTab()
1517+ tb.mu.Lock()
1518+ tb.size.Rows = 40 // valid layout coordinate, outside the old document.
1519+ tb.mu.Unlock()
1520+ return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 30}
1521+ },
1522+ },
1523+ } {
1524+ t.Run(tc.name, func(t *testing.T) {
1525+ p, release := newBlockingPTY(t)
1526+ defer release()
1527+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1528+ d.enterCopyMode(sess, ac)
1529+ mustOutputData(t, sends)
1530+ ev := tc.prepare(t, d, sess, ac)
1531+
1532+ var want copyPointerState
1533+ d.beforeCopyMouseMap = func() {
1534+ ac.overlays.copyMu.Lock()
1535+ ac.overlays.beginCopyPointerLocked(copyPointerState{
1536+ pane: ac.overlays.copyPane,
1537+ document: ac.overlays.copyDocument,
1538+ press: scopy.Pos{Row: 7, Col: 7},
1539+ })
1540+ want = ac.overlays.copyPointer
1541+ ac.overlays.copyMu.Unlock()
1542+ }
1543+ defer func() { d.beforeCopyMouseMap = nil }()
1544+
1545+ d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev)
1546+
1547+ ac.overlays.copyMu.Lock()
1548+ defer ac.overlays.copyMu.Unlock()
1549+ require.True(t, want.valid, "test seam must start the newer pointer")
1550+ require.True(t, ac.overlays.copyPointer.valid)
1551+ require.Equal(t, want.epoch, ac.overlays.copyPointerEpoch)
1552+ require.Equal(t, want.epoch, ac.overlays.copyPointer.epoch)
1553+ require.Same(t, want.pane, ac.overlays.copyPointer.pane)
1554+ require.Same(t, want.document, ac.overlays.copyPointer.document)
1555+ require.Equal(t, want.press, ac.overlays.copyPointer.press)
1556+ })
1557+ }
1558+}
1559+
14761560 func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) {
14771561 p, release := newBlockingPTY(t)
14781562 defer release()
@@ -1473,6 +1473,90 @@ 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+// TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
1477+// deterministic. Each path captures a copy-input snapshot, yields while that
1478+// snapshot is outside copyMu, then starts a new pointer. The old event must
1479+// never invalidate the new interaction.
1480+func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
1481+ for _, tc := range []struct {
1482+ name string
1483+ prepare func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event
1484+ }{
1485+ {
1486+ name: "press-owned release rejects mapping",
1487+ prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
1488+ t.Helper()
1489+ p := sess.activeTab().focusedPane()
1490+ tb := sess.activeTab()
1491+ tb.mu.Lock()
1492+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
1493+ tb.mu.Unlock()
1494+ require.True(t, ok)
1495+ ac.overlays.copyMu.Lock()
1496+ ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: ac.overlays.copyDocument, geometry: geometry})
1497+ ac.overlays.copyMu.Unlock()
1498+ return mouse.Event{Button: mouse.Left, Type: mouse.Release, Col: geometry.content.X - 1, Row: geometry.content.Y}
1499+ },
1500+ },
1501+ {
1502+ name: "current geometry disappears",
1503+ prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1504+ t.Helper()
1505+ tb := sess.activeTab()
1506+ tb.mu.Lock()
1507+ tb.tree = nil
1508+ tb.mu.Unlock()
1509+ return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 2}
1510+ },
1511+ },
1512+ {
1513+ name: "fresh press rejects document mapping",
1514+ prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1515+ t.Helper()
1516+ tb := sess.activeTab()
1517+ tb.mu.Lock()
1518+ tb.size.Rows = 40 // valid layout coordinate, outside the old document.
1519+ tb.mu.Unlock()
1520+ return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 30}
1521+ },
1522+ },
1523+ } {
1524+ t.Run(tc.name, func(t *testing.T) {
1525+ p, release := newBlockingPTY(t)
1526+ defer release()
1527+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1528+ d.enterCopyMode(sess, ac)
1529+ mustOutputData(t, sends)
1530+ ev := tc.prepare(t, d, sess, ac)
1531+
1532+ var want copyPointerState
1533+ d.beforeCopyMouseMap = func() {
1534+ ac.overlays.copyMu.Lock()
1535+ ac.overlays.beginCopyPointerLocked(copyPointerState{
1536+ pane: ac.overlays.copyPane,
1537+ document: ac.overlays.copyDocument,
1538+ press: scopy.Pos{Row: 7, Col: 7},
1539+ })
1540+ want = ac.overlays.copyPointer
1541+ ac.overlays.copyMu.Unlock()
1542+ }
1543+ defer func() { d.beforeCopyMouseMap = nil }()
1544+
1545+ d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev)
1546+
1547+ ac.overlays.copyMu.Lock()
1548+ defer ac.overlays.copyMu.Unlock()
1549+ require.True(t, want.valid, "test seam must start the newer pointer")
1550+ require.True(t, ac.overlays.copyPointer.valid)
1551+ require.Equal(t, want.epoch, ac.overlays.copyPointerEpoch)
1552+ require.Equal(t, want.epoch, ac.overlays.copyPointer.epoch)
1553+ require.Same(t, want.pane, ac.overlays.copyPointer.pane)
1554+ require.Same(t, want.document, ac.overlays.copyPointer.document)
1555+ require.Equal(t, want.press, ac.overlays.copyPointer.press)
1556+ })
1557+ }
1558+}
1559+
1476 func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) {1560 func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) {
1477 p, release := newBlockingPTY(t)1561 p, release := newBlockingPTY(t)
1478 defer release()1562 defer release()

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