1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-02-03 13:21:56 +02:00

properly resolve cyclic dependency

This commit is contained in:
Jesse Duffield 2022-01-22 00:13:51 +11:00
parent 4ab5e54139
commit 5b7dd9e43c
22 changed files with 768 additions and 641 deletions

View File

@ -10,7 +10,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@ -160,12 +159,18 @@ func (self *WorkingTreeCommands) DiscardAllFileChanges(file *models.File) error
return self.DiscardUnstagedFileChanges(file)
}
func (self *WorkingTreeCommands) DiscardAllDirChanges(node *filetree.FileNode) error {
type IFileNode interface {
ForEachFile(cb func(*models.File) error) error
GetFilePathsMatching(test func(*models.File) bool) []string
GetPath() string
}
func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error {
// this could be more efficient but we would need to handle all the edge cases
return node.ForEachFile(self.DiscardAllFileChanges)
}
func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node *filetree.FileNode) error {
func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error {
if err := self.RemoveUntrackedDirFiles(node); err != nil {
return err
}
@ -178,9 +183,9 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node *filetree.FileNo
return nil
}
func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node *filetree.FileNode) error {
untrackedFilePaths := node.GetPathsMatching(
func(n *filetree.FileNode) bool { return n.File != nil && !n.File.GetIsTracked() },
func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error {
untrackedFilePaths := node.GetFilePathsMatching(
func(file *models.File) bool { return !file.GetIsTracked() },
)
for _, path := range untrackedFilePaths {

View File

@ -8,11 +8,11 @@ import (
func (gui *Gui) getSelectedCommitFileNode() *filetree.CommitFileNode {
selectedLine := gui.State.Panels.CommitFiles.SelectedLineIdx
if selectedLine == -1 || selectedLine > gui.State.CommitFileManager.GetItemsLength()-1 {
if selectedLine == -1 || selectedLine > gui.State.CommitFileTreeViewModel.GetItemsLength()-1 {
return nil
}
return gui.State.CommitFileManager.GetItemAtIndex(selectedLine)
return gui.State.CommitFileTreeViewModel.GetItemAtIndex(selectedLine)
}
func (gui *Gui) getSelectedCommitFile() *models.CommitFile {
@ -42,7 +42,7 @@ func (gui *Gui) commitFilesRenderToMain() error {
return nil
}
to := gui.State.CommitFileManager.GetParent()
to := gui.State.CommitFileTreeViewModel.GetParent()
from, reverse := gui.getFromAndReverseArgsForDiff(to)
cmdObj := gui.Git.WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
@ -64,7 +64,7 @@ func (gui *Gui) handleCheckoutCommitFile() error {
}
gui.logAction(gui.Tr.Actions.CheckoutFile)
if err := gui.Git.WorkingTree.CheckoutFile(gui.State.CommitFileManager.GetParent(), node.GetPath()); err != nil {
if err := gui.Git.WorkingTree.CheckoutFile(gui.State.CommitFileTreeViewModel.GetParent(), node.GetPath()); err != nil {
return gui.surfaceError(err)
}
@ -111,7 +111,8 @@ func (gui *Gui) refreshCommitFilesView() error {
if err != nil {
return gui.surfaceError(err)
}
gui.State.CommitFileManager.SetFiles(files, to)
gui.State.CommitFileTreeViewModel.SetParent(to)
gui.State.CommitFileTreeViewModel.SetFiles(files)
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
}
@ -154,7 +155,7 @@ func (gui *Gui) handleToggleFileForPatch() error {
// 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 {
return gui.Git.Patch.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileManager.GetParent()) != patch.WHOLE
return gui.Git.Patch.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileTreeViewModel.GetParent()) != patch.WHOLE
})
err := node.ForEachFile(func(file *models.CommitFile) error {
@ -176,7 +177,7 @@ func (gui *Gui) handleToggleFileForPatch() error {
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
}
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileManager.GetParent() {
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
return gui.ask(askOpts{
title: gui.Tr.DiscardPatch,
prompt: gui.Tr.DiscardPatchConfirm,
@ -224,7 +225,7 @@ func (gui *Gui) enterCommitFile(opts OnFocusOpts) error {
return gui.pushContext(gui.State.Contexts.PatchBuilding, opts)
}
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileManager.GetParent() {
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
return gui.ask(askOpts{
title: gui.Tr.DiscardPatch,
prompt: gui.Tr.DiscardPatchConfirm,
@ -244,7 +245,7 @@ func (gui *Gui) handleToggleCommitFileDirCollapsed() error {
return nil
}
gui.State.CommitFileManager.ToggleCollapsed(node.GetPath())
gui.State.CommitFileTreeViewModel.ToggleCollapsed(node.GetPath())
if err := gui.postRefreshUpdate(gui.State.Contexts.CommitFiles); err != nil {
gui.Log.Error(err)
@ -275,12 +276,12 @@ func (gui *Gui) switchToCommitFilesContext(refName string, canRebase bool, conte
func (gui *Gui) handleToggleCommitFileTreeView() error {
path := gui.getSelectedCommitFilePath()
gui.State.CommitFileManager.ToggleShowTree()
gui.State.CommitFileTreeViewModel.ToggleShowTree()
// find that same node in the new format and move the cursor to it
if path != "" {
gui.State.CommitFileManager.ExpandToPath(path)
index, found := gui.State.CommitFileManager.GetIndexForPath(path)
gui.State.CommitFileTreeViewModel.ExpandToPath(path)
index, found := gui.State.CommitFileTreeViewModel.GetIndexForPath(path)
if found {
gui.State.Contexts.CommitFiles.GetPanelState().SetSelectedLineIdx(index)
}

View File

@ -21,7 +21,7 @@ func (gui *Gui) getSelectedFileNode() *filetree.FileNode {
return nil
}
return gui.State.FileManager.GetItemAtIndex(selectedLine)
return gui.State.FileTreeViewModel.GetItemAtIndex(selectedLine)
}
func (gui *Gui) getSelectedFile() *models.File {
@ -129,7 +129,7 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
// specific functions
func (gui *Gui) stagedFiles() []*models.File {
files := gui.State.FileManager.GetAllFiles()
files := gui.State.FileTreeViewModel.GetAllFiles()
result := make([]*models.File, 0)
for _, file := range files {
if file.HasStagedChanges {
@ -140,7 +140,7 @@ func (gui *Gui) stagedFiles() []*models.File {
}
func (gui *Gui) trackedFiles() []*models.File {
files := gui.State.FileManager.GetAllFiles()
files := gui.State.FileTreeViewModel.GetAllFiles()
result := make([]*models.File, 0, len(files))
for _, file := range files {
if file.Tracked {
@ -244,7 +244,7 @@ func (gui *Gui) handleFilePress() error {
}
func (gui *Gui) allFilesStaged() bool {
for _, file := range gui.State.FileManager.GetAllFiles() {
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
if file.HasUnstagedChanges {
return false
}
@ -378,7 +378,7 @@ func (gui *Gui) handleCommitPress() error {
return gui.surfaceError(err)
}
if gui.State.FileManager.GetItemsLength() == 0 {
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
@ -433,7 +433,7 @@ func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
}
func (gui *Gui) handleAmendCommitPress() error {
if gui.State.FileManager.GetItemsLength() == 0 {
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
@ -459,7 +459,7 @@ func (gui *Gui) handleAmendCommitPress() error {
// handleCommitEditorPress - handle when the user wants to commit changes via
// their editor rather than via the popup panel
func (gui *Gui) handleCommitEditorPress() error {
if gui.State.FileManager.GetItemsLength() == 0 {
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
@ -498,9 +498,9 @@ func (gui *Gui) handleStatusFilterPressed() error {
return gui.createMenu(gui.Tr.FilteringMenuTitle, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) setStatusFiltering(filter filetree.FileManagerDisplayFilter) error {
func (gui *Gui) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
state := gui.State
state.FileManager.SetDisplayFilter(filter)
state.FileTreeViewModel.SetDisplayFilter(filter)
return gui.handleRefreshFiles()
}
@ -555,31 +555,31 @@ func (gui *Gui) refreshStateFiles() error {
selectedNode := gui.getSelectedFileNode()
prevNodes := gui.State.FileManager.GetAllItems()
prevNodes := gui.State.FileTreeViewModel.GetAllItems()
prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
files := gui.Git.Loaders.Files.
GetStatusFiles(loaders.GetStatusFileOptions{})
// for when you stage the old file of a rename and the new file is in a collapsed dir
state.FileManager.RWMutex.Lock()
state.FileTreeViewModel.RWMutex.Lock()
for _, file := range files {
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
state.FileManager.ExpandToPath(file.Name)
state.FileTreeViewModel.ExpandToPath(file.Name)
}
}
state.FileManager.SetFiles(files)
state.FileManager.RWMutex.Unlock()
state.FileTreeViewModel.SetFiles(files)
state.FileTreeViewModel.RWMutex.Unlock()
if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
return err
}
if selectedNode != nil {
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileManager.GetAllItems())
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileTreeViewModel.GetAllItems())
if newIdx != -1 && newIdx != prevSelectedLineIdx {
newNode := state.FileManager.GetItemAtIndex(newIdx)
newNode := state.FileTreeViewModel.GetItemAtIndex(newIdx)
// when not in tree mode, we show merge conflict files at the top, so you
// can work through them one by one without having to sift through a large
// set of files. If you have just fixed the merge conflicts of a file, we
@ -588,7 +588,7 @@ func (gui *Gui) refreshStateFiles() error {
// conflicts: the user in this case would rather work on the next file
// with merge conflicts, which will have moved up to fill the gap left by
// the last file, meaning the cursor doesn't need to move at all.
leaveCursor := !state.FileManager.InTreeMode() && newNode != nil &&
leaveCursor := !state.FileTreeViewModel.InTreeMode() && newNode != nil &&
selectedNode.File != nil && selectedNode.File.HasMergeConflicts &&
newNode.File != nil && !newNode.File.HasMergeConflicts
@ -598,7 +598,7 @@ func (gui *Gui) refreshStateFiles() error {
}
}
gui.refreshSelectedLine(state.Panels.Files, state.FileManager.GetItemsLength())
gui.refreshSelectedLine(state.Panels.Files, state.FileTreeViewModel.GetItemsLength())
return nil
}
@ -871,7 +871,7 @@ func (gui *Gui) openFile(filename string) error {
}
func (gui *Gui) anyFilesWithMergeConflicts() bool {
for _, file := range gui.State.FileManager.GetAllFiles() {
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
if file.HasMergeConflicts {
return true
}
@ -939,7 +939,7 @@ func (gui *Gui) handleToggleDirCollapsed() error {
return nil
}
gui.State.FileManager.ToggleCollapsed(node.GetPath())
gui.State.FileTreeViewModel.ToggleCollapsed(node.GetPath())
if err := gui.postRefreshUpdate(gui.State.Contexts.Files); err != nil {
gui.Log.Error(err)
@ -952,12 +952,12 @@ func (gui *Gui) handleToggleFileTreeView() error {
// get path of currently selected file
path := gui.getSelectedPath()
gui.State.FileManager.ToggleShowTree()
gui.State.FileTreeViewModel.ToggleShowTree()
// find that same node in the new format and move the cursor to it
if path != "" {
gui.State.FileManager.ExpandToPath(path)
index, found := gui.State.FileManager.GetIndexForPath(path)
gui.State.FileTreeViewModel.ExpandToPath(path)
index, found := gui.State.FileTreeViewModel.GetIndexForPath(path)
if found {
gui.filesListContext().GetPanelState().SetSelectedLineIdx(index)
}

View File

@ -0,0 +1,22 @@
## FileTree Package
This package handles the representation of file trees. There are two ways to render files: one is to render them flat, so something like this:
```
dir1/file1
dir1/file2
file3
```
And the other is to render them as a tree
```
dir1/
file1
file2
file3
```
Internally we represent each of the above as a tree, but with the flat approach there's just a single root node and every path is a direct child of that root. Viewing in 'tree' mode (as opposed to 'flat' mode) allows for collapsing and expanding directories, and lets you perform actions on directories e.g. staging a whole directory. But it takes up more vertical space and sometimes you just want to have a flat view where you can go flick through your files one by one to see the diff.
This package is not concerned about rendering the tree: only representing its internal state.

View File

@ -1,118 +0,0 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/sirupsen/logrus"
)
type CommitFileManager struct {
files []*models.CommitFile
tree *CommitFileNode
showTree bool
log *logrus.Entry
collapsedPaths CollapsedPaths
// parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
parent string
}
func (m *CommitFileManager) GetParent() string {
return m.parent
}
func NewCommitFileManager(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileManager {
return &CommitFileManager{
files: files,
log: log,
showTree: showTree,
collapsedPaths: CollapsedPaths{},
}
}
func (m *CommitFileManager) ExpandToPath(path string) {
m.collapsedPaths.ExpandToPath(path)
}
func (m *CommitFileManager) ToggleShowTree() {
m.showTree = !m.showTree
m.SetTree()
}
func (m *CommitFileManager) GetItemAtIndex(index int) *CommitFileNode {
// need to traverse the three depth first until we get to the index.
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
}
func (m *CommitFileManager) GetIndexForPath(path string) (int, bool) {
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
return index - 1, found
}
func (m *CommitFileManager) GetAllItems() []*CommitFileNode {
if m.tree == nil {
return nil
}
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
}
func (m *CommitFileManager) GetItemsLength() int {
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
}
func (m *CommitFileManager) GetAllFiles() []*models.CommitFile {
return m.files
}
func (m *CommitFileManager) SetFiles(files []*models.CommitFile, parent string) {
m.files = files
m.parent = parent
m.SetTree()
}
func (m *CommitFileManager) SetTree() {
if m.showTree {
m.tree = BuildTreeFromCommitFiles(m.files)
} else {
m.tree = BuildFlatTreeFromCommitFiles(m.files)
}
}
func (m *CommitFileManager) IsCollapsed(path string) bool {
return m.collapsedPaths.IsCollapsed(path)
}
func (m *CommitFileManager) ToggleCollapsed(path string) {
m.collapsedPaths.ToggleCollapsed(path)
}
func (m *CommitFileManager) Render(diffName string, patchManager *patch.PatchManager) []string {
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
if m.tree == nil {
return []string{}
}
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
castN := n.(*CommitFileNode)
// 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 {
return patchManager.GetFileStatus(file.Name, m.parent) == patch.WHOLE
}) {
status = patch.WHOLE
} else if castN.EveryFile(func(file *models.CommitFile) bool {
return patchManager.GetFileStatus(file.Name, m.parent) == patch.UNSELECTED
}) {
status = patch.UNSELECTED
} else {
status = patch.PART
}
return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
})
}

View File

@ -11,6 +11,8 @@ type CommitFileNode struct {
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
}
var _ INode = &CommitFileNode{}
// methods satisfying ListItem interface
func (s *CommitFileNode) ID() string {
@ -23,6 +25,10 @@ func (s *CommitFileNode) Description() string {
// methods satisfying INode interface
func (s *CommitFileNode) IsNil() bool {
return s == nil
}
func (s *CommitFileNode) IsLeaf() bool {
return s.File != nil
}
@ -139,13 +145,6 @@ func (s *CommitFileNode) Compress() {
compressAux(s)
}
// This ignores the root
func (node *CommitFileNode) GetPathsMatching(test func(*CommitFileNode) bool) []string {
return getPathsMatching(node, func(n INode) bool {
return test(n.(*CommitFileNode))
})
}
func (s *CommitFileNode) GetLeaves() []*CommitFileNode {
leaves := getLeaves(s)
castLeaves := make([]*CommitFileNode, len(leaves))

View File

@ -0,0 +1,101 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/sirupsen/logrus"
)
type CommitFileTreeViewModel struct {
files []*models.CommitFile
tree *CommitFileNode
showTree bool
log *logrus.Entry
collapsedPaths CollapsedPaths
// parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
parent string
}
func (self *CommitFileTreeViewModel) GetParent() string {
return self.parent
}
func (self *CommitFileTreeViewModel) SetParent(parent string) {
self.parent = parent
}
func NewCommitFileTreeViewModel(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel {
viewModel := &CommitFileTreeViewModel{
log: log,
showTree: showTree,
collapsedPaths: CollapsedPaths{},
}
viewModel.SetFiles(files)
return viewModel
}
func (self *CommitFileTreeViewModel) ExpandToPath(path string) {
self.collapsedPaths.ExpandToPath(path)
}
func (self *CommitFileTreeViewModel) ToggleShowTree() {
self.showTree = !self.showTree
self.SetTree()
}
func (self *CommitFileTreeViewModel) GetItemAtIndex(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
}
func (self *CommitFileTreeViewModel) GetIndexForPath(path string) (int, bool) {
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
return index - 1, found
}
func (self *CommitFileTreeViewModel) GetAllItems() []*CommitFileNode {
if self.tree == nil {
return nil
}
return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
}
func (self *CommitFileTreeViewModel) GetItemsLength() int {
return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
}
func (self *CommitFileTreeViewModel) GetAllFiles() []*models.CommitFile {
return self.files
}
func (self *CommitFileTreeViewModel) SetFiles(files []*models.CommitFile) {
self.files = files
self.SetTree()
}
func (self *CommitFileTreeViewModel) SetTree() {
if self.showTree {
self.tree = BuildTreeFromCommitFiles(self.files)
} else {
self.tree = BuildFlatTreeFromCommitFiles(self.files)
}
}
func (self *CommitFileTreeViewModel) IsCollapsed(path string) bool {
return self.collapsedPaths.IsCollapsed(path)
}
func (self *CommitFileTreeViewModel) ToggleCollapsed(path string) {
self.collapsedPaths.ToggleCollapsed(path)
}
func (self *CommitFileTreeViewModel) Tree() INode {
return self.tree
}
func (self *CommitFileTreeViewModel) CollapsedPaths() CollapsedPaths {
return self.collapsedPaths
}

View File

@ -1,9 +0,0 @@
package filetree
const EXPANDED_ARROW = "▼"
const COLLAPSED_ARROW = "►"
const INNER_ITEM = "├─ "
const LAST_ITEM = "└─ "
const NESTED = "│ "
const NOTHING = " "

View File

@ -1,140 +0,0 @@
package filetree
import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/sirupsen/logrus"
)
type FileManagerDisplayFilter int
const (
DisplayAll FileManagerDisplayFilter = iota
DisplayStaged
DisplayUnstaged
)
type FileManager struct {
files []*models.File
tree *FileNode
showTree bool
log *logrus.Entry
filter FileManagerDisplayFilter
collapsedPaths CollapsedPaths
sync.RWMutex
}
func NewFileManager(files []*models.File, log *logrus.Entry, showTree bool) *FileManager {
return &FileManager{
files: files,
log: log,
showTree: showTree,
filter: DisplayAll,
collapsedPaths: CollapsedPaths{},
RWMutex: sync.RWMutex{},
}
}
func (m *FileManager) InTreeMode() bool {
return m.showTree
}
func (m *FileManager) ExpandToPath(path string) {
m.collapsedPaths.ExpandToPath(path)
}
func (m *FileManager) GetFilesForDisplay() []*models.File {
files := m.files
if m.filter == DisplayAll {
return files
}
result := make([]*models.File, 0)
if m.filter == DisplayStaged {
for _, file := range files {
if file.HasStagedChanges {
result = append(result, file)
}
}
} else {
for _, file := range files {
if !file.HasStagedChanges {
result = append(result, file)
}
}
}
return result
}
func (m *FileManager) SetDisplayFilter(filter FileManagerDisplayFilter) {
m.filter = filter
m.SetTree()
}
func (m *FileManager) ToggleShowTree() {
m.showTree = !m.showTree
m.SetTree()
}
func (m *FileManager) GetItemAtIndex(index int) *FileNode {
// need to traverse the three depth first until we get to the index.
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
}
func (m *FileManager) GetIndexForPath(path string) (int, bool) {
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
return index - 1, found
}
func (m *FileManager) GetAllItems() []*FileNode {
if m.tree == nil {
return nil
}
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
}
func (m *FileManager) GetItemsLength() int {
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
}
func (m *FileManager) GetAllFiles() []*models.File {
return m.files
}
func (m *FileManager) SetFiles(files []*models.File) {
m.files = files
m.SetTree()
}
func (m *FileManager) SetTree() {
filesForDisplay := m.GetFilesForDisplay()
if m.showTree {
m.tree = BuildTreeFromFiles(filesForDisplay)
} else {
m.tree = BuildFlatTreeFromFiles(filesForDisplay)
}
}
func (m *FileManager) IsCollapsed(path string) bool {
return m.collapsedPaths.IsCollapsed(path)
}
func (m *FileManager) ToggleCollapsed(path string) {
m.collapsedPaths.ToggleCollapsed(path)
}
func (m *FileManager) Render(diffName string, submoduleConfigs []*models.SubmoduleConfig) []string {
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
if m.tree == nil {
return []string{}
}
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
castN := n.(*FileNode)
return getFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File)
})
}

View File

@ -1,156 +0,0 @@
package filetree
import (
"testing"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)
func init() {
color.ForceSetColorLevel(terminfo.ColorLevelNone)
}
func TestRender(t *testing.T) {
scenarios := []struct {
name string
root *FileNode
collapsedPaths map[string]bool
expected []string
}{
{
name: "nil node",
root: nil,
expected: []string{},
},
{
name: "leaf node",
root: &FileNode{
Path: "",
Children: []*FileNode{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
expected: []string{" M test"},
},
{
name: "big example",
root: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
},
{
File: &models.File{Name: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file3",
},
},
},
{
Path: "dir2",
Children: []*FileNode{
{
Path: "dir2/dir2",
Children: []*FileNode{
{
File: &models.File{Name: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/dir2/file3",
},
{
File: &models.File{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/dir2/file4",
},
},
},
{
File: &models.File{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/file5",
},
},
},
{
File: &models.File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "file1",
},
},
},
expected: []string{"dir1 ►", "dir2 ▼", "├─ dir2 ▼", "│ ├─ M file3", "│ └─ M file4", "└─ M file5", "M file1"},
collapsedPaths: map[string]bool{"dir1": true},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
mngr := &FileManager{tree: s.root, collapsedPaths: s.collapsedPaths}
result := mngr.Render("", nil)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestFilterAction(t *testing.T) {
scenarios := []struct {
name string
filter FileManagerDisplayFilter
files []*models.File
expected []*models.File
}{
{
name: "filter files with unstaged changes",
filter: DisplayUnstaged,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
},
{
name: "filter files with staged changes",
filter: DisplayStaged,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
},
},
{
name: "filter all files",
filter: DisplayAll,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
mngr := &FileManager{files: s.files, filter: s.filter}
result := mngr.GetFilesForDisplay()
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@ -11,6 +11,8 @@ type FileNode struct {
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
}
var _ INode = &FileNode{}
// methods satisfying ListItem interface
func (s *FileNode) ID() string {
@ -23,6 +25,12 @@ func (s *FileNode) Description() string {
// 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
}
@ -124,10 +132,13 @@ func (s *FileNode) Compress() {
compressAux(s)
}
// This ignores the root
func (node *FileNode) GetPathsMatching(test func(*FileNode) bool) []string {
func (node *FileNode) GetFilePathsMatching(test func(*models.File) bool) []string {
return getPathsMatching(node, func(n INode) bool {
return test(n.(*FileNode))
castNode := n.(*FileNode)
if castNode.File == nil {
return false
}
return test(castNode.File)
})
}

View File

@ -0,0 +1,139 @@
package filetree
import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/sirupsen/logrus"
)
type FileTreeDisplayFilter int
const (
DisplayAll FileTreeDisplayFilter = iota
DisplayStaged
DisplayUnstaged
)
type FileTreeViewModel struct {
files []*models.File
tree *FileNode
showTree bool
log *logrus.Entry
filter FileTreeDisplayFilter
collapsedPaths CollapsedPaths
sync.RWMutex
}
func NewFileTreeViewModel(files []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
viewModel := &FileTreeViewModel{
log: log,
showTree: showTree,
filter: DisplayAll,
collapsedPaths: CollapsedPaths{},
RWMutex: sync.RWMutex{},
}
viewModel.SetFiles(files)
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 {
files := self.files
if self.filter == DisplayAll {
return files
}
result := make([]*models.File, 0)
if self.filter == DisplayStaged {
for _, file := range files {
if file.HasStagedChanges {
result = append(result, file)
}
}
} else {
for _, file := range files {
if !file.HasStagedChanges {
result = append(result, file)
}
}
}
return result
}
func (self *FileTreeViewModel) SetDisplayFilter(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) 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 {
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.files
}
func (self *FileTreeViewModel) SetFiles(files []*models.File) {
self.files = files
self.SetTree()
}
func (self *FileTreeViewModel) SetTree() {
filesForDisplay := self.GetFilesForDisplay()
if self.showTree {
self.tree = BuildTreeFromFiles(filesForDisplay)
} else {
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
}
}
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
}

View File

@ -0,0 +1,67 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
func TestFilterAction(t *testing.T) {
scenarios := []struct {
name string
filter FileTreeDisplayFilter
files []*models.File
expected []*models.File
}{
{
name: "filter files with unstaged changes",
filter: DisplayUnstaged,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
},
{
name: "filter files with staged changes",
filter: DisplayStaged,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
},
},
{
name: "filter all files",
filter: DisplayAll,
files: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
expected: []*models.File{
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
mngr := &FileTreeViewModel{files: s.files, filter: s.filter}
result := mngr.GetFilesForDisplay()
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@ -1,12 +1,11 @@
package filetree
import (
"fmt"
"sort"
"strings"
)
type INode interface {
IsNil() bool
IsLeaf() bool
GetPath() string
GetChildren() []INode
@ -212,51 +211,3 @@ func getLeaves(node INode) []INode {
return output
}
func renderAux(s INode, collapsedPaths CollapsedPaths, prefix string, depth int, renderLine func(INode, int) string) []string {
isRoot := depth == -1
renderLineWithPrefix := func() string {
return prefix + renderLine(s, depth)
}
if s.IsLeaf() {
if isRoot {
return []string{}
}
return []string{renderLineWithPrefix()}
}
if collapsedPaths.IsCollapsed(s.GetPath()) {
return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
}
arr := []string{}
if !isRoot {
arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
}
newPrefix := prefix
if strings.HasSuffix(prefix, LAST_ITEM) {
newPrefix = strings.TrimSuffix(prefix, LAST_ITEM) + NOTHING
} else if strings.HasSuffix(prefix, INNER_ITEM) {
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
}
for i, child := range s.GetChildren() {
isLast := i == len(s.GetChildren())-1
var childPrefix string
if isRoot {
childPrefix = newPrefix
} else if isLast {
childPrefix = newPrefix + LAST_ITEM
} else {
childPrefix = newPrefix + INNER_ITEM
}
arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
}
return arr
}

View File

@ -1,96 +0,0 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// TODO: move this back into presentation package and fix the import cycle
func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, diffName string, submoduleConfigs []*models.SubmoduleConfig, file *models.File) string {
// potentially inefficient to be instantiating these color
// objects with each render
partiallyModifiedColor := style.FgYellow
restColor := style.FgGreen
if name == diffName {
restColor = theme.DiffTerminalColor
} else if file == nil && hasStagedChanges && hasUnstagedChanges {
restColor = partiallyModifiedColor
} else if hasUnstagedChanges {
restColor = style.FgRed
}
output := ""
if file != nil {
// this is just making things look nice when the background attribute is 'reverse'
firstChar := file.ShortStatus[0:1]
firstCharCl := style.FgGreen
if firstChar == "?" {
firstCharCl = style.FgRed
} else if firstChar == " " {
firstCharCl = restColor
}
secondChar := file.ShortStatus[1:2]
secondCharCl := style.FgRed
if secondChar == " " {
secondCharCl = restColor
}
output = firstCharCl.Sprint(firstChar)
output += secondCharCl.Sprint(secondChar)
output += restColor.Sprint(" ")
}
output += restColor.Sprint(utils.EscapeSpecialChars(name))
if file != nil && file.IsSubmodule(submoduleConfigs) {
output += theme.DefaultTextColor.Sprint(" (submodule)")
}
return output
}
func getCommitFileLine(name string, diffName string, commitFile *models.CommitFile, status patch.PatchStatus) string {
var colour style.TextStyle
if diffName == name {
colour = theme.DiffTerminalColor
} else {
switch status {
case patch.WHOLE:
colour = style.FgGreen
case patch.PART:
colour = style.FgYellow
case patch.UNSELECTED:
colour = theme.DefaultTextColor
}
}
name = utils.EscapeSpecialChars(name)
if commitFile == nil {
return colour.Sprint(name)
}
return getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " " + colour.Sprint(name)
}
func getColorForChangeStatus(changeStatus string) style.TextStyle {
switch changeStatus {
case "A":
return style.FgGreen
case "M", "R":
return style.FgYellow
case "D":
return style.FgRed
case "C":
return style.FgCyan
case "T":
return style.FgMagenta
default:
return theme.DefaultTextColor
}
}

View File

@ -290,12 +290,12 @@ type guiMutexes struct {
type guiState struct {
// the file panels (files and commit files) can render as a tree, so we have
// managers for them which handle rendering a flat list of files in tree form
FileManager *filetree.FileManager
CommitFileManager *filetree.CommitFileManager
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
Commits []*models.Commit
StashEntries []*models.StashEntry
FileTreeViewModel *filetree.FileTreeViewModel
CommitFileTreeViewModel *filetree.CommitFileTreeViewModel
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
Commits []*models.Commit
StashEntries []*models.StashEntry
// Suggestions will sometimes appear when typing into a prompt
Suggestions []*types.Suggestion
// FilteredReflogCommits are the ones that appear in the reflog panel.
@ -390,13 +390,13 @@ func (gui *Gui) resetState(filterPath string, reuseState bool) {
}
gui.State = &guiState{
FileManager: filetree.NewFileManager(make([]*models.File, 0), gui.Log, showTree),
CommitFileManager: filetree.NewCommitFileManager(make([]*models.CommitFile, 0), gui.Log, showTree),
Commits: make([]*models.Commit, 0),
FilteredReflogCommits: make([]*models.Commit, 0),
ReflogCommits: make([]*models.Commit, 0),
StashEntries: make([]*models.StashEntry, 0),
BisectInfo: gui.Git.Bisect.GetInfo(),
FileTreeViewModel: filetree.NewFileTreeViewModel(make([]*models.File, 0), gui.Log, showTree),
CommitFileTreeViewModel: filetree.NewCommitFileTreeViewModel(make([]*models.CommitFile, 0), gui.Log, showTree),
Commits: make([]*models.Commit, 0),
FilteredReflogCommits: make([]*models.Commit, 0),
ReflogCommits: make([]*models.Commit, 0),
StashEntries: make([]*models.StashEntry, 0),
BisectInfo: gui.Git.Bisect.GetInfo(),
Panels: &panelStates{
// TODO: work out why some of these are -1 and some are 0. Last time I checked there was a good reason but I'm less certain now
Files: &filePanelState{listPanelState{SelectedLineIdx: -1}},

View File

@ -133,7 +133,7 @@ func (gui *Gui) handleMouseDrag() error {
func (gui *Gui) getSelectedCommitFileName() string {
idx := gui.State.Panels.CommitFiles.SelectedLineIdx
return gui.State.CommitFileManager.GetItemAtIndex(idx).GetPath()
return gui.State.CommitFileTreeViewModel.GetItemAtIndex(idx).GetPath()
}
func (gui *Gui) refreshMainViewForLineByLine(state *LblPanelState) error {

View File

@ -34,14 +34,14 @@ func (gui *Gui) filesListContext() IListContext {
Key: FILES_CONTEXT_KEY,
Kind: SIDE_CONTEXT,
},
GetItemsLength: func() int { return gui.State.FileManager.GetItemsLength() },
GetItemsLength: func() int { return gui.State.FileTreeViewModel.GetItemsLength() },
OnGetPanelState: func() IListPanelState { return gui.State.Panels.Files },
OnFocus: OnFocusWrapper(gui.onFocusFile),
OnRenderToMain: OnFocusWrapper(gui.filesRenderToMain),
OnClickSelectedItem: gui.handleFilePress,
Gui: gui,
GetDisplayStrings: func(startIdx int, length int) [][]string {
lines := gui.State.FileManager.Render(gui.State.Modes.Diffing.Ref, gui.State.Submodules)
lines := presentation.RenderFileTree(gui.State.FileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.State.Submodules)
mappedLines := make([][]string, len(lines))
for i, line := range lines {
mappedLines[i] = []string{line}
@ -309,17 +309,17 @@ func (gui *Gui) commitFilesListContext() IListContext {
Key: COMMIT_FILES_CONTEXT_KEY,
Kind: SIDE_CONTEXT,
},
GetItemsLength: func() int { return gui.State.CommitFileManager.GetItemsLength() },
GetItemsLength: func() int { return gui.State.CommitFileTreeViewModel.GetItemsLength() },
OnGetPanelState: func() IListPanelState { return gui.State.Panels.CommitFiles },
OnFocus: OnFocusWrapper(gui.onCommitFileFocus),
OnRenderToMain: OnFocusWrapper(gui.commitFilesRenderToMain),
Gui: gui,
GetDisplayStrings: func(startIdx int, length int) [][]string {
if gui.State.CommitFileManager.GetItemsLength() == 0 {
if gui.State.CommitFileTreeViewModel.GetItemsLength() == 0 {
return [][]string{{style.FgRed.Sprint("(none)")}}
}
lines := gui.State.CommitFileManager.Render(gui.State.Modes.Diffing.Ref, gui.Git.Patch.PatchManager)
lines := presentation.RenderCommitFileTree(gui.State.CommitFileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.Git.Patch.PatchManager)
mappedLines := make([][]string, len(lines))
for i, line := range lines {
mappedLines[i] = []string{line}

View File

@ -31,7 +31,7 @@ func (gui *Gui) refreshPatchBuildingPanel(selectedLineIdx int) error {
return nil
}
to := gui.State.CommitFileManager.GetParent()
to := gui.State.CommitFileTreeViewModel.GetParent()
from, reverse := gui.getFromAndReverseArgsForDiff(to)
diff, err := gui.Git.WorkingTree.ShowFileDiff(from, to, reverse, node.GetPath(), true)
if err != nil {

View File

@ -0,0 +1,204 @@
package presentation
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
const EXPANDED_ARROW = "▼"
const COLLAPSED_ARROW = "►"
const INNER_ITEM = "├─ "
const LAST_ITEM = "└─ "
const NESTED = "│ "
const NOTHING = " "
func RenderFileTree(
fileMgr *filetree.FileTreeViewModel,
diffName string,
submoduleConfigs []*models.SubmoduleConfig,
) []string {
return renderAux(fileMgr.Tree(), fileMgr.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)
})
}
func RenderCommitFileTree(
commitFileMgr *filetree.CommitFileTreeViewModel,
diffName string,
patchManager *patch.PatchManager,
) []string {
return renderAux(commitFileMgr.Tree(), commitFileMgr.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string {
castN := n.(*filetree.CommitFileNode)
// 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 {
return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.WHOLE
}) {
status = patch.WHOLE
} else if castN.EveryFile(func(file *models.CommitFile) bool {
return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.UNSELECTED
}) {
status = patch.UNSELECTED
} else {
status = patch.PART
}
return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
})
}
func renderAux(
s filetree.INode,
collapsedPaths filetree.CollapsedPaths,
prefix string,
depth int,
renderLine func(filetree.INode, int) string,
) []string {
if s == nil || s.IsNil() {
return []string{}
}
isRoot := depth == -1
renderLineWithPrefix := func() string {
return prefix + renderLine(s, depth)
}
if s.IsLeaf() {
if isRoot {
return []string{}
}
return []string{renderLineWithPrefix()}
}
if collapsedPaths.IsCollapsed(s.GetPath()) {
return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
}
arr := []string{}
if !isRoot {
arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
}
newPrefix := prefix
if strings.HasSuffix(prefix, LAST_ITEM) {
newPrefix = strings.TrimSuffix(prefix, LAST_ITEM) + NOTHING
} else if strings.HasSuffix(prefix, INNER_ITEM) {
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
}
for i, child := range s.GetChildren() {
isLast := i == len(s.GetChildren())-1
var childPrefix string
if isRoot {
childPrefix = newPrefix
} else if isLast {
childPrefix = newPrefix + LAST_ITEM
} else {
childPrefix = newPrefix + INNER_ITEM
}
arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
}
return arr
}
func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, diffName string, submoduleConfigs []*models.SubmoduleConfig, file *models.File) string {
// potentially inefficient to be instantiating these color
// objects with each render
partiallyModifiedColor := style.FgYellow
restColor := style.FgGreen
if name == diffName {
restColor = theme.DiffTerminalColor
} else if file == nil && hasStagedChanges && hasUnstagedChanges {
restColor = partiallyModifiedColor
} else if hasUnstagedChanges {
restColor = style.FgRed
}
output := ""
if file != nil {
// this is just making things look nice when the background attribute is 'reverse'
firstChar := file.ShortStatus[0:1]
firstCharCl := style.FgGreen
if firstChar == "?" {
firstCharCl = style.FgRed
} else if firstChar == " " {
firstCharCl = restColor
}
secondChar := file.ShortStatus[1:2]
secondCharCl := style.FgRed
if secondChar == " " {
secondCharCl = restColor
}
output = firstCharCl.Sprint(firstChar)
output += secondCharCl.Sprint(secondChar)
output += restColor.Sprint(" ")
}
output += restColor.Sprint(utils.EscapeSpecialChars(name))
if file != nil && file.IsSubmodule(submoduleConfigs) {
output += theme.DefaultTextColor.Sprint(" (submodule)")
}
return output
}
func getCommitFileLine(name string, diffName string, commitFile *models.CommitFile, status patch.PatchStatus) string {
var colour style.TextStyle
if diffName == name {
colour = theme.DiffTerminalColor
} else {
switch status {
case patch.WHOLE:
colour = style.FgGreen
case patch.PART:
colour = style.FgYellow
case patch.UNSELECTED:
colour = theme.DefaultTextColor
}
}
name = utils.EscapeSpecialChars(name)
if commitFile == nil {
return colour.Sprint(name)
}
return getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " " + colour.Sprint(name)
}
func getColorForChangeStatus(changeStatus string) style.TextStyle {
switch changeStatus {
case "A":
return style.FgGreen
case "M", "R":
return style.FgYellow
case "D":
return style.FgRed
case "C":
return style.FgCyan
case "T":
return style.FgMagenta
default:
return theme.DefaultTextColor
}
}

View File

@ -0,0 +1,146 @@
package presentation
import (
"strings"
"testing"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)
func init() {
color.ForceSetColorLevel(terminfo.ColorLevelNone)
}
func toStringSlice(str string) []string {
return strings.Split(strings.TrimSpace(str), "\n")
}
func TestRenderFileTree(t *testing.T) {
scenarios := []struct {
name string
root *filetree.FileNode
files []*models.File
collapsedPaths []string
expected []string
}{
{
name: "nil node",
files: nil,
expected: []string{},
},
{
name: "leaf node",
files: []*models.File{
{Name: "test", ShortStatus: " M", HasStagedChanges: true},
},
expected: []string{" M test"},
},
{
name: "big example",
files: []*models.File{
{Name: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
expected: toStringSlice(
`
dir1
dir2
dir2
M file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
viewModel := filetree.NewFileTreeViewModel(s.files, utils.NewDummyLog(), true)
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
result := RenderFileTree(viewModel, "", nil)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestRenderCommitFileTree(t *testing.T) {
scenarios := []struct {
name string
root *filetree.FileNode
files []*models.CommitFile
collapsedPaths []string
expected []string
}{
{
name: "nil node",
files: nil,
expected: []string{},
},
{
name: "leaf node",
files: []*models.CommitFile{
{Name: "test", ChangeStatus: "A"},
},
expected: []string{"A test"},
},
{
name: "big example",
files: []*models.CommitFile{
{Name: "dir1/file2", ChangeStatus: "M"},
{Name: "dir1/file3", ChangeStatus: "A"},
{Name: "dir2/dir2/file3", ChangeStatus: "D"},
{Name: "dir2/dir2/file4", ChangeStatus: "M"},
{Name: "dir2/file5", ChangeStatus: "M"},
{Name: "file1", ChangeStatus: "M"},
},
expected: toStringSlice(
`
dir1
dir2
dir2
D file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
viewModel := filetree.NewCommitFileTreeViewModel(s.files, utils.NewDummyLog(), true)
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
patchManager := patch.NewPatchManager(
utils.NewDummyLog(),
func(patch string, flags ...string) error { return nil },
func(from string, to string, reverse bool, filename string, plain bool) (string, error) {
return "", nil
},
)
patchManager.Start("from", "to", false, false)
result := RenderCommitFileTree(viewModel, "", patchManager)
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@ -96,7 +96,7 @@ func (gui *Gui) handleResetSubmodule(submodule *models.SubmoduleConfig) error {
}
func (gui *Gui) fileForSubmodule(submodule *models.SubmoduleConfig) *models.File {
for _, file := range gui.State.FileManager.GetAllFiles() {
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) {
return file
}