From c7f62ea9b68ed91e2d2a6adb58761d931a1f90e9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 15:16:51 +0200 Subject: [PATCH] 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)