From 002ac12665be069d442cc7465800196f01b9a171 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 9 Aug 2026 19:53:04 +0200 Subject: [PATCH] Select a range of diff lines by dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a range with the mouse is the obvious gesture once clicking selects a line, and it's the only way to get a range without knowing the keybindings. The range has to be anchored where the mouse went down, which the view can't tell us: a click in hunk mode selects a whole block, leaving the view's own range anchor at the block's far end, so a drag from there would grow the selection from the wrong end. So the clicked line is remembered when the click happens, and the drag anchors there. A drag that reaches the edge of the view keeps scrolling, using the same autoscroller the staging view does — mouse capture means the pointer can be dragged past the edge, and there is more diff down there than fits on screen. Unlike the staging view, whose content is a string that is always there in full, this diff loads lazily, so scrolling down has to keep reading it in or the autoscroll would stop at the loaded edge. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/main_context.go | 16 +++ pkg/gui/controllers/main_view_controller.go | 112 +++++++++++++++++- .../main_view/drag_range_with_autoscroll.go | 43 +++++++ .../main_view/drag_selects_diff_line_range.go | 55 +++++++++ pkg/integration/tests/test_list.go | 2 + 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/main_view/drag_range_with_autoscroll.go create mode 100644 pkg/integration/tests/main_view/drag_selects_diff_line_range.go diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 1ce340b8b..92ce65d37 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -10,6 +10,11 @@ type MainContext struct { *SearchTrait diffSelect types.DiffSelectState + // dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a + // drag that follows can anchor its range there. The click may have selected a whole + // hunk, whose range anchor is the block's far end, so the clicked line can't be + // read back from the view. + dragAnchorViewLine int // selectableContentRenderKey names the render whose content HasSelectableContent // was worked out from. What there is to select is a property of the content, so an // answer about the content of another render says nothing about this one. @@ -38,6 +43,17 @@ func (self *MainContext) ResetDiffSelectMode() { self.GetView().CancelRangeSelect() } +// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag +// that follows can anchor its range there (see dragAnchorViewLine). +func (self *MainContext) SetDragAnchorViewLine(viewLine int) { + self.dragAnchorViewLine = viewLine +} + +// DragAnchorViewLine returns the view line the last mouse-down landed on. +func (self *MainContext) DragAnchorViewLine() int { + return self.dragAnchorViewLine +} + // SelectableContentRenderKey returns the render HasSelectableContent describes (see // selectableContentRenderKey). func (self *MainContext) SelectableContentRenderKey() string { diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 91cd66ae4..74c034fc6 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -13,6 +14,9 @@ type MainViewController struct { context *context.MainContext otherContext *context.MainContext + + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool } var _ types.IController = &MainViewController{} @@ -22,12 +26,19 @@ func NewMainViewController( context *context.MainContext, otherContext *context.MainContext, ) *MainViewController { - return &MainViewController{ + controller := &MainViewController{ baseController: baseController{}, c: c, context: context, otherContext: otherContext, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + c.HelperCommon, + context, + controller.canDragAutoscroll, + controller.handleDragAutoscroll, + ) + return controller } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { @@ -113,6 +124,19 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) Handler: self.onClickInOtherViewOfMainViewPair, FocusedView: self.otherContext.GetViewName(), }, + { + // Dragging after a click extends a range selection from the clicked line. + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.onDragInFocusedView, + FocusedView: self.context.GetViewName(), + }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: self.onDragRelease, + }, } } @@ -240,6 +264,86 @@ func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.View return nil } +// onDragInFocusedView extends a range selection as the mouse is dragged after a +// click, anchored at the line the click landed on rather than wherever the click left +// the selection — a click can select a whole hunk, whose far end would otherwise +// become the anchor. Dragging turns hunk mode off: you get a plain range from the +// clicked line to the line under the cursor, which gocui has already moved here. +func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error { + view := self.context.GetView() + if !self.isDiffView() || !view.Highlight { + return nil + } + sel := self.diffSelectState() + sel.Mode = types.DiffSelectModeRange + sel.RangeIsSticky = false + sel.UserEnabledHunkMode = false + view.SetRangeSelectStart(self.context.DragAnchorViewLine()) + + // A drag that reaches the edge of the view keeps going: mouse capture means the + // pointer can be dragged past the edge, and there is more diff down there than + // fits on screen. opts.Y is where the pointer is in the content, which the + // autoscroller wants relative to the viewport. + self.draggingWithMouse = true + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() + return nil +} + +// GetOnFocusLost stops an autoscroll that is still running when the view loses focus +// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it — +// otherwise the pointer would keep driving a view that no longer has focus. +func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } + } +} + +// canDragAutoscroll reports whether the autoscroller should run: only while a drag is +// actually extending a range in a diff. Scrolling down also has to keep the lazily +// loaded content ahead of the scroll, or it would stop at the loaded edge. +func (self *MainViewController) canDragAutoscroll(direction int) bool { + if !self.draggingWithMouse || !self.isDiffView() { + return false + } + view := self.context.GetView() + if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange { + return false + } + if direction > 0 { + self.c.ReadLinesToFillView(view) + } + return true +} + +// handleDragAutoscroll extends the selection to the line the pointer ends up over +// after the autoscroller has scrolled, leaving the range anchored where the drag +// started. It reports whether the autoscroll should carry on. +// +// The pointer is usually outside the view by now — that is what mouse capture is for — +// so the line it is over is clamped to the visible ones, leaving the selection's far +// end at the edge the scroll is moving towards. +func (self *MainViewController) handleDragAutoscroll(viewLine int) bool { + if !self.canDragAutoscroll(0) { + return false + } + view := self.context.GetView() + originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds() + target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1)) + view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1))) + return true +} + // selectClickedDiffLine sets the focused main view's selection from a click at the // given view line. In hunk mode, clicking inside the selected block collapses it to // that line; clicking a change line outside it keeps hunk mode and selects that block. @@ -249,6 +353,9 @@ func (self *MainViewController) selectClickedDiffLine(viewLine int) { return } view := self.context.GetView() + // Remember where the click landed so that a drag that follows anchors its range + // there, even when this click selects a whole hunk. + self.context.SetDragAnchorViewLine(viewLine) if self.diffSelectState().Mode == types.DiffSelectModeHunk { if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok && viewLine >= start && viewLine <= end { @@ -292,6 +399,9 @@ func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContex } if clickedViewLine >= 0 { + // Remember where the click landed so that a drag that follows anchors its range + // there, even when this click selects a whole hunk. + mainContext.SetDragAnchorViewLine(clickedViewLine) if hunkModeApplies(c, view, clickedViewLine) && c.Helpers().DiffLine.IsChangeLine(view, clickedViewLine) { mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk diff --git a/pkg/integration/tests/main_view/drag_range_with_autoscroll.go b/pkg/integration/tests/main_view/drag_range_with_autoscroll.go new file mode 100644 index 000000000..a995049af --- /dev/null +++ b/pkg/integration/tests/main_view/drag_range_with_autoscroll.go @@ -0,0 +1,43 @@ +package main_view + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragRangeWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling while dragging a range selection at the edge of the focused main view", + 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) + shell.Commit("one") + 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(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The diff is longer than the view, so holding the pointer at the bottom edge has + // to keep scrolling and extending the selection rather than stopping there. + t.Views().Main(). + IsFocused(). + ClickAndHold(1, 6). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(9). + MouseRelease() + }, +}) diff --git a/pkg/integration/tests/main_view/drag_selects_diff_line_range.go b/pkg/integration/tests/main_view/drag_selects_diff_line_range.go new file mode 100644 index 000000000..41df0633b --- /dev/null +++ b/pkg/integration/tests/main_view/drag_selects_diff_line_range.go @@ -0,0 +1,55 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragSelectsDiffLineRange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Dragging in the main view's diff selects the range from the line the drag started on", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = true + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Hunk mode is on, so the mouse-down alone selects the whole block; the drag + // anchors the range where the mouse went down instead, one line at a time. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + ClickAndHold(0, 8). + MouseMove(0, 9). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + ). + MouseMove(0, 10). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + MouseRelease(). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7512f68d0..1a8b13d20 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -365,6 +365,8 @@ var tests = []*components.IntegrationTest{ interactive_rebase.SwapWithConflict, interactive_rebase.ViewFilesOfTodoEntries, main_view.ClickSelectsDiffLine, + main_view.DragRangeWithAutoscroll, + main_view.DragSelectsDiffLineRange, main_view.EscapeDismissesSelection, main_view.HideSelectionWhenChangesVanish, main_view.KeepSelectionVisibleWhenDiffShrinks,