From b2b9519bcc87ae4b9a75d72c679c7f25e97fcc69 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 14:52:53 +0200 Subject: [PATCH 1/3] Extract a private helper function preserveSelection The operation around which the selection should be preserved is passed in so that it can be reused for different purposes. --- pkg/gui/filetree/file_tree_view_model.go | 38 ++++++++++++++---------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index aabbbce7f..07dc10ec1 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -167,6 +167,25 @@ func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) { self.IListCursor.SetSelection(0) } +func (self *FileTreeViewModel) preserveSelection(f func()) { + selectedNode := self.GetSelected() + var selectedPath string + if selectedNode != nil { + selectedPath = selectedNode.GetInternalPath() + } + + f() + + if selectedPath != "" { + self.ExpandToPath(selectedPath) + if idx, found := self.GetIndexForPath(selectedPath); found { + self.SetSelection(idx) + return + } + } + self.ClampSelection() +} + // If we're going from flat to tree we want to select the same file. // If we're going from tree to flat and we have a file selected we want to select that. // If instead we've selected a directory we need to select the first file in that directory. @@ -233,22 +252,9 @@ func (self *FileTreeViewModel) GetFilter() string { } func (self *FileTreeViewModel) ClearFilter() { - selectedNode := self.GetSelected() - var selectedPath string - if selectedNode != nil { - selectedPath = selectedNode.GetInternalPath() - } - - self.IFileTree.SetTextFilter("", false) - - if selectedPath != "" { - self.ExpandToPath(selectedPath) - if idx, found := self.GetIndexForPath(selectedPath); found { - self.SetSelection(idx) - return - } - } - self.ClampSelection() + self.preserveSelection(func() { + self.IFileTree.SetTextFilter("", false) + }) } func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { From 4e2a1cd5b07e26266f59d5a7d96a15a4381c7c71 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 14 Aug 2026 18:42:58 +0200 Subject: [PATCH 2/3] Add a function SetStatusFilterPreservingSelection --- pkg/gui/filetree/file_tree_view_model.go | 6 ++++ pkg/gui/filetree/file_tree_view_model_test.go | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 pkg/gui/filetree/file_tree_view_model_test.go diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 07dc10ec1..a5971f592 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -167,6 +167,12 @@ func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) { self.IListCursor.SetSelection(0) } +func (self *FileTreeViewModel) SetStatusFilterPreservingSelection(filter FileTreeDisplayFilter) { + self.preserveSelection(func() { + self.SetStatusFilter(filter) + }) +} + func (self *FileTreeViewModel) preserveSelection(f func()) { selectedNode := self.GetSelected() var selectedPath string diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go new file mode 100644 index 000000000..c14c91ea8 --- /dev/null +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -0,0 +1,32 @@ +package filetree + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestSetStatusFilterPreservingSelection(t *testing.T) { + files := []*models.File{ + {Path: "file1"}, + {Path: "file2", HasMergeConflicts: true}, + {Path: "file3", HasMergeConflicts: true}, + } + viewModel := NewFileTreeViewModel( + func() []*models.File { return files }, + common.NewDummyCommon(), + false, + ) + viewModel.SetTree() + viewModel.SetStatusFilter(DisplayConflicted) + viewModel.SetSelection(viewModel.Len() - 2) + viewModel.ToggleStickyRange() + viewModel.MoveSelectedLine(1) + + viewModel.SetStatusFilterPreservingSelection(DisplayAll) + + assert.Equal(t, "file3", viewModel.GetSelectedPath()) + assert.False(t, viewModel.IsSelectingRange()) +} From 39513d244df621ffbff1b0d483ee18b41b6b6c7e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 14 Aug 2026 18:43:41 +0200 Subject: [PATCH 3/3] Keep the last conflict file selected after resolution When there is a single conflicting file left to be resolved, lazygit dismisses the conflicted-files-only filter when the file no longer has conflict markers. However, the selection moved to the top, which is annoying because very often it is useful to look at that file's resulting diff once more to confirm that conflicts were resolved correctly, and finding it again can be cumbersome when there are many changed files. So keep it selected. Of course, this only helps for the last (or only) conflicted files; when there are multiple, a resolved file disappears from the panel until all are resolved, which makes it hard to double-check the resulting diffs. Doing it afterwards is not easy because you'd have to remember which files were conflicting. This needs a different solution, but for the special case of only a single conflicting file this is already a big improvement. --- pkg/gui/controllers/helpers/refresh_helper.go | 2 +- ...nflicts_fix_build_errors_with_out_of_date_submodule.go | 8 ++++---- pkg/integration/tests/conflicts/resolve_multiple_files.go | 7 +++++-- .../tests/file/discard_various_changes_range_select.go | 5 +++-- .../patch_building/apply_in_reverse_with_conflict.go | 7 +++---- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 40ce86eae..57f9f586b 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1413,7 +1413,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles } } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { - fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) + fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll) self.c.Contexts().Files.GetView().Subtitle = "" } diff --git a/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go b/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go index 1b95fd316..29db1a212 100644 --- a/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go +++ b/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go @@ -75,8 +75,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New t.Views().Files(). Lines( - Equals("▼ /").IsSelected(), - Equals(" MM file"), + Equals("▼ /"), + Equals(" MM file").IsSelected(), Equals(" M submodule (submodule)"), Equals(" ?? untracked-file"), ) @@ -90,8 +90,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New t.Views().Files(). Lines( - Equals("▼ /").IsSelected(), - Equals(" M submodule (submodule)"), + Equals("▼ /"), + Equals(" M submodule (submodule)").IsSelected(), Equals(" ?? untracked-file"), ) diff --git a/pkg/integration/tests/conflicts/resolve_multiple_files.go b/pkg/integration/tests/conflicts/resolve_multiple_files.go index 5a8f9447e..b38f59ee2 100644 --- a/pkg/integration/tests/conflicts/resolve_multiple_files.go +++ b/pkg/integration/tests/conflicts/resolve_multiple_files.go @@ -34,6 +34,7 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ Contains("First Change"), Contains("======="), ). + SelectNextItem(). PressPrimaryAction() t.Views().Files(). @@ -47,12 +48,14 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().MergeConflicts(). IsFocused(). SelectedLines( - Contains("<<<<<<< HEAD"), - Contains("First Change"), Contains("======="), + Contains("Second Change"), + Contains(">>>>>>>"), ). PressPrimaryAction() + t.Views().Files().SelectedLines(Contains("file2")) + t.Common().ContinueOnConflictsResolved("merge") }, }) diff --git a/pkg/integration/tests/file/discard_various_changes_range_select.go b/pkg/integration/tests/file/discard_various_changes_range_select.go index 16ecedd04..2199f1278 100644 --- a/pkg/integration/tests/file/discard_various_changes_range_select.go +++ b/pkg/integration/tests/file/discard_various_changes_range_select.go @@ -46,12 +46,12 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs Cancel() }). Lines( - Equals("▼ /").IsSelected(), + Equals("▼ /"), Equals(" AM added-changed.txt"), Equals(" MD change-delete.txt"), Equals(" D delete-change.txt"), Equals(" D deleted-staged.txt"), - Equals(" D deleted.txt"), + Equals(" D deleted.txt").IsSelected(), Equals(" MM double-modded.txt"), Equals(" M modded-staged.txt"), Equals(" M modded.txt"), @@ -59,6 +59,7 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs Equals(" ?? new.txt"), Equals(" R renamed.txt → renamed2.txt"), ). + NavigateToLine(Equals("▼ /")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("renamed.txt")). Press(keys.Universal.Remove). diff --git a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go b/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go index 1a09cea7a..d9f99a703 100644 --- a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go +++ b/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go @@ -83,11 +83,10 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). Focus(). Lines( - Equals("▼ /").IsSelected(), - Equals(" M file1"), + Equals("▼ /"), + Equals(" M file1").IsSelected(), Equals(" M file2"), - ). - SelectNextItem() + ) t.Views().Main(). ContainsLines(