From f461378598b3a423eb12f53b60c214d09d994783 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 18 Jun 2026 21:31:09 +0200 Subject: [PATCH] Advance to the next change line, not the next block, in the reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-stage reveal picked its primary candidate with AdjacentChangeBlock, which skips the rest of the acted-on line's block. That's right in hunk mode, where the whole block is staged, but wrong in line mode: staging the first line of a multi-line block left the rest of the block in place, yet the selection jumped past it to the next block instead of landing on the next (now first) line of the same block. Use AdjacentChangeLine — the first change line strictly after/before the selection — for the next/previous candidates. It unifies both modes: after a whole block (whose last line is followed by context) the next change line is the next block's, so hunk mode is unchanged; after a single line the next change line is the next line of the same block. place stays mode-aware (hunk-expand vs single line), so nothing else changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/diff_line_navigation.go | 26 ++++++++++++ pkg/gui/controllers/helpers/staging_helper.go | 14 ++++--- ..._line_after_staging_line_from_main_view.go | 42 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 pkg/integration/tests/staging/select_next_line_after_staging_line_from_main_view.go diff --git a/pkg/gui/controllers/helpers/diff_line_navigation.go b/pkg/gui/controllers/helpers/diff_line_navigation.go index a448ceaa1..d57fd325e 100644 --- a/pkg/gui/controllers/helpers/diff_line_navigation.go +++ b/pkg/gui/controllers/helpers/diff_line_navigation.go @@ -32,6 +32,32 @@ func (self *StagingHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine return view.ViewLineForBufferLine(target) } +// AdjacentChangeLine returns the view line of the first change line strictly +// after (forward) or before anchorViewLine in view's displayed diff, or ok=false +// when there's none. Unlike AdjacentChangeBlock it doesn't skip the rest of the +// anchor's own block: the next change line after a line in the middle of a block is +// the very next line of that block. The post-stage reveal uses it so that staging a +// single line advances to the next line of the same block, while staging a whole +// block (whose last line is followed by context) still advances to the next block. +func (self *StagingHelper) AdjacentChangeLine(view *gocui.View, anchorViewLine int, forward bool) (int, bool) { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return 0, false + } + + resolved := self.resolveDiffLines(view.DiffLineContents()) + step := 1 + if !forward { + step = -1 + } + for i := anchor + step; i >= 0 && i < len(resolved); i += step { + if resolved[i].ok && resolved[i].info.IsChange() { + return view.ViewLineForBufferLine(i) + } + } + return 0, false +} + // AdjacentFile returns the view line to move to for next/previous file navigation in // view's (possibly multi-file) displayed diff, starting from anchorViewLine: the // first row belonging to the next/previous file, found where the per-row metadata's diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index a5a1fbb47..82cd4ce75 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -308,11 +308,15 @@ func (self *StagingHelper) RestoreFocusedMainViewOnEscape(explorerView, mainView // pushing the staged remainder to the secondary half — in which case the candidate // lines, captured from sourceView, are found again in targetView's re-render. // -// The candidates, in priority order, are the change block after the selection, then +// The candidates, in priority order, are the change line after the selection, then // the one before it (both survive staging — only the selection itself was staged), // then the selection's own first line, which only turns up when the whole side was -// staged and the view flips to show the other (staged) side. If none survives, place -// isn't called and the view re-renders without moving the selection. +// staged and the view flips to show the other (staged) side. Adjacent change *line*, +// not block: staging a single line should land on the next line of the same block, +// while staging a whole block lands on the next block (its last line is followed by +// context, so the next change line is the next block's). place — line- or hunk-mode — +// then selects from there. If none survives, place isn't called and the view +// re-renders without moving the selection. func (self *StagingHelper) RevealSelectionAfterStaging(sourceView *gocui.View, targetView *gocui.View, firstLine int, lastLine int, place func(viewLine int)) { var candidates []diffLineAnchor addByViewLine := func(viewLine int) { @@ -321,10 +325,10 @@ func (self *StagingHelper) RevealSelectionAfterStaging(sourceView *gocui.View, t } } - if next, ok := self.AdjacentChangeBlock(sourceView, lastLine, true); ok { + if next, ok := self.AdjacentChangeLine(sourceView, lastLine, true); ok { addByViewLine(next) } - if prev, ok := self.AdjacentChangeBlock(sourceView, firstLine, false); ok { + if prev, ok := self.AdjacentChangeLine(sourceView, firstLine, false); ok { addByViewLine(prev) } addByViewLine(firstLine) diff --git a/pkg/integration/tests/staging/select_next_line_after_staging_line_from_main_view.go b/pkg/integration/tests/staging/select_next_line_after_staging_line_from_main_view.go new file mode 100644 index 000000000..806807d16 --- /dev/null +++ b/pkg/integration/tests/staging/select_next_line_after_staging_line_from_main_view.go @@ -0,0 +1,42 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectNextLineAfterStagingLineFromMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "In line mode, staging the first line of a block advances the selection to the next line of the same block, not to the next block", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n9\n") + shell.Commit("one") + + // A three-line added block near the top, and a separate change block further + // down (so "next block" is a distinct, wrong target for line mode). + shell.UpdateFile("file1", "1\n2\nA\nB\nC\n3\n4\n5\n6\nX\n8\n9\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Default (line) mode: the first added line is selected. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+A"), + ). + // Stage just that line. + PressPrimaryAction(). + // The selection advances to the next line of the same block, not to the + // distant change block below. + SelectedLines( + Contains("+B"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 38ff367a2..27171812c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -418,6 +418,7 @@ var tests = []*components.IntegrationTest{ staging.SelectNextHunkAfterUnstagingFromMainView, staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, + staging.SelectNextLineAfterStagingLineFromMainView, staging.StageHunkFromMainView, staging.StageHunks, staging.StageHunksWithRapidKeypresses,