From 4b4f9454020467fa2b2689aca350bd599c487feb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 18 Aug 2026 18:47:03 +0200 Subject: [PATCH] Give the focused main view's selection a home outside the controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the selection starts out, and how it widens to a whole change block, are questions about what the view is showing — the same rendered diff the queries next door read. Nothing about them belongs to a keybinding, and the render funnel is about to need them too, from a layer that can reach a helper but not a controller. Co-authored-by: Claude Opus 5 (1M context) --- .../controllers/helpers/diff_line_queries.go | 11 -- .../helpers/diff_line_selection.go | 133 ++++++++++++++++++ pkg/gui/controllers/main_view_controller.go | 129 ++--------------- .../switch_to_focused_main_view_controller.go | 2 +- 4 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 pkg/gui/controllers/helpers/diff_line_selection.go diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index 1f623b937..a7b8874e6 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -223,17 +223,6 @@ func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine i return startView, endView, true } -// SelectedHunkBounds returns the change block selected in hunk mode. The range -// anchor stays on the block's far end when a click moves the cursor before its -// handler runs, so it still identifies the selected block. -func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) { - anchor := view.RangeSelectStartY() - if anchor < 0 { - return 0, 0, false - } - return self.ChangeBlockBounds(view, anchor) -} - // AdjacentChangeBlock returns the view line to move to for next/previous change-block // navigation in view's rendered diff, starting from anchorViewLine. A change block is // lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go new file mode 100644 index 000000000..1b42cc181 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -0,0 +1,133 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// Putting a selection in the focused main view: where it starts out, and how it is +// widened to a whole change block. Both are answered from what the view is showing, +// as recovered by the queries next door. + +// EstablishSelection turns on the focused main view's selection once the view has +// been focused. clickedViewLine is the view line a click pointed at, or -1 for +// keyboard focus, which points at no particular line and so starts at the first +// change line on screen. +// +// Focusing never moves the view: you focus the diff you are reading in order to point +// at something in it, so the selection goes where you are looking rather than the +// view going where the selection would like to be. With no change line on screen at +// all — a long stretch of context — it lands on the middle visible line, the likeliest +// one to be the one being read. +// +// With hunk mode configured as the default the selection widens to the whole change +// block: keyboard focus lands on the first block on screen, and a click on a change +// line selects that line's block, ready to act on. A click on context still selects +// just that line — the click points at it precisely, so it stays editable. +func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, clickedViewLine int) { + mainContext.ResetDiffSelectMode() + view := mainContext.GetView() + + // The panel beneath renders a diff, but that diff may hold nothing to act on: a + // binary file, or an empty commit. Rendering it worked that out, so the pane is + // already showing no selection and there is nowhere to put one. + if !self.ViewHasChangeLines(view) { + return + } + + 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 self.hunkModeApplies(view, clickedViewLine) && self.IsChangeLine(view, clickedViewLine) { + mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk + self.SelectChangeBlock(mainContext, clickedViewLine, false) + return + } + self.ShowSelectionAtLine(view, clickedViewLine, false) + return + } + + target, ok := self.changeToSelectOnScreen(view) + if !ok { + self.ShowSelectionAtLine(view, view.MiddleVisibleLineIdx(), false) + return + } + if self.hunkModeApplies(view, target) { + mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk + self.SelectChangeBlock(mainContext, target, false) + return + } + self.ShowSelectionAtLine(view, target, false) +} + +// changeToSelectOnScreen returns the change line keyboard focus establishes the +// selection on. In hunk mode that is the first block that begins on screen, so that +// the block being offered up is one the user can see the extent of, falling back to a +// block that reaches into the view from above — a change longer than the screen, where +// there is nothing else to offer. Line by line it is simply the first change line on +// screen. ok is false when the viewport shows no change at all. +func (self *DiffLineHelper) changeToSelectOnScreen(view *gocui.View) (int, bool) { + if self.c.UserConfig().Gui.UseHunkModeInStagingView { + return self.FirstChangeBlockInView(view) + } + return self.FirstChangeLineInView(view) +} + +// hunkModeApplies reports whether an established selection should start out as the +// whole change block around the given change line. That's what the config asks for, +// except over a file shown as one solid block of changes, where it would select the +// whole file — see IsSingleHunkForWholeFile. +func (self *DiffLineHelper) hunkModeApplies(view *gocui.View, changeViewLine int) bool { + return self.c.UserConfig().Gui.UseHunkModeInStagingView && + !self.IsSingleHunkForWholeFile(view, changeViewLine) +} + +// ShowSelectionAtLine moves the focused main view's selection to the given view line, +// clamped to the content. scrollIntoView scrolls the line into view when it's +// off-screen, for navigating to it; a click leaves it false, the clicked line being on +// screen already. +func (self *DiffLineHelper) ShowSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) { + view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView) + + // A search carries on from where the selection now is, so that stepping to the + // next match goes to the one after it rather than the one after the match the + // user last stepped to. + view.SetNearestSearchPosition() +} + +// SelectChangeBlock selects the whole change block around the given change line, for +// hunk mode: the cursor goes to the block's first line and the range anchor to its +// last, so the native range highlight spans the block. With no block to be found — +// a diff with no changes in it — it falls back to a single-line selection. +// +// scrollIntoView brings the block's first line on screen, for the commands that mean +// to go there; a click leaves it false, so that the view doesn't move under the mouse +// when the block the click landed in starts above the viewport. +func (self *DiffLineHelper) SelectChangeBlock( + pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool, +) { + view := pane.GetView() + start, end, ok := self.ChangeBlockBounds(view, changeViewLine) + if !ok { + pane.DiffSelectState().Mode = types.DiffSelectModeLine + view.CancelRangeSelect() + self.ShowSelectionAtLine(view, changeViewLine, scrollIntoView) + return + } + view.SetRangeSelectStart(end) + self.ShowSelectionAtLine(view, start, scrollIntoView) +} + +// SelectedHunkBounds returns the change block selected in hunk mode. The range +// anchor stays on the block's far end when a click moves the cursor before its +// handler runs, so it still identifies the selected block. +func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) { + anchor := view.RangeSelectStartY() + if anchor < 0 { + return 0, 0, false + } + return self.ChangeBlockBounds(view, anchor) +} diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index f8fcaa4da..71561043c 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -207,7 +207,7 @@ func (self *MainViewController) togglePanel() error { isDiff := self.isDiffView() self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) if isDiff { - establishDiffSelection(self.c, self.otherContext, -1) + self.c.Helpers().DiffLine.EstablishSelection(self.otherContext, -1) } return nil } @@ -322,11 +322,11 @@ func revealSelectionAfterAction(c *ControllerCommon, pane types.DiffPaneContext, c.Helpers().DiffLine.RevealChangeLineAtOrdinal(view, ordinal, func(viewLine int) { if selectHunk { - selectDiffHunk(c, pane, viewLine, true) + c.Helpers().DiffLine.SelectChangeBlock(pane, viewLine, true) return } view.CancelRangeSelect() - showSelectionAtLine(view, viewLine, true) + c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, true) }) } @@ -539,7 +539,7 @@ func (self *MainViewController) selectClickedDiffLine(viewLine int) { if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok && viewLine >= start && viewLine <= end { self.context.ResetDiffSelectMode() - showSelectionAtLine(view, viewLine, false) + self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false) return } if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) { @@ -548,122 +548,11 @@ func (self *MainViewController) selectClickedDiffLine(viewLine int) { } } self.context.ResetDiffSelectMode() - showSelectionAtLine(view, viewLine, false) -} - -// establishDiffSelection turns on the focused main view's selection once the view has -// been focused. clickedViewLine is the view line a click pointed at, or -1 for -// keyboard focus, which points at no particular line and so starts at the first -// change line on screen. -// -// Focusing never moves the view: you focus the diff you are reading in order to point -// at something in it, so the selection goes where you are looking rather than the -// view going where the selection would like to be. With no change line on screen at -// all — a long stretch of context — it lands on the middle visible line, the likeliest -// one to be the one being read. -// -// With hunk mode configured as the default the selection widens to the whole change -// block: keyboard focus lands on the first block on screen, and a click on a change -// line selects that line's block, ready to act on. A click on context still selects -// just that line — the click points at it precisely, so it stays editable. -func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) { - mainContext.ResetDiffSelectMode() - view := mainContext.GetView() - - // The panel beneath renders a diff, but that diff may hold nothing to act on: a - // binary file, or an empty commit. Rendering it worked that out, so the pane is - // already showing no selection and there is nowhere to put one. - if !c.Helpers().DiffLine.ViewHasChangeLines(view) { - return - } - - 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 - selectDiffHunk(c, mainContext, clickedViewLine, false) - return - } - showSelectionAtLine(view, clickedViewLine, false) - return - } - - target, ok := changeToSelectOnScreen(c, view) - if !ok { - showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false) - return - } - if hunkModeApplies(c, view, target) { - mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk - selectDiffHunk(c, mainContext, target, false) - return - } - showSelectionAtLine(view, target, false) -} - -// changeToSelectOnScreen returns the change line keyboard focus establishes the -// selection on. In hunk mode that is the first block that begins on screen, so that -// the block being offered up is one the user can see the extent of, falling back to a -// block that reaches into the view from above — a change longer than the screen, where -// there is nothing else to offer. Line by line it is simply the first change line on -// screen. ok is false when the viewport shows no change at all. -func changeToSelectOnScreen(c *ControllerCommon, view *gocui.View) (int, bool) { - if c.UserConfig().Gui.UseHunkModeInStagingView { - return c.Helpers().DiffLine.FirstChangeBlockInView(view) - } - return c.Helpers().DiffLine.FirstChangeLineInView(view) -} - -// hunkModeApplies reports whether an established selection should start out as the -// whole change block around the given change line. That's what the config asks for, -// except over a file shown as one solid block of changes, where it would select the -// whole file — see DiffLineHelper.IsSingleHunkForWholeFile. -func hunkModeApplies(c *ControllerCommon, view *gocui.View, changeViewLine int) bool { - return c.UserConfig().Gui.UseHunkModeInStagingView && - !c.Helpers().DiffLine.IsSingleHunkForWholeFile(view, changeViewLine) -} - -// showSelectionAtLine moves the focused main view's selection to the given view line, -// clamped to the content. scrollIntoView scrolls the line into view when it's -// off-screen, for navigating to it; a click leaves it false, the clicked line being on -// screen already. -func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) { - view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView) - - // A search carries on from where the selection now is, so that stepping to the - // next match goes to the one after it rather than the one after the match the - // user last stepped to. - view.SetNearestSearchPosition() + self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false) } func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) { - selectDiffHunk(self.c, self.context, changeViewLine, scrollIntoView) -} - -// selectDiffHunk selects the whole change block around the given change line, for -// hunk mode: the cursor goes to the block's first line and the range anchor to its -// last, so the native range highlight spans the block. With no block to be found — -// a diff with no changes in it — it falls back to a single-line selection. -// -// scrollIntoView brings the block's first line on screen, for the commands that mean -// to go there; a click leaves it false, so that the view doesn't move under the mouse -// when the block the click landed in starts above the viewport. -func selectDiffHunk( - c *ControllerCommon, pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool, -) { - view := pane.GetView() - start, end, ok := c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine) - if !ok { - pane.DiffSelectState().Mode = types.DiffSelectModeLine - view.CancelRangeSelect() - showSelectionAtLine(view, changeViewLine, scrollIntoView) - return - } - view.SetRangeSelectStart(end) - showSelectionAtLine(view, start, scrollIntoView) + self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, scrollIntoView) } // navigate moves the focused main view to the row find locates from the current @@ -762,7 +651,7 @@ func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) } // Line mode leaves a single-line selection at the target; an active range extends // to it, the anchor being untouched. - showSelectionAtLine(v, target, true) + self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true) } // scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving @@ -794,7 +683,7 @@ func (self *MainViewController) moveCursor(delta int) { } else if delta == 1 { checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after) } - showSelectionAtLine(v, after, true) + self.c.Helpers().DiffLine.ShowSelectionAtLine(v, after, true) } // collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line @@ -841,7 +730,7 @@ func (self *MainViewController) adjustSelection(delta int) { // of the diff — dropping hunk mode and a non-sticky range like a plain move does. func (self *MainViewController) selectAbsoluteLine(target int) { self.collapseForLineMove() - showSelectionAtLine(self.context.GetView(), target, true) + self.c.Helpers().DiffLine.ShowSelectionAtLine(self.context.GetView(), target, true) } // selectingRange reports whether a range selection is currently active: we're in diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index e179b3a66..189d3c6ac 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -84,7 +84,7 @@ func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *co self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) if _, ok := self.context.(types.DiffMainViewContext); ok { - establishDiffSelection(self.c, mainViewContext, clickedLineIdx) + self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) } return nil