diff --git a/pkg/commands/loading_files.go b/pkg/commands/loading_files.go index f2a285f58..411c0251a 100644 --- a/pkg/commands/loading_files.go +++ b/pkg/commands/loading_files.go @@ -58,6 +58,7 @@ func (c *GitCommand) GetStatusFiles(opts GetStatusFileOptions) []*models.File { } files = append(files, file) } + return files } diff --git a/pkg/commands/models/status_line_node.go b/pkg/commands/models/status_line_node.go new file mode 100644 index 000000000..042a0fceb --- /dev/null +++ b/pkg/commands/models/status_line_node.go @@ -0,0 +1,137 @@ +package models + +import ( + "sort" +) + +type StatusLineNode struct { + Children []*StatusLineNode + File *File + Name string + Collapsed bool +} + +func (s *StatusLineNode) GetShortStatus() string { + // need to see if any child has unstaged changes. + if s.IsLeaf() { + return s.File.ShortStatus + } + + firstChar := " " + secondChar := " " + if s.HasStagedChanges() { + firstChar = "M" + } + if s.HasUnstagedChanges() { + secondChar = "M" + } + + return firstChar + secondChar +} + +func (s *StatusLineNode) HasUnstagedChanges() bool { + if s.IsLeaf() { + return s.File.HasUnstagedChanges + } + + for _, child := range s.Children { + if child.HasUnstagedChanges() { + return true + } + } + + return false +} + +func (s *StatusLineNode) HasStagedChanges() bool { + if s.IsLeaf() { + return s.File.HasStagedChanges + } + + for _, child := range s.Children { + if child.HasStagedChanges() { + return true + } + } + + return false +} + +func (s *StatusLineNode) GetNodeAtIndex(index int) *StatusLineNode { + node, _ := s.getNodeAtIndexAux(index) + + return node +} + +func (s *StatusLineNode) getNodeAtIndexAux(index int) (*StatusLineNode, int) { + offset := 1 + + if index == 0 { + return s, offset + } + + for _, child := range s.Children { + node, offsetChange := child.getNodeAtIndexAux(index - offset) + offset += offsetChange + if node != nil { + return node, offset + } + } + + return nil, offset +} + +func (s *StatusLineNode) IsLeaf() bool { + return len(s.Children) == 0 +} + +func (s *StatusLineNode) Size() int { + output := 1 + + for _, child := range s.Children { + output += child.Size() + } + + return output +} + +func (s *StatusLineNode) Flatten() []*StatusLineNode { + arr := []*StatusLineNode{s} + + for _, child := range s.Children { + arr = append(arr, child.Flatten()...) + } + + return arr +} + +func (s *StatusLineNode) SortTree() { + s.sortChildren() + + for _, child := range s.Children { + child.SortTree() + } +} + +func (s *StatusLineNode) sortChildren() { + if s.IsLeaf() { + return + } + + sortedChildren := make([]*StatusLineNode, len(s.Children)) + copy(sortedChildren, s.Children) + + sort.Slice(sortedChildren, func(i, j int) bool { + if !sortedChildren[i].IsLeaf() && sortedChildren[j].IsLeaf() { + return true + } + if sortedChildren[i].IsLeaf() && !sortedChildren[j].IsLeaf() { + return false + } + + return sortedChildren[i].Name < sortedChildren[j].Name + }) + + // TODO: think about making this in-place + s.Children = sortedChildren +} diff --git a/pkg/commands/models/status_line_node_test.go b/pkg/commands/models/status_line_node_test.go new file mode 100644 index 000000000..f8114fcf1 --- /dev/null +++ b/pkg/commands/models/status_line_node_test.go @@ -0,0 +1,76 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRender(t *testing.T) { + scenarios := []struct { + name string + root *StatusLineNode + expected []string + }{ + { + name: "nil node", + root: nil, + expected: []string{}, + }, + { + name: "leaf node", + root: &StatusLineNode{ + Name: "", + Children: []*StatusLineNode{ + {File: &File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Name: "test"}, + }, + }, + expected: []string{" M test"}, + }, + { + name: "big example", + root: &StatusLineNode{ + Name: "", + Children: []*StatusLineNode{ + { + Name: "dir1", + Collapsed: true, + Children: []*StatusLineNode{ + { + File: &File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, + Name: "file2", + }, + }, + }, + { + Name: "dir2", + Children: []*StatusLineNode{ + { + File: &File{Name: "file3", ShortStatus: " M", HasStagedChanges: true}, + Name: "file3", + }, + { + File: &File{Name: "file4", ShortStatus: "M ", HasUnstagedChanges: true}, + Name: "file4", + }, + }, + }, + { + File: &File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, + Name: "file1", + }, + }, + }, + + expected: []string{"M dir1 ►", "MM dir2 ▼", " M file3", " M file4", "M file1"}, + }, + } + + for _, s := range scenarios { + s := s + t.Run(s.name, func(t *testing.T) { + result := s.root.Render()[1:] + assert.EqualValues(t, s.expected, result) + }) + } +} diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go index 52bd6c346..8a388f9a0 100644 --- a/pkg/gui/files_panel.go +++ b/pkg/gui/files_panel.go @@ -21,13 +21,26 @@ import ( // list panel functions +// func (gui *Gui) getSelectedStatusNode() *models.StatusLineNode { +// selectedLine := gui.State.Panels.Files.SelectedLineIdx +// if selectedLine == -1 { +// return nil +// } + +// return gui.State.StatusLineManager.GetItemAtIndex(selectedLine) +// } + func (gui *Gui) getSelectedFile() *models.File { selectedLine := gui.State.Panels.Files.SelectedLineIdx if selectedLine == -1 { return nil } - return gui.State.Files[selectedLine] + node := gui.State.StatusLineManager.GetItemAtIndex(selectedLine) + if node == nil { + return nil + } + return node.File } func (gui *Gui) selectFile(alreadySelected bool) error { @@ -131,7 +144,7 @@ func (gui *Gui) refreshFilesAndSubmodules() error { // specific functions func (gui *Gui) stagedFiles() []*models.File { - files := gui.State.Files + files := gui.State.StatusLineManager.GetAllFiles() result := make([]*models.File, 0) for _, file := range files { if file.HasStagedChanges { @@ -142,7 +155,7 @@ func (gui *Gui) stagedFiles() []*models.File { } func (gui *Gui) trackedFiles() []*models.File { - files := gui.State.Files + files := gui.State.StatusLineManager.GetAllFiles() result := make([]*models.File, 0, len(files)) for _, file := range files { if file.Tracked { @@ -216,7 +229,7 @@ func (gui *Gui) handleFilePress() error { } func (gui *Gui) allFilesStaged() bool { - for _, file := range gui.State.Files { + for _, file := range gui.State.StatusLineManager.GetAllFiles() { if file.HasUnstagedChanges { return false } @@ -450,8 +463,11 @@ func (gui *Gui) refreshStateFiles() error { prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx // get files to stage - files := gui.GitCommand.GetStatusFiles(commands.GetStatusFileOptions{}) - gui.State.Files = gui.GitCommand.MergeStatusFiles(gui.State.Files, files, selectedFile) + noRenames := gui.State.StatusLineManager.TreeMode + files := gui.GitCommand.GetStatusFiles(commands.GetStatusFileOptions{NoRenames: noRenames}) + gui.State.StatusLineManager.SetFiles( + gui.GitCommand.MergeStatusFiles(gui.State.StatusLineManager.GetAllFiles(), files, selectedFile), + ) if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil { return err @@ -459,8 +475,9 @@ func (gui *Gui) refreshStateFiles() error { // let's try to find our file again and move the cursor to that if selectedFile != nil { - for idx, f := range gui.State.Files { - selectedFileHasMoved := f.Matches(selectedFile) && idx != prevSelectedLineIdx + for idx, node := range gui.State.StatusLineManager.GetAllItems() { + // TODO: check that this works + selectedFileHasMoved := node.File != nil && node.File.Matches(selectedFile) && idx != prevSelectedLineIdx if selectedFileHasMoved { gui.State.Panels.Files.SelectedLineIdx = idx break @@ -468,7 +485,7 @@ func (gui *Gui) refreshStateFiles() error { } } - gui.refreshSelectedLine(gui.State.Panels.Files, len(gui.State.Files)) + gui.refreshSelectedLine(gui.State.Panels.Files, gui.State.StatusLineManager.GetItemsLength()) return nil } @@ -661,7 +678,7 @@ func (gui *Gui) openFile(filename string) error { } func (gui *Gui) anyFilesWithMergeConflicts() bool { - for _, file := range gui.State.Files { + for _, file := range gui.State.StatusLineManager.GetAllFiles() { if file.HasMergeConflicts { return true } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 77bd82f63..8f6badb6e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -303,12 +303,12 @@ type guiStateMutexes struct { } type guiState struct { - Files []*models.File - Submodules []*models.SubmoduleConfig - Branches []*models.Branch - Commits []*models.Commit - StashEntries []*models.StashEntry - CommitFiles []*models.CommitFile + StatusLineManager *StatusLineManager + Submodules []*models.SubmoduleConfig + Branches []*models.Branch + Commits []*models.Commit + StashEntries []*models.StashEntry + CommitFiles []*models.CommitFile // Suggestions will sometimes appear when typing into a prompt Suggestions []*types.Suggestion // FilteredReflogCommits are the ones that appear in the reflog panel. @@ -378,7 +378,7 @@ func (gui *Gui) resetState() { } gui.State = &guiState{ - Files: make([]*models.File, 0), + StatusLineManager: &StatusLineManager{Files: make([]*models.File, 0), Log: gui.Log, TreeMode: true}, Commits: make([]*models.Commit, 0), FilteredReflogCommits: make([]*models.Commit, 0), ReflogCommits: make([]*models.Commit, 0), diff --git a/pkg/gui/list_context.go b/pkg/gui/list_context.go index 908f35f7b..7a7c6a8e8 100644 --- a/pkg/gui/list_context.go +++ b/pkg/gui/list_context.go @@ -262,7 +262,7 @@ func (gui *Gui) filesListContext() *ListContext { return &ListContext{ ViewName: "files", ContextKey: FILES_CONTEXT_KEY, - GetItemsLength: func() int { return len(gui.State.Files) }, + GetItemsLength: func() int { return gui.State.StatusLineManager.GetItemsLength() }, GetPanelState: func() IListPanelState { return gui.State.Panels.Files }, OnFocus: gui.focusAndSelectFile, OnClickSelectedItem: gui.handleFilePress, @@ -270,7 +270,15 @@ func (gui *Gui) filesListContext() *ListContext { ResetMainViewOriginOnFocus: false, Kind: SIDE_CONTEXT, GetDisplayStrings: func() [][]string { - return presentation.GetFileListDisplayStrings(gui.State.Files, gui.State.Modes.Diffing.Ref, gui.State.Submodules) + lines := gui.State.StatusLineManager.Render(gui.State.Modes.Diffing.Ref, gui.State.Submodules) + mappedLines := make([][]string, len(lines)) + for i, line := range lines { + mappedLines[i] = []string{line} + } + + return mappedLines + + return presentation.GetFileListDisplayStrings(gui.State.StatusLineManager.GetAllFiles(), gui.State.Modes.Diffing.Ref, gui.State.Submodules) }, SelectedItem: func() (ListItem, bool) { item := gui.getSelectedFile() diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index 4adb78ea2..d64437b2c 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -11,41 +11,45 @@ func GetFileListDisplayStrings(files []*models.File, diffName string, submoduleC lines := make([][]string, len(files)) for i := range files { - diffed := files[i].Name == diffName - lines[i] = getFileDisplayStrings(files[i], diffed, submoduleConfigs) + lines[i] = getFileDisplayStrings(files[i], diffName, submoduleConfigs) } return lines } // getFileDisplayStrings returns the display string of branch -func getFileDisplayStrings(f *models.File, diffed bool, submoduleConfigs []*models.SubmoduleConfig) []string { +func getFileDisplayStrings(f *models.File, diffName string, submoduleConfigs []*models.SubmoduleConfig) []string { + output := GetStatusNodeLine(f.HasUnstagedChanges, f.ShortStatus, f.Name, diffName, submoduleConfigs, f) + + return []string{output} +} + +func GetStatusNodeLine(hasUnstagedChanges bool, shortStatus string, name string, diffName string, submoduleConfigs []*models.SubmoduleConfig, file *models.File) string { // potentially inefficient to be instantiating these color // objects with each render red := color.New(color.FgRed) green := color.New(color.FgGreen) diffColor := color.New(theme.DiffTerminalColor) - if !f.Tracked && !f.HasStagedChanges { - return []string{red.Sprint(f.DisplayString)} - } var restColor *color.Color - if diffed { + if name == diffName { restColor = diffColor - } else if f.HasUnstagedChanges { + } else if hasUnstagedChanges { restColor = red } else { restColor = green } // this is just making things look nice when the background attribute is 'reverse' - firstChar := f.DisplayString[0:1] + firstChar := shortStatus[0:1] firstCharCl := green - if firstChar == " " { + if firstChar == "?" { + firstCharCl = red + } else if firstChar == " " { firstCharCl = restColor } - secondChar := f.DisplayString[1:2] + secondChar := shortStatus[1:2] secondCharCl := red if secondChar == " " { secondCharCl = restColor @@ -53,11 +57,11 @@ func getFileDisplayStrings(f *models.File, diffed bool, submoduleConfigs []*mode output := firstCharCl.Sprint(firstChar) output += secondCharCl.Sprint(secondChar) - output += restColor.Sprintf(" %s", f.Name) + output += restColor.Sprintf(" %s", name) - if f.IsSubmodule(submoduleConfigs) { + if file != nil && file.IsSubmodule(submoduleConfigs) { output += utils.ColoredString(" (submodule)", theme.DefaultTextColor) } - return []string{output} + return output } diff --git a/pkg/gui/status_line_manager.go b/pkg/gui/status_line_manager.go new file mode 100644 index 000000000..2dd69b0cf --- /dev/null +++ b/pkg/gui/status_line_manager.go @@ -0,0 +1,87 @@ +package gui + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/sirupsen/logrus" +) + +const EXPANDED_ARROW = "▼" +const COLLAPSED_ARROW = "►" + +type StatusLineManager struct { + Files []*models.File + Tree *models.StatusLineNode + TreeMode bool + Log *logrus.Entry +} + +func (m *StatusLineManager) GetItemAtIndex(index int) *models.StatusLineNode { + if m.TreeMode { + // need to traverse the three depth first until we get to the index. + return m.Tree.GetNodeAtIndex(index + 1) // ignoring root + } + + m.Log.Warn(index) + if index > len(m.Files)-1 { + return nil + } + + return &models.StatusLineNode{File: m.Files[index]} +} + +func (m *StatusLineManager) GetAllItems() []*models.StatusLineNode { + return m.Tree.Flatten()[1:] // ignoring root +} + +func (m *StatusLineManager) GetItemsLength() int { + return m.Tree.Size() - 1 // ignoring root +} + +func (m *StatusLineManager) GetAllFiles() []*models.File { + return m.Files +} + +func (m *StatusLineManager) SetFiles(files []*models.File) { + m.Files = files + m.Tree = GetTreeFromStatusFiles(files) +} + +func (m *StatusLineManager) Render(diffName string, submoduleConfigs []*models.SubmoduleConfig) []string { + return m.renderAux(m.Tree, -1, diffName, submoduleConfigs) +} + +func (m *StatusLineManager) renderAux(s *models.StatusLineNode, depth int, diffName string, submoduleConfigs []*models.SubmoduleConfig) []string { + if s == nil { + return []string{} + } + + getLine := func() string { + return strings.Repeat(" ", depth) + presentation.GetStatusNodeLine(s.HasUnstagedChanges(), s.GetShortStatus(), s.Name, diffName, submoduleConfigs, s.File) + } + + if s.IsLeaf() { + if depth == -1 { + return []string{} + } + return []string{getLine()} + } + + if s.Collapsed { + return []string{fmt.Sprintf("%s%s %s", strings.Repeat(" ", depth), s.Name, COLLAPSED_ARROW)} + } + + arr := []string{} + if depth > -1 { + arr = append(arr, fmt.Sprintf("%s%s %s", strings.Repeat(" ", depth), s.Name, EXPANDED_ARROW)) + } + + for _, child := range s.Children { + arr = append(arr, m.renderAux(child, depth+1, diffName, submoduleConfigs)...) + } + + return arr +} diff --git a/pkg/gui/status_tree.go b/pkg/gui/status_tree.go new file mode 100644 index 000000000..758f308a7 --- /dev/null +++ b/pkg/gui/status_tree.go @@ -0,0 +1,47 @@ +package gui + +import ( + "os" + "sort" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/models" +) + +func GetTreeFromStatusFiles(files []*models.File) *models.StatusLineNode { + root := &models.StatusLineNode{} + + sort.SliceStable(files, func(i, j int) bool { + return files[i].Name < files[j].Name + }) + + var curr *models.StatusLineNode + for _, file := range files { + split := strings.Split(file.Name, string(os.PathSeparator)) + curr = root + outer: + for i, dir := range split { + var setFile *models.File + if i == len(split)-1 { + setFile = file + } + for _, existingChild := range curr.Children { + if existingChild.Name == dir { + curr = existingChild + continue outer + } + } + newChild := &models.StatusLineNode{ + Name: dir, + File: setFile, + } + curr.Children = append(curr.Children, newChild) + + curr = newChild + } + } + + root.SortTree() + + return root +} diff --git a/pkg/gui/submodules_panel.go b/pkg/gui/submodules_panel.go index 470a20e11..d4b76b7dd 100644 --- a/pkg/gui/submodules_panel.go +++ b/pkg/gui/submodules_panel.go @@ -98,7 +98,7 @@ func (gui *Gui) handleResetSubmodule(submodule *models.SubmoduleConfig) error { } func (gui *Gui) fileForSubmodule(submodule *models.SubmoduleConfig) *models.File { - for _, file := range gui.State.Files { + for _, file := range gui.State.StatusLineManager.GetAllFiles() { if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) { return file }