move more view model logic into the files view model
This commit is contained in:
@@ -136,19 +136,19 @@ func TestCompress(t *testing.T) {
|
||||
func TestGetFile(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
viewModel *FileTreeViewModel
|
||||
viewModel *FileTree
|
||||
path string
|
||||
expected *models.File
|
||||
}{
|
||||
{
|
||||
name: "valid case",
|
||||
viewModel: NewFileTreeViewModel(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
|
||||
viewModel: NewFileTree(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
|
||||
path: "blah/two",
|
||||
expected: &models.File{Name: "blah/two"},
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
viewModel: NewFileTreeViewModel(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
|
||||
viewModel: NewFileTree(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
|
||||
path: "blah/three",
|
||||
expected: nil,
|
||||
},
|
||||
|
||||
174
pkg/gui/filetree/file_tree.go
Normal file
174
pkg/gui/filetree/file_tree.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type FileTreeDisplayFilter int
|
||||
|
||||
const (
|
||||
DisplayAll FileTreeDisplayFilter = iota
|
||||
DisplayStaged
|
||||
DisplayUnstaged
|
||||
// this shows files with merge conflicts
|
||||
DisplayConflicted
|
||||
)
|
||||
|
||||
type IFileTree interface {
|
||||
InTreeMode() bool
|
||||
ExpandToPath(path string)
|
||||
FilterFiles(test func(*models.File) bool) []*models.File
|
||||
SetFilter(filter FileTreeDisplayFilter)
|
||||
ToggleShowTree()
|
||||
|
||||
GetItemAtIndex(index int) *FileNode
|
||||
GetFile(path string) *models.File
|
||||
GetIndexForPath(path string) (int, bool)
|
||||
GetAllItems() []*FileNode
|
||||
GetItemsLength() int
|
||||
GetAllFiles() []*models.File
|
||||
|
||||
SetTree()
|
||||
IsCollapsed(path string) bool
|
||||
ToggleCollapsed(path string)
|
||||
Tree() INode
|
||||
CollapsedPaths() CollapsedPaths
|
||||
GetFilter() FileTreeDisplayFilter
|
||||
}
|
||||
|
||||
type FileTree struct {
|
||||
getFiles func() []*models.File
|
||||
tree *FileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
filter FileTreeDisplayFilter
|
||||
collapsedPaths CollapsedPaths
|
||||
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree {
|
||||
return &FileTree{
|
||||
getFiles: getFiles,
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
RWMutex: sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTree) InTreeMode() bool {
|
||||
return self.showTree
|
||||
}
|
||||
|
||||
func (self *FileTree) ExpandToPath(path string) {
|
||||
self.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (self *FileTree) getFilesForDisplay() []*models.File {
|
||||
switch self.filter {
|
||||
case DisplayAll:
|
||||
return self.getFiles()
|
||||
case DisplayStaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
|
||||
case DisplayUnstaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
|
||||
case DisplayConflicted:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
|
||||
default:
|
||||
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File {
|
||||
result := make([]*models.File, 0)
|
||||
for _, file := range self.getFiles() {
|
||||
if test(file) {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *FileTree) SetFilter(filter FileTreeDisplayFilter) {
|
||||
self.filter = filter
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTree) ToggleShowTree() {
|
||||
self.showTree = !self.showTree
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTree) GetItemAtIndex(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
|
||||
}
|
||||
|
||||
func (self *FileTree) GetFile(path string) *models.File {
|
||||
for _, file := range self.getFiles() {
|
||||
if file.Name == path {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FileTree) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
// note: this gets all items when the filter is taken into consideration. There may
|
||||
// be hidden files that aren't included here. Files off the screen however will
|
||||
// be included
|
||||
func (self *FileTree) GetAllItems() []*FileNode {
|
||||
if self.tree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTree) GetItemsLength() int {
|
||||
return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTree) GetAllFiles() []*models.File {
|
||||
return self.getFiles()
|
||||
}
|
||||
|
||||
func (self *FileTree) SetTree() {
|
||||
filesForDisplay := self.getFilesForDisplay()
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromFiles(filesForDisplay)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTree) IsCollapsed(path string) bool {
|
||||
return self.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTree) ToggleCollapsed(path string) {
|
||||
self.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTree) Tree() INode {
|
||||
return self.tree
|
||||
}
|
||||
|
||||
func (self *FileTree) CollapsedPaths() CollapsedPaths {
|
||||
return self.collapsedPaths
|
||||
}
|
||||
|
||||
func (self *FileTree) GetFilter() FileTreeDisplayFilter {
|
||||
return self.filter
|
||||
}
|
||||
@@ -73,8 +73,8 @@ func TestFilterAction(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
mngr := &FileTreeViewModel{getFiles: s.files, filter: s.filter}
|
||||
result := mngr.GetFilesForDisplay()
|
||||
mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter}
|
||||
result := mngr.getFilesForDisplay()
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
@@ -1,150 +1,139 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type FileTreeDisplayFilter int
|
||||
|
||||
const (
|
||||
DisplayAll FileTreeDisplayFilter = iota
|
||||
DisplayStaged
|
||||
DisplayUnstaged
|
||||
// this shows files with merge conflicts
|
||||
DisplayConflicted
|
||||
)
|
||||
|
||||
type FileTreeViewModel struct {
|
||||
getFiles func() []*models.File
|
||||
tree *FileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
filter FileTreeDisplayFilter
|
||||
collapsedPaths CollapsedPaths
|
||||
sync.RWMutex
|
||||
type IFileTreeViewModel interface {
|
||||
IFileTree
|
||||
types.IListCursor
|
||||
}
|
||||
|
||||
// This combines our FileTree struct with a cursor that retains information about
|
||||
// which item is selected. It also contains logic for repositioning that cursor
|
||||
// after the files are refreshed
|
||||
type FileTreeViewModel struct {
|
||||
sync.RWMutex
|
||||
IFileTree
|
||||
types.IListCursor
|
||||
}
|
||||
|
||||
var _ IFileTreeViewModel = &FileTreeViewModel{}
|
||||
|
||||
func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
|
||||
viewModel := &FileTreeViewModel{
|
||||
getFiles: getFiles,
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
RWMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) InTreeMode() bool {
|
||||
return self.showTree
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ExpandToPath(path string) {
|
||||
self.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFilesForDisplay() []*models.File {
|
||||
switch self.filter {
|
||||
case DisplayAll:
|
||||
return self.getFiles()
|
||||
case DisplayStaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
|
||||
case DisplayUnstaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
|
||||
case DisplayConflicted:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
|
||||
default:
|
||||
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
|
||||
fileTree := NewFileTree(getFiles, log, showTree)
|
||||
listCursor := traits.NewListCursor(fileTree)
|
||||
return &FileTreeViewModel{
|
||||
IFileTree: fileTree,
|
||||
IListCursor: listCursor,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) FilterFiles(test func(*models.File) bool) []*models.File {
|
||||
result := make([]*models.File, 0)
|
||||
for _, file := range self.getFiles() {
|
||||
if test(file) {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetFilter(filter FileTreeDisplayFilter) {
|
||||
self.filter = filter
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ToggleShowTree() {
|
||||
self.showTree = !self.showTree
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetItemAtIndex(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
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFile(path string) *models.File {
|
||||
for _, file := range self.getFiles() {
|
||||
if file.Name == path {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetAllItems() []*FileNode {
|
||||
if self.tree == nil {
|
||||
func (self *FileTreeViewModel) GetSelectedFileNode() *FileNode {
|
||||
if self.GetItemsLength() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetItemsLength() int {
|
||||
return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetAllFiles() []*models.File {
|
||||
return self.getFiles()
|
||||
return self.GetItemAtIndex(self.GetSelectedLineIdx())
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetTree() {
|
||||
filesForDisplay := self.GetFilesForDisplay()
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromFiles(filesForDisplay)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
|
||||
newFiles := self.GetAllFiles()
|
||||
selectedNode := self.GetSelectedFileNode()
|
||||
|
||||
// for when you stage the old file of a rename and the new file is in a collapsed dir
|
||||
for _, file := range newFiles {
|
||||
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
|
||||
self.ExpandToPath(file.Name)
|
||||
}
|
||||
}
|
||||
|
||||
prevNodes := self.GetAllItems()
|
||||
prevSelectedLineIdx := self.GetSelectedLineIdx()
|
||||
|
||||
self.IFileTree.SetTree()
|
||||
|
||||
if selectedNode != nil {
|
||||
newNodes := self.GetAllItems()
|
||||
newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes)
|
||||
if newIdx != -1 && newIdx != prevSelectedLineIdx {
|
||||
self.SetSelectedLineIdx(newIdx)
|
||||
}
|
||||
}
|
||||
|
||||
self.RefreshSelectedIdx()
|
||||
}
|
||||
|
||||
// Let's try to find our file again and move the cursor to that.
|
||||
// If we can't find our file, it was probably just removed by the user. In that
|
||||
// case, we go looking for where the next file has been moved to. Given that the
|
||||
// user could have removed a whole directory, we continue iterating through the old
|
||||
// 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
|
||||
func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int {
|
||||
getPaths := func(node *FileNode) []string {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.File != nil && node.File.IsRename() {
|
||||
return node.File.Names()
|
||||
} else {
|
||||
return []string{node.Path}
|
||||
}
|
||||
}
|
||||
|
||||
for _, prevNode := range prevNodes {
|
||||
selectedPaths := getPaths(prevNode)
|
||||
|
||||
for idx, node := range currNodes {
|
||||
paths := getPaths(node)
|
||||
|
||||
// If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
|
||||
// This is because the new should be in the same position as the rename was meaning less cursor jumping
|
||||
foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.Path == prevNode.File.PreviousName
|
||||
foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
|
||||
if foundNode {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetFilter(filter FileTreeDisplayFilter) {
|
||||
self.IFileTree.SetFilter(filter)
|
||||
self.IListCursor.SetSelectedLineIdx(0)
|
||||
}
|
||||
|
||||
// If we're going from flat to tree we want to select the same file.
|
||||
// If we're going from tree to flat and we have a file selected we want to select that.
|
||||
// If instead we've selected a directory we need to select the first file in that directory.
|
||||
func (self *FileTreeViewModel) ToggleShowTree() {
|
||||
selectedNode := self.GetSelectedFileNode()
|
||||
|
||||
self.IFileTree.ToggleShowTree()
|
||||
|
||||
if selectedNode == nil {
|
||||
return
|
||||
}
|
||||
path := selectedNode.Path
|
||||
|
||||
if self.InTreeMode() {
|
||||
self.ExpandToPath(path)
|
||||
} else if len(selectedNode.Children) > 0 {
|
||||
path = selectedNode.GetLeaves()[0].Path
|
||||
}
|
||||
|
||||
index, found := self.GetIndexForPath(path)
|
||||
if found {
|
||||
self.SetSelectedLineIdx(index)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) IsCollapsed(path string) bool {
|
||||
return self.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ToggleCollapsed(path string) {
|
||||
self.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) Tree() INode {
|
||||
return self.tree
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) CollapsedPaths() CollapsedPaths {
|
||||
return self.collapsedPaths
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFilter() FileTreeDisplayFilter {
|
||||
return self.filter
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user