1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-12-04 10:34:55 +02:00

Merge pull request #2075 from jesseduffield/generic-trees

This commit is contained in:
Jesse Duffield 2022-07-31 19:47:06 +10:00 committed by GitHub
commit 69f4292fe3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 500 additions and 659 deletions

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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",

View File

@ -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)
return &CommitFileNode{Node: node}
}
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)
}
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
}

View File

@ -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 {

View File

@ -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 &FileNode{Node: node}
}
// returns the underlying node, without any file-specific methods attached
func (self *FileNode) Raw() *Node[models.File] {
return self.Node
}
func (self *FileNode) GetHasUnstagedChanges() bool {
return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges })
}
func (self *FileNode) GetHasStagedChanges() bool {
return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges })
}
func (self *FileNode) GetHasInlineMergeConflicts() bool {
return self.SomeFile(func(file *models.File) bool { return file.HasInlineMergeConflicts })
}
func (self *FileNode) GetIsTracked() bool {
return self.SomeFile(func(file *models.File) bool { return file.Tracked })
}
func (self *FileNode) GetPreviousPath() string {
if self.File == nil {
return ""
}
return result.(*FileNode)
}
func (node *FileNode) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) {
return getIndexForPath(node, path, collapsedPaths)
}
func (node *FileNode) Size(collapsedPaths *CollapsedPaths) int {
if node == nil {
return 0
}
return size(node, collapsedPaths)
}
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 (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 (s *FileNode) GetLeaves() []*FileNode {
leaves := getLeaves(s)
return slices.Map(leaves, func(leaf INode) *FileNode {
return leaf.(*FileNode)
})
}
// 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
}
return name
return self.File.PreviousName
}

View File

@ -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",

View File

@ -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 {

View File

@ -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)
})
}

301
pkg/gui/filetree/node.go Normal file
View File

@ -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()
}

View File

@ -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, "/")
}