From f455f99705759c91fb50d3e1777fe887d5f4db22 Mon Sep 17 00:00:00 2001 From: johannaschwarz Date: Sun, 8 Dec 2024 12:04:45 +0100 Subject: [PATCH] Add user config gui.showNumstatInFilesView When enabled, it adds "+n -m" after each file in the Files panel to show how many lines were added and deleted, as with `git diff --numstat` on the command line. --- docs/Config.md | 3 + pkg/commands/git_commands/file_loader.go | 63 ++++++++++++++++ pkg/commands/git_commands/file_loader_test.go | 72 ++++++++++++------- pkg/commands/models/file.go | 2 + pkg/config/user_config.go | 3 + pkg/gui/context/working_tree_context.go | 3 +- pkg/gui/presentation/files.go | 27 ++++++- pkg/gui/presentation/files_test.go | 29 ++++++-- schema/config.json | 5 ++ 9 files changed, 174 insertions(+), 33 deletions(-) diff --git a/docs/Config.md b/docs/Config.md index d63987f065f..e6a4a4a754a 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -164,6 +164,9 @@ gui: # This can be toggled from within Lazygit with the '~' key, but that will not change the default. showFileTree: true + # If true, show the number of lines changed per file in the Files view + showNumstatInFilesView: false + # If true, show a random tip in the command log when Lazygit starts showRandomTip: true diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 72329543a77..4cf0da2d0c5 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -3,6 +3,7 @@ package git_commands import ( "fmt" "path/filepath" + "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -48,6 +49,14 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File } files := []*models.File{} + fileDiffs := map[string]FileDiff{} + if self.GitCommon.Common.UserConfig().Gui.ShowNumstatInFilesView { + fileDiffs, err = self.getFileDiffs() + if err != nil { + self.Log.Error(err) + } + } + for _, status := range statuses { if strings.HasPrefix(status.StatusString, "warning") { self.Log.Warningf("warning when calling git status: %s", status.StatusString) @@ -60,6 +69,11 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File DisplayString: status.StatusString, } + if diff, ok := fileDiffs[status.Name]; ok { + file.LinesAdded = diff.LinesAdded + file.LinesDeleted = diff.LinesDeleted + } + models.SetStatusFields(file, status.Change) files = append(files, file) } @@ -87,6 +101,45 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File return files } +type FileDiff struct { + LinesAdded int + LinesDeleted int +} + +func (fileLoader *FileLoader) getFileDiffs() (map[string]FileDiff, error) { + diffs, err := fileLoader.gitDiffNumStat() + if err != nil { + return nil, err + } + + splitLines := strings.Split(diffs, "\x00") + + fileDiffs := map[string]FileDiff{} + for _, line := range splitLines { + splitLine := strings.Split(line, "\t") + if len(splitLine) != 3 { + continue + } + + linesAdded, err := strconv.Atoi(splitLine[0]) + if err != nil { + continue + } + linesDeleted, err := strconv.Atoi(splitLine[1]) + if err != nil { + continue + } + + fileName := splitLine[2] + fileDiffs[fileName] = FileDiff{ + LinesAdded: linesAdded, + LinesDeleted: linesDeleted, + } + } + + return fileDiffs, nil +} + // GitStatus returns the file status of the repo type GitStatusOptions struct { NoRenames bool @@ -100,6 +153,16 @@ type FileStatus struct { PreviousName string } +func (fileLoader *FileLoader) gitDiffNumStat() (string, error) { + return fileLoader.cmd.New( + NewGitCmd("diff"). + Arg("--numstat"). + Arg("-z"). + Arg("HEAD"). + ToArgv(), + ).DontLog().RunWithOutput() +} + func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) { cmdArgs := NewGitCmd("status"). Arg(opts.UntrackedFilesArg). diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index 5a9f15700ed..cc4bbaa07ed 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -11,29 +11,35 @@ import ( func TestFileGetStatusFiles(t *testing.T) { type scenario struct { - testName string - similarityThreshold int - runner oscommands.ICmdObjRunner - expectedFiles []*models.File + testName string + similarityThreshold int + runner oscommands.ICmdObjRunner + showNumstatInFilesView bool + expectedFiles []*models.File } scenarios := []scenario{ { - "No files found", - 50, - oscommands.NewFakeRunner(t). + testName: "No files found", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), - []*models.File{}, + expectedFiles: []*models.File{}, }, { - "Several files found", - 50, - oscommands.NewFakeRunner(t). + testName: "Several files found", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM file1.txt\x00A file3.txt\x00AM file2.txt\x00?? file4.txt\x00UU file5.txt", nil, + ). + ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"}, + "4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt", + nil, ), - []*models.File{ + showNumstatInFilesView: true, + expectedFiles: []*models.File{ { Name: "file1.txt", HasStagedChanges: true, @@ -45,6 +51,8 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: false, DisplayString: "MM file1.txt", ShortStatus: "MM", + LinesAdded: 4, + LinesDeleted: 1, }, { Name: "file3.txt", @@ -57,6 +65,8 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: false, DisplayString: "A file3.txt", ShortStatus: "A ", + LinesAdded: 2, + LinesDeleted: 2, }, { Name: "file2.txt", @@ -69,6 +79,8 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: false, DisplayString: "AM file2.txt", ShortStatus: "AM", + LinesAdded: 1, + LinesDeleted: 0, }, { Name: "file4.txt", @@ -81,6 +93,8 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: false, DisplayString: "?? file4.txt", ShortStatus: "??", + LinesAdded: 0, + LinesDeleted: 2, }, { Name: "file5.txt", @@ -93,15 +107,17 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: true, DisplayString: "UU file5.txt", ShortStatus: "UU", + LinesAdded: 2, + LinesDeleted: 2, }, }, }, { - "File with new line char", - 50, - oscommands.NewFakeRunner(t). + testName: "File with new line char", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM a\nb.txt", nil), - []*models.File{ + expectedFiles: []*models.File{ { Name: "a\nb.txt", HasStagedChanges: true, @@ -117,14 +133,14 @@ func TestFileGetStatusFiles(t *testing.T) { }, }, { - "Renamed files", - 50, - oscommands.NewFakeRunner(t). + testName: "Renamed files", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "R after1.txt\x00before1.txt\x00RM after2.txt\x00before2.txt", nil, ), - []*models.File{ + expectedFiles: []*models.File{ { Name: "after1.txt", PreviousName: "before1.txt", @@ -154,14 +170,14 @@ func TestFileGetStatusFiles(t *testing.T) { }, }, { - "File with arrow in name", - 50, - oscommands.NewFakeRunner(t). + testName: "File with arrow in name", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, `?? a -> b.txt`, nil, ), - []*models.File{ + expectedFiles: []*models.File{ { Name: "a -> b.txt", HasStagedChanges: false, @@ -185,8 +201,14 @@ func TestFileGetStatusFiles(t *testing.T) { appState := &config.AppState{} appState.RenameSimilarityThreshold = s.similarityThreshold + userConfig := &config.UserConfig{ + Gui: config.GuiConfig{ + ShowNumstatInFilesView: s.showNumstatInFilesView, + }, + } + loader := &FileLoader{ - GitCommon: buildGitCommon(commonDeps{appState: appState}), + GitCommon: buildGitCommon(commonDeps{appState: appState, userConfig: userConfig}), cmd: cmd, config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"}, getFileType: func(string) string { return "file" }, diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index 45f1ec5d7f0..4be424e2235 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -19,6 +19,8 @@ type File struct { HasInlineMergeConflicts bool DisplayString string ShortStatus string // e.g. 'AD', ' A', 'M ', '??' + LinesDeleted int + LinesAdded int // If true, this must be a worktree folder IsWorktree bool diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index b02a959f531..dd732e0bea6 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -109,6 +109,8 @@ type GuiConfig struct { // If true, display the files in the file views as a tree. If false, display the files as a flat list. // This can be toggled from within Lazygit with the '~' key, but that will not change the default. ShowFileTree bool `yaml:"showFileTree"` + // If true, show the number of lines changed per file in the Files view + ShowNumstatInFilesView bool `yaml:"showNumstatInFilesView"` // If true, show a random tip in the command log when Lazygit starts ShowRandomTip bool `yaml:"showRandomTip"` // If true, show the command log @@ -714,6 +716,7 @@ func GetDefaultConfig() *UserConfig { ShowBottomLine: true, ShowPanelJumps: true, ShowFileTree: true, + ShowNumstatInFilesView: false, ShowRandomTip: true, ShowIcons: false, NerdFontsVersion: "", diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index 88d2ab9fef6..cef1eb5c293 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -30,7 +30,8 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { getDisplayStrings := func(_ int, _ int) [][]string { showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons - lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons) + showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView + lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat) return lo.Map(lines, func(line string, _ int) []string { return []string{line} }) diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index 5941934c60e..ed558c17025 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -22,12 +22,13 @@ func RenderFileTree( tree filetree.IFileTree, submoduleConfigs []*models.SubmoduleConfig, showFileIcons bool, + showNumstat bool, ) []string { collapsedPaths := tree.CollapsedPaths() return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string { fileNode := filetree.NewFileNode(node) - return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showFileIcons, submoduleConfigs, node) + return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node) }) } @@ -111,6 +112,7 @@ func getFileLine( hasStagedChanges bool, treeDepth int, visualDepth int, + showNumstat, showFileIcons bool, submoduleConfigs []*models.SubmoduleConfig, node *filetree.Node[models.File], @@ -165,6 +167,12 @@ func getFileLine( output += theme.DefaultTextColor.Sprint(" (submodule)") } + if file != nil && showNumstat { + if lineChanges := formatLineChanges(file.LinesAdded, file.LinesDeleted); lineChanges != "" { + output += " " + lineChanges + } + } + return output } @@ -186,6 +194,23 @@ func formatFileStatus(file *models.File, restColor style.TextStyle) string { return firstCharCl.Sprint(firstChar) + secondCharCl.Sprint(secondChar) } +func formatLineChanges(linesAdded, linesDeleted int) string { + output := "" + + if linesAdded != 0 { + output += style.FgGreen.Sprintf("+%d", linesAdded) + } + + if linesDeleted != 0 { + if output != "" { + output += " " + } + output += style.FgRed.Sprintf("-%d", linesDeleted) + } + + return output +} + func getCommitFileLine( isCollapsed bool, treeDepth int, diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index f041991412f..a6cdbf99df4 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -19,11 +19,12 @@ func toStringSlice(str string) []string { func TestRenderFileTree(t *testing.T) { scenarios := []struct { - name string - root *filetree.FileNode - files []*models.File - collapsedPaths []string - expected []string + name string + root *filetree.FileNode + files []*models.File + collapsedPaths []string + showLineChanges bool + expected []string }{ { name: "nil node", @@ -37,6 +38,22 @@ func TestRenderFileTree(t *testing.T) { }, expected: []string{" M test"}, }, + { + name: "numstat", + files: []*models.File{ + {Name: "test", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 1, LinesDeleted: 1}, + {Name: "test2", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 1}, + {Name: "test3", ShortStatus: " M", HasStagedChanges: true, LinesDeleted: 1}, + {Name: "test4", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 0, LinesDeleted: 0}, + }, + showLineChanges: true, + expected: []string{ + " M test +1 -1", + " M test2 +1", + " M test3 -1", + " M test4", + }, + }, { name: "big example", files: []*models.File{ @@ -72,7 +89,7 @@ M file1 for _, path := range s.collapsedPaths { viewModel.ToggleCollapsed(path) } - result := RenderFileTree(viewModel, nil, false) + result := RenderFileTree(viewModel, nil, false, s.showLineChanges) assert.EqualValues(t, s.expected, result) }) } diff --git a/schema/config.json b/schema/config.json index 7b0ef0b2bf2..1498b82ba89 100644 --- a/schema/config.json +++ b/schema/config.json @@ -293,6 +293,11 @@ "description": "If true, display the files in the file views as a tree. If false, display the files as a flat list.\nThis can be toggled from within Lazygit with the '~' key, but that will not change the default.", "default": true }, + "showNumstatInFilesView": { + "type": "boolean", + "description": "If true, show the number of lines changed per file in the Files view", + "default": false + }, "showRandomTip": { "type": "boolean", "description": "If true, show a random tip in the command log when Lazygit starts",