Auto-scroll when dragging to create range selection in staging view

When the pointer reaches the edge of the view during a drag (or leaves
the view entirely, which mouse capture makes possible), keep scrolling
and extending the selection: slowly on the innermost edge row, faster
on the outermost row, and very fast beyond. Scrolling starts after a
short delay so that a drag merely passing near the edge doesn't scroll.

When the view loses focus mid-drag (e.g. because a popup appeared),
cancel the autoscroll and the mouse capture.
This commit is contained in:
Stefan Haller 2026-07-24 08:52:07 +02:00
parent f5a069342b
commit b682fb7635
8 changed files with 176 additions and 11 deletions

View file

@ -4,6 +4,7 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@ -19,18 +20,27 @@ func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerContro
}
func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController {
return &PatchExplorerController{
controller := &PatchExplorerController{
baseController: baseController{},
c: self.c,
context: context,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
self.c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
type PatchExplorerController struct {
baseController
c *ControllerCommon
context types.IPatchExplorerContext
context types.IPatchExplorerContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
func (self *PatchExplorerController) Context() types.Context {
@ -153,10 +163,74 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: func(gocui.ViewMouseBindingOpts) error {
return self.withRenderAndFocus(self.HandleMouseDrag)()
},
Handler: self.handleMouseDrag,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() },
},
}
}
func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error {
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(opts.Y)
self.renderDragSelection()
return nil
})(); err != nil {
return err
}
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *PatchExplorerController) canDragAutoscroll(int) bool {
state := self.context.GetState()
return state != nil && state.SelectingRange()
}
func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool {
if !self.canDragAutoscroll(0) {
return false
}
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(viewIndex)
self.renderDragSelection()
return nil
})(); err != nil {
return false
}
return true
}
func (self *PatchExplorerController) renderDragSelection() {
view := self.context.GetView()
state := self.context.GetState()
originY := view.OriginY()
startIndex, _ := state.SelectedViewRange()
view.SetRangeSelectStart(startIndex)
view.SetCursorY(state.GetSelectedViewLineIdx() - originY)
self.context.Render()
}
func (self *PatchExplorerController) handleDragRelease() error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
return nil
}
func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
@ -266,12 +340,6 @@ func (self *PatchExplorerController) HandleMouseDown() error {
return nil
}
func (self *PatchExplorerController) HandleMouseDrag() error {
self.context.GetState().DragSelectLine(self.context.GetViewTrait().SelectedLineIdx())
return nil
}
func (self *PatchExplorerController) CopySelectedToClipboard() error {
selected := self.context.GetState().PlainRenderSelected()

View file

@ -73,6 +73,10 @@ func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, buttons, 0),

View file

@ -1,9 +1,13 @@
package components
import (
"time"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
)
const eventuallyTimeout = 2 * time.Second
type assertionHelper struct {
gui integrationTypes.GuiDriver
}
@ -24,6 +28,21 @@ func (self *assertionHelper) assertWithRetries(test func() (bool, string)) {
}
}
func (self *assertionHelper) assertEventually(test func() (bool, string)) {
deadline := time.Now().Add(eventuallyTimeout)
for {
ok, message := test()
if ok {
return
}
if time.Now().After(deadline) {
self.fail(message)
return
}
time.Sleep(10 * time.Millisecond)
}
}
func (self *assertionHelper) fail(message string) {
self.gui.Fail(message)
}

View file

@ -53,6 +53,10 @@ func (self *fakeGuiDriver) MouseRelease(x, y int) {
self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) {
f()
}
func (self *fakeGuiDriver) FocusIn() {
}

View file

@ -343,6 +343,30 @@ func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver {
return self
}
func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver {
self.t.assertEventually(func() (bool, string) {
var actual int
self.t.gui.OnUIThreadAndWait(func() {
actual = self.getView().SelectedLineIdx()
})
return actual >= expected, fmt.Sprintf("%s: Expected selected line index to be at least %d, got %d", self.context, expected, actual)
})
return self
}
func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
self.t.assertEventually(func() (bool, string) {
var actual int
self.t.gui.OnUIThreadAndWait(func() {
actual = self.getView().OriginY()
})
return actual >= expected, fmt.Sprintf("%s: Expected origin Y to be at least %d, got %d", self.context, expected, actual)
})
return self
}
// focus the view (assumes the view is a side-view)
func (self *ViewDriver) Focus() *ViewDriver {
viewName := self.getView().Name()
@ -505,6 +529,10 @@ func (self *ViewDriver) MouseMove(x, y int) *ViewDriver {
return self
}
func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver {
return self.MouseMove(x, self.getView().InnerHeight()-1)
}
func (self *ViewDriver) RepeatMouseMove() *ViewDriver {
self.t.repeatMouseMove()
return self

View file

@ -499,6 +499,7 @@ var tests = []*components.IntegrationTest{
ui.OpenLinkFailure,
ui.PromoteTabToSidePanel,
ui.RangeSelect,
ui.RangeSelectWithAutoscroll,
ui.ReloadSidePanels,
ui.ReorderSidePanels,
ui.SwitchTabFromMenu,

View file

@ -0,0 +1,38 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep scrolling while creating a range selection at the panel edge",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
fileContent := "base\n"
shell.CreateFileAndAdd("file1", fileContent)
for i := 1; i <= 40; i++ {
fileContent += fmt.Sprintf("line %d\n", i)
}
shell.UpdateFile("file1", fileContent)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
PressEnter()
t.Views().Staging().
ClickAndHold(1, 6).
MouseMoveToBottom(1).
OriginYAtLeast(3).
SelectedLineIdxAtLeast(9).
MouseRelease()
},
})

View file

@ -31,6 +31,9 @@ type GuiDriver interface {
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)
// Can be used to avoid data races with the UI thread in the uncommon cases that
// the test driver needs to assert state while the gui is not idle.
OnUIThreadAndWait(func())
// Simulate the terminal window regaining focus (which triggers a reload of
// changed config files)
FocusIn()