From 61b72cef38c8763727afe0bac144c5e5fb95a00c Mon Sep 17 00:00:00 2001 From: blakemckeany Date: Wed, 25 Mar 2026 14:46:33 +0100 Subject: [PATCH] Add GetVisualDepth method to FileTree/CommitFileTree --- pkg/gui/filetree/commit_file_tree.go | 4 + pkg/gui/filetree/file_tree.go | 5 + pkg/gui/filetree/node.go | 26 ++++-- pkg/gui/filetree/node_test.go | 134 +++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 pkg/gui/filetree/node_test.go diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index bf6d1251c..38232ea3c 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -151,6 +151,10 @@ func (self *CommitFileTree) GetFile(path string) *models.CommitFile { return nil } +func (self *CommitFileTree) GetVisualDepth(index int) int { + return self.tree.GetVisualDepthAtIndex(index+1, self.collapsedPaths) // +1 to skip root +} + func (self *CommitFileTree) InTreeMode() bool { return self.showTree } diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 9840fd8dd..a70876221 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -34,6 +34,7 @@ type ITree[T any] interface { CollapsedPaths() *CollapsedPaths CollapseAll() ExpandAll() + GetVisualDepth(index int) int } type IFileTree interface { @@ -221,6 +222,10 @@ func (self *FileTree) CollapsedPaths() *CollapsedPaths { return self.collapsedPaths } +func (self *FileTree) GetVisualDepth(index int) int { + return self.tree.GetVisualDepthAtIndex(index+1, self.collapsedPaths) // +1 to skip root +} + func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter { return self.filter } diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go index 97d5232b5..5d9cefcd4 100644 --- a/pkg/gui/filetree/node.go +++ b/pkg/gui/filetree/node.go @@ -202,29 +202,43 @@ func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) * return nil } - node, _ := self.getNodeAtIndexAux(index, collapsedPaths) + node, _, _ := self.getNodeAtIndexAux(index, collapsedPaths, -1) return node } -func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) { +// GetVisualDepthAtIndex returns the visual depth (indentation level) of the +// node at the given flat index. Visual depth differs from tree depth because +// compressed nodes (e.g. "a/b/") count as a single visual level. +// Returns -1 if the index is out of range. +func (self *Node[T]) GetVisualDepthAtIndex(index int, collapsedPaths *CollapsedPaths) int { + if self == nil { + return -1 + } + + _, _, depth := self.getNodeAtIndexAux(index, collapsedPaths, -1) + + return depth +} + +func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths, visualDepth int) (*Node[T], int, int) { offset := 1 if index == 0 { - return self, offset + return self, offset, visualDepth } if !collapsedPaths.IsCollapsed(self.path) { for _, child := range self.Children { - foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths) + foundNode, offsetChange, depth := child.getNodeAtIndexAux(index-offset, collapsedPaths, visualDepth+1) offset += offsetChange if foundNode != nil { - return foundNode, offset + return foundNode, offset, depth } } } - return nil, offset + return nil, offset, -1 } func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { diff --git a/pkg/gui/filetree/node_test.go b/pkg/gui/filetree/node_test.go new file mode 100644 index 000000000..d44441f39 --- /dev/null +++ b/pkg/gui/filetree/node_test.go @@ -0,0 +1,134 @@ +package filetree + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/stretchr/testify/assert" +) + +func TestGetVisualDepthAtIndex(t *testing.T) { + scenarios := []struct { + name string + files []*models.File + showRootItem bool + collapsedPaths []string + expectedDepths []int // one per visible node, skipping root + }{ + { + name: "flat files with root item", + files: []*models.File{ + {Path: "a"}, + {Path: "b"}, + }, + showRootItem: true, + // Displayed as: + // index 0: ▼ / (depth 0, the "." root dir) + // index 1: a (depth 1) + // index 2: b (depth 1) + expectedDepths: []int{0, 1, 1}, + }, + { + name: "flat files without root item", + files: []*models.File{ + {Path: "a"}, + {Path: "b"}, + }, + showRootItem: false, + // Displayed as: + // index 0: a (depth 0) + // index 1: b (depth 0) + expectedDepths: []int{0, 0}, + }, + { + name: "nested directories with root item", + files: []*models.File{ + {Path: "dir/a"}, + {Path: "dir/b"}, + {Path: "c"}, + }, + showRootItem: true, + // Displayed as: + // index 0: ▼ / (depth 0) + // index 1: ▼ dir (depth 1) + // index 2: a (depth 2) + // index 3: b (depth 2) + // index 4: c (depth 1) + expectedDepths: []int{0, 1, 2, 2, 1}, + }, + { + name: "compressed paths with root item", + files: []*models.File{ + {Path: "dir1/dir3/a"}, + {Path: "dir2/dir4/b"}, + }, + showRootItem: true, + // Tree compresses dir1/dir3 and dir2/dir4 into single nodes. + // Displayed as: + // index 0: ▼ / (depth 0) + // index 1: ▼ dir1/dir3 (depth 1, compressed) + // index 2: a (depth 2) + // index 3: ▼ dir2/dir4 (depth 1, compressed) + // index 4: b (depth 2) + expectedDepths: []int{0, 1, 2, 1, 2}, + }, + { + name: "compressed paths without root item", + files: []*models.File{ + {Path: "dir1/dir3/a"}, + {Path: "dir2/dir4/b"}, + }, + showRootItem: false, + // Displayed as: + // index 0: ▼ dir1/dir3 (depth 0, compressed) + // index 1: a (depth 1) + // index 2: ▼ dir2/dir4 (depth 0, compressed) + // index 3: b (depth 1) + expectedDepths: []int{0, 1, 0, 1}, + }, + { + name: "collapsed directory hides children", + files: []*models.File{ + {Path: "dir/a"}, + {Path: "dir/b"}, + {Path: "c"}, + }, + showRootItem: true, + collapsedPaths: []string{"./dir"}, + // Displayed as: + // index 0: ▼ / (depth 0) + // index 1: ▶ dir (depth 1, collapsed) + // index 2: c (depth 1) + expectedDepths: []int{0, 1, 1}, + }, + { + name: "out of range returns -1", + files: []*models.File{ + {Path: "a"}, + }, + showRootItem: false, + expectedDepths: []int{0}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + tree := BuildTreeFromFiles(s.files, s.showRootItem) + collapsedPaths := NewCollapsedPaths() + for _, p := range s.collapsedPaths { + collapsedPaths.Collapse(p) + } + + for i, expectedDepth := range s.expectedDepths { + // +1 to skip the invisible root node, matching what FileTree.GetVisualDepth does + actualDepth := tree.GetVisualDepthAtIndex(i+1, collapsedPaths) + assert.Equal(t, expectedDepth, actualDepth, + "index %d: expected depth %d, got %d", i, expectedDepth, actualDepth) + } + + // Verify out-of-range returns -1 + outOfRange := tree.GetVisualDepthAtIndex(len(s.expectedDepths)+1, collapsedPaths) + assert.Equal(t, -1, outOfRange, "out of range index should return -1") + }) + } +}