Files
lazygit/pkg/gui/controllers/helpers/diff_line_selection.go
T
Stefan HallerandClaude Opus 5 1e58904a93 Take lines back out of the custom patch from the pane showing it
Space in the pane previewing the patch had nothing to act on: everything
there is in the patch already, so it can only mean taking those lines back
out, in the way that space in the staged side of the working tree's diff
only unstages.

A line of the patch can't be found by its number: the patch renumbers
whatever follows a change it leaves out, so a line of it names a line of
the commit's diff that isn't the one shown. A line is identified instead
by its position among its file's changes, counted in the diff the patch is
shown as. That is the same position it has among the changes the patch
holds for the file, since both are in the order the file has them. This
holds however the rendering came out, so a renderer that groups a hunk's
deletions before its additions, or leaves a change out altogether, no
longer matters.

The pane's rows are stated in terms of the trees the patch was
materialized into, so the paths come back to the repo's own before
anything is looked up in them. This also lets the lines there be copied
and edited, as the lines of a commit's diff can be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 11:33:11 +02:00

204 lines
8.9 KiB
Go

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 pane may hold nothing to act on: a diff of a binary file or an empty commit,
// or content that is no diff of the panel's at all. Rendering it worked that out,
// so the pane is already showing no selection and there is nowhere to put one.
if !mainContext.HasSelectableContent() {
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)
}
// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which
// say which of its lines are in the custom patch being built from it.
//
// They are shown whenever the pane shows a diff the patch is being built from, whether
// or not it has the focus. The pane beside it previews the patch all the while, and the
// marks show the same lines from the side of the diff. A diff of some other commit gets
// none, since a patch built from another commit says nothing about the lines of this one.
//
// Call it whenever either of those can have changed: as a pane's content settles, and
// when the patch itself changes.
func (self *DiffLineHelper) RefreshInclusionGutter() {
view := self.c.Contexts().Normal.GetView()
included := self.gutterInclusion()
if included == nil {
view.SetInclusionGutter(false, nil)
return
}
resolved := self.resolveDiffLines(view.DiffLineContents())
marks := make([]bool, len(resolved))
for i, row := range resolved {
if row.ok && row.info.IsChange() {
marks[i] = included(row.info)
}
}
view.SetInclusionGutter(true, marks)
}
// ShowsInclusionGutter reports whether the custom patch's marks are drawn over what the
// given view is being given to show: the upper main pane, while it shows the diff of a
// panel the patch is being built from.
//
// It goes by what is known before the content arrives, so that a render can be laid out
// to the width the gutter leaves it. Whether the diff turns out to have any change lines
// to mark doesn't come into it.
func (self *DiffLineHelper) ShowsInclusionGutter(view *gocui.View) bool {
return view == self.c.Contexts().Normal.GetView() && self.gutterInclusion() != nil
}
// gutterInclusion is patchInclusion for the gutter over the upper main pane, which is
// shown only over the panel's diff and not, say, over a message in its place.
func (self *DiffLineHelper) gutterInclusion() func(types.DiffLineInfo) bool {
if !self.c.Contexts().Normal.ContentIsDiff() {
return nil
}
return self.patchInclusion()
}
// patchInclusion asks the panel whose diff the main pane is showing which of that diff's
// lines are in the custom patch being built from it, and answers nil where there is no
// such patch.
func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool {
// The side panel nearest the top of the stack, whether it holds the focus itself or
// either main pane above it does.
sidePanel := self.c.Context().CurrentSide()
actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions)
if !ok {
return nil
}
return actions.PatchInclusion()
}
// ShowsCustomPatch reports whether the given view is the one previewing the custom patch
// being built, which is the lower pane while a patch is being built from the diff in the
// upper one.
func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool {
return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil
}