From 43b47d16dd801b2df99fb5d57e148d495d105571 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 16 Aug 2026 09:27:49 +0200 Subject: [PATCH] Keep showing files whose conflicts have been resolved When several files have conflicts, resolving one of them makes it vanish from the files panel as soon as it is auto-staged, and it only comes back once the last conflict is resolved and the filter turns off again. By then it sits among all the other changed files of the merge, so it is hard to find the ones whose resulting diff you still wanted to check. So remember which files had conflicts while the conflicted-files filter is on, and keep showing them once they are resolved. This is the general solution that 39513d244df6 called for; that commit only helped for the case of a single conflicted file. The consequence is that the selection no longer moves on to the next conflicted file when one is resolved: it stays on the file you just resolved, which shows you its diff right away. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 4 ++ pkg/gui/filetree/file_tree.go | 45 +++++++++++++------ pkg/gui/filetree/file_tree_test.go | 30 ++++++++++--- .../tests/conflicts/resolve_multiple_files.go | 18 ++++++-- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 78d4c8c75..941bd3b9c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1436,6 +1436,10 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Contexts().Files.GetView().Subtitle = "" } + if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { + fileTreeViewModel.RememberConflictedPaths(conflictedPaths) + } + self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath()) diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 2d3cec514..6c8eff72e 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -3,6 +3,7 @@ package filetree import ( "fmt" + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -42,6 +43,7 @@ type IFileTree interface { FilterFiles(test func(*models.File) bool) []*models.File SetStatusFilter(filter FileTreeDisplayFilter) + RememberConflictedPaths(paths []string) ForceShowUntracked() bool Get(index int) *FileNode GetFile(path string) *models.File @@ -54,25 +56,31 @@ type IFileTree interface { } type FileTree struct { - getFiles func() []*models.File - tree *Node[models.File] - showTree bool - common *common.Common - filter FileTreeDisplayFilter - collapsedPaths *CollapsedPaths - textFilter string - useFuzzySearch bool + getFiles func() []*models.File + tree *Node[models.File] + showTree bool + common *common.Common + filter FileTreeDisplayFilter + // Paths of the files that had conflicts while the current filter has been + // active. The DisplayConflicted filter keeps showing them after their + // conflicts have been resolved, so that their diffs can be reviewed while + // the remaining files are still being worked on. + conflictedPaths *set.Set[string] + collapsedPaths *CollapsedPaths + textFilter string + useFuzzySearch bool } var _ IFileTree = &FileTree{} func NewFileTree(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTree { return &FileTree{ - getFiles: getFiles, - common: common, - showTree: showTree, - filter: DisplayAll, - collapsedPaths: NewCollapsedPaths(), + getFiles: getFiles, + common: common, + showTree: showTree, + filter: DisplayAll, + conflictedPaths: set.New[string](), + collapsedPaths: NewCollapsedPaths(), } } @@ -100,7 +108,9 @@ func (self *FileTree) getFilesForDisplay() []*models.File { case DisplayUntracked: files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) }) case DisplayConflicted: - files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts }) + files = self.FilterFiles(func(file *models.File) bool { + return file.HasMergeConflicts || self.conflictedPaths.Includes(file.Path) + }) default: panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter)) } @@ -122,9 +132,16 @@ func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File { func (self *FileTree) SetStatusFilter(filter FileTreeDisplayFilter) { self.filter = filter + self.conflictedPaths = set.New[string]() self.SetTree() } +// RememberConflictedPaths records which files have conflicts right now, so that +// the DisplayConflicted filter keeps showing them once they are resolved. +func (self *FileTree) RememberConflictedPaths(paths []string) { + self.conflictedPaths.Add(paths...) +} + func (self *FileTree) ToggleShowTree() { self.showTree = !self.showTree self.SetTree() diff --git a/pkg/gui/filetree/file_tree_test.go b/pkg/gui/filetree/file_tree_test.go index 1c7960a6e..3058e8db9 100644 --- a/pkg/gui/filetree/file_tree_test.go +++ b/pkg/gui/filetree/file_tree_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" @@ -12,10 +13,11 @@ import ( func TestFilterAction(t *testing.T) { scenarios := []struct { - name string - filter FileTreeDisplayFilter - files []*models.File - expected []*models.File + name string + filter FileTreeDisplayFilter + conflictedPaths []string + files []*models.File + expected []*models.File }{ { name: "filter files with unstaged changes", @@ -84,11 +86,29 @@ func TestFilterAction(t *testing.T) { {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, }, }, + { + name: "keep showing conflicted files whose conflicts have been resolved", + filter: DisplayConflicted, + conflictedPaths: []string{"dir2/dir2/file4", "file1"}, + files: []*models.File{ + {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, + {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, + {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, + }, + expected: []*models.File{ + {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, + {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, + }, + }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter} + mngr := &FileTree{ + getFiles: func() []*models.File { return s.files }, + filter: s.filter, + conflictedPaths: set.NewFromSlice(s.conflictedPaths), + } result := mngr.getFilesForDisplay() assert.EqualValues(t, s.expected, result) }) diff --git a/pkg/integration/tests/conflicts/resolve_multiple_files.go b/pkg/integration/tests/conflicts/resolve_multiple_files.go index b38f59ee2..66d0f8ae4 100644 --- a/pkg/integration/tests/conflicts/resolve_multiple_files.go +++ b/pkg/integration/tests/conflicts/resolve_multiple_files.go @@ -7,7 +7,7 @@ import ( ) var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Ensures that upon resolving conflicts for one file, the next file is selected", + Description: "Ensures that a file whose conflicts have been resolved keeps being shown while other files still have conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -37,11 +37,16 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). PressPrimaryAction() + // The resolved file is still shown, and stays selected so that its diff + // can be reviewed t.Views().Files(). IsFocused(). Lines( - Equals("UU file2").IsSelected(), + Equals("▼ /"), + Equals(" M file1").IsSelected(), + Equals(" UU file2"), ). + SelectNextItem(). PressEnter() // coincidentally these files have the same conflict @@ -54,7 +59,14 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ ). PressPrimaryAction() - t.Views().Files().SelectedLines(Contains("file2")) + // Now that all conflicts are resolved, the filter is turned off again + t.Views().Files(). + Lines( + Equals("▼ /"), + Equals(" M file1"), + Equals(" M file2").IsSelected(), + Equals(" A file3"), + ) t.Common().ContinueOnConflictsResolved("merge") },