Keep your place in the diff when ignoring whitespace

Ignoring whitespace is how you ask whether what you are looking at is more
than reindentation, so being dropped at the top of the diff is a poor
answer: you have to find your way back to the change you were asking about
to see what became of it.

Unlike the other ways of re-rendering a diff, this one can take the line
you were on away for good, along with the hunk or even the file it was in.
When it does, the view lands on the nearest line the diff kept, wherever in
it that is — the walk of fallbacks doesn't stop at the file's edge, since a
file of nothing but reindentation leaves nothing nearer to land on. And
when ignoring whitespace empties the diff altogether, there is nothing to
keep and the view simply shows what is left.

A whitespace-only change that shares a hunk with a real one is a happier
case: it is shown as a context line rather than as a change, but it is
still the same line of the same file, so we stay on it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 18:26:55 +02:00
co-authored by Claude Opus 5
parent 9ec77382cc
commit 02e873d1fe
5 changed files with 245 additions and 0 deletions
@@ -27,6 +27,12 @@ func (self *ToggleWhitespaceAction) Call() error {
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
// You toggle this to see whether what you are looking at is more than
// reindentation, so that is the thing to keep in front of you — even though
// ignoring whitespace, unlike the other ways of re-rendering a diff, can take
// the line away entirely along with the hunk or file it was in.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
self.c.Context().CurrentSide().HandleRenderToMain()
return nil
}
@@ -0,0 +1,70 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionInBothPanesWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace keeps the place in the lower pane too, not only in the upper one",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Staged: real changes at lines 5, 25 and 45, and a whitespace-only one at 15,
// whose hunk goes when whitespace stops counting.
lines[4] = strings.ToUpper(lines[4])
lines[14] = " " + lines[14]
lines[24] = strings.ToUpper(lines[24])
lines[44] = strings.ToUpper(lines[44])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
shell.GitAddAll()
// And one unstaged change, to split the file's diff across both panes.
lines[59] = strings.ToUpper(lines[59])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
PressTab()
t.Views().Secondary().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line45"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// The hunk above this one held nothing but a whitespace change, so it is
// gone and has taken nine lines of the lower pane's diff with it — leaving
// the line we were on where it was on the screen.
SelectedLines(
Contains("-line45"),
).
SelectedLineIdx(26).
OriginY(5)
},
})
@@ -0,0 +1,85 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace keeps the line you were looking at where it was, even when it turns into a context line",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Real changes at lines 5 and 25, whitespace-only ones at 15, 27 and 35. The
// one at 27 shares a hunk with the change at 25, so ignoring whitespace turns
// it into a context line rather than taking its hunk away.
lines[4] = strings.ToUpper(lines[4])
lines[14] = " " + lines[14]
lines[24] = strings.ToUpper(lines[24])
lines[26] = lines[26] + " "
lines[34] = " " + lines[34]
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(26).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// The hunk above this one held nothing but a whitespace change, so it is
// gone and has taken nine lines of diff with it. This is still the line we
// were on, on the row we were on (26 - 14 = 17 - 5).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(17).
OriginY(5).
// And back again, whitespace and all.
Press(keys.Universal.ToggleWhitespaceInDiffView).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(26).
OriginY(14).
// The whitespace-only change further down this hunk is a line of the file
// like any other: ignoring whitespace shows it as context instead of as a
// change, and that is still where we are.
Press(keys.Main.NextHunk).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+line27"),
).
SelectedLineIdx(30).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
SelectedLines(
Contains(" line27"),
).
SelectedLineIdx(20).
OriginY(4)
},
})
@@ -0,0 +1,81 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenIgnoringWhitespaceRemovesIt = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace where that takes the line you were on out of the diff lands on the nearest line it kept",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
shell.Commit("one")
// Real changes at lines 5, 15 and 25, and a whitespace-only one at 35, far
// enough apart to be hunks of their own.
for _, i := range []int{5, 15, 25} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
lines[34] = " " + lines[34]
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
// Nothing but reindentation, so ignoring whitespace leaves no diff at all.
shell.UpdateFile("file2", " one\n two\n three\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
SelectNextItem().
SelectedLine(Contains("file1")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// That hunk was a whitespace change and nothing else, so ignoring
// whitespace takes it — and the context around it — out of the diff
// entirely. The nearest line the diff kept is the last line of the hunk
// above, so that is where the selection lands; it goes back on the row it
// was on itself, which leaves everything above it exactly where it was.
SelectedLines(
Contains(" line28"),
).
SelectedLineIdx(30).
OriginY(14).
// The whole diff can go this way, and then there is nothing to land on.
Press(keys.Universal.ToggleWhitespaceInDiffView).
PressEscape()
t.Views().Files().
IsFocused().
SelectNextItem().
SelectedLine(Contains("file2")).
Press(keys.Universal.ToggleWhitespaceInDiffView)
t.Views().Main().
Content(Equals(""))
},
})
+3
View File
@@ -371,8 +371,11 @@ var tests = []*components.IntegrationTest{
main_view.FileNavigationScrollsToTheTop,
main_view.HideSelectionWhenChangesVanish,
main_view.KeepPositionInBothPanesWhenChangingContextSize,
main_view.KeepPositionInBothPanesWhenIgnoringWhitespace,
main_view.KeepPositionInBothPanesWhenSwitchingDiffRenderers,
main_view.KeepPositionWhenChangingContextSize,
main_view.KeepPositionWhenIgnoringWhitespace,
main_view.KeepPositionWhenIgnoringWhitespaceRemovesIt,
main_view.KeepPositionWhenSwitchingDiffRenderers,
main_view.KeepPositionWhenTheSelectionIsOffScreen,
main_view.KeepScrollWhenTheDiffCantBeRead,