From 77cbb532f2a3e052d274f6aaa2e371f8d8a9dd69 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 15:31:38 +0200 Subject: [PATCH 1/5] Add a test for the selection after a selected rename splits in two When a rename is selected in the files panel and then splits into its two halves, e.g. because it was unstaged, the selection is meant to move to the new half. This only works with gui.showRootItemInFileTree turned off. With the default setting, the selection lands on the file that follows the rename in the list instead. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/filetree/file_tree_view_model_test.go | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go index c14c91ea8..9777a2e31 100644 --- a/pkg/gui/filetree/file_tree_view_model_test.go +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -5,6 +5,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) @@ -30,3 +31,53 @@ func TestSetStatusFilterPreservingSelection(t *testing.T) { assert.Equal(t, "file3", viewModel.GetSelectedPath()) assert.False(t, viewModel.IsSelectingRange()) } + +func TestSetTreeSelectsNewFileWhenSelectedRenameSplits(t *testing.T) { + scenarios := []struct { + name string + showRootItem bool + expectedPath string + }{ + { + name: "with root item", + showRootItem: true, + /* EXPECTED: + expectedPath: "dir/new.go", + ACTUAL: */ + expectedPath: "other.go", + }, + { + name: "without root item", + showRootItem: false, + expectedPath: "dir/new.go", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + userConfig := config.GetDefaultConfig() + userConfig.Gui.ShowRootItemInFileTree = s.showRootItem + cmn := common.NewDummyCommonWithUserConfigAndAppState(userConfig, nil) + + files := []*models.File{ + {Path: "dir/new.go", PreviousPath: "dir/old.go"}, + {Path: "other.go"}, + } + viewModel := NewFileTreeViewModel(func() []*models.File { return files }, cmn, true) + viewModel.SetTree() + idx, found := viewModel.GetIndexForPath(InternalTreePathForFilePath("dir/new.go", s.showRootItem)) + assert.True(t, found) + viewModel.SetSelection(idx) + + // the rename is split into its two halves, e.g. because it was unstaged + files = []*models.File{ + {Path: "dir/new.go"}, + {Path: "dir/old.go"}, + {Path: "other.go"}, + } + viewModel.SetTree() + + assert.Equal(t, s.expectedPath, viewModel.GetSelectedPath()) + }) + } +} From 4766ce009edda8a4f20025fe79a03bb4e50fbbfc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 15:32:33 +0200 Subject: [PATCH 2/5] Compare user-facing paths when re-finding the selection after a refresh When the selected rename splits into its two halves, e.g. because it was unstaged, the selection is meant to move to the new half. With the default setting of gui.showRootItemInFileTree, it lands on the file that follows the rename in the list instead. findNewSelectedIdx identifies a rename by the names of its two halves. These are user-facing paths, without the "./" prefix that the root item adds to every internal path, but they were compared against internal paths. The comparison never matched while the root item was shown. Compare user-facing paths throughout findNewSelectedIdx. Node.ID already identifies list items by their user-facing path. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/filetree/file_tree_view_model.go | 6 ++++-- pkg/gui/filetree/file_tree_view_model_test.go | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 68829b444..514fbca01 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -132,6 +132,8 @@ func (self *FileTreeViewModel) SetTree() { // to that. // prevNodes starts from our previously selected node because we don't need to consider anything above that func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int { + // Paths are compared as the user sees them, without the "./" prefix of the + // root item, so that they line up with the names of a rename. getPaths := func(node *FileNode) []string { if node == nil { return nil @@ -139,7 +141,7 @@ func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNod if node.File != nil && node.File.IsRename() { return node.File.Names() } - return []string{node.path} + return []string{node.GetPath()} } for _, prevNode := range prevNodes { @@ -150,7 +152,7 @@ func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNod // If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file. // This is because the new should be in the same position as the rename was meaning less cursor jumping - foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.path == prevNode.File.PreviousPath + foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.GetPath() == prevNode.File.PreviousPath foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename if foundNode { return idx diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go index 9777a2e31..fcc24a1b4 100644 --- a/pkg/gui/filetree/file_tree_view_model_test.go +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -41,10 +41,7 @@ func TestSetTreeSelectsNewFileWhenSelectedRenameSplits(t *testing.T) { { name: "with root item", showRootItem: true, - /* EXPECTED: expectedPath: "dir/new.go", - ACTUAL: */ - expectedPath: "other.go", }, { name: "without root item", From b4b6a993eac873dc92b850a6acb0dcc28273eb89 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:03:05 +0200 Subject: [PATCH 3/5] Add a test for following a file into a rename in a collapsed directory When the deletion of the selected file is staged and git then reports it as the old half of a rename whose new half sits in a collapsed directory, the selection is meant to move to the rename, expanding the directory on the way. With gui.showRootItemInFileTree turned on, the directory stays collapsed and the selection lands on it instead. With the option turned off, the directory does expand, but if another file in it sorts before the rename, that file gets selected. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/filetree/file_tree_view_model_test.go | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go index fcc24a1b4..d05cf7e93 100644 --- a/pkg/gui/filetree/file_tree_view_model_test.go +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -78,3 +78,57 @@ func TestSetTreeSelectsNewFileWhenSelectedRenameSplits(t *testing.T) { }) } } + +func TestSetTreeFollowsRenameIntoCollapsedDir(t *testing.T) { + scenarios := []struct { + name string + showRootItem bool + expectedPath string + }{ + { + name: "with root item", + showRootItem: true, + /* EXPECTED: + expectedPath: "a/new.go", + ACTUAL: */ + expectedPath: "a", + }, + { + name: "without root item", + showRootItem: false, + /* EXPECTED: + expectedPath: "a/new.go", + ACTUAL: */ + expectedPath: "a/b.go", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + userConfig := config.GetDefaultConfig() + userConfig.Gui.ShowRootItemInFileTree = s.showRootItem + cmn := common.NewDummyCommonWithUserConfigAndAppState(userConfig, nil) + + files := []*models.File{ + {Path: "a/b.go"}, + {Path: "a/new.go"}, + {Path: "old.go"}, + } + viewModel := NewFileTreeViewModel(func() []*models.File { return files }, cmn, true) + viewModel.SetTree() + viewModel.ToggleCollapsed(InternalTreePathForFilePath("a", s.showRootItem)) + idx, found := viewModel.GetIndexForPath(InternalTreePathForFilePath("old.go", s.showRootItem)) + assert.True(t, found) + viewModel.SetSelection(idx) + + // staging the deletion of old.go turns it into the old half of a rename + files = []*models.File{ + {Path: "a/b.go"}, + {Path: "a/new.go", PreviousPath: "old.go"}, + } + viewModel.SetTree() + + assert.Equal(t, s.expectedPath, viewModel.GetSelectedPath()) + }) + } +} From 7d576c3d30db91a2c4d30e4c80e4fd65fa490612 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:03:49 +0200 Subject: [PATCH 4/5] Expand to the new half of a rename after rebuilding the tree When the deletion of the selected file is staged and git then reports it as the old half of a rename whose new half sits in a collapsed directory, the selection is meant to move to the rename. With gui.showRootItemInFileTree turned on, the directory stays collapsed and the selection lands on it. With the option turned off, the directory expands, but if another file in it sorts before the rename, that file gets selected. The loop that expands the directory runs before the tree is rebuilt and works on the file list instead of on tree nodes. It compares the rename's previous path, a user-facing path, against the selected node's internal path, so the two never match while the root item is shown. The path it hands to ExpandToPath is user-facing as well, so the directory would stay collapsed either way. And because the loop runs before the old node list is captured, expanding a directory above the selection shifts that list, and the search for the new selection starts from whatever node moved into the selected line. Rebuild the tree first, capture the old node list before anything expands, and then look for the rename among the leaves of the new tree. ExpandToPath gets the leaf's own internal path, so the two kinds of paths never need converting. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/filetree/file_tree_view_model.go | 18 +++++++++--------- pkg/gui/filetree/file_tree_view_model_test.go | 6 ------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 514fbca01..5ca010b8a 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -98,22 +98,22 @@ func (self *FileTreeViewModel) GetSelectedPath() string { } func (self *FileTreeViewModel) SetTree() { - newFiles := self.GetAllFiles() selectedNode := self.GetSelected() - - // for when you stage the old file of a rename and the new file is in a collapsed dir - for _, file := range newFiles { - if selectedNode != nil && selectedNode.path != "" && file.PreviousPath == selectedNode.path { - self.ExpandToPath(file.Path) - } - } - prevNodes := self.GetAllItems() prevSelectedLineIdx := self.GetSelectedLineIdx() self.IFileTree.SetTree() if selectedNode != nil { + // If the selected file has become the old half of a rename, e.g. because + // its deletion was staged, make sure the rename is visible so that the + // selection can move to it. + for _, node := range self.GetRoot().GetLeaves() { + if node.File.PreviousPath == selectedNode.GetPath() { + self.ExpandToPath(node.GetInternalPath()) + } + } + newNodes := self.GetAllItems() newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes) if newIdx != -1 && newIdx != prevSelectedLineIdx { diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go index d05cf7e93..42844b482 100644 --- a/pkg/gui/filetree/file_tree_view_model_test.go +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -88,18 +88,12 @@ func TestSetTreeFollowsRenameIntoCollapsedDir(t *testing.T) { { name: "with root item", showRootItem: true, - /* EXPECTED: expectedPath: "a/new.go", - ACTUAL: */ - expectedPath: "a", }, { name: "without root item", showRootItem: false, - /* EXPECTED: expectedPath: "a/new.go", - ACTUAL: */ - expectedPath: "a/b.go", }, } From c7f62ea9b68ed91e2d2a6adb58761d931a1f90e9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 15:16:51 +0200 Subject: [PATCH 5/5] Keep the topmost directory selected when a compressed directory splits When a single file is modified inside a nested directory, the file tree compresses the whole chain of directories into one line, such as "pkg/gui/controllers/helpers". Selecting that line shows the diff of the entire working tree. When a second file is then modified in another subdirectory of pkg/gui, the tree splits the line into "pkg/gui" with "context" and "controllers/helpers" below it, and the refresh moves the selection down to "controllers/helpers". Users who keep the top directory selected to see the diff of everything lose that view and have to move the cursor back up after every such refresh. This happens because the selection is re-found by the node's own path, and a compressed node's path is the deepest directory in its chain. The node stood for every directory in that chain, though, and the topmost piece of the split is the one that stays on the same line. Match a compressed directory node against any new node that stands for at least one of the same directories. The list is in depth-first order, so the topmost piece wins and the cursor stays on its line. Files are never compressed, so their handling doesn't change. The reverse case, where two directories fold back into one compressed line, already selected the merged line and still does. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/filetree/file_tree_view_model.go | 9 ++- pkg/gui/filetree/file_tree_view_model_test.go | 77 +++++++++++++++++++ pkg/gui/filetree/node.go | 14 ++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 5ca010b8a..f6465e64b 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -131,6 +131,13 @@ func (self *FileTreeViewModel) SetTree() { // nodes until we find one that exists in the new set of nodes, then move the cursor // to that. // prevNodes starts from our previously selected node because we don't need to consider anything above that +// +// A compressed directory node stands for every directory that was squished +// into it, so it matches any new node that stands for at least one of the same +// directories. When a compressed directory splits into several nodes because +// a file appeared in another of its subdirectories, the topmost of these nodes +// comes first in currNodes and takes over the selection; this keeps the cursor +// on the same line. func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int { // Paths are compared as the user sees them, without the "./" prefix of the // root item, so that they line up with the names of a rename. @@ -141,7 +148,7 @@ func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNod if node.File != nil && node.File.IsRename() { return node.File.Names() } - return []string{node.GetPath()} + return node.GetPaths() } for _, prevNode := range prevNodes { diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go index 42844b482..088b2b859 100644 --- a/pkg/gui/filetree/file_tree_view_model_test.go +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -126,3 +127,79 @@ func TestSetTreeFollowsRenameIntoCollapsedDir(t *testing.T) { }) } } + +func TestSetTreeKeepsSelectionAcrossCompressionChanges(t *testing.T) { + scenarios := []struct { + name string + filesBefore []string + selectedPath string + filesAfter []string + expectedPath string + }{ + { + name: "compressed root directory splits", + filesBefore: []string{"pkg/gui/controllers/helpers/refresh_helper.go"}, + selectedPath: "pkg/gui/controllers/helpers", + filesAfter: []string{ + "pkg/gui/context/base_context.go", + "pkg/gui/controllers/helpers/refresh_helper.go", + }, + expectedPath: "pkg/gui", + }, + { + name: "compressed subdirectory splits", + filesBefore: []string{"a/b/c/file1", "file2"}, + selectedPath: "a/b/c", + filesAfter: []string{"a/b/c/file1", "a/b/d/file3", "file2"}, + expectedPath: "a/b", + }, + { + name: "file inside a compressed directory that splits", + filesBefore: []string{"pkg/gui/controllers/helpers/refresh_helper.go"}, + selectedPath: "pkg/gui/controllers/helpers/refresh_helper.go", + filesAfter: []string{ + "pkg/gui/context/base_context.go", + "pkg/gui/controllers/helpers/refresh_helper.go", + }, + expectedPath: "pkg/gui/controllers/helpers/refresh_helper.go", + }, + { + name: "directories merge into one compressed node", + filesBefore: []string{ + "pkg/gui/context/base_context.go", + "pkg/gui/controllers/helpers/refresh_helper.go", + }, + selectedPath: "pkg/gui", + filesAfter: []string{"pkg/gui/controllers/helpers/refresh_helper.go"}, + expectedPath: "pkg/gui/controllers/helpers", + }, + } + + toFiles := func(paths []string) []*models.File { + return lo.Map(paths, func(path string, _ int) *models.File { + return &models.File{Path: path} + }) + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + files := toFiles(s.filesBefore) + cmn := common.NewDummyCommon() + viewModel := NewFileTreeViewModel( + func() []*models.File { return files }, + cmn, + true, + ) + viewModel.SetTree() + showRootItem := cmn.UserConfig().Gui.ShowRootItemInFileTree + idx, found := viewModel.GetIndexForPath(InternalTreePathForFilePath(s.selectedPath, showRootItem)) + assert.True(t, found) + viewModel.SetSelection(idx) + + files = toFiles(s.filesAfter) + viewModel.SetTree() + + assert.Equal(t, s.expectedPath, viewModel.GetSelectedPath()) + }) + } +} diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go index 143cdeeff..fc409d501 100644 --- a/pkg/gui/filetree/node.go +++ b/pkg/gui/filetree/node.go @@ -63,6 +63,20 @@ func (self *Node[T]) GetInternalPath() string { return self.path } +// This returns the logical paths of all the directories that this node stands +// for, from the user's point of view like GetPath. For most nodes that's just +// its own path. A compressed node (see CompressionLevel) also stands for the +// directories that were squished into it, so for "a/b/c" with a +// CompressionLevel of 2 this returns "a/b/c", "a/b" and "a". +func (self *Node[T]) GetPaths() []string { + splitPath := split(self.path) + paths := make([]string, 0, self.CompressionLevel+1) + for i := 0; i <= self.CompressionLevel; i++ { + paths = append(paths, strings.TrimPrefix(join(splitPath[:len(splitPath)-i]), "./")) + } + return paths +} + func (self *Node[T]) Sort(cmp func(a, b *Node[T]) int) { self.SortChildren(cmp)