diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 20e78997ebb..5beecba0247 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -165,7 +165,7 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) // if there is any file that hasn't been fully added we'll fully add everything, // otherwise we'll remove everything - adding := node.AnyFile(func(file *models.CommitFile) bool { + adding := node.SomeFile(func(file *models.CommitFile) bool { return self.git.Patch.PatchManager.GetFileStatus(file.Name, self.context().GetRef().RefName()) != patch.WHOLE }) @@ -203,8 +203,7 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) } func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error { - // not a fan of type assertions but this will be fixed very soon thanks to generics - root := self.context().CommitFileTreeViewModel.Tree().(*filetree.CommitFileNode) + root := self.context().CommitFileTreeViewModel.GetRoot() return self.toggleForPatch(root) } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 220891a4d9d..5304d0d8191 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -247,7 +247,7 @@ func (self *FilesController) pressWithLock(node *filetree.FileNode) error { self.mutexes.RefreshingFilesMutex.Lock() defer self.mutexes.RefreshingFilesMutex.Unlock() - if node.IsLeaf() { + if node.IsFile() { file := node.File if file.HasInlineMergeConflicts { diff --git a/pkg/gui/filetree/build_tree.go b/pkg/gui/filetree/build_tree.go index 36034d02de5..c7c465e28e8 100644 --- a/pkg/gui/filetree/build_tree.go +++ b/pkg/gui/filetree/build_tree.go @@ -7,10 +7,10 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" ) -func BuildTreeFromFiles(files []*models.File) *FileNode { - root := &FileNode{} +func BuildTreeFromFiles(files []*models.File) *Node[models.File] { + root := &Node[models.File]{} - var curr *FileNode + var curr *Node[models.File] for _, file := range files { splitPath := split(file.Name) curr = root @@ -30,7 +30,7 @@ func BuildTreeFromFiles(files []*models.File) *FileNode { } } - newChild := &FileNode{ + newChild := &Node[models.File]{ Path: path, File: setFile, } @@ -46,17 +46,17 @@ func BuildTreeFromFiles(files []*models.File) *FileNode { return root } -func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { +func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { rootAux := BuildTreeFromCommitFiles(files) sortedFiles := rootAux.GetLeaves() - return &CommitFileNode{Children: sortedFiles} + return &Node[models.CommitFile]{Children: sortedFiles} } -func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { - root := &CommitFileNode{} +func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { + root := &Node[models.CommitFile]{} - var curr *CommitFileNode + var curr *Node[models.CommitFile] for _, file := range files { splitPath := split(file.Name) curr = root @@ -77,7 +77,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { } } - newChild := &CommitFileNode{ + newChild := &Node[models.CommitFile]{ Path: path, File: setFile, } @@ -93,7 +93,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { return root } -func BuildFlatTreeFromFiles(files []*models.File) *FileNode { +func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] { rootAux := BuildTreeFromFiles(files) sortedFiles := rootAux.GetLeaves() @@ -128,7 +128,7 @@ func BuildFlatTreeFromFiles(files []*models.File) *FileNode { return false }) - return &FileNode{Children: sortedFiles} + return &Node[models.File]{Children: sortedFiles} } func split(str string) []string { diff --git a/pkg/gui/filetree/build_tree_test.go b/pkg/gui/filetree/build_tree_test.go index c486ddfa56d..ac36be9af6b 100644 --- a/pkg/gui/filetree/build_tree_test.go +++ b/pkg/gui/filetree/build_tree_test.go @@ -11,14 +11,14 @@ func TestBuildTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File - expected *FileNode + expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{}, + Children: nil, }, }, { @@ -31,12 +31,12 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -60,12 +60,12 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "dir2/dir4/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1/dir3", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/dir3/a"}, Path: "dir1/dir3/a", @@ -75,7 +75,7 @@ func TestBuildTreeFromFiles(t *testing.T) { }, { Path: "dir2/dir4", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir2/dir4/b"}, Path: "dir2/dir4/b", @@ -96,9 +96,9 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -124,11 +124,11 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", // it is a little strange that we're not bubbling up our merge conflict // here but we are technically still in in tree mode and that's the rule - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -159,14 +159,14 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File - expected *FileNode + expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{}, + Children: []*Node[models.File]{}, }, }, { @@ -179,9 +179,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -205,9 +205,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "dir2/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -231,9 +231,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -273,9 +273,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Tracked: true, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "c1", HasMergeConflicts: true}, Path: "c1", @@ -318,14 +318,14 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile - expected *CommitFileNode + expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{}, + Children: nil, }, }, { @@ -338,12 +338,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { Path: "dir1", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -367,12 +367,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "dir2/dir4/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { Path: "dir1/dir3", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/dir3/a"}, Path: "dir1/dir3/a", @@ -382,7 +382,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { }, { Path: "dir2/dir4", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir2/dir4/b"}, Path: "dir2/dir4/b", @@ -403,9 +403,9 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "a", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "a"}, Path: "a", @@ -432,14 +432,14 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile - expected *CommitFileNode + expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{}, + Children: []*Node[models.CommitFile]{}, }, }, { @@ -452,9 +452,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -478,9 +478,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "dir2/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -504,9 +504,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "a", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "a"}, Path: "a", diff --git a/pkg/gui/filetree/commit_file_node.go b/pkg/gui/filetree/commit_file_node.go index ad794c0c25c..067eee68275 100644 --- a/pkg/gui/filetree/commit_file_node.go +++ b/pkg/gui/filetree/commit_file_node.go @@ -1,166 +1,21 @@ package filetree -import ( - "github.com/jesseduffield/generics/slices" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) +import "github.com/jesseduffield/lazygit/pkg/commands/models" +// CommitFileNode wraps a node and provides some commit-file-specific methods for it. type CommitFileNode struct { - Children []*CommitFileNode - File *models.CommitFile - Path string // e.g. '/path/to/mydir' - CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode + *Node[models.CommitFile] } -var ( - _ INode = &CommitFileNode{} - _ types.ListItem = &CommitFileNode{} -) - -func (s *CommitFileNode) ID() string { - return s.GetPath() -} - -func (s *CommitFileNode) Description() string { - return s.GetPath() -} - -// methods satisfying INode interface - -func (s *CommitFileNode) IsNil() bool { - return s == nil -} - -func (s *CommitFileNode) IsLeaf() bool { - return s.File != nil -} - -func (s *CommitFileNode) GetPath() string { - return s.Path -} - -func (s *CommitFileNode) GetChildren() []INode { - return slices.Map(s.Children, func(child *CommitFileNode) INode { - return child - }) -} - -func (s *CommitFileNode) SetChildren(children []INode) { - castChildren := slices.Map(children, func(child INode) *CommitFileNode { - return child.(*CommitFileNode) - }) - - s.Children = castChildren -} - -func (s *CommitFileNode) GetCompressionLevel() int { - return s.CompressionLevel -} - -func (s *CommitFileNode) SetCompressionLevel(level int) { - s.CompressionLevel = level -} - -// methods utilising generic functions for INodes - -func (s *CommitFileNode) Sort() { - sortNode(s) -} - -func (s *CommitFileNode) ForEachFile(cb func(*models.CommitFile) error) error { - return forEachLeaf(s, func(n INode) error { - castNode := n.(*CommitFileNode) - return cb(castNode.File) - }) -} - -func (s *CommitFileNode) Any(test func(node *CommitFileNode) bool) bool { - return any(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - return test(castNode) - }) -} - -func (s *CommitFileNode) Every(test func(node *CommitFileNode) bool) bool { - return every(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - return test(castNode) - }) -} - -func (s *CommitFileNode) EveryFile(test func(file *models.CommitFile) bool) bool { - return every(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - - return castNode.File == nil || test(castNode.File) - }) -} - -func (n *CommitFileNode) Flatten(collapsedPaths *CollapsedPaths) []*CommitFileNode { - results := flatten(n, collapsedPaths) - - return slices.Map(results, func(result INode) *CommitFileNode { - return result.(*CommitFileNode) - }) -} - -func (node *CommitFileNode) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *CommitFileNode { +func NewCommitFileNode(node *Node[models.CommitFile]) *CommitFileNode { if node == nil { return nil } - result := getNodeAtIndex(node, index, collapsedPaths) - if result == nil { - // not sure how this can be nil: we probably are missing a mutex somewhere - return nil - } - - return result.(*CommitFileNode) -} - -func (node *CommitFileNode) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { - return getIndexForPath(node, path, collapsedPaths) -} - -func (node *CommitFileNode) Size(collapsedPaths *CollapsedPaths) int { - if node == nil { - return 0 - } - - return size(node, collapsedPaths) -} - -func (s *CommitFileNode) Compress() { - // with these functions I try to only have type conversion code on the actual struct, - // but comparing interface values to nil is fraught with danger so I'm duplicating - // that code here. - if s == nil { - return - } - - compressAux(s) + return &CommitFileNode{Node: node} } -func (s *CommitFileNode) GetLeaves() []*CommitFileNode { - leaves := getLeaves(s) - - return slices.Map(leaves, func(leaf INode) *CommitFileNode { - return leaf.(*CommitFileNode) - }) -} - -// extra methods - -func (s *CommitFileNode) AnyFile(test func(file *models.CommitFile) bool) bool { - return s.Any(func(node *CommitFileNode) bool { - return node.IsLeaf() && test(node.File) - }) -} - -func (s *CommitFileNode) NameAtDepth(depth int) string { - splitName := split(s.Path) - name := join(splitName[depth:]) - - return name +// returns the underlying node, without any commit-file-specific methods attached +func (self *CommitFileNode) Raw() *Node[models.CommitFile] { + return self.Node } diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index e539c9deaae..862db26f126 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -1,22 +1,24 @@ package filetree import ( + "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/sirupsen/logrus" ) type ICommitFileTree interface { - ITree + ITree[models.CommitFile] Get(index int) *CommitFileNode GetFile(path string) *models.CommitFile GetAllItems() []*CommitFileNode GetAllFiles() []*models.CommitFile + GetRoot() *CommitFileNode } type CommitFileTree struct { getFiles func() []*models.CommitFile - tree *CommitFileNode + tree *Node[models.CommitFile] showTree bool log *logrus.Entry collapsedPaths *CollapsedPaths @@ -44,7 +46,7 @@ func (self *CommitFileTree) ToggleShowTree() { func (self *CommitFileTree) Get(index int) *CommitFileNode { // need to traverse the three depth first until we get to the index. - return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root + return NewCommitFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *CommitFileTree) GetIndexForPath(path string) (int, bool) { @@ -57,7 +59,10 @@ func (self *CommitFileTree) GetAllItems() []*CommitFileNode { return nil } - return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root + // ignoring root + return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.CommitFile]) *CommitFileNode { + return NewCommitFileNode(node) + }) } func (self *CommitFileTree) Len() int { @@ -84,8 +89,8 @@ func (self *CommitFileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } -func (self *CommitFileTree) Tree() INode { - return self.tree +func (self *CommitFileTree) GetRoot() *CommitFileNode { + return NewCommitFileNode(self.tree) } func (self *CommitFileTree) CollapsedPaths() *CollapsedPaths { diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index 53f87c71bf1..092841b52d5 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -1,199 +1,47 @@ package filetree -import ( - "github.com/jesseduffield/generics/slices" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) +import "github.com/jesseduffield/lazygit/pkg/commands/models" +// FileNode wraps a node and provides some file-specific methods for it. type FileNode struct { - Children []*FileNode - File *models.File - Path string // e.g. '/path/to/mydir' - CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode + *Node[models.File] } -var ( - _ INode = &FileNode{} - _ types.ListItem = &FileNode{} -) +var _ models.IFile = &FileNode{} -func (s *FileNode) ID() string { - return s.GetPath() -} - -func (s *FileNode) Description() string { - return s.GetPath() -} - -// methods satisfying INode interface - -// interfaces values whose concrete value is nil are not themselves nil -// hence the existence of this method -func (s *FileNode) IsNil() bool { - return s == nil -} - -func (s *FileNode) IsLeaf() bool { - return s.File != nil -} - -func (s *FileNode) GetPath() string { - return s.Path -} - -func (s *FileNode) GetPreviousPath() string { - if s.File != nil { - return s.File.GetPreviousPath() - } - return "" -} - -func (s *FileNode) GetChildren() []INode { - return slices.Map(s.Children, func(child *FileNode) INode { - return child - }) -} - -func (s *FileNode) SetChildren(children []INode) { - castChildren := slices.Map(children, func(child INode) *FileNode { - return child.(*FileNode) - }) - - s.Children = castChildren -} - -func (s *FileNode) GetCompressionLevel() int { - return s.CompressionLevel -} - -func (s *FileNode) SetCompressionLevel(level int) { - s.CompressionLevel = level -} - -// methods utilising generic functions for INodes - -func (s *FileNode) Sort() { - sortNode(s) -} - -func (s *FileNode) ForEachFile(cb func(*models.File) error) error { - return forEachLeaf(s, func(n INode) error { - castNode := n.(*FileNode) - return cb(castNode.File) - }) -} - -func (s *FileNode) Any(test func(node *FileNode) bool) bool { - return any(s, func(n INode) bool { - castNode := n.(*FileNode) - return test(castNode) - }) -} - -func (n *FileNode) Flatten(collapsedPaths *CollapsedPaths) []*FileNode { - results := flatten(n, collapsedPaths) - return slices.Map(results, func(result INode) *FileNode { - return result.(*FileNode) - }) -} - -func (node *FileNode) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *FileNode { +func NewFileNode(node *Node[models.File]) *FileNode { if node == nil { return nil } - result := getNodeAtIndex(node, index, collapsedPaths) - if result == nil { - // not sure how this can be nil: we probably are missing a mutex somewhere - return nil - } - - return result.(*FileNode) + return &FileNode{Node: node} } -func (node *FileNode) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { - return getIndexForPath(node, path, collapsedPaths) +// returns the underlying node, without any file-specific methods attached +func (self *FileNode) Raw() *Node[models.File] { + return self.Node } -func (node *FileNode) Size(collapsedPaths *CollapsedPaths) int { - if node == nil { - return 0 - } - - return size(node, collapsedPaths) +func (self *FileNode) GetHasUnstagedChanges() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges }) } -func (s *FileNode) Compress() { - // with these functions I try to only have type conversion code on the actual struct, - // but comparing interface values to nil is fraught with danger so I'm duplicating - // that code here. - if s == nil { - return - } - - compressAux(s) +func (self *FileNode) GetHasStagedChanges() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges }) } -func (node *FileNode) GetFilePathsMatching(test func(*models.File) bool) []string { - return getPathsMatching(node, func(n INode) bool { - castNode := n.(*FileNode) - if castNode.File == nil { - return false - } - return test(castNode.File) - }) +func (self *FileNode) GetHasInlineMergeConflicts() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasInlineMergeConflicts }) } -func (s *FileNode) GetLeaves() []*FileNode { - leaves := getLeaves(s) - - return slices.Map(leaves, func(leaf INode) *FileNode { - return leaf.(*FileNode) - }) +func (self *FileNode) GetIsTracked() bool { + return self.SomeFile(func(file *models.File) bool { return file.Tracked }) } -// extra methods - -func (s *FileNode) GetHasUnstagedChanges() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasUnstagedChanges }) -} - -func (s *FileNode) GetHasStagedChanges() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasStagedChanges }) -} - -func (s *FileNode) GetHasInlineMergeConflicts() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasInlineMergeConflicts }) -} - -func (s *FileNode) GetIsTracked() bool { - return s.AnyFile(func(file *models.File) bool { return file.Tracked }) -} - -func (s *FileNode) AnyFile(test func(file *models.File) bool) bool { - return s.Any(func(node *FileNode) bool { - return node.IsLeaf() && test(node.File) - }) -} - -func (s *FileNode) NameAtDepth(depth int) string { - splitName := split(s.Path) - name := join(splitName[depth:]) - - if s.File != nil && s.File.IsRename() { - splitPrevName := split(s.File.PreviousName) - - prevName := s.File.PreviousName - // if the file has just been renamed inside the same directory, we can shave off - // the prefix for the previous path too. Otherwise we'll keep it unchanged - sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) - if sameParentDir { - prevName = join(splitPrevName[depth:]) - } - - return prevName + " → " + name +func (self *FileNode) GetPreviousPath() string { + if self.File == nil { + return "" } - return name + return self.File.PreviousName } diff --git a/pkg/gui/filetree/file_node_test.go b/pkg/gui/filetree/file_node_test.go index c7649bd1696..a3b2b9aeeaf 100644 --- a/pkg/gui/filetree/file_node_test.go +++ b/pkg/gui/filetree/file_node_test.go @@ -10,8 +10,8 @@ import ( func TestCompress(t *testing.T) { scenarios := []struct { name string - root *FileNode - expected *FileNode + root *Node[models.File] + expected *Node[models.File] }{ { name: "nil node", @@ -20,27 +20,27 @@ func TestCompress(t *testing.T) { }, { name: "leaf node", - root: &FileNode{ + root: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ {File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"}, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ {File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"}, }, }, }, { name: "big example", - root: &FileNode{ + root: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir1/file2", @@ -49,7 +49,7 @@ func TestCompress(t *testing.T) { }, { Path: "dir2", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true}, Path: "dir2/file3", @@ -62,10 +62,10 @@ func TestCompress(t *testing.T) { }, { Path: "dir3", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir3/dir3-1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir3/dir3-1/file5", @@ -80,12 +80,12 @@ func TestCompress(t *testing.T) { }, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir1/file2", @@ -94,7 +94,7 @@ func TestCompress(t *testing.T) { }, { Path: "dir2", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true}, Path: "dir2/file3", @@ -108,7 +108,7 @@ func TestCompress(t *testing.T) { { Path: "dir3/dir3-1", CompressionLevel: 1, - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir3/dir3-1/file5", diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 17dafaa5d64..950bf24be2b 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -18,7 +18,7 @@ const ( DisplayConflicted ) -type ITree interface { +type ITree[T any] interface { InTreeMode() bool ExpandToPath(path string) ToggleShowTree() @@ -27,12 +27,11 @@ type ITree interface { SetTree() IsCollapsed(path string) bool ToggleCollapsed(path string) - Tree() INode CollapsedPaths() *CollapsedPaths } type IFileTree interface { - ITree + ITree[models.File] FilterFiles(test func(*models.File) bool) []*models.File SetFilter(filter FileTreeDisplayFilter) @@ -46,13 +45,15 @@ type IFileTree interface { type FileTree struct { getFiles func() []*models.File - tree *FileNode + tree *Node[models.File] showTree bool log *logrus.Entry filter FileTreeDisplayFilter collapsedPaths *CollapsedPaths } +var _ IFileTree = &FileTree{} + func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree { return &FileTree{ getFiles: getFiles, @@ -102,7 +103,7 @@ func (self *FileTree) ToggleShowTree() { func (self *FileTree) Get(index int) *FileNode { // need to traverse the three depth first until we get to the index. - return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root + return NewFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *FileTree) GetFile(path string) *models.File { @@ -128,7 +129,10 @@ func (self *FileTree) GetAllItems() []*FileNode { return nil } - return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root + // ignoring root + return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.File]) *FileNode { + return NewFileNode(node) + }) } func (self *FileTree) Len() int { @@ -156,12 +160,12 @@ func (self *FileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } -func (self *FileTree) Tree() INode { - return self.tree +func (self *FileTree) Tree() *FileNode { + return NewFileNode(self.tree) } func (self *FileTree) GetRoot() *FileNode { - return self.tree + return NewFileNode(self.tree) } func (self *FileTree) CollapsedPaths() *CollapsedPaths { diff --git a/pkg/gui/filetree/inode.go b/pkg/gui/filetree/inode.go deleted file mode 100644 index d59315b28cd..00000000000 --- a/pkg/gui/filetree/inode.go +++ /dev/null @@ -1,206 +0,0 @@ -package filetree - -import "github.com/jesseduffield/generics/slices" - -type INode interface { - IsNil() bool - IsLeaf() bool - GetPath() string - GetChildren() []INode - SetChildren([]INode) - GetCompressionLevel() int - SetCompressionLevel(int) -} - -func sortNode(node INode) { - sortChildren(node) - - for _, child := range node.GetChildren() { - sortNode(child) - } -} - -func sortChildren(node INode) { - if node.IsLeaf() { - return - } - - sortedChildren := slices.Clone(node.GetChildren()) - - slices.SortFunc(sortedChildren, func(a, b INode) bool { - if !a.IsLeaf() && b.IsLeaf() { - return true - } - if a.IsLeaf() && !b.IsLeaf() { - return false - } - - return a.GetPath() < b.GetPath() - }) - - // TODO: think about making this in-place - node.SetChildren(sortedChildren) -} - -func forEachLeaf(node INode, cb func(INode) error) error { - if node.IsLeaf() { - if err := cb(node); err != nil { - return err - } - } - - for _, child := range node.GetChildren() { - if err := forEachLeaf(child, cb); err != nil { - return err - } - } - - return nil -} - -func any(node INode, test func(INode) bool) bool { - if test(node) { - return true - } - - for _, child := range node.GetChildren() { - if any(child, test) { - return true - } - } - - return false -} - -func every(node INode, test func(INode) bool) bool { - if !test(node) { - return false - } - - for _, child := range node.GetChildren() { - if !every(child, test) { - return false - } - } - - return true -} - -func flatten(node INode, collapsedPaths *CollapsedPaths) []INode { - result := []INode{} - result = append(result, node) - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - result = append(result, flatten(child, collapsedPaths)...) - } - } - - return result -} - -func getNodeAtIndex(node INode, index int, collapsedPaths *CollapsedPaths) INode { - foundNode, _ := getNodeAtIndexAux(node, index, collapsedPaths) - - return foundNode -} - -func getNodeAtIndexAux(node INode, index int, collapsedPaths *CollapsedPaths) (INode, int) { - offset := 1 - - if index == 0 { - return node, offset - } - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - foundNode, offsetChange := getNodeAtIndexAux(child, index-offset, collapsedPaths) - offset += offsetChange - if foundNode != nil { - return foundNode, offset - } - } - } - - return nil, offset -} - -func getIndexForPath(node INode, path string, collapsedPaths *CollapsedPaths) (int, bool) { - offset := 0 - - if node.GetPath() == path { - return offset, true - } - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - offsetChange, found := getIndexForPath(child, path, collapsedPaths) - offset += offsetChange + 1 - if found { - return offset, true - } - } - } - - return offset, false -} - -func size(node INode, collapsedPaths *CollapsedPaths) int { - output := 1 - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - output += size(child, collapsedPaths) - } - } - - return output -} - -func compressAux(node INode) INode { - if node.IsLeaf() { - return node - } - - children := node.GetChildren() - for i := range children { - grandchildren := children[i].GetChildren() - for len(grandchildren) == 1 && !grandchildren[0].IsLeaf() { - grandchildren[0].SetCompressionLevel(children[i].GetCompressionLevel() + 1) - children[i] = grandchildren[0] - grandchildren = children[i].GetChildren() - } - } - - for i := range children { - children[i] = compressAux(children[i]) - } - - node.SetChildren(children) - - return node -} - -func getPathsMatching(node INode, test func(INode) bool) []string { - paths := []string{} - - if test(node) { - paths = append(paths, node.GetPath()) - } - - for _, child := range node.GetChildren() { - paths = append(paths, getPathsMatching(child, test)...) - } - - return paths -} - -func getLeaves(node INode) []INode { - if node.IsLeaf() { - return []INode{node} - } - - return slices.FlatMap(node.GetChildren(), func(child INode) []INode { - return getLeaves(child) - }) -} diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go new file mode 100644 index 00000000000..8de655b377c --- /dev/null +++ b/pkg/gui/filetree/node.go @@ -0,0 +1,301 @@ +package filetree + +import ( + "github.com/jesseduffield/generics/slices" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// Represents a file or directory in a file tree. +type Node[T any] struct { + // File will be nil if the node is a directory. + File *T + + // If the node is a directory, Children contains the contents of the directory, + // otherwise it's nil. + Children []*Node[T] + + // path of the file/directory + Path string + + // rather than render a tree as: + // a/ + // b/ + // file.blah + // + // we instead render it as: + // a/b/ + // file.blah + // This saves vertical space. The CompressionLevel of a node is equal to the + // number of times a 'compression' like the above has happened, where two + // nodes are squished into one. + CompressionLevel int +} + +var _ types.ListItem = &Node[models.File]{} + +func (self *Node[T]) IsFile() bool { + return self.File != nil +} + +func (self *Node[T]) GetPath() string { + return self.Path +} + +func (self *Node[T]) Sort() { + self.SortChildren() + + for _, child := range self.Children { + child.Sort() + } +} + +func (self *Node[T]) ForEachFile(cb func(*T) error) error { + if self.IsFile() { + if err := cb(self.File); err != nil { + return err + } + } + + for _, child := range self.Children { + if err := child.ForEachFile(cb); err != nil { + return err + } + } + + return nil +} + +func (self *Node[T]) SortChildren() { + if self.IsFile() { + return + } + + children := slices.Clone(self.Children) + + slices.SortFunc(children, func(a, b *Node[T]) bool { + if !a.IsFile() && b.IsFile() { + return true + } + if a.IsFile() && !b.IsFile() { + return false + } + + return a.GetPath() < b.GetPath() + }) + + // TODO: think about making this in-place + self.Children = children +} + +func (self *Node[T]) Some(test func(*Node[T]) bool) bool { + if test(self) { + return true + } + + for _, child := range self.Children { + if child.Some(test) { + return true + } + } + + return false +} + +func (self *Node[T]) SomeFile(test func(*T) bool) bool { + if self.IsFile() { + if test(self.File) { + return true + } + } else { + for _, child := range self.Children { + if child.SomeFile(test) { + return true + } + } + } + + return false +} + +func (self *Node[T]) Every(test func(*Node[T]) bool) bool { + if !test(self) { + return false + } + + for _, child := range self.Children { + if !child.Every(test) { + return false + } + } + + return true +} + +func (self *Node[T]) EveryFile(test func(*T) bool) bool { + if self.IsFile() { + if !test(self.File) { + return false + } + } else { + for _, child := range self.Children { + if !child.EveryFile(test) { + return false + } + } + } + + return true +} + +func (self *Node[T]) Flatten(collapsedPaths *CollapsedPaths) []*Node[T] { + result := []*Node[T]{self} + + if len(self.Children) > 0 && !collapsedPaths.IsCollapsed(self.GetPath()) { + result = append(result, slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] { + return child.Flatten(collapsedPaths) + })...) + } + + return result +} + +func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *Node[T] { + if self == nil { + return nil + } + + node, _ := self.getNodeAtIndexAux(index, collapsedPaths) + + return node +} + +func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) { + offset := 1 + + if index == 0 { + return self, offset + } + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths) + offset += offsetChange + if foundNode != nil { + return foundNode, offset + } + } + } + + return nil, offset +} + +func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { + offset := 0 + + if self.GetPath() == path { + return offset, true + } + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + offsetChange, found := child.GetIndexForPath(path, collapsedPaths) + offset += offsetChange + 1 + if found { + return offset, true + } + } + } + + return offset, false +} + +func (self *Node[T]) Size(collapsedPaths *CollapsedPaths) int { + if self == nil { + return 0 + } + + output := 1 + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + output += child.Size(collapsedPaths) + } + } + + return output +} + +func (self *Node[T]) Compress() { + if self == nil { + return + } + + self.compressAux() +} + +func (self *Node[T]) compressAux() *Node[T] { + if self.IsFile() { + return self + } + + children := self.Children + for i := range children { + grandchildren := children[i].Children + for len(grandchildren) == 1 && !grandchildren[0].IsFile() { + grandchildren[0].CompressionLevel = children[i].CompressionLevel + 1 + children[i] = grandchildren[0] + grandchildren = children[i].Children + } + } + + for i := range children { + children[i] = children[i].compressAux() + } + + self.Children = children + + return self +} + +func (self *Node[T]) GetPathsMatching(test func(*Node[T]) bool) []string { + paths := []string{} + + if test(self) { + paths = append(paths, self.GetPath()) + } + + for _, child := range self.Children { + paths = append(paths, child.GetPathsMatching(test)...) + } + + return paths +} + +func (self *Node[T]) GetFilePathsMatching(test func(*T) bool) []string { + matchingFileNodes := slices.Filter(self.GetLeaves(), func(node *Node[T]) bool { + return test(node.File) + }) + + return slices.Map(matchingFileNodes, func(node *Node[T]) string { + return node.GetPath() + }) +} + +func (self *Node[T]) GetLeaves() []*Node[T] { + if self.IsFile() { + return []*Node[T]{self} + } + + return slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] { + return child.GetLeaves() + }) +} + +func (self *Node[T]) ID() string { + return self.GetPath() +} + +func (self *Node[T]) Description() string { + return self.GetPath() +} diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index cb4d19cecf6..394b39f73ad 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -30,9 +30,10 @@ func RenderFileTree( diffName string, submoduleConfigs []*models.SubmoduleConfig, ) []string { - return renderAux(tree.Tree(), tree.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string { - castN := n.(*filetree.FileNode) - return getFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File) + return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.File], depth int) string { + fileNode := filetree.NewFileNode(node) + + return getFileLine(fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), fileNameAtDepth(node, depth), diffName, submoduleConfigs, node.File) }) } @@ -41,19 +42,17 @@ func RenderCommitFileTree( diffName string, patchManager *patch.PatchManager, ) []string { - return renderAux(tree.Tree(), tree.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string { - castN := n.(*filetree.CommitFileNode) - + return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.CommitFile], depth int) string { // This is a little convoluted because we're dealing with either a leaf or a non-leaf. // But this code actually applies to both. If it's a leaf, the status will just // be whatever status it is, but if it's a non-leaf it will determine its status // based on the leaves of that subtree var status patch.PatchStatus - if castN.EveryFile(func(file *models.CommitFile) bool { + if node.EveryFile(func(file *models.CommitFile) bool { return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.WHOLE }) { status = patch.WHOLE - } else if castN.EveryFile(func(file *models.CommitFile) bool { + } else if node.EveryFile(func(file *models.CommitFile) bool { return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.UNSELECTED }) { status = patch.UNSELECTED @@ -61,37 +60,37 @@ func RenderCommitFileTree( status = patch.PART } - return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status) + return getCommitFileLine(commitFileNameAtDepth(node, depth), diffName, node.File, status) }) } -func renderAux( - s filetree.INode, +func renderAux[T any]( + node *filetree.Node[T], collapsedPaths *filetree.CollapsedPaths, prefix string, depth int, - renderLine func(filetree.INode, int) string, + renderLine func(*filetree.Node[T], int) string, ) []string { - if s == nil || s.IsNil() { + if node == nil { return []string{} } isRoot := depth == -1 - if s.IsLeaf() { + if node.IsFile() { if isRoot { return []string{} } - return []string{prefix + renderLine(s, depth)} + return []string{prefix + renderLine(node, depth)} } - if collapsedPaths.IsCollapsed(s.GetPath()) { - return []string{prefix + COLLAPSED_ARROW + " " + renderLine(s, depth)} + if collapsedPaths.IsCollapsed(node.GetPath()) { + return []string{prefix + COLLAPSED_ARROW + " " + renderLine(node, depth)} } arr := []string{} if !isRoot { - arr = append(arr, prefix+EXPANDED_ARROW+" "+renderLine(s, depth)) + arr = append(arr, prefix+EXPANDED_ARROW+" "+renderLine(node, depth)) } newPrefix := prefix @@ -101,8 +100,8 @@ func renderAux( newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED } - for i, child := range s.GetChildren() { - isLast := i == len(s.GetChildren())-1 + for i, child := range node.Children { + isLast := i == len(node.Children)-1 var childPrefix string if isRoot { @@ -113,7 +112,7 @@ func renderAux( childPrefix = newPrefix + INNER_ITEM } - arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...) + arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+node.CompressionLevel, renderLine)...) } return arr @@ -220,3 +219,39 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle { return theme.DefaultTextColor } } + +func fileNameAtDepth(node *filetree.Node[models.File], depth int) string { + splitName := split(node.Path) + name := join(splitName[depth:]) + + if node.File != nil && node.File.IsRename() { + splitPrevName := split(node.File.PreviousName) + + prevName := node.File.PreviousName + // if the file has just been renamed inside the same directory, we can shave off + // the prefix for the previous path too. Otherwise we'll keep it unchanged + sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) + if sameParentDir { + prevName = join(splitPrevName[depth:]) + } + + return prevName + " → " + name + } + + return name +} + +func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string { + splitName := split(node.Path) + name := join(splitName[depth:]) + + return name +} + +func split(str string) []string { + return strings.Split(str, "/") +} + +func join(strs []string) string { + return strings.Join(strs, "/") +}