diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index c40f75693..4e97913fb 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -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 { diff --git a/pkg/gui/commit_files_panel.go b/pkg/gui/commit_files_panel.go index 7e1ecd130..61f3b72b8 100644 --- a/pkg/gui/commit_files_panel.go +++ b/pkg/gui/commit_files_panel.go @@ -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) } diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go index 4201afb11..c62da8e21 100644 --- a/pkg/gui/files_panel.go +++ b/pkg/gui/files_panel.go @@ -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) } diff --git a/pkg/gui/filetree/README.md b/pkg/gui/filetree/README.md new file mode 100644 index 000000000..d2d16ace6 --- /dev/null +++ b/pkg/gui/filetree/README.md @@ -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. diff --git a/pkg/gui/filetree/commit_file_manager.go b/pkg/gui/filetree/commit_file_manager.go deleted file mode 100644 index 852a67b09..000000000 --- a/pkg/gui/filetree/commit_file_manager.go +++ /dev/null @@ -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) - }) -} diff --git a/pkg/gui/filetree/commit_file_node.go b/pkg/gui/filetree/commit_file_node.go index 173281035..14960ee30 100644 --- a/pkg/gui/filetree/commit_file_node.go +++ b/pkg/gui/filetree/commit_file_node.go @@ -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)) diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go new file mode 100644 index 000000000..301396462 --- /dev/null +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -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 +} diff --git a/pkg/gui/filetree/constants.go b/pkg/gui/filetree/constants.go deleted file mode 100644 index d510650e2..000000000 --- a/pkg/gui/filetree/constants.go +++ /dev/null @@ -1,9 +0,0 @@ -package filetree - -const EXPANDED_ARROW = "▼" -const COLLAPSED_ARROW = "►" - -const INNER_ITEM = "├─ " -const LAST_ITEM = "└─ " -const NESTED = "│ " -const NOTHING = " " diff --git a/pkg/gui/filetree/file_manager.go b/pkg/gui/filetree/file_manager.go deleted file mode 100644 index b028ef961..000000000 --- a/pkg/gui/filetree/file_manager.go +++ /dev/null @@ -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) - }) -} diff --git a/pkg/gui/filetree/file_manager_test.go b/pkg/gui/filetree/file_manager_test.go deleted file mode 100644 index 83ba5b086..000000000 --- a/pkg/gui/filetree/file_manager_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index cb545d391..f332f0a76 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -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) }) } diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go new file mode 100644 index 000000000..d12814976 --- /dev/null +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -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 +} diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go new file mode 100644 index 000000000..10c32d31d --- /dev/null +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -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) + }) + } +} diff --git a/pkg/gui/filetree/inode.go b/pkg/gui/filetree/inode.go index f7bfc0518..7d9035fe3 100644 --- a/pkg/gui/filetree/inode.go +++ b/pkg/gui/filetree/inode.go @@ -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 -} diff --git a/pkg/gui/filetree/presentation.go b/pkg/gui/filetree/presentation.go deleted file mode 100644 index 6cecb78ad..000000000 --- a/pkg/gui/filetree/presentation.go +++ /dev/null @@ -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 - } -} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 731e56b5b..f46d3c756 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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}}, diff --git a/pkg/gui/line_by_line_panel.go b/pkg/gui/line_by_line_panel.go index 3c1668b76..0576d3c0f 100644 --- a/pkg/gui/line_by_line_panel.go +++ b/pkg/gui/line_by_line_panel.go @@ -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 { diff --git a/pkg/gui/list_context_config.go b/pkg/gui/list_context_config.go index f05375b67..7ddd56e25 100644 --- a/pkg/gui/list_context_config.go +++ b/pkg/gui/list_context_config.go @@ -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} diff --git a/pkg/gui/patch_building_panel.go b/pkg/gui/patch_building_panel.go index bf0c95fa1..eb7728100 100644 --- a/pkg/gui/patch_building_panel.go +++ b/pkg/gui/patch_building_panel.go @@ -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 { diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go new file mode 100644 index 000000000..116d4fc4b --- /dev/null +++ b/pkg/gui/presentation/files.go @@ -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 + } +} diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go new file mode 100644 index 000000000..c40f6247e --- /dev/null +++ b/pkg/gui/presentation/files_test.go @@ -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) + }) + } +} diff --git a/pkg/gui/submodules_panel.go b/pkg/gui/submodules_panel.go index bb230d372..96648b6ce 100644 --- a/pkg/gui/submodules_panel.go +++ b/pkg/gui/submodules_panel.go @@ -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 }