detached 50dce66

fix(daemon): capture copy source on drag entry

commit
50dce664c2d658fffd9460243483229ca9276d5b
author
brice · · 2026-07-10 07:45 UTC
parent
a1d7783
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +37 -3
M internal/usecase/daemon/copymode.go +5 -1
@@ -437,7 +437,11 @@ func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame
437437 }
438438 }
439439 statusY := frame.Height - 1
440- clear(frame.Row(statusY))
440+ statusRow := frame.Row(statusY)
441+ blank := renderer.BlankCell()
442+ for x := range statusRow {
443+ statusRow[x] = blank
444+ }
441445 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
442446 return frame, []renderer.Damage{renderer.FullRedraw()}
443447 }
@@ -437,7 +437,11 @@ func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame
437 }437 }
438 }438 }
439 statusY := frame.Height - 1439 statusY := frame.Height - 1
440- clear(frame.Row(statusY))440+ statusRow := frame.Row(statusY)
441+ blank := renderer.BlankCell()
442+ for x := range statusRow {
443+ statusRow[x] = blank
444+ }
441 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])445 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
442 return frame, []renderer.Damage{renderer.FullRedraw()}446 return frame, []renderer.Damage{renderer.FullRedraw()}
443 }447 }
M internal/usecase/daemon/copymode_test.go +27 -2
@@ -752,12 +752,14 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
752752 viewportTop := ac.overlays.copyMode.ViewportTop
753753 inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner
754754
755- press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2)
755+ // Copy body row k renders at wire row inner.Y+k+2 (top bar + content
756+ // offset); clicking a row must select exactly that row.
757+ press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
756758 d.handleInput(sess, ac, []byte(press))
757759 awaitFrame(t, sends, ports.MsgOutput)
758760 require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
759761
760- motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3)
762+ motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
761763 d.handleInput(sess, ac, []byte(motion))
762764 awaitFrame(t, sends, ports.MsgOutput)
763765 lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
@@ -790,3 +792,26 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
790792 require.NotContains(t, string(data), "[SCROLL]")
791793 require.NotContains(t, string(data), "flt-live")
792794 }
795+
796+func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
797+ pty, _ := newBlockingPTY(t)
798+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
799+ pane := sess.tabs[0].focusedPane()
800+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
801+ copy(pane.screen.Frame.Row(1), testRow("bravo"))
802+
803+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
804+ mustOutputData(t, sends)
805+ require.NotNil(t, ac.overlays.copyMode)
806+ ac.overlays.copyMu.Lock()
807+ captured := ac.overlays.copyPane
808+ ac.overlays.copyMu.Unlock()
809+ require.Same(t, pane, captured, "drag entry must capture the copy source pane")
810+
811+ d.handleInput(sess, ac, []byte("y"))
812+ out := awaitFrame(t, sends, ports.MsgOutput)
813+ msg, err := ports.UnmarshalOutput(out.Payload)
814+ require.NoError(t, err)
815+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07"
816+ require.Equal(t, want, string(msg.Data))
817+}
@@ -752,12 +752,14 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
752 viewportTop := ac.overlays.copyMode.ViewportTop752 viewportTop := ac.overlays.copyMode.ViewportTop
753 inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner753 inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner
754 754
755- press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2)755+ // Copy body row k renders at wire row inner.Y+k+2 (top bar + content
756+ // offset); clicking a row must select exactly that row.
757+ press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
756 d.handleInput(sess, ac, []byte(press))758 d.handleInput(sess, ac, []byte(press))
757 awaitFrame(t, sends, ports.MsgOutput)759 awaitFrame(t, sends, ports.MsgOutput)
758 require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)760 require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
759 761
760- motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3)762+ motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
761 d.handleInput(sess, ac, []byte(motion))763 d.handleInput(sess, ac, []byte(motion))
762 awaitFrame(t, sends, ports.MsgOutput)764 awaitFrame(t, sends, ports.MsgOutput)
763 lo, hi, ok := ac.overlays.copyMode.SelectedBounds()765 lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
@@ -790,3 +792,26 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
790 require.NotContains(t, string(data), "[SCROLL]")792 require.NotContains(t, string(data), "[SCROLL]")
791 require.NotContains(t, string(data), "flt-live")793 require.NotContains(t, string(data), "flt-live")
792 }794 }
795+
796+func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
797+ pty, _ := newBlockingPTY(t)
798+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
799+ pane := sess.tabs[0].focusedPane()
800+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
801+ copy(pane.screen.Frame.Row(1), testRow("bravo"))
802+
803+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
804+ mustOutputData(t, sends)
805+ require.NotNil(t, ac.overlays.copyMode)
806+ ac.overlays.copyMu.Lock()
807+ captured := ac.overlays.copyPane
808+ ac.overlays.copyMu.Unlock()
809+ require.Same(t, pane, captured, "drag entry must capture the copy source pane")
810+
811+ d.handleInput(sess, ac, []byte("y"))
812+ out := awaitFrame(t, sends, ports.MsgOutput)
813+ msg, err := ports.UnmarshalOutput(out.Payload)
814+ require.NoError(t, err)
815+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07"
816+ require.Equal(t, want, string(msg.Data))
817+}
M internal/usecase/daemon/input.go +4 -0
@@ -84,6 +84,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
8484 if !pointInRect(ev.Col, contentRow, geometry.Inner) {
8585 return
8686 }
87+ // Match the multi-pane translation: hand copyMouse a zero-based
88+ // popup row so a click selects exactly the row under the pointer.
89+ ev.Row = contentRow
8790 ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
8891 switch ev.Button {
8992 case mouse.Left:
@@ -276,6 +279,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
276279 mode.StartSelectionAt(snap, pressTop+pressRow)
277280 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
278281 rt.copyMode = mode
282+ rt.copyPane = p
279283 rt.copyPressRow = pressTop + pressRow
280284 rt.copyPressRowValid = true
281285 rt.copyDragging = true
@@ -84,6 +84,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
84 if !pointInRect(ev.Col, contentRow, geometry.Inner) {84 if !pointInRect(ev.Col, contentRow, geometry.Inner) {
85 return85 return
86 }86 }
87+ // Match the multi-pane translation: hand copyMouse a zero-based
88+ // popup row so a click selects exactly the row under the pointer.
89+ ev.Row = contentRow
87 ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)90 ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
88 switch ev.Button {91 switch ev.Button {
89 case mouse.Left:92 case mouse.Left:
@@ -276,6 +279,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
276 mode.StartSelectionAt(snap, pressTop+pressRow)279 mode.StartSelectionAt(snap, pressTop+pressRow)
277 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)280 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
278 rt.copyMode = mode281 rt.copyMode = mode
282+ rt.copyPane = p
279 rt.copyPressRow = pressTop + pressRow283 rt.copyPressRow = pressTop + pressRow
280 rt.copyPressRowValid = true284 rt.copyPressRowValid = true
281 rt.copyDragging = true285 rt.copyDragging = true
M internal/usecase/daemon/render_test.go +1 -0
@@ -1447,6 +1447,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14471447 status := rowText(frame.Row(7))
14481448 require.Contains(t, status, "[SCROLL]")
14491449 require.NotContains(t, status, "#")
1450+ require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
14501451 }
14511452
14521453 func TestCopyTargetRectLocked(t *testing.T) {
@@ -1447,6 +1447,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1447 status := rowText(frame.Row(7))1447 status := rowText(frame.Row(7))
1448 require.Contains(t, status, "[SCROLL]")1448 require.Contains(t, status, "[SCROLL]")
1449 require.NotContains(t, status, "#")1449 require.NotContains(t, status, "#")
1450+ require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
1450 }1451 }
1451 1452
1452 func TestCopyTargetRectLocked(t *testing.T) {1453 func TestCopyTargetRectLocked(t *testing.T) {

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